Compare commits
20
Commits
7f225e0faf
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ece97ca7b | ||
|
|
5fe6fb46c8 | ||
|
|
c0c9480fa5 | ||
|
|
9090ba59f5 | ||
|
|
6a6431c528 | ||
|
|
491553e9f5 | ||
|
|
dd0455c6d1 | ||
|
|
60dd4eab6a | ||
|
|
2f06ca7c1d | ||
|
|
2fbe65fcc5 | ||
|
|
adcd6376b6 | ||
|
|
ef4740f26d | ||
|
|
8353033cb1 | ||
|
|
fd96d67fca | ||
|
|
003fd6d1f0 | ||
|
|
680bbca8c7 | ||
|
|
580417e952 | ||
|
|
d6c018242f | ||
|
|
6d44f9e7b1 | ||
|
|
f28fc43150 |
Generated
+2
-2
@@ -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/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-web-bundler/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/java" 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/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash/src/main/java" charset="UTF-8" />
|
||||
|
||||
@@ -37,7 +37,7 @@ Format: `<type>(<scope>): <short description>`
|
||||
| `ci` | Changes to GitHub Actions workflows |
|
||||
|
||||
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-cache-core`, `ext-cache-caffeine`, `release`, `deps`, `ci`.
|
||||
|
||||
|
||||
@@ -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-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-security-core` | Security: authentication chain, annotations, sessions, OpenAPI |
|
||||
| `flash-extensions/flash-ext-security-oidc` | OpenID Connect: bearer tokens, code flow + PKCE |
|
||||
| `flash-extensions/flash-ext-security-apikey` | API keys |
|
||||
| `flash-extensions/flash-ext-security-form` | Password sign-in |
|
||||
| `flash-extensions/flash-ext-security-oauth-server` | OAuth 2.1 authorization server for the application's own users and resources |
|
||||
| `flash-extensions/flash-ext-security-test` | Test identities, fake OpenID Provider |
|
||||
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, secured by flash-ext-security-core |
|
||||
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
|
||||
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
|
||||
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
|
||||
| `flash-extensions/flash-ext-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-cache-core` | Caching contract — `Cache`, `CacheManager`, `CacheSpec` |
|
||||
| `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:
|
||||
- [`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-security-core`](flash-extensions/flash-ext-security-core/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-view-jte`](flash-extensions/flash-ext-view-jte/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-cache-caffeine`](flash-extensions/flash-ext-cache-caffeine/docs/README.md)
|
||||
- [`flash-testing`](flash-testing/docs/README.md)
|
||||
|
||||
@@ -112,6 +112,8 @@ public abstract class Repository<T, ID> {
|
||||
- `Tx` in the `FlashContext`
|
||||
- `TxManager` in the `FlashContext`
|
||||
- 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.
|
||||
|
||||
|
||||
+1
@@ -34,6 +34,7 @@ public final class DataExtension implements FlashExtension {
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.onClose(txManager::close);
|
||||
ctx.provide(Tx.class, tx);
|
||||
ctx.provide(TxManager.class, txManager);
|
||||
if (data != null) ctx.provide(Data.class, data);
|
||||
|
||||
+5
-1
@@ -1,7 +1,11 @@
|
||||
package dev.relism.flash.ext.data.core;
|
||||
|
||||
public interface TxManager {
|
||||
public interface TxManager extends AutoCloseable {
|
||||
TxStatus begin(TxDefinition definition);
|
||||
void commit(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();
|
||||
}
|
||||
|
||||
+22
@@ -3,6 +3,10 @@ package dev.relism.flash.ext.data.hibernate;
|
||||
import dev.relism.flash.ext.data.core.*;
|
||||
import org.hibernate.Session;
|
||||
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;
|
||||
|
||||
@@ -16,6 +20,24 @@ public class HibernateTxManager implements TxManager {
|
||||
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
|
||||
public TxStatus begin(TxDefinition definition) {
|
||||
return switch (definition.propagation()) {
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.relism.flash.ext.data.hibernate;
|
||||
|
||||
import org.hibernate.SessionFactory;
|
||||
import org.hibernate.boot.MetadataSources;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
||||
import org.hibernate.cfg.AvailableSettings;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.io.PrintWriter;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class HibernateTxManagerCloseTest {
|
||||
|
||||
/** Stands in for a pool: closeable, and it records being closed. */
|
||||
static final class Pool implements DataSource, AutoCloseable {
|
||||
boolean closed;
|
||||
@Override public Connection getConnection() throws SQLException { return DriverManager.getConnection("jdbc:h2:mem:tx-close;DB_CLOSE_DELAY=-1"); }
|
||||
@Override public Connection getConnection(String user, String password) throws SQLException { return getConnection(); }
|
||||
@Override public void close() { closed = true; }
|
||||
@Override public <T> T unwrap(Class<T> type) { throw new UnsupportedOperationException(); }
|
||||
@Override public boolean isWrapperFor(Class<?> type) { return false; }
|
||||
@Override public PrintWriter getLogWriter() { return null; }
|
||||
@Override public void setLogWriter(PrintWriter out) {}
|
||||
@Override public void setLoginTimeout(int seconds) {}
|
||||
@Override public int getLoginTimeout() { return 0; }
|
||||
@Override public Logger getParentLogger() { throw new UnsupportedOperationException(); }
|
||||
}
|
||||
|
||||
@Test
|
||||
void closingTheManagerClosesTheDataSourceHibernateWasGiven() {
|
||||
Pool pool = new Pool();
|
||||
SessionFactory sf = new MetadataSources(new StandardServiceRegistryBuilder()
|
||||
.applySetting(AvailableSettings.JAKARTA_NON_JTA_DATASOURCE, pool)
|
||||
.applySetting(AvailableSettings.DIALECT, "org.hibernate.dialect.H2Dialect")
|
||||
.build()).buildMetadata().buildSessionFactory();
|
||||
|
||||
new HibernateTxManager(sf).close();
|
||||
|
||||
assertTrue(sf.isClosed());
|
||||
assertTrue(pool.closed);
|
||||
}
|
||||
}
|
||||
+12
@@ -17,6 +17,18 @@ public class JdbcTxManager implements TxManager {
|
||||
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
|
||||
public TxStatus begin(TxDefinition definition) {
|
||||
return switch (definition.propagation()) {
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.relism.flash.ext.data.jdbc;
|
||||
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import dev.relism.flash.ext.data.DataExtension;
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JdbcTxManagerCloseTest {
|
||||
|
||||
/** A pool outlives its app unless someone closes it; the data extension does, once requests have drained. */
|
||||
@Test
|
||||
void stoppingTheAppClosesThePool() {
|
||||
HikariDataSource pool = new HikariDataSource();
|
||||
pool.setJdbcUrl(TestDataSource.URL);
|
||||
FlashApp.create(0).install(new DataExtension(new JdbcTxManager(pool))).start().stop().join();
|
||||
assertTrue(pool.isClosed());
|
||||
}
|
||||
}
|
||||
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.util.regex.Pattern;
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.Response;
|
||||
|
||||
/**
|
||||
* One Jackson mapper, in the shape a handler needs it.
|
||||
*
|
||||
* <p>Every Jackson data format is the same databind model behind a different factory, so this is
|
||||
* the whole of what a format module has to say: which mapper, and which content type it writes.
|
||||
* What the annotations mean, what a body is checked against and how a type is described are the
|
||||
* same for all of them.
|
||||
*
|
||||
* <p>Retrieve it once at boot — {@code @Inject private Json json;} — and call it on the hot path.
|
||||
* The underlying {@link ObjectMapper} is thread-safe once configured.
|
||||
*/
|
||||
public abstract class Codec {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final ContentType contentType;
|
||||
|
||||
protected Codec(ObjectMapper mapper, ContentType contentType) {
|
||||
this.mapper = mapper;
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the request body as {@code type} and verifies its constraints.
|
||||
*
|
||||
* <p>Read straight off the request's stream, which Flash reuses per connection: nothing
|
||||
* buffers the body to hand it over. A type that declares no constraints is not checked at all.
|
||||
*
|
||||
* @throws HttpException 400 if the body cannot be parsed as {@code type}
|
||||
* @throws ValidationException 422 if it parses but violates a constraint
|
||||
*/
|
||||
public <T> T body(Request request, Class<T> type) throws Exception {
|
||||
T value;
|
||||
try {
|
||||
value = mapper.readValue(request.body().stream(), type);
|
||||
} catch (JsonProcessingException malformed) {
|
||||
throw HttpException.badRequest("Invalid request body: " + malformed.getOriginalMessage());
|
||||
}
|
||||
Validator.of(type).verify(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Serializes {@code value} and sets this codec's content type on the response. */
|
||||
public String write(Response response, Object value) throws Exception {
|
||||
response.type(contentType);
|
||||
return mapper.writeValueAsString(value);
|
||||
}
|
||||
|
||||
/** Like {@link #write}, restricted to the fields visible under a Jackson {@code @JsonView}. */
|
||||
public String writeView(Response response, Object value, Class<?> view) throws Exception {
|
||||
response.type(contentType);
|
||||
return mapper.writerWithView(view).writeValueAsString(value);
|
||||
}
|
||||
|
||||
/** What this codec writes, for a handler that sets the response type itself. */
|
||||
public ContentType contentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
/** The mapper itself, for everything these methods do not cover. */
|
||||
public ObjectMapper mapper() {
|
||||
return mapper;
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import dev.relism.flash.models.BodyHandler;
|
||||
import dev.relism.flash.models.Request;
|
||||
|
||||
/**
|
||||
* A handler whose body one Jackson format parses and whose constraints are checked before it
|
||||
* arrives.
|
||||
*
|
||||
* <p>Format modules extend this and name their codec — {@code JsonHandler}, {@code XmlHandler}.
|
||||
* An application extends those, never this one.
|
||||
*
|
||||
* @param <B> the body type, which is also what the published OpenAPI document describes
|
||||
*/
|
||||
public abstract class JacksonHandler<B> extends BodyHandler<B> {
|
||||
|
||||
private final Class<B> type = bodyType();
|
||||
|
||||
protected JacksonHandler() {
|
||||
if (type == null) {
|
||||
throw new IllegalStateException(getClass().getSimpleName()
|
||||
+ " extends a body handler without naming its body type — write it as Handler<YourBody>");
|
||||
}
|
||||
}
|
||||
|
||||
/** The format this handler speaks. Injected by the subclass, resolved once at boot. */
|
||||
protected abstract Codec codec();
|
||||
|
||||
@Override
|
||||
protected final B body(Request request) throws Exception {
|
||||
return codec().body(request, type);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
|
||||
/**
|
||||
* Turns whatever a handler returns into a serialized body.
|
||||
*
|
||||
* <p>Pass-through for what is already a response: {@code null}, a {@link Response}, a
|
||||
* {@code byte[]} or a {@link CharSequence}. Everything else is serialized straight to bytes.
|
||||
*/
|
||||
public final class Marshalling {
|
||||
|
||||
private Marshalling() {}
|
||||
|
||||
public static Middleware of(ObjectMapper mapper, ContentType contentType) {
|
||||
return next -> (req, res) -> {
|
||||
Object out = next.handle(req, res);
|
||||
if (out == null || out instanceof Response || out instanceof byte[] || out instanceof CharSequence) return out;
|
||||
|
||||
res.type(contentType);
|
||||
try {
|
||||
return mapper.writeValueAsBytes(out);
|
||||
} catch (JsonProcessingException failure) {
|
||||
throw new IllegalStateException("Could not serialize " + out.getClass().getName(), failure);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
|
||||
@@ -14,7 +14,7 @@ public final class ValidationException extends HttpException {
|
||||
|
||||
private final transient List<Violation> violations;
|
||||
|
||||
ValidationException(List<Violation> violations) {
|
||||
public ValidationException(List<Violation> violations) {
|
||||
super(422, describe(violations));
|
||||
this.violations = List.copyOf(violations);
|
||||
}
|
||||
+51
-12
@@ -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.Max;
|
||||
@@ -31,12 +31,38 @@ public final class Validator {
|
||||
|
||||
private static final Check[] NONE = new Check[0];
|
||||
|
||||
/**
|
||||
* One compiled validator per type, kept beside the class itself: no map lookup, no lock, and
|
||||
* the entry is collected with the class rather than pinning it.
|
||||
*/
|
||||
private static final ClassValue<Validator> COMPILED = new ClassValue<>() {
|
||||
@Override protected Validator computeValue(Class<?> type) {
|
||||
return compile(type);
|
||||
}
|
||||
};
|
||||
|
||||
private final Check[] checks;
|
||||
|
||||
private Validator(Check[] checks) {
|
||||
this.checks = checks;
|
||||
}
|
||||
|
||||
/** The constraints of {@code type}, compiled the first time it is seen and reused after. */
|
||||
public static Validator of(Class<?> type) {
|
||||
return COMPILED.get(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a value against its own type's constraints.
|
||||
*
|
||||
* @return {@code value}, so it can be used inline
|
||||
* @throws ValidationException 422, listing every failure
|
||||
*/
|
||||
public static <T> T check(T value) {
|
||||
of(value.getClass()).verify(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** True when the type declares no constraints at all — {@link #verify} is then a no-op. */
|
||||
public boolean isEmpty() {
|
||||
return checks.length == 0;
|
||||
@@ -152,25 +178,28 @@ public final class Validator {
|
||||
Class<?> type = field.getType();
|
||||
MethodHandle ref = type.isPrimitive() ? null : asReference(getter);
|
||||
|
||||
if (field.isAnnotationPresent(NotNull.class) && ref != null)
|
||||
checks.add(Check.reference(Check.NOT_NULL, name, "must not be null", ref));
|
||||
NotNull notNull = field.getAnnotation(NotNull.class);
|
||||
if (notNull != null && ref != null)
|
||||
checks.add(Check.reference(Check.NOT_NULL, name, said(notNull.message(), "must not be null"), ref));
|
||||
|
||||
if (field.isAnnotationPresent(NotBlank.class) && ref != null)
|
||||
checks.add(Check.reference(Check.NOT_BLANK, name, "must not be blank", ref));
|
||||
NotBlank notBlank = field.getAnnotation(NotBlank.class);
|
||||
if (notBlank != null && ref != null)
|
||||
checks.add(Check.reference(Check.NOT_BLANK, name, said(notBlank.message(), "must not be blank"), ref));
|
||||
|
||||
if (field.isAnnotationPresent(NotEmpty.class) && ref != null)
|
||||
checks.add(Check.reference(Check.NOT_EMPTY, name, "must not be empty", ref));
|
||||
NotEmpty notEmpty = field.getAnnotation(NotEmpty.class);
|
||||
if (notEmpty != null && ref != null)
|
||||
checks.add(Check.reference(Check.NOT_EMPTY, name, said(notEmpty.message(), "must not be empty"), ref));
|
||||
|
||||
Size size = field.getAnnotation(Size.class);
|
||||
if (size != null && ref != null)
|
||||
checks.add(Check.size(name, 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);
|
||||
Max max = field.getAnnotation(Max.class);
|
||||
if (min != null || max != null) {
|
||||
long lo = min != null ? min.value() : Long.MIN_VALUE;
|
||||
long hi = max != null ? max.value() : Long.MAX_VALUE;
|
||||
String message = rangeMessage(min, max);
|
||||
String message = said(min != null ? min.message() : max.message(), rangeMessage(min, max));
|
||||
if (isIntegralPrimitive(type)) {
|
||||
checks.add(Check.rangePrimitive(name, message, asLong(getter), lo, hi));
|
||||
} else if (Number.class.isAssignableFrom(type) && ref != null) {
|
||||
@@ -178,14 +207,15 @@ public final class Validator {
|
||||
}
|
||||
}
|
||||
|
||||
if (field.isAnnotationPresent(Email.class) && ref != null)
|
||||
checks.add(Check.reference(Check.EMAIL, name, "must be a well-formed email address", ref));
|
||||
Email email = field.getAnnotation(Email.class);
|
||||
if (email != null && ref != null)
|
||||
checks.add(Check.reference(Check.EMAIL, name, said(email.message(), "must be a well-formed email address"), ref));
|
||||
|
||||
Pattern pattern = field.getAnnotation(Pattern.class);
|
||||
if (pattern != null && ref != null) {
|
||||
// ponytail: the one allocating check — Pattern.matcher() per call. The regex itself is
|
||||
// compiled once here; swap for a structural check if a hot route ever needs it.
|
||||
checks.add(Check.pattern(name, "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())));
|
||||
}
|
||||
}
|
||||
@@ -202,6 +232,15 @@ public final class Validator {
|
||||
return getter.asType(MethodType.methodType(long.class, Object.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* What a failure says: the constraint's own {@code message} when it sets one, and otherwise a
|
||||
* plain description of the rule. Jakarta's defaults are bundle keys in braces, and a key is
|
||||
* not something to put in front of whoever sent the request.
|
||||
*/
|
||||
private static String said(String message, String otherwise) {
|
||||
return message == null || message.isBlank() || message.startsWith("{") ? otherwise : message;
|
||||
}
|
||||
|
||||
private static String sizeMessage(Size size) {
|
||||
if (size.min() == 0) return "size must be at most " + size.max();
|
||||
if (size.max() == Integer.MAX_VALUE) return "size must be at least " + size.min();
|
||||
+12
-1
@@ -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.Max;
|
||||
@@ -114,4 +114,15 @@ class ValidatorTest {
|
||||
assertTrue(validator.isEmpty());
|
||||
assertDoesNotThrow(() -> validator.verify(new Plain(null)));
|
||||
}
|
||||
|
||||
record Keyed(@jakarta.validation.constraints.Pattern(regexp = "[a-z.]+",
|
||||
message = "uses lowercase letters and dots") String key) {}
|
||||
|
||||
@org.junit.jupiter.api.Test
|
||||
void a_constraint_says_what_it_wants_in_its_own_words() {
|
||||
ValidationException refused = org.junit.jupiter.api.Assertions.assertThrows(
|
||||
ValidationException.class, () -> Validator.check(new Keyed("Not A Key")));
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertEquals("key uses lowercase letters and dots", refused.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# flash-ext-jackson-json
|
||||
|
||||
JSON bodies and JSON responses.
|
||||
|
||||
## Install
|
||||
|
||||
```java
|
||||
JsonExtension json = new JsonExtension();
|
||||
|
||||
FlashApp.create(8080)
|
||||
.install(json)
|
||||
.use(json.auto())
|
||||
.scan("com.acme.handlers")
|
||||
.startAndBlock();
|
||||
```
|
||||
|
||||
The default mapper discovers the modules on the classpath (Java Time among them) and writes dates
|
||||
as ISO strings; `new JsonExtension(mapper)` takes one of your own. `auto()` serializes whatever a
|
||||
handler returns, leaving alone what is already a response: `null`, a `Response`, a `byte[]` or a
|
||||
`CharSequence`.
|
||||
|
||||
## A handler with a body
|
||||
|
||||
The body type is the handler's type argument, and that is the whole declaration — it is also what
|
||||
the OpenAPI document describes and what the constraints are read from:
|
||||
|
||||
```java
|
||||
@POST("/users")
|
||||
public final class CreateUser extends JsonHandler<NewUser> {
|
||||
@Inject private UserService users;
|
||||
|
||||
@Override protected Object handle(Request req, Response res, NewUser body) {
|
||||
return users.create(body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A malformed body never reaches it (400), nor does one that breaks a constraint (422).
|
||||
|
||||
## A handler that reads it itself
|
||||
|
||||
```java
|
||||
public final class Import extends RequestHandler {
|
||||
@Inject private Json json;
|
||||
|
||||
@Override public Object handle(Request req, Response res) throws Exception {
|
||||
return archive.store(json.body(req, Manifest.class));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Json` is the [`Codec`](../flash-ext-jackson-core) for `application/json`: `body`, `write`,
|
||||
`writeView`, `mapper`.
|
||||
|
||||
## Notes
|
||||
|
||||
- One mapper per application (or per scope), shared by every handler; `ObjectMapper` is
|
||||
thread-safe once configured.
|
||||
- Install `flash-ext-jackson-xml` beside this one when an application speaks both: a route picks
|
||||
its format by the handler it extends, not by negotiation.
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-jackson-json</artifactId>
|
||||
<name>flash-ext-jackson-json</name>
|
||||
<description>JSON bodies and responses: the Json codec, JsonHandler and the marshalling middleware.</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- The constraints a body declares are described by the published document too. -->
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-openapi</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.ext.jackson.Codec;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
|
||||
/**
|
||||
* JSON in and out, checked against the body type's own constraints.
|
||||
*
|
||||
* <pre>{@code
|
||||
* public final class CreateItem extends RequestHandler {
|
||||
* @Inject private Json json;
|
||||
*
|
||||
* @Override public Object handle(Request req, Response res) throws Exception {
|
||||
* return items.create(json.body(req, NewItem.class));
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>A handler whose whole body is one type has nothing to write at all: see {@link JsonHandler}.
|
||||
*/
|
||||
public final class Json extends Codec {
|
||||
|
||||
public Json(ObjectMapper mapper) {
|
||||
super(mapper, ContentType.JSON);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import dev.relism.flash.ext.jackson.Marshalling;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
|
||||
/**
|
||||
* JSON for an application: the {@link Json} codec, the mapper behind it, and the middleware that
|
||||
* serializes whatever a handler returns.
|
||||
*
|
||||
* <pre>{@code
|
||||
* JsonExtension json = new JsonExtension();
|
||||
* app.install(json).use(json.auto());
|
||||
* }</pre>
|
||||
*
|
||||
* <p>The default mapper discovers the modules on the classpath (Java Time among them) and writes
|
||||
* dates as ISO strings. Hand it a mapper of your own to decide otherwise.
|
||||
*/
|
||||
public class JsonExtension implements FlashExtension {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public JsonExtension() {
|
||||
this(JsonMapper.builder()
|
||||
.findAndAddModules()
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.build());
|
||||
}
|
||||
|
||||
public JsonExtension(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/** Serializes what a handler returns, unless it already returned a response, bytes or text. */
|
||||
public Middleware auto() {
|
||||
return Marshalling.of(mapper, ContentType.JSON);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.provide(Json.class, new Json(mapper));
|
||||
ctx.provide(ObjectMapper.class, mapper);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import dev.relism.flash.ext.jackson.Codec;
|
||||
import dev.relism.flash.ext.jackson.JacksonHandler;
|
||||
import dev.relism.flash.extension.Inject;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.routing.Consumes;
|
||||
|
||||
/**
|
||||
* A handler that takes a JSON body of one type.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @POST("/users")
|
||||
* public final class CreateUser extends JsonHandler<NewUser> {
|
||||
* @Inject private UserService users;
|
||||
*
|
||||
* @Override protected Object handle(Request req, Response res, NewUser body) {
|
||||
* return users.create(body);
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>The body is read off the request stream, verified against the constraints its type declares,
|
||||
* and handed over. A malformed body is a 400, a body that breaks a constraint is a 422, and
|
||||
* neither ever reaches the handler. The published OpenAPI document describes the same type.
|
||||
*/
|
||||
@Consumes(ContentType.JSON)
|
||||
public abstract class JsonHandler<B> extends JacksonHandler<B> {
|
||||
|
||||
@Inject private Json json;
|
||||
|
||||
@Override protected final Codec codec() {
|
||||
return json;
|
||||
}
|
||||
}
|
||||
+6
-7
@@ -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.ApiOperation;
|
||||
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
|
||||
* its own when they are on the classpath.
|
||||
*/
|
||||
class ValidationOpenApiInteropTest {
|
||||
class ConstraintsInTheDocumentTest {
|
||||
|
||||
record Account(
|
||||
@NotBlank @Size(max = 40) String name,
|
||||
@@ -33,7 +33,7 @@ class ValidationOpenApiInteropTest {
|
||||
|
||||
@GET("/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 {
|
||||
@Override public Object handle(Request request, Response response) {
|
||||
return new Account("alice", "a@b.com", 30);
|
||||
@@ -42,10 +42,9 @@ class ValidationOpenApiInteropTest {
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest app = FlashTest.of(configured -> {
|
||||
configured.install(new JacksonExtension());
|
||||
configured.install(new ValidationExtension());
|
||||
configured.install(new JsonExtension());
|
||||
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
|
||||
+6
-7
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.assertTrue;
|
||||
|
||||
class JacksonExtensionTest {
|
||||
class JsonExtensionTest {
|
||||
|
||||
@Test
|
||||
void configure_registers_json_mapper_and_middleware() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JacksonExtension ext = new JacksonExtension(mapper);
|
||||
JsonExtension ext = new JsonExtension(mapper);
|
||||
|
||||
ext.configure(null, ctx);
|
||||
ctx.complete();
|
||||
|
||||
assertNotNull(ctx.require(Json.class));
|
||||
assertNotNull(ctx.require(JacksonMiddleware.class));
|
||||
assertSame(mapper, ctx.require(ObjectMapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoJson_factory_delegates_to_middleware_policy() throws Exception {
|
||||
void auto_marshals_what_a_handler_returns() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JacksonExtension ext = new JacksonExtension(mapper);
|
||||
JsonExtension ext = new JsonExtension(mapper);
|
||||
RequestHandler next = new RequestHandler() {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
@@ -43,7 +42,7 @@ class JacksonExtensionTest {
|
||||
}
|
||||
};
|
||||
RequestHandler wrapped = new RequestHandler() {
|
||||
private final SimpleHandler.FunctionalHandler delegate = ext.autoJson().wrap(next);
|
||||
private final SimpleHandler.FunctionalHandler delegate = ext.auto().wrap(next);
|
||||
|
||||
@Override
|
||||
public Object handle(Request request, Response response) throws Exception {
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.Http1HeaderMap;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestLine;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JsonHandlerTest {
|
||||
|
||||
public record NewUser(String name) {}
|
||||
|
||||
static final class Create extends JsonHandler<NewUser> {
|
||||
@Override protected Object handle(Request request, Response response, NewUser body) {
|
||||
return body.name();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("rawtypes")
|
||||
static final class Untyped extends JsonHandler {
|
||||
@Override protected Object handle(Request request, Response response, Object body) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theBodyArrivesParsedAsTheTypeTheHandlerDeclares() throws Exception {
|
||||
Create handler = new Create();
|
||||
handler.bind(context());
|
||||
|
||||
Object answer = handler.handle(request("{\"name\":\"alice\"}"), new Response(200, ContentType.JSON));
|
||||
|
||||
assertEquals("alice", answer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMalformedBodyIsTheUsualBadRequest() throws Exception {
|
||||
Create handler = new Create();
|
||||
handler.bind(context());
|
||||
|
||||
dev.relism.flash.exceptions.HttpException refused = assertThrows(dev.relism.flash.exceptions.HttpException.class,
|
||||
() -> handler.handle(request("not json"), new Response(200, ContentType.JSON)));
|
||||
|
||||
assertEquals(400, refused.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHandlerThatNeverNamedItsBodyTypeIsRefusedAtBoot() {
|
||||
IllegalStateException refused = assertThrows(IllegalStateException.class, Untyped::new);
|
||||
|
||||
assertTrue(refused.getMessage().contains("Handler<YourBody>"), refused.getMessage());
|
||||
}
|
||||
|
||||
private static FlashContext context() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ctx.provide(Json.class, new Json(new ObjectMapper()));
|
||||
ctx.complete();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private static Request request(String body) {
|
||||
return new Request(new RequestLine(HttpMethod.POST,
|
||||
new FastPathViews.StringByteView("/users"), null,
|
||||
new FastPathViews.StringByteView("HTTP/1.1"), new Http1HeaderMap()),
|
||||
body.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
+3
-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.databind.ObjectMapper;
|
||||
@@ -37,16 +37,11 @@ class JsonTest {
|
||||
}
|
||||
|
||||
@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());
|
||||
|
||||
Request ok = request("{\"id\":\"u2\",\"name\":\"bob\"}");
|
||||
UserDto dto = json.bodyFrom(ok, UserDto.class);
|
||||
assertEquals("u2", dto.id);
|
||||
assertEquals("bob", dto.name);
|
||||
HttpException ex = assertThrows(HttpException.class, () -> json.body(request("["), UserDto.class));
|
||||
|
||||
Request bad = request("[");
|
||||
HttpException ex = assertThrows(HttpException.class, () -> json.bodyFrom(bad, UserDto.class));
|
||||
assertEquals(400, ex.status());
|
||||
}
|
||||
|
||||
+13
-11
@@ -1,6 +1,8 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.ext.jackson.Marshalling;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
import dev.relism.flash.models.SimpleHandler;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
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.assertTrue;
|
||||
|
||||
class JacksonMiddlewareTest {
|
||||
class MarshallingTest {
|
||||
|
||||
private static final Request REQ = null;
|
||||
|
||||
@Test
|
||||
void autoJson_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception {
|
||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
||||
void marshalling_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception {
|
||||
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
|
||||
RequestHandler wrapped = wrap(mw, new UserDto("u1", "alice"));
|
||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
||||
|
||||
@@ -34,8 +36,8 @@ class JacksonMiddlewareTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoJson_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception {
|
||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
||||
void marshalling_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception {
|
||||
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
|
||||
|
||||
Response payloadResponse = new Response(201, ContentType.TEXT_PLAIN).body("ok");
|
||||
RequestHandler wrappedResponse = wrap(mw, payloadResponse);
|
||||
@@ -55,17 +57,17 @@ class JacksonMiddlewareTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoJson_wraps_serialization_errors_as_illegal_state() {
|
||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
||||
void marshalling_wraps_serialization_errors_as_illegal_state() {
|
||||
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
|
||||
RequestHandler wrapped = wrap(mw, new CyclicDto());
|
||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
||||
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> wrapped.handle(REQ, res));
|
||||
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() {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
@@ -73,7 +75,7 @@ class JacksonMiddlewareTest {
|
||||
}
|
||||
};
|
||||
return new RequestHandler() {
|
||||
private final SimpleHandler.FunctionalHandler delegate = mw.autoJson().wrap(next);
|
||||
private final SimpleHandler.FunctionalHandler delegate = mw.wrap(next);
|
||||
|
||||
@Override
|
||||
public Object handle(Request request, Response response) throws Exception {
|
||||
+5
-7
@@ -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 jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Min;
|
||||
@@ -12,19 +11,18 @@ import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/** The whole path: JSON in, constraints checked, status out — with no error handling wired up. */
|
||||
class ValidationRoutesTest {
|
||||
class ValidatedBodyTest {
|
||||
|
||||
record CreateUser(@NotBlank @Size(max = 8) String name, @Email String email, @Min(18) int age) {}
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest app = FlashTest.of(configured -> {
|
||||
configured.install(new JacksonExtension());
|
||||
configured.install(new ValidationExtension());
|
||||
configured.install(new JsonExtension());
|
||||
|
||||
configured.ctx().onReady(() -> {
|
||||
Validation validation = configured.ctx().require(Validation.class);
|
||||
Json json = configured.ctx().require(Json.class);
|
||||
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.
|
||||
+13
-21
@@ -10,42 +10,34 @@
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</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>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
<artifactId>flash-ext-jackson-core</artifactId>
|
||||
</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>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
</dependency>
|
||||
<!-- Only needed for Validation.body(...); check(...) works without it. -->
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
<optional>true</optional>
|
||||
<groupId>com.fasterxml.jackson.dataformat</groupId>
|
||||
<artifactId>jackson-dataformat-xml</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Interop only: proves constraints reach the published schema. -->
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-openapi</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.relism.flash.ext.jackson.xml;
|
||||
|
||||
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
|
||||
import dev.relism.flash.ext.jackson.Codec;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
|
||||
/**
|
||||
* XML in and out, checked against the body type's own constraints.
|
||||
*
|
||||
* <p>The same databind model as every other Jackson format: a type is annotated once and can be
|
||||
* read as XML here and as JSON elsewhere in the same application. XML's own concerns — a root
|
||||
* element name, an attribute rather than an element, how a list is wrapped — are Jackson's
|
||||
* {@code @JacksonXml*} annotations on the type.
|
||||
*/
|
||||
public final class Xml extends Codec {
|
||||
|
||||
public Xml(XmlMapper mapper) {
|
||||
super(mapper, ContentType.XML);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package dev.relism.flash.ext.jackson.xml;
|
||||
|
||||
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
|
||||
import dev.relism.flash.ext.jackson.Marshalling;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
|
||||
/**
|
||||
* XML for an application: the {@link Xml} codec and the middleware that serializes what a handler
|
||||
* returns.
|
||||
*
|
||||
* <pre>{@code
|
||||
* XmlExtension xml = new XmlExtension();
|
||||
* app.install(xml).use(xml.auto());
|
||||
* }</pre>
|
||||
*
|
||||
* <p>Install it beside {@code JsonExtension} when an application speaks both: each provides its
|
||||
* own codec, and a route picks one by the handler it extends.
|
||||
*/
|
||||
public class XmlExtension implements FlashExtension {
|
||||
|
||||
private final XmlMapper mapper;
|
||||
|
||||
public XmlExtension() {
|
||||
this(XmlMapper.builder().findAndAddModules().build());
|
||||
}
|
||||
|
||||
public XmlExtension(XmlMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/** Serializes what a handler returns, unless it already returned a response, bytes or text. */
|
||||
public Middleware auto() {
|
||||
return Marshalling.of(mapper, ContentType.XML);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.provide(Xml.class, new Xml(mapper));
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.relism.flash.ext.jackson.xml;
|
||||
|
||||
import dev.relism.flash.ext.jackson.Codec;
|
||||
import dev.relism.flash.ext.jackson.JacksonHandler;
|
||||
import dev.relism.flash.extension.Inject;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.routing.Consumes;
|
||||
|
||||
/**
|
||||
* A handler that takes an XML body of one type.
|
||||
*
|
||||
* <p>Everything {@code JsonHandler} does, in XML: the body is read off the request stream,
|
||||
* verified against its type's constraints, and handed over. Both can live in the same
|
||||
* application, on different routes, over the same types.
|
||||
*/
|
||||
@Consumes(ContentType.XML)
|
||||
public abstract class XmlHandler<B> extends JacksonHandler<B> {
|
||||
|
||||
@Inject private Xml xml;
|
||||
|
||||
@Override protected final Codec codec() {
|
||||
return xml;
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package dev.relism.flash.ext.jackson.xml;
|
||||
|
||||
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.Http1HeaderMap;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestLine;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/** The same handler shape as JSON, over the same types and the same constraints. */
|
||||
class XmlHandlerTest {
|
||||
|
||||
public record Order(@NotBlank String reference) {}
|
||||
|
||||
static final class Place extends XmlHandler<Order> {
|
||||
@Override protected Object handle(Request request, Response response, Order body) {
|
||||
return body.reference();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theBodyArrivesParsedAsTheTypeTheHandlerDeclares() throws Exception {
|
||||
Place handler = new Place();
|
||||
handler.bind(context());
|
||||
|
||||
Object answer = handler.handle(request("<Order><reference>A-1</reference></Order>"), response());
|
||||
|
||||
assertEquals("A-1", answer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBodyThatBreaksAConstraintNeverReachesTheHandler() throws Exception {
|
||||
Place handler = new Place();
|
||||
handler.bind(context());
|
||||
|
||||
HttpException refused = assertThrows(HttpException.class,
|
||||
() -> handler.handle(request("<Order><reference></reference></Order>"), response()));
|
||||
|
||||
assertEquals(422, refused.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMalformedBodyIsABadRequest() throws Exception {
|
||||
Place handler = new Place();
|
||||
handler.bind(context());
|
||||
|
||||
HttpException refused = assertThrows(HttpException.class,
|
||||
() -> handler.handle(request("<Order><reference>"), response()));
|
||||
|
||||
assertEquals(400, refused.status());
|
||||
}
|
||||
|
||||
private static FlashContext context() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ctx.provide(Xml.class, new Xml(XmlMapper.builder().build()));
|
||||
ctx.complete();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private static Response response() {
|
||||
return new Response(200, ContentType.XML);
|
||||
}
|
||||
|
||||
private static Request request(String body) {
|
||||
return new Request(new RequestLine(HttpMethod.POST,
|
||||
new FastPathViews.StringByteView("/orders"), null,
|
||||
new FastPathViews.StringByteView("HTTP/1.1"), new Http1HeaderMap()),
|
||||
body.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
# flash-ext-jackson
|
||||
|
||||
Jackson JSON integration for Flash with an opinionated auto-marshal middleware.
|
||||
|
||||
## What it provides
|
||||
|
||||
| Component | Description |
|
||||
|---|---|
|
||||
| `JacksonExtension` | Registers JSON services into `FlashContext` |
|
||||
| `Json` | JSON read/write helper (`body`, `bodyFrom`, `write`, `writeView`) |
|
||||
| `ObjectMapper` | Raw mapper escape hatch for advanced usage |
|
||||
| `JacksonMiddleware` | `autoJson()` middleware for automatic outbound JSON marshalling |
|
||||
|
||||
Default mapper behavior (`new JacksonExtension()`):
|
||||
|
||||
- auto-discovers Jackson modules on classpath (`findAndAddModules()`)
|
||||
- includes Java Time support (`jackson-datatype-jsr310`)
|
||||
- writes date/time values as ISO-8601 strings (not numeric timestamps)
|
||||
|
||||
## Recommended default
|
||||
|
||||
Install the extension, then apply `autoJson()` once at app or scope level.
|
||||
|
||||
```java
|
||||
JacksonExtension jackson = new JacksonExtension();
|
||||
|
||||
FlashApp app = FlashApp.create(8080)
|
||||
.install(jackson)
|
||||
.use(jackson.autoJson());
|
||||
|
||||
app.startAndBlock();
|
||||
```
|
||||
|
||||
Behavior of `autoJson()`:
|
||||
|
||||
- pass-through: `null`, `Response`, `byte[]`, `String`, `CharSequence`
|
||||
- any other return value: serialize to JSON `byte[]`
|
||||
- sets `Content-Type: application/json` for marshalled responses
|
||||
- serialization failures throw `IllegalStateException`
|
||||
|
||||
This keeps handlers concise while preserving Flash's direct byte write path.
|
||||
|
||||
## Installation
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
<version>1.1-indev2</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Json helper API
|
||||
|
||||
Use `Json` when you want explicit, local control in a handler.
|
||||
|
||||
```java
|
||||
@POST("/users")
|
||||
public final class CreateUser extends RequestHandler {
|
||||
private Json json;
|
||||
|
||||
@Override
|
||||
protected void onInit() {
|
||||
json = require(Json.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object handle(Request req, Response res) throws Exception {
|
||||
CreateUserBody body = json.body(req, CreateUserBody.class);
|
||||
UserDto created = service.create(body);
|
||||
res.status(201);
|
||||
return json.write(res, created);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Methods:
|
||||
|
||||
- `body(req, Type.class)` -> parse from `req.body().bytes()`
|
||||
- `bodyFrom(req, Type.class)` -> parse from `req.body().stream()`
|
||||
- `write(res, obj)` -> writes JSON string and sets JSON content type
|
||||
- `writeView(res, obj, View.class)` -> JSON with Jackson `@JsonView`
|
||||
- `mapper()` -> raw `ObjectMapper`
|
||||
|
||||
## Custom mapper
|
||||
|
||||
```java
|
||||
ObjectMapper mapper = JsonMapper.builder()
|
||||
.addModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.build();
|
||||
|
||||
FlashApp.create(8080)
|
||||
.install(new JacksonExtension(mapper));
|
||||
```
|
||||
|
||||
## Scope usage
|
||||
|
||||
`autoJson()` works the same at scope level:
|
||||
|
||||
```java
|
||||
JacksonExtension jackson = new JacksonExtension();
|
||||
|
||||
app.mount("/api", api -> {
|
||||
api.use(jackson.autoJson());
|
||||
api.get("/health", (req, res) -> Map.of("ok", true));
|
||||
});
|
||||
```
|
||||
|
||||
If you need to pull it from context, `JacksonMiddleware` is also provided as a service
|
||||
after the app boots (same lifecycle model as other extension-provided services).
|
||||
|
||||
## Notes
|
||||
|
||||
- Install order is irrelevant (Flash two-phase extension lifecycle).
|
||||
- `autoJson()` and OpenAPI are intentionally decoupled.
|
||||
@@ -1,82 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
|
||||
<properties>
|
||||
<jacoco.version>0.8.12</jacoco.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>${jacoco.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>jacoco-prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>jacoco-report-and-check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>LINE</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.80</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
-94
@@ -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);
|
||||
}
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
|
||||
/**
|
||||
* Outbound JSON marshalling middleware for class-based and lambda routes.
|
||||
*
|
||||
* <p>{@link #autoJson()} marshals any non-body-native return value to JSON bytes,
|
||||
* writes {@code Content-Type: application/json}, and returns {@code byte[]} so
|
||||
* the Flash write path stays direct.
|
||||
*
|
||||
* <p>Pass-through return types:
|
||||
* <ul>
|
||||
* <li>{@code null}</li>
|
||||
* <li>{@link Response}</li>
|
||||
* <li>{@code byte[]}</li>
|
||||
* <li>{@link String}</li>
|
||||
* <li>{@link CharSequence}</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class JacksonMiddleware {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
JacksonMiddleware(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatic JSON marshalling policy.
|
||||
*
|
||||
* <p>For non-pass-through return values, serializes with Jackson directly to
|
||||
* {@code byte[]} and sets response content type to JSON.
|
||||
*
|
||||
* @throws IllegalStateException when serialization fails
|
||||
*/
|
||||
public Middleware autoJson() {
|
||||
return next -> (req, res) -> {
|
||||
Object out = next.handle(req, res);
|
||||
if (isPassThrough(out)) return out;
|
||||
|
||||
res.type(ContentType.JSON);
|
||||
try {
|
||||
return mapper.writeValueAsBytes(out);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException(
|
||||
"Failed to serialize handler result as JSON: " + out.getClass().getName(), e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean isPassThrough(Object out) {
|
||||
return out == null
|
||||
|| out instanceof Response
|
||||
|| out instanceof byte[]
|
||||
|| out instanceof CharSequence;
|
||||
}
|
||||
}
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.Response;
|
||||
|
||||
/**
|
||||
* Thread-safe JSON toolbox. Single point of access for all JSON I/O operations
|
||||
* within a Flash application.
|
||||
*
|
||||
* <p>Retrieve once at boot time via {@code require(Json.class)} inside
|
||||
* {@code onInit()}, cache in a private field, and call on the hot path
|
||||
* with zero lookup or allocation overhead:
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Route(method = HttpMethod.POST, path = "/api/items")
|
||||
* public class CreateItemHandler extends RequestHandler {
|
||||
*
|
||||
* private Json json;
|
||||
*
|
||||
* @Override
|
||||
* protected void onInit() {
|
||||
* json = require(Json.class);
|
||||
* }
|
||||
*
|
||||
* public Object handle(Request req, Response res) throws Exception {
|
||||
* CreateItemRequest body = json.body(req, CreateItemRequest.class);
|
||||
* return json.write(res, itemService.create(body));
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>The underlying {@link ObjectMapper} is shared across all handlers in the same
|
||||
* scope (one instance per app / per child scope). Jackson's {@code ObjectMapper}
|
||||
* is fully thread-safe after configuration — no synchronization is needed.
|
||||
*
|
||||
* <p>Install via {@link JacksonExtension} before calling {@code scan()} or
|
||||
* {@code register()}.
|
||||
*/
|
||||
public final class Json {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
/** Package-private — constructed exclusively by {@link JacksonExtension}. */
|
||||
Json(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
// ── Input ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Deserializes the full request body into an instance of {@code type}.
|
||||
*
|
||||
* <p>Reads {@code req.body().bytes()} in one shot. For streaming bodies
|
||||
* use {@link #bodyFrom(Request, Class)} instead.
|
||||
*
|
||||
* @throws HttpException 400 if the body cannot be parsed as {@code type}
|
||||
*/
|
||||
public <T> T body(Request req, Class<T> type) throws Exception {
|
||||
try {
|
||||
return mapper.readValue(req.body().bytes(), type);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes the request body via the raw {@link java.io.InputStream},
|
||||
* avoiding the intermediate {@code byte[]} allocation. Prefer this for
|
||||
* large bodies or when allocation budget is tight.
|
||||
*
|
||||
* @throws HttpException 400 on parse failure
|
||||
*/
|
||||
public <T> T bodyFrom(Request req, Class<T> type) throws Exception {
|
||||
try {
|
||||
return mapper.readValue(req.body().stream(), type);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Output ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serializes {@code obj} to a JSON string and sets
|
||||
* {@code Content-Type: application/json} on the response.
|
||||
*
|
||||
* <p>The returned string is used as the response body by the Flash runtime.
|
||||
*/
|
||||
public String write(Response res, Object obj) throws Exception {
|
||||
res.type(ContentType.JSON);
|
||||
return mapper.writeValueAsString(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #write} but applies a Jackson {@code @JsonView} filter,
|
||||
* restricting serialization to fields visible under {@code view}.
|
||||
*/
|
||||
public String writeView(Response res, Object obj, Class<?> view) throws Exception {
|
||||
res.type(ContentType.JSON);
|
||||
return mapper.writerWithView(view).writeValueAsString(obj);
|
||||
}
|
||||
|
||||
// ── Escape hatch ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the underlying {@link ObjectMapper} for advanced operations
|
||||
* (custom serialization, schema generation, etc.) not covered by the
|
||||
* methods above.
|
||||
*/
|
||||
public ObjectMapper mapper() {
|
||||
return mapper;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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
|
||||
request from `X-Forwarded-Proto`/`-Host` or `Host`), every issuer as `authorization_servers`, and
|
||||
`scopes_supported` when `McpConfig.scopesSupported(...)` is set;
|
||||
- `GET /.well-known/oauth-protected-resource/mcp` serves RFC 9728 metadata: the `resource` (the
|
||||
application's `SecurityExtension.origin(...)` plus the path), every issuer as `authorization_servers`,
|
||||
and `scopes_supported` when `McpConfig.scopesSupported(...)` is set;
|
||||
- an anonymous call gets `401` with `WWW-Authenticate: Bearer resource_metadata="…"`;
|
||||
- a token whose `aud` does not include the resource is `403` (RFC 8707) and logged at `WARN`. Credentials
|
||||
that are not audience-bound, such as API keys, are unaffected.
|
||||
@@ -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
|
||||
endpoint, and the boot logs say so.
|
||||
|
||||
## Which credentials
|
||||
|
||||
By default every mechanism in the chain authenticates `/mcp`, and the session cookie too.
|
||||
`McpConfig.mechanisms(...)` narrows that to the ones named: nothing else is a credential on the endpoint,
|
||||
and only their issuers are published — so a client is sent to exactly the authorization server the
|
||||
endpoint trusts.
|
||||
|
||||
```java
|
||||
McpConfig.builder("app").toolsPackage("com.example.tools").mechanisms(authorizationServer).build();
|
||||
```
|
||||
|
||||
## Tool policies
|
||||
|
||||
The core annotations work on tools as on handlers, checked per `tools/call` against the caller the route
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.ext.security.AuthenticationMechanism;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -30,6 +31,7 @@ public final class McpConfig {
|
||||
private final List<String> allowedOrigins;
|
||||
private final List<String> scopesSupported;
|
||||
private final List<Middleware> middleware;
|
||||
private final List<AuthenticationMechanism> mechanisms;
|
||||
|
||||
private McpConfig(Builder b) {
|
||||
this.name = b.name;
|
||||
@@ -42,6 +44,7 @@ public final class McpConfig {
|
||||
this.allowedOrigins = List.copyOf(b.allowedOrigins);
|
||||
this.scopesSupported = List.copyOf(b.scopesSupported);
|
||||
this.middleware = List.copyOf(b.middleware);
|
||||
this.mechanisms = List.copyOf(b.mechanisms);
|
||||
}
|
||||
|
||||
String name() { return name; }
|
||||
@@ -54,6 +57,7 @@ public final class McpConfig {
|
||||
List<String> allowedOrigins() { return allowedOrigins; }
|
||||
List<String> scopesSupported() { return scopesSupported; }
|
||||
List<Middleware> middleware() { return middleware; }
|
||||
List<AuthenticationMechanism> mechanisms() { return mechanisms; }
|
||||
|
||||
public static Builder builder(String name) { return new Builder(name); }
|
||||
|
||||
@@ -68,6 +72,7 @@ public final class McpConfig {
|
||||
private final List<String> allowedOrigins = new ArrayList<>();
|
||||
private final List<String> scopesSupported = new ArrayList<>();
|
||||
private final List<Middleware> middleware = new ArrayList<>();
|
||||
private final List<AuthenticationMechanism> mechanisms = new ArrayList<>();
|
||||
|
||||
private Builder(String name) {
|
||||
if (name == null || name.isBlank())
|
||||
@@ -108,6 +113,15 @@ public final class McpConfig {
|
||||
/** Published as {@code scopes_supported} in the RFC 9728 metadata, so OAuth clients request them. */
|
||||
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
|
||||
|
||||
/**
|
||||
* The only mechanisms that authenticate the endpoint, and the only issuers its RFC 9728 metadata
|
||||
* names: any other credential, the session cookie included, is none here. Default: the whole chain.
|
||||
*/
|
||||
public Builder mechanisms(AuthenticationMechanism... mechanisms) {
|
||||
this.mechanisms.addAll(List.of(mechanisms));
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Runs on the MCP route after the transport guards and authentication — rate limiting, auditing, tracing. */
|
||||
public Builder middleware(Middleware... middleware) {
|
||||
this.middleware.addAll(List.of(middleware));
|
||||
|
||||
+27
-11
@@ -1,6 +1,8 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.ext.security.AuthenticationEntryPoint;
|
||||
import dev.relism.flash.ext.security.AuthenticationMechanism;
|
||||
import dev.relism.flash.ext.security.SecurityExtension;
|
||||
import dev.relism.flash.ext.security.SecurityIdentity;
|
||||
import dev.relism.flash.ext.security.SecurityPolicy;
|
||||
@@ -38,6 +40,7 @@ import java.util.Objects;
|
||||
public class McpExtension implements FlashExtension {
|
||||
|
||||
private final McpConfig config;
|
||||
private volatile List<SecurityScheme> schemes;
|
||||
|
||||
public McpExtension(McpConfig config) {
|
||||
this.config = config;
|
||||
@@ -65,16 +68,19 @@ public class McpExtension implements FlashExtension {
|
||||
|
||||
private void protect(FlashRegistrar<?> app, SecurityExtension security, List<Middleware> chain) {
|
||||
String metadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
|
||||
chain.add(security.enforce(SecurityPolicy.AUTHENTICATED, (req, res) -> {
|
||||
List<String> issuers = issuers(security);
|
||||
res.header("WWW-Authenticate", issuers.isEmpty()
|
||||
? String.join(", ", security.schemes().stream().map(SecurityScheme::challenge).toList())
|
||||
: "Bearer resource_metadata=\"" + req.origin() + metadataPath + "\"");
|
||||
List<AuthenticationMechanism> only = config.mechanisms();
|
||||
AuthenticationEntryPoint anonymous = (req, res) -> {
|
||||
List<SecurityScheme> schemes = schemes(security);
|
||||
res.header("WWW-Authenticate", issuers(schemes, null).isEmpty()
|
||||
? String.join(", ", schemes.stream().map(SecurityScheme::challenge).toList())
|
||||
: "Bearer resource_metadata=\"" + security.origin(req) + metadataPath + "\"");
|
||||
throw HttpException.unauthorized();
|
||||
}));
|
||||
};
|
||||
chain.add(only.isEmpty() ? security.enforce(SecurityPolicy.AUTHENTICATED, anonymous)
|
||||
: security.enforce(SecurityPolicy.AUTHENTICATED, anonymous, only));
|
||||
if (config.requireTokenAudience()) {
|
||||
chain.add(next -> (req, res) -> {
|
||||
String resource = req.origin() + config.rootPath();
|
||||
String resource = security.origin(req) + config.rootPath();
|
||||
if (!SecurityIdentity.current().principal().hasAudience(resource)) {
|
||||
log.warn("[flash-ext-mcp] Rejected a token not issued for {} (RFC 8707) — the authorization server must put it in aud", resource);
|
||||
throw HttpException.forbidden();
|
||||
@@ -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());
|
||||
}
|
||||
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");
|
||||
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) {
|
||||
return security.schemes().stream().map(SecurityScheme::issuer).filter(Objects::nonNull).toList();
|
||||
/** Resolved at the first request, once every mechanism has registered — which may be after this extension was ready. */
|
||||
private List<SecurityScheme> schemes(SecurityExtension security) {
|
||||
if (schemes == null) {
|
||||
schemes = config.mechanisms().isEmpty() ? security.schemes()
|
||||
: config.mechanisms().stream().flatMap(mechanism -> mechanism.schemes().stream()).toList();
|
||||
}
|
||||
return schemes;
|
||||
}
|
||||
|
||||
/** {@code "/"} is the application's own authorization server, at {@code origin}. */
|
||||
private static List<String> issuers(List<SecurityScheme> schemes, String origin) {
|
||||
return schemes.stream().map(SecurityScheme::issuer).filter(Objects::nonNull).map(issuer -> issuer.equals("/") ? origin : issuer).toList();
|
||||
}
|
||||
}
|
||||
|
||||
+48
@@ -1,6 +1,10 @@
|
||||
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.SecurityScheme;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.ext.security.apikey.ApiKey;
|
||||
import dev.relism.flash.ext.security.apikey.ApiKeyExtension;
|
||||
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.extension.RegisterExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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")
|
||||
.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. */
|
||||
@RegisterExtension
|
||||
static final FlashTest relaxed = FlashTest.of(flash -> flash
|
||||
@@ -83,6 +109,28 @@ class McpSecurityTest {
|
||||
.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. */
|
||||
@Test
|
||||
void anApiKeyIsAcceptedBesideOAuth() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# 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
|
||||
|
||||
@@ -10,8 +11,6 @@ OpenAPI 3.0.3 generation + Swagger UI for Flash.
|
||||
| `GET /openapi.yaml` | OpenAPI spec YAML |
|
||||
| `GET /openapi/swagger` | Swagger UI |
|
||||
|
||||
## Install
|
||||
|
||||
```java
|
||||
FlashApp.create(8080)
|
||||
.install(new JacksonExtension())
|
||||
@@ -20,131 +19,140 @@ FlashApp.create(8080)
|
||||
.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
|
||||
@GET("/users/{id}")
|
||||
@ApiOperation(summary = "Get user", description = "Returns one user", tags = {"users"})
|
||||
@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 { ... }
|
||||
```
|
||||
|
||||
## 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
|
||||
@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, example = "{\"id\":\"usr-1\"}"))
|
||||
@APIResponse(responseCode = "409", description = "That email is taken")
|
||||
@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
|
||||
@APIResponse(
|
||||
responseCode = "200",
|
||||
description = "Users listed",
|
||||
content = @Content(contentType = ContentType.JSON, schema = UserDto.class, array = true)
|
||||
)
|
||||
@Schema(name = "User", title = "User DTO", description = "Public user")
|
||||
public record UserDto(
|
||||
@SchemaProperty(title = "ID", example = "USR-100") String id,
|
||||
@SchemaProperty(hidden = true) String internalDebug) {}
|
||||
```
|
||||
|
||||
### No content
|
||||
|
||||
```java
|
||||
@APIResponse(
|
||||
responseCode = "204",
|
||||
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`
|
||||
Each type is described once under `components.schemas` and referenced everywhere it appears.
|
||||
Field-level exclusion: `@Schema(hidden = true)`, `@SchemaProperty(hidden = true)`, `@JsonIgnore`,
|
||||
`@JsonIgnoreProperties`, `transient`, `static`. `jakarta.validation` constraints (`@NotNull`,
|
||||
`@NotBlank`, `@NotEmpty`, `@Size`, `@Min`, `@Max`, `@Email`, `@Pattern`) become the schema's own
|
||||
bounds and required fields, so a rule is written once and documented for free.
|
||||
|
||||
## Contributor API
|
||||
|
||||
OpenAPI is extension-agnostic. Other extensions contribute with `OpenApiContributor` via
|
||||
`OpenApiContributorRegistry`.
|
||||
OpenAPI is extension-agnostic. Other extensions contribute through `OpenApiContributor`, held in
|
||||
`OpenApiContributorRegistry`:
|
||||
|
||||
Supported contribution surfaces:
|
||||
|
||||
- `components` fragments (merged with last-wins)
|
||||
- `components` fragments (merged last-wins)
|
||||
- operation `security` requirements (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**
|
||||
- manual `@APIResponse` description always wins over contributors for the same status
|
||||
|
||||
## Security interop
|
||||
### Security interop
|
||||
|
||||
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
|
||||
`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` 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`.
|
||||
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`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Operations are collected from final boot-time routes for class-based handlers with `@ApiOperation`.
|
||||
- Documented paths always match runtime paths (including scope namespaces/prefixes/rewrites).
|
||||
- Route path params are auto-discovered from `/{id}`.
|
||||
- Parameter annotations are mainly for query/header/cookie enrichment.
|
||||
- Output responses are sorted by numeric status code.
|
||||
- Operations come from the final boot-time routes, so documented paths match runtime paths,
|
||||
namespaces, prefixes and rewrites included.
|
||||
- Lambda routes are not documented: there is no class to read.
|
||||
- Responses are sorted by status code; the document is rebuilt only when a route is added.
|
||||
|
||||
+8
-4
@@ -1,19 +1,23 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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)
|
||||
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD})
|
||||
public @interface Content {
|
||||
ContentType contentType() default ContentType.JSON;
|
||||
Class<?> schema() default Void.class;
|
||||
boolean array() default false;
|
||||
|
||||
/** One example body, shown beside the schema. */
|
||||
String example() default "";
|
||||
}
|
||||
|
||||
+311
-421
@@ -1,47 +1,51 @@
|
||||
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.HttpMethod;
|
||||
import dev.relism.flash.http.HttpStatus;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.models.BodyHandler;
|
||||
import dev.relism.flash.routing.Consumes;
|
||||
import dev.relism.flash.routing.Produces;
|
||||
import dev.relism.flash.routing.Route;
|
||||
|
||||
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.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.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
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 {
|
||||
|
||||
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 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, Class<?>>> operationHandlers = new LinkedHashMap<>();
|
||||
private final SchemaRegistry schemas = new SchemaRegistry();
|
||||
private final Schemas schemas = new Schemas();
|
||||
private OpenApiContributorRegistry contributorRegistry;
|
||||
private boolean errorsDocumented;
|
||||
private int revision;
|
||||
private int builtRevision = -1;
|
||||
private Map<String, Object> cachedSpec;
|
||||
@@ -60,274 +65,384 @@ public final class OpenApiBuilder {
|
||||
public OpenApiBuilder description(String description) { this.description = description; return this; }
|
||||
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) {
|
||||
if (handlerClass.isAnnotationPresent(Undocumented.class)) return;
|
||||
|
||||
String path = normalizePath(route.path());
|
||||
String method = route.method().name().toLowerCase(Locale.ROOT);
|
||||
|
||||
Map<String, Object> operation = new LinkedHashMap<>();
|
||||
if (op != null) {
|
||||
if (!op.operationId().isEmpty()) operation.put("operationId", op.operationId());
|
||||
if (!op.summary().isEmpty()) operation.put("summary", op.summary());
|
||||
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);
|
||||
}
|
||||
|
||||
buildParameters(operation, handlerClass, route);
|
||||
buildResponses(operation, handlerClass);
|
||||
parameters(operation, handlerClass, route);
|
||||
requestBody(operation, handlerClass);
|
||||
responses(operation, handlerClass);
|
||||
|
||||
paths.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, operation);
|
||||
operationHandlers.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, handlerClass);
|
||||
paths.computeIfAbsent(path, p -> new LinkedHashMap<>()).put(method, operation);
|
||||
operationHandlers.computeIfAbsent(path, p -> new LinkedHashMap<>()).put(method, handlerClass);
|
||||
revision++;
|
||||
}
|
||||
|
||||
public Map<String, Object> build() {
|
||||
int r = revision;
|
||||
int current = revision;
|
||||
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<>();
|
||||
info.put("title", title);
|
||||
info.put("version", version);
|
||||
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<>();
|
||||
spec.put("openapi", OPENAPI_VERSION);
|
||||
spec.put("info", info);
|
||||
spec.put("paths", renderedPaths);
|
||||
|
||||
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 (!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);
|
||||
|
||||
cachedSpec = spec;
|
||||
builtRevision = r;
|
||||
builtRevision = current;
|
||||
return spec;
|
||||
}
|
||||
|
||||
private void buildParameters(Map<String, Object> op, Class<?> cls, Route route) {
|
||||
List<Map<String, Object>> params = new ArrayList<>();
|
||||
// ── Parameters ────────────────────────────────────────────────────────────
|
||||
|
||||
private void parameters(Map<String, Object> operation, Class<?> handlerClass, Route route) {
|
||||
List<Map<String, Object>> parameters = new ArrayList<>();
|
||||
|
||||
String path = route.path();
|
||||
int i = 0;
|
||||
while (i < path.length()) {
|
||||
int open = path.indexOf('{', i);
|
||||
if (open < 0) break;
|
||||
for (int open = path.indexOf('{'); open >= 0; open = path.indexOf('{', open + 1)) {
|
||||
int close = path.indexOf('}', open);
|
||||
if (close < 0) break;
|
||||
String name = path.substring(open + 1, close);
|
||||
params.add(new LinkedHashMap<>(Map.of(
|
||||
"name", name,
|
||||
parameters.add(new LinkedHashMap<>(Map.of(
|
||||
"name", path.substring(open + 1, close),
|
||||
"in", "path",
|
||||
"required", true,
|
||||
"schema", Map.of("type", "string")
|
||||
)));
|
||||
i = close + 1;
|
||||
"schema", Map.of("type", "string"))));
|
||||
open = close;
|
||||
}
|
||||
|
||||
for (Parameter ann : cls.getAnnotationsByType(Parameter.class)) {
|
||||
Map<String, Object> p = new LinkedHashMap<>();
|
||||
p.put("name", ann.name());
|
||||
p.put("in", ann.in().wireValue());
|
||||
p.put("required", ann.required());
|
||||
if (!ann.description().isEmpty()) p.put("description", ann.description());
|
||||
if (!ann.style().isEmpty()) p.put("style", ann.style());
|
||||
if (ann.explode()) p.put("explode", true);
|
||||
if (ann.allowEmptyValue()) p.put("allowEmptyValue", true);
|
||||
for (Parameter declared : handlerClass.getAnnotationsByType(Parameter.class)) {
|
||||
Map<String, Object> parameter = new LinkedHashMap<>();
|
||||
parameter.put("name", declared.name());
|
||||
parameter.put("in", declared.in().wireValue());
|
||||
parameter.put("required", declared.required());
|
||||
if (!declared.description().isEmpty()) parameter.put("description", declared.description());
|
||||
if (!declared.style().isEmpty()) parameter.put("style", declared.style());
|
||||
if (declared.explode()) parameter.put("explode", true);
|
||||
if (declared.allowEmptyValue()) parameter.put("allowEmptyValue", true);
|
||||
|
||||
Map<String, Object> schema = new LinkedHashMap<>();
|
||||
schema.put("type", ann.type().wireValue());
|
||||
if (!ann.example().isEmpty()) schema.put("example", ann.example());
|
||||
p.put("schema", schema);
|
||||
if (ann.examples().length > 0) p.put("examples", toExamples(ann.examples()));
|
||||
params.add(p);
|
||||
schema.put("type", declared.type().wireValue());
|
||||
if (!declared.example().isEmpty()) schema.put("example", declared.example());
|
||||
parameter.put("schema", schema);
|
||||
if (declared.examples().length > 0) parameter.put("examples", examples(declared.examples()));
|
||||
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) {
|
||||
APIResponse[] anns = cls.getAnnotationsByType(APIResponse.class);
|
||||
Map<Integer, Map<String, Object>> responseByCode = new LinkedHashMap<>();
|
||||
|
||||
for (APIResponse ann : anns) {
|
||||
int code = parseStatus(ann.responseCode());
|
||||
responseByCode.put(code, buildAnnotatedResponse(code, ann, cls));
|
||||
private static Map<String, Object> examples(String[] values) {
|
||||
Map<String, Object> examples = new LinkedHashMap<>();
|
||||
for (int i = 0; i < values.length; i++) examples.put("example" + (i + 1), Map.of("value", values[i]));
|
||||
return examples;
|
||||
}
|
||||
|
||||
if (responseByCode.isEmpty()) {
|
||||
// Mutable: contributors merge descriptions and headers into it.
|
||||
responseByCode.put(200, new LinkedHashMap<>(Map.of("description", "OK")));
|
||||
// ── Request body ──────────────────────────────────────────────────────────
|
||||
|
||||
/** 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<>();
|
||||
responseByCode.entrySet().stream()
|
||||
byStatus.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey(Comparator.naturalOrder()))
|
||||
.forEach(e -> responses.put(String.valueOf(e.getKey()), e.getValue()));
|
||||
op.put("responses", responses);
|
||||
.forEach(entry -> responses.put(String.valueOf(entry.getKey()), entry.getValue()));
|
||||
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() {
|
||||
return contributorRegistry != null ? contributorRegistry.contributors() : List.of();
|
||||
}
|
||||
|
||||
private void applyContributorResponses(Map<Integer, Map<String, Object>> responseByCode, Class<?> handlerClass) {
|
||||
APIResponse[] manual = handlerClass.getAnnotationsByType(APIResponse.class);
|
||||
Set<Integer> manualStatusCodes = new HashSet<>();
|
||||
for (APIResponse ann : manual) {
|
||||
manualStatusCodes.add(parseStatus(ann.responseCode()));
|
||||
}
|
||||
|
||||
private void applyContributorResponses(Map<Integer, Map<String, Object>> byStatus, Class<?> handlerClass,
|
||||
Set<Integer> declaredStatuses) {
|
||||
for (OpenApiContributor contributor : contributors()) {
|
||||
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
|
||||
if (contribution == null) continue;
|
||||
|
||||
for (Map.Entry<Integer, OpenApiResponseContribution> entry : contribution.responses().entrySet()) {
|
||||
int status = entry.getKey();
|
||||
OpenApiResponseContribution responseContribution = entry.getValue();
|
||||
if (responseContribution == null) continue;
|
||||
|
||||
Map<String, Object> response = responseByCode.computeIfAbsent(status, __ -> new LinkedHashMap<>());
|
||||
mergeContributorResponse(response, responseContribution, status, manualStatusCodes);
|
||||
for (var contributed : contribution.responses().entrySet()) {
|
||||
if (contributed.getValue() == null) continue;
|
||||
int status = contributed.getKey();
|
||||
Map<String, Object> response = byStatus.computeIfAbsent(status, s -> new LinkedHashMap<>());
|
||||
merge(response, contributed.getValue(), status, declaredStatuses.contains(status));
|
||||
}
|
||||
|
||||
OpenApiResponseContribution allResponses = contribution.allResponses();
|
||||
if (allResponses != null) {
|
||||
for (Map.Entry<Integer, Map<String, Object>> entry : responseByCode.entrySet()) {
|
||||
mergeContributorResponse(entry.getValue(), allResponses, entry.getKey(), manualStatusCodes);
|
||||
}
|
||||
OpenApiResponseContribution everywhere = contribution.allResponses();
|
||||
if (everywhere == null) continue;
|
||||
for (var response : byStatus.entrySet()) {
|
||||
merge(response.getValue(), everywhere, response.getKey(), declaredStatuses.contains(response.getKey()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void mergeContributorResponse(Map<String, Object> response,
|
||||
OpenApiResponseContribution contribution,
|
||||
int status,
|
||||
Set<Integer> manualStatusCodes) {
|
||||
String desc = contribution.description();
|
||||
if (!manualStatusCodes.contains(status) && desc != null && !desc.isBlank()) {
|
||||
response.put("description", desc);
|
||||
}
|
||||
private void merge(Map<String, Object> response, OpenApiResponseContribution contributed, int status, boolean declared) {
|
||||
String description = contributed.description();
|
||||
if (!declared && description != null && !description.isBlank()) response.put("description", description);
|
||||
|
||||
Map<String, Map<String, Object>> headerContributions = contribution.headers();
|
||||
if (!headerContributions.isEmpty()) {
|
||||
Map<String, Map<String, Object>> headers = contributed.headers();
|
||||
if (!headers.isEmpty()) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> headers = (Map<String, Object>) response.computeIfAbsent("headers", __ -> new LinkedHashMap<>());
|
||||
for (Map.Entry<String, Map<String, Object>> h : headerContributions.entrySet()) {
|
||||
headers.put(h.getKey(), new LinkedHashMap<>(h.getValue()));
|
||||
Map<String, Object> target = (Map<String, Object>) response.computeIfAbsent("headers", h -> new LinkedHashMap<>());
|
||||
headers.forEach((name, header) -> target.put(name, new LinkedHashMap<>(header)));
|
||||
}
|
||||
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")) {
|
||||
response.put("description", defaultDescription(status));
|
||||
}
|
||||
}
|
||||
|
||||
private static void applyContributorComponents(Map<String, Object> components, List<OpenApiContributor> contributors) {
|
||||
private void applyContributorSecurity(Map<String, Object> operation, Class<?> handlerClass,
|
||||
List<OpenApiContributor> contributors) {
|
||||
for (OpenApiContributor contributor : contributors) {
|
||||
Map<String, Object> c = contributor.componentContributions();
|
||||
if (c == null || c.isEmpty()) continue;
|
||||
deepMergeLastWins(components, c);
|
||||
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
|
||||
if (contribution == null || contribution.security().isEmpty()) continue;
|
||||
@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")
|
||||
private static void deepMergeLastWins(Map<String, Object> target, Map<String, Object> incoming) {
|
||||
for (Map.Entry<String, Object> e : incoming.entrySet()) {
|
||||
Object existing = target.get(e.getKey());
|
||||
Object value = e.getValue();
|
||||
if (existing instanceof Map<?, ?> em && value instanceof Map<?, ?> vm) {
|
||||
Map<String, Object> merged = new LinkedHashMap<>((Map<String, Object>) em);
|
||||
deepMergeLastWins(merged, (Map<String, Object>) vm);
|
||||
target.put(e.getKey(), merged);
|
||||
incoming.forEach((key, value) -> {
|
||||
Object existing = target.get(key);
|
||||
if (existing instanceof Map<?, ?> from && value instanceof Map<?, ?> to) {
|
||||
Map<String, Object> merged = new LinkedHashMap<>((Map<String, Object>) from);
|
||||
deepMergeLastWins(merged, (Map<String, Object>) to);
|
||||
target.put(key, merged);
|
||||
} else {
|
||||
target.put(e.getKey(), value);
|
||||
}
|
||||
target.put(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void applyContributorOperation(Map<String, Object> op, Class<?> handlerClass, List<OpenApiContributor> contributors) {
|
||||
for (OpenApiContributor contributor : contributors) {
|
||||
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
|
||||
if (contribution == null || contribution.isEmpty()) continue;
|
||||
// ── Shared responses ──────────────────────────────────────────────────────
|
||||
|
||||
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")
|
||||
List<Map<String, List<String>>> security = (List<Map<String, List<String>>>) op
|
||||
.computeIfAbsent("security", __ -> new ArrayList<>());
|
||||
security.addAll(contribution.security());
|
||||
private Map<String, Object> hoistSharedResponses(Map<String, Object> renderedPaths) {
|
||||
Map<String, Map<Object, Integer>> seen = new LinkedHashMap<>();
|
||||
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) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("description", ann.description().isEmpty() ? defaultDescription(code) : ann.description());
|
||||
// ── Odds and ends ─────────────────────────────────────────────────────────
|
||||
|
||||
Content content = ann.content();
|
||||
if (content.contentType() == ContentType.NONE) return out;
|
||||
|
||||
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 Map<String, Object> arrayOf(Map<String, Object> items) {
|
||||
return Map.of("type", "array", "items", items);
|
||||
}
|
||||
|
||||
private static int parseStatus(String code) {
|
||||
try {
|
||||
return Integer.parseInt(code.trim());
|
||||
} catch (Exception e) {
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalStateException("Invalid APIResponse.responseCode: " + code);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> resolveResponseSchema(Content content, Class<?> handlerClass) {
|
||||
if (content.schema() != Void.class) {
|
||||
Map<String, Object> base = schemas.referenceFor(content.schema());
|
||||
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 reasonFor(int status) {
|
||||
String reason = HttpStatus.reasonForCode(status);
|
||||
return reason == null ? "" : reason;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
String normalized = path.startsWith("/") ? path : "/" + path;
|
||||
while (normalized.startsWith("//")) {
|
||||
normalized = normalized.substring(1);
|
||||
}
|
||||
while (normalized.startsWith("//")) normalized = normalized.substring(1);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static String defaultDescription(int status) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** The route a handler class declares, through {@code @Route} or any shorthand carrying it. */
|
||||
static Route routeOf(Class<?> cls) {
|
||||
Route direct = cls.getAnnotation(Route.class);
|
||||
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;
|
||||
String path = readPathValue(ann);
|
||||
String path = pathOf(annotation);
|
||||
if (path == null) continue;
|
||||
HttpMethod method = meta.method();
|
||||
return new Route() {
|
||||
@@ -585,11 +476,10 @@ public final class OpenApiBuilder {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String readPathValue(Annotation ann) {
|
||||
private static String pathOf(Annotation annotation) {
|
||||
try {
|
||||
Object v = ann.annotationType().getMethod("value").invoke(ann);
|
||||
return v instanceof String s ? s : null;
|
||||
} catch (ReflectiveOperationException ignored) {
|
||||
return annotation.annotationType().getMethod("value").invoke(annotation) instanceof String path ? path : null;
|
||||
} catch (ReflectiveOperationException absent) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-13
@@ -10,7 +10,6 @@ import dev.relism.flash.extension.RouteEvent;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
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.
|
||||
@@ -25,8 +24,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
* <p>If {@code flash-ext-jackson} is installed, this extension reuses its
|
||||
* {@link ObjectMapper}. Otherwise it uses a local default mapper.
|
||||
*
|
||||
* <p>Operations are collected at boot from handlers annotated with {@link ApiOperation}
|
||||
* that also have route metadata ({@link Route} or shorthand verb annotations).
|
||||
* <p>Every class-based route is collected at boot, whether or not it is annotated: a path, the
|
||||
* body its handler takes and what it returns are already in the code. {@link ApiOperation} adds
|
||||
* the prose.
|
||||
*
|
||||
* <pre>{@code
|
||||
* FlashApp.create(8080)
|
||||
@@ -35,7 +35,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
* .start();
|
||||
* }</pre>
|
||||
*/
|
||||
@Slf4j
|
||||
public class OpenApiExtension implements FlashExtension {
|
||||
|
||||
private static final String YAML_CONTENT_TYPE = "application/yaml";
|
||||
@@ -121,18 +120,12 @@ public class OpenApiExtension implements FlashExtension {
|
||||
"</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) {
|
||||
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);
|
||||
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);
|
||||
builder.addOperation(routeOf(event), handlerClass.getAnnotation(ApiOperation.class), handlerClass);
|
||||
}
|
||||
|
||||
private static Route routeOf(RouteEvent event) {
|
||||
|
||||
+31
@@ -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 "";
|
||||
}
|
||||
+247
@@ -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;
|
||||
}
|
||||
}
|
||||
+21
@@ -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 {}
|
||||
+185
-3
@@ -23,7 +23,7 @@ class OpenApiBuilderTest {
|
||||
@GET("/users/{id}")
|
||||
@ApiOperation(summary = "Get user")
|
||||
@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 {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
@@ -33,7 +33,7 @@ class OpenApiBuilderTest {
|
||||
|
||||
@GET("/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 {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
@@ -43,7 +43,7 @@ class OpenApiBuilderTest {
|
||||
|
||||
@GET("/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 {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
@@ -133,6 +133,188 @@ class OpenApiBuilderTest {
|
||||
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
|
||||
void builds_single_response_and_parameters_and_schema() {
|
||||
OpenApiBuilder b = new OpenApiBuilder().title("X").version("1");
|
||||
|
||||
+3
-2
@@ -15,6 +15,7 @@ import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 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
|
||||
public SecurityScheme scheme() {
|
||||
return SecurityScheme.bearer("apiKey", prefix + "_<id>.<secret>");
|
||||
public List<SecurityScheme> schemes() {
|
||||
return List.of(SecurityScheme.bearer("apiKey", prefix + "_<id>.<secret>"));
|
||||
}
|
||||
|
||||
@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
|
||||
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`.
|
||||
|
||||
`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
|
||||
|
||||
`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
|
||||
return p;
|
||||
}
|
||||
public SecurityScheme scheme() { return SecurityScheme.bearer("key", "opaque"); }
|
||||
public List<SecurityScheme> schemes() { return List.of(SecurityScheme.bearer("key", "opaque")); }
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
+4
-2
@@ -2,6 +2,8 @@ package dev.relism.flash.ext.security;
|
||||
|
||||
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
|
||||
* 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);
|
||||
|
||||
/** How OpenAPI documents the credential and a 401 challenges for it; {@code null} for neither. */
|
||||
default SecurityScheme scheme() { return null; }
|
||||
/** How OpenAPI documents each credential this mechanism reads, and how a 401 challenges for it. */
|
||||
default List<SecurityScheme> schemes() { return List.of(); }
|
||||
}
|
||||
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
-13
@@ -60,7 +60,8 @@ public class SecurityExtension implements FlashExtension {
|
||||
private AuthenticationEntryPoint entryPoint = this::commence;
|
||||
private SessionStore sessions = new InMemorySessionStore();
|
||||
private Duration sessionTimeout = Duration.ofHours(12);
|
||||
private String loginPage = "/login";
|
||||
private String loginPage;
|
||||
private String origin;
|
||||
|
||||
// -- Configuration --------------------------------------------------------
|
||||
|
||||
@@ -92,23 +93,33 @@ public class SecurityExtension implements FlashExtension {
|
||||
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) {
|
||||
this.loginPage = loginPage;
|
||||
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) ---------------------------------------------
|
||||
|
||||
public synchronized SecurityExtension mechanism(AuthenticationMechanism mechanism) {
|
||||
mechanisms = append(mechanisms, mechanism);
|
||||
return mechanism.scheme() == null ? this : scheme(mechanism.scheme());
|
||||
}
|
||||
|
||||
/** Documents and challenges for a credential beyond the one {@link AuthenticationMechanism#scheme()} names. */
|
||||
public synchronized SecurityExtension scheme(SecurityScheme scheme) {
|
||||
for (SecurityScheme scheme : mechanism.schemes()) {
|
||||
schemes = append(schemes, scheme);
|
||||
challenges = challenges == null ? scheme.challenge() : challenges + ", " + scheme.challenge();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -136,17 +147,27 @@ public class SecurityExtension implements FlashExtension {
|
||||
|
||||
// -- 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.
|
||||
*
|
||||
* @throws AuthenticationFailedException a mechanism recognised one and rejected it
|
||||
*/
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
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) -> {
|
||||
SecurityIdentity identity;
|
||||
try {
|
||||
identity = authenticate(req);
|
||||
identity = authenticate(req, only);
|
||||
} catch (AuthenticationFailedException rejected) {
|
||||
if (policy.required) {
|
||||
if (rejected.challenge() != null) res.header(WWW_AUTHENTICATE, rejected.challenge());
|
||||
@@ -209,7 +243,7 @@ public class SecurityExtension implements FlashExtension {
|
||||
RANDOM.nextBytes(id);
|
||||
Session session = new Session(Base64.getUrlEncoder().withoutPadding().encodeToString(id), principal, expiresAt);
|
||||
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. */
|
||||
@@ -251,8 +285,9 @@ public class SecurityExtension implements FlashExtension {
|
||||
private Object commence(Request req, Response res) {
|
||||
LoginMethod[] methods = loginMethods;
|
||||
String accept = req.header("Accept");
|
||||
if (methods.length > 0 && accept != null && accept.contains("text/html")) {
|
||||
String login = methods.length == 1 && methods[0].kind() == LoginMethod.Kind.REDIRECT ? methods[0].url() : loginPage;
|
||||
if ((loginPage != null || methods.length > 0) && accept != null && accept.contains("text/html")) {
|
||||
String login = loginPage != null ? loginPage
|
||||
: methods.length == 1 && methods[0].kind() == LoginMethod.Kind.REDIRECT ? methods[0].url() : "/login";
|
||||
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);
|
||||
|
||||
+3
-1
@@ -7,7 +7,9 @@ import java.util.Map;
|
||||
* API call receives for it, and — for OAuth — the issuer that grants it.
|
||||
*
|
||||
* @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) {
|
||||
|
||||
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-2
@@ -2,6 +2,7 @@ package dev.relism.flash.ext.security;
|
||||
|
||||
import dev.relism.flash.ext.openapi.OpenApiExtension;
|
||||
import dev.relism.flash.models.Request;
|
||||
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;
|
||||
@@ -9,7 +10,9 @@ import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import java.time.Instant;
|
||||
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.assertTrue;
|
||||
|
||||
class SecurityExtensionTest {
|
||||
|
||||
@@ -29,8 +32,8 @@ class SecurityExtensionTest {
|
||||
}
|
||||
|
||||
@Override
|
||||
public SecurityScheme scheme() {
|
||||
return SecurityScheme.bearer("key", "opaque");
|
||||
public java.util.List<SecurityScheme> schemes() {
|
||||
return java.util.List.of(SecurityScheme.bearer("key", "opaque"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -45,6 +48,8 @@ class SecurityExtensionTest {
|
||||
.install(security)
|
||||
.install(new OpenApiExtension("/openapi", "test", "1"))
|
||||
.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) -> {
|
||||
security.signIn(req, res, new KeyPrincipal("carol", Set.of()), Instant.now().minusSeconds(1));
|
||||
return "signed in";
|
||||
@@ -76,6 +81,19 @@ class SecurityExtensionTest {
|
||||
.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
|
||||
void permitAllIdentifiesWhoeverAuthenticatesAndToleratesEveryoneElse() {
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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
|
||||
void loginMethodsAreListed() {
|
||||
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>
|
||||
+124
@@ -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;
|
||||
}
|
||||
}
|
||||
+60
@@ -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);
|
||||
}
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
+633
@@ -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);
|
||||
}
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
}
|
||||
+78
@@ -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;
|
||||
}
|
||||
}
|
||||
+265
@@ -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);
|
||||
}
|
||||
}
|
||||
+16
@@ -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
|
||||
|
||||
`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
|
||||
lookup, however many providers are configured. A token from an unconfigured issuer is left to other
|
||||
mechanisms; a token from a configured one that fails verification is `401 invalid_token`.
|
||||
provider's keys (RS/PS/ES algorithms, `iss`, `sub`, `exp`). One parse, one map lookup, however many
|
||||
providers are configured. A token from an unconfigured issuer is left to other mechanisms; a token from
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
`{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`.
|
||||
`{origin}/auth/oidc/{id}/callback` as a redirect URI and `{origin}/` as a post-logout redirect URI,
|
||||
where `{origin}` is `SecurityExtension.origin(...)`.
|
||||
|
||||
Each provider is listed at `/auth/methods` (`"kind":"redirect"`) and published to OpenAPI as an
|
||||
`openIdConnect` scheme.
|
||||
@@ -45,7 +49,7 @@ oidc.unregister("acme");
|
||||
```
|
||||
|
||||
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
|
||||
`/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
|
||||
|
||||
+14
-7
@@ -19,8 +19,10 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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
|
||||
* 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 IllegalStateException the provider could not be reached
|
||||
*/
|
||||
@@ -111,12 +113,17 @@ public final class OidcExtension implements FlashExtension, AuthenticationMechan
|
||||
ctx.onReady(() -> {
|
||||
security = ctx.require(SecurityExtension.class).mechanism(this).refresher(OidcPrincipal.class, this::refresh);
|
||||
for (OidcProvider config : configured) {
|
||||
security.scheme(SecurityScheme.openIdConnect(config.id(), byId.get(config.id()).issuer))
|
||||
.loginMethod(new LoginMethod(config.id(), config.name(), "/auth/oidc/" + config.id() + "/login", LoginMethod.Kind.REDIRECT));
|
||||
security.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 -------------------------------------------------
|
||||
|
||||
@Override
|
||||
@@ -150,9 +157,9 @@ public final class OidcExtension implements FlashExtension, AuthenticationMechan
|
||||
String nonce = random(16);
|
||||
res.header("Set-Cookie", FLOW + "=" + state + "." + verifier + "." + nonce + "."
|
||||
+ 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)));
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
try {
|
||||
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");
|
||||
Map<String, Object> claims = sessionClaims(provider.verifyIdToken(idToken), tokens);
|
||||
if (!flow[2].equals(claims.get("nonce"))) throw new IllegalStateException("nonce mismatch");
|
||||
String logout = provider.endSessionEndpoint == null ? null : provider.endSessionEndpoint
|
||||
+ (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,
|
||||
(String) tokens.get("access_token"), (String) tokens.get("refresh_token"), logout);
|
||||
security.signIn(req, res, principal, expiry(tokens));
|
||||
|
||||
+7
-20
@@ -12,9 +12,8 @@ import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
|
||||
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.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
@@ -48,17 +47,17 @@ final class Provider {
|
||||
Provider(OidcProvider config, boolean guarded) {
|
||||
this.config = config;
|
||||
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(),
|
||||
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");
|
||||
authorizationEndpoint = (String) discovery.get("authorization_endpoint");
|
||||
tokenEndpoint = (String) discovery.get("token_endpoint");
|
||||
endSessionEndpoint = (String) discovery.get("end_session_endpoint");
|
||||
JWKSource<SecurityContext> keys = JWKSourceBuilder.create(URI.create((String) discovery.get("jwks_uri")).toURL()).retrying(true).build();
|
||||
accessTokens = processor(keys, null, new JOSEObjectType("at+jwt"));
|
||||
idTokens = processor(keys, config.clientId(), null);
|
||||
accessTokens = processor(keys, null, new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt"), new JOSEObjectType("application/at+jwt")));
|
||||
idTokens = processor(keys, config.clientId(), DefaultJOSEObjectTypeVerifier.JWT);
|
||||
} catch (Exception e) {
|
||||
if (e instanceof IllegalArgumentException rejected) throw rejected;
|
||||
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) {
|
||||
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);
|
||||
algorithms.addAll(JWSAlgorithm.Family.EC);
|
||||
DefaultJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
|
||||
processor.setJWSKeySelector(new JWSVerificationKeySelector<>(algorithms, keys));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -64,6 +64,13 @@ class OidcExtensionTest {
|
||||
.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. */
|
||||
@Test
|
||||
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
|
||||
```
|
||||
|
||||
`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`
|
||||
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:
|
||||
|
||||
+13
-3
@@ -1,5 +1,6 @@
|
||||
package dev.relism.flash.ext.security.test;
|
||||
|
||||
import com.nimbusds.jose.JOSEObjectType;
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.JWSHeader;
|
||||
import com.nimbusds.jose.crypto.RSASSASigner;
|
||||
@@ -76,7 +77,7 @@ public final class FakeOidcProvider implements AutoCloseable {
|
||||
Map<String, Object> id = new HashMap<>(claims);
|
||||
id.put("aud", "app");
|
||||
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")));
|
||||
});
|
||||
server.start();
|
||||
@@ -106,13 +107,22 @@ public final class FakeOidcProvider implements AutoCloseable {
|
||||
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) {
|
||||
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 {
|
||||
JWTClaimsSet.Builder set = new JWTClaimsSet.Builder().issuer(issuer).subject(subject)
|
||||
.issueTime(new Date()).expirationTime(new Date(System.currentTimeMillis() + 300_000));
|
||||
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));
|
||||
return jwt.serialize();
|
||||
} catch (Exception e) {
|
||||
|
||||
+138
@@ -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.
|
||||
-69
@@ -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);
|
||||
}
|
||||
}
|
||||
-37
@@ -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>
|
||||
+108
@@ -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).");
|
||||
}
|
||||
}
|
||||
+63
@@ -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.
|
||||
+1
-9
@@ -10,25 +10,17 @@
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-web-bundler</artifactId>
|
||||
<artifactId>flash-ext-vite</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+173
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -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;
|
||||
}
|
||||
}
|
||||
+73
@@ -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;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
+74
@@ -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]; }
|
||||
};
|
||||
}
|
||||
}
|
||||
+56
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -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));
|
||||
}
|
||||
}
|
||||
+83
@@ -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");
|
||||
BIN
Binary file not shown.
@@ -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
Reference in New Issue
Block a user