Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
4aada4a
docs: Add CLAUDE.md with build commands and architecture overview
thced May 8, 2026
e2b57b3
docs: Add refactor design for OpenAPI 3.1 readiness
thced May 8, 2026
52a884d
docs: Add implementation plan for OpenAPI refactor
thced May 8, 2026
2e9fcc0
docs(plan): Use explicit imports throughout, no FQDNs in code bodies
thced May 8, 2026
6f9d443
build: Bump Java to 25
thced May 8, 2026
8071d52
feat(schema): Add TypeName enum
thced May 8, 2026
40d6b28
feat(schema): Add Schema sealed interface, BooleanSchema, AdditionalP…
thced May 8, 2026
2588171
feat(schema): Add primitive Schema records
thced May 8, 2026
2f0bdea
feat(schema): Add ObjectSchema and ArraySchema records
thced May 8, 2026
917594f
feat(schema): Scaffold combinator records (oneOf/anyOf/allOf/not/cons…
thced May 8, 2026
6dd90d9
feat(schema): SchemaParser handles primitives, refs, nullable forms
thced May 8, 2026
a7cbfbf
feat(schema): SchemaParser handles objects (with additionalProperties…
thced May 8, 2026
103cc03
feat(schema): SchemaParser handles combinators, const, top-level enum
thced May 8, 2026
5ea5a69
feat(spec): Add HttpMethod enum
thced May 8, 2026
55794da
feat(spec): Add PathTemplate value object with regex extraction
thced May 8, 2026
f615adf
feat(spec): Add Parameter, RequestBody, MediaType, Response, Server, …
thced May 8, 2026
2491210
feat(spec): Add Operation record
thced May 8, 2026
630697b
feat(spec): Add Spec.from(Map) walker for the full document
thced May 8, 2026
456196b
test: Replace fiscal API copy in openapi.yaml with equivalent of open…
thced May 8, 2026
fd6d8e8
feat(validate): Add ValidationError, ValidationException, Validator i…
thced May 8, 2026
2ff1fba
feat(validate): DefaultValidator skeleton with dispatch + boolean/nul…
thced May 8, 2026
4c3f158
feat(validate): String/integer/number validation with full 3.1 numeri…
thced May 8, 2026
0afd3a7
feat(validate): Object validation with required/properties/additional…
thced May 8, 2026
3afdf7e
feat(validate): Array validation with items/minItems/maxItems/uniqueI…
thced May 8, 2026
1539616
feat(internal): Router with exact and templated indexes plus allowedM…
thced May 8, 2026
31753a6
feat(http): Add NotFoundException and MethodNotAllowedException
thced May 8, 2026
6a0786d
feat(http): Add Request static accessors for exchange attributes
thced May 8, 2026
10a6d08
feat(http): JsonMapper SAM in public package (no generic)
thced May 8, 2026
926c1a5
feat(http): RFC 7807 problem+json renderer + default handler covers n…
thced May 8, 2026
c3cee1b
style: Add curly braces to brace-less control-flow one-liners
thced May 8, 2026
97c339d
refactor(internal): Extract ProblemDetailRenderer helpers and constants
thced May 8, 2026
a6ed432
feat(internal): ExceptionFilter delegates to consumer ExceptionHandler
thced May 8, 2026
5b89fbe
feat(internal): RequestPreparationFilter combines body capture, routi…
thced May 8, 2026
0ca140d
feat(internal): DispatchHandler dispatches to registered HttpHandler …
thced May 8, 2026
c7ea34a
refactor(http): Rewrite OpenApiServer against new Spec/Validator/Rout…
thced May 8, 2026
a3988e9
refactor(test): Migrate test launcher, handlers, and integration test…
thced May 8, 2026
0719216
docs: Update README for Java 25 and post-refactor public API
thced May 8, 2026
02cde6d
docs(claude): Refresh architecture section for refactor
thced May 8, 2026
6c108e4
refactor: Delete legacy openapi.* packages and old filter/wrapper cla…
thced May 8, 2026
499d6d6
test: Annotate JSON body literals with language=JSON
thced May 8, 2026
123c301
fix(validate): Use BigDecimal for multipleOf to avoid float equality
thced May 8, 2026
51604e4
refactor: Address SonarQube code smells across new packages
thced May 8, 2026
7df51b7
refactor: Drop residual unused vars and SuppressWarnings
thced May 8, 2026
c444389
refactor(validate): Extract FORMAT_KEYWORD, use record patterns, comm…
thced May 8, 2026
f7a8f11
test: Tidy k6 script and fix ParamHandler empty-body sentinel
thced May 8, 2026
f01387c
docs(readme): Document performance envelope and HttpServer caveats
thced May 8, 2026
44ab0d4
fix(http): Use ScopedValue for per-request state, not HttpExchange at…
thced May 8, 2026
bb93184
chore: Stop tracking .claude/settings.local.json (per-developer file)
thced May 8, 2026
9f1845d
refactor(internal): Override equals/hashCode/toString on RequestContext
thced May 8, 2026
0d47046
refactor(internal): Use record pattern in RequestContext.equals
thced May 8, 2026
bbb3c07
test(internal): Cover RequestContext equals/hashCode/toString
thced May 8, 2026
69c176c
docs: Capture JFR-based performance findings for follow-up PR
thced May 8, 2026
c8ddaba
test: Address SonarQube findings on RequestTest and RequestContextTest
thced May 8, 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,6 @@ build/

### Mac OS ###
.DS_Store

### Claude Code per-developer settings ###
.claude/settings.local.json
2 changes: 1 addition & 1 deletion .java-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
21
25
49 changes: 49 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project

A lightweight Java 25 library that wraps the JDK's built-in `com.sun.net.httpserver.HttpServer` and exposes endpoints declared in an OpenAPI 3.1.x specification. Consumers register `HttpHandler` instances by OpenAPI `operationId`. The library is published as a JAR; the example launcher under `src/test/java/.../start/ServerLauncher.java` is for local development only.

Java 25 is required (see `.java-version`). The server uses thread-per-request with virtual threads.

## Common commands

- Build: `mvn package`
- Unit tests (Surefire): `mvn test`
- Integration tests (Failsafe, `*IT.java`): `mvn verify`
- Single test class: `mvn test -Dtest=OpenApiServerTest`
- Single test method: `mvn test -Dtest=OpenApiServerTest#methodName`
- Coverage report: produced at `target/site/jacoco/` after `mvn verify`
- POM is sort-checked by `sortpom-maven-plugin` during `validate`; fix with `mvn sortpom:sort`
- Pre-commit hooks (Google Java formatter, commitlint, editorconfig, etc.) run via `pre-commit`; install with `pre-commit install --hook-type pre-commit --hook-type commit-msg`
- Run example server locally: `mvn test-compile exec:java -Dexec.mainClass=com.retailsvc.http.start.ServerLauncher -Dexec.classpathScope=test` (or run `ServerLauncher` from the IDE). Test schema lives at `src/test/resources/openapi.json`.
- Acceptance/load probes: k6 scripts under `acceptance/k6/`. ZAP scan via `./zap.sh`.

## Architecture

Request flow when `OpenApiServer` boots (`src/main/java/com/retailsvc/http/OpenApiServer.java`):

1. `HttpServer` is created on a port with a virtual-thread-per-task executor.
2. A single `HttpContext` is registered at `spec.basePath()` (the first `servers[].url` path from the OpenAPI doc). A catch-all `/` context returns 404.
3. Three filters run in order on every request:
- `ExceptionFilter` — wraps the chain; delegates uncaught exceptions to the user-supplied `ExceptionHandler` (default in `Handlers`).
- `RequestPreparationFilter` — reads the raw request body, stashes it as an exchange attribute, runs OpenAPI parameter + body validation via `DefaultValidator`, and stores the resolved `operationId` on the exchange.
- `DispatchHandler` — looks up the `HttpHandler` registered for that `operationId` in the user-supplied map and invokes it. Missing handler → `MissingOperationHandlerException`.

Key abstractions:

- `com.retailsvc.http.spec.Spec` — parsed from a consumer-supplied `Map<String, Object>` via `Spec.from(raw)`. No JSON library dependency in the library itself; callers use Gson, Jackson, SnakeYAML, etc. to produce the map.
- Sealed `com.retailsvc.http.spec.schema.Schema` interface with per-kind records (`StringSchema`, `NumberSchema`, `IntegerSchema`, `ArraySchema`, `ObjectSchema`, `BooleanSchema`, `NullSchema`, `AnyOfSchema`, `AllOfSchema`, `OneOfSchema`). Pattern-match dispatch eliminates instanceof chains.
- `com.retailsvc.http.validate.DefaultValidator` — single class using `switch` pattern-match over `Schema` subtypes. Validation failures produce RFC 7807 `application/problem+json` 400 responses.
- `com.retailsvc.http.internal.Router` — two indexes: exact path map and templated path list. Resolves `operationId` + extracted path variables for each request.
- `JsonMapper` — `@FunctionalInterface`; single method `Object mapFrom(byte[])`. Callers supply a lambda (see README).
- `com.retailsvc.http.Request` — static helper; `Request.bytes(exchange)` returns raw body bytes, `Request.parsed(exchange)` returns the `Object` produced by the `JsonMapper`.

## Conventions

- Code is formatted with the Google Java Formatter (enforced by pre-commit). Do not hand-format.
- Commit messages must satisfy commitlint (Conventional Commits).
- Integration tests are named `*IT.java` and run only under `mvn verify`, not `mvn test`.
- The library has `slf4j-api` as `provided` — never add a transitive logging binding to main scope.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM eclipse-temurin:21-jre-alpine
FROM eclipse-temurin:25-jre-alpine

WORKDIR /app

Expand Down
87 changes: 54 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ It is designed to be simple to use while providing the essential features needed
## Getting Started

### Prerequisites
- Java SDK 21 or later
- Java SDK 25 or later
- A serialization library, e.g. Gson or Jackson
- OpenAPI specification file in JSON format (`openapi.json`)

Expand All @@ -29,64 +29,72 @@ It is designed to be simple to use while providing the essential features needed
2. Define your HTTP handlers by implementing the `HttpHandler` interface:
``` java
public class GetDataHandler implements HttpHandler {
// Implement your POST endpoint logic
@Override
public void handle(HttpExchange exchange) throws IOException {
try (exchange) {
byte[] bytes = """
{
"id": "some-id"
}""".getBytes();

// Example
try (exchange) {
byte[] bytes = """
{
"id": "some-id"
}""".getBytes();

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

exchange.sendResponseHeaders(HTTP_OK, bytes.length);

os.write(bytes);
try (var os = exchange.getResponseBody()) {
os.write(bytes);
}
}
}
}

public class PostDataHandler implements HttpHandler, GetRequestBody {
// Implement your POST endpoint logic
public class PostDataHandler implements HttpHandler {
@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);
}
}
}
```

1. Initialize the server (using Gson in this example):
3. Initialize the server (using Gson in this example):
``` java
public class YourServerLauncher {
public static void main(String[] args) throws Exception {
final Gson gson = new Gson();
Gson gson = new Gson();

// Parse OpenAPI specification (or build your instance of OpenApi manually)
var specification = parseSpecification("openapi.json", s -> gson.fromJson(s, OpenApi.class));
// Parse spec to a generic Map (works for JSON; for YAML use SnakeYAML).
String text = Files.readString(Path.of("openapi.json"));
Map<String, Object> raw = (Map<String, Object>) gson.fromJson(text, Map.class);
Spec spec = Spec.from(raw);

// Register your handlers (operation-id -> handler)
// 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());
handlers.put("post-data", new PostDataHandler());

// Create JSON mapper (supports both arrays and objects)
JsonMapper mapper = new JsonMapper() {
@Override
public <T> T mapFrom(byte[] body) {
if (body.length > 0 && body[0] == '[') {
return (T) gson.fromJson(new String(body), List.class);
}
return (T) gson.fromJson(new String(body), Map.class);
}
};

ExceptionHandler exceptionHandler = Handlers.defaultExceptionHandler();

// Start the server
new OpenApiServer(specification, mapper, handlers, exceptionHandler);
new OpenApiServer(spec, mapper, handlers, Handlers.defaultExceptionHandler());
}
}
```

### YAML specifications
For YAML, replace the JSON parsing line with SnakeYAML:
``` java
Map<String, Object> raw = new Yaml().load(Files.newInputStream(Path.of("openapi.yaml")));
```
The rest is identical.

## Features
- OpenAPI specification support
- Automatic request body parsing for JSON arrays and objects
Expand All @@ -112,4 +120,17 @@ Schemas are located under test resources folder.
- Example requests can be found under `acceptance/k6` that can be a base for exploring the functionality.
- The logger in the configuration needs to be enabled to get some insight into the code.

## Performance and caveats

The library wraps the JDK's bundled `com.sun.net.httpserver.HttpServer` and uses a virtual-thread-per-request executor. On a developer laptop (Apple Silicon, single instance, default JVM flags) it sustains roughly:

- **~32k requests/second** for small JSON GETs and POSTs (~300 byte bodies), measured via `k6` at 30 sustained VUs over 45 seconds (1.4M requests, **100% of checks passing**, 0% HTTP failures).

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.

## Known limitations or missing features
53 changes: 24 additions & 29 deletions acceptance/k6/script.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import http from 'k6/http';
import { group, check, sleep } from 'k6';
import { group, check } from 'k6';

// Mirrors the local "xargs -P 30" curl smoke test: a single sustained step
// at 30 concurrent virtual users. Keeps the JDK HttpServer well within the
// load level it's designed for — higher VU counts surface k6/keep-alive
// edge cases unrelated to the library's correctness.
export const options = {
stages: [
{ duration: '30s', target: 10 },
{ duration: '30s', target: 100 },
{ duration: '1m', target: 100 },
{ duration: '10s', target: 0 },
{ duration: '10s', target: 30 },
{ duration: '30s', target: 30 },
{ duration: '5s', target: 0 },
],
};

Expand Down Expand Up @@ -41,39 +44,43 @@ const exampleListRequest = [
const objectBody = JSON.stringify(exampleObjectRequest);
const listBody = JSON.stringify(exampleListRequest);

function safeHasOwn(body, prop) {
try {
return JSON.parse(body).hasOwnProperty(prop);
} catch (_) {
return false;
}
}

export default function () {
group('get request', () => {
const url = 'http://localhost:8080/api/v1/data';
const res = http.get(url, { headers: { 'X-Name': "Alotta" }});
const res = http.get(url, { headers: { 'X-Name': 'Alotta' } });

check(res, {
'is status 200': (r) => r.status === 200,
'is response in JSON format': (r) => r.headers['Content-Type'] === 'application/json',
'id exists in response': (r) => JSON.parse(r.body).hasOwnProperty('id'),
'id exists in response': (r) => safeHasOwn(r.body, 'id'),
});
});

group('post request', () => {
const url = 'http://localhost:8080/api/v1/data';
const res = http.post(url, objectBody, {
headers: {
'Content-Type':'application/json',
}
headers: { 'Content-Type': 'application/json' },
});

check(res, {
'is status 200': (r) => r.status === 200,
'is response in JSON format': (r) => r.headers['Content-Type'] === 'application/json',
'id exists in response': (r) => JSON.parse(r.body).hasOwnProperty('id'),
'id exists in response': (r) => safeHasOwn(r.body, 'id'),
});
});

group('post list-of-objects request', () => {
const url = 'http://localhost:8080/api/v1/list/objects';
const res = http.post(url, listBody, {
headers: {
'Content-Type':'application/json',
}
headers: { 'Content-Type': 'application/json' },
});

check(res, {
Expand All @@ -83,11 +90,7 @@ export default function () {

group('get query params', () => {
const url = 'http://localhost:8080/api/v1/params/query?q1=data&q2=data';
const res = http.get(url, listBody, {
headers: {
'Content-Type':'application/json',
}
});
const res = http.get(url);

check(res, {
'is status 200': (r) => r.status === 200,
Expand All @@ -96,11 +99,7 @@ export default function () {

group('get path params', () => {
const url = 'http://localhost:8080/api/v1/params/path/1234567890';
const res = http.get(url, listBody, {
headers: {
'Content-Type':'application/json',
}
});
const res = http.get(url);

check(res, {
'is status 200': (r) => r.status === 200,
Expand All @@ -109,11 +108,7 @@ export default function () {

group('get with many path params', () => {
const url = 'http://localhost:8080/api/v1/params/path/1234567890/Justin/Case';
const res = http.get(url, listBody, {
headers: {
'Content-Type':'application/json',
}
});
const res = http.get(url);

check(res, {
'is status 200': (r) => r.status === 200,
Expand Down
Loading
Loading