-
Notifications
You must be signed in to change notification settings - Fork 65
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
Changes from 9 commits
328dff2
db5fe3d
ec94086
dcf0d90
25fa2ff
defdac3
52052d6
2527d79
1978801
e834fea
1f8270b
3230778
aa5c8cf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
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 |
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 |
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
module SharedHooks | ||
|
||
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 |
There was a problem hiding this comment.
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:rspec-openapi/rspec-openapi.gemspec
Lines 31 to 33 in cdda8fc
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.There was a problem hiding this comment.
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:
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
excellent solution