Skip to content

Commit aa46c6b

Browse files
Integrate protocol multipart form data.
1 parent dfba2e0 commit aa46c6b

9 files changed

Lines changed: 156 additions & 102 deletions

File tree

lib/utopia/application.rb

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ class Application < Protocol::HTTP::Middleware
2222
# @parameter default_app [Interface(:call)] The terminal application used when the block does not call `run`.
2323
# @parameter block [Proc] The middleware builder block.
2424
# @returns [Application] The protocol-facing Utopia application.
25-
def self.build(default_app = Response::NotFound, &block)
25+
# @parameter form_data_options [Hash | Nil] Default form-data parsing options for each request.
26+
def self.build(default_app = Response::NotFound, form_data_options: nil, &block)
2627
builder = Protocol::HTTP::Middleware::Builder.new(default_app)
2728

2829
if block
@@ -33,13 +34,14 @@ def self.build(default_app = Response::NotFound, &block)
3334
end
3435
end
3536

36-
return self.new(builder.to_app)
37+
return self.new(builder.to_app, form_data_options: form_data_options)
3738
end
3839

3940
# Build the default Utopia application.
41+
# @parameter options [Hash] Options passed to the application constructor.
4042
# @returns [Application] The default protocol-facing Utopia application.
41-
def self.default
42-
self.build
43+
def self.default(**options)
44+
self.build(**options)
4345
end
4446

4547
# Load a Utopia application from a conventional configuration file.
@@ -68,20 +70,23 @@ def self.load(path = PATH, **options)
6870
end
6971
end
7072

71-
return self.default
73+
return self.default(**options)
7274
end
7375

7476
# Initialize the protocol-facing application boundary.
7577
# @parameter delegate [Interface(:call)] The Utopia application stack.
76-
def initialize(delegate)
78+
# @parameter form_data_options [Hash | Nil] Default form-data parsing options for each request.
79+
def initialize(delegate, form_data_options: nil)
7780
super(delegate)
81+
82+
@form_data_options = form_data_options&.dup&.freeze
7883
end
7984

8085
# Process a protocol HTTP request.
8186
# @parameter request [Protocol::HTTP::Request] The incoming protocol request.
8287
# @returns [Protocol::HTTP::Response] The normalized protocol response.
8388
def call(request)
84-
request = Request.new(request)
89+
request = Request.new(request, form_data_options: @form_data_options)
8590

8691
return Response.wrap(super(request))
8792
end

lib/utopia/controller/actions.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,24 +25,24 @@ on "new" do |request|
2525
@user = User.new
2626

2727
if request.post?
28-
@user.update_attributes(request.arguments["user"])
28+
@user.update_attributes(request.form_data["user"])
2929

3030
redirect! "index"
3131
end
3232
end
3333

3434
on "edit" do |request|
35-
@user = User.find(request.arguments["id"])
35+
@user = User.find(request.query_arguments["id"])
3636

3737
if request.post?
38-
@user.update_attributes(request.arguments["user"])
38+
@user.update_attributes(request.form_data["user"])
3939

4040
redirect! "index"
4141
end
4242
end
4343

4444
on "delete" do |request|
45-
User.find(request.arguments["id"]).destroy
45+
User.find(request.query_arguments["id"]).destroy
4646

4747
redirect! "index"
4848
end

lib/utopia/exceptions/mailer.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ def generate_body(exception, request)
113113
io.puts "request.#{key}: #{value.inspect}"
114114
end
115115

116-
request.arguments.each do |key, value|
117-
io.puts "request.arguments.#{key}: #{value.inspect}"
116+
request.query_arguments.each do |key, value|
117+
io.puts "request.query_arguments.#{key}: #{value.inspect}"
118118
end
119119

120120
io.puts

lib/utopia/request.rb

Lines changed: 65 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
require "tempfile"
77

88
require "protocol/http/request"
9-
require "protocol/multipart/parser"
9+
require "protocol/multipart/form_data"
1010
require "protocol/url/encoding"
1111

1212
module Utopia
@@ -24,6 +24,12 @@ class Request
2424
# The maximum nesting depth accepted for structured arguments.
2525
MAXIMUM_ARGUMENT_DEPTH = 8
2626

27+
# The default maximum size of a URL-encoded form body.
28+
MAXIMUM_URL_ENCODED_SIZE = Protocol::Multipart::FormData::MAXIMUM_FIELD_SIZE
29+
30+
FORM_DATA_UNDEFINED = Object.new.freeze
31+
private_constant :FORM_DATA_UNDEFINED
32+
2733
# A file uploaded as part of a multipart form.
2834
class Upload
2935
# Initialize an uploaded file.
@@ -52,7 +58,7 @@ def initialize(headers, filename, tempfile, size)
5258

5359
# The submitted content type, if present.
5460
def content_type
55-
@headers["content-type"]
61+
@headers["content-type"]&.type
5662
end
5763
end
5864

@@ -119,18 +125,20 @@ def self.[](*arguments)
119125
# Initialize the request proxy.
120126
# @parameter delegate [Protocol::HTTP::Request] The underlying protocol request.
121127
# @parameter request_path [String | Nil] The original path before internal rewrites.
122-
def initialize(delegate, request_path: nil)
128+
# @parameter form_data_options [Hash | Nil] Default options for parsing form data.
129+
def initialize(delegate, request_path: nil, form_data_options: nil)
123130
@delegate = delegate
124131
@request_path = request_path
132+
@form_data_options = form_data_options&.dup&.freeze || {}.freeze
125133
@session = nil
126134
@variables = nil
127135
@locale = nil
128136
@localization = nil
129137
@exception = nil
130138

131139
@query_arguments = nil
132-
@form_arguments = nil
133-
@arguments = nil
140+
@form_data = FORM_DATA_UNDEFINED
141+
@form_data_effective_options = nil
134142
@cookies = nil
135143
end
136144

@@ -144,8 +152,8 @@ def initialize_copy(other)
144152

145153
@delegate = other.delegate.dup
146154
@query_arguments = nil
147-
@form_arguments = nil
148-
@arguments = nil
155+
@form_data = FORM_DATA_UNDEFINED
156+
@form_data_effective_options = nil
149157
@cookies = nil
150158
end
151159

@@ -157,15 +165,14 @@ def path= value
157165

158166
@delegate.path = value
159167
@query_arguments = nil
160-
@arguments = nil
161168
end
162169

163-
# Assign the request body and clear any decoded form arguments.
170+
# Assign the request body and clear any decoded form data.
164171
# @parameter value [Protocol::HTTP::Body::Readable | Nil] The new request body.
165172
def body= value
166173
@delegate.body = value
167-
@form_arguments = nil
168-
@arguments = nil
174+
@form_data = FORM_DATA_UNDEFINED
175+
@form_data_effective_options = nil
169176
end
170177

171178
# Whether the request method is POST.
@@ -208,14 +215,26 @@ def query_arguments
208215
@query_arguments ||= decode_arguments(self.query)
209216
end
210217

211-
# Decoded form arguments, when the request has a supported form content type.
212-
def form_arguments
213-
@form_arguments ||= decode_form_arguments
214-
end
215-
216-
# Decoded query and form arguments. Form arguments take precedence on collision.
217-
def arguments
218-
@arguments ||= self.query_arguments.merge(self.form_arguments)
218+
# Decode form data using the request defaults and any endpoint-specific overrides.
219+
#
220+
# @parameter options [Hash] Endpoint-specific form-data parsing options.
221+
# @returns [Hash] The decoded fields and uploads, or an empty hash for an unsupported content type.
222+
def form_data(**options)
223+
effective_options = @form_data_options.merge(options)
224+
225+
unless @form_data.equal?(FORM_DATA_UNDEFINED)
226+
if options.any? and effective_options != @form_data_effective_options
227+
raise ArgumentError, "Form data has already been decoded with different options!"
228+
end
229+
230+
return @form_data
231+
end
232+
233+
form_data = decode_form_data(effective_options)
234+
@form_data_effective_options = effective_options.freeze
235+
@form_data = form_data
236+
237+
return form_data
219238
end
220239

221240
# Decoded request cookies.
@@ -272,7 +291,7 @@ def with(method: self.method, path: self.path, path_info: nil)
272291
delegate = @delegate.dup
273292
delegate.method = method
274293

275-
request = self.class.new(delegate, request_path: self.request_path)
294+
request = self.class.new(delegate, request_path: self.request_path, form_data_options: @form_data_options)
276295
request.session = @session
277296
request.variables = @variables
278297
request.locale = @locale
@@ -316,54 +335,55 @@ def decode_arguments(query)
316335
return Protocol::URL::Encoding.decode(query.gsub("+", "%20"), MAXIMUM_ARGUMENT_DEPTH)
317336
end
318337

319-
def decode_form_arguments
320-
content_type, parameters = parse_header(self.headers["content-type"])
338+
def decode_form_data(options)
339+
value = self.headers["content-type"]
340+
return {} unless value
321341

322-
case content_type
342+
content_type = Protocol::Multipart::Header::ContentType.coerce(value)
343+
344+
case content_type.type
323345
when FORM_URL_ENCODED
324-
return decode_arguments(read_body)
346+
maximum_size = options.fetch(:maximum_total_size, MAXIMUM_URL_ENCODED_SIZE)
347+
return decode_arguments(read_body(maximum_size))
325348
when MULTIPART_FORM_DATA
326-
boundary = parameters["boundary"]
349+
boundary = content_type["boundary"]
327350

328351
unless boundary
329352
raise ArgumentError, "Multipart form data is missing a boundary!"
330353
end
331354

332-
return decode_multipart_form(boundary)
355+
return decode_multipart_form(boundary, **options)
333356
else
334357
return {}
335358
end
336359
end
337360

338-
def read_body
361+
def read_body(maximum_size)
362+
content = String.new.b
363+
limit = Protocol::Multipart::ByteLimit.new(maximum_size, name: :form_size)
364+
339365
if body = self.body
340-
return body.join || String.new
366+
while chunk = body.read
367+
limit.consume(chunk.bytesize)
368+
content << chunk
369+
end
341370
end
342371

343-
return String.new
372+
return content
344373
end
345374

346-
def decode_multipart_form(boundary)
375+
def decode_multipart_form(boundary, **options)
347376
arguments = {}
348377
body = self.body
349378

350379
return arguments unless body
351380

352381
io = BodyIO.new(body)
353-
parser = Protocol::Multipart::Parser.new(io, boundary)
354382

355383
begin
356-
parser.each do |part|
357-
disposition, parameters = parse_header(part.headers["content-disposition"])
358-
359-
unless disposition == "form-data" and name = parameters["name"]
360-
raise ArgumentError, "Multipart form part is missing a form-data name!"
361-
end
362-
363-
if filename = parameters["filename"]
364-
value = create_upload(part, filename)
365-
else
366-
value = read_part(part)
384+
Protocol::Multipart::FormData.parse(io, boundary, **options) do |name, value|
385+
if value.is_a?(Protocol::Multipart::FormData::Upload)
386+
value = create_upload(value)
367387
end
368388

369389
assign_argument(arguments, name, value)
@@ -375,24 +395,16 @@ def decode_multipart_form(boundary)
375395
return arguments
376396
end
377397

378-
def read_part(part)
379-
content = String.new.b
380-
part.each{|chunk| content << chunk}
381-
return content
382-
end
383-
384-
def create_upload(part, filename)
398+
def create_upload(upload)
385399
tempfile = Tempfile.new("utopia-upload", binmode: true)
386-
size = 0
387400

388401
begin
389-
part.each do |chunk|
402+
upload.each do |chunk|
390403
tempfile.write(chunk)
391-
size += chunk.bytesize
392404
end
393405

394406
tempfile.rewind
395-
return Upload.new(part.headers, filename, tempfile, size)
407+
return Upload.new(upload.headers, upload.filename, tempfile, upload.size)
396408
rescue
397409
tempfile.close!
398410
raise
@@ -413,21 +425,6 @@ def assign_argument(arguments, name, value)
413425
Protocol::URL::Encoding.assign(keys, value, arguments)
414426
end
415427

416-
PARAMETER = /;\s*([!#$%&'*+\-.^_`|~0-9A-Za-z]+)\s*=\s*(?:"((?:\\.|[^"])*)"|([^;\s]*))/.freeze
417-
418-
def parse_header(value)
419-
return [nil, {}] unless value
420-
421-
value = value.first if value.is_a?(Array)
422-
parameters = {}
423-
424-
value.scan(PARAMETER) do |name, quoted, token|
425-
parameters[name.downcase] = quoted ? quoted.gsub(/\\(.)/, "\\1") : token
426-
end
427-
428-
return [value.split(";", 2).first.strip.downcase, parameters]
429-
end
430-
431428
def parse_cookies(cookie_header)
432429
cookies = {}
433430

test/utopia/application.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,24 @@ def response_object.to_response
5959
expect(response.status).to be == 404
6060
end
6161

62+
it "provides default form data options to requests" do
63+
application = subject.build(form_data_options: {maximum_total_size: 4}) do
64+
run lambda{|request|
65+
request.form_data
66+
Utopia::Response.text("Parsed")
67+
}
68+
end
69+
70+
request = Protocol::HTTP::Request[
71+
"POST",
72+
"/submit",
73+
{"content-type" => "application/x-www-form-urlencoded"},
74+
Protocol::HTTP::Body::Buffered.wrap("value=large")
75+
]
76+
77+
expect{application.call(request)}.to raise_exception(RangeError, message: be =~ /form_size exceeded/)
78+
end
79+
6280
it "loads a top-level application constant" do
6381
Dir.mktmpdir do |directory|
6482
path = File.join(directory, "application.rb")

test/utopia/exceptions/.handler/controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class TharSheBlows < StandardError
1414

1515
# The ExceptionHandler middleware will redirect here when an exception occurs. If this also fails, things get ugly.
1616
on 'exception' do |request|
17-
if request.arguments['fatal']
17+
if request.query_arguments["fatal"]
1818
raise TharSheBlows.new("Yarrh!")
1919
else
2020
succeed! :content => "Error: #{request.exception.message}", :type => 'text/plain'

0 commit comments

Comments
 (0)