Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions lib/openapi_first.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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, &)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you want .load to be able to accept a Definition instance? In order to avoid something like if path_or_definition.is_a?(Definition) in test.rb?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was treating .load as a single place to handle this. A definition or file path goes in at the edges and then you always get a definition. However, if you prefer to not do that, I'm happy to change it! I leave to you.

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)
Expand Down
18 changes: 18 additions & 0 deletions lib/openapi_first/definition.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thoughts? You mentioned using a hash of the definition. Would you prefer that over this?

Copy link
Copy Markdown
Owner

@ahx ahx May 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Until now openapi_first did not have an opinion about the contents of the OAD…, but I think its' fine the way you did it, because it will lead to a human readable key and the code is easy to follow.

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.
Expand Down
15 changes: 10 additions & 5 deletions lib/openapi_first/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions lib/openapi_first/test/coverage.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions lib/openapi_first/test/coverage/plan.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand why you changed this. This makes sense to me.

oad.routes.each do |route|
responses = skip_response ? route.responses.reject(&skip_response) : route.responses
plan.add_route request_method: route.request_method,
Expand All @@ -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)
Expand Down
3 changes: 1 addition & 2 deletions lib/openapi_first/test/coverage/terminal_formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
41 changes: 41 additions & 0 deletions spec/definition_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
7 changes: 7 additions & 0 deletions spec/openapi_first_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions spec/test_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown
Contributor Author

@smizell smizell May 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've created a definition_key and api_identifier on the plan. However, it seems like they'll always be the same. Thoughts? Maybe always use api_identifier?

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. api_identifier is the better option here.

end

it 'raises an error if the same API description is registered twice' do
described_class.register('./examples/openapi.yaml')
expect do
Expand Down