grape 2.4.0 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +351 -0
- data/CONTRIBUTING.md +18 -10
- data/README.md +457 -228
- data/UPGRADING.md +1240 -1
- data/grape.gemspec +7 -7
- data/lib/grape/api/instance.rb +65 -116
- data/lib/grape/api.rb +53 -62
- data/lib/grape/content_types.rb +58 -3
- data/lib/grape/cookies.rb +2 -6
- data/lib/grape/declared_params_handler.rb +116 -0
- data/lib/grape/dry_types.rb +48 -4
- data/lib/grape/dsl/callbacks.rb +8 -58
- data/lib/grape/dsl/declared.rb +35 -0
- data/lib/grape/dsl/desc.rb +24 -67
- data/lib/grape/dsl/entity.rb +106 -0
- data/lib/grape/dsl/helpers.rb +80 -65
- data/lib/grape/dsl/inside_route.rb +56 -296
- data/lib/grape/dsl/logger.rb +4 -9
- data/lib/grape/dsl/middleware.rb +22 -40
- data/lib/grape/dsl/parameters.rb +86 -93
- data/lib/grape/dsl/request_response.rb +139 -139
- data/lib/grape/dsl/rescue_options.rb +25 -0
- data/lib/grape/dsl/routing.rb +328 -203
- data/lib/grape/dsl/settings.rb +29 -135
- data/lib/grape/dsl/validations.rb +38 -44
- data/lib/grape/dsl/version_options.rb +23 -0
- data/lib/grape/endpoint/options.rb +25 -0
- data/lib/grape/endpoint.rb +285 -245
- data/lib/grape/env.rb +8 -3
- data/lib/grape/error_formatter/base.rb +27 -21
- data/lib/grape/error_formatter/json.rb +6 -0
- data/lib/grape/error_formatter/txt.rb +10 -10
- data/lib/grape/error_formatter.rb +6 -2
- data/lib/grape/exceptions/base.rb +20 -44
- data/lib/grape/exceptions/error_response.rb +48 -0
- data/lib/grape/exceptions/incompatible_option_values.rb +1 -1
- data/lib/grape/exceptions/internal_server_error.rb +16 -0
- data/lib/grape/exceptions/invalid_accept_header.rb +1 -1
- data/lib/grape/exceptions/invalid_formatter.rb +1 -1
- data/lib/grape/exceptions/invalid_message_body.rb +1 -1
- data/lib/grape/exceptions/invalid_version_header.rb +1 -1
- data/lib/grape/exceptions/invalid_versioner_option.rb +1 -1
- data/lib/grape/exceptions/method_not_allowed.rb +1 -1
- data/lib/grape/exceptions/missing_group_type.rb +0 -2
- data/lib/grape/exceptions/missing_mime_type.rb +1 -1
- data/lib/grape/exceptions/request_error.rb +11 -0
- data/lib/grape/exceptions/unknown_auth_strategy.rb +1 -1
- data/lib/grape/exceptions/unknown_error_formatter.rb +11 -0
- data/lib/grape/exceptions/unknown_parameter.rb +1 -1
- data/lib/grape/exceptions/unknown_params_builder.rb +1 -1
- data/lib/grape/exceptions/unknown_validator.rb +1 -1
- data/lib/grape/exceptions/unsupported_group_type.rb +0 -2
- data/lib/grape/exceptions/validation.rb +25 -7
- data/lib/grape/exceptions/validation_array_errors.rb +4 -0
- data/lib/grape/exceptions/validation_errors.rb +21 -23
- data/lib/grape/formatter/json.rb +1 -0
- data/lib/grape/formatter/serializable_hash.rb +6 -9
- data/lib/grape/json.rb +38 -2
- data/lib/grape/locale/en.yml +3 -5
- data/lib/grape/middleware/auth/base.rb +4 -3
- data/lib/grape/middleware/auth/dsl.rb +9 -23
- data/lib/grape/middleware/base.rb +41 -36
- data/lib/grape/middleware/error.rb +271 -73
- data/lib/grape/middleware/formatter.rb +152 -63
- data/lib/grape/middleware/precomputed_content_types.rb +51 -0
- data/lib/grape/middleware/stack.rb +20 -11
- data/lib/grape/middleware/versioner/accept_version_header.rb +3 -3
- data/lib/grape/middleware/versioner/base.rb +40 -48
- data/lib/grape/middleware/versioner/header.rb +5 -23
- data/lib/grape/middleware/versioner/param.rb +2 -2
- data/lib/grape/middleware/versioner/path.rb +54 -7
- data/lib/grape/mountable.rb +22 -0
- data/lib/grape/namespace.rb +19 -11
- data/lib/grape/params_builder/base.rb +2 -0
- data/lib/grape/params_builder/hash_with_indifferent_access.rb +1 -1
- data/lib/grape/params_builder.rb +2 -19
- data/lib/grape/parser/json.rb +1 -1
- data/lib/grape/path.rb +8 -72
- data/lib/grape/precompiled_json.rb +50 -0
- data/lib/grape/request.rb +57 -20
- data/lib/grape/router/base_route.rb +53 -7
- data/lib/grape/router/greedy_route.rb +16 -6
- data/lib/grape/router/mustermann_pattern.rb +44 -0
- data/lib/grape/router/pattern/path.rb +78 -0
- data/lib/grape/router/pattern.rb +65 -37
- data/lib/grape/router/route.rb +66 -18
- data/lib/grape/router.rb +126 -115
- data/lib/grape/serve_stream/file_body.rb +7 -0
- data/lib/grape/serve_stream/sendfile_response.rb +3 -5
- data/lib/grape/serve_stream/stream_response.rb +7 -0
- data/lib/grape/testing.rb +33 -0
- data/lib/grape/util/api_description.rb +67 -0
- data/lib/grape/util/cache.rb +22 -2
- data/lib/grape/util/deep_freeze.rb +34 -0
- data/lib/grape/util/freeze_on_new.rb +20 -0
- data/lib/grape/util/inheritable_setting.rb +777 -39
- data/lib/grape/util/lazy/base.rb +16 -0
- data/lib/grape/util/lazy/block.rb +2 -9
- data/lib/grape/util/lazy/value.rb +2 -9
- data/lib/grape/util/lazy/value_enumerable.rb +13 -16
- data/lib/grape/util/media_type.rb +13 -9
- data/lib/grape/util/path_normalizer.rb +37 -0
- data/lib/grape/util/registry.rb +13 -3
- data/lib/grape/util/shadowed_rescue_handlers.rb +49 -0
- data/lib/grape/util/stackable_values.rb +39 -10
- data/lib/grape/util/translation.rb +42 -0
- data/lib/grape/validations/attributes_iterator.rb +61 -29
- data/lib/grape/validations/coerce_options.rb +21 -0
- data/lib/grape/validations/contract_scope.rb +8 -13
- data/lib/grape/validations/multiple_attributes_iterator.rb +1 -1
- data/lib/grape/validations/oneof_collector.rb +35 -0
- data/lib/grape/validations/param_scope_tracker.rb +62 -0
- data/lib/grape/validations/params_documentation.rb +52 -0
- data/lib/grape/validations/params_scope.rb +217 -304
- data/lib/grape/validations/shared_options.rb +19 -0
- data/lib/grape/validations/single_attribute_iterator.rb +6 -4
- data/lib/grape/validations/types/array_coercer.rb +7 -10
- data/lib/grape/validations/types/custom_type_coercer.rb +47 -85
- data/lib/grape/validations/types/custom_type_collection_coercer.rb +1 -1
- data/lib/grape/validations/types/dry_type_coercer.rb +10 -15
- data/lib/grape/validations/types/json.rb +1 -3
- data/lib/grape/validations/types/multiple_type_coercer.rb +5 -3
- data/lib/grape/validations/types/primitive_coercer.rb +14 -33
- data/lib/grape/validations/types/set_coercer.rb +1 -1
- data/lib/grape/validations/types/variant_collection_coercer.rb +16 -3
- data/lib/grape/validations/types.rb +29 -51
- data/lib/grape/validations/validations_spec.rb +164 -0
- data/lib/grape/validations/validators/all_or_none_of_validator.rb +6 -3
- data/lib/grape/validations/validators/allow_blank_validator.rb +10 -5
- data/lib/grape/validations/validators/at_least_one_of_validator.rb +5 -2
- data/lib/grape/validations/validators/base.rb +118 -34
- data/lib/grape/validations/validators/coerce_validator.rb +25 -37
- data/lib/grape/validations/validators/contract_scope_validator.rb +10 -5
- data/lib/grape/validations/validators/default_validator.rb +13 -20
- data/lib/grape/validations/validators/exactly_one_of_validator.rb +10 -3
- data/lib/grape/validations/validators/except_values_validator.rb +14 -4
- data/lib/grape/validations/validators/length_validator.rb +23 -22
- data/lib/grape/validations/validators/multiple_params_base.rb +12 -9
- data/lib/grape/validations/validators/{mutual_exclusion_validator.rb → mutually_exclusive_validator.rb} +4 -2
- data/lib/grape/validations/validators/oneof_validator.rb +51 -0
- data/lib/grape/validations/validators/presence_validator.rb +4 -2
- data/lib/grape/validations/validators/regexp_validator.rb +11 -3
- data/lib/grape/validations/validators/same_as_validator.rb +7 -15
- data/lib/grape/validations/validators/values_validator.rb +34 -26
- data/lib/grape/version.rb +1 -2
- data/lib/grape/xml.rb +8 -1
- data/lib/grape.rb +58 -31
- metadata +62 -39
- data/lib/grape/api/helpers.rb +0 -9
- data/lib/grape/dsl/api.rb +0 -17
- data/lib/grape/dsl/configuration.rb +0 -15
- data/lib/grape/exceptions/conflicting_types.rb +0 -11
- data/lib/grape/exceptions/empty_message_body.rb +0 -11
- data/lib/grape/exceptions/invalid_parameters.rb +0 -11
- data/lib/grape/exceptions/missing_option.rb +0 -11
- data/lib/grape/exceptions/too_deep_parameters.rb +0 -11
- data/lib/grape/exceptions/too_many_multipart_files.rb +0 -11
- data/lib/grape/exceptions/unknown_options.rb +0 -11
- data/lib/grape/extensions/active_support/hash_with_indifferent_access.rb +0 -24
- data/lib/grape/extensions/hash.rb +0 -27
- data/lib/grape/extensions/hashie/mash.rb +0 -24
- data/lib/grape/middleware/globals.rb +0 -14
- data/lib/grape/types/invalid_value.rb +0 -8
- data/lib/grape/util/base_inheritable.rb +0 -43
- data/lib/grape/util/inheritable_values.rb +0 -29
- data/lib/grape/util/reverse_stackable_values.rb +0 -15
- data/lib/grape/util/strict_hash_configuration.rb +0 -108
- data/lib/grape/validations/attributes_doc.rb +0 -60
- data/lib/grape/validations/validator_factory.rb +0 -15
|
@@ -3,9 +3,40 @@
|
|
|
3
3
|
module Grape
|
|
4
4
|
module Middleware
|
|
5
5
|
class Formatter < Base
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
include PrecomputedContentTypes
|
|
7
|
+
|
|
8
|
+
Options = Data.define(:content_types, :default_format, :format, :formatters, :parsers) do
|
|
9
|
+
def initialize(content_types: nil, default_format: :txt, format: nil, formatters: nil, parsers: nil)
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
ALL_MEDIA_TYPES = '*/*'
|
|
15
|
+
|
|
16
|
+
# The request methods that can carry a body worth parsing. See
|
|
17
|
+
# {#read_body_input?}, which tests the env against this before anything
|
|
18
|
+
# asks for a Rack::Request. QUERY is here because its content *is* the
|
|
19
|
+
# query (RFC 10008, Section 2), not an optional payload.
|
|
20
|
+
BODY_CARRYING_METHODS = [Rack::POST, Rack::PUT, Rack::PATCH, Rack::DELETE, Grape::QUERY].freeze
|
|
21
|
+
|
|
22
|
+
# Read off ivars rather than delegated into +config+ on every request:
|
|
23
|
+
# +negotiate_content_type+ asks for +format+ and +default_format+ per
|
|
24
|
+
# request and +fetch_formatter+ for +formatters+, and each delegator cost
|
|
25
|
+
# a Forwardable frame plus a Data reader for a value that was frozen when
|
|
26
|
+
# the middleware was built.
|
|
27
|
+
attr_reader :default_format, :format, :formatters, :parsers
|
|
28
|
+
|
|
29
|
+
# The formatter is the only middleware that maps an incoming media type
|
|
30
|
+
# back to a format, so it warms +mime_types+ itself rather than making
|
|
31
|
+
# every content-type-aware middleware build a table none of them read.
|
|
32
|
+
def initialize(app, **options)
|
|
33
|
+
super
|
|
34
|
+
@default_format = config.default_format
|
|
35
|
+
@format = config.format
|
|
36
|
+
@formatters = config.formatters
|
|
37
|
+
@parsers = config.parsers
|
|
38
|
+
mime_types
|
|
39
|
+
end
|
|
9
40
|
|
|
10
41
|
def before
|
|
11
42
|
negotiate_content_type
|
|
@@ -15,19 +46,17 @@ module Grape
|
|
|
15
46
|
def after
|
|
16
47
|
return unless @app_response
|
|
17
48
|
|
|
18
|
-
status, headers, bodies =
|
|
49
|
+
status, headers, bodies = @app_response
|
|
19
50
|
|
|
20
|
-
if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.include?(status)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
build_formatted_response(status, headers, bodies)
|
|
24
|
-
end
|
|
51
|
+
return [status, headers, []] if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.include?(status)
|
|
52
|
+
|
|
53
|
+
build_formatted_response(status, headers, bodies)
|
|
25
54
|
end
|
|
26
55
|
|
|
27
56
|
private
|
|
28
57
|
|
|
29
58
|
def build_formatted_response(status, headers, bodies)
|
|
30
|
-
|
|
59
|
+
ensure_content_type!(headers)
|
|
31
60
|
|
|
32
61
|
if bodies.is_a?(Grape::ServeStream::StreamResponse)
|
|
33
62
|
Grape::ServeStream::SendfileResponse.new([], status, headers) do |resp|
|
|
@@ -35,44 +64,64 @@ module Grape
|
|
|
35
64
|
end
|
|
36
65
|
else
|
|
37
66
|
# Allow content-type to be explicitly overwritten
|
|
38
|
-
formatter = fetch_formatter(headers
|
|
39
|
-
bodymap =
|
|
40
|
-
bodies.
|
|
67
|
+
formatter = fetch_formatter(headers)
|
|
68
|
+
bodymap = instrument_format_response(formatter) do
|
|
69
|
+
bodies.map { |body| formatter.call(body, env) }
|
|
41
70
|
end
|
|
42
|
-
Rack::Response
|
|
71
|
+
# A bare Rack tuple rather than a Rack::Response: +headers+ is already
|
|
72
|
+
# a Grape::Util::Header (a Rack::Headers on Rack 3), so wrapping only
|
|
73
|
+
# re-normalizes the same keys into a second Headers hash that
|
|
74
|
+
# +Middleware::Base#call+ unwraps again with +to_a+ on the way out.
|
|
75
|
+
# The 204/304 bodies Rack::Response#finish would blank are returned
|
|
76
|
+
# above, before this point.
|
|
77
|
+
[status, headers, bodymap]
|
|
43
78
|
end
|
|
44
79
|
rescue Grape::Exceptions::InvalidFormatter => e
|
|
45
|
-
throw :error, status: 500, message: e.message, backtrace: e.backtrace, original_exception: e
|
|
80
|
+
throw :error, Grape::Exceptions::ErrorResponse.new(status: 500, message: e.message, backtrace: e.backtrace, original_exception: e)
|
|
46
81
|
end
|
|
47
82
|
|
|
48
|
-
|
|
83
|
+
# Guards on +listening?+ so that with no subscriber the payload Hash and
|
|
84
|
+
# notification machinery are skipped and the block runs directly (no added
|
|
85
|
+
# allocations); the block is forwarded anonymously.
|
|
86
|
+
def instrument_format_response(formatter, &)
|
|
87
|
+
return yield unless ActiveSupport::Notifications.notifier.listening?('format_response.grape')
|
|
88
|
+
|
|
89
|
+
ActiveSupport::Notifications.instrument('format_response.grape', formatter:, env:, &)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def fetch_formatter(headers)
|
|
49
93
|
api_format = env.fetch(Grape::Env::API_FORMAT) { mime_types[headers[Rack::CONTENT_TYPE]] }
|
|
50
|
-
Grape::Formatter.formatter_for(api_format,
|
|
94
|
+
Grape::Formatter.formatter_for(api_format, formatters)
|
|
51
95
|
end
|
|
52
96
|
|
|
53
97
|
# Set the content type header for the API format if it is not already present.
|
|
54
98
|
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
99
|
+
# Written into +headers+ rather than returned as a copy, hence the +!+:
|
|
100
|
+
# this runs on every response, and copying the hash costs an allocation
|
|
101
|
+
# per request for a header the caller is about to send anyway.
|
|
102
|
+
#
|
|
103
|
+
# @param headers [Hash] the response headers, mutated in place
|
|
104
|
+
# @return [void]
|
|
105
|
+
def ensure_content_type!(headers)
|
|
106
|
+
return if headers[Rack::CONTENT_TYPE]
|
|
107
|
+
|
|
108
|
+
headers[Rack::CONTENT_TYPE] = content_type_for(env[Grape::Env::API_FORMAT])
|
|
63
109
|
end
|
|
64
110
|
|
|
65
111
|
def read_body_input
|
|
112
|
+
return unless read_body_input?
|
|
113
|
+
|
|
66
114
|
input = rack_request.body # reads RACK_INPUT
|
|
67
115
|
return if input.nil?
|
|
68
|
-
return unless read_body_input?
|
|
69
116
|
|
|
70
|
-
input.
|
|
117
|
+
rewind = input.respond_to?(:rewind)
|
|
118
|
+
|
|
119
|
+
input.rewind if rewind
|
|
71
120
|
body = env[Grape::Env::API_REQUEST_INPUT] = input.read
|
|
72
121
|
begin
|
|
73
122
|
read_rack_input(body)
|
|
74
123
|
ensure
|
|
75
|
-
input.
|
|
124
|
+
input.rewind if rewind
|
|
76
125
|
end
|
|
77
126
|
end
|
|
78
127
|
|
|
@@ -80,28 +129,33 @@ module Grape
|
|
|
80
129
|
return if body.empty?
|
|
81
130
|
|
|
82
131
|
media_type = rack_request.media_type
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
132
|
+
|
|
133
|
+
# RFC 10008, Sections 2 and 2.1: a QUERY carries its query in the
|
|
134
|
+
# content, so a request that never says what that content is cannot be
|
|
135
|
+
# interpreted and must fail rather than be read as the default format.
|
|
136
|
+
# Every other method keeps that fallback.
|
|
137
|
+
throw :error, Grape::Exceptions::ErrorResponse.new(status: 400, message: 'The QUERY method requires a content-type.') if media_type.nil? && env[Rack::REQUEST_METHOD] == Grape::QUERY
|
|
138
|
+
|
|
139
|
+
fmt = media_type ? mime_types[media_type] : default_format
|
|
140
|
+
|
|
141
|
+
throw :error, Grape::Exceptions::ErrorResponse.new(status: 415, message: "The provided content-type '#{media_type}' is not supported.") unless content_type_for(fmt)
|
|
142
|
+
parser = Grape::Parser.parser_for fmt, parsers
|
|
143
|
+
return env[Grape::Env::API_REQUEST_BODY] = body unless parser
|
|
144
|
+
|
|
145
|
+
begin
|
|
146
|
+
parsed = (env[Grape::Env::API_REQUEST_BODY] = parser.call(body, env))
|
|
147
|
+
if parsed.is_a?(Hash)
|
|
148
|
+
if (form_hash = env[Rack::RACK_REQUEST_FORM_HASH])
|
|
149
|
+
form_hash.merge!(parsed)
|
|
150
|
+
else
|
|
151
|
+
env[Rack::RACK_REQUEST_FORM_HASH] = parsed
|
|
97
152
|
end
|
|
98
|
-
|
|
99
|
-
raise e
|
|
100
|
-
rescue StandardError => e
|
|
101
|
-
throw :error, status: 400, message: e.message, backtrace: e.backtrace, original_exception: e
|
|
153
|
+
env[Rack::RACK_REQUEST_FORM_INPUT] = env[Rack::RACK_INPUT]
|
|
102
154
|
end
|
|
103
|
-
|
|
104
|
-
|
|
155
|
+
rescue Grape::Exceptions::Base => e
|
|
156
|
+
raise e
|
|
157
|
+
rescue StandardError => e
|
|
158
|
+
throw :error, Grape::Exceptions::ErrorResponse.new(status: 400, message: e.message, backtrace: e.backtrace, original_exception: e)
|
|
105
159
|
end
|
|
106
160
|
end
|
|
107
161
|
|
|
@@ -112,35 +166,70 @@ module Grape
|
|
|
112
166
|
# - multipart/related
|
|
113
167
|
# - multipart/mixed
|
|
114
168
|
def read_body_input?
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
169
|
+
# Read off the env rather than through Rack::Request's predicates: this
|
|
170
|
+
# is what decides the question for every request, and on the GET, HEAD
|
|
171
|
+
# and OPTIONS majority it is the only thing the formatter would have
|
|
172
|
+
# built a Rack::Request for.
|
|
173
|
+
return false unless BODY_CARRYING_METHODS.include?(env[Rack::REQUEST_METHOD])
|
|
174
|
+
return false if rack_request.form_data? && rack_request.content_type
|
|
175
|
+
return false if rack_request.parseable_data?
|
|
176
|
+
|
|
177
|
+
rack_request.content_length.to_i.positive? || env['HTTP_TRANSFER_ENCODING'] == 'chunked'
|
|
119
178
|
end
|
|
120
179
|
|
|
121
180
|
def negotiate_content_type
|
|
122
|
-
fmt = format_from_extension ||
|
|
123
|
-
if content_type_for(fmt)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
throw :error, status: 406, message: "The requested format '#{fmt}' is not supported."
|
|
127
|
-
end
|
|
181
|
+
fmt = format_from_extension || format_from_query || format || format_from_header || default_format
|
|
182
|
+
return env[Grape::Env::API_FORMAT] = fmt.to_sym if content_type_for(fmt)
|
|
183
|
+
|
|
184
|
+
throw :error, Grape::Exceptions::ErrorResponse.new(status: 406, message: "The requested format '#{fmt}' is not supported.")
|
|
128
185
|
end
|
|
129
186
|
|
|
187
|
+
# Only the extension is scrubbed, and only once the path turns out to have
|
|
188
|
+
# one: +String#rindex+ takes a byte offset and never raises on an invalid
|
|
189
|
+
# sequence, and a +.+ byte cannot be part of a multi-byte one, so the dot
|
|
190
|
+
# sits at the same place before and after scrubbing. The overwhelming
|
|
191
|
+
# majority of paths carry no extension and now skip the scrub entirely.
|
|
130
192
|
def format_from_extension
|
|
131
|
-
request_path =
|
|
193
|
+
request_path = path_for_extension
|
|
132
194
|
dot_pos = request_path.rindex('.')
|
|
133
195
|
return unless dot_pos
|
|
134
196
|
|
|
135
|
-
extension = request_path[dot_pos + 1..]
|
|
197
|
+
extension = try_scrub(request_path[(dot_pos + 1)..])
|
|
136
198
|
extension if content_type_for(extension)
|
|
137
199
|
end
|
|
138
200
|
|
|
201
|
+
# The extension is the tail of the request path, so PATH_INFO answers it
|
|
202
|
+
# on its own whenever there is one: a dot in SCRIPT_NAME is followed by
|
|
203
|
+
# the slash that opens PATH_INFO, and no registered extension holds a
|
|
204
|
+
# slash. Only an empty PATH_INFO needs +Rack::Request#path+ — and with it
|
|
205
|
+
# the String its concatenation allocates. Tested with +empty?+ rather
|
|
206
|
+
# than +blank?+: the path is not scrubbed yet, and a regexp match on an
|
|
207
|
+
# invalid byte sequence raises.
|
|
208
|
+
def path_for_extension
|
|
209
|
+
path_info = env[Rack::PATH_INFO]
|
|
210
|
+
return rack_request.path if path_info.nil? || path_info.empty?
|
|
211
|
+
|
|
212
|
+
path_info
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# +?format=+ can only be there when there is a query string at all, so
|
|
216
|
+
# the common query-less request skips parsing one.
|
|
217
|
+
def format_from_query
|
|
218
|
+
query_string = env[Rack::QUERY_STRING]
|
|
219
|
+
return if query_string.nil? || query_string.empty?
|
|
220
|
+
|
|
221
|
+
query_params['format']
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Media types are case-insensitive (RFC 9110 §8.3.1) but the registered
|
|
225
|
+
# ones are spelled in lower case and Rack matches them literally, so an
|
|
226
|
+
# `Accept: TEXT/PLAIN` found nothing and fell through to the default
|
|
227
|
+
# format — the client quietly got something other than what it asked for.
|
|
139
228
|
def format_from_header
|
|
140
|
-
accept_header = env['HTTP_ACCEPT']
|
|
141
|
-
return if accept_header.blank?
|
|
229
|
+
accept_header = try_scrub(env['HTTP_ACCEPT'])
|
|
230
|
+
return if accept_header.blank? || accept_header == ALL_MEDIA_TYPES
|
|
142
231
|
|
|
143
|
-
media_type = Rack::Utils.best_q_match(accept_header, mime_types.keys)
|
|
232
|
+
media_type = Rack::Utils.best_q_match(accept_header.downcase, mime_types.keys)
|
|
144
233
|
mime_types[media_type] if media_type
|
|
145
234
|
end
|
|
146
235
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Grape
|
|
4
|
+
module Middleware
|
|
5
|
+
# Include in a middleware subclass that needs content-type negotiation.
|
|
6
|
+
# Provides +content_types+ / +mime_types+ / +content_type_for+ /
|
|
7
|
+
# +content_type+ resolved from +config.content_types+ and
|
|
8
|
+
# +config.format+ — so the consuming middleware's +Options+ Data class
|
|
9
|
+
# must declare both fields. Warms those caches on the parent instance
|
|
10
|
+
# at initialization so per-request +dup+s inherit them rather than
|
|
11
|
+
# rebuilding them.
|
|
12
|
+
#
|
|
13
|
+
# +mime_types+ is not warmed here: Formatter is the only middleware that
|
|
14
|
+
# reads it, and it warms it itself. The tables behind +mime_types+ and
|
|
15
|
+
# +content_type_for+ are shared process-wide per content-type registry
|
|
16
|
+
# (see Grape::ContentTypes), so the ivars below memoize a lookup, not a
|
|
17
|
+
# copy.
|
|
18
|
+
#
|
|
19
|
+
# Opt-in: plain +Grape::Middleware::Base+ subclasses that don't need
|
|
20
|
+
# content-type-aware helpers don't pay for them.
|
|
21
|
+
module PrecomputedContentTypes
|
|
22
|
+
def initialize(app, **options)
|
|
23
|
+
super
|
|
24
|
+
content_types
|
|
25
|
+
content_types_lookup
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def content_types
|
|
29
|
+
@content_types ||= Grape::ContentTypes.content_types_for(config.content_types)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def mime_types
|
|
33
|
+
@mime_types ||= Grape::ContentTypes.mime_types_for(content_types)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def content_type_for(format)
|
|
37
|
+
content_types_lookup[format]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def content_type
|
|
41
|
+
content_type_for(env[Grape::Env::API_FORMAT] || config.format) || 'text/html'
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def content_types_lookup
|
|
47
|
+
@content_types_lookup ||= Grape::ContentTypes.lookup_for(content_types)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -6,6 +6,7 @@ module Grape
|
|
|
6
6
|
# It allows to insert and insert after
|
|
7
7
|
class Stack
|
|
8
8
|
extend Forwardable
|
|
9
|
+
|
|
9
10
|
class Middleware
|
|
10
11
|
attr_reader :args, :block, :klass
|
|
11
12
|
|
|
@@ -27,6 +28,16 @@ module Grape
|
|
|
27
28
|
klass == other || (name.nil? && klass.superclass == other)
|
|
28
29
|
end
|
|
29
30
|
end
|
|
31
|
+
alias eql? ==
|
|
32
|
+
|
|
33
|
+
# Keyed on the wrapped class, so two Middleware wrapping the same
|
|
34
|
+
# class hash alike — and so does the class itself, which #== also
|
|
35
|
+
# accepts. The superclass fallback above can't be honoured here (a
|
|
36
|
+
# class and its superclass hash differently); it only ever serves
|
|
37
|
+
# #index / #include?, which compare with #== rather than by hash.
|
|
38
|
+
def hash
|
|
39
|
+
klass.hash
|
|
40
|
+
end
|
|
30
41
|
|
|
31
42
|
def inspect
|
|
32
43
|
klass.to_s
|
|
@@ -53,15 +64,15 @@ module Grape
|
|
|
53
64
|
end
|
|
54
65
|
|
|
55
66
|
def insert(index, klass, *args, &block)
|
|
56
|
-
|
|
57
|
-
middlewares.insert(
|
|
67
|
+
at = assert_index(index, :before)
|
|
68
|
+
middlewares.insert(at, self.class::Middleware.new(klass, args, block))
|
|
58
69
|
end
|
|
59
70
|
|
|
60
71
|
alias insert_before insert
|
|
61
72
|
|
|
62
|
-
def insert_after(index,
|
|
63
|
-
|
|
64
|
-
insert(
|
|
73
|
+
def insert_after(index, ...)
|
|
74
|
+
at = assert_index(index, :after)
|
|
75
|
+
insert(at + 1, ...)
|
|
65
76
|
end
|
|
66
77
|
|
|
67
78
|
def use(klass, *args, &block)
|
|
@@ -82,12 +93,10 @@ module Grape
|
|
|
82
93
|
|
|
83
94
|
# @return [Rack::Builder] the builder object with our middlewares applied
|
|
84
95
|
def build
|
|
85
|
-
Rack::Builder.new
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
end
|
|
90
|
-
end
|
|
96
|
+
builder = Rack::Builder.new
|
|
97
|
+
others.shift(others.size).each { |m| merge_with(m) }
|
|
98
|
+
middlewares.each { |m| m.build(builder) }
|
|
99
|
+
builder
|
|
91
100
|
end
|
|
92
101
|
|
|
93
102
|
# @description Add middlewares with :use operation to the stack. Store others with :insert_* operation for later
|
|
@@ -18,8 +18,8 @@ module Grape
|
|
|
18
18
|
# route.
|
|
19
19
|
class AcceptVersionHeader < Base
|
|
20
20
|
def before
|
|
21
|
-
potential_version = env['HTTP_ACCEPT_VERSION']
|
|
22
|
-
not_acceptable!('Accept-Version header must be set.') if strict
|
|
21
|
+
potential_version = try_scrub(env['HTTP_ACCEPT_VERSION'])
|
|
22
|
+
not_acceptable!('Accept-Version header must be set.') if strict && potential_version.blank?
|
|
23
23
|
|
|
24
24
|
return if potential_version.blank?
|
|
25
25
|
|
|
@@ -30,7 +30,7 @@ module Grape
|
|
|
30
30
|
private
|
|
31
31
|
|
|
32
32
|
def not_acceptable!(message)
|
|
33
|
-
throw :error, status: 406, headers: error_headers, message:
|
|
33
|
+
throw :error, Grape::Exceptions::ErrorResponse.new(status: 406, headers: error_headers, message:)
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
end
|
|
@@ -4,69 +4,61 @@ module Grape
|
|
|
4
4
|
module Middleware
|
|
5
5
|
module Versioner
|
|
6
6
|
class Base < Grape::Middleware::Base
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
version_options: {
|
|
10
|
-
strict: false,
|
|
11
|
-
cascade: true,
|
|
12
|
-
parameter: 'apiver'
|
|
13
|
-
}.freeze
|
|
14
|
-
}.freeze
|
|
7
|
+
extend Forwardable
|
|
8
|
+
include Grape::Middleware::PrecomputedContentTypes
|
|
15
9
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
10
|
+
Options = Data.define(
|
|
11
|
+
:content_types, :format, :mount_path, :prefix, :version_options, :versions
|
|
12
|
+
) do
|
|
13
|
+
def initialize(
|
|
14
|
+
content_types: nil, format: nil, mount_path: nil, prefix: nil,
|
|
15
|
+
version_options: Grape::DSL::VersionOptions.new, versions: nil
|
|
16
|
+
)
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
options[:versions]
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def prefix
|
|
26
|
-
options[:prefix]
|
|
27
|
-
end
|
|
21
|
+
CASCADE_PASS_HEADER = { 'X-Cascade' => 'pass' }.freeze
|
|
28
22
|
|
|
29
|
-
def
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def pattern
|
|
34
|
-
options[:pattern]
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def version_options
|
|
38
|
-
options[:version_options]
|
|
23
|
+
def self.inherited(klass)
|
|
24
|
+
super
|
|
25
|
+
Versioner.register(klass)
|
|
39
26
|
end
|
|
40
27
|
|
|
41
|
-
|
|
42
|
-
version_options[:strict]
|
|
43
|
-
end
|
|
28
|
+
attr_reader :available_media_types, :error_headers, :versions
|
|
44
29
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
# this behavior, and not add the `X-Cascade` header, one can set the `:cascade` option to `false`.
|
|
48
|
-
def cascade?
|
|
49
|
-
version_options[:cascade]
|
|
50
|
-
end
|
|
30
|
+
def_delegators :config, :mount_path, :prefix, :version_options
|
|
31
|
+
def_delegators :version_options, :cascade, :parameter, :strict, :vendor
|
|
51
32
|
|
|
52
|
-
def
|
|
53
|
-
|
|
33
|
+
def initialize(app, **options)
|
|
34
|
+
super
|
|
35
|
+
@versions = config.versions&.map(&:to_s) # making sure versions are strings to ease potential match
|
|
36
|
+
@error_headers = cascade ? CASCADE_PASS_HEADER : {}
|
|
37
|
+
@available_media_types = build_available_media_types
|
|
54
38
|
end
|
|
55
39
|
|
|
56
|
-
def
|
|
57
|
-
|
|
40
|
+
def potential_version_match?(potential_version)
|
|
41
|
+
versions.blank? || versions.include?(potential_version)
|
|
58
42
|
end
|
|
59
43
|
|
|
60
|
-
def
|
|
61
|
-
|
|
44
|
+
def version_not_found!
|
|
45
|
+
throw :error, Grape::Exceptions::ErrorResponse.new(status: 404, message: '404 API Version Not Found', headers: CASCADE_PASS_HEADER)
|
|
62
46
|
end
|
|
63
47
|
|
|
64
|
-
|
|
65
|
-
versions.blank? || versions.any? { |v| v.to_s == potential_version }
|
|
66
|
-
end
|
|
48
|
+
private
|
|
67
49
|
|
|
68
|
-
def
|
|
69
|
-
|
|
50
|
+
def build_available_media_types
|
|
51
|
+
media_types = []
|
|
52
|
+
base_media_type = "application/vnd.#{vendor}"
|
|
53
|
+
versions&.reverse_each do |version|
|
|
54
|
+
versioned_base = "#{base_media_type}-#{version}"
|
|
55
|
+
content_types.each_key { |extension| media_types << "#{versioned_base}+#{extension}" }
|
|
56
|
+
media_types << versioned_base
|
|
57
|
+
end
|
|
58
|
+
content_types.each_key { |extension| media_types << "#{base_media_type}+#{extension}" }
|
|
59
|
+
media_types << base_media_type
|
|
60
|
+
media_types.concat(content_types.values.flatten)
|
|
61
|
+
media_types
|
|
70
62
|
end
|
|
71
63
|
end
|
|
72
64
|
end
|
|
@@ -41,11 +41,9 @@ module Grape
|
|
|
41
41
|
|
|
42
42
|
strict_header_checks!
|
|
43
43
|
media_type = Grape::Util::MediaType.best_quality(accept_header, available_media_types)
|
|
44
|
-
if media_type
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
fail!
|
|
48
|
-
end
|
|
44
|
+
return yield media_type if media_type
|
|
45
|
+
|
|
46
|
+
fail!
|
|
49
47
|
end
|
|
50
48
|
|
|
51
49
|
def accept_header
|
|
@@ -53,7 +51,7 @@ module Grape
|
|
|
53
51
|
end
|
|
54
52
|
|
|
55
53
|
def strict_header_checks!
|
|
56
|
-
return unless strict
|
|
54
|
+
return unless strict
|
|
57
55
|
|
|
58
56
|
accept_header_check!
|
|
59
57
|
version_and_vendor_check!
|
|
@@ -101,26 +99,10 @@ module Grape
|
|
|
101
99
|
end
|
|
102
100
|
|
|
103
101
|
def version_not_found!(media_types)
|
|
104
|
-
return unless media_types.all? { |media_type| media_type&.version && versions
|
|
102
|
+
return unless media_types.all? { |media_type| media_type&.version && versions && !versions.include?(media_type.version) }
|
|
105
103
|
|
|
106
104
|
invalid_version_header!('API version not found.')
|
|
107
105
|
end
|
|
108
|
-
|
|
109
|
-
def available_media_types
|
|
110
|
-
[].tap do |available_media_types|
|
|
111
|
-
base_media_type = "application/vnd.#{vendor}"
|
|
112
|
-
content_types.each_key do |extension|
|
|
113
|
-
versions&.reverse_each do |version|
|
|
114
|
-
available_media_types << "#{base_media_type}-#{version}+#{extension}"
|
|
115
|
-
available_media_types << "#{base_media_type}-#{version}"
|
|
116
|
-
end
|
|
117
|
-
available_media_types << "#{base_media_type}+#{extension}"
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
available_media_types << base_media_type
|
|
121
|
-
available_media_types.concat(content_types.values.flatten)
|
|
122
|
-
end
|
|
123
|
-
end
|
|
124
106
|
end
|
|
125
107
|
end
|
|
126
108
|
end
|
|
@@ -20,11 +20,11 @@ module Grape
|
|
|
20
20
|
# env['api.version'] => 'v1'
|
|
21
21
|
class Param < Base
|
|
22
22
|
def before
|
|
23
|
-
potential_version = query_params[
|
|
23
|
+
potential_version = query_params[parameter]
|
|
24
24
|
return if potential_version.blank?
|
|
25
25
|
|
|
26
26
|
version_not_found! unless potential_version_match?(potential_version)
|
|
27
|
-
env[Grape::Env::API_VERSION] = env[Rack::RACK_REQUEST_QUERY_HASH].delete(
|
|
27
|
+
env[Grape::Env::API_VERSION] = env[Rack::RACK_REQUEST_QUERY_HASH].delete(parameter)
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
end
|