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
@@ -92,6 +92,9 @@ public class PriceHandler extends RequestHandler { ... }
Each annotation is processed by its own processor; Flash collects all middleware and
composes them in processor registration order.
When `flash-ext-openapi` is installed, `@Limit` also contributes OpenAPI response
headers (`X-RateLimit-*`) and `Retry-After` on `429` automatically.
```java
@Route(method = HttpMethod.DELETE, path = "/admin/users/{id}")
@Limit(key = "auth_user", requests = 5, window = 1, windowUnit = TimeUnit.MINUTES)
@@ -117,6 +117,12 @@ and **instead of** calling it on rejected requests. This means:
## Integration with Swagger UI (flash-ext-openapi)
Rate-limit headers are not currently injected into the OpenAPI spec. If you want to
document them, add them manually via `@ApiOperation` on the handler class using the
response headers section of the OpenAPI spec.
When `flash-ext-openapi` is installed, handlers annotated with `@Limit` automatically
contribute rate-limit response headers to generated OpenAPI responses:
- `X-RateLimit-Limit`
- `X-RateLimit-Remaining`
- `X-RateLimit-Reset`
- `Retry-After` on `429`
If `429` is not manually declared, OpenAPI auto-adds `429 Too Many Requests`.
@@ -17,6 +17,11 @@
<groupId>dev.relism</groupId>
<artifactId>flash</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-openapi</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
@@ -1,13 +1,18 @@
package dev.relism.ext.limiter;
import dev.relism.ext.openapi.OpenApiContributorRegistry;
import dev.relism.ext.openapi.OpenApiOperationContribution;
import dev.relism.ext.openapi.OpenApiResponseContribution;
import dev.relism.extension.ExtensionPhase;
import dev.relism.extension.FlashContext;
import dev.relism.extension.FlashExtension;
import dev.relism.extension.FlashRegistrar;
import dev.relism.http.HttpStatus;
import dev.relism.routing.Middleware;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
/**
* Rate-limiting extension for Flash.
@@ -87,6 +92,15 @@ public final class LimiterExtension implements FlashExtension {
});
}
@Override
public void routes(FlashRegistrar<?> app, FlashContext ctx) {
try {
OpenApiIntegration.register(ctx);
} catch (NoClassDefFoundError ignored) {
// flash-ext-openapi not available — OpenAPI integration disabled
}
}
// ── Package-private helper — shared with Guard ────────────────────────────
/**
@@ -126,4 +140,57 @@ public final class LimiterExtension implements FlashExtension {
return next.handle(req, res);
};
}
private static final class OpenApiIntegration {
private static final Map<String, Object> INTEGER_SCHEMA = Map.of("type", "integer");
private static final Map<String, Object> LIMIT_HEADER = Map.of(
"description", "Maximum requests allowed in current window",
"schema", INTEGER_SCHEMA
);
private static final Map<String, Object> REMAINING_HEADER = Map.of(
"description", "Requests remaining in current window",
"schema", INTEGER_SCHEMA
);
private static final Map<String, Object> RESET_HEADER = Map.of(
"description", "Unix epoch seconds when quota resets or next token arrives",
"schema", INTEGER_SCHEMA
);
private static final Map<String, Object> RETRY_AFTER_HEADER = Map.of(
"description", "Seconds to wait before retrying",
"schema", INTEGER_SCHEMA
);
static void register(FlashContext ctx) {
ctx.find(OpenApiContributorRegistry.class)
.ifPresent(registry -> registry.add(new dev.relism.ext.openapi.OpenApiContributor() {
@Override
public dev.relism.ext.openapi.OpenApiOperationContribution operationFor(Class<?> handlerClass) {
if (handlerClass.getAnnotation(Limit.class) == null) {
return OpenApiOperationContribution.builder().build();
}
OpenApiResponseContribution common =
OpenApiResponseContribution.builder()
.header("X-RateLimit-Limit", LIMIT_HEADER)
.header("X-RateLimit-Remaining", REMAINING_HEADER)
.header("X-RateLimit-Reset", RESET_HEADER)
.build();
OpenApiResponseContribution tooManyRequests =
OpenApiResponseContribution.builder()
.description("Too Many Requests")
.header("X-RateLimit-Limit", LIMIT_HEADER)
.header("X-RateLimit-Remaining", REMAINING_HEADER)
.header("X-RateLimit-Reset", RESET_HEADER)
.header("Retry-After", RETRY_AFTER_HEADER)
.build();
return OpenApiOperationContribution.builder()
.allResponses(common)
.response(429, tooManyRequests)
.build();
}
}));
}
}
}
@@ -0,0 +1,84 @@
package dev.relism.ext.limiter;
import dev.relism.ext.openapi.OpenApiContributor;
import dev.relism.ext.openapi.OpenApiContributorRegistry;
import dev.relism.ext.openapi.OpenApiOperationContribution;
import dev.relism.ext.openapi.OpenApiResponseContribution;
import dev.relism.extension.FlashContext;
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 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 LimiterOpenApiInteropTest {
@GET("/limited")
@Limit(requests = 10, window = 1)
static class LimitedHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@GET("/plain")
static class PlainHandler extends RequestHandler {
@Override
public Object handle(Request request, Response response) {
return null;
}
}
@Test
void registersContributor_whenOpenApiRegistryExists() {
FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry);
new LimiterExtension().routes(null, ctx);
assertEquals(1, registry.contributors().size());
}
@Test
void limitedHandler_contributesHeadersAnd429() {
FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry);
new LimiterExtension().routes(null, ctx);
OpenApiContributor contributor = registry.contributors().getFirst();
OpenApiOperationContribution operation = contributor.operationFor(LimitedHandler.class);
OpenApiResponseContribution all = operation.allResponses();
assertNotNull(all);
assertTrue(all.headers().containsKey("X-RateLimit-Limit"));
assertTrue(all.headers().containsKey("X-RateLimit-Remaining"));
assertTrue(all.headers().containsKey("X-RateLimit-Reset"));
OpenApiResponseContribution tooMany = operation.responses().get(429);
assertNotNull(tooMany);
assertEquals("Too Many Requests", tooMany.description());
assertTrue(tooMany.headers().containsKey("Retry-After"));
}
@Test
void plainHandler_hasNoContribution() {
FlashContext ctx = new FlashContext();
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
ctx.provide(OpenApiContributorRegistry.class, registry);
new LimiterExtension().routes(null, ctx);
OpenApiContributor contributor = registry.contributors().getFirst();
OpenApiOperationContribution operation = contributor.operationFor(PlainHandler.class);
assertTrue(operation.isEmpty());
assertFalse(operation.responses().containsKey(429));
}
}