diff --git a/lib/openapi_first.rb b/lib/openapi_first.rb index f90ec527..cb41d0a3 100644 --- a/lib/openapi_first.rb +++ b/lib/openapi_first.rb @@ -52,9 +52,13 @@ def self.find_error_response(name) end end - # Load and dereference an OpenAPI spec file + # 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 # @return [Definition] - def self.load(filepath, only: nil, &) + def self.load(filepath_or_definition, only: nil, &) + return filepath_or_definition if filepath_or_definition.is_a?(Definition) + + filepath = filepath_or_definition raise FileNotFoundError, "File not found: #{filepath}" unless File.exist?(filepath) contents = FileLoader.load(filepath) diff --git a/lib/openapi_first/definition.rb b/lib/openapi_first/definition.rb index 6d4863ce..cf8bd530 100644 --- a/lib/openapi_first/definition.rb +++ b/lib/openapi_first/definition.rb @@ -40,6 +40,24 @@ def initialize(contents, filepath = nil) # @return [Enumerable[Router::Route]] def_delegators :@router, :routes + # Returns a unique identifier for this API definition + # @return [String] A unique key for this API definition + def key + return filepath if filepath + + info = self['info'] || {} + title = info['title'] + version = info['version'] + + if title.nil? || version.nil? + raise ArgumentError, + "Cannot generate key for the OpenAPI document because 'info.title' or 'info.version' is missing. " \ + 'Please add these fields to your OpenAPI document.' + end + + "#{title} @ #{version}" + 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. diff --git a/lib/openapi_first/test.rb b/lib/openapi_first/test.rb index 9be47f64..0515e2b2 100644 --- a/lib/openapi_first/test.rb +++ b/lib/openapi_first/test.rb @@ -103,18 +103,23 @@ class AlreadyRegisteredError < StandardError; end class << self attr_reader :definitions - def register(path, as: :default) - if definitions.key?(:default) + # Register an OpenAPI definition for testing + # @param path_or_definition [String, Definition] Path to the OpenAPI file or a Definition object + # @param as [Symbol] Name to register the API definition as + def register(path_or_definition, as: :default) + if definitions.key?(as) && as == :default raise( AlreadyRegisteredError, "#{definitions[as].filepath.inspect} is already registered " \ - "as ':default' so you cannot register #{path.inspect} without " \ + "as ':default' so you cannot register #{path_or_definition.inspect} without " \ 'giving it a custom name. Please call register with a custom key like: ' \ - "OpenapiFirst::Test.register(#{path.inspect}, as: :my_other_api)" + "OpenapiFirst::Test.register(#{path_or_definition.inspect}, as: :my_other_api)" ) end - definitions[as] = OpenapiFirst.load(path) + definition = OpenapiFirst.load(path_or_definition) + definitions[as] = definition + definition end def [](api) diff --git a/lib/openapi_first/test/coverage.rb b/lib/openapi_first/test/coverage.rb index 935b7a6d..2c35d631 100644 --- a/lib/openapi_first/test/coverage.rb +++ b/lib/openapi_first/test/coverage.rb @@ -36,7 +36,7 @@ def install def start(skip_response: nil) @current_run = Test.definitions.values.to_h do |oad| plan = Plan.for(oad, skip_response:) - [oad.filepath, plan] + [oad.key, plan] end end @@ -53,11 +53,11 @@ def reset end def track_request(request, oad) - current_run[oad.filepath].track_request(request) + current_run[oad.key].track_request(request) end def track_response(response, _request, oad) - current_run[oad.filepath].track_response(response) + current_run[oad.key].track_response(response) end def result diff --git a/lib/openapi_first/test/coverage/plan.rb b/lib/openapi_first/test/coverage/plan.rb index 99a0faef..da0ac2a5 100644 --- a/lib/openapi_first/test/coverage/plan.rb +++ b/lib/openapi_first/test/coverage/plan.rb @@ -13,7 +13,7 @@ class Plan class UnknownRequestError < StandardError; end def self.for(oad, skip_response: nil) - plan = new(filepath: oad.filepath) + plan = new(definition_key: oad.key, filepath: oad.filepath) oad.routes.each do |route| responses = skip_response ? route.responses.reject(&skip_response) : route.responses plan.add_route request_method: route.request_method, @@ -24,13 +24,14 @@ def self.for(oad, skip_response: nil) plan end - def initialize(filepath:) + def initialize(definition_key:, filepath: nil) @routes = [] @index = {} + @api_identifier = filepath || definition_key @filepath = filepath end - attr_reader :filepath, :routes + attr_reader :api_identifier, :filepath, :routes private attr_reader :index def track_request(validated_request) diff --git a/lib/openapi_first/test/coverage/terminal_formatter.rb b/lib/openapi_first/test/coverage/terminal_formatter.rb index 412bf6bd..57d23fe7 100644 --- a/lib/openapi_first/test/coverage/terminal_formatter.rb +++ b/lib/openapi_first/test/coverage/terminal_formatter.rb @@ -30,8 +30,7 @@ def print(string) end def format_plan(plan) - filepath = plan.filepath - puts ['', "API validation coverage for #{filepath}: #{plan.coverage}%"] + puts ['', "API validation coverage for #{plan.api_identifier}: #{plan.coverage}%"] return if plan.done? && !verbose plan.routes.each do |route| diff --git a/spec/definition_spec.rb b/spec/definition_spec.rb index 57ce6897..3bfd824a 100644 --- a/spec/definition_spec.rb +++ b/spec/definition_spec.rb @@ -21,6 +21,47 @@ def build_request(path, method: 'GET') end end + describe '#key' do + context 'when filepath is available' do + it 'returns the filepath' do + definition = OpenapiFirst.parse({ + 'openapi' => '3.1.0', + 'paths' => {} + }, filepath: '/path/to/openapi.yaml') + + expect(definition.key).to eq('/path/to/openapi.yaml') + end + end + + context 'when filepath is not available' do + it 'generates a key from info.title and info.version' do + definition = OpenapiFirst.parse({ + 'openapi' => '3.1.0', + 'info' => { + 'title' => 'Test API', + 'version' => '1.0.0' + }, + 'paths' => {} + }) + expect(definition.key).to eq('Test API @ 1.0.0') + end + end + + context 'when the OpenAPI document is missing info.title or info.version' do + it 'raises an error' do + definition = OpenapiFirst.parse({ + 'openapi' => '3.1.0', + 'info' => { + 'title' => 'Test API' + # Missing version + }, + 'paths' => {} + }) + expect { definition.key }.to raise_error(ArgumentError, /Cannot generate key/) + end + end + end + describe '#paths' do it 'returns all paths' do definition = OpenapiFirst.load('./spec/data/petstore.yaml') diff --git a/spec/openapi_first_spec.rb b/spec/openapi_first_spec.rb index 55e97020..8cc26c6e 100644 --- a/spec/openapi_first_spec.rb +++ b/spec/openapi_first_spec.rb @@ -57,6 +57,13 @@ expect(definition.paths).to include('/pets') end + it 'returns the same definition when a Definition object is passed in' do + original_definition = OpenapiFirst.load('./spec/data/petstore.yaml') + returned_definition = OpenapiFirst.load(original_definition) + + expect(returned_definition).to be(original_definition) + end + describe 'only option' do specify 'with empty filter' do definition = OpenapiFirst.load(spec_path, only: nil) diff --git a/spec/test_spec.rb b/spec/test_spec.rb index 5b5b38dc..218848f7 100644 --- a/spec/test_spec.rb +++ b/spec/test_spec.rb @@ -29,6 +29,48 @@ expect(described_class[:mine].filepath).to eq('./examples/openapi.yaml') end + it 'can register a Definition object' do + definition = OpenapiFirst.load('./examples/openapi.yaml') + described_class.register(definition, as: :from_definition) + expect(described_class[:from_definition]).to eq(definition) + end + + it 'uses filepath as key for Definition objects with filepath' do + # Register a definition with filepath and start tracking + definition = OpenapiFirst.load('./spec/data/dice.yaml') + described_class.register(definition, as: :with_filepath) + OpenapiFirst::Test::Coverage.start + + # Verify the plan was registered with the filepath key + filepath = './spec/data/dice.yaml' + plan = OpenapiFirst::Test::Coverage.current_run[filepath] + + expect(plan).not_to be_nil + expect(plan.filepath).to eq(filepath) + expect(plan.api_identifier).to eq(filepath) + end + + it 'uses the definition key for Definition objects without filepath' do + # Create a definition without filepath + dice_hash = YAML.load_file('./spec/data/dice.yaml') + dice_hash['info'] = { + 'title' => 'Dice API', + 'version' => '1.0.0' + } + definition = OpenapiFirst.parse(dice_hash) + + # Register and start tracking + described_class.register(definition, as: :without_filepath) + OpenapiFirst::Test::Coverage.start + + expected_key = definition.key + plan = OpenapiFirst::Test::Coverage.current_run[expected_key] + + # Verify the plan was registered with the definition key + expect(plan).to be_a(OpenapiFirst::Test::Coverage::Plan) + expect(plan.api_identifier).to eq(expected_key) + end + it 'raises an error if the same API description is registered twice' do described_class.register('./examples/openapi.yaml') expect do