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>
This commit is contained in:
Zakaria El Orche
2026-09-24 12:03:48 +00:00
co-authored by Claude Opus 5
parent 5164f8c41f
commit 439588c19f
8 changed files with 575 additions and 68 deletions
+29 -3
View File
@@ -1,7 +1,7 @@
# flash-ext-openapi
OpenAPI 3.0.3 generation and Swagger UI, built from what the handlers already say about
themselves.
OpenAPI 3.0.3 generation and a documentation page, built from what the handlers already say
about themselves.
## What it provides
@@ -9,7 +9,7 @@ themselves.
|---|---|
| `GET /openapi.json` | OpenAPI spec JSON |
| `GET /openapi.yaml` | OpenAPI spec YAML |
| `GET /openapi/swagger` | Swagger UI |
| `GET /openapi/docs` | The documentation page: Swagger UI, Redoc or Scalar |
```java
FlashApp.create(8080)
@@ -19,6 +19,32 @@ FlashApp.create(8080)
.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:
```java
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: