Sinatra extension that adds an endpoint DSL for declaring request/response JSON schemas on routes. Validates payloads at runtime and generates an OpenAPI 3.1 spec from the declarations.
Add to your Gemfile:
gem 'sinatra-exchange-schema', github: 'attribution/sinatra-exchange-schema'Register the extension on your Sinatra app:
require 'sinatra/exchange_schema'
class App < Sinatra::Base
register Sinatra::ExchangeSchema
# Optional: set a default auth scheme for all endpoints
set :endpoint_security, :bearer
# Optional: route all endpoints in this controller to a separate OpenAPI file
set :openapi_file, 'admin.yaml'
endPlace endpoint blocks above your route handlers. The block accepts summary, body, query, path, response, and security directives.
endpoint :post, '/articles' do
summary 'Create an article'
body do
string :name, required: true, description: 'Title of the article'
string :status, enum: %w[active archived]
integer :priority
boolean :published, nullable: true
object :metadata do
string :source
end
object :extra_options
array :tags do
string :value
end
end
response 200 do
integer :id, required: true
string :name, required: true
end
end
post '/articles' do
# ...
endWhen an endpoint returns a top-level array, use response with the items: keyword argument. The generated schema is { type: 'array', items: <element schema> } — OpenAPI and validation both see an array, not a bare element.
# Array of strings — e.g. ["key1", "key2", "key3"]
endpoint :get, '/tags' do
summary 'List tags'
response 200, items: :string
end
# Array of arrays (tuples) — e.g. [["value", 1], ["other", 2]]
endpoint :get, '/tags/filtered/:key' do
summary 'Tag values with counts'
response 200, items: :array
end
# Array of objects — e.g. [{"id": 1, "name": "First"}, ...]
endpoint :get, '/articles' do
summary 'List articles'
response 200, items: :object do
integer :id, required: true
string :name, required: true
end
endSupported element types: :string, :integer, :number, :boolean, :array, :object (with a block).
The same symbol syntax works in the array field DSL inside body/query/response blocks — e.g. array :tags, items: :string.
endpoint :get, '/articles' do
summary 'List articles'
query do
string :status, required: true, enum: %w[active archived], description: 'Filter by status'
integer :page, nullable: true
integer :per_page
end
response 200 do
array :articles, required: true do
integer :id, required: true
string :name
end
end
endA path parameter gets its OpenAPI type from its name: one ending in _id is documented as an
integer, any other as a string. When the name misleads — a UUID, a slug or another service's
id behind an _id placeholder — a path block declares the type:
endpoint :get, '/articles/:article_id/uploads/:upload_id' do
summary 'Get an upload'
path do
string :upload_id, description: 'UUID of the upload'
end
endA parameter the block leaves out keeps the name-based type, so article_id above stays an
integer. Every path parameter is required: true whatever the block says, because OpenAPI
requires it. Naming a parameter the route does not have raises when the endpoint is declared.
The block shapes the generated contract only: Sinatra hands path parameters over as strings, and runtime validation covers the body and the query.
A default security scheme can be set at the app level with set :endpoint_security, :bearer. Individual endpoints can override it:
# Public endpoint — no auth required
endpoint :get, '/health' do
security :none
endSupported schemes: :bearer (HTTP Bearer token). Use :none to mark an endpoint as public.
data_types declares what customer data the endpoint's success payloads can expose, as free-form snake_case tokens (/\A[a-z][a-z0-9_]*\z/; anything else raises at declaration time). It is optional and has no runtime effect: absent means the endpoint was not assessed, :none means assessed and exposes nothing. Declare the union across query-param variants — if ?include=address adds the postal address, declare it too.
endpoint :get, '/users/:id' do
security :bearer, scopes: ['users:read']
data_types :email, :postal_address
end
endpoint :get, '/health' do
security :none
data_types :none
endThe OpenAPI generator emits it as the x-data-types extension on the operation ([] for :none, omitted when not declared). Use rake exchange_schema:data_types to review declarations side by side — see Rake Task.
The body, query, path, and response blocks use a builder DSL with these types. (For response, you can also pass items: directly — see Array Responses above.)
| Method | Options |
|---|---|
string |
required:, enum:, nullable:, description: |
integer |
required:, enum:, nullable:, description: |
number |
required:, enum:, nullable:, description: |
boolean |
required:, nullable:, description: |
array |
required:, nullable:, items:, description:, block |
object |
required:, nullable:, description:, block (optional) |
Once registered, the extension installs before/after filters that automatically validate requests and responses against declared schemas. Three independent concerns control behavior:
| Concern | What it checks |
|---|---|
request_validation |
JSON body and query params vs declared schemas |
response_validation |
JSON response body vs response schemas |
missing_schema |
Routes that have no endpoint declaration at all |
Each concern accepts one of three modes:
| Mode | Behavior |
|---|---|
:off |
Skip validation entirely |
:warn |
Log a warning (default) |
:strict |
Log a warning and raise |
A fourth setting, additional_properties, is a boolean rather than a mode — see below.
JSON Schema allows any key a body did not declare, so without this an endpoint answers 200 to
descripton and does nothing with it — the client is never told it misspelled anything.
additional_properties is the JSON Schema keyword of the same name, lifted to a setting. It
defaults to false, so an undeclared key is a violation; an app that is not ready for that turns
it back on:
Sinatra::ExchangeSchema.additional_properties = true # default: falseThis is orthogonal to the modes above: additional_properties decides what counts as a
violation, request_validation decides what happens to one. Leaving it closed while
request_validation is :warn reports undeclared keys without rejecting them — the way to find
out what real clients send before turning the screw.
It also keeps the generated OpenAPI honest: an endpoint that reads a key has to declare it.
Two deliberate exceptions:
- Nested objects stay open.
object :settingswith no block declares no properties, so closing it would reject every key inside. - Query parameters stay open. Proxies, analytics and cache-busting add their own.
An endpoint that genuinely accepts arbitrary keys — a third-party webhook whose payload you do not control — opts out for itself:
endpoint :post, '/webhook' do
additional_properties true
body do
string :event
end
endSettings are resolved with endpoint > controller > app-wide precedence.
App-wide — sets the global default for all controllers:
Sinatra::ExchangeSchema.request_validation = :strict
Sinatra::ExchangeSchema.response_validation = :off
Sinatra::ExchangeSchema.missing_schema = :warn
Sinatra::ExchangeSchema.additional_properties = truePer-controller — overrides the app-wide default for a single Sinatra class:
class Api < Sinatra::Base
register Sinatra::ExchangeSchema
set :request_validation, :strict
set :additional_properties, true
endPer-endpoint — overrides both app-wide and controller settings for one route:
endpoint :post, '/articles' do
request_validation :strict
response_validation :off
additional_properties true
body do
string :name, required: true
end
endLeaving a level unset means "inherit"; an explicit value at a lower level wins over the one
above it, including additional_properties true under an app-wide false. missing_schema has no per-endpoint level — it fires precisely when no
declaration matched. additional_properties is read once, when the endpoint is declared, so
set :additional_properties must appear above the endpoint blocks it should affect (the
same ordering endpoint_security already requires).
A common pattern is to make all validation strict in the test environment:
# spec/spec_helper.rb
Sinatra::ExchangeSchema.request_validation = :strict
Sinatra::ExchangeSchema.response_validation = :strict
Sinatra::ExchangeSchema.missing_schema = :strictBy default, validation warnings are sent to logger.warn. To route them to a
custom reporter (e.g. Rollbar, Sentry), define an on_exchange_schema_warning
instance method on your controller:
def on_exchange_schema_warning(type, message, context)
# type — :request_validation, :response_validation, or :missing_schema
# message — human-readable description
# context — { method:, path:, errors: [, status:] }
# :status is present only for response validation
Rollbar.warning(message, type: type, **context)
endIn :strict mode the callback fires before the error is raised, so warnings
are still reported even when the request is halted.
The gem can generate an OpenAPI 3.1 YAML spec from your endpoint declarations.
Require the rake task explicitly (it is not auto-required):
# tasks/exchange_schema.rake
require 'sinatra/exchange_schema/rake_task'
Sinatra::ExchangeSchema::RakeTask.install(
app: -> { MyApp::Controllers::Base },
info: { title: 'My API', version: 'v1' }
)Then run:
bundle exec rake exchange_schema:openapiOptions:
| Parameter | Default | Description |
|---|---|---|
app: |
(required) | App class or lambda returning one |
info: |
{} |
OpenAPI info (title, version, description) |
output: |
./openapi.yaml |
Output file path (or ENV['OUTPUT']) |
depends_on: |
:environment |
Rake task prerequisites |
install also defines exchange_schema:data_types, a review dump of every declaration: endpoint, scopes, declared data types and the top-level properties of its first 2xx response, one aligned row each (- = not assessed, none = assessed, exposes nothing). DISTINCT=1 prints the distinct data type tokens with the number of endpoints declaring each instead.
bundle exec rake exchange_schema:data_types
DISTINCT=1 bundle exec rake exchange_schema:data_typesBy default every endpoint is written to the single output file (e.g. openapi.yaml). You can route endpoints to separate files with the openapi_file setting.
You can override it per-controller (set :openapi_file, 'admin.yaml') or per-endpoint (openapi_file 'articles.yaml'). Use openapi_file false to exclude an endpoint from all spec files entirely.
Precedence: endpoint > controller > default "openapi.yaml".
require 'sinatra/exchange_schema'
declarations = MyApp::Controllers::Base.endpoint_declarations
doc = Sinatra::ExchangeSchema::OpenapiGenerator.call(
declarations,
info: { title: 'My API', version: 'v1' }
)bundle install
bundle exec rspec