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
7 changes: 7 additions & 0 deletions lib/rspec/openapi.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true

require 'bundler/setup'
Copy link
Owner

@exoego exoego Apr 11, 2024

Choose a reason for hiding this comment

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

Is bundler gem always included in runtime?
If not, it should be added to add_dependency section:

spec.add_dependency 'actionpack', '>= 5.2.0'
spec.add_dependency 'rails-dom-testing'
spec.add_dependency 'rspec-core'

Since bundler is not so small (400KB+ since 2.4.3), I prefer not to include extra dependency.
I hope Ruby's native mechanism like defined?(Rails) && Rails.respond_to?(:application), defined?(Hanami) && Hanami.respond_to?(....) can be used.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I tried not using bundler and using the standard methods you suggested, however at this point it seems due to the loading order, these constants don't exist yet.
If i try this:

module SharedHooks
  def self.find_extractor
    if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
      require 'rspec/openapi/extractors/rails'

      RSpec::OpenAPI::Extractors::Rails
    elsif defined?(Hanami) && Hanami.respond_to?(:app) && Hanami.app?
      require 'rspec/openapi/extractors/hanami'

      RSpec::OpenAPI::Extractors::Hanami
      # elsif defined?(Roda)
      #   some Roda extractor
    else
      require 'rspec/openapi/extractors/rack'

      RSpec::OpenAPI::Extractors::Rack
    end
  end
end

it won't work for hanami because of the way I use to throw the inspector into the router.

Would be glad for help in this aspect

Copy link
Owner

Choose a reason for hiding this comment

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

How about

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

excellent solution

require 'rspec/openapi/version'
require 'rspec/openapi/components_updater'
require 'rspec/openapi/default_schema'
Expand All @@ -11,6 +12,12 @@
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'

require 'rspec/openapi/extractors/hanami' if Bundler.load.specs.map(&:name).include?('hanami')
require 'rspec/openapi/extractors/rails' if Bundler.load.specs.map(&:name).include?('rails')

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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
17 changes: 17 additions & 0 deletions lib/rspec/openapi/shared_hooks.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module SharedHooks

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
names = Bundler.load.specs.map(&:name)

if names.include?('rails') && defined?(Rails) &&
Rails.respond_to?(:application) && Rails.application
RSpec::OpenAPI::Extractors::Rails
elsif names.include?('hanami') && 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
Loading