Files
Flash5/flash-extensions/flash-ext-openapi
Zakaria El OrcheandClaude Opus 5 439588c19f feat(ext-openapi): pick the page that reads the document
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>
2026-09-24 12:03:48 +00:00
..

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 handle returns — an object, a List<T>, a Map<String, T>
  • the media types: @Consumes for what it reads, @Produces for 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 BodyHandlerJsonHandler 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.schema omitted 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 = true wraps 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:

  • components fragments (merged last-wins)
  • operation security requirements (additive)
  • operation responses and response headers (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.