feat(ext-openapi): pick the page that reads the document #26
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+23
-1
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -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.
|
||||||
|
|||||||
+53
-41
@@ -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";
|
||||||
String yamlPath = basePath + ".yaml";
|
|
||||||
String swaggerPath = basePath + "/swagger";
|
|
||||||
String swaggerHtml = buildSwaggerHtml(jsonPath);
|
|
||||||
|
|
||||||
app.get(jsonPath, (req, res) -> { res.type(ContentType.JSON); return jsonMapper.writeValueAsString(resolvedBuilder.build()); });
|
Document asJson = new Document(json, spec);
|
||||||
app.get(yamlPath, (req, res) -> { res.type(YAML_CONTENT_TYPE); return yamlMapper.writeValueAsString(resolvedBuilder.build()); });
|
Document asYaml = new Document(new YAMLMapper(), spec);
|
||||||
app.get(swaggerPath, (req, res) -> { res.type(ContentType.TEXT_HTML); return swaggerHtml; });
|
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; });
|
||||||
|
|
||||||
|
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) {
|
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. */
|
||||||
|
|||||||
+260
@@ -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>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -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();
|
||||||
|
|||||||
+72
-21
@@ -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")
|
||||||
|
|||||||
+155
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user