diff --git a/AGENTS.md b/AGENTS.md index 985077f..11aa19e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,9 +36,9 @@ Format: `(): ` | `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: diff --git a/README.md b/README.md index bce2420..84cf184 100644 --- a/README.md +++ b/README.md @@ -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 + + dev.relism + flash-testing + ${flash.version} + test + +``` + +```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 ``` diff --git a/flash-extensions/flash-ext-mcp/pom.xml b/flash-extensions/flash-ext-mcp/pom.xml index e5effe7..db7f5ac 100644 --- a/flash-extensions/flash-ext-mcp/pom.xml +++ b/flash-extensions/flash-ext-mcp/pom.xml @@ -38,6 +38,11 @@ org.junit.jupiter junit-jupiter + + dev.relism + flash-testing + test + diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java index fb31b9c..af42bdb 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java @@ -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 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 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 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 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 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 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); } } } diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionIntegrationTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionIntegrationTest.java index 051af49..f0e760f 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionIntegrationTest.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionIntegrationTest.java @@ -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 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 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 resp = post(body); - assertEquals(200, resp.statusCode()); - return MAPPER.readTree(resp.body()); + return MAPPER.readTree(post(body).expectStatus(200).body()); } - private HttpResponse 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"); } } diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java index ee040ad..111b5ea 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java @@ -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. + * + *

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 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 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 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 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 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 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 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 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()); - } } diff --git a/flash-extensions/flash-ext-openapi/pom.xml b/flash-extensions/flash-ext-openapi/pom.xml index a3ba1fe..2aae4e7 100644 --- a/flash-extensions/flash-ext-openapi/pom.xml +++ b/flash-extensions/flash-ext-openapi/pom.xml @@ -29,6 +29,15 @@ org.projectlombok lombok + + + jakarta.validation + jakarta.validation-api + true + org.junit.jupiter junit-jupiter diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java new file mode 100644 index 0000000..a92a8aa --- /dev/null +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java @@ -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. + * + *

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 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 supported() { + return List.of("@NotNull", "@NotBlank", "@NotEmpty", "@Size", "@Min", "@Max", "@Email", "@Pattern"); + } +} diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java index 07a52b1..a420587 100644 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java @@ -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> 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); diff --git a/flash-extensions/flash-ext-validation/docs/README.md b/flash-extensions/flash-ext-validation/docs/README.md new file mode 100644 index 0000000..09f5076 --- /dev/null +++ b/flash-extensions/flash-ext-validation/docs/README.md @@ -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 + + dev.relism + flash-ext-validation + ${flash.version} + +``` + +## 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. diff --git a/flash-extensions/flash-ext-validation/pom.xml b/flash-extensions/flash-ext-validation/pom.xml new file mode 100644 index 0000000..c7fa868 --- /dev/null +++ b/flash-extensions/flash-ext-validation/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-validation + + + + dev.relism + flash + + + + jakarta.validation + jakarta.validation-api + + + + dev.relism + flash-ext-jackson + true + + + org.junit.jupiter + junit-jupiter + + + dev.relism + flash-testing + test + + + + dev.relism + flash-ext-openapi + test + + + diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Check.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Check.java new file mode 100644 index 0000000..eda228f --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Check.java @@ -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. + * + *

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); + } +} diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validation.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validation.java new file mode 100644 index 0000000..df692c2 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validation.java @@ -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)}. + * + *

{@code
+ * CreateUser dto = validation.body(req, CreateUser.class);   // parse + verify
+ * }
+ * + *

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 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 body(Request request, Class 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 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); + } +} diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationException.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationException.java new file mode 100644 index 0000000..dac202e --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationException.java @@ -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. + * + *

Allocated only on failure — a passing validation constructs nothing. + */ +public final class ValidationException extends HttpException { + + private final transient List violations; + + ValidationException(List violations) { + super(422, describe(violations)); + this.violations = List.copyOf(violations); + } + + /** The individual failures, in field declaration order. */ + public List violations() { + return violations; + } + + private static String describe(List 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) {} +} diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationExtension.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationExtension.java new file mode 100644 index 0000000..029594b --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/ValidationExtension.java @@ -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. + * + *

{@code
+ * FlashApp.create(8080)
+ *     .install(new JacksonExtension())
+ *     .install(new ValidationExtension())
+ *     .scan("dev.example.api");
+ * }
+ * + *

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. + * + *

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))); + } +} diff --git a/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validator.java b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validator.java new file mode 100644 index 0000000..153a639 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/main/java/dev/relism/flash/ext/validation/Validator.java @@ -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. + * + *

{@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 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. + * + *

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. + * + *

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 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 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(); + } +} diff --git a/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationOpenApiInteropTest.java b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationOpenApiInteropTest.java new file mode 100644 index 0000000..11868e6 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationOpenApiInteropTest.java @@ -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\"]"); + } +} diff --git a/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationRoutesTest.java b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationRoutesTest.java new file mode 100644 index 0000000..f32b5e2 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidationRoutesTest.java @@ -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"); + } +} diff --git a/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidatorTest.java b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidatorTest.java new file mode 100644 index 0000000..bd8e1d2 --- /dev/null +++ b/flash-extensions/flash-ext-validation/src/test/java/dev/relism/flash/ext/validation/ValidatorTest.java @@ -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 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))); + } +} diff --git a/flash-extensions/flash-ext-web-bundler/pom.xml b/flash-extensions/flash-ext-web-bundler/pom.xml index 6fb24c2..45ffc5a 100644 --- a/flash-extensions/flash-ext-web-bundler/pom.xml +++ b/flash-extensions/flash-ext-web-bundler/pom.xml @@ -33,5 +33,10 @@ org.junit.jupiter junit-jupiter + + dev.relism + flash-testing + test + diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java index df7c443..6060fa4 100644 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java +++ b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java @@ -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", "spa", + "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"), "spa"); - 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 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", "static", + "style.css", "body{color:red}"); - HttpResponse 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 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"), "static"); - 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 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 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); + } + } } diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml index 65ed15a..3749b9b 100644 --- a/flash-extensions/pom.xml +++ b/flash-extensions/pom.xml @@ -24,6 +24,7 @@ flash-ext-limiter flash-ext-web-bundler flash-ext-mcp + flash-ext-validation flash-ext-data-core flash-ext-data-jdbc flash-ext-data-hibernate @@ -31,6 +32,17 @@ + + dev.relism + flash-ext-validation + ${project.version} + + + dev.relism + flash-testing + ${project.version} + test + dev.relism flash-ext-jackson diff --git a/flash-testing/docs/README.md b/flash-testing/docs/README.md new file mode 100644 index 0000000..47c3d51 --- /dev/null +++ b/flash-testing/docs/README.md @@ -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 + + dev.relism + flash-testing + ${flash.version} + test + +``` + +## 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 diff --git a/flash-testing/docs/limits.md b/flash-testing/docs/limits.md new file mode 100644 index 0000000..01cfdd5 --- /dev/null +++ b/flash-testing/docs/limits.md @@ -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:`; 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. diff --git a/flash-testing/pom.xml b/flash-testing/pom.xml new file mode 100644 index 0000000..a8c2fe6 --- /dev/null +++ b/flash-testing/pom.xml @@ -0,0 +1,38 @@ + + + 4.0.0 + + + dev.relism + flash-parent + 2.1.0-SNAPSHOT + + + flash-testing + jar + + + + + dev.relism + flash + + + org.junit.jupiter + junit-jupiter-api + + + + org.junit.jupiter + junit-jupiter + test + + + diff --git a/flash-testing/src/main/java/dev/relism/flash/testing/FlashRequest.java b/flash-testing/src/main/java/dev/relism/flash/testing/FlashRequest.java new file mode 100644 index 0000000..b55b01b --- /dev/null +++ b/flash-testing/src/main/java/dev/relism/flash/testing/FlashRequest.java @@ -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}: + * + *

{@code
+ * app.request()
+ *    .header("Authorization", "Bearer " + token)
+ *    .json("{\"name\":\"bob\"}")
+ *    .post("/api/users")
+ *    .expectStatus(201);
+ * }
+ * + * 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 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; + } +} diff --git a/flash-testing/src/main/java/dev/relism/flash/testing/FlashResponse.java b/flash-testing/src/main/java/dev/relism/flash/testing/FlashResponse.java new file mode 100644 index 0000000..454173b --- /dev/null +++ b/flash-testing/src/main/java/dev/relism/flash/testing/FlashResponse.java @@ -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. + * + *
{@code
+ * app.get("/api/users")
+ *    .expectStatus(200)
+ *    .expectHeader("content-type", "application/json")
+ *    .expectBodyContains("alice");
+ * }
+ * + * 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 response; + + FlashResponse(String method, String path, HttpResponse 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(); + } +} diff --git a/flash-testing/src/main/java/dev/relism/flash/testing/FlashTest.java b/flash-testing/src/main/java/dev/relism/flash/testing/FlashTest.java new file mode 100644 index 0000000..18605e6 --- /dev/null +++ b/flash-testing/src/main/java/dev/relism/flash/testing/FlashTest.java @@ -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. + * + *
{@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");
+ *     }
+ * }
+ * }
+ * + *

More than one server

+ * 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: + * + *
{@code
+ * @RegisterExtension static FlashTest auth = FlashTest.of(new FakeOidcApp());
+ * @RegisterExtension static FlashTest api  = FlashTest.of(new BlogApp(auth.baseUri()));
+ * }
+ * + *

Scope

+ * 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. + * + *

Replacing services

+ * {@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, Object> overrides = new LinkedHashMap<>(); + private Consumer 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. + * + *

{@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. + * + *

The harness stamps host, port and the shutdown drain window after 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 profile) { + requireNotStarted("profile(...)"); + this.profile = Objects.requireNonNull(profile, "profile"); + return this; + } + + /** + * Replaces the service bound to {@code type} with {@code instance} for this server. + * + *

Wins over anything the application or its extensions declare, including services + * provided by an installed {@code FlashExtension}. + * + *

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 FlashTest mock(Class 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) 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"); + } +} diff --git a/flash-testing/src/main/java/dev/relism/flash/testing/FlashWebSocket.java b/flash-testing/src/main/java/dev/relism/flash/testing/FlashWebSocket.java new file mode 100644 index 0000000..3888e9e --- /dev/null +++ b/flash-testing/src/main/java/dev/relism/flash/testing/FlashWebSocket.java @@ -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. + * + *
{@code
+ * try (FlashWebSocket socket = app.ws("/live")) {
+ *     socket.sendText("hello");
+ *     assertEquals("echo:hello", socket.awaitText(Duration.ofSeconds(2)));
+ * }
+ * }
+ * + * 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 received = new LinkedBlockingQueue<>(); + private final CompletableFuture 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. + * + *

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); + } + } +} diff --git a/flash-testing/src/test/java/dev/relism/flash/testing/FlashTestPerMethodScopeTest.java b/flash-testing/src/test/java/dev/relism/flash/testing/FlashTestPerMethodScopeTest.java new file mode 100644 index 0000000..780c28b --- /dev/null +++ b/flash-testing/src/test/java/dev/relism/flash/testing/FlashTestPerMethodScopeTest.java @@ -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"); + } +} diff --git a/flash-testing/src/test/java/dev/relism/flash/testing/FlashTestSelfTest.java b/flash-testing/src/test/java/dev/relism/flash/testing/FlashTestSelfTest.java new file mode 100644 index 0000000..f0a6e6e --- /dev/null +++ b/flash-testing/src/test/java/dev/relism/flash/testing/FlashTestSelfTest.java @@ -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(); } +} diff --git a/flash-testing/src/test/java/dev/relism/flash/testing/FlashWebSocketTest.java b/flash-testing/src/test/java/dev/relism/flash/testing/FlashWebSocketTest.java new file mode 100644 index 0000000..0a0287e --- /dev/null +++ b/flash-testing/src/test/java/dev/relism/flash/testing/FlashWebSocketTest.java @@ -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()); + } + } +} diff --git a/flash/src/main/java/dev/relism/flash/ServerHandle.java b/flash/src/main/java/dev/relism/flash/ServerHandle.java index e03e8ce..f02a5b0 100644 --- a/flash/src/main/java/dev/relism/flash/ServerHandle.java +++ b/flash/src/main/java/dev/relism/flash/ServerHandle.java @@ -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 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 ports(); + static ServerHandle create(FlashConfiguration config, AbstractRouter httpRouter, AbstractWsRouter wsRouter) throws IOException { diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashApp.java b/flash/src/main/java/dev/relism/flash/extension/FlashApp.java index 61a83a8..896be15 100644 --- a/flash/src/main/java/dev/relism/flash/extension/FlashApp.java +++ b/flash/src/main/java/dev/relism/flash/extension/FlashApp.java @@ -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 { 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 { server.startAndBlock(); } - public CompletableFuture 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 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 ports() { return server.ports(); } // ── FlashRegistrar impl ─────────────────────────────────────────────────── diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashApplication.java b/flash/src/main/java/dev/relism/flash/extension/FlashApplication.java new file mode 100644 index 0000000..4d992b9 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/extension/FlashApplication.java @@ -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. + * + *

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: + * + *

{@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();
+ * }
+ * + *

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. + * + *

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); +} diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashContext.java b/flash/src/main/java/dev/relism/flash/extension/FlashContext.java index 36bed75..1bf3134 100644 --- a/flash/src/main/java/dev/relism/flash/extension/FlashContext.java +++ b/flash/src/main/java/dev/relism/flash/extension/FlashContext.java @@ -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 processors = new ArrayList<>(); private final List routeListeners = new ArrayList<>(); private final List readyCallbacks = new ArrayList<>(); + private final List closeCallbacks = new ArrayList<>(); private final List children = new ArrayList<>(); private final Deque> 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. + * + *

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 void override(Class 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 void supply(Class type, Supplier 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. + * + *

Deliberately callable outside {@code DECLARING} so a factory can register its own + * teardown while it is being resolved: + *

{@code
+     * ctx.supply(DataSource.class, c -> {
+     *     HikariDataSource ds = new HikariDataSource(cfg);
+     *     c.onClose(ds::close);
+     *     return ds;
+     * });
+     * }
+ */ + public void onClose(Runnable callback) { closeCallbacks.add(Objects.requireNonNull(callback)); } + @SuppressWarnings("unchecked") public T require(Class 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(); diff --git a/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java b/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java index 4473359..dc4e5ae 100644 --- a/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java +++ b/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java @@ -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. + * + *

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; } diff --git a/flash/src/main/java/dev/relism/flash/transport/ServerLifecycle.java b/flash/src/main/java/dev/relism/flash/transport/ServerLifecycle.java index d0536dc..c422587 100644 --- a/flash/src/main/java/dev/relism/flash/transport/ServerLifecycle.java +++ b/flash/src/main/java/dev/relism/flash/transport/ServerLifecycle.java @@ -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 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. */ diff --git a/flash/src/test/java/dev/relism/flash/HttpServerConcurrencyTest.java b/flash/src/test/java/dev/relism/flash/HttpServerConcurrencyTest.java index ea4368e..942a1d7 100644 --- a/flash/src/test/java/dev/relism/flash/HttpServerConcurrencyTest.java +++ b/flash/src/test/java/dev/relism/flash/HttpServerConcurrencyTest.java @@ -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; diff --git a/flash/src/test/java/dev/relism/flash/HttpServerTest.java b/flash/src/test/java/dev/relism/flash/HttpServerTest.java index ff4705f..3f7ab21 100644 --- a/flash/src/test/java/dev/relism/flash/HttpServerTest.java +++ b/flash/src/test/java/dev/relism/flash/HttpServerTest.java @@ -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 --- diff --git a/flash/src/test/java/dev/relism/flash/HttpServerTimeoutTest.java b/flash/src/test/java/dev/relism/flash/HttpServerTimeoutTest.java index a354a90..471ef5a 100644 --- a/flash/src/test/java/dev/relism/flash/HttpServerTimeoutTest.java +++ b/flash/src/test/java/dev/relism/flash/HttpServerTimeoutTest.java @@ -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(); diff --git a/flash/src/test/java/dev/relism/flash/HttpServerTlsTest.java b/flash/src/test/java/dev/relism/flash/HttpServerTlsTest.java index d1c55d3..635ba36 100644 --- a/flash/src/test/java/dev/relism/flash/HttpServerTlsTest.java +++ b/flash/src/test/java/dev/relism/flash/HttpServerTlsTest.java @@ -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 observedSecure = new AtomicReference<>(); AtomicReference observedSession = new AtomicReference<>(); diff --git a/flash/src/test/java/dev/relism/flash/HttpServerWebSocketTest.java b/flash/src/test/java/dev/relism/flash/HttpServerWebSocketTest.java index d5eddd4..9d5e8d2 100644 --- a/flash/src/test/java/dev/relism/flash/HttpServerWebSocketTest.java +++ b/flash/src/test/java/dev/relism/flash/HttpServerWebSocketTest.java @@ -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 diff --git a/flash/src/test/java/dev/relism/flash/extension/FlashAppLifecycleTest.java b/flash/src/test/java/dev/relism/flash/extension/FlashAppLifecycleTest.java new file mode 100644 index 0000000..099f406 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/extension/FlashAppLifecycleTest.java @@ -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 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 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 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(); } +} diff --git a/flash/src/test/java/dev/relism/flash/extension/FlashAppWebSocketTest.java b/flash/src/test/java/dev/relism/flash/extension/FlashAppWebSocketTest.java index ffa059a..9589e9b 100644 --- a/flash/src/test/java/dev/relism/flash/extension/FlashAppWebSocketTest.java +++ b/flash/src/test/java/dev/relism/flash/extension/FlashAppWebSocketTest.java @@ -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 diff --git a/flash/src/test/java/dev/relism/flash/http2/CurlInteropTest.java b/flash/src/test/java/dev/relism/flash/http2/CurlInteropTest.java index d269a74..ece753a 100644 --- a/flash/src/test/java/dev/relism/flash/http2/CurlInteropTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/CurlInteropTest.java @@ -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(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/GrpcInteropTest.java b/flash/src/test/java/dev/relism/flash/http2/GrpcInteropTest.java index 1d06280..71692ec 100644 --- a/flash/src/test/java/dev/relism/flash/http2/GrpcInteropTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/GrpcInteropTest.java @@ -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) {} } diff --git a/flash/src/test/java/dev/relism/flash/http2/H2LoadMeasurementTest.java b/flash/src/test/java/dev/relism/flash/http2/H2LoadMeasurementTest.java index f0dee81..7b73b3e 100644 --- a/flash/src/test/java/dev/relism/flash/http2/H2LoadMeasurementTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/H2LoadMeasurementTest.java @@ -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(); diff --git a/flash/src/test/java/dev/relism/flash/http2/H2SpecComplianceTest.java b/flash/src/test/java/dev/relism/flash/http2/H2SpecComplianceTest.java index bbd29cb..40a7a9d 100644 --- a/flash/src/test/java/dev/relism/flash/http2/H2SpecComplianceTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/H2SpecComplianceTest.java @@ -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) {} } diff --git a/flash/src/test/java/dev/relism/flash/http2/H2cPriorKnowledgeTest.java b/flash/src/test/java/dev/relism/flash/http2/H2cPriorKnowledgeTest.java index fdd6186..a043eb5 100644 --- a/flash/src/test/java/dev/relism/flash/http2/H2cPriorKnowledgeTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/H2cPriorKnowledgeTest.java @@ -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(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2AbuseTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2AbuseTest.java index c44a589..1c6bf96 100644 --- a/flash/src/test/java/dev/relism/flash/http2/Http2AbuseTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/Http2AbuseTest.java @@ -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 frames) { int lastGoAwayError() { diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2ConcurrencyTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2ConcurrencyTest.java index 9793bf2..84c7096 100644 --- a/flash/src/test/java/dev/relism/flash/http2/Http2ConcurrencyTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/Http2ConcurrencyTest.java @@ -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(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2ConnectTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2ConnectTest.java index 733a9fb..0cfa99f 100644 --- a/flash/src/test/java/dev/relism/flash/http2/Http2ConnectTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/Http2ConnectTest.java @@ -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(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2ConnectionIntegrationTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2ConnectionIntegrationTest.java index febbb76..13acd2b 100644 --- a/flash/src/test/java/dev/relism/flash/http2/Http2ConnectionIntegrationTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/Http2ConnectionIntegrationTest.java @@ -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()]; diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2MisdirectedRequestTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2MisdirectedRequestTest.java index e8f04c2..0b8b9c3 100644 --- a/flash/src/test/java/dev/relism/flash/http2/Http2MisdirectedRequestTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/Http2MisdirectedRequestTest.java @@ -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(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2RegressionCorpusTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2RegressionCorpusTest.java index 9cfcce0..0445ecd 100644 --- a/flash/src/test/java/dev/relism/flash/http2/Http2RegressionCorpusTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/Http2RegressionCorpusTest.java @@ -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(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2SoakTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2SoakTest.java index cb8f827..8714f65 100644 --- a/flash/src/test/java/dev/relism/flash/http2/Http2SoakTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/Http2SoakTest.java @@ -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(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2TrailersTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2TrailersTest.java index 880c2ae..dcf3c1e 100644 --- a/flash/src/test/java/dev/relism/flash/http2/Http2TrailersTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/Http2TrailersTest.java @@ -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(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/NghttpInteropTest.java b/flash/src/test/java/dev/relism/flash/http2/NghttpInteropTest.java index 46fc5d7..0b542f2 100644 --- a/flash/src/test/java/dev/relism/flash/http2/NghttpInteropTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/NghttpInteropTest.java @@ -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(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/WebSocketOverH2Test.java b/flash/src/test/java/dev/relism/flash/http2/WebSocketOverH2Test.java index d269f51..8f1df4c 100644 --- a/flash/src/test/java/dev/relism/flash/http2/WebSocketOverH2Test.java +++ b/flash/src/test/java/dev/relism/flash/http2/WebSocketOverH2Test.java @@ -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(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/http2/WebSocketParityTest.java b/flash/src/test/java/dev/relism/flash/http2/WebSocketParityTest.java index fb120ae..c73d838 100644 --- a/flash/src/test/java/dev/relism/flash/http2/WebSocketParityTest.java +++ b/flash/src/test/java/dev/relism/flash/http2/WebSocketParityTest.java @@ -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(); - } - } } diff --git a/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java b/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java index ede5000..9871486 100644 --- a/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java +++ b/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java @@ -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); + } } diff --git a/flash/src/test/java/dev/relism/flash/transport/ServerLifecycleGracefulShutdownTest.java b/flash/src/test/java/dev/relism/flash/transport/ServerLifecycleGracefulShutdownTest.java index e148835..b114284 100644 --- a/flash/src/test/java/dev/relism/flash/transport/ServerLifecycleGracefulShutdownTest.java +++ b/flash/src/test/java/dev/relism/flash/transport/ServerLifecycleGracefulShutdownTest.java @@ -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(); diff --git a/pom.xml b/pom.xml index cdbc07d..c8bd745 100644 --- a/pom.xml +++ b/pom.xml @@ -11,6 +11,7 @@ flash + flash-testing flash-extensions @@ -36,6 +37,8 @@ 3.2.8 2.18.0 1.37 + 5.11.0 + 3.1.1 3.6.0 @@ -60,6 +63,21 @@ flash ${project.version} + + dev.relism + flash-testing + ${project.version} + + + dev.relism + flash-ext-validation + ${project.version} + + + jakarta.validation + jakarta.validation-api + ${jakarta.validation.version} + dev.relism flash-ext-jackson @@ -139,9 +157,14 @@ org.junit.jupiter junit-jupiter - 5.11.0 + ${junit.version} test + + org.junit.jupiter + junit-jupiter-api + ${junit.version} +