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 2ced2e2..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: 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: *

* - *

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; *

{@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();
  * }
*/ 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/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()); + } +}