From 8407e1f8842724c55913c94cddca2db33562274e Mon Sep 17 00:00:00 2001 From: Joshua Studt Date: Tue, 16 Dec 2025 16:14:41 -0500 Subject: [PATCH 1/3] support static path_prefix on Definition --- .gitignore | 3 +++ lib/openapi_first.rb | 10 +++++---- lib/openapi_first/definition.rb | 7 ++++++- spec/definition_spec.rb | 37 +++++++++++++++++++++++++++++++-- spec/openapi_first_spec.rb | 21 +++++++++++++++++-- 5 files changed, 69 insertions(+), 9 deletions(-) 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..69db5edb 100644 --- a/lib/openapi_first.rb +++ b/lib/openapi_first.rb @@ -55,8 +55,10 @@ 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 +66,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..14f356f7 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 @@ -104,6 +108,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..23893489 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/42') + 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/42') 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 From 01adaaeffa118134b9793a496a24f76c1e51c12b Mon Sep 17 00:00:00 2001 From: Joshua Studt Date: Tue, 16 Dec 2025 17:07:07 -0500 Subject: [PATCH 2/3] fix tests --- lib/openapi_first.rb | 3 ++- lib/openapi_first/definition.rb | 19 +++++++++---------- spec/definition_spec.rb | 14 ++++++++++++-- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/lib/openapi_first.rb b/lib/openapi_first.rb index 69db5edb..b8922e29 100644 --- a/lib/openapi_first.rb +++ b/lib/openapi_first.rb @@ -55,7 +55,8 @@ 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 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, path_prefix: nil, &) diff --git a/lib/openapi_first/definition.rb b/lib/openapi_first/definition.rb index 14f356f7..623eab5f 100644 --- a/lib/openapi_first/definition.rb +++ b/lib/openapi_first/definition.rb @@ -83,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 diff --git a/spec/definition_spec.rb b/spec/definition_spec.rb index 23893489..34d00a07 100644 --- a/spec/definition_spec.rb +++ b/spec/definition_spec.rb @@ -302,8 +302,18 @@ def build_request(path, method: 'GET') { 'openapi' => '3.1.0', 'paths' => { - '/stuff' => { + '/stuff/{id}' => { 'get' => { + 'parameters' => [ + { + 'name' => 'id', + 'in' => 'path', + 'required' => true, + 'schema' => { + 'type' => 'integer' + } + } + ], 'responses' => { '200' => { 'description' => 'OK', @@ -331,7 +341,7 @@ def build_request(path, method: 'GET') OpenapiFirst.parse(definition_contents) end - let(:request) { build_request('/stuff') } + let(:request) { build_request('/stuff/42') } context 'when response is valid' do let(:response) { Rack::Response.new(JSON.generate({ 'id' => 42 }), 200, { 'Content-Type' => 'application/json' }) } From 7cba0123b82effe8b0b22d2b78bdaad64c7251fa Mon Sep 17 00:00:00 2001 From: Joshua Studt Date: Tue, 16 Dec 2025 17:10:30 -0500 Subject: [PATCH 3/3] remove unnecessary test changes --- spec/definition_spec.rb | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/spec/definition_spec.rb b/spec/definition_spec.rb index 34d00a07..9198f2d6 100644 --- a/spec/definition_spec.rb +++ b/spec/definition_spec.rb @@ -302,18 +302,8 @@ def build_request(path, method: 'GET') { 'openapi' => '3.1.0', 'paths' => { - '/stuff/{id}' => { + '/stuff' => { 'get' => { - 'parameters' => [ - { - 'name' => 'id', - 'in' => 'path', - 'required' => true, - 'schema' => { - 'type' => 'integer' - } - } - ], 'responses' => { '200' => { 'description' => 'OK', @@ -341,7 +331,7 @@ def build_request(path, method: 'GET') OpenapiFirst.parse(definition_contents) end - let(:request) { build_request('/stuff/42') } + let(:request) { build_request('/stuff') } context 'when response is valid' do let(:response) { Rack::Response.new(JSON.generate({ 'id' => 42 }), 200, { 'Content-Type' => 'application/json' }) } @@ -403,7 +393,7 @@ 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/42') + request = build_request('/prefix/stuff') validated = definition.validate_response(request, response) expect(validated).to be_valid expect(validated.parsed_body).to eq({ 'id' => 42 }) @@ -420,7 +410,7 @@ 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, and path_prefix takes precedence over config' do - request = build_request('/static_prefix/stuff/42') + 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 })