implement OpenAPI contributor integration for rate limiting and response headers

This commit is contained in:
Relism
2026-04-19 23:42:50 +02:00
parent e161497f2c
commit 34fd74068a
26 changed files with 703 additions and 331 deletions
@@ -105,6 +105,22 @@ Supported field-level exclusion:
- `@JsonIgnoreProperties(...)`
- `transient` / `static`
## Contributor API
OpenAPI is extension-agnostic. Other extensions contribute with `OpenApiContributor` via
`OpenApiContributorRegistry`.
Supported contribution surfaces:
- `components` fragments (merged with last-wins)
- operation `security` requirements (additive)
- operation `responses` and response `headers` (additive)
Merge policy:
- contributor collisions use **last-wins**
- manual `@APIResponse` description always wins over contributors for the same status
## OIDC interop
When `flash-ext-oidc` is installed, OpenAPI integrates automatically:
@@ -117,6 +133,18 @@ When `flash-ext-oidc` is installed, OpenAPI integrates automatically:
Manual `@APIResponse` for the same status code always wins.
## Limiter interop
When `flash-ext-limiter` is installed, handlers with `@Limit` automatically get response
headers documented in OpenAPI:
- `X-RateLimit-Limit`
- `X-RateLimit-Remaining`
- `X-RateLimit-Reset`
- `Retry-After` on `429`
If `429` is missing, it is auto-added as `Too Many Requests`.
## Notes
- Operations are collected from final boot-time routes for class-based handlers with `@ApiOperation`.
@@ -51,7 +51,7 @@ public final class OpenApiBuilder {
private final Map<String, Map<String, Object>> paths = new LinkedHashMap<>();
private final Map<String, Map<String, Class<?>>> operationHandlers = new LinkedHashMap<>();
private final SchemaRegistry schemas = new SchemaRegistry();
private OpenApiSecurityRegistry securityRegistry;
private OpenApiContributorRegistry contributorRegistry;
private int revision;
private int builtRevision = -1;
private Map<String, Object> cachedSpec;
@@ -59,7 +59,7 @@ public final class OpenApiBuilder {
public OpenApiBuilder title(String title) { this.title = title; return this; }
public OpenApiBuilder version(String version) { this.version = version; return this; }
public OpenApiBuilder description(String description) { this.description = description; return this; }
void setSecurityRegistry(OpenApiSecurityRegistry registry) { this.securityRegistry = registry; }
void setContributorRegistry(OpenApiContributorRegistry registry) { this.contributorRegistry = registry; }
public void addOperation(Route route, ApiOperation op, Class<?> handlerClass) {
String path = normalizePath(route.path());
@@ -90,8 +90,8 @@ public final class OpenApiBuilder {
info.put("version", version);
if (!description.isEmpty()) info.put("description", description);
List<OpenApiSecurityContributor> contributors = securityRegistry != null
? securityRegistry.contributors() : List.of();
List<OpenApiContributor> contributors = contributorRegistry != null
? contributorRegistry.contributors() : List.of();
Map<String, Object> renderedPaths = new LinkedHashMap<>();
for (var pathEntry : paths.entrySet()) {
@@ -103,8 +103,7 @@ public final class OpenApiBuilder {
Map<String, Object> op = new LinkedHashMap<>(original);
Class<?> handler = handlers.get(methodEntry.getKey());
if (handler != null && !contributors.isEmpty()) {
List<Map<String, List<String>>> security = buildOperationSecurity(contributors, handler);
if (!security.isEmpty()) op.put("security", security);
applyContributorOperation(op, handler, contributors);
}
renderedPathItem.put(methodEntry.getKey(), op);
}
@@ -119,11 +118,7 @@ public final class OpenApiBuilder {
Map<String, Object> components = new LinkedHashMap<>();
Map<String, Object> renderedSchemas = schemas.render();
if (!renderedSchemas.isEmpty()) components.put("schemas", renderedSchemas);
if (!contributors.isEmpty()) {
Map<String, Object> securitySchemes = new LinkedHashMap<>();
for (OpenApiSecurityContributor c : contributors) securitySchemes.put(c.schemeName(), c.schemeDefinition());
components.put("securitySchemes", securitySchemes);
}
if (!contributors.isEmpty()) applyContributorComponents(components, contributors);
if (!components.isEmpty()) spec.put("components", components);
cachedSpec = spec;
@@ -181,16 +176,12 @@ public final class OpenApiBuilder {
responseByCode.put(code, buildAnnotatedResponse(code, ann, cls));
}
for (OpenApiSecurityContributor c : securityContributors()) {
for (var auto : c.autoResponsesFor(cls).entrySet()) {
responseByCode.putIfAbsent(auto.getKey(), Map.of("description", auto.getValue()));
}
}
if (responseByCode.isEmpty()) {
responseByCode.put(200, Map.of("description", "OK"));
}
applyContributorResponses(responseByCode, cls);
Map<String, Object> responses = new LinkedHashMap<>();
responseByCode.entrySet().stream()
.sorted(Map.Entry.comparingByKey(Comparator.naturalOrder()))
@@ -198,8 +189,97 @@ public final class OpenApiBuilder {
op.put("responses", responses);
}
private List<OpenApiSecurityContributor> securityContributors() {
return securityRegistry != null ? securityRegistry.contributors() : List.of();
private List<OpenApiContributor> contributors() {
return contributorRegistry != null ? contributorRegistry.contributors() : List.of();
}
private void applyContributorResponses(Map<Integer, Map<String, Object>> responseByCode, Class<?> handlerClass) {
APIResponse[] manual = handlerClass.getAnnotationsByType(APIResponse.class);
Set<Integer> manualStatusCodes = new HashSet<>();
for (APIResponse ann : manual) {
manualStatusCodes.add(parseStatus(ann.responseCode()));
}
for (OpenApiContributor contributor : contributors()) {
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
if (contribution == null) continue;
for (Map.Entry<Integer, OpenApiResponseContribution> entry : contribution.responses().entrySet()) {
int status = entry.getKey();
OpenApiResponseContribution responseContribution = entry.getValue();
if (responseContribution == null) continue;
Map<String, Object> response = responseByCode.computeIfAbsent(status, __ -> new LinkedHashMap<>());
mergeContributorResponse(response, responseContribution, status, manualStatusCodes);
}
OpenApiResponseContribution allResponses = contribution.allResponses();
if (allResponses != null) {
for (Map.Entry<Integer, Map<String, Object>> entry : responseByCode.entrySet()) {
mergeContributorResponse(entry.getValue(), allResponses, entry.getKey(), manualStatusCodes);
}
}
}
}
private static void mergeContributorResponse(Map<String, Object> response,
OpenApiResponseContribution contribution,
int status,
Set<Integer> manualStatusCodes) {
String desc = contribution.description();
if (!manualStatusCodes.contains(status) && desc != null && !desc.isBlank()) {
response.put("description", desc);
}
Map<String, Map<String, Object>> headerContributions = contribution.headers();
if (!headerContributions.isEmpty()) {
@SuppressWarnings("unchecked")
Map<String, Object> headers = (Map<String, Object>) response.computeIfAbsent("headers", __ -> new LinkedHashMap<>());
for (Map.Entry<String, Map<String, Object>> h : headerContributions.entrySet()) {
headers.put(h.getKey(), new LinkedHashMap<>(h.getValue()));
}
}
if (!response.containsKey("description")) {
response.put("description", defaultDescription(status));
}
}
private static void applyContributorComponents(Map<String, Object> components, List<OpenApiContributor> contributors) {
for (OpenApiContributor contributor : contributors) {
Map<String, Object> c = contributor.componentContributions();
if (c == null || c.isEmpty()) continue;
deepMergeLastWins(components, c);
}
}
@SuppressWarnings("unchecked")
private static void deepMergeLastWins(Map<String, Object> target, Map<String, Object> incoming) {
for (Map.Entry<String, Object> e : incoming.entrySet()) {
Object existing = target.get(e.getKey());
Object value = e.getValue();
if (existing instanceof Map<?, ?> em && value instanceof Map<?, ?> vm) {
Map<String, Object> merged = new LinkedHashMap<>((Map<String, Object>) em);
deepMergeLastWins(merged, (Map<String, Object>) vm);
target.put(e.getKey(), merged);
} else {
target.put(e.getKey(), value);
}
}
}
private void applyContributorOperation(Map<String, Object> op, Class<?> handlerClass, List<OpenApiContributor> contributors) {
for (OpenApiContributor contributor : contributors) {
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
if (contribution == null || contribution.isEmpty()) continue;
if (!contribution.security().isEmpty()) {
@SuppressWarnings("unchecked")
List<Map<String, List<String>>> security = (List<Map<String, List<String>>>) op
.computeIfAbsent("security", __ -> new ArrayList<>());
security.addAll(contribution.security());
}
}
}
private Map<String, Object> buildAnnotatedResponse(int code, APIResponse ann, Class<?> handlerClass) {
@@ -272,16 +352,6 @@ public final class OpenApiBuilder {
return reason == null ? "" : reason;
}
private static List<Map<String, List<String>>> buildOperationSecurity(List<OpenApiSecurityContributor> contributors,
Class<?> handlerClass) {
List<Map<String, List<String>>> security = new ArrayList<>();
for (OpenApiSecurityContributor c : contributors) {
List<String> scopes = c.requiredFor(handlerClass);
if (scopes != null) security.add(Map.of(c.schemeName(), scopes));
}
return security;
}
private static Class<?> rawType(Type type) {
if (type instanceof Class<?> c) return c;
if (type instanceof ParameterizedType p && p.getRawType() instanceof Class<?> c) return c;
@@ -0,0 +1,14 @@
package dev.relism.ext.openapi;
import java.util.Map;
public interface OpenApiContributor {
default Map<String, Object> componentContributions() {
return Map.of();
}
default OpenApiOperationContribution operationFor(Class<?> handlerClass) {
return OpenApiOperationContribution.empty();
}
}
@@ -0,0 +1,17 @@
package dev.relism.ext.openapi;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
public final class OpenApiContributorRegistry {
private final List<OpenApiContributor> contributors = new CopyOnWriteArrayList<>();
public void add(OpenApiContributor contributor) {
contributors.add(contributor);
}
public List<OpenApiContributor> contributors() {
return Collections.unmodifiableList(contributors);
}
}
@@ -65,12 +65,12 @@ public class OpenApiExtension implements FlashExtension {
@Override
public void provide(FlashContext ctx) {
OpenApiBuilder builder = new OpenApiBuilder().title(title).version(version).description(description);
OpenApiSecurityRegistry secRegistry = new OpenApiSecurityRegistry();
OpenApiBuilder builder = new OpenApiBuilder().title(title).version(version).description(description);
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiSecurityRegistry.class, secRegistry);
ctx.provide(OpenApiBuilder.class, builder);
builder.setSecurityRegistry(secRegistry);
ctx.provide(OpenApiContributorRegistry.class, registry);
ctx.provide(OpenApiBuilder.class, builder);
builder.setContributorRegistry(registry);
// Collect operation metadata from final compiled routes.
// This guarantees full runtime paths (namespaces/prefixes/rewrites) in the spec.
@@ -0,0 +1,71 @@
package dev.relism.ext.openapi;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class OpenApiOperationContribution {
private static final OpenApiOperationContribution EMPTY = new OpenApiOperationContribution(List.of(), Map.of(), null);
private final List<Map<String, List<String>>> security;
private final Map<Integer, OpenApiResponseContribution> responses;
private final OpenApiResponseContribution allResponses;
private OpenApiOperationContribution(List<Map<String, List<String>>> security,
Map<Integer, OpenApiResponseContribution> responses,
OpenApiResponseContribution allResponses) {
this.security = List.copyOf(security);
this.responses = Map.copyOf(responses);
this.allResponses = allResponses;
}
public List<Map<String, List<String>>> security() {
return security;
}
public Map<Integer, OpenApiResponseContribution> responses() {
return responses;
}
public OpenApiResponseContribution allResponses() {
return allResponses;
}
public boolean isEmpty() {
return security.isEmpty() && responses.isEmpty() && allResponses == null;
}
public static OpenApiOperationContribution empty() {
return EMPTY;
}
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private final List<Map<String, List<String>>> security = new ArrayList<>();
private final Map<Integer, OpenApiResponseContribution> responses = new LinkedHashMap<>();
private OpenApiResponseContribution allResponses;
public Builder security(String schemeName, List<String> scopes) {
security.add(Map.of(schemeName, List.copyOf(scopes)));
return this;
}
public Builder response(int statusCode, OpenApiResponseContribution response) {
responses.put(statusCode, response);
return this;
}
public Builder allResponses(OpenApiResponseContribution response) {
this.allResponses = response;
return this;
}
public OpenApiOperationContribution build() {
return new OpenApiOperationContribution(security, responses, allResponses);
}
}
}
@@ -0,0 +1,49 @@
package dev.relism.ext.openapi;
import java.util.LinkedHashMap;
import java.util.Map;
public final class OpenApiResponseContribution {
private final String description;
private final Map<String, Map<String, Object>> headers;
private OpenApiResponseContribution(String description, Map<String, Map<String, Object>> headers) {
this.description = description;
this.headers = Map.copyOf(headers);
}
public String description() {
return description;
}
public Map<String, Map<String, Object>> headers() {
return headers;
}
public static Builder builder() {
return new Builder();
}
public static OpenApiResponseContribution of(String description) {
return builder().description(description).build();
}
public static final class Builder {
private String description;
private final Map<String, Map<String, Object>> headers = new LinkedHashMap<>();
public Builder description(String description) {
this.description = description;
return this;
}
public Builder header(String name, Map<String, Object> headerObject) {
headers.put(name, Map.copyOf(headerObject));
return this;
}
public OpenApiResponseContribution build() {
return new OpenApiResponseContribution(description, headers);
}
}
}
@@ -1,57 +0,0 @@
package dev.relism.ext.openapi;
import java.util.List;
import java.util.Map;
/**
* Pluggable security scheme contributor for the OpenAPI spec.
*
* <p>Extensions that enforce authentication (e.g. {@code flash-ext-oidc}) implement
* this interface and register an instance into {@link OpenApiSecurityRegistry} via the
* {@link dev.relism.extension.FlashContext}. {@link OpenApiExtension} picks it up
* at spec-generation time — no coupling between the two extensions at install time.
*
* <p>Multi-tenant: multiple contributors may coexist. For handlers secured by
* {@code @Authenticated}/{@code @RolesAllowed}, each matching contributor adds its
* own entry to the operation's {@code security} array (OpenAPI OR semantics).
*/
public interface OpenApiSecurityContributor {
/**
* Unique scheme name used as a key in {@code components.securitySchemes}
* and referenced from each operation's {@code security} array.
*/
String schemeName();
/**
* The OpenAPI security scheme definition object placed under
* {@code components.securitySchemes.<schemeName>}.
*
* <p>Example for OIDC:
* <pre>{@code
* Map.of("type", "openIdConnect",
* "openIdConnectUrl", "https://idp.example.com/.well-known/openid-configuration")
* }</pre>
*/
Map<String, Object> schemeDefinition();
/**
* Returns the scopes/roles required for the given handler class under this scheme,
* or {@code null} if this contributor does not secure the handler.
*
* <ul>
* <li>{@code null} — handler is not secured by this contributor (skip)</li>
* <li>empty list — handler requires authentication, no specific scopes</li>
* <li>non-empty list — handler requires these scopes/roles</li>
* </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();
}
}
@@ -1,31 +0,0 @@
package dev.relism.ext.openapi;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* Mutable registry of {@link OpenApiSecurityContributor}s.
*
* <p>Created and provided to the {@link dev.relism.extension.FlashContext} by
* {@link OpenApiExtension} at install time. Other extensions (e.g. {@code flash-ext-oidc})
* retrieve it via {@code ctx.find(OpenApiSecurityRegistry.class)} and register their
* contributor — the OpenAPI extension then picks it up lazily at spec-generation time.
*
* <p>Thread-safe: {@link CopyOnWriteArrayList} allows concurrent reads during spec
* generation without blocking registration.
*/
public final class OpenApiSecurityRegistry {
private final List<OpenApiSecurityContributor> contributors = new CopyOnWriteArrayList<>();
/** Registers a contributor. Safe to call concurrently. */
public void add(OpenApiSecurityContributor contributor) {
contributors.add(contributor);
}
/** Returns an unmodifiable snapshot of all registered contributors. */
public List<OpenApiSecurityContributor> contributors() {
return Collections.unmodifiableList(contributors);
}
}
@@ -88,6 +88,16 @@ class OpenApiBuilderTest {
}
}
@GET("/merge")
@ApiOperation(summary = "Merge")
@APIResponse(responseCode = "200", description = "Manual 200")
static class MergeHandler extends dev.relism.models.RequestHandler {
@Override
public Object handle(dev.relism.models.Request request, dev.relism.models.Response response) {
return null;
}
}
@Schema(name = "UserDTO", title = "User model", description = "DTO", deprecated = true)
@JsonIgnoreProperties({"ignoredByType"})
static class UserDto {
@@ -177,16 +187,23 @@ class OpenApiBuilderTest {
@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");
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
registry.add(new OpenApiContributor() {
@Override
public Map<String, Object> componentContributions() {
return Map.of("securitySchemes", Map.of("oidc", Map.of("type", "oauth2")));
}
@Override
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
return OpenApiOperationContribution.builder()
.security("oidc", List.of())
.response(401, OpenApiResponseContribution.of("Authentication required"))
.response(403, OpenApiResponseContribution.of("Auto forbidden"))
.build();
}
});
b.setSecurityRegistry(registry);
b.setContributorRegistry(registry);
b.addOperation(OpenApiBuilder.routeOf(SecureHandler.class), SecureHandler.class.getAnnotation(ApiOperation.class), SecureHandler.class);
Map<String, Object> spec = b.build();
@@ -250,6 +267,77 @@ class OpenApiBuilderTest {
assertEquals("#/components/schemas/UserDTO", additionalProperties.get("$ref"));
}
@Test
void contributor_response_merge_appliesAllResponses_andManualDescriptionWins() {
OpenApiBuilder b = new OpenApiBuilder();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
registry.add(new OpenApiContributor() {
@Override
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
return OpenApiOperationContribution.builder()
.allResponses(OpenApiResponseContribution.builder()
.header("X-Trace", Map.of("schema", Map.of("type", "string")))
.build())
.response(200, OpenApiResponseContribution.of("Auto 200"))
.response(429, OpenApiResponseContribution.of("Auto 429"))
.build();
}
});
b.setContributorRegistry(registry);
b.addOperation(OpenApiBuilder.routeOf(MergeHandler.class), MergeHandler.class.getAnnotation(ApiOperation.class), MergeHandler.class);
Map<String, Object> spec = b.build();
Map<String, Object> get = getOperation(spec, "/merge", "get");
Map<String, Object> responses = cast(get.get("responses"));
Map<String, Object> resp200 = cast(responses.get("200"));
assertEquals("Manual 200", resp200.get("description"));
Map<String, Object> headers200 = cast(resp200.get("headers"));
assertTrue(headers200.containsKey("X-Trace"));
Map<String, Object> resp429 = cast(responses.get("429"));
assertEquals("Auto 429", resp429.get("description"));
Map<String, Object> headers429 = cast(resp429.get("headers"));
assertTrue(headers429.containsKey("X-Trace"));
}
@Test
void contributor_lastWins_forResponseHeaderCollisions() {
OpenApiBuilder b = new OpenApiBuilder();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
registry.add(new OpenApiContributor() {
@Override
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
return OpenApiOperationContribution.builder()
.response(200, OpenApiResponseContribution.builder()
.header("X-RateLimit-Limit", Map.of("description", "old", "schema", Map.of("type", "integer")))
.build())
.build();
}
});
registry.add(new OpenApiContributor() {
@Override
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
return OpenApiOperationContribution.builder()
.response(200, OpenApiResponseContribution.builder()
.header("X-RateLimit-Limit", Map.of("description", "new", "schema", Map.of("type", "integer")))
.build())
.build();
}
});
b.setContributorRegistry(registry);
b.addOperation(OpenApiBuilder.routeOf(MergeHandler.class), MergeHandler.class.getAnnotation(ApiOperation.class), MergeHandler.class);
Map<String, Object> spec = b.build();
Map<String, Object> get = getOperation(spec, "/merge", "get");
Map<String, Object> responses = cast(get.get("responses"));
Map<String, Object> resp200 = cast(responses.get("200"));
Map<String, Object> headers = cast(resp200.get("headers"));
Map<String, Object> header = cast(headers.get("X-RateLimit-Limit"));
assertEquals("new", header.get("description"));
}
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));