i spent the last year just spinning
This commit is contained in:
@@ -1,14 +1,44 @@
|
||||
# flash-ext-jackson
|
||||
|
||||
Jackson JSON integration for the Flash HTTP server.
|
||||
Jackson JSON integration for Flash with an opinionated auto-marshal middleware.
|
||||
|
||||
## What it provides
|
||||
|
||||
| Component | Description |
|
||||
|---|---|
|
||||
| `JacksonExtension` | Installs Jackson into the extension layer |
|
||||
| `JacksonHandler` | Base class for handlers that need JSON I/O |
|
||||
| Global exception handler | Maps `HttpException` → JSON error; all other exceptions → 500 |
|
||||
| `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
|
||||
|
||||
@@ -16,21 +46,43 @@ Jackson JSON integration for the Flash HTTP server.
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<version>1.1-indev2</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Json helper API
|
||||
|
||||
Use `Json` when you want explicit, local control in a handler.
|
||||
|
||||
```java
|
||||
FlashApp.create(8080)
|
||||
.install(new JacksonExtension())
|
||||
.install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
|
||||
.start();
|
||||
@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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Install order is irrelevant — Flash's two-phase extension model ensures `ObjectMapper` is
|
||||
resolved before any extension that calls `ctx.require(ObjectMapper.class)` needs it.
|
||||
Methods:
|
||||
|
||||
### Custom mapper
|
||||
- `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()
|
||||
@@ -42,63 +94,23 @@ FlashApp.create(8080)
|
||||
.install(new JacksonExtension(mapper));
|
||||
```
|
||||
|
||||
## JacksonHandler
|
||||
## Scope usage
|
||||
|
||||
Extend `JacksonHandler` to get `bodyAs` and `json` helpers without constructor boilerplate.
|
||||
The `ObjectMapper` is injected once at startup and shared across all subclasses.
|
||||
`autoJson()` works the same at scope level:
|
||||
|
||||
```java
|
||||
@Route(method = HttpMethod.POST, path = "/api/blogs")
|
||||
@ApiOperation(summary = "Create blog")
|
||||
@ApiResponse(status = 201, description = "Created", schema = Blog.class)
|
||||
@ApiResponse(status = 400, description = "Invalid body")
|
||||
public class CreateBlog extends JacksonHandler {
|
||||
JacksonExtension jackson = new JacksonExtension();
|
||||
|
||||
@Override
|
||||
public Object handle(Request req, Response res) throws Exception {
|
||||
CreateBlogRequest body = bodyAs(req, CreateBlogRequest.class);
|
||||
Blog created = service.create(body);
|
||||
res.status(201);
|
||||
return json(res, created); // sets Content-Type: application/json
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `bodyAs(req, Type.class)` | Deserializes the request body; throws `HttpException` 400 on parse errors |
|
||||
| `json(res, obj)` | Serializes `obj`, sets `Content-Type: application/json`, returns the JSON string |
|
||||
| `jsonView(res, obj, View.class)` | Like `json` but applies a Jackson `@JsonView` filter |
|
||||
|
||||
## Exception handling
|
||||
|
||||
`JacksonExtension` installs a global exception handler that applies to every route:
|
||||
|
||||
```
|
||||
HttpException(status, message) → HTTP <status> {"error": "<message>"}
|
||||
Any other Throwable → HTTP 500 {"error": "Internal Server Error"}
|
||||
```
|
||||
|
||||
To throw a handled HTTP error from any handler:
|
||||
|
||||
```java
|
||||
throw HttpException.notFound("Blog not found");
|
||||
throw HttpException.badRequest("Missing field: title");
|
||||
throw HttpException.unauthorized();
|
||||
throw HttpException.forbidden();
|
||||
```
|
||||
|
||||
## Lambda routes
|
||||
|
||||
For lambda-style routes, use the `ObjectMapper` directly from the context:
|
||||
|
||||
```java
|
||||
ObjectMapper mapper = app.ctx().require(ObjectMapper.class);
|
||||
|
||||
app.get("/api/status", (req, res) -> {
|
||||
res.type(ContentType.JSON);
|
||||
return mapper.writeValueAsString(Map.of("status", "ok"));
|
||||
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.
|
||||
|
||||
@@ -7,11 +7,15 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<version>1.1-indev5</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
|
||||
<properties>
|
||||
<jacoco.version>0.8.12</jacoco.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
@@ -21,6 +25,10 @@
|
||||
<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>
|
||||
@@ -31,4 +39,44 @@
|
||||
</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>
|
||||
|
||||
+31
-4
@@ -1,9 +1,11 @@
|
||||
package dev.relism.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import dev.relism.extension.FlashContext;
|
||||
import dev.relism.extension.FlashExtension;
|
||||
import dev.relism.routing.Middleware;
|
||||
|
||||
/**
|
||||
* Registers JSON support into the Flash extension layer.
|
||||
@@ -15,6 +17,9 @@ import dev.relism.extension.FlashExtension;
|
||||
* <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 {
|
||||
@@ -45,21 +50,43 @@ import dev.relism.extension.FlashExtension;
|
||||
public class JacksonExtension implements FlashExtension {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final JacksonMiddleware middleware;
|
||||
|
||||
/** Installs with a default {@link JsonMapper} (no extra modules). */
|
||||
/**
|
||||
* 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().build());
|
||||
this(JsonMapper.builder()
|
||||
.findAndAddModules()
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.build());
|
||||
}
|
||||
|
||||
/** Installs with a fully configured custom {@link ObjectMapper}. */
|
||||
public JacksonExtension(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
this.middleware = new JacksonMiddleware(mapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opinionated outbound JSON middleware factory.
|
||||
*
|
||||
* <p>Use for app/scope-level registration:
|
||||
* <pre>{@code
|
||||
* JacksonExtension jackson = new JacksonExtension();
|
||||
* app.install(jackson).use(jackson.autoJson());
|
||||
* }</pre>
|
||||
*/
|
||||
public Middleware autoJson() {
|
||||
return middleware.autoJson();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void provide(FlashContext ctx) {
|
||||
Json json = new Json(mapper);
|
||||
ctx.provide(Json.class, json);
|
||||
ctx.provide(ObjectMapper.class, mapper);
|
||||
ctx.provide(Json.class, json);
|
||||
ctx.provide(ObjectMapper.class, mapper);
|
||||
ctx.provide(JacksonMiddleware.class, middleware);
|
||||
}
|
||||
}
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package dev.relism.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.models.Response;
|
||||
import dev.relism.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;
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package dev.relism.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.extension.FlashContext;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.Response;
|
||||
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.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JacksonExtensionTest {
|
||||
|
||||
@Test
|
||||
void provide_registers_json_mapper_and_middleware() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JacksonExtension ext = new JacksonExtension(mapper);
|
||||
|
||||
ext.provide(ctx);
|
||||
|
||||
assertNotNull(ctx.require(Json.class));
|
||||
assertNotNull(ctx.require(JacksonMiddleware.class));
|
||||
assertSame(mapper, ctx.require(ObjectMapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoJson_factory_delegates_to_middleware_policy() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JacksonExtension ext = new JacksonExtension(mapper);
|
||||
RequestHandler next = new RequestHandler() {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
return new Payload("ok");
|
||||
}
|
||||
};
|
||||
RequestHandler wrapped = new RequestHandler() {
|
||||
private final dev.relism.models.SimpleHandler.FunctionalHandler delegate = ext.autoJson().wrap(next);
|
||||
|
||||
@Override
|
||||
public Object handle(Request request, Response response) throws Exception {
|
||||
return delegate.handle(request, response);
|
||||
}
|
||||
};
|
||||
|
||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
||||
Object out = wrapped.handle(null, res);
|
||||
|
||||
assertTrue(out instanceof byte[]);
|
||||
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private record Payload(String status) {}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package dev.relism.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.Response;
|
||||
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.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JacksonMiddlewareTest {
|
||||
|
||||
private static final Request REQ = null;
|
||||
|
||||
@Test
|
||||
void autoJson_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception {
|
||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
||||
RequestHandler wrapped = wrap(mw, new UserDto("u1", "alice"));
|
||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
||||
|
||||
Object out = wrapped.handle(REQ, res);
|
||||
|
||||
assertInstanceOf(byte[].class, out);
|
||||
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
|
||||
assertEquals("{\"id\":\"u1\",\"name\":\"alice\"}", new String((byte[]) out, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoJson_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception {
|
||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
||||
|
||||
Response payloadResponse = new Response(201, ContentType.TEXT_PLAIN).body("ok");
|
||||
RequestHandler wrappedResponse = wrap(mw, payloadResponse);
|
||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
||||
assertSame(payloadResponse, wrappedResponse.handle(REQ, res));
|
||||
|
||||
String s = "hello";
|
||||
assertSame(s, wrap(mw, s).handle(REQ, res));
|
||||
|
||||
CharSequence cs = new StringBuilder("hello-cs");
|
||||
assertSame(cs, wrap(mw, cs).handle(REQ, res));
|
||||
|
||||
byte[] bytes = new byte[]{1, 2, 3};
|
||||
assertSame(bytes, wrap(mw, bytes).handle(REQ, res));
|
||||
|
||||
assertSame(null, wrap(mw, null).handle(REQ, res));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoJson_wraps_serialization_errors_as_illegal_state() {
|
||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
||||
RequestHandler wrapped = wrap(mw, new CyclicDto());
|
||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
||||
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> wrapped.handle(REQ, res));
|
||||
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
|
||||
assertTrue(ex.getMessage().startsWith("Failed to serialize handler result as JSON:"));
|
||||
}
|
||||
|
||||
private static RequestHandler wrap(JacksonMiddleware mw, Object fixedReturn) {
|
||||
RequestHandler next = new RequestHandler() {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
return fixedReturn;
|
||||
}
|
||||
};
|
||||
return new RequestHandler() {
|
||||
private final dev.relism.models.SimpleHandler.FunctionalHandler delegate = mw.autoJson().wrap(next);
|
||||
|
||||
@Override
|
||||
public Object handle(Request request, Response response) throws Exception {
|
||||
return delegate.handle(request, response);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private record UserDto(String id, String name) {}
|
||||
|
||||
private static final class CyclicDto {
|
||||
CyclicDto self = this;
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package dev.relism.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonView;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.exceptions.HttpException;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.http.HttpMethod;
|
||||
import dev.relism.models.HeaderMap;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.RequestLine;
|
||||
import dev.relism.models.Response;
|
||||
import dev.relism.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.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JsonTest {
|
||||
|
||||
@Test
|
||||
void body_parses_valid_json_and_maps_bad_payload_to_http_400() throws Exception {
|
||||
Json json = new Json(new ObjectMapper());
|
||||
|
||||
Request ok = request("{\"id\":\"u1\",\"name\":\"alice\"}");
|
||||
UserDto dto = json.body(ok, UserDto.class);
|
||||
assertEquals("u1", dto.id);
|
||||
assertEquals("alice", dto.name);
|
||||
|
||||
Request bad = request("not-json");
|
||||
HttpException ex = assertThrows(HttpException.class, () -> json.body(bad, UserDto.class));
|
||||
assertEquals(400, ex.status());
|
||||
assertTrue(ex.getMessage().startsWith("Invalid request body:"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bodyFrom_parses_stream_and_maps_bad_payload_to_http_400() throws Exception {
|
||||
Json json = new Json(new ObjectMapper());
|
||||
|
||||
Request ok = request("{\"id\":\"u2\",\"name\":\"bob\"}");
|
||||
UserDto dto = json.bodyFrom(ok, UserDto.class);
|
||||
assertEquals("u2", dto.id);
|
||||
assertEquals("bob", dto.name);
|
||||
|
||||
Request bad = request("[");
|
||||
HttpException ex = assertThrows(HttpException.class, () -> json.bodyFrom(bad, UserDto.class));
|
||||
assertEquals(400, ex.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void write_and_writeView_set_content_type_and_render_expected_payload() throws Exception {
|
||||
Json json = new Json(new ObjectMapper());
|
||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
||||
|
||||
String payload = json.write(res, new UserDto("u3", "carol"));
|
||||
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
|
||||
assertEquals("{\"id\":\"u3\",\"name\":\"carol\"}", payload);
|
||||
|
||||
Response viewRes = new Response(200, ContentType.TEXT_PLAIN);
|
||||
String viewed = json.writeView(viewRes, new ViewDto("u4", "hidden"), PublicView.class);
|
||||
assertEquals("application/json", new String(viewRes.getContentType(), StandardCharsets.UTF_8));
|
||||
assertEquals("{\"id\":\"u4\"}", viewed);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapper_returns_underlying_object_mapper_instance() {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
Json json = new Json(mapper);
|
||||
assertSame(mapper, json.mapper());
|
||||
}
|
||||
|
||||
private static Request request(String body) {
|
||||
return request(body.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static Request request(byte[] body) {
|
||||
RequestLine line = new RequestLine(
|
||||
HttpMethod.POST,
|
||||
new FastPathViews.StringByteView("/json"),
|
||||
null,
|
||||
new FastPathViews.StringByteView("HTTP/1.1"),
|
||||
new HeaderMap()
|
||||
);
|
||||
return new Request(line, body);
|
||||
}
|
||||
|
||||
private static final class UserDto {
|
||||
public String id;
|
||||
public String name;
|
||||
|
||||
public UserDto() {}
|
||||
|
||||
private UserDto(String id, String name) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
private interface PublicView {}
|
||||
private interface InternalView {}
|
||||
|
||||
private static final class ViewDto {
|
||||
@JsonView(PublicView.class)
|
||||
public String id;
|
||||
@JsonView(InternalView.class)
|
||||
public String secret;
|
||||
|
||||
private ViewDto(String id, String secret) {
|
||||
this.id = id;
|
||||
this.secret = secret;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<version>1.1-indev5</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-limiter</artifactId>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<version>1.1-indev5</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-oidc</artifactId>
|
||||
|
||||
@@ -295,7 +295,41 @@ public class OidcExtension implements FlashExtension {
|
||||
public java.util.List<String> requiredFor(Class<?> handlerClass) {
|
||||
return OidcAuthPolicy.openApiScopesFor(handlerClass);
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Map<Integer, String> autoResponsesFor(Class<?> handlerClass) {
|
||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
|
||||
if (policy == null || policy.optionalAuth()) return java.util.Map.of();
|
||||
|
||||
java.util.LinkedHashMap<Integer, String> out = new java.util.LinkedHashMap<>();
|
||||
out.put(401, "Authentication required");
|
||||
|
||||
String[] roles = policy.requiredRoles();
|
||||
String[] scopes = policy.requiredScopes();
|
||||
if (roles.length == 0 && scopes.length == 0) return out;
|
||||
|
||||
String roleMessage = roles.length == 0 ? null : roleRequiredMessage(roles);
|
||||
String scopeMessage = scopes.length == 0 ? null : scopeRequiredMessage(scopes);
|
||||
if (roleMessage != null && scopeMessage != null) {
|
||||
out.put(403, roleMessage + "; " + scopeMessage);
|
||||
} else if (roleMessage != null) {
|
||||
out.put(403, roleMessage);
|
||||
} else {
|
||||
out.put(403, scopeMessage);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private static String roleRequiredMessage(String[] roles) {
|
||||
if (roles.length == 1) return "\"" + roles[0] + "\" role required";
|
||||
return "Roles \"" + String.join(", ", roles) + "\" are required";
|
||||
}
|
||||
|
||||
private static String scopeRequiredMessage(String[] scopes) {
|
||||
if (scopes.length == 1) return "\"" + scopes[0] + "\" scope required";
|
||||
return "Scopes \"" + String.join(", ", scopes) + "\" are required";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
import dev.relism.ext.openapi.OpenApiSecurityContributor;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class OidcOpenApiInteropTest {
|
||||
|
||||
@Authenticated
|
||||
static class AuthOnly {}
|
||||
|
||||
@Authenticated(optional = true)
|
||||
static class AuthOptional {}
|
||||
|
||||
@RolesAllowed("admin")
|
||||
static class OneRole {}
|
||||
|
||||
@RolesAllowed({"admin", "operator"})
|
||||
static class MultiRole {}
|
||||
|
||||
@ScopesAllowed("orders:write")
|
||||
static class OneScope {}
|
||||
|
||||
@ScopesAllowed({"orders:write", "payments:write"})
|
||||
static class MultiScope {}
|
||||
|
||||
@RolesAllowed("admin")
|
||||
@ScopesAllowed("orders:write")
|
||||
static class RoleAndScope {}
|
||||
|
||||
@Test
|
||||
void autoResponses_authOnly() throws Exception {
|
||||
Map<Integer, String> responses = contributor().autoResponsesFor(AuthOnly.class);
|
||||
assertEquals("Authentication required", responses.get(401));
|
||||
assertFalse(responses.containsKey(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoResponses_optionalAuth_addsNothing() throws Exception {
|
||||
Map<Integer, String> responses = contributor().autoResponsesFor(AuthOptional.class);
|
||||
assertTrue(responses.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoResponses_oneRole_formatsSingular() throws Exception {
|
||||
Map<Integer, String> responses = contributor().autoResponsesFor(OneRole.class);
|
||||
assertEquals("Authentication required", responses.get(401));
|
||||
assertEquals("\"admin\" role required", responses.get(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoResponses_multiRoles_formatsPlural() throws Exception {
|
||||
Map<Integer, String> responses = contributor().autoResponsesFor(MultiRole.class);
|
||||
assertEquals("Roles \"admin, operator\" are required", responses.get(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoResponses_oneScope_formatsSingular() throws Exception {
|
||||
Map<Integer, String> responses = contributor().autoResponsesFor(OneScope.class);
|
||||
assertEquals("\"orders:write\" scope required", responses.get(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoResponses_multiScopes_formatsPlural() throws Exception {
|
||||
Map<Integer, String> responses = contributor().autoResponsesFor(MultiScope.class);
|
||||
assertEquals("Scopes \"orders:write, payments:write\" are required", responses.get(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoResponses_roleAndScope_combinesMessages() throws Exception {
|
||||
Map<Integer, String> responses = contributor().autoResponsesFor(RoleAndScope.class);
|
||||
assertEquals("\"admin\" role required; \"orders:write\" scope required", responses.get(403));
|
||||
}
|
||||
|
||||
private static OpenApiSecurityContributor contributor() throws Exception {
|
||||
Class<?> clazz = Class.forName("dev.relism.ext.oidc.OidcExtension$OpenApiIntegration");
|
||||
Constructor<?> ctor = clazz.getDeclaredConstructor();
|
||||
ctor.setAccessible(true);
|
||||
Object instance = ctor.newInstance();
|
||||
|
||||
Method m = clazz.getDeclaredMethod("register", dev.relism.extension.FlashContext.class, OidcConfig.class, OidcProviderMetadata.class);
|
||||
m.setAccessible(true);
|
||||
|
||||
dev.relism.extension.FlashContext ctx = new dev.relism.extension.FlashContext();
|
||||
dev.relism.ext.openapi.OpenApiSecurityRegistry registry = new dev.relism.ext.openapi.OpenApiSecurityRegistry();
|
||||
ctx.provide(dev.relism.ext.openapi.OpenApiSecurityRegistry.class, registry);
|
||||
|
||||
OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build();
|
||||
OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e");
|
||||
m.invoke(instance, ctx, config, meta);
|
||||
|
||||
return registry.contributors().getFirst();
|
||||
}
|
||||
}
|
||||
@@ -1,161 +1,126 @@
|
||||
# flash-ext-openapi
|
||||
|
||||
OpenAPI 3.0 spec generation and Swagger UI for the Flash HTTP server.
|
||||
OpenAPI 3.0.3 generation + Swagger UI for Flash.
|
||||
|
||||
## What it provides
|
||||
|
||||
| Route | Description |
|
||||
|---|---|
|
||||
| `GET /openapi.json` | OpenAPI 3.0.3 spec as JSON |
|
||||
| `GET /openapi.yaml` | OpenAPI 3.0.3 spec as YAML |
|
||||
| `GET /openapi/swagger` | Swagger UI (loaded from unpkg CDN) |
|
||||
| `GET /openapi.json` | OpenAPI spec JSON |
|
||||
| `GET /openapi.yaml` | OpenAPI spec YAML |
|
||||
| `GET /openapi/swagger` | Swagger UI |
|
||||
|
||||
The base path is configurable. Operations are collected automatically at handler-registration time
|
||||
from class-based handlers annotated with `@ApiOperation`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Requires `flash-ext-jackson` (shares its `ObjectMapper` from context).
|
||||
If `flash-ext-oidc` is also installed, OIDC security schemes are injected automatically.
|
||||
Install order is irrelevant — the two-phase extension model handles dependency ordering.
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-openapi</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Installation
|
||||
## Install
|
||||
|
||||
```java
|
||||
FlashApp.create(8080)
|
||||
.install(new JacksonExtension())
|
||||
.install(new OpenApiExtension("/openapi", "My API", "2.0.0", "Optional description"))
|
||||
.register(new MyHandler());
|
||||
.install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
|
||||
.scan("com.acme.handlers")
|
||||
.startAndBlock();
|
||||
```
|
||||
|
||||
### Constructors
|
||||
## Operation annotation
|
||||
|
||||
```java
|
||||
new OpenApiExtension() // base path: /openapi, title: API, version: 1.0.0
|
||||
new OpenApiExtension("/docs") // custom base path
|
||||
new OpenApiExtension("/docs", "My API", "2.0.0") // title + version
|
||||
new OpenApiExtension("/docs", "My API", "2.0.0", "desc") // full
|
||||
```
|
||||
|
||||
## Annotating handlers
|
||||
|
||||
All annotations target the **handler class** (`@Target(ElementType.TYPE)`).
|
||||
|
||||
### @ApiOperation
|
||||
|
||||
```java
|
||||
@GET("/api/blogs")
|
||||
@ApiOperation(
|
||||
summary = "List all blogs",
|
||||
description = "Returns a paginated list of published blog posts.",
|
||||
tags = {"blogs"},
|
||||
operationId = "listBlogs",
|
||||
deprecated = false
|
||||
@GET("/users/{id}")
|
||||
@ApiOperation(summary = "Get user", description = "Returns one user", tags = {"users"})
|
||||
@Parameter(name = "expand", in = ParameterIn.QUERY, type = SchemaType.STRING, examples = {"roles", "permissions"})
|
||||
@APIResponse(
|
||||
responseCode = "200",
|
||||
description = "User found",
|
||||
content = @Content(contentType = ContentType.JSON, schema = UserDto.class)
|
||||
)
|
||||
public class ListBlogs extends JacksonHandler { ... }
|
||||
public final class GetUser extends RequestHandler { ... }
|
||||
```
|
||||
|
||||
| Field | Default | Description |
|
||||
|---|---|---|
|
||||
| `summary` | `""` | Short one-liner shown in the operation title |
|
||||
| `description` | `""` | Longer Markdown description |
|
||||
| `tags` | `{}` | Groups operations in the Swagger UI sidebar |
|
||||
| `operationId` | `""` | Unique machine-readable ID |
|
||||
| `deprecated` | `false` | Marks the operation with a strikethrough |
|
||||
## Response patterns
|
||||
|
||||
### @ApiResponse
|
||||
|
||||
Repeatable — annotate as many status codes as the handler can return.
|
||||
### Single object
|
||||
|
||||
```java
|
||||
@ApiResponse(status = 200, description = "Blog created", schema = Blog.class)
|
||||
@ApiResponse(status = 400, description = "Invalid input")
|
||||
@ApiResponse(status = 409, description = "Slug already exists")
|
||||
public class CreateBlog extends JacksonHandler { ... }
|
||||
@APIResponse(
|
||||
responseCode = "200",
|
||||
description = "User found",
|
||||
content = @Content(contentType = ContentType.JSON, schema = UserDto.class)
|
||||
)
|
||||
```
|
||||
|
||||
`schema` references `#/components/schemas/<ClassName>` — you are responsible for populating
|
||||
`components.schemas` if you need full model documentation (not yet auto-generated).
|
||||
|
||||
`@ApiResponse` is repeatable. The container `@ApiResponses({ @ApiResponse(...), ... })` is also available.
|
||||
|
||||
### @ApiParam
|
||||
|
||||
Repeatable — declare query, path, header, or cookie parameters explicitly.
|
||||
### Array
|
||||
|
||||
```java
|
||||
@ApiParam(name = "limit", in = "query", type = "integer", description = "Max results (default 20)")
|
||||
@ApiParam(name = "offset", in = "query", type = "integer", description = "Pagination offset")
|
||||
@ApiParam(name = "slug", in = "path", type = "string", required = true)
|
||||
@ApiParam(name = "X-Trace-Id", in = "header", type = "string")
|
||||
public class GetBlog extends JacksonHandler { ... }
|
||||
@APIResponse(
|
||||
responseCode = "200",
|
||||
description = "Users listed",
|
||||
content = @Content(contentType = ContentType.JSON, schema = UserDto.class, array = true)
|
||||
)
|
||||
```
|
||||
|
||||
> Path parameters in the route (e.g. `@GET("/blogs/{id}")` or `@Route(path = "/blogs/{id}")`) are extracted automatically
|
||||
> as required path parameters — you only need `@ApiParam` for query / header / cookie params.
|
||||
|
||||
`@ApiParam` is repeatable. If you prefer grouping them, `@ApiParams({ @ApiParam(...), @ApiParam(...) })` is
|
||||
the container annotation.
|
||||
|
||||
| Field | Default | Description |
|
||||
|---|---|---|
|
||||
| `name` | — | Parameter name |
|
||||
| `in` | `"query"` | Location: `"query"`, `"path"`, `"header"`, `"cookie"` |
|
||||
| `type` | `"string"` | OpenAPI primitive: `"string"`, `"integer"`, `"number"`, `"boolean"` |
|
||||
| `description` | `""` | Human-readable description |
|
||||
| `required` | `false` | Whether the parameter is mandatory |
|
||||
| `example` | `""` | Inline example value shown in Swagger UI |
|
||||
|
||||
## Security integration
|
||||
|
||||
`flash-ext-openapi` defines the `OpenApiSecurityContributor` / `OpenApiSecurityRegistry` contracts.
|
||||
Security extensions (e.g. `flash-ext-oidc`) register a contributor at install time; the spec
|
||||
builder picks it up automatically — no coupling between extensions.
|
||||
|
||||
### How it works
|
||||
|
||||
1. `OpenApiExtension` creates an `OpenApiSecurityRegistry` and exposes it in the `FlashContext`.
|
||||
2. `flash-ext-oidc` calls `ctx.find(OpenApiSecurityRegistry.class)` and registers its contributor.
|
||||
3. At spec build time, `OpenApiBuilder` iterates contributors and injects `security` entries on each
|
||||
operation whose handler class carries `@Authenticated` or `@RolesAllowed`.
|
||||
|
||||
### Implementing a custom contributor
|
||||
### No content
|
||||
|
||||
```java
|
||||
public class MyAuthContributor implements OpenApiSecurityContributor {
|
||||
@APIResponse(
|
||||
responseCode = "204",
|
||||
description = "Deleted",
|
||||
content = @Content(contentType = ContentType.NONE)
|
||||
)
|
||||
```
|
||||
|
||||
@Override
|
||||
public String schemeName() { return "myScheme"; }
|
||||
### Inferred from handler return type
|
||||
|
||||
@Override
|
||||
public Map<String, Object> schemeDefinition() {
|
||||
return Map.of("type", "apiKey", "in", "header", "name", "X-API-Key");
|
||||
}
|
||||
```java
|
||||
@APIResponse(
|
||||
responseCode = "200",
|
||||
content = @Content
|
||||
)
|
||||
```
|
||||
|
||||
@Override
|
||||
public List<String> requiredFor(Class<?> handlerClass) {
|
||||
if (handlerClass.isAnnotationPresent(MyAuth.class)) return List.of();
|
||||
return null; // not secured by this contributor
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// Register during extension install:
|
||||
ctx.find(OpenApiSecurityRegistry.class)
|
||||
.ifPresent(r -> r.add(new MyAuthContributor()));
|
||||
```
|
||||
|
||||
Return values from `requiredFor`:
|
||||
Supported field-level exclusion:
|
||||
|
||||
| Return | Meaning |
|
||||
|---|---|
|
||||
| `null` | Handler is not secured by this contributor — skip |
|
||||
| `List.of()` | Requires authentication, no specific scopes |
|
||||
| `List.of("admin", "user")` | Requires one of these scopes (OpenAPI OR semantics) |
|
||||
- `@Schema(hidden = true)` / `@SchemaProperty(hidden = true)`
|
||||
- `@JsonIgnore`
|
||||
- `@JsonIgnoreProperties(...)`
|
||||
- `transient` / `static`
|
||||
|
||||
## OIDC interop
|
||||
|
||||
When `flash-ext-oidc` is installed, OpenAPI integrates automatically:
|
||||
|
||||
- security scheme under `components.securitySchemes`
|
||||
- per-operation `security`
|
||||
- auto responses (class-based handlers):
|
||||
- `401 Authentication required`
|
||||
- `403` role/scope required messages when applicable
|
||||
|
||||
Manual `@APIResponse` for the same status code always wins.
|
||||
|
||||
## Notes
|
||||
|
||||
- Operations are collected from final boot-time routes for class-based handlers with `@ApiOperation`.
|
||||
- Documented paths always match runtime paths (including scope namespaces/prefixes/rewrites).
|
||||
- Route path params are auto-discovered from `/{id}`.
|
||||
- Parameter annotations are mainly for query/header/cookie enrichment.
|
||||
- Output responses are sorted by numeric status code.
|
||||
|
||||
@@ -7,11 +7,15 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<version>1.1-indev5</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-openapi</artifactId>
|
||||
|
||||
<properties>
|
||||
<jacoco.version>0.8.12</jacoco.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
@@ -31,4 +35,44 @@
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>${jacoco.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>jacoco-prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>jacoco-report-and-check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>LINE</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.80</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* Declares a single parameter (query, path, header, or cookie) for an operation.
|
||||
* Repeatable — place multiple annotations on the same handler class.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @ApiParam(name = "limit", in = "query", type = "integer", description = "Max results (default 20)")
|
||||
* @ApiParam(name = "offset", in = "query", type = "integer", description = "Pagination offset")
|
||||
* public class ListBlogs extends JacksonHandler { ... }
|
||||
* }</pre>
|
||||
*/
|
||||
@Repeatable(ApiParams.class)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface ApiParam {
|
||||
String name();
|
||||
/** "query", "path", "header", or "cookie". */
|
||||
String in() default "query";
|
||||
/** OpenAPI primitive type: "string", "integer", "number", "boolean". */
|
||||
String type() default "string";
|
||||
String description() default "";
|
||||
boolean required() default false;
|
||||
String example() default "";
|
||||
}
|
||||
+10
-15
@@ -1,24 +1,19 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Repeatable;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Declares a single response for an operation. Repeatable — use multiple
|
||||
* {@code @ApiResponse} annotations on the same handler to document several status codes.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @ApiResponse(status = 200, description = "Blog created", schema = Blog.class)
|
||||
* @ApiResponse(status = 400, description = "Invalid input")
|
||||
* @ApiResponse(status = 409, description = "Slug already exists")
|
||||
* public class CreateBlog extends JacksonHandler { ... }
|
||||
* }</pre>
|
||||
* Declares one OpenAPI response for a class-based handler operation.
|
||||
*/
|
||||
@Repeatable(ApiResponses.class)
|
||||
@Repeatable(APIResponses.class)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface ApiResponse {
|
||||
int status();
|
||||
public @interface APIResponse {
|
||||
String responseCode();
|
||||
String description() default "";
|
||||
Class<?> schema() default Void.class;
|
||||
boolean useReturnType() default false;
|
||||
Content content() default @Content;
|
||||
}
|
||||
|
||||
+3
-3
@@ -5,9 +5,9 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/** Container for repeated {@link ApiResponse} annotations. */
|
||||
/** Container for repeated {@link APIResponse} annotations. */
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface ApiResponses {
|
||||
ApiResponse[] value();
|
||||
public @interface APIResponses {
|
||||
APIResponse[] value();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* OpenAPI response content descriptor.
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD})
|
||||
public @interface Content {
|
||||
ContentType contentType() default ContentType.JSON;
|
||||
Class<?> schema() default Void.class;
|
||||
boolean array() default false;
|
||||
}
|
||||
+180
-69
@@ -4,20 +4,46 @@ 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.http.ContentType;
|
||||
import dev.relism.http.HttpStatus;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.Response;
|
||||
import dev.relism.routing.Route;
|
||||
|
||||
import java.lang.reflect.*;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.lang.reflect.Array;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.GenericArrayType;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.lang.reflect.ParameterizedType;
|
||||
import java.lang.reflect.Type;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* OpenAPI document assembler.
|
||||
*
|
||||
* Collects operation metadata at route registration time and renders an OpenAPI 3.0.3 map.
|
||||
* Response schemas are resolved automatically into components.schemas.
|
||||
*/
|
||||
public final class OpenApiBuilder {
|
||||
|
||||
private static final String OPENAPI_VERSION = "3.0.3";
|
||||
|
||||
private String title = "API";
|
||||
private String version = "1.0.0";
|
||||
private String description = "";
|
||||
@@ -26,7 +52,6 @@ public final class OpenApiBuilder {
|
||||
private final Map<String, Map<String, Class<?>>> operationHandlers = new LinkedHashMap<>();
|
||||
private final SchemaRegistry schemas = new SchemaRegistry();
|
||||
private OpenApiSecurityRegistry securityRegistry;
|
||||
// Build cache: OpenAPI is rendered only when the document revision changes.
|
||||
private int revision;
|
||||
private int builtRevision = -1;
|
||||
private Map<String, Object> cachedSpec;
|
||||
@@ -87,7 +112,7 @@ public final class OpenApiBuilder {
|
||||
}
|
||||
|
||||
Map<String, Object> spec = new LinkedHashMap<>();
|
||||
spec.put("openapi", "3.0.3");
|
||||
spec.put("openapi", OPENAPI_VERSION);
|
||||
spec.put("info", info);
|
||||
spec.put("paths", renderedPaths);
|
||||
|
||||
@@ -126,16 +151,21 @@ public final class OpenApiBuilder {
|
||||
i = close + 1;
|
||||
}
|
||||
|
||||
for (ApiParam ann : cls.getAnnotationsByType(ApiParam.class)) {
|
||||
for (Parameter ann : cls.getAnnotationsByType(Parameter.class)) {
|
||||
Map<String, Object> p = new LinkedHashMap<>();
|
||||
p.put("name", ann.name());
|
||||
p.put("in", ann.in());
|
||||
p.put("in", ann.in().wireValue());
|
||||
p.put("required", ann.required());
|
||||
if (!ann.description().isEmpty()) p.put("description", ann.description());
|
||||
if (!ann.style().isEmpty()) p.put("style", ann.style());
|
||||
if (ann.explode()) p.put("explode", true);
|
||||
if (ann.allowEmptyValue()) p.put("allowEmptyValue", true);
|
||||
|
||||
Map<String, Object> schema = new LinkedHashMap<>();
|
||||
schema.put("type", ann.type());
|
||||
schema.put("type", ann.type().wireValue());
|
||||
if (!ann.example().isEmpty()) schema.put("example", ann.example());
|
||||
p.put("schema", schema);
|
||||
if (ann.examples().length > 0) p.put("examples", toExamples(ann.examples()));
|
||||
params.add(p);
|
||||
}
|
||||
|
||||
@@ -143,68 +173,107 @@ public final class OpenApiBuilder {
|
||||
}
|
||||
|
||||
private void buildResponses(Map<String, Object> op, Class<?> cls) {
|
||||
ApiResponse[] anns = cls.getAnnotationsByType(ApiResponse.class);
|
||||
Map<String, Object> responses = new LinkedHashMap<>();
|
||||
APIResponse[] anns = cls.getAnnotationsByType(APIResponse.class);
|
||||
Map<Integer, Map<String, Object>> responseByCode = new LinkedHashMap<>();
|
||||
|
||||
if (anns.length == 0) {
|
||||
responses.put("200", Map.of("description", "OK"));
|
||||
op.put("responses", responses);
|
||||
return;
|
||||
for (APIResponse ann : anns) {
|
||||
int code = parseStatus(ann.responseCode());
|
||||
responseByCode.put(code, buildAnnotatedResponse(code, ann, cls));
|
||||
}
|
||||
|
||||
for (ApiResponse ann : anns) {
|
||||
Map<String, Object> r = new LinkedHashMap<>();
|
||||
r.put("description", ann.description().isEmpty() ? httpPhrase(ann.status()) : ann.description());
|
||||
|
||||
Class<?> schemaType = resolveSchemaType(ann, cls);
|
||||
if (schemaType != null && schemaType != Void.class) {
|
||||
r.put("content", Map.of(
|
||||
"application/json", Map.of("schema", schemas.referenceFor(schemaType))
|
||||
));
|
||||
for (OpenApiSecurityContributor c : securityContributors()) {
|
||||
for (var auto : c.autoResponsesFor(cls).entrySet()) {
|
||||
responseByCode.putIfAbsent(auto.getKey(), Map.of("description", auto.getValue()));
|
||||
}
|
||||
responses.put(String.valueOf(ann.status()), r);
|
||||
}
|
||||
|
||||
if (responseByCode.isEmpty()) {
|
||||
responseByCode.put(200, Map.of("description", "OK"));
|
||||
}
|
||||
|
||||
Map<String, Object> responses = new LinkedHashMap<>();
|
||||
responseByCode.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey(Comparator.naturalOrder()))
|
||||
.forEach(e -> responses.put(String.valueOf(e.getKey()), e.getValue()));
|
||||
op.put("responses", responses);
|
||||
}
|
||||
|
||||
private static Class<?> resolveSchemaType(ApiResponse ann, Class<?> handlerClass) {
|
||||
if (ann.schema() != Void.class) return ann.schema();
|
||||
if (!ann.useReturnType()) return null;
|
||||
private List<OpenApiSecurityContributor> securityContributors() {
|
||||
return securityRegistry != null ? securityRegistry.contributors() : List.of();
|
||||
}
|
||||
|
||||
private Map<String, Object> buildAnnotatedResponse(int code, APIResponse ann, Class<?> handlerClass) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("description", ann.description().isEmpty() ? defaultDescription(code) : ann.description());
|
||||
|
||||
Content content = ann.content();
|
||||
if (content.contentType() == ContentType.NONE) return out;
|
||||
|
||||
Map<String, Object> schema = resolveResponseSchema(content, handlerClass);
|
||||
if (schema == null || schema.isEmpty()) return out;
|
||||
|
||||
out.put("content", Map.of(mediaTypeOf(content.contentType()), Map.of("schema", schema)));
|
||||
return out;
|
||||
}
|
||||
|
||||
private static int parseStatus(String code) {
|
||||
try {
|
||||
Method handle = handlerClass.getMethod("handle", dev.relism.models.Request.class, dev.relism.models.Response.class);
|
||||
return Integer.parseInt(code.trim());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Invalid APIResponse.responseCode: " + code);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> resolveResponseSchema(Content content, Class<?> handlerClass) {
|
||||
if (content.schema() != Void.class) {
|
||||
Map<String, Object> base = schemas.referenceFor(content.schema());
|
||||
return content.array() ? asArraySchema(base) : base;
|
||||
}
|
||||
|
||||
try {
|
||||
Method handle = handlerClass.getMethod("handle", Request.class, Response.class);
|
||||
Type ret = handle.getGenericReturnType();
|
||||
Class<?> raw = rawType(ret);
|
||||
if (raw == null || raw == Object.class || raw == dev.relism.models.Response.class || raw == Void.class || raw == void.class)
|
||||
if (raw == null || raw == Object.class || raw == Response.class || raw == Void.class || raw == void.class)
|
||||
return null;
|
||||
return raw;
|
||||
|
||||
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) {
|
||||
byte[] bytes = type.getBytes();
|
||||
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) {
|
||||
return path.startsWith("/") ? path : "/" + path;
|
||||
}
|
||||
|
||||
private static String httpPhrase(int status) {
|
||||
return switch (status) {
|
||||
case 200 -> "OK";
|
||||
case 201 -> "Created";
|
||||
case 204 -> "No Content";
|
||||
case 400 -> "Bad Request";
|
||||
case 401 -> "Unauthorized";
|
||||
case 403 -> "Forbidden";
|
||||
case 404 -> "Not Found";
|
||||
case 409 -> "Conflict";
|
||||
case 422 -> "Unprocessable Entity";
|
||||
case 500 -> "Internal Server Error";
|
||||
default -> "";
|
||||
};
|
||||
private static String defaultDescription(int status) {
|
||||
String reason = HttpStatus.reasonForCode(status);
|
||||
return reason == null ? "" : reason;
|
||||
}
|
||||
|
||||
private static List<Map<String, List<String>>> buildOperationSecurity(List<OpenApiSecurityContributor> contributors,
|
||||
Class<?> handlerClass) {
|
||||
Class<?> handlerClass) {
|
||||
List<Map<String, List<String>>> security = new ArrayList<>();
|
||||
for (OpenApiSecurityContributor c : contributors) {
|
||||
List<String> scopes = c.requiredFor(handlerClass);
|
||||
@@ -239,6 +308,10 @@ public final class OpenApiBuilder {
|
||||
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());
|
||||
@@ -283,6 +356,7 @@ public final class OpenApiBuilder {
|
||||
names.put(cls, name);
|
||||
|
||||
if (resolving.contains(cls)) return name;
|
||||
|
||||
resolving.add(cls);
|
||||
docs.put(name, buildPojoSchema(cls));
|
||||
resolving.remove(cls);
|
||||
@@ -298,7 +372,7 @@ public final class OpenApiBuilder {
|
||||
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("type", "object");
|
||||
if (typeSchema != null && !typeSchema.description().isEmpty()) out.put("description", typeSchema.description());
|
||||
if (typeSchema != null) applySchemaHints(out, typeSchema);
|
||||
|
||||
Map<String, Object> properties = new LinkedHashMap<>();
|
||||
List<String> required = new ArrayList<>();
|
||||
@@ -338,18 +412,24 @@ public final class OpenApiBuilder {
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void applySchemaHints(Map<String, Object> property, Schema schema) {
|
||||
if (!schema.description().isEmpty()) property.put("description", schema.description());
|
||||
if (!schema.format().isEmpty()) property.put("format", schema.format());
|
||||
if (!schema.example().isEmpty()) property.put("example", schema.example());
|
||||
if (schema.nullable()) property.put("nullable", true);
|
||||
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> property, SchemaProperty schema) {
|
||||
if (!schema.description().isEmpty()) property.put("description", schema.description());
|
||||
if (!schema.format().isEmpty()) property.put("format", schema.format());
|
||||
if (!schema.example().isEmpty()) property.put("example", schema.example());
|
||||
if (schema.nullable()) property.put("nullable", 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) {
|
||||
@@ -378,24 +458,55 @@ public final class OpenApiBuilder {
|
||||
return cls.getSimpleName();
|
||||
}
|
||||
|
||||
private Map<String, Object> simpleSchema(Class<?> cls) {
|
||||
if (cls == String.class || cls == CharSequence.class || cls == UUID.class) return Map.of("type", "string");
|
||||
if (cls == boolean.class || cls == Boolean.class) return Map.of("type", "boolean");
|
||||
if (cls == byte.class || cls == Byte.class || cls == short.class || cls == Short.class ||
|
||||
cls == int.class || cls == Integer.class) return Map.of("type", "integer", "format", "int32");
|
||||
if (cls == long.class || cls == Long.class) return Map.of("type", "integer", "format", "int64");
|
||||
if (cls == float.class || cls == Float.class) return Map.of("type", "number", "format", "float");
|
||||
if (cls == double.class || cls == Double.class) return Map.of("type", "number", "format", "double");
|
||||
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(((Enum<?>) c).name());
|
||||
for (Object c : constants) values.add(String.valueOf(c));
|
||||
return Map.of("type", "string", "enum", values);
|
||||
}
|
||||
if (!SIMPLE.contains(cls) && cls.getName().startsWith("java.")) return Map.of("type", "string");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Route routeOf(Class<?> cls) {
|
||||
Route direct = cls.getAnnotation(Route.class);
|
||||
if (direct != null) return direct;
|
||||
for (Annotation ann : cls.getAnnotations()) {
|
||||
Route meta = ann.annotationType().getAnnotation(Route.class);
|
||||
if (meta == null) continue;
|
||||
String path = readPathValue(ann);
|
||||
if (path == null) continue;
|
||||
dev.relism.http.HttpMethod method = meta.method();
|
||||
return new Route() {
|
||||
@Override public dev.relism.http.HttpMethod method() { return method; }
|
||||
@Override public String path() { return path; }
|
||||
@Override public Class<? extends Annotation> annotationType() { return Route.class; }
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String readPathValue(Annotation ann) {
|
||||
try {
|
||||
Object v = ann.annotationType().getMethod("value").invoke(ann);
|
||||
return v instanceof String s ? s : null;
|
||||
} catch (ReflectiveOperationException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+18
-33
@@ -6,12 +6,10 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
|
||||
import dev.relism.extension.FlashContext;
|
||||
import dev.relism.extension.FlashExtension;
|
||||
import dev.relism.extension.FlashRegistrar;
|
||||
import dev.relism.extension.RouteEvent;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.http.HttpMethod;
|
||||
import dev.relism.routing.Route;
|
||||
|
||||
import java.lang.annotation.Annotation;
|
||||
|
||||
/**
|
||||
* Generates and serves an OpenAPI 3.0 spec and Swagger UI under a configurable base path.
|
||||
*
|
||||
@@ -74,13 +72,9 @@ public class OpenApiExtension implements FlashExtension {
|
||||
ctx.provide(OpenApiBuilder.class, builder);
|
||||
builder.setSecurityRegistry(secRegistry);
|
||||
|
||||
// Collect operation metadata at handler-registration time (no middleware injected).
|
||||
ctx.addAnnotationProcessor(handlerClass -> {
|
||||
ApiOperation op = handlerClass.getAnnotation(ApiOperation.class);
|
||||
Route route = routeOf(handlerClass);
|
||||
if (op != null && route != null) builder.addOperation(route, op, handlerClass);
|
||||
return java.util.List.of();
|
||||
});
|
||||
// Collect operation metadata from final compiled routes.
|
||||
// This guarantees full runtime paths (namespaces/prefixes/rewrites) in the spec.
|
||||
ctx.addRouteListener(event -> addOperationFromEvent(builder, event));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -126,30 +120,21 @@ public class OpenApiExtension implements FlashExtension {
|
||||
"</html>";
|
||||
}
|
||||
|
||||
private static Route routeOf(Class<?> cls) {
|
||||
Route direct = cls.getAnnotation(Route.class);
|
||||
if (direct != null) return direct;
|
||||
for (Annotation ann : cls.getAnnotations()) {
|
||||
Route meta = ann.annotationType().getAnnotation(Route.class);
|
||||
if (meta == null) continue;
|
||||
String path = readPathValue(ann);
|
||||
if (path == null) continue;
|
||||
HttpMethod method = meta.method();
|
||||
return new Route() {
|
||||
@Override public HttpMethod method() { return method; }
|
||||
@Override public String path() { return path; }
|
||||
@Override public Class<? extends Annotation> annotationType() { return Route.class; }
|
||||
};
|
||||
}
|
||||
return null;
|
||||
private static void addOperationFromEvent(OpenApiBuilder builder, RouteEvent event) {
|
||||
Class<?> handlerClass = event.handlerClass();
|
||||
if (handlerClass == null) return; // lambda route: no annotation metadata
|
||||
|
||||
ApiOperation op = handlerClass.getAnnotation(ApiOperation.class);
|
||||
if (op == null) return;
|
||||
|
||||
builder.addOperation(routeOf(event), op, handlerClass);
|
||||
}
|
||||
|
||||
private static String readPathValue(Annotation ann) {
|
||||
try {
|
||||
Object v = ann.annotationType().getMethod("value").invoke(ann);
|
||||
return v instanceof String s ? s : null;
|
||||
} catch (ReflectiveOperationException ignored) {
|
||||
return null;
|
||||
}
|
||||
private static Route routeOf(RouteEvent event) {
|
||||
return new Route() {
|
||||
@Override public dev.relism.http.HttpMethod method() { return event.method(); }
|
||||
@Override public String path() { return event.path(); }
|
||||
@Override public Class<? extends java.lang.annotation.Annotation> annotationType() { return Route.class; }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -46,4 +46,12 @@ public interface OpenApiSecurityContributor {
|
||||
* </ul>
|
||||
*/
|
||||
List<String> requiredFor(Class<?> handlerClass);
|
||||
|
||||
/**
|
||||
* Optional auto-injected operation responses for handlers secured by this contributor.
|
||||
* Key = HTTP status code, value = description.
|
||||
*/
|
||||
default Map<Integer, String> autoResponsesFor(Class<?> handlerClass) {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Repeatable;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Declares one OpenAPI parameter for a class-based handler operation.
|
||||
*/
|
||||
@Repeatable(Parameters.class)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface Parameter {
|
||||
String name();
|
||||
ParameterIn in() default ParameterIn.QUERY;
|
||||
String description() default "";
|
||||
boolean required() default false;
|
||||
String example() default "";
|
||||
String[] examples() default {};
|
||||
SchemaType type() default SchemaType.STRING;
|
||||
String style() default "";
|
||||
boolean explode() default false;
|
||||
boolean allowEmptyValue() default false;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public enum ParameterIn {
|
||||
QUERY,
|
||||
PATH,
|
||||
HEADER,
|
||||
COOKIE;
|
||||
|
||||
public String wireValue() {
|
||||
return name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -5,9 +5,9 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/** Container for repeated {@link ApiParam} annotations. */
|
||||
/** Container for repeated {@link Parameter} annotations. */
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface ApiParams {
|
||||
ApiParam[] value();
|
||||
public @interface Parameters {
|
||||
Parameter[] value();
|
||||
}
|
||||
@@ -10,10 +10,13 @@ import java.lang.annotation.Target;
|
||||
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD})
|
||||
public @interface Schema {
|
||||
String name() default "";
|
||||
String title() default "";
|
||||
String description() default "";
|
||||
String format() default "";
|
||||
String example() default "";
|
||||
String[] enumeration() default {};
|
||||
boolean nullable() default false;
|
||||
boolean required() default false;
|
||||
boolean deprecated() default false;
|
||||
boolean hidden() default false;
|
||||
}
|
||||
|
||||
+3
@@ -10,10 +10,13 @@ import java.lang.annotation.Target;
|
||||
@Target({ElementType.FIELD, ElementType.METHOD})
|
||||
public @interface SchemaProperty {
|
||||
String name() default "";
|
||||
String title() default "";
|
||||
String description() default "";
|
||||
String format() default "";
|
||||
String example() default "";
|
||||
String[] enumeration() default {};
|
||||
boolean nullable() default false;
|
||||
boolean required() default false;
|
||||
boolean deprecated() default false;
|
||||
boolean hidden() default false;
|
||||
}
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public enum SchemaType {
|
||||
STRING,
|
||||
INTEGER,
|
||||
NUMBER,
|
||||
BOOLEAN,
|
||||
OBJECT,
|
||||
ARRAY;
|
||||
|
||||
public String wireValue() {
|
||||
return name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.routing.GET;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class OpenApiBuilderTest {
|
||||
|
||||
@GET("/users/{id}")
|
||||
@ApiOperation(summary = "Get user")
|
||||
@Parameter(name = "expand", in = ParameterIn.QUERY, required = false, type = SchemaType.STRING, examples = {"roles", "permissions"})
|
||||
@APIResponse(responseCode = "200", description = "User found", content = @Content(contentType = ContentType.JSON, schema = UserDto.class))
|
||||
static class GetUserHandler extends dev.relism.models.RequestHandler {
|
||||
@Override
|
||||
public Object handle(dev.relism.models.Request request, dev.relism.models.Response response) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@GET("/users")
|
||||
@ApiOperation(summary = "List users")
|
||||
@APIResponse(responseCode = "200", content = @Content(contentType = ContentType.JSON, schema = UserDto.class, array = true))
|
||||
static class ListUsersHandler extends dev.relism.models.RequestHandler {
|
||||
@Override
|
||||
public Object handle(dev.relism.models.Request request, dev.relism.models.Response response) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@GET("/ping")
|
||||
@ApiOperation(summary = "Ping")
|
||||
@APIResponse(responseCode = "204", description = "No content", content = @Content(contentType = ContentType.NONE))
|
||||
static class PingHandler extends dev.relism.models.RequestHandler {
|
||||
@Override
|
||||
public Object handle(dev.relism.models.Request request, dev.relism.models.Response response) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@GET("/secure")
|
||||
@ApiOperation(summary = "Secure")
|
||||
@APIResponse(responseCode = "403", description = "Custom forbidden")
|
||||
static class SecureHandler extends dev.relism.models.RequestHandler {
|
||||
@Override
|
||||
public Object handle(dev.relism.models.Request request, dev.relism.models.Response response) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@GET("/typed")
|
||||
@ApiOperation(summary = "Typed")
|
||||
@APIResponse(responseCode = "200", content = @Content)
|
||||
static class TypedHandler extends dev.relism.models.RequestHandler {
|
||||
@Override
|
||||
public UserDto handle(dev.relism.models.Request request, dev.relism.models.Response response) {
|
||||
return new UserDto();
|
||||
}
|
||||
}
|
||||
|
||||
@GET("/typed-list")
|
||||
@ApiOperation(summary = "Typed list")
|
||||
@APIResponse(responseCode = "200", content = @Content)
|
||||
static class TypedListHandler extends dev.relism.models.RequestHandler {
|
||||
@Override
|
||||
public List<UserDto> handle(dev.relism.models.Request request, dev.relism.models.Response response) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
@GET("/typed-map")
|
||||
@ApiOperation(summary = "Typed map")
|
||||
@APIResponse(responseCode = "200", content = @Content)
|
||||
static class TypedMapHandler extends dev.relism.models.RequestHandler {
|
||||
@Override
|
||||
public Map<String, UserDto> handle(dev.relism.models.Request request, dev.relism.models.Response response) {
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
@Schema(name = "UserDTO", title = "User model", description = "DTO", deprecated = true)
|
||||
@JsonIgnoreProperties({"ignoredByType"})
|
||||
static class UserDto {
|
||||
@SchemaProperty(title = "Identifier", description = "Unique id", required = true, example = "usr-1", enumeration = {"usr-1", "usr-2"})
|
||||
public String id;
|
||||
|
||||
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
|
||||
public String readOnlyField;
|
||||
|
||||
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
|
||||
public String writeOnlyField;
|
||||
|
||||
@JsonIgnore
|
||||
public String hiddenByIgnore;
|
||||
|
||||
public transient String transientField;
|
||||
|
||||
public String ignoredByType;
|
||||
|
||||
@ArraySchema(uniqueItems = true, minItems = 1)
|
||||
public List<String> tags;
|
||||
}
|
||||
|
||||
@Test
|
||||
void builds_single_response_and_parameters_and_schema() {
|
||||
OpenApiBuilder b = new OpenApiBuilder().title("X").version("1");
|
||||
b.addOperation(OpenApiBuilder.routeOf(GetUserHandler.class), GetUserHandler.class.getAnnotation(ApiOperation.class), GetUserHandler.class);
|
||||
|
||||
Map<String, Object> spec = b.build();
|
||||
Map<String, Object> paths = cast(spec.get("paths"));
|
||||
Map<String, Object> userPath = cast(paths.get("/users/{id}"));
|
||||
Map<String, Object> get = cast(userPath.get("get"));
|
||||
|
||||
List<Map<String, Object>> params = cast(get.get("parameters"));
|
||||
assertEquals(2, params.size());
|
||||
|
||||
Map<String, Object> responses = cast(get.get("responses"));
|
||||
assertTrue(responses.containsKey("200"));
|
||||
|
||||
Map<String, Object> resp200 = cast(responses.get("200"));
|
||||
Map<String, Object> content = cast(resp200.get("content"));
|
||||
assertTrue(content.containsKey("application/json"));
|
||||
|
||||
Map<String, Object> components = cast(spec.get("components"));
|
||||
Map<String, Object> schemas = cast(components.get("schemas"));
|
||||
assertTrue(schemas.containsKey("UserDTO"));
|
||||
Map<String, Object> userSchema = cast(schemas.get("UserDTO"));
|
||||
assertEquals("User model", userSchema.get("title"));
|
||||
assertEquals(true, userSchema.get("deprecated"));
|
||||
|
||||
Map<String, Object> properties = cast(userSchema.get("properties"));
|
||||
assertFalse(properties.containsKey("hiddenByIgnore"));
|
||||
assertFalse(properties.containsKey("ignoredByType"));
|
||||
assertFalse(properties.containsKey("transientField"));
|
||||
assertTrue(properties.containsKey("id"));
|
||||
assertTrue(properties.containsKey("tags"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void builds_array_response() {
|
||||
OpenApiBuilder b = new OpenApiBuilder();
|
||||
b.addOperation(OpenApiBuilder.routeOf(ListUsersHandler.class), ListUsersHandler.class.getAnnotation(ApiOperation.class), ListUsersHandler.class);
|
||||
|
||||
Map<String, Object> spec = b.build();
|
||||
Map<String, Object> get = getOperation(spec, "/users", "get");
|
||||
Map<String, Object> responses = cast(get.get("responses"));
|
||||
Map<String, Object> resp200 = cast(responses.get("200"));
|
||||
Map<String, Object> content = cast(resp200.get("content"));
|
||||
Map<String, Object> appJson = cast(content.get("application/json"));
|
||||
Map<String, Object> schema = cast(appJson.get("schema"));
|
||||
assertEquals("array", schema.get("type"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void builds_no_content_response_without_content_block() {
|
||||
OpenApiBuilder b = new OpenApiBuilder();
|
||||
b.addOperation(OpenApiBuilder.routeOf(PingHandler.class), PingHandler.class.getAnnotation(ApiOperation.class), PingHandler.class);
|
||||
|
||||
Map<String, Object> spec = b.build();
|
||||
Map<String, Object> get = getOperation(spec, "/ping", "get");
|
||||
Map<String, Object> responses = cast(get.get("responses"));
|
||||
Map<String, Object> resp204 = cast(responses.get("204"));
|
||||
assertEquals("No content", resp204.get("description"));
|
||||
assertFalse(resp204.containsKey("content"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void auto_security_responses_are_added_and_manual_wins_sorted() {
|
||||
OpenApiBuilder b = new OpenApiBuilder();
|
||||
OpenApiSecurityRegistry registry = new OpenApiSecurityRegistry();
|
||||
registry.add(new OpenApiSecurityContributor() {
|
||||
@Override public String schemeName() { return "oidc"; }
|
||||
@Override public Map<String, Object> schemeDefinition() { return Map.of("type", "oauth2"); }
|
||||
@Override public List<String> requiredFor(Class<?> handlerClass) { return List.of(); }
|
||||
@Override public Map<Integer, String> autoResponsesFor(Class<?> handlerClass) {
|
||||
return Map.of(401, "Authentication required", 403, "Auto forbidden");
|
||||
}
|
||||
});
|
||||
b.setSecurityRegistry(registry);
|
||||
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> resp403 = cast(responses.get("403"));
|
||||
Map<String, Object> resp401 = cast(responses.get("401"));
|
||||
assertEquals(List.of("401", "403"), new java.util.ArrayList<>(responses.keySet()));
|
||||
assertEquals("Custom forbidden", resp403.get("description"));
|
||||
assertEquals("Authentication required", resp401.get("description"));
|
||||
|
||||
List<Map<String, List<String>>> security = cast(get.get("security"));
|
||||
assertNotNull(security);
|
||||
assertEquals(1, security.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void use_return_type_for_response_schema() {
|
||||
OpenApiBuilder b = new OpenApiBuilder();
|
||||
b.addOperation(OpenApiBuilder.routeOf(TypedHandler.class), TypedHandler.class.getAnnotation(ApiOperation.class), TypedHandler.class);
|
||||
|
||||
Map<String, Object> spec = b.build();
|
||||
Map<String, Object> get = getOperation(spec, "/typed", "get");
|
||||
Map<String, Object> responses = cast(get.get("responses"));
|
||||
Map<String, Object> resp200 = cast(responses.get("200"));
|
||||
Map<String, Object> content = cast(resp200.get("content"));
|
||||
assertTrue(content.containsKey("application/json"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void infer_array_schema_from_collection_return_type() {
|
||||
OpenApiBuilder b = new OpenApiBuilder();
|
||||
b.addOperation(OpenApiBuilder.routeOf(TypedListHandler.class), TypedListHandler.class.getAnnotation(ApiOperation.class), TypedListHandler.class);
|
||||
|
||||
Map<String, Object> spec = b.build();
|
||||
Map<String, Object> get = getOperation(spec, "/typed-list", "get");
|
||||
Map<String, Object> responses = cast(get.get("responses"));
|
||||
Map<String, Object> resp200 = cast(responses.get("200"));
|
||||
Map<String, Object> content = cast(resp200.get("content"));
|
||||
Map<String, Object> appJson = cast(content.get("application/json"));
|
||||
Map<String, Object> schema = cast(appJson.get("schema"));
|
||||
assertEquals("array", schema.get("type"));
|
||||
Map<String, Object> items = cast(schema.get("items"));
|
||||
assertEquals("#/components/schemas/UserDTO", items.get("$ref"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void infer_map_value_schema_from_map_return_type() {
|
||||
OpenApiBuilder b = new OpenApiBuilder();
|
||||
b.addOperation(OpenApiBuilder.routeOf(TypedMapHandler.class), TypedMapHandler.class.getAnnotation(ApiOperation.class), TypedMapHandler.class);
|
||||
|
||||
Map<String, Object> spec = b.build();
|
||||
Map<String, Object> get = getOperation(spec, "/typed-map", "get");
|
||||
Map<String, Object> responses = cast(get.get("responses"));
|
||||
Map<String, Object> resp200 = cast(responses.get("200"));
|
||||
Map<String, Object> content = cast(resp200.get("content"));
|
||||
Map<String, Object> appJson = cast(content.get("application/json"));
|
||||
Map<String, Object> schema = cast(appJson.get("schema"));
|
||||
assertEquals("object", schema.get("type"));
|
||||
Map<String, Object> additionalProperties = cast(schema.get("additionalProperties"));
|
||||
assertEquals("#/components/schemas/UserDTO", additionalProperties.get("$ref"));
|
||||
}
|
||||
|
||||
private static Map<String, Object> getOperation(Map<String, Object> spec, String path, String method) {
|
||||
Map<String, Object> paths = cast(spec.get("paths"));
|
||||
Map<String, Object> pathItem = cast(paths.get(path));
|
||||
return cast(pathItem.get(method));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> T cast(Object value) {
|
||||
return (T) value;
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.extension.FlashContext;
|
||||
import dev.relism.extension.FlashRegistrar;
|
||||
import dev.relism.extension.RouteEvent;
|
||||
import dev.relism.extension.RouteListener;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.http.HttpMethod;
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.Response;
|
||||
import dev.relism.routing.GET;
|
||||
import dev.relism.routing.Middleware;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class OpenApiExtensionTest {
|
||||
|
||||
@GET("/health")
|
||||
@ApiOperation(summary = "Health check")
|
||||
@APIResponse(responseCode = "200", description = "OK")
|
||||
static class HealthHandler extends RequestHandler {
|
||||
@Override
|
||||
public Object handle(dev.relism.models.Request request, Response response) {
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void provide_collects_operations_and_routes_serve_json_yaml_swagger() throws Exception {
|
||||
FlashContext ctx = new FlashContext();
|
||||
OpenApiExtension ext = new OpenApiExtension("/docs", "My API", "2.0.0", "desc");
|
||||
ext.provide(ctx);
|
||||
|
||||
emitRoute(ctx, HttpMethod.GET, "/health", "/", HealthHandler.class);
|
||||
|
||||
TestRegistrar app = new TestRegistrar(ctx);
|
||||
ext.routes(app, ctx);
|
||||
|
||||
assertNotNull(app.route(HttpMethod.GET, "/docs.json"));
|
||||
assertNotNull(app.route(HttpMethod.GET, "/docs.yaml"));
|
||||
assertNotNull(app.route(HttpMethod.GET, "/docs/swagger"));
|
||||
|
||||
Response jsonRes = new Response(200, ContentType.NONE);
|
||||
Object jsonBody = app.route(HttpMethod.GET, "/docs.json").handle(null, jsonRes);
|
||||
assertEquals(new String(ContentType.JSON.getBytes()), new String(jsonRes.getContentType()));
|
||||
assertTrue(String.valueOf(jsonBody).contains("\"openapi\":\"3.0.3\""));
|
||||
assertTrue(String.valueOf(jsonBody).contains("\"title\":\"My API\""));
|
||||
|
||||
Response yamlRes = new Response(200, ContentType.NONE);
|
||||
Object yamlBody = app.route(HttpMethod.GET, "/docs.yaml").handle(null, yamlRes);
|
||||
assertEquals("application/yaml", new String(yamlRes.getContentType()));
|
||||
assertTrue(String.valueOf(yamlBody).contains("openapi: \"3.0.3\""));
|
||||
|
||||
Response swaggerRes = new Response(200, ContentType.NONE);
|
||||
Object swaggerBody = app.route(HttpMethod.GET, "/docs/swagger").handle(null, swaggerRes);
|
||||
assertEquals(new String(ContentType.TEXT_HTML.getBytes()), new String(swaggerRes.getContentType()));
|
||||
assertTrue(String.valueOf(swaggerBody).contains("SwaggerUIBundle"));
|
||||
assertTrue(String.valueOf(swaggerBody).contains("/docs.json"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void routes_use_mapper_from_context_when_provided() throws Exception {
|
||||
FlashContext ctx = new FlashContext();
|
||||
OpenApiExtension ext = new OpenApiExtension();
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
ctx.provide(ObjectMapper.class, mapper);
|
||||
|
||||
ext.provide(ctx);
|
||||
TestRegistrar app = new TestRegistrar(ctx);
|
||||
ext.routes(app, ctx);
|
||||
|
||||
Response jsonRes = new Response(200, ContentType.NONE);
|
||||
Object jsonBody = app.route(HttpMethod.GET, "/openapi.json").handle(null, jsonRes);
|
||||
assertTrue(String.valueOf(jsonBody).contains("\"openapi\":\"3.0.3\""));
|
||||
}
|
||||
|
||||
@GET("/users")
|
||||
@ApiOperation(summary = "Scoped users")
|
||||
static class ScopedUsersHandler extends RequestHandler {
|
||||
@Override
|
||||
public Object handle(dev.relism.models.Request request, Response response) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void collects_full_runtime_path_from_route_event() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
OpenApiExtension ext = new OpenApiExtension();
|
||||
ext.provide(ctx);
|
||||
|
||||
emitRoute(ctx, HttpMethod.GET, "/api/v1/users", "/api/v1", ScopedUsersHandler.class);
|
||||
|
||||
OpenApiBuilder builder = ctx.require(OpenApiBuilder.class);
|
||||
Map<String, Object> spec = builder.build();
|
||||
Map<String, Object> paths = cast(spec.get("paths"));
|
||||
assertTrue(paths.containsKey("/api/v1/users"));
|
||||
assertFalse(paths.containsKey("/users"));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<RouteListener> listeners(FlashContext ctx) {
|
||||
try {
|
||||
java.lang.reflect.Method m = FlashContext.class.getDeclaredMethod("routeListeners");
|
||||
m.setAccessible(true);
|
||||
return (List<RouteListener>) m.invoke(ctx);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void emitRoute(FlashContext ctx, HttpMethod method, String path, String namespace,
|
||||
Class<? extends RequestHandler> handlerClass) {
|
||||
RouteEvent event = new RouteEvent(method, path, namespace, "FlashApp", handlerClass, List.of());
|
||||
for (RouteListener listener : listeners(ctx)) listener.onRoute(event);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> cast(Object value) {
|
||||
return (Map<String, Object>) value;
|
||||
}
|
||||
|
||||
private static final class TestRegistrar extends FlashRegistrar<TestRegistrar> {
|
||||
private final FlashContext ctx;
|
||||
private final Map<String, RequestHandler> routes = new HashMap<>();
|
||||
private final List<Middleware> middlewares = new ArrayList<>();
|
||||
|
||||
private TestRegistrar(FlashContext ctx) {
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FlashContext ctx() {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addRoute(HttpMethod method, String path, RequestHandler handler, List<Middleware> mw) {
|
||||
routes.put(method.name() + " " + path, handler);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void addMiddleware(Middleware mw) {
|
||||
middlewares.add(mw);
|
||||
}
|
||||
|
||||
RequestHandler route(HttpMethod method, String path) {
|
||||
return routes.get(method.name() + " " + path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<version>1.1-indev5</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-routeviewer</artifactId>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Flash Route Viewer</title>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Flash Route Viewer</title>
|
||||
<script type="module" crossorigin src="/routeviewer/app.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/routeviewer/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>␍
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# flash-ext-view
|
||||
|
||||
Lightweight SSR view extension for Flash.
|
||||
|
||||
This module provides a focused MVC surface:
|
||||
|
||||
- `ViewExtension`
|
||||
- `ViewHandler`
|
||||
- `@Page` and `@Partial`
|
||||
- `ViewModel` and opinionated `global.*` values
|
||||
- `ViewEngineAdapter`
|
||||
|
||||
No legacy annotation/renderer API is exposed.
|
||||
|
||||
`@Page`/`@Partial` are valid only on `ViewHandler` subclasses.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```java
|
||||
import dev.relism.extension.FlashApp;
|
||||
import dev.relism.ext.view.*;
|
||||
|
||||
FlashApp.create(8080)
|
||||
.install(new ViewExtension(ViewEngineType.THYMELEAF)
|
||||
.addGlobal("appName", req -> "Flash")
|
||||
.addGlobal("requestPath", req -> req.path()))
|
||||
.scan("com.example.web")
|
||||
.startAndBlock();
|
||||
```
|
||||
|
||||
`global` is a reserved namespace. Handlers cannot write a top-level `global` key.
|
||||
|
||||
```java
|
||||
import dev.relism.ext.view.*;
|
||||
import dev.relism.routing.GET;
|
||||
|
||||
@GET("/")
|
||||
@Page("pages/home")
|
||||
public final class HomePage extends ViewHandler {
|
||||
@Override
|
||||
public ViewModel render(dev.relism.models.Request req) {
|
||||
return ViewModel.of("title", "Home");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
- `docs/architecture.md`
|
||||
- `docs/handlers.md`
|
||||
- `docs/model-and-globals.md`
|
||||
- `docs/partials.md`
|
||||
- `docs/adapters.md`
|
||||
- `docs/performance.md`
|
||||
- `docs/migration-from-legacy-view.md`
|
||||
@@ -0,0 +1,29 @@
|
||||
# Adapters
|
||||
|
||||
`ViewEngineAdapter` is the rendering boundary.
|
||||
|
||||
## Built-in
|
||||
|
||||
- `ViewEngineType.THYMELEAF`
|
||||
|
||||
## Custom adapter
|
||||
|
||||
```java
|
||||
public final class MyAdapter implements ViewEngineAdapter {
|
||||
@Override
|
||||
public EngineCapabilities capabilities() {
|
||||
return EngineCapabilities.NONE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RenderOutput render(ViewTarget target,
|
||||
Map<String, Object> model,
|
||||
Request req,
|
||||
Response res) {
|
||||
String body = "...";
|
||||
return RenderOutput.html(body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Adapter instances must be thread-safe after construction.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Architecture
|
||||
|
||||
`flash-ext-view` runs in two layers:
|
||||
|
||||
1. **Boot-time**
|
||||
- `ViewExtension` registers `ViewRuntime` and an annotation processor.
|
||||
- `ViewTargetResolver` validates handlers and maps annotations to `ViewTarget`.
|
||||
- Resolved targets are cached per handler class.
|
||||
|
||||
2. **Request-time**
|
||||
- Handler builds local `ViewModel`.
|
||||
- `ViewRuntime` injects extension globals under reserved `global` namespace, then merges local model.
|
||||
- `ViewEngineAdapter` renders `RenderOutput`.
|
||||
|
||||
## Valid Handler Contract
|
||||
|
||||
- Must extend `ViewHandler`.
|
||||
- Must have route annotation (`@Route`, `@GET`, `@POST`, ...).
|
||||
- Must declare exactly one view annotation:
|
||||
- `@Page`
|
||||
- `@Partial`
|
||||
|
||||
Invalid configurations fail fast at startup.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Handlers
|
||||
|
||||
Use `ViewHandler` for class-based SSR routes.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- `onViewInit()` runs once at boot.
|
||||
- `render(...)` runs per request.
|
||||
|
||||
Use `onViewInit()` to cache dependencies via `require(...)`.
|
||||
|
||||
## Example
|
||||
|
||||
```java
|
||||
@GET("/dashboard")
|
||||
@Page("pages/dashboard")
|
||||
public final class DashboardPage extends ViewHandler {
|
||||
|
||||
private DashboardService service;
|
||||
|
||||
@Override
|
||||
protected void onViewInit() {
|
||||
service = require(DashboardService.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ViewModel render(dev.relism.models.Request req) {
|
||||
return ViewModel.empty()
|
||||
.with("title", "Dashboard")
|
||||
.with("stats", service.stats());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use `render(Request, Response)` only when you need response access while building the model.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Migration from Legacy View API
|
||||
|
||||
Legacy API (`@View`, `ViewEngine`, `Renderer`, `Template`) has been removed.
|
||||
|
||||
## Replace annotations
|
||||
|
||||
- `@View("page")` -> `@Page("page")`
|
||||
- `@View(value = "x", fragment = true)` -> `@Partial(template = "x")`
|
||||
|
||||
`@Page` and `@Partial` must be declared on classes extending `ViewHandler`.
|
||||
|
||||
## Replace handler return contract
|
||||
|
||||
Before (legacy):
|
||||
|
||||
```java
|
||||
public Object handle(Request req, Response res) {
|
||||
return Map.of("name", "Flash");
|
||||
}
|
||||
```
|
||||
|
||||
Now:
|
||||
|
||||
```java
|
||||
public ViewModel render(Request req) {
|
||||
return ViewModel.of("name", "Flash");
|
||||
}
|
||||
```
|
||||
|
||||
## Replace engine integration
|
||||
|
||||
- Implement `ViewEngineAdapter` directly.
|
||||
- Or use `ViewEngineType.THYMELEAF`.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Model and Globals
|
||||
|
||||
`ViewModel` is the per-request model builder.
|
||||
|
||||
## Merge Order
|
||||
|
||||
Runtime merge order is:
|
||||
|
||||
1. all extension globals under reserved `global` namespace
|
||||
2. local handler model
|
||||
|
||||
Handlers cannot set top-level `global`; runtime throws fail-fast to prevent namespace collisions.
|
||||
|
||||
## Globals
|
||||
|
||||
Register globals on extension setup:
|
||||
|
||||
```java
|
||||
new ViewExtension(ViewEngineType.THYMELEAF)
|
||||
.addGlobal("appName", req -> "Flash")
|
||||
.addGlobal("path", req -> req.path());
|
||||
```
|
||||
|
||||
Template usage:
|
||||
|
||||
```html
|
||||
<span th:text="${global.appName}"></span>
|
||||
<span th:text="${global.path}"></span>
|
||||
```
|
||||
|
||||
Keep globals cheap: no blocking I/O or heavy allocations.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Partials
|
||||
|
||||
Use `@Partial` for fragment responses.
|
||||
|
||||
```java
|
||||
@GET("/users/table")
|
||||
@Partial(template = "fragments/users", slot = "rows")
|
||||
public final class UsersRows extends ViewHandler {
|
||||
@Override
|
||||
public ViewModel render(dev.relism.models.Request req) {
|
||||
return ViewModel.of("users", List.of());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If slot is empty, the adapter default slot is used.
|
||||
|
||||
If a slot is specified but the adapter does not support slot selection,
|
||||
startup fails with a clear error.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Performance
|
||||
|
||||
`flash-ext-view` is optimized for low overhead on request path.
|
||||
|
||||
## Current runtime choices
|
||||
|
||||
- Handler view metadata resolved once and cached.
|
||||
- Global/local model merge done in a single pass.
|
||||
- No legacy rendering branches in runtime pipeline.
|
||||
|
||||
## Best practices
|
||||
|
||||
- Cache services in `onViewInit()`.
|
||||
- Keep `addGlobal(...)` resolvers cheap and side-effect free.
|
||||
- Build only the model fields needed by template.
|
||||
- Avoid blocking I/O in `render(...)`; delegate to precomputed service data when possible.
|
||||
@@ -7,11 +7,15 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<version>1.1-indev5</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-view</artifactId>
|
||||
|
||||
<properties>
|
||||
<jacoco.version>0.8.12</jacoco.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
@@ -41,4 +45,44 @@
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>${jacoco.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>jacoco-prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>jacoco-report-and-check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>LINE</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.80</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
|
||||
+1
@@ -1,5 +1,6 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
/** Engine feature flags used for boot-time validation. */
|
||||
public record EngineCapabilities(boolean supportsPartialSlot) {
|
||||
public static final EngineCapabilities NONE = new EngineCapabilities(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import dev.relism.models.Request;
|
||||
|
||||
import java.util.function.Function;
|
||||
|
||||
record GlobalBinding(String key, Function<Request, Object> resolver) {}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.Response;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
final class LegacyViewEngineAdapter implements ViewEngineAdapter {
|
||||
private static final String DEFAULT_FRAGMENT_SLOT = "content";
|
||||
|
||||
private final ViewEngine engine;
|
||||
|
||||
LegacyViewEngineAdapter(ViewEngine engine) {
|
||||
this.engine = engine;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EngineCapabilities capabilities() {
|
||||
return EngineCapabilities.NONE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RenderOutput render(ViewTarget target, Map<String, Object> model, Request req, Response res) throws Exception {
|
||||
String template = target.template();
|
||||
boolean fragment = target.kind() == ViewKind.PARTIAL;
|
||||
|
||||
if (fragment && target.slot() != null && !target.slot().isBlank()) {
|
||||
template = template + " :: " + target.slot();
|
||||
fragment = false;
|
||||
} else if (fragment) {
|
||||
template = template + " :: " + DEFAULT_FRAGMENT_SLOT;
|
||||
fragment = false;
|
||||
}
|
||||
|
||||
return new RenderOutput(engine.render(template, model, fragment), ContentType.TEXT_HTML);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,16 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Binds a class-based route handler to a full-page template.
|
||||
*
|
||||
* <p>Use on subclasses of {@link dev.relism.models.RequestHandler}, typically
|
||||
* {@link ViewHandler}. The handler must also declare a route annotation
|
||||
* ({@code @Route}, {@code @GET}, {@code @POST}, ...).
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface Page {
|
||||
/** Template name/path (engine-specific). */
|
||||
String value();
|
||||
}
|
||||
|
||||
@@ -5,9 +5,18 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Binds a class-based route handler to a partial template render.
|
||||
*
|
||||
* <p>Useful for progressive/fragment updates (e.g. HTMX). When {@link #slot()} is blank,
|
||||
* the adapter default slot is used.
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface Partial {
|
||||
/** Template containing the fragment slot. */
|
||||
String template();
|
||||
|
||||
/** Optional fragment slot selector. */
|
||||
String slot() default "";
|
||||
}
|
||||
|
||||
@@ -2,6 +2,13 @@ package dev.relism.ext.view;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
|
||||
/**
|
||||
* Render result produced by a {@link ViewEngineAdapter}.
|
||||
*
|
||||
* @param body rendered response body
|
||||
* @param contentType optional explicit response content type; if null runtime falls back to
|
||||
* route-level default ({@code text/html})
|
||||
*/
|
||||
public record RenderOutput(String body, ContentType contentType) {
|
||||
public static RenderOutput html(String body) {
|
||||
return new RenderOutput(body, ContentType.TEXT_HTML);
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.models.Response;
|
||||
|
||||
/**
|
||||
* Imperative view renderer — the programmatic counterpart to {@link View @View}.
|
||||
*
|
||||
* <p>Retrieve once at boot time in {@link dev.relism.models.RequestHandler#onInit onInit()},
|
||||
* cache in a private field, and call on the hot-path with zero lookup overhead:
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Route(method = HttpMethod.GET, path = "/dashboard")
|
||||
* public class DashboardHandler extends RequestHandler {
|
||||
* private Renderer renderer;
|
||||
* private DashboardService svc;
|
||||
*
|
||||
* @Override protected void onInit() {
|
||||
* renderer = require(Renderer.class);
|
||||
* svc = require(DashboardService.class);
|
||||
* }
|
||||
*
|
||||
* @Override public Object handle(Request req, Response res) throws Exception {
|
||||
* return renderer.view(res, "dashboard", Map.of("data", svc.stats()));
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>For lambda handlers, capture {@code Renderer} from the context at registration
|
||||
* time — it is available immediately after {@code ViewExtension} is installed:
|
||||
*
|
||||
* <pre>{@code
|
||||
* app.install(new ViewExtension(engine));
|
||||
* Renderer renderer = app.ctx().require(Renderer.class);
|
||||
* app.get("/about", (req, res) -> renderer.view(res, "about"));
|
||||
* }</pre>
|
||||
*
|
||||
* <p>The underlying {@link ViewEngine} is thread-safe after construction — no
|
||||
* synchronization is needed on the hot-path.
|
||||
*/
|
||||
public final class Renderer {
|
||||
|
||||
private final ViewEngine engine;
|
||||
|
||||
/** Package-private — constructed exclusively by {@link ViewExtension}. */
|
||||
Renderer(ViewEngine engine) {
|
||||
this.engine = engine;
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Renders {@code template} with {@code model}, sets the {@code Content-Type}
|
||||
* header to {@code type}, and returns the rendered string as the handler body.
|
||||
*/
|
||||
public String view(Response res, String template, Object model, ContentType type) throws Exception {
|
||||
res.type(type);
|
||||
return engine.render(template, model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders {@code template} with {@code model} and sets
|
||||
* {@code Content-Type: text/html}.
|
||||
*/
|
||||
public String view(Response res, String template, Object model) throws Exception {
|
||||
return view(res, template, model, ContentType.TEXT_HTML);
|
||||
}
|
||||
|
||||
/** Renders {@code template} with a {@code null} model. */
|
||||
public String view(Response res, String template) throws Exception {
|
||||
return view(res, template, null);
|
||||
}
|
||||
|
||||
// ── Template signal ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates a deferred {@link Template} signal that will be intercepted by the
|
||||
* {@link View @View} middleware. Use this from handlers that carry {@code @View}
|
||||
* but need to dynamically override the template name or supply a different model.
|
||||
*
|
||||
* <p>Does <em>not</em> render immediately — rendering happens in the middleware.
|
||||
*/
|
||||
public Template template(String name, Object model) {
|
||||
return Template.of(name, model);
|
||||
}
|
||||
|
||||
/** Creates a {@link Template} signal with a {@code null} model. */
|
||||
public Template template(String name) {
|
||||
return Template.of(name);
|
||||
}
|
||||
|
||||
// ── Escape hatch ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Direct access to the underlying {@link ViewEngine} for advanced use cases. */
|
||||
public ViewEngine engine() {
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
/**
|
||||
* Explicit render signal returned from a handler to override the template name
|
||||
* and/or model chosen by {@link View @View}.
|
||||
*
|
||||
* <p>{@code Template} is a lightweight value object — it carries the template
|
||||
* name and an optional model, but performs no rendering itself. The
|
||||
* {@link ViewExtension}-injected middleware detects it at the call site and
|
||||
* delegates to the {@link ViewEngine}.
|
||||
*
|
||||
* <p>Use {@code Template} when:
|
||||
* <ul>
|
||||
* <li>The handler is annotated with {@code @View} but needs to redirect to a
|
||||
* different template dynamically (e.g. on validation failure).</li>
|
||||
* <li>A lambda handler or a handler <em>without</em> {@code @View} wants to
|
||||
* trigger rendering without registering the annotation — pair with a
|
||||
* {@link Renderer} captured at construction time.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Inside a @View-annotated handler — overrides the default template on error
|
||||
* public Object handle(Request req, Response res) {
|
||||
* if (!valid) return Template.of("form-error", Map.of("errors", errors));
|
||||
* return service.findAll(); // falls back to @View template
|
||||
* }
|
||||
*
|
||||
* // Lambda handler — pair with Renderer captured from ctx at boot time
|
||||
* Renderer renderer = ctx.require(Renderer.class);
|
||||
* app.get("/page", (req, res) -> renderer.view(res, "page", model));
|
||||
* }</pre>
|
||||
*/
|
||||
public final class Template {
|
||||
|
||||
private final String name;
|
||||
private final Object model;
|
||||
|
||||
private Template(String name, Object model) {
|
||||
this.name = name;
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
/** Creates a {@code Template} signal with the given name and model. */
|
||||
public static Template of(String name, Object model) {
|
||||
return new Template(name, model);
|
||||
}
|
||||
|
||||
/** Creates a {@code Template} signal with a {@code null} model. */
|
||||
public static Template of(String name) {
|
||||
return new Template(name, null);
|
||||
}
|
||||
|
||||
/** The template name/path to render. */
|
||||
public String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/** The model to bind; may be {@code null}. */
|
||||
public Object model() {
|
||||
return model;
|
||||
}
|
||||
}
|
||||
+11
-18
@@ -1,6 +1,5 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import org.thymeleaf.IEngineConfiguration;
|
||||
import org.thymeleaf.TemplateEngine;
|
||||
import org.thymeleaf.context.Context;
|
||||
import org.thymeleaf.context.IExpressionContext;
|
||||
@@ -11,9 +10,10 @@ import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* {@link ViewEngine} bridge for Thymeleaf 3.x.
|
||||
* Thymeleaf 3.x adapter for Flash SSR views.
|
||||
*
|
||||
* <p>Package-private — instantiated exclusively by {@link ViewEngineType#THYMELEAF}.
|
||||
*
|
||||
@@ -41,12 +41,10 @@ import java.util.Map;
|
||||
* <li>{@code null} model → empty context.</li>
|
||||
* </ul>
|
||||
*/
|
||||
final class ThymeleafEngine implements ViewEngine, ViewEngineAdapter {
|
||||
final class ThymeleafEngine implements ViewEngineAdapter {
|
||||
|
||||
private static final String PREFIX = "/templates/";
|
||||
private static final String SUFFIX = ".html";
|
||||
private static final String FRAGMENT = " :: content";
|
||||
|
||||
private final TemplateEngine engine;
|
||||
|
||||
ThymeleafEngine(boolean cacheEnabled) {
|
||||
@@ -64,13 +62,6 @@ final class ThymeleafEngine implements ViewEngine, ViewEngineAdapter {
|
||||
this.engine.addLinkBuilder(FlashLinkBuilder.INSTANCE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String render(String template, Object model, boolean fragment) {
|
||||
Context ctx = new Context();
|
||||
populateContext(ctx, model);
|
||||
return engine.process(fragment ? template + FRAGMENT : template, ctx);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EngineCapabilities capabilities() {
|
||||
return new EngineCapabilities(true);
|
||||
@@ -80,16 +71,18 @@ final class ThymeleafEngine implements ViewEngine, ViewEngineAdapter {
|
||||
public RenderOutput render(ViewTarget target, Map<String, Object> model,
|
||||
dev.relism.models.Request req,
|
||||
dev.relism.models.Response res) {
|
||||
String selector;
|
||||
String template = target.template();
|
||||
if (target.kind() == ViewKind.PAGE) {
|
||||
selector = target.template();
|
||||
Context ctx = new Context();
|
||||
populateContext(ctx, model);
|
||||
return RenderOutput.html(engine.process(template, ctx));
|
||||
} else {
|
||||
String slot = target.slot();
|
||||
selector = target.template() + " :: " + ((slot == null || slot.isBlank()) ? "content" : slot);
|
||||
String fragment = (slot == null || slot.isBlank()) ? "content" : slot;
|
||||
Context ctx = new Context();
|
||||
populateContext(ctx, model);
|
||||
return RenderOutput.html(engine.process(template, Set.of(fragment), ctx));
|
||||
}
|
||||
Context ctx = new Context();
|
||||
populateContext(ctx, model);
|
||||
return RenderOutput.html(engine.process(selector, ctx));
|
||||
}
|
||||
|
||||
private static void populateContext(Context ctx, Object model) {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Declarative view binding for class-based handlers.
|
||||
*
|
||||
* <p>When {@code ViewExtension} is installed, handlers annotated with {@code @View}
|
||||
* receive an injected middleware that intercepts the handler's return value and
|
||||
* passes it to the {@link ViewEngine} for rendering. The rendered string replaces
|
||||
* the handler's return value as the response body, and {@link #contentType()} is
|
||||
* written to the {@code Content-Type} header.
|
||||
*
|
||||
* <h3>Return-value semantics</h3>
|
||||
* <ul>
|
||||
* <li>Return a {@link Template} — overrides both the template name <em>and</em>
|
||||
* the model dynamically (e.g. redirect to a different template on error).</li>
|
||||
* <li>Return any other non-null value — used as the model; the template name
|
||||
* comes from {@link #value()}.</li>
|
||||
* <li>Return {@code null} — renders {@link #value()} with a {@code null} model.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Route(method = HttpMethod.GET, path = "/")
|
||||
* @View("home")
|
||||
* public class HomeHandler extends RequestHandler {
|
||||
* private PostService posts;
|
||||
* @Override protected void onInit() { posts = require(PostService.class); }
|
||||
*
|
||||
* @Override public Object handle(Request req, Response res) {
|
||||
* return Map.of("posts", posts.findAll()); // model → home template
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // Dynamic template override via Template signal
|
||||
* @View("list")
|
||||
* public class ConditionalHandler extends RequestHandler {
|
||||
* public Object handle(Request req, Response res) {
|
||||
* if (something) return Template.of("error", Map.of("msg", "oops"));
|
||||
* return data; // uses "list" template
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>The annotation is inspected via superclass traversal, so a base handler class
|
||||
* can declare the view template and all concrete subclasses inherit it.
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface View {
|
||||
|
||||
/**
|
||||
* Template name or path passed to the {@link ViewEngine}.
|
||||
* The exact format is engine-specific (e.g. {@code "home"}, {@code "views/home.html"}).
|
||||
*/
|
||||
String value();
|
||||
|
||||
/**
|
||||
* {@code Content-Type} written to the response.
|
||||
* Defaults to {@link ContentType#TEXT_HTML}.
|
||||
*/
|
||||
ContentType contentType() default ContentType.TEXT_HTML;
|
||||
|
||||
/**
|
||||
* If {@code true}, instructs the {@link ViewEngine} to render only a named
|
||||
* fragment inside the template rather than the full page.
|
||||
* Useful for HTMX / partial-update patterns.
|
||||
*/
|
||||
boolean fragment() default false;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
/**
|
||||
* Contract for template engine integrations.
|
||||
*
|
||||
* <p>Implement this interface to plug any template engine (Thymeleaf, Jinjava,
|
||||
* Mustache, FreeMarker, …) into the Flash view layer. A single instance is
|
||||
* shared across all handlers, so implementations must be thread-safe.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Thymeleaf example
|
||||
* ViewEngine thymeleaf = (template, model, fragment) -> {
|
||||
* Context ctx = new Context();
|
||||
* if (model instanceof Map<?,?> m) m.forEach((k, v) -> ctx.setVariable(k.toString(), v));
|
||||
* else if (model != null) ctx.setVariable("model", model);
|
||||
* return engine.process(fragment ? template + " :: fragment" : template, ctx);
|
||||
* };
|
||||
*
|
||||
* app.install(new ViewExtension(thymeleaf));
|
||||
* }</pre>
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface ViewEngine {
|
||||
|
||||
/**
|
||||
* Renders {@code template} with the supplied {@code model}.
|
||||
*
|
||||
* @param template the template name or path — engine-specific convention
|
||||
* (e.g. {@code "views/home"}, {@code "home.html"})
|
||||
* @param model the model object passed to the template; may be {@code null}
|
||||
* @param fragment if {@code true}, only a named fragment inside the template
|
||||
* should be rendered (Thymeleaf: {@code template :: fragment},
|
||||
* Mustache: partial name, etc.)
|
||||
* @return the rendered output string
|
||||
* @throws Exception any rendering error — propagated as a 500 by the Flash runtime
|
||||
*/
|
||||
String render(String template, Object model, boolean fragment) throws Exception;
|
||||
|
||||
/**
|
||||
* Convenience overload — renders the full template ({@code fragment = false}).
|
||||
*/
|
||||
default String render(String template, Object model) throws Exception {
|
||||
return render(template, model, false);
|
||||
}
|
||||
}
|
||||
+18
@@ -5,7 +5,25 @@ import dev.relism.models.Response;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Rendering adapter contract used by the Flash SSR view runtime.
|
||||
*
|
||||
* <p>Implementations must be thread-safe after construction because one instance is shared by
|
||||
* all requests.
|
||||
*/
|
||||
public interface ViewEngineAdapter {
|
||||
|
||||
/** Engine feature flags used for boot-time route/view validation. */
|
||||
EngineCapabilities capabilities();
|
||||
|
||||
/**
|
||||
* Renders a page/partial target with the merged model for the current request.
|
||||
*
|
||||
* @param target resolved rendering target
|
||||
* @param model merged request model (globals first, local model last)
|
||||
* @param req current request
|
||||
* @param res current response
|
||||
* @return response body + optional explicit content type
|
||||
*/
|
||||
RenderOutput render(ViewTarget target, Map<String, Object> model, Request req, Response res) throws Exception;
|
||||
}
|
||||
|
||||
+4
-10
@@ -22,8 +22,8 @@ package dev.relism.ext.view;
|
||||
* In all other cases caching is enabled (production default).
|
||||
*
|
||||
* <h3>Adding your own engine</h3>
|
||||
* For unsupported engines, implement {@link ViewEngine} directly and use
|
||||
* {@link ViewExtension#ViewExtension(ViewEngine)} instead.
|
||||
* For unsupported engines, implement {@link ViewEngineAdapter} and pass it to
|
||||
* {@link ViewExtension#ViewExtension(ViewEngineAdapter)}.
|
||||
*/
|
||||
public enum ViewEngineType {
|
||||
|
||||
@@ -47,8 +47,8 @@ public enum ViewEngineType {
|
||||
// ── Factory ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Instantiates and configures the {@link ViewEngine} for this type.
|
||||
* Called once at {@link ViewExtension#install} time — never on the hot-path.
|
||||
* Instantiates and configures the {@link ViewEngineAdapter} for this type.
|
||||
* Called once at extension setup time — never on the hot-path.
|
||||
*
|
||||
* @param cacheEnabled whether the engine should cache compiled templates
|
||||
* @throws IllegalStateException if the required library is not on the classpath
|
||||
@@ -59,12 +59,6 @@ public enum ViewEngineType {
|
||||
};
|
||||
}
|
||||
|
||||
ViewEngine createEngine(boolean cacheEnabled) {
|
||||
ViewEngineAdapter adapter = createAdapter(cacheEnabled);
|
||||
if (adapter instanceof ViewEngine engine) return engine;
|
||||
throw new IllegalStateException("Selected engine does not expose legacy ViewEngine interface: " + this);
|
||||
}
|
||||
|
||||
// ── Engine factories ──────────────────────────────────────────────────────
|
||||
|
||||
private static ViewEngineAdapter createThymeleaf(boolean cacheEnabled) {
|
||||
|
||||
+28
-43
@@ -3,39 +3,45 @@ package dev.relism.ext.view;
|
||||
import dev.relism.Flash;
|
||||
import dev.relism.extension.FlashContext;
|
||||
import dev.relism.extension.FlashExtension;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.routing.Middleware;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* Installs server-side view rendering for class-based handlers.
|
||||
*
|
||||
* <p>Strict contract: only {@link ViewHandler} subclasses may declare {@link Page}/{@link Partial}.
|
||||
* Annotating a plain {@link dev.relism.models.RequestHandler} fails fast at boot.
|
||||
*/
|
||||
public final class ViewExtension implements FlashExtension {
|
||||
|
||||
private final ViewEngineAdapter adapter;
|
||||
private final ViewEngine legacyEngine;
|
||||
private final List<ViewGlobals> globals = new ArrayList<>();
|
||||
private final List<GlobalBinding> globals = new ArrayList<>();
|
||||
|
||||
public ViewExtension(ViewEngineType type) {
|
||||
this(type.createAdapter(!Flash.DEV));
|
||||
}
|
||||
|
||||
public ViewExtension(ViewEngine engine) {
|
||||
this(new LegacyViewEngineAdapter(engine), engine);
|
||||
}
|
||||
|
||||
public ViewExtension(ViewEngineAdapter adapter) {
|
||||
this(adapter, null);
|
||||
}
|
||||
|
||||
private ViewExtension(ViewEngineAdapter adapter, ViewEngine legacyEngine) {
|
||||
this.adapter = Objects.requireNonNull(adapter, "ViewEngineAdapter must not be null");
|
||||
this.legacyEngine = legacyEngine;
|
||||
}
|
||||
|
||||
public ViewExtension addGlobals(ViewGlobals provider) {
|
||||
globals.add(Objects.requireNonNull(provider, "ViewGlobals must not be null"));
|
||||
/**
|
||||
* Registers one request-scoped global value under {@code global.<key>}.
|
||||
*
|
||||
* <p>This is the only supported global registration API. Keep resolvers fast and side-effect free.
|
||||
*/
|
||||
public ViewExtension addGlobal(String key, Function<dev.relism.models.Request, Object> resolver) {
|
||||
String k = Objects.requireNonNull(key, "global key must not be null").trim();
|
||||
if (k.isEmpty()) {
|
||||
throw new IllegalArgumentException("global key must not be blank");
|
||||
}
|
||||
if (k.equals("global") || k.contains(".")) {
|
||||
throw new IllegalArgumentException("global key must be a simple key (no dots), received: " + key);
|
||||
}
|
||||
globals.add(new GlobalBinding(k, Objects.requireNonNull(resolver, "global resolver must not be null")));
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -45,37 +51,16 @@ public final class ViewExtension implements FlashExtension {
|
||||
ctx.provide(ViewEngineAdapter.class, adapter);
|
||||
ctx.provide(ViewRuntime.class, runtime);
|
||||
|
||||
if (legacyEngine != null) {
|
||||
ctx.provide(ViewEngine.class, legacyEngine);
|
||||
ctx.provide(Renderer.class, new Renderer(legacyEngine));
|
||||
} else if (adapter instanceof ViewEngine engine) {
|
||||
ctx.provide(ViewEngine.class, engine);
|
||||
ctx.provide(Renderer.class, new Renderer(engine));
|
||||
}
|
||||
|
||||
// Processor kept for boot-time contract enforcement. Rendering itself stays in ViewHandler.
|
||||
ctx.addAnnotationProcessor(handlerClass -> {
|
||||
if (ViewHandler.class.isAssignableFrom(handlerClass)) return List.of();
|
||||
|
||||
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(handlerClass, adapter.capabilities());
|
||||
if (resolved == null) return List.of();
|
||||
|
||||
Middleware renderingMiddleware = next -> (req, res) -> {
|
||||
Object result = next.handle(req, res);
|
||||
ViewModel local = ViewRuntime.legacyLocalModel(result);
|
||||
ViewModel merged = runtime.merge(req, local);
|
||||
RenderOutput out = adapter.render(resolved.target(), merged.toMap(), req, res);
|
||||
|
||||
if (resolved.legacy()) {
|
||||
ContentType type = resolved.contentType();
|
||||
if (type != null) res.type(type);
|
||||
else if (out.contentType() != null) res.type(out.contentType());
|
||||
} else if (out.contentType() != null) {
|
||||
res.type(out.contentType());
|
||||
}
|
||||
return out.body();
|
||||
};
|
||||
|
||||
return List.of(renderingMiddleware);
|
||||
if (!ViewHandler.class.isAssignableFrom(handlerClass)) {
|
||||
throw new IllegalStateException("Handler " + handlerClass.getName()
|
||||
+ " declares @Page/@Partial but does not extend ViewHandler");
|
||||
}
|
||||
return List.of();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import dev.relism.models.Request;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ViewGlobals {
|
||||
ViewModel provide(Request req);
|
||||
}
|
||||
@@ -4,25 +4,52 @@ import dev.relism.models.Request;
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.Response;
|
||||
|
||||
/**
|
||||
* Base class for class-based SSR handlers.
|
||||
*
|
||||
* <p>Subclass contract:
|
||||
* <ol>
|
||||
* <li>Declare exactly one of {@link Page} or {@link Partial} on the class hierarchy.</li>
|
||||
* <li>Cache dependencies in {@link #onViewInit()} (one-time, boot-time).</li>
|
||||
* <li>Build per-request model in {@link #render(Request)} or {@link #render(Request, Response)}.</li>
|
||||
* </ol>
|
||||
*/
|
||||
public abstract class ViewHandler extends RequestHandler {
|
||||
private ViewRuntime runtime;
|
||||
private ViewTargetResolver.ResolvedView resolved;
|
||||
|
||||
/**
|
||||
* Per-request model hook.
|
||||
*
|
||||
* <p>Override this method for request-only rendering. If you need to mutate response
|
||||
* metadata while building the model, override {@link #render(Request, Response)}.
|
||||
*/
|
||||
public ViewModel render(Request req) throws Exception {
|
||||
throw new UnsupportedOperationException("Override render(Request) or render(Request, Response)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-request model hook with response access.
|
||||
*
|
||||
* <p>Default implementation delegates to {@link #render(Request)}.
|
||||
*/
|
||||
public ViewModel render(Request req, Response res) throws Exception {
|
||||
return render(req);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected final void onInit() {
|
||||
runtime = require(ViewRuntime.class);
|
||||
runtime = require(ViewRuntime.class);
|
||||
resolved = runtime.resolve(getClass());
|
||||
onViewInit();
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time initialization hook invoked after view metadata resolution.
|
||||
*
|
||||
* <p>Use this to cache services via {@link #require(Class)}. Do not perform request-bound
|
||||
* work here.
|
||||
*/
|
||||
protected void onViewInit() {}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
/** Render mode for resolved handler view targets. */
|
||||
public enum ViewKind {
|
||||
PAGE,
|
||||
PARTIAL
|
||||
|
||||
@@ -7,6 +7,13 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Mutable view-model builder used by the SSR view runtime.
|
||||
*
|
||||
* <p>Instances are typically created per request in {@code ViewHandler.render(...)} then passed
|
||||
* to the renderer. The internal map preserves insertion order and is exposed as an immutable
|
||||
* snapshot through {@link #toMap()}.
|
||||
*/
|
||||
public final class ViewModel {
|
||||
private final LinkedHashMap<String, Object> values;
|
||||
|
||||
@@ -22,11 +29,13 @@ public final class ViewModel {
|
||||
return empty().with(key, value);
|
||||
}
|
||||
|
||||
/** Adds or replaces a model entry. Nested maps/lists/view-models are normalized recursively. */
|
||||
public ViewModel with(String key, Object value) {
|
||||
values.put(Objects.requireNonNull(key, "key"), unwrapValue(value));
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Bulk variant of {@link #with(String, Object)}. */
|
||||
public ViewModel withAll(Map<String, Object> values) {
|
||||
if (values == null || values.isEmpty()) return this;
|
||||
for (Map.Entry<String, Object> e : values.entrySet()) {
|
||||
@@ -35,12 +44,17 @@ public final class ViewModel {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a merged copy where {@code other} wins on key collisions.
|
||||
* Both source models remain unchanged.
|
||||
*/
|
||||
public ViewModel merge(ViewModel other) {
|
||||
ViewModel merged = new ViewModel(new LinkedHashMap<>(this.values));
|
||||
if (other != null && !other.values.isEmpty()) merged.values.putAll(other.values);
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Immutable view over current values. */
|
||||
public Map<String, Object> toMap() {
|
||||
return Collections.unmodifiableMap(values);
|
||||
}
|
||||
@@ -50,6 +64,10 @@ public final class ViewModel {
|
||||
return new ViewModel(new LinkedHashMap<>(source.values));
|
||||
}
|
||||
|
||||
static ViewModel owned(LinkedHashMap<String, Object> values) {
|
||||
return new ViewModel(values);
|
||||
}
|
||||
|
||||
static Object unwrapValue(Object value) {
|
||||
if (value instanceof ViewModel vm) {
|
||||
return vm.toMap();
|
||||
|
||||
+44
-32
@@ -4,17 +4,23 @@ import dev.relism.models.Request;
|
||||
import dev.relism.models.Response;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Internal runtime for view target resolution and per-request rendering.
|
||||
*
|
||||
* <p>All expensive reflection is done once and cached per handler class.
|
||||
*/
|
||||
final class ViewRuntime {
|
||||
private static final ViewModel EMPTY = ViewModel.empty();
|
||||
private static final String GLOBAL_NAMESPACE = "global";
|
||||
|
||||
private final ViewEngineAdapter adapter;
|
||||
private final List<ViewGlobals> globals;
|
||||
private final List<GlobalBinding> globals;
|
||||
private final ConcurrentHashMap<Class<?>, ViewTargetResolver.ResolvedView> resolvedCache = new ConcurrentHashMap<>();
|
||||
|
||||
ViewRuntime(ViewEngineAdapter adapter, List<ViewGlobals> globals) {
|
||||
ViewRuntime(ViewEngineAdapter adapter, List<GlobalBinding> globals) {
|
||||
this.adapter = adapter;
|
||||
this.globals = globals;
|
||||
}
|
||||
@@ -25,7 +31,7 @@ final class ViewRuntime {
|
||||
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(handlerClass, adapter.capabilities());
|
||||
if (resolved == null) {
|
||||
throw new IllegalStateException("ViewHandler " + handlerClass.getName()
|
||||
+ " must declare @Page, @Partial, or @View");
|
||||
+ " must declare @Page or @Partial");
|
||||
}
|
||||
resolvedCache.put(handlerClass, resolved);
|
||||
return resolved;
|
||||
@@ -34,46 +40,52 @@ final class ViewRuntime {
|
||||
Object render(ViewHandler handler, ViewTargetResolver.ResolvedView resolved, Request req, Response res) throws Exception {
|
||||
if (resolved == null) {
|
||||
throw new IllegalStateException("ViewHandler " + handler.getClass().getName()
|
||||
+ " must declare @Page, @Partial, or @View");
|
||||
+ " must declare @Page or @Partial");
|
||||
}
|
||||
|
||||
ViewModel local = handler.render(req, res);
|
||||
ViewModel merged = merge(req, local);
|
||||
RenderOutput out = render(resolved, req, res, handler.render(req, res));
|
||||
return out.body();
|
||||
}
|
||||
|
||||
RenderOutput render(ViewTargetResolver.ResolvedView resolved, Request req, Response res, ViewModel local) throws Exception {
|
||||
ViewModel merged = merge(req, local);
|
||||
RenderOutput out = adapter.render(resolved.target(), merged.toMap(), req, res);
|
||||
|
||||
if (out.contentType() != null) {
|
||||
res.type(out.contentType());
|
||||
} else {
|
||||
res.type(resolved.contentType());
|
||||
}
|
||||
return out.body();
|
||||
}
|
||||
|
||||
ViewModel merge(Request req, ViewModel local) {
|
||||
ViewModel global = computeGlobals(req);
|
||||
return global.merge(local == null ? EMPTY : local);
|
||||
}
|
||||
|
||||
private ViewModel computeGlobals(Request req) {
|
||||
if (globals.isEmpty()) return EMPTY;
|
||||
ViewModel out = ViewModel.empty();
|
||||
for (ViewGlobals provider : globals) {
|
||||
ViewModel vm = provider.provide(req);
|
||||
if (vm != null) out.withAll(vm.toMap());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static ViewModel legacyLocalModel(Object legacyModel) {
|
||||
if (legacyModel == null) return ViewModel.empty();
|
||||
if (legacyModel instanceof ViewModel vm) return ViewModel.copyOf(vm);
|
||||
if (legacyModel instanceof Map<?, ?> map) {
|
||||
ViewModel vm = ViewModel.empty();
|
||||
for (Map.Entry<?, ?> e : map.entrySet()) {
|
||||
vm.with(String.valueOf(e.getKey()), ViewModel.unwrapValue(e.getValue()));
|
||||
ViewModel merge(Request req, ViewModel local) {
|
||||
// Single-pass merge: reserved global namespace + local model.
|
||||
// We avoid intermediate ViewModel allocations on the hot path.
|
||||
LinkedHashMap<String, Object> values = null;
|
||||
|
||||
if (!globals.isEmpty()) {
|
||||
LinkedHashMap<String, Object> globalMap = new LinkedHashMap<>();
|
||||
for (GlobalBinding binding : globals) {
|
||||
Object resolved = binding.resolver().apply(req);
|
||||
globalMap.put(binding.key(), ViewModel.unwrapValue(resolved));
|
||||
}
|
||||
if (!globalMap.isEmpty()) {
|
||||
if (values == null) values = new LinkedHashMap<>();
|
||||
values.put(GLOBAL_NAMESPACE, Collections.unmodifiableMap(globalMap));
|
||||
}
|
||||
return vm;
|
||||
}
|
||||
return ViewModel.of("it", legacyModel);
|
||||
|
||||
if (local != null) {
|
||||
var localMap = local.toMap();
|
||||
if (localMap.containsKey(GLOBAL_NAMESPACE)) {
|
||||
throw new IllegalStateException("ViewModel key 'global' is reserved for framework globals");
|
||||
}
|
||||
if (values == null) return ViewModel.copyOf(local);
|
||||
values.putAll(localMap);
|
||||
}
|
||||
|
||||
if (values == null || values.isEmpty()) return ViewModel.empty();
|
||||
return ViewModel.owned(values);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
/**
|
||||
* Resolved render target for one handler class.
|
||||
*
|
||||
* @param kind page or partial render
|
||||
* @param template template identifier/path
|
||||
* @param slot optional partial slot selector
|
||||
*/
|
||||
public record ViewTarget(
|
||||
ViewKind kind,
|
||||
String template,
|
||||
|
||||
+19
-11
@@ -4,6 +4,7 @@ import dev.relism.http.ContentType;
|
||||
import dev.relism.routing.Route;
|
||||
import dev.relism.routing.Routes;
|
||||
|
||||
/** Boot-time resolver that maps handler annotations to concrete render targets. */
|
||||
final class ViewTargetResolver {
|
||||
|
||||
private ViewTargetResolver() {}
|
||||
@@ -11,9 +12,8 @@ final class ViewTargetResolver {
|
||||
static ResolvedView resolve(Class<?> handlerClass, EngineCapabilities capabilities) {
|
||||
Page page = find(handlerClass, Page.class);
|
||||
Partial partial = find(handlerClass, Partial.class);
|
||||
View legacy = find(handlerClass, View.class);
|
||||
|
||||
int count = (page != null ? 1 : 0) + (partial != null ? 1 : 0) + (legacy != null ? 1 : 0);
|
||||
int count = (page != null ? 1 : 0) + (partial != null ? 1 : 0);
|
||||
if (count == 0) return null;
|
||||
|
||||
Route route = Routes.of(handlerClass);
|
||||
@@ -25,28 +25,36 @@ final class ViewTargetResolver {
|
||||
if (count > 1) {
|
||||
throw new IllegalStateException("Handler " + handlerClass.getName()
|
||||
+ " route " + route.method() + " " + route.path()
|
||||
+ " declares multiple view annotations. Use exactly one of @Page, @Partial, @View");
|
||||
+ " declares multiple view annotations. Use exactly one of @Page, @Partial");
|
||||
}
|
||||
|
||||
if (page != null) {
|
||||
return new ResolvedView(new ViewTarget(ViewKind.PAGE, page.value(), ""), ContentType.TEXT_HTML, false);
|
||||
String template = page.value() == null ? "" : page.value().trim();
|
||||
if (template.isEmpty()) {
|
||||
throw new IllegalStateException("Handler " + handlerClass.getName()
|
||||
+ " route " + route.method() + " " + route.path()
|
||||
+ " uses @Page with an empty template name");
|
||||
}
|
||||
return new ResolvedView(new ViewTarget(ViewKind.PAGE, template, ""), ContentType.TEXT_HTML);
|
||||
}
|
||||
|
||||
if (partial != null) {
|
||||
String template = partial.template() == null ? "" : partial.template().trim();
|
||||
if (template.isEmpty()) {
|
||||
throw new IllegalStateException("Handler " + handlerClass.getName()
|
||||
+ " route " + route.method() + " " + route.path()
|
||||
+ " uses @Partial with an empty template name");
|
||||
}
|
||||
String slot = partial.slot() == null ? "" : partial.slot().trim();
|
||||
if (!slot.isEmpty() && !capabilities.supportsPartialSlot()) {
|
||||
throw new IllegalStateException("Handler " + handlerClass.getName()
|
||||
+ " route " + route.method() + " " + route.path()
|
||||
+ " uses @Partial(slot=\"" + slot + "\") but current engine does not support partial slots");
|
||||
}
|
||||
return new ResolvedView(new ViewTarget(ViewKind.PARTIAL, partial.template(), slot), ContentType.TEXT_HTML, false);
|
||||
return new ResolvedView(new ViewTarget(ViewKind.PARTIAL, template, slot), ContentType.TEXT_HTML);
|
||||
}
|
||||
|
||||
return new ResolvedView(
|
||||
new ViewTarget(legacy.fragment() ? ViewKind.PARTIAL : ViewKind.PAGE, legacy.value(), ""),
|
||||
legacy.contentType(),
|
||||
true
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static <A extends java.lang.annotation.Annotation> A find(Class<?> cls, Class<A> type) {
|
||||
@@ -58,5 +66,5 @@ final class ViewTargetResolver {
|
||||
return null;
|
||||
}
|
||||
|
||||
record ResolvedView(ViewTarget target, ContentType contentType, boolean legacy) {}
|
||||
record ResolvedView(ViewTarget target, ContentType contentType) {}
|
||||
}
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.models.Response;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ThymeleafEngineTest {
|
||||
|
||||
@Test
|
||||
void render_page_resolvesTemplateAndLinks() {
|
||||
ThymeleafEngine engine = new ThymeleafEngine(false);
|
||||
|
||||
RenderOutput out = engine.render(
|
||||
new ViewTarget(ViewKind.PAGE, "pages/home", ""),
|
||||
ViewModel.empty().with("title", "Home").with("id", 42).with("page", 2).toMap(),
|
||||
null,
|
||||
new Response(200, ContentType.JSON)
|
||||
);
|
||||
|
||||
assertEquals(ContentType.TEXT_HTML, out.contentType());
|
||||
assertTrue(out.body().contains("Home"));
|
||||
assertTrue(out.body().contains("/users/42?page=2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void render_partial_usesExplicitSlot() {
|
||||
ThymeleafEngine engine = new ThymeleafEngine(false);
|
||||
|
||||
RenderOutput out = engine.render(
|
||||
new ViewTarget(ViewKind.PARTIAL, "pages/home", "rows"),
|
||||
ViewModel.empty().with("id", 42).toMap(),
|
||||
null,
|
||||
new Response(200, ContentType.JSON)
|
||||
);
|
||||
|
||||
assertTrue(out.body().contains("row-42"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void render_partial_usesDefaultContentSlotWhenBlank() {
|
||||
ThymeleafEngine engine = new ThymeleafEngine(false);
|
||||
|
||||
RenderOutput out = engine.render(
|
||||
new ViewTarget(ViewKind.PARTIAL, "pages/home", " "),
|
||||
ViewModel.empty().with("title", "ContentSlot").toMap(),
|
||||
null,
|
||||
new Response(200, ContentType.JSON)
|
||||
);
|
||||
|
||||
assertTrue(out.body().contains("content-ContentSlot"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void capabilities_supportSlotSelection() {
|
||||
ThymeleafEngine engine = new ThymeleafEngine(true);
|
||||
assertTrue(engine.capabilities().supportsPartialSlot());
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import dev.relism.extension.FlashContext;
|
||||
import dev.relism.extension.AnnotationProcessor;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.Response;
|
||||
import dev.relism.routing.GET;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class ViewExtensionContractTest {
|
||||
|
||||
@GET("/plain")
|
||||
@Page("pages/plain")
|
||||
static final class PlainPageHandler extends RequestHandler {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@GET("/view")
|
||||
@Page("pages/view")
|
||||
static final class ViewPageHandler extends ViewHandler {
|
||||
@Override
|
||||
public ViewModel render(Request req) {
|
||||
return ViewModel.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void processor_rejectsViewAnnotationOnNonViewHandler() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
new ViewExtension(new NoopAdapter()).provide(ctx);
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> processors(ctx).forEach(p -> p.process(PlainPageHandler.class)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void processor_acceptsViewHandlerWithViewAnnotation() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
new ViewExtension(new NoopAdapter()).provide(ctx);
|
||||
|
||||
assertDoesNotThrow(() -> processors(ctx).forEach(p -> p.process(ViewPageHandler.class)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_withEngineType_buildsAndProvidesRuntime() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ViewExtension extension = new ViewExtension(ViewEngineType.THYMELEAF);
|
||||
|
||||
extension.provide(ctx);
|
||||
|
||||
assertNotNull(ctx.require(ViewRuntime.class));
|
||||
assertNotNull(ctx.require(ViewEngineAdapter.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void addGlobal_nullResolver_throws() {
|
||||
ViewExtension extension = new ViewExtension(new NoopAdapter());
|
||||
assertThrows(NullPointerException.class, () -> extension.addGlobal("appName", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void addGlobal_invalidKey_throws() {
|
||||
ViewExtension extension = new ViewExtension(new NoopAdapter());
|
||||
assertThrows(IllegalArgumentException.class, () -> extension.addGlobal("", req -> "x"));
|
||||
assertThrows(IllegalArgumentException.class, () -> extension.addGlobal("global", req -> "x"));
|
||||
assertThrows(IllegalArgumentException.class, () -> extension.addGlobal("a.b", req -> "x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_withNullAdapter_throws() {
|
||||
assertThrows(NullPointerException.class, () -> new ViewExtension((ViewEngineAdapter) null));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<AnnotationProcessor> processors(FlashContext ctx) {
|
||||
try {
|
||||
Method m = FlashContext.class.getDeclaredMethod("processors");
|
||||
m.setAccessible(true);
|
||||
return ((List<AnnotationProcessor>) m.invoke(ctx)).stream()
|
||||
.filter(p -> p.getClass().getName().contains("ViewExtension"))
|
||||
.collect(Collectors.toList());
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NoopAdapter implements ViewEngineAdapter {
|
||||
@Override
|
||||
public EngineCapabilities capabilities() {
|
||||
return new EngineCapabilities(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RenderOutput render(ViewTarget target, java.util.Map<String, Object> model,
|
||||
Request req,
|
||||
Response res) {
|
||||
return RenderOutput.html("");
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import dev.relism.extension.FlashContext;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.Response;
|
||||
import dev.relism.routing.GET;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ViewHandlerLifecycleTest {
|
||||
|
||||
@GET("/lifecycle")
|
||||
@Page("pages/home")
|
||||
static final class LifecycleHandler extends ViewHandler {
|
||||
boolean onViewInitCalled;
|
||||
DummyService service;
|
||||
|
||||
@Override
|
||||
protected void onViewInit() {
|
||||
onViewInitCalled = true;
|
||||
service = require(DummyService.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ViewModel render(Request req) {
|
||||
return ViewModel.of("title", service.value());
|
||||
}
|
||||
}
|
||||
|
||||
static final class DummyService {
|
||||
String value() { return "ok"; }
|
||||
}
|
||||
|
||||
@Test
|
||||
void onInit_resolvesRuntime_and_onViewInit_runs_once() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ctx.provide(DummyService.class, new DummyService());
|
||||
ctx.provide(ViewRuntime.class, new ViewRuntime(new EchoAdapter(), List.of()));
|
||||
|
||||
LifecycleHandler handler = new LifecycleHandler();
|
||||
handler.bind(ctx);
|
||||
|
||||
assertTrue(handler.onViewInitCalled);
|
||||
assertEquals("ok", handler.service.value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void handle_rendersThroughRuntime() throws Exception {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ctx.provide(DummyService.class, new DummyService());
|
||||
ctx.provide(ViewRuntime.class, new ViewRuntime(new EchoAdapter(), List.of()));
|
||||
|
||||
LifecycleHandler handler = new LifecycleHandler();
|
||||
handler.bind(ctx);
|
||||
Response res = new Response(200, ContentType.JSON);
|
||||
|
||||
Object out = handler.handle(null, res);
|
||||
|
||||
assertEquals("ok", out);
|
||||
assertEquals(new String(ContentType.TEXT_HTML.getBytes()), new String(res.getContentType()));
|
||||
}
|
||||
|
||||
private static final class EchoAdapter implements ViewEngineAdapter {
|
||||
@Override
|
||||
public EngineCapabilities capabilities() {
|
||||
return EngineCapabilities.NONE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RenderOutput render(ViewTarget target, java.util.Map<String, Object> model,
|
||||
Request req,
|
||||
Response res) {
|
||||
return RenderOutput.html(String.valueOf(model.get("title")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,4 +53,31 @@ class ViewModelTest {
|
||||
assertTrue(list instanceof List<?>);
|
||||
assertTrue(((List<?>) list).getFirst() instanceof Map<?, ?>);
|
||||
}
|
||||
|
||||
@Test
|
||||
void with_nullKey_throws() {
|
||||
ViewModel model = ViewModel.empty();
|
||||
assertThrows(NullPointerException.class, () -> model.with(null, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withAll_nullOrEmpty_noop() {
|
||||
ViewModel model = ViewModel.of("a", 1);
|
||||
|
||||
model.withAll(null);
|
||||
model.withAll(Map.of());
|
||||
|
||||
assertEquals(1, model.toMap().size());
|
||||
assertEquals(1, model.toMap().get("a"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void merge_nullOther_returnsCopy() {
|
||||
ViewModel base = ViewModel.of("a", 1);
|
||||
|
||||
ViewModel merged = base.merge(null);
|
||||
|
||||
assertNotSame(base, merged);
|
||||
assertEquals(1, merged.toMap().get("a"));
|
||||
}
|
||||
}
|
||||
|
||||
+115
-8
@@ -3,33 +3,112 @@ package dev.relism.ext.view;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
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 ViewRuntimeGlobalsTest {
|
||||
|
||||
private static final GlobalBinding APP = new GlobalBinding("appName", req -> "flash");
|
||||
private static final GlobalBinding PATH = new GlobalBinding("requestPath", req -> "/x");
|
||||
|
||||
@Test
|
||||
void merge_globalsThenLocal_localWins() {
|
||||
void merge_globalsAreNestedUnderReservedNamespace() {
|
||||
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of(
|
||||
req -> ViewModel.of("nav", "global").with("app", "flash"),
|
||||
req -> ViewModel.of("nav", "global-2")
|
||||
APP,
|
||||
PATH
|
||||
));
|
||||
|
||||
ViewModel merged = runtime.merge(null, ViewModel.of("nav", "local"));
|
||||
ViewModel merged = runtime.merge(null, ViewModel.of("title", "dashboard"));
|
||||
|
||||
assertEquals("local", merged.toMap().get("nav"));
|
||||
assertEquals("flash", merged.toMap().get("app"));
|
||||
Map<?, ?> global = (Map<?, ?>) merged.toMap().get("global");
|
||||
assertEquals("flash", global.get("appName"));
|
||||
assertEquals("/x", global.get("requestPath"));
|
||||
assertEquals("dashboard", merged.toMap().get("title"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void merge_nullLocal_keepsGlobals() {
|
||||
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of(
|
||||
req -> ViewModel.of("signedIn", true)
|
||||
new GlobalBinding("signedIn", req -> true)
|
||||
));
|
||||
|
||||
ViewModel merged = runtime.merge(null, null);
|
||||
|
||||
assertEquals(true, merged.toMap().get("signedIn"));
|
||||
Map<?, ?> global = (Map<?, ?>) merged.toMap().get("global");
|
||||
assertEquals(true, global.get("signedIn"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void merge_withoutGlobals_returnsCopyOfLocal() {
|
||||
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of());
|
||||
ViewModel local = ViewModel.of("k", "v");
|
||||
|
||||
ViewModel merged = runtime.merge(null, local);
|
||||
|
||||
assertEquals("v", merged.toMap().get("k"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void merge_withNullGlobalsAndNullLocal_returnsEmpty() {
|
||||
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of());
|
||||
|
||||
ViewModel merged = runtime.merge(null, null);
|
||||
|
||||
assertTrue(merged.toMap().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void merge_localGlobalNamespace_throws() {
|
||||
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of(APP));
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> runtime.merge(null, ViewModel.of("global", Map.of("x", 1))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_withoutViewAnnotation_failsFast() {
|
||||
ViewRuntime runtime = new ViewRuntime(new NoopAdapter(), List.of());
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> runtime.resolve(NoViewHandler.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void render_prefersAdapterContentTypeWhenProvided() throws Exception {
|
||||
ViewRuntime runtime = new ViewRuntime(new PlainTextAdapter(), List.of());
|
||||
ViewTargetResolver.ResolvedView resolved = new ViewTargetResolver.ResolvedView(
|
||||
new ViewTarget(ViewKind.PAGE, "pages/home", ""),
|
||||
dev.relism.http.ContentType.TEXT_HTML
|
||||
);
|
||||
dev.relism.models.Response res = new dev.relism.models.Response(200, dev.relism.http.ContentType.JSON);
|
||||
|
||||
runtime.render(resolved, null, res, ViewModel.of("a", 1));
|
||||
|
||||
assertEquals(new String(dev.relism.http.ContentType.TEXT_PLAIN.getBytes()), new String(res.getContentType()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void render_usesResolvedDefaultContentTypeWhenAdapterOmitsIt() throws Exception {
|
||||
ViewRuntime runtime = new ViewRuntime(new NoTypeAdapter(), List.of());
|
||||
ViewTargetResolver.ResolvedView resolved = new ViewTargetResolver.ResolvedView(
|
||||
new ViewTarget(ViewKind.PAGE, "pages/home", ""),
|
||||
dev.relism.http.ContentType.TEXT_HTML
|
||||
);
|
||||
dev.relism.models.Response res = new dev.relism.models.Response(200, dev.relism.http.ContentType.JSON);
|
||||
|
||||
runtime.render(resolved, null, res, ViewModel.of("a", 1));
|
||||
|
||||
assertEquals(new String(dev.relism.http.ContentType.TEXT_HTML.getBytes()), new String(res.getContentType()));
|
||||
}
|
||||
|
||||
static final class NoViewHandler extends dev.relism.models.RequestHandler {
|
||||
@Override
|
||||
public Object handle(dev.relism.models.Request request, dev.relism.models.Response response) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NoopAdapter implements ViewEngineAdapter {
|
||||
@@ -45,4 +124,32 @@ class ViewRuntimeGlobalsTest {
|
||||
return RenderOutput.html("");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NoTypeAdapter implements ViewEngineAdapter {
|
||||
@Override
|
||||
public EngineCapabilities capabilities() {
|
||||
return EngineCapabilities.NONE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RenderOutput render(ViewTarget target, java.util.Map<String, Object> model,
|
||||
dev.relism.models.Request req,
|
||||
dev.relism.models.Response res) {
|
||||
return new RenderOutput("", null);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PlainTextAdapter implements ViewEngineAdapter {
|
||||
@Override
|
||||
public EngineCapabilities capabilities() {
|
||||
return EngineCapabilities.NONE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RenderOutput render(ViewTarget target, java.util.Map<String, Object> model,
|
||||
dev.relism.models.Request req,
|
||||
dev.relism.models.Response res) {
|
||||
return new RenderOutput("", dev.relism.http.ContentType.TEXT_PLAIN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-8
@@ -1,6 +1,5 @@
|
||||
package dev.relism.ext.view;
|
||||
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.Response;
|
||||
@@ -29,9 +28,9 @@ class ViewTargetResolverTest {
|
||||
}
|
||||
}
|
||||
|
||||
@GET("/legacy")
|
||||
@View(value = "legacy/home", contentType = ContentType.TEXT_PLAIN, fragment = true)
|
||||
static class LegacyHandler extends RequestHandler {
|
||||
@GET("/partial-default-slot")
|
||||
@Partial(template = "fragments/card")
|
||||
static class DefaultSlotPartialHandler extends RequestHandler {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
return null;
|
||||
@@ -72,13 +71,30 @@ class ViewTargetResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_legacyView_mapsToResolvedTarget() {
|
||||
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(LegacyHandler.class, EngineCapabilities.NONE);
|
||||
void resolve_partial_withoutSlot_defaultsToEmptySlot() {
|
||||
ViewTargetResolver.ResolvedView resolved = ViewTargetResolver.resolve(DefaultSlotPartialHandler.class, new EngineCapabilities(true));
|
||||
|
||||
assertNotNull(resolved);
|
||||
assertTrue(resolved.legacy());
|
||||
assertEquals(ContentType.TEXT_PLAIN, resolved.contentType());
|
||||
assertEquals(ViewKind.PARTIAL, resolved.target().kind());
|
||||
assertEquals("", resolved.target().slot());
|
||||
}
|
||||
|
||||
@GET("/blank-page")
|
||||
@Page(" ")
|
||||
static class BlankPageHandler extends RequestHandler {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@GET("/blank-partial")
|
||||
@Partial(template = " ")
|
||||
static class BlankPartialTemplateHandler extends RequestHandler {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -92,4 +108,16 @@ class ViewTargetResolverTest {
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> ViewTargetResolver.resolve(NoRouteHandler.class, new EngineCapabilities(true)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_blankPageTemplate_fails() {
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> ViewTargetResolver.resolve(BlankPageHandler.class, new EngineCapabilities(true)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolve_blankPartialTemplate_fails() {
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> ViewTargetResolver.resolve(BlankPartialTemplateHandler.class, new EngineCapabilities(true)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<h1 th:text="${title}">fallback</h1>
|
||||
<a th:href="@{/users/{id}(id=${id},page=${page})}">user</a>
|
||||
<div th:fragment="content" th:text="'content-' + ${title}">content</div>
|
||||
<div th:fragment="rows" th:attr="id=${'row-' + id}">row</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<version>1.1-indev5</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-web-bundler</artifactId>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-parent</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<version>1.1-indev5</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
@@ -40,6 +40,11 @@
|
||||
<artifactId>jackson-dataformat-yaml</artifactId>
|
||||
<version>2.17.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
<version>2.17.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.nimbusds</groupId>
|
||||
<artifactId>nimbus-jose-jwt</artifactId>
|
||||
@@ -58,4 +63,18 @@
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-deploy-plugin</artifactId>
|
||||
<version>3.1.2</version>
|
||||
<inherited>false</inherited>
|
||||
<configuration>
|
||||
<skip>true</skip>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
|
||||
Reference in New Issue
Block a user