i spent the last year just spinning
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user