20 Commits
Author SHA1 Message Date
Relism 5ece97ca7b Merge pull request 'fix(ext-openapi): one shared answer per status, not a numbered family' (#24) from fix/openapi/one-shared-answer-per-status into master
Publish Maven packages / publish (push) Successful in 3m36s
2026-09-23 16:34:15 +00:00
Zakaria El OrcheandClaude Opus 5 5fe6fb46c8 fix(ext-openapi): one shared answer per status, not a numbered family
Hoisting named a shared response after its status and disambiguated with a
counter, so a document with three wordings for 403 grew Forbidden, Forbidden2
and Forbidden3 in its components. Numbered names say nothing and move as soon
as a route is added.

The answer a status is usually given is now the one hoisted, under that
status's own name, and a route that answers the same status differently keeps
its wording inline where it belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 16:34:13 +00:00
Relism c0c9480fa5 Merge pull request 'feat(ext-openapi): a route can say it is not part of the API' (#23) from feat/openapi/undocumented into master
Publish Maven packages / publish (push) Successful in 2m56s
2026-09-23 15:47:37 +00:00
Zakaria El OrcheandClaude Opus 5 9090ba59f5 feat(ext-openapi): a route can say it is not part of the API
Every class-based route is documented, which is what keeps a document from
lying by omission. Some routes are not API at all — a health check, an
internal callback, something on its way out — and @Undocumented says so, once,
where the handler is. Inherited, so a base class leaves out every handler
written against it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 15:47:35 +00:00
Relism 6a6431c528 Merge pull request 'fix(ext-jackson): a constraint says what it wants in its own words' (#22) from fix/validation/constraint-messages into master
Publish Maven packages / publish (push) Successful in 2m27s
2026-09-23 14:51:28 +00:00
Zakaria El OrcheandClaude Opus 5 491553e9f5 fix(ext-jackson): a constraint says what it wants in its own words
The compiler ignored the message a constraint declares and always wrote its
own, so a failed @Pattern answered the caller with a regex. It now uses the
annotation's message whenever one is set, and keeps the plain description for
jakarta's default, which is a resource bundle key and not something to put in
front of whoever sent the request.

This is what makes moving a check out of a service and onto the type it
belongs to cost nothing: the wording moves with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 14:51:16 +00:00
Relism dd0455c6d1 Merge pull request 'fix(ext-openapi): an answer's media type is the handler's, not the body's' (#21) from fix/openapi/answer-media-type into master
Publish Maven packages / publish (push) Successful in 2m29s
2026-09-23 14:42:14 +00:00
Zakaria El OrcheandClaude Opus 5 60dd4eab6a fix(ext-openapi): an answer's media type is the handler's, not the body's
@Consumes says what a route reads. It was also deciding what the document said
a route answers, through a JSON default nothing could override: a handler that
takes a JSON body is not thereby a handler that answers JSON.

@Produces now says that, beside @Consumes and as descriptive as it is — on the
handler, or once on a base class. Every response takes its media type from it,
JSON when nothing declares one, and the error object stays JSON because that is
what Flash answers a failure with whatever the route produces.

Content loses contentType with it. One handler answers in one format and a
status code does not change that, so the media type was in the wrong place; a
response with no schema and no return type to infer one from is a response
with no body, which is what a 204 was using it to say.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 14:41:56 +00:00
Relism 2f06ca7c1d Merge pull request 'feat: typed bodies, injected services, one Jackson module per format' (#20) from feature/handlers/typed-bodies-and-injected-services into master
Publish Maven packages / publish (push) Successful in 2m31s
2026-09-23 14:11:17 +00:00
Zakaria El OrcheandClaude Opus 5 2fbe65fcc5 feat(ext-openapi): document what the code already says
Every class-based route is documented now, annotated or not: a route without
@ApiOperation used to be dropped with a warning, which made the document lie by
omission.

Read off the handler: the request body from the type it declares (or from the
new @RequestBody, for one that reads the body itself), the success schema from
the most specific handle it implements, and the media type from @Consumes.

Failures are described too. Any 4xx or 5xx without an explicit schema documents
the error object Flash actually answers with, written once under
components.schemas.Error — before this, a declared 4xx inherited the success
schema, which was simply wrong. And an answer two or more operations give
identically is hoisted into components.responses and referenced, so the 401 of
every guarded route appears once rather than on every path.

@Content gained an example, and the schema registry moved into its own class.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 13:31:28 +00:00
Zakaria El OrcheandClaude Opus 5 adcd6376b6 feat(ext-jackson): one module per data format, bodies checked on the way in
flash-ext-jackson and flash-ext-validation become three modules:

- flash-ext-jackson-core: the Codec (one mapper, body/write/writeView),
  JacksonHandler, the outbound marshalling, and the constraint engine that
  used to be flash-ext-validation
- flash-ext-jackson-json: Json, JsonExtension, JsonHandler
- flash-ext-jackson-xml: Xml, XmlExtension, XmlHandler

Every Jackson format is the same databind model behind a different factory, so
the annotations, the constraints and the published schema are the same for all
of them: only the mapper and the content type differ, and that is all a format
module says. A route picks its format by the handler it extends — there is no
negotiation and nothing to configure.

A typed body is now always verified against its own type's jakarta
constraints, whatever the format: malformed is a 400, a broken constraint is a
422, and neither reaches the handler. The validator was already allocation-free
and stays so; validating is no longer something an application remembers to do.

bodyFrom is gone. body has the streaming semantics, because the request's
stream is reused per connection while bytes() allocates the whole body: one
name, the path that does not allocate. JacksonExtension is JsonExtension, and
autoJson() is auto().

The root POM now manages every module of this build, so anything composing
Flash imports it once and never names a version again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 13:31:28 +00:00
Zakaria El OrcheandClaude Opus 5 ef4740f26d feat(core): a service a handler asks for, and a body typed in its signature
Two things every handler was writing by hand.

@Inject on a field is filled inside bind, before onInit, once per handler at
boot: the request path still reads a field. The service is looked up by the
field's exact declared type; a static or final field is refused, and a type
nothing provides fails the boot naming the field. onInit stays for what has to
be computed, or for a service that may not be there.

BodyHandler<B> puts the body type in the signature — handle(req, res, body) —
and leaves reading it to the format. bodyTypeOf resolves that type argument
through a whole chain of bases, so tooling can read off a class what a route
takes. @Consumes says in which media type, inherited from the base class that
implements the reading, and is descriptive: the router does not enforce it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 13:31:13 +00:00
Relism 8353033cb1 Merge pull request 'feat: Vite extension and Maven plugin; data pools close on stop' (#19) from feature/ext-vite/replace-web-bundler into master
Publish Maven packages / publish (push) Successful in 2m49s
2026-09-22 16:27:46 +00:00
Zakaria El Orche fd96d67fca Merge branch 'feature/ext-data/close-pool-on-stop' into feature/ext-vite/replace-web-bundler 2026-09-22 16:25:16 +00:00
Zakaria El OrcheandClaude Opus 5 003fd6d1f0 feat(ext-vite): recognise navigations by Sec-Fetch-Mode, read every header in place
A path that is no file falls back to index.html when the request is a navigation:
Sec-Fetch-Mode: navigate, or an Accept naming text/html for older clients. That check,
Accept-Encoding and If-None-Match are all matched on the header bytes through the new
Request.headerView(name), so serving still allocates nothing. navigationOnly(false)
drops the check for an app that wants every GET miss to get the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:16:27 +00:00
Zakaria El OrcheandClaude Opus 5 680bbca8c7 feat(ext-vite): gzip at build time, fall back to the index only for navigations
The Maven plugin now writes a maximally compressed .gz beside every text file of 1 KB
and more, so the server compresses nothing and boot only reads the files: about 50 ms
for Glossa's 500. Hashed files under assets/ skip the ETag, which nothing ever asks for.

A path that is no file gets index.html only when the request's Accept names text/html,
as a browser navigation does. Everything else, an API call to a missing route included,
gets the app's own 404 instead of the index, and a dot in a client route no longer
matters. The base path itself always serves the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:54:19 +00:00
Zakaria El OrcheandClaude Opus 5 580417e952 feat(ext-vite): replace the web bundler with a Vite extension and its Maven plugin
flash-ext-vite runs Vite's dev server in DEV and otherwise serves the build from the
classpath, read straight from the directory or jar with no manifest. The Maven plugin
flash-ext-vite-maven-plugin builds the frontend at prepare-package and packages it
there, so mvn package makes a jar that serves its own frontend and mvn test needs no
Node. Three overrides remain (root, devPort, basePath); the package manager is read
from the nearest lockfile.

Serving fixes what the bundler got wrong: Vite's hashed files under assets/ are
cached as immutable instead of revalidated, HEAD reports the real Content-Length, a
missing asset is a 404 instead of the index, 304s carry ETag and Cache-Control, and
gzip respects q=0 and is prepared at boot. Every response header is pre-encoded, so
serving allocates nothing, which is what Response.type(byte[]) is for. Vite stops
with the app through onClose, and a lockfile change reinstalls before restarting.

The modes, strategies, logging and command-safety options, the asset-source
abstraction, the manifest and the Jackson dependency are gone: 1,535 lines of main
code become 480, plus 84 for the plugin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:47:28 +00:00
Zakaria El OrcheandClaude Opus 5 d6c018242f feat(ext-data): close the connection pool when the app stops
TxManager is AutoCloseable and releases what it was built on: JdbcTxManager its data
source when closeable, HibernateTxManager its session factory and then the data source
Hibernate was handed, which Hibernate itself never closes. DataExtension registers the
close as an onClose callback, so a stopped app no longer leaves its pool connected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:02:35 +00:00
Relism 6d44f9e7b1 Merge pull request 'feat(ext-security): add an OAuth 2.1 authorization server' (#18) from feature/ext-security/oauth-server into master
Publish Maven packages / publish (push) Successful in 2m18s
2026-09-22 11:42:26 +00:00
Zakaria El OrcheandClaude Opus 5 f28fc43150 feat(ext-security): add an OAuth 2.1 authorization server
flash-ext-security-oauth-server issues RFC 9068 access tokens (code + PKCE S256,
CIMD and DCR clients, RFC 8707 resources, rotating refresh tokens) for resources on
the application's own origin. Around it: SecurityExtension resolves a configured
origin instead of X-Forwarded-* headers, mechanisms expose schemes() and a route can
be restricted to some of them, McpConfig.mechanisms(...) uses that, OIDC bearers must
be typed at+jwt, and PublicUrl guards outbound fetches against internal addresses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 11:39:09 +00:00
171 changed files with 5508 additions and 3637 deletions
+2 -2
View File
@@ -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" />
+1 -1
View File
@@ -37,7 +37,7 @@ Format: `<type>(<scope>): <short description>`
| `ci` | Changes to GitHub Actions workflows | | `ci` | Changes to GitHub Actions workflows |
Allowed scopes: `core`, `testing`, `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-mcp`, `ext-validation`, `ext-scheduler`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `ext-mcp`, `ext-validation`, `ext-scheduler`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`,
`ext-cache-core`, `ext-cache-caffeine`, `release`, `deps`, `ci`. `ext-cache-core`, `ext-cache-caffeine`, `release`, `deps`, `ci`.
+9 -4
View File
@@ -9,18 +9,22 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
|---|---| |---|---|
| `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model | | `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model |
| `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses | | `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses |
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration | | `flash-extensions/flash-ext-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-security-core` | Security: authentication chain, annotations, sessions, OpenAPI | | `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-oidc` | OpenID Connect: bearer tokens, code flow + PKCE |
| `flash-extensions/flash-ext-security-apikey` | API keys | | `flash-extensions/flash-ext-security-apikey` | API keys |
| `flash-extensions/flash-ext-security-form` | Password sign-in | | `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-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-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-extensions/flash-ext-validation` | Request validation — jakarta constraints, compiled once per type | | `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-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-core` | Caching contract — `Cache`, `CacheManager`, `CacheSpec` |
| `flash-extensions/flash-ext-cache-caffeine` | In-process cache backed by Caffeine | | `flash-extensions/flash-ext-cache-caffeine` | In-process cache backed by Caffeine |
@@ -148,7 +152,9 @@ FlashApp.create(8080)
``` ```
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-security-core`](flash-extensions/flash-ext-security-core/docs/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-oidc`](flash-extensions/flash-ext-security-oidc/docs/README.md)
@@ -158,7 +164,6 @@ See extension-specific READMEs for full details:
- [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/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-validation`](flash-extensions/flash-ext-validation/docs/README.md)
- [`flash-ext-scheduler`](flash-extensions/flash-ext-scheduler/docs/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-ext-cache-caffeine`](flash-extensions/flash-ext-cache-caffeine/docs/README.md)
- [`flash-testing`](flash-testing/docs/README.md) - [`flash-testing`](flash-testing/docs/README.md)
@@ -112,6 +112,8 @@ public abstract class Repository<T, ID> {
- `Tx` in the `FlashContext` - `Tx` in the `FlashContext`
- `TxManager` in the `FlashContext` - `TxManager` in the `FlashContext`
- an annotation processor for `@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
This makes the data layer composable with Flash's extension system without global state. This makes the data layer composable with Flash's extension system without global state.
@@ -34,6 +34,7 @@ public final class DataExtension implements FlashExtension {
@Override @Override
public void configure(FlashRegistrar<?> app, 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); if (data != null) ctx.provide(Data.class, data);
@@ -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();
} }
@@ -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()) {
@@ -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);
}
}
@@ -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()) {
@@ -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());
}
}
@@ -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>
@@ -1,4 +1,4 @@
package dev.relism.flash.ext.validation; package dev.relism.flash.ext.jackson;
import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandle;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -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;
}
}
@@ -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);
}
}
@@ -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);
}
};
}
}
@@ -1,4 +1,4 @@
package dev.relism.flash.ext.validation; package dev.relism.flash.ext.jackson;
import dev.relism.flash.exceptions.HttpException; import dev.relism.flash.exceptions.HttpException;
@@ -14,7 +14,7 @@ public final class ValidationException extends HttpException {
private final transient List<Violation> violations; private final transient List<Violation> violations;
ValidationException(List<Violation> violations) { public ValidationException(List<Violation> violations) {
super(422, describe(violations)); super(422, describe(violations));
this.violations = List.copyOf(violations); this.violations = List.copyOf(violations);
} }
@@ -1,4 +1,4 @@
package dev.relism.flash.ext.validation; package dev.relism.flash.ext.jackson;
import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Max;
@@ -31,12 +31,38 @@ public final class Validator {
private static final Check[] NONE = new Check[0]; 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 final Check[] checks;
private Validator(Check[] checks) { private Validator(Check[] checks) {
this.checks = 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. */ /** True when the type declares no constraints at all — {@link #verify} is then a no-op. */
public boolean isEmpty() { public boolean isEmpty() {
return checks.length == 0; return checks.length == 0;
@@ -152,25 +178,28 @@ public final class Validator {
Class<?> type = field.getType(); Class<?> type = field.getType();
MethodHandle ref = type.isPrimitive() ? null : asReference(getter); MethodHandle ref = type.isPrimitive() ? null : asReference(getter);
if (field.isAnnotationPresent(NotNull.class) && ref != null) NotNull notNull = field.getAnnotation(NotNull.class);
checks.add(Check.reference(Check.NOT_NULL, name, "must not be null", ref)); if (notNull != null && ref != null)
checks.add(Check.reference(Check.NOT_NULL, name, said(notNull.message(), "must not be null"), ref));
if (field.isAnnotationPresent(NotBlank.class) && ref != null) NotBlank notBlank = field.getAnnotation(NotBlank.class);
checks.add(Check.reference(Check.NOT_BLANK, name, "must not be blank", ref)); if (notBlank != null && ref != null)
checks.add(Check.reference(Check.NOT_BLANK, name, said(notBlank.message(), "must not be blank"), ref));
if (field.isAnnotationPresent(NotEmpty.class) && ref != null) NotEmpty notEmpty = field.getAnnotation(NotEmpty.class);
checks.add(Check.reference(Check.NOT_EMPTY, name, "must not be empty", ref)); 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); Size size = field.getAnnotation(Size.class);
if (size != null && ref != null) if (size != null && ref != null)
checks.add(Check.size(name, sizeMessage(size), ref, size.min(), size.max())); checks.add(Check.size(name, said(size.message(), sizeMessage(size)), ref, size.min(), size.max()));
Min min = field.getAnnotation(Min.class); Min min = field.getAnnotation(Min.class);
Max max = field.getAnnotation(Max.class); Max max = field.getAnnotation(Max.class);
if (min != null || max != null) { if (min != null || max != null) {
long lo = min != null ? min.value() : Long.MIN_VALUE; long lo = min != null ? min.value() : Long.MIN_VALUE;
long hi = max != null ? max.value() : Long.MAX_VALUE; long hi = max != null ? max.value() : Long.MAX_VALUE;
String message = rangeMessage(min, max); String message = said(min != null ? min.message() : max.message(), rangeMessage(min, max));
if (isIntegralPrimitive(type)) { if (isIntegralPrimitive(type)) {
checks.add(Check.rangePrimitive(name, message, asLong(getter), lo, hi)); checks.add(Check.rangePrimitive(name, message, asLong(getter), lo, hi));
} else if (Number.class.isAssignableFrom(type) && ref != null) { } else if (Number.class.isAssignableFrom(type) && ref != null) {
@@ -178,14 +207,15 @@ public final class Validator {
} }
} }
if (field.isAnnotationPresent(Email.class) && ref != null) Email email = field.getAnnotation(Email.class);
checks.add(Check.reference(Check.EMAIL, name, "must be a well-formed email address", ref)); 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); Pattern pattern = field.getAnnotation(Pattern.class);
if (pattern != null && ref != null) { if (pattern != null && ref != null) {
// ponytail: the one allocating check Pattern.matcher() per call. The regex itself is // 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. // compiled once here; swap for a structural check if a hot route ever needs it.
checks.add(Check.pattern(name, "must match " + pattern.regexp(), ref, checks.add(Check.pattern(name, said(pattern.message(), "must match " + pattern.regexp()), ref,
java.util.regex.Pattern.compile(pattern.regexp()))); java.util.regex.Pattern.compile(pattern.regexp())));
} }
} }
@@ -202,6 +232,15 @@ public final class Validator {
return getter.asType(MethodType.methodType(long.class, Object.class)); 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) { private static String sizeMessage(Size size) {
if (size.min() == 0) return "size must be at most " + size.max(); 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(); if (size.max() == Integer.MAX_VALUE) return "size must be at least " + size.min();
@@ -1,4 +1,4 @@
package dev.relism.flash.ext.validation; package dev.relism.flash.ext.jackson;
import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Max;
@@ -114,4 +114,15 @@ class ValidatorTest {
assertTrue(validator.isEmpty()); assertTrue(validator.isEmpty());
assertDoesNotThrow(() -> validator.verify(new Plain(null))); 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>
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -1,6 +1,6 @@
package dev.relism.flash.ext.validation; package dev.relism.flash.ext.jackson.json;
import dev.relism.flash.ext.jackson.JacksonExtension; import dev.relism.flash.ext.jackson.json.JsonExtension;
import dev.relism.flash.ext.openapi.APIResponse; import dev.relism.flash.ext.openapi.APIResponse;
import dev.relism.flash.ext.openapi.ApiOperation; import dev.relism.flash.ext.openapi.ApiOperation;
import dev.relism.flash.ext.openapi.Content; import dev.relism.flash.ext.openapi.Content;
@@ -24,7 +24,7 @@ import org.junit.jupiter.api.extension.RegisterExtension;
* describes them. Nothing registers this bridge flash-ext-openapi picks the annotations up on * describes them. Nothing registers this bridge flash-ext-openapi picks the annotations up on
* its own when they are on the classpath. * its own when they are on the classpath.
*/ */
class ValidationOpenApiInteropTest { class ConstraintsInTheDocumentTest {
record Account( record Account(
@NotBlank @Size(max = 40) String name, @NotBlank @Size(max = 40) String name,
@@ -33,7 +33,7 @@ class ValidationOpenApiInteropTest {
@GET("/accounts") @GET("/accounts")
@ApiOperation(summary = "List accounts") @ApiOperation(summary = "List accounts")
@APIResponse(responseCode = "200", content = @Content(contentType = ContentType.JSON, schema = Account.class)) @APIResponse(responseCode = "200", content = @Content(schema = Account.class))
public static class ListAccounts extends RequestHandler { public static class ListAccounts extends RequestHandler {
@Override public Object handle(Request request, Response response) { @Override public Object handle(Request request, Response response) {
return new Account("alice", "a@b.com", 30); return new Account("alice", "a@b.com", 30);
@@ -42,10 +42,9 @@ class ValidationOpenApiInteropTest {
@RegisterExtension @RegisterExtension
static FlashTest app = FlashTest.of(configured -> { static FlashTest app = FlashTest.of(configured -> {
configured.install(new JacksonExtension()); configured.install(new JsonExtension());
configured.install(new ValidationExtension());
configured.install(new OpenApiExtension("/openapi", "Accounts", "1.0.0")); configured.install(new OpenApiExtension("/openapi", "Accounts", "1.0.0"));
configured.scan("dev.relism.flash.ext.validation"); configured.scan("dev.relism.flash.ext.jackson.json");
}); });
@Test @Test
@@ -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,26 +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 configure_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.configure(null, ctx); ext.configure(null, ctx);
ctx.complete(); 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) {
@@ -43,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 {
@@ -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));
}
}
@@ -1,4 +1,4 @@
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;
@@ -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());
} }
@@ -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 {
@@ -1,6 +1,5 @@
package dev.relism.flash.ext.validation; package dev.relism.flash.ext.jackson.json;
import dev.relism.flash.ext.jackson.JacksonExtension;
import dev.relism.flash.testing.FlashTest; import dev.relism.flash.testing.FlashTest;
import jakarta.validation.constraints.Email; import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
@@ -12,19 +11,18 @@ import org.junit.jupiter.api.extension.RegisterExtension;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
/** The whole path: JSON in, constraints checked, status out — with no error handling wired up. */ /** The whole path: JSON in, constraints checked, status out — with no error handling wired up. */
class ValidationRoutesTest { class ValidatedBodyTest {
record CreateUser(@NotBlank @Size(max = 8) String name, @Email String email, @Min(18) int age) {} record CreateUser(@NotBlank @Size(max = 8) String name, @Email String email, @Min(18) int age) {}
@RegisterExtension @RegisterExtension
static FlashTest app = FlashTest.of(configured -> { static FlashTest app = FlashTest.of(configured -> {
configured.install(new JacksonExtension()); configured.install(new JsonExtension());
configured.install(new ValidationExtension());
configured.ctx().onReady(() -> { configured.ctx().onReady(() -> {
Validation validation = configured.ctx().require(Validation.class); Json json = configured.ctx().require(Json.class);
configured.post("/users", (req, res) -> configured.post("/users", (req, res) ->
res.status(201).body("created:" + validation.body(req, CreateUser.class).name())); res.status(201).body("created:" + json.body(req, CreateUser.class).name()));
}); });
}); });
@@ -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.
@@ -10,42 +10,34 @@
<version>2.1.0-SNAPSHOT</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-validation</artifactId> <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> <dependencies>
<dependency> <dependency>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash</artifactId> <artifactId>flash-ext-jackson-core</artifactId>
</dependency> </dependency>
<!--
Annotations only (~90 KB). Hibernate Validator's engine is deliberately absent: it
resolves constraints reflectively per call and pulls ~2 MB plus EL. This module
compiles the same annotations into a flat check table once per class instead.
-->
<dependency> <dependency>
<groupId>jakarta.validation</groupId> <groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jakarta.validation-api</artifactId> <artifactId>jackson-dataformat-xml</artifactId>
</dependency>
<!-- Only needed for Validation.body(...); check(...) works without it. -->
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-jackson</artifactId>
<optional>true</optional>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId> <artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<scope>test</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId> <artifactId>flash-testing</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!-- Interop only: proves constraints reach the published schema. -->
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-openapi</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>
@@ -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);
}
}
@@ -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));
}
}
@@ -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;
}
}
@@ -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>
@@ -1,94 +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.FlashRegistrar;
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 from a {@link FlashContext#onReady(Runnable)}
* callback (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 configure(FlashRegistrar<?> app, FlashContext ctx) {
Json json = new Json(mapper);
ctx.provide(Json.class, json);
ctx.provide(ObjectMapper.class, mapper);
ctx.provide(JacksonMiddleware.class, middleware);
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -14,9 +14,9 @@ authenticate `/mcp` exactly as they authenticate every other route.
When a registered mechanism publishes an OAuth2 issuer — `flash-ext-security-oidc` does — the endpoint 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: behaves as the MCP authorization spec requires, with nothing to configure:
- `GET /.well-known/oauth-protected-resource/mcp` serves RFC 9728 metadata: the `resource` (derived per - `GET /.well-known/oauth-protected-resource/mcp` serves RFC 9728 metadata: the `resource` (the
request from `X-Forwarded-Proto`/`-Host` or `Host`), every issuer as `authorization_servers`, and application's `SecurityExtension.origin(...)` plus the path), every issuer as `authorization_servers`,
`scopes_supported` when `McpConfig.scopesSupported(...)` is set; and `scopes_supported` when `McpConfig.scopesSupported(...)` is set;
- an anonymous call gets `401` with `WWW-Authenticate: Bearer resource_metadata="…"`; - 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 - 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. that are not audience-bound, such as API keys, are unaffected.
@@ -31,6 +31,17 @@ mint a resource audience at all — Keycloak ignores RFC 8707's `resource` param
cannot add the mapper has no other way in. Every token a registered issuer signs is then accepted on the 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. 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 ## Tool policies
The core annotations work on tools as on handlers, checked per `tools/call` against the caller the route The core annotations work on tools as on handlers, checked per `tools/call` against the caller the route
@@ -1,5 +1,6 @@
package dev.relism.flash.ext.mcp; package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.security.AuthenticationMechanism;
import dev.relism.flash.routing.Middleware; import dev.relism.flash.routing.Middleware;
import java.util.ArrayList; import java.util.ArrayList;
@@ -30,6 +31,7 @@ public final class McpConfig {
private final List<String> allowedOrigins; private final List<String> allowedOrigins;
private final List<String> scopesSupported; private final List<String> scopesSupported;
private final List<Middleware> middleware; private final List<Middleware> middleware;
private final List<AuthenticationMechanism> mechanisms;
private McpConfig(Builder b) { private McpConfig(Builder b) {
this.name = b.name; this.name = b.name;
@@ -42,6 +44,7 @@ public final class McpConfig {
this.allowedOrigins = List.copyOf(b.allowedOrigins); this.allowedOrigins = List.copyOf(b.allowedOrigins);
this.scopesSupported = List.copyOf(b.scopesSupported); this.scopesSupported = List.copyOf(b.scopesSupported);
this.middleware = List.copyOf(b.middleware); this.middleware = List.copyOf(b.middleware);
this.mechanisms = List.copyOf(b.mechanisms);
} }
String name() { return name; } String name() { return name; }
@@ -54,6 +57,7 @@ public final class McpConfig {
List<String> allowedOrigins() { return allowedOrigins; } List<String> allowedOrigins() { return allowedOrigins; }
List<String> scopesSupported() { return scopesSupported; } List<String> scopesSupported() { return scopesSupported; }
List<Middleware> middleware() { return middleware; } List<Middleware> middleware() { return middleware; }
List<AuthenticationMechanism> mechanisms() { return mechanisms; }
public static Builder builder(String name) { return new Builder(name); } public static Builder builder(String name) { return new Builder(name); }
@@ -68,6 +72,7 @@ public final class McpConfig {
private final List<String> allowedOrigins = new ArrayList<>(); private final List<String> allowedOrigins = new ArrayList<>();
private final List<String> scopesSupported = new ArrayList<>(); private final List<String> scopesSupported = new ArrayList<>();
private final List<Middleware> middleware = new ArrayList<>(); private final List<Middleware> middleware = new ArrayList<>();
private final List<AuthenticationMechanism> mechanisms = new ArrayList<>();
private Builder(String name) { private Builder(String name) {
if (name == null || name.isBlank()) if (name == null || name.isBlank())
@@ -108,6 +113,15 @@ public final class McpConfig {
/** Published as {@code scopes_supported} in the RFC 9728 metadata, so OAuth clients request them. */ /** 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; } 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. */ /** Runs on the MCP route after the transport guards and authentication — rate limiting, auditing, tracing. */
public Builder middleware(Middleware... middleware) { public Builder middleware(Middleware... middleware) {
this.middleware.addAll(List.of(middleware)); this.middleware.addAll(List.of(middleware));
@@ -1,6 +1,8 @@
package dev.relism.flash.ext.mcp; package dev.relism.flash.ext.mcp;
import dev.relism.flash.exceptions.HttpException; 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.SecurityExtension;
import dev.relism.flash.ext.security.SecurityIdentity; import dev.relism.flash.ext.security.SecurityIdentity;
import dev.relism.flash.ext.security.SecurityPolicy; import dev.relism.flash.ext.security.SecurityPolicy;
@@ -38,6 +40,7 @@ import java.util.Objects;
public class McpExtension implements FlashExtension { public class McpExtension implements FlashExtension {
private final McpConfig config; private final McpConfig config;
private volatile List<SecurityScheme> schemes;
public McpExtension(McpConfig config) { public McpExtension(McpConfig config) {
this.config = config; this.config = config;
@@ -65,16 +68,19 @@ public class McpExtension implements FlashExtension {
private void protect(FlashRegistrar<?> app, SecurityExtension security, List<Middleware> chain) { private void protect(FlashRegistrar<?> app, SecurityExtension security, List<Middleware> chain) {
String metadataPath = "/.well-known/oauth-protected-resource" + config.rootPath(); String metadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
chain.add(security.enforce(SecurityPolicy.AUTHENTICATED, (req, res) -> { List<AuthenticationMechanism> only = config.mechanisms();
List<String> issuers = issuers(security); AuthenticationEntryPoint anonymous = (req, res) -> {
res.header("WWW-Authenticate", issuers.isEmpty() List<SecurityScheme> schemes = schemes(security);
? String.join(", ", security.schemes().stream().map(SecurityScheme::challenge).toList()) res.header("WWW-Authenticate", issuers(schemes, null).isEmpty()
: "Bearer resource_metadata=\"" + req.origin() + metadataPath + "\""); ? String.join(", ", schemes.stream().map(SecurityScheme::challenge).toList())
: "Bearer resource_metadata=\"" + security.origin(req) + metadataPath + "\"");
throw HttpException.unauthorized(); throw HttpException.unauthorized();
})); };
chain.add(only.isEmpty() ? security.enforce(SecurityPolicy.AUTHENTICATED, anonymous)
: security.enforce(SecurityPolicy.AUTHENTICATED, anonymous, only));
if (config.requireTokenAudience()) { if (config.requireTokenAudience()) {
chain.add(next -> (req, res) -> { chain.add(next -> (req, res) -> {
String resource = req.origin() + config.rootPath(); String resource = security.origin(req) + config.rootPath();
if (!SecurityIdentity.current().principal().hasAudience(resource)) { 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); 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(); throw HttpException.forbidden();
@@ -85,14 +91,24 @@ public class McpExtension implements FlashExtension {
log.warn("[flash-ext-mcp] Token audience validation (RFC 8707) is DISABLED for {} — every token a registered issuer signs is accepted.", config.rootPath()); 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) -> { app.get(metadataPath, (req, res) -> {
List<String> issuers = issuers(security); List<String> issuers = issuers(schemes(security), security.origin(req));
if (issuers.isEmpty()) throw HttpException.notFound("Protected resource metadata"); if (issuers.isEmpty()) throw HttpException.notFound("Protected resource metadata");
res.type(ContentType.JSON); res.type(ContentType.JSON);
return McpResourceMetadata.build(req.origin() + config.rootPath(), issuers, config.scopesSupported()); return McpResourceMetadata.build(security.origin(req) + config.rootPath(), issuers, config.scopesSupported());
}); });
} }
private static List<String> issuers(SecurityExtension security) { /** Resolved at the first request, once every mechanism has registered — which may be after this extension was ready. */
return security.schemes().stream().map(SecurityScheme::issuer).filter(Objects::nonNull).toList(); 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();
} }
} }
@@ -1,6 +1,10 @@
package dev.relism.flash.ext.mcp; package dev.relism.flash.ext.mcp;
import dev.relism.flash.ext.security.AuthenticationMechanism;
import dev.relism.flash.ext.security.Principal;
import dev.relism.flash.ext.security.SecurityExtension; import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.ext.security.SecurityScheme;
import dev.relism.flash.models.Request;
import dev.relism.flash.ext.security.apikey.ApiKey; import dev.relism.flash.ext.security.apikey.ApiKey;
import dev.relism.flash.ext.security.apikey.ApiKeyExtension; import dev.relism.flash.ext.security.apikey.ApiKeyExtension;
import dev.relism.flash.ext.security.apikey.GeneratedApiKey; import dev.relism.flash.ext.security.apikey.GeneratedApiKey;
@@ -13,6 +17,7 @@ import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.extension.RegisterExtension;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.function.Consumer; import java.util.function.Consumer;
@@ -32,6 +37,27 @@ class McpSecurityTest {
.install(new McpExtension(McpConfig.builder("secure").toolsPackage("dev.relism.flash.ext.mcp.authfixtures.secured") .install(new McpExtension(McpConfig.builder("secure").toolsPackage("dev.relism.flash.ext.mcp.authfixtures.secured")
.scopesSupported("openid", "email").build()))); .scopesSupported("openid", "email").build())));
static final OidcExtension oidc = new OidcExtension(OidcProvider.of("fake", provider.issuer(), "app", "secret"));
/** Another issuer the application trusts, which the restricted endpoint below must neither accept nor advertise. */
static final AuthenticationMechanism elsewhere = new AuthenticationMechanism() {
@Override public Principal authenticate(Request req) {
return "Other".equals(req.header("Authorization")) ? () -> "other" : null;
}
@Override public List<SecurityScheme> schemes() {
return List.of(SecurityScheme.openIdConnect("other", "https://other.example"));
}
};
/** Only the OIDC provider authenticates this endpoint, however many mechanisms the application has. */
@RegisterExtension
static final FlashTest restricted = FlashTest.of(flash -> flash
.install(new SecurityExtension().mechanism(elsewhere))
.install(oidc)
.install(apiKeys)
.install(new McpExtension(McpConfig.builder("restricted").toolsPackage("dev.relism.flash.ext.mcp.fixtures")
.mechanisms(oidc).requireTokenAudience(false).build())));
/** The same chain with the RFC 8707 check turned off, for an authorization server that cannot mint a resource audience. */ /** The same chain with the RFC 8707 check turned off, for an authorization server that cannot mint a resource audience. */
@RegisterExtension @RegisterExtension
static final FlashTest relaxed = FlashTest.of(flash -> flash static final FlashTest relaxed = FlashTest.of(flash -> flash
@@ -83,6 +109,28 @@ class McpSecurityTest {
.post("/mcp").expectStatus(200).expectBodyContains("protocolVersion"); .post("/mcp").expectStatus(200).expectBodyContains("protocolVersion");
} }
/** The resource is the application's own origin: a forwarded header naming another host cannot make its tokens good here. */
@Test
void aForwardedHostCannotChooseTheResource() {
app.request().with(provider.bearer("u", Map.of("aud", "https://evil.example/mcp")))
.header("X-Forwarded-Proto", "https").header("X-Forwarded-Host", "evil.example").header("Accept", "application/json")
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}").post("/mcp").expectStatus(403);
}
@Test
void anEndpointRestrictedToSomeMechanismsAcceptsAndAdvertisesOnlyThem() {
restricted.get("/.well-known/oauth-protected-resource/mcp").expectStatus(200)
.expectBody("{\"resource\":\"http://127.0.0.1:" + restricted.port() + "/mcp\",\"authorization_servers\":[\"" + provider.issuer() + "\"]}");
Consumer<FlashRequest> key = request -> request.header("Authorization", "Bearer " + KEY.token());
Consumer<FlashRequest> other = request -> request.header("Authorization", "Other");
for (Consumer<FlashRequest> refused : List.of(key, other)) {
restricted.request().with(refused).header("Accept", "application/json")
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}").post("/mcp").expectStatus(401);
}
restricted.request().with(provider.bearer("u", Map.of())).header("Accept", "application/json")
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}").post("/mcp").expectStatus(200);
}
/** An API key is not audience-bound: the same chain authenticates agents that never saw an authorization server. */ /** An API key is not audience-bound: the same chain authenticates agents that never saw an authorization server. */
@Test @Test
void anApiKeyIsAcceptedBesideOAuth() { void anApiKeyIsAcceptedBesideOAuth() {
+105 -97
View File
@@ -1,6 +1,7 @@
# flash-ext-openapi # flash-ext-openapi
OpenAPI 3.0.3 generation + Swagger UI for Flash. OpenAPI 3.0.3 generation and Swagger UI, built from what the handlers already say about
themselves.
## What it provides ## What it provides
@@ -10,8 +11,6 @@ OpenAPI 3.0.3 generation + Swagger UI for Flash.
| `GET /openapi.yaml` | OpenAPI spec YAML | | `GET /openapi.yaml` | OpenAPI spec YAML |
| `GET /openapi/swagger` | Swagger UI | | `GET /openapi/swagger` | Swagger UI |
## Install
```java ```java
FlashApp.create(8080) FlashApp.create(8080)
.install(new JacksonExtension()) .install(new JacksonExtension())
@@ -20,131 +19,140 @@ FlashApp.create(8080)
.startAndBlock(); .startAndBlock();
``` ```
## Operation annotation ## What you get without writing anything
Every class-based route is documented, annotated or not. Read off the code:
- **path parameters**, from `/{id}` in the route
- **the request body**, from the handler's own body type (see below)
- **the response schema**, from what `handle` returns — an object, a `List<T>`, a `Map<String, T>`
- **the media types**: `@Consumes` for what it reads, `@Produces` for what it answers, which are
two different questions — taking a JSON body does not make the answer JSON
- **error responses**, in the one shape Flash answers failures with: `{"error": "...", "status": 404}`
- **security and rate limiting**, from the extensions that enforce them
Annotations add what the code cannot say: prose, extra statuses, examples. They never repeat it.
## Leaving a route out
```java
@GET("/healthz")
@Undocumented
public final class Health extends RequestHandler { ... }
```
Every route is documented, so a document never lies by omission. `@Undocumented` says a route is
not part of the API — a health check, an internal callback, something on its way out. On a base
class it leaves out every handler written against it.
## Request bodies
A handler that extends `BodyHandler``JsonHandler` and `XmlHandler`, and anything else that
reads a format — declares its body type in its signature, and that is the whole documentation:
```java
@POST("/users")
public final class CreateUser extends JsonHandler<NewUser> {
@Override protected Object handle(Request req, Response res, NewUser body) {
return users.create(body);
}
}
```
```yaml
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NewUser' }
```
The media type of the body comes from `@Consumes` on the base class, so an XML handler documents
itself as XML without a word from the route.
For a handler that reads the body by hand, or to describe it as something else, declare it:
```java
@PUT("/users")
@RequestBody(value = User.class, array = true, description = "Users to store")
public final class ReplaceUsers extends RequestHandler { ... }
```
## Operations
```java ```java
@GET("/users/{id}") @GET("/users/{id}")
@ApiOperation(summary = "Get user", description = "Returns one user", tags = {"users"}) @ApiOperation(summary = "Get user", description = "Returns one user", tags = {"users"})
@Parameter(name = "expand", in = ParameterIn.QUERY, type = SchemaType.STRING, examples = {"roles", "permissions"}) @Parameter(name = "expand", in = ParameterIn.QUERY, type = SchemaType.STRING, examples = {"roles", "permissions"})
@APIResponse(
responseCode = "200",
description = "User found",
content = @Content(contentType = ContentType.JSON, schema = UserDto.class)
)
public final class GetUser extends RequestHandler { ... } public final class GetUser extends RequestHandler { ... }
``` ```
## Response patterns `@ApiOperation` is optional: without it the route is still in the document, with no summary.
### Single object ## Responses
The success response is inferred. Declare one only to say more:
```java ```java
@APIResponse( @APIResponse(responseCode = "200", description = "User found",
responseCode = "200", content = @Content(schema = UserDto.class, example = "{\"id\":\"usr-1\"}"))
description = "User found", @APIResponse(responseCode = "409", description = "That email is taken")
content = @Content(contentType = ContentType.JSON, schema = UserDto.class) @APIResponse(responseCode = "204", description = "Deleted")
)
``` ```
### Array - `content.schema` omitted on a 2xx: the handler's return type.
- Any 4xx or 5xx without an explicit schema: Flash's error object, referenced from `components`
in JSON, which is what Flash answers a failure with whatever the route produces.
- `content.array = true` wraps whichever schema was chosen.
- No schema and nothing to infer one from: a response with no body, which is what a 204 is.
The media type of every answer is the handler's `@Produces`, JSON when nothing says otherwise. It
is not on `@Content`: one handler answers in one format, and a status code does not change that.
**A response several operations share is written once.** Identical answers — the 401 of every
guarded route, the 429 of every limited one — become `components.responses` entries referenced by
`$ref`, instead of being repeated on every path.
## DTO schemas
```java ```java
@APIResponse( @Schema(name = "User", title = "User DTO", description = "Public user")
responseCode = "200", public record UserDto(
description = "Users listed", @SchemaProperty(title = "ID", example = "USR-100") String id,
content = @Content(contentType = ContentType.JSON, schema = UserDto.class, array = true) @SchemaProperty(hidden = true) String internalDebug) {}
)
``` ```
### No content Each type is described once under `components.schemas` and referenced everywhere it appears.
Field-level exclusion: `@Schema(hidden = true)`, `@SchemaProperty(hidden = true)`, `@JsonIgnore`,
```java `@JsonIgnoreProperties`, `transient`, `static`. `jakarta.validation` constraints (`@NotNull`,
@APIResponse( `@NotBlank`, `@NotEmpty`, `@Size`, `@Min`, `@Max`, `@Email`, `@Pattern`) become the schema's own
responseCode = "204", bounds and required fields, so a rule is written once and documented for free.
description = "Deleted",
content = @Content(contentType = ContentType.NONE)
)
```
### Inferred from handler return type
```java
@APIResponse(
responseCode = "200",
content = @Content
)
```
If `content.schema` is omitted, schema is inferred from the handler `handle(...)` return type.
Explicit `content.schema` always wins over inference.
Inference defaults:
- `UserDto` -> object schema for `UserDto`
- `List<UserDto>` / `Set<UserDto>` / `UserDto[]` -> `array` with `items: UserDto`
- `Map<String, UserDto>` -> `object` with `additionalProperties: UserDto`
## DTO schema metadata
```java
@Schema(name = "User", title = "User DTO", description = "Public user", deprecated = false)
public class UserDto {
@SchemaProperty(title = "ID", required = true, example = "USR-100", enumeration = {"USR-100", "USR-101"})
public String id;
@SchemaProperty(hidden = true)
public String internalDebug;
}
```
Supported field-level exclusion:
- `@Schema(hidden = true)` / `@SchemaProperty(hidden = true)`
- `@JsonIgnore`
- `@JsonIgnoreProperties(...)`
- `transient` / `static`
## Contributor API ## Contributor API
OpenAPI is extension-agnostic. Other extensions contribute with `OpenApiContributor` via OpenAPI is extension-agnostic. Other extensions contribute through `OpenApiContributor`, held in
`OpenApiContributorRegistry`. `OpenApiContributorRegistry`:
Supported contribution surfaces: - `components` fragments (merged last-wins)
- `components` fragments (merged with last-wins)
- operation `security` requirements (additive) - operation `security` requirements (additive)
- operation `responses` and response `headers` (additive) - operation `responses` and response `headers` (additive)
Merge policy: Manual `@APIResponse` description always wins over a contributor's for the same status.
- contributor collisions use **last-wins** ### Security interop
- manual `@APIResponse` description always wins over contributors for the same status
## Security interop
With `flash-ext-security-core` installed, every registered mechanism's scheme lands under With `flash-ext-security-core` installed, every registered mechanism's scheme lands under
`components.securitySchemes`, and every operation carrying a security annotation lists them as `components.securitySchemes`, and every operation carrying a security annotation lists them as
`security` alternatives with automatic `401` and — for roles or scopes — `403` responses. `security` alternatives with automatic `401` and — for roles or scopes — `403` responses.
Manual `@APIResponse` for the same status code always wins. ### Limiter interop
## Limiter interop When `flash-ext-limiter` is installed, handlers with `@Limit` document `X-RateLimit-Limit`,
`X-RateLimit-Remaining`, `X-RateLimit-Reset`, and a `429` with `Retry-After`.
When `flash-ext-limiter` is installed, handlers with `@Limit` automatically get response
headers documented in OpenAPI:
- `X-RateLimit-Limit`
- `X-RateLimit-Remaining`
- `X-RateLimit-Reset`
- `Retry-After` on `429`
If `429` is missing, it is auto-added as `Too Many Requests`.
## Notes ## Notes
- Operations are collected from final boot-time routes for class-based handlers with `@ApiOperation`. - Operations come from the final boot-time routes, so documented paths match runtime paths,
- Documented paths always match runtime paths (including scope namespaces/prefixes/rewrites). namespaces, prefixes and rewrites included.
- Route path params are auto-discovered from `/{id}`. - Lambda routes are not documented: there is no class to read.
- Parameter annotations are mainly for query/header/cookie enrichment. - Responses are sorted by status code; the document is rebuilt only when a route is added.
- Output responses are sorted by numeric status code.
@@ -1,19 +1,23 @@
package dev.relism.flash.ext.openapi; package dev.relism.flash.ext.openapi;
import dev.relism.flash.http.ContentType;
import java.lang.annotation.ElementType; import java.lang.annotation.ElementType;
import java.lang.annotation.Retention; import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy; import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; import java.lang.annotation.Target;
/** /**
* OpenAPI response content descriptor. * What a response carries: the schema, and an example of it.
*
* <p>The media type is not here — it is the handler's, declared with
* {@link dev.relism.flash.routing.Produces @Produces} and JSON when nothing says otherwise. A
* response with no schema, and no return type to infer one from, is documented without a body.
*/ */
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD}) @Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD})
public @interface Content { public @interface Content {
ContentType contentType() default ContentType.JSON;
Class<?> schema() default Void.class; Class<?> schema() default Void.class;
boolean array() default false; boolean array() default false;
/** One example body, shown beside the schema. */
String example() default "";
} }
@@ -1,47 +1,51 @@
package dev.relism.flash.ext.openapi; package dev.relism.flash.ext.openapi;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonProperty.Access;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http.ContentType; import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http.HttpStatus; import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.models.Request; import dev.relism.flash.models.BodyHandler;
import dev.relism.flash.models.Response; import dev.relism.flash.routing.Consumes;
import dev.relism.flash.routing.Produces;
import dev.relism.flash.routing.Route; import dev.relism.flash.routing.Route;
import java.lang.annotation.Annotation; import java.lang.annotation.Annotation;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.GenericArrayType;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type; import java.lang.reflect.Type;
import java.time.Instant; import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID;
import java.nio.charset.StandardCharsets;
/** /**
* OpenAPI document assembler. * Assembles the OpenAPI document from what the handlers already say about themselves.
*
* <p>A route is documented whether or not it carries annotations: its path parameters come from
* the path, its request body from the handler's own body type, its response schema from what
* {@code handle} returns, and its error bodies from the one shape Flash answers failures with.
* Annotations add what code cannot say — a summary, an example, a second status — and never have
* to repeat what it can.
*
* <p>A response that several operations share is written once under {@code components} and
* referenced, so the security and rate-limiting answers appear once rather than on every path.
*/ */
public final class OpenApiBuilder { public final class OpenApiBuilder {
private static final String OPENAPI_VERSION = "3.0.3"; private static final String OPENAPI_VERSION = "3.0.3";
private static final String ERROR_SCHEMA = "Error";
private static final String ERROR_REF = "#/components/schemas/" + ERROR_SCHEMA;
private static final String RESPONSE_REF = "#/components/responses/";
/** What {@code AbstractRouter} answers every failure with: one object, everywhere. */
private static final Map<String, Object> ERROR_SHAPE = Map.of(
"type", "object",
"properties", Map.of("error", Map.of("type", "string"), "status", Map.of("type", "integer")),
"required", List.of("error", "status"));
private String title = "API"; private String title = "API";
private String version = "1.0.0"; private String version = "1.0.0";
@@ -49,8 +53,9 @@ public final class OpenApiBuilder {
private final Map<String, Map<String, Object>> paths = new LinkedHashMap<>(); private final Map<String, Map<String, Object>> paths = new LinkedHashMap<>();
private final Map<String, Map<String, Class<?>>> operationHandlers = new LinkedHashMap<>(); private final Map<String, Map<String, Class<?>>> operationHandlers = new LinkedHashMap<>();
private final SchemaRegistry schemas = new SchemaRegistry(); private final Schemas schemas = new Schemas();
private OpenApiContributorRegistry contributorRegistry; private OpenApiContributorRegistry contributorRegistry;
private boolean errorsDocumented;
private int revision; private int revision;
private int builtRevision = -1; private int builtRevision = -1;
private Map<String, Object> cachedSpec; private Map<String, Object> cachedSpec;
@@ -60,274 +65,384 @@ public final class OpenApiBuilder {
public OpenApiBuilder description(String description) { this.description = description; return this; } public OpenApiBuilder description(String description) { this.description = description; return this; }
void setContributorRegistry(OpenApiContributorRegistry registry) { this.contributorRegistry = registry; } void setContributorRegistry(OpenApiContributorRegistry registry) { this.contributorRegistry = registry; }
/**
* Documents one route. {@code op} is optional: a route without it is still an operation.
* A handler marked {@link Undocumented} is left out entirely.
*/
public void addOperation(Route route, ApiOperation op, Class<?> handlerClass) { public void addOperation(Route route, ApiOperation op, Class<?> handlerClass) {
if (handlerClass.isAnnotationPresent(Undocumented.class)) return;
String path = normalizePath(route.path()); String path = normalizePath(route.path());
String method = route.method().name().toLowerCase(Locale.ROOT); String method = route.method().name().toLowerCase(Locale.ROOT);
Map<String, Object> operation = new LinkedHashMap<>(); Map<String, Object> operation = new LinkedHashMap<>();
if (op != null) {
if (!op.operationId().isEmpty()) operation.put("operationId", op.operationId()); if (!op.operationId().isEmpty()) operation.put("operationId", op.operationId());
if (!op.summary().isEmpty()) operation.put("summary", op.summary()); if (!op.summary().isEmpty()) operation.put("summary", op.summary());
if (!op.description().isEmpty()) operation.put("description", op.description()); if (!op.description().isEmpty()) operation.put("description", op.description());
if (op.tags().length > 0) operation.put("tags", Arrays.asList(op.tags())); if (op.tags().length > 0) operation.put("tags", List.of(op.tags()));
if (op.deprecated()) operation.put("deprecated", true); if (op.deprecated()) operation.put("deprecated", true);
}
buildParameters(operation, handlerClass, route); parameters(operation, handlerClass, route);
buildResponses(operation, handlerClass); requestBody(operation, handlerClass);
responses(operation, handlerClass);
paths.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, operation); paths.computeIfAbsent(path, p -> new LinkedHashMap<>()).put(method, operation);
operationHandlers.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, handlerClass); operationHandlers.computeIfAbsent(path, p -> new LinkedHashMap<>()).put(method, handlerClass);
revision++; revision++;
} }
public Map<String, Object> build() { public Map<String, Object> build() {
int r = revision; int current = revision;
Map<String, Object> cached = cachedSpec; Map<String, Object> cached = cachedSpec;
if (cached != null && builtRevision == r) return cached; if (cached != null && builtRevision == current) return cached;
List<OpenApiContributor> contributors = contributors();
Map<String, Object> renderedPaths = new LinkedHashMap<>();
for (var path : paths.entrySet()) {
Map<String, Class<?>> handlers = operationHandlers.getOrDefault(path.getKey(), Map.of());
Map<String, Object> pathItem = new LinkedHashMap<>();
for (var method : path.getValue().entrySet()) {
@SuppressWarnings("unchecked")
Map<String, Object> declared = (Map<String, Object>) method.getValue();
Map<String, Object> operation = new LinkedHashMap<>(declared);
Class<?> handler = handlers.get(method.getKey());
if (handler != null && !contributors.isEmpty()) applyContributorSecurity(operation, handler, contributors);
pathItem.put(method.getKey(), operation);
}
renderedPaths.put(path.getKey(), pathItem);
}
Map<String, Object> sharedResponses = hoistSharedResponses(renderedPaths);
Map<String, Object> info = new LinkedHashMap<>(); Map<String, Object> info = new LinkedHashMap<>();
info.put("title", title); info.put("title", title);
info.put("version", version); info.put("version", version);
if (!description.isEmpty()) info.put("description", description); if (!description.isEmpty()) info.put("description", description);
List<OpenApiContributor> contributors = contributorRegistry != null
? contributorRegistry.contributors() : List.of();
Map<String, Object> renderedPaths = new LinkedHashMap<>();
for (var pathEntry : paths.entrySet()) {
Map<String, Object> renderedPathItem = new LinkedHashMap<>();
Map<String, Class<?>> handlers = operationHandlers.getOrDefault(pathEntry.getKey(), Map.of());
for (var methodEntry : pathEntry.getValue().entrySet()) {
@SuppressWarnings("unchecked")
Map<String, Object> original = (Map<String, Object>) methodEntry.getValue();
Map<String, Object> op = new LinkedHashMap<>(original);
Class<?> handler = handlers.get(methodEntry.getKey());
if (handler != null && !contributors.isEmpty()) {
applyContributorOperation(op, handler, contributors);
}
renderedPathItem.put(methodEntry.getKey(), op);
}
renderedPaths.put(pathEntry.getKey(), renderedPathItem);
}
Map<String, Object> spec = new LinkedHashMap<>(); Map<String, Object> spec = new LinkedHashMap<>();
spec.put("openapi", OPENAPI_VERSION); spec.put("openapi", OPENAPI_VERSION);
spec.put("info", info); spec.put("info", info);
spec.put("paths", renderedPaths); spec.put("paths", renderedPaths);
Map<String, Object> components = new LinkedHashMap<>(); Map<String, Object> components = new LinkedHashMap<>();
Map<String, Object> renderedSchemas = schemas.render(); Map<String, Object> renderedSchemas = new LinkedHashMap<>(schemas.render());
if (errorsDocumented) renderedSchemas.put(ERROR_SCHEMA, ERROR_SHAPE);
if (!renderedSchemas.isEmpty()) components.put("schemas", renderedSchemas); if (!renderedSchemas.isEmpty()) components.put("schemas", renderedSchemas);
if (!contributors.isEmpty()) applyContributorComponents(components, contributors); if (!sharedResponses.isEmpty()) components.put("responses", sharedResponses);
for (OpenApiContributor contributor : contributors) {
Map<String, Object> contributed = contributor.componentContributions();
if (contributed != null && !contributed.isEmpty()) deepMergeLastWins(components, contributed);
}
if (!components.isEmpty()) spec.put("components", components); if (!components.isEmpty()) spec.put("components", components);
cachedSpec = spec; cachedSpec = spec;
builtRevision = r; builtRevision = current;
return spec; return spec;
} }
private void buildParameters(Map<String, Object> op, Class<?> cls, Route route) { // ── Parameters ────────────────────────────────────────────────────────────
List<Map<String, Object>> params = new ArrayList<>();
private void parameters(Map<String, Object> operation, Class<?> handlerClass, Route route) {
List<Map<String, Object>> parameters = new ArrayList<>();
String path = route.path(); String path = route.path();
int i = 0; for (int open = path.indexOf('{'); open >= 0; open = path.indexOf('{', open + 1)) {
while (i < path.length()) {
int open = path.indexOf('{', i);
if (open < 0) break;
int close = path.indexOf('}', open); int close = path.indexOf('}', open);
if (close < 0) break; if (close < 0) break;
String name = path.substring(open + 1, close); parameters.add(new LinkedHashMap<>(Map.of(
params.add(new LinkedHashMap<>(Map.of( "name", path.substring(open + 1, close),
"name", name,
"in", "path", "in", "path",
"required", true, "required", true,
"schema", Map.of("type", "string") "schema", Map.of("type", "string"))));
))); open = close;
i = close + 1;
} }
for (Parameter ann : cls.getAnnotationsByType(Parameter.class)) { for (Parameter declared : handlerClass.getAnnotationsByType(Parameter.class)) {
Map<String, Object> p = new LinkedHashMap<>(); Map<String, Object> parameter = new LinkedHashMap<>();
p.put("name", ann.name()); parameter.put("name", declared.name());
p.put("in", ann.in().wireValue()); parameter.put("in", declared.in().wireValue());
p.put("required", ann.required()); parameter.put("required", declared.required());
if (!ann.description().isEmpty()) p.put("description", ann.description()); if (!declared.description().isEmpty()) parameter.put("description", declared.description());
if (!ann.style().isEmpty()) p.put("style", ann.style()); if (!declared.style().isEmpty()) parameter.put("style", declared.style());
if (ann.explode()) p.put("explode", true); if (declared.explode()) parameter.put("explode", true);
if (ann.allowEmptyValue()) p.put("allowEmptyValue", true); if (declared.allowEmptyValue()) parameter.put("allowEmptyValue", true);
Map<String, Object> schema = new LinkedHashMap<>(); Map<String, Object> schema = new LinkedHashMap<>();
schema.put("type", ann.type().wireValue()); schema.put("type", declared.type().wireValue());
if (!ann.example().isEmpty()) schema.put("example", ann.example()); if (!declared.example().isEmpty()) schema.put("example", declared.example());
p.put("schema", schema); parameter.put("schema", schema);
if (ann.examples().length > 0) p.put("examples", toExamples(ann.examples())); if (declared.examples().length > 0) parameter.put("examples", examples(declared.examples()));
params.add(p); parameters.add(parameter);
} }
if (!params.isEmpty()) op.put("parameters", params); if (!parameters.isEmpty()) operation.put("parameters", parameters);
} }
private void buildResponses(Map<String, Object> op, Class<?> cls) { private static Map<String, Object> examples(String[] values) {
APIResponse[] anns = cls.getAnnotationsByType(APIResponse.class); Map<String, Object> examples = new LinkedHashMap<>();
Map<Integer, Map<String, Object>> responseByCode = new LinkedHashMap<>(); for (int i = 0; i < values.length; i++) examples.put("example" + (i + 1), Map.of("value", values[i]));
return examples;
for (APIResponse ann : anns) {
int code = parseStatus(ann.responseCode());
responseByCode.put(code, buildAnnotatedResponse(code, ann, cls));
} }
if (responseByCode.isEmpty()) { // ── Request body ──────────────────────────────────────────────────────────
// Mutable: contributors merge descriptions and headers into it.
responseByCode.put(200, new LinkedHashMap<>(Map.of("description", "OK"))); /** From {@link RequestBody}, or from the body type the handler declares in its own signature. */
private void requestBody(Map<String, Object> operation, Class<?> handlerClass) {
RequestBody declared = handlerClass.getAnnotation(RequestBody.class);
Class<?> type = declared != null ? declared.value() : BodyHandler.bodyTypeOf(handlerClass);
if (type == null || type == Void.class || type == Object.class) return;
Consumes consumes = handlerClass.getAnnotation(Consumes.class);
ContentType contentType = declared != null ? declared.contentType()
: consumes != null ? consumes.value() : ContentType.JSON;
Map<String, Object> schema = schemas.referenceFor(type);
if (declared != null && declared.array()) schema = arrayOf(schema);
Map<String, Object> body = new LinkedHashMap<>();
if (declared != null && !declared.description().isEmpty()) body.put("description", declared.description());
body.put("required", declared == null || declared.required());
body.put("content", Map.of(mediaTypeOf(contentType), Map.of("schema", schema)));
operation.put("requestBody", body);
} }
applyContributorResponses(responseByCode, cls); // ── Responses ─────────────────────────────────────────────────────────────
private void responses(Map<String, Object> operation, Class<?> handlerClass) {
APIResponse[] declared = handlerClass.getAnnotationsByType(APIResponse.class);
Map<Integer, Map<String, Object>> byStatus = new LinkedHashMap<>();
Set<Integer> declaredStatuses = new HashSet<>();
for (APIResponse response : declared) {
int status = parseStatus(response.responseCode());
declaredStatuses.add(status);
byStatus.put(status, response(status, response, handlerClass));
}
if (byStatus.isEmpty()) byStatus.put(200, inferredResponse(handlerClass));
applyContributorResponses(byStatus, handlerClass, declaredStatuses);
Map<String, Object> responses = new LinkedHashMap<>(); Map<String, Object> responses = new LinkedHashMap<>();
responseByCode.entrySet().stream() byStatus.entrySet().stream()
.sorted(Map.Entry.comparingByKey(Comparator.naturalOrder())) .sorted(Map.Entry.comparingByKey(Comparator.naturalOrder()))
.forEach(e -> responses.put(String.valueOf(e.getKey()), e.getValue())); .forEach(entry -> responses.put(String.valueOf(entry.getKey()), entry.getValue()));
op.put("responses", responses); operation.put("responses", responses);
} }
private Map<String, Object> response(int status, APIResponse declared, Class<?> handlerClass) {
Map<String, Object> response = new LinkedHashMap<>();
response.put("description", declared.description().isEmpty() ? reasonFor(status) : declared.description());
Content content = declared.content();
Map<String, Object> schema = schemaFor(content, status, handlerClass);
if (schema == null) return response; // nothing to describe: a response with no body
Map<String, Object> media = new LinkedHashMap<>();
media.put("schema", schema);
if (!content.example().isEmpty()) media.put("example", content.example());
response.put("content", Map.of(mediaTypeOf(producedBy(handlerClass)), media));
return response;
}
/** Explicit first, then what the handler returns for a success, and the error shape for a failure. */
private Map<String, Object> schemaFor(Content content, int status, Class<?> handlerClass) {
if (content.schema() != Void.class) {
Map<String, Object> schema = schemas.referenceFor(content.schema());
return content.array() ? arrayOf(schema) : schema;
}
if (status >= 400) return errorSchema();
Map<String, Object> inferred = returnSchema(handlerClass);
if (inferred == null) return null;
return content.array() && !"array".equals(inferred.get("type")) ? arrayOf(inferred) : inferred;
}
/** A route that documents nothing still answers something: describe what it returns. */
private Map<String, Object> inferredResponse(Class<?> handlerClass) {
Map<String, Object> response = new LinkedHashMap<>(Map.of("description", reasonFor(200)));
Map<String, Object> schema = returnSchema(handlerClass);
if (schema != null) {
response.put("content", Map.of(mediaTypeOf(producedBy(handlerClass)), Map.of("schema", schema)));
}
return response;
}
/**
* What this handler answers in. {@code @Consumes} says what it reads, which is a different
* question: taking a JSON body does not make the answer JSON.
*/
private static ContentType producedBy(Class<?> handlerClass) {
Produces produces = handlerClass.getAnnotation(Produces.class);
return produces == null ? ContentType.JSON : produces.value();
}
/** The schema of whatever {@code handle} gives back, or null when it says nothing useful. */
private Map<String, Object> returnSchema(Class<?> handlerClass) {
Type returned = returnTypeOf(handlerClass);
Class<?> raw = Schemas.rawType(returned);
if (raw == null || raw == Object.class || raw == Void.class || raw == void.class) return null;
if (raw.getName().equals("dev.relism.flash.models.Response")) return null;
Map<String, Object> schema = schemas.schemaForType(returned);
return schema == null || schema.isEmpty() ? null : schema;
}
/**
* The {@code handle} a handler writes itself, not the one its base class fixes: a handler that
* takes a body implements the three-argument one, and that is where its return type is.
*/
private static Type returnTypeOf(Class<?> handlerClass) {
for (Class<?> current = handlerClass; current != null && current != Object.class; current = current.getSuperclass()) {
Method found = null;
for (Method method : current.getDeclaredMethods()) {
if (!method.getName().equals("handle") || method.isBridge() || method.isSynthetic()) continue;
if (found == null || method.getParameterCount() > found.getParameterCount()) found = method;
}
if (found != null) return found.getGenericReturnType();
}
return null;
}
private Map<String, Object> errorSchema() {
errorsDocumented = true;
return Map.of("$ref", ERROR_REF);
}
// ── Contributors ──────────────────────────────────────────────────────────
private List<OpenApiContributor> contributors() { private List<OpenApiContributor> contributors() {
return contributorRegistry != null ? contributorRegistry.contributors() : List.of(); return contributorRegistry != null ? contributorRegistry.contributors() : List.of();
} }
private void applyContributorResponses(Map<Integer, Map<String, Object>> responseByCode, Class<?> handlerClass) { private void applyContributorResponses(Map<Integer, Map<String, Object>> byStatus, Class<?> handlerClass,
APIResponse[] manual = handlerClass.getAnnotationsByType(APIResponse.class); Set<Integer> declaredStatuses) {
Set<Integer> manualStatusCodes = new HashSet<>();
for (APIResponse ann : manual) {
manualStatusCodes.add(parseStatus(ann.responseCode()));
}
for (OpenApiContributor contributor : contributors()) { for (OpenApiContributor contributor : contributors()) {
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass); OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
if (contribution == null) continue; if (contribution == null) continue;
for (Map.Entry<Integer, OpenApiResponseContribution> entry : contribution.responses().entrySet()) { for (var contributed : contribution.responses().entrySet()) {
int status = entry.getKey(); if (contributed.getValue() == null) continue;
OpenApiResponseContribution responseContribution = entry.getValue(); int status = contributed.getKey();
if (responseContribution == null) continue; Map<String, Object> response = byStatus.computeIfAbsent(status, s -> new LinkedHashMap<>());
merge(response, contributed.getValue(), status, declaredStatuses.contains(status));
Map<String, Object> response = responseByCode.computeIfAbsent(status, __ -> new LinkedHashMap<>());
mergeContributorResponse(response, responseContribution, status, manualStatusCodes);
} }
OpenApiResponseContribution allResponses = contribution.allResponses(); OpenApiResponseContribution everywhere = contribution.allResponses();
if (allResponses != null) { if (everywhere == null) continue;
for (Map.Entry<Integer, Map<String, Object>> entry : responseByCode.entrySet()) { for (var response : byStatus.entrySet()) {
mergeContributorResponse(entry.getValue(), allResponses, entry.getKey(), manualStatusCodes); merge(response.getValue(), everywhere, response.getKey(), declaredStatuses.contains(response.getKey()));
}
} }
} }
} }
private static void mergeContributorResponse(Map<String, Object> response, private void merge(Map<String, Object> response, OpenApiResponseContribution contributed, int status, boolean declared) {
OpenApiResponseContribution contribution, String description = contributed.description();
int status, if (!declared && description != null && !description.isBlank()) response.put("description", description);
Set<Integer> manualStatusCodes) {
String desc = contribution.description();
if (!manualStatusCodes.contains(status) && desc != null && !desc.isBlank()) {
response.put("description", desc);
}
Map<String, Map<String, Object>> headerContributions = contribution.headers(); Map<String, Map<String, Object>> headers = contributed.headers();
if (!headerContributions.isEmpty()) { if (!headers.isEmpty()) {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Map<String, Object> headers = (Map<String, Object>) response.computeIfAbsent("headers", __ -> new LinkedHashMap<>()); Map<String, Object> target = (Map<String, Object>) response.computeIfAbsent("headers", h -> new LinkedHashMap<>());
for (Map.Entry<String, Map<String, Object>> h : headerContributions.entrySet()) { headers.forEach((name, header) -> target.put(name, new LinkedHashMap<>(header)));
headers.put(h.getKey(), new LinkedHashMap<>(h.getValue()));
} }
if (!response.containsKey("description")) response.put("description", reasonFor(status));
// A status a contributor added is a failure Flash answers in its own shape.
if (status >= 400 && !response.containsKey("content")) {
response.put("content", Map.of(mediaTypeOf(ContentType.JSON), Map.of("schema", errorSchema())));
} // Flash answers a failure in JSON whatever the route produces
} }
if (!response.containsKey("description")) { private void applyContributorSecurity(Map<String, Object> operation, Class<?> handlerClass,
response.put("description", defaultDescription(status)); List<OpenApiContributor> contributors) {
}
}
private static void applyContributorComponents(Map<String, Object> components, List<OpenApiContributor> contributors) {
for (OpenApiContributor contributor : contributors) { for (OpenApiContributor contributor : contributors) {
Map<String, Object> c = contributor.componentContributions(); OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
if (c == null || c.isEmpty()) continue; if (contribution == null || contribution.security().isEmpty()) continue;
deepMergeLastWins(components, c); @SuppressWarnings("unchecked")
List<Map<String, List<String>>> security =
(List<Map<String, List<String>>>) operation.computeIfAbsent("security", s -> new ArrayList<>());
security.addAll(contribution.security());
} }
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private static void deepMergeLastWins(Map<String, Object> target, Map<String, Object> incoming) { private static void deepMergeLastWins(Map<String, Object> target, Map<String, Object> incoming) {
for (Map.Entry<String, Object> e : incoming.entrySet()) { incoming.forEach((key, value) -> {
Object existing = target.get(e.getKey()); Object existing = target.get(key);
Object value = e.getValue(); if (existing instanceof Map<?, ?> from && value instanceof Map<?, ?> to) {
if (existing instanceof Map<?, ?> em && value instanceof Map<?, ?> vm) { Map<String, Object> merged = new LinkedHashMap<>((Map<String, Object>) from);
Map<String, Object> merged = new LinkedHashMap<>((Map<String, Object>) em); deepMergeLastWins(merged, (Map<String, Object>) to);
deepMergeLastWins(merged, (Map<String, Object>) vm); target.put(key, merged);
target.put(e.getKey(), merged);
} else { } else {
target.put(e.getKey(), value); target.put(key, value);
}
} }
});
} }
private void applyContributorOperation(Map<String, Object> op, Class<?> handlerClass, List<OpenApiContributor> contributors) { // ── Shared responses ──────────────────────────────────────────────────────
for (OpenApiContributor contributor : contributors) {
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
if (contribution == null || contribution.isEmpty()) continue;
if (!contribution.security().isEmpty()) { /**
* The answer a status is usually given is written once under {@code components.responses} and
* referenced. Authentication and rate limiting say the same thing on every route they guard;
* the document should say it once, under that status's own name. A route that answers the same
* status differently keeps its own wording, inline, rather than pushing a second name into the
* components.
*/
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
List<Map<String, List<String>>> security = (List<Map<String, List<String>>>) op private Map<String, Object> hoistSharedResponses(Map<String, Object> renderedPaths) {
.computeIfAbsent("security", __ -> new ArrayList<>()); Map<String, Map<Object, Integer>> seen = new LinkedHashMap<>();
security.addAll(contribution.security()); for (Object pathItem : renderedPaths.values()) {
for (Object operation : ((Map<String, Object>) pathItem).values()) {
Map<String, Object> responses = (Map<String, Object>) ((Map<String, Object>) operation).get("responses");
responses.forEach((status, response) ->
seen.computeIfAbsent(status, s -> new LinkedHashMap<>()).merge(response, 1, Integer::sum));
}
}
Map<String, Object> shared = new LinkedHashMap<>();
Map<String, Object> hoisted = new LinkedHashMap<>(); // status to the one body that is shared
seen.forEach((status, bodies) -> {
Map.Entry<Object, Integer> commonest = bodies.entrySet().stream()
.max(Map.Entry.comparingByValue()).orElseThrow();
if (commonest.getValue() < 2) return;
String name = reasonFor(Integer.parseInt(status)).replace(" ", "");
if (name.isEmpty() || shared.containsKey(name)) name = "Status" + status;
shared.put(name, commonest.getKey());
hoisted.put(status, name);
});
for (Object pathItem : renderedPaths.values()) {
for (Object operation : ((Map<String, Object>) pathItem).values()) {
Map<String, Object> responses = (Map<String, Object>) ((Map<String, Object>) operation).get("responses");
for (var response : responses.entrySet()) {
String name = (String) hoisted.get(response.getKey());
if (name != null && shared.get(name).equals(response.getValue())) {
response.setValue(Map.of("$ref", RESPONSE_REF + name));
} }
} }
} }
}
return shared;
}
private Map<String, Object> buildAnnotatedResponse(int code, APIResponse ann, Class<?> handlerClass) { // ── Odds and ends ─────────────────────────────────────────────────────────
Map<String, Object> out = new LinkedHashMap<>();
out.put("description", ann.description().isEmpty() ? defaultDescription(code) : ann.description());
Content content = ann.content(); private static Map<String, Object> arrayOf(Map<String, Object> items) {
if (content.contentType() == ContentType.NONE) return out; return Map.of("type", "array", "items", items);
Map<String, Object> schema = resolveResponseSchema(content, handlerClass);
if (schema == null || schema.isEmpty()) return out;
out.put("content", Map.of(mediaTypeOf(content.contentType()), Map.of("schema", schema)));
return out;
} }
private static int parseStatus(String code) { private static int parseStatus(String code) {
try { try {
return Integer.parseInt(code.trim()); return Integer.parseInt(code.trim());
} catch (Exception e) { } catch (NumberFormatException e) {
throw new IllegalStateException("Invalid APIResponse.responseCode: " + code); throw new IllegalStateException("Invalid APIResponse.responseCode: " + code);
} }
} }
private Map<String, Object> resolveResponseSchema(Content content, Class<?> handlerClass) { private static String reasonFor(int status) {
if (content.schema() != Void.class) { String reason = HttpStatus.reasonForCode(status);
Map<String, Object> base = schemas.referenceFor(content.schema()); return reason == null ? "" : reason;
return content.array() ? asArraySchema(base) : base;
}
try {
Method handle = handlerClass.getMethod("handle", Request.class, Response.class);
Type ret = handle.getGenericReturnType();
Class<?> raw = rawType(ret);
if (raw == null || raw == Object.class || raw == Response.class || raw == Void.class || raw == void.class)
return null;
Map<String, Object> inferred = schemas.schemaForType(ret);
if (inferred == null || inferred.isEmpty()) return null;
if (content.array() && !"array".equals(inferred.get("type"))) return asArraySchema(inferred);
return inferred;
} catch (NoSuchMethodException e) {
return null;
}
}
private static Map<String, Object> asArraySchema(Map<String, Object> itemSchema) {
return Map.of("type", "array", "items", itemSchema);
} }
private static String mediaTypeOf(ContentType type) { private static String mediaTypeOf(ContentType type) {
@@ -335,245 +450,21 @@ public final class OpenApiBuilder {
return bytes.length == 0 ? "application/octet-stream" : new String(bytes, StandardCharsets.UTF_8); return bytes.length == 0 ? "application/octet-stream" : new String(bytes, StandardCharsets.UTF_8);
} }
private static Map<String, Object> toExamples(String[] examples) {
Map<String, Object> out = new LinkedHashMap<>();
for (int i = 0; i < examples.length; i++) {
out.put("ex" + (i + 1), Map.of("value", examples[i]));
}
return out;
}
private static String normalizePath(String path) { private static String normalizePath(String path) {
String normalized = path.startsWith("/") ? path : "/" + path; String normalized = path.startsWith("/") ? path : "/" + path;
while (normalized.startsWith("//")) { while (normalized.startsWith("//")) normalized = normalized.substring(1);
normalized = normalized.substring(1);
}
return normalized; return normalized;
} }
private static String defaultDescription(int status) { /** The route a handler class declares, through {@code @Route} or any shorthand carrying it. */
String reason = HttpStatus.reasonForCode(status);
return reason == null ? "" : reason;
}
private static Class<?> rawType(Type type) {
if (type instanceof Class<?> c) return c;
if (type instanceof ParameterizedType p && p.getRawType() instanceof Class<?> c) return c;
if (type instanceof GenericArrayType a) {
Class<?> component = rawType(a.getGenericComponentType());
return component == null ? null : Array.newInstance(component, 0).getClass();
}
return null;
}
/** Resolved once: jakarta.validation is an optional dependency of this module. */
private static final boolean CONSTRAINTS_PRESENT = ConstraintHints.available();
private static final class SchemaRegistry {
private static final Set<Class<?>> SIMPLE = Set.of(
String.class, CharSequence.class,
Boolean.class, Byte.class, Short.class, Integer.class, Long.class, Float.class, Double.class,
boolean.class, byte.class, short.class, int.class, long.class, float.class, double.class,
UUID.class, LocalDate.class, LocalDateTime.class, OffsetDateTime.class, Instant.class
);
private final Map<Class<?>, String> names = new LinkedHashMap<>();
private final Map<String, Map<String, Object>> docs = new LinkedHashMap<>();
private final Set<Class<?>> resolving = new HashSet<>();
Map<String, Object> referenceFor(Class<?> type) {
return schemaFor(type);
}
Map<String, Object> schemaForType(Type type) {
return schemaFor(type);
}
Map<String, Object> render() {
Map<String, Object> out = new LinkedHashMap<>();
for (var e : docs.entrySet()) out.put(e.getKey(), e.getValue());
return out;
}
private Map<String, Object> schemaFor(Type type) {
if (type instanceof ParameterizedType p) {
Class<?> raw = rawType(p);
if (raw != null && Collection.class.isAssignableFrom(raw)) {
Type item = p.getActualTypeArguments()[0];
return Map.of("type", "array", "items", schemaFor(item));
}
if (raw != null && Map.class.isAssignableFrom(raw)) {
Type value = p.getActualTypeArguments().length > 1 ? p.getActualTypeArguments()[1] : Object.class;
return Map.of("type", "object", "additionalProperties", schemaFor(value));
}
if (raw != null) return schemaFor(raw);
}
Class<?> cls = rawType(type);
if (cls == null || cls == Object.class) return Map.of("type", "object");
if (cls.isArray()) return Map.of("type", "array", "items", schemaFor(cls.getComponentType()));
if (Collection.class.isAssignableFrom(cls)) return Map.of("type", "array", "items", Map.of("type", "object"));
if (Map.class.isAssignableFrom(cls)) return Map.of("type", "object", "additionalProperties", Map.of("type", "object"));
Map<String, Object> simple = simpleSchema(cls);
if (simple != null) return simple;
return Map.of("$ref", "#/components/schemas/" + registerPojo(cls));
}
private String registerPojo(Class<?> cls) {
String existing = names.get(cls);
if (existing != null) return existing;
String base = schemaName(cls);
String name = base;
int i = 2;
while (docs.containsKey(name)) name = base + i++;
names.put(cls, name);
if (resolving.contains(cls)) return name;
resolving.add(cls);
docs.put(name, buildPojoSchema(cls));
resolving.remove(cls);
return name;
}
private Map<String, Object> buildPojoSchema(Class<?> cls) {
Schema typeSchema = cls.getAnnotation(Schema.class);
JsonIgnoreProperties ignoredType = cls.getAnnotation(JsonIgnoreProperties.class);
Set<String> ignored = ignoredType == null
? Set.of()
: new HashSet<>(Arrays.asList(ignoredType.value()));
Map<String, Object> out = new LinkedHashMap<>();
out.put("type", "object");
if (typeSchema != null) applySchemaHints(out, typeSchema);
Map<String, Object> properties = new LinkedHashMap<>();
List<String> required = new ArrayList<>();
for (Field f : cls.getDeclaredFields()) {
int mod = f.getModifiers();
if (Modifier.isStatic(mod) || Modifier.isTransient(mod)) continue;
if (f.isAnnotationPresent(JsonIgnore.class)) continue;
if (ignored.contains(f.getName())) continue;
String name = f.getName();
JsonProperty jp = f.getAnnotation(JsonProperty.class);
if (jp != null && !jp.value().isEmpty()) name = jp.value();
Schema ps = f.getAnnotation(Schema.class);
SchemaProperty sp = f.getAnnotation(SchemaProperty.class);
ArraySchema array = f.getAnnotation(ArraySchema.class);
if ((ps != null && ps.hidden()) || (sp != null && sp.hidden())) continue;
if (sp != null && !sp.name().isEmpty()) name = sp.name();
Map<String, Object> property = new LinkedHashMap<>(schemaFor(f.getGenericType()));
if (ps != null) applySchemaHints(property, ps);
if (sp != null) applySchemaHints(property, sp);
if (array != null) applyArrayHints(property, array);
if (jp != null) {
if (jp.access() == Access.READ_ONLY) property.put("readOnly", true);
if (jp.access() == Access.WRITE_ONLY) property.put("writeOnly", true);
}
// Constraints declared for flash-ext-validation also describe the contract, so
// mirror them here rather than making callers restate every rule as @Schema.
boolean constrainedRequired = CONSTRAINTS_PRESENT && ConstraintHints.apply(f, property);
properties.put(name, property);
if (constrainedRequired
|| (ps != null && ps.required()) || (sp != null && sp.required()) || (jp != null && jp.required()))
required.add(name);
}
if (!properties.isEmpty()) out.put("properties", properties);
if (!required.isEmpty()) out.put("required", required);
return out;
}
private static void applySchemaHints(Map<String, Object> target, Schema schema) {
if (!schema.title().isEmpty()) target.put("title", schema.title());
if (!schema.description().isEmpty()) target.put("description", schema.description());
if (!schema.format().isEmpty()) target.put("format", schema.format());
if (!schema.example().isEmpty()) target.put("example", schema.example());
if (schema.enumeration().length > 0) target.put("enum", Arrays.asList(schema.enumeration()));
if (schema.nullable()) target.put("nullable", true);
if (schema.deprecated()) target.put("deprecated", true);
}
private static void applySchemaHints(Map<String, Object> target, SchemaProperty schema) {
if (!schema.title().isEmpty()) target.put("title", schema.title());
if (!schema.description().isEmpty()) target.put("description", schema.description());
if (!schema.format().isEmpty()) target.put("format", schema.format());
if (!schema.example().isEmpty()) target.put("example", schema.example());
if (schema.enumeration().length > 0) target.put("enum", Arrays.asList(schema.enumeration()));
if (schema.nullable()) target.put("nullable", true);
if (schema.deprecated()) target.put("deprecated", true);
}
private Map<String, Object> withArrayType(Map<String, Object> property, ArraySchema array) {
if ("array".equals(property.get("type"))) return property;
Type itemType = array.itemClass() != Void.class ? array.itemClass() : Object.class;
Map<String, Object> wrapped = new LinkedHashMap<>();
wrapped.put("type", "array");
wrapped.put("items", schemaFor(itemType));
return wrapped;
}
private void applyArrayHints(Map<String, Object> property, ArraySchema array) {
Map<String, Object> target = withArrayType(property, array);
if (target != property) {
property.clear();
property.putAll(target);
}
if (array.uniqueItems()) property.put("uniqueItems", true);
if (array.minItems() >= 0) property.put("minItems", array.minItems());
if (array.maxItems() >= 0) property.put("maxItems", array.maxItems());
}
private static String schemaName(Class<?> cls) {
Schema schema = cls.getAnnotation(Schema.class);
if (schema != null && !schema.name().isEmpty()) return schema.name();
return cls.getSimpleName();
}
private static Map<String, Object> simpleSchema(Class<?> cls) {
if (!SIMPLE.contains(cls) && !cls.isEnum()) return null;
if (cls == String.class || CharSequence.class.isAssignableFrom(cls)) return Map.of("type", "string");
if (cls == Boolean.class || cls == boolean.class) return Map.of("type", "boolean");
if (cls == Integer.class || cls == int.class || cls == Long.class || cls == long.class ||
cls == Short.class || cls == short.class || cls == Byte.class || cls == byte.class) {
return Map.of("type", "integer");
}
if (cls == Float.class || cls == float.class || cls == Double.class || cls == double.class) {
return Map.of("type", "number");
}
if (cls == UUID.class) return Map.of("type", "string", "format", "uuid");
if (cls == LocalDate.class) return Map.of("type", "string", "format", "date");
if (cls == LocalDateTime.class || cls == OffsetDateTime.class || cls == Instant.class)
return Map.of("type", "string", "format", "date-time");
if (cls.isEnum()) {
Object[] constants = cls.getEnumConstants();
List<String> values = new ArrayList<>(constants.length);
for (Object c : constants) values.add(String.valueOf(c));
return Map.of("type", "string", "enum", values);
}
return null;
}
}
static Route routeOf(Class<?> cls) { static Route routeOf(Class<?> cls) {
Route direct = cls.getAnnotation(Route.class); Route direct = cls.getAnnotation(Route.class);
if (direct != null) return direct; if (direct != null) return direct;
for (Annotation ann : cls.getAnnotations()) {
Route meta = ann.annotationType().getAnnotation(Route.class); for (Annotation annotation : cls.getAnnotations()) {
Route meta = annotation.annotationType().getAnnotation(Route.class);
if (meta == null) continue; if (meta == null) continue;
String path = readPathValue(ann); String path = pathOf(annotation);
if (path == null) continue; if (path == null) continue;
HttpMethod method = meta.method(); HttpMethod method = meta.method();
return new Route() { return new Route() {
@@ -585,11 +476,10 @@ public final class OpenApiBuilder {
return null; return null;
} }
private static String readPathValue(Annotation ann) { private static String pathOf(Annotation annotation) {
try { try {
Object v = ann.annotationType().getMethod("value").invoke(ann); return annotation.annotationType().getMethod("value").invoke(annotation) instanceof String path ? path : null;
return v instanceof String s ? s : null; } catch (ReflectiveOperationException absent) {
} catch (ReflectiveOperationException ignored) {
return null; return null;
} }
} }
@@ -10,7 +10,6 @@ import dev.relism.flash.extension.RouteEvent;
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.routing.Route; import dev.relism.flash.routing.Route;
import lombok.extern.slf4j.Slf4j;
/** /**
* Generates and serves an OpenAPI 3.0 spec and Swagger UI under a configurable base path. * Generates and serves an OpenAPI 3.0 spec and Swagger UI under a configurable base path.
@@ -25,8 +24,9 @@ import lombok.extern.slf4j.Slf4j;
* <p>If {@code flash-ext-jackson} is installed, this extension reuses its * <p>If {@code flash-ext-jackson} is installed, this extension reuses its
* {@link ObjectMapper}. Otherwise it uses a local default mapper. * {@link ObjectMapper}. Otherwise it uses a local default mapper.
* *
* <p>Operations are collected at boot from handlers annotated with {@link ApiOperation} * <p>Every class-based route is collected at boot, whether or not it is annotated: a path, the
* that also have route metadata ({@link Route} or shorthand verb annotations). * body its handler takes and what it returns are already in the code. {@link ApiOperation} adds
* the prose.
* *
* <pre>{@code * <pre>{@code
* FlashApp.create(8080) * FlashApp.create(8080)
@@ -35,7 +35,6 @@ import lombok.extern.slf4j.Slf4j;
* .start(); * .start();
* }</pre> * }</pre>
*/ */
@Slf4j
public class OpenApiExtension implements FlashExtension { public class OpenApiExtension implements FlashExtension {
private static final String YAML_CONTENT_TYPE = "application/yaml"; private static final String YAML_CONTENT_TYPE = "application/yaml";
@@ -121,18 +120,12 @@ public class OpenApiExtension implements FlashExtension {
"</html>"; "</html>";
} }
/** Every class-based route is an operation; {@link ApiOperation} only adds what the code cannot say. */
private static void addOperationFromEvent(OpenApiBuilder builder, RouteEvent event) { private static void addOperationFromEvent(OpenApiBuilder builder, RouteEvent event) {
Class<?> handlerClass = event.handlerClass(); Class<?> handlerClass = event.handlerClass();
if (handlerClass == null) return; // lambda route: no annotation metadata if (handlerClass == null) return; // a lambda route has nothing to read
ApiOperation op = handlerClass.getAnnotation(ApiOperation.class); builder.addOperation(routeOf(event), handlerClass.getAnnotation(ApiOperation.class), handlerClass);
if (op == null) {
log.warn("{} {} ({}) has no @ApiOperation — omitted from the OpenAPI spec",
event.method(), event.path(), handlerClass.getSimpleName());
return;
}
builder.addOperation(routeOf(event), op, handlerClass);
} }
private static Route routeOf(RouteEvent event) { private static Route routeOf(RouteEvent event) {
@@ -0,0 +1,31 @@
package dev.relism.flash.ext.openapi;
import dev.relism.flash.http.ContentType;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* The body this operation takes, for a handler that reads it by hand.
*
* <p>A handler extending {@code BodyHandler} — {@code JsonHandler} and its like — needs none of
* this: its body type is its type argument and its media type comes from {@code @Consumes}. Use
* this when the body is read straight off the request, or to describe it as something other than
* what the handler parses.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface RequestBody {
Class<?> value();
ContentType contentType() default ContentType.JSON;
boolean array() default false;
boolean required() default true;
String description() default "";
}
@@ -0,0 +1,247 @@
package dev.relism.flash.ext.openapi;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonProperty.Access;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.GenericArrayType;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* Every schema the document names, and the types they were built from.
*
* <p>A type is described once and referenced by {@code $ref} everywhere it appears, so a document
* over a hundred routes carries one copy of each model. What a field means comes from the type
* itself: Jackson's annotations decide what is exposed, {@code jakarta.validation} constraints
* become the schema's own bounds, and {@link Schema}/{@link SchemaProperty} say the rest.
*/
final class Schemas {
/** Resolved once: jakarta.validation is an optional dependency of this module. */
private static final boolean CONSTRAINTS_PRESENT = ConstraintHints.available();
private static final Set<Class<?>> SIMPLE = Set.of(
String.class, CharSequence.class,
Boolean.class, Byte.class, Short.class, Integer.class, Long.class, Float.class, Double.class,
boolean.class, byte.class, short.class, int.class, long.class, float.class, double.class,
UUID.class, LocalDate.class, LocalDateTime.class, OffsetDateTime.class, Instant.class
);
private final Map<Class<?>, String> names = new LinkedHashMap<>();
private final Map<String, Map<String, Object>> docs = new LinkedHashMap<>();
private final Set<Class<?>> resolving = new HashSet<>();
Map<String, Object> referenceFor(Class<?> type) {
return schemaFor(type);
}
Map<String, Object> schemaForType(Type type) {
return schemaFor(type);
}
Map<String, Object> render() {
Map<String, Object> out = new LinkedHashMap<>();
for (var e : docs.entrySet()) out.put(e.getKey(), e.getValue());
return out;
}
private Map<String, Object> schemaFor(Type type) {
if (type instanceof ParameterizedType p) {
Class<?> raw = rawType(p);
if (raw != null && Collection.class.isAssignableFrom(raw)) {
Type item = p.getActualTypeArguments()[0];
return Map.of("type", "array", "items", schemaFor(item));
}
if (raw != null && Map.class.isAssignableFrom(raw)) {
Type value = p.getActualTypeArguments().length > 1 ? p.getActualTypeArguments()[1] : Object.class;
return Map.of("type", "object", "additionalProperties", schemaFor(value));
}
if (raw != null) return schemaFor(raw);
}
Class<?> cls = rawType(type);
if (cls == null || cls == Object.class) return Map.of("type", "object");
if (cls.isArray()) return Map.of("type", "array", "items", schemaFor(cls.getComponentType()));
if (Collection.class.isAssignableFrom(cls)) return Map.of("type", "array", "items", Map.of("type", "object"));
if (Map.class.isAssignableFrom(cls)) return Map.of("type", "object", "additionalProperties", Map.of("type", "object"));
Map<String, Object> simple = simpleSchema(cls);
if (simple != null) return simple;
return Map.of("$ref", "#/components/schemas/" + registerPojo(cls));
}
private String registerPojo(Class<?> cls) {
String existing = names.get(cls);
if (existing != null) return existing;
String base = schemaName(cls);
String name = base;
int i = 2;
while (docs.containsKey(name)) name = base + i++;
names.put(cls, name);
if (resolving.contains(cls)) return name;
resolving.add(cls);
docs.put(name, buildPojoSchema(cls));
resolving.remove(cls);
return name;
}
private Map<String, Object> buildPojoSchema(Class<?> cls) {
Schema typeSchema = cls.getAnnotation(Schema.class);
JsonIgnoreProperties ignoredType = cls.getAnnotation(JsonIgnoreProperties.class);
Set<String> ignored = ignoredType == null
? Set.of()
: new HashSet<>(Arrays.asList(ignoredType.value()));
Map<String, Object> out = new LinkedHashMap<>();
out.put("type", "object");
if (typeSchema != null) applySchemaHints(out, typeSchema);
Map<String, Object> properties = new LinkedHashMap<>();
List<String> required = new ArrayList<>();
for (Field f : cls.getDeclaredFields()) {
int mod = f.getModifiers();
if (Modifier.isStatic(mod) || Modifier.isTransient(mod)) continue;
if (f.isAnnotationPresent(JsonIgnore.class)) continue;
if (ignored.contains(f.getName())) continue;
String name = f.getName();
JsonProperty jp = f.getAnnotation(JsonProperty.class);
if (jp != null && !jp.value().isEmpty()) name = jp.value();
Schema ps = f.getAnnotation(Schema.class);
SchemaProperty sp = f.getAnnotation(SchemaProperty.class);
ArraySchema array = f.getAnnotation(ArraySchema.class);
if ((ps != null && ps.hidden()) || (sp != null && sp.hidden())) continue;
if (sp != null && !sp.name().isEmpty()) name = sp.name();
Map<String, Object> property = new LinkedHashMap<>(schemaFor(f.getGenericType()));
if (ps != null) applySchemaHints(property, ps);
if (sp != null) applySchemaHints(property, sp);
if (array != null) applyArrayHints(property, array);
if (jp != null) {
if (jp.access() == Access.READ_ONLY) property.put("readOnly", true);
if (jp.access() == Access.WRITE_ONLY) property.put("writeOnly", true);
}
// Constraints a body is checked against also describe it, so
// mirror them here rather than making callers restate every rule as @Schema.
boolean constrainedRequired = CONSTRAINTS_PRESENT && ConstraintHints.apply(f, property);
properties.put(name, property);
if (constrainedRequired
|| (ps != null && ps.required()) || (sp != null && sp.required()) || (jp != null && jp.required()))
required.add(name);
}
if (!properties.isEmpty()) out.put("properties", properties);
if (!required.isEmpty()) out.put("required", required);
return out;
}
private static void applySchemaHints(Map<String, Object> target, Schema schema) {
if (!schema.title().isEmpty()) target.put("title", schema.title());
if (!schema.description().isEmpty()) target.put("description", schema.description());
if (!schema.format().isEmpty()) target.put("format", schema.format());
if (!schema.example().isEmpty()) target.put("example", schema.example());
if (schema.enumeration().length > 0) target.put("enum", Arrays.asList(schema.enumeration()));
if (schema.nullable()) target.put("nullable", true);
if (schema.deprecated()) target.put("deprecated", true);
}
private static void applySchemaHints(Map<String, Object> target, SchemaProperty schema) {
if (!schema.title().isEmpty()) target.put("title", schema.title());
if (!schema.description().isEmpty()) target.put("description", schema.description());
if (!schema.format().isEmpty()) target.put("format", schema.format());
if (!schema.example().isEmpty()) target.put("example", schema.example());
if (schema.enumeration().length > 0) target.put("enum", Arrays.asList(schema.enumeration()));
if (schema.nullable()) target.put("nullable", true);
if (schema.deprecated()) target.put("deprecated", true);
}
private Map<String, Object> withArrayType(Map<String, Object> property, ArraySchema array) {
if ("array".equals(property.get("type"))) return property;
Type itemType = array.itemClass() != Void.class ? array.itemClass() : Object.class;
Map<String, Object> wrapped = new LinkedHashMap<>();
wrapped.put("type", "array");
wrapped.put("items", schemaFor(itemType));
return wrapped;
}
private void applyArrayHints(Map<String, Object> property, ArraySchema array) {
Map<String, Object> target = withArrayType(property, array);
if (target != property) {
property.clear();
property.putAll(target);
}
if (array.uniqueItems()) property.put("uniqueItems", true);
if (array.minItems() >= 0) property.put("minItems", array.minItems());
if (array.maxItems() >= 0) property.put("maxItems", array.maxItems());
}
private static String schemaName(Class<?> cls) {
Schema schema = cls.getAnnotation(Schema.class);
if (schema != null && !schema.name().isEmpty()) return schema.name();
return cls.getSimpleName();
}
private static Map<String, Object> simpleSchema(Class<?> cls) {
if (!SIMPLE.contains(cls) && !cls.isEnum()) return null;
if (cls == String.class || CharSequence.class.isAssignableFrom(cls)) return Map.of("type", "string");
if (cls == Boolean.class || cls == boolean.class) return Map.of("type", "boolean");
if (cls == Integer.class || cls == int.class || cls == Long.class || cls == long.class ||
cls == Short.class || cls == short.class || cls == Byte.class || cls == byte.class) {
return Map.of("type", "integer");
}
if (cls == Float.class || cls == float.class || cls == Double.class || cls == double.class) {
return Map.of("type", "number");
}
if (cls == UUID.class) return Map.of("type", "string", "format", "uuid");
if (cls == LocalDate.class) return Map.of("type", "string", "format", "date");
if (cls == LocalDateTime.class || cls == OffsetDateTime.class || cls == Instant.class)
return Map.of("type", "string", "format", "date-time");
if (cls.isEnum()) {
Object[] constants = cls.getEnumConstants();
List<String> values = new ArrayList<>(constants.length);
for (Object c : constants) values.add(String.valueOf(c));
return Map.of("type", "string", "enum", values);
}
return null;
}
static Class<?> rawType(Type type) {
if (type instanceof Class<?> c) return c;
if (type instanceof ParameterizedType p && p.getRawType() instanceof Class<?> c) return c;
if (type instanceof GenericArrayType a) {
Class<?> component = rawType(a.getGenericComponentType());
return component == null ? null : Array.newInstance(component, 0).getClass();
}
return null;
}
}
@@ -0,0 +1,21 @@
package dev.relism.flash.ext.openapi;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Keeps a route out of the published document.
*
* <p>Every class-based route is documented, which is what stops a document from lying by
* omission. Some routes are not part of the API anyway — a health check, an internal callback,
* something on its way out — and this says so, once, where the handler is.
*
* <p>Inherited: on a base class it leaves out every handler written against it.
*/
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Undocumented {}
@@ -23,7 +23,7 @@ class OpenApiBuilderTest {
@GET("/users/{id}") @GET("/users/{id}")
@ApiOperation(summary = "Get user") @ApiOperation(summary = "Get user")
@Parameter(name = "expand", in = ParameterIn.QUERY, required = false, type = SchemaType.STRING, examples = {"roles", "permissions"}) @Parameter(name = "expand", in = ParameterIn.QUERY, required = false, type = SchemaType.STRING, examples = {"roles", "permissions"})
@APIResponse(responseCode = "200", description = "User found", content = @Content(contentType = ContentType.JSON, schema = UserDto.class)) @APIResponse(responseCode = "200", description = "User found", content = @Content(schema = UserDto.class))
static class GetUserHandler extends RequestHandler { static class GetUserHandler extends RequestHandler {
@Override @Override
public Object handle(Request request, Response response) { public Object handle(Request request, Response response) {
@@ -33,7 +33,7 @@ class OpenApiBuilderTest {
@GET("/users") @GET("/users")
@ApiOperation(summary = "List users") @ApiOperation(summary = "List users")
@APIResponse(responseCode = "200", content = @Content(contentType = ContentType.JSON, schema = UserDto.class, array = true)) @APIResponse(responseCode = "200", content = @Content(schema = UserDto.class, array = true))
static class ListUsersHandler extends RequestHandler { static class ListUsersHandler extends RequestHandler {
@Override @Override
public Object handle(Request request, Response response) { public Object handle(Request request, Response response) {
@@ -43,7 +43,7 @@ class OpenApiBuilderTest {
@GET("/ping") @GET("/ping")
@ApiOperation(summary = "Ping") @ApiOperation(summary = "Ping")
@APIResponse(responseCode = "204", description = "No content", content = @Content(contentType = ContentType.NONE)) @APIResponse(responseCode = "204", description = "No content")
static class PingHandler extends RequestHandler { static class PingHandler extends RequestHandler {
@Override @Override
public Object handle(Request request, Response response) { public Object handle(Request request, Response response) {
@@ -133,6 +133,188 @@ class OpenApiBuilderTest {
public List<String> tags; public List<String> tags;
} }
@dev.relism.flash.routing.POST("/bodies")
@ApiOperation(summary = "Typed body")
static final class TypedBodyHandler extends JsonLikeHandler<UserDto> {
@Override protected UserDto handle(Request request, Response response, UserDto body) { return body; }
}
@dev.relism.flash.routing.Consumes(ContentType.JSON)
static abstract class JsonLikeHandler<B> extends dev.relism.flash.models.BodyHandler<B> {
@Override protected B body(Request request) { return null; }
}
@dev.relism.flash.routing.PUT("/declared-body")
@ApiOperation(summary = "Declared body")
@RequestBody(value = UserDto.class, array = true, required = false, description = "Users to store")
static class DeclaredBodyHandler extends RequestHandler {
@Override public Object handle(Request request, Response response) { return null; }
}
@GET("/bare")
static class BareHandler extends RequestHandler {
@Override public UserDto handle(Request request, Response response) { return null; }
}
@GET("/example")
@ApiOperation(summary = "Example")
@APIResponse(responseCode = "200", content = @Content(schema = UserDto.class, example = "{\"id\":\"usr-1\"}"))
static class ExampleHandler extends RequestHandler {
@Override public Object handle(Request request, Response response) { return null; }
}
@GET("/secure-too")
@ApiOperation(summary = "Secure too")
static class SecondSecureHandler extends RequestHandler {
@Override public Object handle(Request request, Response response) { return null; }
}
@GET("/xml")
@dev.relism.flash.routing.Produces(ContentType.XML)
@ApiOperation(summary = "Answers XML")
static class XmlAnswerHandler extends RequestHandler {
@Override public UserDto handle(Request request, Response response) { return null; }
}
@Test
void the_media_type_of_an_answer_is_the_handler_s_own() {
OpenApiBuilder b = new OpenApiBuilder();
b.addOperation(OpenApiBuilder.routeOf(XmlAnswerHandler.class), XmlAnswerHandler.class.getAnnotation(ApiOperation.class), XmlAnswerHandler.class);
Map<String, Object> get = getOperation(b.build(), "/xml", "get");
Map<String, Object> responses = cast(get.get("responses"));
Map<String, Object> ok = cast(responses.get("200"));
Map<String, Object> content = cast(ok.get("content"));
assertTrue(content.containsKey("application/xml"), content.keySet().toString());
}
@Test
void a_typed_handler_documents_its_body_without_saying_the_type_twice() {
OpenApiBuilder b = new OpenApiBuilder();
b.addOperation(OpenApiBuilder.routeOf(TypedBodyHandler.class), TypedBodyHandler.class.getAnnotation(ApiOperation.class), TypedBodyHandler.class);
Map<String, Object> post = getOperation(b.build(), "/bodies", "post");
Map<String, Object> body = cast(post.get("requestBody"));
Map<String, Object> content = cast(body.get("content"));
Map<String, Object> json = cast(content.get("application/json"));
Map<String, Object> schema = cast(json.get("schema"));
assertEquals(true, body.get("required"));
assertEquals("#/components/schemas/UserDTO", schema.get("$ref"));
}
@Test
void a_declared_body_wins_and_carries_its_own_shape() {
OpenApiBuilder b = new OpenApiBuilder();
b.addOperation(OpenApiBuilder.routeOf(DeclaredBodyHandler.class), DeclaredBodyHandler.class.getAnnotation(ApiOperation.class), DeclaredBodyHandler.class);
Map<String, Object> put = getOperation(b.build(), "/declared-body", "put");
Map<String, Object> body = cast(put.get("requestBody"));
Map<String, Object> content = cast(body.get("content"));
Map<String, Object> json = cast(content.get("application/json"));
Map<String, Object> schema = cast(json.get("schema"));
assertEquals("Users to store", body.get("description"));
assertEquals(false, body.get("required"));
assertEquals("array", schema.get("type"));
}
@GET("/internal")
@Undocumented
static class InternalHandler extends RequestHandler {
@Override public Object handle(Request request, Response response) { return null; }
}
@Test
void a_route_that_says_it_is_not_part_of_the_api_is_left_out() {
OpenApiBuilder b = new OpenApiBuilder();
b.addOperation(OpenApiBuilder.routeOf(InternalHandler.class), null, InternalHandler.class);
b.addOperation(OpenApiBuilder.routeOf(BareHandler.class), null, BareHandler.class);
Map<String, Object> paths = cast(b.build().get("paths"));
assertFalse(paths.containsKey("/internal"));
assertTrue(paths.containsKey("/bare"), "the others are still documented");
}
@Test
void a_route_with_no_annotations_is_still_documented() {
OpenApiBuilder b = new OpenApiBuilder();
b.addOperation(OpenApiBuilder.routeOf(BareHandler.class), null, BareHandler.class);
Map<String, Object> get = getOperation(b.build(), "/bare", "get");
Map<String, Object> responses = cast(get.get("responses"));
Map<String, Object> ok = cast(responses.get("200"));
Map<String, Object> content = cast(ok.get("content"));
Map<String, Object> json = cast(content.get("application/json"));
Map<String, Object> schema = cast(json.get("schema"));
assertEquals("#/components/schemas/UserDTO", schema.get("$ref"));
}
@Test
void a_failure_is_documented_with_the_shape_flash_answers_with() {
OpenApiBuilder b = new OpenApiBuilder();
b.addOperation(OpenApiBuilder.routeOf(SecureHandler.class), SecureHandler.class.getAnnotation(ApiOperation.class), SecureHandler.class);
Map<String, Object> spec = b.build();
Map<String, Object> get = getOperation(spec, "/secure", "get");
Map<String, Object> responses = cast(get.get("responses"));
Map<String, Object> forbidden = cast(responses.get("403"));
Map<String, Object> content = cast(forbidden.get("content"));
Map<String, Object> json = cast(content.get("application/json"));
Map<String, Object> schema = cast(json.get("schema"));
Map<String, Object> components = cast(spec.get("components"));
Map<String, Object> schemas = cast(components.get("schemas"));
assertEquals("#/components/schemas/Error", schema.get("$ref"));
assertTrue(schemas.containsKey("Error"));
}
@Test
void an_answer_two_operations_share_is_written_once() {
OpenApiBuilder b = new OpenApiBuilder();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
registry.add(new OpenApiContributor() {
@Override public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
return OpenApiOperationContribution.builder()
.response(401, OpenApiResponseContribution.of("Authentication required"))
.build();
}
});
b.setContributorRegistry(registry);
b.addOperation(OpenApiBuilder.routeOf(SecureHandler.class), SecureHandler.class.getAnnotation(ApiOperation.class), SecureHandler.class);
b.addOperation(OpenApiBuilder.routeOf(SecondSecureHandler.class), SecondSecureHandler.class.getAnnotation(ApiOperation.class), SecondSecureHandler.class);
Map<String, Object> spec = b.build();
Map<String, Object> components = cast(spec.get("components"));
Map<String, Object> shared = cast(components.get("responses"));
Map<String, Object> firstResponses = cast(getOperation(spec, "/secure", "get").get("responses"));
Map<String, Object> secondResponses = cast(getOperation(spec, "/secure-too", "get").get("responses"));
Map<String, Object> first = cast(firstResponses.get("401"));
Map<String, Object> second = cast(secondResponses.get("401"));
Map<String, Object> unauthorized = cast(shared.get("Unauthorized"));
assertEquals("#/components/responses/Unauthorized", first.get("$ref"));
assertEquals("#/components/responses/Unauthorized", second.get("$ref"));
assertEquals("Authentication required", unauthorized.get("description"));
}
@Test
void an_example_sits_beside_the_schema() {
OpenApiBuilder b = new OpenApiBuilder();
b.addOperation(OpenApiBuilder.routeOf(ExampleHandler.class), ExampleHandler.class.getAnnotation(ApiOperation.class), ExampleHandler.class);
Map<String, Object> get = getOperation(b.build(), "/example", "get");
Map<String, Object> responses = cast(get.get("responses"));
Map<String, Object> ok = cast(responses.get("200"));
Map<String, Object> content = cast(ok.get("content"));
Map<String, Object> json = cast(content.get("application/json"));
assertEquals("{\"id\":\"usr-1\"}", json.get("example"));
}
@Test @Test
void builds_single_response_and_parameters_and_schema() { void builds_single_response_and_parameters_and_schema() {
OpenApiBuilder b = new OpenApiBuilder().title("X").version("1"); OpenApiBuilder b = new OpenApiBuilder().title("X").version("1");
@@ -15,6 +15,7 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Base64; import java.util.Base64;
import java.util.List;
/** /**
* API keys sent as {@code Authorization: Bearer <prefix>_<id>.<secret>}. The prefix makes a key * API keys sent as {@code Authorization: Bearer <prefix>_<id>.<secret>}. The prefix makes a key
@@ -63,8 +64,8 @@ public final class ApiKeyExtension<G> implements FlashExtension, AuthenticationM
} }
@Override @Override
public SecurityScheme scheme() { public List<SecurityScheme> schemes() {
return SecurityScheme.bearer("apiKey", prefix + "_<id>.<secret>"); return List.of(SecurityScheme.bearer("apiKey", prefix + "_<id>.<secret>"));
} }
@Override @Override
@@ -47,11 +47,30 @@ On a handler or an MCP tool class:
Mechanisms are tried in registration order, then the session cookie. The first to return a Mechanisms are tried in registration order, then the session cookie. The first to return a
principal wins. When none does: principal wins. When none does:
- a browser (`Accept: text/html`) is redirected to the only login method, or to `loginPage` when there are several; - a browser (`Accept: text/html`) is redirected to `loginPage` when one is set, otherwise to the only
login method when it is a redirect, otherwise to `/login`;
- anything else gets `401` with every mechanism's challenge in `WWW-Authenticate`. - anything else gets `401` with every mechanism's challenge in `WWW-Authenticate`.
`entryPoint(...)` replaces that, e.g. to pick an identity provider from the user's email domain. `entryPoint(...)` replaces that, e.g. to pick an identity provider from the user's email domain.
`enforce(policy, entryPoint, mechanisms)` restricts a route to some mechanisms: any other credential,
the session cookie included, is no credential there — a bearer-only endpoint a browser's cookie must
not reach.
## Origin
`origin("https://app.example")` is where the application is served. Session cookies, sign-in
callbacks and token audiences are built from `origin(req)`: the configured origin, or the request's
own scheme and `Host` when none is. `X-Forwarded-*` is never read: the client can send anything, and
an origin taken from it lets a caller choose which audience a token must have. Configure it in every
deployment; the fallback is for development and tests.
## Addresses someone else chose
`PublicUrl.require(url)` refuses anything that is not https on a public address. Call it before the
server fetches a URL a customer or a client supplied — an identity provider, a metadata document — or
that fetch becomes a request forgery against the server's own network.
## Sessions ## Sessions
`signIn(req, res, principal[, expiresAt])` stores the principal under a `flash_session` cookie; `signIn(req, res, principal[, expiresAt])` stores the principal under a `flash_session` cookie;
@@ -78,7 +97,7 @@ security.mechanism(new AuthenticationMechanism() {
if (p == null) throw new AuthenticationFailedException(null); // mine, and invalid if (p == null) throw new AuthenticationFailedException(null); // mine, and invalid
return p; return p;
} }
public SecurityScheme scheme() { return SecurityScheme.bearer("key", "opaque"); } public List<SecurityScheme> schemes() { return List.of(SecurityScheme.bearer("key", "opaque")); }
}); });
``` ```
@@ -2,6 +2,8 @@ package dev.relism.flash.ext.security;
import dev.relism.flash.models.Request; import dev.relism.flash.models.Request;
import java.util.List;
/** /**
* Reads one kind of credential off a request. A mechanism never writes the response: an anonymous * Reads one kind of credential off a request. A mechanism never writes the response: an anonymous
* request is answered by the {@link AuthenticationEntryPoint}, a rejected one by the 401 its * request is answered by the {@link AuthenticationEntryPoint}, a rejected one by the 401 its
@@ -16,6 +18,6 @@ public interface AuthenticationMechanism {
*/ */
Principal authenticate(Request req); Principal authenticate(Request req);
/** How OpenAPI documents the credential and a 401 challenges for it; {@code null} for neither. */ /** How OpenAPI documents each credential this mechanism reads, and how a 401 challenges for it. */
default SecurityScheme scheme() { return null; } default List<SecurityScheme> schemes() { return List.of(); }
} }
@@ -0,0 +1,32 @@
package dev.relism.flash.ext.security;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
/**
* The guard for a URL someone other than the operator chose — an identity provider an organization
* registers, a client's metadata document: fetching it must not become a request forgery against the
* server's own network.
*/
public final class PublicUrl {
private PublicUrl() {}
/**
* ponytail: resolved here and again by the HTTP client, so DNS rebinding between the two is not covered.
*
* @throws IllegalArgumentException {@code url} is not https, or one of its host's addresses is not public
*/
public static void require(String url) throws UnknownHostException {
URI uri = URI.create(url);
if (!"https".equals(uri.getScheme())) throw new IllegalArgumentException("Not https: " + url);
for (InetAddress address : InetAddress.getAllByName(uri.getHost())) {
if (address.isLoopbackAddress() || address.isSiteLocalAddress() || address.isLinkLocalAddress() || address.isAnyLocalAddress()
|| address.isMulticastAddress() || (address instanceof Inet6Address && (address.getAddress()[0] & 0xfe) == 0xfc)) {
throw new IllegalArgumentException("Not a public address: " + url);
}
}
}
}
@@ -60,7 +60,8 @@ public class SecurityExtension implements FlashExtension {
private AuthenticationEntryPoint entryPoint = this::commence; private AuthenticationEntryPoint entryPoint = this::commence;
private SessionStore sessions = new InMemorySessionStore(); private SessionStore sessions = new InMemorySessionStore();
private Duration sessionTimeout = Duration.ofHours(12); private Duration sessionTimeout = Duration.ofHours(12);
private String loginPage = "/login"; private String loginPage;
private String origin;
// -- Configuration -------------------------------------------------------- // -- Configuration --------------------------------------------------------
@@ -92,23 +93,33 @@ public class SecurityExtension implements FlashExtension {
return this; return this;
} }
/** Where a browser signs in, unless the only {@link LoginMethod} is a redirect it can follow directly. */ /**
* Where a browser signs in. Unset: straight to the only {@link LoginMethod} when it is a redirect, {@code /login}
* otherwise. Set it when the application's page knows ways in the list does not, like a provider picked by email.
*/
public SecurityExtension loginPage(String loginPage) { public SecurityExtension loginPage(String loginPage) {
this.loginPage = loginPage; this.loginPage = loginPage;
return this; return this;
} }
/**
* Where the application is served, e.g. {@code https://app.example}: what session cookies, sign-in
* callbacks and token audiences are built from. Unset, each request's own {@link Request#origin()}
* is used, which the client chooses — set it wherever that matters, which is every deployment.
*/
public SecurityExtension origin(String origin) {
this.origin = origin.endsWith("/") ? origin.substring(0, origin.length() - 1) : origin;
return this;
}
// -- Registration (boot time) --------------------------------------------- // -- Registration (boot time) ---------------------------------------------
public synchronized SecurityExtension mechanism(AuthenticationMechanism mechanism) { public synchronized SecurityExtension mechanism(AuthenticationMechanism mechanism) {
mechanisms = append(mechanisms, mechanism); mechanisms = append(mechanisms, mechanism);
return mechanism.scheme() == null ? this : scheme(mechanism.scheme()); for (SecurityScheme scheme : mechanism.schemes()) {
}
/** Documents and challenges for a credential beyond the one {@link AuthenticationMechanism#scheme()} names. */
public synchronized SecurityExtension scheme(SecurityScheme scheme) {
schemes = append(schemes, scheme); schemes = append(schemes, scheme);
challenges = challenges == null ? scheme.challenge() : challenges + ", " + scheme.challenge(); challenges = challenges == null ? scheme.challenge() : challenges + ", " + scheme.challenge();
}
return this; return this;
} }
@@ -136,17 +147,27 @@ public class SecurityExtension implements FlashExtension {
// -- Runtime -------------------------------------------------------------- // -- Runtime --------------------------------------------------------------
/** The configured {@link #origin(String)}, or the one {@code req} names when none is. */
public String origin(Request req) {
return origin != null ? origin : req.origin();
}
/** /**
* The caller, or {@code null} when no mechanism recognises a credential. * The caller, or {@code null} when no mechanism recognises a credential.
* *
* @throws AuthenticationFailedException a mechanism recognised one and rejected it * @throws AuthenticationFailedException a mechanism recognised one and rejected it
*/ */
public SecurityIdentity authenticate(Request req) { public SecurityIdentity authenticate(Request req) {
for (AuthenticationMechanism mechanism : mechanisms) { return authenticate(req, null);
}
/** {@code only} restricts the chain to those mechanisms, without the session; {@code null} is every one of them and the session. */
private SecurityIdentity authenticate(Request req, AuthenticationMechanism[] only) {
for (AuthenticationMechanism mechanism : only != null ? only : mechanisms) {
Principal principal = mechanism.authenticate(req); Principal principal = mechanism.authenticate(req);
if (principal != null) return new SecurityIdentity(principal, this, req); if (principal != null) return new SecurityIdentity(principal, this, req);
} }
Principal principal = sessionPrincipal(req); Principal principal = only != null ? null : sessionPrincipal(req);
return principal == null ? null : new SecurityIdentity(principal, this, req); return principal == null ? null : new SecurityIdentity(principal, this, req);
} }
@@ -168,10 +189,23 @@ public class SecurityExtension implements FlashExtension {
/** {@code anonymous} answers a caller without credentials on this route instead of the configured entry point. */ /** {@code anonymous} answers a caller without credentials on this route instead of the configured entry point. */
public Middleware enforce(SecurityPolicy policy, AuthenticationEntryPoint anonymous) { public Middleware enforce(SecurityPolicy policy, AuthenticationEntryPoint anonymous) {
return guard(policy, anonymous, null);
}
/**
* A route only {@code mechanisms} authenticate: every other credential, the session cookie included,
* is no credential at all here — a bearer route that must not be reached with a browser's cookie or
* with another mechanism's token.
*/
public Middleware enforce(SecurityPolicy policy, AuthenticationEntryPoint anonymous, List<AuthenticationMechanism> mechanisms) {
return guard(policy, anonymous, mechanisms.toArray(AuthenticationMechanism[]::new));
}
private Middleware guard(SecurityPolicy policy, AuthenticationEntryPoint anonymous, AuthenticationMechanism[] only) {
return next -> (req, res) -> { return next -> (req, res) -> {
SecurityIdentity identity; SecurityIdentity identity;
try { try {
identity = authenticate(req); identity = authenticate(req, only);
} catch (AuthenticationFailedException rejected) { } catch (AuthenticationFailedException rejected) {
if (policy.required) { if (policy.required) {
if (rejected.challenge() != null) res.header(WWW_AUTHENTICATE, rejected.challenge()); if (rejected.challenge() != null) res.header(WWW_AUTHENTICATE, rejected.challenge());
@@ -209,7 +243,7 @@ public class SecurityExtension implements FlashExtension {
RANDOM.nextBytes(id); RANDOM.nextBytes(id);
Session session = new Session(Base64.getUrlEncoder().withoutPadding().encodeToString(id), principal, expiresAt); Session session = new Session(Base64.getUrlEncoder().withoutPadding().encodeToString(id), principal, expiresAt);
sessions.save(session); sessions.save(session);
res.header("Set-Cookie", COOKIE + "=" + session.id() + "; Path=/; HttpOnly; SameSite=Lax" + (req.origin().startsWith("https") ? "; Secure" : "")); res.header("Set-Cookie", COOKIE + "=" + session.id() + "; Path=/; HttpOnly; SameSite=Lax" + (origin(req).startsWith("https") ? "; Secure" : ""));
} }
/** Ends the caller's session and returns where the browser goes next. */ /** Ends the caller's session and returns where the browser goes next. */
@@ -251,8 +285,9 @@ public class SecurityExtension implements FlashExtension {
private Object commence(Request req, Response res) { private Object commence(Request req, Response res) {
LoginMethod[] methods = loginMethods; LoginMethod[] methods = loginMethods;
String accept = req.header("Accept"); String accept = req.header("Accept");
if (methods.length > 0 && accept != null && accept.contains("text/html")) { if ((loginPage != null || methods.length > 0) && accept != null && accept.contains("text/html")) {
String login = methods.length == 1 && methods[0].kind() == LoginMethod.Kind.REDIRECT ? methods[0].url() : loginPage; String login = loginPage != null ? loginPage
: methods.length == 1 && methods[0].kind() == LoginMethod.Kind.REDIRECT ? methods[0].url() : "/login";
ByteView query = req.getRequestLine().getQuery(); ByteView query = req.getRequestLine().getQuery();
byte[] raw = new byte[query == null ? 0 : query.length()]; byte[] raw = new byte[query == null ? 0 : query.length()];
for (int i = 0; i < raw.length; i++) raw[i] = query.byteAt(i); for (int i = 0; i < raw.length; i++) raw[i] = query.byteAt(i);
@@ -7,7 +7,9 @@ import java.util.Map;
* API call receives for it, and — for OAuth — the issuer that grants it. * API call receives for it, and — for OAuth — the issuer that grants it.
* *
* @param definition the OpenAPI Security Scheme Object, verbatim * @param definition the OpenAPI Security Scheme Object, verbatim
* @param issuer the authorization server's issuer identifier, {@code null} for anything else * @param issuer the authorization server's issuer identifier {@code "/"} when the application is its own,
* at whatever {@link SecurityExtension#origin(dev.relism.flash.models.Request)} it is reached by;
* {@code null} for anything else
*/ */
public record SecurityScheme(String name, Map<String, Object> definition, String challenge, String issuer) { public record SecurityScheme(String name, Map<String, Object> definition, String challenge, String issuer) {
@@ -0,0 +1,18 @@
package dev.relism.flash.ext.security;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
class PublicUrlTest {
@Test
void onlyHttpsOnAPublicAddressPasses() {
assertDoesNotThrow(() -> PublicUrl.require("https://1.1.1.1/.well-known/openid-configuration"));
for (String url : new String[]{"http://1.1.1.1/", "https://127.0.0.1/", "https://10.0.0.8/", "https://192.168.1.1/",
"https://169.254.169.254/latest/meta-data", "https://[::1]/", "https://[fd00::1]/", "https://0.0.0.0/"}) {
assertThrows(IllegalArgumentException.class, () -> PublicUrl.require(url), url);
}
}
}
@@ -2,6 +2,7 @@ package dev.relism.flash.ext.security;
import dev.relism.flash.ext.openapi.OpenApiExtension; import dev.relism.flash.ext.openapi.OpenApiExtension;
import dev.relism.flash.models.Request; import dev.relism.flash.models.Request;
import dev.relism.flash.testing.FlashResponse;
import dev.relism.flash.testing.FlashTest; import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.extension.RegisterExtension;
@@ -9,7 +10,9 @@ import org.junit.jupiter.api.extension.RegisterExtension;
import java.time.Instant; import java.time.Instant;
import java.util.Set; import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SecurityExtensionTest { class SecurityExtensionTest {
@@ -29,8 +32,8 @@ class SecurityExtensionTest {
} }
@Override @Override
public SecurityScheme scheme() { public java.util.List<SecurityScheme> schemes() {
return SecurityScheme.bearer("key", "opaque"); return java.util.List.of(SecurityScheme.bearer("key", "opaque"));
} }
}; };
@@ -45,6 +48,8 @@ class SecurityExtensionTest {
.install(security) .install(security)
.install(new OpenApiExtension("/openapi", "test", "1")) .install(new OpenApiExtension("/openapi", "test", "1"))
.get("/me", (req, res) -> SecurityIdentity.current().principal().name(), security.enforce(SecurityPolicy.AUTHENTICATED)) .get("/me", (req, res) -> SecurityIdentity.current().principal().name(), security.enforce(SecurityPolicy.AUTHENTICATED))
.get("/key-only", (req, res) -> SecurityIdentity.current().principal().name(), security.enforce(SecurityPolicy.AUTHENTICATED,
(req, res) -> { throw dev.relism.flash.exceptions.HttpException.unauthorized(); }, java.util.List.of(KEY)))
.post("/login", (req, res) -> { .post("/login", (req, res) -> {
security.signIn(req, res, new KeyPrincipal("carol", Set.of()), Instant.now().minusSeconds(1)); security.signIn(req, res, new KeyPrincipal("carol", Set.of()), Instant.now().minusSeconds(1));
return "signed in"; return "signed in";
@@ -76,6 +81,19 @@ class SecurityExtensionTest {
.expectHeader("Location", "/auth/key/login?redirect=%2Fme%3Ftab%3Dkeys"); .expectHeader("Location", "/auth/key/login?redirect=%2Fme%3Ftab%3Dkeys");
} }
/** An application's own page may know ways in the list does not, so a configured one always wins. */
@Test
void aConfiguredLoginPageWinsOverTheOnlyLoginMethod() {
SecurityExtension paged = new SecurityExtension().loginPage("/sign-in")
.loginMethod(new LoginMethod("key", "Key", "/auth/key/login", LoginMethod.Kind.REDIRECT));
FlashTest own = FlashTest.of(flash -> flash.install(paged).get("/me", (req, res) -> "me", paged.enforce(SecurityPolicy.AUTHENTICATED)));
try {
own.request().header("Accept", "text/html").get("/me").expectStatus(302).expectHeader("Location", "/sign-in?redirect=%2Fme");
} finally {
own.app().stop().join();
}
}
@Test @Test
void permitAllIdentifiesWhoeverAuthenticatesAndToleratesEveryoneElse() { void permitAllIdentifiesWhoeverAuthenticatesAndToleratesEveryoneElse() {
app.request().header("Authorization", "Key bob").get("/open").expectBody("bob"); app.request().header("Authorization", "Key bob").get("/open").expectBody("bob");
@@ -108,6 +126,37 @@ class SecurityExtensionTest {
app.request().header("Cookie", session).get("/me").expectStatus(401); app.request().header("Cookie", session).get("/me").expectStatus(401);
} }
/** A route restricted to one mechanism takes that credential and nothing else, not even a signed-in browser. */
@Test
void aRouteRestrictedToAMechanismIgnoresTheSession() {
String cookie = app.request().post("/login").expectStatus(200).header("Set-Cookie");
String session = cookie.substring(0, cookie.indexOf(';'));
app.request().header("Cookie", session).get("/me").expectStatus(200);
app.request().header("Cookie", session).get("/key-only").expectStatus(401);
app.request().header("Authorization", "Key alice").get("/key-only").expectStatus(200).expectBody("alice");
}
/** The client chooses its Host and any X-Forwarded-* it likes: only a configured origin is the application's own. */
@Test
void theOriginIsConfiguredNeverTakenFromForwardedHeaders() {
String forwarded = app.request().header("X-Forwarded-Proto", "https").header("X-Forwarded-Host", "evil.example")
.post("/login").expectStatus(200).header("Set-Cookie");
assertFalse(forwarded.contains("Secure"), forwarded);
SecurityExtension served = new SecurityExtension().origin("https://app.example/");
FlashTest configured = FlashTest.of(flash -> flash.install(served).post("/login", (req, res) -> {
served.signIn(req, res, new KeyPrincipal("carol", Set.of()));
return served.origin(req);
}));
try {
FlashResponse login = configured.request().post("/login").expectStatus(200).expectBody("https://app.example");
assertTrue(login.header("Set-Cookie").contains("; Secure"));
} finally {
configured.app().stop().join();
}
}
@Test @Test
void loginMethodsAreListed() { void loginMethodsAreListed() {
app.get("/auth/methods").expectStatus(200).expectBody("[{\"id\":\"key\",\"name\":\"Key\",\"url\":\"/auth/key/login\",\"kind\":\"redirect\"}]"); app.get("/auth/methods").expectStatus(200).expectBody("[{\"id\":\"key\",\"name\":\"Key\",\"url\":\"/auth/key/login\",\"kind\":\"redirect\"}]");
@@ -0,0 +1,95 @@
# flash-ext-security-oauth-server
An OAuth 2.1 authorization server for [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md):
the application's own users authorize clients — an MCP client, a CLI, another service — to call the
application's own resources. It is the server side of OAuth; signing users in *through* someone else's
provider is [`flash-ext-security-oidc`](../../flash-ext-security-oidc/docs/README.md).
```java
SecurityExtension security = new SecurityExtension().origin("https://app.example").loginPage("/login");
OAuthServerExtension oauth = new OAuthServerExtension("/mcp") // the resources tokens are for
.store(new JdbcOAuthStore(db)) // default: in memory
.signingKeys(System.getenv("OAUTH_SIGNING_KEYS")); // default: generated at boot
app.install(security).install(new CaffeineCacheExtension()).install(oauth)
.install(new McpExtension(McpConfig.builder("app").toolsPackage("com.example.tools").mechanisms(oauth).build()));
```
The issuer is `SecurityExtension.origin(...)`. The server is also an `AuthenticationMechanism`: a bearer
token it issued authenticates as an `OAuthPrincipal` (`name()` is `sub`, `clientId()`, `claim(...)`,
`hasScope`, `hasAudience`). Any other bearer is left to other mechanisms. A token is good only under the
resource it was issued for: one for `/mcp` is `401 invalid_token` on `/api/...`, whatever the route asks.
## What it implements
| | |
|---|---|
| OAuth 2.1 | authorization code with PKCE `S256` only; exact redirect URIs; no implicit, no password grant |
| RFC 8252 | a loopback redirect (`http://127.0.0.1`, `[::1]`, `localhost`) may come back on any port |
| RFC 8414 | `GET /.well-known/oauth-authorization-server`, CORS-enabled like every client-facing endpoint |
| Client ID metadata documents | a `client_id` that is an https URL is fetched, under `PublicUrl`'s rules, and trusted only if it names itself |
| RFC 7591 | `POST /oauth/register`; `registration(false)` closes it |
| RFC 8707 | `resource` on authorize and token requests, one of the constructor's paths on the origin; the first is the default |
| RFC 9068 | access tokens are ES256 JWTs, `typ` `at+jwt`, with `iss`, `sub`, `aud`, `client_id`, `scope`, `iat`, `exp`, `jti`; `GET /oauth/jwks` |
| Refresh tokens | opaque, stored hashed, rotated on every use; reusing one — or replaying a code — revokes everything issued from that authorization |
| RFC 7009 | `POST /oauth/revoke` takes a refresh token's whole authorization with it |
| RFC 9207 | `iss` in every authorization response |
| `client_credentials` | only for a client the application registers itself, with `register(metadata)` |
Clients authenticate at the token and revocation endpoints with `none` (public, PKCE), `client_secret_basic`
or `client_secret_post` — whichever they registered. Secrets, codes and refresh tokens are 256 random bits,
stored as SHA-256.
Not implemented: DPoP, mTLS, PAR, JAR, `private_key_jwt`, device authorization, token exchange,
introspection (the access token is self-contained: verify it with the JWKS), RFC 7592 client management,
and OpenID Connect — there are no ID tokens and no userinfo.
## Signing in and consent
`GET /oauth/authorize` runs behind the security chain: a browser without a session goes to the
application's sign-in (set `loginPage`, so a page that knows every way in is shown rather than the only
listed provider), and comes back when it is signed in.
The first time a user meets a client, and whenever it asks for more scope than they allowed, the browser
is sent to `consentPage(...)` (default `/consent`) with the authorization request as its query string.
That page reads what to show from `GET /oauth/authorize/request?<same query>`
`client_name`, `client_uri`, `logo_uri`, `redirect_uri`, `scope`, `resource` — and submits a form
`POST /oauth/authorize` with the same parameters and `consent=allow` or `consent=deny`. The form is
refused if its `Origin` is not the application's; the session cookie is `SameSite=Lax`, so another
site's form does not carry it.
An unknown client or a redirect URI it did not register is answered with a 400, never a redirect; any
other refusal goes back to the redirect URI as `error`, with `state` and `iss`.
## Subjects
`subjects(identity -> new OAuthSubject(id, claims))` decides what a token says about who authorized it:
`id` becomes `sub`, `claims` go into every token issued from that authorization, refreshes included.
Default: the principal's name, no claims. Put there whatever the application must know on the other side
— which provider signed the user in, for instance, when that is a tenant boundary.
The same function decides who may authorize at all: throwing refuses the caller. `/oauth/authorize` runs
behind the whole chain, so refuse any credential narrower than the user behind it — an API key scoped to
one project must not come back as a token carrying everything its user may do.
## Caches
The server needs a `CacheManager` — install [`flash-ext-cache-caffeine`](../../flash-ext-cache-caffeine/docs/README.md).
It keeps client metadata documents there, bounded and for ten minutes, failures included: the
`client_id` is a URL whoever calls the server chose, so neither the cache nor the fetching it saves
may grow with what they send. The RFC 8414 document is cached per issuer.
## Storage and keys
`OAuthStore` holds clients (their RFC 7591 metadata, verbatim), grants — codes and refresh tokens, one
`OAuthGrant` record, keyed by hash and grouped in a family per authorization — and consents.
`use(hash)` must be atomic: it is what makes a code single-use. `InMemoryOAuthStore` loses everything
on restart.
`signingKeys(jwkSet)` takes a JWK set whose first key is a private P-256 key; the others only verify, so a
key rotates by putting its successor first. `OAuthServerExtension.generateSigningKeys()` makes one.
## Testing
`OAuthTestClient` in [`flash-ext-security-test`](../../flash-ext-security-test/docs/README.md) registers,
authorizes as any principal, consents and exchanges, discovering every endpoint from the metadata.
@@ -0,0 +1,56 @@
<?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-security-oauth-server</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-security-core</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-cache-core</artifactId>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</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-ext-security-test</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-cache-caffeine</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-mcp</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,124 @@
package dev.relism.flash.ext.security.oauthserver;
import com.nimbusds.jose.util.JSONObjectUtils;
import dev.relism.flash.ext.cache.Cache;
import dev.relism.flash.ext.security.PublicUrl;
import dev.relism.flash.ext.security.oauthserver.OAuthServerExtension.OAuthError;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
/**
* Finds a client — registered, or described by the metadata document its https {@code client_id} points
* at — and validates what a client says about itself.
*/
final class Clients {
static final List<String> AUTH_METHODS = List.of("none", "client_secret_basic", "client_secret_post");
private static final Set<String> LOOPBACK = Set.of("127.0.0.1", "[::1]", "localhost");
private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
private final OAuthStore store;
private final boolean localDocuments;
private final Cache<String, Optional<OAuthClient>> documents;
/** @param documents bounded and expiring: the keys are URLs whoever calls the server chose */
Clients(OAuthStore store, boolean localDocuments, Cache<String, Optional<OAuthClient>> documents) {
this.store = store;
this.localDocuments = localDocuments;
this.documents = documents;
}
/** The client, or {@code null} when there is none by that id. */
OAuthClient find(String id) {
if (id == null) return null;
if (!id.startsWith("https://") && !(localDocuments && id.startsWith("http://"))) return store.client(id);
return documents.get(id, this::document).orElse(null);
}
/**
* A client ID Metadata Document: fetched from where its id points, under {@link PublicUrl}'s rules,
* and trusted only if it names that same URL. Such a client holds no secret, so it is public. A URL
* that fails is remembered as failing, so naming it again does not fetch it again.
*/
private Optional<OAuthClient> document(String url) {
try {
if (!localDocuments) PublicUrl.require(url);
HttpResponse<String> response = HTTP.send(HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(5))
.header("Accept", "application/json").build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200 || response.body().length() > 16_384) return Optional.empty();
Map<String, Object> metadata = new HashMap<>(JSONObjectUtils.parse(response.body()));
if (!url.equals(metadata.remove("client_id"))) return Optional.empty();
metadata.putIfAbsent("token_endpoint_auth_method", "none");
if (!"none".equals(metadata.get("token_endpoint_auth_method"))) return Optional.empty();
return Optional.of(new OAuthClient(url, null, validate(metadata, false)));
} catch (Exception unusable) {
return Optional.empty();
}
}
/**
* RFC 7591 metadata with its defaults filled in: {@code authorization_code}, {@code code}, and
* {@code client_secret_basic}. Only an application registering a client itself may grant it
* {@code client_credentials}: a client registering itself would otherwise mint its own tokens.
*
* @throws OAuthError naming the RFC 7591 error
*/
static Map<String, Object> validate(Map<String, Object> given, boolean trusted) {
Map<String, Object> metadata = new HashMap<>(given);
metadata.putIfAbsent("grant_types", List.of("authorization_code"));
metadata.putIfAbsent("response_types", List.of("code"));
metadata.putIfAbsent("token_endpoint_auth_method", "client_secret_basic");
List<?> grants = list(metadata.get("grant_types"));
Set<String> allowed = trusted ? Set.of("authorization_code", "refresh_token", "client_credentials") : Set.of("authorization_code", "refresh_token");
if (grants == null || grants.isEmpty() || !allowed.containsAll(grants)) throw new OAuthError("invalid_client_metadata", "Unsupported grant_types");
if (!List.of("code").equals(metadata.get("response_types")) && grants.contains("authorization_code")) {
throw new OAuthError("invalid_client_metadata", "response_types must be [\"code\"]");
}
if (!AUTH_METHODS.contains(metadata.get("token_endpoint_auth_method"))) throw new OAuthError("invalid_client_metadata", "Unsupported token_endpoint_auth_method");
if (grants.contains("client_credentials") && "none".equals(metadata.get("token_endpoint_auth_method"))) {
throw new OAuthError("invalid_client_metadata", "client_credentials needs a confidential client");
}
List<?> uris = list(metadata.getOrDefault("redirect_uris", List.of()));
if (uris == null || grants.contains("authorization_code") && uris.isEmpty()) throw new OAuthError("invalid_redirect_uri", "redirect_uris is required");
for (Object uri : uris) {
if (!(uri instanceof String text) || !redirectable(text)) throw new OAuthError("invalid_redirect_uri", "Not an https or loopback URI: " + uri);
}
return metadata;
}
/** OAuth 2.1 §2.3.1: exactly as registered, except that a loopback redirect may use any port (RFC 8252 §7.3). */
static boolean matches(String registered, String requested) {
if (registered.equals(requested)) return true;
try {
URI a = URI.create(registered), b = URI.create(requested);
return "http".equals(a.getScheme()) && "http".equals(b.getScheme()) && LOOPBACK.contains(a.getHost()) && a.getHost().equals(b.getHost())
&& a.getRawPath().equals(b.getRawPath()) && Objects.equals(a.getRawQuery(), b.getRawQuery()) && b.getRawFragment() == null;
} catch (IllegalArgumentException malformed) {
return false;
}
}
private static boolean redirectable(String uri) {
try {
URI parsed = URI.create(uri);
return parsed.getRawFragment() == null && parsed.getHost() != null
&& ("https".equals(parsed.getScheme()) || "http".equals(parsed.getScheme()) && LOOPBACK.contains(parsed.getHost()));
} catch (IllegalArgumentException malformed) {
return false;
}
}
private static List<?> list(Object value) {
return value instanceof List<?> list ? list : null;
}
}
@@ -0,0 +1,60 @@
package dev.relism.flash.ext.security.oauthserver;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/** One instance's clients, grants and consents, lost on restart. Expired grants are swept whenever one is saved. */
public final class InMemoryOAuthStore implements OAuthStore {
private final Map<String, OAuthClient> clients = new ConcurrentHashMap<>();
private final Map<String, OAuthGrant> grants = new ConcurrentHashMap<>();
private final Map<String, String> consents = new ConcurrentHashMap<>();
@Override
public OAuthClient client(String id) {
return clients.get(id);
}
@Override
public void save(OAuthClient client) {
clients.put(client.id(), client);
}
@Override
public void save(OAuthGrant grant) {
Instant now = Instant.now();
grants.values().removeIf(g -> g.expiresAt().isBefore(now));
grants.put(grant.hash(), grant);
}
@Override
public OAuthGrant find(String hash) {
return grants.get(hash);
}
@Override
public OAuthGrant use(String hash) {
OAuthGrant[] before = new OAuthGrant[1];
grants.computeIfPresent(hash, (key, grant) -> {
before[0] = grant;
return grant.usedAt() == null ? grant.used(Instant.now()) : grant;
});
return before[0];
}
@Override
public void revoke(String family) {
grants.values().removeIf(grant -> grant.family().equals(family));
}
@Override
public String consent(String subject, String clientId) {
return consents.get(subject + " " + clientId);
}
@Override
public void consent(String subject, String clientId, String scope) {
consents.put(subject + " " + clientId, scope);
}
}
@@ -0,0 +1,38 @@
package dev.relism.flash.ext.security.oauthserver;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* A client of the authorization server, described by its RFC 7591 metadata exactly as it was registered
* (defaults filled in). A confidential client's secret is kept only as a hash; a public one has none.
*
* @param id either issued at registration, or the https URL of the client's metadata document
*/
public record OAuthClient(String id, String secretHash, Map<String, Object> metadata) {
public OAuthClient {
metadata = Map.copyOf(metadata);
}
/** {@code none}, {@code client_secret_basic} or {@code client_secret_post}. */
public String authMethod() {
return (String) metadata.get("token_endpoint_auth_method");
}
@SuppressWarnings("unchecked")
public List<String> redirectUris() {
return (List<String>) metadata.getOrDefault("redirect_uris", List.of());
}
@SuppressWarnings("unchecked")
public Set<String> grantTypes() {
return Set.copyOf((List<String>) metadata.get("grant_types"));
}
/** What a consent page calls it: its {@code client_name}, or its id when it gave none. */
public String name() {
return (String) metadata.getOrDefault("client_name", id);
}
}
@@ -0,0 +1,29 @@
package dev.relism.flash.ext.security.oauthserver;
import java.time.Instant;
import java.util.Map;
/**
* An authorization code or a refresh token, stored under the hash of its value. Every grant descending
* from one authorization shares a {@code family}, so a replayed code or a reused refresh token revokes
* the whole of it (OAuth 2.1 §4.1.2, §4.3.1).
*
* @param subject who authorized it, as {@link OAuthSubject#id()}
* @param claims carried into every access token issued from it
* @param redirectUri the one the authorization request named, which the code exchange must repeat; {@code null} otherwise
* @param challenge the PKCE {@code S256} challenge; codes only
* @param usedAt when it was exchanged, {@code null} while it is unused
*/
public record OAuthGrant(String hash, Kind kind, String family, String clientId, String subject, Map<String, Object> claims,
String scope, String resource, String redirectUri, String challenge, Instant expiresAt, Instant usedAt) {
public enum Kind { CODE, REFRESH }
public OAuthGrant {
claims = Map.copyOf(claims);
}
public OAuthGrant used(Instant at) {
return new OAuthGrant(hash, kind, family, clientId, subject, claims, scope, resource, redirectUri, challenge, expiresAt, at);
}
}
@@ -0,0 +1,31 @@
package dev.relism.flash.ext.security.oauthserver;
import dev.relism.flash.ext.security.Principal;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* A caller holding an access token this application issued. {@link #name()} is {@code sub}: the
* {@link OAuthSubject#id()} the user authorized as, or the client's own id for {@code client_credentials}.
*
* @param claims the token's verified claims
*/
public record OAuthPrincipal(String name, String clientId, Map<String, Object> claims) implements Principal {
public Object claim(String name) {
return claims.get(name);
}
@Override
public boolean hasScope(String scope) {
return claims.get("scope") instanceof String granted && List.of(granted.split(" ")).contains(scope);
}
@Override
public boolean hasAudience(String audience) {
Object aud = claims.get("aud");
return aud instanceof Collection<?> list ? list.contains(audience) : audience.equals(aud);
}
}
@@ -0,0 +1,633 @@
package dev.relism.flash.ext.security.oauthserver;
import com.nimbusds.jose.util.JSONObjectUtils;
import com.nimbusds.jwt.SignedJWT;
import dev.relism.flash.Flash;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.ext.cache.Cache;
import dev.relism.flash.ext.cache.CacheManager;
import dev.relism.flash.ext.security.AuthenticationFailedException;
import dev.relism.flash.ext.security.AuthenticationMechanism;
import dev.relism.flash.ext.security.Principal;
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.models.Request;
import dev.relism.flash.models.Response;
import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.routing.Middleware;
import dev.relism.fpr.core.ByteView;
import lombok.extern.slf4j.Slf4j;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* An OAuth 2.1 authorization server for the application's own users and resources. A user signs in the
* way the application's security chain signs anyone in, consents on the application's page, and the
* client receives an RFC 9068 access token for one of the application's resources — which this extension,
* as an {@link AuthenticationMechanism}, then authenticates.
*
* <pre>{@code
* app.install(new SecurityExtension().origin("https://app.example").loginPage("/login"))
* .install(new OAuthServerExtension("/mcp").store(store).signingKeys(keys));
* }</pre>
*
* <p>Authorization code with PKCE {@code S256} (the only grant a user takes part in), refresh tokens that
* rotate and revoke their family on reuse, {@code client_credentials} for clients the application registers
* itself; RFC 8414 metadata, RFC 7591 registration, client ID metadata documents, RFC 8707 resource
* indicators, RFC 7009 revocation, RFC 9207 {@code iss} in the authorization response. The issuer is the
* application's {@link SecurityExtension#origin(Request)}.
*/
@Slf4j
public final class OAuthServerExtension implements FlashExtension, AuthenticationMechanism {
public static final String AUTHORIZE = "/oauth/authorize";
public static final String TOKEN = "/oauth/token";
public static final String REGISTER = "/oauth/register";
public static final String REVOKE = "/oauth/revoke";
public static final String JWKS = "/oauth/jwks";
public static final String METADATA = "/.well-known/oauth-authorization-server";
private static final AuthenticationFailedException INVALID = new AuthenticationFailedException("Bearer error=\"invalid_token\"");
private static final SecureRandom RANDOM = new SecureRandom();
private static final Base64.Encoder BASE64URL = Base64.getUrlEncoder().withoutPadding();
private static final Duration CODE_LIFETIME = Duration.ofMinutes(1);
private final List<String> resources;
private OAuthStore store = new InMemoryOAuthStore();
private SigningKeys keys;
private Function<SecurityIdentity, OAuthSubject> subjects = identity -> new OAuthSubject(identity.principal().name(), Map.of());
private Set<String> scopes = Set.of();
private Duration accessTokenLifetime = Duration.ofMinutes(10);
private Duration refreshTokenLifetime = Duration.ofDays(30);
private String consentPage = "/consent";
private boolean registration = true;
private boolean localClients;
private Clients clients;
private Cache<String, String> metadata;
private SecurityExtension security;
/**
* @param resources the paths tokens may be issued for, as RFC 8707 resources on the application's origin;
* the first is the audience of a request that names none
*/
public OAuthServerExtension(String... resources) {
if (resources.length == 0) throw new IllegalArgumentException("Name at least one resource path, e.g. /mcp");
for (String resource : resources) {
if (!resource.startsWith("/")) throw new IllegalArgumentException("A resource is a path on this application: " + resource);
}
this.resources = List.of(resources);
}
// -- Configuration --------------------------------------------------------
public OAuthServerExtension store(OAuthStore store) {
this.store = store;
return this;
}
/**
* The signing keys, as a JWK set whose first key is a private P-256 key — see {@link #generateSigningKeys()}.
* Unset, a key is generated at boot, and every token dies with the process.
*/
public OAuthServerExtension signingKeys(String jwkSet) {
this.keys = new SigningKeys(jwkSet);
return this;
}
/**
* Whom an authorization names, and what its tokens carry — and who may authorize at all: throw to refuse a
* caller. A credential narrower than its user, like an API key, must not become a token with all the user's
* rights. Default: the principal's name, no claims, anyone signed in.
*/
public OAuthServerExtension subjects(Function<SecurityIdentity, OAuthSubject> subjects) {
this.subjects = subjects;
return this;
}
/** The scopes a client may be granted; any other it asks for is left out of the grant. Default: none. */
public OAuthServerExtension scopes(String... scopes) {
this.scopes = Set.of(scopes);
return this;
}
public OAuthServerExtension accessTokenLifetime(Duration lifetime) {
this.accessTokenLifetime = lifetime;
return this;
}
public OAuthServerExtension refreshTokenLifetime(Duration lifetime) {
this.refreshTokenLifetime = lifetime;
return this;
}
/**
* The application's page asking a signed-in user to allow a client, sent the authorization request as its
* query string. It reads {@code GET /oauth/authorize/request} with that same query, and posts it back to
* {@code /oauth/authorize} with {@code consent=allow} or {@code consent=deny}. Default {@code /consent}.
*/
public OAuthServerExtension consentPage(String consentPage) {
this.consentPage = consentPage;
return this;
}
/** Whether any client may register itself at {@link #REGISTER} (RFC 7591). Default {@code true}. */
public OAuthServerExtension registration(boolean open) {
this.registration = open;
return this;
}
/** Lets client metadata documents live on http and private addresses — for development, never production. */
public OAuthServerExtension allowLocalClients() {
this.localClients = true;
return this;
}
/** A new JWK set for {@link #signingKeys(String)}, private key included: generate once, keep it secret. */
public static String generateSigningKeys() {
return SigningKeys.generate();
}
// -- Clients --------------------------------------------------------------
/** A registered client, and its secret — shown this once, never stored. {@code null} for a public client. */
public record Registration(OAuthClient client, String secret) {}
/**
* Registers a client from its RFC 7591 metadata — what {@link #REGISTER} does for a client registering
* itself, and the only way to a {@code client_credentials} client.
*/
public Registration register(Map<String, Object> metadata) {
return register(metadata, true);
}
private Registration register(Map<String, Object> metadata, boolean trusted) {
Map<String, Object> valid = Clients.validate(metadata, trusted);
String secret = "none".equals(valid.get("token_endpoint_auth_method")) ? null : random(32);
OAuthClient client = new OAuthClient(random(16), secret == null ? null : s256(secret), valid);
store.save(client);
return new Registration(client, secret);
}
// -- Extension ------------------------------------------------------------
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
if (keys == null) {
keys = new SigningKeys(SigningKeys.generate());
log.warn("[flash-ext-security-oauth-server] No signing keys configured: tokens will not survive a restart.");
}
ctx.provide(OAuthServerExtension.class, this);
ctx.onReady(() -> {
security = ctx.require(SecurityExtension.class).mechanism(this);
CacheManager caches = ctx.require(CacheManager.class);
clients = new Clients(store, localClients, caches.build("oauth-server.client-documents", spec -> spec.maxSize(1_000).ttl(Duration.ofMinutes(10))));
// Keyed by issuer, which is the request's own origin when none is configured: bounded for that.
metadata = caches.build("oauth-server.metadata", spec -> spec.maxSize(16));
Middleware signedIn = security.enforce(SecurityPolicy.AUTHENTICATED);
Middleware cors = Flash.commons.middlewares.cors(c -> c.methods("GET", "POST", "OPTIONS").headers("Authorization", "Content-Type"));
app.get(AUTHORIZE, this::authorize, signedIn);
app.post(AUTHORIZE, this::decide, signedIn);
app.get(AUTHORIZE + "/request", this::request, signedIn);
app.get(METADATA, (req, res) -> json(res, 200, metadata.get(issuer(req), this::describe)), cors);
app.get(JWKS, (req, res) -> json(res, 200, keys.publicJwks()), cors);
app.post(TOKEN, endpoint(this::token), cors);
app.post(REVOKE, endpoint(this::revoke), cors);
if (registration) app.post(REGISTER, endpoint(this::registerSelf), cors);
for (String path : registration ? List.of(METADATA, JWKS, TOKEN, REVOKE, REGISTER) : List.of(METADATA, JWKS, TOKEN, REVOKE)) {
app.options(path, (req, res) -> {
res.status(204);
return null;
}, cors);
}
});
}
// -- Bearer access tokens -------------------------------------------------
/**
* Only tokens this server issued are this mechanism's: any other bearer is left to the rest of the chain.
* A token is good only under the resource it was issued for (RFC 8707, RFC 9068 §4): one for {@code /mcp}
* authenticates nothing outside it.
*/
@Override
public Principal authenticate(Request req) {
String header = req.header("Authorization");
if (header == null || !header.startsWith("Bearer ")) return null;
SignedJWT token;
try {
token = SignedJWT.parse(header.substring(7));
if (!issuer(req).equals(token.getJWTClaimsSet().getIssuer())) return null;
} catch (java.text.ParseException notAJwt) {
return null;
}
try {
Map<String, Object> claims = keys.verify(token);
OAuthPrincipal principal = new OAuthPrincipal((String) claims.get("sub"), (String) claims.get("client_id"), claims);
String resource = resourceAt(req.path());
if (resource != null && principal.hasAudience(issuer(req) + resource)) return principal;
} catch (Exception invalid) {
// Refused below, like a token for another resource.
}
throw INVALID;
}
/** The innermost of this server's resources {@code path} lies in, or {@code null}. */
private String resourceAt(String path) {
String found = null;
for (String resource : resources) {
boolean within = resource.equals("/") || path.equals(resource) || path.startsWith(resource + "/");
if (within && (found == null || resource.length() > found.length())) found = resource;
}
return found;
}
/** Its issuer is {@code "/"}: this application, at whatever origin it is reached by. */
@Override
public List<SecurityScheme> schemes() {
Map<String, Object> flow = Map.of("authorizationUrl", AUTHORIZE, "tokenUrl", TOKEN, "refreshUrl", TOKEN,
"scopes", scopes.stream().collect(Collectors.toMap(scope -> scope, scope -> scope)));
return List.of(new SecurityScheme("oauth", Map.of("type", "oauth2", "flows", Map.of("authorizationCode", flow)), "Bearer realm=\"oauth\"", "/"));
}
// -- Authorization endpoint -----------------------------------------------
/** A validated authorization request. */
private record Authorization(OAuthClient client, String redirectUri, String requestedRedirect, String state, String scope,
String resource, String challenge) {}
/** An RFC 6749 / RFC 7591 error: its code, what to tell the client, and the status to answer with. */
static final class OAuthError extends RuntimeException {
final String error;
final int status;
OAuthError(String error, String description) {
this(400, error, description);
}
OAuthError(int status, String error, String description) {
super(description, null, false, false);
this.error = error;
this.status = status;
}
}
/** A request whose client and redirect URI are good, and which is refused anyway: answered at the redirect URI. */
private static final class Refused extends RuntimeException {
final String error;
final String redirectUri;
final String state;
Refused(String error, String description, String redirectUri, String state) {
super(description, null, false, false);
this.error = error;
this.redirectUri = redirectUri;
this.state = state;
}
}
private Object authorize(Request req, Response res) {
try {
Authorization authorization = authorization(req::query, req);
OAuthSubject subject = subjects.apply(SecurityIdentity.current());
String consented = store.consent(subject.id(), authorization.client().id());
if (consented != null && covers(consented, authorization.scope())) return back(req, res, 302, authorization, subject);
res.redirect(consentPage + "?" + query(req));
} catch (Refused refused) {
refuse(req, res, 302, refused);
}
return null;
}
/** The consent page's answer. SameSite=Lax keeps another site's form from carrying the session here. */
private Object decide(Request req, Response res) {
String origin = req.header("Origin");
if (origin != null && !origin.equals(issuer(req))) throw HttpException.forbidden();
Map<String, String> form = form(req);
try {
Authorization authorization = authorization(form::get, req);
if (!"allow".equals(form.get("consent"))) {
throw new Refused("access_denied", "The user did not allow it", authorization.redirectUri(), authorization.state());
}
OAuthSubject subject = subjects.apply(SecurityIdentity.current());
store.consent(subject.id(), authorization.client().id(), authorization.scope());
return back(req, res, 303, authorization, subject);
} catch (Refused refused) {
refuse(req, res, 303, refused);
return null;
}
}
/** What a consent page shows about the request it was sent. */
private Object request(Request req, Response res) {
try {
Authorization authorization = authorization(req::query, req);
Map<String, Object> view = new LinkedHashMap<>();
view.put("client_id", authorization.client().id());
view.put("client_name", authorization.client().name());
for (String key : List.of("client_uri", "logo_uri")) {
if (authorization.client().metadata().get(key) != null) view.put(key, authorization.client().metadata().get(key));
}
view.put("redirect_uri", authorization.redirectUri());
view.put("scope", authorization.scope());
view.put("resource", authorization.resource());
return json(res, 200, JSONObjectUtils.toJSONString(view));
} catch (Refused refused) {
return json(res, 400, JSONObjectUtils.toJSONString(Map.of("error", refused.error, "error_description", refused.getMessage())));
}
}
/**
* An unknown client or an unregistered redirect URI is refused here and never redirected (OAuth 2.1
* §4.1.2.1): redirecting would hand the response to whoever wrote the URI. Anything else goes back to it.
*/
private Authorization authorization(Function<String, String> param, Request req) {
OAuthClient client = clients.find(param.apply("client_id"));
if (client == null) throw HttpException.badRequest("Unknown client");
String requested = param.apply("redirect_uri");
List<String> registered = client.redirectUris();
String redirectUri = requested == null ? registered.size() == 1 ? registered.getFirst() : null
: registered.stream().anyMatch(uri -> Clients.matches(uri, requested)) ? requested : null;
if (redirectUri == null) throw HttpException.badRequest("Unregistered redirect_uri");
String state = param.apply("state");
if (!"code".equals(param.apply("response_type"))) throw new Refused("unsupported_response_type", "Only code is supported", redirectUri, state);
if (!client.grantTypes().contains("authorization_code")) throw new Refused("unauthorized_client", "Not registered for authorization_code", redirectUri, state);
String challenge = param.apply("code_challenge");
if (challenge == null || !"S256".equals(param.apply("code_challenge_method"))) {
throw new Refused("invalid_request", "PKCE with S256 is required", redirectUri, state);
}
String resource = resource(param.apply("resource"), req);
if (resource == null) throw new Refused("invalid_target", "Unknown resource", redirectUri, state);
return new Authorization(client, redirectUri, requested, state, granted(param.apply("scope")), resource, challenge);
}
/** A code for the authorization, delivered at the client's redirect URI. */
private Object back(Request req, Response res, int status, Authorization authorization, OAuthSubject subject) {
String code = random(32);
store.save(new OAuthGrant(s256(code), OAuthGrant.Kind.CODE, random(16), authorization.client().id(), subject.id(), subject.claims(),
authorization.scope(), authorization.resource(), authorization.requestedRedirect(), authorization.challenge(),
Instant.now().plus(CODE_LIFETIME), null));
res.status(status).header("Location", redirect(authorization.redirectUri(), "code", code, authorization.state(), issuer(req)));
return null;
}
private void refuse(Request req, Response res, int status, Refused refused) {
res.status(status).header("Location", redirect(refused.redirectUri, "error", refused.error, refused.state, issuer(req)));
}
/** RFC 9207: the issuer travels with the response, so a client talking to several servers cannot be mixed up. */
private static String redirect(String uri, String key, String value, String state, String issuer) {
return uri + (uri.indexOf('?') < 0 ? '?' : '&') + key + "=" + encode(value)
+ (state == null ? "" : "&state=" + encode(state)) + "&iss=" + encode(issuer);
}
// -- Token endpoint -------------------------------------------------------
private Object token(Request req, Response res) {
Map<String, String> form = form(req);
OAuthClient client = client(req, form);
String grantType = form.get("grant_type");
if (grantType == null) throw new OAuthError("invalid_request", "grant_type is required");
if (!client.grantTypes().contains(grantType)) throw new OAuthError("unauthorized_client", "Not registered for " + grantType);
return json(res, 200, switch (grantType) {
case "authorization_code" -> exchange(req, client, form);
case "refresh_token" -> refresh(req, client, form);
case "client_credentials" -> {
String resource = resource(form.get("resource"), req);
if (resource == null) throw new OAuthError("invalid_target", "Unknown resource");
yield issue(req, client, client.id(), Map.of(), granted(form.get("scope")), resource, null);
}
default -> throw new OAuthError("unsupported_grant_type", "Unsupported grant_type");
});
}
private String exchange(Request req, OAuthClient client, Map<String, String> form) {
OAuthGrant code = use(form.get("code"), OAuthGrant.Kind.CODE, client);
if (code.redirectUri() != null && !code.redirectUri().equals(form.get("redirect_uri"))) throw new OAuthError("invalid_grant", "redirect_uri does not match");
String verifier = form.get("code_verifier");
if (verifier == null || !MessageDigest.isEqual(s256(verifier).getBytes(StandardCharsets.US_ASCII), code.challenge().getBytes(StandardCharsets.US_ASCII))) {
throw new OAuthError("invalid_grant", "code_verifier does not match");
}
if (form.get("resource") != null && !form.get("resource").equals(code.resource())) throw new OAuthError("invalid_target", "Not the resource authorized");
return issue(req, client, code.subject(), code.claims(), code.scope(), code.resource(), code.family());
}
/** Rotates: the token used is spent, and its successor carries the same authorization — or less of it. */
private String refresh(Request req, OAuthClient client, Map<String, String> form) {
OAuthGrant refresh = use(form.get("refresh_token"), OAuthGrant.Kind.REFRESH, client);
String scope = form.get("scope") == null ? refresh.scope() : form.get("scope");
if (!covers(refresh.scope(), scope)) throw new OAuthError("invalid_scope", "More than was authorized");
if (form.get("resource") != null && !form.get("resource").equals(refresh.resource())) throw new OAuthError("invalid_target", "Not the resource authorized");
return issue(req, client, refresh.subject(), refresh.claims(), scope, refresh.resource(), refresh.family());
}
/** A code or refresh token spent now. One already spent revokes everything issued from the same authorization. */
private OAuthGrant use(String value, OAuthGrant.Kind kind, OAuthClient client) {
OAuthGrant grant = value == null ? null : store.use(s256(value));
if (grant == null || grant.kind() != kind || !grant.clientId().equals(client.id())) throw new OAuthError("invalid_grant", "Unknown " + kind.name().toLowerCase());
if (grant.usedAt() != null) {
store.revoke(grant.family());
throw new OAuthError("invalid_grant", "Already used: every token from this authorization is revoked");
}
if (grant.expiresAt().isBefore(Instant.now())) throw new OAuthError("invalid_grant", "Expired");
return grant;
}
/** @param family {@code null} for {@code client_credentials}, which gets no refresh token */
private String issue(Request req, OAuthClient client, String subject, Map<String, Object> extra, String scope, String resource, String family) {
Instant now = Instant.now();
Map<String, Object> claims = new HashMap<>(extra);
claims.putAll(Map.of("iss", issuer(req), "sub", subject, "aud", resource, "client_id", client.id(), "jti", random(16),
"iat", Date.from(now), "exp", Date.from(now.plus(accessTokenLifetime))));
if (!scope.isEmpty()) claims.put("scope", scope);
Map<String, Object> response = new LinkedHashMap<>();
response.put("access_token", keys.sign(claims));
response.put("token_type", "Bearer");
response.put("expires_in", accessTokenLifetime.toSeconds());
if (!scope.isEmpty()) response.put("scope", scope);
if (family != null && client.grantTypes().contains("refresh_token")) {
String refresh = random(32);
store.save(new OAuthGrant(s256(refresh), OAuthGrant.Kind.REFRESH, family, client.id(), subject, extra, scope, resource,
null, null, now.plus(refreshTokenLifetime), null));
response.put("refresh_token", refresh);
}
return JSONObjectUtils.toJSONString(response);
}
// -- Revocation and registration ------------------------------------------
/** RFC 7009: a refresh token takes its whole authorization with it. An access token simply expires; an unknown token is no error. */
private Object revoke(Request req, Response res) {
Map<String, String> form = form(req);
OAuthClient client = client(req, form);
OAuthGrant grant = form.get("token") == null ? null : store.find(s256(form.get("token")));
if (grant != null && !grant.clientId().equals(client.id())) throw new OAuthError("unauthorized_client", "Not this client's token");
if (grant != null) store.revoke(grant.family());
res.status(200);
return null;
}
private Object registerSelf(Request req, Response res) {
Map<String, Object> metadata;
try {
metadata = JSONObjectUtils.parse(new String(req.body().bytes(), StandardCharsets.UTF_8));
} catch (java.text.ParseException malformed) {
throw new OAuthError("invalid_client_metadata", "Not a JSON object");
}
Registration registration = register(metadata, false);
Map<String, Object> response = new LinkedHashMap<>(registration.client().metadata());
response.put("client_id", registration.client().id());
response.put("client_id_issued_at", Instant.now().getEpochSecond());
if (registration.secret() != null) {
response.put("client_secret", registration.secret());
response.put("client_secret_expires_at", 0);
}
return json(res, 201, JSONObjectUtils.toJSONString(response));
}
// -- Metadata ---------------------------------------------------------------
/** RFC 8414, plus what the MCP authorization spec asks a server to say about client ID metadata documents. */
private String describe(String issuer) {
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("issuer", issuer);
metadata.put("authorization_endpoint", issuer + AUTHORIZE);
metadata.put("token_endpoint", issuer + TOKEN);
if (registration) metadata.put("registration_endpoint", issuer + REGISTER);
metadata.put("revocation_endpoint", issuer + REVOKE);
metadata.put("jwks_uri", issuer + JWKS);
if (!scopes.isEmpty()) metadata.put("scopes_supported", List.copyOf(scopes));
metadata.put("response_types_supported", List.of("code"));
metadata.put("response_modes_supported", List.of("query"));
metadata.put("grant_types_supported", List.of("authorization_code", "refresh_token", "client_credentials"));
metadata.put("code_challenge_methods_supported", List.of("S256"));
metadata.put("token_endpoint_auth_methods_supported", Clients.AUTH_METHODS);
metadata.put("revocation_endpoint_auth_methods_supported", Clients.AUTH_METHODS);
metadata.put("authorization_response_iss_parameter_supported", true);
metadata.put("client_id_metadata_document_supported", true);
return JSONObjectUtils.toJSONString(metadata);
}
// -- Helpers ----------------------------------------------------------------
/** RFC 6749 §5.2: every failure is a JSON error with its code; nothing here is ever cached. */
private static SimpleHandler.FunctionalHandler endpoint(SimpleHandler.FunctionalHandler handler) {
return (req, res) -> {
res.header("Cache-Control", "no-store");
try {
return handler.handle(req, res);
} catch (OAuthError invalid) {
if (invalid.status == 401) res.header("WWW-Authenticate", "Basic realm=\"oauth\"");
return json(res, invalid.status, JSONObjectUtils.toJSONString(Map.of("error", invalid.error, "error_description", invalid.getMessage())));
}
};
}
/**
* The client making a token or revocation request, authenticated as it registered: a secret in the
* {@code Authorization} header, a secret in the form, or — a public client — its id alone.
*/
private OAuthClient client(Request req, Map<String, String> form) {
String header = req.header("Authorization");
String id = form.get("client_id");
String secret = form.get("client_secret");
String method = secret != null ? "client_secret_post" : "none";
if (header != null && header.startsWith("Basic ")) {
String[] pair = new String(Base64.getDecoder().decode(header.substring(6)), StandardCharsets.UTF_8).split(":", 2);
if (pair.length != 2) throw new OAuthError(401, "invalid_client", "Malformed Basic credentials");
id = URLDecoder.decode(pair[0], StandardCharsets.UTF_8);
secret = URLDecoder.decode(pair[1], StandardCharsets.UTF_8);
method = "client_secret_basic";
}
OAuthClient client = clients.find(id);
boolean authenticated = client != null && client.authMethod().equals(method)
&& (method.equals("none") || MessageDigest.isEqual(s256(secret).getBytes(StandardCharsets.US_ASCII), client.secretHash().getBytes(StandardCharsets.US_ASCII)));
if (!authenticated) throw new OAuthError(401, "invalid_client", "Client authentication failed");
return client;
}
/** RFC 8707: one of this application's resources, or the first when the request names none. */
private String resource(String requested, Request req) {
String origin = issuer(req);
if (requested == null) return origin + resources.getFirst();
return resources.stream().map(path -> origin + path).filter(requested::equals).findFirst().orElse(null);
}
/** What was asked for, less what this server does not grant. */
private String granted(String requested) {
if (requested == null) return "";
return Arrays.stream(requested.split(" ")).filter(scopes::contains).distinct().collect(Collectors.joining(" "));
}
private static boolean covers(String granted, String requested) {
return List.of(granted.split(" ")).containsAll(List.of(requested.split(" ")));
}
private String issuer(Request req) {
return security.origin(req);
}
private static Object json(Response res, int status, String body) {
res.status(status).type(ContentType.JSON);
return body;
}
private static Map<String, String> form(Request req) {
Map<String, String> fields = new HashMap<>();
for (String pair : new String(req.body().bytes(), StandardCharsets.UTF_8).split("&")) {
int eq = pair.indexOf('=');
if (eq > 0) fields.putIfAbsent(URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8), URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8));
}
return fields;
}
private static String query(Request req) {
ByteView query = req.getRequestLine().getQuery();
byte[] raw = new byte[query == null ? 0 : query.length()];
for (int i = 0; i < raw.length; i++) raw[i] = query.byteAt(i);
return new String(raw, StandardCharsets.UTF_8);
}
/**
* PKCE's {@code S256}, and how codes, refresh tokens and client secrets are stored: they are 256 random
* bits, so a slow KDF would protect nothing.
*/
private static String s256(String verifier) {
try {
return BASE64URL.encodeToString(MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII)));
} catch (java.security.NoSuchAlgorithmException impossible) {
throw new IllegalStateException(impossible);
}
}
private static String random(int bytes) {
byte[] value = new byte[bytes];
RANDOM.nextBytes(value);
return BASE64URL.encodeToString(value);
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,30 @@
package dev.relism.flash.ext.security.oauthserver;
/** Where the authorization server keeps its clients, grants and consents. {@link InMemoryOAuthStore} unless one that survives a restart is configured. */
public interface OAuthStore {
/** The client registered under {@code id}, or {@code null}. */
OAuthClient client(String id);
void save(OAuthClient client);
void save(OAuthGrant grant);
/** The grant stored under {@code hash}, or {@code null}. */
OAuthGrant find(String hash);
/**
* Marks the grant used and returns it as it was before: a non-null {@link OAuthGrant#usedAt()} means it
* had been used already. {@code null} for an unknown hash. Atomic — two concurrent uses must not both
* see it unused.
*/
OAuthGrant use(String hash);
/** Every grant of {@code family} stops working. */
void revoke(String family);
/** The scope {@code subject} granted {@code clientId} at its last consent, or {@code null} for none. */
String consent(String subject, String clientId);
void consent(String subject, String clientId, String scope);
}
@@ -0,0 +1,14 @@
package dev.relism.flash.ext.security.oauthserver;
import java.util.Map;
/**
* Whom an authorization is for, as the application names them: {@code id} becomes the access token's
* {@code sub}, {@code claims} travel in every token issued from it. Standard claims win over these.
*/
public record OAuthSubject(String id, Map<String, Object> claims) {
public OAuthSubject {
claims = Map.copyOf(claims);
}
}
@@ -0,0 +1,78 @@
package dev.relism.flash.ext.security.oauthserver;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.ECDSASigner;
import com.nimbusds.jose.jwk.Curve;
import com.nimbusds.jose.jwk.ECKey;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.jwk.gen.ECKeyGenerator;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* The server's ES256 keys: the first signs, all verify, so a key is rotated by putting its successor first
* and dropping it once the tokens it signed have expired. Access tokens are RFC 9068 JWTs, {@code typ} {@code at+jwt}.
*/
final class SigningKeys {
private final ECKey signing;
private final String publicJwks;
private final DefaultJWTProcessor<SecurityContext> verifier = new DefaultJWTProcessor<>();
SigningKeys(String jwkSet) {
try {
JWKSet keys = JWKSet.parse(jwkSet);
signing = keys.getKeys().getFirst().toECKey();
if (!signing.isPrivate() || !Curve.P_256.equals(signing.getCurve())) throw new IllegalArgumentException("The first key must be a private P-256 key");
publicJwks = keys.toPublicJWKSet().toString();
verifier.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt")));
verifier.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.ES256, new ImmutableJWKSet<>(keys.toPublicJWKSet())));
verifier.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(null, Set.of("iss", "sub", "aud", "exp", "client_id")));
} catch (java.text.ParseException | ClassCastException invalid) {
throw new IllegalArgumentException("Not a JWK set with a private EC key first", invalid);
}
}
/** A JWK set holding one new private P-256 key — what {@link OAuthServerExtension#signingKeys(String)} takes. */
static String generate() {
try {
return new JWKSet(new ECKeyGenerator(Curve.P_256).keyUse(KeyUse.SIGNATURE).keyID(UUID.randomUUID().toString()).generate()).toString(false);
} catch (com.nimbusds.jose.JOSEException impossible) {
throw new IllegalStateException(impossible);
}
}
String sign(Map<String, Object> claims) {
try {
JWTClaimsSet.Builder set = new JWTClaimsSet.Builder();
claims.forEach(set::claim);
SignedJWT jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.ES256).type(new JOSEObjectType("at+jwt")).keyID(signing.getKeyID()).build(), set.build());
jwt.sign(new ECDSASigner(signing));
return jwt.serialize();
} catch (com.nimbusds.jose.JOSEException impossible) {
throw new IllegalStateException(impossible);
}
}
/** The token's claims, once its type, signature, expiry and required claims check out. */
Map<String, Object> verify(SignedJWT token) throws Exception {
return verifier.process(token, null).getClaims();
}
String publicJwks() {
return publicJwks;
}
}
@@ -0,0 +1,265 @@
package dev.relism.flash.ext.security.oauthserver;
import com.nimbusds.jose.util.JSONObjectUtils;
import com.sun.net.httpserver.HttpServer;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.ext.cache.caffeine.CaffeineCacheExtension;
import dev.relism.flash.ext.mcp.McpConfig;
import dev.relism.flash.ext.mcp.McpExtension;
import dev.relism.flash.ext.security.Principal;
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.test.OAuthTestClient;
import dev.relism.flash.ext.security.test.TestSecurity;
import dev.relism.flash.testing.FlashResponse;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OAuthServerExtensionTest {
static final Principal ALICE = () -> "alice";
static final SecurityExtension security = new SecurityExtension().loginPage("/login");
static final OAuthServerExtension server = new OAuthServerExtension("/mcp", "/api").scopes("read", "write").allowLocalClients()
.subjects(identity -> new OAuthSubject(identity.principal().name(), Map.of("tenant", "acme")));
/** /api only takes this server's tokens; /mcp is an OAuth-protected MCP endpoint trusting nothing else. */
@RegisterExtension
static final FlashTest app = FlashTest.of(flash -> flash
.install(security).install(new CaffeineCacheExtension()).install(server).install(new TestSecurity())
.install(new McpExtension(McpConfig.builder("test").toolsPackage("dev.relism.flash.ext.security.oauthserver.tools").mechanisms(server).build()))
.get("/api/me", (req, res) -> {
OAuthPrincipal principal = SecurityIdentity.current().principal(OAuthPrincipal.class);
return principal.name() + " " + principal.claim("tenant") + " " + principal.claim("scope");
}, security.enforce(SecurityPolicy.AUTHENTICATED, (req, res) -> { throw HttpException.unauthorized(); }, List.of(server))));
String origin() {
return "http://127.0.0.1:" + app.port();
}
@Test
void theMetadataDescribesACompliantServer() {
app.get("/.well-known/oauth-authorization-server").expectStatus(200)
.expectBodyContains("\"issuer\":\"" + origin() + "\"")
.expectBodyContains("\"code_challenge_methods_supported\":[\"S256\"]")
.expectBodyContains("\"authorization_response_iss_parameter_supported\":true")
.expectBodyContains("\"client_id_metadata_document_supported\":true")
.expectHeader("Access-Control-Allow-Origin", "*");
app.get("/oauth/jwks").expectStatus(200).expectBodyContains("\"crv\":\"P-256\"");
assertTrue(!app.get("/oauth/jwks").body().contains("\"d\""), "the private key never leaves");
}
/** What an MCP client does end to end: discovery from the resource, registration, consent, and a token for this endpoint only. */
@Test
void anMcpClientIsSentHereAndItsTokenWorksOnTheEndpoint() {
app.get("/.well-known/oauth-protected-resource/mcp").expectStatus(200)
.expectBody("{\"resource\":\"" + origin() + "/mcp\",\"authorization_servers\":[\"" + origin() + "\"]}");
OAuthTestClient.Tokens tokens = OAuthTestClient.register(app).authorize(ALICE);
app.request().with(tokens.bearer()).header("Accept", "application/json")
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"whoami\"}}")
.post("/mcp").expectStatus(200).expectBodyContains("alice");
// The default resource is /mcp: a token is good under the resource it was issued for and nowhere else.
app.request().with(tokens.bearer()).get("/api/me").expectStatus(401);
OAuthTestClient.Tokens api = OAuthTestClient.register(app).authorize(ALICE, Map.of("resource", origin() + "/api"));
app.request().with(api.bearer()).get("/api/me").expectStatus(200);
app.request().with(api.bearer()).header("Accept", "application/json")
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}").post("/mcp").expectStatus(401);
}
@Test
void consentIsAskedOnceAndTheSubjectsClaimsTravel() {
OAuthTestClient client = OAuthTestClient.register(app);
OAuthTestClient.Tokens tokens = client.authorize(ALICE, Map.of("scope", "read admin", "resource", origin() + "/api"));
assertEquals("read", tokens.scope(), "a scope this server does not grant is left out");
app.request().with(tokens.bearer()).get("/api/me").expectStatus(200).expectBody("alice acme read");
String authorize = "/oauth/authorize?response_type=code&client_id=" + client.clientId() + "&code_challenge=x&code_challenge_method=S256&scope=read";
assertTrue(app.request().with(TestSecurity.as(ALICE)).get(authorize).expectStatus(302).header("Location").startsWith(OAuthTestClient.REDIRECT_URI));
assertTrue(app.request().with(TestSecurity.as(ALICE)).get(authorize + "%20write").expectStatus(302).header("Location").startsWith("/consent?"),
"more than was consented to is asked again");
app.request().with(TestSecurity.as(ALICE)).get("/oauth/authorize/request?" + authorize.substring(authorize.indexOf('?') + 1))
.expectStatus(200).expectBodyContains("\"client_name\":\"Test client\"");
}
@Test
void aBrowserWithoutASessionSignsInFirst() {
app.request().header("Accept", "text/html").get("/oauth/authorize?client_id=x").expectStatus(302)
.expectHeader("Location", "/login?redirect=%2Foauth%2Fauthorize%3Fclient_id%3Dx");
}
/** OAuth 2.1 §4.1.2.1: an unknown client or redirect URI is never redirected to; anything else is reported there. */
@Test
void refusalsGoToTheRedirectUriOnlyOnceItIsTrusted() {
String client = OAuthTestClient.register(app).clientId();
app.request().with(TestSecurity.as(ALICE)).get("/oauth/authorize?response_type=code&client_id=nobody").expectStatus(400);
app.request().with(TestSecurity.as(ALICE)).get("/oauth/authorize?response_type=code&client_id=" + client + "&redirect_uri=https%3A%2F%2Fevil.example%2F")
.expectStatus(400);
String plain = app.request().with(TestSecurity.as(ALICE))
.get("/oauth/authorize?response_type=code&client_id=" + client + "&state=s&code_challenge=x&code_challenge_method=plain")
.expectStatus(302).header("Location");
assertEquals(OAuthTestClient.REDIRECT_URI + "?error=invalid_request&state=s&iss=" + encode(origin()), plain);
String elsewhere = app.request().with(TestSecurity.as(ALICE))
.get("/oauth/authorize?response_type=code&client_id=" + client + "&code_challenge=x&code_challenge_method=S256&resource=https%3A%2F%2Fother.example%2Fmcp")
.expectStatus(302).header("Location");
assertTrue(elsewhere.contains("error=invalid_target"), elsewhere);
// RFC 8252: a loopback redirect may come back on any port.
assertTrue(app.request().with(TestSecurity.as(ALICE))
.get("/oauth/authorize?response_type=code&client_id=" + client + "&redirect_uri=http%3A%2F%2F127.0.0.1%3A53123%2Fcallback&code_challenge=x&code_challenge_method=S256")
.expectStatus(302).header("Location").startsWith("/consent?"));
}
@Test
void aDeniedConsentAndAForeignFormAreRefused() {
String client = OAuthTestClient.register(app).clientId();
String query = "response_type=code&client_id=" + client + "&code_challenge=x&code_challenge_method=S256";
app.request().with(TestSecurity.as(ALICE)).header("Content-Type", "application/x-www-form-urlencoded").body(query + "&consent=deny")
.post("/oauth/authorize").expectStatus(303).expectHeader("Location", OAuthTestClient.REDIRECT_URI + "?error=access_denied&iss=" + encode(origin()));
app.request().with(TestSecurity.as(ALICE)).header("Origin", "https://evil.example").header("Content-Type", "application/x-www-form-urlencoded")
.body(query + "&consent=allow").post("/oauth/authorize").expectStatus(403);
}
/** A refresh token is spent by its use; spending it twice means it leaked, and the whole authorization goes. */
@Test
void refreshTokensRotateAndAReuseRevokesTheFamily() {
OAuthTestClient client = OAuthTestClient.register(app);
OAuthTestClient.Tokens first = client.authorize(ALICE);
OAuthTestClient.Tokens second = client.refresh(first.refreshToken());
assertNotEquals(first.refreshToken(), second.refreshToken());
token("grant_type=refresh_token&client_id=" + client.clientId() + "&refresh_token=" + first.refreshToken())
.expectStatus(400).expectBodyContains("invalid_grant");
token("grant_type=refresh_token&client_id=" + client.clientId() + "&refresh_token=" + second.refreshToken())
.expectStatus(400).expectBodyContains("invalid_grant");
}
@Test
void aRevokedRefreshTokenStopsWorking() {
OAuthTestClient client = OAuthTestClient.register(app);
OAuthTestClient.Tokens tokens = client.authorize(ALICE);
app.request().header("Content-Type", "application/x-www-form-urlencoded")
.body("client_id=" + client.clientId() + "&token=" + tokens.refreshToken()).post("/oauth/revoke").expectStatus(200);
token("grant_type=refresh_token&client_id=" + client.clientId() + "&refresh_token=" + tokens.refreshToken()).expectStatus(400);
}
@Test
void aClientRegisteringItselfIsHeldToTheRules() {
register("{\"redirect_uris\":[\"http://evil.example/cb\"]}").expectStatus(400).expectBodyContains("invalid_redirect_uri");
register("{\"redirect_uris\":[\"https://app.example/cb#x\"]}").expectStatus(400).expectBodyContains("invalid_redirect_uri");
register("{\"grant_types\":[\"client_credentials\"],\"token_endpoint_auth_method\":\"client_secret_basic\"}")
.expectStatus(400).expectBodyContains("invalid_client_metadata");
register("{\"redirect_uris\":[\"https://app.example/cb\"]}").expectStatus(201)
.expectBodyContains("\"token_endpoint_auth_method\":\"client_secret_basic\"").expectBodyContains("\"client_secret\"");
}
/** A machine the application registered itself: its secret in Basic, its own id as the subject, no refresh token. */
@Test
void aConfidentialClientGetsATokenForItself() throws Exception {
OAuthServerExtension.Registration machine = server.register(Map.of("client_name", "Sync job", "grant_types", List.of("client_credentials"),
"token_endpoint_auth_method", "client_secret_basic"));
String basic = java.util.Base64.getEncoder().encodeToString((machine.client().id() + ":" + machine.secret()).getBytes(StandardCharsets.UTF_8));
Map<String, Object> response = JSONObjectUtils.parse(app.request().header("Authorization", "Basic " + basic)
.header("Content-Type", "application/x-www-form-urlencoded").body("grant_type=client_credentials&scope=write&resource=" + encode(origin() + "/api"))
.post("/oauth/token").expectStatus(200).expectHeader("Cache-Control", "no-store").body());
assertNull(response.get("refresh_token"));
app.request().header("Authorization", "Bearer " + response.get("access_token")).get("/api/me").expectStatus(200)
.expectBody(machine.client().id() + " null write");
String wrong = java.util.Base64.getEncoder().encodeToString((machine.client().id() + ":nope").getBytes(StandardCharsets.UTF_8));
app.request().header("Authorization", "Basic " + wrong).header("Content-Type", "application/x-www-form-urlencoded")
.body("grant_type=client_credentials").post("/oauth/token").expectStatus(401).expectBodyContains("invalid_client");
}
/** A code is good once, with its own verifier; a replay revokes what the first exchange issued. */
@Test
void aCodeNeedsItsVerifierAndCannotBeReplayed() {
OAuthTestClient client = OAuthTestClient.register(app);
client.authorize(ALICE);
String authorize = "/oauth/authorize?response_type=code&client_id=" + client.clientId() + "&code_challenge=" + s256("right") + "&code_challenge_method=S256";
String location = app.request().with(TestSecurity.as(ALICE)).get(authorize).expectStatus(302).header("Location");
String code = location.replaceAll(".*code=([^&]+).*", "$1");
String exchange = "grant_type=authorization_code&client_id=" + client.clientId() + "&code=" + code + "&code_verifier=";
token(exchange + "wrong").expectStatus(400).expectBodyContains("invalid_grant");
String code2 = app.request().with(TestSecurity.as(ALICE)).get(authorize).expectStatus(302).header("Location").replaceAll(".*code=([^&]+).*", "$1");
String issued = token("grant_type=authorization_code&client_id=" + client.clientId() + "&code=" + code2 + "&code_verifier=right").expectStatus(200).body();
String refresh = issued.replaceAll(".*\"refresh_token\":\"([^\"]+)\".*", "$1");
token("grant_type=authorization_code&client_id=" + client.clientId() + "&code=" + code2 + "&code_verifier=right").expectStatus(400);
token("grant_type=refresh_token&client_id=" + client.clientId() + "&refresh_token=" + refresh).expectStatus(400);
}
@Test
void aForgedTokenIsInvalidAndOtherBearersAreNotThisServers() {
OAuthTestClient.Tokens tokens = OAuthTestClient.register(app).authorize(ALICE, Map.of("resource", origin() + "/api"));
String forged = tokens.accessToken().substring(0, tokens.accessToken().length() - 4) + "AAAA";
app.request().header("Authorization", "Bearer " + forged).get("/api/me").expectStatus(401);
app.request().header("Authorization", "Bearer gk_not.a-jwt").get("/api/me").expectStatus(401);
}
/** MCP's preferred registration: the client is the https URL of its own metadata document. */
@Test
void aClientMetadataDocumentIsAClient() throws Exception {
HttpServer host = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
String url = "http://127.0.0.1:" + host.getAddress().getPort() + "/client.json";
String liar = "http://127.0.0.1:" + host.getAddress().getPort() + "/liar.json";
serve(host, "/client.json", "{\"client_id\":\"" + url + "\",\"client_name\":\"Documented\",\"redirect_uris\":[\"" + OAuthTestClient.REDIRECT_URI + "\"]}");
serve(host, "/liar.json", "{\"client_id\":\"https://someone.else/client.json\",\"redirect_uris\":[\"" + OAuthTestClient.REDIRECT_URI + "\"]}");
host.start();
try {
String query = "response_type=code&code_challenge=x&code_challenge_method=S256&client_id=";
app.request().with(TestSecurity.as(ALICE)).get("/oauth/authorize/request?" + query + encode(url)).expectStatus(200)
.expectBodyContains("\"client_name\":\"Documented\"");
app.request().with(TestSecurity.as(ALICE)).get("/oauth/authorize?" + query + encode(liar)).expectStatus(400);
// A document that fails is remembered as failing: naming it again fetches nothing.
app.request().with(TestSecurity.as(ALICE)).get("/oauth/authorize?" + query + encode(liar)).expectStatus(400);
assertEquals(1, fetched.get(liar));
} finally {
host.stop(0);
}
}
private static FlashResponse token(String form) {
return app.request().header("Content-Type", "application/x-www-form-urlencoded").body(form).post("/oauth/token");
}
private static FlashResponse register(String json) {
return app.request().header("Content-Type", "application/json").body(json).post("/oauth/register");
}
static final Map<String, Integer> fetched = new java.util.concurrent.ConcurrentHashMap<>();
private static void serve(HttpServer host, String path, String body) {
host.createContext(path, exchange -> {
fetched.merge("http://127.0.0.1:" + host.getAddress().getPort() + path, 1, Integer::sum);
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, bytes.length);
try (var out = exchange.getResponseBody()) {
out.write(bytes);
}
});
}
private static String s256(String verifier) {
try {
return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(
java.security.MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII)));
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
private static String encode(String value) {
return java.net.URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,16 @@
package dev.relism.flash.ext.security.oauthserver.tools;
import dev.relism.flash.ext.mcp.McpTool;
import dev.relism.flash.ext.mcp.TextContent;
import dev.relism.flash.ext.mcp.Tool;
import dev.relism.flash.ext.mcp.ToolArguments;
import dev.relism.flash.ext.mcp.ToolResponse;
import dev.relism.flash.ext.security.SecurityIdentity;
@Tool(name = "whoami", description = "The caller's name")
public class WhoAmITool extends McpTool {
@Override
public ToolResponse call(ToolArguments args) {
return ToolResponse.success(new TextContent(SecurityIdentity.current().principal().name()));
}
}
@@ -15,9 +15,13 @@ Discovery runs at boot, so an unreachable provider fails the start rather than t
## Bearer tokens ## Bearer tokens
`Authorization: Bearer <jwt>` is matched to its provider by `iss`, then verified against that `Authorization: Bearer <jwt>` is matched to its provider by `iss`, then verified against that
provider's keys (RS/PS/ES algorithms, `typ` `JWT` or `at+jwt`, `iss`, `sub`, `exp`). One parse, one map provider's keys (RS/PS/ES algorithms, `iss`, `sub`, `exp`). One parse, one map lookup, however many
lookup, however many providers are configured. A token from an unconfigured issuer is left to other providers are configured. A token from an unconfigured issuer is left to other mechanisms; a token from
mechanisms; a token from a configured one that fails verification is `401 invalid_token`. a configured one that fails verification is `401 invalid_token`.
Only an access token is a bearer credential, and RFC 9068 is how one says so: its `typ` is `at+jwt`.
An ID token — `typ` `JWT` — is the client's proof of sign-in and never passes. Keycloak emits `at+jwt`
once the client's `access.token.header.type.rfc9068` attribute is `true`.
## Sign-in ## Sign-in
@@ -30,8 +34,8 @@ The PKCE verifier, nonce and state travel in a short-lived `HttpOnly` cookie sco
so sign-in needs no server-side state and works across instances. The session's principal is renewed so sign-in needs no server-side state and works across instances. The session's principal is renewed
with the refresh token when its access token expires; `POST /auth/logout` ends it and continues to the with the refresh token when its access token expires; `POST /auth/logout` ends it and continues to the
provider's `end_session_endpoint`. The client authenticates with `client_secret_basic`. Register provider's `end_session_endpoint`. The client authenticates with `client_secret_basic`. Register
`{origin}/auth/oidc/{id}/callback` as a redirect URI and `{origin}/` as a post-logout redirect URI; `{origin}/auth/oidc/{id}/callback` as a redirect URI and `{origin}/` as a post-logout redirect URI,
behind a proxy, forward `X-Forwarded-Proto` and `X-Forwarded-Host`. where `{origin}` is `SecurityExtension.origin(...)`.
Each provider is listed at `/auth/methods` (`"kind":"redirect"`) and published to OpenAPI as an Each provider is listed at `/auth/methods` (`"kind":"redirect"`) and published to OpenAPI as an
`openIdConnect` scheme. `openIdConnect` scheme.
@@ -45,7 +49,7 @@ oidc.unregister("acme");
``` ```
Discovery runs inside `register`, which refuses a provider — or any endpoint its discovery names — that Discovery runs inside `register`, which refuses a provider — or any endpoint its discovery names — that
is not https on a public address: registration makes the server fetch URLs someone else chose. is not https on a public address (`PublicUrl`): registration makes the server fetch URLs someone else chose.
`allowLocalProviders()` lifts that for development. Registered providers serve bearer tokens and `allowLocalProviders()` lifts that for development. Registered providers serve bearer tokens and
`/auth/oidc/{id}/login` immediately, but are not listed at `/auth/methods` or in OpenAPI: which provider `/auth/oidc/{id}/login` immediately, but are not listed at `/auth/methods` or in OpenAPI: which provider
a given user signs in with is the application's decision — typically an `AuthenticationEntryPoint` that a given user signs in with is the application's decision — typically an `AuthenticationEntryPoint` that
@@ -19,8 +19,10 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.Instant; import java.time.Instant;
import java.util.Arrays;
import java.util.Base64; import java.util.Base64;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@@ -84,7 +86,7 @@ public final class OidcExtension implements FlashExtension, AuthenticationMechan
* Checks a provider before trusting it: discovery, the client ID and secret, and the redirect URI a * Checks a provider before trusting it: discovery, the client ID and secret, and the redirect URI a
* sign-in from {@code origin} will send. Registers nothing, and holds {@link #register}'s address rules. * sign-in from {@code origin} will send. Registers nothing, and holds {@link #register}'s address rules.
* *
* @param origin where users will sign in from, as {@link Request#origin()} gives it * @param origin where users will sign in from, as {@link SecurityExtension#origin(Request)} gives it
* @throws IllegalArgumentException naming what the provider refused * @throws IllegalArgumentException naming what the provider refused
* @throws IllegalStateException the provider could not be reached * @throws IllegalStateException the provider could not be reached
*/ */
@@ -111,12 +113,17 @@ public final class OidcExtension implements FlashExtension, AuthenticationMechan
ctx.onReady(() -> { ctx.onReady(() -> {
security = ctx.require(SecurityExtension.class).mechanism(this).refresher(OidcPrincipal.class, this::refresh); security = ctx.require(SecurityExtension.class).mechanism(this).refresher(OidcPrincipal.class, this::refresh);
for (OidcProvider config : configured) { for (OidcProvider config : configured) {
security.scheme(SecurityScheme.openIdConnect(config.id(), byId.get(config.id()).issuer)) security.loginMethod(new LoginMethod(config.id(), config.name(), "/auth/oidc/" + config.id() + "/login", LoginMethod.Kind.REDIRECT));
.loginMethod(new LoginMethod(config.id(), config.name(), "/auth/oidc/" + config.id() + "/login", LoginMethod.Kind.REDIRECT));
} }
}); });
} }
/** One per configured provider; one registered at runtime is the application's to name, never listed. */
@Override
public List<SecurityScheme> schemes() {
return Arrays.stream(configured).map(config -> SecurityScheme.openIdConnect(config.id(), byId.get(config.id()).issuer)).toList();
}
// -- Bearer access tokens ------------------------------------------------- // -- Bearer access tokens -------------------------------------------------
@Override @Override
@@ -150,9 +157,9 @@ public final class OidcExtension implements FlashExtension, AuthenticationMechan
String nonce = random(16); String nonce = random(16);
res.header("Set-Cookie", FLOW + "=" + state + "." + verifier + "." + nonce + "." res.header("Set-Cookie", FLOW + "=" + state + "." + verifier + "." + nonce + "."
+ BASE64URL.encodeToString(localPath(req.query("redirect")).getBytes(StandardCharsets.UTF_8)) + BASE64URL.encodeToString(localPath(req.query("redirect")).getBytes(StandardCharsets.UTF_8))
+ "; Path=/auth/oidc; Max-Age=600; HttpOnly; SameSite=Lax" + (req.origin().startsWith("https") ? "; Secure" : "")); + "; Path=/auth/oidc; Max-Age=600; HttpOnly; SameSite=Lax" + (security.origin(req).startsWith("https") ? "; Secure" : ""));
String challenge = BASE64URL.encodeToString(MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII))); String challenge = BASE64URL.encodeToString(MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII)));
res.redirect(provider.authorizeUrl(callbackUri(req.origin(), provider.config.id()), state, nonce, challenge)); res.redirect(provider.authorizeUrl(callbackUri(security.origin(req), provider.config.id()), state, nonce, challenge));
return null; return null;
} }
@@ -169,13 +176,13 @@ public final class OidcExtension implements FlashExtension, AuthenticationMechan
if (req.query("error") != null) throw HttpException.badRequest("The identity provider refused sign-in: " + req.query("error")); if (req.query("error") != null) throw HttpException.badRequest("The identity provider refused sign-in: " + req.query("error"));
try { try {
Map<String, Object> tokens = provider.token("grant_type=authorization_code&code=" + Provider.encode(req.query("code")) Map<String, Object> tokens = provider.token("grant_type=authorization_code&code=" + Provider.encode(req.query("code"))
+ "&redirect_uri=" + Provider.encode(callbackUri(req.origin(), provider.config.id())) + "&code_verifier=" + flow[1]); + "&redirect_uri=" + Provider.encode(callbackUri(security.origin(req), provider.config.id())) + "&code_verifier=" + flow[1]);
String idToken = (String) tokens.get("id_token"); String idToken = (String) tokens.get("id_token");
Map<String, Object> claims = sessionClaims(provider.verifyIdToken(idToken), tokens); Map<String, Object> claims = sessionClaims(provider.verifyIdToken(idToken), tokens);
if (!flow[2].equals(claims.get("nonce"))) throw new IllegalStateException("nonce mismatch"); if (!flow[2].equals(claims.get("nonce"))) throw new IllegalStateException("nonce mismatch");
String logout = provider.endSessionEndpoint == null ? null : provider.endSessionEndpoint String logout = provider.endSessionEndpoint == null ? null : provider.endSessionEndpoint
+ (provider.endSessionEndpoint.indexOf('?') < 0 ? '?' : '&') + "client_id=" + Provider.encode(provider.config.clientId()) + (provider.endSessionEndpoint.indexOf('?') < 0 ? '?' : '&') + "client_id=" + Provider.encode(provider.config.clientId())
+ "&id_token_hint=" + idToken + "&post_logout_redirect_uri=" + Provider.encode(req.origin() + "/"); + "&id_token_hint=" + idToken + "&post_logout_redirect_uri=" + Provider.encode(security.origin(req) + "/");
OidcPrincipal principal = new OidcPrincipal(provider.config.id(), (String) claims.get("sub"), claims, OidcPrincipal principal = new OidcPrincipal(provider.config.id(), (String) claims.get("sub"), claims,
(String) tokens.get("access_token"), (String) tokens.get("refresh_token"), logout); (String) tokens.get("access_token"), (String) tokens.get("refresh_token"), logout);
security.signIn(req, res, principal, expiry(tokens)); security.signIn(req, res, principal, expiry(tokens));
@@ -12,9 +12,8 @@ import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT; import com.nimbusds.jwt.SignedJWT;
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier; import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
import com.nimbusds.jwt.proc.DefaultJWTProcessor; import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import dev.relism.flash.ext.security.PublicUrl;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.URI; import java.net.URI;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.net.http.HttpClient; import java.net.http.HttpClient;
@@ -48,17 +47,17 @@ final class Provider {
Provider(OidcProvider config, boolean guarded) { Provider(OidcProvider config, boolean guarded) {
this.config = config; this.config = config;
try { try {
if (guarded) requirePublic(config.discoveryUrl()); if (guarded) PublicUrl.require(config.discoveryUrl());
Map<String, Object> discovery = JSONObjectUtils.parse(HTTP.send(HttpRequest.newBuilder(URI.create(config.discoveryUrl())).build(), Map<String, Object> discovery = JSONObjectUtils.parse(HTTP.send(HttpRequest.newBuilder(URI.create(config.discoveryUrl())).build(),
HttpResponse.BodyHandlers.ofString()).body()); HttpResponse.BodyHandlers.ofString()).body());
if (guarded) for (String key : new String[]{"authorization_endpoint", "token_endpoint", "jwks_uri"}) requirePublic((String) discovery.get(key)); if (guarded) for (String key : new String[]{"authorization_endpoint", "token_endpoint", "jwks_uri"}) PublicUrl.require((String) discovery.get(key));
issuer = (String) discovery.get("issuer"); issuer = (String) discovery.get("issuer");
authorizationEndpoint = (String) discovery.get("authorization_endpoint"); authorizationEndpoint = (String) discovery.get("authorization_endpoint");
tokenEndpoint = (String) discovery.get("token_endpoint"); tokenEndpoint = (String) discovery.get("token_endpoint");
endSessionEndpoint = (String) discovery.get("end_session_endpoint"); endSessionEndpoint = (String) discovery.get("end_session_endpoint");
JWKSource<SecurityContext> keys = JWKSourceBuilder.create(URI.create((String) discovery.get("jwks_uri")).toURL()).retrying(true).build(); JWKSource<SecurityContext> keys = JWKSourceBuilder.create(URI.create((String) discovery.get("jwks_uri")).toURL()).retrying(true).build();
accessTokens = processor(keys, null, new JOSEObjectType("at+jwt")); accessTokens = processor(keys, null, new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt"), new JOSEObjectType("application/at+jwt")));
idTokens = processor(keys, config.clientId(), null); idTokens = processor(keys, config.clientId(), DefaultJOSEObjectTypeVerifier.JWT);
} catch (Exception e) { } catch (Exception e) {
if (e instanceof IllegalArgumentException rejected) throw rejected; if (e instanceof IllegalArgumentException rejected) throw rejected;
throw new IllegalStateException("OIDC discovery failed for " + config.discoveryUrl(), e); throw new IllegalStateException("OIDC discovery failed for " + config.discoveryUrl(), e);
@@ -136,29 +135,17 @@ final class Provider {
} }
} }
/** ponytail: resolved once here and again by the HTTP client, so DNS rebinding between the two is not covered. */
private static void requirePublic(String url) throws Exception {
URI uri = URI.create(url);
if (!"https".equals(uri.getScheme())) throw new IllegalArgumentException("Not https: " + url);
for (InetAddress address : InetAddress.getAllByName(uri.getHost())) {
if (address.isLoopbackAddress() || address.isSiteLocalAddress() || address.isLinkLocalAddress() || address.isAnyLocalAddress()
|| address.isMulticastAddress() || (address instanceof Inet6Address && (address.getAddress()[0] & 0xfe) == 0xfc)) {
throw new IllegalArgumentException("Not a public address: " + url);
}
}
}
static String encode(String value) { static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8); return URLEncoder.encode(value, StandardCharsets.UTF_8);
} }
private DefaultJWTProcessor<SecurityContext> processor(JWKSource<SecurityContext> keys, String audience, JOSEObjectType type) { private DefaultJWTProcessor<SecurityContext> processor(JWKSource<SecurityContext> keys, String audience, DefaultJOSEObjectTypeVerifier<SecurityContext> type) {
Set<JWSAlgorithm> algorithms = new HashSet<>(JWSAlgorithm.Family.RSA); Set<JWSAlgorithm> algorithms = new HashSet<>(JWSAlgorithm.Family.RSA);
algorithms.addAll(JWSAlgorithm.Family.EC); algorithms.addAll(JWSAlgorithm.Family.EC);
DefaultJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>(); DefaultJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
processor.setJWSKeySelector(new JWSVerificationKeySelector<>(algorithms, keys)); processor.setJWSKeySelector(new JWSVerificationKeySelector<>(algorithms, keys));
processor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(audience, new JWTClaimsSet.Builder().issuer(issuer).build(), Set.of("sub", "exp"))); processor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(audience, new JWTClaimsSet.Builder().issuer(issuer).build(), Set.of("sub", "exp")));
if (type != null) processor.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(JOSEObjectType.JWT, type, null)); processor.setJWSTypeVerifier(type);
return processor; return processor;
} }
} }
@@ -64,6 +64,13 @@ class OidcExtensionTest {
.expectStatus(401).expectHeader("WWW-Authenticate", "Bearer error=\"invalid_token\""); .expectStatus(401).expectHeader("WWW-Authenticate", "Bearer error=\"invalid_token\"");
} }
/** RFC 9068: an access token says so in its typ. An ID token is the client's, never a bearer credential. */
@Test
void anIdTokenIsNotAnAccessToken() {
app.request().header("Authorization", "Bearer " + provider.idToken("bob", Map.of("aud", "app"))).get("/me")
.expectStatus(401).expectHeader("WWW-Authenticate", "Bearer error=\"invalid_token\"");
}
/** Not rejected as invalid: an issuer this application does not know is some other mechanism's business. */ /** Not rejected as invalid: an issuer this application does not know is some other mechanism's business. */
@Test @Test
void aTokenFromAnUnknownIssuerIsNotThisMechanisms() { void aTokenFromAnUnknownIssuerIsNotThisMechanisms() {
@@ -9,6 +9,15 @@ app.request().with(TestSecurity.as(() -> "alice")).get("/me"); /
app.request().with(TestSecurity.as(new OidcPrincipal(...))).get("/projects"); // a mechanism's own type app.request().with(TestSecurity.as(new OidcPrincipal(...))).get("/projects"); // a mechanism's own type
``` ```
`OAuthTestClient` drives an application's own authorization server
([`flash-ext-security-oauth-server`](../../flash-ext-security-oauth-server/docs/README.md)) the way an MCP
client does — discovery, registration, PKCE, consent, exchange — signing in as any principal:
```java
OAuthTestClient.Tokens tokens = OAuthTestClient.register(app).authorize(() -> "alice");
app.request().with(tokens.bearer()).post("/mcp");
```
`TestSecurity.as(principal)` hands the principal to the application by reference — `FlashTest` `TestSecurity.as(principal)` hands the principal to the application by reference — `FlashTest`
serves it in the same JVM — so tests exercise the real `UserResolver`, `RoleResolver` and policies serves it in the same JVM — so tests exercise the real `UserResolver`, `RoleResolver` and policies
with no identity provider running. Tests of the flows themselves use the mechanism's own kit: with no identity provider running. Tests of the flows themselves use the mechanism's own kit:
@@ -1,5 +1,6 @@
package dev.relism.flash.ext.security.test; package dev.relism.flash.ext.security.test;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader; import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner; import com.nimbusds.jose.crypto.RSASSASigner;
@@ -76,7 +77,7 @@ public final class FakeOidcProvider implements AutoCloseable {
Map<String, Object> id = new HashMap<>(claims); Map<String, Object> id = new HashMap<>(claims);
id.put("aud", "app"); id.put("aud", "app");
if (nonce != null) id.put("nonce", nonce); if (nonce != null) id.put("nonce", nonce);
send(ex, 200, JSONObjectUtils.toJSONString(Map.of("access_token", token(subject, claims), "id_token", token(subject, id), send(ex, 200, JSONObjectUtils.toJSONString(Map.of("access_token", token(subject, claims), "id_token", idToken(subject, id),
"refresh_token", UUID.randomUUID().toString(), "expires_in", expiresIn, "scope", "openid email"))); "refresh_token", UUID.randomUUID().toString(), "expires_in", expiresIn, "scope", "openid email")));
}); });
server.start(); server.start();
@@ -106,13 +107,22 @@ public final class FakeOidcProvider implements AutoCloseable {
return this; return this;
} }
/** A signed token for {@code subject}, valid five minutes, with {@code claims} added. */ /** A signed access token for {@code subject}, valid five minutes, with {@code claims} added — {@code typ} {@code at+jwt} (RFC 9068). */
public String token(String subject, Map<String, Object> claims) { public String token(String subject, Map<String, Object> claims) {
return sign(new JOSEObjectType("at+jwt"), subject, claims);
}
/** A signed ID token for {@code subject}, which a resource server must never take for an access token. */
public String idToken(String subject, Map<String, Object> claims) {
return sign(JOSEObjectType.JWT, subject, claims);
}
private String sign(JOSEObjectType type, String subject, Map<String, Object> claims) {
try { try {
JWTClaimsSet.Builder set = new JWTClaimsSet.Builder().issuer(issuer).subject(subject) JWTClaimsSet.Builder set = new JWTClaimsSet.Builder().issuer(issuer).subject(subject)
.issueTime(new Date()).expirationTime(new Date(System.currentTimeMillis() + 300_000)); .issueTime(new Date()).expirationTime(new Date(System.currentTimeMillis() + 300_000));
claims.forEach(set::claim); claims.forEach(set::claim);
SignedJWT jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.getKeyID()).build(), set.build()); SignedJWT jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).type(type).keyID(key.getKeyID()).build(), set.build());
jwt.sign(new RSASSASigner(key)); jwt.sign(new RSASSASigner(key));
return jwt.serialize(); return jwt.serialize();
} catch (Exception e) { } catch (Exception e) {
@@ -0,0 +1,138 @@
package dev.relism.flash.ext.security.test;
import com.nimbusds.jose.util.JSONObjectUtils;
import dev.relism.flash.ext.security.Principal;
import dev.relism.flash.testing.FlashRequest;
import dev.relism.flash.testing.FlashResponse;
import dev.relism.flash.testing.FlashTest;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/**
* An OAuth 2.1 client of the application under test, driving its own authorization server the way an MCP
* client does: discovery (RFC 8414), registration (RFC 7591), authorization code with PKCE, consent, and
* the token exchange. Its user signs in as any principal, through {@link TestSecurity}.
*
* <pre>{@code
* OAuthTestClient.Tokens tokens = OAuthTestClient.register(app).authorize(() -> "alice");
* app.request().with(tokens.bearer()).post("/mcp");
* }</pre>
*/
public final class OAuthTestClient {
/** Where the authorization server sends the code: never contacted, only read off the redirect. */
public static final String REDIRECT_URI = "http://127.0.0.1/callback";
private static final Base64.Encoder BASE64URL = Base64.getUrlEncoder().withoutPadding();
private static final SecureRandom RANDOM = new SecureRandom();
private final FlashTest app;
private final Map<String, Object> metadata;
private final String clientId;
/** What the exchange returned. */
public record Tokens(String accessToken, String refreshToken, String scope) {
public Consumer<FlashRequest> bearer() {
return request -> request.header("Authorization", "Bearer " + accessToken);
}
}
private OAuthTestClient(FlashTest app, Map<String, Object> metadata, String clientId) {
this.app = app;
this.metadata = metadata;
this.clientId = clientId;
}
/** A public client, registered at the server's registration endpoint, allowed refresh tokens. */
public static OAuthTestClient register(FlashTest app) {
Map<String, Object> metadata = json(app.get("/.well-known/oauth-authorization-server").expectStatus(200));
Map<String, Object> client = json(app.request().header("Content-Type", "application/json")
.body(JSONObjectUtils.toJSONString(Map.of("client_name", "Test client", "redirect_uris", List.of(REDIRECT_URI),
"grant_types", List.of("authorization_code", "refresh_token"), "token_endpoint_auth_method", "none")))
.post(path(metadata, "registration_endpoint")).expectStatus(201));
return new OAuthTestClient(app, metadata, (String) client.get("client_id"));
}
public String clientId() {
return clientId;
}
/** Signs in as {@code user}, allows this client if asked to, and exchanges the code: the application's default resource and no scope. */
public Tokens authorize(Principal user) {
return authorize(user, Map.of());
}
/** The same, with extra authorization request parameters — {@code scope}, {@code resource}. */
public Tokens authorize(Principal user, Map<String, String> parameters) {
byte[] secret = new byte[32];
RANDOM.nextBytes(secret);
String verifier = BASE64URL.encodeToString(secret);
StringBuilder query = new StringBuilder("response_type=code&client_id=" + encode(clientId) + "&redirect_uri=" + encode(REDIRECT_URI)
+ "&state=test&code_challenge=" + s256(verifier) + "&code_challenge_method=S256");
parameters.forEach((key, value) -> query.append('&').append(key).append('=').append(encode(value)));
String authorize = path(metadata, "authorization_endpoint");
String location = app.request().with(TestSecurity.as(user)).get(authorize + "?" + query).expectStatus(302).header("Location");
if (!location.startsWith(REDIRECT_URI)) {
location = app.request().with(TestSecurity.as(user)).header("Content-Type", "application/x-www-form-urlencoded")
.body(query + "&consent=allow").post(authorize).expectStatus(303).header("Location");
}
String code = parameter(location, "code");
if (code == null) throw new AssertionError("No code: " + location);
return tokens(json(app.request().header("Content-Type", "application/x-www-form-urlencoded")
.body("grant_type=authorization_code&client_id=" + encode(clientId) + "&code=" + encode(code)
+ "&redirect_uri=" + encode(REDIRECT_URI) + "&code_verifier=" + verifier)
.post(path(metadata, "token_endpoint")).expectStatus(200)));
}
/** Trades a refresh token for a new pair. */
public Tokens refresh(String refreshToken) {
return tokens(json(app.request().header("Content-Type", "application/x-www-form-urlencoded")
.body("grant_type=refresh_token&client_id=" + encode(clientId) + "&refresh_token=" + encode(refreshToken))
.post(path(metadata, "token_endpoint")).expectStatus(200)));
}
private static Tokens tokens(Map<String, Object> response) {
return new Tokens((String) response.get("access_token"), (String) response.get("refresh_token"), (String) response.get("scope"));
}
private static String path(Map<String, Object> metadata, String endpoint) {
return URI.create((String) metadata.get(endpoint)).getRawPath();
}
private static String parameter(String uri, String name) {
for (String pair : URI.create(uri).getRawQuery().split("&")) {
if (pair.startsWith(name + "=")) return java.net.URLDecoder.decode(pair.substring(name.length() + 1), StandardCharsets.UTF_8);
}
return null;
}
private static Map<String, Object> json(FlashResponse response) {
try {
return JSONObjectUtils.parse(response.body());
} catch (java.text.ParseException e) {
throw new AssertionError("Not JSON: " + response.body(), e);
}
}
private static String s256(String verifier) {
try {
return BASE64URL.encodeToString(MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII)));
} catch (java.security.NoSuchAlgorithmException impossible) {
throw new IllegalStateException(impossible);
}
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
@@ -1,143 +0,0 @@
# flash-ext-validation
Request validation for Flash. Standard `jakarta.validation` annotations, compiled once per type
into a flat check table, with zero allocation on the passing path.
## What it provides
| Component | Description |
|---|---|
| `Validation` | The service — `body(req, type)` parses and verifies, `validate(value)` verifies |
| `Validator` | One type's compiled constraints; reusable and thread-safe |
| `ValidationException` | 422 carrying every violation, not just the first |
## Dependency
```xml
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-validation</artifactId>
<version>${flash.version}</version>
</dependency>
```
## Quick start
```java
FlashApp.create(8080)
.install(new JacksonExtension())
.install(new ValidationExtension())
.scan("dev.example.api");
```
```java
public record CreateUser(
@NotBlank @Size(max = 80) String name,
@Email String email,
@Min(18) int age) {}
```
```java
@POST("/api/users")
public final class CreateUserHandler extends RequestHandler {
private Validation validation;
private UserService users;
@Override protected void onInit() {
validation = require(Validation.class);
users = require(UserService.class);
}
@Override public Object handle(Request req, Response res) throws Exception {
CreateUser dto = validation.body(req, CreateUser.class);
return res.status(201).body(users.create(dto));
}
}
```
There is nothing to configure. Constraints come from the annotations already on your types, and
failures reach the client as `422` on their own — see [Error responses](#error-responses).
## Supported constraints
`@NotNull` · `@NotBlank` · `@NotEmpty` · `@Size` · `@Min` · `@Max` · `@Email` · `@Pattern`
Jakarta null semantics are honoured exactly: **only `@NotNull` rejects null**. Every other
constraint passes a null value, so `@Email String email` means "if present, must look like an
email" — combine with `@NotNull` when it is mandatory.
`@Size` applies to `CharSequence`, `Collection`, `Map` and object arrays. `@Min`/`@Max` apply to
primitive integrals and to `Number` subtypes.
An unsupported annotation is ignored rather than rejected, so adding one is never a boot failure.
## Records and classes
Constraints are read from **declared fields**. A constraint on a record component propagates to
its backing field, so records and plain classes take the same path with no extra configuration:
```java
record CreateUser(@NotBlank String name) {} // works
class CreateUser { @NotBlank private String name; } // works
```
## Error responses
`ValidationException` extends Flash's `HttpException` with status 422, so the default exception
handler renders it. Nothing is registered, and your own `onException` still wins if you set one.
```json
{"error":"name must not be blank; age must be at least 18","status":422}
```
Malformed JSON is a different failure and comes back as `400` from the codec, before any
constraint runs.
## OpenAPI
Install `flash-ext-openapi` alongside and the generated schema mirrors the same annotations —
`minLength`, `maxLength`, `minItems`, `minimum`, `maximum`, `pattern`, `format: email`, and
`required`. Declared once, enforced and published.
Nothing registers this. `flash-ext-openapi` carries `jakarta.validation-api` as an optional
dependency and detects it at boot; without it the bridge class is never loaded.
An explicit `@Schema` always wins — the bridge only fills keys nobody set.
## Without Jackson
`flash-ext-jackson` is optional. Without it `validate(value)` still works on values you construct
or parse yourself; only `body(req, type)` needs a codec and says so if one is missing.
## Performance
The passing path is the one that runs on every request, so it allocates nothing:
- **Compiled once per type.** Constraints resolve to an opcode plus operands at first use, cached
in a `ClassValue` — stored beside the class by the JVM, so no map lookup, no lock, and the entry
is collected with the class rather than pinning it.
- **No reflection per request.** Fields are read through `MethodHandle`s adapted to an exact
signature: `(Object)Object` for references, `(Object)long` for primitive integrals. `invokeExact`
neither boxes nor builds the argument array that `Field.get` and `Method.invoke` allocate.
- **No megamorphic dispatch.** Checks are a flat array walked by a `tableswitch` on an opcode, not
a class hierarchy behind a virtual call.
- **No copies.** `@Size` reads a length the object already knows; `@Email` scans with `indexOf`
rather than a regex, because `Pattern.matcher` allocates a matcher and two int arrays per call.
- **Messages pre-rendered at compile time**, so even a failure formats nothing.
The list, the violations and the exception exist only once something fails.
`@Pattern` is the deliberate exception: its regex is compiled once, but `matcher()` allocates per
call. It is marked in the source. Prefer `@Size`/`@Email` on hot routes, or validate the shape
structurally.
## Pre-warming
Compilation happens on a type's first request. To pay it at boot instead:
```java
ctx.onReady(() -> ctx.require(Validation.class).forType(CreateUser.class));
```
Worth it only for a route that must not pay first-call cost. Everything else warms itself.
@@ -1,69 +0,0 @@
package dev.relism.flash.ext.validation;
import dev.relism.flash.ext.jackson.Json;
import dev.relism.flash.models.Request;
/**
* The validation service. Resolve it with {@code require(Validation.class)}.
*
* <pre>{@code
* CreateUser dto = validation.body(req, CreateUser.class); // parse + verify
* }</pre>
*
* <p>Constraints are compiled the first time a type is seen and cached in a {@link ClassValue},
* which the JVM stores beside the class itself — no map lookup, no lock, and the entry is
* collected with the class rather than pinning it. Every later request walks the compiled table.
*/
public final class Validation {
private final ClassValue<Validator> validators = new ClassValue<>() {
@Override protected Validator computeValue(Class<?> type) {
return Validator.compile(type);
}
};
/** Null when flash-ext-jackson is absent; only {@link #body} needs it. */
private Json json;
Validation() {}
/** Called once at boot by {@link ValidationExtension}, after the service graph resolves. */
void bindCodec(Json json) {
this.json = json;
}
/**
* Deserializes the request body into {@code type} and verifies its constraints.
*
* @throws dev.relism.flash.exceptions.HttpException 400 if the body is not valid JSON
* @throws ValidationException 422 if it parses but violates a constraint
*/
public <T> T body(Request request, Class<T> type) throws Exception {
if (json == null)
throw new IllegalStateException(
"Validation.body(...) needs a JSON codec — install JacksonExtension, "
+ "or parse yourself and call validate(...)");
T value = json.body(request, type);
validators.get(type).verify(value);
return value;
}
/**
* Verifies an already-constructed value.
*
* @return {@code value}, so it can be used inline
* @throws ValidationException 422 on the first type's worth of failures
*/
public <T> T validate(T value) {
validators.get(value.getClass()).verify(value);
return value;
}
/**
* The compiled constraints of {@code type}. Useful to pre-warm a hot DTO at boot, or to
* check whether a type declares constraints at all.
*/
public Validator forType(Class<?> type) {
return validators.get(type);
}
}
@@ -1,37 +0,0 @@
package dev.relism.flash.ext.validation;
import dev.relism.flash.ext.jackson.Json;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
/**
* Installs request validation.
*
* <pre>{@code
* FlashApp.create(8080)
* .install(new JacksonExtension())
* .install(new ValidationExtension())
* .scan("dev.example.api");
* }</pre>
*
* <p>No configuration. There is nothing to tune: constraints come from the annotations already on
* your types, failures come back as 422 through Flash's default exception handler because
* {@link ValidationException} carries its own status, and the JSON codec is picked up if
* {@code flash-ext-jackson} is installed.
*
* <p>Install order does not matter — Flash resolves the whole service graph before any handler
* initialises.
*/
public final class ValidationExtension implements FlashExtension {
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.supply(Validation.class, Validation::new);
// Resolved here rather than declared as a dependency: jackson is optional, and a declared
// dependency would make it mandatory. By the time ready callbacks run the graph is
// complete, so find() sees whatever was actually installed.
ctx.onReady(() -> ctx.require(Validation.class).bindCodec(ctx.find(Json.class).orElse(null)));
}
}
@@ -0,0 +1,51 @@
<?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-vite-maven-plugin</artifactId>
<packaging>maven-plugin</packaging>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-vite</artifactId>
</dependency>
<dependency>
<groupId>org.apache.maven</groupId>
<artifactId>maven-plugin-api</artifactId>
<version>3.9.9</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.maven.plugin-tools</groupId>
<artifactId>maven-plugin-annotations</artifactId>
<version>3.15.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-plugin-plugin</artifactId>
<version>3.15.1</version>
<configuration>
<goalPrefix>flash-vite</goalPrefix>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,108 @@
package dev.relism.flash.ext.vite.maven;
import dev.relism.flash.ext.vite.PackageManager;
import dev.relism.flash.ext.vite.ViteExtension;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.stream.Stream;
import java.util.zip.Deflater;
import java.util.zip.GZIPOutputStream;
/**
* Builds the Vite project and packages the result where {@code ViteExtension} serves it from in
* production, with a maximally gzipped {@code .gz} beside each text file, so the server compresses
* nothing. Bound to {@code prepare-package}: tests never need Node, a packaged jar always has its
* frontend.
*/
@Mojo(name = "build", defaultPhase = LifecyclePhase.PREPARE_PACKAGE, threadSafe = true)
public final class BuildMojo extends AbstractMojo {
/** Formats that are not compressed already; images and woff gain nothing from gzip. */
private static final Set<String> COMPRESSIBLE = Set.of("html", "js", "mjs", "css", "json", "map", "webmanifest", "txt", "xml", "svg", "ttf", "otf", "wasm");
/** The Vite project. */
@Parameter(property = "flash.vite.root", defaultValue = "${project.basedir}/web")
File root;
@Parameter(defaultValue = "${project.build.outputDirectory}", readonly = true, required = true)
File classes;
/** Leaves the frontend out, for a build that only needs the backend. */
@Parameter(property = "flash.vite.skip", defaultValue = "false")
boolean skip;
@Override
public void execute() throws MojoExecutionException {
if (skip) {
getLog().info("Skipping the frontend build");
return;
}
Path project = root.toPath();
if (!Files.isRegularFile(project.resolve("package.json"))) {
throw new MojoExecutionException("No package.json in " + project + ": set <root> to the Vite project.");
}
PackageManager.Found packages = PackageManager.of(project);
run(project, packages.install());
run(project, packages.run("build"));
Path dist = project.resolve("dist");
if (!Files.isRegularFile(dist.resolve("index.html"))) {
throw new MojoExecutionException("The build left no dist/index.html in " + project + ".");
}
Path target = classes.toPath().resolve(ViteExtension.CLASSPATH);
try {
if (Files.exists(target)) {
try (Stream<Path> old = Files.walk(target)) {
for (Path path : old.sorted(Comparator.reverseOrder()).toList()) Files.delete(path);
}
}
Files.createDirectories(target.getParent());
try (Stream<Path> built = Files.walk(dist)) {
for (Path from : built.toList()) {
Path to = target.resolve(dist.relativize(from).toString());
Files.copy(from, to);
if (Files.isRegularFile(to)) gzip(to);
}
}
} catch (IOException e) {
throw new MojoExecutionException("Cannot copy " + dist + " to " + target, e);
}
getLog().info("Packaged " + dist + " as " + ViteExtension.CLASSPATH + "/");
}
/** Only worth it from 1 KB, and only kept when it is smaller. */
private static void gzip(Path file) throws IOException {
String name = file.getFileName().toString();
if (!COMPRESSIBLE.contains(name.substring(name.lastIndexOf('.') + 1).toLowerCase()) || Files.size(file) < 1024) return;
byte[] raw = Files.readAllBytes(file);
ByteArrayOutputStream out = new ByteArrayOutputStream(raw.length / 2);
try (GZIPOutputStream zip = new GZIPOutputStream(out) {{ def.setLevel(Deflater.BEST_COMPRESSION); }}) {
zip.write(raw);
}
if (out.size() < raw.length) Files.write(file.resolveSibling(name + ".gz"), out.toByteArray());
}
private static void run(Path project, List<String> command) throws MojoExecutionException {
int exit;
try {
exit = new ProcessBuilder(command).directory(project.toFile()).inheritIO().start().waitFor();
} catch (IOException e) {
throw new MojoExecutionException("Cannot run " + command.getFirst() + ": is Node installed and " + command.getFirst() + " on the PATH?", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new MojoExecutionException("Interrupted running " + String.join(" ", command), e);
}
if (exit != 0) throw new MojoExecutionException(String.join(" ", command) + " failed with " + exit + " (its output is above).");
}
}
@@ -0,0 +1,63 @@
package dev.relism.flash.ext.vite.maven;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/** A package.json whose build script stands in for Vite's. Needs npm on the PATH. */
class BuildMojoTest {
@TempDir
Path dir;
@Test
void theBuildLandsWhereTheExtensionServesItFrom() throws Exception {
assumeTrue(onPath(), "npm is not on the PATH");
Path web = Files.createDirectories(dir.resolve("web"));
Files.writeString(web.resolve("package.json"), """
{"name":"t","private":true,"scripts":{"build":"node -e \\"const f=require('fs');f.mkdirSync('dist/assets',{recursive:true});f.writeFileSync('dist/index.html','built');f.writeFileSync('dist/assets/a-12345678.js','1');f.writeFileSync('dist/assets/b-12345678.js','x'.repeat(4096))\\""}}
""");
Path classes = dir.resolve("classes");
Files.createDirectories(classes.resolve("flash-vite"));
Files.writeString(classes.resolve("flash-vite/stale.js"), "from the last build");
mojo(web, classes, false).execute();
assertEquals("built", Files.readString(classes.resolve("flash-vite/index.html")));
assertEquals("1", Files.readString(classes.resolve("flash-vite/assets/a-12345678.js")));
assertFalse(Files.exists(classes.resolve("flash-vite/stale.js")));
// Big enough to gzip, and it shrinks; the 1-byte file is left alone.
assertTrue(Files.size(classes.resolve("flash-vite/assets/b-12345678.js.gz")) < 4096);
assertFalse(Files.exists(classes.resolve("flash-vite/assets/a-12345678.js.gz")));
}
@Test
void skipBuildsNothing() throws Exception {
mojo(dir.resolve("nowhere"), dir.resolve("classes"), true).execute();
assertFalse(Files.exists(dir.resolve("classes")));
}
private static BuildMojo mojo(Path root, Path classes, boolean skip) {
BuildMojo mojo = new BuildMojo();
mojo.root = root.toFile();
mojo.classes = classes.toFile();
mojo.skip = skip;
return mojo;
}
private static boolean onPath() {
try {
return new ProcessBuilder("npm", "--version").start().waitFor() == 0;
} catch (IOException | InterruptedException e) {
return false;
}
}
}
@@ -0,0 +1,147 @@
# flash-ext-vite
A Vite frontend for a Flash app, in two artifacts:
- **`flash-ext-vite`**, the extension. In DEV it runs Vite's dev server beside the app. Anywhere
else it serves the built frontend from the classpath as a single-page app.
- **`flash-ext-vite-maven-plugin`**, the build. It builds the frontend during `mvn package` and puts
the result where the extension reads it, so the jar runs on its own.
## Quick start
A Vite project in `web/` with the template's `dev` and `build` scripts, and:
```java
FlashApp.create(8080)
.install(new ViteExtension())
.get("/api/hello", (req, res) -> "hi")
.start();
```
```xml
<plugin>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-vite-maven-plugin</artifactId>
<version>${flash.version}</version>
<executions>
<execution>
<goals><goal>build</goal></goals>
</execution>
</executions>
</plugin>
```
That is the whole setup. `FLASH_ENV=dev` gives you Vite with hot reload, and `mvn package` gives you
a jar that serves the frontend.
## Conventions
These are fixed on purpose: each one is the Vite default, or the thing that keeps the two artifacts
in agreement.
| | |
|---|---|
| Project | a Vite project whose `package.json` has a `dev` and a `build` script |
| Build output | `dist/` inside the project (Vite's `build.outDir` default) |
| Inside the jar | `flash-vite/` (`ViteExtension.CLASSPATH`) |
| Content-hashed files | everything under `assets/` (Vite's `build.assetsDir` default) |
| Package manager | the one whose lockfile is nearest at or above the project: `pnpm-lock.yaml`, `yarn.lock`, `bun.lock`, `package-lock.json`. With none, npm. A project inside a workspace therefore uses the workspace's lockfile. |
| Node | on the `PATH`, with the package manager. Nothing is downloaded. |
## What you can override
| Extension | Default | |
|---|---|---|
| `root(Path)` | `web` | The Vite project, relative to the working directory. DEV only. |
| `devPort(int)` | `5173` | Vite's port in DEV. |
| `basePath(String)` | `/` | Where the frontend is served. Vite's `base` must match it. |
| `navigationOnly(boolean)` | `true` | Only browser navigations fall back to `index.html` (see below). `false` gives every `GET` that matches no file the page, as nginx's `try_files` does, which also turns API 404s into the page. Rarely wanted. |
| Plugin parameter | Default | |
|---|---|---|
| `root` / `-Dflash.vite.root` | `${project.basedir}/web` | The Vite project. |
| `skip` / `-Dflash.vite.skip` | `false` | Leaves the frontend out, for a backend-only build. |
There is nothing else to set. If a convention above does not fit, that is a change to this module,
not a configuration option.
## DEV
`Flash.DEV` (`FLASH_ENV=dev` or `-Dflash.env=dev`) decides the mode:
1. The dependencies are installed with the lockfile pinned (`pnpm install --frozen-lockfile`,
`npm ci`, and so on). This is skipped when `node_modules` was already installed from that same
lockfile: its hash is kept in `node_modules/.flash-vite`, so deleting `node_modules` resets it.
2. The extension runs `<pm> run dev --host 127.0.0.1 --port <devPort> --strictPort` and waits up to
30 s for the port to answer. A port already in use, a script that exits, or a timeout fails the
boot with the reason. Vite's output is logged at INFO, prefixed `[vite]`.
3. While the app runs, a change to the lockfile (`pnpm add …`) reinstalls and restarts Vite. Vite
handles changes to its own config itself.
4. When the app stops (`ctx.onClose`), Vite and its whole process tree stop with it.
The extension registers no routes in DEV. The browser opens Vite's port, and Vite forwards the
backend's paths to Flash, so `vite.config` needs a `server.proxy` for them:
```ts
server: {
proxy: { '^/(api|auth)(/|$)': 'http://localhost:8080' },
},
```
## Production
Anywhere `Flash.DEV` is false, the build is read once at boot from `flash-vite/` on the classpath,
whether that is a directory (`target/classes`) or a jar. A missing build fails the boot and names
the plugin. Compression already happened in the build, so boot only reads files and hashes the few
that revalidate: about 50 ms for a 500-file app. Everything a response carries is prepared at
boot, headers included, so serving allocates nothing.
`GET` and `HEAD` on `basePath/**` answer as follows. Backend routes still win, because Flash
prefers specific routes over the wildcard.
| Request | Answer |
|---|---|
| `basePath` itself (`/`) | `index.html`, whatever the client accepts |
| a built file under `assets/` | `Cache-Control: public, max-age=31536000, immutable`, no `ETag` (it is never asked for again) |
| any other built file (`index.html`, `favicon.svg`, …) | `Cache-Control: no-cache`, revalidated by `ETag` |
| no such file, and the request is a navigation | `index.html`, so the client-side router takes it |
| no such file otherwise (a `fetch` to `/api/typo`, a missing script) | the app's own `404` |
| `If-None-Match` with the current `ETag` | `304` with `ETag` and `Cache-Control`, no body |
| `HEAD` | the `GET` headers, `Content-Length` included, no body |
A navigation is what a browser sends when a person opens a URL: `Sec-Fetch-Mode: navigate`, which
every current browser sets for exactly this purpose, or an `Accept` that names `text/html`, which
covers older browsers, crawlers and `curl -H 'Accept: text/html'`. The check reads the header bytes
in place and allocates nothing. This is stricter than Vite's own dev-server fallback, which also
takes `Accept: */*` and so gives `fetch('/api/typo')` the page. It is the same rule service
workers use for their navigation fallback. With `navigationOnly(false)` the check is skipped.
A `.gz` beside a file is its gzipped form: the plugin writes one for every text file of 1 KB and
more, and it is sent to clients whose `Accept-Encoding` allows gzip (`gzip;q=0` does not), with
`Vary: Accept-Encoding`.
Known limits:
- A browser navigating straight to an API path that does not exist gets the app, which shows its
own not-found screen. Only an HTML navigation falls back, so API clients always get the 404.
- Every file is held in memory, gzip included. That suits an app's own frontend. Large media belongs
on a CDN or behind its own route.
## The plugin
The `build` goal is bound to `prepare-package`, so `mvn test` never needs Node, and `mvn package`,
`verify` and `install` always produce a jar with its frontend. It:
1. installs the dependencies with the lockfile pinned;
2. runs `<pm> run build` in the project;
3. replaces `target/classes/flash-vite/` with the project's `dist/`;
4. writes a gzip `.gz` at maximum compression beside every html, js, css, json, map, svg, txt,
xml, webmanifest, font and wasm file of 1 KB and more, and keeps it only when it is smaller.
Images and woff are left alone, because they are compressed already.
Missing Node or package manager, a failing install or build, or a build that leaves no
`dist/index.html` fails the Maven build with the reason. The package manager's own output shows in
the Maven log.
A Docker image therefore needs one build stage with both a JDK and Node. The frontend does not need
its own stage.
@@ -10,25 +10,17 @@
<version>2.1.0-SNAPSHOT</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-web-bundler</artifactId> <artifactId>flash-ext-vite</artifactId>
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash</artifactId> <artifactId>flash</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId> <artifactId>slf4j-api</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId> <artifactId>junit-jupiter</artifactId>
@@ -0,0 +1,210 @@
package dev.relism.flash.ext.vite;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.models.PreEncodedHeader;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
import dev.relism.fpr.core.ByteView;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystem;
import java.nio.file.FileSystemAlreadyExistsException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.Map;
import java.util.stream.Stream;
/**
* The built frontend, read once from the classpath at boot and served from memory: every byte a
* response carries, headers included, is prepared here, so serving allocates nothing. Compression
* happened at build time: a {@code .gz} beside a file is its gzipped form.
*/
final class Assets {
private static final PreEncodedHeader IMMUTABLE = new PreEncodedHeader("Cache-Control", "public, max-age=31536000, immutable");
private static final PreEncodedHeader REVALIDATE = new PreEncodedHeader("Cache-Control", "no-cache");
private static final PreEncodedHeader VARY = new PreEncodedHeader("Vary", "Accept-Encoding");
private static final PreEncodedHeader GZIP = new PreEncodedHeader("Content-Encoding", "gzip");
private static final Map<String, String> TYPES = Map.ofEntries(
Map.entry("html", "text/html; charset=utf-8"),
Map.entry("js", "text/javascript; charset=utf-8"),
Map.entry("mjs", "text/javascript; charset=utf-8"),
Map.entry("css", "text/css; charset=utf-8"),
Map.entry("json", "application/json"),
Map.entry("map", "application/json"),
Map.entry("webmanifest", "application/manifest+json"),
Map.entry("txt", "text/plain; charset=utf-8"),
Map.entry("xml", "application/xml"),
Map.entry("svg", "image/svg+xml"),
Map.entry("png", "image/png"),
Map.entry("jpg", "image/jpeg"),
Map.entry("jpeg", "image/jpeg"),
Map.entry("gif", "image/gif"),
Map.entry("webp", "image/webp"),
Map.entry("avif", "image/avif"),
Map.entry("ico", "image/x-icon"),
Map.entry("woff", "font/woff"),
Map.entry("woff2", "font/woff2"),
Map.entry("ttf", "font/ttf"),
Map.entry("otf", "font/otf"),
Map.entry("wasm", "application/wasm"));
private static final byte[] HTML = ascii("text/html");
private static final byte[] GZIP_TOKEN = ascii("gzip");
private static final byte[] Q = ascii("q=");
record Asset(byte[] raw, byte[] gzip, byte[] type, PreEncodedHeader cache, PreEncodedHeader etag, byte[] tag) {}
final Map<String, Asset> byPath;
private final Asset index;
private final boolean navigationOnly;
/**
* Every file under {@link ViteExtension#CLASSPATH} on {@code loader}'s classpath, routed under
* {@code basePath}; {@code navigationOnly} as in {@link ViteExtension#navigationOnly}.
*/
Assets(ClassLoader loader, String basePath, boolean navigationOnly) {
this.navigationOnly = navigationOnly;
URL index = loader.getResource(ViteExtension.CLASSPATH + "/index.html");
if (index == null) {
throw new IllegalStateException("No built frontend on the classpath (" + ViteExtension.CLASSPATH + "/index.html): "
+ "package the application with flash-ext-vite-maven-plugin, or run it with FLASH_ENV=dev.");
}
String prefix = "/".equals(basePath) ? "" : basePath;
Map<String, Asset> assets = new HashMap<>();
try {
URI uri = index.toURI();
FileSystem opened = null;
if ("jar".equals(uri.getScheme())) {
try {
opened = FileSystems.newFileSystem(uri, Map.of());
} catch (FileSystemAlreadyExistsException alreadyOpen) {
// Path.of below resolves against the one already open.
}
}
Path root = Path.of(uri).getParent();
try (Stream<Path> files = Files.walk(root)) {
for (Path file : files.filter(Files::isRegularFile).toList()) {
String relative = root.relativize(file).toString().replace('\\', '/');
if (relative.endsWith(".gz")) continue;
Path gzip = file.resolveSibling(file.getFileName() + ".gz");
assets.put(prefix + "/" + relative, asset(relative, Files.readAllBytes(file),
Files.isRegularFile(gzip) ? Files.readAllBytes(gzip) : null));
}
} finally {
if (opened != null) opened.close();
}
} catch (IOException | URISyntaxException e) {
throw new IllegalStateException("Cannot read the built frontend at " + index, e);
}
// The root is the app itself, whatever the client accepts.
Asset root = assets.get(prefix + "/index.html");
assets.put(prefix + "/", root);
if (!prefix.isEmpty()) assets.put(prefix, root);
this.byPath = Map.copyOf(assets);
this.index = root;
}
/**
* The asset at the request's path. A path that is none gets {@code index.html} when a browser
* navigates to it, so the client-side router takes it; anything else, an API call or a missing
* script, gets the app's own 404. Headers are read as bytes, so nothing here allocates.
*/
void serve(Request req, Response res) {
Asset asset = byPath.get(req.path());
if (asset == null) {
if (navigationOnly && !navigation(req)) throw HttpException.notFound(req.path());
asset = index;
}
res.header(asset.cache);
if (asset.etag != null) {
res.header(asset.etag);
if (indexOf(req.headerView("If-None-Match"), asset.tag, 0) >= 0) {
res.status(HttpStatus.NOT_MODIFIED);
return;
}
}
res.type(asset.type);
if (asset.gzip != null) {
res.header(VARY);
if (acceptsGzip(req.headerView("Accept-Encoding"))) {
res.header(GZIP).body(asset.gzip);
return;
}
}
res.body(asset.raw);
}
/** What browsers send when a person opens a URL: {@code Sec-Fetch-Mode}, or {@code Accept} naming HTML for older clients. */
static boolean navigation(Request req) {
return req.headerEquals("Sec-Fetch-Mode", "navigate") || indexOf(req.headerView("Accept"), HTML, 0) >= 0;
}
/** Listed in {@code Accept-Encoding}, and not with a q of zero. */
static boolean acceptsGzip(ByteView accept) {
int at = indexOf(accept, GZIP_TOKEN, 0);
if (at < 0) return false;
int end = at;
while (end < accept.length() && accept.byteAt(end) != ',') end++;
int q = indexOf(accept, Q, at);
if (q < 0 || q > end) return true;
for (int i = q + 2; i < end; i++) {
byte c = accept.byteAt(i);
if (c >= '1' && c <= '9') return true;
if (c != '0' && c != '.') break;
}
return false;
}
/**
* Where lowercase {@code needle} starts in {@code view} at or after {@code from}, ASCII case
* ignored, or -1 (also for a missing header). Folding with {@code | 0x20} leaves digits, quotes
* and slashes as they are, which is all the needles here contain besides letters.
*/
static int indexOf(ByteView view, byte[] needle, int from) {
if (view == null) return -1;
outer:
for (int i = from, last = view.length() - needle.length; i <= last; i++) {
for (int j = 0; j < needle.length; j++) {
if ((view.byteAt(i + j) | 0x20) != needle[j]) continue outer;
}
return i;
}
return -1;
}
/**
* Vite puts every content-hashed file under {@code assets/}: those never change, so they are
* cached for good and never revalidated, and need no ETag. The rest revalidate by one.
*/
private static Asset asset(String path, byte[] raw, byte[] gzip) {
String extension = path.substring(path.lastIndexOf('.') + 1).toLowerCase();
byte[] type = ascii(TYPES.getOrDefault(extension, "application/octet-stream"));
if (path.startsWith("assets/")) return new Asset(raw, gzip, type, IMMUTABLE, null, null);
String tag = "\"" + HexFormat.of().formatHex(sha1(raw)) + "\"";
return new Asset(raw, gzip, type, REVALIDATE, new PreEncodedHeader("ETag", tag), ascii(tag));
}
private static byte[] ascii(String text) {
return text.getBytes(StandardCharsets.US_ASCII);
}
private static byte[] sha1(byte[] raw) {
try {
return MessageDigest.getInstance("SHA-1").digest(raw);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,173 @@
package dev.relism.flash.ext.vite;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* Vite's dev server as a child process of the app: dependencies installed whenever the lockfile
* differs from the one they were installed from, the server restarted when it changes under it,
* and the whole process tree gone when the app stops.
*/
final class DevServer implements AutoCloseable {
private static final Logger log = LoggerFactory.getLogger(DevServer.class);
private final Path root;
private final int port;
private final PackageManager.Found packages;
private final ScheduledExecutorService watch = Executors.newSingleThreadScheduledExecutor(
Thread.ofPlatform().daemon().name("flash-vite-watch").factory());
private Process process;
private FileTime lockfileTime;
DevServer(Path root, int port) {
if (!Files.isRegularFile(root.resolve("package.json"))) {
throw new IllegalStateException("No package.json in " + root.toAbsolutePath() + ": point ViteExtension.root(...) at the Vite project.");
}
this.root = root;
this.port = port;
this.packages = PackageManager.of(root);
lockfileTime = modified();
try {
install();
start();
} catch (RuntimeException failed) {
close();
throw failed;
}
if (packages.lockfile() != null) watch.scheduleWithFixedDelay(this::restartIfLockfileChanged, 2, 2, TimeUnit.SECONDS);
}
@Override
public synchronized void close() {
watch.shutdownNow();
stop();
}
private synchronized void restartIfLockfileChanged() {
FileTime now = modified();
if (now.equals(lockfileTime)) return;
lockfileTime = now;
log.info("{} changed, reinstalling and restarting Vite", packages.lockfile().getFileName());
try {
stop();
install();
start();
} catch (RuntimeException failed) {
log.error("Vite did not come back; fix the cause and touch the lockfile to retry", failed);
}
}
/** Skipped when {@code node_modules} was installed from this very lockfile. */
private void install() {
Path stamp = root.resolve("node_modules/.flash-vite");
String lockfile = packages.lockfile() == null ? "" : sha256(packages.lockfile());
try {
if (Files.isRegularFile(stamp) && Files.readString(stamp).equals(lockfile)) return;
Process install = spawn(packages.install());
if (install.waitFor() != 0) throw new IllegalStateException(String.join(" ", packages.install()) + " failed with " + install.exitValue());
Files.createDirectories(stamp.getParent());
Files.writeString(stamp, lockfile);
} catch (IOException e) {
throw new UncheckedIOException(e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted installing the frontend's dependencies", e);
}
}
private void start() {
if (answers()) throw new IllegalStateException("Port " + port + " is taken: stop what uses it or set ViteExtension.devPort(...).");
process = spawn(packages.run("dev", "--host", "127.0.0.1", "--port", String.valueOf(port), "--strictPort"));
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
while (!answers()) {
if (!process.isAlive()) throw new IllegalStateException("Vite exited with " + process.exitValue() + " before serving on port " + port + " (its output is logged above).");
if (System.nanoTime() > deadline) throw new IllegalStateException("Vite did not answer on port " + port + " within 30 seconds.");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted waiting for Vite", e);
}
}
log.info("Vite is serving http://127.0.0.1:{}", port);
}
/** npm, pnpm and friends are wrappers: their children have to go as well, gracefully first. */
private void stop() {
if (process == null) return;
List<ProcessHandle> tree = process.descendants().toList();
tree.forEach(ProcessHandle::destroy);
process.destroy();
try {
process.onExit().get(5, TimeUnit.SECONDS);
} catch (Exception gone) {
// Forced below.
}
tree.stream().filter(ProcessHandle::isAlive).forEach(ProcessHandle::destroyForcibly);
if (process.isAlive()) process.destroyForcibly();
process = null;
}
/** Starts {@code command} in the project, its output logged line by line. */
private Process spawn(List<String> command) {
Process started;
try {
started = new ProcessBuilder(command).directory(root.toFile()).redirectErrorStream(true).start();
} catch (IOException e) {
throw new IllegalStateException("Cannot run " + command.getFirst() + ": is it installed and on the PATH?", e);
}
Thread.ofVirtual().start(() -> {
try (BufferedReader out = new BufferedReader(new InputStreamReader(started.getInputStream(), StandardCharsets.UTF_8))) {
for (String line; (line = out.readLine()) != null; ) log.info("[vite] {}", line);
} catch (IOException closed) {
// The process ended.
}
});
return started;
}
private boolean answers() {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress("127.0.0.1", port), 200);
return true;
} catch (IOException e) {
return false;
}
}
private FileTime modified() {
try {
return packages.lockfile() == null ? FileTime.fromMillis(0) : Files.getLastModifiedTime(packages.lockfile());
} catch (IOException e) {
return FileTime.fromMillis(0);
}
}
private static String sha256(Path file) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file)));
} catch (IOException e) {
throw new UncheckedIOException(e);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,63 @@
package dev.relism.flash.ext.vite;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/** The package manager a frontend uses, told apart by its lockfile. Shared with the Maven plugin. */
public enum PackageManager {
PNPM("pnpm-lock.yaml", "install", "--frozen-lockfile"),
YARN("yarn.lock", "install", "--frozen-lockfile"),
BUN("bun.lock", "install", "--frozen-lockfile"),
NPM("package-lock.json", "ci");
private static final boolean WINDOWS = System.getProperty("os.name", "").startsWith("Windows");
private final String lockfile;
private final String[] install;
PackageManager(String lockfile, String... install) {
this.lockfile = lockfile;
this.install = install;
}
/** A package manager and the lockfile it was found by, {@code null} when there is none. */
public record Found(PackageManager manager, Path lockfile) {
/** Installs exactly what the lockfile pins; without one, npm resolves afresh. */
public List<String> install() {
if (lockfile == null) return List.of(manager.binary(), "install");
List<String> command = new ArrayList<>(List.of(manager.binary()));
command.addAll(List.of(manager.install));
return command;
}
/** Runs a {@code package.json} script, passing {@code args} through to it. */
public List<String> run(String script, String... args) {
List<String> command = new ArrayList<>(List.of(manager.binary(), "run", script));
if (manager == NPM && args.length > 0) command.add("--");
command.addAll(List.of(args));
return command;
}
}
/**
* The nearest lockfile at or above {@code root}, so a project inside a workspace finds the
* workspace's. None at all is npm without a lockfile.
*/
public static Found of(Path root) {
for (Path dir = root.toAbsolutePath().normalize(); dir != null; dir = dir.getParent()) {
for (PackageManager manager : values()) {
Path lockfile = dir.resolve(manager.lockfile);
if (Files.isRegularFile(lockfile)) return new Found(manager, lockfile);
}
}
return new Found(NPM, null);
}
private String binary() {
String name = name().toLowerCase();
return WINDOWS ? name + ".cmd" : name;
}
}
@@ -0,0 +1,73 @@
package dev.relism.flash.ext.vite;
import dev.relism.flash.Flash;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import java.nio.file.Path;
/**
* A Vite frontend for a Flash app. In DEV it runs Vite's dev server beside the app, which the
* browser talks to and which proxies the backend's paths to Flash. Otherwise it serves the build
* that {@code flash-ext-vite-maven-plugin} packaged into the jar, as a single-page app.
*/
public final class ViteExtension implements FlashExtension {
/** Where the build lives on the classpath: the Maven plugin puts it there, production reads it from there. */
public static final String CLASSPATH = "flash-vite";
private Path root = Path.of("web");
private int devPort = 5173;
private String basePath = "/";
private boolean navigationOnly = true;
/** The Vite project, {@code web} (relative to the working directory) by default. Read in DEV only. */
public ViteExtension root(Path root) {
this.root = root;
return this;
}
/** Vite's port in DEV, 5173 by default. */
public ViteExtension devPort(int devPort) {
this.devPort = devPort;
return this;
}
/** Where the frontend is served from, {@code /} by default; Vite's {@code base} has to match it. */
public ViteExtension basePath(String basePath) {
String trimmed = basePath.endsWith("/") ? basePath.substring(0, basePath.length() - 1) : basePath;
this.basePath = trimmed.isEmpty() ? "/" : trimmed.startsWith("/") ? trimmed : "/" + trimmed;
return this;
}
/**
* Whether only a browser navigation to a path that is no file gets {@code index.html}, {@code
* true} by default, so an API call to a missing route gets a 404 rather than the page. {@code
* false} serves the page for every such {@code GET}, as nginx's {@code try_files} would.
*/
public ViteExtension navigationOnly(boolean navigationOnly) {
this.navigationOnly = navigationOnly;
return this;
}
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
if (Flash.DEV) {
ctx.onClose(new DevServer(root, devPort)::close);
return;
}
Assets assets = new Assets(ViteExtension.class.getClassLoader(), basePath, navigationOnly);
String everything = "/".equals(basePath) ? "/**" : basePath + "/**";
ctx.onReady(() -> {
app.get(everything, (req, res) -> {
assets.serve(req, res);
return null;
});
app.head(everything, (req, res) -> {
assets.serve(req, res);
return null;
});
});
}
}
@@ -0,0 +1,74 @@
package dev.relism.flash.ext.vite;
import dev.relism.fpr.core.ByteView;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AssetsTest {
@TempDir
Path dir;
/** What production runs from: the build inside a jar, not a directory. */
@Test
void theBuildIsReadFromInsideAJar() throws IOException {
Path jar = dir.resolve("app.jar");
try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(jar))) {
for (String name : new String[]{"flash-vite/index.html", "flash-vite/assets/a-12345678.css"}) {
out.putNextEntry(new JarEntry(name));
out.write("x".getBytes());
}
}
try (URLClassLoader loader = new URLClassLoader(new URL[]{jar.toUri().toURL()}, null)) {
assertEquals(Set.of("/", "/index.html", "/assets/a-12345678.css"), new Assets(loader, "/", true).byPath.keySet());
}
}
@Test
void noBuildFailsTheBootNamingThePlugin() throws IOException {
try (URLClassLoader empty = new URLClassLoader(new URL[0], null)) {
assertTrue(assertThrows(IllegalStateException.class, () -> new Assets(empty, "/", true)).getMessage().contains("flash-ext-vite-maven-plugin"));
}
}
@Test
void gzipIsAcceptedUnlessRefused() {
assertTrue(Assets.acceptsGzip(view("gzip, deflate, br")));
assertTrue(Assets.acceptsGzip(view("br;q=1.0, GZIP;q=0.5")));
assertFalse(Assets.acceptsGzip(view("gzip;q=0, br")));
assertFalse(Assets.acceptsGzip(view("gzip;q=0.000")));
assertFalse(Assets.acceptsGzip(view("br")));
assertFalse(Assets.acceptsGzip(null));
}
@Test
void searchingIgnoresAsciiCaseAndFindsTheFirstMatch() {
byte[] html = "text/html".getBytes();
assertEquals(0, Assets.indexOf(view("Text/HTML,*/*"), html, 0));
assertEquals(12, Assets.indexOf(view("application/text/html"), html, 0));
assertEquals(-1, Assets.indexOf(view("text/htm"), html, 0));
assertEquals(-1, Assets.indexOf(null, html, 0));
}
private static ByteView view(String text) {
byte[] bytes = text.getBytes();
return new ByteView() {
@Override public int length() { return bytes.length; }
@Override public byte byteAt(int i) { return bytes[i]; }
};
}
}
@@ -0,0 +1,56 @@
package dev.relism.flash.ext.vite;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/** A real child process: a package.json whose dev script stands in for Vite. Needs npm on the PATH. */
class DevServerTest {
@TempDir
Path project;
@Test
void startsTheDevScriptWaitsForItAndTakesItDownOnClose() throws Exception {
assumeTrue(onPath("npm"), "npm is not on the PATH");
int port;
try (ServerSocket free = new ServerSocket(0)) {
port = free.getLocalPort();
}
Files.writeString(project.resolve("package.json"), """
{"name":"t","private":true,"scripts":{"dev":"node -e \\"require('http').createServer((q,s)=>s.end('ok')).listen(+process.argv[process.argv.indexOf('--port')+1],'127.0.0.1')\\" --"}}
""");
DevServer server = new DevServer(project, port);
assertTrue(answers(port));
assertTrue(Files.isRegularFile(project.resolve("node_modules/.flash-vite")));
server.close();
assertFalse(answers(port));
}
private static boolean answers(int port) {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress("127.0.0.1", port), 200);
return true;
} catch (IOException e) {
return false;
}
}
private static boolean onPath(String command) {
try {
return new ProcessBuilder(command, "--version").start().waitFor() == 0;
} catch (IOException | InterruptedException e) {
return false;
}
}
}
@@ -0,0 +1,41 @@
package dev.relism.flash.ext.vite;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PackageManagerTest {
@TempDir
Path workspace;
/** A project inside a workspace installs from the workspace's lockfile; its own one wins when it has one. */
@Test
void theNearestLockfileDecides() throws IOException {
Path project = Files.createDirectories(workspace.resolve("apps/web"));
Files.writeString(workspace.resolve("pnpm-lock.yaml"), "");
PackageManager.Found found = PackageManager.of(project);
assertEquals(PackageManager.PNPM, found.manager());
assertTrue(found.install().getFirst().startsWith("pnpm"));
assertEquals(List.of("install", "--frozen-lockfile"), found.install().subList(1, 3));
Files.writeString(project.resolve("package-lock.json"), "");
assertEquals(PackageManager.NPM, PackageManager.of(project).manager());
assertEquals("ci", PackageManager.of(project).install().get(1));
}
@Test
void npmNeedsTheSeparatorBeforeScriptArguments() {
PackageManager.Found npm = new PackageManager.Found(PackageManager.NPM, null);
assertEquals(List.of("run", "dev", "--", "--port", "1"), npm.run("dev", "--port", "1").subList(1, 6));
assertEquals("install", npm.install().get(1));
assertEquals(List.of("run", "dev", "--port", "1"), new PackageManager.Found(PackageManager.PNPM, null).run("dev", "--port", "1").subList(1, 5));
}
}
@@ -0,0 +1,83 @@
package dev.relism.flash.ext.vite;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/** The packaged build (src/test/resources/flash-vite) served by a real server, outside DEV. */
class ViteExtensionTest {
private static final String JS = "/assets/app-AbCd1234.js";
private static final String NAVIGATION = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
@RegisterExtension
static final FlashTest app = FlashTest.of(flash -> flash.install(new ViteExtension()));
@RegisterExtension
static final FlashTest everything = FlashTest.of(flash -> flash.install(new ViteExtension().navigationOnly(false)));
@RegisterExtension
static final FlashTest nested = FlashTest.of(flash -> flash.install(new ViteExtension().basePath("/app/")));
@Test
void hashedAssetsAreCachedForeverTheRestRevalidates() {
app.get(JS).expectStatus(200)
.expectHeader("Cache-Control", "public, max-age=31536000, immutable")
.expectHeader("Content-Type", "text/javascript; charset=utf-8")
.expectBodyContains("built");
app.get("/favicon.svg").expectStatus(200).expectHeader("Cache-Control", "no-cache");
}
@Test
void gzipIsServedOnlyWhenAccepted() {
app.request().header("Accept-Encoding", "br, gzip").get(JS).expectHeader("Content-Encoding", "gzip").expectHeader("Vary", "Accept-Encoding");
assertNull(app.request().header("Accept-Encoding", "gzip;q=0").get(JS).header("Content-Encoding"));
assertNull(app.get(JS).header("Content-Encoding"));
}
/** A browser navigating gets the app, dots and all; a fetch or a script tag for nothing gets a 404. */
@Test
void onlyNavigationsFallBackToTheIndex() {
for (String route : new String[]{"/content/2", "/users/ada.lovelace"}) {
app.request().header("Accept", NAVIGATION).get(route).expectStatus(200).expectHeader("Cache-Control", "no-cache").expectBodyContains("spa");
}
app.request().header("Sec-Fetch-Mode", "navigate").get("/content/2").expectStatus(200).expectBodyContains("spa");
app.request().header("Accept", "application/json").get("/api/nope").expectStatus(404);
app.request().header("Accept", "*/*").get("/assets/missing.js").expectStatus(404);
app.get("/content/2").expectStatus(404);
app.get("/").expectStatus(200).expectBodyContains("spa");
nested.get("/app").expectStatus(200).expectBodyContains("spa");
}
/** Opted out of the navigation check, every GET that is no file gets the page, as nginx's try_files would. */
@Test
void withoutTheNavigationCheckEveryMissGetsTheIndex() {
everything.request().header("Accept", "application/json").get("/api/nope").expectStatus(200).expectBodyContains("spa");
everything.get("/assets/missing.js").expectStatus(200).expectBodyContains("spa");
}
@Test
void anUnchangedFileIsNotSentAgainAHashedOneIsNeverAsked() {
String etag = app.get("/favicon.svg").header("ETag");
app.request().header("If-None-Match", etag).get("/favicon.svg").expectStatus(304)
.expectHeader("ETag", etag).expectHeader("Cache-Control", "no-cache");
assertNull(app.get(JS).header("ETag"));
}
@Test
void headDescribesTheBodyWithoutSendingIt() {
String length = app.get(JS).header("Content-Length");
var head = app.request().head(JS).expectStatus(200).expectHeader("Content-Length", length);
assertEquals("", head.body());
}
@Test
void aBasePathPrefixesEveryRoute() {
nested.get("/app" + JS).expectStatus(200);
nested.request().header("Accept", NAVIGATION).get("/app/settings").expectStatus(200).expectBodyContains("spa");
nested.get(JS).expectStatus(404);
}
}
@@ -0,0 +1,100 @@
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
console.log("built");
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg"/>

After

Width:  |  Height:  |  Size: 42 B

Some files were not shown because too many files have changed in this diff Show More