diff --git a/.gitignore b/.gitignore index 2914690d..a57732fa 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ # rspec failure tracking .rspec_status Gemfile.*.lock + +# MacOS +.DS_Store diff --git a/lib/openapi_first.rb b/lib/openapi_first.rb index 161b9f96..b8922e29 100644 --- a/lib/openapi_first.rb +++ b/lib/openapi_first.rb @@ -55,8 +55,11 @@ def self.find_error_response(name) # Load and dereference an OpenAPI spec file or return the Definition if it's already loaded # @param filepath_or_definition [String, Definition] The path to the file or a Definition object + # @param only [Proc, nil] An optional proc to filter paths. It is called with the path string and should return + # true/false + # @param path_prefix [String, nil] An optional path prefix, that is not documented, that all requests begin with. # @return [Definition] - def self.load(filepath_or_definition, only: nil, &) + def self.load(filepath_or_definition, only: nil, path_prefix: nil, &) return filepath_or_definition if filepath_or_definition.is_a?(Definition) return self[filepath_or_definition] if filepath_or_definition.is_a?(Symbol) @@ -64,16 +67,16 @@ def self.load(filepath_or_definition, only: nil, &) raise FileNotFoundError, "File not found: #{filepath}" unless File.exist?(filepath) contents = FileLoader.load(filepath) - parse(contents, only:, filepath:, &) + parse(contents, only:, filepath:, path_prefix:, &) end # Parse a dereferenced Hash # @return [Definition] # TODO: This needs to work with unresolved contents as well - def self.parse(contents, only: nil, filepath: nil, &) + def self.parse(contents, only: nil, filepath: nil, path_prefix: nil, &) contents = ::JSON.parse(::JSON.generate(contents)) # Deeply stringify keys, because of YAML. See https://github.com/ahx/openapi_first/issues/367 contents['paths'].filter!(&->(key, _) { only.call(key) }) if only - Definition.new(contents, filepath, &) + Definition.new(contents, filepath, path_prefix, &) end end diff --git a/lib/openapi_first/definition.rb b/lib/openapi_first/definition.rb index dada7c64..623eab5f 100644 --- a/lib/openapi_first/definition.rb +++ b/lib/openapi_first/definition.rb @@ -11,6 +11,8 @@ class Definition # @return [String,nil] attr_reader :filepath + # @return [String,nil] + attr_reader :path_prefix # @return [Configuration] attr_reader :config # @return [Enumerable[String]] @@ -20,8 +22,10 @@ class Definition # @param contents [Hash] The OpenAPI document. # @param filepath [String] The file path of the OpenAPI document. - def initialize(contents, filepath = nil) + # @param path_prefix [String,nil] An optional path prefix, that is not documented, that all requests begin with. + def initialize(contents, filepath = nil, path_prefix = nil) @filepath = filepath + @path_prefix = path_prefix @config = OpenapiFirst.configuration.child yield @config if block_given? @config.freeze @@ -79,23 +83,22 @@ def validate_request(request, raise_error: false) end # Validates the response against the API description. - # @param rack_request [Rack::Request] The Rack request object. - # @param rack_response [Rack::Response] The Rack response object. - # @param raise_error [Boolean] Whethir to raise an error if validation fails. + # @param request [Rack::Request] The Rack request object. + # @param response [Rack::Response] The Rack response object. + # @param raise_error [Boolean] Whether to raise an error if validation fails. # @return [ValidatedResponse] The validated response object. - def validate_response(rack_request, rack_response, raise_error: false) - route = @router.match(rack_request.request_method, resolve_path(rack_request), - content_type: rack_request.content_type) + def validate_response(request, response, raise_error: false) + route = @router.match(request.request_method, resolve_path(request), content_type: request.content_type) return if route.error # Skip response validation for unknown requests - response_match = route.match_response(status: rack_response.status, content_type: rack_response.content_type) + response_match = route.match_response(status: response.status, content_type: response.content_type) error = response_match.error validated = if error - ValidatedResponse.new(rack_response, error:) + ValidatedResponse.new(response, error:) else - response_match.response.validate(rack_response) + response_match.response.validate(response) end - @config.after_response_validation&.each { |hook| hook.call(validated, rack_request, self) } + @config.after_response_validation&.each { |hook| hook.call(validated, request, self) } raise validated.error.exception(validated) if raise_error && validated.invalid? validated @@ -104,6 +107,7 @@ def validate_response(rack_request, rack_response, raise_error: false) private def resolve_path(rack_request) + return rack_request.path.delete_prefix(path_prefix) if path_prefix && rack_request.path.start_with?(path_prefix) return rack_request.path unless @config.path @config.path.call(rack_request) diff --git a/spec/definition_spec.rb b/spec/definition_spec.rb index 41e6dbef..9198f2d6 100644 --- a/spec/definition_spec.rb +++ b/spec/definition_spec.rb @@ -266,7 +266,7 @@ def build_request(path, method: 'GET') end end - context 'with an alternate path used for schema matching' do + context 'with an alternate path for schema matching, from global config' do let(:definition) do OpenapiFirst.parse(definition_contents) do |config| config.path = ->(req) { req.path.delete_prefix('/prefix') } @@ -280,6 +280,21 @@ def build_request(path, method: 'GET') expect(validated.parsed_path_parameters).to eq({ 'id' => 42 }) end end + + context 'with an alternate path for schema matching, from path_prefix' do + let(:definition) do + OpenapiFirst.parse(definition_contents, path_prefix: '/static_prefix') do |config| + config.path = ->(req) { req.path.delete_prefix('/config_prefix') } + end + end + + it 'returns a valid request, and path_prefix takes precedence over config' do + request = build_request('/static_prefix/stuff/42') + validated = definition.validate_request(request) + expect(validated).to be_valid + expect(validated.parsed_path_parameters).to eq({ 'id' => 42 }) + end + end end describe '#validate_response' do @@ -368,7 +383,7 @@ def build_request(path, method: 'GET') end end - context 'with an alternate path used for schema matching' do + context 'with an alternate path used for schema matching, from global config' do let(:definition) do OpenapiFirst.parse(definition_contents) do |config| config.path = ->(req) { req.path.delete_prefix('/prefix') } @@ -378,6 +393,24 @@ def build_request(path, method: 'GET') let(:response) { Rack::Response.new(JSON.generate({ 'id' => 42 }), 200, { 'Content-Type' => 'application/json' }) } it 'returns a valid response' do + request = build_request('/prefix/stuff') + validated = definition.validate_response(request, response) + expect(validated).to be_valid + expect(validated.parsed_body).to eq({ 'id' => 42 }) + end + end + + context 'with an alternate path for schema matching, from path_prefix' do + let(:definition) do + OpenapiFirst.parse(definition_contents, path_prefix: '/static_prefix') do |config| + config.path = ->(req) { req.path.delete_prefix('/config_prefix') } + end + end + + let(:response) { Rack::Response.new(JSON.generate({ 'id' => 42 }), 200, { 'Content-Type' => 'application/json' }) } + + it 'returns a valid response, and path_prefix takes precedence over config' do + request = build_request('/static_prefix/stuff') validated = definition.validate_response(request, response) expect(validated).to be_valid expect(validated.parsed_body).to eq({ 'id' => 42 }) diff --git a/spec/openapi_first_spec.rb b/spec/openapi_first_spec.rb index 545ecb19..ff56eda8 100644 --- a/spec/openapi_first_spec.rb +++ b/spec/openapi_first_spec.rb @@ -5,8 +5,6 @@ require 'rack/test' require 'openapi_first' -# frozen_string_literal: true - RSpec.describe OpenapiFirst do it 'has a version number' do expect(OpenapiFirst::VERSION).not_to be nil @@ -31,6 +29,13 @@ expect(paths).not_to include('/pets/{petId}') end + it 'supports :path_prefix' do + hash = YAML.safe_load_file('./spec/data/petstore.yaml') + path_prefix = '/api/v1' + definition = OpenapiFirst.parse(hash, path_prefix:) + expect(definition.path_prefix).to eq(path_prefix) + end + it 'loads a Hash' do definition = OpenapiFirst.parse(YAML.safe_load_file('./spec/data/petstore.yaml')) expect(definition.paths).to include('/pets') @@ -126,5 +131,17 @@ expect(definition.paths).to eq expected end end + + describe 'path_prefix option' do + specify 'without a path prefix' do + definition = OpenapiFirst.load(spec_path, path_prefix: nil) + expect(definition.path_prefix).to be_nil + end + + specify 'with a path prefix' do + definition = OpenapiFirst.load(spec_path, path_prefix: '/api/v1') + expect(definition.path_prefix).to eq('/api/v1') + end + end end end