Skip to content

About

Sinatra request/response exchange validation DSL using JSON Schema

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

25 Commits

Folders and files

Repository files navigation

sinatra-exchange-schema

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.

Installation

Add to your Gemfile:

gem 'sinatra-exchange-schema', github: 'attribution/sinatra-exchange-schema'

Setup

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'
end

Declaring Endpoints

Place 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
  # ...
end

Array Responses

When 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
end

Supported 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.

Query Parameters

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
end

Path Parameters

A 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
end

A 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.

Security

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
end

Supported schemes: :bearer (HTTP Bearer token). Use :none to mark an endpoint as public.

Data Types

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
end

The 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.

Schema Builder DSL

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)

Runtime Validation

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.

Undeclared Body Keys

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: false

This 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 :settings with 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
end

Configuration Levels

Settings 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 = true

Per-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
end

Per-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
end

Leaving 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).

Strict in Tests

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      = :strict

Warning Handler

By 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)
end

In :strict mode the callback fires before the error is raised, so warnings are still reported even when the request is halted.

OpenAPI Generation

The gem can generate an OpenAPI 3.1 YAML spec from your endpoint declarations.

Rake Task

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:openapi

Options:

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_types

Multi-File Output

By 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".

Programmatic

require 'sinatra/exchange_schema'

declarations = MyApp::Controllers::Base.endpoint_declarations
doc = Sinatra::ExchangeSchema::OpenapiGenerator.call(
  declarations,
  info: { title: 'My API', version: 'v1' }
)

Development

bundle install
bundle exec rspec

About

Sinatra request/response exchange validation DSL using JSON Schema

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages