OpenAPI documents
howdy_openapi is an optional package that documents a JSON API as an
OpenAPI 3.1 document, and serves an
interactive reference for it.
howdy_openapi = { git = "https://github.com/mikeyjones/howdy.git", ref = "v2", path = "openapi" }
howdy_openapi is not in the v2.0.0 release, so depend on the v2 branch until the next tag.
Gleam cannot look inside a decoder or a handler, so there is nothing to read a document from after the fact. Instead you describe each value once, as a schema, and that one description is the decoder a handler reads with, the encoder a response writes with, and what the document shows. The document cannot drift from what the server really accepts, because the server uses it.
Endpoints
An endpoint is a handler that declares its inputs with schemas. Mount it on
an ordinary controller with endpoint.get, endpoint.post, endpoint.put,
endpoint.patch or endpoint.delete:
import howdy/controller
import howdy/openapi/endpoint.{type Endpoint}
import howdy/openapi/schema
import howdy/service
pub fn controller() -> controller.Controller {
controller.new("user")
|> endpoint.get("/:id", by_id())
|> endpoint.post("/", create())
}
fn by_id() -> Endpoint(Nil) {
use <- endpoint.describe([
endpoint.summary("Find a user"),
endpoint.response(200, "The user", user.user()),
endpoint.error(404, "No user has this id"),
])
use id <- endpoint.path("id", schema.int())
use ctx <- endpoint.handle
user_service.find(id)
|> service.respond(ctx, schema.to_json(_, user.user()))
}
fn create() -> Endpoint(Nil) {
use <- endpoint.describe([
endpoint.summary("Create a user"),
endpoint.response(201, "The new user", user.user()),
])
use input <- endpoint.body(user.new_user())
use ctx <- endpoint.handle
user_service.create(input)
|> service.created(ctx, schema.to_json(_, user.user()))
}
The inputs are:
-
endpoint.path(name, schema): a:nameor*namepath segment. -
endpoint.query(name, schema)andendpoint.optional_query: a query parameter. Withschema.list, every occurrence of the key, as in?tag=a&tag=b. -
endpoint.header(name, schema)andendpoint.optional_header. -
endpoint.body(schema): a JSON body, up to one mebibyte;endpoint.body_with_limittakes another limit.
Each runs in order before endpoint.handle, and stops the request if it
fails:
-
A missing or malformed parameter answers
400with a message such as{"error":"parameter id must be an integer"}, ashowdy/paramandhowdy/querydo. -
A body that cannot be read or is not JSON answers
400. -
A body that does not match its schema answers
422with every field error, ashowdy/validatedoes:{"error":"validation failed","fields":[{"field":"age","message":"must be at least 13"}]}. Nested fields are named by path, such asaddress.cityortags.0.
An endpoint is documented by running its inputs with placeholder values,
such as 0 for an integer. So do the work inside endpoint.handle, never
between the inputs, and documenting an endpoint will never run it.
Controller guards and middleware work as they do for any route. An endpoint
on a guarded controller is an Endpoint(guard), and its handler sees
ctx.guard. Gleam cannot tell the guard's type inside the handler, so name
it when you use ctx.guard:
fn mine() -> Endpoint(Principal) {
use <- endpoint.describe([endpoint.security("session")])
use ctx: GuardedContext(Principal) <- endpoint.handle
controller.text(ctx, ctx.guard.user.email)
}
endpoint.wrap runs middleware around one endpoint.
Describing an endpoint
endpoint.describe takes a list of docs:
-
summaryanddescription. -
response(status, description, schema)for a JSON response. Encode the response with the same schema, throughschema.to_json. -
empty_response(status, description)for one without a body, such as204. -
error(status, description)for Howdy's error body, the oneservice.error_responsesends. -
tag(name), to group endpoints. Without one, an endpoint is tagged with the first fixed segment of its path, such asuser. -
operation_id(id), which client generators use as a function name. Without one it is made from the method and path:get_user_by_idforGET /user/:id. -
security(scheme), naming a scheme declared on the document. -
deprecated().
Endpoints with inputs document their 400 response, and endpoints with a
body their 422, without being told. An endpoint that declares no response
is documented as answering 200.
Serving the document
import howdy/openapi
let spec =
openapi.new(title: "Users API", version: "1.0.0")
|> openapi.description("A small user directory.")
|> openapi.bearer_auth("bearer")
howdy.new()
|> howdy.controller(user.controller())
|> openapi.serve(spec, at: "/openapi.json")
|> openapi.reference(at: "/docs", document: "/openapi.json")
|> howdy.start
openapi.serve builds the document once, from the controllers mounted
before it, so call it after them. Only routes added as endpoints appear:
ordinary controller routes stay out, so you choose what you publish.
openapi.reference serves an interactive API reference, drawn by
Scalar. The page loads Scalar from the jsDelivr CDN,
so a browser viewing it needs to reach cdn.jsdelivr.net.
With Howdy Admin mounted, the admin finds the document too, and lets you call each endpoint, signed in as any user.
openapi.document(spec, app) gives the document as JSON, to write to a file
for a client generator or check in a test.
Security schemes are declared on the spec with openapi.bearer_auth,
openapi.api_key_header and openapi.api_key_cookie. openapi.require
makes one apply to every endpoint. These only document authentication: a
guard or middleware still has to enforce it.
Building a document panics, when the app starts, if an endpoint reads a path parameter its route does not capture, two different schemas share a name, or two endpoints share an operation id.
Versions
An app with a version group gets one
document per version. Each holds the app's unversioned endpoints, which
answer whatever the version, and the version's own, including those it falls
back to. Where a version overrides a route, its own endpoint is the one
documented, as it is the one that answers. The document's info.version is
the spec's version followed by the group's name in parentheses, such as
1.0.0 (v2), so both the API version and the group it describes are visible.
let api =
version.new(version.path())
|> version.default("v1")
|> version.add("v1", [user_v1.controller()])
|> version.add("v2", [user_v2.controller()])
howdy.new()
|> howdy.versions(api)
|> openapi.serve(spec, at: "/openapi.json")
|> openapi.reference(at: "/docs", document: "/openapi.json")
serve puts each version's document beside the path it is given:
/openapi/v1.json and /openapi/v2.json. The path itself serves the
default version's document, or the newest version's when there is no
default. The reference page offers every version, opening on that one.
How a client asks for a version is documented the way OpenAPI expects:
-
version.path(): paths start with the version, as in/v2/users. -
version.header(name): each versioned operation takes the header, restricted to that version's name. It is required unless the version is the default. -
version.accept(vendor): JSON responses have the vendor media type, such asapplication/vnd.howdy.v2+json, so clients send it inaccept. -
version.custom(fn): the function cannot be described, so paths are shown as they are. Say how to choose a version inopenapi.description.
openapi.version_document(spec, app, "v2") gives one version's document as
JSON, and openapi.versions(app) lists the versions.
See examples/openapi
for a complete API.
