Skip to content

Commit

Permalink
Revisit versioner middlewares (#2484)
Browse files Browse the repository at this point in the history
* 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`
  • Loading branch information
ericproulx authored Jul 27, 2024
1 parent 2b8567a commit 2292486
Show file tree
Hide file tree
Showing 12 changed files with 230 additions and 340 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#### Features

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

#### Fixes
Expand Down
7 changes: 3 additions & 4 deletions lib/grape/endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,7 @@ def prepare_default_route_attributes

def prepare_version
version = namespace_inheritable(:version)
return unless version
return if version.empty?
return if version.blank?

version.length == 1 ? version.first : version
end
Expand Down Expand Up @@ -298,9 +297,9 @@ def build_stack(helpers)

stack.concat namespace_stackable(:middleware)

if namespace_inheritable(:version)
if namespace_inheritable(:version).present?
stack.use Grape::Middleware::Versioner.using(namespace_inheritable(:version_options)[:using]),
versions: namespace_inheritable(:version)&.flatten,
versions: namespace_inheritable(:version).flatten,
version_options: namespace_inheritable(:version_options),
prefix: namespace_inheritable(:root_prefix),
mount_path: namespace_stackable(:mount_path).first
Expand Down
5 changes: 2 additions & 3 deletions lib/grape/middleware/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,17 @@ module Grape
module Middleware
class Base
include Helpers
include Grape::DSL::Headers

attr_reader :app, :env, :options

TEXT_HTML = 'text/html'

include Grape::DSL::Headers

# @param [Rack Application] app The standard argument for a Rack middleware.
# @param [Hash] options A hash of options, simply stored for use by subclasses.
def initialize(app, *options)
@app = app
@options = options.any? ? default_options.merge(options.shift) : default_options
@options = options.any? ? default_options.deep_merge(options.shift) : default_options
@app_response = nil
end

Expand Down
19 changes: 5 additions & 14 deletions lib/grape/middleware/versioner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,21 @@
# on the requests. The current methods for determining version are:
#
# :header - version from HTTP Accept header.
# :accept_version_header - version from HTTP Accept-Version header
# :path - version from uri. e.g. /v1/resource
# :param - version from uri query string, e.g. /v1/resource?apiver=v1
#
# See individual classes for details.
module Grape
module Middleware
module Versioner
module_function

# @param strategy [Symbol] :path, :header or :param
# @param strategy [Symbol] :path, :header, :accept_version_header or :param
# @return a middleware class based on strategy
def using(strategy)
case strategy
when :path
Path
when :header
Header
when :param
Param
when :accept_version_header
AcceptVersionHeader
else
raise Grape::Exceptions::InvalidVersionerOption.new(strategy)
end
Grape::Middleware::Versioner.const_get(:"#{strategy.to_s.camelize}")
rescue NameError
raise Grape::Exceptions::InvalidVersionerOption, strategy
end
end
end
Expand Down
39 changes: 8 additions & 31 deletions lib/grape/middleware/versioner/accept_version_header.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,45 +17,22 @@ module Versioner
# X-Cascade header to alert Grape::Router to attempt the next matched
# route.
class AcceptVersionHeader < Base
def before
potential_version = (env[Grape::Http::Headers::HTTP_ACCEPT_VERSION] || '').strip

if strict? && potential_version.empty?
# If no Accept-Version header:
throw :error, status: 406, headers: error_headers, message: 'Accept-Version header must be set.'
end
include VersionerHelpers

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

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

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

private

def versions
options[:versions] || []
end

def strict?
options[:version_options] && options[:version_options][:strict]
end

# By default those errors contain an `X-Cascade` header set to `pass`, which allows nesting and stacking
# of routes (see Grape::Router) for more information). To prevent
# this behavior, and not add the `X-Cascade` header, one can set the `:cascade` option to `false`.
def cascade?
if options[:version_options]&.key?(:cascade)
options[:version_options][:cascade]
else
true
end
end

def error_headers
cascade? ? { Grape::Http::Headers::X_CASCADE => 'pass' } : {}
def not_acceptable!(message)
throw :error, status: 406, headers: error_headers, message: message
end
end
end
Expand Down
105 changes: 95 additions & 10 deletions lib/grape/middleware/versioner/header.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,10 @@ module Versioner
# X-Cascade header to alert Grape::Router to attempt the next matched
# route.
class Header < Base
include VersionerHelpers

def before
handler = Grape::Util::AcceptHeaderHandler.new(
accept_header: env[Grape::Http::Headers::HTTP_ACCEPT],
versions: options[:versions],
**options.fetch(:version_options) { {} }
)

handler.match_best_quality_media_type!(
content_types: content_types,
allowed_methods: env[Grape::Env::GRAPE_ALLOWED_METHODS]
) do |media_type|
match_best_quality_media_type! do |media_type|
env.update(
Grape::Env::API_TYPE => media_type.type,
Grape::Env::API_SUBTYPE => media_type.subtype,
Expand All @@ -42,6 +35,98 @@ def before
)
end
end

private

def match_best_quality_media_type!
return unless vendor

strict_header_checks!
media_type = Grape::Util::MediaType.best_quality(accept_header, available_media_types)
if media_type
yield media_type
else
fail!(allowed_methods)
end
end

def allowed_methods
env[Grape::Env::GRAPE_ALLOWED_METHODS]
end

def accept_header
env[Grape::Http::Headers::HTTP_ACCEPT]
end

def strict_header_checks!
return unless strict?

accept_header_check!
version_and_vendor_check!
end

def accept_header_check!
return if accept_header.present?

invalid_accept_header!('Accept header must be set.')
end

def version_and_vendor_check!
return if versions.blank? || version_and_vendor?

invalid_accept_header!('API vendor or version not found.')
end

def q_values_mime_types
@q_values_mime_types ||= Rack::Utils.q_values(accept_header).map(&:first)
end

def version_and_vendor?
q_values_mime_types.any? { |mime_type| Grape::Util::MediaType.match?(mime_type) }
end

def invalid_accept_header!(message)
raise Grape::Exceptions::InvalidAcceptHeader.new(message, error_headers)
end

def invalid_version_header!(message)
raise Grape::Exceptions::InvalidVersionHeader.new(message, error_headers)
end

def fail!(grape_allowed_methods)
return grape_allowed_methods if grape_allowed_methods.present?

media_types = q_values_mime_types.map { |mime_type| Grape::Util::MediaType.parse(mime_type) }
vendor_not_found!(media_types) || version_not_found!(media_types)
end

def vendor_not_found!(media_types)
return unless media_types.all? { |media_type| media_type&.vendor && media_type.vendor != vendor }

invalid_accept_header!('API vendor not found.')
end

def version_not_found!(media_types)
return unless media_types.all? { |media_type| media_type&.version && versions&.exclude?(media_type.version) }

invalid_version_header!('API version not found.')
end

def available_media_types
[].tap do |available_media_types|
base_media_type = "application/vnd.#{vendor}"
content_types.each_key do |extension|
versions&.reverse_each do |version|
available_media_types << "#{base_media_type}-#{version}+#{extension}"
available_media_types << "#{base_media_type}-#{version}"
end
available_media_types << "#{base_media_type}+#{extension}"
end

available_media_types << base_media_type
available_media_types.concat(content_types.values.flatten)
end
end
end
end
end
Expand Down
26 changes: 5 additions & 21 deletions lib/grape/middleware/versioner/param.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,31 +19,15 @@ module Versioner
#
# env['api.version'] => 'v1'
class Param < Base
def default_options
{
version_options: {
parameter: 'apiver'
}
}
end
include VersionerHelpers

def before
potential_version = Rack::Utils.parse_nested_query(env[Rack::QUERY_STRING])[paramkey]
return if potential_version.nil?
potential_version = Rack::Utils.parse_nested_query(env[Rack::QUERY_STRING])[parameter_key]
return if potential_version.blank?

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 }
version_not_found! unless potential_version_match?(potential_version)
env[Grape::Env::API_VERSION] = potential_version
env[Rack::RACK_REQUEST_QUERY_HASH].delete(paramkey) if env.key? Rack::RACK_REQUEST_QUERY_HASH
end

private

def paramkey
version_options[:parameter] || default_options[:version_options][:parameter]
end

def version_options
options[:version_options]
env[Rack::RACK_REQUEST_QUERY_HASH].delete(parameter_key) if env.key? Rack::RACK_REQUEST_QUERY_HASH
end
end
end
Expand Down
42 changes: 11 additions & 31 deletions lib/grape/middleware/versioner/path.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,44 +17,24 @@ module Versioner
# env['api.version'] => 'v1'
#
class Path < Base
def default_options
{
pattern: /.*/i
}
end
include VersionerHelpers

def before
path = env[Rack::PATH_INFO].dup
path.sub!(mount_path, '') if mounted_path?(path)
path_info = Grape::Router.normalize_path(env[Rack::PATH_INFO])
return if path_info == '/'

if prefix && path.index(prefix) == 0 # rubocop:disable all
path.sub!(prefix, '')
path = Grape::Router.normalize_path(path)
[mount_path, Grape::Router.normalize_path(prefix)].each do |path|
path_info.delete_prefix!(path) if path.present? && path != '/' && path_info.start_with?(path)
end

pieces = path.split('/')
potential_version = pieces[1]
return unless potential_version&.match?(options[:pattern])

throw :error, status: 404, message: '404 API Version Not Found' if options[:versions] && !options[:versions].find { |v| v.to_s == potential_version }
env[Grape::Env::API_VERSION] = potential_version
end

private
slash_position = path_info.index('/', 1) # omit the first one
return unless slash_position

def mounted_path?(path)
return false unless mount_path && path.start_with?(mount_path)
potential_version = path_info[1..slash_position - 1]
return unless potential_version.match?(pattern)

rest = path.slice(mount_path.length..-1)
rest.start_with?('/') || rest.empty?
end

def mount_path
@mount_path ||= options[:mount_path] && options[:mount_path] != '/' ? options[:mount_path] : ''
end

def prefix
Grape::Router.normalize_path(options[:prefix].to_s) if options[:prefix]
version_not_found! unless potential_version_match?(potential_version)
env[Grape::Env::API_VERSION] = potential_version
end
end
end
Expand Down
Loading

0 comments on commit 2292486

Please sign in to comment.