Swagger UI, Redoc or Scalar, each configured with its own options under the names its own documentation gives them, or no page at all. What a UI does not name still passes through, so a bundle's whole option set stays reachable without this extension tracking it. The page is rendered once at boot and the document is encoded once per revision, so a request to any of the three routes hands out bytes rather than building them: the spec used to be serialized again on every single request. /openapi/swagger becomes /openapi/docs, because the path names what is served and not which bundle happens to serve it. That page also named a preset that lives in a bundle it never loaded, and BaseLayout never needed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.1 KiB
flash-ext-openapi
OpenAPI 3.0.3 generation and a documentation page, built from what the handlers already say about themselves.
What it provides
| Route | Description |
|---|---|
GET /openapi.json |
OpenAPI spec JSON |
GET /openapi.yaml |
OpenAPI spec YAML |
GET /openapi/docs |
The documentation page: Swagger UI, Redoc or Scalar |
FlashApp.create(8080)
.install(new JacksonExtension())
.install(new OpenApiExtension("/openapi", "My API", "1.0.0"))
.scan("com.acme.handlers")
.startAndBlock();
Both documents are encoded once, on the first request, and served as those same bytes afterwards.
The documentation page
Swagger UI by default; Redoc and Scalar are one call away, and Ui.none() serves no page at all.
Each is loaded from jsDelivr at a pinned version, and configured with its own options:
new OpenApiExtension("/openapi", "My API", "1.0.0")
.ui(Ui.scalar().theme(Ui.Scalar.Theme.DEEP_SPACE).layout(Ui.Scalar.Layout.CLASSIC)
.darkMode(true).hideModels(true))
| Named options | |
|---|---|
Ui.swagger() |
docExpansion, modelsExpandDepth, deepLinking, filter, tryItOut, persistAuthorization, syntaxTheme, sortAlphabetically |
Ui.redoc() |
hideDownloadButton, disableSearch, requiredPropsFirst, sortPropsAlphabetically, jsonSampleExpandLevel, hideSchemaTitles, pathInMiddlePanel, hideHostname, nativeScrollbars, menuToggle |
Ui.scalar() |
theme, layout, darkMode, hideDarkModeToggle, hideModels, hideSearch, hideTestRequestButton, hideClientButton, showSidebar, defaultOpenAllTags, sortOperationsBy |
The names are the bundles' own, so their documentation is the reference. What is not named here
still gets through — option("theme", Map.of("colors", …)) passes Redoc a whole theme object.
Two more apply to all three: customCss(…), appended to the page, and cdn(…), which points the
bundle at a mirror, a proxy, or files the application serves itself.
The page is rendered once, at boot.
What you get without writing anything
Every class-based route is documented, annotated or not. Read off the code:
- path parameters, from
/{id}in the route - the request body, from the handler's own body type (see below)
- the response schema, from what
handlereturns — an object, aList<T>, aMap<String, T> - the media types:
@Consumesfor what it reads,@Producesfor what it answers, which are two different questions — taking a JSON body does not make the answer JSON - error responses, in the one shape Flash answers failures with:
{"error": "...", "status": 404} - security and rate limiting, from the extensions that enforce them
Annotations add what the code cannot say: prose, extra statuses, examples. They never repeat it.
Leaving a route out
@GET("/healthz")
@Undocumented
public final class Health extends RequestHandler { ... }
Every route is documented, so a document never lies by omission. @Undocumented says a route is
not part of the API — a health check, an internal callback, something on its way out. On a base
class it leaves out every handler written against it.
Request bodies
A handler that extends BodyHandler — JsonHandler and XmlHandler, and anything else that
reads a format — declares its body type in its signature, and that is the whole documentation:
@POST("/users")
public final class CreateUser extends JsonHandler<NewUser> {
@Override protected Object handle(Request req, Response res, NewUser body) {
return users.create(body);
}
}
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NewUser' }
The media type of the body comes from @Consumes on the base class, so an XML handler documents
itself as XML without a word from the route.
For a handler that reads the body by hand, or to describe it as something else, declare it:
@PUT("/users")
@RequestBody(value = User.class, array = true, description = "Users to store")
public final class ReplaceUsers extends RequestHandler { ... }
Operations
@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"})
public final class GetUser extends RequestHandler { ... }
@ApiOperation is optional: without it the route is still in the document, with no summary.
Responses
The success response is inferred. Declare one only to say more:
@APIResponse(responseCode = "200", description = "User found",
content = @Content(schema = UserDto.class, example = "{\"id\":\"usr-1\"}"))
@APIResponse(responseCode = "409", description = "That email is taken")
@APIResponse(responseCode = "204", description = "Deleted")
content.schemaomitted on a 2xx: the handler's return type.- Any 4xx or 5xx without an explicit schema: Flash's error object, referenced from
components— in JSON, which is what Flash answers a failure with whatever the route produces. content.array = truewraps whichever schema was chosen.- No schema and nothing to infer one from: a response with no body, which is what a 204 is.
The media type of every answer is the handler's @Produces, JSON when nothing says otherwise. It
is not on @Content: one handler answers in one format, and a status code does not change that.
A response several operations share is written once. Identical answers — the 401 of every
guarded route, the 429 of every limited one — become components.responses entries referenced by
$ref, instead of being repeated on every path.
DTO schemas
@Schema(name = "User", title = "User DTO", description = "Public user")
public record UserDto(
@SchemaProperty(title = "ID", example = "USR-100") String id,
@SchemaProperty(hidden = true) String internalDebug) {}
Each type is described once under components.schemas and referenced everywhere it appears.
Field-level exclusion: @Schema(hidden = true), @SchemaProperty(hidden = true), @JsonIgnore,
@JsonIgnoreProperties, transient, static. jakarta.validation constraints (@NotNull,
@NotBlank, @NotEmpty, @Size, @Min, @Max, @Email, @Pattern) become the schema's own
bounds and required fields, so a rule is written once and documented for free.
A @Pattern that declares a message says it in the description too, after whatever the property
already said: a regex is precise and unreadable, and both belong in the document.
@SchemaProperty(description = "Unique in the project.")
@Pattern(regexp = "[a-z.]+", message = "uses lowercase letters and dots")
String key
key:
type: string
description: Unique in the project. Uses lowercase letters and dots.
pattern: '[a-z.]+'
No other constraint does this: required, maxLength and format: email are already readable,
and repeating them as prose would be noise.
Contributor API
OpenAPI is extension-agnostic. Other extensions contribute through OpenApiContributor, held in
OpenApiContributorRegistry:
componentsfragments (merged last-wins)- operation
securityrequirements (additive) - operation
responsesand responseheaders(additive)
Manual @APIResponse description always wins over a contributor's for the same status.
Security interop
With flash-ext-security-core installed, every registered mechanism's scheme lands under
components.securitySchemes, and every operation carrying a security annotation lists them as
security alternatives with automatic 401 and — for roles or scopes — 403 responses.
Limiter interop
When flash-ext-limiter is installed, handlers with @Limit document X-RateLimit-Limit,
X-RateLimit-Remaining, X-RateLimit-Reset, and a 429 with Retry-After.
Notes
- Operations come from the final boot-time routes, so documented paths match runtime paths, namespaces, prefixes and rewrites included.
- Lambda routes are not documented: there is no class to read.
- Responses are sorted by status code; the document is rebuilt only when a route is added.