Feature/ext validation/request validation #15
@@ -36,9 +36,9 @@ Format: `<type>(<scope>): <short description>`
|
||||
| `chore` | Build, deps, tooling — no production code |
|
||||
| `ci` | Changes to GitHub Actions workflows |
|
||||
|
||||
Allowed scopes: `core`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`,
|
||||
Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`,
|
||||
`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`,
|
||||
`ext-mcp`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`.
|
||||
`ext-mcp`, `ext-validation`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`.
|
||||
|
||||
Examples:
|
||||
```
|
||||
@@ -85,6 +85,9 @@ chore(release): 2.1.0
|
||||
|
||||
- Root POM: `flash-parent` — defines all dependency versions and plugin config.
|
||||
- `flash` module: the core framework JAR.
|
||||
- `flash-testing` module: JUnit 5 harness for testing Flash applications. Deliberately not
|
||||
under `flash-extensions/` — it is not something you `install()`, and it carries
|
||||
`junit-jupiter-api` at compile scope.
|
||||
- `flash-extensions` POM: aggregator for all extension modules.
|
||||
- Extensions live under `flash-extensions/flash-ext-*/`.
|
||||
- When adding a new extension:
|
||||
|
||||
@@ -8,6 +8,7 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
|
||||
| Module | Description |
|
||||
|---|---|
|
||||
| `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model |
|
||||
| `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses |
|
||||
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
|
||||
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
|
||||
| `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow |
|
||||
@@ -145,6 +146,7 @@ See extension-specific READMEs for full details:
|
||||
- [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md)
|
||||
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md)
|
||||
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
|
||||
- [`flash-testing`](flash-testing/docs/README.md)
|
||||
|
||||
## Error handlers
|
||||
|
||||
@@ -389,6 +391,110 @@ The API renders as chunked data and trailers on HTTP/1.1, and DATA plus trailing
|
||||
HTTP/2. Flash core supplies these transport primitives; a higher-level gRPC codec belongs in a
|
||||
future `flash-ext-grpc` extension.
|
||||
|
||||
## Testing
|
||||
|
||||
`flash-testing` boots a real app on an OS-assigned port for the duration of a test, and hands you
|
||||
a client pointed at it. Add it with test scope:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<version>${flash.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
```java
|
||||
class UserRoutesTest {
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest app = FlashTest.of(new BlogApp())
|
||||
.mock(UserService.class, new InMemoryUserService());
|
||||
|
||||
@Test
|
||||
void listsUsers() {
|
||||
app.get("/api/users")
|
||||
.expectStatus(200)
|
||||
.expectHeader("content-type", "application/json")
|
||||
.expectBodyContains("alice");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`FlashTest.of` takes a `FlashApplication` — your app's routes, extensions and services expressed
|
||||
independently of which port they run on:
|
||||
|
||||
```java
|
||||
public final class BlogApp implements FlashApplication {
|
||||
@Override public void configure(FlashApp app) {
|
||||
app.install(new JacksonExtension());
|
||||
app.mount("/api", scope -> scope.scan("dev.blog.api"));
|
||||
}
|
||||
}
|
||||
|
||||
FlashApp.create(8080).apply(new BlogApp()).startAndBlock(); // production
|
||||
```
|
||||
|
||||
It is a functional interface, so a lambda works too:
|
||||
`FlashTest.of(app -> app.get("/ping", (req, res) -> "pong"))`.
|
||||
|
||||
### Requests
|
||||
|
||||
The HTTP verb sends the request; `expect*` assertions chain and report the real response body on
|
||||
failure. `get` and `delete` skip the builder when there is nothing to add.
|
||||
|
||||
```java
|
||||
app.get("/api/users").expectStatus(200);
|
||||
|
||||
app.request()
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.json("{\"name\":\"bob\"}")
|
||||
.post("/api/users")
|
||||
.expectStatus(201);
|
||||
|
||||
try (FlashWebSocket socket = app.ws("/live")) {
|
||||
socket.sendText("hello");
|
||||
assertEquals("echo:hello", socket.awaitText(Duration.ofSeconds(2)));
|
||||
}
|
||||
```
|
||||
|
||||
### Replacing services
|
||||
|
||||
`mock` installs replacements after everything your app and its extensions declare, so a fake always
|
||||
wins. Any object will do — `flash-testing` depends on no mocking library, so a hand-written fake and
|
||||
a Mockito mock are equally welcome.
|
||||
|
||||
### More than one server
|
||||
|
||||
`FlashTest` is an ordinary object in a field, so a test class can hold as many as it needs and wire
|
||||
one from another in plain Java. Startup is lazy — reading `baseUri()` boots that server on the spot
|
||||
— so declaration order does the wiring:
|
||||
|
||||
```java
|
||||
@RegisterExtension static FlashTest auth = FlashTest.of(new FakeOidcApp());
|
||||
@RegisterExtension static FlashTest api = FlashTest.of(new BlogApp(auth.baseUri()));
|
||||
```
|
||||
|
||||
### Scope
|
||||
|
||||
A `static` field boots once for the test class; a non-static field boots a fresh app for every test.
|
||||
That is stock JUnit field semantics — the isolation switch is the keyword, not an option.
|
||||
|
||||
### Configuration
|
||||
|
||||
Full reference: [`flash-testing/docs`](flash-testing/docs/README.md), including the
|
||||
[limits](flash-testing/docs/limits.md) the harness deliberately does not cross.
|
||||
|
||||
`profile` customises the `FlashConfiguration` — timeouts, HTTP/2 switches, buffer sizes. Host, port
|
||||
and the shutdown drain window are stamped afterwards, so a profile cannot break the harness;
|
||||
`listener(...)` and `tls(...)` are rejected because the harness owns the loopback listener it gives
|
||||
you a client for.
|
||||
|
||||
```java
|
||||
FlashTest.of(new BlogApp()).profile(cfg -> cfg.http2CleartextEnabled(true));
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
|
||||
@@ -38,6 +38,11 @@
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
+95
-104
@@ -3,16 +3,14 @@ package dev.relism.flash.ext.mcp;
|
||||
import dev.relism.flash.ext.oidc.OidcConfig;
|
||||
import dev.relism.flash.ext.oidc.OidcExtension;
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.testing.FlashResponse;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@@ -26,125 +24,118 @@ class McpAuthPolicyTest {
|
||||
private static final String SECURED_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.secured";
|
||||
private static final String AUTHENTICATED_ONLY_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.authenticatedonly";
|
||||
|
||||
private FlashApp app;
|
||||
private FakeOidcProvider provider;
|
||||
private static final FakeOidcProvider provider = newProvider();
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (app != null) app.stop();
|
||||
if (provider != null) provider.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolesAllowed_deniesWithoutRole_allowsWithRole() throws Exception {
|
||||
int port = bootSecuredApp(SECURED_TOOLS);
|
||||
String resourceId = "http://127.0.0.1:" + port + "/mcp";
|
||||
|
||||
String noRole = provider.signToken("user-1", resourceId, null);
|
||||
HttpResponse<String> denied = callTool(port, "admin_only", noRole);
|
||||
assertEquals(200, denied.statusCode());
|
||||
assertTrue(denied.body().contains("\"isError\":true"), denied.body());
|
||||
assertTrue(denied.body().contains("missing required role"), denied.body());
|
||||
|
||||
String withRole = provider.signToken("user-1", resourceId, null, "admin");
|
||||
HttpResponse<String> allowed = callTool(port, "admin_only", withRole);
|
||||
assertEquals(200, allowed.statusCode());
|
||||
assertTrue(allowed.body().contains("\"isError\":false"), allowed.body());
|
||||
assertTrue(allowed.body().contains("ok"), allowed.body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopesAllowed_deniesWithoutScope_allowsWithScope() throws Exception {
|
||||
int port = bootSecuredApp(SECURED_TOOLS);
|
||||
String resourceId = "http://127.0.0.1:" + port + "/mcp";
|
||||
|
||||
String noScope = provider.signToken("user-1", resourceId, "read");
|
||||
HttpResponse<String> denied = callTool(port, "write_only", noScope);
|
||||
assertEquals(200, denied.statusCode());
|
||||
assertTrue(denied.body().contains("\"isError\":true"), denied.body());
|
||||
assertTrue(denied.body().contains("missing required scope"), denied.body());
|
||||
|
||||
String withScope = provider.signToken("user-1", resourceId, "read write");
|
||||
HttpResponse<String> allowed = callTool(port, "write_only", withScope);
|
||||
assertEquals(200, allowed.statusCode());
|
||||
assertTrue(allowed.body().contains("\"isError\":false"), allowed.body());
|
||||
assertTrue(allowed.body().contains("written"), allowed.body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unannotatedTool_unaffectedByOtherToolsPolicies() throws Exception {
|
||||
int port = bootSecuredApp(SECURED_TOOLS);
|
||||
String resourceId = "http://127.0.0.1:" + port + "/mcp";
|
||||
|
||||
String plain = provider.signToken("user-1", resourceId, null);
|
||||
HttpResponse<String> resp = callTool(port, "open", plain);
|
||||
assertEquals(200, resp.statusCode());
|
||||
assertTrue(resp.body().contains("\"isError\":false"), resp.body());
|
||||
assertTrue(resp.body().contains("open"), resp.body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolAnnotated_butSecurityNone_failsAtBoot() throws Exception {
|
||||
provider = new FakeOidcProvider();
|
||||
int port = freePort();
|
||||
app = FlashApp.create(port);
|
||||
@RegisterExtension
|
||||
static FlashTest secured = FlashTest.of(app -> {
|
||||
app.install(new OidcExtension(OidcConfig.builder(
|
||||
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||
.toolsPackage(SECURED_TOOLS)
|
||||
.security(McpSecurity.NONE)
|
||||
.security(McpSecurity.REQUIRED)
|
||||
.build()));
|
||||
});
|
||||
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class, () -> app.start());
|
||||
assertTrue(e.getMessage().contains("no active OAuth2 protection"), e.getMessage());
|
||||
/** Tokens are audience-bound to this server, so the port has to be read back after boot. */
|
||||
private static String resourceId() {
|
||||
return "http://127.0.0.1:" + secured.port() + "/mcp";
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void closeProvider() {
|
||||
provider.close();
|
||||
}
|
||||
|
||||
// ── Tool policy ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void rolesAllowed_deniesWithoutRole_allowsWithRole() throws Exception {
|
||||
callTool("admin_only", provider.signToken("user-1", resourceId(), null))
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"isError\":true")
|
||||
.expectBodyContains("missing required role");
|
||||
|
||||
callTool("admin_only", provider.signToken("user-1", resourceId(), null, "admin"))
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"isError\":false")
|
||||
.expectBodyContains("ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bareAuthenticated_hasNoEffect_failsAtBoot() throws Exception {
|
||||
provider = new FakeOidcProvider();
|
||||
int port = freePort();
|
||||
app = FlashApp.create(port);
|
||||
app.install(new OidcExtension(OidcConfig.builder(
|
||||
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||
.toolsPackage(AUTHENTICATED_ONLY_TOOLS)
|
||||
.security(McpSecurity.REQUIRED)
|
||||
.build()));
|
||||
void scopesAllowed_deniesWithoutScope_allowsWithScope() throws Exception {
|
||||
callTool("write_only", provider.signToken("user-1", resourceId(), "read"))
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"isError\":true")
|
||||
.expectBodyContains("missing required scope");
|
||||
|
||||
IllegalStateException e = assertThrows(IllegalStateException.class, () -> app.start());
|
||||
assertTrue(e.getMessage().contains("no effect"), e.getMessage());
|
||||
callTool("write_only", provider.signToken("user-1", resourceId(), "read write"))
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"isError\":false")
|
||||
.expectBodyContains("written");
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
@Test
|
||||
void unannotatedTool_unaffectedByOtherToolsPolicies() throws Exception {
|
||||
callTool("open", provider.signToken("user-1", resourceId(), null))
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"isError\":false")
|
||||
.expectBodyContains("open");
|
||||
}
|
||||
|
||||
private int bootSecuredApp(String toolsPackage) throws Exception {
|
||||
provider = new FakeOidcProvider();
|
||||
int port = freePort();
|
||||
private static FlashResponse callTool(String toolName, String token) {
|
||||
return secured.request()
|
||||
.header("Accept", "application/json")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\""
|
||||
+ toolName + "\"}}")
|
||||
.post("/mcp");
|
||||
}
|
||||
|
||||
app = FlashApp.create(port);
|
||||
// ── Boot-time rejection ──────────────────────────────────────────────────
|
||||
// These assert that start() throws, so they build the app directly rather than through
|
||||
// FlashTest — a harness whose job is to boot an app is the wrong tool for asserting that
|
||||
// booting fails. Port 0 still removes the old free-port dance.
|
||||
|
||||
private FlashApp bootFailure;
|
||||
|
||||
@AfterEach
|
||||
void releaseBootFailureListener() {
|
||||
if (bootFailure != null) bootFailure.stop().join();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolAnnotated_butSecurityNone_failsAtBoot() {
|
||||
bootFailure = mcpApp(SECURED_TOOLS, McpSecurity.NONE);
|
||||
|
||||
IllegalStateException error = assertThrows(IllegalStateException.class, bootFailure::start);
|
||||
assertTrue(error.getMessage().contains("no active OAuth2 protection"), error.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bareAuthenticated_hasNoEffect_failsAtBoot() {
|
||||
bootFailure = mcpApp(AUTHENTICATED_ONLY_TOOLS, McpSecurity.REQUIRED);
|
||||
|
||||
IllegalStateException error = assertThrows(IllegalStateException.class, bootFailure::start);
|
||||
assertTrue(error.getMessage().contains("no effect"), error.getMessage());
|
||||
}
|
||||
|
||||
private static FlashApp mcpApp(String toolsPackage, McpSecurity security) {
|
||||
FlashApp app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build());
|
||||
app.install(new OidcExtension(OidcConfig.builder(
|
||||
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||
.toolsPackage(toolsPackage)
|
||||
.security(McpSecurity.REQUIRED)
|
||||
.security(security)
|
||||
.build()));
|
||||
app.start();
|
||||
return port;
|
||||
return app;
|
||||
}
|
||||
|
||||
private static HttpResponse<String> callTool(int port, String toolName, String token) throws Exception {
|
||||
String body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"" + toolName + "\"}}";
|
||||
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/mcp"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||
return HttpClient.newHttpClient().send(req.build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
return s.getLocalPort();
|
||||
private static FakeOidcProvider newProvider() {
|
||||
try {
|
||||
return new FakeOidcProvider();
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("Could not start the fake OIDC provider", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-53
@@ -2,16 +2,10 @@ package dev.relism.flash.ext.mcp;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import dev.relism.flash.testing.FlashResponse;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
@@ -22,34 +16,15 @@ class McpExtensionIntegrationTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private FlashApp app;
|
||||
private String mcpUrl;
|
||||
private HttpClient client;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
int port;
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
port = s.getLocalPort();
|
||||
}
|
||||
mcpUrl = "http://127.0.0.1:" + port + "/mcp";
|
||||
client = HttpClient.newHttpClient();
|
||||
|
||||
McpConfig config = McpConfig.builder("test-server")
|
||||
.version("9.9.9")
|
||||
.toolsPackage("dev.relism.flash.ext.mcp.fixtures")
|
||||
.security(McpSecurity.NONE)
|
||||
.build();
|
||||
|
||||
app = FlashApp.create(port);
|
||||
app.install(new McpExtension(config));
|
||||
app.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (app != null) app.stop();
|
||||
}
|
||||
// Every test here is a stateless JSON-RPC call against the same server, so one boot for
|
||||
// the class rather than one per test.
|
||||
@RegisterExtension
|
||||
static FlashTest mcp = FlashTest.of(app -> app.install(new McpExtension(
|
||||
McpConfig.builder("test-server")
|
||||
.version("9.9.9")
|
||||
.toolsPackage("dev.relism.flash.ext.mcp.fixtures")
|
||||
.security(McpSecurity.NONE)
|
||||
.build())));
|
||||
|
||||
@Test
|
||||
void initialize_returnsProtocolVersionCapabilitiesAndServerInfo() throws Exception {
|
||||
@@ -104,17 +79,13 @@ class McpExtensionIntegrationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void notification_returns202WithEmptyBody() throws Exception {
|
||||
String body = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}";
|
||||
HttpResponse<String> resp = post(body);
|
||||
assertEquals(202, resp.statusCode());
|
||||
void notification_returns202WithEmptyBody() {
|
||||
post("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}").expectStatus(202);
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedJson_returns400ParseError() throws Exception {
|
||||
HttpResponse<String> resp = post("not json");
|
||||
assertEquals(400, resp.statusCode());
|
||||
JsonNode json = MAPPER.readTree(resp.body());
|
||||
JsonNode json = MAPPER.readTree(post("not json").expectStatus(400).body());
|
||||
assertEquals(-32700, json.get("error").get("code").asInt());
|
||||
}
|
||||
|
||||
@@ -128,16 +99,10 @@ class McpExtensionIntegrationTest {
|
||||
|
||||
private JsonNode call(int id, String method, String paramsJson) throws Exception {
|
||||
String body = "{\"jsonrpc\":\"2.0\",\"id\":" + id + ",\"method\":\"" + method + "\",\"params\":" + paramsJson + "}";
|
||||
HttpResponse<String> resp = post(body);
|
||||
assertEquals(200, resp.statusCode());
|
||||
return MAPPER.readTree(resp.body());
|
||||
return MAPPER.readTree(post(body).expectStatus(200).body());
|
||||
}
|
||||
|
||||
private HttpResponse<String> post(String body) throws Exception {
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(mcpUrl))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
return client.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
private FlashResponse post(String body) {
|
||||
return mcp.request().json(body).post("/mcp");
|
||||
}
|
||||
}
|
||||
|
||||
+103
-118
@@ -3,16 +3,15 @@ package dev.relism.flash.ext.mcp;
|
||||
import dev.relism.flash.ext.oidc.OidcConfig;
|
||||
import dev.relism.flash.ext.oidc.OidcExtension;
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import dev.relism.flash.extension.FlashApplication;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.testing.FlashRequest;
|
||||
import dev.relism.flash.testing.FlashResponse;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@@ -20,175 +19,161 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
* Exercises the actual OAuth2 resolution rules against a real {@code flash-ext-oidc}
|
||||
* installation backed by {@link FakeOidcProvider} — real discovery, real JWKS, real RS256
|
||||
* tokens — plus the fail-fast/degrade behavior when oidc is absent.
|
||||
*
|
||||
* <p>Four server configurations differ only in how MCP security is declared, so each gets its
|
||||
* own {@link FlashTest} and they share one provider.
|
||||
*/
|
||||
class McpExtensionSecurityTest {
|
||||
|
||||
private static final String TOOLS_PACKAGE = "dev.relism.flash.ext.mcp.fixtures";
|
||||
private static final String EXPLICIT_RESOURCE_ID = "https://mcp.example.com/mcp";
|
||||
|
||||
private FlashApp app;
|
||||
private FakeOidcProvider provider;
|
||||
private static final FakeOidcProvider provider = newProvider();
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (app != null) app.stop();
|
||||
if (provider != null) provider.close();
|
||||
/** MCP asked for AUTO security with no oidc installed — should degrade to public. */
|
||||
@RegisterExtension
|
||||
static FlashTest degraded = FlashTest.of(app -> app.install(new McpExtension(
|
||||
McpConfig.builder("auto-server")
|
||||
.toolsPackage(TOOLS_PACKAGE)
|
||||
.security(McpSecurity.AUTO)
|
||||
.build())));
|
||||
|
||||
/** REQUIRED with oidc, resource identifier derived from the request. */
|
||||
@RegisterExtension
|
||||
static FlashTest secured = FlashTest.of(securedApp(null, null));
|
||||
|
||||
/** REQUIRED with oidc and an explicitly declared resource identifier. */
|
||||
@RegisterExtension
|
||||
static FlashTest securedWithResourceId = FlashTest.of(securedApp(EXPLICIT_RESOURCE_ID, null));
|
||||
|
||||
/** REQUIRED with oidc and advertised scopes. */
|
||||
@RegisterExtension
|
||||
static FlashTest securedWithScopes =
|
||||
FlashTest.of(securedApp(null, new String[] {"openid", "profile", "email"}));
|
||||
|
||||
@AfterAll
|
||||
static void closeProvider() {
|
||||
provider.close();
|
||||
}
|
||||
|
||||
// ── No oidc installed ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void required_withoutOidc_throwsAtBoot() throws Exception {
|
||||
int port = freePort();
|
||||
app = FlashApp.create(port);
|
||||
void required_withoutOidc_throwsAtBoot() {
|
||||
// Asserting that boot fails, so this one builds its app directly rather than through
|
||||
// the harness; port(0) still removes the old free-port dance.
|
||||
FlashApp app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build());
|
||||
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||
.toolsPackage(TOOLS_PACKAGE)
|
||||
.security(McpSecurity.REQUIRED)
|
||||
.build()));
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> app.start());
|
||||
try {
|
||||
assertThrows(IllegalStateException.class, app::start);
|
||||
} finally {
|
||||
app.stop().join();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void auto_withoutOidc_degradesToPublic() throws Exception {
|
||||
int port = freePort();
|
||||
app = FlashApp.create(port);
|
||||
app.install(new McpExtension(McpConfig.builder("auto-server")
|
||||
.toolsPackage(TOOLS_PACKAGE)
|
||||
.security(McpSecurity.AUTO)
|
||||
.build()));
|
||||
app.start();
|
||||
|
||||
HttpResponse<String> resp = post(port, initializeBody(), null);
|
||||
assertEquals(200, resp.statusCode());
|
||||
void auto_withoutOidc_degradesToPublic() {
|
||||
post(degraded, initializeBody(), null).expectStatus(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void required_withOidc_rejectsMissingToken() throws Exception {
|
||||
int port = bootSecuredApp(null);
|
||||
// ── REQUIRED with oidc ───────────────────────────────────────────────────
|
||||
|
||||
HttpResponse<String> resp = post(port, initializeBody(), null);
|
||||
assertEquals(401, resp.statusCode());
|
||||
@Test
|
||||
void required_withOidc_rejectsMissingToken() {
|
||||
post(secured, initializeBody(), null).expectStatus(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void required_withOidc_rejectsWrongAudience() throws Exception {
|
||||
int port = bootSecuredApp("https://mcp.example.com/mcp");
|
||||
String token = provider.signToken("user-1", "https://someone-else.example.com/resource");
|
||||
|
||||
HttpResponse<String> resp = post(port, initializeBody(), token);
|
||||
assertEquals(403, resp.statusCode());
|
||||
post(securedWithResourceId, initializeBody(), token).expectStatus(403);
|
||||
}
|
||||
|
||||
@Test
|
||||
void required_withOidc_acceptsValidAudience() throws Exception {
|
||||
String resourceId = "https://mcp.example.com/mcp";
|
||||
int port = bootSecuredApp(resourceId);
|
||||
String token = provider.signToken("user-1", resourceId);
|
||||
String token = provider.signToken("user-1", EXPLICIT_RESOURCE_ID);
|
||||
|
||||
HttpResponse<String> resp = post(port, initializeBody(), token);
|
||||
assertEquals(200, resp.statusCode());
|
||||
assertTrue(resp.body().contains("\"protocolVersion\""));
|
||||
post(securedWithResourceId, initializeBody(), token)
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"protocolVersion\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void required_withOidc_noExplicitResourceIdentifier_derivesFromRequestAndEnforcesAudience() throws Exception {
|
||||
int port = bootSecuredApp(null);
|
||||
String derivedResourceId = "http://127.0.0.1:" + port + "/mcp";
|
||||
String derivedResourceId = "http://127.0.0.1:" + secured.port() + "/mcp";
|
||||
|
||||
String matching = provider.signToken("user-1", derivedResourceId);
|
||||
assertEquals(200, post(port, initializeBody(), matching).statusCode());
|
||||
|
||||
String mismatched = provider.signToken("user-1", "https://someone-else.example.com/resource");
|
||||
assertEquals(403, post(port, initializeBody(), mismatched).statusCode());
|
||||
post(secured, initializeBody(), provider.signToken("user-1", derivedResourceId))
|
||||
.expectStatus(200);
|
||||
post(secured, initializeBody(), provider.signToken("user-1", "https://someone-else.example.com/resource"))
|
||||
.expectStatus(403);
|
||||
}
|
||||
|
||||
@Test
|
||||
void required_withOidc_missingToken_challengeIncludesResourceMetadata() throws Exception {
|
||||
int port = bootSecuredApp(null);
|
||||
void required_withOidc_missingToken_challengeIncludesResourceMetadata() {
|
||||
FlashResponse response = post(secured, initializeBody(), null).expectStatus(401);
|
||||
|
||||
HttpResponse<String> resp = post(port, initializeBody(), null);
|
||||
assertEquals(401, resp.statusCode());
|
||||
String challenge = resp.headers().firstValue("WWW-Authenticate").orElse("");
|
||||
assertTrue(challenge.contains(
|
||||
"resource_metadata=\"http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp\""),
|
||||
String challenge = response.header("WWW-Authenticate");
|
||||
assertTrue(challenge != null && challenge.contains("resource_metadata=\"http://127.0.0.1:"
|
||||
+ secured.port() + "/.well-known/oauth-protected-resource/mcp\""),
|
||||
"WWW-Authenticate: " + challenge);
|
||||
}
|
||||
|
||||
// ── Protected resource metadata ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void required_withOidc_noExplicitConfig_publishesProtectedResourceMetadata() throws Exception {
|
||||
int port = bootSecuredApp(null);
|
||||
void required_withOidc_noExplicitConfig_publishesProtectedResourceMetadata() {
|
||||
FlashResponse response = secured.get("/.well-known/oauth-protected-resource/mcp")
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"resource\":\"http://127.0.0.1:" + secured.port() + "/mcp\"")
|
||||
.expectBodyContains("\"authorization_servers\":[\"" + provider.issuer() + "\"]");
|
||||
|
||||
HttpResponse<String> resp = HttpClient.newHttpClient().send(
|
||||
HttpRequest.newBuilder(URI.create(
|
||||
"http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp")).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(200, resp.statusCode());
|
||||
assertTrue(resp.body().contains("\"resource\":\"http://127.0.0.1:" + port + "/mcp\""), resp.body());
|
||||
assertTrue(resp.body().contains("\"authorization_servers\":[\"" + provider.issuer() + "\"]"), resp.body());
|
||||
assertTrue(!resp.body().contains("scopes_supported"), "scopes_supported must be omitted when unset: " + resp.body());
|
||||
assertTrue(!response.body().contains("scopes_supported"),
|
||||
"scopes_supported must be omitted when unset: " + response.body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopesSupported_published_inProtectedResourceMetadata() throws Exception {
|
||||
provider = new FakeOidcProvider();
|
||||
int port = freePort();
|
||||
|
||||
app = FlashApp.create(port);
|
||||
app.install(new OidcExtension(OidcConfig.builder(
|
||||
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||
.toolsPackage(TOOLS_PACKAGE)
|
||||
.security(McpSecurity.REQUIRED)
|
||||
.scopesSupported("openid", "profile", "email")
|
||||
.build()));
|
||||
app.start();
|
||||
|
||||
HttpResponse<String> resp = HttpClient.newHttpClient().send(
|
||||
HttpRequest.newBuilder(URI.create(
|
||||
"http://127.0.0.1:" + port + "/.well-known/oauth-protected-resource/mcp")).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(200, resp.statusCode());
|
||||
assertTrue(resp.body().contains("\"scopes_supported\":[\"openid\",\"profile\",\"email\"]"), resp.body());
|
||||
void scopesSupported_published_inProtectedResourceMetadata() {
|
||||
securedWithScopes.get("/.well-known/oauth-protected-resource/mcp")
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"scopes_supported\":[\"openid\",\"profile\",\"email\"]");
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private int bootSecuredApp(String resourceIdentifier) throws Exception {
|
||||
provider = new FakeOidcProvider();
|
||||
int port = freePort();
|
||||
private static FlashApplication securedApp(String resourceIdentifier, String[] scopesSupported) {
|
||||
return app -> {
|
||||
app.install(new OidcExtension(OidcConfig.builder(
|
||||
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||
|
||||
OidcConfig oidcConfig = OidcConfig.builder(
|
||||
provider.issuer(), "mcp-client", "secret", "/auth/callback")
|
||||
.build();
|
||||
McpConfig.Builder mcp = McpConfig.builder("secure-server")
|
||||
.toolsPackage(TOOLS_PACKAGE)
|
||||
.security(McpSecurity.REQUIRED);
|
||||
if (resourceIdentifier != null) mcp.resourceIdentifier(resourceIdentifier);
|
||||
if (scopesSupported != null) mcp.scopesSupported(scopesSupported);
|
||||
app.install(new McpExtension(mcp.build()));
|
||||
};
|
||||
}
|
||||
|
||||
var mcpBuilder = McpConfig.builder("secure-server")
|
||||
.toolsPackage(TOOLS_PACKAGE)
|
||||
.security(McpSecurity.REQUIRED);
|
||||
if (resourceIdentifier != null) mcpBuilder.resourceIdentifier(resourceIdentifier);
|
||||
|
||||
app = FlashApp.create(port);
|
||||
app.install(new OidcExtension(oidcConfig));
|
||||
app.install(new McpExtension(mcpBuilder.build()));
|
||||
app.start();
|
||||
return port;
|
||||
private static FlashResponse post(FlashTest server, String body, String bearerToken) {
|
||||
FlashRequest request = server.request().header("Accept", "application/json").json(body);
|
||||
if (bearerToken != null) request.header("Authorization", "Bearer " + bearerToken);
|
||||
return request.post("/mcp");
|
||||
}
|
||||
|
||||
private static String initializeBody() {
|
||||
return "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}";
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
return s.getLocalPort();
|
||||
private static FakeOidcProvider newProvider() {
|
||||
try {
|
||||
return new FakeOidcProvider();
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("Could not start the fake OIDC provider", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpResponse<String> post(int port, String body, String bearerToken) throws Exception {
|
||||
HttpRequest.Builder req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/mcp"))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||
if (bearerToken != null) req.header("Authorization", "Bearer " + bearerToken);
|
||||
return HttpClient.newHttpClient().send(req.build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,15 @@
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
<!--
|
||||
Annotations only, and optional: when present the generated schema mirrors the
|
||||
constraints; when absent ConstraintHints is never loaded and nothing changes.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package dev.relism.flash.ext.openapi;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Mirrors {@code jakarta.validation} constraints into the generated schema, so a type carries its
|
||||
* rules once and both the validator and the published contract read them.
|
||||
*
|
||||
* <p>Loaded reflectively by {@link OpenApiBuilder} and used only when the annotations are on the
|
||||
* classpath — this class is never touched otherwise, so {@code flash-ext-openapi} keeps working
|
||||
* with no validation dependency at all. Nothing to install and nothing to configure: if the
|
||||
* annotations are there, the schema gains {@code minLength}, {@code maximum}, {@code format} and
|
||||
* {@code required} on its own.
|
||||
*/
|
||||
final class ConstraintHints {
|
||||
|
||||
private ConstraintHints() {}
|
||||
|
||||
/** True when jakarta.validation is resolvable, so the caller may use this class. */
|
||||
static boolean available() {
|
||||
try {
|
||||
Class.forName("jakarta.validation.constraints.NotNull", false, ConstraintHints.class.getClassLoader());
|
||||
return true;
|
||||
} catch (Throwable absent) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges {@code field}'s constraints into {@code property}, and reports whether the field is
|
||||
* required. Never overwrites a key an explicit {@code @Schema} already set.
|
||||
*/
|
||||
static boolean apply(Field field, Map<String, Object> property) {
|
||||
boolean isString = "string".equals(property.get("type"));
|
||||
|
||||
Size size = field.getAnnotation(Size.class);
|
||||
if (size != null) {
|
||||
if (isString) {
|
||||
if (size.min() > 0) property.putIfAbsent("minLength", size.min());
|
||||
if (size.max() != Integer.MAX_VALUE) property.putIfAbsent("maxLength", size.max());
|
||||
} else if ("array".equals(property.get("type"))) {
|
||||
if (size.min() > 0) property.putIfAbsent("minItems", size.min());
|
||||
if (size.max() != Integer.MAX_VALUE) property.putIfAbsent("maxItems", size.max());
|
||||
}
|
||||
}
|
||||
|
||||
Min min = field.getAnnotation(Min.class);
|
||||
if (min != null) property.putIfAbsent("minimum", min.value());
|
||||
|
||||
Max max = field.getAnnotation(Max.class);
|
||||
if (max != null) property.putIfAbsent("maximum", max.value());
|
||||
|
||||
if (field.isAnnotationPresent(Email.class)) property.putIfAbsent("format", "email");
|
||||
|
||||
Pattern pattern = field.getAnnotation(Pattern.class);
|
||||
if (pattern != null) property.putIfAbsent("pattern", pattern.regexp());
|
||||
|
||||
if (field.isAnnotationPresent(NotBlank.class) && isString) property.putIfAbsent("minLength", 1);
|
||||
if (field.isAnnotationPresent(NotEmpty.class)) {
|
||||
if (isString) property.putIfAbsent("minLength", 1);
|
||||
else if ("array".equals(property.get("type"))) property.putIfAbsent("minItems", 1);
|
||||
}
|
||||
|
||||
return field.isAnnotationPresent(NotNull.class)
|
||||
|| field.isAnnotationPresent(NotBlank.class)
|
||||
|| field.isAnnotationPresent(NotEmpty.class);
|
||||
}
|
||||
|
||||
/** Constraint annotations this bridge understands, for documentation and tests. */
|
||||
static List<String> supported() {
|
||||
return List.of("@NotNull", "@NotBlank", "@NotEmpty", "@Size", "@Min", "@Max", "@Email", "@Pattern");
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -365,6 +365,9 @@ public final class OpenApiBuilder {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolved once: jakarta.validation is an optional dependency of this module. */
|
||||
private static final boolean CONSTRAINTS_PRESENT = ConstraintHints.available();
|
||||
|
||||
private static final class SchemaRegistry {
|
||||
private static final Set<Class<?>> SIMPLE = Set.of(
|
||||
String.class, CharSequence.class,
|
||||
@@ -476,8 +479,14 @@ public final class OpenApiBuilder {
|
||||
if (jp.access() == Access.WRITE_ONLY) property.put("writeOnly", true);
|
||||
}
|
||||
|
||||
// Constraints declared for flash-ext-validation also describe the contract, so
|
||||
// mirror them here rather than making callers restate every rule as @Schema.
|
||||
boolean constrainedRequired = CONSTRAINTS_PRESENT && ConstraintHints.apply(f, property);
|
||||
|
||||
properties.put(name, property);
|
||||
if ((ps != null && ps.required()) || (sp != null && sp.required()) || (jp != null && jp.required())) required.add(name);
|
||||
if (constrainedRequired
|
||||
|| (ps != null && ps.required()) || (sp != null && sp.required()) || (jp != null && jp.required()))
|
||||
required.add(name);
|
||||
}
|
||||
|
||||
if (!properties.isEmpty()) out.put("properties", properties);
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# flash-ext-validation
|
||||
|
||||
Request validation for Flash. Standard `jakarta.validation` annotations, compiled once per type
|
||||
into a flat check table, with zero allocation on the passing path.
|
||||
|
||||
## What it provides
|
||||
|
||||
| Component | Description |
|
||||
|---|---|
|
||||
| `Validation` | The service — `body(req, type)` parses and verifies, `validate(value)` verifies |
|
||||
| `Validator` | One type's compiled constraints; reusable and thread-safe |
|
||||
| `ValidationException` | 422 carrying every violation, not just the first |
|
||||
|
||||
## Dependency
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-validation</artifactId>
|
||||
<version>${flash.version}</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```java
|
||||
FlashApp.create(8080)
|
||||
.install(new JacksonExtension())
|
||||
.install(new ValidationExtension())
|
||||
.scan("dev.example.api");
|
||||
```
|
||||
|
||||
```java
|
||||
public record CreateUser(
|
||||
@NotBlank @Size(max = 80) String name,
|
||||
@Email String email,
|
||||
@Min(18) int age) {}
|
||||
```
|
||||
|
||||
```java
|
||||
@POST("/api/users")
|
||||
public final class CreateUserHandler extends RequestHandler {
|
||||
|
||||
private Validation validation;
|
||||
private UserService users;
|
||||
|
||||
@Override protected void onInit() {
|
||||
validation = require(Validation.class);
|
||||
users = require(UserService.class);
|
||||
}
|
||||
|
||||
@Override public Object handle(Request req, Response res) throws Exception {
|
||||
CreateUser dto = validation.body(req, CreateUser.class);
|
||||
return res.status(201).body(users.create(dto));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
There is nothing to configure. Constraints come from the annotations already on your types, and
|
||||
failures reach the client as `422` on their own — see [Error responses](#error-responses).
|
||||
|
||||
## Supported constraints
|
||||
|
||||
`@NotNull` · `@NotBlank` · `@NotEmpty` · `@Size` · `@Min` · `@Max` · `@Email` · `@Pattern`
|
||||
|
||||
Jakarta null semantics are honoured exactly: **only `@NotNull` rejects null**. Every other
|
||||
constraint passes a null value, so `@Email String email` means "if present, must look like an
|
||||
email" — combine with `@NotNull` when it is mandatory.
|
||||
|
||||
`@Size` applies to `CharSequence`, `Collection`, `Map` and object arrays. `@Min`/`@Max` apply to
|
||||
primitive integrals and to `Number` subtypes.
|
||||
|
||||
An unsupported annotation is ignored rather than rejected, so adding one is never a boot failure.
|
||||
|
||||
## Records and classes
|
||||
|
||||
Constraints are read from **declared fields**. A constraint on a record component propagates to
|
||||
its backing field, so records and plain classes take the same path with no extra configuration:
|
||||
|
||||
```java
|
||||
record CreateUser(@NotBlank String name) {} // works
|
||||
class CreateUser { @NotBlank private String name; } // works
|
||||
```
|
||||
|
||||
## Error responses
|
||||
|
||||
`ValidationException` extends Flash's `HttpException` with status 422, so the default exception
|
||||
handler renders it. Nothing is registered, and your own `onException` still wins if you set one.
|
||||
|
||||
```json
|
||||
{"error":"name must not be blank; age must be at least 18","status":422}
|
||||
```
|
||||
|
||||
Malformed JSON is a different failure and comes back as `400` from the codec, before any
|
||||
constraint runs.
|
||||
|
||||
## OpenAPI
|
||||
|
||||
Install `flash-ext-openapi` alongside and the generated schema mirrors the same annotations —
|
||||
`minLength`, `maxLength`, `minItems`, `minimum`, `maximum`, `pattern`, `format: email`, and
|
||||
`required`. Declared once, enforced and published.
|
||||
|
||||
Nothing registers this. `flash-ext-openapi` carries `jakarta.validation-api` as an optional
|
||||
dependency and detects it at boot; without it the bridge class is never loaded.
|
||||
|
||||
An explicit `@Schema` always wins — the bridge only fills keys nobody set.
|
||||
|
||||
## Without Jackson
|
||||
|
||||
`flash-ext-jackson` is optional. Without it `validate(value)` still works on values you construct
|
||||
or parse yourself; only `body(req, type)` needs a codec and says so if one is missing.
|
||||
|
||||
## Performance
|
||||
|
||||
The passing path is the one that runs on every request, so it allocates nothing:
|
||||
|
||||
- **Compiled once per type.** Constraints resolve to an opcode plus operands at first use, cached
|
||||
in a `ClassValue` — stored beside the class by the JVM, so no map lookup, no lock, and the entry
|
||||
is collected with the class rather than pinning it.
|
||||
- **No reflection per request.** Fields are read through `MethodHandle`s adapted to an exact
|
||||
signature: `(Object)Object` for references, `(Object)long` for primitive integrals. `invokeExact`
|
||||
neither boxes nor builds the argument array that `Field.get` and `Method.invoke` allocate.
|
||||
- **No megamorphic dispatch.** Checks are a flat array walked by a `tableswitch` on an opcode, not
|
||||
a class hierarchy behind a virtual call.
|
||||
- **No copies.** `@Size` reads a length the object already knows; `@Email` scans with `indexOf`
|
||||
rather than a regex, because `Pattern.matcher` allocates a matcher and two int arrays per call.
|
||||
- **Messages pre-rendered at compile time**, so even a failure formats nothing.
|
||||
|
||||
The list, the violations and the exception exist only once something fails.
|
||||
|
||||
`@Pattern` is the deliberate exception: its regex is compiled once, but `matcher()` allocates per
|
||||
call. It is marked in the source. Prefer `@Size`/`@Email` on hot routes, or validate the shape
|
||||
structurally.
|
||||
|
||||
## Pre-warming
|
||||
|
||||
Compilation happens on a type's first request. To pay it at boot instead:
|
||||
|
||||
```java
|
||||
ctx.onReady(() -> ctx.require(Validation.class).forType(CreateUser.class));
|
||||
```
|
||||
|
||||
Worth it only for a route that must not pay first-call cost. Everything else warms itself.
|
||||
@@ -0,0 +1,51 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-validation</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
</dependency>
|
||||
<!--
|
||||
Annotations only (~90 KB). Hibernate Validator's engine is deliberately absent: it
|
||||
resolves constraints reflectively per call and pulls ~2 MB plus EL. This module
|
||||
compiles the same annotations into a flat check table once per class instead.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
</dependency>
|
||||
<!-- Only needed for Validation.body(...); check(...) works without it. -->
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Interop only: proves constraints reach the published schema. -->
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-openapi</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* One constraint, compiled. Flattened into an opcode plus its operands rather than a class per
|
||||
* constraint type: the check loop becomes a {@code tableswitch} over a monomorphic array instead
|
||||
* of a megamorphic virtual call, and a passing check touches no allocation at all.
|
||||
*
|
||||
* <p>Field access goes through a {@link MethodHandle} adapted at compile time to an exact
|
||||
* signature — {@code (Object)Object} for reference fields, {@code (Object)long} for primitive
|
||||
* integrals — so {@code invokeExact} neither boxes nor allocates an argument array the way
|
||||
* {@code Field.get} and {@code Method.invoke} do.
|
||||
*/
|
||||
final class Check {
|
||||
|
||||
static final int NOT_NULL = 0;
|
||||
static final int NOT_BLANK = 1;
|
||||
static final int NOT_EMPTY = 2;
|
||||
static final int SIZE = 3;
|
||||
static final int RANGE_PRIMITIVE = 4;
|
||||
static final int RANGE_BOXED = 5;
|
||||
static final int EMAIL = 6;
|
||||
static final int PATTERN = 7;
|
||||
|
||||
final int op;
|
||||
final String field;
|
||||
/** Pre-rendered at compile time, so even the failure path formats nothing. */
|
||||
final String message;
|
||||
|
||||
/** {@code (Object)Object} — set for every op except {@link #RANGE_PRIMITIVE}. */
|
||||
final MethodHandle ref;
|
||||
/** {@code (Object)long} — set only for {@link #RANGE_PRIMITIVE}. */
|
||||
final MethodHandle num;
|
||||
|
||||
final int min;
|
||||
final int max;
|
||||
final long lo;
|
||||
final long hi;
|
||||
final Pattern pattern;
|
||||
|
||||
private Check(int op, String field, String message, MethodHandle ref, MethodHandle num,
|
||||
int min, int max, long lo, long hi, Pattern pattern) {
|
||||
this.op = op;
|
||||
this.field = field;
|
||||
this.message = message;
|
||||
this.ref = ref;
|
||||
this.num = num;
|
||||
this.min = min;
|
||||
this.max = max;
|
||||
this.lo = lo;
|
||||
this.hi = hi;
|
||||
this.pattern = pattern;
|
||||
}
|
||||
|
||||
static Check reference(int op, String field, String message, MethodHandle ref) {
|
||||
return new Check(op, field, message, ref, null, 0, 0, 0, 0, null);
|
||||
}
|
||||
|
||||
static Check size(String field, String message, MethodHandle ref, int min, int max) {
|
||||
return new Check(SIZE, field, message, ref, null, min, max, 0, 0, null);
|
||||
}
|
||||
|
||||
static Check rangePrimitive(String field, String message, MethodHandle num, long lo, long hi) {
|
||||
return new Check(RANGE_PRIMITIVE, field, message, null, num, 0, 0, lo, hi, null);
|
||||
}
|
||||
|
||||
static Check rangeBoxed(String field, String message, MethodHandle ref, long lo, long hi) {
|
||||
return new Check(RANGE_BOXED, field, message, ref, null, 0, 0, lo, hi, null);
|
||||
}
|
||||
|
||||
static Check pattern(String field, String message, MethodHandle ref, Pattern pattern) {
|
||||
return new Check(PATTERN, field, message, ref, null, 0, 0, 0, 0, pattern);
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
|
||||
import dev.relism.flash.ext.jackson.Json;
|
||||
import dev.relism.flash.models.Request;
|
||||
|
||||
/**
|
||||
* The validation service. Resolve it with {@code require(Validation.class)}.
|
||||
*
|
||||
* <pre>{@code
|
||||
* CreateUser dto = validation.body(req, CreateUser.class); // parse + verify
|
||||
* }</pre>
|
||||
*
|
||||
* <p>Constraints are compiled the first time a type is seen and cached in a {@link ClassValue},
|
||||
* which the JVM stores beside the class itself — no map lookup, no lock, and the entry is
|
||||
* collected with the class rather than pinning it. Every later request walks the compiled table.
|
||||
*/
|
||||
public final class Validation {
|
||||
|
||||
private final ClassValue<Validator> validators = new ClassValue<>() {
|
||||
@Override protected Validator computeValue(Class<?> type) {
|
||||
return Validator.compile(type);
|
||||
}
|
||||
};
|
||||
|
||||
/** Null when flash-ext-jackson is absent; only {@link #body} needs it. */
|
||||
private Json json;
|
||||
|
||||
Validation() {}
|
||||
|
||||
/** Called once at boot by {@link ValidationExtension}, after the service graph resolves. */
|
||||
void bindCodec(Json json) {
|
||||
this.json = json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes the request body into {@code type} and verifies its constraints.
|
||||
*
|
||||
* @throws dev.relism.flash.exceptions.HttpException 400 if the body is not valid JSON
|
||||
* @throws ValidationException 422 if it parses but violates a constraint
|
||||
*/
|
||||
public <T> T body(Request request, Class<T> type) throws Exception {
|
||||
if (json == null)
|
||||
throw new IllegalStateException(
|
||||
"Validation.body(...) needs a JSON codec — install JacksonExtension, "
|
||||
+ "or parse yourself and call validate(...)");
|
||||
T value = json.body(request, type);
|
||||
validators.get(type).verify(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an already-constructed value.
|
||||
*
|
||||
* @return {@code value}, so it can be used inline
|
||||
* @throws ValidationException 422 on the first type's worth of failures
|
||||
*/
|
||||
public <T> T validate(T value) {
|
||||
validators.get(value.getClass()).verify(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The compiled constraints of {@code type}. Useful to pre-warm a hot DTO at boot, or to
|
||||
* check whether a type declares constraints at all.
|
||||
*/
|
||||
public Validator forType(Class<?> type) {
|
||||
return validators.get(type);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Raised when a value fails its constraints. Extends {@link HttpException} with status 422, so
|
||||
* Flash's default exception handler renders it without this extension registering anything.
|
||||
*
|
||||
* <p>Allocated only on failure — a passing validation constructs nothing.
|
||||
*/
|
||||
public final class ValidationException extends HttpException {
|
||||
|
||||
private final transient List<Violation> violations;
|
||||
|
||||
ValidationException(List<Violation> violations) {
|
||||
super(422, describe(violations));
|
||||
this.violations = List.copyOf(violations);
|
||||
}
|
||||
|
||||
/** The individual failures, in field declaration order. */
|
||||
public List<Violation> violations() {
|
||||
return violations;
|
||||
}
|
||||
|
||||
private static String describe(List<Violation> violations) {
|
||||
StringBuilder out = new StringBuilder(32 * violations.size());
|
||||
for (int i = 0; i < violations.size(); i++) {
|
||||
if (i > 0) out.append("; ");
|
||||
Violation v = violations.get(i);
|
||||
out.append(v.field()).append(' ').append(v.message());
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/** One failed constraint. */
|
||||
public record Violation(String field, String message) {}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
|
||||
import dev.relism.flash.ext.jackson.Json;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
|
||||
/**
|
||||
* Installs request validation.
|
||||
*
|
||||
* <pre>{@code
|
||||
* FlashApp.create(8080)
|
||||
* .install(new JacksonExtension())
|
||||
* .install(new ValidationExtension())
|
||||
* .scan("dev.example.api");
|
||||
* }</pre>
|
||||
*
|
||||
* <p>No configuration. There is nothing to tune: constraints come from the annotations already on
|
||||
* your types, failures come back as 422 through Flash's default exception handler because
|
||||
* {@link ValidationException} carries its own status, and the JSON codec is picked up if
|
||||
* {@code flash-ext-jackson} is installed.
|
||||
*
|
||||
* <p>Install order does not matter — Flash resolves the whole service graph before any handler
|
||||
* initialises.
|
||||
*/
|
||||
public final class ValidationExtension implements FlashExtension {
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.supply(Validation.class, Validation::new);
|
||||
|
||||
// Resolved here rather than declared as a dependency: jackson is optional, and a declared
|
||||
// dependency would make it mandatory. By the time ready callbacks run the graph is
|
||||
// complete, so find() sees whatever was actually installed.
|
||||
ctx.onReady(() -> ctx.require(Validation.class).bindCodec(ctx.find(Json.class).orElse(null)));
|
||||
}
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.lang.invoke.MethodType;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The compiled constraints of one type. Built once per class and reused for every request.
|
||||
*
|
||||
* <p>{@link #verify} allocates nothing when a value passes: the loop walks an array (no iterator),
|
||||
* reads fields through exact-signature {@link MethodHandle}s (no boxing, no argument array), and
|
||||
* compares against operands resolved at compile time. The violation list and the exception are
|
||||
* constructed only once something actually fails.
|
||||
*/
|
||||
public final class Validator {
|
||||
|
||||
private static final Check[] NONE = new Check[0];
|
||||
|
||||
private final Check[] checks;
|
||||
|
||||
private Validator(Check[] checks) {
|
||||
this.checks = checks;
|
||||
}
|
||||
|
||||
/** True when the type declares no constraints at all — {@link #verify} is then a no-op. */
|
||||
public boolean isEmpty() {
|
||||
return checks.length == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies every constraint on {@code target}.
|
||||
*
|
||||
* @throws ValidationException with all failures, never just the first
|
||||
*/
|
||||
public void verify(Object target) {
|
||||
List<ValidationException.Violation> failures = null;
|
||||
for (Check check : checks) {
|
||||
if (passes(check, target)) continue;
|
||||
if (failures == null) failures = new ArrayList<>(4);
|
||||
failures.add(new ValidationException.Violation(check.field, check.message));
|
||||
}
|
||||
if (failures != null) throw new ValidationException(failures);
|
||||
}
|
||||
|
||||
private static boolean passes(Check check, Object target) {
|
||||
try {
|
||||
if (check.op == Check.RANGE_PRIMITIVE) {
|
||||
long value = (long) check.num.invokeExact(target);
|
||||
return value >= check.lo && value <= check.hi;
|
||||
}
|
||||
Object value = (Object) check.ref.invokeExact(target);
|
||||
// Jakarta semantics: only @NotNull rejects null; every other constraint passes it.
|
||||
return switch (check.op) {
|
||||
case Check.NOT_NULL -> value != null;
|
||||
case Check.NOT_BLANK -> value instanceof String text && !text.isBlank();
|
||||
case Check.NOT_EMPTY -> value != null && sizeOf(value) > 0;
|
||||
case Check.SIZE -> value == null || withinSize(check, value);
|
||||
case Check.RANGE_BOXED -> value == null || withinRange(check, (Number) value);
|
||||
case Check.EMAIL -> value == null || (value instanceof String text && isEmail(text));
|
||||
case Check.PATTERN -> value == null
|
||||
|| (value instanceof String text && check.pattern.matcher(text).matches());
|
||||
default -> true;
|
||||
};
|
||||
} catch (Throwable failure) {
|
||||
throw new IllegalStateException("Could not read " + check.field + " for validation", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean withinSize(Check check, Object value) {
|
||||
int size = sizeOf(value);
|
||||
return size >= check.min && size <= check.max;
|
||||
}
|
||||
|
||||
private static boolean withinRange(Check check, Number value) {
|
||||
long asLong = value.longValue();
|
||||
return asLong >= check.lo && asLong <= check.hi;
|
||||
}
|
||||
|
||||
/** No copies: every branch reads a length the object already knows. */
|
||||
private static int sizeOf(Object value) {
|
||||
if (value instanceof CharSequence text) return text.length();
|
||||
if (value instanceof Collection<?> items) return items.size();
|
||||
if (value instanceof Map<?, ?> entries) return entries.size();
|
||||
if (value instanceof Object[] array) return array.length;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural check rather than a regex: {@code Pattern.matcher} allocates a matcher, an int
|
||||
* array and a group array on every call, which is exactly the per-request cost this module
|
||||
* exists to avoid. {@code indexOf} allocates nothing.
|
||||
*
|
||||
* <p>Accepts what a mail server would plausibly route and rejects the shapes people actually
|
||||
* typo. Deliverability is the confirmation mail's job, not a validator's.
|
||||
*/
|
||||
private static boolean isEmail(String value) {
|
||||
int at = value.indexOf('@');
|
||||
if (at <= 0 || at == value.length() - 1) return false;
|
||||
if (value.indexOf('@', at + 1) >= 0) return false;
|
||||
int dot = value.indexOf('.', at + 2);
|
||||
return dot > 0 && dot < value.length() - 1 && value.indexOf(' ') < 0;
|
||||
}
|
||||
|
||||
// ── Compilation ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compiles {@code type}'s constraints once.
|
||||
*
|
||||
* <p>Reads declared fields rather than record accessors: a constraint on a record component
|
||||
* propagates to the backing field, so records and plain classes need one code path, not two.
|
||||
*/
|
||||
static Validator compile(Class<?> type) {
|
||||
MethodHandles.Lookup lookup;
|
||||
try {
|
||||
lookup = MethodHandles.privateLookupIn(type, MethodHandles.lookup());
|
||||
} catch (IllegalAccessException denied) {
|
||||
throw new IllegalStateException(
|
||||
"Cannot read " + type.getName() + " for validation — open its module or package", denied);
|
||||
}
|
||||
|
||||
List<Check> checks = new ArrayList<>();
|
||||
for (Field field : type.getDeclaredFields()) {
|
||||
if (Modifier.isStatic(field.getModifiers())) continue;
|
||||
MethodHandle getter;
|
||||
try {
|
||||
getter = lookup.unreflectGetter(field);
|
||||
} catch (IllegalAccessException denied) {
|
||||
continue;
|
||||
}
|
||||
compileField(field, getter, checks);
|
||||
}
|
||||
return new Validator(checks.isEmpty() ? NONE : checks.toArray(new Check[0]));
|
||||
}
|
||||
|
||||
private static void compileField(Field field, MethodHandle getter, List<Check> checks) {
|
||||
String name = field.getName();
|
||||
Class<?> type = field.getType();
|
||||
MethodHandle ref = type.isPrimitive() ? null : asReference(getter);
|
||||
|
||||
if (field.isAnnotationPresent(NotNull.class) && ref != null)
|
||||
checks.add(Check.reference(Check.NOT_NULL, name, "must not be null", ref));
|
||||
|
||||
if (field.isAnnotationPresent(NotBlank.class) && ref != null)
|
||||
checks.add(Check.reference(Check.NOT_BLANK, name, "must not be blank", ref));
|
||||
|
||||
if (field.isAnnotationPresent(NotEmpty.class) && ref != null)
|
||||
checks.add(Check.reference(Check.NOT_EMPTY, name, "must not be empty", ref));
|
||||
|
||||
Size size = field.getAnnotation(Size.class);
|
||||
if (size != null && ref != null)
|
||||
checks.add(Check.size(name, sizeMessage(size), ref, size.min(), size.max()));
|
||||
|
||||
Min min = field.getAnnotation(Min.class);
|
||||
Max max = field.getAnnotation(Max.class);
|
||||
if (min != null || max != null) {
|
||||
long lo = min != null ? min.value() : Long.MIN_VALUE;
|
||||
long hi = max != null ? max.value() : Long.MAX_VALUE;
|
||||
String message = rangeMessage(min, max);
|
||||
if (isIntegralPrimitive(type)) {
|
||||
checks.add(Check.rangePrimitive(name, message, asLong(getter), lo, hi));
|
||||
} else if (Number.class.isAssignableFrom(type) && ref != null) {
|
||||
checks.add(Check.rangeBoxed(name, message, ref, lo, hi));
|
||||
}
|
||||
}
|
||||
|
||||
if (field.isAnnotationPresent(Email.class) && ref != null)
|
||||
checks.add(Check.reference(Check.EMAIL, name, "must be a well-formed email address", ref));
|
||||
|
||||
Pattern pattern = field.getAnnotation(Pattern.class);
|
||||
if (pattern != null && ref != null) {
|
||||
// ponytail: the one allocating check — Pattern.matcher() per call. The regex itself is
|
||||
// compiled once here; swap for a structural check if a hot route ever needs it.
|
||||
checks.add(Check.pattern(name, "must match " + pattern.regexp(), ref,
|
||||
java.util.regex.Pattern.compile(pattern.regexp())));
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isIntegralPrimitive(Class<?> type) {
|
||||
return type == int.class || type == long.class || type == short.class || type == byte.class;
|
||||
}
|
||||
|
||||
private static MethodHandle asReference(MethodHandle getter) {
|
||||
return getter.asType(MethodType.methodType(Object.class, Object.class));
|
||||
}
|
||||
|
||||
private static MethodHandle asLong(MethodHandle getter) {
|
||||
return getter.asType(MethodType.methodType(long.class, Object.class));
|
||||
}
|
||||
|
||||
private static String sizeMessage(Size size) {
|
||||
if (size.min() == 0) return "size must be at most " + size.max();
|
||||
if (size.max() == Integer.MAX_VALUE) return "size must be at least " + size.min();
|
||||
return "size must be between " + size.min() + " and " + size.max();
|
||||
}
|
||||
|
||||
private static String rangeMessage(Min min, Max max) {
|
||||
if (min == null) return "must be at most " + max.value();
|
||||
if (max == null) return "must be at least " + min.value();
|
||||
return "must be between " + min.value() + " and " + max.value();
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
|
||||
import dev.relism.flash.ext.jackson.JacksonExtension;
|
||||
import dev.relism.flash.ext.openapi.APIResponse;
|
||||
import dev.relism.flash.ext.openapi.ApiOperation;
|
||||
import dev.relism.flash.ext.openapi.Content;
|
||||
import dev.relism.flash.ext.openapi.OpenApiExtension;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.GET;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
/**
|
||||
* Constraints are declared once and read twice: the validator enforces them, the published schema
|
||||
* describes them. Nothing registers this bridge — flash-ext-openapi picks the annotations up on
|
||||
* its own when they are on the classpath.
|
||||
*/
|
||||
class ValidationOpenApiInteropTest {
|
||||
|
||||
record Account(
|
||||
@NotBlank @Size(max = 40) String name,
|
||||
@Email String email,
|
||||
@Min(18) @Max(120) int age) {}
|
||||
|
||||
@GET("/accounts")
|
||||
@ApiOperation(summary = "List accounts")
|
||||
@APIResponse(responseCode = "200", content = @Content(contentType = ContentType.JSON, schema = Account.class))
|
||||
public static class ListAccounts extends RequestHandler {
|
||||
@Override public Object handle(Request request, Response response) {
|
||||
return new Account("alice", "a@b.com", 30);
|
||||
}
|
||||
}
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest app = FlashTest.of(configured -> {
|
||||
configured.install(new JacksonExtension());
|
||||
configured.install(new ValidationExtension());
|
||||
configured.install(new OpenApiExtension("/openapi", "Accounts", "1.0.0"));
|
||||
configured.scan("dev.relism.flash.ext.validation");
|
||||
});
|
||||
|
||||
@Test
|
||||
void constraintsAppearInTheGeneratedSchema() {
|
||||
app.get("/openapi.json")
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"maxLength\":40")
|
||||
.expectBodyContains("\"format\":\"email\"")
|
||||
.expectBodyContains("\"minimum\":18")
|
||||
.expectBodyContains("\"maximum\":120");
|
||||
}
|
||||
|
||||
@Test
|
||||
void notBlankMarksThePropertyRequiredAndNonEmpty() {
|
||||
app.get("/openapi.json")
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"minLength\":1")
|
||||
.expectBodyContains("\"required\":[\"name\"]");
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
|
||||
import dev.relism.flash.ext.jackson.JacksonExtension;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/** The whole path: JSON in, constraints checked, status out — with no error handling wired up. */
|
||||
class ValidationRoutesTest {
|
||||
|
||||
record CreateUser(@NotBlank @Size(max = 8) String name, @Email String email, @Min(18) int age) {}
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest app = FlashTest.of(configured -> {
|
||||
configured.install(new JacksonExtension());
|
||||
configured.install(new ValidationExtension());
|
||||
|
||||
configured.ctx().onReady(() -> {
|
||||
Validation validation = configured.ctx().require(Validation.class);
|
||||
configured.post("/users", (req, res) ->
|
||||
res.status(201).body("created:" + validation.body(req, CreateUser.class).name()));
|
||||
});
|
||||
});
|
||||
|
||||
@Test
|
||||
void validBodyReachesTheHandler() {
|
||||
app.request().json("{\"name\":\"alice\",\"email\":\"a@b.com\",\"age\":30}").post("/users")
|
||||
.expectStatus(201)
|
||||
.expectBody("created:alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
void constraintViolationBecomes422WithEveryFailureListed() {
|
||||
app.request().json("{\"name\":\"\",\"email\":\"nope\",\"age\":5}").post("/users")
|
||||
.expectStatus(422)
|
||||
.expectHeader("Content-Type", "application/json")
|
||||
.expectBodyContains("name must not be blank")
|
||||
.expectBodyContains("email must be a well-formed email address")
|
||||
.expectBodyContains("age must be at least 18");
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedJsonBecomes400NotAValidationFailure() {
|
||||
app.request().json("not json").post("/users")
|
||||
.expectStatus(400)
|
||||
.expectBodyContains("Invalid request body");
|
||||
}
|
||||
|
||||
/** Regression guard: HttpException used to reach the catch-all and come back as 500. */
|
||||
@Test
|
||||
void statusCarriedByTheExceptionSurvivesToTheWire() {
|
||||
assertEquals(422, app.request().json("{\"name\":\"x\",\"email\":\"a@b.com\",\"age\":1}")
|
||||
.post("/users").status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void errorBodyIsValidJsonEvenWhenTheMessageContainsQuotes() {
|
||||
app.request().json("{\"name\":\"waaaaaaaaaay-too-long\",\"email\":\"a@b.com\",\"age\":30}").post("/users")
|
||||
.expectStatus(422)
|
||||
.expectBodyContains("\"status\":422")
|
||||
.expectBodyContains("size must be at most 8");
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ValidatorTest {
|
||||
|
||||
record CreateUser(
|
||||
@NotBlank @Size(max = 8) String name,
|
||||
@Email String email,
|
||||
@Min(18) @Max(120) int age,
|
||||
@NotNull String role) {}
|
||||
|
||||
record Boxed(@Min(1) Integer count) {}
|
||||
|
||||
record Sized(@NotEmpty List<String> tags, @Size(min = 2, max = 4) String code) {}
|
||||
|
||||
record Patterned(@Pattern(regexp = "[a-z]+") String slug) {}
|
||||
|
||||
record Plain(String anything) {}
|
||||
|
||||
private static ValidationException failureOf(Object value) {
|
||||
return assertThrows(ValidationException.class, () -> Validator.compile(value.getClass()).verify(value));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aValidValuePasses() {
|
||||
assertDoesNotThrow(() ->
|
||||
Validator.compile(CreateUser.class).verify(new CreateUser("alice", "a@b.com", 30, "admin")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsEveryViolationNotJustTheFirst() {
|
||||
ValidationException failure = failureOf(new CreateUser(" ", "nope", 5, null));
|
||||
|
||||
assertEquals(List.of("name", "email", "age", "role"),
|
||||
failure.violations().stream().map(ValidationException.Violation::field).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void violationsCarryFieldAndMessage() {
|
||||
ValidationException failure = failureOf(new CreateUser("alice", "a@b.com", 5, "admin"));
|
||||
|
||||
assertEquals(1, failure.violations().size());
|
||||
assertEquals("age", failure.violations().get(0).field());
|
||||
assertEquals("must be between 18 and 120", failure.violations().get(0).message());
|
||||
assertEquals(422, failure.status());
|
||||
assertEquals("age must be between 18 and 120", failure.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sizeCountsCharactersWithoutCopying() {
|
||||
assertEquals("name", failureOf(new CreateUser("far-too-long", "a@b.com", 30, "x"))
|
||||
.violations().get(0).field());
|
||||
}
|
||||
|
||||
@Test
|
||||
void onlyNotNullRejectsNull() {
|
||||
// @Email, @Size and @Min all accept null per Jakarta semantics; @NotNull is the one that does not.
|
||||
ValidationException failure = failureOf(new CreateUser("alice", null, 30, null));
|
||||
|
||||
assertEquals(List.of("role"),
|
||||
failure.violations().stream().map(ValidationException.Violation::field).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void boxedNumbersUseTheReferencePathAndTolerateNull() {
|
||||
assertDoesNotThrow(() -> Validator.compile(Boxed.class).verify(new Boxed(null)));
|
||||
assertEquals("count", failureOf(new Boxed(0)).violations().get(0).field());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sizeAppliesToCollectionsAndStrings() {
|
||||
assertDoesNotThrow(() -> Validator.compile(Sized.class).verify(new Sized(List.of("a"), "abc")));
|
||||
|
||||
ValidationException failure = failureOf(new Sized(List.of(), "x"));
|
||||
assertEquals(List.of("tags", "code"),
|
||||
failure.violations().stream().map(ValidationException.Violation::field).toList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void patternIsAnchoredLikeJakarta() {
|
||||
assertDoesNotThrow(() -> Validator.compile(Patterned.class).verify(new Patterned("abc")));
|
||||
assertEquals("slug", failureOf(new Patterned("Abc1")).violations().get(0).field());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emailAcceptsPlausibleAddressesAndRejectsTypos() {
|
||||
assertDoesNotThrow(() ->
|
||||
Validator.compile(CreateUser.class).verify(new CreateUser("a", "first.last@sub.example.co", 20, "x")));
|
||||
|
||||
for (String bad : List.of("no-at", "@leading.com", "trailing@", "two@@at.com", "no dots@x", "a@b")) {
|
||||
assertThrows(ValidationException.class,
|
||||
() -> Validator.compile(CreateUser.class).verify(new CreateUser("a", bad, 20, "x")),
|
||||
bad);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aTypeWithNoConstraintsCompilesToANoOp() {
|
||||
Validator validator = Validator.compile(Plain.class);
|
||||
|
||||
assertTrue(validator.isEmpty());
|
||||
assertDoesNotThrow(() -> validator.verify(new Plain(null)));
|
||||
}
|
||||
}
|
||||
@@ -33,5 +33,10 @@
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
+53
-94
@@ -1,122 +1,81 @@
|
||||
package dev.relism.flash.ext.webbundler;
|
||||
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Two frontend layouts served by a real server. Each is laid out on disk inside the
|
||||
* application's own configure(), which the harness runs lazily at first access — by then
|
||||
* {@link TempDir} is populated, and a server whose test never runs is never booted.
|
||||
*/
|
||||
class WebBundlerExtensionIntegrationTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
static Path tempDir;
|
||||
|
||||
private FlashApp app;
|
||||
@RegisterExtension
|
||||
static FlashTest managed = FlashTest.of(app -> {
|
||||
Path webRoot = write(tempDir.resolve("web").resolve("dist"),
|
||||
"index.html", "<html>spa</html>",
|
||||
"app.js", "console.log('ok');").getParent();
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (app != null) app.stop();
|
||||
}
|
||||
|
||||
@Test
|
||||
void prodMode_servesAssetsAndFallback_withoutBreakingBackendRoutes() throws Exception {
|
||||
Path webRoot = tempDir.resolve("web");
|
||||
Path dist = webRoot.resolve("dist");
|
||||
Files.createDirectories(dist);
|
||||
Files.writeString(dist.resolve("index.html"), "<html>spa</html>");
|
||||
Files.writeString(dist.resolve("app.js"), "console.log('ok');");
|
||||
|
||||
int port;
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
port = s.getLocalPort();
|
||||
}
|
||||
|
||||
WebBundlerConfig config = WebBundlerConfig.builder()
|
||||
app.install(new WebBundlerExtension(WebBundlerConfig.builder()
|
||||
.runtimeMode(RuntimeMode.PROD)
|
||||
.operationMode(OperationMode.MANAGED)
|
||||
.webRoot(webRoot)
|
||||
.assetsFromFilesystem(Path.of("dist"))
|
||||
.basePath("/app")
|
||||
.build();
|
||||
|
||||
app = FlashApp.create(port);
|
||||
app.install(new WebBundlerExtension(config));
|
||||
.build()));
|
||||
app.get("/api/ping", (req, res) -> "pong");
|
||||
app.start();
|
||||
});
|
||||
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
HttpResponse<String> backend = client.send(
|
||||
HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/api/ping")).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString()
|
||||
);
|
||||
assertEquals(200, backend.statusCode());
|
||||
assertEquals("pong", backend.body());
|
||||
// PROD is deterministic in a test JVM (Flash.DEV depends on env/system-property detection
|
||||
// that can't be forced per-test); STATIC's actual guarantee — that it never orchestrates,
|
||||
// in DEV or PROD — is enforced structurally by the same requiresOrchestration() gate in
|
||||
// both WebBundlerExtension.provide() and .routes(), not by this test.
|
||||
@RegisterExtension
|
||||
static FlashTest staticFrontend = FlashTest.of(app -> {
|
||||
Path webRoot = write(tempDir.resolve("public"),
|
||||
"index.html", "<html>static</html>",
|
||||
"style.css", "body{color:red}");
|
||||
|
||||
HttpResponse<String> asset = client.send(
|
||||
HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/app/app.js")).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString()
|
||||
);
|
||||
assertEquals(200, asset.statusCode());
|
||||
assertTrue(asset.body().contains("console.log"));
|
||||
|
||||
HttpResponse<String> fallback = client.send(
|
||||
HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/app/some/client/route")).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString()
|
||||
);
|
||||
assertEquals(200, fallback.statusCode());
|
||||
assertTrue(fallback.body().contains("spa"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void staticFrontend_servesAssetsWithoutOrchestration() throws Exception {
|
||||
Path webRoot = tempDir.resolve("public");
|
||||
Files.createDirectories(webRoot);
|
||||
Files.writeString(webRoot.resolve("index.html"), "<html>static</html>");
|
||||
Files.writeString(webRoot.resolve("style.css"), "body{color:red}");
|
||||
|
||||
int port;
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
port = s.getLocalPort();
|
||||
}
|
||||
|
||||
// PROD is deterministic in a test JVM (Flash.DEV depends on env/system-property detection
|
||||
// that can't be forced per-test); STATIC's actual guarantee — that it never orchestrates,
|
||||
// in DEV or PROD — is enforced structurally by the same requiresOrchestration() gate in
|
||||
// both WebBundlerExtension.provide() and .routes(), not by this test.
|
||||
WebBundlerConfig config = WebBundlerConfig.builder()
|
||||
app.install(new WebBundlerExtension(WebBundlerConfig.builder()
|
||||
.runtimeMode(RuntimeMode.PROD)
|
||||
.frontendType(FrontendType.STATIC)
|
||||
.webRoot(webRoot)
|
||||
.build();
|
||||
|
||||
app = FlashApp.create(port);
|
||||
app.install(new WebBundlerExtension(config));
|
||||
.build()));
|
||||
app.get("/api/ping", (req, res) -> "pong");
|
||||
app.start();
|
||||
});
|
||||
|
||||
HttpClient client = HttpClient.newHttpClient();
|
||||
HttpResponse<String> backend = client.send(
|
||||
HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/api/ping")).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString()
|
||||
);
|
||||
assertEquals(200, backend.statusCode());
|
||||
assertEquals("pong", backend.body());
|
||||
|
||||
HttpResponse<String> asset = client.send(
|
||||
HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/style.css")).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString()
|
||||
);
|
||||
assertEquals(200, asset.statusCode());
|
||||
assertTrue(asset.body().contains("color:red"));
|
||||
@Test
|
||||
void prodMode_servesAssetsAndFallback_withoutBreakingBackendRoutes() {
|
||||
managed.get("/api/ping").expectStatus(200).expectBody("pong");
|
||||
managed.get("/app/app.js").expectStatus(200).expectBodyContains("console.log");
|
||||
managed.get("/app/some/client/route").expectStatus(200).expectBodyContains("spa");
|
||||
}
|
||||
|
||||
@Test
|
||||
void staticFrontend_servesAssetsWithoutOrchestration() {
|
||||
staticFrontend.get("/api/ping").expectStatus(200).expectBody("pong");
|
||||
staticFrontend.get("/style.css").expectStatus(200).expectBodyContains("color:red");
|
||||
}
|
||||
|
||||
/** Creates {@code directory} and writes the given name/content pairs into it. */
|
||||
private static Path write(Path directory, String... nameThenContent) {
|
||||
try {
|
||||
Files.createDirectories(directory);
|
||||
for (int i = 0; i < nameThenContent.length; i += 2)
|
||||
Files.writeString(directory.resolve(nameThenContent[i]), nameThenContent[i + 1]);
|
||||
return directory;
|
||||
} catch (IOException failure) {
|
||||
throw new UncheckedIOException(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<module>flash-ext-limiter</module>
|
||||
<module>flash-ext-web-bundler</module>
|
||||
<module>flash-ext-mcp</module>
|
||||
<module>flash-ext-validation</module>
|
||||
<module>flash-ext-data-core</module>
|
||||
<module>flash-ext-data-jdbc</module>
|
||||
<module>flash-ext-data-hibernate</module>
|
||||
@@ -31,6 +32,17 @@
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-validation</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# flash-testing
|
||||
|
||||
JUnit 5 harness for testing Flash applications. Boots a real app on an OS-assigned port,
|
||||
swaps services for fakes, and asserts on responses. No mocking library, no assertion library —
|
||||
`flash` and `junit-jupiter-api`, nothing else.
|
||||
|
||||
## What it provides
|
||||
|
||||
| Component | Description |
|
||||
|---|---|
|
||||
| `FlashTest` | JUnit 5 extension — owns one app's lifecycle and hands you a client |
|
||||
| `FlashRequest` | Header/body builder; the HTTP verb is terminal and sends |
|
||||
| `FlashResponse` | Chainable assertions that report the real response on failure |
|
||||
| `FlashWebSocket` | WebSocket client over `java.net.http`, with a queue and timeouts |
|
||||
|
||||
## Dependency
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<version>${flash.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```java
|
||||
class UserRoutesTest {
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest app = FlashTest.of(new BlogApp())
|
||||
.mock(UserService.class, new InMemoryUserService());
|
||||
|
||||
@Test
|
||||
void listsUsers() {
|
||||
app.get("/api/users")
|
||||
.expectStatus(200)
|
||||
.expectHeader("content-type", "application/json")
|
||||
.expectBodyContains("alice");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## The application under test
|
||||
|
||||
`FlashTest.of` takes a `FlashApplication` — your app's routes, extensions and services declared
|
||||
independently of the port they run on:
|
||||
|
||||
```java
|
||||
public final class BlogApp implements FlashApplication {
|
||||
@Override public void configure(FlashApp app) {
|
||||
app.install(new JacksonExtension());
|
||||
app.mount("/api", scope -> scope.scan("dev.blog.api"));
|
||||
app.ws("/live", new FeedSocket());
|
||||
}
|
||||
}
|
||||
|
||||
FlashApp.create(8080).apply(new BlogApp()).startAndBlock(); // production
|
||||
```
|
||||
|
||||
It takes `FlashApp` rather than `FlashRegistrar` deliberately: `ws()` and `mount()` live there,
|
||||
and an application that could not register a WebSocket route or a mounted namespace would be a
|
||||
half-application.
|
||||
|
||||
It is a `@FunctionalInterface`, so a lambda and a named class are the same thing:
|
||||
|
||||
```java
|
||||
FlashTest.of(app -> app.get("/ping", (req, res) -> "pong"))
|
||||
```
|
||||
|
||||
## Requests
|
||||
|
||||
`get` and `delete` skip the builder when there is nothing to add. Everything else goes through
|
||||
`request()`, where the HTTP verb sends:
|
||||
|
||||
```java
|
||||
app.get("/api/users").expectStatus(200);
|
||||
|
||||
app.request()
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.json("{\"name\":\"bob\"}")
|
||||
.post("/api/users")
|
||||
.expectStatus(201);
|
||||
```
|
||||
|
||||
`json(...)` sets the body and `content-type: application/json`. `send(method, path)` reaches any
|
||||
verb, including the ones Flash adds beyond RFC 9110 (`PURGE`, `QUERY`).
|
||||
|
||||
Assertions chain, and every failure message carries the request line, status and body:
|
||||
|
||||
```
|
||||
expected: <200> but was: <404>
|
||||
request: GET /api/users
|
||||
status: 404
|
||||
body: {"status":404,"error":"Not Found"}
|
||||
```
|
||||
|
||||
`status()`, `body()`, `headers()` and `header(name)` are there for anything the assertions do
|
||||
not cover.
|
||||
|
||||
## Replacing services
|
||||
|
||||
`mock` installs its replacements as the **last** extension, after everything the application and
|
||||
its own extensions declare, so a fake always wins:
|
||||
|
||||
```java
|
||||
FlashTest.of(new BlogApp())
|
||||
.mock(UserService.class, new InMemoryUserService())
|
||||
.mock(Clock.class, Clock.fixed(instant, ZoneOffset.UTC));
|
||||
```
|
||||
|
||||
Any object is accepted — a hand-written fake, or a Mockito mock you created yourself. This module
|
||||
depends on no mocking library.
|
||||
|
||||
Prefer a constructor parameter when the application already takes one:
|
||||
`new AdminApi(upstreamUri)` needs no override at all. `mock` is for what you do not control —
|
||||
services declared inside `configure`, or provided by an installed extension.
|
||||
|
||||
Calling `mock` after the server has started throws.
|
||||
|
||||
## More than one server
|
||||
|
||||
`FlashTest` is an ordinary object in a field, so a class can hold as many as it needs and wire one
|
||||
from another in plain Java. Booting is lazy — reading `baseUri()` starts that server on the spot —
|
||||
so declaration order does the wiring, with no `@Order` and no reliance on JUnit's extension
|
||||
ordering:
|
||||
|
||||
```java
|
||||
@RegisterExtension static FlashTest auth = FlashTest.of(new FakeOidcApp());
|
||||
@RegisterExtension static FlashTest api = FlashTest.of(new BlogApp(auth.baseUri()));
|
||||
```
|
||||
|
||||
Because binding happens when the app is created rather than when it starts, an application can
|
||||
even be configured against its own address:
|
||||
|
||||
```java
|
||||
FlashTest.of(app -> app.install(new McpExtension(
|
||||
McpConfig.builder("srv").resourceIdentifier("http://127.0.0.1:" + app.port() + "/mcp").build())));
|
||||
```
|
||||
|
||||
A server nothing touches is never booted.
|
||||
|
||||
## Scope
|
||||
|
||||
A `static` field boots once for the test class; a non-static field boots a fresh app for every
|
||||
test. That is stock JUnit field semantics — the isolation switch is the keyword, not an option:
|
||||
|
||||
```java
|
||||
@RegisterExtension static FlashTest shared = FlashTest.of(new BlogApp()); // one boot per class
|
||||
@RegisterExtension FlashTest fresh = FlashTest.of(new BlogApp()); // one boot per test
|
||||
```
|
||||
|
||||
A class-scoped server shares state across its tests, including anything a `mock` fake accumulates.
|
||||
Reset it in `@BeforeEach`, or use an instance field.
|
||||
|
||||
## WebSockets
|
||||
|
||||
```java
|
||||
try (FlashWebSocket socket = app.ws("/live")) {
|
||||
socket.sendText("hello");
|
||||
assertEquals("echo:hello", socket.awaitText(Duration.ofSeconds(2)));
|
||||
}
|
||||
```
|
||||
|
||||
Incoming text is queued as it arrives, so a message that lands before `awaitText` is called is not
|
||||
lost. `awaitClose(timeout)` returns the server's close status code. `close()` never throws — a
|
||||
connection the server already dropped must not mask the failure the test was reporting.
|
||||
|
||||
## Configuration
|
||||
|
||||
`profile` customises the `FlashConfiguration` — timeouts, HTTP/2 switches, buffer sizes:
|
||||
|
||||
```java
|
||||
FlashTest.of(new BlogApp()).profile(cfg -> cfg.http2CleartextEnabled(true));
|
||||
```
|
||||
|
||||
Host, port and the shutdown drain window are stamped **after** the profile runs, so it cannot
|
||||
break the harness. `listener(...)` and `tls(...)` are rejected — see [limits](limits.md).
|
||||
|
||||
## Teardown
|
||||
|
||||
The client is cancelled before the server stops. `HttpClient` holds keep-alive sockets open and
|
||||
`ServerLifecycle.stop()` spins until the last one closes or the drain window expires, so the
|
||||
default 15s drain would otherwise be paid on every test class. The harness pins it to 250ms and
|
||||
uses `shutdownNow()` rather than `close()`, which blocks until every operation completes and would
|
||||
hang on a leaked WebSocket.
|
||||
|
||||
Service cleanup registered with `FlashContext.onClose` runs after the drain, so a pooled
|
||||
`DataSource` is released between test classes rather than at JVM exit.
|
||||
|
||||
## See also
|
||||
|
||||
- [limits.md](limits.md) — what the harness deliberately cannot do, and what to use instead
|
||||
@@ -0,0 +1,79 @@
|
||||
# Limits
|
||||
|
||||
What `flash-testing` deliberately cannot do, why, and what to use instead. Each of these is a
|
||||
consequence of a real constraint, not an unfinished feature.
|
||||
|
||||
## TLS
|
||||
|
||||
`profile(cfg -> cfg.tls(...))` is rejected. The harness serves plaintext on loopback and hands you
|
||||
a client bound to `http://127.0.0.1:<port>`; a TLS listener would leave that base URI pointing at
|
||||
the wrong scheme, and the client would need the test certificate in a trust store.
|
||||
|
||||
**Instead:** build the app directly with `FlashApp.create(...)` and a raw `SSLSocket`, as
|
||||
`HttpServerTlsTest` does. `port(0)` plus `FlashApp.port()` still removes the free-port dance.
|
||||
|
||||
Lifting this is the only limit here worth reconsidering, and only if application-level TLS testing
|
||||
is actually wanted — transport-level TLS is already covered by the core suite.
|
||||
|
||||
## HTTP/2
|
||||
|
||||
`java.net.http` reaches cleartext h2 through an `Upgrade:` handshake. Flash implements HTTP/2
|
||||
cleartext by **prior knowledge only** — a deliberate choice recorded in the root README — so the
|
||||
harness client cannot negotiate h2 against a plaintext Flash listener. Over TLS it would work
|
||||
through ALPN, but TLS is unavailable per the section above.
|
||||
|
||||
**Instead:** the `flash/src/test/.../http2/` suites drive h2 frames over raw sockets. That is the
|
||||
right tool for protocol behaviour anyway.
|
||||
|
||||
## WebSocket over HTTP/2
|
||||
|
||||
`java.net.http.WebSocket` does not negotiate RFC 8441 extended CONNECT, so `FlashWebSocket` always
|
||||
speaks the HTTP/1.1 upgrade. Flash supports both, but this client can only exercise one.
|
||||
|
||||
**Instead:** `WebSocketOverH2Test` and `WebSocketParityTest` frame extended CONNECT by hand.
|
||||
|
||||
## Malformed requests
|
||||
|
||||
Every request goes through `java.net.http`, which structurally cannot emit an invalid request
|
||||
line, a bad header block, a smuggled `Content-Length`, or a chunked *request* body on demand. That
|
||||
is a feature for application testing and a blocker for parser testing.
|
||||
|
||||
**Instead:** `RequestParserSecurityTest`, `RequestParserFuzzTest` and the raw-socket half of
|
||||
`HttpServerTest` write bytes directly. A harness that could send malformed requests would just be
|
||||
a socket.
|
||||
|
||||
## Response framing
|
||||
|
||||
`java.net.http` transparently decodes chunked responses and hides connection reuse, so
|
||||
`Transfer-Encoding: chunked` and `Connection: keep-alive` are not observable through
|
||||
`FlashResponse`.
|
||||
|
||||
**Instead:** `HttpServerTest` keeps raw sockets for exactly those assertions, taking its port from
|
||||
a `FlashTest` field so the class still boots once. Mixing the two styles in one class is the
|
||||
intended pattern, not a workaround.
|
||||
|
||||
## The `flash` core module
|
||||
|
||||
`flash-testing` depends on `flash`, so `flash`'s own tests cannot depend on `flash-testing` —
|
||||
Maven rejects module cycles regardless of scope.
|
||||
|
||||
**Instead:** core tests use `FlashApp.create(...)` with `port(0)` and read `port()` back. Every
|
||||
core suite already does this. Unblocking it would need a third module depending on both, which is
|
||||
not worth it for the two suites that would benefit.
|
||||
|
||||
## Scoped services
|
||||
|
||||
`mock` writes to the app's root `FlashContext`. `FlashContext.require` checks its own bindings
|
||||
before its parent's, so a service declared inside a `mount(...)` scope's child context shadows the
|
||||
root and is **not** reachable from `mock`.
|
||||
|
||||
**Instead:** declare the service on the app rather than inside the scope, or assert against the
|
||||
real one. Child-context targeting would be a small addition if a scoped service ever needs faking.
|
||||
|
||||
## Shutdown draining
|
||||
|
||||
The harness pins `shutdownDrainTimeoutMs` to 250ms after any profile runs, so a test cannot
|
||||
exercise graceful-drain behaviour through it.
|
||||
|
||||
**Instead:** `ServerLifecycleGracefulShutdownTest` builds its app directly. A profile escape hatch
|
||||
would be easy to add if this ever comes up twice.
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-parent</artifactId>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<!--
|
||||
Deliberately NOT under flash-extensions/: that aggregator holds things you install()
|
||||
onto an app. This module is a JUnit 5 extension, and it depends on junit-jupiter-api at
|
||||
COMPILE scope — nothing installable should drag JUnit onto an application's classpath.
|
||||
Consumers add this with <scope>test</scope>.
|
||||
-->
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,94 @@
|
||||
package dev.relism.flash.testing;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A request being built against a {@link FlashTest} server. The HTTP verb is terminal — it
|
||||
* sends the request and hands back a {@link FlashResponse}:
|
||||
*
|
||||
* <pre>{@code
|
||||
* app.request()
|
||||
* .header("Authorization", "Bearer " + token)
|
||||
* .json("{\"name\":\"bob\"}")
|
||||
* .post("/api/users")
|
||||
* .expectStatus(201);
|
||||
* }</pre>
|
||||
*
|
||||
* For a bodyless {@code GET} or {@code DELETE}, {@link FlashTest#get} and
|
||||
* {@link FlashTest#delete} skip the builder entirely.
|
||||
*/
|
||||
public final class FlashRequest {
|
||||
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10);
|
||||
|
||||
private final FlashTest server;
|
||||
private final HttpRequest.Builder request = HttpRequest.newBuilder().timeout(REQUEST_TIMEOUT);
|
||||
private byte[] body;
|
||||
|
||||
FlashRequest(FlashTest server) {
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
/** Adds a header. Repeatable — a name may be sent more than once. */
|
||||
public FlashRequest header(String name, String value) {
|
||||
request.header(name, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Sets a UTF-8 request body. */
|
||||
public FlashRequest body(String text) {
|
||||
this.body = Objects.requireNonNull(text, "text").getBytes(StandardCharsets.UTF_8);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Sets a raw request body. */
|
||||
public FlashRequest body(byte[] bytes) {
|
||||
this.body = Objects.requireNonNull(bytes, "bytes").clone();
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Sets a UTF-8 body and {@code content-type: application/json}. */
|
||||
public FlashRequest json(String json) {
|
||||
return body(json).header("content-type", "application/json");
|
||||
}
|
||||
|
||||
public FlashResponse get(String path) { return send("GET", path); }
|
||||
public FlashResponse post(String path) { return send("POST", path); }
|
||||
public FlashResponse put(String path) { return send("PUT", path); }
|
||||
public FlashResponse patch(String path) { return send("PATCH", path); }
|
||||
public FlashResponse delete(String path) { return send("DELETE", path); }
|
||||
public FlashResponse head(String path) { return send("HEAD", path); }
|
||||
public FlashResponse options(String path) { return send("OPTIONS", path); }
|
||||
|
||||
/** Sends any method, including the ones Flash adds beyond RFC 9110 ({@code PURGE}, {@code QUERY}). */
|
||||
public FlashResponse send(String method, String path) {
|
||||
URI target = server.baseUri().resolve(normalise(path));
|
||||
HttpRequest built = request.uri(target)
|
||||
.method(method, body == null
|
||||
? HttpRequest.BodyPublishers.noBody()
|
||||
: HttpRequest.BodyPublishers.ofByteArray(body))
|
||||
.build();
|
||||
try {
|
||||
HttpResponse<String> response =
|
||||
server.client().send(built, HttpResponse.BodyHandlers.ofString());
|
||||
return new FlashResponse(method, target.getPath(), response);
|
||||
} catch (IOException failure) {
|
||||
throw new AssertionError(method + ' ' + target + " failed", failure);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError(method + ' ' + target + " was interrupted", interrupted);
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared with {@link FlashTest#ws} — the ws:// URI needs the same leading slash. */
|
||||
static String normalise(String path) {
|
||||
Objects.requireNonNull(path, "path");
|
||||
return path.startsWith("/") ? path : '/' + path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package dev.relism.flash.testing;
|
||||
|
||||
import java.net.http.HttpHeaders;
|
||||
import java.net.http.HttpResponse;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* A response from a {@link FlashTest} server, with chainable assertions.
|
||||
*
|
||||
* <pre>{@code
|
||||
* app.get("/api/users")
|
||||
* .expectStatus(200)
|
||||
* .expectHeader("content-type", "application/json")
|
||||
* .expectBodyContains("alice");
|
||||
* }</pre>
|
||||
*
|
||||
* Every failure message carries the request line, the status and the body, so a red test says
|
||||
* what actually came back rather than only what did not match. Use {@link #status()},
|
||||
* {@link #body()} and {@link #headers()} for anything the assertions do not cover.
|
||||
*/
|
||||
public final class FlashResponse {
|
||||
|
||||
private final String method;
|
||||
private final String path;
|
||||
private final HttpResponse<String> response;
|
||||
|
||||
FlashResponse(String method, String path, HttpResponse<String> response) {
|
||||
this.method = method;
|
||||
this.path = path;
|
||||
this.response = response;
|
||||
}
|
||||
|
||||
// ── Raw access ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Response status code. */
|
||||
public int status() { return response.statusCode(); }
|
||||
|
||||
/** Response body decoded as a string. */
|
||||
public String body() { return response.body(); }
|
||||
|
||||
/** All response headers. */
|
||||
public HttpHeaders headers() { return response.headers(); }
|
||||
|
||||
/** First value of {@code name} (case-insensitive), or {@code null} if absent. */
|
||||
public String header(String name) { return response.headers().firstValue(name).orElse(null); }
|
||||
|
||||
// ── Assertions ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Asserts the status code. */
|
||||
public FlashResponse expectStatus(int expected) {
|
||||
assertEquals(expected, status(), this::describe);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Asserts the first value of a header (name case-insensitive). */
|
||||
public FlashResponse expectHeader(String name, String expected) {
|
||||
assertEquals(expected, header(name), () -> "header '" + name + '\'' + describe());
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Asserts the body matches exactly. */
|
||||
public FlashResponse expectBody(String expected) {
|
||||
assertEquals(expected, body(), this::describe);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Asserts the body contains {@code fragment}. */
|
||||
public FlashResponse expectBodyContains(String fragment) {
|
||||
assertTrue(body().contains(fragment), () -> "expected body to contain '" + fragment + '\'' + describe());
|
||||
return this;
|
||||
}
|
||||
|
||||
private String describe() {
|
||||
return "\n request: " + method + ' ' + path
|
||||
+ "\n status: " + status()
|
||||
+ "\n body: " + body();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package dev.relism.flash.testing;
|
||||
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashApplication;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import org.junit.jupiter.api.extension.AfterAllCallback;
|
||||
import org.junit.jupiter.api.extension.AfterEachCallback;
|
||||
import org.junit.jupiter.api.extension.BeforeAllCallback;
|
||||
import org.junit.jupiter.api.extension.BeforeEachCallback;
|
||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Boots a real {@link FlashApp} on an OS-assigned port for the duration of a test class or a
|
||||
* single test, and gives you a client pointed at it.
|
||||
*
|
||||
* <pre>{@code
|
||||
* class UserRoutesTest {
|
||||
*
|
||||
* @RegisterExtension
|
||||
* static FlashTest app = FlashTest.of(new BlogApp())
|
||||
* .mock(UserService.class, new InMemoryUserService());
|
||||
*
|
||||
* @Test
|
||||
* void listsUsers() {
|
||||
* app.get("/api/users")
|
||||
* .expectStatus(200)
|
||||
* .expectBodyContains("alice");
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <h3>More than one server</h3>
|
||||
* Because this is an ordinary object in a field rather than an annotation, a test class can
|
||||
* hold as many as it needs, and one can be wired from another with plain Java. Startup is lazy
|
||||
* — reading {@code baseUri()} boots that server on the spot — so declaration order does the
|
||||
* wiring, with no reliance on JUnit's extension ordering:
|
||||
*
|
||||
* <pre>{@code
|
||||
* @RegisterExtension static FlashTest auth = FlashTest.of(new FakeOidcApp());
|
||||
* @RegisterExtension static FlashTest api = FlashTest.of(new BlogApp(auth.baseUri()));
|
||||
* }</pre>
|
||||
*
|
||||
* <h3>Scope</h3>
|
||||
* A {@code static} field boots once for the class; a non-static field boots a fresh app for
|
||||
* every test. That is stock JUnit field semantics — the isolation switch is the keyword.
|
||||
*
|
||||
* <h3>Replacing services</h3>
|
||||
* {@link #mock} installs its replacements as the last extension, after everything the
|
||||
* application and its extensions declare, so a fake always wins. Any object will do — a
|
||||
* hand-written fake or a Mockito mock you created; this module depends on no mocking library.
|
||||
*
|
||||
* @see FlashRequest
|
||||
* @see FlashResponse
|
||||
*/
|
||||
public final class FlashTest implements BeforeAllCallback, AfterAllCallback,
|
||||
BeforeEachCallback, AfterEachCallback {
|
||||
|
||||
private static final String LOOPBACK = "127.0.0.1";
|
||||
|
||||
/**
|
||||
* ponytail: the harness pins the drain window so teardown is fast — the default 15s would
|
||||
* be paid on every class. Add a profile escape hatch if a test ever needs to exercise
|
||||
* draining itself.
|
||||
*/
|
||||
private static final int DRAIN_MS = 250;
|
||||
|
||||
private final FlashApplication application;
|
||||
private final Map<Class<?>, Object> overrides = new LinkedHashMap<>();
|
||||
private Consumer<FlashConfiguration.FlashConfigurationBuilder> profile = builder -> { };
|
||||
|
||||
private FlashApp app;
|
||||
private HttpClient client;
|
||||
private URI baseUri;
|
||||
private boolean classScoped;
|
||||
|
||||
private FlashTest(FlashApplication application) {
|
||||
this.application = Objects.requireNonNull(application, "application");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a harness for {@code application}. Nothing is bound or started until first use.
|
||||
*
|
||||
* <p>{@link FlashApplication} is a functional interface, so a lambda works as well as a
|
||||
* named class: {@code FlashTest.of(app -> app.get("/ping", (req, res) -> "pong"))}.
|
||||
*/
|
||||
public static FlashTest of(FlashApplication application) {
|
||||
return new FlashTest(application);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customises the {@link FlashConfiguration} this server runs with — timeouts, HTTP/2
|
||||
* switches, buffer sizes.
|
||||
*
|
||||
* <p>The harness stamps host, port and the shutdown drain window <em>after</em> this runs,
|
||||
* so a profile cannot break it. Setting {@code listener(...)} or {@code tls(...)} is
|
||||
* rejected: the harness owns the single plaintext loopback listener it hands you a client
|
||||
* for.
|
||||
*/
|
||||
public FlashTest profile(Consumer<FlashConfiguration.FlashConfigurationBuilder> profile) {
|
||||
requireNotStarted("profile(...)");
|
||||
this.profile = Objects.requireNonNull(profile, "profile");
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the service bound to {@code type} with {@code instance} for this server.
|
||||
*
|
||||
* <p>Wins over anything the application or its extensions declare, including services
|
||||
* provided by an installed {@code FlashExtension}.
|
||||
*
|
||||
* <p>ponytail: overrides are applied to the app's root context. A service declared inside
|
||||
* a {@code mount(...)} scope's child context shadows the root and is therefore not
|
||||
* reachable — add child-context targeting if that ever comes up.
|
||||
*/
|
||||
public <T> FlashTest mock(Class<T> type, T instance) {
|
||||
requireNotStarted("mock(...)");
|
||||
overrides.put(Objects.requireNonNull(type, "type"), Objects.requireNonNull(instance, "instance"));
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── Accessors (each boots the server if it is not running) ────────────────
|
||||
|
||||
/** Base URI of the running server, e.g. {@code http://127.0.0.1:41307}. */
|
||||
public URI baseUri() { ensureStarted(); return baseUri; }
|
||||
|
||||
/** OS-assigned port of the running server. */
|
||||
public int port() { ensureStarted(); return app.port(); }
|
||||
|
||||
/** The running app — escape hatch for assertions the harness does not cover. */
|
||||
public FlashApp app() { ensureStarted(); return app; }
|
||||
|
||||
/** The client the harness issues requests with. */
|
||||
public HttpClient client() { ensureStarted(); return client; }
|
||||
|
||||
// ── Requests ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Starts a request with headers or a body; the HTTP verb sends it. */
|
||||
public FlashRequest request() { ensureStarted(); return new FlashRequest(this); }
|
||||
|
||||
/** Sends {@code GET path} with no headers or body. */
|
||||
public FlashResponse get(String path) { return request().get(path); }
|
||||
|
||||
/** Sends {@code DELETE path} with no headers or body. */
|
||||
public FlashResponse delete(String path) { return request().delete(path); }
|
||||
|
||||
/**
|
||||
* Opens a WebSocket to {@code path} on this server. Close it when done — a
|
||||
* try-with-resources block is the usual shape.
|
||||
*/
|
||||
public FlashWebSocket ws(String path) {
|
||||
ensureStarted();
|
||||
return new FlashWebSocket(client,
|
||||
URI.create("ws://" + LOOPBACK + ':' + app.port() + FlashRequest.normalise(path)));
|
||||
}
|
||||
|
||||
// ── JUnit lifecycle ──────────────────────────────────────────────────────
|
||||
// A static field receives class- AND method-level callbacks, so afterEach would otherwise
|
||||
// tear the server down after the first test. classScoped records which tier owns it.
|
||||
//
|
||||
// Neither hook starts anything: booting stays lazy, so a class holding several servers
|
||||
// only pays for the ones a test actually touches, and an application whose configure()
|
||||
// reads @TempDir sees it populated rather than null.
|
||||
|
||||
@Override public void beforeAll(ExtensionContext context) { classScoped = true; }
|
||||
@Override public void afterAll(ExtensionContext context) { stop(); }
|
||||
@Override public void beforeEach(ExtensionContext context) { /* lazy */ }
|
||||
@Override public void afterEach(ExtensionContext context) { if (!classScoped) stop(); }
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────────────────
|
||||
|
||||
private void ensureStarted() {
|
||||
if (app == null) start();
|
||||
}
|
||||
|
||||
private void start() {
|
||||
FlashConfiguration.FlashConfigurationBuilder builder = FlashConfiguration.builder();
|
||||
profile.accept(builder);
|
||||
FlashConfiguration config = builder
|
||||
.port(0)
|
||||
.host(LOOPBACK)
|
||||
.shutdownDrainTimeoutMs(DRAIN_MS)
|
||||
.build();
|
||||
rejectListenerOverrides(config);
|
||||
|
||||
FlashApp starting = FlashApp.create(config).apply(application);
|
||||
if (!overrides.isEmpty())
|
||||
starting.install((registrar, ctx) -> applyOverrides(ctx));
|
||||
starting.start();
|
||||
|
||||
app = starting;
|
||||
baseUri = URI.create("http://" + LOOPBACK + ':' + starting.port());
|
||||
client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code listeners} is a lombok {@code @Singular} field, so it can only be inspected after
|
||||
* the build — hence build-then-check rather than a guard on the builder.
|
||||
*/
|
||||
private static void rejectListenerOverrides(FlashConfiguration config) {
|
||||
if (!config.getListeners().isEmpty())
|
||||
throw new IllegalStateException(
|
||||
"FlashTest owns the listener — remove listener(...) from the profile");
|
||||
if (config.getTls() != null)
|
||||
throw new IllegalStateException(
|
||||
"FlashTest serves plaintext on loopback — remove tls(...) from the profile");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void applyOverrides(FlashContext ctx) {
|
||||
overrides.forEach((type, instance) -> ctx.override((Class<Object>) type, instance));
|
||||
}
|
||||
|
||||
private void stop() {
|
||||
if (app == null) return;
|
||||
// Order matters: the client holds keep-alive sockets open, and ServerLifecycle.stop()
|
||||
// spins until the last one closes or the drain window expires. shutdownNow rather than
|
||||
// close(), which blocks until every operation completes — a test that leaked an open
|
||||
// WebSocket would hang teardown forever.
|
||||
client.shutdownNow();
|
||||
try {
|
||||
client.awaitTermination(Duration.ofSeconds(2));
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
app.stop().join();
|
||||
app = null;
|
||||
client = null;
|
||||
baseUri = null;
|
||||
}
|
||||
|
||||
private void requireNotStarted(String what) {
|
||||
if (app != null)
|
||||
throw new IllegalStateException(what + " must be configured before the server starts");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package dev.relism.flash.testing;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.WebSocket;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.LinkedBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
/**
|
||||
* A WebSocket connected to a {@link FlashTest} server, for asserting on what a Flash endpoint
|
||||
* pushes back.
|
||||
*
|
||||
* <pre>{@code
|
||||
* try (FlashWebSocket socket = app.ws("/live")) {
|
||||
* socket.sendText("hello");
|
||||
* assertEquals("echo:hello", socket.awaitText(Duration.ofSeconds(2)));
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* Backed by {@link java.net.http.WebSocket}, so the RFC 6455 handshake, masking and
|
||||
* fragmentation are the JDK's, not hand-rolled. Incoming text is queued as it arrives, so a
|
||||
* message that lands before {@link #awaitText} is called is not lost.
|
||||
*/
|
||||
public final class FlashWebSocket implements AutoCloseable {
|
||||
|
||||
private final BlockingQueue<String> received = new LinkedBlockingQueue<>();
|
||||
private final CompletableFuture<Integer> closed = new CompletableFuture<>();
|
||||
private final WebSocket socket;
|
||||
|
||||
FlashWebSocket(HttpClient client, URI uri) {
|
||||
this.socket = client.newWebSocketBuilder()
|
||||
.buildAsync(uri, new QueueingListener())
|
||||
.join();
|
||||
}
|
||||
|
||||
/** Sends a whole text message. */
|
||||
public FlashWebSocket sendText(String message) {
|
||||
socket.sendText(Objects.requireNonNull(message, "message"), true).join();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the next text message.
|
||||
*
|
||||
* @throws AssertionError if none arrives within {@code timeout}
|
||||
*/
|
||||
public String awaitText(Duration timeout) {
|
||||
String message = poll(timeout);
|
||||
if (message == null)
|
||||
throw new AssertionError("No WebSocket text message within " + timeout);
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the server to close the connection and returns its close status code.
|
||||
*
|
||||
* @throws AssertionError if the server does not close within {@code timeout}
|
||||
*/
|
||||
public int awaitClose(Duration timeout) {
|
||||
try {
|
||||
return closed.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||
} catch (TimeoutException notClosed) {
|
||||
throw new AssertionError("WebSocket was not closed within " + timeout, notClosed);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError("Interrupted awaiting WebSocket close", interrupted);
|
||||
} catch (ExecutionException failure) {
|
||||
throw new AssertionError("WebSocket failed before closing", failure.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a normal close and gives the server a moment to answer it.
|
||||
*
|
||||
* <p>Never throws: this is cleanup, usually in a try-with-resources, and a connection the
|
||||
* server already dropped must not mask the failure the test was actually reporting. Use
|
||||
* {@link #awaitClose} when the close itself is what you are asserting on.
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
try {
|
||||
if (!socket.isOutputClosed()) socket.sendClose(WebSocket.NORMAL_CLOSURE, "").join();
|
||||
} catch (RuntimeException alreadyGone) {
|
||||
// nothing to close
|
||||
}
|
||||
closed.completeOnTimeout(WebSocket.NORMAL_CLOSURE, 1, TimeUnit.SECONDS)
|
||||
.exceptionally(failure -> WebSocket.NORMAL_CLOSURE)
|
||||
.join();
|
||||
}
|
||||
|
||||
private String poll(Duration timeout) {
|
||||
try {
|
||||
return received.poll(timeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError("Interrupted awaiting a WebSocket message", interrupted);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reassembles fragmented text and queues whole messages. */
|
||||
private final class QueueingListener implements WebSocket.Listener {
|
||||
|
||||
private final StringBuilder partial = new StringBuilder();
|
||||
|
||||
@Override
|
||||
public void onOpen(WebSocket webSocket) {
|
||||
webSocket.request(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
|
||||
partial.append(data);
|
||||
if (last) {
|
||||
received.add(partial.toString());
|
||||
partial.setLength(0);
|
||||
}
|
||||
webSocket.request(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
|
||||
closed.complete(statusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(WebSocket webSocket, Throwable error) {
|
||||
closed.completeExceptionally(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package dev.relism.flash.testing;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* A non-static field gets JUnit's method-level callbacks, so the app is rebuilt for every
|
||||
* test. Counting boots proves the isolation rather than inferring it from a fresh port, which
|
||||
* the OS is free to reuse.
|
||||
*/
|
||||
class FlashTestPerMethodScopeTest {
|
||||
|
||||
private static final AtomicInteger boots = new AtomicInteger();
|
||||
|
||||
@RegisterExtension
|
||||
FlashTest app = FlashTest.of(configured -> {
|
||||
boots.incrementAndGet();
|
||||
configured.get("/ping", (req, res) -> "pong");
|
||||
});
|
||||
|
||||
@AfterAll
|
||||
static void bootedOncePerTest() {
|
||||
assertEquals(2, boots.get(), "an instance FlashTest field should boot per test");
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstTestGetsItsOwnApp() {
|
||||
app.get("/ping").expectStatus(200).expectBody("pong");
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondTestGetsAnotherApp() {
|
||||
app.get("/ping").expectStatus(200).expectBody("pong");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package dev.relism.flash.testing;
|
||||
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashApplication;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* The scenario the harness exists for: two servers in one class, the second configured from
|
||||
* the first's address, with a service faked out on the second.
|
||||
*/
|
||||
class FlashTestSelfTest {
|
||||
|
||||
private static final AtomicInteger downstreamBoots = new AtomicInteger();
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest upstream = FlashTest.of(app -> app
|
||||
.get("/health", (req, res) -> "UP")
|
||||
.get("/echo", (req, res) -> "upstream:" + req.query("v")));
|
||||
|
||||
// upstream.baseUri() boots it right here, during this field's initialiser — declaration
|
||||
// order does the wiring, with no @Order and no reliance on JUnit's extension ordering.
|
||||
@RegisterExtension
|
||||
static FlashTest downstream = FlashTest.of(new Downstream(upstream.baseUri()))
|
||||
.mock(Greeter.class, () -> "faked");
|
||||
|
||||
@AfterAll
|
||||
static void classScopedServerBootsExactlyOnce() {
|
||||
assertEquals(1, downstreamBoots.get(), "a static FlashTest field should boot once per class");
|
||||
}
|
||||
|
||||
// ── two servers, wired together ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void eachServerGetsItsOwnPort() {
|
||||
assertNotEquals(upstream.port(), downstream.port());
|
||||
assertTrue(upstream.port() > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void downstreamReachesUpstreamThroughItsInjectedBaseUri() {
|
||||
downstream.get("/call-upstream")
|
||||
.expectStatus(200)
|
||||
.expectBody("UP");
|
||||
}
|
||||
|
||||
// ── mocking ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void mockWinsOverAServiceProvidedByAnInstalledExtension() {
|
||||
downstream.get("/greeting")
|
||||
.expectStatus(200)
|
||||
.expectBody("faked");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mockAfterStartIsRejected() {
|
||||
IllegalStateException error = assertThrows(IllegalStateException.class,
|
||||
() -> downstream.mock(Greeter.class, () -> "too late"));
|
||||
assertTrue(error.getMessage().contains("before the server starts"));
|
||||
}
|
||||
|
||||
// ── request / response surface ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void sendsHeadersQueriesAndBodies() {
|
||||
upstream.get("/echo?v=7").expectBody("upstream:7");
|
||||
|
||||
downstream.request()
|
||||
.header("X-Trace", "abc")
|
||||
.json("{\"name\":\"bob\"}")
|
||||
.post("/submit")
|
||||
.expectStatus(201)
|
||||
.expectHeader("X-Trace", "abc")
|
||||
.expectBodyContains("bob");
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedAssertionsReportTheActualResponse() {
|
||||
AssertionError error = assertThrows(AssertionError.class,
|
||||
() -> upstream.get("/health").expectStatus(404));
|
||||
|
||||
String message = error.getMessage();
|
||||
assertTrue(message.contains("GET /health"), message);
|
||||
assertTrue(message.contains("200"), message);
|
||||
assertTrue(message.contains("UP"), message);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmatchedRoutesStillComeBackAsResponses() {
|
||||
upstream.get("/nope").expectStatus(404);
|
||||
}
|
||||
|
||||
// ── fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Counts its own boots so the class-scoped lifecycle can be asserted. */
|
||||
private record Downstream(URI upstream) implements FlashApplication {
|
||||
|
||||
@Override
|
||||
public void configure(FlashApp app) {
|
||||
downstreamBoots.incrementAndGet();
|
||||
|
||||
// Provided by an extension, so the .mock(...) above has something real to beat.
|
||||
app.install((registrar, ctx) -> ctx.provide(Greeter.class, () -> "real"));
|
||||
|
||||
app.get("/greeting", (req, res) -> app.ctx().require(Greeter.class).greet());
|
||||
|
||||
app.get("/call-upstream", (req, res) -> {
|
||||
try (HttpClient http = HttpClient.newHttpClient()) {
|
||||
return http.send(HttpRequest.newBuilder(upstream.resolve("/health")).build(),
|
||||
HttpResponse.BodyHandlers.ofString()).body();
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException(failure);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(interrupted);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/submit", (req, res) -> res.status(201)
|
||||
.header("X-Trace", req.header("X-Trace"))
|
||||
.body(req.body().bytes()));
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface Greeter { String greet(); }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package dev.relism.flash.testing;
|
||||
|
||||
import dev.relism.flash.websocket.WebSocketFrame;
|
||||
import dev.relism.flash.websocket.WebSocketHandler;
|
||||
import dev.relism.flash.websocket.WebSocketSession;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class FlashWebSocketTest {
|
||||
|
||||
private static final Duration TIMEOUT = Duration.ofSeconds(2);
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest app = FlashTest.of(configured -> {
|
||||
configured.ws("/echo", new WebSocketHandler() {
|
||||
@Override public void onOpen(WebSocketSession session) { }
|
||||
@Override public void onMessage(WebSocketSession session, WebSocketFrame frame) {
|
||||
if (frame.opcode() != WebSocketFrame.OP_TEXT) return;
|
||||
try {
|
||||
byte[] echo = ("echo:" + new String(frame.copyPayload(), frame.payloadOffset(),
|
||||
frame.payloadLength(), StandardCharsets.UTF_8)).getBytes(StandardCharsets.UTF_8);
|
||||
session.sendText(echo, 0, echo.length);
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException(failure);
|
||||
}
|
||||
}
|
||||
});
|
||||
configured.ws("/silent", new WebSocketHandler() {
|
||||
@Override public void onOpen(WebSocketSession session) { }
|
||||
@Override public void onMessage(WebSocketSession session, WebSocketFrame frame) { }
|
||||
});
|
||||
});
|
||||
|
||||
@Test
|
||||
void roundTripsTextThroughARealHandshake() {
|
||||
try (FlashWebSocket socket = app.ws("/echo")) {
|
||||
socket.sendText("hello");
|
||||
assertEquals("echo:hello", socket.awaitText(TIMEOUT));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void queuesEveryMessageInOrder() {
|
||||
try (FlashWebSocket socket = app.ws("/echo")) {
|
||||
socket.sendText("one").sendText("two");
|
||||
assertEquals("echo:one", socket.awaitText(TIMEOUT));
|
||||
assertEquals("echo:two", socket.awaitText(TIMEOUT));
|
||||
}
|
||||
}
|
||||
|
||||
/** The likeliest user mistake: teardown must cancel it rather than block on it. */
|
||||
@Test
|
||||
void anUnclosedSocketDoesNotHangTeardown() {
|
||||
FlashWebSocket leaked = app.ws("/echo");
|
||||
leaked.sendText("no try-with-resources here");
|
||||
assertEquals("echo:no try-with-resources here", leaked.awaitText(TIMEOUT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void awaitTextFailsLoudlyWhenNothingArrives() {
|
||||
try (FlashWebSocket socket = app.ws("/silent")) {
|
||||
socket.sendText("ignored");
|
||||
AssertionError error = assertThrows(AssertionError.class,
|
||||
() -> socket.awaitText(Duration.ofMillis(300)));
|
||||
assertEquals("No WebSocket text message within PT0.3S", error.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import dev.relism.flash.routing.AbstractWsRouter;
|
||||
import dev.relism.flash.transport.TransportFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
@@ -28,6 +29,16 @@ public interface ServerHandle {
|
||||
/** Gracefully stops the server, draining active connections. */
|
||||
CompletableFuture<Void> stop();
|
||||
|
||||
/**
|
||||
* Port of the first bound listener. Valid as soon as the handle exists — listeners are
|
||||
* bound at construction, not at {@link #start()} — so configuring with port {@code 0}
|
||||
* and reading the OS-assigned port back is a supported pattern.
|
||||
*/
|
||||
int port();
|
||||
|
||||
/** Ports of every bound listener, in configuration order. */
|
||||
List<Integer> ports();
|
||||
|
||||
static ServerHandle create(FlashConfiguration config,
|
||||
AbstractRouter httpRouter,
|
||||
AbstractWsRouter wsRouter) throws IOException {
|
||||
|
||||
@@ -22,6 +22,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@@ -138,6 +139,16 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a {@link FlashApplication} to this app. Runs immediately, so {@code configure}
|
||||
* sees a context that is still open for declarations and a listener that is already
|
||||
* bound ({@link #port()}).
|
||||
*/
|
||||
public FlashApp apply(FlashApplication application) {
|
||||
Objects.requireNonNull(application, "application").configure(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -166,7 +177,24 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
|
||||
server.startAndBlock();
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> stop() { return server.stop(); }
|
||||
/**
|
||||
* Gracefully stops the server, then runs every {@link FlashContext#onClose} callback so
|
||||
* services release what they hold. Draining first means in-flight requests still see a
|
||||
* live service graph.
|
||||
*/
|
||||
public CompletableFuture<Void> stop() {
|
||||
return server.stop().whenComplete((ignored, failure) -> ctx.runCloseCallbacks());
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of the first bound listener. Usable before {@link #start()}: listeners bind when
|
||||
* the app is created, so {@code FlashApp.create(cfg with port 0).port()} yields the
|
||||
* OS-assigned port, and an app can even be configured against its own address.
|
||||
*/
|
||||
public int port() { return server.port(); }
|
||||
|
||||
/** Ports of every bound listener, in configuration order. */
|
||||
public List<Integer> ports() { return server.ports(); }
|
||||
|
||||
// ── FlashRegistrar impl ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package dev.relism.flash.extension;
|
||||
|
||||
/**
|
||||
* One application's complete contribution to a {@link FlashApp} — its routes, extensions and
|
||||
* services — expressed independently of which port or configuration it runs on.
|
||||
*
|
||||
* <p>Optional. The fluent form keeps working exactly as before; this interface exists so the
|
||||
* same application can be created more than once, on more than one port:
|
||||
*
|
||||
* <pre>{@code
|
||||
* public final class BlogApp implements FlashApplication {
|
||||
* @Override public void configure(FlashApp app) {
|
||||
* app.install(new JacksonExtension());
|
||||
* app.mount("/api", scope -> scope.scan("dev.blog.api"));
|
||||
* app.ws("/live", new FeedSocket());
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // production
|
||||
* FlashApp.create(8080).apply(new BlogApp()).startAndBlock();
|
||||
* }</pre>
|
||||
*
|
||||
* <p>It takes {@link FlashApp} rather than {@link FlashRegistrar} deliberately: {@code ws} and
|
||||
* {@code mount} live on {@code FlashApp}, and an application that could not register a
|
||||
* WebSocket route or a mounted namespace would be a half-application.
|
||||
*
|
||||
* <p>Being a {@code @FunctionalInterface}, a lambda and a named class are the same thing here —
|
||||
* {@code app -> app.get("/ping", ...)} is as valid as {@code new BlogApp()}.
|
||||
*
|
||||
* @see FlashApp#apply(FlashApplication)
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface FlashApplication {
|
||||
|
||||
/**
|
||||
* Registers this application onto {@code app}. Called immediately by
|
||||
* {@link FlashApp#apply}, so the service graph is still open for declarations
|
||||
* ({@code app.ctx().supply(...)}) and the listener is already bound ({@code app.port()}).
|
||||
*/
|
||||
void configure(FlashApp app);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package dev.relism.flash.extension;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/** Deterministic boot-time service graph, frozen before handlers are initialised. */
|
||||
@Slf4j
|
||||
public final class FlashContext {
|
||||
private enum State { DECLARING, RESOLVING, READY }
|
||||
|
||||
@@ -13,6 +16,7 @@ public final class FlashContext {
|
||||
private final List<AnnotationProcessor> processors = new ArrayList<>();
|
||||
private final List<RouteListener> routeListeners = new ArrayList<>();
|
||||
private final List<Runnable> readyCallbacks = new ArrayList<>();
|
||||
private final List<Runnable> closeCallbacks = new ArrayList<>();
|
||||
private final List<FlashContext> children = new ArrayList<>();
|
||||
private final Deque<Class<?>> resolutionPath = new ArrayDeque<>();
|
||||
private State state = State.DECLARING;
|
||||
@@ -34,6 +38,23 @@ public final class FlashContext {
|
||||
declare(type, new Binding<>(type, List.of(), ignored -> Objects.requireNonNull(instance, "instance")));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the binding for {@code type}, whether or not one already exists.
|
||||
*
|
||||
* <p>The declaration rule everywhere else is that a duplicate is an error
|
||||
* ({@link #provide}, {@link #supply}); this is the single deliberate exception, and it
|
||||
* exists for tests — {@code flash-testing} installs overrides as the last extension so a
|
||||
* fake wins over whatever the application or an extension declared. Using it in
|
||||
* production code is legal but almost always a mistake, so a replacement is logged.
|
||||
*/
|
||||
public <T> void override(Class<T> type, T instance) {
|
||||
requireDeclaring();
|
||||
Objects.requireNonNull(type, "type");
|
||||
Objects.requireNonNull(instance, "instance");
|
||||
if (bindings.put(type, new Binding<>(type, List.of(), ignored -> instance)) != null)
|
||||
log.info("Service binding overridden: {}", type.getName());
|
||||
}
|
||||
|
||||
/** Declares a no-dependency boot factory. */
|
||||
public <T> void supply(Class<T> type, Supplier<T> factory) {
|
||||
declare(type, new Binding<>(type, List.of(), ignored -> factory.get()));
|
||||
@@ -53,6 +74,21 @@ public final class FlashContext {
|
||||
/** Registers work materialised after all services are resolved. */
|
||||
public void onReady(Runnable callback) { requireDeclaring(); readyCallbacks.add(Objects.requireNonNull(callback)); }
|
||||
|
||||
/**
|
||||
* Registers cleanup to run when the app stops, after in-flight requests have drained.
|
||||
*
|
||||
* <p>Deliberately callable outside {@code DECLARING} so a factory can register its own
|
||||
* teardown while it is being resolved:
|
||||
* <pre>{@code
|
||||
* ctx.supply(DataSource.class, c -> {
|
||||
* HikariDataSource ds = new HikariDataSource(cfg);
|
||||
* c.onClose(ds::close);
|
||||
* return ds;
|
||||
* });
|
||||
* }</pre>
|
||||
*/
|
||||
public void onClose(Runnable callback) { closeCallbacks.add(Objects.requireNonNull(callback)); }
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> T require(Class<T> type) {
|
||||
if (state == State.DECLARING)
|
||||
@@ -106,6 +142,21 @@ public final class FlashContext {
|
||||
for (FlashContext child : children) child.runReadyCallbacks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs every {@link #onClose} callback once: children first, then this context's own in
|
||||
* reverse registration order, so a service always closes before whatever it depends on.
|
||||
* A throwing callback is logged and does not stop the rest. Clearing makes a second
|
||||
* {@code stop()} a no-op.
|
||||
*/
|
||||
void runCloseCallbacks() {
|
||||
for (FlashContext child : children) child.runCloseCallbacks();
|
||||
for (int i = closeCallbacks.size() - 1; i >= 0; i--) {
|
||||
try { closeCallbacks.get(i).run(); }
|
||||
catch (RuntimeException e) { log.error("Close callback failed", e); }
|
||||
}
|
||||
closeCallbacks.clear();
|
||||
}
|
||||
|
||||
/** Completes graph resolution and runs all deferred materialisation callbacks once. */
|
||||
public void complete() {
|
||||
resolveAll();
|
||||
|
||||
@@ -4,6 +4,7 @@ import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.models.*;
|
||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl;
|
||||
import dev.relism.flash.Flash;
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.template.ErrorPages;
|
||||
@@ -56,17 +57,56 @@ public abstract class AbstractRouter {
|
||||
|
||||
protected ExceptionHandler exceptionHandler = Flash.DEV
|
||||
? (ex, req, res) -> {
|
||||
if (ex instanceof HttpException http) return renderHttpException(http, res);
|
||||
res.status(500);
|
||||
res.type(ContentType.TEXT_HTML);
|
||||
return ErrorPages.renderException(req, ex);
|
||||
}
|
||||
: (ex, req, res) -> {
|
||||
if (ex instanceof HttpException http) return renderHttpException(http, res);
|
||||
log.error("Unhandled exception in {} {}", req.method(), req.path(), ex);
|
||||
res.status(500);
|
||||
res.type(ContentType.JSON);
|
||||
return JSON_500;
|
||||
};
|
||||
|
||||
/**
|
||||
* {@link HttpException} carries the status the caller meant; without this it reached the
|
||||
* catch-all above and every one of them came back as 500 — including the 400s
|
||||
* {@code RequestHelper} and {@code flash-ext-jackson} raise for malformed input.
|
||||
*
|
||||
* <p>Deliberately not pre-encoded like {@link #JSON_404}: the message is per-exception, and
|
||||
* an error path that already unwound a stack does not need the allocation shaved.
|
||||
*/
|
||||
private static byte[] renderHttpException(HttpException failure, Response res) {
|
||||
res.status(failure.status());
|
||||
res.type(ContentType.JSON);
|
||||
String message = failure.getMessage();
|
||||
StringBuilder out = new StringBuilder(48 + (message == null ? 0 : message.length()));
|
||||
out.append("{\"error\":\"");
|
||||
escapeJson(message == null ? "" : message, out);
|
||||
out.append("\",\"status\":").append(failure.status()).append('}');
|
||||
return out.toString().getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** Minimal RFC 8259 string escaping — enough for an exception message. */
|
||||
private static void escapeJson(String text, StringBuilder out) {
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
char c = text.charAt(i);
|
||||
switch (c) {
|
||||
case '"' -> out.append("\\\"");
|
||||
case '\\' -> out.append("\\\\");
|
||||
case '\n' -> out.append("\\n");
|
||||
case '\r' -> out.append("\\r");
|
||||
case '\t' -> out.append("\\t");
|
||||
default -> {
|
||||
if (c < 0x20) out.append(String.format("\\u%04x", (int) c));
|
||||
else out.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SimpleHandler getNotFoundHandler() { return notFoundHandler; }
|
||||
public ExceptionHandler getExceptionHandler() { return exceptionHandler; }
|
||||
|
||||
|
||||
@@ -46,6 +46,14 @@ public final class ServerLifecycle implements ServerHandle {
|
||||
this.acceptLatch = new CountDownLatch(TransportTuning.ACCEPT_THREADS * listeners.size());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int port() { return listeners.get(0).socket().getLocalPort(); }
|
||||
|
||||
@Override
|
||||
public List<Integer> ports() {
|
||||
return listeners.stream().map(bound -> bound.socket().getLocalPort()).toList();
|
||||
}
|
||||
|
||||
/** Whether the server has begun shutting down. Passed down to every connection as a
|
||||
* {@link java.util.function.BooleanSupplier} so in-flight request loops can drain
|
||||
* promptly instead of waiting for their next keep-alive request. */
|
||||
|
||||
@@ -4,11 +4,10 @@ import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.models.Response;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
@@ -25,20 +24,17 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class HttpServerConcurrencyTest {
|
||||
|
||||
private FlashApp app;
|
||||
private int port;
|
||||
private HttpClient httpClient;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
port = s.getLocalPort();
|
||||
}
|
||||
private static FlashApp app;
|
||||
private static int port;
|
||||
private static HttpClient httpClient;
|
||||
|
||||
@BeforeAll
|
||||
static void setUp() {
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.port(0)
|
||||
.host("127.0.0.1")
|
||||
.build());
|
||||
port = app.port();
|
||||
|
||||
app.get("/ping", (req, res) -> "pong");
|
||||
app.post("/echo", (req, res) -> {
|
||||
@@ -53,9 +49,9 @@ class HttpServerConcurrencyTest {
|
||||
.build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (app != null) app.stop();
|
||||
@AfterAll
|
||||
static void tearDown() {
|
||||
if (app != null) app.stop().join();
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
@@ -173,15 +169,13 @@ class HttpServerConcurrencyTest {
|
||||
*/
|
||||
@Test
|
||||
void concurrent_lazyCompile_noRaceCondition() throws Exception {
|
||||
int freshPort;
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
freshPort = s.getLocalPort();
|
||||
}
|
||||
|
||||
// Deliberately a brand-new app: this test is about compiling routes lazily on first
|
||||
// use, so it must not share the class-scoped server.
|
||||
FlashApp freshApp = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(freshPort)
|
||||
.port(0)
|
||||
.host("127.0.0.1")
|
||||
.build());
|
||||
int freshPort = freshApp.port();
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
final int idx = i;
|
||||
|
||||
@@ -2,15 +2,14 @@ package dev.relism.flash;
|
||||
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@@ -18,19 +17,17 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class HttpServerTest {
|
||||
|
||||
private FlashApp app;
|
||||
private int port;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
port = s.getLocalPort();
|
||||
}
|
||||
// Every test here is read-only against the same routes, so one boot for the class.
|
||||
private static FlashApp app;
|
||||
private static int port;
|
||||
|
||||
@BeforeAll
|
||||
static void setUp() {
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.port(0)
|
||||
.host("127.0.0.1")
|
||||
.build());
|
||||
port = app.port();
|
||||
|
||||
app.get("/api/ping", (req, res) -> "pong");
|
||||
|
||||
@@ -61,9 +58,9 @@ class HttpServerTest {
|
||||
app.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (app != null) app.stop();
|
||||
@AfterAll
|
||||
static void tearDown() {
|
||||
if (app != null) app.stop().join();
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
@@ -9,7 +9,6 @@ import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
@@ -30,21 +29,15 @@ class HttpServerTimeoutTest {
|
||||
if (app != null) app.stop();
|
||||
}
|
||||
|
||||
private int freePort() throws Exception {
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
return s.getLocalPort();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void slowlorisHeaderDribble_disconnectedWithinHeaderReadTimeout() throws Exception {
|
||||
int headerTimeoutMs = 300;
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.port(0).host("127.0.0.1")
|
||||
.headerReadTimeoutMs(headerTimeoutMs)
|
||||
.idleKeepAliveTimeoutMs(60_000)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get("/", (req, res) -> "ok");
|
||||
app.start();
|
||||
|
||||
@@ -71,12 +64,12 @@ class HttpServerTimeoutTest {
|
||||
@Test
|
||||
void idleKeepAliveConnection_disconnectedWithinIdleTimeout() throws Exception {
|
||||
int idleTimeoutMs = 300;
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.port(0).host("127.0.0.1")
|
||||
.headerReadTimeoutMs(10_000)
|
||||
.idleKeepAliveTimeoutMs(idleTimeoutMs)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get("/", (req, res) -> "ok");
|
||||
app.start();
|
||||
|
||||
@@ -96,13 +89,13 @@ class HttpServerTimeoutTest {
|
||||
@Test
|
||||
void slowBodyDribble_disconnectedWithinBodyReadTimeout() throws Exception {
|
||||
int bodyTimeoutMs = 300;
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.port(0).host("127.0.0.1")
|
||||
.headerReadTimeoutMs(10_000)
|
||||
.idleKeepAliveTimeoutMs(10_000)
|
||||
.bodyReadTimeoutMs(bodyTimeoutMs)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.post("/echo", (req, res) -> req.body().bytes());
|
||||
app.start();
|
||||
|
||||
@@ -130,12 +123,12 @@ class HttpServerTimeoutTest {
|
||||
int headerTimeoutMs = 300;
|
||||
Path ks = TestKeystores.build(dir, "timeout.p12", "changeit",
|
||||
TestKeystores.Entry.of("only", "timeout.test"));
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.port(0).host("127.0.0.1")
|
||||
.tls(TlsConfig.keystore(ks, "changeit"))
|
||||
.headerReadTimeoutMs(headerTimeoutMs)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get("/", (req, res) -> "ok");
|
||||
app.start();
|
||||
|
||||
@@ -157,13 +150,13 @@ class HttpServerTimeoutTest {
|
||||
|
||||
@Test
|
||||
void wellBehavedRequest_wellWithinTimeouts_unaffected() throws Exception {
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.port(0).host("127.0.0.1")
|
||||
.headerReadTimeoutMs(300)
|
||||
.idleKeepAliveTimeoutMs(300)
|
||||
.bodyReadTimeoutMs(300)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get("/ping", (req, res) -> "pong");
|
||||
app.start();
|
||||
|
||||
|
||||
@@ -60,9 +60,6 @@ class HttpServerTlsTest {
|
||||
if (app != null) app.stop();
|
||||
}
|
||||
|
||||
private static int freePort() throws IOException {
|
||||
try (ServerSocket s = new ServerSocket(0)) { return s.getLocalPort(); }
|
||||
}
|
||||
|
||||
private static String httpGet(SSLSocket socket, String path) throws IOException {
|
||||
socket.setSoTimeout(SOCKET_TIMEOUT_MS);
|
||||
@@ -79,11 +76,11 @@ class HttpServerTlsTest {
|
||||
@Test
|
||||
void httpsRequest_servedOverModernTls(@TempDir java.nio.file.Path dir) throws Exception {
|
||||
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.port(0).host("127.0.0.1")
|
||||
.tls(TlsConfig.keystore(ks, "changeit"))
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get("/ping", (req, res) -> "pong");
|
||||
app.start();
|
||||
|
||||
@@ -103,11 +100,11 @@ class HttpServerTlsTest {
|
||||
var ks = TestKeystores.build(dir, "sni.p12", "changeit",
|
||||
TestKeystores.Entry.of("a", "a.test"),
|
||||
TestKeystores.Entry.of("b", "b.test"));
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.port(0).host("127.0.0.1")
|
||||
.tls(TlsConfig.keystore(ks, "changeit"))
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get("/ping", (req, res) -> "pong");
|
||||
app.start();
|
||||
|
||||
@@ -143,11 +140,11 @@ class HttpServerTlsTest {
|
||||
@Test
|
||||
void mTls_requireRejectsClientWithNoCertificate(@TempDir java.nio.file.Path dir) throws Exception {
|
||||
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.port(0).host("127.0.0.1")
|
||||
.tls(TlsConfig.keystore(ks, "changeit").clientAuth(ClientAuth.REQUIRE))
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get("/ping", (req, res) -> "pong");
|
||||
app.start();
|
||||
|
||||
@@ -180,11 +177,11 @@ class HttpServerTlsTest {
|
||||
SSLContext serverCtx = SSLContext.getInstance("TLS");
|
||||
serverCtx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);
|
||||
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.port(0).host("127.0.0.1")
|
||||
.tls(TlsConfig.ofContext(serverCtx).clientAuth(ClientAuth.REQUIRE))
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get("/ping", (req, res) -> "pong");
|
||||
app.start();
|
||||
|
||||
@@ -219,12 +216,12 @@ class HttpServerTlsTest {
|
||||
@Test
|
||||
void multipleListeners_plainAndTlsServeTheSameApp(@TempDir java.nio.file.Path dir) throws Exception {
|
||||
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
|
||||
int plainPort = freePort();
|
||||
int tlsPort = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.listener(new FlashConfiguration.Listener(plainPort, "127.0.0.1", null))
|
||||
.listener(new FlashConfiguration.Listener(tlsPort, "127.0.0.1", TlsConfig.keystore(ks, "changeit")))
|
||||
.listener(new FlashConfiguration.Listener(0, "127.0.0.1", null))
|
||||
.listener(new FlashConfiguration.Listener(0, "127.0.0.1", TlsConfig.keystore(ks, "changeit")))
|
||||
.build());
|
||||
int plainPort = app.ports().get(0);
|
||||
int tlsPort = app.ports().get(1);
|
||||
app.get("/ping", (req, res) -> "pong");
|
||||
app.start();
|
||||
|
||||
@@ -287,11 +284,11 @@ class HttpServerTlsTest {
|
||||
SSLContext ctx = SSLContext.getInstance("TLS");
|
||||
ctx.init(managers, null, null);
|
||||
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.port(0).host("127.0.0.1")
|
||||
.tls(TlsConfig.ofContext(ctx))
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get("/ping", (req, res) -> "pong");
|
||||
app.start();
|
||||
|
||||
@@ -364,11 +361,11 @@ class HttpServerTlsTest {
|
||||
SSLContext[] boxedCtx = new SSLContext[1];
|
||||
RecordingKeyManager recorder = buildRecordingContext(ks, boxedCtx);
|
||||
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.port(0).host("127.0.0.1")
|
||||
.tls(TlsConfig.ofContext(boxedCtx[0]).applicationProtocols("acme-tls/1", "http/1.1"))
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get("/ping", (req, res) -> "pong");
|
||||
app.start();
|
||||
|
||||
@@ -422,11 +419,11 @@ class HttpServerTlsTest {
|
||||
@Test
|
||||
void request_isSecureAndSessionAvailableOverTls(@TempDir java.nio.file.Path dir) throws Exception {
|
||||
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.port(0).host("127.0.0.1")
|
||||
.tls(TlsConfig.keystore(ks, "changeit"))
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get("/secure-info", (req, res) -> {
|
||||
SSLSession session = req.sslSession();
|
||||
return req.isSecure() + ":" + (session != null) + ":" + (session != null ? session.getCipherSuite() : "");
|
||||
@@ -456,13 +453,13 @@ class HttpServerTlsTest {
|
||||
SSLContext serverCtx = SSLContext.getInstance("TLS");
|
||||
serverCtx.init(kmf.getKeyManagers(), tmf.getTrustManagers(), null);
|
||||
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.port(0).host("127.0.0.1")
|
||||
// OPTIONAL, not REQUIRE: proves getPeerCertificates() works without also
|
||||
// re-testing the REQUIRE-rejection path already covered elsewhere in this file.
|
||||
.tls(TlsConfig.ofContext(serverCtx).clientAuth(ClientAuth.OPTIONAL))
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get("/secure-info", (req, res) -> {
|
||||
try {
|
||||
X509Certificate peer = (X509Certificate) req.sslSession().getPeerCertificates()[0];
|
||||
@@ -488,8 +485,8 @@ class HttpServerTlsTest {
|
||||
|
||||
@Test
|
||||
void request_isNotSecureAndSessionIsNullOnPlainListener() throws Exception {
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder().port(port).host("127.0.0.1").build());
|
||||
app = FlashApp.create(FlashConfiguration.builder().port(0).host("127.0.0.1").build());
|
||||
int port = app.port();
|
||||
app.get("/secure-info", (req, res) -> req.isSecure() + ":" + (req.sslSession() == null));
|
||||
app.start();
|
||||
|
||||
@@ -524,11 +521,11 @@ class HttpServerTlsTest {
|
||||
@Test
|
||||
void wss_sessionIsSecureAndExposesSslSession(@TempDir java.nio.file.Path dir) throws Exception {
|
||||
var ks = TestKeystores.build(dir, "id.p12", "changeit", TestKeystores.Entry.of("srv", "localhost"));
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.port(0).host("127.0.0.1")
|
||||
.tls(TlsConfig.keystore(ks, "changeit"))
|
||||
.build());
|
||||
int port = app.port();
|
||||
|
||||
AtomicReference<Boolean> observedSecure = new AtomicReference<>();
|
||||
AtomicReference<SSLSession> observedSession = new AtomicReference<>();
|
||||
|
||||
@@ -13,7 +13,6 @@ import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
@@ -29,14 +28,11 @@ class HttpServerWebSocketTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
port = s.getLocalPort();
|
||||
}
|
||||
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.port(0)
|
||||
.host("127.0.0.1")
|
||||
.build());
|
||||
port = app.port();
|
||||
|
||||
app.ws("/chat", new WebSocketHandler() {
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package dev.relism.flash.extension;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* The four seams {@code flash-testing} is built on: reading back an ephemeral port,
|
||||
* replacing a binding, applying an application, and closing services at stop.
|
||||
*/
|
||||
class FlashAppLifecycleTest {
|
||||
|
||||
private static FlashConfiguration ephemeral() {
|
||||
return FlashConfiguration.builder().port(0).host("127.0.0.1")
|
||||
.shutdownDrainTimeoutMs(250).build();
|
||||
}
|
||||
|
||||
// ── port() ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void exposesTheOsAssignedPortBeforeStart() {
|
||||
FlashApp app = FlashApp.create(ephemeral());
|
||||
try {
|
||||
assertTrue(app.port() > 0, "port 0 should resolve to a real bound port");
|
||||
assertEquals(List.of(app.port()), app.ports());
|
||||
} finally {
|
||||
app.stop().join();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsTheSamePortAcrossStart() {
|
||||
FlashApp app = FlashApp.create(ephemeral()).get("/ping", (req, res) -> "pong");
|
||||
try {
|
||||
int beforeStart = app.port();
|
||||
app.start();
|
||||
assertEquals(beforeStart, app.port());
|
||||
} finally {
|
||||
app.stop().join();
|
||||
}
|
||||
}
|
||||
|
||||
// ── override() ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void overrideReplacesAnExistingBinding() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ctx.provide(Greeter.class, () -> "real");
|
||||
ctx.override(Greeter.class, () -> "fake");
|
||||
|
||||
ctx.resolveAll();
|
||||
|
||||
assertEquals("fake", ctx.require(Greeter.class).greet());
|
||||
}
|
||||
|
||||
@Test
|
||||
void overrideAlsoDeclaresWhenNothingWasBound() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ctx.override(Greeter.class, () -> "fake");
|
||||
|
||||
ctx.resolveAll();
|
||||
|
||||
assertEquals("fake", ctx.require(Greeter.class).greet());
|
||||
}
|
||||
|
||||
@Test
|
||||
void overrideIsRejectedOnceDeclarationsAreClosed() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ctx.resolveAll();
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> ctx.override(Greeter.class, () -> "fake"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lastInstalledExtensionWinsOverAnEarlierProvider() {
|
||||
FlashApp app = FlashApp.create(ephemeral())
|
||||
.install((registrar, ctx) -> ctx.provide(Greeter.class, () -> "real"))
|
||||
.install((registrar, ctx) -> ctx.override(Greeter.class, () -> "fake"));
|
||||
try {
|
||||
app.start();
|
||||
assertEquals("fake", app.ctx().require(Greeter.class).greet());
|
||||
} finally {
|
||||
app.stop().join();
|
||||
}
|
||||
}
|
||||
|
||||
// ── apply() ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void applyRunsImmediatelyWithAnOpenContextAndABoundPort() {
|
||||
List<Integer> portSeenInsideConfigure = new ArrayList<>();
|
||||
|
||||
FlashApp app = FlashApp.create(ephemeral()).apply(configured -> {
|
||||
portSeenInsideConfigure.add(configured.port());
|
||||
configured.ctx().provide(Greeter.class, () -> "from-application");
|
||||
configured.get("/hello", (req, res) -> "hi");
|
||||
});
|
||||
try {
|
||||
assertEquals(List.of(app.port()), portSeenInsideConfigure);
|
||||
app.start();
|
||||
assertEquals("from-application", app.ctx().require(Greeter.class).greet());
|
||||
} finally {
|
||||
app.stop().join();
|
||||
}
|
||||
}
|
||||
|
||||
// ── onClose() ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void stopRunsCloseCallbacksInReverseOrder() {
|
||||
List<String> closed = new ArrayList<>();
|
||||
FlashApp app = FlashApp.create(ephemeral()).apply(configured -> {
|
||||
configured.ctx().onClose(() -> closed.add("first"));
|
||||
configured.ctx().onClose(() -> closed.add("second"));
|
||||
});
|
||||
|
||||
app.start();
|
||||
app.stop().join();
|
||||
|
||||
assertEquals(List.of("second", "first"), closed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFactoryCanRegisterItsOwnTeardownWhileResolving() {
|
||||
AtomicInteger closes = new AtomicInteger();
|
||||
FlashApp app = FlashApp.create(ephemeral()).apply(configured ->
|
||||
configured.ctx().supply(Greeter.class, services -> {
|
||||
services.onClose(closes::incrementAndGet);
|
||||
return () -> "resolved";
|
||||
}));
|
||||
|
||||
app.start();
|
||||
assertEquals(0, closes.get(), "must not close while the app is live");
|
||||
|
||||
app.stop().join();
|
||||
assertEquals(1, closes.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aThrowingCloseCallbackDoesNotStopTheRest() {
|
||||
List<String> closed = new ArrayList<>();
|
||||
FlashApp app = FlashApp.create(ephemeral()).apply(configured -> {
|
||||
configured.ctx().onClose(() -> closed.add("ran"));
|
||||
configured.ctx().onClose(() -> { throw new IllegalStateException("boom"); });
|
||||
});
|
||||
|
||||
app.start();
|
||||
assertDoesNotThrow(() -> app.stop().join());
|
||||
|
||||
assertEquals(List.of("ran"), closed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeCallbacksRunAtMostOnce() {
|
||||
AtomicInteger closes = new AtomicInteger();
|
||||
FlashApp app = FlashApp.create(ephemeral())
|
||||
.apply(configured -> configured.ctx().onClose(closes::incrementAndGet));
|
||||
|
||||
app.start();
|
||||
app.stop().join();
|
||||
app.stop().join();
|
||||
|
||||
assertEquals(1, closes.get());
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface Greeter { String greet(); }
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.net.ServerSocket;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@@ -20,14 +19,11 @@ class FlashAppWebSocketTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
port = s.getLocalPort();
|
||||
}
|
||||
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.port(0)
|
||||
.host("127.0.0.1")
|
||||
.build());
|
||||
port = app.port();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
|
||||
@@ -32,7 +32,6 @@ class CurlInteropTest {
|
||||
|
||||
@Test
|
||||
void tlsGetPostLargeUploadAndLargeDownload(@TempDir Path directory) throws Exception {
|
||||
int port = freePort();
|
||||
Path keystore =
|
||||
TestKeystores.build(
|
||||
directory,
|
||||
@@ -43,23 +42,24 @@ class CurlInteropTest {
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.port(0)
|
||||
.tls(TlsConfig.keystore(keystore, "changeit"))
|
||||
.http2Enabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
exercise(directory, "https://localhost:" + port, "--http2", "--insecure");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleartextGetPostLargeUploadAndLargeDownload(@TempDir Path directory) throws Exception {
|
||||
int port = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.port(0)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
exercise(directory, "http://127.0.0.1:" + port, "--http2-prior-knowledge");
|
||||
}
|
||||
|
||||
@@ -112,9 +112,4 @@ class CurlInteropTest {
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +28,14 @@ class GrpcInteropTest {
|
||||
|
||||
@Test
|
||||
void grpcurlCompletesUnaryStreamingAndErrorCalls(@TempDir Path directory) throws Exception {
|
||||
int port = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.port(0)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.post("/flash.test.Echo/Unary", (request, response) ->
|
||||
response.type("application/grpc")
|
||||
.body(request.body().bytes())
|
||||
@@ -156,11 +156,6 @@ class GrpcInteropTest {
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
|
||||
private record Result(int exitCode, String output) {}
|
||||
}
|
||||
|
||||
@@ -42,16 +42,16 @@ class H2LoadMeasurementTest {
|
||||
|
||||
@Test
|
||||
void measureFlashAndNghttpdMatrix(@TempDir Path directory) throws Exception {
|
||||
int flashPort = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(flashPort)
|
||||
.port(0)
|
||||
.http2CleartextEnabled(true)
|
||||
.h2MaxStreamsCreatedPerInterval(Integer.MAX_VALUE)
|
||||
.h2MaxStreamsPerConnection(0)
|
||||
.build());
|
||||
int flashPort = app.port();
|
||||
app.get("/index.html", (request, response) -> "flash-load");
|
||||
app.start();
|
||||
|
||||
@@ -133,6 +133,10 @@ class H2LoadMeasurementTest {
|
||||
if (path != null) builder.environment().put("LD_LIBRARY_PATH", path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Still needed here: this port is handed to an external nghttpd process, which has no
|
||||
* equivalent of {@code FlashApp.port()} to read an OS-assigned port back from.
|
||||
*/
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
|
||||
@@ -36,14 +36,14 @@ class H2SpecComplianceTest {
|
||||
|
||||
@Test
|
||||
void cleartextSuiteHasNoFailuresOrSkips(@TempDir Path directory) throws Exception {
|
||||
int port = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.port(0)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
registerProbeRoutes();
|
||||
app.start();
|
||||
|
||||
@@ -52,7 +52,6 @@ class H2SpecComplianceTest {
|
||||
|
||||
@Test
|
||||
void tlsSuiteHasNoFailuresOrSkips(@TempDir Path directory) throws Exception {
|
||||
int port = freePort();
|
||||
Path keystore =
|
||||
TestKeystores.build(
|
||||
directory,
|
||||
@@ -63,10 +62,11 @@ class H2SpecComplianceTest {
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.port(0)
|
||||
.tls(TlsConfig.keystore(keystore, "changeit"))
|
||||
.http2Enabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
registerProbeRoutes();
|
||||
app.start();
|
||||
|
||||
@@ -145,11 +145,6 @@ class H2SpecComplianceTest {
|
||||
assertEquals(0, skipped.getLength(), output);
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
|
||||
private record ProcessResult(int exitCode, String output) {}
|
||||
}
|
||||
|
||||
@@ -28,14 +28,14 @@ class H2cPriorKnowledgeTest {
|
||||
|
||||
@Test
|
||||
void priorKnowledgeRequiresItsIndependentOptIn() throws Exception {
|
||||
int disabledPort = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(disabledPort)
|
||||
.port(0)
|
||||
.http2Enabled(true)
|
||||
.build());
|
||||
int disabledPort = app.port();
|
||||
app.get("/", (request, response) -> "wrong protocol");
|
||||
app.start();
|
||||
|
||||
@@ -47,14 +47,14 @@ class H2cPriorKnowledgeTest {
|
||||
}
|
||||
app.stop().join();
|
||||
|
||||
int enabledPort = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(enabledPort)
|
||||
.port(0)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
int enabledPort = app.port();
|
||||
app.get("/", (request, response) -> "h2c");
|
||||
app.start();
|
||||
|
||||
@@ -125,9 +125,4 @@ class H2cPriorKnowledgeTest {
|
||||
payload);
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,15 +183,15 @@ class Http2AbuseTest {
|
||||
|
||||
@Test
|
||||
void idleOpenStreamIsCancelledWithinConfiguredDeadline() throws Exception {
|
||||
int port = freePort();
|
||||
FlashApp app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.port(0)
|
||||
.http2CleartextEnabled(true)
|
||||
.h2StreamIdleTimeoutMs(20)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.post("/idle", (request, response) -> request.body().bytes());
|
||||
app.start();
|
||||
ByteWriter headers = new ByteWriter(64);
|
||||
@@ -295,11 +295,6 @@ class Http2AbuseTest {
|
||||
throw new AssertionError("missing " + expected);
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
|
||||
private record Run(List<Http2TestFrames.WireFrame> frames) {
|
||||
int lastGoAwayError() {
|
||||
|
||||
@@ -29,16 +29,16 @@ class Http2ConcurrencyTest {
|
||||
|
||||
@Test
|
||||
void thousandMultiplexedStreamsCompleteOnOneConnection() throws Exception {
|
||||
int port = freePort();
|
||||
AtomicInteger handled = new AtomicInteger();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.port(0)
|
||||
.http2CleartextEnabled(true)
|
||||
.h2MaxStreamsCreatedPerInterval(2_000)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get("/work", (request, response) -> Integer.toString(handled.incrementAndGet()));
|
||||
app.start();
|
||||
|
||||
@@ -110,9 +110,4 @@ class Http2ConcurrencyTest {
|
||||
payload);
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +28,14 @@ class Http2ConnectTest {
|
||||
|
||||
@Test
|
||||
void connectTunnelCanExchangeDataBeforeEitherSideCloses() throws Exception {
|
||||
int port = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.port(0)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.connect("tunnel", (request, response) ->
|
||||
response.type(ContentType.NONE).streaming(output -> {
|
||||
byte[] bytes = new byte[16];
|
||||
@@ -100,9 +100,4 @@ class Http2ConnectTest {
|
||||
return text.getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ class Http2ConnectionIntegrationTest {
|
||||
@Test
|
||||
void javaHttpClientUsesHttp2AgainstAnExistingParameterizedRoute(@TempDir Path directory)
|
||||
throws Exception {
|
||||
int port = freePort();
|
||||
Path keystore =
|
||||
TestKeystores.build(
|
||||
directory,
|
||||
@@ -58,11 +57,12 @@ class Http2ConnectionIntegrationTest {
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.port(0)
|
||||
.host("127.0.0.1")
|
||||
.tls(TlsConfig.keystore(keystore, "changeit"))
|
||||
.http2Enabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get(
|
||||
"/users/{id}", (request, response) -> request.param("id") + ":" + request.header("host"));
|
||||
app.start();
|
||||
@@ -87,7 +87,6 @@ class Http2ConnectionIntegrationTest {
|
||||
@Test
|
||||
void javaHttpClientUploadsAndDownloadsFlowControlledBodies(@TempDir Path directory)
|
||||
throws Exception {
|
||||
int port = freePort();
|
||||
Path keystore =
|
||||
TestKeystores.build(
|
||||
directory,
|
||||
@@ -102,11 +101,12 @@ class Http2ConnectionIntegrationTest {
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.port(0)
|
||||
.host("127.0.0.1")
|
||||
.tls(TlsConfig.keystore(keystore, "changeit"))
|
||||
.http2Enabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.post("/echo", (request, response) -> request.body().bytes());
|
||||
app.get("/fixed", (request, response) -> response.body(download));
|
||||
app.get(
|
||||
@@ -143,7 +143,6 @@ class Http2ConnectionIntegrationTest {
|
||||
@Test
|
||||
void pushStreamingAppliesBackpressureAcrossMultipleWindows(@TempDir Path directory)
|
||||
throws Exception {
|
||||
int port = freePort();
|
||||
int length = 2 * 1024 * 1024 + 31;
|
||||
Path keystore =
|
||||
TestKeystores.build(
|
||||
@@ -154,11 +153,12 @@ class Http2ConnectionIntegrationTest {
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.port(0)
|
||||
.host("127.0.0.1")
|
||||
.tls(TlsConfig.keystore(keystore, "changeit"))
|
||||
.http2Enabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get(
|
||||
"/push",
|
||||
(request, response) ->
|
||||
@@ -197,7 +197,6 @@ class Http2ConnectionIntegrationTest {
|
||||
|
||||
@Test
|
||||
void hundredMegabyteUploadAndDownloadRemainStreaming(@TempDir Path directory) throws Exception {
|
||||
int port = freePort();
|
||||
long length = 100L * 1024 * 1024;
|
||||
Path keystore =
|
||||
TestKeystores.build(
|
||||
@@ -208,11 +207,12 @@ class Http2ConnectionIntegrationTest {
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.port(0)
|
||||
.host("127.0.0.1")
|
||||
.tls(TlsConfig.keystore(keystore, "changeit"))
|
||||
.http2Enabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.post(
|
||||
"/upload",
|
||||
(request, response) -> {
|
||||
@@ -250,14 +250,14 @@ class Http2ConnectionIntegrationTest {
|
||||
|
||||
@Test
|
||||
void bodylessGetRunsExistingRouteAndReturnsHeadersAndData() throws Exception {
|
||||
int port = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.port(0)
|
||||
.host("127.0.0.1")
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get("/api/ping", (request, response) -> "pong");
|
||||
app.start();
|
||||
|
||||
@@ -314,15 +314,15 @@ class Http2ConnectionIntegrationTest {
|
||||
@Test
|
||||
void resetQueuedStreamCannotReleaseOrReuseItBeforeDispatchObservesCancellation()
|
||||
throws Exception {
|
||||
int port = freePort();
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.port(0)
|
||||
.host("127.0.0.1")
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get(
|
||||
"/queued",
|
||||
(request, response) -> {
|
||||
@@ -384,15 +384,15 @@ class Http2ConnectionIntegrationTest {
|
||||
|
||||
@Test
|
||||
void h2cTransportDispatchesControlFramesWithoutRunningApplicationWork() throws Exception {
|
||||
int port = freePort();
|
||||
AtomicBoolean handlerEntered = new AtomicBoolean();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.port(0)
|
||||
.host("127.0.0.1")
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get(
|
||||
"/",
|
||||
(request, response) -> {
|
||||
@@ -442,15 +442,15 @@ class Http2ConnectionIntegrationTest {
|
||||
|
||||
@Test
|
||||
void serverStopInitiatesTwoStageGoAwayOnIdleConnection() throws Exception {
|
||||
int port = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.port(0)
|
||||
.host("127.0.0.1")
|
||||
.http2CleartextEnabled(true)
|
||||
.shutdownDrainTimeoutMs(5_000)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.start();
|
||||
|
||||
try (Socket socket = new Socket("127.0.0.1", port)) {
|
||||
@@ -485,14 +485,14 @@ class Http2ConnectionIntegrationTest {
|
||||
|
||||
@Test
|
||||
void protocolStateDoesNotLeakAcrossConsecutiveConnections() throws Exception {
|
||||
int port = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.port(0)
|
||||
.host("127.0.0.1")
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.start();
|
||||
|
||||
try (Socket first = new Socket("127.0.0.1", port)) {
|
||||
@@ -537,7 +537,6 @@ class Http2ConnectionIntegrationTest {
|
||||
@Test
|
||||
void tlsListenerOffersAndNegotiatesH2WhenHttp2IsEnabled(@TempDir Path directory)
|
||||
throws Exception {
|
||||
int port = freePort();
|
||||
Path keystore =
|
||||
TestKeystores.build(
|
||||
directory,
|
||||
@@ -547,11 +546,12 @@ class Http2ConnectionIntegrationTest {
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.port(0)
|
||||
.host("127.0.0.1")
|
||||
.tls(TlsConfig.keystore(keystore, "changeit"))
|
||||
.http2Enabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.start();
|
||||
|
||||
try (SSLSocket socket =
|
||||
@@ -630,11 +630,6 @@ class Http2ConnectionIntegrationTest {
|
||||
payload);
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
|
||||
private static String ascii(dev.relism.fpr.core.ByteView view) {
|
||||
byte[] bytes = new byte[view.length()];
|
||||
|
||||
@@ -31,7 +31,6 @@ class Http2MisdirectedRequestTest {
|
||||
|
||||
@Test
|
||||
void authorityOutsideSelectedCertificateReceives421(@TempDir Path directory) throws Exception {
|
||||
int port = freePort();
|
||||
Path keystore =
|
||||
TestKeystores.build(
|
||||
directory,
|
||||
@@ -42,10 +41,11 @@ class Http2MisdirectedRequestTest {
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.port(0)
|
||||
.tls(TlsConfig.keystore(keystore, "changeit"))
|
||||
.http2Enabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get("/", (request, response) -> "must not run");
|
||||
app.start();
|
||||
|
||||
@@ -114,9 +114,4 @@ class Http2MisdirectedRequestTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,14 +47,14 @@ class Http2RegressionCorpusTest {
|
||||
|
||||
@Test
|
||||
void headersOnHalfClosedRemoteStreamUseStreamErrorBeforeDispatch() throws Exception {
|
||||
int port = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.port(0)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get("/", (request, response) -> "ok");
|
||||
app.start();
|
||||
|
||||
@@ -101,9 +101,4 @@ class Http2RegressionCorpusTest {
|
||||
payload);
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,18 +35,18 @@ class Http2SoakTest {
|
||||
@Test
|
||||
void sustainedMixedTrafficRetainsBoundedHeapAndCompletesRequests() throws Exception {
|
||||
long seconds = Long.getLong("flash.http2.soak.seconds", 600L);
|
||||
int port = freePort();
|
||||
byte[] streamBody = new byte[8 * 1024];
|
||||
Arrays.fill(streamBody, (byte) 's');
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.port(0)
|
||||
.http2CleartextEnabled(true)
|
||||
.h2MaxStreamsCreatedPerInterval(100_000)
|
||||
.h2MaxStreamsPerConnection(0)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get("/get", (request, response) -> "get");
|
||||
app.post("/post", (request, response) -> request.body().bytes());
|
||||
app.get("/stream", (request, response) -> response.chunked(new ByteArrayInputStream(streamBody)));
|
||||
@@ -182,9 +182,4 @@ class Http2SoakTest {
|
||||
payload);
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,14 +27,14 @@ class Http2TrailersTest {
|
||||
|
||||
@Test
|
||||
void requestTrailersReachHandlerAfterBodyEof() throws Exception {
|
||||
int port = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.port(0)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.post("/trailers", (request, response) -> {
|
||||
assertEquals("abc", new String(request.body().bytes(), StandardCharsets.US_ASCII));
|
||||
return request.trailers().first("grpc-status");
|
||||
@@ -99,14 +99,14 @@ class Http2TrailersTest {
|
||||
}
|
||||
|
||||
private int startBlockingRoute() throws Exception {
|
||||
int port = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.port(0)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.post("/trailers", (request, response) -> request.body().bytes());
|
||||
app.start();
|
||||
return port;
|
||||
@@ -152,9 +152,4 @@ class Http2TrailersTest {
|
||||
payload);
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,9 +38,8 @@ class NghttpInteropTest {
|
||||
}
|
||||
|
||||
private void exercise(Path directory, boolean tls) throws Exception {
|
||||
int port = freePort();
|
||||
FlashConfiguration.FlashConfigurationBuilder builder =
|
||||
FlashConfiguration.builder().host("127.0.0.1").port(port);
|
||||
FlashConfiguration.builder().host("127.0.0.1").port(0);
|
||||
if (tls) {
|
||||
Path keystore =
|
||||
TestKeystores.build(
|
||||
@@ -54,6 +53,7 @@ class NghttpInteropTest {
|
||||
}
|
||||
byte[] large = new byte[2 * 1024 * 1024 + 29];
|
||||
app = FlashApp.create(builder.build());
|
||||
int port = app.port();
|
||||
app.get("/get", (request, response) -> "nghttp-get");
|
||||
app.post("/post", (request, response) -> "uploaded-" + request.body().bytes().length);
|
||||
app.get("/large", (request, response) -> response.body(large));
|
||||
@@ -93,9 +93,4 @@ class NghttpInteropTest {
|
||||
assertTrue(trace.contains("recv DATA frame"), trace);
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,15 +23,15 @@ class WebSocketOverH2Test {
|
||||
|
||||
@Test
|
||||
void opensEchoesFragmentsAndCarriesAMessageLargerThanTheFlowWindow() throws Exception {
|
||||
int port = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.port(0)
|
||||
.http2CleartextEnabled(true)
|
||||
.wsFrameBufferSize(2 * 1024 * 1024)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.ws(
|
||||
"/chat",
|
||||
new WebSocketHandler() {
|
||||
@@ -67,9 +67,4 @@ class WebSocketOverH2Test {
|
||||
}
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +28,14 @@ class WebSocketParityTest {
|
||||
|
||||
@Test
|
||||
void oneRouteAndHandlerEchoTheSameMessageOverHttp1AndHttp2() throws Exception {
|
||||
int port = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.port(0)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.ws(
|
||||
"/parity",
|
||||
new WebSocketHandler() {
|
||||
@@ -135,9 +135,4 @@ class WebSocketParityTest {
|
||||
return input.readNBytes(length);
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package dev.relism.flash.routing;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
@@ -7,6 +9,8 @@ import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.models.SimpleHandler;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AbstractRouterTest {
|
||||
@@ -77,4 +81,23 @@ class AbstractRouterTest {
|
||||
router.onException((ex, req, res) -> "Caught");
|
||||
assertEquals("Caught", router.getExceptionHandler().handle(new RuntimeException(), null, null));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* HttpException carries the status the caller meant. Before this was honoured every one of
|
||||
* them came back as 500, including the 400s RequestHelper and flash-ext-jackson raise.
|
||||
*/
|
||||
@Test
|
||||
void defaultExceptionHandlerHonoursHttpExceptionStatus() throws Exception {
|
||||
DummyRouter router = new DummyRouter();
|
||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
||||
|
||||
Object body = router.getExceptionHandler()
|
||||
.handle(HttpException.badRequest("bad \"input\""), null, res);
|
||||
|
||||
assertEquals(400, res.getStatusCode());
|
||||
String rendered = new String((byte[]) body, StandardCharsets.UTF_8);
|
||||
assertTrue(rendered.contains("\\\"input\\\""), "message must be JSON-escaped: " + rendered);
|
||||
assertTrue(rendered.contains("\"status\":400"), rendered);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-9
@@ -28,22 +28,17 @@ class ServerLifecycleGracefulShutdownTest {
|
||||
if (app != null) app.stop();
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket s = new ServerSocket(0)) {
|
||||
return s.getLocalPort();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void inFlightRequest_completesWithConnectionClose_duringShutdown() throws Exception {
|
||||
int port = freePort();
|
||||
CountDownLatch handlerStarted = new CountDownLatch(1);
|
||||
CountDownLatch releaseHandler = new CountDownLatch(1);
|
||||
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.port(0).host("127.0.0.1")
|
||||
.shutdownDrainTimeoutMs(5_000)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get("/slow", (req, res) -> {
|
||||
handlerStarted.countDown();
|
||||
assertTrue(releaseHandler.await(5, TimeUnit.SECONDS));
|
||||
@@ -80,11 +75,11 @@ class ServerLifecycleGracefulShutdownTest {
|
||||
|
||||
@Test
|
||||
void stop_closesListener_soNewConnectionsAreRefused() throws Exception {
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(port).host("127.0.0.1")
|
||||
.port(0).host("127.0.0.1")
|
||||
.shutdownDrainTimeoutMs(500)
|
||||
.build());
|
||||
int port = app.port();
|
||||
app.get("/ping", (req, res) -> "pong");
|
||||
app.start();
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
<modules>
|
||||
<module>flash</module>
|
||||
<module>flash-testing</module>
|
||||
<module>flash-extensions</module>
|
||||
</modules>
|
||||
|
||||
@@ -36,6 +37,8 @@
|
||||
<maven.gpg.plugin.version>3.2.8</maven.gpg.plugin.version>
|
||||
<maven.versions.plugin.version>2.18.0</maven.versions.plugin.version>
|
||||
<jmh.version>1.37</jmh.version>
|
||||
<junit.version>5.11.0</junit.version>
|
||||
<jakarta.validation.version>3.1.1</jakarta.validation.version>
|
||||
<build.helper.plugin.version>3.6.0</build.helper.plugin.version>
|
||||
</properties>
|
||||
|
||||
@@ -60,6 +63,21 @@
|
||||
<artifactId>flash</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-validation</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
<version>${jakarta.validation.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
@@ -139,9 +157,14 @@
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>5.11.0</version>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter-api</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user