diff --git a/README.md b/README.md
index 89714cd..2b0a1e1 100644
--- a/README.md
+++ b/README.md
@@ -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 |
diff --git a/flash-extensions/flash-ext-limiter/docs/http-headers.md b/flash-extensions/flash-ext-limiter/docs/http-headers.md
index 42f7772..54c70dc 100644
--- a/flash-extensions/flash-ext-limiter/docs/http-headers.md
+++ b/flash-extensions/flash-ext-limiter/docs/http-headers.md
@@ -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:
diff --git a/flash-extensions/flash-ext-openapi/README.md b/flash-extensions/flash-ext-openapi/README.md
index e32d760..1c0d0db 100644
--- a/flash-extensions/flash-ext-openapi/README.md
+++ b/flash-extensions/flash-ext-openapi/README.md
@@ -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
diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java
index a92a8aa..971a835 100644
--- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java
+++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/ConstraintHints.java
@@ -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.
+ *
+ *
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 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 supported() {
return List.of("@NotNull", "@NotBlank", "@NotEmpty", "@Size", "@Min", "@Max", "@Email", "@Pattern");
}
diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java
index 4941178..0a11f08 100644
--- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java
+++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java
@@ -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.
diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java
index 49cd16c..1e33e12 100644
--- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java
+++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiExtension.java
@@ -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.
*
*
Given {@code basePath = "/openapi"} (the default), three routes are registered:
*
- *
{@code GET /openapi.json} — OpenAPI 3.0 spec as JSON
- *
{@code GET /openapi.yaml} — OpenAPI 3.0 spec as YAML
- *
{@code GET /openapi/swagger} — Swagger UI pointing at {@code /openapi.json}
+ *
{@code GET /openapi.json} — OpenAPI 3.0 spec as JSON
+ *
{@code GET /openapi.yaml} — the same document as YAML
+ *
{@code GET /openapi/docs} — the {@link Ui}, Swagger UI unless another is chosen
*
*
- *
If {@code flash-ext-jackson} is installed, this extension reuses its
- * {@link ObjectMapper}. Otherwise it uses a local default mapper.
+ *
If {@code flash-ext-jackson} is installed, this extension reuses its {@link ObjectMapper}.
+ * Otherwise it uses a local default mapper.
*
*
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;
*
*/
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 "\n" +
- "\n" +
- "\n" +
- " \n" +
- " \n" +
- " Swagger UI\n" +
- " \n" +
- "\n" +
- "\n" +
- "\n" +
- "\n" +
- "\n" +
- "\n" +
- "";
+ 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. */
diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Ui.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Ui.java
new file mode 100644
index 0000000..158f733
--- /dev/null
+++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/Ui.java
@@ -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.
+ *
+ *
{@code
+ * new OpenApiExtension("/openapi", "My API", "1.0.0")
+ * .ui(Ui.scalar().theme(Ui.Scalar.Theme.PURPLE).layout(Ui.Scalar.Layout.CLASSIC).darkMode(true));
+ * }
+ *
+ *
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}).
+ *
+ *
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 the concrete UI, so every setter chains whatever the order
+ */
+public abstract class Ui> {
+
+ /** 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 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 (""
+ + ""
+ + "" + title + "" + head()
+ + ""
+ + body(configuration(json)) + "").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 }: 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 ────────────────────────────────────────────────────────────
+
+ /** Swagger UI, pinned to 5.x. */
+ public static final class Swagger extends Ui {
+
+ /** 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 ""; }
+
+ @Override String body(String config) {
+ return ""
+ + "";
+ }
+ }
+
+ // ── Redoc ─────────────────────────────────────────────────────────────────
+
+ /** Redoc community edition, pinned to 2.x. */
+ public static final class Redoc extends Ui {
+
+ 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 ""
+ + "";
+ }
+ }
+
+ // ── Scalar ────────────────────────────────────────────────────────────────
+
+ /** Scalar's API reference, pinned to 1.x. */
+ public static final class Scalar extends Ui {
+
+ /** 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 ""
+ + "";
+ }
+ }
+}
diff --git a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java
index debca82..5054e02 100644
--- a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java
+++ b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java
@@ -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 components = cast(b.build().get("components"));
+ Map schemas = cast(components.get("schemas"));
+ Map keyed = cast(schemas.get("Keyed"));
+ Map properties = cast(keyed.get("properties"));
+ Map key = cast(properties.get("key"));
+ Map 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();
diff --git a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiExtensionTest.java b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiExtensionTest.java
index b5e4fbd..8cda383 100644
--- a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiExtensionTest.java
+++ b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiExtensionTest.java
@@ -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")
diff --git a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/UiTest.java b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/UiTest.java
new file mode 100644
index 0000000..156b70a
--- /dev/null
+++ b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/UiTest.java
@@ -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("My API"));
+ 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(""));
+ 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());
+ }
+}