Generates Sorbet-typed Rails server stubs from an OpenAPI 3 document. Handlers are strict interfaces bound through a generated registry, responses are sealed, and authentication is enforced where the document says it should be. Change the document and the build tells you what no longer compiles.
gem "openapi_kit" # what generated code calls
group :development do
gem "openapi_kit-codegen" # the generator
end- Generating
- Integrating with Rails
- Security
- Custom types
- Files and binary responses
- Not supported yet
- Development
# openapi_kit.yml
spec: openapi/petstore.yaml
output: app/api/petstore/v1
modules: [Petstore, V1]
controller_base: Api::BaseController
principal: "::Petstore::Principal"| Option | Meaning |
|---|---|
spec |
root OpenAPI document, resolved relative to this file |
output |
directory the tree is written into, which openapi_kit owns |
modules |
namespace every generated constant is declared in, so Petstore::V1::Types::Pet |
controller_base |
class the generated controllers inherit from |
principal |
the class a successful authentication produces |
type_mappings |
your Ruby type for a type:format pair |
name_overrides |
a different Ruby name for a schema |
The first four are required.
$ bundle exec openapi_kit generate -c openapi_kit.yml
app/api/petstore/v1/types/pet.rb
app/api/petstore/v1/operations/list_pets.rb
app/api/petstore/v1/handlers/pets.rb
app/api/petstore/v1/controllers/pets_controller.rb
app/api/petstore/v1/security.rb
app/api/petstore/v1/registry.rb
app/api/petstore/v1/routes.rbNothing is written outside output, and modules decides only what the files declare. One
constant per file at the path that constant implies, counting from output, so laying the
directory out to match the namespace is what lets Rails autoload it. Each run wipes
output, but only after checking openapi_kit generated every .rb in it.
Autoload the tree, if it is not already under app/. The root is the directory
modules counts from, not output itself. An acronym in modules is a directory like any
other, so it needs an inflection.
# config/application.rb
config.autoload_paths << Rails.root.join("app/api").to_sDraw the routes. They carry no prefix of their own, so mount them where you like.
# config/routes.rb
Rails.application.routes.draw do
scope "/v1" do
Petstore::V1::Routes.draw(self)
end
endImplement one handler per tag. Miss an operation and Sorbet names the abstract method. Return an undeclared response variant and it will not compile.
class PetsHandler
extend T::Sig
include Petstore::V1::Handlers::Pets
sig do
override.params(request: Petstore::V1::Operations::ListPets::Request)
.returns(Petstore::V1::Operations::ListPets::Response)
end
def list_pets(request:)
pets = Pet.where(store: request.path.store_id).page(request.query.page)
Petstore::V1::Operations::ListPets::Ok.new(body: pets.map { |pet| present(pet) })
end
endGive the controllers a base class. They inherit whatever you put there and need
nothing from it, so this is where your own concerns and error mapping live. A request
openapi_kit cannot decode raises OpenAPIKit::DecodeError, carrying detail and a json_pointer
naming the field, and openapi_kit takes no view on the wire format.
# app/controllers/api/base_controller.rb
module Api
class BaseController < ApplicationController
rescue_from OpenAPIKit::DecodeError, with: :unprocessable
rescue_from OpenAPIKit::SecurityError, with: :unauthorized
private
def unauthorized = head(:unauthorized)
def unprocessable(error)
render json: { detail: error.detail, pointer: error.json_pointer },
status: 422
end
end
endAssign the registry. openapi_kit generates a Registry struct with one slot per handler
and authenticator, and controllers read the one you assigned. Rails instantiates controllers
itself, so they cannot be handed one. Omit a slot, or pass something that does not
implement its interface, and it does not compile.
# config/initializers/openapi_kit.rb
Rails.application.config.to_prepare do
Petstore::V1::Registry.instance = Petstore::V1::Registry.new(
pets: PetsHandler.new(repo: PetRepo.new),
system: SystemHandler.new,
bearer_auth: BearerAuthenticator.new(decoder: TokenDecoder.new)
)
endThe registry is openapi_kit's boundary and nothing more. What a handler needs behind it is yours, and unlike controllers you construct handlers, so they take whatever they need.
Building the registry in to_prepare constructs every handler at boot, along with
whatever their constructors resolve, and reassigns them on a code reload.
Where the document declares security, the generated action authenticates before decoding
anything, and your handler receives the principal. Nothing else can reach the handler.
security: [{ bearerAuth: [] }] # the document's default
paths:
/stores/{storeId}/pets:
post:
security: # this operation overrides it
- bearerAuth: [pets:write]
- apiKeyAuth: []Name what authentication produces, and seal it if your schemes produce different shapes:
that is what makes a handler's case exhaustive.
module Petstore::Principal
extend T::Helpers
sealed!
class Token < T::Struct # a JWT carries its permissions
include Petstore::Principal
const :user_id, Integer
const :permissions, T::Array[String]
end
class Key < T::Struct # an API key does not
include Petstore::Principal
const :client, String
end
endThen write one authenticator per scheme. Return nil to say this alternative was not
satisfied, so openapi_kit tries the next one. Where the credential lives is what the document
declares, so credential is implemented for you.
class BearerAuthenticator
extend T::Sig
include Petstore::V1::Security::BearerAuth
sig { params(decoder: TokenDecoder).void }
def initialize(decoder:)
@decoder = decoder
end
sig do
override.params(request: ActionDispatch::Request, scopes: T::Array[String])
.returns(T.nilable(Petstore::Principal::Token))
end
def authenticate(request:, scopes:)
token = credential(request) or return nil
claims = @decoder.decode(token) or return nil
return nil unless scopes.all? { |scope| claims.scopes.include?(scope) }
Petstore::Principal::Token.new(user_id: claims.sub, permissions: claims.scopes)
end
endcredential reads the Authorization header and strips the declared scheme for http,
oauth2 and openIdConnect, and reads the named header, query parameter or cookie for
apiKey. A basic scheme also gets basic_credential, returning the decoded
[user, password].
Your handler then reads request.principal:
def create_pet(request:)
owner = case request.principal
when Petstore::Principal::Token then "user-#{request.principal.user_id}"
when Petstore::Principal::Key then request.principal.client
else T.absurd(request.principal)
end
endAlternatives are tried in document order and the first to produce a principal wins. If
none do, openapi_kit raises OpenAPIKit::SecurityError. An operation offering anonymous
access (security: [..., {}]) makes the context T.nilable and raises nothing.
SCHEMES is the document's securitySchemes as typed values, so an authenticator reads
its own configuration rather than restating the document.
type |
The scheme carries |
|---|---|
http |
scheme, bearer_format |
apiKey |
location, parameter_name |
oauth2 |
the declared scopes catalogue |
openIdConnect |
url, the discovery document |
Plus any x- keys, on all four. OpenAPI 3.0 fixes these four types, so a bespoke scheme
is an http one with your own name, and anything the type cannot express goes in x-:
securitySchemes:
hmacAuth:
type: http
scheme: HMAC-SHA256
x-signing-key: SIGNING_KEYThat reaches the scheme as extensions, which is also where an oauth2 scheme's issuer
and JWKS URI belong, since OpenAPI has no field for them. An openIdConnect scheme needs
neither, because url discovers both.
A type:format pair with no built-in mapping is an error naming the config to add:
type_mappings:
"string:money":
type: "::Money"
codec: "MyApp::MoneyCodec"type appears in signatures. codec converts it, and Value ties the halves together so
a codec cannot decode one type and encode another:
module MyApp::MoneyCodec
extend T::Sig
extend T::Generic
extend OpenAPIKit::Codec::Contract
Value = type_template { { fixed: ::Money } }
sig { override.params(value: OpenAPIKit::Wire).returns(::Money) }
def self.from_wire(value) = ::Money.parse(OpenAPIKit::Codec::String.from_wire(value))
sig { override.params(value: ::Money).returns(OpenAPIKit::Wire) }
def self.to_wire(value) = value.format
endcodec need only name a constant answering from_wire and to_wire, so a codec that
needs configuration can include the contract instead and you name the instance you
built. The same override works inline, for one property rather than every occurrence of a
format:
price:
type: string
x-ruby-type: "::Money"
x-ruby-codec: "MyApp::MoneyCodec"A file is the one thing outside OpenAPIKit::Wire, the value model every media type shares,
so it never goes through a codec. format: binary is handled in two places instead, and
refused everywhere else.
An upload is a property of a multipart/form-data request body, decoded through a
Form where other types carry a Codec:
class UploadPetPhotoBody < T::Struct
const :photo, ::ActionDispatch::Http::UploadedFile
const :description, T.nilable(::String)
end
def upload_pet_photo(request:)
Blob.store!(io: request.body.photo.tempfile)
Petstore::V1::Operations::UploadPetPhoto::NoContent.new
endA binary response is the whole body, taking a file on disk or a block that writes bytes:
body: OpenAPIKit::Body::File.new(path: Rails.root.join("photos", name))
body: OpenAPIKit::Body::Stream.new(
body: ->(sink) { Archive.open(pet) { |zip| IO.copy_stream(zip, sink) } }
)
body: OpenAPIKit::Body::Stream.new(body: ->(sink) { sink << header << row })A path names the file and nothing more, so the server sends it however it likes: sendfile
under Puma, an X-Accel-Redirect or X-Sendfile under nginx and Apache through
Rack::Sendfile, and Content-Length comes off the file. A stream sends no
Content-Length, and whatever its block opens it also closes. Neither supports Range.
Every response variant answers to_body, a sealed OpenAPIKit::Body of Empty, Json,
Stream or File. string:binary takes no type_mappings entry, since a
file has nothing for a codec to convert.
Refused at generation time, rather than mis-generated:
- Parameter styles other than
simplefor path andformfor query. - One content type per request body, and it must be
application/json, a+jsontype,application/x-www-form-urlencodedormultipart/form-data. A response body whose schema isformat: binarymay declare any content type at all. - Two security schemes required together in one alternative (
{a: [], b: []}). One scheme per alternative. - A path template Rails cannot route, such as
{pet-id}.
Documented behaviour to know about:
- Array and object query parameters follow Rails' conventions, not OpenAPI's: send
?tags[]=a&tags[]=band?filter[lat]=1, not?tags=a&tags=bor an exploded?lat=1. - Schema keyword validation (
minLength,pattern,minimum) is not enforced. Types and formats only. - Codecs coerce strings, since path, query and header values arrive as strings. That
leniency also applies to bodies, so
{"count": "42"}satisfiestype: integer. format: binaryis only valid as a top-level property of amultipart/form-datarequest body, or as the whole schema of a response body. Anywhere else is refused: a file is bytes rather than a parsed value, so no codec can convert it. Useformat: byteto carry bytes inside a value.- An OAuth2 flow's
authorizationUrl,tokenUrlandrefreshUrlare not carried, since they tell a client where to obtain a token and a resource server never calls them.
$ bundle exec rake golden # regenerate the output the specs compare against
$ bundle exec rake # rspec, srb tc, rubocopsrb tc covers spec/golden as well as the generator, so output that does not typecheck
fails the build. spec/dummy is a Rails application whose app/api is generated the same
way, and spec/generated/rails_request_spec.rb issues real requests against it.
ARCHITECTURE.md covers the pipeline and where to change what.