Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

## Unreleased

- Added Sinatra integration (OpenapiFirst::Sinatra)
A Sinatra extension to define routes by referencing OpenAPI operations:

```ruby
require 'openapi_first/sinatra'

class PetsApi < Sinatra::Base
register OpenapiFirst::Sinatra
openapi 'openapi.yaml'

operation :index_pets do |params|
json index_pets(params[:filter])
end
end
```

The HTTP method and path for each route come from the operationId.
Request validation is called automatically for these operations
## 3.4.3

Fixed: Loading a document no longer raises `NoMethodError: undefined method 'schema' for nil` when a Media Type Object has no `schema` (e.g. it only declares an `example`). `schema` is optional in a Media Type Object; such media types now impose no body-schema constraint.
Expand Down
64 changes: 63 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,69 @@ Using rack middlewares is supported in probably all Ruby web frameworks.

The contract testing feature is designed to be used via rack-test, which should be compatible all Ruby web frameworks as well.

That aside, closer integration with specific frameworks like Sinatra, Hanami, Roda or others would be great. If you have ideas, pain points or PRs, please don't hesitate to [share](https://github.com/ahx/openapi_first/discussions).
That aside, closer integration with specific frameworks like Hanami, Roda or others would be great. If you have ideas, pain points or PRs, please don't hesitate to [share](https://github.com/ahx/openapi_first/discussions).

### Sinatra

> [!NOTE]
> Experimental.

`OpenapiFirst::Sinatra` is a Sinatra extension to define routes by referencing operations in your OpenAPI description. The URL and HTTP method for each route are read from the OAD by `operationId`, so they are never repeated in your code – the API description stays the single source of truth for your routes.

```ruby
require 'sinatra/base'
require 'openapi_first/sinatra'

class PetsApi < Sinatra::Base
register OpenapiFirst::Sinatra
openapi 'openapi.yaml'

operation :index_pets do |params|
json index_pets(params[:filter])
end

operation :create_pet do
# openapi_request # => OpenapiFirst::ValidatedRequest

json create_pet(parsed_body[:data])
end
end
```

In a **classic** (top-level) app the extension registers itself, so requiring the file is enough:

```ruby
require 'sinatra'
require 'openapi_first/sinatra'

openapi 'openapi.yaml'

operation :create_pet do
json create_pet(parsed_params)
end
```

Each `operation` route validates its request against the description before the block runs, so contract violations return `400`/`415` and the block is not reached. Validation reuses Sinatra's own routing (the operation's path and method are known when the route is defined), so openapi_first does not run its own router – there is no request-validation middleware.

Because routing is left to Sinatra, requests to paths without an `operation` block fall through to Sinatra's normal handling (a `404` by default), and you can add plain Sinatra routes (health checks, assets, …) alongside `operation` blocks. Likewise, an undocumented method on a documented path returns Sinatra's `404`.

Inside an `operation` block:

- `parsed_params` – the path, query and body parameters parsed and coerced per the description, merged into one `Sinatra::IndifferentHash` (so `parsed_params[:id]` and `parsed_params['id']` both work). It contains only the parameters defined in the description, each typed as its schema specifies.
- `params` – Sinatra's own params, left untouched (raw, unparsed values).
- `openapi_request` – the [`ValidatedRequest`](#manual-use) for finer-grained access, e.g. `openapi_request.parsed_body` or `openapi_request.parsed_path_parameters`.

If the block declares an argument, it receives `parsed_params` directly:

```ruby
operation(:show_pet) { |params| json find_pet(params[:id]) }
```

Use a String for operationIds that are not valid Ruby symbols, e.g. `operation 'pets.list'`.

To validate responses too, add the [response validation middleware](#response-validation) yourself, e.g. `use OpenapiFirst::Middlewares::ResponseValidation if ENV['RACK_ENV'] == 'test'`.

See [`examples/sinatra_app.rb`](examples/sinatra_app.rb) for a runnable example.

## Alternatives

Expand Down
20 changes: 17 additions & 3 deletions lib/openapi_first/definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,23 @@ def inspect
# Validates the request against the API description.
# @param [Rack::Request] request The Rack request object.
# @param [Boolean] raise_error Whether to raise an error if validation fails.
# @param [String,nil] path_template The OpenAPI path template (e.g. "/pets/{petId}") of the
# already-matched route. Pass this when your own router has matched the route, to skip
# openapi_first's path matching. Used by framework integrations like Sinatra.
# @param [Hash,nil] path_params The path parameters extracted by your own router
# (e.g. { "petId" => "42" }), keyed by parameter name. Pass this together with +path_template+
# to skip openapi_first's path-parameter extraction; otherwise they are extracted from the path.
# @yield [ValidatedRequest] Optional block called after successful validation.
# The block runs inside the same catch(FAILURE) as the after_request_validation hooks,
# so it may call OpenapiFirst::Failure.fail! to short-circuit and produce an error.
# @return [ValidatedRequest] The validated request object.
def validate_request(request, raise_error: false, &after_block)
route = @router.match(request.request_method, resolve_path(request), content_type: request.content_type)
def validate_request(request, raise_error: false, path_template: nil, path_params: nil, &after_block)
route = match_route(request, path_template, params: path_params)
validated = if route.error
ValidatedRequest.new(request, error: route.error)
else
result = call_before_request_validation_hooks(request, route.request_definition)
result ||= route.request_definition.validate(request, route_params: route.params)
result ||= route.request_definition.validate(request, path_params: route.params)
result.is_a?(Failure) ? ValidatedRequest.new(request, error: result) : result
end
validated = call_after_request_validation_hooks(request, validated, &after_block)
Expand Down Expand Up @@ -117,6 +123,14 @@ def validate_response(request, response, raise_error: false)

private

def match_route(request, path_template, params:)
request_method = request.request_method
content_type = request.content_type
return @router.match_route(request_method, path_template, params:, content_type:) if path_template

@router.match(request_method, resolve_path(request), content_type:)
end

def call_before_request_validation_hooks(request, request_definition)
return if @config.before_request_validation.none?

Expand Down
8 changes: 4 additions & 4 deletions lib/openapi_first/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ def allow_empty_content?
@allow_empty_content
end

def validate(request, route_params:)
parsed_request, error = parse_request(request, route_params:)
def validate(request, path_params:)
parsed_request, error = parse_request(request, path_params:)
error ||= @validator.call(parsed_request) if parsed_request
ValidatedRequest.new(request, parsed_request:, error:, request_definition: self, query_parser:)
end
Expand All @@ -56,15 +56,15 @@ def operation_id

private

def parse_request(request, route_params:)
def parse_request(request, path_params:)
query, query_error = parse_query(request.env[Rack::QUERY_STRING])
return [nil, query_error] if query_error

body = @body_parsers&.call(request)
return [nil, body] if body.is_a?(Failure)

[ParsedRequest.new(
path: @path_parser&.unpack(route_params),
path: @path_parser&.unpack(path_params),
query:,
headers: @headers_parser&.unpack_env(request.env),
cookies: @cookies_parser&.unpack(request.env[Rack::HTTP_COOKIE]),
Expand Down
18 changes: 16 additions & 2 deletions lib/openapi_first/router.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,22 @@ def match(request_method, path, content_type: nil)
return NOT_FOUND.with(error: Failure.new(:not_found, message:))
end

match_path_item(path_item, params, request_method, content_type:)
end

def match_route(request_method, template, params:, content_type: nil)
path_item = @static[template] || @dynamic[template]
unless path_item
message = "Request path #{template} is not defined in API description."
return NOT_FOUND.with(error: Failure.new(:not_found, message:))
end

match_path_item(path_item, params, request_method, content_type:)
end

private

def match_path_item(path_item, params, request_method, content_type:)
contents = path_item.dig(request_method, :requests)
return NOT_FOUND.with(error: Failure.new(:method_not_allowed)) unless contents

Expand All @@ -72,8 +88,6 @@ def match(request_method, path, content_type: nil)
RequestMatch.new(request_definition:, params:, error: nil, responses:)
end

private

def route_at(path, request_method)
request_method = request_method.upcase
path_item = if PathTemplate.template?(path)
Expand Down
188 changes: 188 additions & 0 deletions lib/openapi_first/sinatra.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# frozen_string_literal: true

# :nocov:
begin
require 'sinatra/base'
rescue LoadError
raise LoadError, 'openapi_first/sinatra needs the `sinatra` gem. Add `gem "sinatra"` to your Gemfile.'
end
# :nocov:

require 'did_you_mean'
require 'openapi_first'

module OpenapiFirst
# Sinatra extension to define routes by referencing operations in an OpenAPI description via operationId.
#
# require 'openapi_first/sinatra'
#
# In a classic (top-level) app the extension is registered automatically, so the +openapi+
# and +operation+ keywords are available right away:
#
# require 'sinatra'
# require 'openapi_first/sinatra'
#
# openapi 'openapi.yaml'
# operation :create_customer do
# json create_customer(parsed_params)
# end
#
# In a modular app register it explicitly, like any other Sinatra extension:
#
# require 'sinatra/base'
# require 'openapi_first/sinatra'
#
# class PetsApi < Sinatra::Base
# register OpenapiFirst::Sinatra
# openapi 'openapi.yaml'
#
# operation :index_pets do |params|
# json index_pets(params[:filter])
# end
#
# operation :create_pet do
# json create_pet(parsed_body[:data])
# end
# end
#
# Each +operation+ route validates its request against the OpenAPI description before the block
# runs, so contract violations return 400/415 and the block is not reached. Validation reuses
# Sinatra's own routing (the operation's path template is known when the route is defined), so
# openapi_first does not run its own router - there is no request-validation middleware.
#
# Because routing is left to Sinatra, requests to paths without an +operation+ block fall through
# to Sinatra's normal handling (a 404 by default), and you can add plain Sinatra routes
# (health checks, assets, ...) alongside +operation+ blocks.
# This relaxes the strict approach of openapi_first's request validation middleware
# where all unknown routes that are not described in the OAD return 404. Take care to avoid API drift
module Sinatra
PATH_PARAMETER = /\{[^}]+\}/
private_constant :PATH_PARAMETER

# The configuration lives in Sinatra settings (rather than plain instance variables) so a
# subclass of a configured app inherits the loaded description and its operation index.
def self.registered(app)
app.helpers(Helpers)
# Declared up front so the reader methods exist (returning nil) before #openapi runs,
# which keeps the "call `openapi` first" guard in #operation working.
app.set :openapi_definition, nil
app.set :openapi_operations_index, nil
app.set :openapi_error_response, nil
end

# Loads an OpenAPI description for this app. Call this once per app; the loaded description is
# then available via {#openapi_definition}. Each {#operation} route validates its request
# against the description before the block runs.
# @param spec [String, Symbol, OpenapiFirst::Definition] A file path, a key registered via
# OpenapiFirst.register, or a Definition instance.
# @return [OpenapiFirst::Definition]
# @raise [OpenapiFirst::Error] if {#openapi} has already been called for this app.
def openapi(spec)
raise ::OpenapiFirst::Error, '`openapi` can only be called once per app.' if openapi_definition

definition = ::OpenapiFirst.load(spec)
set :openapi_definition, definition
set :openapi_operations_index, build_operation_index(definition)
set :openapi_error_response, ::OpenapiFirst.configuration.request_validation_error_response
definition
end

# Defines a route for the operation with the given +operationId+. The HTTP method and path
# are taken from the OpenAPI description; the block is the Sinatra route handler.
#
# If the block declares an argument, it receives {Helpers#parsed_params}:
#
# operation(:show_pet) { |params| json find_pet(params[:id]) }
#
# A block without arguments runs as a normal Sinatra route (use {Helpers#parsed_params} inside).
# A block that takes a splat or optional argument (arity < 0) is also passed {Helpers#parsed_params}.
#
# Symbols are the idiomatic form (+operation :create_customer+). Use a String for operationIds
# that are not valid Ruby symbols, e.g. +operation 'pets.list'+.
# @param operation_id [String, Symbol] An operationId present in the API description.
# @raise [OpenapiFirst::Error] if {#openapi} has not been called yet.
# @raise [ArgumentError] if the operationId is not defined in the API description.
def operation(operation_id, &block)
unless openapi_operations_index
raise ::OpenapiFirst::Error, 'Call `openapi` with your API description before defining operations.'
end

request_method, path = openapi_operations_index.fetch(operation_id.to_s) do
raise ArgumentError, unknown_operation_message(operation_id.to_s)
end
public_send(request_method.downcase, sinatra_pattern(path), &operation_handler(path, block))
end

private

def unknown_operation_message(operation_id)
defined_ids = openapi_operations_index.keys
message = "Operation #{operation_id.inspect} is not defined in #{openapi_definition.key}."
suggestions = ::DidYouMean::SpellChecker.new(dictionary: defined_ids).correct(operation_id)
message << if suggestions.any?
" Did you mean #{suggestions.map(&:inspect).join(' or ')}?"
else
" Defined operationIds are: #{defined_ids.join(', ')}."
end
end

def operation_handler(path_template, block)
param_names = path_template.scan(PATH_PARAMETER).map! { |placeholder| placeholder[1..-2] }
proc do |*captures|
path_params = param_names.zip(captures).to_h
validated = settings.openapi_definition.validate_request(request, path_template:, path_params:)
env[::OpenapiFirst::REQUEST] = validated
if (failure = validated.error) && (error_response = settings.openapi_error_response)
halt(*error_response.new(failure:).render)
end

block.arity.zero? ? instance_exec(&block) : instance_exec(parsed_params, &block)
end
end

def sinatra_pattern(path)
path.gsub(PATH_PARAMETER) { |placeholder| ":#{placeholder[1..-2].gsub(/[^A-Za-z0-9_]/, '_')}" }
end

def build_operation_index(definition)
definition.routes.each_with_object({}) do |route, index|
route.requests.each do |request|
operation_id = request.operation_id
next unless operation_id

index[operation_id] ||= [route.request_method, route.path]
end
end
end

# Helpers available inside route blocks.
module Helpers
# The merged path and query parameters parsed and coerced per the OpenAPI description. See also
# OpenapiFirst::ValidatedRequest#parsed_query, OpenapiFirst::ValidatedRequest#parsed_path_parameters).
#
# Sinatra's own +params+ is left untouched and still returns the raw, unparsed values. For
# parts that can have colliding names, read them explicitly via {#openapi_request}
# (e.g. +openapi_request.parsed_headers+).
#
# @return [Sinatra::IndifferentHash]
def parsed_params
::Sinatra::IndifferentHash[openapi_request.parsed_query.merge(openapi_request.parsed_path_parameters)]
end

# The parsed request body
# @return [Sinatra::IndifferentHash]
def parsed_body
::Sinatra::IndifferentHash[openapi_request.parsed_body]
end

# @return [OpenapiFirst::ValidatedRequest] The validated request for the current request.
def openapi_request
env[::OpenapiFirst::REQUEST]
end
end
end
end

# Make the +openapi+/+operation+ keywords available to classic (top-level) apps, so that
# requiring this single file is enough. Modular apps still `register OpenapiFirst::Sinatra`.
Sinatra.register(OpenapiFirst::Sinatra)
Loading
Loading