Compare commits
103
Commits
fa0a2d79b4
...
master
@@ -0,0 +1,24 @@
|
|||||||
|
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
|
||||||
|
<!--
|
||||||
|
Used only by .gitea/workflows/publish-maven.yml (mvn -s .gitea/maven-settings.xml deploy).
|
||||||
|
Not used for local builds. PACKAGES_TOKEN is a real personal access token (write:package
|
||||||
|
scope) on the Relism account, read from the env var the workflow exports — never written
|
||||||
|
to disk. Deliberately not Gitea's own per-job GITEA_TOKEN: that token can't publish to
|
||||||
|
any package registry at all, a known unimplemented limitation
|
||||||
|
(https://github.com/go-gitea/gitea/issues/23642) — confirmed here by testing: it
|
||||||
|
authenticated fine against the plain API but still got 401 from this endpoint.
|
||||||
|
|
||||||
|
Basic auth (username/password): the <httpHeaders> form Gitea's own docs show for this is
|
||||||
|
honored by Maven's resolver (used for reading <repositories>) but not reliably by the
|
||||||
|
wagon-http provider maven-deploy-plugin actually uploads through.
|
||||||
|
-->
|
||||||
|
<servers>
|
||||||
|
<server>
|
||||||
|
<id>gitea</id>
|
||||||
|
<username>Relism</username>
|
||||||
|
<password>${env.PACKAGES_TOKEN}</password>
|
||||||
|
</server>
|
||||||
|
</servers>
|
||||||
|
</settings>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
name: Publish Maven packages
|
||||||
|
|
||||||
|
# Flash's own POM keeps `2.1.0-SNAPSHOT` as its committed version — that's what local
|
||||||
|
# `mvn install` (Pathway's normal dev loop, see its pom.xml `flash.version` comment) always
|
||||||
|
# produces, and changing it here would break that. Gitea's Maven registry, unlike a real
|
||||||
|
# snapshot repository, refuses to re-publish an existing name+version (must delete first —
|
||||||
|
# see https://docs.gitea.com/usage/packages/maven#publish-a-package), so every push instead
|
||||||
|
# publishes under a throwaway version stamped with the commit it built from
|
||||||
|
# (`2.1.0-<short-sha>`), via `versions:set` on a checkout copy — never touching the committed
|
||||||
|
# POMs. Consumers (Pathway's `docker` Maven profile) pin `flash.version` to one specific
|
||||||
|
# published build and bump it by hand to pick up newer Flash changes; see
|
||||||
|
# pathway/pom.xml's `docker` profile for the other half of this.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# No actions/checkout here on purpose: it's a Node-based action, and this container
|
||||||
|
# (chosen for its preinstalled mvn/JDK 21) has no Node — checkout would fail with
|
||||||
|
# "node: executable file not found". A plain git clone needs neither.
|
||||||
|
container:
|
||||||
|
image: maven:3.9-eclipse-temurin-21
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends git
|
||||||
|
git clone https://git.pixel-services.com/Relism/Flash5.git .
|
||||||
|
git checkout ${{ gitea.sha }}
|
||||||
|
|
||||||
|
- name: Stamp every module with a commit-scoped version
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
mvn -B versions:set -DnewVersion="2.1.0-${SHORT_SHA}" -DprocessAllModules=true -DgenerateBackupPoms=false
|
||||||
|
echo "Publishing as 2.1.0-${SHORT_SHA}"
|
||||||
|
|
||||||
|
- name: Deploy to the Gitea Maven registry
|
||||||
|
env:
|
||||||
|
# Not GITEA_TOKEN: Gitea's own job token can't publish to package registries at
|
||||||
|
# all (a known, still-unimplemented limitation — see
|
||||||
|
# https://github.com/go-gitea/gitea/issues/23642). Confirmed by testing: GITEA_TOKEN
|
||||||
|
# authenticated fine against the plain API but still got 401 from this endpoint no
|
||||||
|
# matter the auth style. PACKAGES_TOKEN is a real PAT with write:package scope.
|
||||||
|
PACKAGES_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
|
||||||
|
run: |
|
||||||
|
mvn -B -s .gitea/maven-settings.xml -DskipTests deploy \
|
||||||
|
-DaltReleaseDeploymentRepository=gitea::https://git.pixel-services.com/api/packages/Relism/maven \
|
||||||
|
-DaltSnapshotDeploymentRepository=gitea::https://git.pixel-services.com/api/packages/Relism/maven
|
||||||
@@ -32,8 +32,40 @@ jobs:
|
|||||||
server-username: MAVEN_USERNAME
|
server-username: MAVEN_USERNAME
|
||||||
server-password: MAVEN_PASSWORD
|
server-password: MAVEN_PASSWORD
|
||||||
|
|
||||||
|
- name: Install h2spec 2.6.0
|
||||||
|
run: |
|
||||||
|
curl --fail --location --silent --show-error \
|
||||||
|
--output /tmp/h2spec.tar.gz \
|
||||||
|
https://github.com/summerwind/h2spec/releases/download/v2.6.0/h2spec_linux_amd64.tar.gz
|
||||||
|
echo "157ee0de702e01ad40e752dbf074b366027e550c8e7504f9450da2809e279318 /tmp/h2spec.tar.gz" \
|
||||||
|
| sha256sum --check
|
||||||
|
tar --extract --gzip --file /tmp/h2spec.tar.gz --directory /tmp
|
||||||
|
|
||||||
|
- name: Install nghttp client
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install --yes nghttp2-client
|
||||||
|
|
||||||
|
- name: Install grpcurl 1.9.3
|
||||||
|
run: |
|
||||||
|
curl --fail --location --silent --show-error \
|
||||||
|
--output /tmp/grpcurl.tgz \
|
||||||
|
https://github.com/fullstorydev/grpcurl/releases/download/v1.9.3/grpcurl_1.9.3_linux_x86_64.tar.gz
|
||||||
|
echo "a926b62a85787ccf73ef8736b3ae554f1242e39d92bb8767a79d6dd23b11d1d5 /tmp/grpcurl.tgz" \
|
||||||
|
| sha256sum --check
|
||||||
|
tar --extract --gzip --file /tmp/grpcurl.tgz --directory /tmp grpcurl
|
||||||
|
|
||||||
- name: Build and test
|
- name: Build and test
|
||||||
run: mvn -B --settings .github/settings.xml clean verify
|
run: >-
|
||||||
|
mvn -B --settings .github/settings.xml
|
||||||
|
-Dh2spec.executable=/tmp/h2spec
|
||||||
|
-Dcurl.executable=/usr/bin/curl
|
||||||
|
-Dnghttp.executable=/usr/bin/nghttp
|
||||||
|
-Dgrpcurl.executable=/tmp/grpcurl
|
||||||
|
-Djdk.tracePinnedThreads=full
|
||||||
|
-Pjmh
|
||||||
|
-Dflash.performance.gates=true
|
||||||
|
clean verify
|
||||||
env:
|
env:
|
||||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
||||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||||
|
|||||||
Generated
+4
-4
@@ -17,8 +17,8 @@
|
|||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-security-oidc/src/main/java" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-security-oidc/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" />
|
||||||
@@ -31,8 +31,8 @@
|
|||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-vite/src/main/java" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-vite/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/java" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/resources" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash/src/main/java" charset="UTF-8" />
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ Format: `<type>(<scope>): <short description>`
|
|||||||
| `chore` | Build, deps, tooling — no production code |
|
| `chore` | Build, deps, tooling — no production code |
|
||||||
| `ci` | Changes to GitHub Actions workflows |
|
| `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-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-vite`,
|
||||||
`ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `release`, `deps`, `ci`.
|
`ext-mcp`, `ext-validation`, `ext-scheduler`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`,
|
||||||
|
`ext-cache-core`, `ext-cache-caffeine`, `release`, `deps`, `ci`.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
```
|
```
|
||||||
@@ -85,6 +86,9 @@ chore(release): 2.1.0
|
|||||||
|
|
||||||
- Root POM: `flash-parent` — defines all dependency versions and plugin config.
|
- Root POM: `flash-parent` — defines all dependency versions and plugin config.
|
||||||
- `flash` module: the core framework JAR.
|
- `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.
|
- `flash-extensions` POM: aggregator for all extension modules.
|
||||||
- Extensions live under `flash-extensions/flash-ext-*/`.
|
- Extensions live under `flash-extensions/flash-ext-*/`.
|
||||||
- When adding a new extension:
|
- When adding a new extension:
|
||||||
|
|||||||
@@ -1,19 +1,33 @@
|
|||||||
# Flash
|
# Flash
|
||||||
|
|
||||||
A high-performance HTTP/1.1 server library for Java 21, built around virtual threads and a zero-allocation FSM router.
|
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
|
## Modules
|
||||||
|
|
||||||
| Module | Description |
|
| Module | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `flash` | Core server library — router, request parser, HTTP I/O transport |
|
| `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model |
|
||||||
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
|
| `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-openapi` | OpenAPI 3.0 spec + Swagger UI |
|
||||||
| `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow |
|
| `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-core` | Minimal shared SSR runtime primitives |
|
||||||
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
|
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
|
||||||
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
|
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
|
||||||
| `flash-bench` | Demo harness (OIDC + OpenAPI + Jackson) |
|
| `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
|
## Requirements
|
||||||
|
|
||||||
@@ -57,31 +71,31 @@ app.post("/echo", (req, res) -> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get("/users/{id}", (req, res) -> {
|
app.get("/users/{id}", (req, res) -> {
|
||||||
String id = req.pathParam("id");
|
String id = req.param("id");
|
||||||
return "user:" + id;
|
return "user:" + id;
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Class-based handlers
|
### Class-based handlers
|
||||||
|
|
||||||
Extend `RequestHandler` (or a subclass like `JacksonHandler`) and annotate with `@Route`:
|
Extend `RequestHandler`, annotate it, then scan its package. Dependencies are cached in
|
||||||
|
`onInit()` after Flash has resolved its complete boot-time service graph:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
@Route(method = HttpMethod.GET, path = "/api/users")
|
@GET("/api/users")
|
||||||
public class ListUsers extends JacksonHandler {
|
public class ListUsers extends RequestHandler {
|
||||||
@Override
|
private UserService users;
|
||||||
public Object handle(Request req, Response res) throws Exception {
|
|
||||||
return json(res, List.of("alice", "bob"));
|
@Override protected void onInit() { users = require(UserService.class); }
|
||||||
}
|
@Override public Object handle(Request req, Response res) { return users.list(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register:
|
app.scan("dev.example.api");
|
||||||
app.register(new ListUsers());
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Middleware
|
### Middleware
|
||||||
|
|
||||||
Apply middleware via `.with()` on the `RouteHandle` returned by any registration call:
|
Apply middleware at registration. Flash composes the final chain at boot:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
Middleware authCheck = next -> (req, res) -> {
|
Middleware authCheck = next -> (req, res) -> {
|
||||||
@@ -90,14 +104,13 @@ Middleware authCheck = next -> (req, res) -> {
|
|||||||
return next.handle(req, res);
|
return next.handle(req, res);
|
||||||
};
|
};
|
||||||
|
|
||||||
app.get("/secure", (req, res) -> "secret data")
|
app.get("/secure", (req, res) -> "secret data", authCheck);
|
||||||
.with(authCheck);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Multiple middlewares are composed outermost-first (left-to-right in the call):
|
Multiple middlewares are composed outermost-first (left-to-right in the call):
|
||||||
|
|
||||||
```java
|
```java
|
||||||
app.get("/admin", handler).with(logging, auth, rateLimit);
|
app.get("/admin", handler, logging, auth, rateLimit);
|
||||||
// execution order: logging → auth → rateLimit → handler
|
// execution order: logging → auth → rateLimit → handler
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -119,31 +132,41 @@ processors, services):
|
|||||||
```java
|
```java
|
||||||
app.mount("/api", scope -> {
|
app.mount("/api", scope -> {
|
||||||
scope.get("/health", (req, res) -> "ok"); // → GET /api/health
|
scope.get("/health", (req, res) -> "ok"); // → GET /api/health
|
||||||
scope.register(new UserHandler()); // @Route(path="/users") → GET /api/users
|
|
||||||
scope.scan("dev.example.api");
|
scope.scan("dev.example.api");
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Extensions
|
## Extensions
|
||||||
|
|
||||||
Extensions are installed before route registration. Each extension receives the `FlashRegistrar`
|
Extensions have one declarative `configure` method. They declare services, processors and route
|
||||||
and `FlashContext` — it can register routes, expose services, and register annotation processors.
|
callbacks; Flash resolves the complete graph, materialises routes, compiles both routers, then
|
||||||
|
opens listeners. Extension install order never makes a service “not ready”.
|
||||||
|
|
||||||
```java
|
```java
|
||||||
FlashApp.create(8080)
|
FlashApp.create(8080)
|
||||||
.install(new JacksonExtension())
|
.install(new JacksonExtension())
|
||||||
.install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
|
.install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
|
||||||
.install(new OidcExtension(oidcConfig))
|
.install(new OidcExtension(oidcConfig))
|
||||||
.register(new MyHandler())
|
.scan("dev.example.handlers")
|
||||||
.start();
|
.start();
|
||||||
```
|
```
|
||||||
|
|
||||||
See extension-specific READMEs for full details:
|
See extension-specific READMEs for full details:
|
||||||
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
|
- [`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-openapi`](flash-extensions/flash-ext-openapi/README.md)
|
||||||
- [`flash-ext-oidc`](flash-extensions/flash-ext-oidc/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-jte`](flash-extensions/flash-ext-view-jte/README.md)
|
||||||
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/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
|
## Error handlers
|
||||||
|
|
||||||
@@ -166,13 +189,64 @@ app.onException((ex, req, res) -> {
|
|||||||
| `tls` | `null` | TLS for the default listener — see [TLS](#tls) |
|
| `tls` | `null` | TLS for the default listener — see [TLS](#tls) |
|
||||||
| `listeners` | `[]` | Multiple bind targets (port + host + optional TLS) on one app — 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) |
|
| `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
|
## TLS
|
||||||
|
|
||||||
HTTPS and WSS are a transport-layer concern only: once a listener is bound, the accepted
|
HTTPS and WSS are a transport-layer concern only. Once a listener is bound, the accepted socket
|
||||||
`Socket` is either plain or an `SSLSocket` indistinguishably from `HttpServer`'s point of view
|
is plain or TLS; the selected HTTP connection implementation then performs either the HTTP/1.1
|
||||||
onward — the request parser, router, and WebSocket upgrade never branch on it. WSS is therefore
|
upgrade or the HTTP/2 extended CONNECT. WSS does not require a separate route or handler API.
|
||||||
not a separate feature; it's a WebSocket upgrade running over whatever transport it was handed.
|
|
||||||
|
|
||||||
### Quick start
|
### Quick start
|
||||||
|
|
||||||
@@ -253,23 +327,212 @@ that got the request this far has already completed, never a forced handshake.
|
|||||||
`WebSocketSession` mirrors this exactly (`isSecure()`, `sslSession()`) by delegating to the
|
`WebSocketSession` mirrors this exactly (`isSecure()`, `sslSession()`) by delegating to the
|
||||||
upgrading `Request` — no separate TLS state is tracked for WS.
|
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
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
ServerSocket.accept()
|
TransportFactory.create() # binds every listener, wires the connection runner
|
||||||
→ RequestParser.parse() # zero-alloc header parsing, buffer reuse across keep-alive
|
→ AcceptLoop # one per listener × accept thread; hands sockets off
|
||||||
→ GlobalRouter.route() # two-tier: mounted sub-routers (longest prefix) then FastPathRouterImpl
|
→ ConnectionRunner.accept() # per-connection setup: TLS handshake, protocol negotiation
|
||||||
→ RequestHandler.handle() # user handler; return value sets body
|
→ ProtocolNegotiator # ALPN / h2c-preface — decides the protocol once
|
||||||
→ Request.drain() # consume unread body for keep-alive
|
├─ Http1Connection.run() # request parser, router, handler, h1 response writer
|
||||||
→ HttpServer writes response # status line, headers, then fixed or chunked body
|
└─ Http2Connection.run() # frame demux, HPACK, stream dispatch, flow control
|
||||||
→ loop or close socket # based on Connection header
|
→ RequestHandler.handle() # the same protocol-neutral request/response API
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`). Java 21 required.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
## Build & test
|
||||||
|
|
||||||
@@ -282,7 +545,4 @@ mvn test
|
|||||||
|
|
||||||
# Run a single test class
|
# Run a single test class
|
||||||
mvn test -pl flash -Dtest=RequestParserTest
|
mvn test -pl flash -Dtest=RequestParserTest
|
||||||
|
|
||||||
# Run the benchmark demo server
|
|
||||||
java -jar flash-bench/target/flash-bench-1.0-SNAPSHOT.jar
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# flash-ext-cache-caffeine
|
||||||
|
|
||||||
|
In-process caching backed by [Caffeine](https://github.com/ben-manes/caffeine). Implements
|
||||||
|
[`flash-ext-cache-core`](../../flash-ext-cache-core/docs/README.md).
|
||||||
|
|
||||||
|
## Dependency
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-cache-caffeine</artifactId>
|
||||||
|
<version>${flash.version}</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```java
|
||||||
|
FlashApp.create(8080)
|
||||||
|
.install(new CaffeineCacheExtension())
|
||||||
|
.scan("dev.example.api");
|
||||||
|
```
|
||||||
|
|
||||||
|
```java
|
||||||
|
@GET("/api/users/{id}")
|
||||||
|
public final class GetUser extends RequestHandler {
|
||||||
|
|
||||||
|
private Cache<String, User> users;
|
||||||
|
private UserRepository repo;
|
||||||
|
|
||||||
|
@Override protected void onInit() {
|
||||||
|
repo = require(UserRepository.class);
|
||||||
|
users = require(CacheManager.class).build("users", spec -> spec
|
||||||
|
.maxSize(10_000)
|
||||||
|
.ttl(Duration.ofMinutes(10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public Object handle(Request req, Response res) {
|
||||||
|
return users.get(req.param("id"), repo::findById);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The extension takes no configuration. Each cache declares its own size and TTL where it is built.
|
||||||
|
|
||||||
|
## Why Caffeine and not a `LinkedHashMap`
|
||||||
|
|
||||||
|
An LRU on top of `LinkedHashMap` is about sixty lines, and for a cache that is genuinely
|
||||||
|
low-traffic it is the right answer — `ConcurrentHashMap::computeIfAbsent` is one line and has no
|
||||||
|
hit rate to get wrong.
|
||||||
|
|
||||||
|
This module exists for the case where that stops being true. Caffeine's W-TinyLFU admission,
|
||||||
|
striped frequency counters and amortised eviction are not a weekend's work to reproduce, and the
|
||||||
|
failure mode of getting them wrong is a cache that is *slower* than no cache — lock contention on
|
||||||
|
every lookup, or an eviction policy that throws away exactly the entries you were about to want.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
Caches are released through `FlashContext.onClose`, so `app.stop()` drops every entry. That is
|
||||||
|
invisible in production with one app per process and matters immediately under test, where many
|
||||||
|
apps start and stop in one JVM.
|
||||||
|
|
||||||
|
## Statistics
|
||||||
|
|
||||||
|
```java
|
||||||
|
CacheStats stats = users.stats();
|
||||||
|
stats.hitRate(); // 0.0 until something is looked up
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `recordStats()` on the spec. Without it you get `CacheStats.DISABLED`, which is honest
|
||||||
|
about being unmeasured rather than reporting zeroes that look like a cold cache.
|
||||||
|
|
||||||
|
`manager.names()` lists every cache built so far, for an ops endpoint.
|
||||||
|
|
||||||
|
## What this is not
|
||||||
|
|
||||||
|
**HTTP caching.** If what you want is for the *client* to stop asking — `Cache-Control`, `ETag`,
|
||||||
|
`304 Not Modified` — that is a middleware, not an object cache, and it saves the whole request
|
||||||
|
rather than the lookup inside it. Reach for that first: it is cheaper, and the two solve different
|
||||||
|
problems.
|
||||||
|
|
||||||
|
**A shared cache.** Every replica has its own. Two instances will hold different values for the
|
||||||
|
same key, and an invalidation on one does not reach the other. When that becomes a problem the
|
||||||
|
answer is a networked backend — see the note on `flash-ext-cache-redis` — and the semantics change
|
||||||
|
with it: a cache that can fail is no longer transparent.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-extensions</artifactId>
|
||||||
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>flash-ext-cache-caffeine</artifactId>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-cache-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!--
|
||||||
|
Caffeine rather than a hand-rolled LRU: W-TinyLFU admission, striped counters and
|
||||||
|
amortised eviction are not a weekend's work to get right, and getting them wrong is a
|
||||||
|
cache that is slower than no cache.
|
||||||
|
-->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||||
|
<artifactId>caffeine</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-testing</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package dev.relism.flash.ext.cache.caffeine;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.cache.Cache;
|
||||||
|
import dev.relism.flash.ext.cache.CacheStats;
|
||||||
|
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link Cache} over a Caffeine cache. A thin adapter by design: every method delegates directly,
|
||||||
|
* adding no wrapper object, no copy and no synchronisation of its own.
|
||||||
|
*/
|
||||||
|
final class CaffeineCache<K, V> implements Cache<K, V> {
|
||||||
|
|
||||||
|
private final com.github.benmanes.caffeine.cache.Cache<K, V> delegate;
|
||||||
|
private final boolean statsRecorded;
|
||||||
|
|
||||||
|
CaffeineCache(com.github.benmanes.caffeine.cache.Cache<K, V> delegate, boolean statsRecorded) {
|
||||||
|
this.delegate = delegate;
|
||||||
|
this.statsRecorded = statsRecorded;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public V get(K key, Function<? super K, ? extends V> loader) {
|
||||||
|
// Caffeine's own get(key, mappingFunction) already guarantees the loader runs once per key
|
||||||
|
// across concurrent callers; wrapping it in anything of ours would only add a race.
|
||||||
|
return delegate.get(key, loader);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public V getIfPresent(K key) { return delegate.getIfPresent(key); }
|
||||||
|
@Override public void put(K key, V value) { delegate.put(key, value); }
|
||||||
|
@Override public void invalidate(K key) { delegate.invalidate(key); }
|
||||||
|
@Override public void invalidateAll() { delegate.invalidateAll(); }
|
||||||
|
@Override public long estimatedSize() { return delegate.estimatedSize(); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CacheStats stats() {
|
||||||
|
if (!statsRecorded) return CacheStats.DISABLED;
|
||||||
|
com.github.benmanes.caffeine.cache.stats.CacheStats snapshot = delegate.stats();
|
||||||
|
return new CacheStats(snapshot.hitCount(), snapshot.missCount(), snapshot.evictionCount());
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package dev.relism.flash.ext.cache.caffeine;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.cache.CacheManager;
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
|
import dev.relism.flash.extension.FlashRegistrar;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs an in-process {@link CacheManager} backed by Caffeine.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* FlashApp.create(8080)
|
||||||
|
* .install(new CaffeineCacheExtension())
|
||||||
|
* .scan("dev.example.api");
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>No configuration. Each cache declares its own size and TTL where it is built, because those
|
||||||
|
* are properties of what is being cached, not of the process caching it.
|
||||||
|
*
|
||||||
|
* <p>Caches are dropped through {@link FlashContext#onClose}, so a stopped app does not keep its
|
||||||
|
* values alive — which matters when many apps start and stop in one JVM, as they do under test.
|
||||||
|
*/
|
||||||
|
public final class CaffeineCacheExtension implements FlashExtension {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
|
ctx.supply(CacheManager.class, services -> {
|
||||||
|
CaffeineCacheManager manager = new CaffeineCacheManager();
|
||||||
|
services.onClose(manager::clear);
|
||||||
|
return manager;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package dev.relism.flash.ext.cache.caffeine;
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import dev.relism.flash.ext.cache.Cache;
|
||||||
|
import dev.relism.flash.ext.cache.CacheManager;
|
||||||
|
import dev.relism.flash.ext.cache.CacheSpec;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/** In-process {@link CacheManager} backed by Caffeine. */
|
||||||
|
final class CaffeineCacheManager implements CacheManager {
|
||||||
|
|
||||||
|
private final Map<String, Entry> caches = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public <K, V> Cache<K, V> build(String name, java.util.function.Consumer<CacheSpec> configure) {
|
||||||
|
CacheSpec spec = CacheSpec.of();
|
||||||
|
configure.accept(spec);
|
||||||
|
|
||||||
|
Entry entry = caches.computeIfAbsent(name, key -> new Entry(describe(spec), create(spec)));
|
||||||
|
// Two handlers sharing a cache is the point; two handlers disagreeing about its size or
|
||||||
|
// TTL is a bug that would otherwise resolve to whichever one ran first.
|
||||||
|
String requested = describe(spec);
|
||||||
|
if (!entry.signature.equals(requested))
|
||||||
|
throw new IllegalStateException("Cache '" + name + "' already exists as " + entry.signature
|
||||||
|
+ " but was requested as " + requested);
|
||||||
|
return (Cache<K, V>) entry.cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public <K, V> Cache<K, V> cache(String name) {
|
||||||
|
Entry entry = caches.get(name);
|
||||||
|
return entry == null ? null : (Cache<K, V>) entry.cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<String> names() {
|
||||||
|
return Set.copyOf(caches.keySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases every entry so a stopped app does not keep its values alive. */
|
||||||
|
void clear() {
|
||||||
|
caches.values().forEach(entry -> entry.cache.invalidateAll());
|
||||||
|
caches.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CaffeineCache<Object, Object> create(CacheSpec spec) {
|
||||||
|
Caffeine<Object, Object> builder = Caffeine.newBuilder();
|
||||||
|
if (spec.bounded()) builder.maximumSize(spec.maxSize());
|
||||||
|
if (spec.ttl() != null) builder.expireAfterWrite(spec.ttl());
|
||||||
|
if (spec.ttlAfterAccess() != null) builder.expireAfterAccess(spec.ttlAfterAccess());
|
||||||
|
if (spec.statsRecorded()) builder.recordStats();
|
||||||
|
return new CaffeineCache<>(builder.build(), spec.statsRecorded());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String describe(CacheSpec spec) {
|
||||||
|
return "maxSize=" + spec.maxSize() + " ttl=" + spec.ttl()
|
||||||
|
+ " ttlAfterAccess=" + spec.ttlAfterAccess() + " stats=" + spec.statsRecorded();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Entry(String signature, CaffeineCache<Object, Object> cache) {}
|
||||||
|
}
|
||||||
+160
@@ -0,0 +1,160 @@
|
|||||||
|
package dev.relism.flash.ext.cache.caffeine;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.cache.Cache;
|
||||||
|
import dev.relism.flash.ext.cache.CacheManager;
|
||||||
|
import dev.relism.flash.ext.cache.CacheStats;
|
||||||
|
import dev.relism.flash.extension.FlashApp;
|
||||||
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
|
import dev.relism.flash.testing.FlashTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class CaffeineCacheTest {
|
||||||
|
|
||||||
|
private static final AtomicInteger loads = new AtomicInteger();
|
||||||
|
|
||||||
|
@RegisterExtension
|
||||||
|
static FlashTest app = FlashTest.of(configured -> {
|
||||||
|
configured.install(new CaffeineCacheExtension());
|
||||||
|
configured.ctx().onReady(() -> {
|
||||||
|
Cache<String, String> users = configured.ctx().require(CacheManager.class)
|
||||||
|
.build("users", spec -> spec.maxSize(100).ttl(Duration.ofMinutes(5)).recordStats());
|
||||||
|
configured.get("/users/{id}", (req, res) ->
|
||||||
|
users.get(req.param("id"), id -> "loaded:" + id + ":" + loads.incrementAndGet()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
private static CacheManager manager() {
|
||||||
|
return app.app().ctx().require(CacheManager.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aRepeatedRequestIsServedFromCache() {
|
||||||
|
String first = app.get("/users/alice").expectStatus(200).body();
|
||||||
|
String second = app.get("/users/alice").expectStatus(200).body();
|
||||||
|
|
||||||
|
assertEquals(first, second);
|
||||||
|
assertTrue(first.startsWith("loaded:alice:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void distinctKeysLoadSeparately() {
|
||||||
|
assertNotEquals(app.get("/users/bob").body(), app.get("/users/carol").body());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statsCountHitsAndMisses() {
|
||||||
|
Cache<String, String> cache = manager().build("stats-probe", spec -> spec.maxSize(10).recordStats());
|
||||||
|
cache.get("k", key -> "v");
|
||||||
|
cache.get("k", key -> "v");
|
||||||
|
|
||||||
|
CacheStats stats = cache.stats();
|
||||||
|
assertEquals(1, stats.misses());
|
||||||
|
assertEquals(1, stats.hits());
|
||||||
|
assertEquals(0.5, stats.hitRate());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void statsAreDisabledUnlessAskedFor() {
|
||||||
|
Cache<String, String> cache = manager().build("no-stats", spec -> spec.maxSize(10));
|
||||||
|
cache.get("k", key -> "v");
|
||||||
|
|
||||||
|
assertEquals(CacheStats.DISABLED, cache.stats());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theLoaderRunsOncePerKeyUnderConcurrency() throws Exception {
|
||||||
|
Cache<String, String> cache = manager().build("single-flight", spec -> spec.maxSize(10));
|
||||||
|
AtomicInteger invocations = new AtomicInteger();
|
||||||
|
int threads = 16;
|
||||||
|
CountDownLatch start = new CountDownLatch(1);
|
||||||
|
CountDownLatch done = new CountDownLatch(threads);
|
||||||
|
|
||||||
|
for (int i = 0; i < threads; i++) {
|
||||||
|
Thread.ofVirtual().start(() -> {
|
||||||
|
try {
|
||||||
|
start.await();
|
||||||
|
cache.get("hot", key -> {
|
||||||
|
invocations.incrementAndGet();
|
||||||
|
return "value";
|
||||||
|
});
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
} finally {
|
||||||
|
done.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
start.countDown();
|
||||||
|
assertTrue(done.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
||||||
|
|
||||||
|
assertEquals(1, invocations.get(), "concurrent callers must share one load, not race");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aNullLoaderResultStoresNothing() {
|
||||||
|
Cache<String, String> cache = manager().build("nulls", spec -> spec.maxSize(10));
|
||||||
|
|
||||||
|
assertNull(cache.get("missing", key -> null));
|
||||||
|
assertNull(cache.getIfPresent("missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidateDropsOneKeyAndInvalidateAllDropsEverything() {
|
||||||
|
Cache<String, String> cache = manager().build("invalidation", spec -> spec.maxSize(10));
|
||||||
|
cache.put("a", "1");
|
||||||
|
cache.put("b", "2");
|
||||||
|
|
||||||
|
cache.invalidate("a");
|
||||||
|
assertNull(cache.getIfPresent("a"));
|
||||||
|
assertEquals("2", cache.getIfPresent("b"));
|
||||||
|
|
||||||
|
cache.invalidateAll();
|
||||||
|
assertNull(cache.getIfPresent("b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildIsIdempotentPerName() {
|
||||||
|
Cache<String, String> first = manager().build("shared", spec -> spec.maxSize(10));
|
||||||
|
Cache<String, String> second = manager().build("shared", spec -> spec.maxSize(10));
|
||||||
|
|
||||||
|
assertSame(first, second, "two handlers asking for one cache must get one cache");
|
||||||
|
assertSame(first, manager().cache("shared"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disagreeingOnASharedCacheIsARejectedMistakeNotASilentWinner() {
|
||||||
|
manager().build("contested", spec -> spec.maxSize(10));
|
||||||
|
|
||||||
|
IllegalStateException conflict = assertThrows(IllegalStateException.class,
|
||||||
|
() -> manager().build("contested", spec -> spec.maxSize(999)));
|
||||||
|
assertTrue(conflict.getMessage().contains("contested"), conflict.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownNameReturnsNullRatherThanBuildingOne() {
|
||||||
|
assertNull(manager().cache("never-built"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why CaffeineCacheExtension registers onClose: values must not outlive the app holding them. */
|
||||||
|
@Test
|
||||||
|
void stoppingTheAppReleasesEveryCache() {
|
||||||
|
FlashApp standalone = FlashApp.create(FlashConfiguration.builder()
|
||||||
|
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build())
|
||||||
|
.install(new CaffeineCacheExtension());
|
||||||
|
standalone.start();
|
||||||
|
CacheManager manager = standalone.ctx().require(CacheManager.class);
|
||||||
|
manager.build("scoped", spec -> spec.maxSize(10)).put("k", "v");
|
||||||
|
assertEquals(1, manager.names().size());
|
||||||
|
|
||||||
|
standalone.stop().join();
|
||||||
|
|
||||||
|
assertTrue(manager.names().isEmpty(), "caches must be released when the app stops");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# flash-ext-cache-core
|
||||||
|
|
||||||
|
The caching contract, shared across backends. Like `flash-ext-data-core`, this module talks to
|
||||||
|
nothing: it defines the abstractions and a backend implements them.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
- `Cache<K, V>` — a named cache. `get(key, loader)` is the method that matters.
|
||||||
|
- `CacheManager` — creates and hands back named caches.
|
||||||
|
- `CacheSpec` — size and expiry for one cache.
|
||||||
|
- `CacheStats` — hit/miss/eviction counters.
|
||||||
|
|
||||||
|
Install a backend, not this module: [`flash-ext-cache-caffeine`](../../flash-ext-cache-caffeine/docs/README.md)
|
||||||
|
for in-process caching.
|
||||||
|
|
||||||
|
## The one shape that matters
|
||||||
|
|
||||||
|
```java
|
||||||
|
User user = users.get(id, repo::findById);
|
||||||
|
```
|
||||||
|
|
||||||
|
Compute-if-absent is the only cache operation most code needs, and the only one that is hard to
|
||||||
|
get right — the loader runs **once per key** across concurrent callers, and the rest wait rather
|
||||||
|
than each computing their own. `getIfPresent`, `put`, `invalidate` and `invalidateAll` exist for
|
||||||
|
what it cannot express.
|
||||||
|
|
||||||
|
A loader returning `null` stores nothing and returns `null`. Caching absence is a decision, not a
|
||||||
|
default; wrap it in an `Optional` or a sentinel if you want it.
|
||||||
|
|
||||||
|
## Naming and sharing
|
||||||
|
|
||||||
|
`CacheManager.build(name, spec)` is idempotent per name: two handlers asking for `"users"` get one
|
||||||
|
cache, not two, so nobody has to coordinate who creates it first.
|
||||||
|
|
||||||
|
If they disagree about the spec, that throws. The alternative is a cache whose size depends on
|
||||||
|
which handler happened to initialise first, which is the kind of bug that only shows up under
|
||||||
|
load.
|
||||||
|
|
||||||
|
## Specs
|
||||||
|
|
||||||
|
```java
|
||||||
|
CacheSpec.of()
|
||||||
|
.maxSize(10_000)
|
||||||
|
.ttl(Duration.ofMinutes(10))
|
||||||
|
.recordStats();
|
||||||
|
```
|
||||||
|
|
||||||
|
Every field is optional, but a spec that sets neither `maxSize` nor `ttl` is an unbounded cache
|
||||||
|
that never expires — a memory leak wearing a hat. Set at least one.
|
||||||
|
|
||||||
|
`recordStats()` is off by default: counting costs a pair of atomic increments on every lookup, and
|
||||||
|
a cache nobody is measuring should not pay for numbers nobody reads. Without it, `stats()` returns
|
||||||
|
`CacheStats.DISABLED` rather than silently zero.
|
||||||
|
|
||||||
|
## Where the spec lives
|
||||||
|
|
||||||
|
On the cache, at the point it is built — not in application config. Size and TTL are properties of
|
||||||
|
*what is being cached*, not of the process doing the caching, and a TTL in a config file is a TTL
|
||||||
|
nobody can relate back to the data it governs.
|
||||||
|
|
||||||
|
## Writing a backend
|
||||||
|
|
||||||
|
Implement `CacheManager` and `Cache`, provide the manager from a `FlashExtension`, and register
|
||||||
|
cleanup with `FlashContext.onClose` so a stopped app does not keep its values alive.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-extensions</artifactId>
|
||||||
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>flash-ext-cache-core</artifactId>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
package dev.relism.flash.ext.cache;
|
||||||
|
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A named cache. Obtained from a {@link CacheManager}, safe to hold in a handler field and share
|
||||||
|
* across threads.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* User user = users.get(id, repo::findById);
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>{@link #get} is the only method most code needs. The rest exist for the cases it cannot
|
||||||
|
* express: reading without populating, writing a value computed elsewhere, and invalidating.
|
||||||
|
*
|
||||||
|
* @param <K> key type — must have a stable {@code hashCode}/{@code equals}
|
||||||
|
* @param <V> value type
|
||||||
|
*/
|
||||||
|
public interface Cache<K, V> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cached value, computing and storing it with {@code loader} if absent.
|
||||||
|
*
|
||||||
|
* <p>The loader runs at most once per key across concurrent callers; the others wait for it
|
||||||
|
* rather than each computing their own. A loader returning {@code null} stores nothing and
|
||||||
|
* {@code null} is returned.
|
||||||
|
*/
|
||||||
|
V get(K key, Function<? super K, ? extends V> loader);
|
||||||
|
|
||||||
|
/** The cached value, or {@code null} if absent. Never invokes a loader. */
|
||||||
|
V getIfPresent(K key);
|
||||||
|
|
||||||
|
/** Stores {@code value}, replacing any existing entry. */
|
||||||
|
void put(K key, V value);
|
||||||
|
|
||||||
|
/** Drops {@code key}. Does nothing if it was absent. */
|
||||||
|
void invalidate(K key);
|
||||||
|
|
||||||
|
/** Drops every entry. */
|
||||||
|
void invalidateAll();
|
||||||
|
|
||||||
|
/** Approximate entry count. Approximate because eviction is asynchronous in most backends. */
|
||||||
|
long estimatedSize();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hit/miss counters since this cache was built, or {@link CacheStats#DISABLED} when the
|
||||||
|
* backend was not asked to record them.
|
||||||
|
*/
|
||||||
|
CacheStats stats();
|
||||||
|
}
|
||||||
Vendored
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package dev.relism.flash.ext.cache;
|
||||||
|
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and hands back named caches. Resolve it with {@code require(CacheManager.class)}; a
|
||||||
|
* backend extension such as {@code flash-ext-cache-caffeine} provides the implementation.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* @Override protected void onInit() {
|
||||||
|
* users = require(CacheManager.class).build("users", spec -> spec
|
||||||
|
* .maxSize(10_000)
|
||||||
|
* .ttl(Duration.ofMinutes(10)));
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>{@link #build} is idempotent per name: calling it twice returns the same cache rather than
|
||||||
|
* two, so several handlers can share one without coordinating who creates it. The spec of the
|
||||||
|
* first call wins; a later call with a different spec is a configuration mistake and throws.
|
||||||
|
*/
|
||||||
|
public interface CacheManager {
|
||||||
|
|
||||||
|
/** Creates the named cache, or returns the existing one. */
|
||||||
|
<K, V> Cache<K, V> build(String name, Consumer<CacheSpec> spec);
|
||||||
|
|
||||||
|
/** The named cache, or {@code null} if {@link #build} has not been called for it. */
|
||||||
|
<K, V> Cache<K, V> cache(String name);
|
||||||
|
|
||||||
|
/** Every cache name built so far, for an ops endpoint. */
|
||||||
|
java.util.Set<String> names();
|
||||||
|
}
|
||||||
Vendored
+64
@@ -0,0 +1,64 @@
|
|||||||
|
package dev.relism.flash.ext.cache;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How one cache should behave. Every field is optional — a spec that sets nothing gives an
|
||||||
|
* unbounded cache that never expires, which is a memory leak wearing a hat, so set at least one
|
||||||
|
* of {@link #maxSize} or {@link #ttl}.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* CacheSpec.of().maxSize(10_000).ttl(Duration.ofMinutes(10))
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>Mutable builder rather than a record with {@code withX} copies: it is constructed once at
|
||||||
|
* boot inside a lambda and never shared.
|
||||||
|
*/
|
||||||
|
public final class CacheSpec {
|
||||||
|
|
||||||
|
private long maxSize = -1;
|
||||||
|
private Duration ttl;
|
||||||
|
private Duration ttlAfterAccess;
|
||||||
|
private boolean recordStats;
|
||||||
|
|
||||||
|
private CacheSpec() {}
|
||||||
|
|
||||||
|
public static CacheSpec of() {
|
||||||
|
return new CacheSpec();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maximum entries before the backend starts evicting. Negative means unbounded. */
|
||||||
|
public CacheSpec maxSize(long maxSize) {
|
||||||
|
this.maxSize = maxSize;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entries expire this long after they were written. */
|
||||||
|
public CacheSpec ttl(Duration ttl) {
|
||||||
|
this.ttl = ttl;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entries expire this long after they were last read or written. */
|
||||||
|
public CacheSpec ttlAfterAccess(Duration ttlAfterAccess) {
|
||||||
|
this.ttlAfterAccess = ttlAfterAccess;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records hit/miss counters for {@link Cache#stats()}.
|
||||||
|
*
|
||||||
|
* <p>Off by default: counting costs a pair of atomic increments on every lookup, and a cache
|
||||||
|
* nobody is measuring should not pay for numbers nobody reads.
|
||||||
|
*/
|
||||||
|
public CacheSpec recordStats() {
|
||||||
|
this.recordStats = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long maxSize() { return maxSize; }
|
||||||
|
public Duration ttl() { return ttl; }
|
||||||
|
public Duration ttlAfterAccess() { return ttlAfterAccess; }
|
||||||
|
public boolean statsRecorded() { return recordStats; }
|
||||||
|
public boolean bounded() { return maxSize >= 0; }
|
||||||
|
}
|
||||||
Vendored
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.relism.flash.ext.cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hit/miss counters for one cache.
|
||||||
|
*
|
||||||
|
* @param hits lookups that found a value
|
||||||
|
* @param misses lookups that had to load
|
||||||
|
* @param evictions entries dropped to respect {@link CacheSpec#maxSize()}
|
||||||
|
*/
|
||||||
|
public record CacheStats(long hits, long misses, long evictions) {
|
||||||
|
|
||||||
|
/** Returned when {@link CacheSpec#recordStats()} was not set — all zero, and says so. */
|
||||||
|
public static final CacheStats DISABLED = new CacheStats(0, 0, 0);
|
||||||
|
|
||||||
|
/** Hits divided by lookups, or 0 when nothing has been looked up yet. */
|
||||||
|
public double hitRate() {
|
||||||
|
long total = hits + misses;
|
||||||
|
return total == 0 ? 0 : (double) hits / total;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# flash-ext-cache-redis — planned
|
||||||
|
|
||||||
|
Not implemented. This directory holds the design so the decision is written down rather than
|
||||||
|
rediscovered; there is deliberately **no module, no pom and no source**, because an empty module
|
||||||
|
that builds an empty jar is dead weight in the reactor and in everyone's dependency tree.
|
||||||
|
|
||||||
|
Add it when there is a second replica that actually needs shared state.
|
||||||
|
|
||||||
|
## What it would implement
|
||||||
|
|
||||||
|
`CacheManager` and `Cache` from [`flash-ext-cache-core`](../../flash-ext-cache-core/docs/README.md),
|
||||||
|
so switching backend is an install-line change:
|
||||||
|
|
||||||
|
```java
|
||||||
|
.install(new RedisCacheExtension(RedisConfig.of("redis://localhost:6379")))
|
||||||
|
```
|
||||||
|
|
||||||
|
## The part that is not a drop-in
|
||||||
|
|
||||||
|
`flash-ext-cache-caffeine` cannot fail. A networked cache can, and that changes the contract in
|
||||||
|
ways an adapter cannot hide:
|
||||||
|
|
||||||
|
- **`get(key, loader)` can fail before reaching the loader.** The honest default is to fall
|
||||||
|
through to the loader and serve the value uncached, so Redis being down degrades throughput
|
||||||
|
rather than taking the application with it. That has to be a decision, not an accident.
|
||||||
|
- **Values must be serialized.** Caffeine stores references. A `byte[]` codec belongs in the spec,
|
||||||
|
and the natural default is whatever `flash-ext-jackson` is already configured with.
|
||||||
|
- **`invalidateAll()` is not free.** Against a shared keyspace it is either a scan or a key
|
||||||
|
prefix per cache name. The prefix is the right answer, and it means cache names become part of
|
||||||
|
the wire contract.
|
||||||
|
- **Stats are per-client, not per-cache.** Hit rate stays meaningful; eviction count does not,
|
||||||
|
because Redis evicts on its own policy.
|
||||||
|
|
||||||
|
## Why it is not built yet
|
||||||
|
|
||||||
|
Nothing in the codebase has two replicas sharing cache state. Building it now would mean choosing
|
||||||
|
a client library, a serialization format and a failure policy with no real usage to check them
|
||||||
|
against — and the failure policy in particular is the kind of decision that is wrong until a
|
||||||
|
production incident tells you otherwise.
|
||||||
@@ -1,53 +1,93 @@
|
|||||||
# flash-ext-data-core
|
# flash-ext-data-core
|
||||||
|
|
||||||
Core comune per il layer dati di Flash.
|
Shared core for Flash's data layer.
|
||||||
|
|
||||||
## Scopo
|
## Purpose
|
||||||
|
|
||||||
Questo modulo definisce il contratto transazionale condiviso tra le implementazioni backend.
|
This module defines the transactional contract shared across backend implementations. It does not
|
||||||
Non parla con Hibernate o JDBC direttamente: espone solo astrazioni e un runtime minimale.
|
talk to Hibernate or JDBC directly: it exposes abstractions and a minimal runtime, nothing else.
|
||||||
|
|
||||||
## Componenti
|
## Components
|
||||||
|
|
||||||
- `TxDefinition`: metadata immutabile della transazione.
|
- `TxDefinition`: immutable transaction metadata.
|
||||||
- `TxStatus`: stato runtime restituito dal manager.
|
- `TxStatus`: runtime state returned by the manager.
|
||||||
- `TxManager`: contratto per `begin`, `commit`, `rollback`.
|
- `TxManager`: the `begin`/`commit`/`rollback` contract.
|
||||||
- `Tx`: orchestration runtime e stack transazionale per thread.
|
- `Tx`: runtime orchestration and the per-thread transaction stack.
|
||||||
- `ResourceRegistry`: storage thread-local di risorse e synchronizations.
|
- `ResourceRegistry`: thread-local storage for resources and synchronizations.
|
||||||
- `Repository<T, ID>`: base repository auto-transazionale.
|
- `Repository<T, ID>`: self-transactional base repository.
|
||||||
- `Spec<T>`: predicato componibile.
|
- `Spec<T>`: composable predicate.
|
||||||
- `Query<T>`: oggetto query con spec, sort e paging.
|
- `Query<T>`: query object carrying spec, sort and paging.
|
||||||
- `SpecBuilder<T>`: DSL fluente per costruire spec tipizzate.
|
- `SpecBuilder<T>`: fluent DSL for building typed specs.
|
||||||
- `RepositorySupport<T, ID>`: helper interno condiviso.
|
- `RepositorySupport<T, ID>`: shared internal helper.
|
||||||
- `TransactionPropagation`: semantica di propagazione.
|
- `TransactionPropagation`: propagation semantics.
|
||||||
- `TransactionIsolation`: livello di isolamento.
|
- `TransactionIsolation`: isolation level.
|
||||||
- `TxSynchronization`: hook lifecycle.
|
- `TxSynchronization`: lifecycle hooks (see below).
|
||||||
|
|
||||||
## Modello di esecuzione
|
## Execution model
|
||||||
|
|
||||||
Il flusso è:
|
The flow is:
|
||||||
|
|
||||||
1. `Tx.call(definition, work)` chiama `TxManager.begin(definition)`.
|
1. `Tx.call(definition, work)` calls `TxManager.begin(definition)`.
|
||||||
2. Il `TxManager` crea un `TxStatus` backend-specific.
|
2. The `TxManager` creates a backend-specific `TxStatus`.
|
||||||
3. Lo status viene pushato nello stack thread-local.
|
3. The status is pushed onto the thread-local stack.
|
||||||
4. Il lavoro usa `Tx.resource(Class)` per ottenere la risorsa corrente.
|
4. The work uses `Tx.resource(Class)` to obtain the current resource.
|
||||||
5. A fine lavoro `Tx` decide tra `commit` e `rollback`.
|
5. When the work ends, `Tx` chooses between `commit` and `rollback`.
|
||||||
6. Lo stack viene poppato e il thread-local viene pulito se vuoto.
|
6. The stack is popped, and the thread-local is cleared once it is empty.
|
||||||
|
|
||||||
## Propagation supportata
|
## Supported propagation
|
||||||
|
|
||||||
- `REQUIRED`: usa la tx attiva oppure ne apre una nuova.
|
- `REQUIRED`: use the active transaction, or open a new one.
|
||||||
- `REQUIRES_NEW`: sospende la tx corrente e apre una nuova tx.
|
- `REQUIRES_NEW`: suspend the current transaction and open a new one.
|
||||||
- `SUPPORTS`: se esiste una tx attiva si aggancia, altrimenti esegue senza tx.
|
- `SUPPORTS`: join the active transaction if there is one, otherwise run without a transaction.
|
||||||
- `NOT_SUPPORTED`: sospende la tx corrente ed esegue senza tx.
|
- `NOT_SUPPORTED`: suspend the current transaction and run without one.
|
||||||
- `MANDATORY`: richiede una tx attiva.
|
- `MANDATORY`: require an active transaction.
|
||||||
|
|
||||||
## Uso di `Repository`
|
## Synchronizations (`TxSynchronization`)
|
||||||
|
|
||||||
`Repository` è la base comune per le repository concrete.
|
Lifecycle hooks for **one** transaction, registered through `Data.afterCommit(...)` (or directly
|
||||||
Ogni operazione pubblica usa internamente una tx `REQUIRED` o `REQUIRED` read-only.
|
with `ResourceRegistry.addSynchronization(...)`).
|
||||||
|
|
||||||
Le sottoclassi implementano i metodi `doXxx(...)` del nuovo modello:
|
Every callback belongs to exactly the innermost transaction active at registration time, and fires
|
||||||
|
exactly once, when *that* transaction completes:
|
||||||
|
|
||||||
|
- a **joined** inner transaction (`REQUIRED`) is not a transaction of its own, so callbacks
|
||||||
|
registered inside one wait for the outermost commit;
|
||||||
|
- a `REQUIRES_NEW` transaction is, so completing it fires only its own callbacks and leaves the
|
||||||
|
suspended outer transaction's pending.
|
||||||
|
|
||||||
|
### Which side of the commit each hook sits on
|
||||||
|
|
||||||
|
| hook | when | resource |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `beforeCommit(readOnly)` | immediately **before** the real commit | session/connection still **bound**, transaction still active |
|
||||||
|
| `afterCommit()` / `afterRollback()` | after completion | resource already **unbound** |
|
||||||
|
| `afterCompletion(outcome)` | after the two above | resource already unbound |
|
||||||
|
|
||||||
|
`beforeCommit` is the only hook that can still write through the same resource and have the write
|
||||||
|
land in the same atomic unit: flush a buffer, stamp an audit row, materialize a derived value. It
|
||||||
|
is skipped when the transaction is already `rollback-only`, since there is no commit to precede.
|
||||||
|
|
||||||
|
Post-completion callbacks run with the resource unbound instead: one that opens its own transaction
|
||||||
|
gets a **fresh** one rather than joining the transaction that just finished. That is what makes
|
||||||
|
them the right place to refresh a cache, enqueue a message, or notify anything outside the
|
||||||
|
database.
|
||||||
|
|
||||||
|
### Failure
|
||||||
|
|
||||||
|
Throwing from `beforeCommit` **vetoes the commit**: the transaction is rolled back,
|
||||||
|
`afterRollback`/`afterCompletion(ROLLED_BACK)` fire, and the exception reaches the caller. That is
|
||||||
|
the reason the hook runs before the commit rather than after — it can still refuse.
|
||||||
|
|
||||||
|
The post-completion hooks have no such power: the transaction is already over by the time they run,
|
||||||
|
so an exception propagates but changes nothing already committed, and stops the callbacks queued
|
||||||
|
behind it.
|
||||||
|
|
||||||
|
## Using `Repository`
|
||||||
|
|
||||||
|
`Repository` is the shared base for concrete repositories. Every public operation internally uses a
|
||||||
|
`REQUIRED` transaction, read-only where applicable.
|
||||||
|
|
||||||
|
Subclasses implement the `doXxx(...)` methods:
|
||||||
|
|
||||||
- `doFind(Query<T>)`
|
- `doFind(Query<T>)`
|
||||||
- `doFindOne(Spec<T>)`
|
- `doFindOne(Spec<T>)`
|
||||||
@@ -55,7 +95,8 @@ Le sottoclassi implementano i metodi `doXxx(...)` del nuovo modello:
|
|||||||
- `doDeleteAll(Spec<T>)`
|
- `doDeleteAll(Spec<T>)`
|
||||||
- `doUpdateAll(Spec<T>, T)`
|
- `doUpdateAll(Spec<T>, T)`
|
||||||
|
|
||||||
I vecchi overload di `findAll(...)` e `findPage(...)` sono stati ridotti a una combinazione di `Query<T>` e `Spec<T>`.
|
The old `findAll(...)` and `findPage(...)` overloads were reduced to a combination of `Query<T>` and
|
||||||
|
`Spec<T>`.
|
||||||
|
|
||||||
```java
|
```java
|
||||||
public abstract class Repository<T, ID> {
|
public abstract class Repository<T, ID> {
|
||||||
@@ -64,18 +105,23 @@ public abstract class Repository<T, ID> {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Composizione con Flash
|
## Composing with Flash
|
||||||
|
|
||||||
`DataExtension` registra:
|
`DataExtension` registers:
|
||||||
|
|
||||||
- `Tx` nel `FlashContext`
|
- `Tx` in the `FlashContext`
|
||||||
- `TxManager` nel `FlashContext`
|
- `TxManager` in the `FlashContext`
|
||||||
- un annotation processor per `@Transactional`
|
- an annotation processor for `@Transactional`
|
||||||
|
- `TxManager.close()` as an `onClose` callback, so stopping the app releases the manager's
|
||||||
|
session factory and connection pool; give the manager a pool you want closed with the app
|
||||||
|
|
||||||
Questo rende il layer dati componibile con il sistema di extension di Flash senza stato globale.
|
This makes the data layer composable with Flash's extension system without global state.
|
||||||
|
|
||||||
## Note implementative
|
## Implementation notes
|
||||||
|
|
||||||
- Lo stack transazionale è thread-local e viene ripulito quando torna vuoto.
|
- The transaction stack is thread-local and is cleared once it becomes empty.
|
||||||
- Le risorse backend sono sospese e ripristinate per `REQUIRES_NEW` e `NOT_SUPPORTED`.
|
- Backend resources are suspended and restored for `REQUIRES_NEW` and `NOT_SUPPORTED`.
|
||||||
- `TxSynchronization` è il punto di aggancio per hook di commit/rollback/completion.
|
- `TxSynchronization` is the hook point for commit/rollback/completion callbacks.
|
||||||
|
- Synchronizations live in a thread-local list; every new transaction records how many were already
|
||||||
|
registered when it opened and fires only its own tail, so a `REQUIRES_NEW` does not drag along
|
||||||
|
the suspended transaction's callbacks.
|
||||||
|
|||||||
+16
-9
@@ -1,12 +1,15 @@
|
|||||||
package dev.relism.flash.ext.data;
|
package dev.relism.flash.ext.data;
|
||||||
|
|
||||||
import dev.relism.flash.ext.data.core.Tx;
|
import dev.relism.flash.ext.data.core.Tx;
|
||||||
|
import dev.relism.flash.ext.data.core.Data;
|
||||||
import dev.relism.flash.ext.data.core.TxDefinition;
|
import dev.relism.flash.ext.data.core.TxDefinition;
|
||||||
import dev.relism.flash.ext.data.core.TxManager;
|
import dev.relism.flash.ext.data.core.TxManager;
|
||||||
import dev.relism.flash.ext.data.core.TransactionPropagation;
|
import dev.relism.flash.ext.data.core.TransactionPropagation;
|
||||||
import dev.relism.flash.extension.ExtensionPhase;
|
import dev.relism.flash.extension.FlashRegistrar;
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
|
import dev.relism.flash.routing.MiddlewareKey;
|
||||||
|
import dev.relism.flash.routing.MiddlewareNode;
|
||||||
import dev.relism.flash.routing.Middleware;
|
import dev.relism.flash.routing.Middleware;
|
||||||
import jakarta.transaction.Transactional;
|
import jakarta.transaction.Transactional;
|
||||||
|
|
||||||
@@ -14,18 +17,27 @@ import java.util.List;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
public final class DataExtension implements FlashExtension {
|
public final class DataExtension implements FlashExtension {
|
||||||
|
private static final MiddlewareKey TRANSACTION = MiddlewareKey.of("flash.data.transaction");
|
||||||
private final TxManager txManager;
|
private final TxManager txManager;
|
||||||
private final Tx tx;
|
private final Tx tx;
|
||||||
|
private final Data data;
|
||||||
|
|
||||||
public DataExtension(TxManager txManager) {
|
public DataExtension(TxManager txManager) {
|
||||||
|
this(txManager, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DataExtension(TxManager txManager, Data data) {
|
||||||
this.txManager = Objects.requireNonNull(txManager);
|
this.txManager = Objects.requireNonNull(txManager);
|
||||||
this.tx = new Tx(txManager);
|
this.data = data;
|
||||||
|
this.tx = data != null ? data.tx() : new Tx(txManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void provide(FlashContext ctx) {
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
|
ctx.onClose(txManager::close);
|
||||||
ctx.provide(Tx.class, tx);
|
ctx.provide(Tx.class, tx);
|
||||||
ctx.provide(TxManager.class, txManager);
|
ctx.provide(TxManager.class, txManager);
|
||||||
|
if (data != null) ctx.provide(Data.class, data);
|
||||||
ctx.addAnnotationProcessor(handlerClass -> {
|
ctx.addAnnotationProcessor(handlerClass -> {
|
||||||
Transactional ann = handlerClass.getAnnotation(Transactional.class);
|
Transactional ann = handlerClass.getAnnotation(Transactional.class);
|
||||||
if (ann == null) {
|
if (ann == null) {
|
||||||
@@ -36,15 +48,10 @@ public final class DataExtension implements FlashExtension {
|
|||||||
Middleware middleware = next -> (req, res) -> {
|
Middleware middleware = next -> (req, res) -> {
|
||||||
return tx.call(definition, () -> next.handle(req, res));
|
return tx.call(definition, () -> next.handle(req, res));
|
||||||
};
|
};
|
||||||
return List.of(middleware);
|
return List.of(MiddlewareNode.of(TRANSACTION, middleware));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public int priority() {
|
|
||||||
return ExtensionPhase.EARLY.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private TransactionPropagation mapTxType(Transactional.TxType txType) {
|
private TransactionPropagation mapTxType(Transactional.TxType txType) {
|
||||||
return switch (txType) {
|
return switch (txType) {
|
||||||
case REQUIRED -> TransactionPropagation.REQUIRED;
|
case REQUIRED -> TransactionPropagation.REQUIRED;
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
package dev.relism.flash.ext.data.core;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application-facing data gateway. Repositories are created once per entity type and are safe to
|
||||||
|
* share: transaction/session state stays in {@link Tx}, never in the repository instance.
|
||||||
|
*/
|
||||||
|
public final class Data {
|
||||||
|
private final Tx tx;
|
||||||
|
private final RepositoryFactory repositories;
|
||||||
|
private final ConcurrentHashMap<Class<?>, Repository<?, ?>> cache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public Data(Tx tx, RepositoryFactory repositories) {
|
||||||
|
this.tx = Objects.requireNonNull(tx, "tx");
|
||||||
|
this.repositories = Objects.requireNonNull(repositories, "repositories");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Tx tx() { return tx; }
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public <T, ID extends Serializable> Repository<T, ID> repository(Class<T> type) {
|
||||||
|
Objects.requireNonNull(type, "type");
|
||||||
|
return (Repository<T, ID>) cache.computeIfAbsent(type, key -> repositories.create(tx, type));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void read(Tx.TxRunnable work) { tx.run(tx.readOnly(), work); }
|
||||||
|
public <T> T read(Tx.TxCallable<T> work) { return tx.call(tx.readOnly(), work); }
|
||||||
|
public void write(Tx.TxRunnable work) { tx.run(work); }
|
||||||
|
public <T> T write(Tx.TxCallable<T> work) { return tx.call(work); }
|
||||||
|
/** Transitional low-level access for infrastructure that needs an explicit definition. */
|
||||||
|
public void run(Tx.TxRunnable work) { tx.run(work); }
|
||||||
|
public <T> T call(Tx.TxCallable<T> work) { return tx.call(work); }
|
||||||
|
public <T> T call(TxDefinition definition, Tx.TxCallable<T> work) { return tx.call(definition, work); }
|
||||||
|
public TxDefinition readOnly() { return tx.readOnly(); }
|
||||||
|
|
||||||
|
/** Registers work that runs only after the enclosing write transaction commits. */
|
||||||
|
public void afterCommit(Runnable work) {
|
||||||
|
if (!tx.isActive()) throw new IllegalStateException("afterCommit requires an active transaction");
|
||||||
|
ResourceRegistry.addSynchronization(new TxSynchronization() {
|
||||||
|
@Override public void afterCommit() { work.run(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package dev.relism.flash.ext.data.core;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/** Creates the backend-specific, stateless repository for one entity type. */
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface RepositoryFactory {
|
||||||
|
<T, ID extends Serializable> Repository<T, ID> create(Tx tx, Class<T> type);
|
||||||
|
}
|
||||||
+46
-3
@@ -53,9 +53,52 @@ public final class ResourceRegistry {
|
|||||||
SYNCHRONIZATIONS.get().add(Objects.requireNonNull(sync));
|
SYNCHRONIZATIONS.get().add(Objects.requireNonNull(sync));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void fireSynchronizations(TxOutcome outcome) {
|
/**
|
||||||
List<TxSynchronization> syncs = List.copyOf(SYNCHRONIZATIONS.get());
|
* How many synchronizations are registered right now — captured by a transaction manager when
|
||||||
SYNCHRONIZATIONS.get().clear();
|
* it opens a new transaction, and handed back to {@link #fireSynchronizations} on completion
|
||||||
|
* so that transaction only fires its own. See there for why that matters.
|
||||||
|
*/
|
||||||
|
public static int synchronizationCount() {
|
||||||
|
return SYNCHRONIZATIONS.get().size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs {@link TxSynchronization#beforeCommit} on the synchronizations registered from
|
||||||
|
* {@code fromIndex} onward, while their transaction is still active and its resource still
|
||||||
|
* bound. Unlike {@link #fireSynchronizations} this leaves them registered: they still have
|
||||||
|
* their post-completion callbacks to come. Exceptions propagate on purpose — a beforeCommit
|
||||||
|
* that throws vetoes the commit, see {@link TxSynchronization}.
|
||||||
|
*
|
||||||
|
* <p>Snapshots before iterating, so a callback that registers further synchronizations (a
|
||||||
|
* nested {@code Data#afterCommit}) doesn't mutate the list mid-loop. Those new ones join the
|
||||||
|
* transaction's post-completion callbacks without getting a {@code beforeCommit} of their own,
|
||||||
|
* which is the only coherent answer once the pass is already running.
|
||||||
|
*/
|
||||||
|
public static void fireBeforeCommit(boolean readOnly, int fromIndex) {
|
||||||
|
List<TxSynchronization> pending = SYNCHRONIZATIONS.get();
|
||||||
|
if (fromIndex >= pending.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (TxSynchronization sync : List.copyOf(pending.subList(fromIndex, pending.size()))) {
|
||||||
|
sync.beforeCommit(readOnly);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires (and removes) the synchronizations registered from {@code fromIndex} onward — the ones
|
||||||
|
* belonging to the transaction now completing. Everything before that index was registered by
|
||||||
|
* an enclosing transaction that is merely <em>suspended</em>, not finished: a REQUIRES_NEW
|
||||||
|
* inner transaction sets its own baseline, so committing it no longer drags the outer's
|
||||||
|
* pending callbacks along — which fired them early, and with the inner transaction's outcome,
|
||||||
|
* for an outer transaction that might still roll back.
|
||||||
|
*/
|
||||||
|
public static void fireSynchronizations(TxOutcome outcome, int fromIndex) {
|
||||||
|
List<TxSynchronization> pending = SYNCHRONIZATIONS.get();
|
||||||
|
if (fromIndex >= pending.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<TxSynchronization> syncs = List.copyOf(pending.subList(fromIndex, pending.size()));
|
||||||
|
pending.subList(fromIndex, pending.size()).clear();
|
||||||
for (TxSynchronization sync : syncs) {
|
for (TxSynchronization sync : syncs) {
|
||||||
if (outcome == TxOutcome.COMMITTED) {
|
if (outcome == TxOutcome.COMMITTED) {
|
||||||
sync.afterCommit();
|
sync.afterCommit();
|
||||||
|
|||||||
+5
-1
@@ -1,7 +1,11 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
package dev.relism.flash.ext.data.core;
|
||||||
|
|
||||||
public interface TxManager {
|
public interface TxManager extends AutoCloseable {
|
||||||
TxStatus begin(TxDefinition definition);
|
TxStatus begin(TxDefinition definition);
|
||||||
void commit(TxStatus status);
|
void commit(TxStatus status);
|
||||||
void rollback(TxStatus status);
|
void rollback(TxStatus status);
|
||||||
|
|
||||||
|
/** Releases what this manager was built on, its connection pool included. {@link dev.relism.flash.ext.data.DataExtension} calls it when the app stops. */
|
||||||
|
@Override
|
||||||
|
void close();
|
||||||
}
|
}
|
||||||
|
|||||||
+50
@@ -1,8 +1,58 @@
|
|||||||
package dev.relism.flash.ext.data.core;
|
package dev.relism.flash.ext.data.core;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle hooks for one transaction, registered through {@code Data#afterCommit} (or
|
||||||
|
* {@link ResourceRegistry#addSynchronization} directly) and fired by the {@link TxManager} that
|
||||||
|
* owns the transaction they were registered in.
|
||||||
|
*
|
||||||
|
* <p>Every callback belongs to exactly one transaction — the innermost one active at registration
|
||||||
|
* time — and fires exactly once, when <em>that</em> transaction completes. A joined
|
||||||
|
* ({@code REQUIRED}) inner transaction is not a transaction of its own, so callbacks registered
|
||||||
|
* inside one wait for the outermost commit; a {@code REQUIRES_NEW} transaction is, so completing
|
||||||
|
* it fires only its own callbacks and leaves the suspended outer transaction's alone.
|
||||||
|
*
|
||||||
|
* <h3>Where each hook sits relative to the commit</h3>
|
||||||
|
* <ul>
|
||||||
|
* <li>{@link #beforeCommit(boolean)} — immediately <b>before</b> the real commit, with the
|
||||||
|
* transaction still active and its session/connection still bound. This is the only hook
|
||||||
|
* that can still write through that same resource and have the write land in the same atomic
|
||||||
|
* unit: flush a buffer, stamp an audit row, materialize a derived value. Skipped entirely
|
||||||
|
* when the transaction is already rollback-only, since there is no commit to precede.</li>
|
||||||
|
* <li>{@link #afterCommit()} / {@link #afterRollback()}, then {@link #afterCompletion(TxOutcome)}
|
||||||
|
* — <b>after</b> the transaction has completed and its resource has been unbound. Nothing
|
||||||
|
* done here is part of the transaction: a callback that opens its own transaction gets a
|
||||||
|
* fresh one instead of joining the one that just finished, which is what makes this the
|
||||||
|
* right place to refresh a cache, enqueue a message, or notify anything outside the
|
||||||
|
* database.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h3>Failure</h3>
|
||||||
|
* Throwing from {@link #beforeCommit(boolean)} <b>vetoes the commit</b>: the transaction is rolled
|
||||||
|
* back, {@link #afterRollback()}/{@link #afterCompletion(TxOutcome)} fire with
|
||||||
|
* {@link TxOutcome#ROLLED_BACK}, and the exception propagates to the caller. That is the point of
|
||||||
|
* this hook running before the commit rather than after — it can still refuse.
|
||||||
|
*
|
||||||
|
* <p>The post-completion hooks have no such power: the transaction is over by the time they run,
|
||||||
|
* so an exception from one propagates to the caller but changes nothing already committed, and
|
||||||
|
* stops the callbacks queued behind it.
|
||||||
|
*/
|
||||||
public interface TxSynchronization {
|
public interface TxSynchronization {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs inside the transaction, immediately before it commits — see the interface javadoc.
|
||||||
|
* Throwing from here rolls the transaction back instead of committing it.
|
||||||
|
*
|
||||||
|
* @param readOnly whether the transaction was opened read-only, so a callback that would
|
||||||
|
* otherwise write can skip work it is not allowed to do
|
||||||
|
*/
|
||||||
default void beforeCommit(boolean readOnly) {}
|
default void beforeCommit(boolean readOnly) {}
|
||||||
|
|
||||||
|
/** Runs after a successful commit, with the transaction's resource already unbound. */
|
||||||
default void afterCommit() {}
|
default void afterCommit() {}
|
||||||
|
|
||||||
|
/** Runs after a rollback, with the transaction's resource already unbound. */
|
||||||
default void afterRollback() {}
|
default void afterRollback() {}
|
||||||
|
|
||||||
|
/** Runs after {@link #afterCommit()}/{@link #afterRollback()}, whichever applied. */
|
||||||
default void afterCompletion(TxOutcome outcome) {}
|
default void afterCompletion(TxOutcome outcome) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
# flash-ext-data-hibernate
|
# flash-ext-data-hibernate
|
||||||
|
|
||||||
Backend Hibernate per `flash-ext-data-core`.
|
Hibernate backend for `flash-ext-data-core`.
|
||||||
|
|
||||||
## Scopo
|
## Purpose
|
||||||
|
|
||||||
Questo modulo implementa `TxManager` sopra `SessionFactory` e fornisce una base repository Hibernate-centric.
|
This module implements `TxManager` on top of a `SessionFactory` and provides a Hibernate-centric
|
||||||
|
repository base class.
|
||||||
|
|
||||||
## Come si usa
|
## How to use it
|
||||||
|
|
||||||
### 1. Creare il manager
|
### 1. Create the manager
|
||||||
|
|
||||||
```java
|
```java
|
||||||
SessionFactory sessionFactory = ...;
|
SessionFactory sessionFactory = ...;
|
||||||
@@ -16,12 +17,12 @@ HibernateTxManager txManager = new HibernateTxManager(sessionFactory);
|
|||||||
DataExtension extension = new DataExtension(txManager);
|
DataExtension extension = new DataExtension(txManager);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Installare l’estensione in Flash
|
### 2. Install the extension in Flash
|
||||||
|
|
||||||
L’estensione registra `Tx` e `TxManager` nel `FlashContext`.
|
The extension registers `Tx` and `TxManager` in the `FlashContext`. Class-based handlers annotated
|
||||||
Le handler class-based annotate con `@Transactional` vengono wrappate automaticamente.
|
with `@Transactional` are wrapped automatically.
|
||||||
|
|
||||||
### 3. Definire una repository
|
### 3. Define a repository
|
||||||
|
|
||||||
```java
|
```java
|
||||||
public final class UserRepository extends HibernateRepository<User, Long> {
|
public final class UserRepository extends HibernateRepository<User, Long> {
|
||||||
@@ -31,7 +32,7 @@ public final class UserRepository extends HibernateRepository<User, Long> {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Con il nuovo modello query/spec puoi esporre campi riusabili come costanti:
|
With the query/spec model you can expose reusable fields as constants:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
public final class UserRepository extends HibernateRepository<User, Long> {
|
public final class UserRepository extends HibernateRepository<User, Long> {
|
||||||
@@ -48,7 +49,7 @@ public final class UserRepository extends HibernateRepository<User, Long> {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Le query domain-specific possono usare gli helper della base class:
|
Domain-specific queries can use the base class helpers:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
public List<User> findByEmailDomain(String domain) {
|
public List<User> findByEmailDomain(String domain) {
|
||||||
@@ -58,35 +59,37 @@ public List<User> findByEmailDomain(String domain) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Come funziona sotto
|
## How it works underneath
|
||||||
|
|
||||||
- La tx corrente è rappresentata da `HibernateTxStatus`.
|
- The current transaction is represented by `HibernateTxStatus`.
|
||||||
- La risorsa esposta al core è una `Session`.
|
- The resource exposed to the core is a `Session`.
|
||||||
- `Tx.resource(Session.class)` recupera la `Session` dal contesto corrente.
|
- `Tx.resource(Session.class)` retrieves the `Session` from the current context.
|
||||||
- `REQUIRES_NEW` sospende lo status attivo e apre una nuova `Session`.
|
- `REQUIRES_NEW` suspends the active status and opens a new `Session`.
|
||||||
- `NOT_SUPPORTED` sospende la tx attiva e continua senza sessione bindata.
|
- `NOT_SUPPORTED` suspends the active transaction and continues with no session bound.
|
||||||
|
|
||||||
## Repository base
|
## Repository base class
|
||||||
|
|
||||||
`HibernateRepository` fornisce:
|
`HibernateRepository` provides:
|
||||||
|
|
||||||
- `findById`, `findAll`, `findPage`, `findOne`
|
- `findById`, `findAll`, `findPage`, `findOne`
|
||||||
- `save`, `update`, `delete`, `saveAll`
|
- `save`, `update`, `delete`, `saveAll`
|
||||||
- bulk `deleteAll(Spec<T>)` e `updateAll(Spec<T>, T)`
|
- bulk `deleteAll(Spec<T>)` and `updateAll(Spec<T>, T)`
|
||||||
- helper HQL: `hql(...)`, `hqlMutate(...)`
|
- HQL helpers: `hql(...)`, `hqlMutate(...)`
|
||||||
|
|
||||||
Le classi concrete devono solo implementare query di dominio, non il plumbing transazionale.
|
Concrete classes only have to implement domain queries, never the transactional plumbing.
|
||||||
|
|
||||||
## Semantica transazionale
|
## Transactional semantics
|
||||||
|
|
||||||
- `REQUIRED`: join o apertura nuova tx.
|
- `REQUIRED`: join, or open a new transaction.
|
||||||
- `REQUIRES_NEW`: sospensione del contesto corrente.
|
- `REQUIRES_NEW`: suspend the current context.
|
||||||
- `SUPPORTS`: join se c’è tx, altrimenti no-op.
|
- `SUPPORTS`: join if a transaction exists, otherwise no-op.
|
||||||
- `NOT_SUPPORTED`: sospende e prosegue senza tx.
|
- `NOT_SUPPORTED`: suspend and continue without a transaction.
|
||||||
- `MANDATORY`: fallisce se non c’è tx.
|
- `MANDATORY`: fail if there is no transaction.
|
||||||
|
|
||||||
## Note
|
## Notes
|
||||||
|
|
||||||
- `Session` viene chiusa a fine tx nuova.
|
- The `Session` is closed when a new transaction ends.
|
||||||
- Le synchronizations vengono eseguite al commit/rollback.
|
- Synchronizations registered in a transaction fire when *that* transaction completes:
|
||||||
- Il backend è pensato per essere usato tramite la base class, non direttamente.
|
`beforeCommit` while it is still active and the `Session` still bound, the post-completion hooks
|
||||||
|
once it is unbound. See `flash-ext-data-core/docs/README.md` for the full contract.
|
||||||
|
- This backend is meant to be used through the base class, not directly.
|
||||||
|
|||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package dev.relism.flash.ext.data.hibernate;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.data.core.Data;
|
||||||
|
import dev.relism.flash.ext.data.core.Repository;
|
||||||
|
import dev.relism.flash.ext.data.core.RepositoryFactory;
|
||||||
|
import dev.relism.flash.ext.data.core.Tx;
|
||||||
|
import dev.relism.flash.ext.data.core.TxManager;
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/** Hibernate-backed {@link Data} factory. */
|
||||||
|
public final class HibernateData {
|
||||||
|
private HibernateData() {}
|
||||||
|
|
||||||
|
private static final RepositoryFactory REPOSITORIES = new RepositoryFactory() {
|
||||||
|
@Override
|
||||||
|
public <T, ID extends Serializable> Repository<T, ID> create(Tx tx, Class<T> type) {
|
||||||
|
return new HibernateRepository<T, ID>(tx, type) {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public static Data create(TxManager manager) {
|
||||||
|
Tx tx = new Tx(manager);
|
||||||
|
return new Data(tx, REPOSITORIES);
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
-6
@@ -3,6 +3,10 @@ package dev.relism.flash.ext.data.hibernate;
|
|||||||
import dev.relism.flash.ext.data.core.*;
|
import dev.relism.flash.ext.data.core.*;
|
||||||
import org.hibernate.Session;
|
import org.hibernate.Session;
|
||||||
import org.hibernate.SessionFactory;
|
import org.hibernate.SessionFactory;
|
||||||
|
import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider;
|
||||||
|
import org.hibernate.engine.spi.SessionFactoryImplementor;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
@@ -16,6 +20,24 @@ public class HibernateTxManager implements TxManager {
|
|||||||
this.sf = Objects.requireNonNull(sessionFactory);
|
this.sf = Objects.requireNonNull(sessionFactory);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the session factory, then the data source it was given ({@code jakarta.persistence.nonJtaDataSource}
|
||||||
|
* or {@code hibernate.connection.datasource}): Hibernate stops a pool it built itself, never one handed to it.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
ConnectionProvider connections = sf.unwrap(SessionFactoryImplementor.class).getServiceRegistry().getService(ConnectionProvider.class);
|
||||||
|
DataSource ds = connections != null && connections.isUnwrappableAs(DataSource.class) ? connections.unwrap(DataSource.class) : null;
|
||||||
|
sf.close();
|
||||||
|
if (ds instanceof AutoCloseable closeable) {
|
||||||
|
try {
|
||||||
|
closeable.close();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to close the data source", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TxStatus begin(TxDefinition definition) {
|
public TxStatus begin(TxDefinition definition) {
|
||||||
return switch (definition.propagation()) {
|
return switch (definition.propagation()) {
|
||||||
@@ -43,6 +65,9 @@ public class HibernateTxManager implements TxManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private TxStatus beginNew(TxDefinition definition, HibernateTxStatus suspended) {
|
private TxStatus beginNew(TxDefinition definition, HibernateTxStatus suspended) {
|
||||||
|
// Anything already registered belongs to an enclosing transaction this one is nested
|
||||||
|
// inside (or suspended over) — see ResourceRegistry#fireSynchronizations.
|
||||||
|
int synchronizationBaseline = ResourceRegistry.synchronizationCount();
|
||||||
Session s = sf.openSession();
|
Session s = sf.openSession();
|
||||||
boolean bound = false;
|
boolean bound = false;
|
||||||
try {
|
try {
|
||||||
@@ -56,7 +81,8 @@ public class HibernateTxManager implements TxManager {
|
|||||||
true,
|
true,
|
||||||
definition.readOnly(),
|
definition.readOnly(),
|
||||||
suspended,
|
suspended,
|
||||||
new HibernateTxStatus.RollbackMarker()
|
new HibernateTxStatus.RollbackMarker(),
|
||||||
|
synchronizationBaseline
|
||||||
);
|
);
|
||||||
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, status);
|
ResourceRegistry.bind(HIBERNATE_STATUS_KEY, status);
|
||||||
bound = true;
|
bound = true;
|
||||||
@@ -80,12 +106,15 @@ public class HibernateTxManager implements TxManager {
|
|||||||
if (definition.readOnly() && !existing.isReadOnly()) {
|
if (definition.readOnly() && !existing.isReadOnly()) {
|
||||||
throw new TxException("Cannot join read-write tx as read-only");
|
throw new TxException("Cannot join read-write tx as read-only");
|
||||||
}
|
}
|
||||||
|
// Baseline 0 is never read: a joined status isn't a new transaction, so commit()/rollback()
|
||||||
|
// hand it straight back to the transaction it joined without firing anything.
|
||||||
return new HibernateTxStatus(
|
return new HibernateTxStatus(
|
||||||
existing.session(),
|
existing.session(),
|
||||||
false,
|
false,
|
||||||
definition.readOnly(),
|
definition.readOnly(),
|
||||||
null,
|
null,
|
||||||
existing.rollbackMarker()
|
existing.rollbackMarker(),
|
||||||
|
0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +123,7 @@ public class HibernateTxManager implements TxManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private TxStatus noOp(TxDefinition definition, HibernateTxStatus suspended) {
|
private TxStatus noOp(TxDefinition definition, HibernateTxStatus suspended) {
|
||||||
return new HibernateTxStatus(null, false, definition.readOnly(), suspended, new HibernateTxStatus.RollbackMarker());
|
return new HibernateTxStatus(null, false, definition.readOnly(), suspended, new HibernateTxStatus.RollbackMarker(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private HibernateTxStatus suspendIfNeeded() {
|
private HibernateTxStatus suspendIfNeeded() {
|
||||||
@@ -114,16 +143,43 @@ public class HibernateTxManager implements TxManager {
|
|||||||
cleanupIfIdle();
|
cleanupIfIdle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
TxOutcome outcome = null;
|
||||||
try {
|
try {
|
||||||
if (s.isRollbackOnly() && s.session().getTransaction().isActive()) {
|
if (s.isRollbackOnly() && s.session().getTransaction().isActive()) {
|
||||||
s.session().getTransaction().rollback();
|
s.session().getTransaction().rollback();
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
|
outcome = TxOutcome.ROLLED_BACK;
|
||||||
} else {
|
} else {
|
||||||
|
// Still inside the transaction, session still bound: a beforeCommit callback can
|
||||||
|
// write through it and land in this same commit. Throwing from there vetoes the
|
||||||
|
// commit — see TxSynchronization.
|
||||||
|
ResourceRegistry.fireBeforeCommit(s.isReadOnly(), s.synchronizationBaseline());
|
||||||
s.session().getTransaction().commit();
|
s.session().getTransaction().commit();
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.COMMITTED);
|
outcome = TxOutcome.COMMITTED;
|
||||||
}
|
}
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
// A vetoing beforeCommit, or a commit that failed outright: either way nothing was
|
||||||
|
// committed, so roll back and let the remaining callbacks hear ROLLED_BACK rather than
|
||||||
|
// nothing at all. A rollback failure here is swallowed deliberately — it would mask
|
||||||
|
// the exception that actually explains what went wrong, which is the one propagating.
|
||||||
|
if (s.session().getTransaction().isActive()) {
|
||||||
|
try {
|
||||||
|
s.session().getTransaction().rollback();
|
||||||
|
} catch (RuntimeException suppressed) {
|
||||||
|
e.addSuppressed(suppressed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outcome = TxOutcome.ROLLED_BACK;
|
||||||
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
|
// Order is load-bearing, and getting it wrong is silent: cleanupIfIdle() calls
|
||||||
|
// ResourceRegistry.cleanup(), which removes the very ThreadLocal list of
|
||||||
|
// synchronizations still waiting to be fired — firing afterwards saw a freshly
|
||||||
|
// initialized empty list and dropped every callback on the floor. cleanupAndResume()
|
||||||
|
// still has to come first, so a synchronization that opens its own transaction
|
||||||
|
// (Registry#reload() in Pathway does) starts a fresh one instead of joining the
|
||||||
|
// session that just committed. rollback() below already had this order right.
|
||||||
cleanupAndResume(s);
|
cleanupAndResume(s);
|
||||||
|
if (outcome != null) ResourceRegistry.fireSynchronizations(outcome, s.synchronizationBaseline());
|
||||||
cleanupIfIdle();
|
cleanupIfIdle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,9 +197,11 @@ public class HibernateTxManager implements TxManager {
|
|||||||
if (s.session().getTransaction().isActive()) {
|
if (s.session().getTransaction().isActive()) {
|
||||||
s.session().getTransaction().rollback();
|
s.session().getTransaction().rollback();
|
||||||
}
|
}
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
|
|
||||||
} finally {
|
} finally {
|
||||||
|
// Same order as commit(): unbind the session first so a callback opening its own
|
||||||
|
// transaction gets a fresh one, fire before cleanupIfIdle() can drop the list.
|
||||||
cleanupAndResume(s);
|
cleanupAndResume(s);
|
||||||
|
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK, s.synchronizationBaseline());
|
||||||
cleanupIfIdle();
|
cleanupIfIdle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -13,19 +13,22 @@ class HibernateTxStatus implements TxStatus {
|
|||||||
private final boolean readOnly;
|
private final boolean readOnly;
|
||||||
private final HibernateTxStatus suspended;
|
private final HibernateTxStatus suspended;
|
||||||
private final RollbackMarker rollbackMarker;
|
private final RollbackMarker rollbackMarker;
|
||||||
|
private final int synchronizationBaseline;
|
||||||
|
|
||||||
HibernateTxStatus(
|
HibernateTxStatus(
|
||||||
Session session,
|
Session session,
|
||||||
boolean newTransaction,
|
boolean newTransaction,
|
||||||
boolean readOnly,
|
boolean readOnly,
|
||||||
HibernateTxStatus suspended,
|
HibernateTxStatus suspended,
|
||||||
RollbackMarker rollbackMarker
|
RollbackMarker rollbackMarker,
|
||||||
|
int synchronizationBaseline
|
||||||
) {
|
) {
|
||||||
this.session = session;
|
this.session = session;
|
||||||
this.newTransaction = newTransaction;
|
this.newTransaction = newTransaction;
|
||||||
this.readOnly = readOnly;
|
this.readOnly = readOnly;
|
||||||
this.suspended = suspended;
|
this.suspended = suspended;
|
||||||
this.rollbackMarker = rollbackMarker;
|
this.rollbackMarker = rollbackMarker;
|
||||||
|
this.synchronizationBaseline = synchronizationBaseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public boolean isNewTransaction() { return newTransaction; }
|
@Override public boolean isNewTransaction() { return newTransaction; }
|
||||||
@@ -44,4 +47,7 @@ class HibernateTxStatus implements TxStatus {
|
|||||||
Session session() { return session; }
|
Session session() { return session; }
|
||||||
HibernateTxStatus suspended() { return suspended; }
|
HibernateTxStatus suspended() { return suspended; }
|
||||||
RollbackMarker rollbackMarker() { return rollbackMarker; }
|
RollbackMarker rollbackMarker() { return rollbackMarker; }
|
||||||
|
|
||||||
|
/** Index into {@code ResourceRegistry}'s synchronization list where this transaction's own callbacks start. */
|
||||||
|
int synchronizationBaseline() { return synchronizationBaseline; }
|
||||||
}
|
}
|
||||||
|
|||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package dev.relism.flash.ext.data.hibernate;
|
||||||
|
|
||||||
|
import org.hibernate.SessionFactory;
|
||||||
|
import org.hibernate.boot.MetadataSources;
|
||||||
|
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
||||||
|
import org.hibernate.cfg.AvailableSettings;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class HibernateTxManagerCloseTest {
|
||||||
|
|
||||||
|
/** Stands in for a pool: closeable, and it records being closed. */
|
||||||
|
static final class Pool implements DataSource, AutoCloseable {
|
||||||
|
boolean closed;
|
||||||
|
@Override public Connection getConnection() throws SQLException { return DriverManager.getConnection("jdbc:h2:mem:tx-close;DB_CLOSE_DELAY=-1"); }
|
||||||
|
@Override public Connection getConnection(String user, String password) throws SQLException { return getConnection(); }
|
||||||
|
@Override public void close() { closed = true; }
|
||||||
|
@Override public <T> T unwrap(Class<T> type) { throw new UnsupportedOperationException(); }
|
||||||
|
@Override public boolean isWrapperFor(Class<?> type) { return false; }
|
||||||
|
@Override public PrintWriter getLogWriter() { return null; }
|
||||||
|
@Override public void setLogWriter(PrintWriter out) {}
|
||||||
|
@Override public void setLoginTimeout(int seconds) {}
|
||||||
|
@Override public int getLoginTimeout() { return 0; }
|
||||||
|
@Override public Logger getParentLogger() { throw new UnsupportedOperationException(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void closingTheManagerClosesTheDataSourceHibernateWasGiven() {
|
||||||
|
Pool pool = new Pool();
|
||||||
|
SessionFactory sf = new MetadataSources(new StandardServiceRegistryBuilder()
|
||||||
|
.applySetting(AvailableSettings.JAKARTA_NON_JTA_DATASOURCE, pool)
|
||||||
|
.applySetting(AvailableSettings.DIALECT, "org.hibernate.dialect.H2Dialect")
|
||||||
|
.build()).buildMetadata().buildSessionFactory();
|
||||||
|
|
||||||
|
new HibernateTxManager(sf).close();
|
||||||
|
|
||||||
|
assertTrue(sf.isClosed());
|
||||||
|
assertTrue(pool.closed);
|
||||||
|
}
|
||||||
|
}
|
||||||
+258
@@ -0,0 +1,258 @@
|
|||||||
|
package dev.relism.flash.ext.data.hibernate;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.data.core.*;
|
||||||
|
import org.hibernate.Session;
|
||||||
|
import org.hibernate.SessionFactory;
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction synchronization semantics — the {@code beforeCommit}/{@code afterCommit}/
|
||||||
|
* {@code afterRollback} callbacks {@code Data#afterCommit} exposes, and the contract callers build
|
||||||
|
* on: "my callback runs once, for my transaction, on the right side of the commit".
|
||||||
|
*
|
||||||
|
* <p>None of this was covered before, and the gap was not academic: {@link #afterCommit_fires_on_commit}
|
||||||
|
* failed against the original {@code commit()}, which fired synchronizations only after
|
||||||
|
* {@code cleanupIfIdle()} had already dropped the ThreadLocal list holding them — so every callback
|
||||||
|
* was silently discarded, on every commit, with no error and no log. Downstream that meant an admin
|
||||||
|
* write landing in Postgres while the in-memory cache it was supposed to refresh never heard about
|
||||||
|
* it until the process restarted.
|
||||||
|
*/
|
||||||
|
class HibernateTxManagerSynchronizationTest {
|
||||||
|
|
||||||
|
static SessionFactory sf;
|
||||||
|
static HibernateTxManager manager;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void setup() {
|
||||||
|
sf = TestHelper.buildSessionFactory();
|
||||||
|
manager = new HibernateTxManager(sf);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void teardown() {
|
||||||
|
if (sf != null) {
|
||||||
|
sf.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanup() {
|
||||||
|
ResourceRegistry.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records which callbacks ran, in order — order is part of the contract, not just the fact they ran. */
|
||||||
|
private static final class Recorder implements TxSynchronization {
|
||||||
|
final List<String> calls = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override public void beforeCommit(boolean readOnly) { calls.add("beforeCommit:" + readOnly); }
|
||||||
|
@Override public void afterCommit() { calls.add("afterCommit"); }
|
||||||
|
@Override public void afterRollback() { calls.add("afterRollback"); }
|
||||||
|
@Override public void afterCompletion(TxOutcome outcome) { calls.add("afterCompletion:" + outcome); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final List<String> COMMITTED = List.of("beforeCommit:false", "afterCommit", "afterCompletion:COMMITTED");
|
||||||
|
private static final List<String> ROLLED_BACK = List.of("afterRollback", "afterCompletion:ROLLED_BACK");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void afterCommit_fires_on_commit() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
manager.commit(tx);
|
||||||
|
|
||||||
|
assertEquals(COMMITTED, recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void afterRollback_fires_on_rollback() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
manager.rollback(tx);
|
||||||
|
|
||||||
|
assertEquals(ROLLED_BACK, recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A commit() call on a tx already marked rollback-only really rolls back — and has no commit to precede. */
|
||||||
|
@Test
|
||||||
|
void commit_of_a_rollback_only_tx_rolls_back_without_running_beforeCommit() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
tx.markRollbackOnly();
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
manager.commit(tx);
|
||||||
|
|
||||||
|
assertEquals(ROLLED_BACK, recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** beforeCommit runs inside the transaction: session still bound, transaction still active. */
|
||||||
|
@Test
|
||||||
|
void beforeCommit_runs_while_the_transaction_is_still_active() {
|
||||||
|
List<Boolean> stillActive = new ArrayList<>();
|
||||||
|
List<Session> sessionSeen = new ArrayList<>();
|
||||||
|
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
Session session = tx.resource(Session.class);
|
||||||
|
ResourceRegistry.addSynchronization(new TxSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void beforeCommit(boolean readOnly) {
|
||||||
|
stillActive.add(session.getTransaction().isActive());
|
||||||
|
// MANDATORY only succeeds while a transaction is bound — proof this runs inside it.
|
||||||
|
TxStatus joined = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.MANDATORY));
|
||||||
|
sessionSeen.add(joined.resource(Session.class));
|
||||||
|
manager.commit(joined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.commit(tx);
|
||||||
|
|
||||||
|
assertEquals(List.of(true), stillActive, "the transaction must not have committed yet");
|
||||||
|
assertSame(session, sessionSeen.get(0), "the same session must still be bound, so writes land in this commit");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void beforeCommit_is_told_whether_the_transaction_is_read_only() {
|
||||||
|
Recorder readWrite = new Recorder();
|
||||||
|
TxStatus rw = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(readWrite);
|
||||||
|
manager.commit(rw);
|
||||||
|
|
||||||
|
Recorder readOnly = new Recorder();
|
||||||
|
TxStatus ro = manager.begin(TxDefinition.DEFAULTS.asReadOnly());
|
||||||
|
ResourceRegistry.addSynchronization(readOnly);
|
||||||
|
manager.commit(ro);
|
||||||
|
|
||||||
|
assertEquals("beforeCommit:false", readWrite.calls.get(0));
|
||||||
|
assertEquals("beforeCommit:true", readOnly.calls.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throwing from beforeCommit is a veto: no commit, the rollback callbacks run, the exception propagates. */
|
||||||
|
@Test
|
||||||
|
void a_throwing_beforeCommit_vetoes_the_commit() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
Session session = tx.resource(Session.class);
|
||||||
|
ResourceRegistry.addSynchronization(new TxSynchronization() {
|
||||||
|
@Override public void beforeCommit(boolean readOnly) { throw new IllegalStateException("veto"); }
|
||||||
|
});
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> manager.commit(tx));
|
||||||
|
|
||||||
|
assertEquals("veto", thrown.getMessage());
|
||||||
|
assertFalse(session.getTransaction().isActive(), "the vetoed transaction must be rolled back, not left open");
|
||||||
|
assertEquals(ROLLED_BACK, recorder.calls, "the surviving callbacks must hear ROLLED_BACK, not silence");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The load-bearing ordering detail: post-completion callbacks run <em>after</em> the committed
|
||||||
|
* session is unbound, so a callback that opens its own transaction (a cache reload, an outbox
|
||||||
|
* drain) gets a fresh one instead of silently joining the transaction that just committed.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void a_synchronization_may_open_its_own_transaction() {
|
||||||
|
List<Session> sessionsSeen = new ArrayList<>();
|
||||||
|
List<Boolean> wasNewTransaction = new ArrayList<>();
|
||||||
|
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
Session committedSession = outer.resource(Session.class);
|
||||||
|
ResourceRegistry.addSynchronization(new TxSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
TxStatus own = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
sessionsSeen.add(own.resource(Session.class));
|
||||||
|
wasNewTransaction.add(own.isNewTransaction());
|
||||||
|
manager.commit(own);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.commit(outer);
|
||||||
|
|
||||||
|
assertEquals(1, sessionsSeen.size(), "the callback must have run");
|
||||||
|
assertEquals(List.of(true), wasNewTransaction, "must start its own transaction, not join the committed one");
|
||||||
|
assertNotSame(committedSession, sessionsSeen.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A joined (REQUIRED) inner commit is not a real commit — callbacks wait for the outermost one. */
|
||||||
|
@Test
|
||||||
|
void a_joined_commit_defers_synchronizations_to_the_outermost_commit() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
manager.commit(inner);
|
||||||
|
assertEquals(List.of(), recorder.calls, "the joined commit did not commit anything yet");
|
||||||
|
|
||||||
|
manager.commit(outer);
|
||||||
|
assertEquals(COMMITTED, recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each callback belongs to one transaction: a second transaction must not re-run the first's. */
|
||||||
|
@Test
|
||||||
|
void synchronizations_do_not_leak_into_the_next_transaction() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus first = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
manager.commit(first);
|
||||||
|
recorder.calls.clear();
|
||||||
|
|
||||||
|
TxStatus second = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
manager.commit(second);
|
||||||
|
|
||||||
|
assertEquals(List.of(), recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A REQUIRES_NEW inner transaction suspends the outer one; committing the inner must not drag
|
||||||
|
* the still-pending outer transaction's callbacks along with it. They belong to a transaction
|
||||||
|
* that has not committed — and may yet roll back, in which case firing {@code afterCommit} for
|
||||||
|
* it would be a straight lie.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void a_requires_new_commit_leaves_the_suspended_transactions_synchronizations_alone() {
|
||||||
|
Recorder outerSync = new Recorder();
|
||||||
|
Recorder innerSync = new Recorder();
|
||||||
|
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(outerSync);
|
||||||
|
|
||||||
|
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
|
||||||
|
ResourceRegistry.addSynchronization(innerSync);
|
||||||
|
manager.commit(inner);
|
||||||
|
|
||||||
|
assertEquals(COMMITTED, innerSync.calls);
|
||||||
|
assertEquals(List.of(), outerSync.calls, "the outer transaction has not committed yet");
|
||||||
|
|
||||||
|
manager.commit(outer);
|
||||||
|
assertEquals(COMMITTED, outerSync.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A rolled-back inner REQUIRES_NEW must not fire the outer's callbacks either — same reason, opposite outcome. */
|
||||||
|
@Test
|
||||||
|
void a_requires_new_rollback_leaves_the_suspended_transactions_synchronizations_alone() {
|
||||||
|
Recorder outerSync = new Recorder();
|
||||||
|
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(outerSync);
|
||||||
|
|
||||||
|
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
|
||||||
|
manager.rollback(inner);
|
||||||
|
|
||||||
|
assertEquals(List.of(), outerSync.calls, "the outer transaction is still open");
|
||||||
|
|
||||||
|
manager.commit(outer);
|
||||||
|
assertEquals(COMMITTED, outerSync.calls);
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -65,4 +65,44 @@ class HibernateTxManagerTest {
|
|||||||
assertTrue(outer.isRollbackOnly());
|
assertTrue(outer.isRollbackOnly());
|
||||||
manager.rollback(outer);
|
manager.rollback(outer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SUPPORTS without an active transaction yields a sessionless status: not a transaction, no session to hand out. */
|
||||||
|
@Test
|
||||||
|
void supports_without_active_transaction_is_a_sessionless_no_op() {
|
||||||
|
TxStatus s = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.SUPPORTS));
|
||||||
|
|
||||||
|
assertFalse(s.isNewTransaction());
|
||||||
|
assertThrows(IllegalStateException.class, () -> s.resource(Session.class));
|
||||||
|
assertDoesNotThrow(() -> manager.commit(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void not_supported_suspends_the_active_transaction_and_restores_it_on_commit() {
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
Session outerSession = outer.resource(Session.class);
|
||||||
|
|
||||||
|
TxStatus suspended = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.NOT_SUPPORTED));
|
||||||
|
assertFalse(suspended.isNewTransaction());
|
||||||
|
assertThrows(IllegalStateException.class, () -> suspended.resource(Session.class));
|
||||||
|
manager.commit(suspended);
|
||||||
|
|
||||||
|
TxStatus rejoined = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
|
||||||
|
assertSame(outerSession, rejoined.resource(Session.class), "the suspended transaction must be back");
|
||||||
|
manager.rollback(outer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mandatory_without_active_transaction_is_rejected() {
|
||||||
|
assertThrows(IllegalStateException.class,
|
||||||
|
() -> manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.MANDATORY)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A read-only join onto a read-write transaction is a contract violation, not a silent downgrade. */
|
||||||
|
@Test
|
||||||
|
void read_only_cannot_join_a_read_write_transaction() {
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
assertThrows(TxException.class,
|
||||||
|
() -> manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED).asReadOnly()));
|
||||||
|
manager.rollback(outer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
# flash-ext-data-jdbc
|
# flash-ext-data-jdbc
|
||||||
|
|
||||||
Backend JDBC per `flash-ext-data-core`.
|
JDBC backend for `flash-ext-data-core`.
|
||||||
|
|
||||||
## Scopo
|
## Purpose
|
||||||
|
|
||||||
Questo modulo implementa `TxManager` sopra `DataSource` e fornisce una base repository SQL raw.
|
This module implements `TxManager` on top of a `DataSource` and provides a raw-SQL repository base
|
||||||
|
class.
|
||||||
|
|
||||||
## Come si usa
|
## How to use it
|
||||||
|
|
||||||
### 1. Creare il manager
|
### 1. Create the manager
|
||||||
|
|
||||||
```java
|
```java
|
||||||
DataSource dataSource = ...;
|
DataSource dataSource = ...;
|
||||||
@@ -16,11 +17,12 @@ JdbcTxManager txManager = new JdbcTxManager(dataSource);
|
|||||||
DataExtension extension = new DataExtension(txManager);
|
DataExtension extension = new DataExtension(txManager);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Installare l’estensione in Flash
|
### 2. Install the extension in Flash
|
||||||
|
|
||||||
Come per Hibernate, `DataExtension` registra `Tx` nel `FlashContext` e abilita `@Transactional` sugli handler class-based.
|
As with Hibernate, `DataExtension` registers `Tx` in the `FlashContext` and enables
|
||||||
|
`@Transactional` on class-based handlers.
|
||||||
|
|
||||||
### 3. Definire una repository
|
### 3. Define a repository
|
||||||
|
|
||||||
```java
|
```java
|
||||||
public final class UserRepository extends JdbcRepository<User, Long> {
|
public final class UserRepository extends JdbcRepository<User, Long> {
|
||||||
@@ -35,7 +37,7 @@ public final class UserRepository extends JdbcRepository<User, Long> {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Anche qui puoi esporre `Spec` riusabili e comporre query dal service layer:
|
Here too you can expose reusable `Spec`s and compose queries from the service layer:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
public final class UserRepository extends JdbcRepository<User, Long> {
|
public final class UserRepository extends JdbcRepository<User, Long> {
|
||||||
@@ -47,7 +49,7 @@ public final class UserRepository extends JdbcRepository<User, Long> {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Per il salvataggio e l’update devi fornire il binding esplicito:
|
Saving and updating need an explicit binding:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
@Override
|
@Override
|
||||||
@@ -61,36 +63,39 @@ protected void bindInsert(PreparedStatement ps, User entity) throws SQLException
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Come funziona sotto
|
## How it works underneath
|
||||||
|
|
||||||
- La tx corrente espone una `Connection`.
|
- The current transaction exposes a `Connection`.
|
||||||
- `Tx.resource(Connection.class)` recupera la connessione bindata al thread.
|
- `Tx.resource(Connection.class)` retrieves the connection bound to the thread.
|
||||||
- `REQUIRES_NEW` sospende la connessione attiva e ne apre una nuova.
|
- `REQUIRES_NEW` suspends the active connection and opens a new one.
|
||||||
- `NOT_SUPPORTED` sospende il contesto e prosegue senza tx.
|
- `NOT_SUPPORTED` suspends the context and continues without a transaction.
|
||||||
|
|
||||||
## Repository base
|
## Repository base class
|
||||||
|
|
||||||
`JdbcRepository` fornisce:
|
`JdbcRepository` provides:
|
||||||
|
|
||||||
- query `select` con `queryOne`, `queryMany`
|
- `select` queries through `queryOne`, `queryMany`
|
||||||
- mutation con `mutate`
|
- mutations through `mutate`
|
||||||
- persistenza con `doSave`, `doUpdate`
|
- persistence through `doSave`, `doUpdate`
|
||||||
- paging con `doFindPage`
|
- paging through `doFindPage`
|
||||||
- bulk `deleteAll(Spec<T>)`
|
- bulk `deleteAll(Spec<T>)`
|
||||||
- helper raw `queryOne(...)`, `queryMany(...)`, `mutate(...)`
|
- raw helpers `queryOne(...)`, `queryMany(...)`, `mutate(...)`
|
||||||
|
|
||||||
Le repository concrete devono solo tradurre tra `ResultSet` e dominio.
|
Concrete repositories only have to translate between `ResultSet` and the domain.
|
||||||
|
|
||||||
## Semantica transazionale
|
## Transactional semantics
|
||||||
|
|
||||||
- `REQUIRED`: join o apertura nuova tx.
|
- `REQUIRED`: join, or open a new transaction.
|
||||||
- `REQUIRES_NEW`: sospensione del contesto corrente.
|
- `REQUIRES_NEW`: suspend the current context.
|
||||||
- `SUPPORTS`: join se c’è tx, altrimenti no-op.
|
- `SUPPORTS`: join if a transaction exists, otherwise no-op.
|
||||||
- `NOT_SUPPORTED`: sospende e prosegue senza tx.
|
- `NOT_SUPPORTED`: suspend and continue without a transaction.
|
||||||
- `MANDATORY`: fallisce se non c’è tx.
|
- `MANDATORY`: fail if there is no transaction.
|
||||||
|
|
||||||
## Note
|
## Notes
|
||||||
|
|
||||||
- La `Connection` viene chiusa a fine tx nuova.
|
- The `Connection` is closed when a new transaction ends.
|
||||||
- Le synchronizations vengono eseguite al commit/rollback.
|
- Synchronizations registered in a transaction fire when *that* transaction completes:
|
||||||
- Se una repository usa `doDelete(T)`, il comportamento predefinito è non supportato: usare `deleteById` o override specifico.
|
`beforeCommit` while it is still active and the `Connection` still bound, the post-completion
|
||||||
|
hooks once it is unbound. See `flash-ext-data-core/docs/README.md` for the full contract.
|
||||||
|
- If a repository uses `doDelete(T)`, the default behaviour is unsupported: use `deleteById` or
|
||||||
|
override it.
|
||||||
|
|||||||
+58
-9
@@ -17,6 +17,18 @@ public class JdbcTxManager implements TxManager {
|
|||||||
this.ds = Objects.requireNonNull(ds);
|
this.ds = Objects.requireNonNull(ds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Closes the data source when it is closeable, as a pool is; a plain {@code DataSource} holds nothing to release. */
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
if (ds instanceof AutoCloseable closeable) {
|
||||||
|
try {
|
||||||
|
closeable.close();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to close the data source", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TxStatus begin(TxDefinition definition) {
|
public TxStatus begin(TxDefinition definition) {
|
||||||
return switch (definition.propagation()) {
|
return switch (definition.propagation()) {
|
||||||
@@ -41,6 +53,9 @@ public class JdbcTxManager implements TxManager {
|
|||||||
|
|
||||||
private TxStatus beginNew(TxDefinition definition) {
|
private TxStatus beginNew(TxDefinition definition) {
|
||||||
Connection conn = null;
|
Connection conn = null;
|
||||||
|
// Anything already registered belongs to an enclosing transaction this one is nested
|
||||||
|
// inside (or suspended over) — see ResourceRegistry#fireSynchronizations.
|
||||||
|
int synchronizationBaseline = ResourceRegistry.synchronizationCount();
|
||||||
JdbcTxStatus suspended = suspendIfNeeded();
|
JdbcTxStatus suspended = suspendIfNeeded();
|
||||||
boolean bound = false;
|
boolean bound = false;
|
||||||
try {
|
try {
|
||||||
@@ -55,7 +70,8 @@ public class JdbcTxManager implements TxManager {
|
|||||||
true,
|
true,
|
||||||
definition.readOnly(),
|
definition.readOnly(),
|
||||||
suspended,
|
suspended,
|
||||||
new JdbcTxStatus.RollbackMarker()
|
new JdbcTxStatus.RollbackMarker(),
|
||||||
|
synchronizationBaseline
|
||||||
);
|
);
|
||||||
ResourceRegistry.bind(JDBC_STATUS_KEY, status);
|
ResourceRegistry.bind(JDBC_STATUS_KEY, status);
|
||||||
bound = true;
|
bound = true;
|
||||||
@@ -76,12 +92,15 @@ public class JdbcTxManager implements TxManager {
|
|||||||
if (definition.readOnly() && !existing.isReadOnly()) {
|
if (definition.readOnly() && !existing.isReadOnly()) {
|
||||||
throw new TxException("Cannot join read-write tx as read-only");
|
throw new TxException("Cannot join read-write tx as read-only");
|
||||||
}
|
}
|
||||||
|
// Baseline 0 is never read: a joined status isn't a new transaction, so commit()/rollback()
|
||||||
|
// hand it straight back to the transaction it joined without firing anything.
|
||||||
return new JdbcTxStatus(
|
return new JdbcTxStatus(
|
||||||
existing.connection(),
|
existing.connection(),
|
||||||
false,
|
false,
|
||||||
definition.readOnly(),
|
definition.readOnly(),
|
||||||
null,
|
null,
|
||||||
existing.rollbackMarker()
|
existing.rollbackMarker(),
|
||||||
|
0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +109,7 @@ public class JdbcTxManager implements TxManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private TxStatus noOp(TxDefinition definition, JdbcTxStatus suspended) {
|
private TxStatus noOp(TxDefinition definition, JdbcTxStatus suspended) {
|
||||||
return new JdbcTxStatus(null, false, definition.readOnly(), suspended, new JdbcTxStatus.RollbackMarker());
|
return new JdbcTxStatus(null, false, definition.readOnly(), suspended, new JdbcTxStatus.RollbackMarker(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private JdbcTxStatus suspendIfNeeded() {
|
private JdbcTxStatus suspendIfNeeded() {
|
||||||
@@ -110,22 +129,51 @@ public class JdbcTxManager implements TxManager {
|
|||||||
cleanupIfIdle();
|
cleanupIfIdle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
TxOutcome outcome = null;
|
||||||
try {
|
try {
|
||||||
if (s.isRollbackOnly()) {
|
if (s.isRollbackOnly()) {
|
||||||
s.connection().rollback();
|
s.connection().rollback();
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
|
outcome = TxOutcome.ROLLED_BACK;
|
||||||
return;
|
} else {
|
||||||
}
|
// Still inside the transaction, connection still bound: a beforeCommit callback
|
||||||
|
// can write through it and land in this same commit. Throwing from there vetoes
|
||||||
|
// the commit — see TxSynchronization.
|
||||||
|
ResourceRegistry.fireBeforeCommit(s.isReadOnly(), s.synchronizationBaseline());
|
||||||
s.connection().commit();
|
s.connection().commit();
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.COMMITTED);
|
outcome = TxOutcome.COMMITTED;
|
||||||
|
}
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
throw new TxException(e);
|
TxException wrapped = new TxException(e);
|
||||||
|
outcome = rollbackAfterFailedCommit(s, wrapped);
|
||||||
|
throw wrapped;
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
outcome = rollbackAfterFailedCommit(s, e);
|
||||||
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
|
// Unbind the connection before firing, so a callback that opens its own transaction
|
||||||
|
// gets a fresh one instead of joining the connection that just committed — and fire
|
||||||
|
// before cleanupIfIdle(), whose ResourceRegistry.cleanup() drops the pending list.
|
||||||
cleanupAndResume(s);
|
cleanupAndResume(s);
|
||||||
|
if (outcome != null) ResourceRegistry.fireSynchronizations(outcome, s.synchronizationBaseline());
|
||||||
cleanupIfIdle();
|
cleanupIfIdle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nothing was committed — a vetoing {@code beforeCommit}, or a commit that failed outright —
|
||||||
|
* so undo whatever the transaction had done and report {@code ROLLED_BACK} to the callbacks
|
||||||
|
* still queued behind it. A failure to roll back is attached to the exception already on its
|
||||||
|
* way out rather than replacing it: that one explains what actually went wrong.
|
||||||
|
*/
|
||||||
|
private static TxOutcome rollbackAfterFailedCommit(JdbcTxStatus s, Throwable propagating) {
|
||||||
|
try {
|
||||||
|
s.connection().rollback();
|
||||||
|
} catch (SQLException suppressed) {
|
||||||
|
propagating.addSuppressed(suppressed);
|
||||||
|
}
|
||||||
|
return TxOutcome.ROLLED_BACK;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void rollback(TxStatus status) {
|
public void rollback(TxStatus status) {
|
||||||
JdbcTxStatus s = (JdbcTxStatus) status;
|
JdbcTxStatus s = (JdbcTxStatus) status;
|
||||||
@@ -137,11 +185,12 @@ public class JdbcTxManager implements TxManager {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
s.connection().rollback();
|
s.connection().rollback();
|
||||||
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK);
|
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
throw new TxException(e);
|
throw new TxException(e);
|
||||||
} finally {
|
} finally {
|
||||||
|
// Same order as commit() above, for the same two reasons.
|
||||||
cleanupAndResume(s);
|
cleanupAndResume(s);
|
||||||
|
ResourceRegistry.fireSynchronizations(TxOutcome.ROLLED_BACK, s.synchronizationBaseline());
|
||||||
cleanupIfIdle();
|
cleanupIfIdle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-3
@@ -3,7 +3,6 @@ package dev.relism.flash.ext.data.jdbc;
|
|||||||
import dev.relism.flash.ext.data.core.TxStatus;
|
import dev.relism.flash.ext.data.core.TxStatus;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
class JdbcTxStatus implements TxStatus {
|
class JdbcTxStatus implements TxStatus {
|
||||||
static final class RollbackMarker {
|
static final class RollbackMarker {
|
||||||
@@ -15,19 +14,27 @@ class JdbcTxStatus implements TxStatus {
|
|||||||
private final boolean readOnly;
|
private final boolean readOnly;
|
||||||
private final JdbcTxStatus suspended;
|
private final JdbcTxStatus suspended;
|
||||||
private final RollbackMarker rollbackMarker;
|
private final RollbackMarker rollbackMarker;
|
||||||
|
private final int synchronizationBaseline;
|
||||||
|
|
||||||
|
// No requireNonNull on the connection: a SUPPORTS-without-a-transaction or a NOT_SUPPORTED
|
||||||
|
// status is deliberately connectionless (see JdbcTxManager#noOp), and rejecting null here
|
||||||
|
// turned both of those propagations into an NPE at begin() — the Hibernate manager has
|
||||||
|
// always allowed it. resource() reports the real mistake, asking a connectionless status for
|
||||||
|
// its connection, where it can name it.
|
||||||
JdbcTxStatus(
|
JdbcTxStatus(
|
||||||
Connection connection,
|
Connection connection,
|
||||||
boolean newTransaction,
|
boolean newTransaction,
|
||||||
boolean readOnly,
|
boolean readOnly,
|
||||||
JdbcTxStatus suspended,
|
JdbcTxStatus suspended,
|
||||||
RollbackMarker rollbackMarker
|
RollbackMarker rollbackMarker,
|
||||||
|
int synchronizationBaseline
|
||||||
) {
|
) {
|
||||||
this.connection = Objects.requireNonNull(connection);
|
this.connection = connection;
|
||||||
this.newTransaction = newTransaction;
|
this.newTransaction = newTransaction;
|
||||||
this.readOnly = readOnly;
|
this.readOnly = readOnly;
|
||||||
this.suspended = suspended;
|
this.suspended = suspended;
|
||||||
this.rollbackMarker = rollbackMarker;
|
this.rollbackMarker = rollbackMarker;
|
||||||
|
this.synchronizationBaseline = synchronizationBaseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public boolean isNewTransaction() { return newTransaction; }
|
@Override public boolean isNewTransaction() { return newTransaction; }
|
||||||
@@ -46,4 +53,7 @@ class JdbcTxStatus implements TxStatus {
|
|||||||
Connection connection() { return connection; }
|
Connection connection() { return connection; }
|
||||||
JdbcTxStatus suspended() { return suspended; }
|
JdbcTxStatus suspended() { return suspended; }
|
||||||
RollbackMarker rollbackMarker() { return rollbackMarker; }
|
RollbackMarker rollbackMarker() { return rollbackMarker; }
|
||||||
|
|
||||||
|
/** Index into {@code ResourceRegistry}'s synchronization list where this transaction's own callbacks start. */
|
||||||
|
int synchronizationBaseline() { return synchronizationBaseline; }
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.relism.flash.ext.data.jdbc;
|
||||||
|
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import dev.relism.flash.ext.data.DataExtension;
|
||||||
|
import dev.relism.flash.extension.FlashApp;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class JdbcTxManagerCloseTest {
|
||||||
|
|
||||||
|
/** A pool outlives its app unless someone closes it; the data extension does, once requests have drained. */
|
||||||
|
@Test
|
||||||
|
void stoppingTheAppClosesThePool() {
|
||||||
|
HikariDataSource pool = new HikariDataSource();
|
||||||
|
pool.setJdbcUrl(TestDataSource.URL);
|
||||||
|
FlashApp.create(0).install(new DataExtension(new JdbcTxManager(pool))).start().stop().join();
|
||||||
|
assertTrue(pool.isClosed());
|
||||||
|
}
|
||||||
|
}
|
||||||
+216
@@ -0,0 +1,216 @@
|
|||||||
|
package dev.relism.flash.ext.data.jdbc;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.data.core.*;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction synchronization semantics for the JDBC manager — the same contract {@code
|
||||||
|
* HibernateTxManagerSynchronizationTest} pins down for the Hibernate one, kept deliberately
|
||||||
|
* parallel: the two managers are interchangeable behind {@code TxManager}, so a callback must not
|
||||||
|
* observe a different lifecycle depending on which one is installed.
|
||||||
|
*/
|
||||||
|
class JdbcTxManagerSynchronizationTest {
|
||||||
|
|
||||||
|
private final JdbcTxManager manager = new JdbcTxManager(new TestDataSource());
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void cleanup() {
|
||||||
|
ResourceRegistry.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records which callbacks ran, in order — order is part of the contract, not just the fact they ran. */
|
||||||
|
private static final class Recorder implements TxSynchronization {
|
||||||
|
final List<String> calls = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override public void beforeCommit(boolean readOnly) { calls.add("beforeCommit:" + readOnly); }
|
||||||
|
@Override public void afterCommit() { calls.add("afterCommit"); }
|
||||||
|
@Override public void afterRollback() { calls.add("afterRollback"); }
|
||||||
|
@Override public void afterCompletion(TxOutcome outcome) { calls.add("afterCompletion:" + outcome); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final List<String> COMMITTED = List.of("beforeCommit:false", "afterCommit", "afterCompletion:COMMITTED");
|
||||||
|
private static final List<String> ROLLED_BACK = List.of("afterRollback", "afterCompletion:ROLLED_BACK");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void afterCommit_fires_on_commit() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
manager.commit(tx);
|
||||||
|
|
||||||
|
assertEquals(COMMITTED, recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void afterRollback_fires_on_rollback() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
manager.rollback(tx);
|
||||||
|
|
||||||
|
assertEquals(ROLLED_BACK, recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void commit_of_a_rollback_only_tx_rolls_back_without_running_beforeCommit() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
tx.markRollbackOnly();
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
manager.commit(tx);
|
||||||
|
|
||||||
|
assertEquals(ROLLED_BACK, recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** beforeCommit runs inside the transaction: connection still bound, nothing committed yet. */
|
||||||
|
@Test
|
||||||
|
void beforeCommit_runs_while_the_transaction_is_still_active() {
|
||||||
|
List<Connection> connectionSeen = new ArrayList<>();
|
||||||
|
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
Connection connection = tx.resource(Connection.class);
|
||||||
|
ResourceRegistry.addSynchronization(new TxSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void beforeCommit(boolean readOnly) {
|
||||||
|
// MANDATORY only succeeds while a transaction is bound — proof this runs inside it.
|
||||||
|
TxStatus joined = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.MANDATORY));
|
||||||
|
connectionSeen.add(joined.resource(Connection.class));
|
||||||
|
manager.commit(joined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.commit(tx);
|
||||||
|
|
||||||
|
assertSame(connection, connectionSeen.get(0), "the same connection must still be bound, so writes land in this commit");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void beforeCommit_is_told_whether_the_transaction_is_read_only() {
|
||||||
|
Recorder readWrite = new Recorder();
|
||||||
|
TxStatus rw = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(readWrite);
|
||||||
|
manager.commit(rw);
|
||||||
|
|
||||||
|
Recorder readOnly = new Recorder();
|
||||||
|
TxStatus ro = manager.begin(TxDefinition.DEFAULTS.asReadOnly());
|
||||||
|
ResourceRegistry.addSynchronization(readOnly);
|
||||||
|
manager.commit(ro);
|
||||||
|
|
||||||
|
assertEquals("beforeCommit:false", readWrite.calls.get(0));
|
||||||
|
assertEquals("beforeCommit:true", readOnly.calls.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throwing from beforeCommit is a veto: no commit, the rollback callbacks run, the exception propagates. */
|
||||||
|
@Test
|
||||||
|
void a_throwing_beforeCommit_vetoes_the_commit() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus tx = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(new TxSynchronization() {
|
||||||
|
@Override public void beforeCommit(boolean readOnly) { throw new IllegalStateException("veto"); }
|
||||||
|
});
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> manager.commit(tx));
|
||||||
|
|
||||||
|
assertEquals("veto", thrown.getMessage());
|
||||||
|
assertEquals(ROLLED_BACK, recorder.calls, "the surviving callbacks must hear ROLLED_BACK, not silence");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Post-completion callbacks run after the committed connection is unbound, so opening a transaction gets a fresh one. */
|
||||||
|
@Test
|
||||||
|
void a_synchronization_may_open_its_own_transaction() {
|
||||||
|
List<Connection> connectionsSeen = new ArrayList<>();
|
||||||
|
List<Boolean> wasNewTransaction = new ArrayList<>();
|
||||||
|
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
Connection committedConnection = outer.resource(Connection.class);
|
||||||
|
ResourceRegistry.addSynchronization(new TxSynchronization() {
|
||||||
|
@Override
|
||||||
|
public void afterCommit() {
|
||||||
|
TxStatus own = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
connectionsSeen.add(own.resource(Connection.class));
|
||||||
|
wasNewTransaction.add(own.isNewTransaction());
|
||||||
|
manager.commit(own);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.commit(outer);
|
||||||
|
|
||||||
|
assertEquals(1, connectionsSeen.size(), "the callback must have run");
|
||||||
|
assertEquals(List.of(true), wasNewTransaction, "must start its own transaction, not join the committed one");
|
||||||
|
assertNotSame(committedConnection, connectionsSeen.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void a_joined_commit_defers_synchronizations_to_the_outermost_commit() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
|
||||||
|
manager.commit(inner);
|
||||||
|
assertEquals(List.of(), recorder.calls, "the joined commit did not commit anything yet");
|
||||||
|
|
||||||
|
manager.commit(outer);
|
||||||
|
assertEquals(COMMITTED, recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void synchronizations_do_not_leak_into_the_next_transaction() {
|
||||||
|
Recorder recorder = new Recorder();
|
||||||
|
TxStatus first = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(recorder);
|
||||||
|
manager.commit(first);
|
||||||
|
recorder.calls.clear();
|
||||||
|
|
||||||
|
TxStatus second = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
manager.commit(second);
|
||||||
|
|
||||||
|
assertEquals(List.of(), recorder.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void a_requires_new_commit_leaves_the_suspended_transactions_synchronizations_alone() {
|
||||||
|
Recorder outerSync = new Recorder();
|
||||||
|
Recorder innerSync = new Recorder();
|
||||||
|
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(outerSync);
|
||||||
|
|
||||||
|
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
|
||||||
|
ResourceRegistry.addSynchronization(innerSync);
|
||||||
|
manager.commit(inner);
|
||||||
|
|
||||||
|
assertEquals(COMMITTED, innerSync.calls);
|
||||||
|
assertEquals(List.of(), outerSync.calls, "the outer transaction has not committed yet");
|
||||||
|
|
||||||
|
manager.commit(outer);
|
||||||
|
assertEquals(COMMITTED, outerSync.calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void a_requires_new_rollback_leaves_the_suspended_transactions_synchronizations_alone() {
|
||||||
|
Recorder outerSync = new Recorder();
|
||||||
|
|
||||||
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
ResourceRegistry.addSynchronization(outerSync);
|
||||||
|
|
||||||
|
TxStatus inner = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRES_NEW));
|
||||||
|
manager.rollback(inner);
|
||||||
|
|
||||||
|
assertEquals(List.of(), outerSync.calls, "the outer transaction is still open");
|
||||||
|
|
||||||
|
manager.commit(outer);
|
||||||
|
assertEquals(COMMITTED, outerSync.calls);
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-47
@@ -4,15 +4,12 @@ import dev.relism.flash.ext.data.core.*;
|
|||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import javax.sql.DataSource;
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.DriverManager;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
class JdbcTxManagerTest {
|
class JdbcTxManagerTest {
|
||||||
private final JdbcTxManager manager = new JdbcTxManager(dataSource());
|
private final JdbcTxManager manager = new JdbcTxManager(new TestDataSource());
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
void cleanup() {
|
void cleanup() {
|
||||||
@@ -53,52 +50,38 @@ class JdbcTxManagerTest {
|
|||||||
manager.rollback(outer);
|
manager.rollback(outer);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DataSource dataSource() {
|
/**
|
||||||
return new DataSource() {
|
* SUPPORTS without an active transaction yields a connectionless status — it must not be a
|
||||||
@Override
|
* transaction, and asking it for a connection must say so rather than NPE. Both propagations
|
||||||
public Connection getConnection() throws SQLException {
|
* that produce one used to throw {@link NullPointerException} straight out of {@code begin()}.
|
||||||
return DriverManager.getConnection("jdbc:h2:mem:tx-jdbc;DB_CLOSE_DELAY=-1");
|
*/
|
||||||
|
@Test
|
||||||
|
void supports_without_active_transaction_is_a_connectionless_no_op() {
|
||||||
|
TxStatus s = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.SUPPORTS));
|
||||||
|
|
||||||
|
assertFalse(s.isNewTransaction());
|
||||||
|
assertThrows(IllegalStateException.class, () -> s.resource(Connection.class));
|
||||||
|
assertDoesNotThrow(() -> manager.commit(s));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Test
|
||||||
public Connection getConnection(String username, String password) throws SQLException {
|
void not_supported_suspends_the_active_transaction_and_restores_it_on_commit() {
|
||||||
return DriverManager.getConnection("jdbc:h2:mem:tx-jdbc;DB_CLOSE_DELAY=-1", username, password);
|
TxStatus outer = manager.begin(TxDefinition.DEFAULTS);
|
||||||
|
Connection outerConnection = outer.resource(Connection.class);
|
||||||
|
|
||||||
|
TxStatus suspended = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.NOT_SUPPORTED));
|
||||||
|
assertFalse(suspended.isNewTransaction());
|
||||||
|
assertThrows(IllegalStateException.class, () -> suspended.resource(Connection.class));
|
||||||
|
manager.commit(suspended);
|
||||||
|
|
||||||
|
TxStatus rejoined = manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.REQUIRED));
|
||||||
|
assertSame(outerConnection, rejoined.resource(Connection.class), "the suspended transaction must be back");
|
||||||
|
manager.rollback(outer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Test
|
||||||
public <T> T unwrap(Class<T> iface) {
|
void mandatory_without_active_transaction_is_rejected() {
|
||||||
throw new UnsupportedOperationException();
|
assertThrows(IllegalStateException.class,
|
||||||
}
|
() -> manager.begin(TxDefinition.DEFAULTS.withPropagation(TransactionPropagation.MANDATORY)));
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isWrapperFor(Class<?> iface) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public java.io.PrintWriter getLogWriter() {
|
|
||||||
throw new UnsupportedOperationException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setLogWriter(java.io.PrintWriter out) {
|
|
||||||
throw new UnsupportedOperationException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void setLoginTimeout(int seconds) {
|
|
||||||
throw new UnsupportedOperationException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getLoginTimeout() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public java.util.logging.Logger getParentLogger() {
|
|
||||||
throw new UnsupportedOperationException();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package dev.relism.flash.ext.data.jdbc;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bare in-memory H2 {@link DataSource} — one fresh connection per {@code getConnection()}, which
|
||||||
|
* is all {@code JdbcTxManager} needs to exercise real commit/rollback and suspension. Every method
|
||||||
|
* outside the two {@code getConnection} overloads throws: nothing under test calls them, and a
|
||||||
|
* loud failure beats a silent stub if that ever changes.
|
||||||
|
*/
|
||||||
|
final class TestDataSource implements DataSource {
|
||||||
|
|
||||||
|
static final String URL = "jdbc:h2:mem:tx-jdbc;DB_CLOSE_DELAY=-1";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Connection getConnection() throws SQLException {
|
||||||
|
return DriverManager.getConnection(URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Connection getConnection(String username, String password) throws SQLException {
|
||||||
|
return DriverManager.getConnection(URL, username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public <T> T unwrap(Class<T> iface) { throw new UnsupportedOperationException(); }
|
||||||
|
@Override public boolean isWrapperFor(Class<?> iface) { return false; }
|
||||||
|
@Override public PrintWriter getLogWriter() { throw new UnsupportedOperationException(); }
|
||||||
|
@Override public void setLogWriter(PrintWriter out) { throw new UnsupportedOperationException(); }
|
||||||
|
@Override public void setLoginTimeout(int seconds) { throw new UnsupportedOperationException(); }
|
||||||
|
@Override public int getLoginTimeout() { return 0; }
|
||||||
|
@Override public Logger getParentLogger() { throw new UnsupportedOperationException(); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# flash-ext-jackson-core
|
||||||
|
|
||||||
|
What every Jackson data format shares. Applications do not install this module directly: they
|
||||||
|
install a format — [`flash-ext-jackson-json`](../flash-ext-jackson-json),
|
||||||
|
[`flash-ext-jackson-xml`](../flash-ext-jackson-xml) — and get all of this with it.
|
||||||
|
|
||||||
|
## Why there is a core at all
|
||||||
|
|
||||||
|
Every Jackson data format is the same databind model behind a different factory: `XmlMapper`,
|
||||||
|
`YAMLMapper` and `CBORMapper` are all `ObjectMapper`s. So the annotations on a type, the
|
||||||
|
constraints its fields declare and the schema it publishes are the same whatever writes it. Only
|
||||||
|
the mapper and the content type differ, and that is all a format module has to say.
|
||||||
|
|
||||||
|
## Codec
|
||||||
|
|
||||||
|
One mapper, in the shape a handler needs it.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `body(Request, Class<T>)` | Parses the body and verifies its constraints |
|
||||||
|
| `write(Response, Object)` | Serializes and sets the format's content type |
|
||||||
|
| `writeView(Response, Object, Class<?>)` | The same, through a Jackson `@JsonView` |
|
||||||
|
| `mapper()` | The `ObjectMapper` itself, for everything else |
|
||||||
|
|
||||||
|
`body` reads straight off the request's stream, which Flash reuses per connection: the body is
|
||||||
|
never buffered into an array to be handed over. A malformed body is a 400, a body that breaks a
|
||||||
|
constraint is a 422, and neither reaches the handler.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
A body is checked against the `jakarta.validation` annotations its own type declares — nothing to
|
||||||
|
install, nothing to call:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public record NewUser(@NotBlank @Size(max = 80) String name, @Email String email, @Min(18) int age) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported: `@NotNull`, `@NotBlank`, `@NotEmpty`, `@Size`, `@Min`, `@Max`, `@Email`, `@Pattern`.
|
||||||
|
Jakarta semantics: only `@NotNull` rejects null, every other constraint passes it.
|
||||||
|
|
||||||
|
A failure reads `<field> <message>`, and the message is the constraint's own when it sets one —
|
||||||
|
which is how the person who sent the request is told something better than a regex:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Pattern(regexp = "[A-Za-z0-9_][A-Za-z0-9_.-]{0,254}", message = "uses up to 255 letters, digits, _, . and -")
|
||||||
|
String key
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"error": "key uses up to 255 letters, digits, _, . and -", "status": 422}
|
||||||
|
```
|
||||||
|
|
||||||
|
The constraints of a type are compiled the first time it is seen and kept in a `ClassValue`,
|
||||||
|
beside the class itself — no map, no lock. A check reads the field through an exact-signature
|
||||||
|
`MethodHandle`: no boxing, no argument array, no iterator, and nothing allocated at all unless
|
||||||
|
something fails. A type that declares no constraints compiles to a validator that does nothing.
|
||||||
|
|
||||||
|
Verify a value built by hand with `Validator.check(value)`.
|
||||||
|
|
||||||
|
`flash-ext-openapi` reads the same annotations to publish `minLength`, `maximum`, `pattern` and
|
||||||
|
the required fields, so a rule is written once and both enforced and documented.
|
||||||
|
|
||||||
|
## Writing a format module
|
||||||
|
|
||||||
|
```java
|
||||||
|
public final class Yaml extends Codec {
|
||||||
|
public Yaml(YAMLMapper mapper) { super(mapper, ContentType.TEXT_YAML); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Consumes(ContentType.TEXT_YAML)
|
||||||
|
public abstract class YamlHandler<B> extends JacksonHandler<B> {
|
||||||
|
@Inject private Yaml yaml;
|
||||||
|
@Override protected Codec codec() { return yaml; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus an extension that provides the codec and, for outbound bodies,
|
||||||
|
`Marshalling.of(mapper, contentType)`. That is the whole of it.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-extensions</artifactId>
|
||||||
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>flash-ext-jackson-core</artifactId>
|
||||||
|
<name>flash-ext-jackson-core</name>
|
||||||
|
<description>What every Jackson data format shares: the codec, the body handler and the constraints a body is checked against.</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||||
|
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- The constraint annotations a body is checked against; no implementation, no transitives. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>jakarta.validation</groupId>
|
||||||
|
<artifactId>jakarta.validation-api</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
package dev.relism.flash.ext.jackson;
|
||||||
|
|
||||||
|
import java.lang.invoke.MethodHandle;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One constraint, compiled. Flattened into an opcode plus its operands rather than a class per
|
||||||
|
* constraint type: the check loop becomes a {@code tableswitch} over a monomorphic array instead
|
||||||
|
* of a megamorphic virtual call, and a passing check touches no allocation at all.
|
||||||
|
*
|
||||||
|
* <p>Field access goes through a {@link MethodHandle} adapted at compile time to an exact
|
||||||
|
* signature — {@code (Object)Object} for reference fields, {@code (Object)long} for primitive
|
||||||
|
* integrals — so {@code invokeExact} neither boxes nor allocates an argument array the way
|
||||||
|
* {@code Field.get} and {@code Method.invoke} do.
|
||||||
|
*/
|
||||||
|
final class Check {
|
||||||
|
|
||||||
|
static final int NOT_NULL = 0;
|
||||||
|
static final int NOT_BLANK = 1;
|
||||||
|
static final int NOT_EMPTY = 2;
|
||||||
|
static final int SIZE = 3;
|
||||||
|
static final int RANGE_PRIMITIVE = 4;
|
||||||
|
static final int RANGE_BOXED = 5;
|
||||||
|
static final int EMAIL = 6;
|
||||||
|
static final int PATTERN = 7;
|
||||||
|
|
||||||
|
final int op;
|
||||||
|
final String field;
|
||||||
|
/** Pre-rendered at compile time, so even the failure path formats nothing. */
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
/** {@code (Object)Object} — set for every op except {@link #RANGE_PRIMITIVE}. */
|
||||||
|
final MethodHandle ref;
|
||||||
|
/** {@code (Object)long} — set only for {@link #RANGE_PRIMITIVE}. */
|
||||||
|
final MethodHandle num;
|
||||||
|
|
||||||
|
final int min;
|
||||||
|
final int max;
|
||||||
|
final long lo;
|
||||||
|
final long hi;
|
||||||
|
final Pattern pattern;
|
||||||
|
|
||||||
|
private Check(int op, String field, String message, MethodHandle ref, MethodHandle num,
|
||||||
|
int min, int max, long lo, long hi, Pattern pattern) {
|
||||||
|
this.op = op;
|
||||||
|
this.field = field;
|
||||||
|
this.message = message;
|
||||||
|
this.ref = ref;
|
||||||
|
this.num = num;
|
||||||
|
this.min = min;
|
||||||
|
this.max = max;
|
||||||
|
this.lo = lo;
|
||||||
|
this.hi = hi;
|
||||||
|
this.pattern = pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Check reference(int op, String field, String message, MethodHandle ref) {
|
||||||
|
return new Check(op, field, message, ref, null, 0, 0, 0, 0, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Check size(String field, String message, MethodHandle ref, int min, int max) {
|
||||||
|
return new Check(SIZE, field, message, ref, null, min, max, 0, 0, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Check rangePrimitive(String field, String message, MethodHandle num, long lo, long hi) {
|
||||||
|
return new Check(RANGE_PRIMITIVE, field, message, null, num, 0, 0, lo, hi, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Check rangeBoxed(String field, String message, MethodHandle ref, long lo, long hi) {
|
||||||
|
return new Check(RANGE_BOXED, field, message, ref, null, 0, 0, lo, hi, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Check pattern(String field, String message, MethodHandle ref, Pattern pattern) {
|
||||||
|
return new Check(PATTERN, field, message, ref, null, 0, 0, 0, 0, pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
package dev.relism.flash.ext.jackson;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
import dev.relism.flash.models.Request;
|
||||||
|
import dev.relism.flash.models.Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Jackson mapper, in the shape a handler needs it.
|
||||||
|
*
|
||||||
|
* <p>Every Jackson data format is the same databind model behind a different factory, so this is
|
||||||
|
* the whole of what a format module has to say: which mapper, and which content type it writes.
|
||||||
|
* What the annotations mean, what a body is checked against and how a type is described are the
|
||||||
|
* same for all of them.
|
||||||
|
*
|
||||||
|
* <p>Retrieve it once at boot — {@code @Inject private Json json;} — and call it on the hot path.
|
||||||
|
* The underlying {@link ObjectMapper} is thread-safe once configured.
|
||||||
|
*/
|
||||||
|
public abstract class Codec {
|
||||||
|
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
private final ContentType contentType;
|
||||||
|
|
||||||
|
protected Codec(ObjectMapper mapper, ContentType contentType) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
this.contentType = contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the request body as {@code type} and verifies its constraints.
|
||||||
|
*
|
||||||
|
* <p>Read straight off the request's stream, which Flash reuses per connection: nothing
|
||||||
|
* buffers the body to hand it over. A type that declares no constraints is not checked at all.
|
||||||
|
*
|
||||||
|
* @throws HttpException 400 if the body cannot be parsed as {@code type}
|
||||||
|
* @throws ValidationException 422 if it parses but violates a constraint
|
||||||
|
*/
|
||||||
|
public <T> T body(Request request, Class<T> type) throws Exception {
|
||||||
|
T value;
|
||||||
|
try {
|
||||||
|
value = mapper.readValue(request.body().stream(), type);
|
||||||
|
} catch (JsonProcessingException malformed) {
|
||||||
|
throw HttpException.badRequest("Invalid request body: " + malformed.getOriginalMessage());
|
||||||
|
}
|
||||||
|
Validator.of(type).verify(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serializes {@code value} and sets this codec's content type on the response. */
|
||||||
|
public String write(Response response, Object value) throws Exception {
|
||||||
|
response.type(contentType);
|
||||||
|
return mapper.writeValueAsString(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like {@link #write}, restricted to the fields visible under a Jackson {@code @JsonView}. */
|
||||||
|
public String writeView(Response response, Object value, Class<?> view) throws Exception {
|
||||||
|
response.type(contentType);
|
||||||
|
return mapper.writerWithView(view).writeValueAsString(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What this codec writes, for a handler that sets the response type itself. */
|
||||||
|
public ContentType contentType() {
|
||||||
|
return contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The mapper itself, for everything these methods do not cover. */
|
||||||
|
public ObjectMapper mapper() {
|
||||||
|
return mapper;
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package dev.relism.flash.ext.jackson;
|
||||||
|
|
||||||
|
import dev.relism.flash.models.BodyHandler;
|
||||||
|
import dev.relism.flash.models.Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A handler whose body one Jackson format parses and whose constraints are checked before it
|
||||||
|
* arrives.
|
||||||
|
*
|
||||||
|
* <p>Format modules extend this and name their codec — {@code JsonHandler}, {@code XmlHandler}.
|
||||||
|
* An application extends those, never this one.
|
||||||
|
*
|
||||||
|
* @param <B> the body type, which is also what the published OpenAPI document describes
|
||||||
|
*/
|
||||||
|
public abstract class JacksonHandler<B> extends BodyHandler<B> {
|
||||||
|
|
||||||
|
private final Class<B> type = bodyType();
|
||||||
|
|
||||||
|
protected JacksonHandler() {
|
||||||
|
if (type == null) {
|
||||||
|
throw new IllegalStateException(getClass().getSimpleName()
|
||||||
|
+ " extends a body handler without naming its body type — write it as Handler<YourBody>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The format this handler speaks. Injected by the subclass, resolved once at boot. */
|
||||||
|
protected abstract Codec codec();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected final B body(Request request) throws Exception {
|
||||||
|
return codec().body(request, type);
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package dev.relism.flash.ext.jackson;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
import dev.relism.flash.models.Response;
|
||||||
|
import dev.relism.flash.routing.Middleware;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns whatever a handler returns into a serialized body.
|
||||||
|
*
|
||||||
|
* <p>Pass-through for what is already a response: {@code null}, a {@link Response}, a
|
||||||
|
* {@code byte[]} or a {@link CharSequence}. Everything else is serialized straight to bytes.
|
||||||
|
*/
|
||||||
|
public final class Marshalling {
|
||||||
|
|
||||||
|
private Marshalling() {}
|
||||||
|
|
||||||
|
public static Middleware of(ObjectMapper mapper, ContentType contentType) {
|
||||||
|
return next -> (req, res) -> {
|
||||||
|
Object out = next.handle(req, res);
|
||||||
|
if (out == null || out instanceof Response || out instanceof byte[] || out instanceof CharSequence) return out;
|
||||||
|
|
||||||
|
res.type(contentType);
|
||||||
|
try {
|
||||||
|
return mapper.writeValueAsBytes(out);
|
||||||
|
} catch (JsonProcessingException failure) {
|
||||||
|
throw new IllegalStateException("Could not serialize " + out.getClass().getName(), failure);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package dev.relism.flash.ext.jackson;
|
||||||
|
|
||||||
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raised when a value fails its constraints. Extends {@link HttpException} with status 422, so
|
||||||
|
* Flash's default exception handler renders it without this extension registering anything.
|
||||||
|
*
|
||||||
|
* <p>Allocated only on failure — a passing validation constructs nothing.
|
||||||
|
*/
|
||||||
|
public final class ValidationException extends HttpException {
|
||||||
|
|
||||||
|
private final transient List<Violation> violations;
|
||||||
|
|
||||||
|
public ValidationException(List<Violation> violations) {
|
||||||
|
super(422, describe(violations));
|
||||||
|
this.violations = List.copyOf(violations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The individual failures, in field declaration order. */
|
||||||
|
public List<Violation> violations() {
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String describe(List<Violation> violations) {
|
||||||
|
StringBuilder out = new StringBuilder(32 * violations.size());
|
||||||
|
for (int i = 0; i < violations.size(); i++) {
|
||||||
|
if (i > 0) out.append("; ");
|
||||||
|
Violation v = violations.get(i);
|
||||||
|
out.append(v.field()).append(' ').append(v.message());
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One failed constraint. */
|
||||||
|
public record Violation(String field, String message) {}
|
||||||
|
}
|
||||||
+255
@@ -0,0 +1,255 @@
|
|||||||
|
package dev.relism.flash.ext.jackson;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.lang.invoke.MethodHandle;
|
||||||
|
import java.lang.invoke.MethodHandles;
|
||||||
|
import java.lang.invoke.MethodType;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Modifier;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The compiled constraints of one type. Built once per class and reused for every request.
|
||||||
|
*
|
||||||
|
* <p>{@link #verify} allocates nothing when a value passes: the loop walks an array (no iterator),
|
||||||
|
* reads fields through exact-signature {@link MethodHandle}s (no boxing, no argument array), and
|
||||||
|
* compares against operands resolved at compile time. The violation list and the exception are
|
||||||
|
* constructed only once something actually fails.
|
||||||
|
*/
|
||||||
|
public final class Validator {
|
||||||
|
|
||||||
|
private static final Check[] NONE = new Check[0];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One compiled validator per type, kept beside the class itself: no map lookup, no lock, and
|
||||||
|
* the entry is collected with the class rather than pinning it.
|
||||||
|
*/
|
||||||
|
private static final ClassValue<Validator> COMPILED = new ClassValue<>() {
|
||||||
|
@Override protected Validator computeValue(Class<?> type) {
|
||||||
|
return compile(type);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private final Check[] checks;
|
||||||
|
|
||||||
|
private Validator(Check[] checks) {
|
||||||
|
this.checks = checks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The constraints of {@code type}, compiled the first time it is seen and reused after. */
|
||||||
|
public static Validator of(Class<?> type) {
|
||||||
|
return COMPILED.get(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a value against its own type's constraints.
|
||||||
|
*
|
||||||
|
* @return {@code value}, so it can be used inline
|
||||||
|
* @throws ValidationException 422, listing every failure
|
||||||
|
*/
|
||||||
|
public static <T> T check(T value) {
|
||||||
|
of(value.getClass()).verify(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the type declares no constraints at all — {@link #verify} is then a no-op. */
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return checks.length == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies every constraint on {@code target}.
|
||||||
|
*
|
||||||
|
* @throws ValidationException with all failures, never just the first
|
||||||
|
*/
|
||||||
|
public void verify(Object target) {
|
||||||
|
List<ValidationException.Violation> failures = null;
|
||||||
|
for (Check check : checks) {
|
||||||
|
if (passes(check, target)) continue;
|
||||||
|
if (failures == null) failures = new ArrayList<>(4);
|
||||||
|
failures.add(new ValidationException.Violation(check.field, check.message));
|
||||||
|
}
|
||||||
|
if (failures != null) throw new ValidationException(failures);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean passes(Check check, Object target) {
|
||||||
|
try {
|
||||||
|
if (check.op == Check.RANGE_PRIMITIVE) {
|
||||||
|
long value = (long) check.num.invokeExact(target);
|
||||||
|
return value >= check.lo && value <= check.hi;
|
||||||
|
}
|
||||||
|
Object value = (Object) check.ref.invokeExact(target);
|
||||||
|
// Jakarta semantics: only @NotNull rejects null; every other constraint passes it.
|
||||||
|
return switch (check.op) {
|
||||||
|
case Check.NOT_NULL -> value != null;
|
||||||
|
case Check.NOT_BLANK -> value instanceof String text && !text.isBlank();
|
||||||
|
case Check.NOT_EMPTY -> value != null && sizeOf(value) > 0;
|
||||||
|
case Check.SIZE -> value == null || withinSize(check, value);
|
||||||
|
case Check.RANGE_BOXED -> value == null || withinRange(check, (Number) value);
|
||||||
|
case Check.EMAIL -> value == null || (value instanceof String text && isEmail(text));
|
||||||
|
case Check.PATTERN -> value == null
|
||||||
|
|| (value instanceof String text && check.pattern.matcher(text).matches());
|
||||||
|
default -> true;
|
||||||
|
};
|
||||||
|
} catch (Throwable failure) {
|
||||||
|
throw new IllegalStateException("Could not read " + check.field + " for validation", failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean withinSize(Check check, Object value) {
|
||||||
|
int size = sizeOf(value);
|
||||||
|
return size >= check.min && size <= check.max;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean withinRange(Check check, Number value) {
|
||||||
|
long asLong = value.longValue();
|
||||||
|
return asLong >= check.lo && asLong <= check.hi;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No copies: every branch reads a length the object already knows. */
|
||||||
|
private static int sizeOf(Object value) {
|
||||||
|
if (value instanceof CharSequence text) return text.length();
|
||||||
|
if (value instanceof Collection<?> items) return items.size();
|
||||||
|
if (value instanceof Map<?, ?> entries) return entries.size();
|
||||||
|
if (value instanceof Object[] array) return array.length;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural check rather than a regex: {@code Pattern.matcher} allocates a matcher, an int
|
||||||
|
* array and a group array on every call, which is exactly the per-request cost this module
|
||||||
|
* exists to avoid. {@code indexOf} allocates nothing.
|
||||||
|
*
|
||||||
|
* <p>Accepts what a mail server would plausibly route and rejects the shapes people actually
|
||||||
|
* typo. Deliverability is the confirmation mail's job, not a validator's.
|
||||||
|
*/
|
||||||
|
private static boolean isEmail(String value) {
|
||||||
|
int at = value.indexOf('@');
|
||||||
|
if (at <= 0 || at == value.length() - 1) return false;
|
||||||
|
if (value.indexOf('@', at + 1) >= 0) return false;
|
||||||
|
int dot = value.indexOf('.', at + 2);
|
||||||
|
return dot > 0 && dot < value.length() - 1 && value.indexOf(' ') < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Compilation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compiles {@code type}'s constraints once.
|
||||||
|
*
|
||||||
|
* <p>Reads declared fields rather than record accessors: a constraint on a record component
|
||||||
|
* propagates to the backing field, so records and plain classes need one code path, not two.
|
||||||
|
*/
|
||||||
|
static Validator compile(Class<?> type) {
|
||||||
|
MethodHandles.Lookup lookup;
|
||||||
|
try {
|
||||||
|
lookup = MethodHandles.privateLookupIn(type, MethodHandles.lookup());
|
||||||
|
} catch (IllegalAccessException denied) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Cannot read " + type.getName() + " for validation — open its module or package", denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Check> checks = new ArrayList<>();
|
||||||
|
for (Field field : type.getDeclaredFields()) {
|
||||||
|
if (Modifier.isStatic(field.getModifiers())) continue;
|
||||||
|
MethodHandle getter;
|
||||||
|
try {
|
||||||
|
getter = lookup.unreflectGetter(field);
|
||||||
|
} catch (IllegalAccessException denied) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
compileField(field, getter, checks);
|
||||||
|
}
|
||||||
|
return new Validator(checks.isEmpty() ? NONE : checks.toArray(new Check[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void compileField(Field field, MethodHandle getter, List<Check> checks) {
|
||||||
|
String name = field.getName();
|
||||||
|
Class<?> type = field.getType();
|
||||||
|
MethodHandle ref = type.isPrimitive() ? null : asReference(getter);
|
||||||
|
|
||||||
|
NotNull notNull = field.getAnnotation(NotNull.class);
|
||||||
|
if (notNull != null && ref != null)
|
||||||
|
checks.add(Check.reference(Check.NOT_NULL, name, said(notNull.message(), "must not be null"), ref));
|
||||||
|
|
||||||
|
NotBlank notBlank = field.getAnnotation(NotBlank.class);
|
||||||
|
if (notBlank != null && ref != null)
|
||||||
|
checks.add(Check.reference(Check.NOT_BLANK, name, said(notBlank.message(), "must not be blank"), ref));
|
||||||
|
|
||||||
|
NotEmpty notEmpty = field.getAnnotation(NotEmpty.class);
|
||||||
|
if (notEmpty != null && ref != null)
|
||||||
|
checks.add(Check.reference(Check.NOT_EMPTY, name, said(notEmpty.message(), "must not be empty"), ref));
|
||||||
|
|
||||||
|
Size size = field.getAnnotation(Size.class);
|
||||||
|
if (size != null && ref != null)
|
||||||
|
checks.add(Check.size(name, said(size.message(), 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 = said(min != null ? min.message() : max.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Email email = field.getAnnotation(Email.class);
|
||||||
|
if (email != null && ref != null)
|
||||||
|
checks.add(Check.reference(Check.EMAIL, name, said(email.message(), "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, said(pattern.message(), "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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a failure says: the constraint's own {@code message} when it sets one, and otherwise a
|
||||||
|
* plain description of the rule. Jakarta's defaults are bundle keys in braces, and a key is
|
||||||
|
* not something to put in front of whoever sent the request.
|
||||||
|
*/
|
||||||
|
private static String said(String message, String otherwise) {
|
||||||
|
return message == null || message.isBlank() || message.startsWith("{") ? otherwise : message;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
package dev.relism.flash.ext.jackson;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class ValidatorTest {
|
||||||
|
|
||||||
|
record CreateUser(
|
||||||
|
@NotBlank @Size(max = 8) String name,
|
||||||
|
@Email String email,
|
||||||
|
@Min(18) @Max(120) int age,
|
||||||
|
@NotNull String role) {}
|
||||||
|
|
||||||
|
record Boxed(@Min(1) Integer count) {}
|
||||||
|
|
||||||
|
record Sized(@NotEmpty List<String> tags, @Size(min = 2, max = 4) String code) {}
|
||||||
|
|
||||||
|
record Patterned(@Pattern(regexp = "[a-z]+") String slug) {}
|
||||||
|
|
||||||
|
record Plain(String anything) {}
|
||||||
|
|
||||||
|
private static ValidationException failureOf(Object value) {
|
||||||
|
return assertThrows(ValidationException.class, () -> Validator.compile(value.getClass()).verify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aValidValuePasses() {
|
||||||
|
assertDoesNotThrow(() ->
|
||||||
|
Validator.compile(CreateUser.class).verify(new CreateUser("alice", "a@b.com", 30, "admin")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsEveryViolationNotJustTheFirst() {
|
||||||
|
ValidationException failure = failureOf(new CreateUser(" ", "nope", 5, null));
|
||||||
|
|
||||||
|
assertEquals(List.of("name", "email", "age", "role"),
|
||||||
|
failure.violations().stream().map(ValidationException.Violation::field).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void violationsCarryFieldAndMessage() {
|
||||||
|
ValidationException failure = failureOf(new CreateUser("alice", "a@b.com", 5, "admin"));
|
||||||
|
|
||||||
|
assertEquals(1, failure.violations().size());
|
||||||
|
assertEquals("age", failure.violations().get(0).field());
|
||||||
|
assertEquals("must be between 18 and 120", failure.violations().get(0).message());
|
||||||
|
assertEquals(422, failure.status());
|
||||||
|
assertEquals("age must be between 18 and 120", failure.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sizeCountsCharactersWithoutCopying() {
|
||||||
|
assertEquals("name", failureOf(new CreateUser("far-too-long", "a@b.com", 30, "x"))
|
||||||
|
.violations().get(0).field());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onlyNotNullRejectsNull() {
|
||||||
|
// @Email, @Size and @Min all accept null per Jakarta semantics; @NotNull is the one that does not.
|
||||||
|
ValidationException failure = failureOf(new CreateUser("alice", null, 30, null));
|
||||||
|
|
||||||
|
assertEquals(List.of("role"),
|
||||||
|
failure.violations().stream().map(ValidationException.Violation::field).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void boxedNumbersUseTheReferencePathAndTolerateNull() {
|
||||||
|
assertDoesNotThrow(() -> Validator.compile(Boxed.class).verify(new Boxed(null)));
|
||||||
|
assertEquals("count", failureOf(new Boxed(0)).violations().get(0).field());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sizeAppliesToCollectionsAndStrings() {
|
||||||
|
assertDoesNotThrow(() -> Validator.compile(Sized.class).verify(new Sized(List.of("a"), "abc")));
|
||||||
|
|
||||||
|
ValidationException failure = failureOf(new Sized(List.of(), "x"));
|
||||||
|
assertEquals(List.of("tags", "code"),
|
||||||
|
failure.violations().stream().map(ValidationException.Violation::field).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void patternIsAnchoredLikeJakarta() {
|
||||||
|
assertDoesNotThrow(() -> Validator.compile(Patterned.class).verify(new Patterned("abc")));
|
||||||
|
assertEquals("slug", failureOf(new Patterned("Abc1")).violations().get(0).field());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emailAcceptsPlausibleAddressesAndRejectsTypos() {
|
||||||
|
assertDoesNotThrow(() ->
|
||||||
|
Validator.compile(CreateUser.class).verify(new CreateUser("a", "first.last@sub.example.co", 20, "x")));
|
||||||
|
|
||||||
|
for (String bad : List.of("no-at", "@leading.com", "trailing@", "two@@at.com", "no dots@x", "a@b")) {
|
||||||
|
assertThrows(ValidationException.class,
|
||||||
|
() -> Validator.compile(CreateUser.class).verify(new CreateUser("a", bad, 20, "x")),
|
||||||
|
bad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aTypeWithNoConstraintsCompilesToANoOp() {
|
||||||
|
Validator validator = Validator.compile(Plain.class);
|
||||||
|
|
||||||
|
assertTrue(validator.isEmpty());
|
||||||
|
assertDoesNotThrow(() -> validator.verify(new Plain(null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
record Keyed(@jakarta.validation.constraints.Pattern(regexp = "[a-z.]+",
|
||||||
|
message = "uses lowercase letters and dots") String key) {}
|
||||||
|
|
||||||
|
@org.junit.jupiter.api.Test
|
||||||
|
void a_constraint_says_what_it_wants_in_its_own_words() {
|
||||||
|
ValidationException refused = org.junit.jupiter.api.Assertions.assertThrows(
|
||||||
|
ValidationException.class, () -> Validator.check(new Keyed("Not A Key")));
|
||||||
|
|
||||||
|
org.junit.jupiter.api.Assertions.assertEquals("key uses lowercase letters and dots", refused.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# flash-ext-jackson-json
|
||||||
|
|
||||||
|
JSON bodies and JSON responses.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```java
|
||||||
|
JsonExtension json = new JsonExtension();
|
||||||
|
|
||||||
|
FlashApp.create(8080)
|
||||||
|
.install(json)
|
||||||
|
.use(json.auto())
|
||||||
|
.scan("com.acme.handlers")
|
||||||
|
.startAndBlock();
|
||||||
|
```
|
||||||
|
|
||||||
|
The default mapper discovers the modules on the classpath (Java Time among them) and writes dates
|
||||||
|
as ISO strings; `new JsonExtension(mapper)` takes one of your own. `auto()` serializes whatever a
|
||||||
|
handler returns, leaving alone what is already a response: `null`, a `Response`, a `byte[]` or a
|
||||||
|
`CharSequence`.
|
||||||
|
|
||||||
|
## A handler with a body
|
||||||
|
|
||||||
|
The body type is the handler's type argument, and that is the whole declaration — it is also what
|
||||||
|
the OpenAPI document describes and what the constraints are read from:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@POST("/users")
|
||||||
|
public final class CreateUser extends JsonHandler<NewUser> {
|
||||||
|
@Inject private UserService users;
|
||||||
|
|
||||||
|
@Override protected Object handle(Request req, Response res, NewUser body) {
|
||||||
|
return users.create(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A malformed body never reaches it (400), nor does one that breaks a constraint (422).
|
||||||
|
|
||||||
|
## A handler that reads it itself
|
||||||
|
|
||||||
|
```java
|
||||||
|
public final class Import extends RequestHandler {
|
||||||
|
@Inject private Json json;
|
||||||
|
|
||||||
|
@Override public Object handle(Request req, Response res) throws Exception {
|
||||||
|
return archive.store(json.body(req, Manifest.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Json` is the [`Codec`](../flash-ext-jackson-core) for `application/json`: `body`, `write`,
|
||||||
|
`writeView`, `mapper`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- One mapper per application (or per scope), shared by every handler; `ObjectMapper` is
|
||||||
|
thread-safe once configured.
|
||||||
|
- Install `flash-ext-jackson-xml` beside this one when an application speaks both: a route picks
|
||||||
|
its format by the handler it extends, not by negotiation.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-extensions</artifactId>
|
||||||
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>flash-ext-jackson-json</artifactId>
|
||||||
|
<name>flash-ext-jackson-json</name>
|
||||||
|
<description>JSON bodies and responses: the Json codec, JsonHandler and the marshalling middleware.</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-jackson-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-testing</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- The constraints a body declares are described by the published document too. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-openapi</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package dev.relism.flash.ext.jackson.json;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import dev.relism.flash.ext.jackson.Codec;
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON in and out, checked against the body type's own constraints.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* public final class CreateItem extends RequestHandler {
|
||||||
|
* @Inject private Json json;
|
||||||
|
*
|
||||||
|
* @Override public Object handle(Request req, Response res) throws Exception {
|
||||||
|
* return items.create(json.body(req, NewItem.class));
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>A handler whose whole body is one type has nothing to write at all: see {@link JsonHandler}.
|
||||||
|
*/
|
||||||
|
public final class Json extends Codec {
|
||||||
|
|
||||||
|
public Json(ObjectMapper mapper) {
|
||||||
|
super(mapper, ContentType.JSON);
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
package dev.relism.flash.ext.jackson.json;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
|
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||||
|
import dev.relism.flash.ext.jackson.Marshalling;
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
|
import dev.relism.flash.extension.FlashRegistrar;
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
import dev.relism.flash.routing.Middleware;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON for an application: the {@link Json} codec, the mapper behind it, and the middleware that
|
||||||
|
* serializes whatever a handler returns.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* JsonExtension json = new JsonExtension();
|
||||||
|
* app.install(json).use(json.auto());
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>The default mapper discovers the modules on the classpath (Java Time among them) and writes
|
||||||
|
* dates as ISO strings. Hand it a mapper of your own to decide otherwise.
|
||||||
|
*/
|
||||||
|
public class JsonExtension implements FlashExtension {
|
||||||
|
|
||||||
|
private final ObjectMapper mapper;
|
||||||
|
|
||||||
|
public JsonExtension() {
|
||||||
|
this(JsonMapper.builder()
|
||||||
|
.findAndAddModules()
|
||||||
|
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public JsonExtension(ObjectMapper mapper) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serializes what a handler returns, unless it already returned a response, bytes or text. */
|
||||||
|
public Middleware auto() {
|
||||||
|
return Marshalling.of(mapper, ContentType.JSON);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
|
ctx.provide(Json.class, new Json(mapper));
|
||||||
|
ctx.provide(ObjectMapper.class, mapper);
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package dev.relism.flash.ext.jackson.json;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.jackson.Codec;
|
||||||
|
import dev.relism.flash.ext.jackson.JacksonHandler;
|
||||||
|
import dev.relism.flash.extension.Inject;
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
import dev.relism.flash.routing.Consumes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A handler that takes a JSON body of one type.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* @POST("/users")
|
||||||
|
* public final class CreateUser extends JsonHandler<NewUser> {
|
||||||
|
* @Inject private UserService users;
|
||||||
|
*
|
||||||
|
* @Override protected Object handle(Request req, Response res, NewUser body) {
|
||||||
|
* return users.create(body);
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>The body is read off the request stream, verified against the constraints its type declares,
|
||||||
|
* and handed over. A malformed body is a 400, a body that breaks a constraint is a 422, and
|
||||||
|
* neither ever reaches the handler. The published OpenAPI document describes the same type.
|
||||||
|
*/
|
||||||
|
@Consumes(ContentType.JSON)
|
||||||
|
public abstract class JsonHandler<B> extends JacksonHandler<B> {
|
||||||
|
|
||||||
|
@Inject private Json json;
|
||||||
|
|
||||||
|
@Override protected final Codec codec() {
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
package dev.relism.flash.ext.jackson.json;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.jackson.json.JsonExtension;
|
||||||
|
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 ConstraintsInTheDocumentTest {
|
||||||
|
|
||||||
|
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(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 JsonExtension());
|
||||||
|
configured.install(new OpenApiExtension("/openapi", "Accounts", "1.0.0"));
|
||||||
|
configured.scan("dev.relism.flash.ext.jackson.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
@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\"]");
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-9
@@ -1,4 +1,4 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
package dev.relism.flash.ext.jackson.json;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
@@ -16,25 +16,25 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
class JacksonExtensionTest {
|
class JsonExtensionTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void provide_registers_json_mapper_and_middleware() {
|
void configure_registers_json_mapper_and_middleware() {
|
||||||
FlashContext ctx = new FlashContext();
|
FlashContext ctx = new FlashContext();
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
JacksonExtension ext = new JacksonExtension(mapper);
|
JsonExtension ext = new JsonExtension(mapper);
|
||||||
|
|
||||||
ext.provide(ctx);
|
ext.configure(null, ctx);
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
assertNotNull(ctx.require(Json.class));
|
assertNotNull(ctx.require(Json.class));
|
||||||
assertNotNull(ctx.require(JacksonMiddleware.class));
|
|
||||||
assertSame(mapper, ctx.require(ObjectMapper.class));
|
assertSame(mapper, ctx.require(ObjectMapper.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void autoJson_factory_delegates_to_middleware_policy() throws Exception {
|
void auto_marshals_what_a_handler_returns() throws Exception {
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
JacksonExtension ext = new JacksonExtension(mapper);
|
JsonExtension ext = new JsonExtension(mapper);
|
||||||
RequestHandler next = new RequestHandler() {
|
RequestHandler next = new RequestHandler() {
|
||||||
@Override
|
@Override
|
||||||
public Object handle(Request request, Response response) {
|
public Object handle(Request request, Response response) {
|
||||||
@@ -42,7 +42,7 @@ class JacksonExtensionTest {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
RequestHandler wrapped = new RequestHandler() {
|
RequestHandler wrapped = new RequestHandler() {
|
||||||
private final SimpleHandler.FunctionalHandler delegate = ext.autoJson().wrap(next);
|
private final SimpleHandler.FunctionalHandler delegate = ext.auto().wrap(next);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Object handle(Request request, Response response) throws Exception {
|
public Object handle(Request request, Response response) throws Exception {
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
package dev.relism.flash.ext.jackson.json;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
import dev.relism.flash.http.HttpMethod;
|
||||||
|
import dev.relism.flash.models.Http1HeaderMap;
|
||||||
|
import dev.relism.flash.models.Request;
|
||||||
|
import dev.relism.flash.models.RequestLine;
|
||||||
|
import dev.relism.flash.models.Response;
|
||||||
|
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class JsonHandlerTest {
|
||||||
|
|
||||||
|
public record NewUser(String name) {}
|
||||||
|
|
||||||
|
static final class Create extends JsonHandler<NewUser> {
|
||||||
|
@Override protected Object handle(Request request, Response response, NewUser body) {
|
||||||
|
return body.name();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("rawtypes")
|
||||||
|
static final class Untyped extends JsonHandler {
|
||||||
|
@Override protected Object handle(Request request, Response response, Object body) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theBodyArrivesParsedAsTheTypeTheHandlerDeclares() throws Exception {
|
||||||
|
Create handler = new Create();
|
||||||
|
handler.bind(context());
|
||||||
|
|
||||||
|
Object answer = handler.handle(request("{\"name\":\"alice\"}"), new Response(200, ContentType.JSON));
|
||||||
|
|
||||||
|
assertEquals("alice", answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aMalformedBodyIsTheUsualBadRequest() throws Exception {
|
||||||
|
Create handler = new Create();
|
||||||
|
handler.bind(context());
|
||||||
|
|
||||||
|
dev.relism.flash.exceptions.HttpException refused = assertThrows(dev.relism.flash.exceptions.HttpException.class,
|
||||||
|
() -> handler.handle(request("not json"), new Response(200, ContentType.JSON)));
|
||||||
|
|
||||||
|
assertEquals(400, refused.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aHandlerThatNeverNamedItsBodyTypeIsRefusedAtBoot() {
|
||||||
|
IllegalStateException refused = assertThrows(IllegalStateException.class, Untyped::new);
|
||||||
|
|
||||||
|
assertTrue(refused.getMessage().contains("Handler<YourBody>"), refused.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FlashContext context() {
|
||||||
|
FlashContext ctx = new FlashContext();
|
||||||
|
ctx.provide(Json.class, new Json(new ObjectMapper()));
|
||||||
|
ctx.complete();
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Request request(String body) {
|
||||||
|
return new Request(new RequestLine(HttpMethod.POST,
|
||||||
|
new FastPathViews.StringByteView("/users"), null,
|
||||||
|
new FastPathViews.StringByteView("HTTP/1.1"), new Http1HeaderMap()),
|
||||||
|
body.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-10
@@ -1,11 +1,11 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
package dev.relism.flash.ext.jackson.json;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonView;
|
import com.fasterxml.jackson.annotation.JsonView;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import dev.relism.flash.exceptions.HttpException;
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
import dev.relism.flash.http.ContentType;
|
import dev.relism.flash.http.ContentType;
|
||||||
import dev.relism.flash.http.HttpMethod;
|
import dev.relism.flash.http.HttpMethod;
|
||||||
import dev.relism.flash.models.HeaderMap;
|
import dev.relism.flash.models.Http1HeaderMap;
|
||||||
import dev.relism.flash.models.Request;
|
import dev.relism.flash.models.Request;
|
||||||
import dev.relism.flash.models.RequestLine;
|
import dev.relism.flash.models.RequestLine;
|
||||||
import dev.relism.flash.models.Response;
|
import dev.relism.flash.models.Response;
|
||||||
@@ -37,16 +37,11 @@ class JsonTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void bodyFrom_parses_stream_and_maps_bad_payload_to_http_400() throws Exception {
|
void a_truncated_body_is_a_bad_request_too() throws Exception {
|
||||||
Json json = new Json(new ObjectMapper());
|
Json json = new Json(new ObjectMapper());
|
||||||
|
|
||||||
Request ok = request("{\"id\":\"u2\",\"name\":\"bob\"}");
|
HttpException ex = assertThrows(HttpException.class, () -> json.body(request("["), UserDto.class));
|
||||||
UserDto dto = json.bodyFrom(ok, UserDto.class);
|
|
||||||
assertEquals("u2", dto.id);
|
|
||||||
assertEquals("bob", dto.name);
|
|
||||||
|
|
||||||
Request bad = request("[");
|
|
||||||
HttpException ex = assertThrows(HttpException.class, () -> json.bodyFrom(bad, UserDto.class));
|
|
||||||
assertEquals(400, ex.status());
|
assertEquals(400, ex.status());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +77,7 @@ class JsonTest {
|
|||||||
new FastPathViews.StringByteView("/json"),
|
new FastPathViews.StringByteView("/json"),
|
||||||
null,
|
null,
|
||||||
new FastPathViews.StringByteView("HTTP/1.1"),
|
new FastPathViews.StringByteView("HTTP/1.1"),
|
||||||
new HeaderMap()
|
new Http1HeaderMap()
|
||||||
);
|
);
|
||||||
return new Request(line, body);
|
return new Request(line, body);
|
||||||
}
|
}
|
||||||
+13
-11
@@ -1,6 +1,8 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
package dev.relism.flash.ext.jackson.json;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import dev.relism.flash.ext.jackson.Marshalling;
|
||||||
|
import dev.relism.flash.routing.Middleware;
|
||||||
import dev.relism.flash.models.SimpleHandler;
|
import dev.relism.flash.models.SimpleHandler;
|
||||||
import dev.relism.flash.http.ContentType;
|
import dev.relism.flash.http.ContentType;
|
||||||
import dev.relism.flash.models.Request;
|
import dev.relism.flash.models.Request;
|
||||||
@@ -16,13 +18,13 @@ import static org.junit.jupiter.api.Assertions.assertSame;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
class JacksonMiddlewareTest {
|
class MarshallingTest {
|
||||||
|
|
||||||
private static final Request REQ = null;
|
private static final Request REQ = null;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void autoJson_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception {
|
void marshalling_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception {
|
||||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
|
||||||
RequestHandler wrapped = wrap(mw, new UserDto("u1", "alice"));
|
RequestHandler wrapped = wrap(mw, new UserDto("u1", "alice"));
|
||||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
||||||
|
|
||||||
@@ -34,8 +36,8 @@ class JacksonMiddlewareTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void autoJson_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception {
|
void marshalling_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception {
|
||||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
|
||||||
|
|
||||||
Response payloadResponse = new Response(201, ContentType.TEXT_PLAIN).body("ok");
|
Response payloadResponse = new Response(201, ContentType.TEXT_PLAIN).body("ok");
|
||||||
RequestHandler wrappedResponse = wrap(mw, payloadResponse);
|
RequestHandler wrappedResponse = wrap(mw, payloadResponse);
|
||||||
@@ -55,17 +57,17 @@ class JacksonMiddlewareTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void autoJson_wraps_serialization_errors_as_illegal_state() {
|
void marshalling_wraps_serialization_errors_as_illegal_state() {
|
||||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
|
||||||
RequestHandler wrapped = wrap(mw, new CyclicDto());
|
RequestHandler wrapped = wrap(mw, new CyclicDto());
|
||||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
||||||
|
|
||||||
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> wrapped.handle(REQ, res));
|
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> wrapped.handle(REQ, res));
|
||||||
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
|
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
|
||||||
assertTrue(ex.getMessage().startsWith("Failed to serialize handler result as JSON:"));
|
assertTrue(ex.getMessage().startsWith("Could not serialize"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RequestHandler wrap(JacksonMiddleware mw, Object fixedReturn) {
|
private static RequestHandler wrap(Middleware mw, Object fixedReturn) {
|
||||||
RequestHandler next = new RequestHandler() {
|
RequestHandler next = new RequestHandler() {
|
||||||
@Override
|
@Override
|
||||||
public Object handle(Request request, Response response) {
|
public Object handle(Request request, Response response) {
|
||||||
@@ -73,7 +75,7 @@ class JacksonMiddlewareTest {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
return new RequestHandler() {
|
return new RequestHandler() {
|
||||||
private final SimpleHandler.FunctionalHandler delegate = mw.autoJson().wrap(next);
|
private final SimpleHandler.FunctionalHandler delegate = mw.wrap(next);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Object handle(Request request, Response response) throws Exception {
|
public Object handle(Request request, Response response) throws Exception {
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
package dev.relism.flash.ext.jackson.json;
|
||||||
|
|
||||||
|
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 ValidatedBodyTest {
|
||||||
|
|
||||||
|
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 JsonExtension());
|
||||||
|
|
||||||
|
configured.ctx().onReady(() -> {
|
||||||
|
Json json = configured.ctx().require(Json.class);
|
||||||
|
configured.post("/users", (req, res) ->
|
||||||
|
res.status(201).body("created:" + json.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# flash-ext-jackson-xml
|
||||||
|
|
||||||
|
XML bodies and XML responses, over the same types as every other Jackson format.
|
||||||
|
|
||||||
|
```java
|
||||||
|
XmlExtension xml = new XmlExtension();
|
||||||
|
app.install(xml).use(xml.auto());
|
||||||
|
```
|
||||||
|
|
||||||
|
```java
|
||||||
|
@POST("/orders")
|
||||||
|
public final class PlaceOrder extends XmlHandler<Order> {
|
||||||
|
@Inject private OrderService orders;
|
||||||
|
|
||||||
|
@Override protected Object handle(Request req, Response res, Order body) {
|
||||||
|
return orders.place(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything [`flash-ext-jackson-json`](../flash-ext-jackson-json) does, in XML: the body is read off
|
||||||
|
the request stream, verified against the constraints its type declares, and handed over. A type
|
||||||
|
annotated for JSON works here as is — `Order` can be a JSON body on one route and an XML body on
|
||||||
|
another.
|
||||||
|
|
||||||
|
What is specific to XML is Jackson's own: `@JacksonXmlRootElement` for the root name,
|
||||||
|
`@JacksonXmlProperty(isAttribute = true)` for an attribute rather than an element, and
|
||||||
|
`@JacksonXmlElementWrapper` for how a list is wrapped. This module adds no annotations of its own.
|
||||||
|
|
||||||
|
Brings `jackson-dataformat-xml`, and with it Woodstox.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-extensions</artifactId>
|
||||||
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>flash-ext-jackson-xml</artifactId>
|
||||||
|
<name>flash-ext-jackson-xml</name>
|
||||||
|
<description>XML bodies and responses, over the same types and the same constraints as every other Jackson format.</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-jackson-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.dataformat</groupId>
|
||||||
|
<artifactId>jackson-dataformat-xml</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>jakarta.validation</groupId>
|
||||||
|
<artifactId>jakarta.validation-api</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-testing</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.relism.flash.ext.jackson.xml;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
|
||||||
|
import dev.relism.flash.ext.jackson.Codec;
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XML in and out, checked against the body type's own constraints.
|
||||||
|
*
|
||||||
|
* <p>The same databind model as every other Jackson format: a type is annotated once and can be
|
||||||
|
* read as XML here and as JSON elsewhere in the same application. XML's own concerns — a root
|
||||||
|
* element name, an attribute rather than an element, how a list is wrapped — are Jackson's
|
||||||
|
* {@code @JacksonXml*} annotations on the type.
|
||||||
|
*/
|
||||||
|
public final class Xml extends Codec {
|
||||||
|
|
||||||
|
public Xml(XmlMapper mapper) {
|
||||||
|
super(mapper, ContentType.XML);
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package dev.relism.flash.ext.jackson.xml;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
|
||||||
|
import dev.relism.flash.ext.jackson.Marshalling;
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
|
import dev.relism.flash.extension.FlashRegistrar;
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
import dev.relism.flash.routing.Middleware;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XML for an application: the {@link Xml} codec and the middleware that serializes what a handler
|
||||||
|
* returns.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* XmlExtension xml = new XmlExtension();
|
||||||
|
* app.install(xml).use(xml.auto());
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>Install it beside {@code JsonExtension} when an application speaks both: each provides its
|
||||||
|
* own codec, and a route picks one by the handler it extends.
|
||||||
|
*/
|
||||||
|
public class XmlExtension implements FlashExtension {
|
||||||
|
|
||||||
|
private final XmlMapper mapper;
|
||||||
|
|
||||||
|
public XmlExtension() {
|
||||||
|
this(XmlMapper.builder().findAndAddModules().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
public XmlExtension(XmlMapper mapper) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serializes what a handler returns, unless it already returned a response, bytes or text. */
|
||||||
|
public Middleware auto() {
|
||||||
|
return Marshalling.of(mapper, ContentType.XML);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
|
ctx.provide(Xml.class, new Xml(mapper));
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package dev.relism.flash.ext.jackson.xml;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.jackson.Codec;
|
||||||
|
import dev.relism.flash.ext.jackson.JacksonHandler;
|
||||||
|
import dev.relism.flash.extension.Inject;
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
import dev.relism.flash.routing.Consumes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A handler that takes an XML body of one type.
|
||||||
|
*
|
||||||
|
* <p>Everything {@code JsonHandler} does, in XML: the body is read off the request stream,
|
||||||
|
* verified against its type's constraints, and handed over. Both can live in the same
|
||||||
|
* application, on different routes, over the same types.
|
||||||
|
*/
|
||||||
|
@Consumes(ContentType.XML)
|
||||||
|
public abstract class XmlHandler<B> extends JacksonHandler<B> {
|
||||||
|
|
||||||
|
@Inject private Xml xml;
|
||||||
|
|
||||||
|
@Override protected final Codec codec() {
|
||||||
|
return xml;
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
package dev.relism.flash.ext.jackson.xml;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
|
||||||
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
import dev.relism.flash.http.HttpMethod;
|
||||||
|
import dev.relism.flash.models.Http1HeaderMap;
|
||||||
|
import dev.relism.flash.models.Request;
|
||||||
|
import dev.relism.flash.models.RequestLine;
|
||||||
|
import dev.relism.flash.models.Response;
|
||||||
|
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
/** The same handler shape as JSON, over the same types and the same constraints. */
|
||||||
|
class XmlHandlerTest {
|
||||||
|
|
||||||
|
public record Order(@NotBlank String reference) {}
|
||||||
|
|
||||||
|
static final class Place extends XmlHandler<Order> {
|
||||||
|
@Override protected Object handle(Request request, Response response, Order body) {
|
||||||
|
return body.reference();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theBodyArrivesParsedAsTheTypeTheHandlerDeclares() throws Exception {
|
||||||
|
Place handler = new Place();
|
||||||
|
handler.bind(context());
|
||||||
|
|
||||||
|
Object answer = handler.handle(request("<Order><reference>A-1</reference></Order>"), response());
|
||||||
|
|
||||||
|
assertEquals("A-1", answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aBodyThatBreaksAConstraintNeverReachesTheHandler() throws Exception {
|
||||||
|
Place handler = new Place();
|
||||||
|
handler.bind(context());
|
||||||
|
|
||||||
|
HttpException refused = assertThrows(HttpException.class,
|
||||||
|
() -> handler.handle(request("<Order><reference></reference></Order>"), response()));
|
||||||
|
|
||||||
|
assertEquals(422, refused.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aMalformedBodyIsABadRequest() throws Exception {
|
||||||
|
Place handler = new Place();
|
||||||
|
handler.bind(context());
|
||||||
|
|
||||||
|
HttpException refused = assertThrows(HttpException.class,
|
||||||
|
() -> handler.handle(request("<Order><reference>"), response()));
|
||||||
|
|
||||||
|
assertEquals(400, refused.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FlashContext context() {
|
||||||
|
FlashContext ctx = new FlashContext();
|
||||||
|
ctx.provide(Xml.class, new Xml(XmlMapper.builder().build()));
|
||||||
|
ctx.complete();
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Response response() {
|
||||||
|
return new Response(200, ContentType.XML);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Request request(String body) {
|
||||||
|
return new Request(new RequestLine(HttpMethod.POST,
|
||||||
|
new FastPathViews.StringByteView("/orders"), null,
|
||||||
|
new FastPathViews.StringByteView("HTTP/1.1"), new Http1HeaderMap()),
|
||||||
|
body.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
# flash-ext-jackson
|
|
||||||
|
|
||||||
Jackson JSON integration for Flash with an opinionated auto-marshal middleware.
|
|
||||||
|
|
||||||
## What it provides
|
|
||||||
|
|
||||||
| Component | Description |
|
|
||||||
|---|---|
|
|
||||||
| `JacksonExtension` | Registers JSON services into `FlashContext` |
|
|
||||||
| `Json` | JSON read/write helper (`body`, `bodyFrom`, `write`, `writeView`) |
|
|
||||||
| `ObjectMapper` | Raw mapper escape hatch for advanced usage |
|
|
||||||
| `JacksonMiddleware` | `autoJson()` middleware for automatic outbound JSON marshalling |
|
|
||||||
|
|
||||||
Default mapper behavior (`new JacksonExtension()`):
|
|
||||||
|
|
||||||
- auto-discovers Jackson modules on classpath (`findAndAddModules()`)
|
|
||||||
- includes Java Time support (`jackson-datatype-jsr310`)
|
|
||||||
- writes date/time values as ISO-8601 strings (not numeric timestamps)
|
|
||||||
|
|
||||||
## Recommended default
|
|
||||||
|
|
||||||
Install the extension, then apply `autoJson()` once at app or scope level.
|
|
||||||
|
|
||||||
```java
|
|
||||||
JacksonExtension jackson = new JacksonExtension();
|
|
||||||
|
|
||||||
FlashApp app = FlashApp.create(8080)
|
|
||||||
.install(jackson)
|
|
||||||
.use(jackson.autoJson());
|
|
||||||
|
|
||||||
app.startAndBlock();
|
|
||||||
```
|
|
||||||
|
|
||||||
Behavior of `autoJson()`:
|
|
||||||
|
|
||||||
- pass-through: `null`, `Response`, `byte[]`, `String`, `CharSequence`
|
|
||||||
- any other return value: serialize to JSON `byte[]`
|
|
||||||
- sets `Content-Type: application/json` for marshalled responses
|
|
||||||
- serialization failures throw `IllegalStateException`
|
|
||||||
|
|
||||||
This keeps handlers concise while preserving Flash's direct byte write path.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-ext-jackson</artifactId>
|
|
||||||
<version>1.1-indev2</version>
|
|
||||||
</dependency>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Json helper API
|
|
||||||
|
|
||||||
Use `Json` when you want explicit, local control in a handler.
|
|
||||||
|
|
||||||
```java
|
|
||||||
@POST("/users")
|
|
||||||
public final class CreateUser extends RequestHandler {
|
|
||||||
private Json json;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onInit() {
|
|
||||||
json = require(Json.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Object handle(Request req, Response res) throws Exception {
|
|
||||||
CreateUserBody body = json.body(req, CreateUserBody.class);
|
|
||||||
UserDto created = service.create(body);
|
|
||||||
res.status(201);
|
|
||||||
return json.write(res, created);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
|
|
||||||
- `body(req, Type.class)` -> parse from `req.body().bytes()`
|
|
||||||
- `bodyFrom(req, Type.class)` -> parse from `req.body().stream()`
|
|
||||||
- `write(res, obj)` -> writes JSON string and sets JSON content type
|
|
||||||
- `writeView(res, obj, View.class)` -> JSON with Jackson `@JsonView`
|
|
||||||
- `mapper()` -> raw `ObjectMapper`
|
|
||||||
|
|
||||||
## Custom mapper
|
|
||||||
|
|
||||||
```java
|
|
||||||
ObjectMapper mapper = JsonMapper.builder()
|
|
||||||
.addModule(new JavaTimeModule())
|
|
||||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
FlashApp.create(8080)
|
|
||||||
.install(new JacksonExtension(mapper));
|
|
||||||
```
|
|
||||||
|
|
||||||
## Scope usage
|
|
||||||
|
|
||||||
`autoJson()` works the same at scope level:
|
|
||||||
|
|
||||||
```java
|
|
||||||
JacksonExtension jackson = new JacksonExtension();
|
|
||||||
|
|
||||||
app.mount("/api", api -> {
|
|
||||||
api.use(jackson.autoJson());
|
|
||||||
api.get("/health", (req, res) -> Map.of("ok", true));
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
If you need to pull it from context, `JacksonMiddleware` is also provided as a service
|
|
||||||
after the app boots (same lifecycle model as other extension-provided services).
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Install order is irrelevant (Flash two-phase extension lifecycle).
|
|
||||||
- `autoJson()` and OpenAPI are intentionally decoupled.
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
|
||||||
<modelVersion>4.0.0</modelVersion>
|
|
||||||
|
|
||||||
<parent>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-extensions</artifactId>
|
|
||||||
<version>2.1.0-SNAPSHOT</version>
|
|
||||||
</parent>
|
|
||||||
|
|
||||||
<artifactId>flash-ext-jackson</artifactId>
|
|
||||||
|
|
||||||
<properties>
|
|
||||||
<jacoco.version>0.8.12</jacoco.version>
|
|
||||||
</properties>
|
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.fasterxml.jackson.core</groupId>
|
|
||||||
<artifactId>jackson-databind</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
|
||||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.projectlombok</groupId>
|
|
||||||
<artifactId>lombok</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.junit.jupiter</groupId>
|
|
||||||
<artifactId>junit-jupiter</artifactId>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
|
|
||||||
<build>
|
|
||||||
<plugins>
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.jacoco</groupId>
|
|
||||||
<artifactId>jacoco-maven-plugin</artifactId>
|
|
||||||
<version>${jacoco.version}</version>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<id>jacoco-prepare-agent</id>
|
|
||||||
<goals>
|
|
||||||
<goal>prepare-agent</goal>
|
|
||||||
</goals>
|
|
||||||
</execution>
|
|
||||||
<execution>
|
|
||||||
<id>jacoco-report-and-check</id>
|
|
||||||
<phase>verify</phase>
|
|
||||||
<goals>
|
|
||||||
<goal>report</goal>
|
|
||||||
<goal>check</goal>
|
|
||||||
</goals>
|
|
||||||
<configuration>
|
|
||||||
<rules>
|
|
||||||
<rule>
|
|
||||||
<element>BUNDLE</element>
|
|
||||||
<limits>
|
|
||||||
<limit>
|
|
||||||
<counter>LINE</counter>
|
|
||||||
<value>COVEREDRATIO</value>
|
|
||||||
<minimum>0.80</minimum>
|
|
||||||
</limit>
|
|
||||||
</limits>
|
|
||||||
</rule>
|
|
||||||
</rules>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
|
|
||||||
</project>
|
|
||||||
-92
@@ -1,92 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
|
||||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
|
||||||
import dev.relism.flash.extension.FlashContext;
|
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
|
||||||
import dev.relism.flash.routing.Middleware;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers JSON support into the Flash extension layer.
|
|
||||||
*
|
|
||||||
* <p>Exposes a {@link Json} utility instance in the {@link FlashContext} under
|
|
||||||
* {@code Json.class}. Any handler or extension can retrieve it via {@code ctx.require(Json.class)}
|
|
||||||
* inside {@code onInit()} (class-based) or inside {@link FlashExtension#routes} (extensions).
|
|
||||||
*
|
|
||||||
* <p>The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class}
|
|
||||||
* for extensions that need direct mapper access (e.g. OpenAPI schema generation).
|
|
||||||
*
|
|
||||||
* <p>{@link JacksonMiddleware} is provided under {@code JacksonMiddleware.class} and
|
|
||||||
* exposes opinionated JSON auto-marshalling middleware via {@link JacksonMiddleware#autoJson()}.
|
|
||||||
*
|
|
||||||
* <h3>Usage — composition (preferred)</h3>
|
|
||||||
* <pre>{@code
|
|
||||||
* public class MyHandler extends RequestHandler {
|
|
||||||
* private Json json;
|
|
||||||
*
|
|
||||||
* @Override protected void onInit() {
|
|
||||||
* json = require(Json.class);
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* public Object handle(Request req, Response res) throws Exception {
|
|
||||||
* MyDto dto = json.body(req, MyDto.class);
|
|
||||||
* return json.write(res, 201, dto);
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* }</pre>
|
|
||||||
*
|
|
||||||
* <h3>Custom mapper</h3>
|
|
||||||
* <pre>{@code
|
|
||||||
* ObjectMapper mapper = JsonMapper.builder()
|
|
||||||
* .addModule(new JavaTimeModule())
|
|
||||||
* .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
|
||||||
* .build();
|
|
||||||
*
|
|
||||||
* FlashApp.create(8080)
|
|
||||||
* .install(new JacksonExtension(mapper));
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
public class JacksonExtension implements FlashExtension {
|
|
||||||
|
|
||||||
private final ObjectMapper mapper;
|
|
||||||
private final JacksonMiddleware middleware;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Installs with an opinionated default {@link JsonMapper}:
|
|
||||||
* auto-discovers modules on classpath (e.g. Java Time) and writes dates as ISO strings.
|
|
||||||
*/
|
|
||||||
public JacksonExtension() {
|
|
||||||
this(JsonMapper.builder()
|
|
||||||
.findAndAddModules()
|
|
||||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
|
||||||
.build());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Installs with a fully configured custom {@link ObjectMapper}. */
|
|
||||||
public JacksonExtension(ObjectMapper mapper) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
this.middleware = new JacksonMiddleware(mapper);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opinionated outbound JSON middleware factory.
|
|
||||||
*
|
|
||||||
* <p>Use for app/scope-level registration:
|
|
||||||
* <pre>{@code
|
|
||||||
* JacksonExtension jackson = new JacksonExtension();
|
|
||||||
* app.install(jackson).use(jackson.autoJson());
|
|
||||||
* }</pre>
|
|
||||||
*/
|
|
||||||
public Middleware autoJson() {
|
|
||||||
return middleware.autoJson();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void provide(FlashContext ctx) {
|
|
||||||
Json json = new Json(mapper);
|
|
||||||
ctx.provide(Json.class, json);
|
|
||||||
ctx.provide(ObjectMapper.class, mapper);
|
|
||||||
ctx.provide(JacksonMiddleware.class, middleware);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-62
@@ -1,62 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import dev.relism.flash.http.ContentType;
|
|
||||||
import dev.relism.flash.models.Response;
|
|
||||||
import dev.relism.flash.routing.Middleware;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Outbound JSON marshalling middleware for class-based and lambda routes.
|
|
||||||
*
|
|
||||||
* <p>{@link #autoJson()} marshals any non-body-native return value to JSON bytes,
|
|
||||||
* writes {@code Content-Type: application/json}, and returns {@code byte[]} so
|
|
||||||
* the Flash write path stays direct.
|
|
||||||
*
|
|
||||||
* <p>Pass-through return types:
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code null}</li>
|
|
||||||
* <li>{@link Response}</li>
|
|
||||||
* <li>{@code byte[]}</li>
|
|
||||||
* <li>{@link String}</li>
|
|
||||||
* <li>{@link CharSequence}</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
public final class JacksonMiddleware {
|
|
||||||
|
|
||||||
private final ObjectMapper mapper;
|
|
||||||
|
|
||||||
JacksonMiddleware(ObjectMapper mapper) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Automatic JSON marshalling policy.
|
|
||||||
*
|
|
||||||
* <p>For non-pass-through return values, serializes with Jackson directly to
|
|
||||||
* {@code byte[]} and sets response content type to JSON.
|
|
||||||
*
|
|
||||||
* @throws IllegalStateException when serialization fails
|
|
||||||
*/
|
|
||||||
public Middleware autoJson() {
|
|
||||||
return next -> (req, res) -> {
|
|
||||||
Object out = next.handle(req, res);
|
|
||||||
if (isPassThrough(out)) return out;
|
|
||||||
|
|
||||||
res.type(ContentType.JSON);
|
|
||||||
try {
|
|
||||||
return mapper.writeValueAsBytes(out);
|
|
||||||
} catch (JsonProcessingException e) {
|
|
||||||
throw new IllegalStateException(
|
|
||||||
"Failed to serialize handler result as JSON: " + out.getClass().getName(), e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isPassThrough(Object out) {
|
|
||||||
return out == null
|
|
||||||
|| out instanceof Response
|
|
||||||
|| out instanceof byte[]
|
|
||||||
|| out instanceof CharSequence;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-117
@@ -1,117 +0,0 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import dev.relism.flash.exceptions.HttpException;
|
|
||||||
import dev.relism.flash.http.ContentType;
|
|
||||||
import dev.relism.flash.models.Request;
|
|
||||||
import dev.relism.flash.models.Response;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thread-safe JSON toolbox. Single point of access for all JSON I/O operations
|
|
||||||
* within a Flash application.
|
|
||||||
*
|
|
||||||
* <p>Retrieve once at boot time via {@code require(Json.class)} inside
|
|
||||||
* {@code onInit()}, cache in a private field, and call on the hot path
|
|
||||||
* with zero lookup or allocation overhead:
|
|
||||||
*
|
|
||||||
* <pre>{@code
|
|
||||||
* @Route(method = HttpMethod.POST, path = "/api/items")
|
|
||||||
* public class CreateItemHandler extends RequestHandler {
|
|
||||||
*
|
|
||||||
* private Json json;
|
|
||||||
*
|
|
||||||
* @Override
|
|
||||||
* protected void onInit() {
|
|
||||||
* json = require(Json.class);
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* public Object handle(Request req, Response res) throws Exception {
|
|
||||||
* CreateItemRequest body = json.body(req, CreateItemRequest.class);
|
|
||||||
* return json.write(res, itemService.create(body));
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* }</pre>
|
|
||||||
*
|
|
||||||
* <p>The underlying {@link ObjectMapper} is shared across all handlers in the same
|
|
||||||
* scope (one instance per app / per child scope). Jackson's {@code ObjectMapper}
|
|
||||||
* is fully thread-safe after configuration — no synchronization is needed.
|
|
||||||
*
|
|
||||||
* <p>Install via {@link JacksonExtension} before calling {@code scan()} or
|
|
||||||
* {@code register()}.
|
|
||||||
*/
|
|
||||||
public final class Json {
|
|
||||||
|
|
||||||
private final ObjectMapper mapper;
|
|
||||||
|
|
||||||
/** Package-private — constructed exclusively by {@link JacksonExtension}. */
|
|
||||||
Json(ObjectMapper mapper) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Input ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deserializes the full request body into an instance of {@code type}.
|
|
||||||
*
|
|
||||||
* <p>Reads {@code req.body().bytes()} in one shot. For streaming bodies
|
|
||||||
* use {@link #bodyFrom(Request, Class)} instead.
|
|
||||||
*
|
|
||||||
* @throws HttpException 400 if the body cannot be parsed as {@code type}
|
|
||||||
*/
|
|
||||||
public <T> T body(Request req, Class<T> type) throws Exception {
|
|
||||||
try {
|
|
||||||
return mapper.readValue(req.body().bytes(), type);
|
|
||||||
} catch (JsonProcessingException e) {
|
|
||||||
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deserializes the request body via the raw {@link java.io.InputStream},
|
|
||||||
* avoiding the intermediate {@code byte[]} allocation. Prefer this for
|
|
||||||
* large bodies or when allocation budget is tight.
|
|
||||||
*
|
|
||||||
* @throws HttpException 400 on parse failure
|
|
||||||
*/
|
|
||||||
public <T> T bodyFrom(Request req, Class<T> type) throws Exception {
|
|
||||||
try {
|
|
||||||
return mapper.readValue(req.body().stream(), type);
|
|
||||||
} catch (JsonProcessingException e) {
|
|
||||||
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Output ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serializes {@code obj} to a JSON string and sets
|
|
||||||
* {@code Content-Type: application/json} on the response.
|
|
||||||
*
|
|
||||||
* <p>The returned string is used as the response body by the Flash runtime.
|
|
||||||
*/
|
|
||||||
public String write(Response res, Object obj) throws Exception {
|
|
||||||
res.type(ContentType.JSON);
|
|
||||||
return mapper.writeValueAsString(obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Like {@link #write} but applies a Jackson {@code @JsonView} filter,
|
|
||||||
* restricting serialization to fields visible under {@code view}.
|
|
||||||
*/
|
|
||||||
public String writeView(Response res, Object obj, Class<?> view) throws Exception {
|
|
||||||
res.type(ContentType.JSON);
|
|
||||||
return mapper.writerWithView(view).writeValueAsString(obj);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Escape hatch ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the underlying {@link ObjectMapper} for advanced operations
|
|
||||||
* (custom serialization, schema generation, etc.) not covered by the
|
|
||||||
* methods above.
|
|
||||||
*/
|
|
||||||
public ObjectMapper mapper() {
|
|
||||||
return mapper;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -36,7 +36,7 @@ FlashApp.create(8080)
|
|||||||
// With custom resolvers
|
// With custom resolvers
|
||||||
LimiterConfig conf = new LimiterConfig()
|
LimiterConfig conf = new LimiterConfig()
|
||||||
.registerResolver("auth_user", req ->
|
.registerResolver("auth_user", req ->
|
||||||
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
|
SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous");
|
||||||
|
|
||||||
FlashApp.create(8080)
|
FlashApp.create(8080)
|
||||||
.install(new LimiterExtension(conf))
|
.install(new LimiterExtension(conf))
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ conf.registerResolver("ip", req -> {
|
|||||||
LimiterConfig conf = new LimiterConfig();
|
LimiterConfig conf = new LimiterConfig();
|
||||||
```
|
```
|
||||||
|
|
||||||
### By authenticated user (OIDC / ClaimsHolder)
|
### By authenticated user
|
||||||
|
|
||||||
```java
|
```java
|
||||||
conf.registerResolver("auth_user", req ->
|
conf.registerResolver("auth_user", req ->
|
||||||
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
|
SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous");
|
||||||
```
|
```
|
||||||
|
|
||||||
Requests from unauthenticated users share the `"anonymous"` bucket. If you want
|
Requests from unauthenticated users share the `"anonymous"` bucket. If you want
|
||||||
@@ -88,7 +88,7 @@ returns the same key for the same user regardless of endpoint; the limit is set
|
|||||||
|
|
||||||
```java
|
```java
|
||||||
conf.registerResolver("auth_user", req ->
|
conf.registerResolver("auth_user", req ->
|
||||||
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anon");
|
SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anon");
|
||||||
```
|
```
|
||||||
|
|
||||||
```java
|
```java
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ import dev.relism.flash.models.Request;
|
|||||||
*
|
*
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* conf.registerResolver("ip", req -> req.header("X-Forwarded-For"));
|
* conf.registerResolver("ip", req -> req.header("X-Forwarded-For"));
|
||||||
* conf.registerResolver("auth_user", req -> ClaimsHolder.user().sub());
|
* conf.registerResolver("auth_user", req -> SecurityIdentity.current().principal().name());
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*/
|
*/
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
|
|||||||
+2
-2
@@ -21,8 +21,8 @@ import java.util.Map;
|
|||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* LimiterConfig conf = new LimiterConfig()
|
* LimiterConfig conf = new LimiterConfig()
|
||||||
* .registerResolver("auth_user", req -> {
|
* .registerResolver("auth_user", req -> {
|
||||||
* // custom logic — e.g. extract sub from ClaimsHolder
|
* // custom logic — e.g. key by the authenticated caller
|
||||||
* return ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous";
|
* return SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous";
|
||||||
* });
|
* });
|
||||||
*
|
*
|
||||||
* app.install(new LimiterExtension(conf));
|
* app.install(new LimiterExtension(conf));
|
||||||
|
|||||||
+10
-16
@@ -5,12 +5,13 @@ import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
|
|||||||
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
|
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
|
||||||
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
||||||
import dev.relism.flash.extension.AnnotationProcessor;
|
import dev.relism.flash.extension.AnnotationProcessor;
|
||||||
import dev.relism.flash.extension.ExtensionPhase;
|
import dev.relism.flash.extension.FlashRegistrar;
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
import dev.relism.flash.extension.FlashRegistrar;
|
|
||||||
import dev.relism.flash.http.HttpStatus;
|
import dev.relism.flash.http.HttpStatus;
|
||||||
import dev.relism.flash.routing.Middleware;
|
import dev.relism.flash.routing.Middleware;
|
||||||
|
import dev.relism.flash.routing.MiddlewareKey;
|
||||||
|
import dev.relism.flash.routing.MiddlewareNode;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -42,23 +43,18 @@ import java.util.Map;
|
|||||||
* <h3>Lambda routes (via Guard)</h3>
|
* <h3>Lambda routes (via Guard)</h3>
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* app.install(new LimiterExtension(
|
* app.install(new LimiterExtension(
|
||||||
* new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub())));
|
* new LimiterConfig().registerResolver("auth_user", req -> SecurityIdentity.current().principal().name())));
|
||||||
*
|
*
|
||||||
* // inside FlashExtension.routes() or after install():
|
* // inside a FlashContext.onReady(...) callback:
|
||||||
* Guard guard = ctx.require(Guard.class);
|
* Guard guard = ctx.require(Guard.class);
|
||||||
* app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS));
|
* app.get("/api/search", handler, guard.limit("ip", 50, 1, TimeUnit.SECONDS));
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*/
|
*/
|
||||||
public final class LimiterExtension implements FlashExtension {
|
public final class LimiterExtension implements FlashExtension {
|
||||||
|
private static final MiddlewareKey LIMIT = MiddlewareKey.of("flash.limiter.limit");
|
||||||
|
|
||||||
private final LimiterConfig config;
|
private final LimiterConfig config;
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiting runs before authentication — cheaper check rejects over-limit
|
|
||||||
* requests before any token validation occurs.
|
|
||||||
*/
|
|
||||||
@Override public int priority() { return ExtensionPhase.EARLY.value; }
|
|
||||||
|
|
||||||
/** Installs with default config (only the built-in {@code "ip"} resolver). */
|
/** Installs with default config (only the built-in {@code "ip"} resolver). */
|
||||||
public LimiterExtension() {
|
public LimiterExtension() {
|
||||||
this(new LimiterConfig());
|
this(new LimiterConfig());
|
||||||
@@ -70,7 +66,7 @@ public final class LimiterExtension implements FlashExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void provide(FlashContext ctx) {
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
BucketStore store = new BucketStore();
|
BucketStore store = new BucketStore();
|
||||||
Guard guard = new Guard(config, store);
|
Guard guard = new Guard(config, store);
|
||||||
|
|
||||||
@@ -90,17 +86,15 @@ public final class LimiterExtension implements FlashExtension {
|
|||||||
ann.strategy().create()
|
ann.strategy().create()
|
||||||
);
|
);
|
||||||
|
|
||||||
return List.of(buildMiddleware(resolver, cfg, store));
|
return List.of(MiddlewareNode.of(LIMIT, buildMiddleware(resolver, cfg, store)));
|
||||||
});
|
});
|
||||||
}
|
ctx.onReady(() -> {
|
||||||
|
|
||||||
@Override
|
|
||||||
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
|
|
||||||
try {
|
try {
|
||||||
OpenApiIntegration.register(ctx);
|
OpenApiIntegration.register(ctx);
|
||||||
} catch (NoClassDefFoundError ignored) {
|
} catch (NoClassDefFoundError ignored) {
|
||||||
// flash-ext-openapi not available — OpenAPI integration disabled
|
// flash-ext-openapi not available — OpenAPI integration disabled
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Package-private helper — shared with Guard ────────────────────────────
|
// ── Package-private helper — shared with Guard ────────────────────────────
|
||||||
|
|||||||
+6
-3
@@ -41,7 +41,8 @@ class LimiterOpenApiInteropTest {
|
|||||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||||
ctx.provide(OpenApiContributorRegistry.class, registry);
|
ctx.provide(OpenApiContributorRegistry.class, registry);
|
||||||
|
|
||||||
new LimiterExtension().routes(null, ctx);
|
new LimiterExtension().configure(null, ctx);
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
assertEquals(1, registry.contributors().size());
|
assertEquals(1, registry.contributors().size());
|
||||||
}
|
}
|
||||||
@@ -51,7 +52,8 @@ class LimiterOpenApiInteropTest {
|
|||||||
FlashContext ctx = new FlashContext();
|
FlashContext ctx = new FlashContext();
|
||||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||||
ctx.provide(OpenApiContributorRegistry.class, registry);
|
ctx.provide(OpenApiContributorRegistry.class, registry);
|
||||||
new LimiterExtension().routes(null, ctx);
|
new LimiterExtension().configure(null, ctx);
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
OpenApiContributor contributor = registry.contributors().getFirst();
|
OpenApiContributor contributor = registry.contributors().getFirst();
|
||||||
OpenApiOperationContribution operation = contributor.operationFor(LimitedHandler.class);
|
OpenApiOperationContribution operation = contributor.operationFor(LimitedHandler.class);
|
||||||
@@ -73,7 +75,8 @@ class LimiterOpenApiInteropTest {
|
|||||||
FlashContext ctx = new FlashContext();
|
FlashContext ctx = new FlashContext();
|
||||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||||
ctx.provide(OpenApiContributorRegistry.class, registry);
|
ctx.provide(OpenApiContributorRegistry.class, registry);
|
||||||
new LimiterExtension().routes(null, ctx);
|
new LimiterExtension().configure(null, ctx);
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
OpenApiContributor contributor = registry.contributors().getFirst();
|
OpenApiContributor contributor = registry.contributors().getFirst();
|
||||||
OpenApiOperationContribution operation = contributor.operationFor(PlainHandler.class);
|
OpenApiOperationContribution operation = contributor.operationFor(PlainHandler.class);
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# flash-ext-mcp
|
||||||
|
|
||||||
|
`flash-ext-mcp` turns a Flash5 app into an [MCP](https://modelcontextprotocol.io) (Model Context
|
||||||
|
Protocol) server: JSON-RPC 2.0 over the Streamable HTTP transport, tools/resources/prompts
|
||||||
|
declared as plain classes and discovered at boot, optional OAuth2 protection built on
|
||||||
|
`flash-ext-security-core`.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```java
|
||||||
|
FlashApp.create(8080)
|
||||||
|
.install(new McpExtension(McpConfig.builder("my-mcp-server")
|
||||||
|
.toolsPackage("com.example.tools")
|
||||||
|
.build()))
|
||||||
|
.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Tool(name = "get_weather", description = "Get current weather for a city",
|
||||||
|
args = @ToolArg(name = "city", description = "City name", required = true))
|
||||||
|
public class GetWeatherTool extends McpTool {
|
||||||
|
|
||||||
|
private WeatherService weatherService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onInit() {
|
||||||
|
weatherService = require(WeatherService.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ToolResponse call(ToolArguments args) {
|
||||||
|
return ToolResponse.success(new TextContent(weatherService.fetch(args.getString("city"))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operating Model
|
||||||
|
|
||||||
|
- **One class per tool/resource/prompt** — mirrors `RequestHandler`: a no-arg constructor,
|
||||||
|
`onInit()` to cache services from `FlashContext`, one hot-path method
|
||||||
|
(`call`/`read`/`render`). No CDI, no field injection, no reflection on the hot path.
|
||||||
|
- **Boot-time precompilation** — `tools/list`/`resources/list`/`prompts/list` JSON payloads
|
||||||
|
(including JSON Schema) are built once at boot and spliced verbatim into responses. See
|
||||||
|
`tools-resources-prompts.md`.
|
||||||
|
- **Transport**: Streamable HTTP, `POST`-only, stateless in this revision — see `transport.md`
|
||||||
|
for exactly what that means and why.
|
||||||
|
- **Security**: authenticated by `flash-ext-security-core`, an OAuth2 protected resource when OIDC is installed — see `security.md`.
|
||||||
|
- **JSON**: this extension owns its JSON handling independently of `flash-ext-jackson` — see
|
||||||
|
`jackson-interop.md` for why, and how a future opt-in reuse could work.
|
||||||
|
|
||||||
|
## Documents
|
||||||
|
|
||||||
|
- [`tools-resources-prompts.md`](tools-resources-prompts.md) — defining tools, resources, prompts
|
||||||
|
- [`transport.md`](transport.md) — Streamable HTTP scope, session/SSE limitations, Origin validation
|
||||||
|
- [`security.md`](security.md) — `McpSecurity` policy, OAuth2 resolution, RFC 9728 / RFC 8707
|
||||||
|
- [`jackson-interop.md`](jackson-interop.md) — why this extension does not depend on `flash-ext-jackson`
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Why no `flash-ext-jackson` interop (yet)
|
||||||
|
|
||||||
|
## The decision
|
||||||
|
|
||||||
|
`flash-ext-mcp` does not depend on, or integrate with, `flash-ext-jackson`. It brings its own
|
||||||
|
JSON handling (`jackson-databind`/`jackson-core` as a plain library dependency, wrapped by the
|
||||||
|
internal `McpJson` utility) and never touches `flash-ext-jackson`'s `Json`/`JacksonMiddleware`/
|
||||||
|
shared `ObjectMapper`, even if the host app has `flash-ext-jackson` installed. This was a
|
||||||
|
deliberate choice, discussed and made explicitly — not an oversight — and is written down here
|
||||||
|
so it isn't accidentally "fixed" later without re-litigating the trade-off.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`flash-ext-jackson`'s `Json` class is built around full databinding:
|
||||||
|
`mapper.readValue(bytes, SomeDto.class)` / `mapper.writeValueAsBytes(obj)` — reflection-driven
|
||||||
|
property matching in both directions. The MCP JSON-RPC envelope has a **fixed, known shape**
|
||||||
|
(`{jsonrpc, id, method, params}` in, `{jsonrpc, id, result|error}` out) defined by a spec, not by
|
||||||
|
application DTOs. Given that, hand-writing it with `JsonGenerator` directly is both simpler and
|
||||||
|
strictly cheaper than round-tripping through databinding: no property-name matching, no
|
||||||
|
reflection, no intermediate POJO graph for the parts of the response this extension controls
|
||||||
|
(the envelope itself, `tools/list`/`resources/list`/`prompts/list` — precompiled once at boot,
|
||||||
|
see `tools-resources-prompts.md` — and the fixed `TextContent`/`TextResourceContents`/
|
||||||
|
`PromptMessage` shapes). `ToolArguments`/`PromptArguments` read the incoming `arguments` object
|
||||||
|
as a `JsonNode` tree, not as a databound class, for the same reason — a JSON-RPC tool call's
|
||||||
|
arguments aren't a DTO with getters/setters, they're a dynamic, per-tool-defined bag of values.
|
||||||
|
|
||||||
|
This mirrors how `flash-ext-security-oidc` handles its own JSON needs (Nimbus's parser for
|
||||||
|
token-endpoint responses) independently of `flash-ext-jackson` — extensions with protocol-level
|
||||||
|
JSON needs that are shaped by a spec, not by user code, own that JSON handling themselves rather
|
||||||
|
than routing it through the app's general-purpose JSON extension.
|
||||||
|
|
||||||
|
## What this means practically
|
||||||
|
|
||||||
|
- Installing `flash-ext-mcp` never requires installing `flash-ext-jackson`. A pure MCP server
|
||||||
|
with no other JSON REST routes has zero unrelated dependencies to configure.
|
||||||
|
- If the host app *does* have `flash-ext-jackson` installed for its own REST routes, that
|
||||||
|
`ObjectMapper`'s configuration (custom modules, date formatting, naming strategy, etc.) is
|
||||||
|
**not** consulted by `flash-ext-mcp` — the two JSON paths are entirely independent today.
|
||||||
|
|
||||||
|
## What a future opt-in reuse could look like
|
||||||
|
|
||||||
|
Nothing here rules out a later, additive convenience layer: `McpExtension.routes()` could check
|
||||||
|
`ctx.find(ObjectMapper.class)` (populated by `JacksonExtension.provide()`) and, if present, use
|
||||||
|
that shared mapper as the backing for an escape hatch such as `ToolArguments.as(Class<T>)` or
|
||||||
|
for a tool that wants to `ToolResponse.success(someRecord)` and have it serialized with the
|
||||||
|
app's own conventions — falling back to a locally-constructed default `ObjectMapper` when
|
||||||
|
`flash-ext-jackson` isn't installed. That would be purely additive on top of the
|
||||||
|
`JsonGenerator`-based envelope/content writing described above, not a replacement for it — the
|
||||||
|
fixed-shape protocol plumbing has no reason to ever go through databinding, regardless of what
|
||||||
|
convenience layer gets added around it.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
The MCP endpoint is secured by [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md):
|
||||||
|
whatever mechanisms the application registers — OAuth2 bearer tokens, API keys, custom ones —
|
||||||
|
authenticate `/mcp` exactly as they authenticate every other route.
|
||||||
|
|
||||||
|
| `McpConfig.security(...)` | |
|
||||||
|
|---|---|
|
||||||
|
| `REQUIRED` (default) | every call must be authenticated; boot fails without a `SecurityExtension` |
|
||||||
|
| `NONE` | a public endpoint; a tool carrying security annotations fails the boot |
|
||||||
|
|
||||||
|
## OAuth2 protected resource
|
||||||
|
|
||||||
|
When a registered mechanism publishes an OAuth2 issuer — `flash-ext-security-oidc` does — the endpoint
|
||||||
|
behaves as the MCP authorization spec requires, with nothing to configure:
|
||||||
|
|
||||||
|
- `GET /.well-known/oauth-protected-resource/mcp` serves RFC 9728 metadata: the `resource` (the
|
||||||
|
application's `SecurityExtension.origin(...)` plus the path), every issuer as `authorization_servers`,
|
||||||
|
and `scopes_supported` when `McpConfig.scopesSupported(...)` is set;
|
||||||
|
- an anonymous call gets `401` with `WWW-Authenticate: Bearer resource_metadata="…"`;
|
||||||
|
- a token whose `aud` does not include the resource is `403` (RFC 8707) and logged at `WARN`. Credentials
|
||||||
|
that are not audience-bound, such as API keys, are unaffected.
|
||||||
|
|
||||||
|
For Keycloak, the audience comes from an *Audience* protocol mapper whose included custom audience is
|
||||||
|
the resource URL, attached to a client scope every MCP client receives (the built-in `basic` scope is the
|
||||||
|
one that needs no client cooperation). Clients that register dynamically need Keycloak's anonymous
|
||||||
|
client registration policies relaxed for the trusted hosts.
|
||||||
|
|
||||||
|
`McpConfig.requireTokenAudience(false)` drops that last check for an authorization server that cannot
|
||||||
|
mint a resource audience at all — Keycloak ignores RFC 8707's `resource` parameter, so a deployment that
|
||||||
|
cannot add the mapper has no other way in. Every token a registered issuer signs is then accepted on the
|
||||||
|
endpoint, and the boot logs say so.
|
||||||
|
|
||||||
|
## Which credentials
|
||||||
|
|
||||||
|
By default every mechanism in the chain authenticates `/mcp`, and the session cookie too.
|
||||||
|
`McpConfig.mechanisms(...)` narrows that to the ones named: nothing else is a credential on the endpoint,
|
||||||
|
and only their issuers are published — so a client is sent to exactly the authorization server the
|
||||||
|
endpoint trusts.
|
||||||
|
|
||||||
|
```java
|
||||||
|
McpConfig.builder("app").toolsPackage("com.example.tools").mechanisms(authorizationServer).build();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tool policies
|
||||||
|
|
||||||
|
The core annotations work on tools as on handlers, checked per `tools/call` against the caller the route
|
||||||
|
authenticated:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Tool(name = "approve", description = "Approves a pending proposal")
|
||||||
|
@RolesAllowed(value = "REVIEWER", on = {"project", "locale"}) // read from the tool's arguments
|
||||||
|
public class ApproveTool extends McpTool { … }
|
||||||
|
```
|
||||||
|
|
||||||
|
A denial is a tool result with `isError: true` — the call reached the server, the tool did not run.
|
||||||
|
|
||||||
|
`McpConfig.middleware(...)` runs after authentication, for rate limiting, auditing or tracing.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Tools, Resources, Prompts
|
||||||
|
|
||||||
|
## One class per feature
|
||||||
|
|
||||||
|
Every tool, resource, and prompt is its own class — the same shape as a Flash `RequestHandler`,
|
||||||
|
minus the HTTP-specific bits:
|
||||||
|
|
||||||
|
```java
|
||||||
|
public abstract class McpTool {
|
||||||
|
protected void onInit() {} // cache services here, once, at boot
|
||||||
|
protected <T> T require(Class<T> type) { ... } // FlashContext lookup
|
||||||
|
public abstract ToolResponse call(ToolArguments args) throws Exception; // hot path
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`McpResource` (`read()`) and `McpPrompt` (`render(PromptArguments)`) follow the exact same
|
||||||
|
shape. There is deliberately no CDI-style `@Inject` and no method-per-tool bean class — Flash5
|
||||||
|
handlers are classes, and MCP features follow that convention.
|
||||||
|
|
||||||
|
## Declaring metadata
|
||||||
|
|
||||||
|
Metadata (name, description, input schema) lives entirely in the annotation, not in reflected
|
||||||
|
method signatures — the whole JSON Schema is known at scan time and compiled once:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Tool(
|
||||||
|
name = "get_weather",
|
||||||
|
description = "Get current weather for a city",
|
||||||
|
args = {
|
||||||
|
@ToolArg(name = "city", description = "City name", required = true),
|
||||||
|
@ToolArg(name = "days", type = ToolArgType.INTEGER, description = "Forecast horizon")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
public class GetWeatherTool extends McpTool {
|
||||||
|
@Override
|
||||||
|
public ToolResponse call(ToolArguments args) {
|
||||||
|
String city = args.getString("city");
|
||||||
|
int days = args.getInt("days", 1);
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ToolArgType` maps directly to JSON Schema primitive types: `STRING`, `INTEGER`, `NUMBER`,
|
||||||
|
`BOOLEAN`, `OBJECT`, `ARRAY`. Nested object/array schemas beyond the primitive type keyword are
|
||||||
|
not modeled in this revision — declare those tools with a looser `OBJECT`/`ARRAY` type and parse
|
||||||
|
the raw shape via `ToolArguments.raw(name)`.
|
||||||
|
|
||||||
|
`ToolArguments`/`PromptArguments` are thin typed accessors over the already-parsed JSON — no
|
||||||
|
databinding, no reflection, no intermediate DTO:
|
||||||
|
|
||||||
|
```java
|
||||||
|
args.getString("city");
|
||||||
|
args.getInt("days", 1);
|
||||||
|
args.getBoolean("metric", true);
|
||||||
|
args.raw("filters"); // escape hatch: JsonNode for nested/array arguments
|
||||||
|
```
|
||||||
|
|
||||||
|
## Discovery
|
||||||
|
|
||||||
|
`McpConfig.toolsPackage("com.example.tools")` scans that package (and subpackages) for concrete
|
||||||
|
`McpTool`/`McpResource`/`McpPrompt` subclasses carrying `@Tool`/`@Resource`/`@Prompt`. Same
|
||||||
|
fail-fast contract as `FlashApp.scan()`: missing package, missing no-arg constructor, or a class
|
||||||
|
that fails to load aborts startup immediately with a clear message. Duplicate names/URIs also
|
||||||
|
fail fast at boot.
|
||||||
|
|
||||||
|
## Resources and Prompts
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Resource(uri = "config://app-settings", description = "Application settings", mimeType = "application/json")
|
||||||
|
public class AppSettingsResource extends McpResource {
|
||||||
|
@Override
|
||||||
|
public ResourceContents read() {
|
||||||
|
return TextResourceContents.of(uri(), "application/json", settingsJson());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Prompt(name = "summarize", args = @PromptArg(name = "text", required = true))
|
||||||
|
public class SummarizePrompt extends McpPrompt {
|
||||||
|
@Override
|
||||||
|
public PromptMessage render(PromptArguments args) {
|
||||||
|
return PromptMessage.withUserRole(new TextContent("Summarize: " + args.getString("text")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`McpResource.uri()` returns the URI declared on `@Resource`, cached at bind time — no repeated
|
||||||
|
annotation lookups on the hot path.
|
||||||
|
|
||||||
|
## Content types
|
||||||
|
|
||||||
|
`Content` and `ResourceContents` are `sealed`, currently permitting only `TextContent` and
|
||||||
|
`TextResourceContents` respectively. This is a deliberate v1 scope cut, not an oversight — image
|
||||||
|
content, embedded resources, and blob resources are extension points for a future revision
|
||||||
|
(extend the `permits` clause and `McpContentWriter`).
|
||||||
|
|
||||||
|
## Tool failures vs. protocol errors
|
||||||
|
|
||||||
|
A `McpTool.call(...)` that throws is caught by the dispatcher and turned into
|
||||||
|
`ToolResponse.error(message)` — per the MCP specification this is a normal JSON-RPC *result*
|
||||||
|
with `isError: true`, not a JSON-RPC error, so the calling model can see and react to it. Prefer
|
||||||
|
returning `ToolResponse.error(...)` explicitly when you can produce a better message than the
|
||||||
|
raw exception text.
|
||||||
|
|
||||||
|
`McpResource.read()`/`McpPrompt.render(...)` failures, by contrast, surface as JSON-RPC errors
|
||||||
|
(`-32603 Internal error`) — the specification does not define a soft-failure content convention
|
||||||
|
for those two.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Transport
|
||||||
|
|
||||||
|
`flash-ext-mcp` implements the **Streamable HTTP** transport from the MCP specification
|
||||||
|
(revision `2025-11-25`). `stdio` is out of scope — Flash5 is an HTTP framework, and a
|
||||||
|
subprocess-stdio transport doesn't fit its model.
|
||||||
|
|
||||||
|
## What this revision implements
|
||||||
|
|
||||||
|
- A single `POST {rootPath}` endpoint (default `/mcp`) accepting one JSON-RPC 2.0 message per
|
||||||
|
request and responding with a plain JSON object — the "standard JSON object response" mode the
|
||||||
|
specification allows as an alternative to opening a Server-Sent Events stream per request.
|
||||||
|
- `Origin` header validation (DNS-rebinding protection), configurable via
|
||||||
|
`McpConfig.allowedOrigins(...)`.
|
||||||
|
- Full JSON-RPC lifecycle: `initialize`, `notifications/initialized` (and any other
|
||||||
|
`notifications/*`/id-less message — answered with a bare `202 Accepted`, no body, per
|
||||||
|
JSON-RPC's notification semantics), `ping`, `tools/list`, `tools/call`, `resources/list`,
|
||||||
|
`resources/read`, `prompts/list`, `prompts/get`.
|
||||||
|
|
||||||
|
## What this revision deliberately does not implement
|
||||||
|
|
||||||
|
- **No `Mcp-Session-Id` / session state.** The specification says a server "MAY assign a session
|
||||||
|
ID at initialization time" — it is optional, not mandatory. This server is stateless: every
|
||||||
|
`POST` is handled independently, with no server-side session store. `initialize` does not need
|
||||||
|
to precede other calls for the server to function (there's no session to be "not initialized"
|
||||||
|
yet), which is a looser contract than a session-aware server would enforce — acceptable for a
|
||||||
|
static, boot-time-defined tool/resource/prompt catalog.
|
||||||
|
- **No Server-Sent Events stream.** `GET {rootPath}` (used by session-aware servers to open a
|
||||||
|
standing SSE stream for server-initiated pushes) is not registered — MCP clients that only
|
||||||
|
speak the request/response half of Streamable HTTP work unaffected; clients that require a
|
||||||
|
standing SSE connection are not supported by this revision.
|
||||||
|
|
||||||
|
Both are real, intentional scope cuts for a first version — not just to keep the surface area
|
||||||
|
small: a static, precompiled tool catalog (see `tools-resources-prompts.md`) has no
|
||||||
|
`listChanged` events to push and no long-running server-initiated messages to stream, so the
|
||||||
|
stateful half of the transport buys little for the common case this extension targets. Sessions
|
||||||
|
and SSE are natural extension points if a future revision needs server push (e.g. dynamic tool
|
||||||
|
registration, elicitation, or sampling requests initiated by the server).
|
||||||
|
|
||||||
|
## Why `POST`, not the new `QUERY` HTTP method
|
||||||
|
|
||||||
|
Flash5's core recently gained `HttpMethod.QUERY` (safe, idempotent, carries a body — a good
|
||||||
|
semantic fit for JSON-RPC-over-HTTP in general). It is **not** used here: the MCP Streamable
|
||||||
|
HTTP specification mandates `POST` for the client-to-server message path. Real MCP clients send
|
||||||
|
`POST`; using `QUERY` instead would break interoperability with every existing client for a
|
||||||
|
semantic nicety this extension doesn't need standalone.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-extensions</artifactId>
|
||||||
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>flash-ext-mcp</artifactId>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-security-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.slf4j</groupId>
|
||||||
|
<artifactId>slf4j-api</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-testing</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-security-test</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-security-oidc</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-security-apikey</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP tool/prompt content block. {@code sealed} to the variants this extension currently
|
||||||
|
* writes on the wire — extend the permits clause (and {@link McpContentWriter}) to add
|
||||||
|
* {@code ImageContent}, {@code EmbeddedResource}, etc. in a future revision.
|
||||||
|
*/
|
||||||
|
public sealed interface Content permits TextContent {}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
/** Standard JSON-RPC 2.0 error codes used by the MCP transport. */
|
||||||
|
final class JsonRpcErrorCode {
|
||||||
|
|
||||||
|
private JsonRpcErrorCode() {}
|
||||||
|
|
||||||
|
static final int PARSE_ERROR = -32700;
|
||||||
|
static final int INVALID_REQUEST = -32600;
|
||||||
|
static final int METHOD_NOT_FOUND = -32601;
|
||||||
|
static final int INVALID_PARAMS = -32602;
|
||||||
|
static final int INTERNAL_ERROR = -32603;
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.security.AuthenticationMechanism;
|
||||||
|
import dev.relism.flash.routing.Middleware;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable configuration for {@link McpExtension}.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* McpConfig.builder("my-mcp-server")
|
||||||
|
* .version("1.0.0")
|
||||||
|
* .rootPath("/mcp")
|
||||||
|
* .toolsPackage("com.example.tools")
|
||||||
|
* .security(McpSecurity.REQUIRED)
|
||||||
|
* .scopesSupported("openid", "profile", "email")
|
||||||
|
* .build();
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public final class McpConfig {
|
||||||
|
|
||||||
|
private final String name;
|
||||||
|
private final String version;
|
||||||
|
private final String instructions;
|
||||||
|
private final String rootPath;
|
||||||
|
private final String toolsPackage;
|
||||||
|
private final McpSecurity security;
|
||||||
|
private final boolean requireTokenAudience;
|
||||||
|
private final List<String> allowedOrigins;
|
||||||
|
private final List<String> scopesSupported;
|
||||||
|
private final List<Middleware> middleware;
|
||||||
|
private final List<AuthenticationMechanism> mechanisms;
|
||||||
|
|
||||||
|
private McpConfig(Builder b) {
|
||||||
|
this.name = b.name;
|
||||||
|
this.version = b.version;
|
||||||
|
this.instructions = b.instructions;
|
||||||
|
this.rootPath = b.rootPath;
|
||||||
|
this.toolsPackage = b.toolsPackage;
|
||||||
|
this.security = b.security;
|
||||||
|
this.requireTokenAudience = b.requireTokenAudience;
|
||||||
|
this.allowedOrigins = List.copyOf(b.allowedOrigins);
|
||||||
|
this.scopesSupported = List.copyOf(b.scopesSupported);
|
||||||
|
this.middleware = List.copyOf(b.middleware);
|
||||||
|
this.mechanisms = List.copyOf(b.mechanisms);
|
||||||
|
}
|
||||||
|
|
||||||
|
String name() { return name; }
|
||||||
|
String version() { return version; }
|
||||||
|
String instructions() { return instructions; }
|
||||||
|
String rootPath() { return rootPath; }
|
||||||
|
String toolsPackage() { return toolsPackage; }
|
||||||
|
McpSecurity security() { return security; }
|
||||||
|
boolean requireTokenAudience() { return requireTokenAudience; }
|
||||||
|
List<String> allowedOrigins() { return allowedOrigins; }
|
||||||
|
List<String> scopesSupported() { return scopesSupported; }
|
||||||
|
List<Middleware> middleware() { return middleware; }
|
||||||
|
List<AuthenticationMechanism> mechanisms() { return mechanisms; }
|
||||||
|
|
||||||
|
public static Builder builder(String name) { return new Builder(name); }
|
||||||
|
|
||||||
|
public static final class Builder {
|
||||||
|
private final String name;
|
||||||
|
private String version = "1.0.0";
|
||||||
|
private String instructions;
|
||||||
|
private String rootPath = "/mcp";
|
||||||
|
private String toolsPackage;
|
||||||
|
private McpSecurity security = McpSecurity.REQUIRED;
|
||||||
|
private boolean requireTokenAudience = true;
|
||||||
|
private final List<String> allowedOrigins = new ArrayList<>();
|
||||||
|
private final List<String> scopesSupported = new ArrayList<>();
|
||||||
|
private final List<Middleware> middleware = new ArrayList<>();
|
||||||
|
private final List<AuthenticationMechanism> mechanisms = new ArrayList<>();
|
||||||
|
|
||||||
|
private Builder(String name) {
|
||||||
|
if (name == null || name.isBlank())
|
||||||
|
throw new IllegalArgumentException("McpConfig server name cannot be blank");
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server version reported in {@code initialize}'s {@code serverInfo}. Default {@code "1.0.0"}. */
|
||||||
|
public Builder version(String version) { this.version = version; return this; }
|
||||||
|
|
||||||
|
/** Free-text instructions surfaced to the client at {@code initialize} time. */
|
||||||
|
public Builder instructions(String instructions) { this.instructions = instructions; return this; }
|
||||||
|
|
||||||
|
/** HTTP path for the Streamable HTTP endpoint. Default {@code "/mcp"}. */
|
||||||
|
public Builder rootPath(String rootPath) { this.rootPath = normalize(rootPath); return this; }
|
||||||
|
|
||||||
|
/** Package scanned for {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes. Required. */
|
||||||
|
public Builder toolsPackage(String toolsPackage) { this.toolsPackage = toolsPackage; return this; }
|
||||||
|
|
||||||
|
/** Default {@link McpSecurity#REQUIRED}. */
|
||||||
|
public Builder security(McpSecurity security) { this.security = security; return this; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a bearer token must name this endpoint in its {@code aud} (RFC 8707), as the MCP
|
||||||
|
* authorization spec requires. Default {@code true}. Turn it off for an authorization server
|
||||||
|
* that cannot mint a resource audience — every token a registered issuer signs is then
|
||||||
|
* accepted on the endpoint, and a warning is logged at boot.
|
||||||
|
*/
|
||||||
|
public Builder requireTokenAudience(boolean require) { this.requireTokenAudience = require; return this; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Origins allowed to call the MCP endpoint (DNS-rebinding protection, per the Streamable
|
||||||
|
* HTTP transport spec). If never set, {@code Origin} validation is skipped and a warning
|
||||||
|
* is logged at boot.
|
||||||
|
*/
|
||||||
|
public Builder allowedOrigins(String... origins) { this.allowedOrigins.addAll(List.of(origins)); return this; }
|
||||||
|
|
||||||
|
/** Published as {@code scopes_supported} in the RFC 9728 metadata, so OAuth clients request them. */
|
||||||
|
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only mechanisms that authenticate the endpoint, and the only issuers its RFC 9728 metadata
|
||||||
|
* names: any other credential, the session cookie included, is none here. Default: the whole chain.
|
||||||
|
*/
|
||||||
|
public Builder mechanisms(AuthenticationMechanism... mechanisms) {
|
||||||
|
this.mechanisms.addAll(List.of(mechanisms));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs on the MCP route after the transport guards and authentication — rate limiting, auditing, tracing. */
|
||||||
|
public Builder middleware(Middleware... middleware) {
|
||||||
|
this.middleware.addAll(List.of(middleware));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public McpConfig build() {
|
||||||
|
if (toolsPackage == null || toolsPackage.isBlank())
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"McpConfig.toolsPackage(...) is required — declare at least one @Tool/@Resource/@Prompt class");
|
||||||
|
return new McpConfig(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String path) {
|
||||||
|
if (path == null || path.isBlank()) throw new IllegalArgumentException("rootPath cannot be blank");
|
||||||
|
return path.startsWith("/") ? path : "/" + path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonGenerator;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direct {@link JsonGenerator} writers for the fixed, known shapes of {@link Content},
|
||||||
|
* {@link ResourceContents} and {@link PromptMessage} — no databinding, one {@code switch}
|
||||||
|
* per call, matching the fixed wire shape defined by the MCP specification.
|
||||||
|
*/
|
||||||
|
final class McpContentWriter {
|
||||||
|
|
||||||
|
private McpContentWriter() {}
|
||||||
|
|
||||||
|
static void writeContentArray(JsonGenerator gen, List<Content> items) throws IOException {
|
||||||
|
gen.writeStartArray();
|
||||||
|
for (Content c : items) writeContent(gen, c);
|
||||||
|
gen.writeEndArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void writeContent(JsonGenerator gen, Content content) throws IOException {
|
||||||
|
if (content instanceof TextContent tc) {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("type", "text");
|
||||||
|
gen.writeStringField("text", tc.text());
|
||||||
|
gen.writeEndObject();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("Unhandled Content variant: " + content.getClass());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void writeResourceContents(JsonGenerator gen, ResourceContents contents) throws IOException {
|
||||||
|
if (contents instanceof TextResourceContents trc) {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("uri", trc.uri());
|
||||||
|
gen.writeStringField("mimeType", trc.mimeType());
|
||||||
|
gen.writeStringField("text", trc.text());
|
||||||
|
gen.writeEndObject();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new IllegalStateException("Unhandled ResourceContents variant: " + contents.getClass());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void writePromptMessage(JsonGenerator gen, PromptMessage message) throws IOException {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("role", message.role().name().toLowerCase(Locale.ROOT));
|
||||||
|
gen.writeFieldName("content");
|
||||||
|
writeContent(gen, message.content());
|
||||||
|
gen.writeEndObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
+257
@@ -0,0 +1,257 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonGenerator;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
import dev.relism.flash.ext.security.SecurityIdentity;
|
||||||
|
import dev.relism.flash.ext.security.SecurityPolicy;
|
||||||
|
import dev.relism.flash.models.Request;
|
||||||
|
import dev.relism.flash.models.Response;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON-RPC 2.0 dispatcher for the MCP Streamable HTTP endpoint — one instance per
|
||||||
|
* {@link McpExtension}, built once at boot from a resolved {@link McpRegistry}.
|
||||||
|
*
|
||||||
|
* <p>Per the MCP specification, a {@code tools/call} failure is a normal JSON-RPC
|
||||||
|
* <em>result</em> with {@code isError: true} (see {@link ToolResponse#error}), not a JSON-RPC
|
||||||
|
* error — the model needs to see it. Everything else that goes wrong (bad params, unknown
|
||||||
|
* tool/resource/prompt name, resource/prompt handler exceptions) is a JSON-RPC error object,
|
||||||
|
* always returned with HTTP 200: the HTTP request itself succeeded, only the RPC did not. Only
|
||||||
|
* malformed HTTP-level input (unparsable JSON, not a JSON object) gets HTTP 400. A
|
||||||
|
* {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link SecurityPolicy}) is the same
|
||||||
|
* category — {@code isError: true}, tool never invoked — not a transport-level rejection; the
|
||||||
|
* route-wide 401/403 already happened earlier, in the security middleware, before this
|
||||||
|
* dispatcher ever runs.
|
||||||
|
*/
|
||||||
|
final class McpDispatcher {
|
||||||
|
|
||||||
|
/** Protocol revision this dispatcher implements. */
|
||||||
|
static final String PROTOCOL_VERSION = "2025-11-25";
|
||||||
|
|
||||||
|
private final McpRegistry registry;
|
||||||
|
private final String serverName;
|
||||||
|
private final String serverVersion;
|
||||||
|
private final String instructions;
|
||||||
|
|
||||||
|
McpDispatcher(McpRegistry registry, String serverName, String serverVersion, String instructions) {
|
||||||
|
this.registry = registry;
|
||||||
|
this.serverName = serverName;
|
||||||
|
this.serverVersion = serverVersion;
|
||||||
|
this.instructions = instructions;
|
||||||
|
}
|
||||||
|
|
||||||
|
void handle(Request req, Response res) {
|
||||||
|
byte[] body = req.body().bytes();
|
||||||
|
JsonNode root;
|
||||||
|
try {
|
||||||
|
root = McpJson.parse(body);
|
||||||
|
} catch (IOException e) {
|
||||||
|
writeError(res, 400, null, JsonRpcErrorCode.PARSE_ERROR, "Parse error: " + e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (root == null || !root.isObject()) {
|
||||||
|
writeError(res, 400, null, JsonRpcErrorCode.INVALID_REQUEST, "Request must be a JSON object");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode idNode = root.get("id");
|
||||||
|
boolean isNotification = idNode == null;
|
||||||
|
String method = root.path("method").asText(null);
|
||||||
|
JsonNode params = root.path("params");
|
||||||
|
|
||||||
|
if (method == null || method.isBlank()) {
|
||||||
|
if (isNotification) { res.status(202); return; }
|
||||||
|
writeError(res, 400, idNode, JsonRpcErrorCode.INVALID_REQUEST, "Missing \"method\"");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (method) {
|
||||||
|
case "initialize" -> handleInitialize(res, idNode);
|
||||||
|
case "notifications/initialized", "notifications/cancelled" -> res.status(202);
|
||||||
|
case "ping" -> handlePing(res, idNode);
|
||||||
|
case "tools/list" -> handleToolsList(res, idNode);
|
||||||
|
case "tools/call" -> handleToolsCall(res, idNode, params);
|
||||||
|
case "resources/list" -> handleResourcesList(res, idNode);
|
||||||
|
case "resources/read" -> handleResourcesRead(res, idNode, params);
|
||||||
|
case "prompts/list" -> handlePromptsList(res, idNode);
|
||||||
|
case "prompts/get" -> handlePromptsGet(res, idNode, params);
|
||||||
|
default -> {
|
||||||
|
if (isNotification) { res.status(202); return; }
|
||||||
|
throw McpProtocolException.methodNotFound(method);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (McpProtocolException e) {
|
||||||
|
writeError(res, 200, idNode, e.code, e.getMessage());
|
||||||
|
} catch (Exception e) {
|
||||||
|
writeError(res, 200, idNode, JsonRpcErrorCode.INTERNAL_ERROR, "Internal error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Method handlers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void handleInitialize(Response res, JsonNode id) {
|
||||||
|
writeResult(res, id, gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("protocolVersion", PROTOCOL_VERSION);
|
||||||
|
gen.writeObjectFieldStart("capabilities");
|
||||||
|
if (registry.hasTools()) writeEmptyCapability(gen, "tools");
|
||||||
|
if (registry.hasResources()) writeEmptyCapability(gen, "resources");
|
||||||
|
if (registry.hasPrompts()) writeEmptyCapability(gen, "prompts");
|
||||||
|
gen.writeEndObject();
|
||||||
|
gen.writeObjectFieldStart("serverInfo");
|
||||||
|
gen.writeStringField("name", serverName);
|
||||||
|
gen.writeStringField("version", serverVersion);
|
||||||
|
gen.writeEndObject();
|
||||||
|
if (instructions != null && !instructions.isBlank())
|
||||||
|
gen.writeStringField("instructions", instructions);
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeEmptyCapability(JsonGenerator gen, String field) throws IOException {
|
||||||
|
gen.writeObjectFieldStart(field);
|
||||||
|
gen.writeBooleanField("listChanged", false);
|
||||||
|
gen.writeEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handlePing(Response res, JsonNode id) {
|
||||||
|
writeResult(res, id, gen -> { gen.writeStartObject(); gen.writeEndObject(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleToolsList(Response res, JsonNode id) {
|
||||||
|
writeResult(res, id, gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeFieldName("tools");
|
||||||
|
gen.writeRawValue(registry.toolsListJson());
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleToolsCall(Response res, JsonNode id, JsonNode params) {
|
||||||
|
String name = params.path("name").asText(null);
|
||||||
|
if (name == null || name.isBlank())
|
||||||
|
throw McpProtocolException.invalidParams("\"name\" is required");
|
||||||
|
McpRegistry.RegisteredTool tool = registry.tool(name);
|
||||||
|
if (tool == null)
|
||||||
|
throw McpProtocolException.invalidParams("Unknown tool: " + name);
|
||||||
|
|
||||||
|
ToolArguments args = new ToolArguments(params.path("arguments"));
|
||||||
|
SecurityPolicy policy = tool.policy();
|
||||||
|
ToolResponse result;
|
||||||
|
if (policy != null && !policy.permitsScopes(SecurityIdentity.current())) {
|
||||||
|
result = ToolResponse.error("Tool \"" + name + "\" denied: missing scope");
|
||||||
|
} else if (policy != null && !policy.permitsRoles(SecurityIdentity.current(), args::getString)) {
|
||||||
|
result = ToolResponse.error("Tool \"" + name + "\" denied: missing role");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
result = tool.instance().call(args);
|
||||||
|
} catch (Exception e) {
|
||||||
|
result = ToolResponse.error("Tool \"" + name + "\" failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolResponse finalResult = result;
|
||||||
|
writeResult(res, id, gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeBooleanField("isError", finalResult.isError());
|
||||||
|
gen.writeFieldName("content");
|
||||||
|
McpContentWriter.writeContentArray(gen, finalResult.content());
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleResourcesList(Response res, JsonNode id) {
|
||||||
|
writeResult(res, id, gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeFieldName("resources");
|
||||||
|
gen.writeRawValue(registry.resourcesListJson());
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleResourcesRead(Response res, JsonNode id, JsonNode params) throws Exception {
|
||||||
|
String uri = params.path("uri").asText(null);
|
||||||
|
if (uri == null || uri.isBlank())
|
||||||
|
throw McpProtocolException.invalidParams("\"uri\" is required");
|
||||||
|
McpRegistry.RegisteredResource resource = registry.resource(uri);
|
||||||
|
if (resource == null)
|
||||||
|
throw McpProtocolException.invalidParams("Unknown resource: " + uri);
|
||||||
|
|
||||||
|
ResourceContents contents = resource.instance().read();
|
||||||
|
writeResult(res, id, gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeArrayFieldStart("contents");
|
||||||
|
McpContentWriter.writeResourceContents(gen, contents);
|
||||||
|
gen.writeEndArray();
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handlePromptsList(Response res, JsonNode id) {
|
||||||
|
writeResult(res, id, gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeFieldName("prompts");
|
||||||
|
gen.writeRawValue(registry.promptsListJson());
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handlePromptsGet(Response res, JsonNode id, JsonNode params) throws Exception {
|
||||||
|
String name = params.path("name").asText(null);
|
||||||
|
if (name == null || name.isBlank())
|
||||||
|
throw McpProtocolException.invalidParams("\"name\" is required");
|
||||||
|
McpRegistry.RegisteredPrompt prompt = registry.prompt(name);
|
||||||
|
if (prompt == null)
|
||||||
|
throw McpProtocolException.invalidParams("Unknown prompt: " + name);
|
||||||
|
|
||||||
|
PromptArguments args = new PromptArguments(params.path("arguments"));
|
||||||
|
PromptMessage message = prompt.instance().render(args);
|
||||||
|
writeResult(res, id, gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeArrayFieldStart("messages");
|
||||||
|
McpContentWriter.writePromptMessage(gen, message);
|
||||||
|
gen.writeEndArray();
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Envelope writers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void writeResult(Response res, JsonNode id, McpJson.JsonWriter resultWriter) {
|
||||||
|
String body = McpJson.buildString(gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("jsonrpc", "2.0");
|
||||||
|
gen.writeFieldName("id");
|
||||||
|
writeId(gen, id);
|
||||||
|
gen.writeFieldName("result");
|
||||||
|
resultWriter.write(gen);
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
res.status(200).type(ContentType.JSON).body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeError(Response res, int httpStatus, JsonNode id, int code, String message) {
|
||||||
|
String body = McpJson.buildString(gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("jsonrpc", "2.0");
|
||||||
|
gen.writeFieldName("id");
|
||||||
|
writeId(gen, id);
|
||||||
|
gen.writeObjectFieldStart("error");
|
||||||
|
gen.writeNumberField("code", code);
|
||||||
|
gen.writeStringField("message", message);
|
||||||
|
gen.writeEndObject();
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
res.status(httpStatus).type(ContentType.JSON).body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeId(JsonGenerator gen, JsonNode id) throws IOException {
|
||||||
|
if (id == null || id.isNull() || id.isMissingNode()) { gen.writeNull(); return; }
|
||||||
|
if (id.isTextual()) gen.writeString(id.asText());
|
||||||
|
else if (id.isIntegralNumber()) gen.writeNumber(id.asLong());
|
||||||
|
else if (id.isFloatingPointNumber()) gen.writeNumber(id.asDouble());
|
||||||
|
else gen.writeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
|
import dev.relism.flash.ext.security.AuthenticationEntryPoint;
|
||||||
|
import dev.relism.flash.ext.security.AuthenticationMechanism;
|
||||||
|
import dev.relism.flash.ext.security.SecurityExtension;
|
||||||
|
import dev.relism.flash.ext.security.SecurityIdentity;
|
||||||
|
import dev.relism.flash.ext.security.SecurityPolicy;
|
||||||
|
import dev.relism.flash.ext.security.SecurityScheme;
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
|
import dev.relism.flash.extension.FlashRegistrar;
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
import dev.relism.flash.routing.Middleware;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP (Model Context Protocol) server extension. Streamable HTTP transport — a single {@code POST}
|
||||||
|
* JSON-RPC endpoint, stateless in this revision (see {@code docs/transport.md}) — dispatching to
|
||||||
|
* {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes under
|
||||||
|
* {@link McpConfig#toolsPackage(String)}.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* app.install(new SecurityExtension())
|
||||||
|
* .install(new OidcExtension(OidcProvider.of("sso", issuer, clientId, secret)))
|
||||||
|
* .install(new McpExtension(McpConfig.builder("my-server").toolsPackage("com.example.tools").build()));
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>Every call is authenticated by the application's security chain — OAuth2 bearer tokens, API
|
||||||
|
* keys, anything registered. When an OAuth2 issuer is among its schemes, the endpoint is also an
|
||||||
|
* OAuth2 protected resource: RFC 9728 metadata, a {@code resource_metadata} challenge, and RFC 8707
|
||||||
|
* audience binding for audience-bound tokens. Tool annotations are enforced per call, with
|
||||||
|
* {@code @RolesAllowed(on = ...)} reading tool arguments.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public class McpExtension implements FlashExtension {
|
||||||
|
|
||||||
|
private final McpConfig config;
|
||||||
|
private volatile List<SecurityScheme> schemes;
|
||||||
|
|
||||||
|
public McpExtension(McpConfig config) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
|
ctx.onReady(() -> {
|
||||||
|
SecurityExtension security = config.security() == McpSecurity.NONE ? null : ctx.find(SecurityExtension.class)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("MCP server \"" + config.name()
|
||||||
|
+ "\" requires flash-ext-security-core: install a SecurityExtension, or set McpSecurity.NONE for a public server"));
|
||||||
|
McpDispatcher dispatcher = new McpDispatcher(McpRegistry.scan(config.toolsPackage(), ctx, security),
|
||||||
|
config.name(), config.version(), config.instructions());
|
||||||
|
|
||||||
|
List<Middleware> chain = new ArrayList<>(List.of(
|
||||||
|
McpTransportGuards.httpExceptionGuard(), McpTransportGuards.originGuard(config.allowedOrigins())));
|
||||||
|
if (security != null) protect(app, security, chain);
|
||||||
|
chain.addAll(config.middleware());
|
||||||
|
app.post(config.rootPath(), (req, res) -> {
|
||||||
|
dispatcher.handle(req, res);
|
||||||
|
return null;
|
||||||
|
}, chain.toArray(Middleware[]::new));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void protect(FlashRegistrar<?> app, SecurityExtension security, List<Middleware> chain) {
|
||||||
|
String metadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
|
||||||
|
List<AuthenticationMechanism> only = config.mechanisms();
|
||||||
|
AuthenticationEntryPoint anonymous = (req, res) -> {
|
||||||
|
List<SecurityScheme> schemes = schemes(security);
|
||||||
|
res.header("WWW-Authenticate", issuers(schemes, null).isEmpty()
|
||||||
|
? String.join(", ", schemes.stream().map(SecurityScheme::challenge).toList())
|
||||||
|
: "Bearer resource_metadata=\"" + security.origin(req) + metadataPath + "\"");
|
||||||
|
throw HttpException.unauthorized();
|
||||||
|
};
|
||||||
|
chain.add(only.isEmpty() ? security.enforce(SecurityPolicy.AUTHENTICATED, anonymous)
|
||||||
|
: security.enforce(SecurityPolicy.AUTHENTICATED, anonymous, only));
|
||||||
|
if (config.requireTokenAudience()) {
|
||||||
|
chain.add(next -> (req, res) -> {
|
||||||
|
String resource = security.origin(req) + config.rootPath();
|
||||||
|
if (!SecurityIdentity.current().principal().hasAudience(resource)) {
|
||||||
|
log.warn("[flash-ext-mcp] Rejected a token not issued for {} (RFC 8707) — the authorization server must put it in aud", resource);
|
||||||
|
throw HttpException.forbidden();
|
||||||
|
}
|
||||||
|
return next.handle(req, res);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log.warn("[flash-ext-mcp] Token audience validation (RFC 8707) is DISABLED for {} — every token a registered issuer signs is accepted.", config.rootPath());
|
||||||
|
}
|
||||||
|
app.get(metadataPath, (req, res) -> {
|
||||||
|
List<String> issuers = issuers(schemes(security), security.origin(req));
|
||||||
|
if (issuers.isEmpty()) throw HttpException.notFound("Protected resource metadata");
|
||||||
|
res.type(ContentType.JSON);
|
||||||
|
return McpResourceMetadata.build(security.origin(req) + config.rootPath(), issuers, config.scopesSupported());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolved at the first request, once every mechanism has registered — which may be after this extension was ready. */
|
||||||
|
private List<SecurityScheme> schemes(SecurityExtension security) {
|
||||||
|
if (schemes == null) {
|
||||||
|
schemes = config.mechanisms().isEmpty() ? security.schemes()
|
||||||
|
: config.mechanisms().stream().flatMap(mechanism -> mechanism.schemes().stream()).toList();
|
||||||
|
}
|
||||||
|
return schemes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@code "/"} is the application's own authorization server, at {@code origin}. */
|
||||||
|
private static List<String> issuers(List<SecurityScheme> schemes, String origin) {
|
||||||
|
return schemes.stream().map(SecurityScheme::issuer).filter(Objects::nonNull).map(issuer -> issuer.equals("/") ? origin : issuer).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonEncoding;
|
||||||
|
import com.fasterxml.jackson.core.JsonGenerator;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal JSON access shared by the whole extension. Deliberately tree/streaming only —
|
||||||
|
* no {@code readValue(bytes, Class)} databinding anywhere in this extension. Request bodies
|
||||||
|
* are parsed once into a {@link JsonNode} (no reflection, no property matching against a
|
||||||
|
* target class); responses are written directly with {@link JsonGenerator} against the
|
||||||
|
* envelope's fixed, known shape (also no reflection).
|
||||||
|
*
|
||||||
|
* <p>Not wired to {@code flash-ext-jackson} on purpose: the MCP JSON-RPC envelope is internal
|
||||||
|
* protocol plumbing, not a user-facing serialization concern, so this extension owns its
|
||||||
|
* mapper independently — the same reasoning any protocol-level extension applies to its own JSON needs
|
||||||
|
* (see {@code json-smart} there). See {@code docs/jackson-interop.md} for the full rationale
|
||||||
|
* and how a future opt-in reuse of a shared {@code ObjectMapper} could work.
|
||||||
|
*/
|
||||||
|
final class McpJson {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
private McpJson() {}
|
||||||
|
|
||||||
|
static JsonNode parse(byte[] body) throws IOException {
|
||||||
|
return MAPPER.readTree(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
static JsonGenerator generator(OutputStream out) throws IOException {
|
||||||
|
return MAPPER.getFactory().createGenerator(out, JsonEncoding.UTF8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a small JSON document in one shot; used only for boot-time precompilation. */
|
||||||
|
static byte[] build(JsonWriter writer) {
|
||||||
|
ByteArrayOutputStream buf = new ByteArrayOutputStream(256);
|
||||||
|
try (JsonGenerator gen = generator(buf)) {
|
||||||
|
writer.write(gen);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Failed to build MCP JSON fragment", e);
|
||||||
|
}
|
||||||
|
return buf.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String buildString(JsonWriter writer) {
|
||||||
|
return new String(build(writer), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
interface JsonWriter {
|
||||||
|
void write(JsonGenerator gen) throws IOException;
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import dev.relism.flash.exceptions.InitializationException;
|
||||||
|
|
||||||
|
import java.lang.reflect.Modifier;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import dev.relism.flash.extension.PackageScanner;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal classpath scanner used by {@link McpConfig#toolsPackage(String)}. Finds
|
||||||
|
* {@link McpTool}/{@link McpResource}/{@link McpPrompt} subclasses carrying the matching
|
||||||
|
* annotation ({@link Tool @Tool}, {@link Resource @Resource}, {@link Prompt @Prompt}).
|
||||||
|
* Supports both exploded directories (development) and fat JARs (deployment).
|
||||||
|
*
|
||||||
|
* <p>Deliberately not shared with {@code dev.relism.flash.extension.PackageScanner}: that
|
||||||
|
* scanner is package-private and hardcoded to {@code RequestHandler}/{@code WebSocketEndpoint}.
|
||||||
|
* The directory/JAR walking logic below intentionally mirrors it — same fail-fast contract,
|
||||||
|
* same anonymous-class filtering.
|
||||||
|
*
|
||||||
|
* <p><b>Fail-fast:</b> if the package does not exist, contains no matching class, or a class
|
||||||
|
* cannot be loaded, an {@link InitializationException} is thrown immediately at boot.
|
||||||
|
*/
|
||||||
|
final class McpPackageScanner {
|
||||||
|
|
||||||
|
private McpPackageScanner() {}
|
||||||
|
|
||||||
|
record ScanResult(List<Class<? extends McpTool>> tools,
|
||||||
|
List<Class<? extends McpResource>> resources,
|
||||||
|
List<Class<? extends McpPrompt>> prompts) {}
|
||||||
|
|
||||||
|
static ScanResult scan(String packageName) {
|
||||||
|
if (packageName == null || packageName.isBlank())
|
||||||
|
throw new InitializationException("McpConfig.toolsPackage() called with null or blank package name");
|
||||||
|
|
||||||
|
List<Class<? extends McpTool>> tools = new ArrayList<>();
|
||||||
|
List<Class<? extends McpResource>> resources = new ArrayList<>();
|
||||||
|
List<Class<? extends McpPrompt>> prompts = new ArrayList<>();
|
||||||
|
List<String> errors = new ArrayList<>();
|
||||||
|
for (Class<?> cls : PackageScanner.discover(packageName)) tryLoad(cls, tools, resources, prompts, errors);
|
||||||
|
|
||||||
|
if (!errors.isEmpty())
|
||||||
|
throw new InitializationException(
|
||||||
|
"McpConfig.toolsPackage(\"" + packageName + "\") — failed to load " + errors.size() + " class(es):\n • " +
|
||||||
|
String.join("\n • ", errors));
|
||||||
|
|
||||||
|
if (tools.isEmpty() && resources.isEmpty() && prompts.isEmpty())
|
||||||
|
throw new InitializationException(
|
||||||
|
"McpConfig.toolsPackage(\"" + packageName + "\") — no @Tool/@Resource/@Prompt classes found. " +
|
||||||
|
"Ensure classes extend McpTool/McpResource/McpPrompt, carry the matching annotation, " +
|
||||||
|
"are not abstract, and have a public no-arg constructor.");
|
||||||
|
|
||||||
|
return new ScanResult(List.copyOf(tools), List.copyOf(resources), List.copyOf(prompts));
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static void tryLoad(Class<?> cls,
|
||||||
|
List<Class<? extends McpTool>> tools,
|
||||||
|
List<Class<? extends McpResource>> resources,
|
||||||
|
List<Class<? extends McpPrompt>> prompts,
|
||||||
|
List<String> errors) {
|
||||||
|
try {
|
||||||
|
if (Modifier.isAbstract(cls.getModifiers())) return;
|
||||||
|
|
||||||
|
if (McpTool.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Tool.class)) {
|
||||||
|
assertNoArgConstructor(cls, errors);
|
||||||
|
tools.add((Class<? extends McpTool>) cls);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (McpResource.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Resource.class)) {
|
||||||
|
assertNoArgConstructor(cls, errors);
|
||||||
|
resources.add((Class<? extends McpResource>) cls);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (McpPrompt.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Prompt.class)) {
|
||||||
|
assertNoArgConstructor(cls, errors);
|
||||||
|
prompts.add((Class<? extends McpPrompt>) cls);
|
||||||
|
}
|
||||||
|
} catch (LinkageError e) { errors.add(cls.getName() + " — linkage error: " + e.getMessage()); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertNoArgConstructor(Class<?> cls, List<String> errors) {
|
||||||
|
try { cls.getDeclaredConstructor(); }
|
||||||
|
catch (NoSuchMethodException e) { errors.add(cls.getName() + " — missing public no-arg constructor"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for a single MCP prompt template — one class per prompt, mirroring {@link McpTool}.
|
||||||
|
* Declare metadata with {@link Prompt @Prompt}, cache services in {@link #onInit()}, implement
|
||||||
|
* {@link #render(PromptArguments)} for the hot path.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* @Prompt(name = "summarize", args = @PromptArg(name = "text", required = true))
|
||||||
|
* public class SummarizePrompt extends McpPrompt {
|
||||||
|
* @Override public PromptMessage render(PromptArguments args) {
|
||||||
|
* return PromptMessage.withUserRole(new TextContent("Summarize: " + args.getString("text")));
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public abstract class McpPrompt {
|
||||||
|
|
||||||
|
private FlashContext ctx;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once by the framework after instantiation, before the first {@code prompts/get}.
|
||||||
|
* <b>Infrastructure method</b> — do not call from user code.
|
||||||
|
*/
|
||||||
|
public final void bind(FlashContext ctx) {
|
||||||
|
this.ctx = ctx;
|
||||||
|
onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void onInit() {}
|
||||||
|
|
||||||
|
protected <T> T require(Class<T> type) {
|
||||||
|
checkBound();
|
||||||
|
return ctx.require(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected <T> Optional<T> find(Class<T> type) {
|
||||||
|
checkBound();
|
||||||
|
return ctx.find(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected <T> Optional<T> optional(Class<T> type) {
|
||||||
|
checkBound();
|
||||||
|
return ctx.optional(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkBound() {
|
||||||
|
if (ctx == null)
|
||||||
|
throw new IllegalStateException(
|
||||||
|
getClass().getSimpleName() + " has not been bound to a FlashContext — " +
|
||||||
|
"register via McpConfig.toolsPackage(), not by instantiating directly");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invoked on every matching {@code prompts/get} request (hot path). */
|
||||||
|
public abstract PromptMessage render(PromptArguments args) throws Exception;
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
/** Internal signal carrying a JSON-RPC error code, caught by {@link McpDispatcher} to build the error response. */
|
||||||
|
final class McpProtocolException extends RuntimeException {
|
||||||
|
|
||||||
|
final int code;
|
||||||
|
|
||||||
|
private McpProtocolException(int code, String message) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
static McpProtocolException invalidRequest(String message) {
|
||||||
|
return new McpProtocolException(JsonRpcErrorCode.INVALID_REQUEST, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static McpProtocolException methodNotFound(String method) {
|
||||||
|
return new McpProtocolException(JsonRpcErrorCode.METHOD_NOT_FOUND, "Method not found: " + method);
|
||||||
|
}
|
||||||
|
|
||||||
|
static McpProtocolException invalidParams(String message) {
|
||||||
|
return new McpProtocolException(JsonRpcErrorCode.INVALID_PARAMS, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonGenerator;
|
||||||
|
import dev.relism.flash.exceptions.InitializationException;
|
||||||
|
import dev.relism.flash.ext.security.SecurityExtension;
|
||||||
|
import dev.relism.flash.ext.security.SecurityPolicy;
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.lang.reflect.Constructor;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot-time-built registry of tools/resources/prompts for one MCP server.
|
||||||
|
*
|
||||||
|
* <p>Everything static about the catalog — the {@code tools/list}/{@code resources/list}/
|
||||||
|
* {@code prompts/list} JSON payloads — is assembled exactly once here via
|
||||||
|
* {@link McpJson#buildString}, then spliced verbatim into responses at request time
|
||||||
|
* ({@link McpDispatcher}) with {@link JsonGenerator#writeRawValue(String)}: no
|
||||||
|
* re-serialization, no reflection, no databinding, and no per-request byte[]→String
|
||||||
|
* conversion on the hot path — the string is already sitting in memory, built once at boot.
|
||||||
|
*/
|
||||||
|
final class McpRegistry {
|
||||||
|
|
||||||
|
private static final String EMPTY_ARRAY = "[]";
|
||||||
|
|
||||||
|
/** {@code policy} is {@code null} unless the tool carries @RolesAllowed/@ScopesAllowed. */
|
||||||
|
record RegisteredTool(String name, McpTool instance, SecurityPolicy policy) {}
|
||||||
|
record RegisteredResource(String uri, McpResource instance) {}
|
||||||
|
record RegisteredPrompt(String name, McpPrompt instance) {}
|
||||||
|
|
||||||
|
private final Map<String, RegisteredTool> tools = new LinkedHashMap<>();
|
||||||
|
private final Map<String, RegisteredResource> resources = new LinkedHashMap<>();
|
||||||
|
private final Map<String, RegisteredPrompt> prompts = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
private String toolsListJson = EMPTY_ARRAY;
|
||||||
|
private String resourcesListJson = EMPTY_ARRAY;
|
||||||
|
private String promptsListJson = EMPTY_ARRAY;
|
||||||
|
|
||||||
|
private McpRegistry() {}
|
||||||
|
|
||||||
|
/** @param security {@code null} for a server running with {@link McpSecurity#NONE} */
|
||||||
|
static McpRegistry scan(String packageName, FlashContext ctx, SecurityExtension security) {
|
||||||
|
McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName);
|
||||||
|
McpRegistry registry = new McpRegistry();
|
||||||
|
|
||||||
|
for (Class<? extends McpTool> cls : found.tools()) {
|
||||||
|
Tool ann = cls.getAnnotation(Tool.class);
|
||||||
|
McpTool instance = instantiate(cls);
|
||||||
|
instance.bind(ctx);
|
||||||
|
if (security == null && SecurityPolicy.of(cls) != null)
|
||||||
|
throw new InitializationException("MCP tool \"" + ann.name() + "\" declares security annotations, but the server runs with McpSecurity.NONE");
|
||||||
|
SecurityPolicy policy = security == null ? null : security.policy(cls);
|
||||||
|
if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance, policy)) != null)
|
||||||
|
throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\"");
|
||||||
|
}
|
||||||
|
for (Class<? extends McpResource> cls : found.resources()) {
|
||||||
|
Resource ann = cls.getAnnotation(Resource.class);
|
||||||
|
McpResource instance = instantiate(cls);
|
||||||
|
instance.bind(ctx);
|
||||||
|
if (registry.resources.putIfAbsent(ann.uri(), new RegisteredResource(ann.uri(), instance)) != null)
|
||||||
|
throw new InitializationException("Duplicate MCP resource uri: \"" + ann.uri() + "\"");
|
||||||
|
}
|
||||||
|
for (Class<? extends McpPrompt> cls : found.prompts()) {
|
||||||
|
Prompt ann = cls.getAnnotation(Prompt.class);
|
||||||
|
McpPrompt instance = instantiate(cls);
|
||||||
|
instance.bind(ctx);
|
||||||
|
if (registry.prompts.putIfAbsent(ann.name(), new RegisteredPrompt(ann.name(), instance)) != null)
|
||||||
|
throw new InitializationException("Duplicate MCP prompt name: \"" + ann.name() + "\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found.tools().isEmpty())
|
||||||
|
registry.toolsListJson = McpJson.buildString(gen -> writeToolsArray(gen, found.tools()));
|
||||||
|
if (!found.resources().isEmpty())
|
||||||
|
registry.resourcesListJson = McpJson.buildString(gen -> writeResourcesArray(gen, found.resources()));
|
||||||
|
if (!found.prompts().isEmpty())
|
||||||
|
registry.promptsListJson = McpJson.buildString(gen -> writePromptsArray(gen, found.prompts()));
|
||||||
|
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean hasTools() { return !tools.isEmpty(); }
|
||||||
|
boolean hasResources() { return !resources.isEmpty(); }
|
||||||
|
boolean hasPrompts() { return !prompts.isEmpty(); }
|
||||||
|
|
||||||
|
String toolsListJson() { return toolsListJson; }
|
||||||
|
String resourcesListJson() { return resourcesListJson; }
|
||||||
|
String promptsListJson() { return promptsListJson; }
|
||||||
|
|
||||||
|
RegisteredTool tool(String name) { return tools.get(name); }
|
||||||
|
RegisteredResource resource(String uri) { return resources.get(uri); }
|
||||||
|
RegisteredPrompt prompt(String name) { return prompts.get(name); }
|
||||||
|
|
||||||
|
// ── Boot-time JSON Schema / descriptor precompilation ───────────────────────
|
||||||
|
|
||||||
|
private static void writeToolsArray(JsonGenerator gen, List<Class<? extends McpTool>> classes) throws IOException {
|
||||||
|
gen.writeStartArray();
|
||||||
|
for (Class<? extends McpTool> cls : classes) writeTool(gen, cls.getAnnotation(Tool.class));
|
||||||
|
gen.writeEndArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeTool(JsonGenerator gen, Tool ann) throws IOException {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("name", ann.name());
|
||||||
|
if (!ann.title().isBlank()) gen.writeStringField("title", ann.title());
|
||||||
|
if (!ann.description().isBlank()) gen.writeStringField("description", ann.description());
|
||||||
|
gen.writeFieldName("inputSchema");
|
||||||
|
writeInputSchema(gen, ann.args());
|
||||||
|
gen.writeEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeInputSchema(JsonGenerator gen, ToolArg[] args) throws IOException {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("type", "object");
|
||||||
|
gen.writeObjectFieldStart("properties");
|
||||||
|
for (ToolArg arg : args) {
|
||||||
|
gen.writeObjectFieldStart(arg.name());
|
||||||
|
gen.writeStringField("type", arg.type().jsonSchemaType());
|
||||||
|
if (!arg.description().isBlank()) gen.writeStringField("description", arg.description());
|
||||||
|
gen.writeEndObject();
|
||||||
|
}
|
||||||
|
gen.writeEndObject();
|
||||||
|
if (hasRequired(args)) {
|
||||||
|
gen.writeArrayFieldStart("required");
|
||||||
|
for (ToolArg arg : args) if (arg.required()) gen.writeString(arg.name());
|
||||||
|
gen.writeEndArray();
|
||||||
|
}
|
||||||
|
gen.writeEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasRequired(ToolArg[] args) {
|
||||||
|
for (ToolArg arg : args) if (arg.required()) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeResourcesArray(JsonGenerator gen, List<Class<? extends McpResource>> classes) throws IOException {
|
||||||
|
gen.writeStartArray();
|
||||||
|
for (Class<? extends McpResource> cls : classes) {
|
||||||
|
Resource ann = cls.getAnnotation(Resource.class);
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("uri", ann.uri());
|
||||||
|
gen.writeStringField("name", !ann.name().isBlank() ? ann.name() : ann.uri());
|
||||||
|
if (!ann.description().isBlank()) gen.writeStringField("description", ann.description());
|
||||||
|
gen.writeStringField("mimeType", ann.mimeType());
|
||||||
|
gen.writeEndObject();
|
||||||
|
}
|
||||||
|
gen.writeEndArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writePromptsArray(JsonGenerator gen, List<Class<? extends McpPrompt>> classes) throws IOException {
|
||||||
|
gen.writeStartArray();
|
||||||
|
for (Class<? extends McpPrompt> cls : classes) {
|
||||||
|
Prompt ann = cls.getAnnotation(Prompt.class);
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("name", ann.name());
|
||||||
|
if (!ann.description().isBlank()) gen.writeStringField("description", ann.description());
|
||||||
|
gen.writeArrayFieldStart("arguments");
|
||||||
|
for (PromptArg arg : ann.args()) {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("name", arg.name());
|
||||||
|
if (!arg.description().isBlank()) gen.writeStringField("description", arg.description());
|
||||||
|
gen.writeBooleanField("required", arg.required());
|
||||||
|
gen.writeEndObject();
|
||||||
|
}
|
||||||
|
gen.writeEndArray();
|
||||||
|
gen.writeEndObject();
|
||||||
|
}
|
||||||
|
gen.writeEndArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> T instantiate(Class<T> cls) {
|
||||||
|
try {
|
||||||
|
Constructor<T> ctor = cls.getDeclaredConstructor();
|
||||||
|
return ctor.newInstance();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new InitializationException(
|
||||||
|
"Failed to instantiate " + cls.getName() +
|
||||||
|
" — ensure it has a public no-arg constructor", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for a single MCP resource — one class per resource, mirroring {@link McpTool}.
|
||||||
|
* Declare metadata with {@link Resource @Resource}, cache services in {@link #onInit()},
|
||||||
|
* implement {@link #read()} for the hot path.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* @Resource(uri = "config://app-settings", description = "Application settings", mimeType = "application/json")
|
||||||
|
* public class AppSettingsResource extends McpResource {
|
||||||
|
* @Override public ResourceContents read() {
|
||||||
|
* return TextResourceContents.of(uri(), "application/json", settingsJson());
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public abstract class McpResource {
|
||||||
|
|
||||||
|
private FlashContext ctx;
|
||||||
|
private String uri;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once by the framework after instantiation, before the first {@code resources/read}.
|
||||||
|
* <b>Infrastructure method</b> — do not call from user code.
|
||||||
|
*/
|
||||||
|
public final void bind(FlashContext ctx) {
|
||||||
|
this.ctx = ctx;
|
||||||
|
Resource ann = getClass().getAnnotation(Resource.class);
|
||||||
|
this.uri = ann != null ? ann.uri() : null;
|
||||||
|
onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void onInit() {}
|
||||||
|
|
||||||
|
protected <T> T require(Class<T> type) {
|
||||||
|
checkBound();
|
||||||
|
return ctx.require(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected <T> Optional<T> find(Class<T> type) {
|
||||||
|
checkBound();
|
||||||
|
return ctx.find(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected <T> Optional<T> optional(Class<T> type) {
|
||||||
|
checkBound();
|
||||||
|
return ctx.optional(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URI declared via {@link Resource @Resource}, cached at bind time. */
|
||||||
|
protected final String uri() { return uri; }
|
||||||
|
|
||||||
|
private void checkBound() {
|
||||||
|
if (ctx == null)
|
||||||
|
throw new IllegalStateException(
|
||||||
|
getClass().getSimpleName() + " has not been bound to a FlashContext — " +
|
||||||
|
"register via McpConfig.toolsPackage(), not by instantiating directly");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invoked on every matching {@code resources/read} request (hot path). */
|
||||||
|
public abstract ResourceContents read() throws Exception;
|
||||||
|
}
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** RFC 9728 OAuth 2.0 Protected Resource Metadata. */
|
||||||
|
final class McpResourceMetadata {
|
||||||
|
|
||||||
|
private McpResourceMetadata() {}
|
||||||
|
|
||||||
|
/** {@code scopesSupported} is optional — omitted when empty. */
|
||||||
|
static String build(String resource, List<String> authorizationServers, List<String> scopesSupported) {
|
||||||
|
return McpJson.buildString(gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("resource", resource);
|
||||||
|
gen.writeArrayFieldStart("authorization_servers");
|
||||||
|
for (String issuer : authorizationServers) gen.writeString(issuer);
|
||||||
|
gen.writeEndArray();
|
||||||
|
if (!scopesSupported.isEmpty()) {
|
||||||
|
gen.writeArrayFieldStart("scopes_supported");
|
||||||
|
for (String scope : scopesSupported) gen.writeString(scope);
|
||||||
|
gen.writeEndArray();
|
||||||
|
}
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
/** Whether the MCP endpoint requires an authenticated caller. */
|
||||||
|
public enum McpSecurity {
|
||||||
|
|
||||||
|
/** The default: every call is authenticated by {@code flash-ext-security-core}, which must be installed. */
|
||||||
|
REQUIRED,
|
||||||
|
|
||||||
|
/** A public endpoint. Tools declaring security annotations fail the boot. */
|
||||||
|
NONE
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for a single MCP tool — one class per tool, mirroring
|
||||||
|
* {@link dev.relism.flash.models.RequestHandler}: declare metadata with {@link Tool @Tool},
|
||||||
|
* cache services in {@link #onInit()}, implement {@link #call(ToolArguments)} for the hot path.
|
||||||
|
*
|
||||||
|
* <p>Discovered via {@link McpConfig#toolsPackage(String)} — instantiated with its public
|
||||||
|
* no-arg constructor and bound once at boot, before the first {@code tools/call} request.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* @Tool(name = "get_weather", description = "Get current weather for a city",
|
||||||
|
* args = @ToolArg(name = "city", required = true))
|
||||||
|
* public class GetWeatherTool extends McpTool {
|
||||||
|
* private WeatherService weatherService;
|
||||||
|
*
|
||||||
|
* @Override protected void onInit() {
|
||||||
|
* weatherService = require(WeatherService.class);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @Override public ToolResponse call(ToolArguments args) {
|
||||||
|
* return ToolResponse.success(new TextContent(weatherService.fetch(args.getString("city"))));
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public abstract class McpTool {
|
||||||
|
|
||||||
|
private FlashContext ctx;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once by the framework after instantiation, before the first {@code tools/call}.
|
||||||
|
* <b>Infrastructure method</b> — do not call from user code.
|
||||||
|
*/
|
||||||
|
public final void bind(FlashContext ctx) {
|
||||||
|
this.ctx = ctx;
|
||||||
|
onInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Override to cache services at boot time. See {@link #require}/{@link #find}. */
|
||||||
|
protected void onInit() {}
|
||||||
|
|
||||||
|
protected <T> T require(Class<T> type) {
|
||||||
|
checkBound();
|
||||||
|
return ctx.require(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected <T> Optional<T> find(Class<T> type) {
|
||||||
|
checkBound();
|
||||||
|
return ctx.find(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected <T> Optional<T> optional(Class<T> type) {
|
||||||
|
checkBound();
|
||||||
|
return ctx.optional(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkBound() {
|
||||||
|
if (ctx == null)
|
||||||
|
throw new IllegalStateException(
|
||||||
|
getClass().getSimpleName() + " has not been bound to a FlashContext — " +
|
||||||
|
"register via McpConfig.toolsPackage(), not by instantiating directly");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked on every matching {@code tools/call} request (hot path). {@code args} is a thin
|
||||||
|
* accessor over the already-parsed JSON arguments — no databinding.
|
||||||
|
*/
|
||||||
|
public abstract ToolResponse call(ToolArguments args) throws Exception;
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
import dev.relism.flash.routing.Middleware;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Transport-level guards for the MCP Streamable HTTP endpoint. */
|
||||||
|
@Slf4j
|
||||||
|
final class McpTransportGuards {
|
||||||
|
|
||||||
|
private McpTransportGuards() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the {@code Origin} header per the Streamable HTTP transport's DNS-rebinding
|
||||||
|
* protection requirement. Non-browser clients that omit {@code Origin} entirely are always
|
||||||
|
* allowed through — only a <em>present but disallowed</em> value is rejected.
|
||||||
|
*
|
||||||
|
* <p>If {@code allowedOrigins} is empty, validation is skipped and a boot-time warning is
|
||||||
|
* logged.
|
||||||
|
*/
|
||||||
|
static Middleware originGuard(List<String> allowedOrigins) {
|
||||||
|
if (allowedOrigins.isEmpty()) {
|
||||||
|
log.warn("[flash-ext-mcp] No allowedOrigins configured — Origin header validation " +
|
||||||
|
"(DNS-rebinding protection) is DISABLED. Configure McpConfig.allowedOrigins(...) for production use.");
|
||||||
|
return next -> next::handle;
|
||||||
|
}
|
||||||
|
return next -> (req, res) -> {
|
||||||
|
String origin = req.header("Origin");
|
||||||
|
if (origin != null && !allowedOrigins.contains(origin)) {
|
||||||
|
throw HttpException.forbidden();
|
||||||
|
}
|
||||||
|
return next.handle(req, res);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safety net around the whole MCP route: translates {@link HttpException} (thrown by
|
||||||
|
* {@link #originGuard} or by {@code flash-ext-security-core}) into a proper HTTP status
|
||||||
|
* directly, instead of relying on the app's global exception handler — which defaults to a
|
||||||
|
* generic 500 for every exception type unless the app owner overrides it (see
|
||||||
|
* {@code AbstractRouter}'s default {@code exceptionHandler}). Keeps the MCP endpoint
|
||||||
|
* correct out of the box regardless of what the rest of the app configures.
|
||||||
|
*/
|
||||||
|
static Middleware httpExceptionGuard() {
|
||||||
|
return next -> (req, res) -> {
|
||||||
|
try {
|
||||||
|
return next.handle(req, res);
|
||||||
|
} catch (HttpException e) {
|
||||||
|
String body = McpJson.buildString(gen -> {
|
||||||
|
gen.writeStartObject();
|
||||||
|
gen.writeStringField("error", e.getMessage());
|
||||||
|
gen.writeEndObject();
|
||||||
|
});
|
||||||
|
res.status(e.status()).type(ContentType.JSON).body(body);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a {@link McpPrompt} subclass as an MCP prompt template and declares its metadata,
|
||||||
|
* discovered by {@link McpConfig#toolsPackage(String)}.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* @Prompt(name = "summarize", args = @PromptArg(name = "text", required = true))
|
||||||
|
* public class SummarizePrompt extends McpPrompt {
|
||||||
|
* @Override
|
||||||
|
* public PromptMessage render(PromptArguments args) {
|
||||||
|
* return PromptMessage.withUserRole(new TextContent("Summarize: " + args.getString("text")));
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Target(ElementType.TYPE)
|
||||||
|
public @interface Prompt {
|
||||||
|
/** Unique prompt name (used by clients in {@code prompts/get}). */
|
||||||
|
String name();
|
||||||
|
|
||||||
|
String description() default "";
|
||||||
|
|
||||||
|
/** Arguments accepted by the prompt template — always strings per the MCP specification. */
|
||||||
|
PromptArg[] args() default {};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user