openapi
openapi
Generate and serve OpenAPI documents for Lucky applications.
The document is built from the code you already have:
- Routes and path variables come from Lucky (
get "/pets/:pet_id"). - Query parameters come from
paramdeclarations, including their types, defaults and whether they are required. - Request bodies come from Avram operations (
permit_columns,attribute,file_attribute) orJSON::Serializabletypes. - Responses are described with serializers,
JSON::Serializabletypes or any Crystal type (Array(PetSerializer),Hash(String, Int32), enums, unions, named tuples...).
You add descriptions, examples, constraints, security and responses with a small DSL. The output is OpenAPI 3.1 (or 3.0) that works with documentation tools and client generators, and it can be checked against the running API with Schemathesis.
Installation
Add the dependency to your shard.yml:
dependencies:
openapi:
github: dev-transparent/openapi
Run shards install, then require it in src/shards.cr after Lucky and Avram:
require "lucky"
require "avram/lucky"
require "openapi/lucky"
require "openapi/avram" # optional: request bodies from Avram operations
Quick start
1. Configure the document
# config/openapi.cr
OpenAPI.configure do |config|
config.title = "Pet Store"
config.version = "1.0.0"
config.description = "Manage the pets in the store."
config.server "https://api.example.com", description: "Production"
# `Api::V1::Pets::Index` gets the operationId `pets_index` and the tag `Pets`
config.strip_namespaces = ["Api::V1"]
config.security_scheme "bearer", OpenAPI::SecurityScheme.bearer(format: "JWT")
config.tag "Pets", description: "Add, find and manage pets"
end
2. Document your actions
Include OpenAPI::Action in your base action. Only actions that include it are documented.
abstract class ApiAction < Lucky::Action
include OpenAPI::Action
accepted_formats [:json]
# Inherited by every action
openapi do
response 400, ErrorSerializer, description: "The request was malformed"
# Lucky responds with 422 when a `param` can't be parsed
response 422, ErrorSerializer, description: "A param couldn't be parsed" if lucky_params?
end
end
class Api::V1::Pets::Index < ApiAction
param page : Int32 = 1, description: "Page number", minimum: 1
param status : String?, enum_values: %w(available pending sold)
openapi do
summary "List pets"
description "Returns a page of pets, optionally filtered by status."
response 200, PetListSerializer, headers: {"X-Total-Count" => header(Int32, required: true)}
end
get "/api/v1/pets" do
# ...
end
end
The name, type, default and whether a param is required come from Lucky's param: page is an optional int32 defaulting to 1 and status is an optional string. Extra keywords on param add documentation; without them param is exactly Lucky's macro.
3. Describe your serializers
Lucky serializers render a NamedTuple or Hash, which has no type information to reflect on, so declare the fields with OpenAPI::Schemable:
abstract class BaseSerializer
include Lucky::Serializable
include OpenAPI::Schemable
end
@[OpenAPI::Model(description: "A pet in the store")]
class PetSerializer < BaseSerializer
field id : Int64, description: "Unique identifier", example: 1
field name : String, min_length: 1, max_length: 100
field status : PetStatus
field tag : String?
field owner : OwnerSerializer
def initialize(@pet : Pet)
end
def render
{id: @pet.id, name: @pet.name, status: @pet.status, tag: @pet.tag, owner: OwnerSerializer.new(@pet.owner)}
end
end
This becomes the component schema Pet (the Serializer suffix is removed). Fields are required by default because serializers always render every key; nilable fields accept null. Use required: false for keys that may be missing and key: "type" when the JSON key differs from the field name.
4. Serve the document
# src/actions/openapi.cr
OpenAPI.serve
This adds GET /openapi.json, GET /openapi.yaml and a documentation page at GET /docs. Options:
OpenAPI.serve base: Lucky::Action, json_path: "/openapi.json", yaml_path: nil, docs_path: "/docs", ui: :scalar
ui can be :swagger (Swagger UI), :redoc or :scalar. The actions accept any Accept header even when base restricts accepted_formats. Add your own pipes (for example authentication) by passing a different base.
To write your own actions instead, include OpenAPI::Endpoints and use openapi_spec (JSON or YAML depending on what the client asked for), openapi_json, openapi_yaml or openapi_docs(spec_url, ui: :redoc):
class Docs::Spec < Lucky::Action
include OpenAPI::Endpoints
get "/openapi" do
openapi_spec
end
end
Lucky 1.5 and route extensions
Lucky 1.5 strips known format extensions (.json, .yaml, .csv...) from the path before matching routes, so a route declared as get "/openapi.json" is looked up as /openapi and never matches. Declare routes without the extension and read the format from the request.
OpenAPI.serve handles this for you: on Lucky 1.5 it mounts one route at /openapi that answers /openapi, /openapi.json and /openapi.yaml, and on earlier versions it mounts the paths as given. The URLs you use don't change.
5. Generate a file (optional)
# tasks.cr
require "openapi/lucky/task"
lucky openapi.generate # openapi.json
lucky openapi.generate -o openapi.yaml # YAML
lucky openapi.generate --path=client.json
lucky openapi.generate -o - # stdout
Commit the file, or generate clients from it in CI:
npx @openapitools/openapi-generator-cli generate -i openapi.json -g typescript-fetch -o client
The openapi DSL
The block runs with an OpenAPI::OperationBuilder as the receiver.
| Method | Purpose |
|---|---|
summary "...", description "..." |
Operation text (descriptions support CommonMark) |
tags "A", "B" / tag "A" |
Replace / add tags. Defaults to the action's namespace (Pets) |
operation_id "list_pets" |
Defaults to the underscored action name |
deprecated |
Mark as deprecated |
security "bearer" / security "oauth", scopes: [...] |
Require a security scheme; call again for alternatives |
no_security |
Public operation when security is set document-wide |
parameter name, Type?, location: :query, **options |
Describe or add a parameter |
query_param, path_param, header_param, cookie_param |
Shortcuts for parameter |
request_body Type, description:, required:, content_type:, example: |
Request body from a type or OpenAPI::Schema |
response status, Type?, description:, content_type:, headers:, example:, examples:, links: |
Describe a response |
header(Type, description:, required:, **constraints) |
Build a response header |
link("operation_id", {"pet_id" => "$response.body#/id"}) |
Build a response link |
example(value, summary:) |
Build a named example |
schema(Type), one_of(A, B, discriminator: "kind"), any_of, all_of |
Compose schemas |
extension "x-internal", true, external_docs url, server url |
Everything else |
exclude |
Leave the route out of the document |
lucky_params? |
True when the action declares Lucky params (useful in base actions) |
status may be an Int32, an HTTP::Status, "default" or a range like "4XX". Responses without a type have no body (response 204).
Parameter and field options include description, example, examples, deprecated, required, style, explode, and schema constraints: format, enum_values, default, minimum, maximum, exclusive_minimum, exclusive_maximum, multiple_of, min_length, max_length, pattern, min_items, max_items, unique_items, read_only, write_only, nullable.
Inheritance and mixins
openapi blocks run parent first, so a base action can declare shared responses and a module can document what its pipe does:
module RequireApiToken
macro included
before require_api_token
openapi do
security "bearer"
response 401, ErrorSerializer, description: "Missing or invalid API token"
end
end
end
Lucky params and paths
param page : Int32 = 1→ optional query parameter withdefault: 1param page : Int32 = 1, description: "Page", minimum: 1→ the same, with a description and constraint. Accepts everyparameteroption;parametercalls in theopenapiblock take precedence.param search : String?→ optional query parameterparam token : String→ required query parameterparam ids : Array(Int64)?→ids[]array parameter, as Lucky reads it:pet_id→ required path parameter. Lucky gives path variables to actions as strings, so the type isstringunless you callpath_param "pet_id", Int64?:pageoptional path variables → one path with and one without the variable (OpenAPI path parameters are always required)*/*:restglobs → a path parameter
Schemas for Crystal types
| Crystal | Schema |
|---|---|
String, Symbol |
string |
Int8–Int64, UInt8–UInt64 |
integer with int32/int64 format and range |
Float32, Float64 |
number (float/double) |
Bool |
boolean |
Time |
string, date-time |
UUID, URI |
string, uuid / uri |
T? |
T or null |
A | B |
anyOf |
Array(T), Deque(T), Set(T) |
array (uniqueItems for sets) |
Hash(String, T) |
object with additionalProperties |
Tuple(A, B) |
array with prefixItems |
NamedTuple(a: A) |
inline object |
enum |
component with string values as serialized by Crystal (dark_blue); @[Flags] enums are arrays |
JSON::Any |
any value |
JSON::Serializable |
component object |
OpenAPI::Schemable |
component object from field declarations |
JSON::Serializable
Instance variables become properties. JSON::Field(key:, ignore:, ignore_serialize:, ignore_deserialize:, converter:) are respected, and JSON::Serializable::Strict sets additionalProperties: false. Properties are required when they are not nilable and have no default.
Include JSON::Serializable::Strict in request body types when you want unknown keys rejected; the document then says so and clients and testing tools won't send extra properties.
@[OpenAPI::Model(name: "UpdatePet", description: "Fields to change on a pet")]
struct UpdatePetInput
include JSON::Serializable
@[OpenAPI::Field(description: "Name of the pet", min_length: 1, max_length: 100)]
getter name : String?
@[OpenAPI::Field(required: true, format: "email")]
getter contact : String?
@[OpenAPI::Field(ignore: true)]
getter internal_note : String?
end
Converters Time::EpochConverter, Time::EpochMillisConverter, Enum::ValueConverter, JSON::ArrayConverter, JSON::HashValueConverter and String::RawConverter are understood. Custom converters can define self.openapi_schema(registry).
Custom schemas
Any type can describe itself:
struct EmailAddress
def self.openapi_schema(registry : OpenAPI::Registry) : OpenAPI::Schema
OpenAPI::Schema.string("email")
end
end
Named types are registered as components under their class name without the namespace (Api::V1::Pet → Pet). Two types with the same name raise OpenAPI::ComponentCollisionError; give one a name with @[OpenAPI::Model(name: "...")] or use config.schema_naming = :full.
Avram operations
With require "openapi/avram", request_body SaveUser describes the params the operation reads, nested under its param_key:
{"user": {"email": "...", "name": "..."}}
The body is registered as two components, SaveUser and SaveUserAttributes, so generated clients get readable model names.
Permitted columns (permit_columns) and attributes are included. Nilable columns and String attributes accept null, matching how Avram parses JSON params. Fields are optional by default because the same operation often creates and updates records.
Describe attributes where they're declared. Columns are declared on the model, so describe them with openapi_attribute:
class SaveUser < User::SaveOperation
permit_columns email, name
attribute password : String, required: true, min_length: 8, write_only: true
openapi_attribute :email, required: true, format: "email"
end
Both accept required and every schema option (description, example, format, min_length...).
Operations with file_attribute are described as multipart/form-data using Lucky's user:avatar field names.
OpenAPI 3.0
Documents are generated as OpenAPI 3.1. Some client generators still work best with 3.0:
OpenAPI.configure do |config|
config.openapi_version = OpenAPI::VERSION_3_0
end
Nullable types, $ref siblings, examples, tuples and exclusive bounds are converted to their 3.0 equivalents.
405 Method Not Allowed
Lucky responds with 404 when a path exists but not for the request's method. To respond with 405 and an Allow header instead (Schemathesis checks for this), add the handler after Lucky::RouteHandler:
Lucky::RouteHandler.new,
OpenAPI::MethodNotAllowedHandler.new,
Lucky::RouteNotFoundHandler.new,
Testing with Schemathesis
Schemathesis generates requests from the document and checks the responses against it: status codes, content types, headers, response schemas, invalid input being rejected, authentication and more.
uv tool install --python 3.13 schemathesis # or: pipx install schemathesis
st run http://localhost:3000/openapi.json -H "Authorization: Bearer $TOKEN"
Use a recent Schemathesis (4.27 or later). Older releases report false failures for OpenAPI 3.1 nullable strings with maxLength.
The example app is a small Lucky API that uses every feature above. example/script/schemathesis builds it, starts it and runs Schemathesis. example/schemathesis.toml enables every check and explains the few adjustments a Lucky app needs:
- Unknown query params: Lucky ignores query params an action doesn't declare. Set
generation.allow-extra-parameters = false. - Positive data acceptance: JSON Schema treats
29.0as an integer and allows numbers larger than Int64. Crystal's JSON parser rejects both, so this check reports failures that aren't documentation problems. - Empty query params: Lucky treats
?page=as a missing optional param, so optional params are documented withallowEmptyValue: true. The Schemathesis fuzzer doesn't take that into account and occasionally reports an empty optional param as accepted invalid data. - Avram request bodies: Avram receives JSON values as strings, so
"name": 123is accepted as"123". Turn offnegative_data_rejectionfor those operations, or parse bodies withJSON::Serializable::Stricttypes when you need strict typing.
Things Schemathesis will find in your app rather than in the document:
Lucky::ErrorActionwrites cookies, so configureLucky::Sessioneven in API-only apps, or error responses fail with a 500.Lucky::HttpMethodOverrideHandlerreads_methodfrom the body beforeLucky::ErrorHandlerruns. A JSON body that isn't an object ([],null) makes it raise outside the error handler. Remove it from API apps.- Params can only be read from JSON object bodies; reject other JSON values with a
beforepipe (seeexample/src/actions/api_action.cr).
Without Lucky
require "openapi" loads only the document model and schema generation:
registry = OpenAPI::Registry.new
schema = registry.schema_for(Array(PetInput))
document = OpenAPI::Document.new(OpenAPI::Info.new("API", "1.0"))
document.components = OpenAPI::Components.new(schemas: registry.schemas)
document.to_json
Development
shards install
crystal spec
cd example && shards install && script/schemathesis
openapi
- 0
- 0
- 0
- 0
- 2
- about 3 hours ago
- September 16, 2026
Wed, 16 Sep 2026 04:08:55 GMT