Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f06ca7c1d | ||
|
|
2fbe65fcc5 | ||
|
|
adcd6376b6 | ||
|
|
ef4740f26d |
@@ -9,7 +9,9 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model |
|
| `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model |
|
||||||
| `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses |
|
| `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses |
|
||||||
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
|
| `flash-extensions/flash-ext-jackson-core` | What every Jackson format shares: the codec, the body handler, the constraints a body is checked against |
|
||||||
|
| `flash-extensions/flash-ext-jackson-json` | JSON bodies and responses |
|
||||||
|
| `flash-extensions/flash-ext-jackson-xml` | XML bodies and responses |
|
||||||
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
|
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
|
||||||
| `flash-extensions/flash-ext-security-core` | Security: authentication chain, annotations, sessions, OpenAPI |
|
| `flash-extensions/flash-ext-security-core` | Security: authentication chain, annotations, sessions, OpenAPI |
|
||||||
| `flash-extensions/flash-ext-security-oidc` | OpenID Connect: bearer tokens, code flow + PKCE |
|
| `flash-extensions/flash-ext-security-oidc` | OpenID Connect: bearer tokens, code flow + PKCE |
|
||||||
@@ -23,7 +25,6 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
|
|||||||
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
|
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
|
||||||
| `flash-extensions/flash-ext-vite` | Vite frontend: dev server in DEV, the built SPA from the jar otherwise |
|
| `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-vite-maven-plugin` | Builds the Vite frontend into the jar during `mvn package` |
|
||||||
| `flash-extensions/flash-ext-validation` | Request validation — jakarta constraints, compiled once per type |
|
|
||||||
| `flash-extensions/flash-ext-scheduler` | Interval and cron background jobs on virtual threads |
|
| `flash-extensions/flash-ext-scheduler` | Interval and cron background jobs on virtual threads |
|
||||||
| `flash-extensions/flash-ext-cache-core` | Caching contract — `Cache`, `CacheManager`, `CacheSpec` |
|
| `flash-extensions/flash-ext-cache-core` | Caching contract — `Cache`, `CacheManager`, `CacheSpec` |
|
||||||
| `flash-extensions/flash-ext-cache-caffeine` | In-process cache backed by Caffeine |
|
| `flash-extensions/flash-ext-cache-caffeine` | In-process cache backed by Caffeine |
|
||||||
@@ -151,7 +152,9 @@ FlashApp.create(8080)
|
|||||||
```
|
```
|
||||||
|
|
||||||
See extension-specific READMEs for full details:
|
See extension-specific READMEs for full details:
|
||||||
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
|
- [`flash-ext-jackson-core`](flash-extensions/flash-ext-jackson-core/README.md)
|
||||||
|
- [`flash-ext-jackson-json`](flash-extensions/flash-ext-jackson-json/README.md)
|
||||||
|
- [`flash-ext-jackson-xml`](flash-extensions/flash-ext-jackson-xml/README.md)
|
||||||
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
|
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
|
||||||
- [`flash-ext-security-core`](flash-extensions/flash-ext-security-core/docs/README.md)
|
- [`flash-ext-security-core`](flash-extensions/flash-ext-security-core/docs/README.md)
|
||||||
- [`flash-ext-security-oidc`](flash-extensions/flash-ext-security-oidc/docs/README.md)
|
- [`flash-ext-security-oidc`](flash-extensions/flash-ext-security-oidc/docs/README.md)
|
||||||
@@ -161,7 +164,6 @@ See extension-specific READMEs for full details:
|
|||||||
- [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md)
|
- [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md)
|
||||||
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md)
|
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md)
|
||||||
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
|
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
|
||||||
- [`flash-ext-validation`](flash-extensions/flash-ext-validation/docs/README.md)
|
|
||||||
- [`flash-ext-scheduler`](flash-extensions/flash-ext-scheduler/docs/README.md)
|
- [`flash-ext-scheduler`](flash-extensions/flash-ext-scheduler/docs/README.md)
|
||||||
- [`flash-ext-cache-caffeine`](flash-extensions/flash-ext-cache-caffeine/docs/README.md)
|
- [`flash-ext-cache-caffeine`](flash-extensions/flash-ext-cache-caffeine/docs/README.md)
|
||||||
- [`flash-testing`](flash-testing/docs/README.md)
|
- [`flash-testing`](flash-testing/docs/README.md)
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
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.lang.invoke.MethodHandle;
|
||||||
import java.util.regex.Pattern;
|
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;
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ public final class ValidationException extends HttpException {
|
|||||||
|
|
||||||
private final transient List<Violation> violations;
|
private final transient List<Violation> violations;
|
||||||
|
|
||||||
ValidationException(List<Violation> violations) {
|
public ValidationException(List<Violation> violations) {
|
||||||
super(422, describe(violations));
|
super(422, describe(violations));
|
||||||
this.violations = List.copyOf(violations);
|
this.violations = List.copyOf(violations);
|
||||||
}
|
}
|
||||||
+27
-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.Email;
|
||||||
import jakarta.validation.constraints.Max;
|
import jakarta.validation.constraints.Max;
|
||||||
@@ -31,12 +31,38 @@ public final class Validator {
|
|||||||
|
|
||||||
private static final Check[] NONE = new Check[0];
|
private static final Check[] NONE = new Check[0];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One compiled validator per type, kept beside the class itself: no map lookup, no lock, and
|
||||||
|
* the entry is collected with the class rather than pinning it.
|
||||||
|
*/
|
||||||
|
private static final ClassValue<Validator> COMPILED = new ClassValue<>() {
|
||||||
|
@Override protected Validator computeValue(Class<?> type) {
|
||||||
|
return compile(type);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private final Check[] checks;
|
private final Check[] checks;
|
||||||
|
|
||||||
private Validator(Check[] checks) {
|
private Validator(Check[] checks) {
|
||||||
this.checks = checks;
|
this.checks = checks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The constraints of {@code type}, compiled the first time it is seen and reused after. */
|
||||||
|
public static Validator of(Class<?> type) {
|
||||||
|
return COMPILED.get(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a value against its own type's constraints.
|
||||||
|
*
|
||||||
|
* @return {@code value}, so it can be used inline
|
||||||
|
* @throws ValidationException 422, listing every failure
|
||||||
|
*/
|
||||||
|
public static <T> T check(T value) {
|
||||||
|
of(value.getClass()).verify(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
/** True when the type declares no constraints at all — {@link #verify} is then a no-op. */
|
/** True when the type declares no constraints at all — {@link #verify} is then a no-op. */
|
||||||
public boolean isEmpty() {
|
public boolean isEmpty() {
|
||||||
return checks.length == 0;
|
return checks.length == 0;
|
||||||
+1
-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.Email;
|
||||||
import jakarta.validation.constraints.Max;
|
import jakarta.validation.constraints.Max;
|
||||||
@@ -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.APIResponse;
|
||||||
import dev.relism.flash.ext.openapi.ApiOperation;
|
import dev.relism.flash.ext.openapi.ApiOperation;
|
||||||
import dev.relism.flash.ext.openapi.Content;
|
import dev.relism.flash.ext.openapi.Content;
|
||||||
@@ -24,7 +24,7 @@ import org.junit.jupiter.api.extension.RegisterExtension;
|
|||||||
* describes them. Nothing registers this bridge — flash-ext-openapi picks the annotations up on
|
* describes them. Nothing registers this bridge — flash-ext-openapi picks the annotations up on
|
||||||
* its own when they are on the classpath.
|
* its own when they are on the classpath.
|
||||||
*/
|
*/
|
||||||
class ValidationOpenApiInteropTest {
|
class ConstraintsInTheDocumentTest {
|
||||||
|
|
||||||
record Account(
|
record Account(
|
||||||
@NotBlank @Size(max = 40) String name,
|
@NotBlank @Size(max = 40) String name,
|
||||||
@@ -42,10 +42,9 @@ class ValidationOpenApiInteropTest {
|
|||||||
|
|
||||||
@RegisterExtension
|
@RegisterExtension
|
||||||
static FlashTest app = FlashTest.of(configured -> {
|
static FlashTest app = FlashTest.of(configured -> {
|
||||||
configured.install(new JacksonExtension());
|
configured.install(new JsonExtension());
|
||||||
configured.install(new ValidationExtension());
|
configured.install(new OpenApiExtension("/openapi", "Accounts", "1.0.0"));
|
||||||
configured.install(new OpenApiExtension("/openapi", "Accounts", "1.0.0"));
|
configured.scan("dev.relism.flash.ext.jackson.json");
|
||||||
configured.scan("dev.relism.flash.ext.validation");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@Test
|
@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 com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
@@ -16,26 +16,25 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
class JacksonExtensionTest {
|
class JsonExtensionTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void configure_registers_json_mapper_and_middleware() {
|
void configure_registers_json_mapper_and_middleware() {
|
||||||
FlashContext ctx = new FlashContext();
|
FlashContext ctx = new FlashContext();
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
JacksonExtension ext = new JacksonExtension(mapper);
|
JsonExtension ext = new JsonExtension(mapper);
|
||||||
|
|
||||||
ext.configure(null, ctx);
|
ext.configure(null, ctx);
|
||||||
ctx.complete();
|
ctx.complete();
|
||||||
|
|
||||||
assertNotNull(ctx.require(Json.class));
|
assertNotNull(ctx.require(Json.class));
|
||||||
assertNotNull(ctx.require(JacksonMiddleware.class));
|
|
||||||
assertSame(mapper, ctx.require(ObjectMapper.class));
|
assertSame(mapper, ctx.require(ObjectMapper.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void autoJson_factory_delegates_to_middleware_policy() throws Exception {
|
void auto_marshals_what_a_handler_returns() throws Exception {
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
JacksonExtension ext = new JacksonExtension(mapper);
|
JsonExtension ext = new JsonExtension(mapper);
|
||||||
RequestHandler next = new RequestHandler() {
|
RequestHandler next = new RequestHandler() {
|
||||||
@Override
|
@Override
|
||||||
public Object handle(Request request, Response response) {
|
public Object handle(Request request, Response response) {
|
||||||
@@ -43,7 +42,7 @@ class JacksonExtensionTest {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
RequestHandler wrapped = new RequestHandler() {
|
RequestHandler wrapped = new RequestHandler() {
|
||||||
private final SimpleHandler.FunctionalHandler delegate = ext.autoJson().wrap(next);
|
private final SimpleHandler.FunctionalHandler delegate = ext.auto().wrap(next);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Object handle(Request request, Response response) throws Exception {
|
public Object handle(Request request, Response response) throws Exception {
|
||||||
+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.annotation.JsonView;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -37,16 +37,11 @@ class JsonTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void bodyFrom_parses_stream_and_maps_bad_payload_to_http_400() throws Exception {
|
void a_truncated_body_is_a_bad_request_too() throws Exception {
|
||||||
Json json = new Json(new ObjectMapper());
|
Json json = new Json(new ObjectMapper());
|
||||||
|
|
||||||
Request ok = request("{\"id\":\"u2\",\"name\":\"bob\"}");
|
HttpException ex = assertThrows(HttpException.class, () -> json.body(request("["), UserDto.class));
|
||||||
UserDto dto = json.bodyFrom(ok, UserDto.class);
|
|
||||||
assertEquals("u2", dto.id);
|
|
||||||
assertEquals("bob", dto.name);
|
|
||||||
|
|
||||||
Request bad = request("[");
|
|
||||||
HttpException ex = assertThrows(HttpException.class, () -> json.bodyFrom(bad, UserDto.class));
|
|
||||||
assertEquals(400, ex.status());
|
assertEquals(400, ex.status());
|
||||||
}
|
}
|
||||||
|
|
||||||
+13
-11
@@ -1,6 +1,8 @@
|
|||||||
package dev.relism.flash.ext.jackson;
|
package dev.relism.flash.ext.jackson.json;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import dev.relism.flash.ext.jackson.Marshalling;
|
||||||
|
import dev.relism.flash.routing.Middleware;
|
||||||
import dev.relism.flash.models.SimpleHandler;
|
import dev.relism.flash.models.SimpleHandler;
|
||||||
import dev.relism.flash.http.ContentType;
|
import dev.relism.flash.http.ContentType;
|
||||||
import dev.relism.flash.models.Request;
|
import dev.relism.flash.models.Request;
|
||||||
@@ -16,13 +18,13 @@ import static org.junit.jupiter.api.Assertions.assertSame;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
class JacksonMiddlewareTest {
|
class MarshallingTest {
|
||||||
|
|
||||||
private static final Request REQ = null;
|
private static final Request REQ = null;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void autoJson_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception {
|
void marshalling_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception {
|
||||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
|
||||||
RequestHandler wrapped = wrap(mw, new UserDto("u1", "alice"));
|
RequestHandler wrapped = wrap(mw, new UserDto("u1", "alice"));
|
||||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
||||||
|
|
||||||
@@ -34,8 +36,8 @@ class JacksonMiddlewareTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void autoJson_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception {
|
void marshalling_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception {
|
||||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
|
||||||
|
|
||||||
Response payloadResponse = new Response(201, ContentType.TEXT_PLAIN).body("ok");
|
Response payloadResponse = new Response(201, ContentType.TEXT_PLAIN).body("ok");
|
||||||
RequestHandler wrappedResponse = wrap(mw, payloadResponse);
|
RequestHandler wrappedResponse = wrap(mw, payloadResponse);
|
||||||
@@ -55,17 +57,17 @@ class JacksonMiddlewareTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void autoJson_wraps_serialization_errors_as_illegal_state() {
|
void marshalling_wraps_serialization_errors_as_illegal_state() {
|
||||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
|
||||||
RequestHandler wrapped = wrap(mw, new CyclicDto());
|
RequestHandler wrapped = wrap(mw, new CyclicDto());
|
||||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
||||||
|
|
||||||
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> wrapped.handle(REQ, res));
|
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> wrapped.handle(REQ, res));
|
||||||
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
|
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
|
||||||
assertTrue(ex.getMessage().startsWith("Failed to serialize handler result as JSON:"));
|
assertTrue(ex.getMessage().startsWith("Could not serialize"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RequestHandler wrap(JacksonMiddleware mw, Object fixedReturn) {
|
private static RequestHandler wrap(Middleware mw, Object fixedReturn) {
|
||||||
RequestHandler next = new RequestHandler() {
|
RequestHandler next = new RequestHandler() {
|
||||||
@Override
|
@Override
|
||||||
public Object handle(Request request, Response response) {
|
public Object handle(Request request, Response response) {
|
||||||
@@ -73,7 +75,7 @@ class JacksonMiddlewareTest {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
return new RequestHandler() {
|
return new RequestHandler() {
|
||||||
private final SimpleHandler.FunctionalHandler delegate = mw.autoJson().wrap(next);
|
private final SimpleHandler.FunctionalHandler delegate = mw.wrap(next);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Object handle(Request request, Response response) throws Exception {
|
public Object handle(Request request, Response response) throws Exception {
|
||||||
+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 dev.relism.flash.testing.FlashTest;
|
||||||
import jakarta.validation.constraints.Email;
|
import jakarta.validation.constraints.Email;
|
||||||
import jakarta.validation.constraints.Min;
|
import jakarta.validation.constraints.Min;
|
||||||
@@ -12,19 +11,18 @@ import org.junit.jupiter.api.extension.RegisterExtension;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
/** The whole path: JSON in, constraints checked, status out — with no error handling wired up. */
|
/** The whole path: JSON in, constraints checked, status out — with no error handling wired up. */
|
||||||
class ValidationRoutesTest {
|
class ValidatedBodyTest {
|
||||||
|
|
||||||
record CreateUser(@NotBlank @Size(max = 8) String name, @Email String email, @Min(18) int age) {}
|
record CreateUser(@NotBlank @Size(max = 8) String name, @Email String email, @Min(18) int age) {}
|
||||||
|
|
||||||
@RegisterExtension
|
@RegisterExtension
|
||||||
static FlashTest app = FlashTest.of(configured -> {
|
static FlashTest app = FlashTest.of(configured -> {
|
||||||
configured.install(new JacksonExtension());
|
configured.install(new JsonExtension());
|
||||||
configured.install(new ValidationExtension());
|
|
||||||
|
|
||||||
configured.ctx().onReady(() -> {
|
configured.ctx().onReady(() -> {
|
||||||
Validation validation = configured.ctx().require(Validation.class);
|
Json json = configured.ctx().require(Json.class);
|
||||||
configured.post("/users", (req, res) ->
|
configured.post("/users", (req, res) ->
|
||||||
res.status(201).body("created:" + validation.body(req, CreateUser.class).name()));
|
res.status(201).body("created:" + json.body(req, CreateUser.class).name()));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# flash-ext-jackson-xml
|
||||||
|
|
||||||
|
XML bodies and XML responses, over the same types as every other Jackson format.
|
||||||
|
|
||||||
|
```java
|
||||||
|
XmlExtension xml = new XmlExtension();
|
||||||
|
app.install(xml).use(xml.auto());
|
||||||
|
```
|
||||||
|
|
||||||
|
```java
|
||||||
|
@POST("/orders")
|
||||||
|
public final class PlaceOrder extends XmlHandler<Order> {
|
||||||
|
@Inject private OrderService orders;
|
||||||
|
|
||||||
|
@Override protected Object handle(Request req, Response res, Order body) {
|
||||||
|
return orders.place(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything [`flash-ext-jackson-json`](../flash-ext-jackson-json) does, in XML: the body is read off
|
||||||
|
the request stream, verified against the constraints its type declares, and handed over. A type
|
||||||
|
annotated for JSON works here as is — `Order` can be a JSON body on one route and an XML body on
|
||||||
|
another.
|
||||||
|
|
||||||
|
What is specific to XML is Jackson's own: `@JacksonXmlRootElement` for the root name,
|
||||||
|
`@JacksonXmlProperty(isAttribute = true)` for an attribute rather than an element, and
|
||||||
|
`@JacksonXmlElementWrapper` for how a list is wrapped. This module adds no annotations of its own.
|
||||||
|
|
||||||
|
Brings `jackson-dataformat-xml`, and with it Woodstox.
|
||||||
+13
-21
@@ -10,42 +10,34 @@
|
|||||||
<version>2.1.0-SNAPSHOT</version>
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<artifactId>flash-ext-validation</artifactId>
|
<artifactId>flash-ext-jackson-xml</artifactId>
|
||||||
|
<name>flash-ext-jackson-xml</name>
|
||||||
|
<description>XML bodies and responses, over the same types and the same constraints as every other Jackson format.</description>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash</artifactId>
|
<artifactId>flash-ext-jackson-core</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!--
|
|
||||||
Annotations only (~90 KB). Hibernate Validator's engine is deliberately absent: it
|
|
||||||
resolves constraints reflectively per call and pulls ~2 MB plus EL. This module
|
|
||||||
compiles the same annotations into a flat check table once per class instead.
|
|
||||||
-->
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>jakarta.validation</groupId>
|
<groupId>com.fasterxml.jackson.dataformat</groupId>
|
||||||
<artifactId>jakarta.validation-api</artifactId>
|
<artifactId>jackson-dataformat-xml</artifactId>
|
||||||
</dependency>
|
|
||||||
<!-- Only needed for Validation.body(...); check(...) works without it. -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-ext-jackson</artifactId>
|
|
||||||
<optional>true</optional>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.junit.jupiter</groupId>
|
<groupId>org.junit.jupiter</groupId>
|
||||||
<artifactId>junit-jupiter</artifactId>
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>jakarta.validation</groupId>
|
||||||
|
<artifactId>jakarta.validation-api</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-testing</artifactId>
|
<artifactId>flash-testing</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- Interop only: proves constraints reach the published schema. -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-ext-openapi</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</project>
|
</project>
|
||||||
+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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# flash-ext-openapi
|
# flash-ext-openapi
|
||||||
|
|
||||||
OpenAPI 3.0.3 generation + Swagger UI for Flash.
|
OpenAPI 3.0.3 generation and Swagger UI, built from what the handlers already say about
|
||||||
|
themselves.
|
||||||
|
|
||||||
## What it provides
|
## What it provides
|
||||||
|
|
||||||
@@ -10,8 +11,6 @@ OpenAPI 3.0.3 generation + Swagger UI for Flash.
|
|||||||
| `GET /openapi.yaml` | OpenAPI spec YAML |
|
| `GET /openapi.yaml` | OpenAPI spec YAML |
|
||||||
| `GET /openapi/swagger` | Swagger UI |
|
| `GET /openapi/swagger` | Swagger UI |
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```java
|
```java
|
||||||
FlashApp.create(8080)
|
FlashApp.create(8080)
|
||||||
.install(new JacksonExtension())
|
.install(new JacksonExtension())
|
||||||
@@ -20,131 +19,122 @@ FlashApp.create(8080)
|
|||||||
.startAndBlock();
|
.startAndBlock();
|
||||||
```
|
```
|
||||||
|
|
||||||
## Operation annotation
|
## What you get without writing anything
|
||||||
|
|
||||||
|
Every class-based route is documented, annotated or not. Read off the code:
|
||||||
|
|
||||||
|
- **path parameters**, from `/{id}` in the route
|
||||||
|
- **the request body**, from the handler's own body type (see below)
|
||||||
|
- **the response schema**, from what `handle` returns — an object, a `List<T>`, a `Map<String, T>`
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## 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 comes from `@Consumes` on the base class, so an XML handler documents itself as
|
||||||
|
XML without a word from the route.
|
||||||
|
|
||||||
|
For a handler that reads the body by hand, or to describe it as something else, declare it:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@PUT("/users")
|
||||||
|
@RequestBody(value = User.class, array = true, description = "Users to store")
|
||||||
|
public final class ReplaceUsers extends RequestHandler { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
```java
|
```java
|
||||||
@GET("/users/{id}")
|
@GET("/users/{id}")
|
||||||
@ApiOperation(summary = "Get user", description = "Returns one user", tags = {"users"})
|
@ApiOperation(summary = "Get user", description = "Returns one user", tags = {"users"})
|
||||||
@Parameter(name = "expand", in = ParameterIn.QUERY, type = SchemaType.STRING, examples = {"roles", "permissions"})
|
@Parameter(name = "expand", in = ParameterIn.QUERY, type = SchemaType.STRING, examples = {"roles", "permissions"})
|
||||||
@APIResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
description = "User found",
|
|
||||||
content = @Content(contentType = ContentType.JSON, schema = UserDto.class)
|
|
||||||
)
|
|
||||||
public final class GetUser extends RequestHandler { ... }
|
public final class GetUser extends RequestHandler { ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
## Response patterns
|
`@ApiOperation` is optional: without it the route is still in the document, with no summary.
|
||||||
|
|
||||||
### Single object
|
## Responses
|
||||||
|
|
||||||
|
The success response is inferred. Declare one only to say more:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
@APIResponse(
|
@APIResponse(responseCode = "200", description = "User found",
|
||||||
responseCode = "200",
|
content = @Content(schema = UserDto.class, example = "{\"id\":\"usr-1\"}"))
|
||||||
description = "User found",
|
@APIResponse(responseCode = "409", description = "That email is taken")
|
||||||
content = @Content(contentType = ContentType.JSON, schema = UserDto.class)
|
@APIResponse(responseCode = "204", content = @Content(contentType = ContentType.NONE))
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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`.
|
||||||
|
- `content.array = true` wraps whichever schema was chosen.
|
||||||
|
- `contentType = NONE` documents a response with no body.
|
||||||
|
|
||||||
|
**A response several operations share is written once.** Identical answers — the 401 of every
|
||||||
|
guarded route, the 429 of every limited one — become `components.responses` entries referenced by
|
||||||
|
`$ref`, instead of being repeated on every path.
|
||||||
|
|
||||||
|
## DTO schemas
|
||||||
|
|
||||||
```java
|
```java
|
||||||
@APIResponse(
|
@Schema(name = "User", title = "User DTO", description = "Public user")
|
||||||
responseCode = "200",
|
public record UserDto(
|
||||||
description = "Users listed",
|
@SchemaProperty(title = "ID", example = "USR-100") String id,
|
||||||
content = @Content(contentType = ContentType.JSON, schema = UserDto.class, array = true)
|
@SchemaProperty(hidden = true) String internalDebug) {}
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### No content
|
Each type is described once under `components.schemas` and referenced everywhere it appears.
|
||||||
|
Field-level exclusion: `@Schema(hidden = true)`, `@SchemaProperty(hidden = true)`, `@JsonIgnore`,
|
||||||
```java
|
`@JsonIgnoreProperties`, `transient`, `static`. `jakarta.validation` constraints (`@NotNull`,
|
||||||
@APIResponse(
|
`@NotBlank`, `@NotEmpty`, `@Size`, `@Min`, `@Max`, `@Email`, `@Pattern`) become the schema's own
|
||||||
responseCode = "204",
|
bounds and required fields, so a rule is written once and documented for free.
|
||||||
description = "Deleted",
|
|
||||||
content = @Content(contentType = ContentType.NONE)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Inferred from handler return type
|
|
||||||
|
|
||||||
```java
|
|
||||||
@APIResponse(
|
|
||||||
responseCode = "200",
|
|
||||||
content = @Content
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
If `content.schema` is omitted, schema is inferred from the handler `handle(...)` return type.
|
|
||||||
Explicit `content.schema` always wins over inference.
|
|
||||||
|
|
||||||
Inference defaults:
|
|
||||||
|
|
||||||
- `UserDto` -> object schema for `UserDto`
|
|
||||||
- `List<UserDto>` / `Set<UserDto>` / `UserDto[]` -> `array` with `items: UserDto`
|
|
||||||
- `Map<String, UserDto>` -> `object` with `additionalProperties: UserDto`
|
|
||||||
|
|
||||||
## DTO schema metadata
|
|
||||||
|
|
||||||
```java
|
|
||||||
@Schema(name = "User", title = "User DTO", description = "Public user", deprecated = false)
|
|
||||||
public class UserDto {
|
|
||||||
|
|
||||||
@SchemaProperty(title = "ID", required = true, example = "USR-100", enumeration = {"USR-100", "USR-101"})
|
|
||||||
public String id;
|
|
||||||
|
|
||||||
@SchemaProperty(hidden = true)
|
|
||||||
public String internalDebug;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Supported field-level exclusion:
|
|
||||||
|
|
||||||
- `@Schema(hidden = true)` / `@SchemaProperty(hidden = true)`
|
|
||||||
- `@JsonIgnore`
|
|
||||||
- `@JsonIgnoreProperties(...)`
|
|
||||||
- `transient` / `static`
|
|
||||||
|
|
||||||
## Contributor API
|
## Contributor API
|
||||||
|
|
||||||
OpenAPI is extension-agnostic. Other extensions contribute with `OpenApiContributor` via
|
OpenAPI is extension-agnostic. Other extensions contribute through `OpenApiContributor`, held in
|
||||||
`OpenApiContributorRegistry`.
|
`OpenApiContributorRegistry`:
|
||||||
|
|
||||||
Supported contribution surfaces:
|
- `components` fragments (merged last-wins)
|
||||||
|
|
||||||
- `components` fragments (merged with last-wins)
|
|
||||||
- operation `security` requirements (additive)
|
- operation `security` requirements (additive)
|
||||||
- operation `responses` and response `headers` (additive)
|
- operation `responses` and response `headers` (additive)
|
||||||
|
|
||||||
Merge policy:
|
Manual `@APIResponse` description always wins over a contributor's for the same status.
|
||||||
|
|
||||||
- contributor collisions use **last-wins**
|
### Security interop
|
||||||
- manual `@APIResponse` description always wins over contributors for the same status
|
|
||||||
|
|
||||||
## Security interop
|
|
||||||
|
|
||||||
With `flash-ext-security-core` installed, every registered mechanism's scheme lands under
|
With `flash-ext-security-core` installed, every registered mechanism's scheme lands under
|
||||||
`components.securitySchemes`, and every operation carrying a security annotation lists them as
|
`components.securitySchemes`, and every operation carrying a security annotation lists them as
|
||||||
`security` alternatives with automatic `401` and — for roles or scopes — `403` responses.
|
`security` alternatives with automatic `401` and — for roles or scopes — `403` responses.
|
||||||
|
|
||||||
Manual `@APIResponse` for the same status code always wins.
|
### Limiter interop
|
||||||
|
|
||||||
## Limiter interop
|
When `flash-ext-limiter` is installed, handlers with `@Limit` document `X-RateLimit-Limit`,
|
||||||
|
`X-RateLimit-Remaining`, `X-RateLimit-Reset`, and a `429` with `Retry-After`.
|
||||||
When `flash-ext-limiter` is installed, handlers with `@Limit` automatically get response
|
|
||||||
headers documented in OpenAPI:
|
|
||||||
|
|
||||||
- `X-RateLimit-Limit`
|
|
||||||
- `X-RateLimit-Remaining`
|
|
||||||
- `X-RateLimit-Reset`
|
|
||||||
- `Retry-After` on `429`
|
|
||||||
|
|
||||||
If `429` is missing, it is auto-added as `Too Many Requests`.
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Operations are collected from final boot-time routes for class-based handlers with `@ApiOperation`.
|
- Operations come from the final boot-time routes, so documented paths match runtime paths,
|
||||||
- Documented paths always match runtime paths (including scope namespaces/prefixes/rewrites).
|
namespaces, prefixes and rewrites included.
|
||||||
- Route path params are auto-discovered from `/{id}`.
|
- Lambda routes are not documented: there is no class to read.
|
||||||
- Parameter annotations are mainly for query/header/cookie enrichment.
|
- Responses are sorted by status code; the document is rebuilt only when a route is added.
|
||||||
- Output responses are sorted by numeric status code.
|
|
||||||
|
|||||||
+3
@@ -16,4 +16,7 @@ public @interface Content {
|
|||||||
ContentType contentType() default ContentType.JSON;
|
ContentType contentType() default ContentType.JSON;
|
||||||
Class<?> schema() default Void.class;
|
Class<?> schema() default Void.class;
|
||||||
boolean array() default false;
|
boolean array() default false;
|
||||||
|
|
||||||
|
/** One example body, shown beside the schema. */
|
||||||
|
String example() default "";
|
||||||
}
|
}
|
||||||
|
|||||||
+303
-424
@@ -1,47 +1,50 @@
|
|||||||
package dev.relism.flash.ext.openapi;
|
package dev.relism.flash.ext.openapi;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty.Access;
|
|
||||||
import dev.relism.flash.http.HttpMethod;
|
|
||||||
import dev.relism.flash.http.ContentType;
|
import dev.relism.flash.http.ContentType;
|
||||||
|
import dev.relism.flash.http.HttpMethod;
|
||||||
import dev.relism.flash.http.HttpStatus;
|
import dev.relism.flash.http.HttpStatus;
|
||||||
import dev.relism.flash.models.Request;
|
import dev.relism.flash.models.BodyHandler;
|
||||||
import dev.relism.flash.models.Response;
|
import dev.relism.flash.routing.Consumes;
|
||||||
import dev.relism.flash.routing.Route;
|
import dev.relism.flash.routing.Route;
|
||||||
|
|
||||||
import java.lang.annotation.Annotation;
|
import java.lang.annotation.Annotation;
|
||||||
import java.lang.reflect.Array;
|
|
||||||
import java.lang.reflect.Field;
|
|
||||||
import java.lang.reflect.GenericArrayType;
|
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
import java.lang.reflect.Modifier;
|
|
||||||
import java.lang.reflect.ParameterizedType;
|
|
||||||
import java.lang.reflect.Type;
|
import java.lang.reflect.Type;
|
||||||
import java.time.Instant;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenAPI document assembler.
|
* Assembles the OpenAPI document from what the handlers already say about themselves.
|
||||||
|
*
|
||||||
|
* <p>A route is documented whether or not it carries annotations: its path parameters come from
|
||||||
|
* the path, its request body from the handler's own body type, its response schema from what
|
||||||
|
* {@code handle} returns, and its error bodies from the one shape Flash answers failures with.
|
||||||
|
* Annotations add what code cannot say — a summary, an example, a second status — and never have
|
||||||
|
* to repeat what it can.
|
||||||
|
*
|
||||||
|
* <p>A response that several operations share is written once under {@code components} and
|
||||||
|
* referenced, so the security and rate-limiting answers appear once rather than on every path.
|
||||||
*/
|
*/
|
||||||
public final class OpenApiBuilder {
|
public final class OpenApiBuilder {
|
||||||
|
|
||||||
private static final String OPENAPI_VERSION = "3.0.3";
|
private static final String OPENAPI_VERSION = "3.0.3";
|
||||||
|
private static final String ERROR_SCHEMA = "Error";
|
||||||
|
private static final String ERROR_REF = "#/components/schemas/" + ERROR_SCHEMA;
|
||||||
|
private static final String RESPONSE_REF = "#/components/responses/";
|
||||||
|
|
||||||
|
/** What {@code AbstractRouter} answers every failure with: one object, everywhere. */
|
||||||
|
private static final Map<String, Object> ERROR_SHAPE = Map.of(
|
||||||
|
"type", "object",
|
||||||
|
"properties", Map.of("error", Map.of("type", "string"), "status", Map.of("type", "integer")),
|
||||||
|
"required", List.of("error", "status"));
|
||||||
|
|
||||||
private String title = "API";
|
private String title = "API";
|
||||||
private String version = "1.0.0";
|
private String version = "1.0.0";
|
||||||
@@ -49,8 +52,9 @@ public final class OpenApiBuilder {
|
|||||||
|
|
||||||
private final Map<String, Map<String, Object>> paths = new LinkedHashMap<>();
|
private final Map<String, Map<String, Object>> paths = new LinkedHashMap<>();
|
||||||
private final Map<String, Map<String, Class<?>>> operationHandlers = new LinkedHashMap<>();
|
private final Map<String, Map<String, Class<?>>> operationHandlers = new LinkedHashMap<>();
|
||||||
private final SchemaRegistry schemas = new SchemaRegistry();
|
private final Schemas schemas = new Schemas();
|
||||||
private OpenApiContributorRegistry contributorRegistry;
|
private OpenApiContributorRegistry contributorRegistry;
|
||||||
|
private boolean errorsDocumented;
|
||||||
private int revision;
|
private int revision;
|
||||||
private int builtRevision = -1;
|
private int builtRevision = -1;
|
||||||
private Map<String, Object> cachedSpec;
|
private Map<String, Object> cachedSpec;
|
||||||
@@ -60,274 +64,374 @@ public final class OpenApiBuilder {
|
|||||||
public OpenApiBuilder description(String description) { this.description = description; return this; }
|
public OpenApiBuilder description(String description) { this.description = description; return this; }
|
||||||
void setContributorRegistry(OpenApiContributorRegistry registry) { this.contributorRegistry = registry; }
|
void setContributorRegistry(OpenApiContributorRegistry registry) { this.contributorRegistry = registry; }
|
||||||
|
|
||||||
|
/** Documents one route. {@code op} is optional: a route without it is still an operation. */
|
||||||
public void addOperation(Route route, ApiOperation op, Class<?> handlerClass) {
|
public void addOperation(Route route, ApiOperation op, Class<?> handlerClass) {
|
||||||
String path = normalizePath(route.path());
|
String path = normalizePath(route.path());
|
||||||
String method = route.method().name().toLowerCase(Locale.ROOT);
|
String method = route.method().name().toLowerCase(Locale.ROOT);
|
||||||
|
|
||||||
Map<String, Object> operation = new LinkedHashMap<>();
|
Map<String, Object> operation = new LinkedHashMap<>();
|
||||||
if (!op.operationId().isEmpty()) operation.put("operationId", op.operationId());
|
if (op != null) {
|
||||||
if (!op.summary().isEmpty()) operation.put("summary", op.summary());
|
if (!op.operationId().isEmpty()) operation.put("operationId", op.operationId());
|
||||||
if (!op.description().isEmpty()) operation.put("description", op.description());
|
if (!op.summary().isEmpty()) operation.put("summary", op.summary());
|
||||||
if (op.tags().length > 0) operation.put("tags", Arrays.asList(op.tags()));
|
if (!op.description().isEmpty()) operation.put("description", op.description());
|
||||||
if (op.deprecated()) operation.put("deprecated", true);
|
if (op.tags().length > 0) operation.put("tags", List.of(op.tags()));
|
||||||
|
if (op.deprecated()) operation.put("deprecated", true);
|
||||||
|
}
|
||||||
|
|
||||||
buildParameters(operation, handlerClass, route);
|
parameters(operation, handlerClass, route);
|
||||||
buildResponses(operation, handlerClass);
|
requestBody(operation, handlerClass);
|
||||||
|
responses(operation, handlerClass);
|
||||||
|
|
||||||
paths.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, operation);
|
paths.computeIfAbsent(path, p -> new LinkedHashMap<>()).put(method, operation);
|
||||||
operationHandlers.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, handlerClass);
|
operationHandlers.computeIfAbsent(path, p -> new LinkedHashMap<>()).put(method, handlerClass);
|
||||||
revision++;
|
revision++;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Map<String, Object> build() {
|
public Map<String, Object> build() {
|
||||||
int r = revision;
|
int current = revision;
|
||||||
Map<String, Object> cached = cachedSpec;
|
Map<String, Object> cached = cachedSpec;
|
||||||
if (cached != null && builtRevision == r) return cached;
|
if (cached != null && builtRevision == current) return cached;
|
||||||
|
|
||||||
|
List<OpenApiContributor> contributors = contributors();
|
||||||
|
Map<String, Object> renderedPaths = new LinkedHashMap<>();
|
||||||
|
for (var path : paths.entrySet()) {
|
||||||
|
Map<String, Class<?>> handlers = operationHandlers.getOrDefault(path.getKey(), Map.of());
|
||||||
|
Map<String, Object> pathItem = new LinkedHashMap<>();
|
||||||
|
for (var method : path.getValue().entrySet()) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> declared = (Map<String, Object>) method.getValue();
|
||||||
|
Map<String, Object> operation = new LinkedHashMap<>(declared);
|
||||||
|
Class<?> handler = handlers.get(method.getKey());
|
||||||
|
if (handler != null && !contributors.isEmpty()) applyContributorSecurity(operation, handler, contributors);
|
||||||
|
pathItem.put(method.getKey(), operation);
|
||||||
|
}
|
||||||
|
renderedPaths.put(path.getKey(), pathItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> sharedResponses = hoistSharedResponses(renderedPaths);
|
||||||
|
|
||||||
Map<String, Object> info = new LinkedHashMap<>();
|
Map<String, Object> info = new LinkedHashMap<>();
|
||||||
info.put("title", title);
|
info.put("title", title);
|
||||||
info.put("version", version);
|
info.put("version", version);
|
||||||
if (!description.isEmpty()) info.put("description", description);
|
if (!description.isEmpty()) info.put("description", description);
|
||||||
|
|
||||||
List<OpenApiContributor> contributors = contributorRegistry != null
|
|
||||||
? contributorRegistry.contributors() : List.of();
|
|
||||||
|
|
||||||
Map<String, Object> renderedPaths = new LinkedHashMap<>();
|
|
||||||
for (var pathEntry : paths.entrySet()) {
|
|
||||||
Map<String, Object> renderedPathItem = new LinkedHashMap<>();
|
|
||||||
Map<String, Class<?>> handlers = operationHandlers.getOrDefault(pathEntry.getKey(), Map.of());
|
|
||||||
for (var methodEntry : pathEntry.getValue().entrySet()) {
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> original = (Map<String, Object>) methodEntry.getValue();
|
|
||||||
Map<String, Object> op = new LinkedHashMap<>(original);
|
|
||||||
Class<?> handler = handlers.get(methodEntry.getKey());
|
|
||||||
if (handler != null && !contributors.isEmpty()) {
|
|
||||||
applyContributorOperation(op, handler, contributors);
|
|
||||||
}
|
|
||||||
renderedPathItem.put(methodEntry.getKey(), op);
|
|
||||||
}
|
|
||||||
renderedPaths.put(pathEntry.getKey(), renderedPathItem);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> spec = new LinkedHashMap<>();
|
Map<String, Object> spec = new LinkedHashMap<>();
|
||||||
spec.put("openapi", OPENAPI_VERSION);
|
spec.put("openapi", OPENAPI_VERSION);
|
||||||
spec.put("info", info);
|
spec.put("info", info);
|
||||||
spec.put("paths", renderedPaths);
|
spec.put("paths", renderedPaths);
|
||||||
|
|
||||||
Map<String, Object> components = new LinkedHashMap<>();
|
Map<String, Object> components = new LinkedHashMap<>();
|
||||||
Map<String, Object> renderedSchemas = schemas.render();
|
Map<String, Object> renderedSchemas = new LinkedHashMap<>(schemas.render());
|
||||||
|
if (errorsDocumented) renderedSchemas.put(ERROR_SCHEMA, ERROR_SHAPE);
|
||||||
if (!renderedSchemas.isEmpty()) components.put("schemas", renderedSchemas);
|
if (!renderedSchemas.isEmpty()) components.put("schemas", renderedSchemas);
|
||||||
if (!contributors.isEmpty()) applyContributorComponents(components, contributors);
|
if (!sharedResponses.isEmpty()) components.put("responses", sharedResponses);
|
||||||
|
for (OpenApiContributor contributor : contributors) {
|
||||||
|
Map<String, Object> contributed = contributor.componentContributions();
|
||||||
|
if (contributed != null && !contributed.isEmpty()) deepMergeLastWins(components, contributed);
|
||||||
|
}
|
||||||
if (!components.isEmpty()) spec.put("components", components);
|
if (!components.isEmpty()) spec.put("components", components);
|
||||||
|
|
||||||
cachedSpec = spec;
|
cachedSpec = spec;
|
||||||
builtRevision = r;
|
builtRevision = current;
|
||||||
return spec;
|
return spec;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void buildParameters(Map<String, Object> op, Class<?> cls, Route route) {
|
// ── Parameters ────────────────────────────────────────────────────────────
|
||||||
List<Map<String, Object>> params = new ArrayList<>();
|
|
||||||
|
private void parameters(Map<String, Object> operation, Class<?> handlerClass, Route route) {
|
||||||
|
List<Map<String, Object>> parameters = new ArrayList<>();
|
||||||
|
|
||||||
String path = route.path();
|
String path = route.path();
|
||||||
int i = 0;
|
for (int open = path.indexOf('{'); open >= 0; open = path.indexOf('{', open + 1)) {
|
||||||
while (i < path.length()) {
|
|
||||||
int open = path.indexOf('{', i);
|
|
||||||
if (open < 0) break;
|
|
||||||
int close = path.indexOf('}', open);
|
int close = path.indexOf('}', open);
|
||||||
if (close < 0) break;
|
if (close < 0) break;
|
||||||
String name = path.substring(open + 1, close);
|
parameters.add(new LinkedHashMap<>(Map.of(
|
||||||
params.add(new LinkedHashMap<>(Map.of(
|
"name", path.substring(open + 1, close),
|
||||||
"name", name,
|
|
||||||
"in", "path",
|
"in", "path",
|
||||||
"required", true,
|
"required", true,
|
||||||
"schema", Map.of("type", "string")
|
"schema", Map.of("type", "string"))));
|
||||||
)));
|
open = close;
|
||||||
i = close + 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (Parameter ann : cls.getAnnotationsByType(Parameter.class)) {
|
for (Parameter declared : handlerClass.getAnnotationsByType(Parameter.class)) {
|
||||||
Map<String, Object> p = new LinkedHashMap<>();
|
Map<String, Object> parameter = new LinkedHashMap<>();
|
||||||
p.put("name", ann.name());
|
parameter.put("name", declared.name());
|
||||||
p.put("in", ann.in().wireValue());
|
parameter.put("in", declared.in().wireValue());
|
||||||
p.put("required", ann.required());
|
parameter.put("required", declared.required());
|
||||||
if (!ann.description().isEmpty()) p.put("description", ann.description());
|
if (!declared.description().isEmpty()) parameter.put("description", declared.description());
|
||||||
if (!ann.style().isEmpty()) p.put("style", ann.style());
|
if (!declared.style().isEmpty()) parameter.put("style", declared.style());
|
||||||
if (ann.explode()) p.put("explode", true);
|
if (declared.explode()) parameter.put("explode", true);
|
||||||
if (ann.allowEmptyValue()) p.put("allowEmptyValue", true);
|
if (declared.allowEmptyValue()) parameter.put("allowEmptyValue", true);
|
||||||
|
|
||||||
Map<String, Object> schema = new LinkedHashMap<>();
|
Map<String, Object> schema = new LinkedHashMap<>();
|
||||||
schema.put("type", ann.type().wireValue());
|
schema.put("type", declared.type().wireValue());
|
||||||
if (!ann.example().isEmpty()) schema.put("example", ann.example());
|
if (!declared.example().isEmpty()) schema.put("example", declared.example());
|
||||||
p.put("schema", schema);
|
parameter.put("schema", schema);
|
||||||
if (ann.examples().length > 0) p.put("examples", toExamples(ann.examples()));
|
if (declared.examples().length > 0) parameter.put("examples", examples(declared.examples()));
|
||||||
params.add(p);
|
parameters.add(parameter);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!params.isEmpty()) op.put("parameters", params);
|
if (!parameters.isEmpty()) operation.put("parameters", parameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void buildResponses(Map<String, Object> op, Class<?> cls) {
|
private static Map<String, Object> examples(String[] values) {
|
||||||
APIResponse[] anns = cls.getAnnotationsByType(APIResponse.class);
|
Map<String, Object> examples = new LinkedHashMap<>();
|
||||||
Map<Integer, Map<String, Object>> responseByCode = new LinkedHashMap<>();
|
for (int i = 0; i < values.length; i++) examples.put("example" + (i + 1), Map.of("value", values[i]));
|
||||||
|
return examples;
|
||||||
|
}
|
||||||
|
|
||||||
for (APIResponse ann : anns) {
|
// ── Request body ──────────────────────────────────────────────────────────
|
||||||
int code = parseStatus(ann.responseCode());
|
|
||||||
responseByCode.put(code, buildAnnotatedResponse(code, ann, cls));
|
/** 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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));
|
||||||
|
|
||||||
if (responseByCode.isEmpty()) {
|
applyContributorResponses(byStatus, handlerClass, declaredStatuses);
|
||||||
// Mutable: contributors merge descriptions and headers into it.
|
|
||||||
responseByCode.put(200, new LinkedHashMap<>(Map.of("description", "OK")));
|
|
||||||
}
|
|
||||||
|
|
||||||
applyContributorResponses(responseByCode, cls);
|
|
||||||
|
|
||||||
Map<String, Object> responses = new LinkedHashMap<>();
|
Map<String, Object> responses = new LinkedHashMap<>();
|
||||||
responseByCode.entrySet().stream()
|
byStatus.entrySet().stream()
|
||||||
.sorted(Map.Entry.comparingByKey(Comparator.naturalOrder()))
|
.sorted(Map.Entry.comparingByKey(Comparator.naturalOrder()))
|
||||||
.forEach(e -> responses.put(String.valueOf(e.getKey()), e.getValue()));
|
.forEach(entry -> responses.put(String.valueOf(entry.getKey()), entry.getValue()));
|
||||||
op.put("responses", responses);
|
operation.put("responses", responses);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> response(int status, APIResponse declared, Class<?> handlerClass) {
|
||||||
|
Map<String, Object> response = new LinkedHashMap<>();
|
||||||
|
response.put("description", declared.description().isEmpty() ? reasonFor(status) : declared.description());
|
||||||
|
|
||||||
|
Content content = declared.content();
|
||||||
|
if (content.contentType() == ContentType.NONE) return response;
|
||||||
|
|
||||||
|
Map<String, Object> schema = schemaFor(content, status, handlerClass);
|
||||||
|
if (schema == null) return response;
|
||||||
|
|
||||||
|
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(content.contentType()), 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(ContentType.JSON), Map.of("schema", schema)));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The schema of whatever {@code handle} gives back, or null when it says nothing useful. */
|
||||||
|
private Map<String, Object> returnSchema(Class<?> handlerClass) {
|
||||||
|
Type returned = returnTypeOf(handlerClass);
|
||||||
|
Class<?> raw = Schemas.rawType(returned);
|
||||||
|
if (raw == null || raw == Object.class || raw == Void.class || raw == void.class) return null;
|
||||||
|
if (raw.getName().equals("dev.relism.flash.models.Response")) return null;
|
||||||
|
|
||||||
|
Map<String, Object> schema = schemas.schemaForType(returned);
|
||||||
|
return schema == null || schema.isEmpty() ? null : schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@code handle} a handler writes itself, not the one its base class fixes: a handler that
|
||||||
|
* takes a body implements the three-argument one, and that is where its return type is.
|
||||||
|
*/
|
||||||
|
private static Type returnTypeOf(Class<?> handlerClass) {
|
||||||
|
for (Class<?> current = handlerClass; current != null && current != Object.class; current = current.getSuperclass()) {
|
||||||
|
Method found = null;
|
||||||
|
for (Method method : current.getDeclaredMethods()) {
|
||||||
|
if (!method.getName().equals("handle") || method.isBridge() || method.isSynthetic()) continue;
|
||||||
|
if (found == null || method.getParameterCount() > found.getParameterCount()) found = method;
|
||||||
|
}
|
||||||
|
if (found != null) return found.getGenericReturnType();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> errorSchema() {
|
||||||
|
errorsDocumented = true;
|
||||||
|
return Map.of("$ref", ERROR_REF);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Contributors ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private List<OpenApiContributor> contributors() {
|
private List<OpenApiContributor> contributors() {
|
||||||
return contributorRegistry != null ? contributorRegistry.contributors() : List.of();
|
return contributorRegistry != null ? contributorRegistry.contributors() : List.of();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void applyContributorResponses(Map<Integer, Map<String, Object>> responseByCode, Class<?> handlerClass) {
|
private void applyContributorResponses(Map<Integer, Map<String, Object>> byStatus, Class<?> handlerClass,
|
||||||
APIResponse[] manual = handlerClass.getAnnotationsByType(APIResponse.class);
|
Set<Integer> declaredStatuses) {
|
||||||
Set<Integer> manualStatusCodes = new HashSet<>();
|
|
||||||
for (APIResponse ann : manual) {
|
|
||||||
manualStatusCodes.add(parseStatus(ann.responseCode()));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (OpenApiContributor contributor : contributors()) {
|
for (OpenApiContributor contributor : contributors()) {
|
||||||
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
|
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
|
||||||
if (contribution == null) continue;
|
if (contribution == null) continue;
|
||||||
|
|
||||||
for (Map.Entry<Integer, OpenApiResponseContribution> entry : contribution.responses().entrySet()) {
|
for (var contributed : contribution.responses().entrySet()) {
|
||||||
int status = entry.getKey();
|
if (contributed.getValue() == null) continue;
|
||||||
OpenApiResponseContribution responseContribution = entry.getValue();
|
int status = contributed.getKey();
|
||||||
if (responseContribution == null) continue;
|
Map<String, Object> response = byStatus.computeIfAbsent(status, s -> new LinkedHashMap<>());
|
||||||
|
merge(response, contributed.getValue(), status, declaredStatuses.contains(status));
|
||||||
Map<String, Object> response = responseByCode.computeIfAbsent(status, __ -> new LinkedHashMap<>());
|
|
||||||
mergeContributorResponse(response, responseContribution, status, manualStatusCodes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
OpenApiResponseContribution allResponses = contribution.allResponses();
|
OpenApiResponseContribution everywhere = contribution.allResponses();
|
||||||
if (allResponses != null) {
|
if (everywhere == null) continue;
|
||||||
for (Map.Entry<Integer, Map<String, Object>> entry : responseByCode.entrySet()) {
|
for (var response : byStatus.entrySet()) {
|
||||||
mergeContributorResponse(entry.getValue(), allResponses, entry.getKey(), manualStatusCodes);
|
merge(response.getValue(), everywhere, response.getKey(), declaredStatuses.contains(response.getKey()));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void mergeContributorResponse(Map<String, Object> response,
|
private void merge(Map<String, Object> response, OpenApiResponseContribution contributed, int status, boolean declared) {
|
||||||
OpenApiResponseContribution contribution,
|
String description = contributed.description();
|
||||||
int status,
|
if (!declared && description != null && !description.isBlank()) response.put("description", description);
|
||||||
Set<Integer> manualStatusCodes) {
|
|
||||||
String desc = contribution.description();
|
|
||||||
if (!manualStatusCodes.contains(status) && desc != null && !desc.isBlank()) {
|
|
||||||
response.put("description", desc);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Map<String, Object>> headerContributions = contribution.headers();
|
Map<String, Map<String, Object>> headers = contributed.headers();
|
||||||
if (!headerContributions.isEmpty()) {
|
if (!headers.isEmpty()) {
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
Map<String, Object> headers = (Map<String, Object>) response.computeIfAbsent("headers", __ -> new LinkedHashMap<>());
|
Map<String, Object> target = (Map<String, Object>) response.computeIfAbsent("headers", h -> new LinkedHashMap<>());
|
||||||
for (Map.Entry<String, Map<String, Object>> h : headerContributions.entrySet()) {
|
headers.forEach((name, header) -> target.put(name, new LinkedHashMap<>(header)));
|
||||||
headers.put(h.getKey(), new LinkedHashMap<>(h.getValue()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (!response.containsKey("description")) response.put("description", reasonFor(status));
|
||||||
if (!response.containsKey("description")) {
|
// A status a contributor added is a failure Flash answers in its own shape.
|
||||||
response.put("description", defaultDescription(status));
|
if (status >= 400 && !response.containsKey("content")) {
|
||||||
|
response.put("content", Map.of(mediaTypeOf(ContentType.JSON), Map.of("schema", errorSchema())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
for (OpenApiContributor contributor : contributors) {
|
||||||
Map<String, Object> c = contributor.componentContributions();
|
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
|
||||||
if (c == null || c.isEmpty()) continue;
|
if (contribution == null || contribution.security().isEmpty()) continue;
|
||||||
deepMergeLastWins(components, c);
|
@SuppressWarnings("unchecked")
|
||||||
|
List<Map<String, List<String>>> security =
|
||||||
|
(List<Map<String, List<String>>>) operation.computeIfAbsent("security", s -> new ArrayList<>());
|
||||||
|
security.addAll(contribution.security());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private static void deepMergeLastWins(Map<String, Object> target, Map<String, Object> incoming) {
|
private static void deepMergeLastWins(Map<String, Object> target, Map<String, Object> incoming) {
|
||||||
for (Map.Entry<String, Object> e : incoming.entrySet()) {
|
incoming.forEach((key, value) -> {
|
||||||
Object existing = target.get(e.getKey());
|
Object existing = target.get(key);
|
||||||
Object value = e.getValue();
|
if (existing instanceof Map<?, ?> from && value instanceof Map<?, ?> to) {
|
||||||
if (existing instanceof Map<?, ?> em && value instanceof Map<?, ?> vm) {
|
Map<String, Object> merged = new LinkedHashMap<>((Map<String, Object>) from);
|
||||||
Map<String, Object> merged = new LinkedHashMap<>((Map<String, Object>) em);
|
deepMergeLastWins(merged, (Map<String, Object>) to);
|
||||||
deepMergeLastWins(merged, (Map<String, Object>) vm);
|
target.put(key, merged);
|
||||||
target.put(e.getKey(), merged);
|
|
||||||
} else {
|
} else {
|
||||||
target.put(e.getKey(), value);
|
target.put(key, value);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void applyContributorOperation(Map<String, Object> op, Class<?> handlerClass, List<OpenApiContributor> contributors) {
|
// ── Shared responses ──────────────────────────────────────────────────────
|
||||||
for (OpenApiContributor contributor : contributors) {
|
|
||||||
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
|
|
||||||
if (contribution == null || contribution.isEmpty()) continue;
|
|
||||||
|
|
||||||
if (!contribution.security().isEmpty()) {
|
/**
|
||||||
@SuppressWarnings("unchecked")
|
* Whatever answer more than one operation gives identically is written once under
|
||||||
List<Map<String, List<String>>> security = (List<Map<String, List<String>>>) op
|
* {@code components.responses} and referenced. Authentication and rate limiting say the same
|
||||||
.computeIfAbsent("security", __ -> new ArrayList<>());
|
* thing on every route they guard; the document should say it once.
|
||||||
security.addAll(contribution.security());
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private Map<String, Object> hoistSharedResponses(Map<String, Object> renderedPaths) {
|
||||||
|
Map<Object, Integer> seen = new HashMap<>();
|
||||||
|
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.merge(key(status, response), 1, Integer::sum));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<Object, String> names = new LinkedHashMap<>();
|
||||||
|
Map<String, Object> shared = 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");
|
||||||
|
for (var response : responses.entrySet()) {
|
||||||
|
Object key = key(response.getKey(), response.getValue());
|
||||||
|
if (seen.getOrDefault(key, 0) < 2) continue;
|
||||||
|
|
||||||
|
String name = names.get(key);
|
||||||
|
if (name == null) {
|
||||||
|
name = uniqueName(reasonFor(Integer.parseInt(response.getKey())), shared);
|
||||||
|
names.put(key, name);
|
||||||
|
shared.put(name, response.getValue());
|
||||||
|
}
|
||||||
|
response.setValue(Map.of("$ref", RESPONSE_REF + name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return shared;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> buildAnnotatedResponse(int code, APIResponse ann, Class<?> handlerClass) {
|
private static Object key(String status, Object response) {
|
||||||
Map<String, Object> out = new LinkedHashMap<>();
|
return status + response;
|
||||||
out.put("description", ann.description().isEmpty() ? defaultDescription(code) : ann.description());
|
}
|
||||||
|
|
||||||
Content content = ann.content();
|
private static String uniqueName(String reason, Map<String, Object> taken) {
|
||||||
if (content.contentType() == ContentType.NONE) return out;
|
String base = reason.isEmpty() ? "Response" : reason.replace(" ", "");
|
||||||
|
String name = base;
|
||||||
|
for (int i = 2; taken.containsKey(name); i++) name = base + i;
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, Object> schema = resolveResponseSchema(content, handlerClass);
|
// ── Odds and ends ─────────────────────────────────────────────────────────
|
||||||
if (schema == null || schema.isEmpty()) return out;
|
|
||||||
|
|
||||||
out.put("content", Map.of(mediaTypeOf(content.contentType()), Map.of("schema", schema)));
|
private static Map<String, Object> arrayOf(Map<String, Object> items) {
|
||||||
return out;
|
return Map.of("type", "array", "items", items);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int parseStatus(String code) {
|
private static int parseStatus(String code) {
|
||||||
try {
|
try {
|
||||||
return Integer.parseInt(code.trim());
|
return Integer.parseInt(code.trim());
|
||||||
} catch (Exception e) {
|
} catch (NumberFormatException e) {
|
||||||
throw new IllegalStateException("Invalid APIResponse.responseCode: " + code);
|
throw new IllegalStateException("Invalid APIResponse.responseCode: " + code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, Object> resolveResponseSchema(Content content, Class<?> handlerClass) {
|
private static String reasonFor(int status) {
|
||||||
if (content.schema() != Void.class) {
|
String reason = HttpStatus.reasonForCode(status);
|
||||||
Map<String, Object> base = schemas.referenceFor(content.schema());
|
return reason == null ? "" : reason;
|
||||||
return content.array() ? asArraySchema(base) : base;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
Method handle = handlerClass.getMethod("handle", Request.class, Response.class);
|
|
||||||
Type ret = handle.getGenericReturnType();
|
|
||||||
Class<?> raw = rawType(ret);
|
|
||||||
if (raw == null || raw == Object.class || raw == Response.class || raw == Void.class || raw == void.class)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
Map<String, Object> inferred = schemas.schemaForType(ret);
|
|
||||||
if (inferred == null || inferred.isEmpty()) return null;
|
|
||||||
if (content.array() && !"array".equals(inferred.get("type"))) return asArraySchema(inferred);
|
|
||||||
return inferred;
|
|
||||||
} catch (NoSuchMethodException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Map<String, Object> asArraySchema(Map<String, Object> itemSchema) {
|
|
||||||
return Map.of("type", "array", "items", itemSchema);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String mediaTypeOf(ContentType type) {
|
private static String mediaTypeOf(ContentType type) {
|
||||||
@@ -335,245 +439,21 @@ public final class OpenApiBuilder {
|
|||||||
return bytes.length == 0 ? "application/octet-stream" : new String(bytes, StandardCharsets.UTF_8);
|
return bytes.length == 0 ? "application/octet-stream" : new String(bytes, StandardCharsets.UTF_8);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Map<String, Object> toExamples(String[] examples) {
|
|
||||||
Map<String, Object> out = new LinkedHashMap<>();
|
|
||||||
for (int i = 0; i < examples.length; i++) {
|
|
||||||
out.put("ex" + (i + 1), Map.of("value", examples[i]));
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String normalizePath(String path) {
|
private static String normalizePath(String path) {
|
||||||
String normalized = path.startsWith("/") ? path : "/" + path;
|
String normalized = path.startsWith("/") ? path : "/" + path;
|
||||||
while (normalized.startsWith("//")) {
|
while (normalized.startsWith("//")) normalized = normalized.substring(1);
|
||||||
normalized = normalized.substring(1);
|
|
||||||
}
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String defaultDescription(int status) {
|
/** The route a handler class declares, through {@code @Route} or any shorthand carrying it. */
|
||||||
String reason = HttpStatus.reasonForCode(status);
|
|
||||||
return reason == null ? "" : reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Class<?> rawType(Type type) {
|
|
||||||
if (type instanceof Class<?> c) return c;
|
|
||||||
if (type instanceof ParameterizedType p && p.getRawType() instanceof Class<?> c) return c;
|
|
||||||
if (type instanceof GenericArrayType a) {
|
|
||||||
Class<?> component = rawType(a.getGenericComponentType());
|
|
||||||
return component == null ? null : Array.newInstance(component, 0).getClass();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolved once: jakarta.validation is an optional dependency of this module. */
|
|
||||||
private static final boolean CONSTRAINTS_PRESENT = ConstraintHints.available();
|
|
||||||
|
|
||||||
private static final class SchemaRegistry {
|
|
||||||
private static final Set<Class<?>> SIMPLE = Set.of(
|
|
||||||
String.class, CharSequence.class,
|
|
||||||
Boolean.class, Byte.class, Short.class, Integer.class, Long.class, Float.class, Double.class,
|
|
||||||
boolean.class, byte.class, short.class, int.class, long.class, float.class, double.class,
|
|
||||||
UUID.class, LocalDate.class, LocalDateTime.class, OffsetDateTime.class, Instant.class
|
|
||||||
);
|
|
||||||
|
|
||||||
private final Map<Class<?>, String> names = new LinkedHashMap<>();
|
|
||||||
private final Map<String, Map<String, Object>> docs = new LinkedHashMap<>();
|
|
||||||
private final Set<Class<?>> resolving = new HashSet<>();
|
|
||||||
|
|
||||||
Map<String, Object> referenceFor(Class<?> type) {
|
|
||||||
return schemaFor(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> schemaForType(Type type) {
|
|
||||||
return schemaFor(type);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> render() {
|
|
||||||
Map<String, Object> out = new LinkedHashMap<>();
|
|
||||||
for (var e : docs.entrySet()) out.put(e.getKey(), e.getValue());
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> schemaFor(Type type) {
|
|
||||||
if (type instanceof ParameterizedType p) {
|
|
||||||
Class<?> raw = rawType(p);
|
|
||||||
if (raw != null && Collection.class.isAssignableFrom(raw)) {
|
|
||||||
Type item = p.getActualTypeArguments()[0];
|
|
||||||
return Map.of("type", "array", "items", schemaFor(item));
|
|
||||||
}
|
|
||||||
if (raw != null && Map.class.isAssignableFrom(raw)) {
|
|
||||||
Type value = p.getActualTypeArguments().length > 1 ? p.getActualTypeArguments()[1] : Object.class;
|
|
||||||
return Map.of("type", "object", "additionalProperties", schemaFor(value));
|
|
||||||
}
|
|
||||||
if (raw != null) return schemaFor(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
Class<?> cls = rawType(type);
|
|
||||||
if (cls == null || cls == Object.class) return Map.of("type", "object");
|
|
||||||
|
|
||||||
if (cls.isArray()) return Map.of("type", "array", "items", schemaFor(cls.getComponentType()));
|
|
||||||
if (Collection.class.isAssignableFrom(cls)) return Map.of("type", "array", "items", Map.of("type", "object"));
|
|
||||||
if (Map.class.isAssignableFrom(cls)) return Map.of("type", "object", "additionalProperties", Map.of("type", "object"));
|
|
||||||
|
|
||||||
Map<String, Object> simple = simpleSchema(cls);
|
|
||||||
if (simple != null) return simple;
|
|
||||||
|
|
||||||
return Map.of("$ref", "#/components/schemas/" + registerPojo(cls));
|
|
||||||
}
|
|
||||||
|
|
||||||
private String registerPojo(Class<?> cls) {
|
|
||||||
String existing = names.get(cls);
|
|
||||||
if (existing != null) return existing;
|
|
||||||
|
|
||||||
String base = schemaName(cls);
|
|
||||||
String name = base;
|
|
||||||
int i = 2;
|
|
||||||
while (docs.containsKey(name)) name = base + i++;
|
|
||||||
names.put(cls, name);
|
|
||||||
|
|
||||||
if (resolving.contains(cls)) return name;
|
|
||||||
|
|
||||||
resolving.add(cls);
|
|
||||||
docs.put(name, buildPojoSchema(cls));
|
|
||||||
resolving.remove(cls);
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> buildPojoSchema(Class<?> cls) {
|
|
||||||
Schema typeSchema = cls.getAnnotation(Schema.class);
|
|
||||||
JsonIgnoreProperties ignoredType = cls.getAnnotation(JsonIgnoreProperties.class);
|
|
||||||
Set<String> ignored = ignoredType == null
|
|
||||||
? Set.of()
|
|
||||||
: new HashSet<>(Arrays.asList(ignoredType.value()));
|
|
||||||
|
|
||||||
Map<String, Object> out = new LinkedHashMap<>();
|
|
||||||
out.put("type", "object");
|
|
||||||
if (typeSchema != null) applySchemaHints(out, typeSchema);
|
|
||||||
|
|
||||||
Map<String, Object> properties = new LinkedHashMap<>();
|
|
||||||
List<String> required = new ArrayList<>();
|
|
||||||
|
|
||||||
for (Field f : cls.getDeclaredFields()) {
|
|
||||||
int mod = f.getModifiers();
|
|
||||||
if (Modifier.isStatic(mod) || Modifier.isTransient(mod)) continue;
|
|
||||||
if (f.isAnnotationPresent(JsonIgnore.class)) continue;
|
|
||||||
if (ignored.contains(f.getName())) continue;
|
|
||||||
|
|
||||||
String name = f.getName();
|
|
||||||
JsonProperty jp = f.getAnnotation(JsonProperty.class);
|
|
||||||
if (jp != null && !jp.value().isEmpty()) name = jp.value();
|
|
||||||
|
|
||||||
Schema ps = f.getAnnotation(Schema.class);
|
|
||||||
SchemaProperty sp = f.getAnnotation(SchemaProperty.class);
|
|
||||||
ArraySchema array = f.getAnnotation(ArraySchema.class);
|
|
||||||
if ((ps != null && ps.hidden()) || (sp != null && sp.hidden())) continue;
|
|
||||||
|
|
||||||
if (sp != null && !sp.name().isEmpty()) name = sp.name();
|
|
||||||
|
|
||||||
Map<String, Object> property = new LinkedHashMap<>(schemaFor(f.getGenericType()));
|
|
||||||
if (ps != null) applySchemaHints(property, ps);
|
|
||||||
if (sp != null) applySchemaHints(property, sp);
|
|
||||||
if (array != null) applyArrayHints(property, array);
|
|
||||||
if (jp != null) {
|
|
||||||
if (jp.access() == Access.READ_ONLY) property.put("readOnly", true);
|
|
||||||
if (jp.access() == Access.WRITE_ONLY) property.put("writeOnly", true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Constraints declared for flash-ext-validation also describe the contract, so
|
|
||||||
// mirror them here rather than making callers restate every rule as @Schema.
|
|
||||||
boolean constrainedRequired = CONSTRAINTS_PRESENT && ConstraintHints.apply(f, property);
|
|
||||||
|
|
||||||
properties.put(name, property);
|
|
||||||
if (constrainedRequired
|
|
||||||
|| (ps != null && ps.required()) || (sp != null && sp.required()) || (jp != null && jp.required()))
|
|
||||||
required.add(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!properties.isEmpty()) out.put("properties", properties);
|
|
||||||
if (!required.isEmpty()) out.put("required", required);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void applySchemaHints(Map<String, Object> target, Schema schema) {
|
|
||||||
if (!schema.title().isEmpty()) target.put("title", schema.title());
|
|
||||||
if (!schema.description().isEmpty()) target.put("description", schema.description());
|
|
||||||
if (!schema.format().isEmpty()) target.put("format", schema.format());
|
|
||||||
if (!schema.example().isEmpty()) target.put("example", schema.example());
|
|
||||||
if (schema.enumeration().length > 0) target.put("enum", Arrays.asList(schema.enumeration()));
|
|
||||||
if (schema.nullable()) target.put("nullable", true);
|
|
||||||
if (schema.deprecated()) target.put("deprecated", true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void applySchemaHints(Map<String, Object> target, SchemaProperty schema) {
|
|
||||||
if (!schema.title().isEmpty()) target.put("title", schema.title());
|
|
||||||
if (!schema.description().isEmpty()) target.put("description", schema.description());
|
|
||||||
if (!schema.format().isEmpty()) target.put("format", schema.format());
|
|
||||||
if (!schema.example().isEmpty()) target.put("example", schema.example());
|
|
||||||
if (schema.enumeration().length > 0) target.put("enum", Arrays.asList(schema.enumeration()));
|
|
||||||
if (schema.nullable()) target.put("nullable", true);
|
|
||||||
if (schema.deprecated()) target.put("deprecated", true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> withArrayType(Map<String, Object> property, ArraySchema array) {
|
|
||||||
if ("array".equals(property.get("type"))) return property;
|
|
||||||
Type itemType = array.itemClass() != Void.class ? array.itemClass() : Object.class;
|
|
||||||
Map<String, Object> wrapped = new LinkedHashMap<>();
|
|
||||||
wrapped.put("type", "array");
|
|
||||||
wrapped.put("items", schemaFor(itemType));
|
|
||||||
return wrapped;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void applyArrayHints(Map<String, Object> property, ArraySchema array) {
|
|
||||||
Map<String, Object> target = withArrayType(property, array);
|
|
||||||
if (target != property) {
|
|
||||||
property.clear();
|
|
||||||
property.putAll(target);
|
|
||||||
}
|
|
||||||
if (array.uniqueItems()) property.put("uniqueItems", true);
|
|
||||||
if (array.minItems() >= 0) property.put("minItems", array.minItems());
|
|
||||||
if (array.maxItems() >= 0) property.put("maxItems", array.maxItems());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String schemaName(Class<?> cls) {
|
|
||||||
Schema schema = cls.getAnnotation(Schema.class);
|
|
||||||
if (schema != null && !schema.name().isEmpty()) return schema.name();
|
|
||||||
return cls.getSimpleName();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Map<String, Object> simpleSchema(Class<?> cls) {
|
|
||||||
if (!SIMPLE.contains(cls) && !cls.isEnum()) return null;
|
|
||||||
|
|
||||||
if (cls == String.class || CharSequence.class.isAssignableFrom(cls)) return Map.of("type", "string");
|
|
||||||
if (cls == Boolean.class || cls == boolean.class) return Map.of("type", "boolean");
|
|
||||||
if (cls == Integer.class || cls == int.class || cls == Long.class || cls == long.class ||
|
|
||||||
cls == Short.class || cls == short.class || cls == Byte.class || cls == byte.class) {
|
|
||||||
return Map.of("type", "integer");
|
|
||||||
}
|
|
||||||
if (cls == Float.class || cls == float.class || cls == Double.class || cls == double.class) {
|
|
||||||
return Map.of("type", "number");
|
|
||||||
}
|
|
||||||
if (cls == UUID.class) return Map.of("type", "string", "format", "uuid");
|
|
||||||
if (cls == LocalDate.class) return Map.of("type", "string", "format", "date");
|
|
||||||
if (cls == LocalDateTime.class || cls == OffsetDateTime.class || cls == Instant.class)
|
|
||||||
return Map.of("type", "string", "format", "date-time");
|
|
||||||
if (cls.isEnum()) {
|
|
||||||
Object[] constants = cls.getEnumConstants();
|
|
||||||
List<String> values = new ArrayList<>(constants.length);
|
|
||||||
for (Object c : constants) values.add(String.valueOf(c));
|
|
||||||
return Map.of("type", "string", "enum", values);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static Route routeOf(Class<?> cls) {
|
static Route routeOf(Class<?> cls) {
|
||||||
Route direct = cls.getAnnotation(Route.class);
|
Route direct = cls.getAnnotation(Route.class);
|
||||||
if (direct != null) return direct;
|
if (direct != null) return direct;
|
||||||
for (Annotation ann : cls.getAnnotations()) {
|
|
||||||
Route meta = ann.annotationType().getAnnotation(Route.class);
|
for (Annotation annotation : cls.getAnnotations()) {
|
||||||
|
Route meta = annotation.annotationType().getAnnotation(Route.class);
|
||||||
if (meta == null) continue;
|
if (meta == null) continue;
|
||||||
String path = readPathValue(ann);
|
String path = pathOf(annotation);
|
||||||
if (path == null) continue;
|
if (path == null) continue;
|
||||||
HttpMethod method = meta.method();
|
HttpMethod method = meta.method();
|
||||||
return new Route() {
|
return new Route() {
|
||||||
@@ -585,11 +465,10 @@ public final class OpenApiBuilder {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String readPathValue(Annotation ann) {
|
private static String pathOf(Annotation annotation) {
|
||||||
try {
|
try {
|
||||||
Object v = ann.annotationType().getMethod("value").invoke(ann);
|
return annotation.annotationType().getMethod("value").invoke(annotation) instanceof String path ? path : null;
|
||||||
return v instanceof String s ? s : null;
|
} catch (ReflectiveOperationException absent) {
|
||||||
} catch (ReflectiveOperationException ignored) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-13
@@ -10,7 +10,6 @@ import dev.relism.flash.extension.RouteEvent;
|
|||||||
import dev.relism.flash.http.ContentType;
|
import dev.relism.flash.http.ContentType;
|
||||||
import dev.relism.flash.http.HttpMethod;
|
import dev.relism.flash.http.HttpMethod;
|
||||||
import dev.relism.flash.routing.Route;
|
import dev.relism.flash.routing.Route;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates and serves an OpenAPI 3.0 spec and Swagger UI under a configurable base path.
|
* Generates and serves an OpenAPI 3.0 spec and Swagger UI under a configurable base path.
|
||||||
@@ -25,8 +24,9 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
* <p>If {@code flash-ext-jackson} is installed, this extension reuses its
|
* <p>If {@code flash-ext-jackson} is installed, this extension reuses its
|
||||||
* {@link ObjectMapper}. Otherwise it uses a local default mapper.
|
* {@link ObjectMapper}. Otherwise it uses a local default mapper.
|
||||||
*
|
*
|
||||||
* <p>Operations are collected at boot from handlers annotated with {@link ApiOperation}
|
* <p>Every class-based route is collected at boot, whether or not it is annotated: a path, the
|
||||||
* that also have route metadata ({@link Route} or shorthand verb annotations).
|
* body its handler takes and what it returns are already in the code. {@link ApiOperation} adds
|
||||||
|
* the prose.
|
||||||
*
|
*
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* FlashApp.create(8080)
|
* FlashApp.create(8080)
|
||||||
@@ -35,7 +35,6 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
* .start();
|
* .start();
|
||||||
* }</pre>
|
* }</pre>
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
|
||||||
public class OpenApiExtension implements FlashExtension {
|
public class OpenApiExtension implements FlashExtension {
|
||||||
|
|
||||||
private static final String YAML_CONTENT_TYPE = "application/yaml";
|
private static final String YAML_CONTENT_TYPE = "application/yaml";
|
||||||
@@ -121,18 +120,12 @@ public class OpenApiExtension implements FlashExtension {
|
|||||||
"</html>";
|
"</html>";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every class-based route is an operation; {@link ApiOperation} only adds what the code cannot say. */
|
||||||
private static void addOperationFromEvent(OpenApiBuilder builder, RouteEvent event) {
|
private static void addOperationFromEvent(OpenApiBuilder builder, RouteEvent event) {
|
||||||
Class<?> handlerClass = event.handlerClass();
|
Class<?> handlerClass = event.handlerClass();
|
||||||
if (handlerClass == null) return; // lambda route: no annotation metadata
|
if (handlerClass == null) return; // a lambda route has nothing to read
|
||||||
|
|
||||||
ApiOperation op = handlerClass.getAnnotation(ApiOperation.class);
|
builder.addOperation(routeOf(event), handlerClass.getAnnotation(ApiOperation.class), handlerClass);
|
||||||
if (op == null) {
|
|
||||||
log.warn("{} {} ({}) has no @ApiOperation — omitted from the OpenAPI spec",
|
|
||||||
event.method(), event.path(), handlerClass.getSimpleName());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
builder.addOperation(routeOf(event), op, handlerClass);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Route routeOf(RouteEvent event) {
|
private static Route routeOf(RouteEvent event) {
|
||||||
|
|||||||
+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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+144
@@ -133,6 +133,150 @@ class OpenApiBuilderTest {
|
|||||||
public List<String> tags;
|
public List<String> tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@dev.relism.flash.routing.POST("/bodies")
|
||||||
|
@ApiOperation(summary = "Typed body")
|
||||||
|
static final class TypedBodyHandler extends JsonLikeHandler<UserDto> {
|
||||||
|
@Override protected UserDto handle(Request request, Response response, UserDto body) { return body; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@dev.relism.flash.routing.Consumes(ContentType.JSON)
|
||||||
|
static abstract class JsonLikeHandler<B> extends dev.relism.flash.models.BodyHandler<B> {
|
||||||
|
@Override protected B body(Request request) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@dev.relism.flash.routing.PUT("/declared-body")
|
||||||
|
@ApiOperation(summary = "Declared body")
|
||||||
|
@RequestBody(value = UserDto.class, array = true, required = false, description = "Users to store")
|
||||||
|
static class DeclaredBodyHandler extends RequestHandler {
|
||||||
|
@Override public Object handle(Request request, Response response) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET("/bare")
|
||||||
|
static class BareHandler extends RequestHandler {
|
||||||
|
@Override public UserDto handle(Request request, Response response) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET("/example")
|
||||||
|
@ApiOperation(summary = "Example")
|
||||||
|
@APIResponse(responseCode = "200", content = @Content(schema = UserDto.class, example = "{\"id\":\"usr-1\"}"))
|
||||||
|
static class ExampleHandler extends RequestHandler {
|
||||||
|
@Override public Object handle(Request request, Response response) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET("/secure-too")
|
||||||
|
@ApiOperation(summary = "Secure too")
|
||||||
|
static class SecondSecureHandler extends RequestHandler {
|
||||||
|
@Override public Object handle(Request request, Response response) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void a_route_with_no_annotations_is_still_documented() {
|
||||||
|
OpenApiBuilder b = new OpenApiBuilder();
|
||||||
|
b.addOperation(OpenApiBuilder.routeOf(BareHandler.class), null, BareHandler.class);
|
||||||
|
|
||||||
|
Map<String, Object> get = getOperation(b.build(), "/bare", "get");
|
||||||
|
Map<String, Object> responses = cast(get.get("responses"));
|
||||||
|
Map<String, Object> ok = cast(responses.get("200"));
|
||||||
|
Map<String, Object> content = cast(ok.get("content"));
|
||||||
|
Map<String, Object> json = cast(content.get("application/json"));
|
||||||
|
Map<String, Object> schema = cast(json.get("schema"));
|
||||||
|
|
||||||
|
assertEquals("#/components/schemas/UserDTO", schema.get("$ref"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void a_failure_is_documented_with_the_shape_flash_answers_with() {
|
||||||
|
OpenApiBuilder b = new OpenApiBuilder();
|
||||||
|
b.addOperation(OpenApiBuilder.routeOf(SecureHandler.class), SecureHandler.class.getAnnotation(ApiOperation.class), SecureHandler.class);
|
||||||
|
|
||||||
|
Map<String, Object> spec = b.build();
|
||||||
|
Map<String, Object> get = getOperation(spec, "/secure", "get");
|
||||||
|
Map<String, Object> responses = cast(get.get("responses"));
|
||||||
|
Map<String, Object> forbidden = cast(responses.get("403"));
|
||||||
|
Map<String, Object> content = cast(forbidden.get("content"));
|
||||||
|
Map<String, Object> json = cast(content.get("application/json"));
|
||||||
|
Map<String, Object> schema = cast(json.get("schema"));
|
||||||
|
Map<String, Object> components = cast(spec.get("components"));
|
||||||
|
Map<String, Object> schemas = cast(components.get("schemas"));
|
||||||
|
|
||||||
|
assertEquals("#/components/schemas/Error", schema.get("$ref"));
|
||||||
|
assertTrue(schemas.containsKey("Error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void an_answer_two_operations_share_is_written_once() {
|
||||||
|
OpenApiBuilder b = new OpenApiBuilder();
|
||||||
|
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||||
|
registry.add(new OpenApiContributor() {
|
||||||
|
@Override public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
|
||||||
|
return OpenApiOperationContribution.builder()
|
||||||
|
.response(401, OpenApiResponseContribution.of("Authentication required"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
b.setContributorRegistry(registry);
|
||||||
|
b.addOperation(OpenApiBuilder.routeOf(SecureHandler.class), SecureHandler.class.getAnnotation(ApiOperation.class), SecureHandler.class);
|
||||||
|
b.addOperation(OpenApiBuilder.routeOf(SecondSecureHandler.class), SecondSecureHandler.class.getAnnotation(ApiOperation.class), SecondSecureHandler.class);
|
||||||
|
|
||||||
|
Map<String, Object> spec = b.build();
|
||||||
|
Map<String, Object> components = cast(spec.get("components"));
|
||||||
|
Map<String, Object> shared = cast(components.get("responses"));
|
||||||
|
Map<String, Object> firstResponses = cast(getOperation(spec, "/secure", "get").get("responses"));
|
||||||
|
Map<String, Object> secondResponses = cast(getOperation(spec, "/secure-too", "get").get("responses"));
|
||||||
|
Map<String, Object> first = cast(firstResponses.get("401"));
|
||||||
|
Map<String, Object> second = cast(secondResponses.get("401"));
|
||||||
|
Map<String, Object> unauthorized = cast(shared.get("Unauthorized"));
|
||||||
|
|
||||||
|
assertEquals("#/components/responses/Unauthorized", first.get("$ref"));
|
||||||
|
assertEquals("#/components/responses/Unauthorized", second.get("$ref"));
|
||||||
|
assertEquals("Authentication required", unauthorized.get("description"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void an_example_sits_beside_the_schema() {
|
||||||
|
OpenApiBuilder b = new OpenApiBuilder();
|
||||||
|
b.addOperation(OpenApiBuilder.routeOf(ExampleHandler.class), ExampleHandler.class.getAnnotation(ApiOperation.class), ExampleHandler.class);
|
||||||
|
|
||||||
|
Map<String, Object> get = getOperation(b.build(), "/example", "get");
|
||||||
|
Map<String, Object> responses = cast(get.get("responses"));
|
||||||
|
Map<String, Object> ok = cast(responses.get("200"));
|
||||||
|
Map<String, Object> content = cast(ok.get("content"));
|
||||||
|
Map<String, Object> json = cast(content.get("application/json"));
|
||||||
|
|
||||||
|
assertEquals("{\"id\":\"usr-1\"}", json.get("example"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void builds_single_response_and_parameters_and_schema() {
|
void builds_single_response_and_parameters_and_schema() {
|
||||||
OpenApiBuilder b = new OpenApiBuilder().title("X").version("1");
|
OpenApiBuilder b = new OpenApiBuilder().title("X").version("1");
|
||||||
|
|||||||
@@ -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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
<packaging>pom</packaging>
|
<packaging>pom</packaging>
|
||||||
|
|
||||||
<modules>
|
<modules>
|
||||||
<module>flash-ext-jackson</module>
|
<module>flash-ext-jackson-core</module>
|
||||||
|
<module>flash-ext-jackson-json</module>
|
||||||
|
<module>flash-ext-jackson-xml</module>
|
||||||
<module>flash-ext-openapi</module>
|
<module>flash-ext-openapi</module>
|
||||||
<module>flash-ext-security-core</module>
|
<module>flash-ext-security-core</module>
|
||||||
<module>flash-ext-security-oidc</module>
|
<module>flash-ext-security-oidc</module>
|
||||||
@@ -30,7 +32,6 @@
|
|||||||
<module>flash-ext-vite</module>
|
<module>flash-ext-vite</module>
|
||||||
<module>flash-ext-vite-maven-plugin</module>
|
<module>flash-ext-vite-maven-plugin</module>
|
||||||
<module>flash-ext-mcp</module>
|
<module>flash-ext-mcp</module>
|
||||||
<module>flash-ext-validation</module>
|
|
||||||
<module>flash-ext-scheduler</module>
|
<module>flash-ext-scheduler</module>
|
||||||
<module>flash-ext-data-core</module>
|
<module>flash-ext-data-core</module>
|
||||||
<module>flash-ext-data-jdbc</module>
|
<module>flash-ext-data-jdbc</module>
|
||||||
@@ -41,11 +42,6 @@
|
|||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
|
||||||
<groupId>dev.relism</groupId>
|
|
||||||
<artifactId>flash-ext-validation</artifactId>
|
|
||||||
<version>${project.version}</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-ext-scheduler</artifactId>
|
<artifactId>flash-ext-scheduler</artifactId>
|
||||||
@@ -85,7 +81,17 @@
|
|||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-ext-jackson</artifactId>
|
<artifactId>flash-ext-jackson-core</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-jackson-json</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-jackson-xml</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -98,6 +104,11 @@
|
|||||||
<artifactId>jackson-databind</artifactId>
|
<artifactId>jackson-databind</artifactId>
|
||||||
<version>2.17.2</version>
|
<version>2.17.2</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.dataformat</groupId>
|
||||||
|
<artifactId>jackson-dataformat-xml</artifactId>
|
||||||
|
<version>2.17.2</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.fasterxml.jackson.dataformat</groupId>
|
<groupId>com.fasterxml.jackson.dataformat</groupId>
|
||||||
<artifactId>jackson-dataformat-yaml</artifactId>
|
<artifactId>jackson-dataformat-yaml</artifactId>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package dev.relism.flash.extension;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A service this handler needs, filled in once when the handler is bound.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* public final class ListUsers extends RequestHandler {
|
||||||
|
* @Inject private UserService users;
|
||||||
|
*
|
||||||
|
* @Override public Object handle(Request req, Response res) { return users.findAll(); }
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>This is how a handler takes a service. {@code onInit} stays for what has to be computed at
|
||||||
|
* boot, or for a service that may not be there ({@code find}).
|
||||||
|
*
|
||||||
|
* <h3>What it does, exactly</h3>
|
||||||
|
* <ul>
|
||||||
|
* <li>Filled inside {@code bind}, <b>before</b> {@code onInit}, once per handler instance when
|
||||||
|
* the route is registered. Never per request: the request path reads a field.</li>
|
||||||
|
* <li>Every annotated field of the handler and of its bases up to {@code RequestHandler} is
|
||||||
|
* filled, {@code private} included.</li>
|
||||||
|
* <li>The service is looked up by the field's <b>declared type, exactly</b> — not a supertype,
|
||||||
|
* not a generic parameter. A type nothing provides fails the boot, naming the field.</li>
|
||||||
|
* <li>A {@code static} field is refused: it would be shared by every handler. A {@code final}
|
||||||
|
* one is refused too: it is written after construction, and a reader may have folded it.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Target(ElementType.FIELD)
|
||||||
|
public @interface Inject {}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package dev.relism.flash.models;
|
||||||
|
|
||||||
|
import java.lang.reflect.ParameterizedType;
|
||||||
|
import java.lang.reflect.Type;
|
||||||
|
import java.lang.reflect.TypeVariable;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A handler whose request carries a body of a known type.
|
||||||
|
*
|
||||||
|
* <p>The type is the class's own type argument, so it is written once, in the signature:
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* @POST("/users")
|
||||||
|
* public final class CreateUser extends JsonHandler<NewUser> {
|
||||||
|
* @Override protected Object handle(Request req, Response res, NewUser body) {
|
||||||
|
* return users.create(body);
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <p>Reading the body is the format's job: a subclass such as {@code JsonHandler} implements
|
||||||
|
* {@link #body} and declares its media type with
|
||||||
|
* {@link dev.relism.flash.routing.Consumes @Consumes}. Documentation tools read both off the
|
||||||
|
* class, which is what lets a request body be described without saying its type a second time.
|
||||||
|
*
|
||||||
|
* @param <B> the body type
|
||||||
|
*/
|
||||||
|
public abstract class BodyHandler<B> extends RequestHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The body type a handler class declares, or {@code null} when it leaves it open.
|
||||||
|
*
|
||||||
|
* <p>Resolved through the whole chain, so an intermediate base class that passes its own type
|
||||||
|
* argument along answers with the type its subclass fixed.
|
||||||
|
*/
|
||||||
|
public static Class<?> bodyTypeOf(Class<?> handlerClass) {
|
||||||
|
Map<TypeVariable<?>, Type> bound = new HashMap<>();
|
||||||
|
|
||||||
|
for (Class<?> current = handlerClass; current != null && current != Object.class; ) {
|
||||||
|
if (!(current.getGenericSuperclass() instanceof ParameterizedType parameterized)) {
|
||||||
|
current = current.getSuperclass();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Class<?> raw = (Class<?>) parameterized.getRawType();
|
||||||
|
Type[] arguments = parameterized.getActualTypeArguments();
|
||||||
|
TypeVariable<?>[] variables = raw.getTypeParameters();
|
||||||
|
|
||||||
|
for (int i = 0; i < variables.length && i < arguments.length; i++) {
|
||||||
|
bound.put(variables[i], resolve(arguments[i], bound));
|
||||||
|
}
|
||||||
|
if (raw == BodyHandler.class) {
|
||||||
|
return resolve(arguments[0], bound) instanceof Class<?> type ? type : null;
|
||||||
|
}
|
||||||
|
current = raw;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Type resolve(Type type, Map<TypeVariable<?>, Type> bound) {
|
||||||
|
return type instanceof TypeVariable<?> variable ? bound.getOrDefault(variable, type) : type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This handler's body type, for the reader in {@link #body}. Null only on a handler left generic. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
protected final Class<B> bodyType() {
|
||||||
|
return (Class<B>) bodyTypeOf(getClass());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the body in the format this handler speaks. */
|
||||||
|
protected abstract B body(Request request) throws Exception;
|
||||||
|
|
||||||
|
protected abstract Object handle(Request request, Response response, B body) throws Exception;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public final Object handle(Request request, Response response) throws Exception {
|
||||||
|
return handle(request, response, body(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@ package dev.relism.flash.models;
|
|||||||
|
|
||||||
import dev.relism.flash.extension.FlashApp;
|
import dev.relism.flash.extension.FlashApp;
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
import dev.relism.flash.extension.Inject;
|
||||||
import dev.relism.flash.routing.Route;
|
import dev.relism.flash.routing.Route;
|
||||||
|
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Modifier;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,8 +25,9 @@ import java.util.Optional;
|
|||||||
* </ol>
|
* </ol>
|
||||||
*
|
*
|
||||||
* <h3>Service access</h3>
|
* <h3>Service access</h3>
|
||||||
* Override {@link #onInit()} to cache services from the {@link FlashContext}
|
* Annotate a field with {@link Inject} and it is filled at boot, or override {@link #onInit()}
|
||||||
* into private fields. This keeps the hot-path ({@code handle}) free of map lookups.
|
* to cache services from the {@link FlashContext} by hand. Either way the hot path
|
||||||
|
* ({@code handle}) reads a field and never the context.
|
||||||
*
|
*
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* @GET("/users")
|
* @GET("/users")
|
||||||
@@ -53,9 +57,41 @@ public abstract class RequestHandler {
|
|||||||
*/
|
*/
|
||||||
public final void bind(FlashContext ctx) {
|
public final void bind(FlashContext ctx) {
|
||||||
this.ctx = ctx;
|
this.ctx = ctx;
|
||||||
|
inject();
|
||||||
onInit();
|
onInit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fills every {@link Inject} field, this class's and its bases', before {@link #onInit}. */
|
||||||
|
private void inject() {
|
||||||
|
for (Class<?> type = getClass(); type != null && type != RequestHandler.class; type = type.getSuperclass()) {
|
||||||
|
for (Field field : type.getDeclaredFields()) {
|
||||||
|
if (!field.isAnnotationPresent(Inject.class)) continue;
|
||||||
|
String where = type.getSimpleName() + "." + field.getName();
|
||||||
|
|
||||||
|
// A static field would be shared by every handler, and a final one may already have
|
||||||
|
// been folded into the code that reads it. Both are refused rather than surprising.
|
||||||
|
if (Modifier.isStatic(field.getModifiers()))
|
||||||
|
throw new IllegalStateException(where + " is static: an injected field belongs to the handler");
|
||||||
|
if (Modifier.isFinal(field.getModifiers()))
|
||||||
|
throw new IllegalStateException(where + " is final: an injected field is written after construction");
|
||||||
|
|
||||||
|
Object service;
|
||||||
|
try {
|
||||||
|
service = ctx.require(field.getType());
|
||||||
|
} catch (RuntimeException missing) {
|
||||||
|
throw new IllegalStateException(where + " asks for " + field.getType().getSimpleName()
|
||||||
|
+ ", which nothing provides", missing);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
field.setAccessible(true);
|
||||||
|
field.set(this, service);
|
||||||
|
} catch (ReflectiveOperationException | RuntimeException unreachable) {
|
||||||
|
throw new IllegalStateException("Could not write " + where, unreachable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Override to cache services at boot time. Called once after {@link #bind},
|
* Override to cache services at boot time. Called once after {@link #bind},
|
||||||
* before any request reaches this handler.
|
* before any request reaches this handler.
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package dev.relism.flash.routing;
|
||||||
|
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The media type a handler reads its request body as.
|
||||||
|
*
|
||||||
|
* <p>Declared once on a handler base class — a JSON one, an XML one — and inherited by every
|
||||||
|
* handler written against it, so nothing has to repeat it. Tooling reads it; the router does not.
|
||||||
|
*/
|
||||||
|
@Inherited
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
@Target(ElementType.TYPE)
|
||||||
|
public @interface Consumes {
|
||||||
|
ContentType value();
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package dev.relism.flash.models;
|
||||||
|
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
import dev.relism.flash.http.HttpMethod;
|
||||||
|
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.assertNull;
|
||||||
|
|
||||||
|
class BodyHandlerTest {
|
||||||
|
|
||||||
|
record Payload(String text) {}
|
||||||
|
|
||||||
|
static abstract class TextHandler<B> extends BodyHandler<B> {
|
||||||
|
@Override protected B body(Request request) {
|
||||||
|
return read(new String(request.body().bytes(), StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
protected abstract B read(String text);
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class Echo extends TextHandler<Payload> {
|
||||||
|
@Override protected Payload read(String text) { return new Payload(text); }
|
||||||
|
@Override protected Object handle(Request request, Response response, Payload body) { return body.text(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class Raw extends BodyHandler<Object> {
|
||||||
|
@Override protected Object body(Request request) { return null; }
|
||||||
|
@Override protected Object handle(Request request, Response response, Object body) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theBodyTypeIsReadThroughTheWholeChain() {
|
||||||
|
assertEquals(Payload.class, BodyHandler.bodyTypeOf(Echo.class), "resolved past the intermediate base");
|
||||||
|
assertEquals(Object.class, BodyHandler.bodyTypeOf(Raw.class));
|
||||||
|
assertNull(BodyHandler.bodyTypeOf(RequestHandler.class), "a handler with no body declares none");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theBodyIsReadBeforeTheHandlerSeesIt() throws Exception {
|
||||||
|
Object answer = new Echo().handle(request("hello"), new Response(200, ContentType.NONE));
|
||||||
|
|
||||||
|
assertEquals("hello", answer);
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class Injected extends RequestHandler {
|
||||||
|
@dev.relism.flash.extension.Inject private String service;
|
||||||
|
@Override public Object handle(Request request, Response response) { return service; }
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class Missing extends RequestHandler {
|
||||||
|
@dev.relism.flash.extension.Inject private Integer absent;
|
||||||
|
@Override public Object handle(Request request, Response response) { return absent; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void an_injected_field_is_filled_before_the_handler_runs() throws Exception {
|
||||||
|
dev.relism.flash.extension.FlashContext ctx = new dev.relism.flash.extension.FlashContext();
|
||||||
|
ctx.provide(String.class, "provided");
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
|
Injected handler = new Injected();
|
||||||
|
handler.bind(ctx);
|
||||||
|
|
||||||
|
assertEquals("provided", handler.handle(request(""), new Response(200, ContentType.NONE)));
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class Shared extends RequestHandler {
|
||||||
|
@dev.relism.flash.extension.Inject private static String service;
|
||||||
|
@Override public Object handle(Request request, Response response) { return service; }
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class Frozen extends RequestHandler {
|
||||||
|
@dev.relism.flash.extension.Inject private final String service = "";
|
||||||
|
@Override public Object handle(Request request, Response response) { return service; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void a_static_or_final_field_is_refused() {
|
||||||
|
dev.relism.flash.extension.FlashContext ctx = new dev.relism.flash.extension.FlashContext();
|
||||||
|
ctx.provide(String.class, "provided");
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(org.junit.jupiter.api.Assertions.assertThrows(
|
||||||
|
IllegalStateException.class, () -> new Shared().bind(ctx)).getMessage().contains("is static"));
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(org.junit.jupiter.api.Assertions.assertThrows(
|
||||||
|
IllegalStateException.class, () -> new Frozen().bind(ctx)).getMessage().contains("is final"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void a_field_nothing_provides_fails_the_boot_naming_it() {
|
||||||
|
dev.relism.flash.extension.FlashContext ctx = new dev.relism.flash.extension.FlashContext();
|
||||||
|
ctx.complete();
|
||||||
|
|
||||||
|
IllegalStateException refused = org.junit.jupiter.api.Assertions.assertThrows(
|
||||||
|
IllegalStateException.class, () -> new Missing().bind(ctx));
|
||||||
|
|
||||||
|
org.junit.jupiter.api.Assertions.assertTrue(refused.getMessage().contains("Missing.absent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Request request(String body) {
|
||||||
|
return new Request(new RequestLine(HttpMethod.POST,
|
||||||
|
new FastPathViews.StringByteView("/echo"), null,
|
||||||
|
new FastPathViews.StringByteView("HTTP/1.1"), new Http1HeaderMap()),
|
||||||
|
body.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,11 @@
|
|||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
<!--
|
||||||
|
Every module of this build, so anything composing Flash imports this POM once and
|
||||||
|
never names a version again. An extension left out here is one a consumer has to
|
||||||
|
pin by hand, which is how two versions of Flash end up on one classpath.
|
||||||
|
-->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash</artifactId>
|
<artifactId>flash</artifactId>
|
||||||
@@ -66,12 +71,47 @@
|
|||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-testing</artifactId>
|
<artifactId>flash-ext-security-core</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-ext-validation</artifactId>
|
<artifactId>flash-ext-security-apikey</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-security-form</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-security-oauth-server</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-security-test</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-data-core</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-data-jdbc</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-data-hibernate</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-testing</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -96,7 +136,17 @@
|
|||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-ext-jackson</artifactId>
|
<artifactId>flash-ext-jackson-core</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-jackson-json</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-jackson-xml</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
Reference in New Issue
Block a user