flash-ext-jackson and flash-ext-validation become three modules: - flash-ext-jackson-core: the Codec (one mapper, body/write/writeView), JacksonHandler, the outbound marshalling, and the constraint engine that used to be flash-ext-validation - flash-ext-jackson-json: Json, JsonExtension, JsonHandler - flash-ext-jackson-xml: Xml, XmlExtension, XmlHandler Every Jackson format is the same databind model behind a different factory, so the annotations, the constraints and the published schema are the same for all of them: only the mapper and the content type differ, and that is all a format module says. A route picks its format by the handler it extends — there is no negotiation and nothing to configure. A typed body is now always verified against its own type's jakarta constraints, whatever the format: malformed is a 400, a broken constraint is a 422, and neither reaches the handler. The validator was already allocation-free and stays so; validating is no longer something an application remembers to do. bodyFrom is gone. body has the streaming semantics, because the request's stream is reused per connection while bytes() allocates the whole body: one name, the path that does not allocate. JacksonExtension is JsonExtension, and autoJson() is auto(). The root POM now manages every module of this build, so anything composing Flash imports it once and never names a version again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
549 lines
23 KiB
Markdown
549 lines
23 KiB
Markdown
# Flash
|
||
|
||
A high-performance HTTP/1.1 and HTTP/2 server library for Java 21, built around virtual threads,
|
||
a zero-allocation FSM router, bounded protocol state, and one shared request/response API.
|
||
|
||
## Modules
|
||
|
||
| 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-core` | What every Jackson format shares: the codec, the body handler, the constraints a body is checked against |
|
||
| `flash-extensions/flash-ext-jackson-json` | JSON bodies and responses |
|
||
| `flash-extensions/flash-ext-jackson-xml` | XML bodies and responses |
|
||
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
|
||
| `flash-extensions/flash-ext-security-core` | Security: authentication chain, annotations, sessions, OpenAPI |
|
||
| `flash-extensions/flash-ext-security-oidc` | OpenID Connect: bearer tokens, code flow + PKCE |
|
||
| `flash-extensions/flash-ext-security-apikey` | API keys |
|
||
| `flash-extensions/flash-ext-security-form` | Password sign-in |
|
||
| `flash-extensions/flash-ext-security-oauth-server` | OAuth 2.1 authorization server for the application's own users and resources |
|
||
| `flash-extensions/flash-ext-security-test` | Test identities, fake OpenID Provider |
|
||
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, secured by flash-ext-security-core |
|
||
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
|
||
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
|
||
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
|
||
| `flash-extensions/flash-ext-vite` | Vite frontend: dev server in DEV, the built SPA from the jar otherwise |
|
||
| `flash-extensions/flash-ext-vite-maven-plugin` | Builds the Vite frontend into the jar during `mvn package` |
|
||
| `flash-extensions/flash-ext-scheduler` | Interval and cron background jobs on virtual threads |
|
||
| `flash-extensions/flash-ext-cache-core` | Caching contract — `Cache`, `CacheManager`, `CacheSpec` |
|
||
| `flash-extensions/flash-ext-cache-caffeine` | In-process cache backed by Caffeine |
|
||
|
||
## Requirements
|
||
|
||
- Java 21+
|
||
- Maven 3.8+
|
||
|
||
## Quick start
|
||
|
||
```java
|
||
FlashApp.create(8080)
|
||
.get("/ping", (req, res) -> "pong")
|
||
.start();
|
||
```
|
||
|
||
With full configuration:
|
||
|
||
```java
|
||
FlashApp.create(
|
||
FlashConfiguration.builder()
|
||
.port(8080)
|
||
.host("0.0.0.0")
|
||
.maxHeaderBufferSize(65536)
|
||
.build()
|
||
)
|
||
.get("/ping", (req, res) -> "pong")
|
||
.start();
|
||
```
|
||
|
||
## Route registration
|
||
|
||
### Lambda routes
|
||
|
||
```java
|
||
FlashApp app = FlashApp.create(8080);
|
||
|
||
app.get("/hello", (req, res) -> "world");
|
||
|
||
app.post("/echo", (req, res) -> {
|
||
byte[] body = req.body().bytes();
|
||
return res.status(200).body(body);
|
||
});
|
||
|
||
app.get("/users/{id}", (req, res) -> {
|
||
String id = req.param("id");
|
||
return "user:" + id;
|
||
});
|
||
```
|
||
|
||
### Class-based handlers
|
||
|
||
Extend `RequestHandler`, annotate it, then scan its package. Dependencies are cached in
|
||
`onInit()` after Flash has resolved its complete boot-time service graph:
|
||
|
||
```java
|
||
@GET("/api/users")
|
||
public class ListUsers extends RequestHandler {
|
||
private UserService users;
|
||
|
||
@Override protected void onInit() { users = require(UserService.class); }
|
||
@Override public Object handle(Request req, Response res) { return users.list(); }
|
||
}
|
||
|
||
app.scan("dev.example.api");
|
||
```
|
||
|
||
### Middleware
|
||
|
||
Apply middleware at registration. Flash composes the final chain at boot:
|
||
|
||
```java
|
||
Middleware authCheck = next -> (req, res) -> {
|
||
if (req.header("Authorization") == null)
|
||
return res.status(401).body("Unauthorized");
|
||
return next.handle(req, res);
|
||
};
|
||
|
||
app.get("/secure", (req, res) -> "secret data", authCheck);
|
||
```
|
||
|
||
Multiple middlewares are composed outermost-first (left-to-right in the call):
|
||
|
||
```java
|
||
app.get("/admin", handler, logging, auth, rateLimit);
|
||
// execution order: logging → auth → rateLimit → handler
|
||
```
|
||
|
||
### Classpath scan
|
||
|
||
Scans a package for classes that extend `RequestHandler` and carry `@Route`. Each is
|
||
instantiated via its public no-arg constructor:
|
||
|
||
```java
|
||
app.scan("dev.example.handlers");
|
||
```
|
||
|
||
### Namespace mounting
|
||
|
||
Mount a scoped sub-router under a prefix. All routes registered inside the scope get the
|
||
prefix prepended automatically. The scope inherits the parent's extension context (annotation
|
||
processors, services):
|
||
|
||
```java
|
||
app.mount("/api", scope -> {
|
||
scope.get("/health", (req, res) -> "ok"); // → GET /api/health
|
||
scope.scan("dev.example.api");
|
||
});
|
||
```
|
||
|
||
## Extensions
|
||
|
||
Extensions have one declarative `configure` method. They declare services, processors and route
|
||
callbacks; Flash resolves the complete graph, materialises routes, compiles both routers, then
|
||
opens listeners. Extension install order never makes a service “not ready”.
|
||
|
||
```java
|
||
FlashApp.create(8080)
|
||
.install(new JacksonExtension())
|
||
.install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
|
||
.install(new OidcExtension(oidcConfig))
|
||
.scan("dev.example.handlers")
|
||
.start();
|
||
```
|
||
|
||
See extension-specific READMEs for full details:
|
||
- [`flash-ext-jackson-core`](flash-extensions/flash-ext-jackson-core/README.md)
|
||
- [`flash-ext-jackson-json`](flash-extensions/flash-ext-jackson-json/README.md)
|
||
- [`flash-ext-jackson-xml`](flash-extensions/flash-ext-jackson-xml/README.md)
|
||
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
|
||
- [`flash-ext-security-core`](flash-extensions/flash-ext-security-core/docs/README.md)
|
||
- [`flash-ext-security-oidc`](flash-extensions/flash-ext-security-oidc/docs/README.md)
|
||
- [`flash-ext-security-apikey`](flash-extensions/flash-ext-security-apikey/docs/README.md)
|
||
- [`flash-ext-security-form`](flash-extensions/flash-ext-security-form/docs/README.md)
|
||
- [`flash-ext-security-test`](flash-extensions/flash-ext-security-test/docs/README.md)
|
||
- [`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-ext-scheduler`](flash-extensions/flash-ext-scheduler/docs/README.md)
|
||
- [`flash-ext-cache-caffeine`](flash-extensions/flash-ext-cache-caffeine/docs/README.md)
|
||
- [`flash-testing`](flash-testing/docs/README.md)
|
||
|
||
## Error handlers
|
||
|
||
```java
|
||
app.onNotFound((req, res) -> res.status(404).body("Not found: " + req.path()));
|
||
|
||
app.onException((ex, req, res) -> {
|
||
if (ex instanceof IllegalArgumentException)
|
||
return res.status(400).body(ex.getMessage());
|
||
return res.status(500).body("Internal error");
|
||
});
|
||
```
|
||
|
||
## FlashConfiguration
|
||
|
||
| Field | Default | Description |
|
||
|---|---|---|
|
||
| `port` | — | TCP port to bind |
|
||
| `host` | `"0.0.0.0"` | Bind address |
|
||
| `tls` | `null` | TLS for the default listener — see [TLS](#tls) |
|
||
| `listeners` | `[]` | Multiple bind targets (port + host + optional TLS) on one app — see [TLS](#tls) |
|
||
| `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) |
|
||
| `wsFrameBufferSize` | `65536` | Per-connection WebSocket read buffer (bytes) |
|
||
| `headerReadTimeoutMs` | `10000` | Once a request's first byte arrives, how long the full header block may take. Bounds slowloris-style attacks — see [`HTTP1-HARDENING.md`](flash/docs/core/HTTP1-HARDENING.md). |
|
||
| `idleKeepAliveTimeoutMs` | `60000` | How long a keep-alive connection may sit idle waiting for its next request. |
|
||
| `bodyReadTimeoutMs` | `30000` | How long reading a request body (handler or automatic drain) may take. |
|
||
| `shutdownDrainTimeoutMs` | `15000` | How long graceful shutdown waits for in-flight requests before force-closing. |
|
||
| `maxConnections` | auto (~heap/10MB) | Maximum concurrent connections across all listeners before new ones are closed immediately at accept time, before any per-connection state (TLS handshake included) is created. Auto-scales from `Runtime.maxMemory()`; set explicitly for a known deployment size, or `0` to disable. |
|
||
| `http2Enabled` | `false` | Whether TLS listeners advertise HTTP/2 through ALPN. |
|
||
| `http2CleartextEnabled` | `false` | Whether plaintext listeners accept HTTP/2 prior knowledge (h2c). Independent from TLS HTTP/2. |
|
||
| `h2HuffmanDynamicValues` | `false` | HPACK-Huffman encode runtime response values. Constants remain pre-encoded; the measured default avoids an extra encode pass. |
|
||
| `h2MaxResetStreamsPerInterval` | `200` | Rapid Reset budget per rolling interval. |
|
||
| `h2MaxStreamsCreatedPerInterval` | `400` | New-stream budget per rolling interval. |
|
||
| `h2AbuseRateIntervalMs` | `10000` | Rolling interval for the two operator-tunable rate limits above. |
|
||
| `h2MaxStreamsPerConnection` | `100000` | Total stream budget; `0` disables it. |
|
||
| `h2MaxBytesPerConnection` | `0` | Optional total wire-byte budget; `0` disables it. |
|
||
| `h2MaxConnectionLifetimeMs` | `0` | Optional connection lifetime; `0` disables it. |
|
||
| `h2StreamIdleTimeoutMs` | `60000` | Inactive open-stream deadline. |
|
||
| `sendDate` | `true` | Add an RFC 9110 `Date` field to responses; disable when an upstream proxy supplies it. |
|
||
|
||
## Protocols
|
||
|
||
Routes, middleware, `Request`, `Response`, bodies, trailers, streaming and WebSockets use the same
|
||
API on HTTP/1.1 and HTTP/2. Protocol selection happens once per connection:
|
||
|
||
- On TLS listeners, enable `http2Enabled`; Flash advertises `h2` and `http/1.1` through ALPN and
|
||
uses the protocol selected by the client. Existing HTTP/1.1 clients continue to work.
|
||
- On plaintext listeners, enable `http2CleartextEnabled` to accept the HTTP/2 prior-knowledge
|
||
preface on the same port as HTTP/1.1. Clients that do not send that exact preface are parsed as
|
||
HTTP/1.1.
|
||
- With both switches left at their default `false`, Flash behaves as an HTTP/1.1 server.
|
||
|
||
After enabling the appropriate switch, application routes need no protocol-specific code. TLS
|
||
still requires the normal certificate configuration shown below.
|
||
|
||
Flash deliberately does not implement HTTP/2 server push, RFC 7540 dependency-tree priority
|
||
scheduling, or the obsolete HTTP/1.1 `Upgrade: h2c` transition. Server push has no application API,
|
||
RFC 9113 deprecated the old priority scheme, and cleartext HTTP/2 uses prior knowledge instead.
|
||
See the [HTTP/2 compliance record](flash/docs/http2/COMPLIANCE.md) for exact coverage.
|
||
|
||
## WebSockets over HTTP/2
|
||
|
||
The same `ws(path, handler)` route serves WebSockets over HTTP/1.1 and HTTP/2. When HTTP/2 is
|
||
enabled, Flash advertises RFC 8441 extended CONNECT support and carries WebSocket frames inside
|
||
flow-controlled DATA frames. No alternate handler, route, or session API is required:
|
||
|
||
```java
|
||
app.ws("/live", handler);
|
||
```
|
||
|
||
HTTP/1.1 clients use the ordinary `101 Switching Protocols` upgrade. HTTP/2 clients use an
|
||
extended CONNECT and receive status `200`; Flash applies the same RFC 6455 framing, masking,
|
||
fragmentation, close, and callback behavior on both transports. Client support for negotiating
|
||
WebSockets over HTTP/2 varies, so clients without RFC 8441 support continue to use HTTP/1.1.
|
||
|
||
## TLS
|
||
|
||
HTTPS and WSS are a transport-layer concern only. Once a listener is bound, the accepted socket
|
||
is plain or TLS; the selected HTTP connection implementation then performs either the HTTP/1.1
|
||
upgrade or the HTTP/2 extended CONNECT. WSS does not require a separate route or handler API.
|
||
|
||
### Quick start
|
||
|
||
```java
|
||
FlashApp.create(FlashConfiguration.builder()
|
||
.port(443)
|
||
.tls(TlsConfig.keystore(Path.of("cert.p12"), "changeit"))
|
||
.build())
|
||
.get("/ping", (req, res) -> "pong") // HTTPS
|
||
.ws("/live", handler) // WSS, same route API
|
||
.start();
|
||
```
|
||
|
||
### Multiple listeners
|
||
|
||
One app can bind any number of ports, each independently plain or TLS:
|
||
|
||
```java
|
||
FlashApp.create(FlashConfiguration.builder()
|
||
.listener(new FlashConfiguration.Listener(80)) // plain
|
||
.listener(new FlashConfiguration.Listener(443, TlsConfig.keystore(cert, pass))) // TLS
|
||
.build());
|
||
```
|
||
|
||
A non-empty `listeners` list takes precedence over the top-level `port`/`host`/`tls` fields.
|
||
Each listener gets its own accept threads; the router, WS router, and virtual-thread executor
|
||
are shared by all of them — one app, N ports.
|
||
|
||
### `TlsConfig`
|
||
|
||
| Factory | Use |
|
||
|---|---|
|
||
| `TlsConfig.keystore(Path, String)` | Builds the `SSLContext` from a PKCS12/JKS keystore (type guessed from the extension). Pins `TLSv1.2`/`TLSv1.3` as enabled protocols; cipher suites are left at the JDK's own curated default. |
|
||
| `TlsConfig.ofContext(SSLContext)` | Escape hatch — the given `SSLContext` is used exactly as built. Flash never calls `setSSLParameters` on this path beyond what you explicitly request via `clientAuth`/`applicationProtocols`, so anything else you configured (custom `KeyManager`, ALPN, cipher suites) is authoritative. |
|
||
|
||
Chainable on either factory:
|
||
|
||
```java
|
||
TlsConfig.keystore(cert, pass)
|
||
.clientAuth(ClientAuth.REQUIRE) // mTLS: NONE (default) | OPTIONAL | REQUIRE
|
||
.applicationProtocols("acme-tls/1", "http/1.1") // ALPN, in preference order
|
||
```
|
||
|
||
**SNI** falls out of `keystore()` for free: a keystore holding more than one certificate entry
|
||
is matched against the requested hostname by each certificate's SAN (falling back to CN) — no
|
||
per-hostname config. The first entry in the keystore is the default when SNI is absent or
|
||
matches nothing (same convention as nginx/HAProxy's `default_server`).
|
||
|
||
**ALPN and custom certificate selection** (e.g. TLS-ALPN-01 / RFC 8737 for on-demand ACME
|
||
issuance): ALPN is resolved while consuming `ClientHello`/producing `ServerHello`, which always
|
||
precedes `Certificate` production. A custom `X509ExtendedKeyManager` passed via `ofContext`
|
||
can therefore read `engine.getHandshakeApplicationProtocol()` (or
|
||
`((SSLSocket) socket).getHandshakeApplicationProtocol()`) inside
|
||
`chooseEngineServerAlias`/`chooseServerAlias` — the negotiated protocol is already resolved by
|
||
then, so the certificate decision can key off it.
|
||
|
||
**mTLS with a private CA**: `clientAuth(...)` only requests/requires a client certificate;
|
||
`keystore()` deliberately doesn't expose a way to configure which CAs are trusted for that
|
||
certificate (it uses the JDK default trust store). For a private CA, build the `SSLContext`
|
||
yourself with a `TrustManagerFactory` and use `ofContext(...)`.
|
||
|
||
### Reading TLS info from a request
|
||
|
||
```java
|
||
app.get("/whoami", (req, res) -> {
|
||
if (!req.isSecure()) return "plain";
|
||
SSLSession session = req.sslSession(); // null iff !isSecure()
|
||
X509Certificate peer = (X509Certificate) session.getPeerCertificates()[0]; // mTLS only
|
||
return session.getCipherSuite() + " / " + session.getProtocol();
|
||
});
|
||
```
|
||
|
||
`Request.isSecure()` / `Request.sslSession()` cost nothing extra per request: the `SSLSocket`
|
||
reference is threaded through once per connection (same mechanism as `remoteAddress()`), and
|
||
`sslSession()` only calls `SSLSocket#getSession()` — a cached-field read once the handshake
|
||
that got the request this far has already completed, never a forced handshake.
|
||
|
||
`WebSocketSession` mirrors this exactly (`isSecure()`, `sslSession()`) by delegating to the
|
||
upgrading `Request` — no separate TLS state is tracked for WS.
|
||
|
||
## Object lifetime
|
||
|
||
`Request` and `Response` are **pooled per connection**, not allocated per request: one instance is
|
||
created per connection and repositioned (`reset()`) over each new request/response in turn — the
|
||
same idiom Java NIO buffers use, applied to the whole request/response model
|
||
(`flash/docs/core/MESSAGE-MODEL.md` has the full design record). This is what makes a warm h1
|
||
request/response cycle 0 B/op.
|
||
|
||
**Do not retain a `Request` or `Response` past the handler that received it.** A reference kept in
|
||
a field, a captured closure, a `CompletableFuture` continuation, or a background thread and read
|
||
*after* the handler returns will observe whatever the *next* request on that connection
|
||
repositioned the same instance to — not the request you thought you had:
|
||
|
||
```java
|
||
// WRONG — captures `req`, reads it after the handler has returned
|
||
app.get("/slow", (req, res) -> {
|
||
CompletableFuture.runAsync(() -> log(req.header("X-Trace-Id"))); // may log the NEXT request's header
|
||
return "ok";
|
||
});
|
||
```
|
||
|
||
Copy out whatever you need before returning or handing work off asynchronously — every accessor
|
||
that returns a `String` (`header`, `param`, `query`, `path`, …) gives you an independent heap copy
|
||
that's safe to keep as long as you like:
|
||
|
||
```java
|
||
app.get("/slow", (req, res) -> {
|
||
String traceId = req.header("X-Trace-Id"); // copy now, safe to retain
|
||
CompletableFuture.runAsync(() -> log(traceId));
|
||
return "ok";
|
||
});
|
||
```
|
||
|
||
Run with `-Dflash.env=dev` and a use-after-return access throws `IllegalStateException` immediately
|
||
at the offending call site instead of silently reading the wrong request's data — turn this on in
|
||
tests and local development. It's a no-op in production beyond a single `boolean` field read.
|
||
|
||
`req.body()`/`RequestBody` follows the same rule — materialise (`.bytes()`) or fully consume
|
||
(`.stream()`) it inside the handler; don't stash the `RequestBody` itself for later.
|
||
|
||
### Reusable response headers
|
||
|
||
Use `PreEncodedHeader` for a constant header sent by many responses. It stores the name and value
|
||
once and remains valid on both HTTP versions:
|
||
|
||
```java
|
||
private static final PreEncodedHeader NO_STORE =
|
||
new PreEncodedHeader("cache-control", "no-store");
|
||
|
||
app.get("/health", (req, res) -> res.header(NO_STORE).body("ok"));
|
||
```
|
||
|
||
`Response.header(byte[])` accepts a complete CRLF-terminated HTTP/1 field line and is therefore
|
||
HTTP/1-only; HPACK needs the name and value as separate fields. Prefer `PreEncodedHeader` for shared
|
||
application and middleware code.
|
||
|
||
### Trailers and push streaming
|
||
|
||
Request trailers become available after the body reaches EOF:
|
||
|
||
```java
|
||
byte[] payload = req.body().bytes();
|
||
String status = req.trailers().first("grpc-status");
|
||
```
|
||
|
||
For a producer-driven response, `Response.streaming` provides a blocking `ResponseStream`. Its
|
||
bounded buffer and HTTP/2 flow-control windows apply backpressure directly to the producer's
|
||
virtual thread:
|
||
|
||
```java
|
||
return res.streaming(stream -> {
|
||
try {
|
||
stream.write(payload, 0, payload.length);
|
||
stream.trailer("result", "complete");
|
||
} catch (IOException failure) {
|
||
throw new UncheckedIOException(failure);
|
||
}
|
||
});
|
||
```
|
||
|
||
The API renders as chunked data and trailers on HTTP/1.1, and DATA plus trailing HEADERS on
|
||
HTTP/2. Flash core supplies these transport primitives; a higher-level gRPC codec belongs in a
|
||
future `flash-ext-grpc` extension.
|
||
|
||
## Testing
|
||
|
||
`flash-testing` boots a real app on an OS-assigned port for the duration of a test, and hands you
|
||
a client pointed at it. Add it with test scope:
|
||
|
||
```xml
|
||
<dependency>
|
||
<groupId>dev.relism</groupId>
|
||
<artifactId>flash-testing</artifactId>
|
||
<version>${flash.version}</version>
|
||
<scope>test</scope>
|
||
</dependency>
|
||
```
|
||
|
||
```java
|
||
class UserRoutesTest {
|
||
|
||
@RegisterExtension
|
||
static FlashTest app = FlashTest.of(new BlogApp())
|
||
.mock(UserService.class, new InMemoryUserService());
|
||
|
||
@Test
|
||
void listsUsers() {
|
||
app.get("/api/users")
|
||
.expectStatus(200)
|
||
.expectHeader("content-type", "application/json")
|
||
.expectBodyContains("alice");
|
||
}
|
||
}
|
||
```
|
||
|
||
`FlashTest.of` takes a `FlashApplication` — your app's routes, extensions and services expressed
|
||
independently of which port they run on:
|
||
|
||
```java
|
||
public final class BlogApp implements FlashApplication {
|
||
@Override public void configure(FlashApp app) {
|
||
app.install(new JacksonExtension());
|
||
app.mount("/api", scope -> scope.scan("dev.blog.api"));
|
||
}
|
||
}
|
||
|
||
FlashApp.create(8080).apply(new BlogApp()).startAndBlock(); // production
|
||
```
|
||
|
||
It is a functional interface, so a lambda works too:
|
||
`FlashTest.of(app -> app.get("/ping", (req, res) -> "pong"))`.
|
||
|
||
### Requests
|
||
|
||
The HTTP verb sends the request; `expect*` assertions chain and report the real response body on
|
||
failure. `get` and `delete` skip the builder when there is nothing to add.
|
||
|
||
```java
|
||
app.get("/api/users").expectStatus(200);
|
||
|
||
app.request()
|
||
.header("Authorization", "Bearer " + token)
|
||
.json("{\"name\":\"bob\"}")
|
||
.post("/api/users")
|
||
.expectStatus(201);
|
||
|
||
try (FlashWebSocket socket = app.ws("/live")) {
|
||
socket.sendText("hello");
|
||
assertEquals("echo:hello", socket.awaitText(Duration.ofSeconds(2)));
|
||
}
|
||
```
|
||
|
||
### Replacing services
|
||
|
||
`mock` installs replacements after everything your app and its extensions declare, so a fake always
|
||
wins. Any object will do — `flash-testing` depends on no mocking library, so a hand-written fake and
|
||
a Mockito mock are equally welcome.
|
||
|
||
### More than one server
|
||
|
||
`FlashTest` is an ordinary object in a field, so a test class can hold as many as it needs and wire
|
||
one from another in plain Java. Startup is lazy — reading `baseUri()` boots that server on the spot
|
||
— so declaration order does the wiring:
|
||
|
||
```java
|
||
@RegisterExtension static FlashTest auth = FlashTest.of(new FakeOidcApp());
|
||
@RegisterExtension static FlashTest api = FlashTest.of(new BlogApp(auth.baseUri()));
|
||
```
|
||
|
||
### Scope
|
||
|
||
A `static` field boots once for the test class; a non-static field boots a fresh app for every test.
|
||
That is stock JUnit field semantics — the isolation switch is the keyword, not an option.
|
||
|
||
### Configuration
|
||
|
||
Full reference: [`flash-testing/docs`](flash-testing/docs/README.md), including the
|
||
[limits](flash-testing/docs/limits.md) the harness deliberately does not cross.
|
||
|
||
`profile` customises the `FlashConfiguration` — timeouts, HTTP/2 switches, buffer sizes. Host, port
|
||
and the shutdown drain window are stamped afterwards, so a profile cannot break the harness;
|
||
`listener(...)` and `tls(...)` are rejected because the harness owns the loopback listener it gives
|
||
you a client for.
|
||
|
||
```java
|
||
FlashTest.of(new BlogApp()).profile(cfg -> cfg.http2CleartextEnabled(true));
|
||
```
|
||
|
||
## Architecture
|
||
|
||
```
|
||
TransportFactory.create() # binds every listener, wires the connection runner
|
||
→ AcceptLoop # one per listener × accept thread; hands sockets off
|
||
→ ConnectionRunner.accept() # per-connection setup: TLS handshake, protocol negotiation
|
||
→ ProtocolNegotiator # ALPN / h2c-preface — decides the protocol once
|
||
├─ Http1Connection.run() # request parser, router, handler, h1 response writer
|
||
└─ Http2Connection.run() # frame demux, HPACK, stream dispatch, flow control
|
||
→ RequestHandler.handle() # the same protocol-neutral request/response API
|
||
```
|
||
|
||
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`, owned by `TransportFactory`). Java 21 required.
|
||
- **Zero-allocation router** — `FastPathRouterImpl` uses `fpr-core`, a byte-level FSM that matches on `METHOD + path` bytes with no per-request allocation.
|
||
- **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection.
|
||
- **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported.
|
||
- **TLS is transport-only** — see [TLS](#tls). Listeners bind either a plain `ServerSocket` or an `SSLServerSocket`; nothing downstream of `accept()` branches on which.
|
||
- **`ConnectionProtocol` seam** — HTTP/1.1 and HTTP/2 are peers behind this interface, selected once per connection by `ProtocolNegotiator`; routing and application models are shared.
|
||
|
||
## Build & test
|
||
|
||
```bash
|
||
# Build all modules (skip tests)
|
||
mvn clean package -DskipTests
|
||
|
||
# Run all tests
|
||
mvn test
|
||
|
||
# Run a single test class
|
||
mvn test -pl flash -Dtest=RequestParserTest
|
||
```
|