Skip to content

Commit c42b66f

Browse files
authored
Less query params parsing (#2553)
1 parent 3a4df72 commit c42b66f

File tree

13 files changed

+193
-34
lines changed

13 files changed

+193
-34
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
* [#2547](https://github.com/ruby-grape/grape/pull/2547): Remove jsonapi related code - [@ericproulx](https://github.com/ericproulx).
2020
* [#2548](https://github.com/ruby-grape/grape/pull/2548): Formatting from header acts like versioning from header - [@ericproulx](https://github.com/ericproulx).
2121
* [#2552](https://github.com/ruby-grape/grape/pull/2552): Fix declared params optional array - [@ericproulx](https://github.com/ericproulx).
22+
* [#2553](https://github.com/ruby-grape/grape/pull/2553): Improve performance of query params parsing - [@ericproulx](https://github.com/ericproulx).
2223
* Your contribution here.
2324

2425
### 2.3.0 (2025-02-08)

UPGRADING.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
Upgrading Grape
22
===============
33

4+
### Grape::Middleware::Base
5+
6+
- Constant `TEXT_HTML` has been removed in favor of using literal string 'text/html'.
7+
- `rack_request` and `query_params` have been added. Feel free to call these in your middlewares.
8+
49
#### Params Builder
510

611
- Passing a class to `build_with` or `Grape.config.param_builder` has been deprecated in favor of a symbolized short_name. See `SHORTNAME_LOOKUP` in [params_builder](lib/grape/params_builder.rb).
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
module Grape
4+
module Exceptions
5+
class ConflictingTypes < Base
6+
def initialize
7+
super(message: compose_message(:conflicting_types), status: 400)
8+
end
9+
end
10+
end
11+
end
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
module Grape
4+
module Exceptions
5+
class InvalidParameters < Base
6+
def initialize
7+
super(message: compose_message(:invalid_parameters), status: 400)
8+
end
9+
end
10+
end
11+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# frozen_string_literal: true
2+
3+
module Grape
4+
module Exceptions
5+
class TooDeepParameters < Base
6+
def initialize(limit)
7+
super(message: compose_message(:too_deep_parameters, limit: limit), status: 400)
8+
end
9+
end
10+
end
11+
end

lib/grape/locale/en.yml

+3-1
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,6 @@ en:
5858
problem: 'invalid version header'
5959
resolution: '%{message}'
6060
invalid_response: 'Invalid response'
61-
query_parsing: 'query params are not parsable'
61+
conflicting_types: 'query params contains conflicting types'
62+
invalid_parameters: 'query params contains invalid format or byte sequence'
63+
too_deep_parameters: 'query params are recursively nested over the specified limit (%{limit})'

lib/grape/middleware/base.rb

+13-3
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ class Base
88

99
attr_reader :app, :env, :options
1010

11-
TEXT_HTML = 'text/html'
12-
1311
# @param [Rack Application] app The standard argument for a Rack middleware.
1412
# @param [Hash] options A hash of options, simply stored for use by subclasses.
1513
def initialize(app, *options)
@@ -54,6 +52,10 @@ def before; end
5452
# @return [Response, nil] a Rack SPEC response or nil to call the application afterwards.
5553
def after; end
5654

55+
def rack_request
56+
@rack_request ||= Rack::Request.new(env)
57+
end
58+
5759
def response
5860
return @app_response if @app_response.is_a?(Rack::Response)
5961

@@ -73,7 +75,15 @@ def content_type_for(format)
7375
end
7476

7577
def content_type
76-
content_type_for(env[Grape::Env::API_FORMAT] || options[:format]) || TEXT_HTML
78+
content_type_for(env[Grape::Env::API_FORMAT] || options[:format]) || 'text/html'
79+
end
80+
81+
def query_params
82+
rack_request.GET
83+
rescue Rack::QueryParser::ParamsTooDeepError
84+
raise Grape::Exceptions::TooDeepParameters.new(Rack::Utils.param_depth_limit)
85+
rescue Rack::Utils::ParameterTypeError
86+
raise Grape::Exceptions::ConflictingTypes
7787
end
7888

7989
private

lib/grape/middleware/error.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def call!(env)
3939
private
4040

4141
def rack_response(status, headers, message)
42-
message = Rack::Utils.escape_html(message) if headers[Rack::CONTENT_TYPE] == TEXT_HTML
42+
message = Rack::Utils.escape_html(message) if headers[Rack::CONTENT_TYPE] == 'text/html'
4343
Rack::Response.new(Array.wrap(message), Rack::Utils.status_code(status), Grape::Util::Header.new.merge(headers))
4444
end
4545

lib/grape/middleware/formatter.rb

+24-25
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@
33
module Grape
44
module Middleware
55
class Formatter < Base
6-
CHUNKED = 'chunked'
7-
FORMAT = 'format'
8-
96
def default_options
107
{
118
default_format: :txt,
@@ -69,34 +66,27 @@ def ensure_content_type(headers)
6966
end
7067
end
7168

72-
def request
73-
@request ||= Rack::Request.new(env)
74-
end
75-
76-
# store read input in env['api.request.input']
7769
def read_body_input
78-
return unless
79-
(request.post? || request.put? || request.patch? || request.delete?) &&
80-
(!request.form_data? || !request.media_type) &&
81-
!request.parseable_data? &&
82-
(request.content_length.to_i.positive? || request.env[Grape::Http::Headers::HTTP_TRANSFER_ENCODING] == CHUNKED)
83-
84-
return unless (input = env[Rack::RACK_INPUT])
70+
input = rack_request.body # reads RACK_INPUT
71+
return if input.nil?
72+
return unless read_body_input?
8573

8674
input.try(:rewind)
8775
body = env[Grape::Env::API_REQUEST_INPUT] = input.read
8876
begin
89-
read_rack_input(body) if body && !body.empty?
77+
read_rack_input(body)
9078
ensure
9179
input.try(:rewind)
9280
end
9381
end
9482

95-
# store parsed input in env['api.request.body']
9683
def read_rack_input(body)
97-
fmt = request.media_type ? mime_types[request.media_type] : options[:default_format]
84+
return if body.empty?
85+
86+
media_type = rack_request.media_type
87+
fmt = media_type ? mime_types[media_type] : options[:default_format]
9888

99-
throw :error, status: 415, message: "The provided content-type '#{request.media_type}' is not supported." unless content_type_for(fmt)
89+
throw :error, status: 415, message: "The provided content-type '#{media_type}' is not supported." unless content_type_for(fmt)
10090
parser = Grape::Parser.parser_for fmt, options[:parsers]
10191
if parser
10292
begin
@@ -119,8 +109,21 @@ def read_rack_input(body)
119109
end
120110
end
121111

112+
# this middleware will not try to format the following content-types since Rack already handles them
113+
# when calling Rack's `params` function
114+
# - application/x-www-form-urlencoded
115+
# - multipart/form-data
116+
# - multipart/related
117+
# - multipart/mixed
118+
def read_body_input?
119+
(rack_request.post? || rack_request.put? || rack_request.patch? || rack_request.delete?) &&
120+
!(rack_request.form_data? && rack_request.content_type) &&
121+
!rack_request.parseable_data? &&
122+
(rack_request.content_length.to_i.positive? || rack_request.env[Grape::Http::Headers::HTTP_TRANSFER_ENCODING] == 'chunked')
123+
end
124+
122125
def negotiate_content_type
123-
fmt = format_from_extension || format_from_params || options[:format] || format_from_header || options[:default_format]
126+
fmt = format_from_extension || query_params['format'] || options[:format] || format_from_header || options[:default_format]
124127
if content_type_for(fmt)
125128
env[Grape::Env::API_FORMAT] = fmt.to_sym
126129
else
@@ -129,18 +132,14 @@ def negotiate_content_type
129132
end
130133

131134
def format_from_extension
132-
request_path = request.path.try(:scrub)
135+
request_path = rack_request.path.try(:scrub)
133136
dot_pos = request_path.rindex('.')
134137
return unless dot_pos
135138

136139
extension = request_path[dot_pos + 1..]
137140
extension if content_type_for(extension)
138141
end
139142

140-
def format_from_params
141-
Rack::Utils.parse_nested_query(env[Rack::QUERY_STRING])[FORMAT]
142-
end
143-
144143
def format_from_header
145144
accept_header = env[Grape::Http::Headers::HTTP_ACCEPT].try(:scrub)
146145
return if accept_header.blank?

lib/grape/middleware/versioner/param.rb

+2-3
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,11 @@ module Versioner
2020
# env['api.version'] => 'v1'
2121
class Param < Base
2222
def before
23-
potential_version = Rack::Utils.parse_nested_query(env[Rack::QUERY_STRING])[parameter_key]
23+
potential_version = query_params[parameter_key]
2424
return if potential_version.blank?
2525

2626
version_not_found! unless potential_version_match?(potential_version)
27-
env[Grape::Env::API_VERSION] = potential_version
28-
env[Rack::RACK_REQUEST_QUERY_HASH].delete(parameter_key) if env.key? Rack::RACK_REQUEST_QUERY_HASH
27+
env[Grape::Env::API_VERSION] = env[Rack::RACK_REQUEST_QUERY_HASH].delete(parameter_key)
2928
end
3029
end
3130
end

lib/grape/request.rb

+7-1
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,14 @@ def make_params
3737
@params_builder.call(rack_params).deep_merge!(grape_routing_args)
3838
rescue EOFError
3939
raise Grape::Exceptions::EmptyMessageBody.new(content_type)
40-
rescue Rack::Multipart::MultipartPartLimitError
40+
rescue Rack::Multipart::MultipartPartLimitError, Rack::Multipart::MultipartTotalPartLimitError
4141
raise Grape::Exceptions::TooManyMultipartFiles.new(Rack::Utils.multipart_part_limit)
42+
rescue Rack::QueryParser::ParamsTooDeepError
43+
raise Grape::Exceptions::TooDeepParameters.new(Rack::Utils.param_depth_limit)
44+
rescue Rack::Utils::ParameterTypeError
45+
raise Grape::Exceptions::ConflictingTypes
46+
rescue Rack::Utils::InvalidParameterError
47+
raise Grape::Exceptions::InvalidParameters
4248
end
4349

4450
def build_headers

spec/grape/middleware/base_spec.rb

+32
Original file line numberDiff line numberDiff line change
@@ -223,4 +223,36 @@ def after
223223
expect(last_response.headers['X-Test-Overwriting']).to eq('Bye')
224224
end
225225
end
226+
227+
describe 'query_params' do
228+
let(:dummy_middleware) do
229+
Class.new(Grape::Middleware::Base) do
230+
def before
231+
query_params
232+
end
233+
end
234+
end
235+
236+
let(:app) do
237+
context = self
238+
Rack::Builder.app do
239+
use context.dummy_middleware
240+
run ->(_) { [200, {}, ['Yeah']] }
241+
end
242+
end
243+
244+
context 'when query params are conflicting' do
245+
it 'raises an ConflictingTypes error' do
246+
expect { get '/?x[y]=1&x[y]z=2' }.to raise_error(Grape::Exceptions::ConflictingTypes)
247+
end
248+
end
249+
250+
context 'when query params is over the specified limit' do
251+
let(:query_params) { "foo#{'[a]' * Rack::Utils.param_depth_limit}=bar" }
252+
253+
it 'raises an ConflictingTypes error' do
254+
expect { get "/?foo#{'[a]' * Rack::Utils.param_depth_limit}=bar" }.to raise_error(Grape::Exceptions::TooDeepParameters)
255+
end
256+
end
257+
end
226258
end

spec/grape/request_spec.rb

+72
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,78 @@
5959
expect(request.params).to eq(ActiveSupport::HashWithIndifferentAccess.new(a: '123', b: 'xyz', c: 'ccc'))
6060
end
6161
end
62+
63+
context 'when rack_params raises an EOF error' do
64+
before do
65+
allow(request).to receive(:rack_params).and_raise(EOFError)
66+
end
67+
68+
let(:message) { Grape::Exceptions::EmptyMessageBody.new(nil).to_s }
69+
70+
it 'raises an Grape::Exceptions::EmptyMessageBody' do
71+
expect { request.params }.to raise_error(Grape::Exceptions::EmptyMessageBody, message)
72+
end
73+
end
74+
75+
context 'when rack_params raises a Rack::Multipart::MultipartPartLimitError' do
76+
before do
77+
allow(request).to receive(:rack_params).and_raise(Rack::Multipart::MultipartPartLimitError)
78+
end
79+
80+
let(:message) { Grape::Exceptions::TooManyMultipartFiles.new(Rack::Utils.multipart_part_limit).to_s }
81+
82+
it 'raises an Rack::Multipart::MultipartPartLimitError' do
83+
expect { request.params }.to raise_error(Grape::Exceptions::TooManyMultipartFiles, message)
84+
end
85+
end
86+
87+
context 'when rack_params raises a Rack::Multipart::MultipartTotalPartLimitError' do
88+
before do
89+
allow(request).to receive(:rack_params).and_raise(Rack::Multipart::MultipartTotalPartLimitError)
90+
end
91+
92+
let(:message) { Grape::Exceptions::TooManyMultipartFiles.new(Rack::Utils.multipart_part_limit).to_s }
93+
94+
it 'raises an Rack::Multipart::MultipartPartLimitError' do
95+
expect { request.params }.to raise_error(Grape::Exceptions::TooManyMultipartFiles, message)
96+
end
97+
end
98+
99+
context 'when rack_params raises a Rack::QueryParser::ParamsTooDeepError' do
100+
before do
101+
allow(request).to receive(:rack_params).and_raise(Rack::QueryParser::ParamsTooDeepError)
102+
end
103+
104+
let(:message) { Grape::Exceptions::TooDeepParameters.new(Rack::Utils.param_depth_limit).to_s }
105+
106+
it 'raises a Grape::Exceptions::TooDeepParameters' do
107+
expect { request.params }.to raise_error(Grape::Exceptions::TooDeepParameters, message)
108+
end
109+
end
110+
111+
context 'when rack_params raises a Rack::Utils::ParameterTypeError' do
112+
before do
113+
allow(request).to receive(:rack_params).and_raise(Rack::Utils::ParameterTypeError)
114+
end
115+
116+
let(:message) { Grape::Exceptions::ConflictingTypes.new.to_s }
117+
118+
it 'raises a Grape::Exceptions::ConflictingTypes' do
119+
expect { request.params }.to raise_error(Grape::Exceptions::ConflictingTypes, message)
120+
end
121+
end
122+
123+
context 'when rack_params raises a Rack::Utils::InvalidParameterError' do
124+
before do
125+
allow(request).to receive(:rack_params).and_raise(Rack::Utils::InvalidParameterError)
126+
end
127+
128+
let(:message) { Grape::Exceptions::InvalidParameters.new.to_s }
129+
130+
it 'raises an Rack::Multipart::MultipartPartLimitError' do
131+
expect { request.params }.to raise_error(Grape::Exceptions::InvalidParameters, message)
132+
end
133+
end
62134
end
63135

64136
describe '#headers' do

0 commit comments

Comments
 (0)