Skip to content

Commit

Permalink
Remove rack-accept dependency
Browse files Browse the repository at this point in the history
Create Grape::Util::MediaType
Use Rack::Util functions
  • Loading branch information
ericproulx committed Dec 18, 2023
1 parent e37831c commit 33d8024
Show file tree
Hide file tree
Showing 6 changed files with 96 additions and 120 deletions.
1 change: 0 additions & 1 deletion grape.gemspec
Expand Up @@ -25,7 +25,6 @@ Gem::Specification.new do |s|
s.add_runtime_dependency 'dry-types', '>= 1.1'
s.add_runtime_dependency 'mustermann-grape', '~> 1.1.0'
s.add_runtime_dependency 'rack', '>= 1.3.0'
s.add_runtime_dependency 'rack-accept'

s.files = Dir['lib/**/*', 'CHANGELOG.md', 'CONTRIBUTING.md', 'README.md', 'grape.png', 'UPGRADING.md', 'LICENSE', 'grape.gemspec']
s.require_paths = ['lib']
Expand Down
1 change: 0 additions & 1 deletion lib/grape.rb
Expand Up @@ -3,7 +3,6 @@
require 'logger'
require 'rack'
require 'rack/builder'
require 'rack/accept'
require 'rack/auth/basic'
require 'set'
require 'bigdecimal'
Expand Down
134 changes: 41 additions & 93 deletions lib/grape/middleware/versioner/header.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true

require 'grape/middleware/base'
require 'grape/middleware/versioner/parse_media_type_patch'
require 'grape/util/media_type'

module Grape
module Middleware
Expand All @@ -25,115 +25,87 @@ module Versioner
# X-Cascade header to alert Grape::Router to attempt the next matched
# route.
class Header < Base
VENDOR_VERSION_HEADER_REGEX =
/\Avnd\.([a-z0-9.\-_!#{Regexp.last_match(0)}\^]+?)(?:-([a-z0-9*.]+))?(?:\+([a-z0-9*\-.]+))?\z/.freeze

HAS_VENDOR_REGEX = /\Avnd\.[a-z0-9.\-_!#{Regexp.last_match(0)}\^]+/.freeze
HAS_VERSION_REGEX = /\Avnd\.([a-z0-9.\-_!#{Regexp.last_match(0)}\^]+?)(?:-([a-z0-9*.]+))+/.freeze

def before
strict_header_checks if strict?

if media_type || env[Grape::Env::GRAPE_ALLOWED_METHODS]
media_type_header_handler
elsif headers_contain_wrong_vendor?
fail_with_invalid_accept_header!('API vendor not found.')
elsif headers_contain_wrong_version?
fail_with_invalid_version_header!('API version not found.')
header = env[Grape::Http::Headers::HTTP_ACCEPT]
qvalues = Grape::Util::MediaType.q_values(header)
strict_header_checks(qvalues) if strict?

media_type = Grape::Util::MediaType.from_best_quality_media_type(header, available_media_types)

if media_type
env[Grape::Env::API_TYPE] = media_type.type
env[Grape::Env::API_SUBTYPE] = media_type.subtype
env[Grape::Env::API_VENDOR] = media_type.vendor
env[Grape::Env::API_VERSION] = media_type.version
env[Grape::Env::API_FORMAT] = media_type.format
elsif !env[Grape::Env::GRAPE_ALLOWED_METHODS]
media_types = qvalues.map { |mime_type, _quality| Grape::Util::MediaType.parse(mime_type) }
if headers_contain_wrong_vendor?(media_types)
fail_with_invalid_accept_header!('API vendor not found.')
elsif headers_contain_wrong_version?(media_types)
fail_with_invalid_version_header!('API version not found.')
end
end
end

private

def strict_header_checks
strict_accept_header_presence_check
strict_version_vendor_accept_header_presence_check
def strict_header_checks(qvalues)
strict_accept_header_presence_check(qvalues)
strict_version_vendor_accept_header_presence_check(qvalues)
end

def strict_accept_header_presence_check
return unless header.qvalues.empty?
def strict_accept_header_presence_check(qvalues)
return if qvalues.any?

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

def strict_version_vendor_accept_header_presence_check
return if versions.blank? || an_accept_header_with_version_and_vendor_is_present?
def strict_version_vendor_accept_header_presence_check(qvalues)
return if versions.blank? || an_accept_header_with_version_and_vendor_is_present?(qvalues)

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

def an_accept_header_with_version_and_vendor_is_present?
header.qvalues.keys.any? do |h|
VENDOR_VERSION_HEADER_REGEX.match?(h.sub('application/', ''))
end
end

def header
@header ||= rack_accept_header
end

def media_type
@media_type ||= header.best_of(available_media_types)
end

def media_type_header_handler
type, subtype = Rack::Accept::Header.parse_media_type(media_type)
env[Grape::Env::API_TYPE] = type
env[Grape::Env::API_SUBTYPE] = subtype

return unless VENDOR_VERSION_HEADER_REGEX =~ subtype

env[Grape::Env::API_VENDOR] = Regexp.last_match[1]
env[Grape::Env::API_VERSION] = Regexp.last_match[2]
# weird that Grape::Middleware::Formatter also does this
env[Grape::Env::API_FORMAT] = Regexp.last_match[3]
def an_accept_header_with_version_and_vendor_is_present?(qvalues)
qvalues.any? { |mime_type, _quality| Grape::Util::MediaType.match?(mime_type) }
end

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

def fail_with_invalid_version_header!(message)
raise Grape::Exceptions::InvalidVersionHeader
.new(message, error_headers)
raise Grape::Exceptions::InvalidVersionHeader.new(message, error_headers)
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 << "application/vnd.#{vendor}-#{version}+#{extension}"
available_media_types << "application/vnd.#{vendor}-#{version}"
available_media_types << "#{base_media_type}-#{version}+#{extension}"
available_media_types << "#{base_media_type}-#{version}"
end
available_media_types << "application/vnd.#{vendor}+#{extension}"
available_media_types << "#{base_media_type}+#{extension}"
end

available_media_types << "application/vnd.#{vendor}"
available_media_types << base_media_type
available_media_types.concat(content_types.values.flatten)
end
end

def headers_contain_wrong_vendor?
header.values.all? do |header_value|
vendor?(header_value) && request_vendor(header_value) != vendor
end
def headers_contain_wrong_vendor?(media_types)
media_types.all? { |media_type| media_type&.vendor && media_type.vendor != vendor }
end

def headers_contain_wrong_version?
header.values.all? do |header_value|
version?(header_value) && versions.exclude?(request_version(header_value))
end
end

def rack_accept_header
Rack::Accept::MediaType.new env[Grape::Http::Headers::HTTP_ACCEPT]
rescue RuntimeError => e
fail_with_invalid_accept_header!(e.message)
def headers_contain_wrong_version?(media_types)
media_types.all? { |media_type| media_type&.version && versions.exclude?(media_type.version) }
end

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

def vendor
Expand Down Expand Up @@ -164,30 +136,6 @@ def cascade?
def error_headers
cascade? ? { Grape::Http::Headers::X_CASCADE => 'pass' } : {}
end

# @param [String] media_type a content type
# @return [Boolean] whether the content type sets a vendor
def vendor?(media_type)
_, subtype = Rack::Accept::Header.parse_media_type(media_type)
subtype.present? && subtype[HAS_VENDOR_REGEX]
end

def request_vendor(media_type)
_, subtype = Rack::Accept::Header.parse_media_type(media_type)
subtype.match(VENDOR_VERSION_HEADER_REGEX)[1]
end

def request_version(media_type)
_, subtype = Rack::Accept::Header.parse_media_type(media_type)
subtype.match(VENDOR_VERSION_HEADER_REGEX)[2]
end

# @param [String] media_type a content type
# @return [Boolean] whether the content type sets an API version
def version?(media_type)
_, subtype = Rack::Accept::Header.parse_media_type(media_type)
subtype.present? && subtype[HAS_VERSION_REGEX]
end
end
end
end
Expand Down
24 changes: 0 additions & 24 deletions lib/grape/middleware/versioner/parse_media_type_patch.rb

This file was deleted.

54 changes: 54 additions & 0 deletions lib/grape/util/media_type.rb
@@ -0,0 +1,54 @@
# frozen_string_literal: true

module Grape
module Util
class MediaType
attr_reader :type, :subtype, :vendor, :version, :format

VENDOR_VERSION_HEADER_REGEX = /\Avnd\.(?<vendor>[a-z0-9.\-_!^]+?)(?:-(?<version>[a-z0-9*.]+))?(?:\+(?<format>[a-z0-9*\-.]+))?\z/.freeze

def initialize(type:, subtype:)
@type = type
@subtype = subtype
VENDOR_VERSION_HEADER_REGEX.match(subtype) do |m|
@vendor = m[:vendor]
@version = m[:version]
@format = m[:format]
end
end

class << self

def q_values(header)
Rack::Utils.q_values(header)
end

def from_best_quality_media_type(header, available_media_types)
parse(best_quality_media_type(header, available_media_types))
end

def parse(media_type)
return if media_type.blank?

type, subtype = media_type.split('/', 2)
return if type.blank? && subtype.blank?

new(type: type, subtype: subtype)
end

def match?(media_type)
return if media_type.blank?

subtype = media_type.split('/', 2).last
return if subtype.blank?

VENDOR_VERSION_HEADER_REGEX.match?(subtype)
end

def best_quality_media_type(header, available_media_types)
header.blank? ? available_media_types.first : Rack::Utils.best_q_match(header, available_media_types)
end
end
end
end
end
2 changes: 1 addition & 1 deletion spec/grape/endpoint_spec.rb
Expand Up @@ -990,7 +990,7 @@ def memoized
expect(last_response.status).to eq(406)
end

it 'result in a 406 response if they cannot be parsed by rack-accept' do
it 'result in a 406 response if they cannot be parsed' do
get '/test', {}, 'HTTP_ACCEPT' => 'application/vnd.ohanapi.v1+json; version=1'
expect(last_response.status).to eq(406)
end
Expand Down

0 comments on commit 33d8024

Please sign in to comment.