6 Commits
Author SHA1 Message Date
Relism 26083021ac 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.
2026-09-24 12:06:46 +00:00
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
Zakaria El OrcheandClaude Opus 5 5164f8c41f feat(ext-openapi): a pattern says in words what its regex says in symbols
A schema's pattern is precise and unreadable. When the constraint declares a
message, that message now goes in the property's description too, after
whatever the property already said, so the document carries both the rule a
machine checks and the sentence a person reads.

Only @Pattern does this. Every other constraint has a keyword that already
reads: required, maxLength, format: email. Repeating those as prose would be
noise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 09:56:54 +00:00
Relism 5ece97ca7b Merge pull request 'fix(ext-openapi): one shared answer per status, not a numbered family' (#24) from fix/openapi/one-shared-answer-per-status into master
Publish Maven packages / publish (push) Successful in 3m36s
2026-09-23 16:34:15 +00:00
Zakaria El OrcheandClaude Opus 5 5fe6fb46c8 fix(ext-openapi): one shared answer per status, not a numbered family
Hoisting named a shared response after its status and disambiguated with a
counter, so a document with three wordings for 403 grew Forbidden, Forbidden2
and Forbidden3 in its components. Numbered names say nothing and move as soon
as a route is added.

The answer a status is usually given is now the one hoisted, under that
status's own name, and a route that answers the same status differently keeps
its wording inline where it belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 16:34:13 +00:00
Relism c0c9480fa5 Merge pull request 'feat(ext-openapi): a route can say it is not part of the API' (#23) from feat/openapi/undocumented into master
Publish Maven packages / publish (push) Successful in 2m56s
2026-09-23 15:47:37 +00:00
10 changed files with 673 additions and 95 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-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-json` | JSON bodies and responses |
| `flash-extensions/flash-ext-jackson-xml` | XML 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-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-oidc` | OpenID Connect: bearer tokens, code flow + PKCE |
| `flash-extensions/flash-ext-security-apikey` | API keys | | `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). avoid this by not setting these headers manually).
- On 429, the handler body is never executed — no side effects occur. - 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 When `flash-ext-openapi` is installed, handlers annotated with `@Limit` automatically
contribute rate-limit response headers to generated OpenAPI responses: contribute rate-limit response headers to generated OpenAPI responses:
+48 -3
View File
@@ -1,7 +1,7 @@
# flash-ext-openapi # flash-ext-openapi
OpenAPI 3.0.3 generation and Swagger UI, built from what the handlers already say about OpenAPI 3.0.3 generation and a documentation page, built from what the handlers already say
themselves. about themselves.
## What it provides ## What it provides
@@ -9,7 +9,7 @@ themselves.
|---|---| |---|---|
| `GET /openapi.json` | OpenAPI spec JSON | | `GET /openapi.json` | OpenAPI spec JSON |
| `GET /openapi.yaml` | OpenAPI spec YAML | | `GET /openapi.yaml` | OpenAPI spec YAML |
| `GET /openapi/swagger` | Swagger UI | | `GET /openapi/docs` | The documentation page: Swagger UI, Redoc or Scalar |
```java ```java
FlashApp.create(8080) FlashApp.create(8080)
@@ -19,6 +19,32 @@ FlashApp.create(8080)
.startAndBlock(); .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 ## What you get without writing anything
Every class-based route is documented, annotated or not. Read off the code: 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 `@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. 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 ## Contributor API
OpenAPI is extension-agnostic. Other extensions contribute through `OpenApiContributor`, held in 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"); if (field.isAnnotationPresent(Email.class)) property.putIfAbsent("format", "email");
Pattern pattern = field.getAnnotation(Pattern.class); 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(NotBlank.class) && isString) property.putIfAbsent("minLength", 1);
if (field.isAnnotationPresent(NotEmpty.class)) { if (field.isAnnotationPresent(NotEmpty.class)) {
@@ -78,6 +81,25 @@ final class ConstraintHints {
} }
/** Constraint annotations this bridge understands, for documentation and tests. */ /** 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() { static List<String> supported() {
return List.of("@NotNull", "@NotBlank", "@NotEmpty", "@Size", "@Min", "@Max", "@Email", "@Pattern"); 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; } public OpenApiBuilder description(String description) { this.description = description; return this; }
void setContributorRegistry(OpenApiContributorRegistry registry) { this.contributorRegistry = registry; } 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. * Documents one route. {@code op} is optional: a route without it is still an operation.
* A handler marked {@link Undocumented} is left out entirely. * A handler marked {@link Undocumented} is left out entirely.
@@ -383,53 +386,49 @@ public final class OpenApiBuilder {
// ── Shared responses ────────────────────────────────────────────────────── // ── Shared responses ──────────────────────────────────────────────────────
/** /**
* Whatever answer more than one operation gives identically is written once under * The answer a status is usually given is written once under {@code components.responses} and
* {@code components.responses} and referenced. Authentication and rate limiting say the same * referenced. Authentication and rate limiting say the same thing on every route they guard;
* thing on every route they guard; the document should say it once. * the document should say it once, under that status's own name. A route that answers the same
* status differently keeps its own wording, inline, rather than pushing a second name into the
* components.
*/ */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private Map<String, Object> hoistSharedResponses(Map<String, Object> renderedPaths) { private Map<String, Object> hoistSharedResponses(Map<String, Object> renderedPaths) {
Map<Object, Integer> seen = new HashMap<>(); Map<String, Map<Object, Integer>> seen = new LinkedHashMap<>();
for (Object pathItem : renderedPaths.values()) { for (Object pathItem : renderedPaths.values()) {
for (Object operation : ((Map<String, Object>) pathItem).values()) { for (Object operation : ((Map<String, Object>) pathItem).values()) {
Map<String, Object> responses = (Map<String, Object>) ((Map<String, Object>) operation).get("responses"); Map<String, Object> responses = (Map<String, Object>) ((Map<String, Object>) operation).get("responses");
responses.forEach((status, response) -> seen.merge(key(status, response), 1, Integer::sum)); responses.forEach((status, response) ->
seen.computeIfAbsent(status, s -> new LinkedHashMap<>()).merge(response, 1, Integer::sum));
} }
} }
Map<Object, String> names = new LinkedHashMap<>();
Map<String, Object> shared = new LinkedHashMap<>(); Map<String, Object> shared = new LinkedHashMap<>();
Map<String, Object> hoisted = new LinkedHashMap<>(); // status to the one body that is shared
seen.forEach((status, bodies) -> {
Map.Entry<Object, Integer> commonest = bodies.entrySet().stream()
.max(Map.Entry.comparingByValue()).orElseThrow();
if (commonest.getValue() < 2) return;
String name = reasonFor(Integer.parseInt(status)).replace(" ", "");
if (name.isEmpty() || shared.containsKey(name)) name = "Status" + status;
shared.put(name, commonest.getKey());
hoisted.put(status, name);
});
for (Object pathItem : renderedPaths.values()) { for (Object pathItem : renderedPaths.values()) {
for (Object operation : ((Map<String, Object>) pathItem).values()) { for (Object operation : ((Map<String, Object>) pathItem).values()) {
Map<String, Object> responses = (Map<String, Object>) ((Map<String, Object>) operation).get("responses"); Map<String, Object> responses = (Map<String, Object>) ((Map<String, Object>) operation).get("responses");
for (var response : responses.entrySet()) { for (var response : responses.entrySet()) {
Object key = key(response.getKey(), response.getValue()); String name = (String) hoisted.get(response.getKey());
if (seen.getOrDefault(key, 0) < 2) continue; if (name != null && shared.get(name).equals(response.getValue())) {
response.setValue(Map.of("$ref", RESPONSE_REF + name));
String name = names.get(key);
if (name == null) {
name = uniqueName(reasonFor(Integer.parseInt(response.getKey())), shared);
names.put(key, name);
shared.put(name, response.getValue());
} }
response.setValue(Map.of("$ref", RESPONSE_REF + name));
} }
} }
} }
return shared; return shared;
} }
private static Object key(String status, Object response) {
return status + response;
}
private static String uniqueName(String reason, Map<String, Object> taken) {
String base = reason.isEmpty() ? "Response" : reason.replace(" ", "");
String name = base;
for (int i = 2; taken.containsKey(name); i++) name = base + i;
return name;
}
// ── Odds and ends ───────────────────────────────────────────────────────── // ── Odds and ends ─────────────────────────────────────────────────────────
private static Map<String, Object> arrayOf(Map<String, Object> items) { private static Map<String, Object> arrayOf(Map<String, Object> items) {
@@ -1,5 +1,6 @@
package dev.relism.flash.ext.openapi; package dev.relism.flash.ext.openapi;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; 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.http.HttpMethod;
import dev.relism.flash.routing.Route; 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: * <p>Given {@code basePath = "/openapi"} (the default), three routes are registered:
* <ul> * <ul>
* <li>{@code GET /openapi.json} — OpenAPI 3.0 spec as JSON</li> * <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.yaml} — the same document as YAML</li>
* <li>{@code GET /openapi/swagger} — Swagger UI pointing at {@code /openapi.json}</li> * <li>{@code GET /openapi/docs} — the {@link Ui}, Swagger UI unless another is chosen</li>
* </ul> * </ul>
* *
* <p>If {@code flash-ext-jackson} is installed, this extension reuses its * <p>If {@code flash-ext-jackson} is installed, this extension reuses its {@link ObjectMapper}.
* {@link ObjectMapper}. Otherwise it uses a local default mapper. * 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 * <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 * 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 * <pre>{@code
* FlashApp.create(8080) * FlashApp.create(8080)
* .install(new JacksonExtension()) * .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(); * .start();
* }</pre> * }</pre>
*/ */
public class OpenApiExtension implements FlashExtension { 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 basePath;
private final String title; private final String title;
private final String version; private final String version;
private final String description; private final String description;
private Ui<?> ui = Ui.swagger();
public OpenApiExtension() { public OpenApiExtension() {
this("/openapi", "API", "1.0.0", ""); this("/openapi", "API", "1.0.0", "");
@@ -63,6 +68,12 @@ public class OpenApiExtension implements FlashExtension {
this.description = description; 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 ──────────────────────────────────────────────────────── // ── FlashExtension ────────────────────────────────────────────────────────
@Override @Override
@@ -78,46 +89,47 @@ public class OpenApiExtension implements FlashExtension {
// This guarantees full runtime paths (namespaces/prefixes/rewrites) in the spec. // This guarantees full runtime paths (namespaces/prefixes/rewrites) in the spec.
ctx.addRouteListener(event -> addOperationFromEvent(builder, event)); ctx.addRouteListener(event -> addOperationFromEvent(builder, event));
ctx.onReady(() -> { ctx.onReady(() -> {
ObjectMapper jsonMapper = ctx.find(ObjectMapper.class).orElseGet(() -> JsonMapper.builder().build()); ObjectMapper json = ctx.find(ObjectMapper.class).orElseGet(() -> JsonMapper.builder().build());
YAMLMapper yamlMapper = new YAMLMapper(); OpenApiBuilder spec = ctx.require(OpenApiBuilder.class);
OpenApiBuilder resolvedBuilder = ctx.require(OpenApiBuilder.class); String jsonPath = basePath + ".json";
String jsonPath = basePath + ".json"; Document asJson = new Document(json, spec);
String yamlPath = basePath + ".yaml"; Document asYaml = new Document(new YAMLMapper(), spec);
String swaggerPath = basePath + "/swagger"; app.get(jsonPath, (req, res) -> { res.type(ContentType.JSON).body(asJson.bytes()); return null; });
String swaggerHtml = buildSwaggerHtml(jsonPath); 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()); }); if (ui == null) return;
app.get(yamlPath, (req, res) -> { res.type(YAML_CONTENT_TYPE); return yamlMapper.writeValueAsString(resolvedBuilder.build()); }); byte[] page = ui.page(jsonPath, title, json);
app.get(swaggerPath, (req, res) -> { res.type(ContentType.TEXT_HTML); return swaggerHtml; }); 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) { private final ObjectMapper mapper;
return "<!DOCTYPE html>\n" + private final OpenApiBuilder spec;
"<html lang=\"en\">\n" + private volatile byte[] encoded;
"<head>\n" + private volatile int revision = -1;
" <meta charset=\"UTF-8\">\n" +
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n" + Document(ObjectMapper mapper, OpenApiBuilder spec) {
" <title>Swagger UI</title>\n" + this.mapper = mapper;
" <link rel=\"stylesheet\" href=\"https://unpkg.com/swagger-ui-dist@5/swagger-ui.css\">\n" + this.spec = spec;
"</head>\n" + }
"<body>\n" +
"<div id=\"swagger-ui\"></div>\n" + byte[] bytes() throws JsonProcessingException {
"<script src=\"https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js\"></script>\n" + if (revision != spec.revision()) {
"<script>\n" + encoded = mapper.writeValueAsBytes(spec.build());
"SwaggerUIBundle({\n" + revision = spec.revision();
" url: \"" + specJsonPath + "\",\n" + }
" dom_id: '#swagger-ui',\n" + return encoded;
" deepLinking: true,\n" + }
" presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],\n" +
" layout: \"BaseLayout\"\n" +
"});\n" +
"</script>\n" +
"</body>\n" +
"</html>";
} }
/** Every class-based route is an operation; {@link ApiOperation} only adds what the code cannot say. */ /** 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; } @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 @Test
void a_route_that_says_it_is_not_part_of_the_api_is_left_out() { void a_route_that_says_it_is_not_part_of_the_api_is_left_out() {
OpenApiBuilder b = new OpenApiBuilder(); 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull; 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; import static org.junit.jupiter.api.Assertions.assertTrue;
class OpenApiExtensionTest { 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 @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(); 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); TestRegistrar app = new TestRegistrar(ctx);
ext.configure(app, ctx); ext.configure(app, ctx);
emitRoute(ctx, HttpMethod.GET, "/health", "/", HealthHandler.class);
ctx.complete(); ctx.complete();
emitRoute(ctx, HttpMethod.GET, "/health", "/", HealthHandler.class);
assertNotNull(app.route(HttpMethod.GET, "/docs.json")); Response res = new Response(200, ContentType.NONE);
assertNotNull(app.route(HttpMethod.GET, "/docs.yaml")); app.route(HttpMethod.GET, "/api.json").handle(null, res);
assertNotNull(app.route(HttpMethod.GET, "/docs/swagger")); assertEquals(new String(ContentType.JSON.getBytes()), new String(res.getContentType()));
String json = new String(res.getBody());
Response jsonRes = new Response(200, ContentType.NONE); assertTrue(json.contains("\"openapi\":\"3.0.3\""));
Object jsonBody = app.route(HttpMethod.GET, "/docs.json").handle(null, jsonRes); assertTrue(json.contains("\"title\":\"My API\""));
assertEquals(new String(ContentType.JSON.getBytes()), new String(jsonRes.getContentType())); assertTrue(json.contains("/health"));
assertTrue(String.valueOf(jsonBody).contains("\"openapi\":\"3.0.3\""));
assertTrue(String.valueOf(jsonBody).contains("\"title\":\"My API\""));
Response yamlRes = new Response(200, ContentType.NONE); 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())); 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); Response page = new Response(200, ContentType.NONE);
Object swaggerBody = app.route(HttpMethod.GET, "/docs/swagger").handle(null, swaggerRes); app.route(HttpMethod.GET, "/api/docs").handle(null, page);
assertEquals(new String(ContentType.TEXT_HTML.getBytes()), new String(swaggerRes.getContentType())); assertEquals(new String(ContentType.TEXT_HTML.getBytes()), new String(page.getContentType()));
assertTrue(String.valueOf(swaggerBody).contains("SwaggerUIBundle")); assertTrue(new String(page.getBody()).contains("SwaggerUIBundle"));
assertTrue(String.valueOf(swaggerBody).contains("/docs.json")); 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 @Test
@@ -80,8 +131,8 @@ class OpenApiExtensionTest {
ctx.complete(); ctx.complete();
Response jsonRes = new Response(200, ContentType.NONE); Response jsonRes = new Response(200, ContentType.NONE);
Object jsonBody = app.route(HttpMethod.GET, "/openapi.json").handle(null, jsonRes); app.route(HttpMethod.GET, "/openapi.json").handle(null, jsonRes);
assertTrue(String.valueOf(jsonBody).contains("\"openapi\":\"3.0.3\"")); assertTrue(new String(jsonRes.getBody()).contains("\"openapi\":\"3.0.3\""));
} }
@GET("/users") @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());
}
}