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 param declarations, including their types, defaults and whether they are required.
  • Request bodies come from Avram operations (permit_columns, attribute, file_attribute) or JSON::Serializable types.
  • Responses are described with serializers, JSON::Serializable types 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 with default: 1
  • param page : Int32 = 1, description: "Page", minimum: 1 → the same, with a description and constraint. Accepts every parameter option; parameter calls in the openapi block take precedence.
  • param search : String? → optional query parameter
  • param token : String → required query parameter
  • param 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 is string unless you call path_param "pet_id", Int64
  • ?:page optional path variables → one path with and one without the variable (OpenAPI path parameters are always required)
  • * / *:rest globs → a path parameter

Schemas for Crystal types

Crystal Schema
String, Symbol string
Int8Int64, UInt8UInt64 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::PetPet). 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.0 as 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 with allowEmptyValue: 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": 123 is accepted as "123". Turn off negative_data_rejection for those operations, or parse bodies with JSON::Serializable::Strict types when you need strict typing.

Things Schemathesis will find in your app rather than in the document:

  • Lucky::ErrorAction writes cookies, so configure Lucky::Session even in API-only apps, or error responses fail with a 500.
  • Lucky::HttpMethodOverrideHandler reads _method from the body before Lucky::ErrorHandler runs. 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 before pipe (see example/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
Repository

openapi

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 2
  • about 3 hours ago
  • September 16, 2026
License

Links
Synced at

Wed, 16 Sep 2026 04:08:55 GMT

Languages