From de8bce8a3b247da1b1efc34d773a2f93918a1859 Mon Sep 17 00:00:00 2001 From: Andreas Haller Date: Wed, 3 Jun 2026 15:22:53 +0200 Subject: [PATCH] Add Sinatra integration (OpenapiFirst::Sinatra) A Sinatra extension to define routes by referencing OpenAPI operations, so URLs and HTTP methods live only in the description: 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. --- CHANGELOG.md | 18 ++ README.md | 64 ++++- lib/openapi_first/definition.rb | 20 +- lib/openapi_first/request.rb | 8 +- lib/openapi_first/router.rb | 18 +- lib/openapi_first/sinatra.rb | 188 +++++++++++++ spec/data/sinatra-hyphen-param.yaml | 17 ++ spec/router_spec.rb | 31 +++ spec/sinatra_spec.rb | 391 ++++++++++++++++++++++++++++ 9 files changed, 745 insertions(+), 10 deletions(-) create mode 100644 lib/openapi_first/sinatra.rb create mode 100644 spec/data/sinatra-hyphen-param.yaml create mode 100644 spec/sinatra_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index b9b68946..93be3f1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index c3ca7f42..92445c90 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/openapi_first/definition.rb b/lib/openapi_first/definition.rb index 9be0ea4b..403bb790 100644 --- a/lib/openapi_first/definition.rb +++ b/lib/openapi_first/definition.rb @@ -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) @@ -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? diff --git a/lib/openapi_first/request.rb b/lib/openapi_first/request.rb index ed02ce5e..44d4c25e 100644 --- a/lib/openapi_first/request.rb +++ b/lib/openapi_first/request.rb @@ -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 @@ -56,7 +56,7 @@ 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 @@ -64,7 +64,7 @@ def parse_request(request, route_params:) 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]), diff --git a/lib/openapi_first/router.rb b/lib/openapi_first/router.rb index d31de3e1..0bae46c8 100644 --- a/lib/openapi_first/router.rb +++ b/lib/openapi_first/router.rb @@ -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 @@ -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) diff --git a/lib/openapi_first/sinatra.rb b/lib/openapi_first/sinatra.rb new file mode 100644 index 00000000..17a34783 --- /dev/null +++ b/lib/openapi_first/sinatra.rb @@ -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) diff --git a/spec/data/sinatra-hyphen-param.yaml b/spec/data/sinatra-hyphen-param.yaml new file mode 100644 index 00000000..dffa4946 --- /dev/null +++ b/spec/data/sinatra-hyphen-param.yaml @@ -0,0 +1,17 @@ +openapi: "3.0.0" +info: + title: Hyphenated path parameter + version: 1.0.0 +paths: + /things/{thing-id}: + get: + operationId: showThing + parameters: + - name: thing-id + in: path + required: true + schema: + type: string + responses: + "200": + description: ok diff --git a/spec/router_spec.rb b/spec/router_spec.rb index e41c4d0a..5d2dbc7a 100644 --- a/spec/router_spec.rb +++ b/spec/router_spec.rb @@ -217,6 +217,37 @@ end end + describe '#match_route' do + let(:requests) do + [ + double(path: '/{id}', request_method: 'get'), + double(path: '/a', request_method: 'get') + ] + end + + subject(:router) do + described_class.new.tap do |router| + requests.each do |request| + router.add_request(request, request_method: request.request_method, path: request.path) + end + end + end + + it 'uses caller-provided params instead of extracting them from the path' do + match = router.match_route('GET', '/{id}', params: { 'id' => '42' }) + expect(match.request_definition).to be(requests[0]) + expect(match.params).to eq('id' => '42') + end + + it 'returns a not_found error for an unknown template' do + expect(router.match_route('GET', '/unknown', params: {}).error).to have_attributes(type: :not_found) + end + + it 'returns a method_not_allowed error for an undefined method on a known template' do + expect(router.match_route('DELETE', '/{id}', params: {}).error).to have_attributes(type: :method_not_allowed) + end + end + describe '#routes' do subject(:router) do described_class.new.tap do |router| diff --git a/spec/sinatra_spec.rb b/spec/sinatra_spec.rb new file mode 100644 index 00000000..79af4cf3 --- /dev/null +++ b/spec/sinatra_spec.rb @@ -0,0 +1,391 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' +require 'json' +require 'rack/test' +require 'sinatra/base' +require 'openapi_first/sinatra' + +RSpec.describe OpenapiFirst::Sinatra do + include Rack::Test::Methods + + let(:petstore) { File.expand_path('data/petstore.yaml', __dir__) } + + let(:app) do + petstore_path = petstore + Class.new(Sinatra::Base) do + set :environment, :test + set :raise_errors, true + set :show_exceptions, false + register OpenapiFirst::Sinatra + openapi petstore_path + + operation :listPets do + content_type :json + JSON.generate(parsed_params) + end + + operation :showPetById do + content_type :json + JSON.generate(parsed_params) + end + + operation :createPets do + status 201 + end + end + end + + it 'routes a GET operation to its block and exposes parsed params' do + get '/pets?limit=3' + + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)).to eq('limit' => 3) # coerced to integer per the OAD + end + + it 'derives the path (including path parameters) from the OpenAPI description' do + get '/pets/42' + + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)).to eq('petId' => '42') + end + + it 'routes a POST operation to its block' do + post '/pets' + + expect(last_response.status).to eq(201) + end + + it 'returns 400 for a request that violates the contract (block not reached)' do + get '/pets?limit=not-an-integer' + + expect(last_response.status).to eq(400) + end + + it 'returns 404 for a path that is not in the OpenAPI description' do + get '/unknown' + + expect(last_response.status).to eq(404) + end + + it 'lets a plain Sinatra route alongside operations respond (routing is left to Sinatra)' do + petstore_path = petstore + klass = Class.new(Sinatra::Base) do + set :environment, :test + set :raise_errors, true + set :show_exceptions, false + register OpenapiFirst::Sinatra + openapi petstore_path + + operation :listPets do + content_type :json + JSON.generate([]) + end + get('/health') { 'ok' } + end + + session = Rack::Test::Session.new(klass) + + session.get('/health') + expect(session.last_response.status).to eq(200) + expect(session.last_response.body).to eq('ok') + + session.get('/pets') # the operation route still works alongside the plain route + expect(session.last_response.status).to eq(200) + expect(session.last_response.body).to eq('[]') + end + + it 'returns Sinatra\'s 404 for an undocumented method on a documented path' do + delete '/pets/42' # showPetById defines GET, not DELETE + + expect(last_response.status).to eq(404) + end + + it 'runs request validation hooks exactly once per request' do + petstore_path = petstore + calls = [] + definition = OpenapiFirst.load(petstore_path) do |config| + config.before_request_validation { |_request, _definition| calls << :before } + config.after_request_validation { |_validated, _definition| calls << :after } + end + klass = Class.new(Sinatra::Base) do + set :environment, :test + set :raise_errors, true + set :show_exceptions, false + register OpenapiFirst::Sinatra + openapi definition + + operation :showPetById do + content_type :json + JSON.generate({}) + end + end + + session = Rack::Test::Session.new(klass) + session.get('/pets/42') + + expect(session.last_response.status).to eq(200) + expect(calls).to eq(%i[before after]) + end + + it 'exposes the loaded definition via openapi_definition' do + expect(app.openapi_definition).to be_a(OpenapiFirst::Definition) + end + + context 'with the parsed_params helper' do + let(:app) do + petstore_path = petstore + Class.new(Sinatra::Base) do + set :environment, :test + set :raise_errors, true + set :show_exceptions, false + register OpenapiFirst::Sinatra + openapi petstore_path + + operation :listPets do + content_type :json + JSON.generate( + symbol: parsed_params[:limit], # coerced, symbol access + string: parsed_params['limit'], # coerced, string access + raw: params['limit'] # Sinatra's native params, untouched + ) + end + end + end + + it 'exposes coerced params with indifferent access and leaves Sinatra params raw' do + get '/pets?limit=3' + + expect(last_response.status).to eq(200) + body = JSON.parse(last_response.body) + expect(body['symbol']).to eq(3) # coerced to Integer per the OAD + expect(body['string']).to eq(3) # same value, string access + expect(body['raw']).to eq('3') # Sinatra's params keeps the original String + end + end + + context 'when the operation block declares an argument' do + let(:app) do + petstore_path = petstore + Class.new(Sinatra::Base) do + set :environment, :test + set :raise_errors, true + set :show_exceptions, false + register OpenapiFirst::Sinatra + openapi petstore_path + + operation :showPetById do |params| + content_type :json + JSON.generate(id: params[:petId]) # block receives the parsed params, not URL captures + end + end + end + + it 'passes parsed_params to the block' do + get '/pets/42' + + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)).to eq('id' => '42') + end + end + + it 'ignores operations without an operationId when indexing' do + missing = File.expand_path('data/operation-id-missing.yaml', __dir__) + klass = Class.new(Sinatra::Base) do + register OpenapiFirst::Sinatra + openapi missing + # POST /pets has operationId `createPets`; GET /pets has none and is simply not addressable. + operation :createPets do + status 201 + end + end + + session = Rack::Test::Session.new(klass) + session.post('/pets') + + expect(session.last_response.status).to eq(201) + end + + it 'lets a subclass inherit the loaded description and routing' do + petstore_path = petstore + parent = Class.new(Sinatra::Base) do + set :environment, :test + set :raise_errors, true + set :show_exceptions, false + register OpenapiFirst::Sinatra + openapi petstore_path + + operation :listPets do + content_type :json + JSON.generate(parsed_params) + end + end + child = Class.new(parent) + + session = Rack::Test::Session.new(child) + session.get('/pets?limit=3') + + expect(session.last_response.status).to eq(200) + expect(JSON.parse(session.last_response.body)).to eq('limit' => 3) + expect(child.openapi_definition).to eq(parent.openapi_definition) + end + + it 'raises if openapi is called twice for the same app' do + petstore_path = petstore + expect do + Class.new(Sinatra::Base) do + register OpenapiFirst::Sinatra + openapi petstore_path + openapi petstore_path + end + end.to raise_error(OpenapiFirst::Error, /once per app/) + end + + it 'raises if operation is called before openapi' do + expect do + Class.new(Sinatra::Base) do + register OpenapiFirst::Sinatra + operation(:listPets) { 'never' } + end + end.to raise_error(OpenapiFirst::Error, /Call `openapi`/) + end + + it 'raises if the operationId is not defined in the description, listing the defined operations' do + petstore_path = petstore + expect do + Class.new(Sinatra::Base) do + register OpenapiFirst::Sinatra + openapi petstore_path + operation(:doesNotExist) { 'never' } + end + end.to raise_error(ArgumentError, /doesNotExist.*Defined operationIds are: listPets, createPets, showPetById/m) + end + + it 'suggests the closest operationId for a typo (Did you mean)' do + petstore_path = petstore + expect do + Class.new(Sinatra::Base) do + register OpenapiFirst::Sinatra + openapi petstore_path + operation(:listPet) { 'never' } # typo for listPets + end + end.to raise_error(ArgumentError, /Did you mean "listPets"\?/) + end + + context 'with a JSON request body and an integer path parameter' do + let(:body_spec) { File.expand_path('data/request-body-validation.yaml', __dir__) } + + let(:app) do + spec_path = body_spec + Class.new(Sinatra::Base) do + set :environment, :test + set :raise_errors, true + set :show_exceptions, false + register OpenapiFirst::Sinatra + openapi spec_path + + operation :create_pet do + content_type :json + JSON.generate(parsed_body) + end + + operation :update_pet do + content_type :json + id = parsed_params['id'] + JSON.generate('id' => id, 'id_class' => id.class.name) + end + end + end + + it 'parses a JSON request body via openapi_request.parsed_body' do + post '/pets', JSON.generate(type: 'pet', attributes: { name: 'Rex' }), + 'CONTENT_TYPE' => 'application/json' + + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)).to eq('type' => 'pet', 'attributes' => { 'name' => 'Rex' }) + end + + it 'returns 415 when the content type is not documented (block not reached)' do + post '/pets', 'plain', 'CONTENT_TYPE' => 'application/xml' + + expect(last_response.status).to eq(415) + end + + it 'returns 400 for an invalid body before the route runs' do + post '/pets', JSON.generate(type: 'pet'), 'CONTENT_TYPE' => 'application/json' + + expect(last_response.status).to eq(400) + end + + it 'coerces an integer path parameter per the OpenAPI description' do + patch '/pets/42', JSON.generate(type: 'pet', attributes: { name: 'Rex' }), + 'CONTENT_TYPE' => 'application/json' + + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)).to eq('id' => 42, 'id_class' => 'Integer') + end + + it 'returns 400 when an integer path parameter cannot be coerced' do + patch '/pets/not-a-number', JSON.generate(type: 'pet', attributes: { name: 'Rex' }), + 'CONTENT_TYPE' => 'application/json' + + expect(last_response.status).to eq(400) + end + end + + context 'with multiple path parameters in a single path segment' do + let(:range_spec) { File.expand_path('data/parameters-path.yaml', __dir__) } + + let(:app) do + spec_path = range_spec + Class.new(Sinatra::Base) do + set :environment, :test + set :raise_errors, true + set :show_exceptions, false + register OpenapiFirst::Sinatra + openapi spec_path + + operation :info_date_range do + content_type :json + JSON.generate(openapi_request.parsed_path_parameters) + end + end + end + + it 'routes /info/{start_date}..{end_date} and exposes both parameters' do + get '/info/2020-01-01..2020-02-01' + + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)).to eq( + 'start_date' => '2020-01-01', 'end_date' => '2020-02-01' + ) + end + end + + context 'with a path parameter name containing a hyphen' do + let(:hyphen_spec) { File.expand_path('data/sinatra-hyphen-param.yaml', __dir__) } + + let(:app) do + spec_path = hyphen_spec + Class.new(Sinatra::Base) do + set :environment, :test + set :raise_errors, true + set :show_exceptions, false + register OpenapiFirst::Sinatra + openapi spec_path + + operation :showThing do + content_type :json + JSON.generate(openapi_request.parsed_path_parameters) + end + end + end + + it 'routes /things/{thing-id} to its block and exposes the parameter under its real name' do + get '/things/abc' + + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)).to eq('thing-id' => 'abc') + end + end +end