diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b590182..a9a5d7d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ ## 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 + + operation :create_pet do + pet = create_pet(parsed_body) + headers['Location'] = operation_url(:show_pet, petId: pet.id) + status :created + json pet + end + + operation :show_pet do |params| + json show_pet(params[:petId]) + end + end + ``` + + The HTTP method and path for each route come from the operationId. + Request validation is called automatically for these operations. - Changed: Don't hide covered endpoints in HTML coverage reporter - Added: Filter un/covered endpoints in HTML coverage reporter diff --git a/Gemfile.lock b/Gemfile.lock index 84762892..e4d47365 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -274,6 +274,7 @@ GEM zeitwerk (2.8.2) PLATFORMS + arm64-darwin-24 arm64-darwin-25 x86_64-linux diff --git a/README.md b/README.md index c3ca7f42..b9591cde 100644 --- a/README.md +++ b/README.md @@ -416,7 +416,53 @@ 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 + 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_body) +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`. + +Use a String for operationIds that are not valid Ruby symbols, e.g. `operation 'pets.list'`. + +See [`sinatra.rb`](lib/openapi_first/sinatra.rb) for a more details. ## Alternatives diff --git a/lib/openapi_first/definition.rb b/lib/openapi_first/definition.rb index 9be0ea4b..90c35660 100644 --- a/lib/openapi_first/definition.rb +++ b/lib/openapi_first/definition.rb @@ -71,15 +71,40 @@ def inspect "#<#{self.class.name} @key='#{key}'>" end + # Resolves the path for the named operation, filling in any `{param}` placeholders. + # @param operation_id [String, Symbol] An operationId present in this API description. + # @param params [Hash] Path-parameter values keyed by name (String or Symbol). + # @return [String] The resolved path (e.g. "/pets/42"). + # @raise [ArgumentError] if the operationId is not found or a required path parameter is missing. + def path_for(params = {}, operation_id:) + request_def = routes.lazy.flat_map(&:requests).find { |r| r.operation_id == operation_id.to_s } + raise ArgumentError, "Operation #{operation_id.inspect} is not defined in #{key}." unless request_def + + request_def.path.gsub(/\{([^}]+)\}/) do + name = Regexp.last_match(1) + params.fetch(name.to_sym) do + params.fetch(name) do + raise ArgumentError, "Missing path parameter #{name.inspect} for operation #{operation_id.inspect}." + end + end + end + end + # 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 @@ -117,6 +142,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/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..f2c3cc48 --- /dev/null +++ b/lib/openapi_first/sinatra.rb @@ -0,0 +1,217 @@ +# 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 + # + # NOTE: Requests are matched by Sinatra's router (Mustermann), but validated against the OpenAPI path + # template the route was defined from, using the path parameters Sinatra extracted. The two + # matchers can diverge at the edges (trailing slashes, dots inside a path segment, + # encoded characters, ...) - a request Sinatra matches is not re-checked against openapi_first's own + # path matching. Avoid path shapes where the two routers disagree. + 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 + + entry = [route.request_method, route.path] + existing = index[operation_id] + if existing && existing != entry + raise ::OpenapiFirst::Error, + "operationId #{operation_id.inspect} is used for #{existing.join(' ')} and " \ + "#{entry.join(' ')} in #{definition.key}. operationIds must be unique." + end + + index[operation_id] = entry + 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, Object, nil] + def parsed_body + body = openapi_request.parsed_body + body.is_a?(Hash) ? ::Sinatra::IndifferentHash[body] : body + end + + # Generates a URL for the operation with the given +operationId+, filling in any path + # parameters from +path_params+. Delegates to Sinatra's own +url+ helper so reverse-proxy + # and script-name handling is preserved. + # + # href = operation_url(:show_pet, petId: pet.id) # => "http://example.com/pets/42" + # + # @param operation_id [String, Symbol] An operationId present in the API description. + # @param path_params [Hash] Path-parameter values keyed by name (String or Symbol). + # @return [String] Absolute URL for the operation. + # @raise [ArgumentError] if the operationId is unknown or a required path parameter is missing. + def operation_url(operation_id, path_params = {}) + url(settings.openapi_definition.path_for(path_params, operation_id:)) + 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-array-body.yaml b/spec/data/sinatra-array-body.yaml new file mode 100644 index 00000000..443ca6e6 --- /dev/null +++ b/spec/data/sinatra-array-body.yaml @@ -0,0 +1,22 @@ +openapi: "3.0.0" +info: + title: Top-level array request body + version: 1.0.0 +paths: + /bulk-pets: + post: + operationId: bulkCreatePets + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + type: object + properties: + name: + type: string + responses: + "200": + description: ok diff --git a/spec/data/sinatra-duplicate-operation-id.yaml b/spec/data/sinatra-duplicate-operation-id.yaml new file mode 100644 index 00000000..fda5f8d1 --- /dev/null +++ b/spec/data/sinatra-duplicate-operation-id.yaml @@ -0,0 +1,17 @@ +openapi: "3.0.0" +info: + title: Duplicate operationId on different routes + version: 1.0.0 +paths: + /pets: + get: + operationId: listThings + responses: + "200": + description: ok + /things: + get: + operationId: listThings + responses: + "200": + description: ok 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/definition_spec.rb b/spec/definition_spec.rb index 0effb8a6..a91fdeda 100644 --- a/spec/definition_spec.rb +++ b/spec/definition_spec.rb @@ -463,6 +463,32 @@ def build_request(path, method: 'GET') end end + describe '#path_for' do + let(:definition) { OpenapiFirst.load('./spec/data/petstore.yaml') } + + it 'returns the path for an operation without path parameters' do + expect(definition.path_for(operation_id: :listPets)).to eq('/pets') + end + + it 'fills in path parameters with symbol keys' do + expect(definition.path_for({ petId: 42 }, operation_id: :showPetById)).to eq('/pets/42') + end + + it 'fills in path parameters with string keys' do + expect(definition.path_for({ 'petId' => '42' }, operation_id: :showPetById)).to eq('/pets/42') + end + + it 'raises ArgumentError for an unknown operationId' do + expect { definition.path_for(operation_id: :unknownOperation) } + .to raise_error(ArgumentError, /unknownOperation/) + end + + it 'raises ArgumentError when a required path parameter is missing' do + expect { definition.path_for(operation_id: :showPetById) } + .to raise_error(ArgumentError, /petId/) + end + end + describe '#filepath' do let(:definition) { OpenapiFirst.load('./spec/data/petstore.yaml') } 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..9c924e29 --- /dev/null +++ b/spec/sinatra_spec.rb @@ -0,0 +1,520 @@ +# 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 a top-level JSON array request body' do + let(:app) do + spec_path = File.expand_path('data/sinatra-array-body.yaml', __dir__) + Class.new(Sinatra::Base) do + set :environment, :test + set :raise_errors, true + set :show_exceptions, false + register OpenapiFirst::Sinatra + openapi spec_path + + operation :bulkCreatePets do + content_type :json + JSON.generate(parsed_body) + end + end + end + + it 'returns the parsed array from parsed_body' do + post '/bulk-pets', JSON.generate([{ name: 'Rex' }]), 'CONTENT_TYPE' => 'application/json' + + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)).to eq([{ 'name' => 'Rex' }]) + end + end + + it 'raises if the description uses the same operationId for different routes' do + duplicate = File.expand_path('data/sinatra-duplicate-operation-id.yaml', __dir__) + expect do + Class.new(Sinatra::Base) do + register OpenapiFirst::Sinatra + openapi duplicate + end + end.to raise_error(OpenapiFirst::Error, /listThings/) + end + + context 'with the operation_url 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 :text + operation_url(:listPets) + end + + operation :showPetById do + content_type :text + operation_url(:showPetById, 'petId' => params[:petId]) + end + end + end + + it 'returns the absolute URL for an operation without path parameters' do + get '/pets' + + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('http://example.org/pets') + end + + it 'fills in path parameters with string keys' do + get '/pets/42' + + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('http://example.org/pets/42') + end + + it 'fills in path parameters with symbol keys' 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 :showPetById do + content_type :text + operation_url(:showPetById, petId: params[:petId]) + end + end + + session = Rack::Test::Session.new(klass) + session.get('/pets/42') + + expect(session.last_response.body).to eq('http://example.org/pets/42') + end + + it 'raises ArgumentError for an unknown operationId' 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 + operation_url(:unknownOperation) + end + end + + session = Rack::Test::Session.new(klass) + expect { session.get('/pets') }.to raise_error(ArgumentError, /unknownOperation/) + end + + it 'raises ArgumentError when a required path parameter is missing' 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 + operation_url(:showPetById) + end + end + + session = Rack::Test::Session.new(klass) + expect { session.get('/pets') }.to raise_error(ArgumentError, /petId/) + 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