Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support hanami router #205

Merged
merged 13 commits into from Apr 12, 2024
17 changes: 17 additions & 0 deletions lib/rspec/openapi.rb
Expand Up @@ -11,6 +11,23 @@
require 'rspec/openapi/schema_cleaner'
require 'rspec/openapi/schema_sorter'
require 'rspec/openapi/key_transformer'
require 'rspec/openapi/shared_hooks'
require 'rspec/openapi/extractors'
require 'rspec/openapi/extractors/rack'

begin
require 'hanami'
require 'rspec/openapi/extractors/hanami'
rescue LoadError
puts 'Hanami not detected'

Check warning on line 22 in lib/rspec/openapi.rb

View check run for this annotation

Codecov / codecov/patch

lib/rspec/openapi.rb#L22

Added line #L22 was not covered by tests
end

begin
require 'rails'
require 'rspec/openapi/extractors/rails'
rescue LoadError
puts 'Rails not detected'

Check warning on line 29 in lib/rspec/openapi.rb

View check run for this annotation

Codecov / codecov/patch

lib/rspec/openapi.rb#L29

Added line #L29 was not covered by tests
end

require 'rspec/openapi/minitest_hooks' if Object.const_defined?('Minitest')
require 'rspec/openapi/rspec_hooks' if ENV['OPENAPI'] && Object.const_defined?('RSpec')
Expand Down
5 changes: 5 additions & 0 deletions lib/rspec/openapi/extractors.rb
@@ -0,0 +1,5 @@
# frozen_string_literal: true

# Create namespace
module RSpec::OpenAPI::Extractors
end
118 changes: 118 additions & 0 deletions lib/rspec/openapi/extractors/hanami.rb
@@ -0,0 +1,118 @@
# frozen_string_literal: true

require 'dry/inflector'
require 'hanami'

# https://github.com/hanami/router/blob/97f75b8529574bd4ff23165460e82a6587bc323c/lib/hanami/router/inspector.rb#L13
class Inspector
attr_accessor :routes, :inflector

def initialize(routes: [])
@routes = routes
@inflector = Dry::Inflector.new
end

def add_route(route)
routes.push(route)
end

def call(verb, path)
route = routes.find { |r| r.http_method == verb && r.path == path }

if route.to.is_a?(Proc)
{
tags: [],
summary: "#{verb} #{path}",
}
else
data = route.to.split('.')

{
tags: [inflector.classify(data[0])],
summary: data[1],
}
end
end
end

InspectorAnalyzer = Inspector.new

# Add default parameter to load inspector before test cases run
module InspectorAnalyzerPrepender
Fixed Show fixed Hide fixed
def router(inspector: InspectorAnalyzer)
super
end
end

Hanami::Slice::ClassMethods.prepend(InspectorAnalyzerPrepender)

# Extractor for hanami
class << RSpec::OpenAPI::Extractors::Hanami = Object.new
# @param [RSpec::ExampleGroups::*] context
# @param [RSpec::Core::Example] example
# @return Array
def request_attributes(request, example)
metadata = example.metadata[:openapi] || {}
summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example)
tags = metadata[:tags] || RSpec::OpenAPI.tags_builder.call(example)
operation_id = metadata[:operation_id]
required_request_params = metadata[:required_request_params] || []
security = metadata[:security]
description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example)
deprecated = metadata[:deprecated]
path = request.path

route = Hanami.app.router.recognize(request.path, method: request.method)

raw_path_params = route.params.filter { |_key, value| number_or_nil(value) }

result = InspectorAnalyzer.call(request.method, add_id(path, route))

summary ||= result[:summary]
tags ||= result[:tags]
path = add_openapi_id(path, route)

raw_path_params = raw_path_params.slice(*(raw_path_params.keys - RSpec::OpenAPI.ignored_path_params))

[path, summary, tags, operation_id, required_request_params, raw_path_params, description, security, deprecated]
end

# @param [RSpec::ExampleGroups::*] context
def request_response(context)
request = ActionDispatch::Request.new(context.last_request.env)
request.body.rewind if request.body.respond_to?(:rewind)
response = ActionDispatch::TestResponse.new(*context.last_response.to_a)

[request, response]
end

def add_id(path, route)
return path if route.params.empty?

route.params.each_pair do |key, value|
next unless number_or_nil(value)

path = path.sub("/#{value}", "/:#{key}")
end

path
end

def add_openapi_id(path, route)
return path if route.params.empty?

route.params.each_pair do |key, value|
next unless number_or_nil(value)

path = path.sub("/#{value}", "/{#{key}}")
end

path
end

def number_or_nil(string)
Integer(string || '')
rescue ArgumentError
nil
end
end
31 changes: 31 additions & 0 deletions lib/rspec/openapi/extractors/rack.rb
@@ -0,0 +1,31 @@
# frozen_string_literal: true

# Extractor for rack
class << RSpec::OpenAPI::Extractors::Rack = Object.new
# @param [RSpec::ExampleGroups::*] context
# @param [RSpec::Core::Example] example
# @return Array
def request_attributes(request, example)
metadata = example.metadata[:openapi] || {}
summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example)
tags = metadata[:tags] || RSpec::OpenAPI.tags_builder.call(example)
operation_id = metadata[:operation_id]
required_request_params = metadata[:required_request_params] || []
security = metadata[:security]
description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example)
deprecated = metadata[:deprecated]
raw_path_params = request.path_parameters
path = request.path
summary ||= "#{request.method} #{path}"
[path, summary, tags, operation_id, required_request_params, raw_path_params, description, security, deprecated]
end

# @param [RSpec::ExampleGroups::*] context
def request_response(context)
request = ActionDispatch::Request.new(context.last_request.env)
request.body.rewind if request.body.respond_to?(:rewind)
response = ActionDispatch::TestResponse.new(*context.last_response.to_a)

[request, response]
end
end
58 changes: 58 additions & 0 deletions lib/rspec/openapi/extractors/rails.rb
@@ -0,0 +1,58 @@
# frozen_string_literal: true

# Extractor for rails
class << RSpec::OpenAPI::Extractors::Rails = Object.new
# @param [RSpec::ExampleGroups::*] context
# @param [RSpec::Core::Example] example
# @return Array
def request_attributes(request, example)
metadata = example.metadata[:openapi] || {}
summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example)
tags = metadata[:tags] || RSpec::OpenAPI.tags_builder.call(example)
operation_id = metadata[:operation_id]
required_request_params = metadata[:required_request_params] || []
security = metadata[:security]
description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example)
deprecated = metadata[:deprecated]
raw_path_params = request.path_parameters

# Reverse the destructive modification by Rails https://github.com/rails/rails/blob/v6.0.3.4/actionpack/lib/action_dispatch/journey/router.rb#L33-L41
fixed_request = request.dup
fixed_request.path_info = File.join(request.script_name, request.path_info) if request.script_name.present?

route, path = find_rails_route(fixed_request)
raise "No route matched for #{fixed_request.request_method} #{fixed_request.path_info}" if route.nil?

path = path.delete_suffix('(.:format)')
summary ||= route.requirements[:action]
tags ||= [route.requirements[:controller]&.classify].compact
# :controller and :action always exist. :format is added when routes is configured as such.
# TODO: Use .except(:controller, :action, :format) when we drop support for Ruby 2.x
raw_path_params = raw_path_params.slice(*(raw_path_params.keys - RSpec::OpenAPI.ignored_path_params))

summary ||= "#{request.method} #{path}"

[path, summary, tags, operation_id, required_request_params, raw_path_params, description, security, deprecated]
end

# @param [RSpec::ExampleGroups::*] context
def request_response(context)
[context.request, context.response]
end

# @param [ActionDispatch::Request] request
def find_rails_route(request, app: Rails.application, path_prefix: '')
app.routes.router.recognize(request) do |route|
path = route.path.spec.to_s
if route.app.matches?(request)
if route.app.engine?
route, path = find_rails_route(request, app: route.app.app, path_prefix: path)
next if route.nil?
end
return [route, path_prefix + path]
end
end

nil
end
end
2 changes: 1 addition & 1 deletion lib/rspec/openapi/minitest_hooks.rb
Expand Up @@ -13,7 +13,7 @@ def run(*args)
human_name = name.sub(/^test_/, '').gsub('_', ' ')
example = Example.new(self, human_name, {}, file_path)
path = RSpec::OpenAPI.path.then { |p| p.is_a?(Proc) ? p.call(example) : p }
record = RSpec::OpenAPI::RecordBuilder.build(self, example: example)
record = RSpec::OpenAPI::RecordBuilder.build(self, example: example, extractor: SharedHooks.find_extractor)
RSpec::OpenAPI.path_records[path] << record if record
end
result
Expand Down
71 changes: 3 additions & 68 deletions lib/rspec/openapi/record_builder.rb
Expand Up @@ -7,12 +7,12 @@ class << RSpec::OpenAPI::RecordBuilder = Object.new
# @param [RSpec::ExampleGroups::*] context
# @param [RSpec::Core::Example] example
# @return [RSpec::OpenAPI::Record,nil]
def build(context, example:)
request, response = extract_request_response(context)
def build(context, example:, extractor:)
request, response = extractor.request_response(context)
return if request.nil?

path, summary, tags, operation_id, required_request_params, raw_path_params, description, security, deprecated =
extract_request_attributes(request, example)
extractor.request_attributes(request, example)

return if RSpec::OpenAPI.ignored_paths.any? { |ignored_path| path.match?(ignored_path) }

Expand Down Expand Up @@ -69,71 +69,6 @@ def extract_headers(request, response)
[request_headers, response_headers]
end

def extract_request_attributes(request, example)
metadata = example.metadata[:openapi] || {}
summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example)
tags = metadata[:tags] || RSpec::OpenAPI.tags_builder.call(example)
operation_id = metadata[:operation_id]
required_request_params = metadata[:required_request_params] || []
security = metadata[:security]
description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example)
deprecated = metadata[:deprecated]
raw_path_params = request.path_parameters
path = request.path
if rails?
# Reverse the destructive modification by Rails https://github.com/rails/rails/blob/v6.0.3.4/actionpack/lib/action_dispatch/journey/router.rb#L33-L41
fixed_request = request.dup
fixed_request.path_info = File.join(request.script_name, request.path_info) if request.script_name.present?

route, path = find_rails_route(fixed_request)
raise "No route matched for #{fixed_request.request_method} #{fixed_request.path_info}" if route.nil?

path = path.delete_suffix('(.:format)')
summary ||= route.requirements[:action]
tags ||= [route.requirements[:controller]&.classify].compact
# :controller and :action always exist. :format is added when routes is configured as such.
# TODO: Use .except(:controller, :action, :format) when we drop support for Ruby 2.x
raw_path_params = raw_path_params.slice(*(raw_path_params.keys - RSpec::OpenAPI.ignored_path_params))
end
summary ||= "#{request.method} #{path}"
[path, summary, tags, operation_id, required_request_params, raw_path_params, description, security, deprecated]
end

def extract_request_response(context)
if rack_test?(context)
request = ActionDispatch::Request.new(context.last_request.env)
request.body.rewind if request.body.respond_to?(:rewind)
response = ActionDispatch::TestResponse.new(*context.last_response.to_a)
else
request = context.request
response = context.response
end
[request, response]
end

def rails?
defined?(Rails) && Rails.respond_to?(:application) && Rails.application
end

def rack_test?(context)
defined?(Rack::Test::Methods) && context.class < Rack::Test::Methods
end

# @param [ActionDispatch::Request] request
def find_rails_route(request, app: Rails.application, path_prefix: '')
app.routes.router.recognize(request) do |route|
path = route.path.spec.to_s
if route.app.matches?(request)
if route.app.engine?
route, path = find_rails_route(request, app: route.app.app, path_prefix: path)
next if route.nil?
end
return [route, path_prefix + path]
end
end
nil
end

# workaround to get real request parameters
# because ActionController::ParamsWrapper overwrites request_parameters
def raw_request_params(request)
Expand Down
2 changes: 1 addition & 1 deletion lib/rspec/openapi/rspec_hooks.rb
Expand Up @@ -5,7 +5,7 @@
RSpec.configuration.after(:each) do |example|
if RSpec::OpenAPI.example_types.include?(example.metadata[:type]) && example.metadata[:openapi] != false
path = RSpec::OpenAPI.path.then { |p| p.is_a?(Proc) ? p.call(example) : p }
record = RSpec::OpenAPI::RecordBuilder.build(self, example: example)
record = RSpec::OpenAPI::RecordBuilder.build(self, example: example, extractor: SharedHooks.find_extractor)
RSpec::OpenAPI.path_records[path] << record if record
end
end
Expand Down
13 changes: 13 additions & 0 deletions lib/rspec/openapi/shared_hooks.rb
@@ -0,0 +1,13 @@
module SharedHooks
Fixed Show fixed Hide fixed
Fixed Show fixed Hide fixed

Check notice

Code scanning / Rubocop

Document classes and non-namespace modules. Note

Style/Documentation: Missing top-level documentation comment for module SharedHooks.

Check notice

Code scanning / Rubocop

Add the frozen_string_literal comment to the top of files to help transition to frozen string literals by default. Note

Style/FrozenStringLiteralComment: Missing frozen string literal comment.
def self.find_extractor
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
RSpec::OpenAPI::Extractors::Rails
elsif defined?(Hanami) && Hanami.respond_to?(:app) && Hanami.app?
RSpec::OpenAPI::Extractors::Hanami
# elsif defined?(Roda)
# some Roda extractor
else
RSpec::OpenAPI::Extractors::Rack
end
end
end