Skip to content
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
16 changes: 11 additions & 5 deletions guides/parameters/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,10 @@ Uploads must be declared explicitly. Uploads not accepted by an upload declarati
``` ruby
parameters = Protocol::Content::Parameters.build do
nested "user" do
upload "avatar", required: true
upload "avatar",
required: true,
accept: ["image/jpeg", "image/png"],
size_limit: 5 * 1024 * 1024
end

upload "pictures", multiple: true
Expand All @@ -146,15 +149,18 @@ When an upload handler is provided, its return value is inserted at the upload's
``` ruby
result = parameters.parse(media_type, input) do |name, upload|
stored = uploads.create(name, upload.filename, upload.headers)

upload.each do |chunk|
stored.write(chunk)
end
upload.copy_to(stored)

stored
end
```

The yielded upload exposes `filename`, `headers`, `declared_media_type`, `media_type`, `size`, `each`, `copy_to`, and `save`. `save` creates a new file exclusively with private permissions and removes partial output when streaming fails. Always choose the destination path independently of the submitted filename.

The `accept:` option takes one or more {ruby Protocol::Media::Range media ranges}, including wildcards such as `image/*`; it does not accept filename-extension patterns. The upload's `declared_media_type` is supplied by the client. When it is absent or `application/octet-stream`, `media_type` is inferred from the submitted filename using {ruby Protocol::Media::Registry}. A specific declared media type takes precedence over the filename.

Both the declared media type and filename are untrusted metadata. Media range matching is useful for classification and early rejection, but it does not validate the uploaded bytes. Inspect, decode, or sanitize the content when its actual format matters. Field size limits complement the parser's transport-wide safety limits and are enforced even when the handler does not consume the upload itself.

For an upload named `user[avatar]`, the stored object is available as `result.dig("user", "avatar")`. An `upload "pictures", multiple: true` declaration accepts `pictures[]` and collects each handler result in `result["pictures"]`. Without an upload handler, uploads are consumed and omitted from the resulting arguments.

Upload handlers run while content is being parsed, before validation of the complete argument hierarchy finishes. Applications should therefore use provisional storage or remove stored uploads when the resulting parameters are invalid.
1 change: 1 addition & 0 deletions lib/protocol/content/parameters.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
require_relative "parameters/error"
require_relative "parameters/result"
require_relative "parameters/value"
require_relative "parameters/upload"
require_relative "parameters/field"
require_relative "parameters/model"
require_relative "parameters/builder"
Expand Down
16 changes: 14 additions & 2 deletions lib/protocol/content/parameters/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require "protocol/media/set"

module Protocol
module Content
module Parameters
Expand Down Expand Up @@ -41,9 +43,19 @@ def field(name, type = Object, required: false, nullable: false)
# @parameter name [String] The upload field name.
# @parameter required [Boolean] Whether at least one handled upload must be present.
# @parameter multiple [Boolean] Whether the field accepts multiple uploads using anonymous array notation.
# @parameter accept [Protocol::Media::Set | Array(String | Protocol::Media::Range) | Nil] The accepted media ranges.
# @parameter size_limit [Integer | Nil] The maximum accepted upload size.
# @returns [Field] The upload field.
def upload(name, required: false, multiple: false)
return add(UploadField.new(name, required:, multiple:))
def upload(name, required: false, multiple: false, accept: nil, size_limit: nil)
if size_limit && size_limit < 0
raise ArgumentError, "Upload size limit must be non-negative!"
end

if accept
accept = Protocol::Media::Set.for(accept)
end

return add(UploadField.new(name, required:, multiple:, accept:, size_limit:))
end

# Construct an enumeration converter from accepted values or an input-to-output mapping.
Expand Down
98 changes: 70 additions & 28 deletions lib/protocol/content/parameters/field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ def required?
return @required
end

def accepts_upload?(path)
return false
def upload_field(path)
return nil
end

end
Expand Down Expand Up @@ -55,35 +55,78 @@ def apply(value, output, errors, path)
end

class UploadField < Field
def initialize(name, required:, multiple:)
def initialize(name, required:, multiple:, accept:, size_limit:)
super(name, required:)
@multiple = multiple
@accept = accept
@size_limit = size_limit
end

# Process an upload while its multipart input is available.
#
# The upload handler must consume or store the streaming upload before parsing can continue. Its return value, or any validation failure, is preserved as an internal value for the later field validation phase.
def process(name, upload)
upload = Upload.new(upload, size_limit: @size_limit)

if @accept
media_type = upload.media_type

unless media_type
return Value::Invalid.new(:unsupported_media_type, media_type:, accepted: @accept)
end

unless @accept.include?(media_type)
return Value::Invalid.new(:unsupported_media_type, media_type:, accepted: @accept)
end
end

begin
stored = yield(name, upload)
upload.discard
return Value::Uploaded.new(stored)
rescue Upload::LimitError
return Value::Invalid.new(:too_large, limit: upload.size_limit, size: upload.size)
end
end

def accepts_upload?(path)
def upload_field(path)
if @multiple
# Upload collections require anonymous array notation:
return path == [""]
accepted = (path == [""])
else
return path.empty?
accepted = path.empty?
end

if accepted
return self
end

return nil
end

def apply(value, output, errors, path)
# Resolve the outcome produced while the upload was streamed:
if @multiple
return apply_multiple(value, output, errors, path)
end

# Only values produced by an accepted upload handler are valid:
if value.is_a?(Value::Uploaded)
output[@name] = value.value
else
errors << Error.new(path, :invalid_type, expected: :upload, value: Value.materialize(value))
apply_upload(value, errors, path) do |stored|
output[@name] = stored
end
end

private

# Apply a streaming upload outcome, rejecting ordinary parameter values:
def apply_upload(value, errors, path, &block)
if value.respond_to?(:apply_upload)
return value.apply_upload(errors, path, &block)
end

errors << Error.new(path, :invalid_type, expected: :upload, value: Value.materialize(value))
return false
end

def apply_multiple(value, output, errors, path)
# Upload collections must be represented as arrays by the content parser:
unless value.is_a?(Array)
Expand All @@ -101,21 +144,16 @@ def apply_multiple(value, output, errors, path)
end

result = []
submitted = false

value.each_with_index do |item, index|
case item
when Value::Uploaded
result << item.value
when Value::OMITTED
# Unhandled uploads are consumed by the parser and omitted here:
next
else
errors << Error.new(path + [index], :invalid_type, expected: :upload, value: Value.materialize(item))
if apply_upload(item, errors, path + [index]){|stored| result << stored}
submitted = true
end
end

# Required collections need at least one successfully handled upload:
if @required && result.empty?
# Avoid reporting a required error when an upload was submitted but rejected:
if @required && result.empty? && !submitted
errors << Error.new(path, :required)
end

Expand All @@ -132,19 +170,19 @@ def initialize(name, type, model, required:, nullable:)
@nullable = nullable
end

def accepts_upload?(path)
def upload_field(path)
# Uploads in arrays must target a declared field on an anonymous element:
unless @model
return false
return nil
end

index, *remaining = path

unless index&.empty?
return false
return nil
end

return @model.accepts_upload?(remaining)
return @model.upload_field(remaining)
end

def apply(value, output, errors, path)
Expand Down Expand Up @@ -200,6 +238,8 @@ def apply(value, output, errors, path)
end

def freeze
return self if self.frozen?

if @model
@model.freeze
end
Expand All @@ -216,12 +256,12 @@ def initialize(name, model, required:, nullable:)
@nullable = nullable
end

def accepts_upload?(path)
def upload_field(path)
unless @model
return false
return nil
end

return @model.accepts_upload?(path)
return @model.upload_field(path)
end

def apply(value, output, errors, path)
Expand Down Expand Up @@ -249,6 +289,8 @@ def apply(value, output, errors, path)
end

def freeze
return self if self.frozen?

if @model
@model.freeze
end
Expand Down
24 changes: 17 additions & 7 deletions lib/protocol/content/parameters/model.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,14 @@ def initialize(parser, fields, strict: true)
# @yields {|name, upload| ...} Each streaming upload. Its return value is inserted into the parsed value.
# @returns [Result] The parsed value and validation errors.
def parse(media_type, input, &upload_handler)
# Replace ephemeral multipart uploads with outcomes which can survive until validation:
value = @parser.parse(media_type, input) do |name, item|
if item.is_a?(Protocol::Multipart::FormData::Upload)
path = Protocol::URL::Encoding.split(name)

# Only process uploads accepted by an explicit field:
if upload_handler && accepts_upload?(path)
Value::Uploaded.new(upload_handler.call(name, item))
if upload_handler && field = upload_field(path)
field.process(name, item, &upload_handler)
else
Value::OMITTED
end
Expand All @@ -45,6 +46,7 @@ def parse(media_type, input, &upload_handler)
end
end

# Apply the model after parsing so ordinary values and upload outcomes follow the same hierarchy:
errors = []
value = apply(value, errors)
return Result.new(value, errors)
Expand Down Expand Up @@ -78,9 +80,8 @@ def apply(value, errors, path = [])
return {}
end

# Normalize keys before matching them against fields:
input = {}
value.each{|key, item| input[key.to_s] = item}
# Copy the input so declared fields can be removed without modifying caller-owned data:
input = value.dup
output = {}

# Apply declared values and collect missing required parameters:
Expand Down Expand Up @@ -116,19 +117,28 @@ def apply(value, errors, path = [])
# @parameter path [Array(String)] The decoded upload path.
# @returns [Boolean] Whether the upload is accepted.
def accepts_upload?(path)
return !!upload_field(path)
end

# Find the upload field which accepts the given decoded path.
# @parameter path [Array(String)] The decoded upload path.
# @returns [UploadField | Nil] The accepting upload field.
def upload_field(path)
# Walk fields using the decoded components of the form name:
name, *remaining = path

unless field = @fields[name]
return false
return nil
end

return field.accepts_upload?(remaining)
return field.upload_field(remaining)
end

# Freeze this model and its fields.
# @returns [self] The frozen model.
def freeze
return self if self.frozen?

@parser.freeze
@fields.each_value(&:freeze)
@fields.freeze
Expand Down
Loading