OpenAPI Export

spider-silk-openapi turns app.routes() into an OpenAPI 3.1 document. It is a reader over that list and nothing else. Core hands the routes out as plain data and needs no change for this module: PathPattern’s `{deckId} already is OpenAPI’s path template, so there is nothing to translate and nothing to reflect over. A path is written the way the router reads it, with a leading / and no trailing one, so get("decks", …​) and post("/decks/", …​) share the one item /decks.

The export is a module rather than a method on App for a second reason. A spec format is not the web tier, and a version this framework does not own should not be pinned inside the one artifact every application depends on. The same reasoning keeps the WebTest harness in spider-silk-test.

Installation

dependencies {
    implementation 'net.benelog.spidersilk:spider-silk-openapi:1.1.0'
}

The module adds no dependency beyond core: it is one class over core’s route list and core’s Json.

Serving the document

app.get("/openapi.json", req -> {
    JsonValue resBody = OpenApi.document("Flashcard API", "1.0.0", app.routes());
    return WebResponse.json(resBody);
});

The title and version are arguments because OpenAPI requires both under info, and a default for either would be this module naming someone’s API. document returns a JsonValue, so it composes with everything else in the JSON API. Writing it to a file at build time is the same call.

The handler reads app.routes() per request, so it lists whatever was registered, including routes added after this line.

Which routes go in

Every route in the list goes in, unless you narrow the list first. An application that serves HTML alongside its API passes the API’s routes and not everything:

List<Route> documented = app.routes().stream()
        .filter(route -> route.path().startsWith("/api"))
        .toList();

That selection stays the application’s, which is the reason routes() hands back a list in the first place. This module has no way to tell an HTML page from an endpoint, and a default built on a guess would always be wrong for some application.

A route whose pattern has a bare * segment throws IllegalArgumentException. That wildcard has no OpenAPI path template and no name to put in one. Dropping the route quietly would publish a document claiming the application answers less than it does. The export refuses it instead, and leaving such a route out is a filter you write, visibly, next to the one above.

A named tail is written out rather than refused. /files/\{path*} becomes the path /files/{path}, and its parameter carries the description The rest of the path, slashes included. OpenAPI has no wildcard in a path template, so the star stays behind as core’s syntax and only the variable crosses over.

A segment is a variable only when the whole segment is one, as it is to the router. /files/*.txt is a literal path to the router, and the export writes it as it is. /files/{name}.json is a literal too, so its braces are percent-encoded into /files/%7Bname%7D.json. Written as it is, the path would read as a template with a variable that no parameter declares. Dropping the star of a named tail and encoding the braces of a literal are the only translations this module does. A variable whose name is empty or holds a brace, such as {a}{b}, throws IllegalArgumentException, since a template has no way to name it.

Two routes that OpenAPI would read as one path throw IllegalArgumentException too, naming both. OpenAPI tells templated paths apart by their segments alone, so /decks/{deckId} and /decks/{id} are one path, which a document may list only once. /files/{name} and /files/\{name*} answer different requests, but share one template, under which one operation would quietly replace the other.

What the document contains

Every route becomes an operation under its path, keyed by the lowercased method, in registration order. Every {name} in a path becomes a required path parameter of type string, which OpenAPI insists on declaring. Each operation carries a 200 response described as OK.

A route registered with a description carries it as the operation’s summary, which is the field a spec UI shows beside the route:

app.get("/api/decks", "List every deck", api::listDecks);
{"paths": {"/api/decks": {"get": {"summary": "List every deck", "responses": {"200": {"description": "OK"}}}}}}

A route registered without one gets no summary at all, rather than an empty string that would render as a blank line.

The document contains nothing more, and it is still a valid OpenAPI document. What is missing is missing on purpose: a request or response schema, a server list, and a security scheme. None of those is derivable from a route, so each would have to be declared somewhere. This module sidesteps the question of where by declaring none of them. The automatic HEAD and OPTIONS answers are not listed either, for the reason routes() does not list them.

Testing it

document is a pure function of a list, so a test needs no server and no app:

JsonObject paths = OpenApi.document("Flashcard API", "1.0.0",
        List.of(new Route("GET", "/api/decks/{deckId}/cards"))).asObject().getObject("paths");

assertThat(paths.getObject("/api/decks/{deckId}/cards").getObject("get").getArray("parameters"))
        .hasSize(1);

Route is a record, so a fixture is a constructor call. The example app’s document is reachable at /openapi.json. A spec document is the one resource there meant to be read by something other than the application, so the example scopes its CORS to that path alone.