Merge pull request 'feat(ext-openapi): pick the page that reads the document' (#26) from feat/openapi/documentation-page into master
Publish Maven packages / publish (push) Successful in 2m36s

Carries #25 with it: the documentation page is built on top of that commit, so one merge lands both and the registry gets one build.
This commit was merged in pull request #26.
This commit is contained in:
2026-09-24 12:06:46 +00:00
10 changed files with 651 additions and 69 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
| `flash-extensions/flash-ext-jackson-core` | What every Jackson format shares: the codec, the body handler, the constraints a body is checked against |
| `flash-extensions/flash-ext-jackson-json` | JSON bodies and responses |
| `flash-extensions/flash-ext-jackson-xml` | XML bodies and responses |
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec, read by Swagger UI, Redoc or Scalar |
| `flash-extensions/flash-ext-security-core` | Security: authentication chain, annotations, sessions, OpenAPI |
| `flash-extensions/flash-ext-security-oidc` | OpenID Connect: bearer tokens, code flow + PKCE |
| `flash-extensions/flash-ext-security-apikey` | API keys |
@@ -115,7 +115,7 @@ and **instead of** calling it on rejected requests. This means:
avoid this by not setting these headers manually).
- On 429, the handler body is never executed — no side effects occur.
## Integration with Swagger UI (flash-ext-openapi)
## Integration with OpenAPI (flash-ext-openapi)
When `flash-ext-openapi` is installed, handlers annotated with `@Limit` automatically
contribute rate-limit response headers to generated OpenAPI responses:
+48 -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:
@@ -128,6 +154,25 @@ Field-level exclusion: `@Schema(hidden = true)`, `@SchemaProperty(hidden = true)
`@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.
```java
@SchemaProperty(description = "Unique in the project.")
@Pattern(regexp = "[a-z.]+", message = "uses lowercase letters and dots")
String key
```
```yaml
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
@@ -64,7 +64,10 @@ final class ConstraintHints {
if (field.isAnnotationPresent(Email.class)) property.putIfAbsent("format", "email");
Pattern pattern = field.getAnnotation(Pattern.class);
if (pattern != null) property.putIfAbsent("pattern", pattern.regexp());
if (pattern != null) {
property.putIfAbsent("pattern", pattern.regexp());
describe(property, pattern.message());
}
if (field.isAnnotationPresent(NotBlank.class) && isString) property.putIfAbsent("minLength", 1);
if (field.isAnnotationPresent(NotEmpty.class)) {
@@ -78,6 +81,25 @@ final class ConstraintHints {
}
/** Constraint annotations this bridge understands, for documentation and tests. */
/**
* Says in words what a regex says in symbols.
*
* <p>Only {@code @Pattern} needs this: every other constraint has a keyword a reader
* understands — {@code required}, {@code maxLength}, {@code format: email} — and repeating it
* as prose would be noise. A regex has none, so the rule's own message goes in the
* description beside it, after whatever the property already said.
*/
private static void describe(Map<String, Object> property, String message) {
if (message == null || message.isBlank() || message.startsWith("{")) return; // jakarta's default is a bundle key
String sentence = Character.toUpperCase(message.charAt(0)) + message.substring(1);
if (!sentence.endsWith(".")) sentence += ".";
Object said = property.get("description");
if (said == null) property.put("description", sentence);
else if (!said.toString().contains(sentence)) property.put("description", said + " " + sentence);
}
static List<String> supported() {
return List.of("@NotNull", "@NotBlank", "@NotEmpty", "@Size", "@Min", "@Max", "@Email", "@Pattern");
}
@@ -65,6 +65,9 @@ public final class OpenApiBuilder {
public OpenApiBuilder description(String description) { this.description = description; return this; }
void setContributorRegistry(OpenApiContributorRegistry registry) { this.contributorRegistry = registry; }
/** Bumped by every operation added, so a rendering of this document knows whether it is still current. */
int revision() { return revision; }
/**
* Documents one route. {@code op} is optional: a route without it is still an operation.
* A handler marked {@link Undocumented} is left out entirely.
@@ -1,5 +1,6 @@
package dev.relism.flash.ext.openapi;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
@@ -11,18 +12,21 @@ import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.routing.Route;
import java.nio.charset.StandardCharsets;
/**
* Generates and serves an OpenAPI 3.0 spec and Swagger UI under a configurable base path.
* Generates and serves an OpenAPI 3.0 spec, and the documentation page that reads it, under a
* configurable base path.
*
* <p>Given {@code basePath = "/openapi"} (the default), three routes are registered:
* <ul>
* <li>{@code GET /openapi.json} — OpenAPI 3.0 spec as JSON</li>
* <li>{@code GET /openapi.yaml} — OpenAPI 3.0 spec as YAML</li>
* <li>{@code GET /openapi/swagger} — Swagger UI pointing at {@code /openapi.json}</li>
* <li>{@code GET /openapi.json} — OpenAPI 3.0 spec as JSON</li>
* <li>{@code GET /openapi.yaml} — the same document as YAML</li>
* <li>{@code GET /openapi/docs} — the {@link Ui}, Swagger UI unless another is chosen</li>
* </ul>
*
* <p>If {@code flash-ext-jackson} is installed, this extension reuses its
* {@link ObjectMapper}. Otherwise it uses a local default mapper.
* <p>If {@code flash-ext-jackson} is installed, this extension reuses its {@link ObjectMapper}.
* Otherwise it uses a local default mapper.
*
* <p>Every class-based route is collected at boot, whether or not it is annotated: a path, the
* body its handler takes and what it returns are already in the code. {@link ApiOperation} adds
@@ -31,18 +35,19 @@ import dev.relism.flash.routing.Route;
* <pre>{@code
* FlashApp.create(8080)
* .install(new JacksonExtension())
* .install(new OpenApiExtension("/openapi", "My API", "2.0.0"))
* .install(new OpenApiExtension("/openapi", "My API", "2.0.0").ui(Ui.scalar()))
* .start();
* }</pre>
*/
public class OpenApiExtension implements FlashExtension {
private static final String YAML_CONTENT_TYPE = "application/yaml";
private static final byte[] YAML_CONTENT_TYPE = "application/yaml".getBytes(StandardCharsets.UTF_8);
private final String basePath;
private final String title;
private final String version;
private final String description;
private Ui<?> ui = Ui.swagger();
public OpenApiExtension() {
this("/openapi", "API", "1.0.0", "");
@@ -63,6 +68,12 @@ public class OpenApiExtension implements FlashExtension {
this.description = description;
}
/** Which documentation page to serve, {@link Ui#swagger()} by default; {@link Ui#none()} serves none. */
public OpenApiExtension ui(Ui<?> ui) {
this.ui = ui;
return this;
}
// ── FlashExtension ────────────────────────────────────────────────────────
@Override
@@ -78,46 +89,47 @@ public class OpenApiExtension implements FlashExtension {
// This guarantees full runtime paths (namespaces/prefixes/rewrites) in the spec.
ctx.addRouteListener(event -> addOperationFromEvent(builder, event));
ctx.onReady(() -> {
ObjectMapper jsonMapper = ctx.find(ObjectMapper.class).orElseGet(() -> JsonMapper.builder().build());
YAMLMapper yamlMapper = new YAMLMapper();
OpenApiBuilder resolvedBuilder = ctx.require(OpenApiBuilder.class);
ObjectMapper json = ctx.find(ObjectMapper.class).orElseGet(() -> JsonMapper.builder().build());
OpenApiBuilder spec = ctx.require(OpenApiBuilder.class);
String jsonPath = basePath + ".json";
String jsonPath = basePath + ".json";
String yamlPath = basePath + ".yaml";
String swaggerPath = basePath + "/swagger";
String swaggerHtml = buildSwaggerHtml(jsonPath);
Document asJson = new Document(json, spec);
Document asYaml = new Document(new YAMLMapper(), spec);
app.get(jsonPath, (req, res) -> { res.type(ContentType.JSON).body(asJson.bytes()); return null; });
app.get(basePath + ".yaml", (req, res) -> { res.type(YAML_CONTENT_TYPE).body(asYaml.bytes()); return null; });
app.get(jsonPath, (req, res) -> { res.type(ContentType.JSON); return jsonMapper.writeValueAsString(resolvedBuilder.build()); });
app.get(yamlPath, (req, res) -> { res.type(YAML_CONTENT_TYPE); return yamlMapper.writeValueAsString(resolvedBuilder.build()); });
app.get(swaggerPath, (req, res) -> { res.type(ContentType.TEXT_HTML); return swaggerHtml; });
if (ui == null) return;
byte[] page = ui.page(jsonPath, title, json);
app.get(basePath + "/docs", (req, res) -> { res.type(ContentType.TEXT_HTML).body(page); return null; });
});
}
// ── Swagger UI HTML ───────────────────────────────────────────────────────
/**
* The document encoded once per revision. Routes are collected after the ready callbacks, so
* the first request is what renders it; from then on the same array is handed out, and a
* request allocates nothing. Two first requests at once encode the same bytes twice, which is
* cheaper than the lock that would prevent it; the bytes are written before the revision that
* publishes them, so a reader that sees the revision sees the whole array.
*/
private static final class Document {
private static String buildSwaggerHtml(String specJsonPath) {
return "<!DOCTYPE html>\n" +
"<html lang=\"en\">\n" +
"<head>\n" +
" <meta charset=\"UTF-8\">\n" +
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n" +
" <title>Swagger UI</title>\n" +
" <link rel=\"stylesheet\" href=\"https://unpkg.com/swagger-ui-dist@5/swagger-ui.css\">\n" +
"</head>\n" +
"<body>\n" +
"<div id=\"swagger-ui\"></div>\n" +
"<script src=\"https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js\"></script>\n" +
"<script>\n" +
"SwaggerUIBundle({\n" +
" url: \"" + specJsonPath + "\",\n" +
" dom_id: '#swagger-ui',\n" +
" deepLinking: true,\n" +
" presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],\n" +
" layout: \"BaseLayout\"\n" +
"});\n" +
"</script>\n" +
"</body>\n" +
"</html>";
private final ObjectMapper mapper;
private final OpenApiBuilder spec;
private volatile byte[] encoded;
private volatile int revision = -1;
Document(ObjectMapper mapper, OpenApiBuilder spec) {
this.mapper = mapper;
this.spec = spec;
}
byte[] bytes() throws JsonProcessingException {
if (revision != spec.revision()) {
encoded = mapper.writeValueAsBytes(spec.build());
revision = spec.revision();
}
return encoded;
}
}
/** Every class-based route is an operation; {@link ApiOperation} only adds what the code cannot say. */
@@ -0,0 +1,260 @@
package dev.relism.flash.ext.openapi;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.relism.flash.exceptions.InitializationException;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
/**
* The documentation page served beside the spec: Swagger UI, Redoc or Scalar. Each knows its own
* bundle and its own options; what they share — the page around it, the custom stylesheet, the
* escape hatch for an option this class does not name — lives here.
*
* <pre>{@code
* new OpenApiExtension("/openapi", "My API", "1.0.0")
* .ui(Ui.scalar().theme(Ui.Scalar.Theme.PURPLE).layout(Ui.Scalar.Layout.CLASSIC).darkMode(true));
* }</pre>
*
* <p>Options are the upstream ones, spelled as the upstream documents them, and land in the
* configuration object verbatim. The typed methods cover what is worth naming; anything else goes
* through {@link #option(String, Object)}, including whole nested objects (Redoc's {@code theme}).
*
* <p>The bundle comes from jsDelivr at a pinned version. {@link #cdn(String)} points that
* elsewhere — a mirror, a proxy, or the files served by the application itself.
*
* @param <S> the concrete UI, so every setter chains whatever the order
*/
public abstract class Ui<S extends Ui<S>> {
/** Swagger UI: the one that lets you fire requests from the page. */
public static Swagger swagger() { return new Swagger(); }
/** Redoc: three panels, read-only, the most printable of the three. */
public static Redoc redoc() { return new Redoc(); }
/** Scalar: the modern one, with themes and an API client built in. */
public static Scalar scalar() { return new Scalar(); }
/** No page at all — the spec routes stay. {@code null} is the absence of a UI, and this names it. */
public static Ui<?> none() { return null; }
/** What ends up in the configuration object, in declaration order. */
final Map<String, Object> config = new LinkedHashMap<>();
private String cdn;
private String css = "";
Ui(String cdn) { this.cdn = cdn; }
/** Where the bundle is loaded from, without a trailing slash. Default: jsDelivr, at a pinned version. */
public final S cdn(String cdn) {
this.cdn = cdn.endsWith("/") ? cdn.substring(0, cdn.length() - 1) : cdn;
return self();
}
/** CSS appended to the page, after the bundle's own. */
public final S customCss(String css) {
this.css = css;
return self();
}
/** Any option this class does not name, as its upstream documentation spells it; {@code null} drops it. */
public final S option(String name, Object value) {
if (value == null) config.remove(name); else config.put(name, value);
return self();
}
/**
* The whole page, rendered once at boot and served as bytes from then on.
*
* @param specPath where the JSON document is served, which every one of the three reads from
*/
final byte[] page(String specPath, String title, ObjectMapper json) {
config.put("url", specPath);
return ("<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">"
+ "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
+ "<title>" + title + "</title>" + head()
+ "<style>body{margin:0}" + css + "</style></head><body>"
+ body(configuration(json)) + "</body></html>").getBytes(StandardCharsets.UTF_8);
}
/** An option that Jackson cannot write is one the application passed: a mistake, and a boot-time one. */
private String configuration(ObjectMapper json) {
try {
return json.writeValueAsString(config);
} catch (JsonProcessingException e) {
throw new InitializationException("Cannot write the " + getClass().getSimpleName() + " options as JSON", e);
}
}
/** What the bundle needs in {@code <head>}: a stylesheet, for the one that has one. */
String head() { return ""; }
/** The mount point, the bundle, and the one call that starts it on {@code config}. */
abstract String body(String config);
final String cdn() { return cdn; }
@SuppressWarnings("unchecked")
private S self() { return (S) this; }
/** {@code SOME_NAME} as the wire spells it: {@code some-name} or {@code somename}. */
private static String wire(Enum<?> value, char separator) {
return value.name().toLowerCase(Locale.ROOT).replace('_', separator);
}
// ── Swagger UI ────────────────────────────────────────────────────────────
/** <a href="https://swagger.io/tools/swagger-ui/">Swagger UI</a>, pinned to 5.x. */
public static final class Swagger extends Ui<Swagger> {
/** How much of the document is open when the page loads. */
public enum Expand { LIST, FULL, NONE }
/** The highlighter's palette. */
public enum Syntax { AGATE, ARTA, MONOKAI, NORD, OBSIDIAN, TOMORROW_NIGHT, IDEA }
private Swagger() {
super("https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.0");
config.put("dom_id", "#ui");
config.put("deepLinking", true);
}
/** Tags open, everything open, or nothing. Default: {@link Expand#LIST}. */
public Swagger docExpansion(Expand expand) { return option("docExpansion", wire(expand, '-')); }
/** How deep the models open; {@code -1} hides them. Default: 1. */
public Swagger modelsExpandDepth(int depth) { return option("defaultModelsExpandDepth", depth); }
/** Whether a tag or an operation gets its own URL. Default: on, unlike upstream. */
public Swagger deepLinking(boolean deepLinking) { return option("deepLinking", deepLinking); }
/** The box that filters operations by tag. */
public Swagger filter(boolean filter) { return option("filter", filter); }
/** Whether "Try it out" is already on. */
public Swagger tryItOut(boolean tryItOut) { return option("tryItOutEnabled", tryItOut); }
/** Whether credentials entered in the page survive a reload. */
public Swagger persistAuthorization(boolean persist) { return option("persistAuthorization", persist); }
public Swagger syntaxTheme(Syntax theme) { return option("syntaxHighlight", Map.of("theme", wire(theme, '-'))); }
/** Sorts tags and operations by name rather than by the order the document lists them. */
public Swagger sortAlphabetically(boolean sorted) {
String sorter = sorted ? "alpha" : null;
return option("tagsSorter", sorter).option("operationsSorter", sorter);
}
@Override String head() { return "<link rel=\"stylesheet\" href=\"" + cdn() + "/swagger-ui.css\">"; }
@Override String body(String config) {
return "<div id=\"ui\"></div><script src=\"" + cdn() + "/swagger-ui-bundle.js\"></script>"
+ "<script>SwaggerUIBundle(" + config + ")</script>";
}
}
// ── Redoc ─────────────────────────────────────────────────────────────────
/** <a href="https://github.com/Redocly/redoc">Redoc</a> community edition, pinned to 2.x. */
public static final class Redoc extends Ui<Redoc> {
private Redoc() { super("https://cdn.jsdelivr.net/npm/redoc@2.5.4"); }
public Redoc hideDownloadButton(boolean hide) { return option("hideDownloadButton", hide); }
public Redoc disableSearch(boolean disable) { return option("disableSearch", disable); }
/** Required properties first in every schema, rather than in declaration order. */
public Redoc requiredPropsFirst(boolean first) { return option("requiredPropsFirst", first); }
public Redoc sortPropsAlphabetically(boolean sorted) { return option("sortPropsAlphabetically", sorted); }
/** How deep a JSON sample is expanded; {@code 0} collapses it. Default: 2. */
public Redoc jsonSampleExpandLevel(int level) { return option("jsonSampleExpandLevel", level); }
public Redoc hideSchemaTitles(boolean hide) { return option("hideSchemaTitles", hide); }
/** The path in the middle panel rather than beside the description. */
public Redoc pathInMiddlePanel(boolean middle) { return option("pathInMiddlePanel", middle); }
/** Hides the host the servers declare, leaving the paths. */
public Redoc hideHostname(boolean hide) { return option("hideHostname", hide); }
/** The browser's own scrollbars in the sidebar. */
public Redoc nativeScrollbars(boolean enabled) { return option("nativeScrollbars", enabled); }
/** Whether an open sidebar group can be collapsed again. Default: on. */
public Redoc menuToggle(boolean toggle) { return option("menuToggle", toggle); }
@Override String body(String config) {
return "<div id=\"ui\"></div><script src=\"" + cdn() + "/bundles/redoc.standalone.js\"></script>"
+ "<script>var c=" + config + ";Redoc.init(c.url,c,document.getElementById('ui'))</script>";
}
}
// ── Scalar ────────────────────────────────────────────────────────────────
/** <a href="https://scalar.com/">Scalar</a>'s API reference, pinned to 1.x. */
public static final class Scalar extends Ui<Scalar> {
/** Scalar's own palettes. {@link #NONE} leaves the page unstyled, for {@link #customCss(String)}. */
public enum Theme {
DEFAULT, ALTERNATE, MOON, PURPLE, SOLARIZED, BLUE_PLANET("bluePlanet"), SATURN, KEPLER, MARS,
DEEP_SPACE("deepSpace"), LASERWAVE, NONE;
private final String wire;
Theme() { this.wire = name().toLowerCase(Locale.ROOT); }
Theme(String wire) { this.wire = wire; }
}
/** Sidebar and content in two columns, or the one-column classic. */
public enum Layout { MODERN, CLASSIC }
/** What decides the order of the operations in a tag. */
public enum Sort { ALPHA, METHOD }
private Scalar() {
super("https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.71.0");
// Upstream shows a Configure/Share/Deploy toolbar on localhost, which is Scalar's product,
// not this application's: a page served by Flash is the same page everywhere.
config.put("showDeveloperTools", "never");
}
public Scalar theme(Theme theme) { return option("theme", theme.wire); }
public Scalar layout(Layout layout) { return option("layout", wire(layout, '-')); }
/** Dark on load; the reader's toggle still wins unless {@link #hideDarkModeToggle(boolean)}. */
public Scalar darkMode(boolean dark) { return option("darkMode", dark); }
public Scalar hideDarkModeToggle(boolean hide) { return option("hideDarkModeToggle", hide); }
/** Whether {@code components.schemas} gets a section of its own. */
public Scalar hideModels(boolean hide) { return option("hideModels", hide); }
public Scalar hideSearch(boolean hide) { return option("hideSearch", hide); }
/** The button that fires the request from the page. */
public Scalar hideTestRequestButton(boolean hide) { return option("hideTestRequestButton", hide); }
/** The button that opens the operation in Scalar's API client. */
public Scalar hideClientButton(boolean hide) { return option("hideClientButton", hide); }
public Scalar showSidebar(boolean show) { return option("showSidebar", show); }
/** Every tag open on load, rather than only the first. */
public Scalar defaultOpenAllTags(boolean open) { return option("defaultOpenAllTags", open); }
public Scalar sortOperationsBy(Sort sort) { return option("operationsSorter", wire(sort, '-')); }
@Override String body(String config) {
return "<div id=\"ui\"></div><script src=\"" + cdn() + "\"></script>"
+ "<script>Scalar.createApiReference('#ui'," + config + ")</script>";
}
}
}
@@ -226,6 +226,40 @@ class OpenApiBuilderTest {
@Override public Object handle(Request request, Response response) { return null; }
}
@Schema(name = "Keyed")
static class KeyedDto {
@SchemaProperty(description = "Unique in the project.")
@jakarta.validation.constraints.Pattern(regexp = "[a-z.]+", message = "uses lowercase letters and dots")
public String key;
@jakarta.validation.constraints.Pattern(regexp = "[0-9]+")
public String code;
}
@GET("/keyed")
@ApiOperation(summary = "Keyed")
@APIResponse(responseCode = "200", content = @Content(schema = KeyedDto.class))
static class KeyedHandler extends RequestHandler {
@Override public Object handle(Request request, Response response) { return null; }
}
@Test
void a_pattern_says_in_words_what_its_regex_says_in_symbols() {
OpenApiBuilder b = new OpenApiBuilder();
b.addOperation(OpenApiBuilder.routeOf(KeyedHandler.class), KeyedHandler.class.getAnnotation(ApiOperation.class), KeyedHandler.class);
Map<String, Object> components = cast(b.build().get("components"));
Map<String, Object> schemas = cast(components.get("schemas"));
Map<String, Object> keyed = cast(schemas.get("Keyed"));
Map<String, Object> properties = cast(keyed.get("properties"));
Map<String, Object> key = cast(properties.get("key"));
Map<String, Object> code = cast(properties.get("code"));
assertEquals("Unique in the project. Uses lowercase letters and dots.", key.get("description"));
assertEquals("[a-z.]+", key.get("pattern"));
assertFalse(code.containsKey("description"), "a pattern with no message of its own says nothing extra");
}
@Test
void a_route_that_says_it_is_not_part_of_the_api_is_left_out() {
OpenApiBuilder b = new OpenApiBuilder();
@@ -23,6 +23,9 @@ import java.util.Map;
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.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OpenApiExtensionTest {
@@ -37,35 +40,83 @@ class OpenApiExtensionTest {
}
}
/** Routes are collected after the ready callbacks, so the document is rendered by the first request. */
private static byte[] body(TestRegistrar app, String path) throws Exception {
Response res = new Response(200, ContentType.NONE);
assertNull(app.route(HttpMethod.GET, path).handle(null, res));
return res.getBody();
}
@Test
void provide_collects_operations_and_routes_serve_json_yaml_swagger() throws Exception {
void serves_the_document_as_json_and_yaml_and_the_page_that_reads_it() throws Exception {
FlashContext ctx = new FlashContext();
OpenApiExtension ext = new OpenApiExtension("/docs", "My API", "2.0.0", "desc");
OpenApiExtension ext = new OpenApiExtension("/api", "My API", "2.0.0", "desc");
TestRegistrar app = new TestRegistrar(ctx);
ext.configure(app, ctx);
emitRoute(ctx, HttpMethod.GET, "/health", "/", HealthHandler.class);
ctx.complete();
emitRoute(ctx, HttpMethod.GET, "/health", "/", HealthHandler.class);
assertNotNull(app.route(HttpMethod.GET, "/docs.json"));
assertNotNull(app.route(HttpMethod.GET, "/docs.yaml"));
assertNotNull(app.route(HttpMethod.GET, "/docs/swagger"));
Response jsonRes = new Response(200, ContentType.NONE);
Object jsonBody = app.route(HttpMethod.GET, "/docs.json").handle(null, jsonRes);
assertEquals(new String(ContentType.JSON.getBytes()), new String(jsonRes.getContentType()));
assertTrue(String.valueOf(jsonBody).contains("\"openapi\":\"3.0.3\""));
assertTrue(String.valueOf(jsonBody).contains("\"title\":\"My API\""));
Response res = new Response(200, ContentType.NONE);
app.route(HttpMethod.GET, "/api.json").handle(null, res);
assertEquals(new String(ContentType.JSON.getBytes()), new String(res.getContentType()));
String json = new String(res.getBody());
assertTrue(json.contains("\"openapi\":\"3.0.3\""));
assertTrue(json.contains("\"title\":\"My API\""));
assertTrue(json.contains("/health"));
Response yamlRes = new Response(200, ContentType.NONE);
Object yamlBody = app.route(HttpMethod.GET, "/docs.yaml").handle(null, yamlRes);
app.route(HttpMethod.GET, "/api.yaml").handle(null, yamlRes);
assertEquals("application/yaml", new String(yamlRes.getContentType()));
assertTrue(String.valueOf(yamlBody).contains("openapi: \"3.0.3\""));
assertTrue(new String(yamlRes.getBody()).contains("openapi: \"3.0.3\""));
Response swaggerRes = new Response(200, ContentType.NONE);
Object swaggerBody = app.route(HttpMethod.GET, "/docs/swagger").handle(null, swaggerRes);
assertEquals(new String(ContentType.TEXT_HTML.getBytes()), new String(swaggerRes.getContentType()));
assertTrue(String.valueOf(swaggerBody).contains("SwaggerUIBundle"));
assertTrue(String.valueOf(swaggerBody).contains("/docs.json"));
Response page = new Response(200, ContentType.NONE);
app.route(HttpMethod.GET, "/api/docs").handle(null, page);
assertEquals(new String(ContentType.TEXT_HTML.getBytes()), new String(page.getContentType()));
assertTrue(new String(page.getBody()).contains("SwaggerUIBundle"));
assertTrue(new String(page.getBody()).contains("/api.json"));
}
@Test
void the_chosen_ui_replaces_the_default_one() throws Exception {
FlashContext ctx = new FlashContext();
TestRegistrar app = new TestRegistrar(ctx);
new OpenApiExtension("/api").ui(Ui.scalar().theme(Ui.Scalar.Theme.MOON)).configure(app, ctx);
ctx.complete();
String page = new String(body(app, "/api/docs"));
assertTrue(page.contains("Scalar.createApiReference"));
assertTrue(page.contains("\"theme\":\"moon\""));
}
@Test
void no_ui_leaves_the_document_and_nothing_else() {
FlashContext ctx = new FlashContext();
TestRegistrar app = new TestRegistrar(ctx);
new OpenApiExtension("/api").ui(Ui.none()).configure(app, ctx);
ctx.complete();
assertNotNull(app.route(HttpMethod.GET, "/api.json"));
assertNotNull(app.route(HttpMethod.GET, "/api.yaml"));
assertNull(app.route(HttpMethod.GET, "/api/docs"));
}
/** Encoded once and handed out as it is, until a route the document does not have yet arrives. */
@Test
void the_encoded_document_is_reused_until_an_operation_is_added() throws Exception {
FlashContext ctx = new FlashContext();
OpenApiExtension ext = new OpenApiExtension("/api");
TestRegistrar app = new TestRegistrar(ctx);
ext.configure(app, ctx);
ctx.complete();
emitRoute(ctx, HttpMethod.GET, "/health", "/", HealthHandler.class);
byte[] first = body(app, "/api.json");
assertSame(first, body(app, "/api.json"));
emitRoute(ctx, HttpMethod.GET, "/users", "/", ScopedUsersHandler.class);
byte[] second = body(app, "/api.json");
assertNotSame(first, second);
assertTrue(new String(second).contains("/users"));
}
@Test
@@ -80,8 +131,8 @@ class OpenApiExtensionTest {
ctx.complete();
Response jsonRes = new Response(200, ContentType.NONE);
Object jsonBody = app.route(HttpMethod.GET, "/openapi.json").handle(null, jsonRes);
assertTrue(String.valueOf(jsonBody).contains("\"openapi\":\"3.0.3\""));
app.route(HttpMethod.GET, "/openapi.json").handle(null, jsonRes);
assertTrue(new String(jsonRes.getBody()).contains("\"openapi\":\"3.0.3\""));
}
@GET("/users")
@@ -0,0 +1,155 @@
package dev.relism.flash.ext.openapi;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Each UI renders one page: its own bundle, its own options, the document it reads. */
class UiTest {
private static final ObjectMapper JSON = new ObjectMapper();
private static String page(Ui<?> ui) {
return new String(ui.page("/openapi.json", "My API", JSON), StandardCharsets.UTF_8);
}
@Test
void swagger_mounts_its_bundle_on_the_spec() {
String page = page(Ui.swagger());
assertTrue(page.contains("<title>My API</title>"));
assertTrue(page.contains("https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.0/swagger-ui.css"));
assertTrue(page.contains("https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.0/swagger-ui-bundle.js"));
assertTrue(page.contains("SwaggerUIBundle({"));
assertTrue(page.contains("\"dom_id\":\"#ui\""));
assertTrue(page.contains("\"deepLinking\":true"));
assertTrue(page.contains("\"url\":\"/openapi.json\""));
// The preset it used to name lives in another bundle, and BaseLayout never needed it.
assertFalse(page.contains("StandalonePreset"));
}
@Test
void swagger_options_carry_their_upstream_names() {
String page = page(Ui.swagger()
.docExpansion(Ui.Swagger.Expand.NONE)
.modelsExpandDepth(-1)
.filter(true)
.tryItOut(true)
.persistAuthorization(true)
.syntaxTheme(Ui.Swagger.Syntax.TOMORROW_NIGHT)
.sortAlphabetically(true));
assertTrue(page.contains("\"docExpansion\":\"none\""));
assertTrue(page.contains("\"defaultModelsExpandDepth\":-1"));
assertTrue(page.contains("\"filter\":true"));
assertTrue(page.contains("\"tryItOutEnabled\":true"));
assertTrue(page.contains("\"persistAuthorization\":true"));
assertTrue(page.contains("\"syntaxHighlight\":{\"theme\":\"tomorrow-night\"}"));
assertTrue(page.contains("\"tagsSorter\":\"alpha\""));
assertTrue(page.contains("\"operationsSorter\":\"alpha\""));
}
/** Turning an option off leaves the bundle's own default, rather than writing a null over it. */
@Test
void an_option_set_back_to_its_default_is_dropped() {
String page = page(Ui.swagger().sortAlphabetically(true).sortAlphabetically(false));
assertFalse(page.contains("tagsSorter"));
assertFalse(page.contains("operationsSorter"));
}
@Test
void redoc_initializes_itself_on_the_configuration_it_is_given() {
String page = page(Ui.redoc()
.hideDownloadButton(true)
.disableSearch(true)
.requiredPropsFirst(true)
.sortPropsAlphabetically(true)
.jsonSampleExpandLevel(3)
.hideSchemaTitles(true)
.pathInMiddlePanel(true)
.hideHostname(true)
.nativeScrollbars(true)
.menuToggle(false));
assertTrue(page.contains("https://cdn.jsdelivr.net/npm/redoc@2.5.4/bundles/redoc.standalone.js"));
assertTrue(page.contains("Redoc.init(c.url,c,document.getElementById('ui'))"));
assertTrue(page.contains("\"hideDownloadButton\":true"));
assertTrue(page.contains("\"disableSearch\":true"));
assertTrue(page.contains("\"requiredPropsFirst\":true"));
assertTrue(page.contains("\"sortPropsAlphabetically\":true"));
assertTrue(page.contains("\"jsonSampleExpandLevel\":3"));
assertTrue(page.contains("\"hideSchemaTitles\":true"));
assertTrue(page.contains("\"pathInMiddlePanel\":true"));
assertTrue(page.contains("\"hideHostname\":true"));
assertTrue(page.contains("\"nativeScrollbars\":true"));
assertTrue(page.contains("\"menuToggle\":false"));
}
@Test
void scalar_writes_camel_cased_theme_names_and_hides_its_own_toolbar() {
String page = page(Ui.scalar()
.theme(Ui.Scalar.Theme.BLUE_PLANET)
.layout(Ui.Scalar.Layout.CLASSIC)
.darkMode(true)
.hideDarkModeToggle(true)
.hideModels(true)
.hideSearch(true)
.hideTestRequestButton(true)
.hideClientButton(true)
.showSidebar(false)
.defaultOpenAllTags(true)
.sortOperationsBy(Ui.Scalar.Sort.METHOD));
assertTrue(page.contains("https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.71.0"));
assertTrue(page.contains("Scalar.createApiReference('#ui',{"));
assertTrue(page.contains("\"showDeveloperTools\":\"never\""));
assertTrue(page.contains("\"theme\":\"bluePlanet\""));
assertTrue(page.contains("\"layout\":\"classic\""));
assertTrue(page.contains("\"darkMode\":true"));
assertTrue(page.contains("\"hideDarkModeToggle\":true"));
assertTrue(page.contains("\"hideModels\":true"));
assertTrue(page.contains("\"hideSearch\":true"));
assertTrue(page.contains("\"hideTestRequestButton\":true"));
assertTrue(page.contains("\"hideClientButton\":true"));
assertTrue(page.contains("\"showSidebar\":false"));
assertTrue(page.contains("\"defaultOpenAllTags\":true"));
assertTrue(page.contains("\"operationsSorter\":\"method\""));
}
@Test
void every_theme_name_reaches_the_configuration() {
for (Ui.Scalar.Theme theme : Ui.Scalar.Theme.values()) {
String page = page(Ui.scalar().theme(theme));
assertTrue(page.matches("(?s).*\"theme\":\"[a-zA-Z]+\".*"), theme + " wrote no theme");
}
assertTrue(page(Ui.scalar().theme(Ui.Scalar.Theme.DEEP_SPACE)).contains("\"theme\":\"deepSpace\""));
}
@Test
void a_custom_stylesheet_and_a_mirror_replace_the_defaults() {
String page = page(Ui.redoc().customCss("body{color:red}").cdn("https://assets.example/redoc/"));
assertTrue(page.contains("<style>body{margin:0}body{color:red}</style>"));
assertTrue(page.contains("https://assets.example/redoc/bundles/redoc.standalone.js"));
assertFalse(page.contains("jsdelivr"));
}
/** What this class does not name still reaches the bundle, nested objects included. */
@Test
void an_unnamed_option_passes_through() {
String page = page(Ui.redoc().option("theme", java.util.Map.of("colors", java.util.Map.of("primary", "#0a0"))));
assertTrue(page.contains("\"theme\":{\"colors\":{\"primary\":\"#0a0\"}}"));
}
@Test
void none_is_the_absence_of_a_page() {
assertEquals(null, Ui.none());
}
}