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

Revisit versioner middlewares #2484

Merged
merged 3 commits into from
Jul 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
ericproulx marked this conversation as resolved.
Show resolved Hide resolved
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