Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
b1287ee
docs: BodyReader and RequestHandler design
thced May 13, 2026
5648af8
docs: Switch BodyReader/Writer to single TypeMapper interface
thced May 13, 2026
1c36d75
docs: Drop deprecated JsonMapper adapter from migration plan
thced May 13, 2026
27a4b40
docs: Add optional Gson fallback for application/json mapper
thced May 13, 2026
e3cee8f
docs: Add JSR-310 write adapters to default Gson mapper
thced May 13, 2026
cddb354
docs: Implementation plan for TypeMapper and RequestHandler
thced May 13, 2026
ad51067
docs: Fix test method names to camelCase in plan
thced May 13, 2026
2a6cd23
refactor: Extract form-body schema coercion from FormUrlEncodedParser
thced May 13, 2026
f51a85b
feat: Add TypeMapper interface
thced May 13, 2026
cfbb556
feat: Built-in FormTypeMapper and TextTypeMapper
thced May 13, 2026
e01ef10
build: Make Gson an optional compile dependency
thced May 13, 2026
1f0de23
feat: GsonJsonMapper with integer-preserving and JSR-310 adapters
thced May 13, 2026
86149d2
refactor: Use top-level Function import in GsonJsonMapper
thced May 13, 2026
8e8a70b
feat!: Replace JsonMapper with bodyMapper(mediaType, TypeMapper) + Gs…
thced May 13, 2026
ff7e633
refactor: Use top-level Locale import in OpenApiServer
thced May 13, 2026
03a8ba7
refactor: Move static Request accessors to internal LegacyRequestAccess
thced May 13, 2026
c263cda
refactor: Drop @Deprecated from transitional LegacyRequestAccess
thced May 13, 2026
33ad29f
feat: Add Request, ResponseBuilder, RequestHandler types
thced May 13, 2026
fd23f8a
fix: Use responseLength=-1 for empty bytes() responses
thced May 13, 2026
b9c2915
feat!: Switch handlers to RequestHandler receiving Request
thced May 13, 2026
59c542d
refactor: Use HttpURLConnection.HTTP_OK in GetDataHandler
thced May 13, 2026
e4c7516
refactor: Remove LegacyRequestAccess and RequestContext
thced May 13, 2026
bac0a68
docs: Update README for TypeMapper and RequestHandler
thced May 13, 2026
6766e30
refactor: Use static import for assertThatThrownBy in FormUrlEncodedP…
thced May 13, 2026
11936f5
fix: Close HttpExchange after sending one-shot or streaming responses
thced May 14, 2026
f124828
fix: Enforce one-terminal-per-Request across respond() calls
thced May 14, 2026
ad6ec3f
refactor: Address SonarQube findings on TypeMapper/RequestHandler change
thced May 14, 2026
b6af2a0
feat: Expose query params on Request (queryParams, queryParam, rawQuery)
thced May 14, 2026
f1e6ed1
docs: Document path/query/header accessors in README example
thced May 14, 2026
48f7f0d
feat: Add ResponseDecorator and RequestInterceptor for cross-cutting …
thced May 14, 2026
0761986
docs: Document combining interceptors and decorators in README
thced May 14, 2026
2e02e53
feat!: Handlers return Response value object; remove ResponseBuilder
thced May 14, 2026
c4a9009
docs: Expand Prerequisites to cover non-JSON specs and TypeMapper cho…
thced May 14, 2026
3b8f989
feat: Add Spec.fromPath(Path) with JSON+YAML auto-detect
thced May 14, 2026
98a78d3
refactor: Replace inline FQN type references with proper imports
thced May 14, 2026
42ac911
docs: Add end-to-end example using YAML spec, Gson default, one inter…
thced May 14, 2026
a17aef7
feat: Add Request.pathParam(name) convenience accessor
thced May 14, 2026
10a05eb
feat: Add Response.accepted() / accepted(body) factories
thced May 14, 2026
9f18236
feat: Add Response.created/notFound/notImplemented factories
thced May 14, 2026
d02cf2d
refactor: Address Sonar findings (status constants, renderer complexity)
thced May 14, 2026
6a1874d
feat: Add JacksonJsonTypeMapper adapter for ObjectMapper-backed JSON …
thced May 14, 2026
d7c3694
feat: Add TypedTypeMapper + Request.asPojo(Class) for direct POJO des…
thced May 14, 2026
1813bc7
feat: GsonJsonMapper implements TypedTypeMapper and round-trips JSR-310
thced May 14, 2026
8464ff2
feat: Add Builder.jsonMapper(TypeMapper) shortcut
thced May 14, 2026
337471c
feat!: Request.queryParam/header return Optional<String>, treat blank…
thced May 14, 2026
8961a41
refactor!: Rename Builder.addHandler to extraRoute
thced May 14, 2026
0840082
feat!: Drop Response.created(body, location) overload
thced May 14, 2026
791203c
refactor!: Decouple Request from HttpExchange to enable swappable tra…
thced May 18, 2026
bb9bdad
refactor: Address Sonar findings on transport-neutral Request
thced May 18, 2026
db24306
feat: Add Request.contentType() convenience accessor
thced May 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 64 additions & 55 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,45 +26,27 @@ It is designed to be simple to use while providing the essential features needed

### Basic Usage
1. Create an OpenAPI specification file named `openapi.json` in your project resources.
2. Define your HTTP handlers by implementing the `HttpHandler` interface:
2. Define your handlers using the `RequestHandler` functional interface:
``` java
public class GetDataHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
try (exchange) {
byte[] bytes = """
{
"id": "some-id"
}""".getBytes();

var responseHeaders = exchange.getResponseHeaders();
responseHeaders.add("content-type", "application/json");

exchange.sendResponseHeaders(HTTP_OK, bytes.length);

try (var os = exchange.getResponseBody()) {
os.write(bytes);
}
}
}
}
// Inline lambda — returns JSON using the built-in Gson mapper.
RequestHandler getDataHandler = req ->
req.respond(200).json(Map.of("id", "some-id"));

public class PostDataHandler implements HttpHandler {
// Class form — reads raw bytes or the pre-parsed body object.
public class PostDataHandler implements RequestHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
try (exchange) {
// Access the raw request body bytes.
byte[] body = Request.bytes(exchange);
// Or get the already-parsed object (Map or List) produced by your JsonMapper.
Object parsed = Request.parsed(exchange);

exchange.sendResponseHeaders(HTTP_OK, -1);
}
public void handle(Request request) throws IOException {
// Access the raw request body bytes.
byte[] body = request.bytes();
// Or get the already-parsed object (Map / List) produced by the registered TypeMapper.
Object parsed = request.parsed();

request.respond(200).json(parsed);
}
}
```

3. Initialize the server (using Gson in this example):
3. Initialize the server:
``` java
public class YourServerLauncher {
public static void main(String[] args) throws Exception {
Expand All @@ -75,17 +57,13 @@ public class YourServerLauncher {
Map<String, Object> raw = (Map<String, Object>) gson.fromJson(text, Map.class);
Spec spec = Spec.from(raw);

// Body parser. Returns a Map for objects, List for arrays.
JsonMapper mapper = body -> gson.fromJson(new String(body), Object.class);

// Handlers by operationId.
Map<String, HttpHandler> handlers = new HashMap<>();
handlers.put("get-data", new GetDataHandler());
Map<String, RequestHandler> handlers = new HashMap<>();
handlers.put("get-data", getDataHandler);
handlers.put("post-data", new PostDataHandler());

var server = OpenApiServer.builder()
.spec(spec)
.jsonMapper(mapper)
.handlers(handlers)
.exceptionHandler(Handlers.defaultExceptionHandler())
.build();
Expand All @@ -100,13 +78,51 @@ Map<String, Object> raw = new Yaml().load(Files.newInputStream(Path.of("openapi.
```
The rest is identical.

### JSON mapping

The library ships an internal `GsonJsonMapper` that is auto-registered for `application/json` when Gson is on the classpath and no user-supplied JSON mapper has been registered. It:

- Returns JSON integers as `Long` and fractional numbers as `Double`.
- Writes JSR-310 types (`Instant`, `OffsetDateTime`, `ZonedDateTime`, `LocalDateTime`, `LocalDate`, `LocalTime`) as ISO-8601 strings.

For non-ISO date formats, custom naming strategies, or other custom serialization, register your own `TypeMapper`:

``` java
var server = OpenApiServer.builder()
.spec(spec)
.bodyMapper("application/json", new MyCustomJsonMapper())
.handlers(handlers)
.build();
```

If Gson is not on the classpath and no `application/json` mapper is registered, `build()` throws `IllegalStateException`.

### Body parsers and response writers

`TypeMapper` is the per-media-type read/write contract:

``` java
public interface TypeMapper {
Object readFrom(byte[] body, String contentTypeHeader);
byte[] writeTo(Object value);
}
```

Register a custom mapper for any media type via `Builder.bodyMapper(mediaType, mapper)`. Built-in defaults:

- `application/x-www-form-urlencoded` — read-only. Produces `Map<String, Object>`. A single value is a `String`; repeated keys produce a `List`.
- `text/plain` — read and write. Produces a decoded `String`; writes via `String.getBytes()`.
- `application/json` — auto-registered when Gson is on the classpath (see above).

User-supplied mappers take precedence over built-in defaults, so you can override any of the above.

### Request body content types

The server reads `requestBody.content` from the spec and selects a parser by the request's media type (the bare `type/subtype` from `Content-Type`, e.g. `application/json`; lookup is case-insensitive):
The server reads `requestBody.content` from the spec and selects a mapper by the request's media type (the bare `type/subtype` from `Content-Type`, e.g. `application/json`; lookup is case-insensitive):

| Content type | Parser | Coercion |
| ------------------------------------- | ---------------------------------------------------------------------------- | -------- |
| `application/json` | Caller-supplied `JsonMapper` | No — strict against the schema |
| `application/json` | `GsonJsonMapper` (auto) or caller-supplied `TypeMapper` | No — strict against the schema |
| `application/x-www-form-urlencoded` | Built-in. `Map<String, Object>`. A single value is a `String`; repeated keys produce a `List`. After coercion the element type tracks the schema (e.g. an `integer` array yields `List<Long>`). | Yes — field values coerced to the property schema type (integer / number / boolean / array of those) |
| `text/plain` | Built-in. Decoded `String` | No — schema should be `type: string` |

Expand Down Expand Up @@ -159,7 +175,6 @@ to OpenAPI parameter / body validation.
``` java
var server = OpenApiServer.builder()
.spec(spec)
.jsonMapper(mapper)
.handlers(handlers)
.addHandler("/alive", Handlers.aliveHandler())
.addHandler("/schemas/v1/openapi.yaml",
Expand Down Expand Up @@ -189,7 +204,6 @@ try-with-resources) via the builder:
```java
try (var server = OpenApiServer.builder()
.spec(spec)
.jsonMapper(mapper)
.handlers(handlers)
.shutdownTimeoutSeconds(5) // close() drains up to 5s; default is 0
.build()) {
Expand All @@ -202,20 +216,15 @@ try (var server = OpenApiServer.builder()

## Features
- OpenAPI specification support
- Automatic request body parsing for JSON arrays and objects
- Custom HTTP handler support
- Built on Java's native `HttpServer` with Thread-Per-Request behaviour using Virtual Threads.
- Custom integration for JSON serialization/deserialization
- Automatic request body parsing and response writing per media type via `TypeMapper`
- `RequestHandler` functional interface — a single `handle(Request)` method replaces raw `HttpExchange` manipulation
- Fluent `ResponseBuilder` via `request.respond(status)` with terminals: `empty()`, `bytes()`, `text()`, `json()`, `body()`, `stream()`
- Built-in `GsonJsonMapper` auto-registered when Gson is on the classpath (no explicit wiring needed)
- Built on Java's native `HttpServer` with Thread-Per-Request behaviour using Virtual Threads


## Handler Registration
Handlers are registered using string keys that correspond to your OpenAPI operation IDs.


## JSON Mapping
The library uses a flexible JSON mapping system that automatically detects and parses (using a mapper of choice):
- JSON arrays (`[...]`)
- JSON objects (`{...}`)
Handlers are registered in a `Map<String, RequestHandler>` keyed by OpenAPI `operationId`.

## Local development

Expand All @@ -235,7 +244,7 @@ A few things to know:

- **Single-process model.** No horizontal scaling primitives are bundled; run multiple instances behind a load balancer for production scale.
- **JDK HttpServer is the throughput ceiling.** It's documented as a low-throughput / dev-test server. If you need to go materially above the rates above, deploy the same filter/validator/router stack on Jetty, Helidon Níma, or Netty — the spec and validation code is server-agnostic.
- **Per-request state uses `ScopedValue`** (Java 25, JEP 506), not `HttpExchange.setAttribute`. This matters if a handler offloads work to an executor that's not a `StructuredTaskScope`-managed child thread: the `ScopedValue` is not visible there, so the handler must capture the values it needs (e.g. `byte[] body = Request.bytes();`) before submitting.
- **`HttpExchange.sendResponseHeaders(rCode, length)` gotcha.** When a handler has no response body, pass `-1` (`Content-Length: 0`, no body); passing `0` produces a chunked response with zero chunks, which is technically non-conformant.
- **Per-request state uses `ScopedValue`** (Java 25, JEP 506). This matters if a handler offloads work to an executor that's not a `StructuredTaskScope`-managed child thread: the `ScopedValue` is not visible there, so the handler must capture the values it needs (e.g. `byte[] body = request.bytes();`) before submitting.
- **Empty responses must use `request.respond(status).empty()`**, which sends `responseLength = -1` (`Content-Length: 0`, no body). Passing `0` produces a chunked response with zero chunks, which is technically non-conformant.

## Known limitations or missing features
Loading
Loading