Skip to content

Commit 2292486

Browse files
authored
Revisit versioner middlewares (#2484)
* AcceptHeaderHandler is now part of Grape::Middleware::Versioner::Header Use `const_get` to find versioner Grape::Middleware::Versioner::* uses `default_options` like other middlewares Add versioner_helpers for Grape::Middleware::Versioner::* Replace `merge` by `deep_merge` in Grape::Middleware::Base initialize Add specs * Add CHANGELOG entry * Remove prefix throw_ and add! Use `camelize` instead of `classify`
1 parent 2b8567a commit 2292486

12 files changed

+230
-340
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#### Features
44

55
* [#2475](https://github.com/ruby-grape/grape/pull/2475): Remove Grape::Util::Registrable - [@ericproulx](https://github.com/ericproulx).
6+
* [#2484](https://github.com/ruby-grape/grape/pull/2484): Refactor versioner middlewares - [@ericproulx](https://github.com/ericproulx).
67
* Your contribution here.
78

89
#### Fixes

lib/grape/endpoint.rb

+3-4
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,7 @@ def prepare_default_route_attributes
191191

192192
def prepare_version
193193
version = namespace_inheritable(:version)
194-
return unless version
195-
return if version.empty?
194+
return if version.blank?
196195

197196
version.length == 1 ? version.first : version
198197
end
@@ -298,9 +297,9 @@ def build_stack(helpers)
298297

299298
stack.concat namespace_stackable(:middleware)
300299

301-
if namespace_inheritable(:version)
300+
if namespace_inheritable(:version).present?
302301
stack.use Grape::Middleware::Versioner.using(namespace_inheritable(:version_options)[:using]),
303-
versions: namespace_inheritable(:version)&.flatten,
302+
versions: namespace_inheritable(:version).flatten,
304303
version_options: namespace_inheritable(:version_options),
305304
prefix: namespace_inheritable(:root_prefix),
306305
mount_path: namespace_stackable(:mount_path).first

lib/grape/middleware/base.rb

+2-3
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,17 @@ module Grape
44
module Middleware
55
class Base
66
include Helpers
7+
include Grape::DSL::Headers
78

89
attr_reader :app, :env, :options
910

1011
TEXT_HTML = 'text/html'
1112

12-
include Grape::DSL::Headers
13-
1413
# @param [Rack Application] app The standard argument for a Rack middleware.
1514
# @param [Hash] options A hash of options, simply stored for use by subclasses.
1615
def initialize(app, *options)
1716
@app = app
18-
@options = options.any? ? default_options.merge(options.shift) : default_options
17+
@options = options.any? ? default_options.deep_merge(options.shift) : default_options
1918
@app_response = nil
2019
end
2120

lib/grape/middleware/versioner.rb

+5-14
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,21 @@
44
# on the requests. The current methods for determining version are:
55
#
66
# :header - version from HTTP Accept header.
7+
# :accept_version_header - version from HTTP Accept-Version header
78
# :path - version from uri. e.g. /v1/resource
89
# :param - version from uri query string, e.g. /v1/resource?apiver=v1
9-
#
1010
# See individual classes for details.
1111
module Grape
1212
module Middleware
1313
module Versioner
1414
module_function
1515

16-
# @param strategy [Symbol] :path, :header or :param
16+
# @param strategy [Symbol] :path, :header, :accept_version_header or :param
1717
# @return a middleware class based on strategy
1818
def using(strategy)
19-
case strategy
20-
when :path
21-
Path
22-
when :header
23-
Header
24-
when :param
25-
Param
26-
when :accept_version_header
27-
AcceptVersionHeader
28-
else
29-
raise Grape::Exceptions::InvalidVersionerOption.new(strategy)
30-
end
19+
Grape::Middleware::Versioner.const_get(:"#{strategy.to_s.camelize}")
20+
rescue NameError
21+
raise Grape::Exceptions::InvalidVersionerOption, strategy
3122
end
3223
end
3324
end

lib/grape/middleware/versioner/accept_version_header.rb

+8-31
Original file line numberDiff line numberDiff line change
@@ -17,45 +17,22 @@ module Versioner
1717
# X-Cascade header to alert Grape::Router to attempt the next matched
1818
# route.
1919
class AcceptVersionHeader < Base
20-
def before
21-
potential_version = (env[Grape::Http::Headers::HTTP_ACCEPT_VERSION] || '').strip
22-
23-
if strict? && potential_version.empty?
24-
# If no Accept-Version header:
25-
throw :error, status: 406, headers: error_headers, message: 'Accept-Version header must be set.'
26-
end
20+
include VersionerHelpers
2721

28-
return if potential_version.empty?
22+
def before
23+
potential_version = env[Grape::Http::Headers::HTTP_ACCEPT_VERSION]&.strip
24+
not_acceptable!('Accept-Version header must be set.') if strict? && potential_version.blank?
2925

30-
# If the requested version is not supported:
31-
throw :error, status: 406, headers: error_headers, message: 'The requested version is not supported.' unless versions.any? { |v| v.to_s == potential_version }
26+
return if potential_version.blank?
3227

28+
not_acceptable!('The requested version is not supported.') unless potential_version_match?(potential_version)
3329
env[Grape::Env::API_VERSION] = potential_version
3430
end
3531

3632
private
3733

38-
def versions
39-
options[:versions] || []
40-
end
41-
42-
def strict?
43-
options[:version_options] && options[:version_options][:strict]
44-
end
45-
46-
# By default those errors contain an `X-Cascade` header set to `pass`, which allows nesting and stacking
47-
# of routes (see Grape::Router) for more information). To prevent
48-
# this behavior, and not add the `X-Cascade` header, one can set the `:cascade` option to `false`.
49-
def cascade?
50-
if options[:version_options]&.key?(:cascade)
51-
options[:version_options][:cascade]
52-
else
53-
true
54-
end
55-
end
56-
57-
def error_headers
58-
cascade? ? { Grape::Http::Headers::X_CASCADE => 'pass' } : {}
34+
def not_acceptable!(message)
35+
throw :error, status: 406, headers: error_headers, message: message
5936
end
6037
end
6138
end

lib/grape/middleware/versioner/header.rb

+95-10
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,10 @@ module Versioner
2222
# X-Cascade header to alert Grape::Router to attempt the next matched
2323
# route.
2424
class Header < Base
25+
include VersionerHelpers
26+
2527
def before
26-
handler = Grape::Util::AcceptHeaderHandler.new(
27-
accept_header: env[Grape::Http::Headers::HTTP_ACCEPT],
28-
versions: options[:versions],
29-
**options.fetch(:version_options) { {} }
30-
)
31-
32-
handler.match_best_quality_media_type!(
33-
content_types: content_types,
34-
allowed_methods: env[Grape::Env::GRAPE_ALLOWED_METHODS]
35-
) do |media_type|
28+
match_best_quality_media_type! do |media_type|
3629
env.update(
3730
Grape::Env::API_TYPE => media_type.type,
3831
Grape::Env::API_SUBTYPE => media_type.subtype,
@@ -42,6 +35,98 @@ def before
4235
)
4336
end
4437
end
38+
39+
private
40+
41+
def match_best_quality_media_type!
42+
return unless vendor
43+
44+
strict_header_checks!
45+
media_type = Grape::Util::MediaType.best_quality(accept_header, available_media_types)
46+
if media_type
47+
yield media_type
48+
else
49+
fail!(allowed_methods)
50+
end
51+
end
52+
53+
def allowed_methods
54+
env[Grape::Env::GRAPE_ALLOWED_METHODS]
55+
end
56+
57+
def accept_header
58+
env[Grape::Http::Headers::HTTP_ACCEPT]
59+
end
60+
61+
def strict_header_checks!
62+
return unless strict?
63+
64+
accept_header_check!
65+
version_and_vendor_check!
66+
end
67+
68+
def accept_header_check!
69+
return if accept_header.present?
70+
71+
invalid_accept_header!('Accept header must be set.')
72+
end
73+
74+
def version_and_vendor_check!
75+
return if versions.blank? || version_and_vendor?
76+
77+
invalid_accept_header!('API vendor or version not found.')
78+
end
79+
80+
def q_values_mime_types
81+
@q_values_mime_types ||= Rack::Utils.q_values(accept_header).map(&:first)
82+
end
83+
84+
def version_and_vendor?
85+
q_values_mime_types.any? { |mime_type| Grape::Util::MediaType.match?(mime_type) }
86+
end
87+
88+
def invalid_accept_header!(message)
89+
raise Grape::Exceptions::InvalidAcceptHeader.new(message, error_headers)
90+
end
91+
92+
def invalid_version_header!(message)
93+
raise Grape::Exceptions::InvalidVersionHeader.new(message, error_headers)
94+
end
95+
96+
def fail!(grape_allowed_methods)
97+
return grape_allowed_methods if grape_allowed_methods.present?
98+
99+
media_types = q_values_mime_types.map { |mime_type| Grape::Util::MediaType.parse(mime_type) }
100+
vendor_not_found!(media_types) || version_not_found!(media_types)
101+
end
102+
103+
def vendor_not_found!(media_types)
104+
return unless media_types.all? { |media_type| media_type&.vendor && media_type.vendor != vendor }
105+
106+
invalid_accept_header!('API vendor not found.')
107+
end
108+
109+
def version_not_found!(media_types)
110+
return unless media_types.all? { |media_type| media_type&.version && versions&.exclude?(media_type.version) }
111+
112+
invalid_version_header!('API version not found.')
113+
end
114+
115+
def available_media_types
116+
[].tap do |available_media_types|
117+
base_media_type = "application/vnd.#{vendor}"
118+
content_types.each_key do |extension|
119+
versions&.reverse_each do |version|
120+
available_media_types << "#{base_media_type}-#{version}+#{extension}"
121+
available_media_types << "#{base_media_type}-#{version}"
122+
end
123+
available_media_types << "#{base_media_type}+#{extension}"
124+
end
125+
126+
available_media_types << base_media_type
127+
available_media_types.concat(content_types.values.flatten)
128+
end
129+
end
45130
end
46131
end
47132
end

lib/grape/middleware/versioner/param.rb

+5-21
Original file line numberDiff line numberDiff line change
@@ -19,31 +19,15 @@ module Versioner
1919
#
2020
# env['api.version'] => 'v1'
2121
class Param < Base
22-
def default_options
23-
{
24-
version_options: {
25-
parameter: 'apiver'
26-
}
27-
}
28-
end
22+
include VersionerHelpers
2923

3024
def before
31-
potential_version = Rack::Utils.parse_nested_query(env[Rack::QUERY_STRING])[paramkey]
32-
return if potential_version.nil?
25+
potential_version = Rack::Utils.parse_nested_query(env[Rack::QUERY_STRING])[parameter_key]
26+
return if potential_version.blank?
3327

34-
throw :error, status: 404, message: '404 API Version Not Found', headers: { Grape::Http::Headers::X_CASCADE => 'pass' } if options[:versions] && !options[:versions].find { |v| v.to_s == potential_version }
28+
version_not_found! unless potential_version_match?(potential_version)
3529
env[Grape::Env::API_VERSION] = potential_version
36-
env[Rack::RACK_REQUEST_QUERY_HASH].delete(paramkey) if env.key? Rack::RACK_REQUEST_QUERY_HASH
37-
end
38-
39-
private
40-
41-
def paramkey
42-
version_options[:parameter] || default_options[:version_options][:parameter]
43-
end
44-
45-
def version_options
46-
options[:version_options]
30+
env[Rack::RACK_REQUEST_QUERY_HASH].delete(parameter_key) if env.key? Rack::RACK_REQUEST_QUERY_HASH
4731
end
4832
end
4933
end

lib/grape/middleware/versioner/path.rb

+11-31
Original file line numberDiff line numberDiff line change
@@ -17,44 +17,24 @@ module Versioner
1717
# env['api.version'] => 'v1'
1818
#
1919
class Path < Base
20-
def default_options
21-
{
22-
pattern: /.*/i
23-
}
24-
end
20+
include VersionerHelpers
2521

2622
def before
27-
path = env[Rack::PATH_INFO].dup
28-
path.sub!(mount_path, '') if mounted_path?(path)
23+
path_info = Grape::Router.normalize_path(env[Rack::PATH_INFO])
24+
return if path_info == '/'
2925

30-
if prefix && path.index(prefix) == 0 # rubocop:disable all
31-
path.sub!(prefix, '')
32-
path = Grape::Router.normalize_path(path)
26+
[mount_path, Grape::Router.normalize_path(prefix)].each do |path|
27+
path_info.delete_prefix!(path) if path.present? && path != '/' && path_info.start_with?(path)
3328
end
3429

35-
pieces = path.split('/')
36-
potential_version = pieces[1]
37-
return unless potential_version&.match?(options[:pattern])
38-
39-
throw :error, status: 404, message: '404 API Version Not Found' if options[:versions] && !options[:versions].find { |v| v.to_s == potential_version }
40-
env[Grape::Env::API_VERSION] = potential_version
41-
end
42-
43-
private
30+
slash_position = path_info.index('/', 1) # omit the first one
31+
return unless slash_position
4432

45-
def mounted_path?(path)
46-
return false unless mount_path && path.start_with?(mount_path)
33+
potential_version = path_info[1..slash_position - 1]
34+
return unless potential_version.match?(pattern)
4735

48-
rest = path.slice(mount_path.length..-1)
49-
rest.start_with?('/') || rest.empty?
50-
end
51-
52-
def mount_path
53-
@mount_path ||= options[:mount_path] && options[:mount_path] != '/' ? options[:mount_path] : ''
54-
end
55-
56-
def prefix
57-
Grape::Router.normalize_path(options[:prefix].to_s) if options[:prefix]
36+
version_not_found! unless potential_version_match?(potential_version)
37+
env[Grape::Env::API_VERSION] = potential_version
5838
end
5939
end
6040
end

0 commit comments

Comments
 (0)