From 6da4149f92a1d5de6d5ef4cd93b5cc8332cecc68 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 8 Aug 2026 10:48:32 +1200 Subject: [PATCH 1/9] Constrain content parameter uploads. --- guides/parameters/readme.md | 14 +- lib/protocol/content/parameters.rb | 1 + lib/protocol/content/parameters/builder.rb | 14 +- lib/protocol/content/parameters/field.rb | 66 ++++++++-- lib/protocol/content/parameters/model.rb | 27 +++- lib/protocol/content/parameters/upload.rb | 87 +++++++++++++ lib/protocol/content/parameters/value.rb | 10 ++ protocol-content.gemspec | 2 +- releases.md | 1 + test/protocol/content/parameters.rb | 142 ++++++++++++++++++++- 10 files changed, 337 insertions(+), 27 deletions(-) create mode 100644 lib/protocol/content/parameters/upload.rb diff --git a/guides/parameters/readme.md b/guides/parameters/readme.md index e861d91..24391c9 100644 --- a/guides/parameters/readme.md +++ b/guides/parameters/readme.md @@ -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, + media_types: ["image/jpeg", "image/png"], + size_limit: 5 * 1024 * 1024 end upload "pictures", multiple: true @@ -146,15 +149,16 @@ 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`, `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 untrusted submitted filename. + +Media type restrictions are matched using {ruby Protocol::Media::Range}. The submitted media type is also untrusted metadata; inspect or decode stored 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. diff --git a/lib/protocol/content/parameters.rb b/lib/protocol/content/parameters.rb index bc60d78..27dcfa3 100644 --- a/lib/protocol/content/parameters.rb +++ b/lib/protocol/content/parameters.rb @@ -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" diff --git a/lib/protocol/content/parameters/builder.rb b/lib/protocol/content/parameters/builder.rb index 9821d15..4fd98f1 100644 --- a/lib/protocol/content/parameters/builder.rb +++ b/lib/protocol/content/parameters/builder.rb @@ -41,9 +41,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 media_types [Array(String, #match?) | Nil] The accepted media types or 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, media_types: nil, size_limit: nil) + if size_limit && size_limit < 0 + raise ArgumentError, "Upload size limit must be non-negative!" + end + + if media_types + media_types = media_types.map{|media_type| Protocol::Media::Range.for(media_type)} + end + + return add(UploadField.new(name, required:, multiple:, media_types:, size_limit:)) end # Construct an enumeration converter from accepted values or an input-to-output mapping. diff --git a/lib/protocol/content/parameters/field.rb b/lib/protocol/content/parameters/field.rb index 1072121..813de6e 100644 --- a/lib/protocol/content/parameters/field.rb +++ b/lib/protocol/content/parameters/field.rb @@ -19,8 +19,8 @@ def required? return @required end - def accepts_upload?(path) - return false + def upload_field(path) + return nil end end @@ -55,21 +55,44 @@ def apply(value, output, errors, path) end class UploadField < Field - def initialize(name, required:, multiple:) + def initialize(name, required:, multiple:, media_types:, size_limit:) super(name, required:) @multiple = multiple + @media_types = media_types + @size_limit = size_limit end - def accepts_upload?(path) + def prepare(upload) + upload = Upload.new(upload, size_limit: @size_limit) + + if @media_types && (!upload.media_type || !@media_types.any?{|media_type| media_type.match?(upload.media_type)}) + return Value::Invalid.new(:unsupported_media_type, media_type: upload.media_type, accepted: @media_types) + end + + return upload + end + + 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) + if value.is_a?(Value::Invalid) + errors << Error.new(path, value.code, **value.details) + return + end + if @multiple return apply_multiple(value, output, errors, path) end @@ -82,6 +105,17 @@ def apply(value, output, errors, path) end end + def freeze + return self if self.frozen? + + if @media_types + @media_types.each(&:freeze) + @media_types.freeze + end + + super + end + private def apply_multiple(value, output, errors, path) @@ -101,11 +135,15 @@ def apply_multiple(value, output, errors, path) end result = [] + invalid = false value.each_with_index do |item, index| case item when Value::Uploaded result << item.value + when Value::Invalid + invalid = true + errors << Error.new(path + [index], item.code, **item.details) when Value::OMITTED # Unhandled uploads are consumed by the parser and omitted here: next @@ -115,7 +153,7 @@ def apply_multiple(value, output, errors, path) end # Required collections need at least one successfully handled upload: - if @required && result.empty? + if @required && result.empty? && !invalid errors << Error.new(path, :required) end @@ -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) @@ -216,12 +254,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) diff --git a/lib/protocol/content/parameters/model.rb b/lib/protocol/content/parameters/model.rb index 8587ebe..c4966a7 100644 --- a/lib/protocol/content/parameters/model.rb +++ b/lib/protocol/content/parameters/model.rb @@ -35,8 +35,20 @@ def parse(media_type, input, &upload_handler) 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) + upload = field.prepare(item) + + if upload.is_a?(Value::Invalid) + upload + else + begin + stored = upload_handler.call(name, upload) + upload.discard + Value::Uploaded.new(stored) + rescue Upload::LimitError + Value::Invalid.new(:too_large, limit: upload.size_limit, size: upload.size) + end + end else Value::OMITTED end @@ -116,14 +128,21 @@ 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. diff --git a/lib/protocol/content/parameters/upload.rb b/lib/protocol/content/parameters/upload.rb new file mode 100644 index 0000000..21886bf --- /dev/null +++ b/lib/protocol/content/parameters/upload.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/media/type" +require "protocol/multipart/readable" + +module Protocol + module Content + module Parameters + # A field-constrained streaming upload. + class Upload + include Protocol::Multipart::Readable + + # Raised when a streaming upload exceeds its field size limit. + class LimitError < StandardError + end + + # Initialize a constrained upload. + # @parameter delegate [Object] The underlying streaming upload. + # @parameter size_limit [Integer | Nil] The maximum accepted size. + def initialize(delegate, size_limit: nil) + @delegate = delegate + @size_limit = size_limit + @size = 0 + + if header = delegate.headers["content-type"] + @media_type = Protocol::Media::Type.parse(header.to_s) + end + end + + # The submitted filename. + def filename + return @delegate.filename + end + + # The multipart headers associated with this upload. + def headers + return @delegate.headers + end + + # The submitted media type, if declared. + attr :media_type + + # The maximum accepted size, if configured. + attr :size_limit + + # The number of bytes consumed through this constrained upload. + attr :size + + # Whether the complete upload has been consumed. + def ended? + return @delegate.ended? + end + + # Iterate over the upload while enforcing its field size limit. + # @parameter chunk_size [Integer] The maximum chunk size. + # @yields {|chunk| ...} Each upload chunk. + # @returns [self] The upload. + # @raises [LimitError] If the upload exceeds its field size limit. + def each(chunk_size = 8192) + return to_enum(:each, chunk_size) unless block_given? + + @delegate.each(chunk_size) do |chunk| + @size += chunk.bytesize + + if @size_limit && @size > @size_limit + raise LimitError, "Upload size exceeded field limit of #{@size_limit}!" + end + + yield chunk + end + + return self + end + + # Consume any unread upload content while enforcing the field size limit. + # @returns [Nil] The upload content is discarded. + def discard + each {|_chunk|} + return nil + end + end + end + end +end diff --git a/lib/protocol/content/parameters/value.rb b/lib/protocol/content/parameters/value.rb index 90c7e6c..111b0c2 100644 --- a/lib/protocol/content/parameters/value.rb +++ b/lib/protocol/content/parameters/value.rb @@ -9,6 +9,16 @@ module Parameters module Value OMITTED = Object.new.freeze + class Invalid + def initialize(code, **details) + @code = code + @details = details + end + + attr :code + attr :details + end + class Uploaded def initialize(value) @value = value diff --git a/protocol-content.gemspec b/protocol-content.gemspec index 60ff49a..dd56a05 100644 --- a/protocol-content.gemspec +++ b/protocol-content.gemspec @@ -28,6 +28,6 @@ Gem::Specification.new do |spec| spec.add_dependency "json", "~> 2.0" spec.add_dependency "protocol-media", "~> 0.1" - spec.add_dependency "protocol-multipart", "~> 0.6" + spec.add_dependency "protocol-multipart", "~> 0.7" spec.add_dependency "protocol-url", "~> 0.10" end diff --git a/releases.md b/releases.md index e9866d3..36872bc 100644 --- a/releases.md +++ b/releases.md @@ -4,6 +4,7 @@ - Add declarative content parameter filtering, conversion, validation, and upload handling. - Add exact enumeration validation and input mapping for content parameters. + - Add field-specific upload media type and size constraints. ## v0.1.0 diff --git a/test/protocol/content/parameters.rb b/test/protocol/content/parameters.rb index 977ba6f..74c797c 100644 --- a/test/protocol/content/parameters.rb +++ b/test/protocol/content/parameters.rb @@ -6,6 +6,7 @@ require "protocol/content" require "stringio" +require "tmpdir" describe Protocol::Content::Parameters do BOUNDARY = "parameters-boundary" @@ -412,7 +413,12 @@ def multipart_body(*parts) result = parameters.parse(media_type, StringIO.new(body)) do |name, upload| expect(name).to be == "user[avatar]" - {name: upload.filename, content: upload.each.to_a.join} + expect(upload).to be_a(subject::Upload) + expect(upload.headers["content-type"].type).to be == "text/plain" + expect(upload.media_type.name).to be == "text/plain" + content = upload.each.to_a.join + expect(upload).to be(:ended?) + {name: upload.filename, content:} end expect(result).to be(:valid?) @@ -424,6 +430,140 @@ def multipart_body(*parts) } end + it "identifies declared upload paths" do + parameters = subject.build do + field "name", String + upload "avatar" + end + + expect(parameters.accepts_upload?(["avatar"])).to be == true + expect(parameters.accepts_upload?(["avatar", "nested"])).to be == false + expect(parameters.accepts_upload?(["name"])).to be == false + end + + it "accepts uploads with compatible media types" do + parameters = subject.build do + upload "avatar", media_types: ["image/*"] + end + body = multipart_body([{ + "Content-Disposition" => 'form-data; name="avatar"; filename="avatar.png"', + "Content-Type" => "image/png" + }, "image"]) + media_type = "multipart/form-data; boundary=#{BOUNDARY}" + + result = parameters.parse(media_type, StringIO.new(body)) do |_name, upload| + upload.each.to_a.join + end + + expect(result).to be(:valid?) + expect(result.value).to be == {"avatar" => "image"} + end + + it "rejects missing and unsupported upload media types" do + parameters = subject.build do + upload "pictures", required: true, multiple: true, media_types: ["image/png"] + end + body = multipart_body( + [{ + "Content-Disposition" => 'form-data; name="pictures[]"; filename="first.txt"', + "Content-Type" => "text/plain" + }, "first"], + [{ + "Content-Disposition" => 'form-data; name="pictures[]"; filename="second.bin"' + }, "second"] + ) + media_type = "multipart/form-data; boundary=#{BOUNDARY}" + called = false + + result = parameters.parse(media_type, StringIO.new(body)) do + called = true + end + + expect(called).to be == false + expect(result.value).to be == {"pictures" => []} + expect(result.errors.map(&:path)).to be == [["pictures", 0], ["pictures", 1]] + expect(result.errors.map(&:code)).to be == [:unsupported_media_type, :unsupported_media_type] + expect(result.errors.map{|error| error.details[:media_type]&.name}).to be == ["text/plain", nil] + end + + it "applies upload field size limits at their boundaries" do + parameters = subject.build do + upload "file", size_limit: 4 + end + media_type = "multipart/form-data; boundary=#{BOUNDARY}" + + parse = lambda do |content| + body = multipart_body([{ + "Content-Disposition" => 'form-data; name="file"; filename="data.bin"', + "Content-Type" => "application/octet-stream" + }, content]) + + parameters.parse(media_type, StringIO.new(body)) do |_name, upload| + upload.each.to_a.join + end + end + + expect(parse.call("123").value).to be == {"file" => "123"} + expect(parse.call("1234").value).to be == {"file" => "1234"} + + result = parse.call("12345") + expect(result.value).to be == {} + expect(result.errors.map(&:code)).to be == [:too_large] + expect(result.errors.first.details).to be == {limit: 4, size: 5} + end + + it "rejects negative upload field size limits" do + expect do + subject.build do + upload "file", size_limit: -1 + end + end.to raise_exception(ArgumentError, message: be =~ /must be non-negative/) + end + + it "enforces upload size limits when handlers do not consume content" do + parameters = subject.build do + upload "file", size_limit: 4 + field "name", String + end + body = multipart_body( + [{ + "Content-Disposition" => 'form-data; name="file"; filename="data.bin"', + "Content-Type" => "application/octet-stream" + }, "12345"], + [{"Content-Disposition" => 'form-data; name="name"'}, "Samuel"] + ) + media_type = "multipart/form-data; boundary=#{BOUNDARY}" + + result = parameters.parse(media_type, StringIO.new(body)) do |_name, _upload| + :stored + end + + expect(result.value).to be == {"name" => "Samuel"} + expect(result.errors.map(&:path)).to be == [["file"]] + expect(result.errors.map(&:code)).to be == [:too_large] + end + + it "removes partially saved uploads which exceed field limits" do + parameters = subject.build do + upload "file", size_limit: 4 + end + body = multipart_body([{ + "Content-Disposition" => 'form-data; name="file"; filename="data.bin"', + "Content-Type" => "application/octet-stream" + }, "12345"]) + media_type = "multipart/form-data; boundary=#{BOUNDARY}" + + Dir.mktmpdir do |directory| + path = File.join(directory, "upload") + result = parameters.parse(media_type, StringIO.new(body)) do |_name, upload| + upload.save(path) + end + + expect(result.errors.map(&:code)).to be == [:too_large] + expect(File.exist?(path)).to be == false + end + end + it "inserts handled uploads into array elements" do parameters = subject.build do array "users" do From b6b6a5296766636e243a396ea38c377ed32b89d9 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 8 Aug 2026 10:50:55 +1200 Subject: [PATCH 2/9] Make freezing idempotent. --- lib/protocol/content/parameters/field.rb | 4 ++++ lib/protocol/content/parameters/model.rb | 2 ++ lib/protocol/content/parser.rb | 2 ++ 3 files changed, 8 insertions(+) diff --git a/lib/protocol/content/parameters/field.rb b/lib/protocol/content/parameters/field.rb index 813de6e..c5e0bd5 100644 --- a/lib/protocol/content/parameters/field.rb +++ b/lib/protocol/content/parameters/field.rb @@ -238,6 +238,8 @@ def apply(value, output, errors, path) end def freeze + return self if self.frozen? + if @model @model.freeze end @@ -287,6 +289,8 @@ def apply(value, output, errors, path) end def freeze + return self if self.frozen? + if @model @model.freeze end diff --git a/lib/protocol/content/parameters/model.rb b/lib/protocol/content/parameters/model.rb index c4966a7..68a0291 100644 --- a/lib/protocol/content/parameters/model.rb +++ b/lib/protocol/content/parameters/model.rb @@ -148,6 +148,8 @@ def upload_field(path) # 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 diff --git a/lib/protocol/content/parser.rb b/lib/protocol/content/parser.rb index 6ce5665..1744d09 100644 --- a/lib/protocol/content/parser.rb +++ b/lib/protocol/content/parser.rb @@ -67,6 +67,8 @@ def parse(media_type, input, &block) # Freeze the parser and its handler registry. # @returns [self] The frozen parser. def freeze + return self if self.frozen? + @handlers.freeze super end From 8399f4291e84977e45b1e3f6c63c96e3281e9523 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 8 Aug 2026 11:50:10 +1200 Subject: [PATCH 3/9] Classify content parameter uploads. --- guides/parameters/readme.md | 8 ++-- lib/protocol/content/parameters/builder.rb | 14 +++--- lib/protocol/content/parameters/field.rb | 14 +++--- lib/protocol/content/parameters/upload.rb | 23 +++++++++- protocol-content.gemspec | 1 + test/protocol/content/parameters.rb | 52 +++++++++++++++++++++- 6 files changed, 93 insertions(+), 19 deletions(-) diff --git a/guides/parameters/readme.md b/guides/parameters/readme.md index 24391c9..c4d01c5 100644 --- a/guides/parameters/readme.md +++ b/guides/parameters/readme.md @@ -136,7 +136,7 @@ parameters = Protocol::Content::Parameters.build do nested "user" do upload "avatar", required: true, - media_types: ["image/jpeg", "image/png"], + accept: ["image/jpeg", "image/png"], size_limit: 5 * 1024 * 1024 end @@ -155,9 +155,11 @@ result = parameters.parse(media_type, input) do |name, upload| end ``` -The yielded upload exposes `filename`, `headers`, `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 untrusted submitted filename. +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. -Media type restrictions are matched using {ruby Protocol::Media::Range}. The submitted media type is also untrusted metadata; inspect or decode stored 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. +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. diff --git a/lib/protocol/content/parameters/builder.rb b/lib/protocol/content/parameters/builder.rb index 4fd98f1..e5589fd 100644 --- a/lib/protocol/content/parameters/builder.rb +++ b/lib/protocol/content/parameters/builder.rb @@ -41,19 +41,23 @@ 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 media_types [Array(String, #match?) | Nil] The accepted media types or ranges. + # @parameter accept [String, #match?, Array(String, #match?) | 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, media_types: nil, size_limit: nil) + 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 media_types - media_types = media_types.map{|media_type| Protocol::Media::Range.for(media_type)} + if accept + unless accept.is_a?(Array) + accept = [accept] + end + + accept = accept.map{|media_range| Protocol::Media::Range.for(media_range)} end - return add(UploadField.new(name, required:, multiple:, media_types:, size_limit:)) + return add(UploadField.new(name, required:, multiple:, accept:, size_limit:)) end # Construct an enumeration converter from accepted values or an input-to-output mapping. diff --git a/lib/protocol/content/parameters/field.rb b/lib/protocol/content/parameters/field.rb index c5e0bd5..63170ff 100644 --- a/lib/protocol/content/parameters/field.rb +++ b/lib/protocol/content/parameters/field.rb @@ -55,18 +55,18 @@ def apply(value, output, errors, path) end class UploadField < Field - def initialize(name, required:, multiple:, media_types:, size_limit:) + def initialize(name, required:, multiple:, accept:, size_limit:) super(name, required:) @multiple = multiple - @media_types = media_types + @accept = accept @size_limit = size_limit end def prepare(upload) upload = Upload.new(upload, size_limit: @size_limit) - if @media_types && (!upload.media_type || !@media_types.any?{|media_type| media_type.match?(upload.media_type)}) - return Value::Invalid.new(:unsupported_media_type, media_type: upload.media_type, accepted: @media_types) + if @accept && (!upload.media_type || !@accept.any?{|media_range| media_range.match?(upload.media_type)}) + return Value::Invalid.new(:unsupported_media_type, media_type: upload.media_type, accepted: @accept) end return upload @@ -108,9 +108,9 @@ def apply(value, output, errors, path) def freeze return self if self.frozen? - if @media_types - @media_types.each(&:freeze) - @media_types.freeze + if @accept + @accept.each(&:freeze) + @accept.freeze end super diff --git a/lib/protocol/content/parameters/upload.rb b/lib/protocol/content/parameters/upload.rb index 21886bf..4dd40b3 100644 --- a/lib/protocol/content/parameters/upload.rb +++ b/lib/protocol/content/parameters/upload.rb @@ -4,6 +4,7 @@ # Copyright, 2026, by Samuel Williams. require "protocol/media/type" +require "protocol/media/registry" require "protocol/multipart/readable" module Protocol @@ -13,6 +14,9 @@ module Parameters class Upload include Protocol::Multipart::Readable + GENERIC_MEDIA_TYPE = "application/octet-stream" + private_constant :GENERIC_MEDIA_TYPE + # Raised when a streaming upload exceeds its field size limit. class LimitError < StandardError end @@ -24,9 +28,21 @@ def initialize(delegate, size_limit: nil) @delegate = delegate @size_limit = size_limit @size = 0 + @declared_media_type = nil if header = delegate.headers["content-type"] - @media_type = Protocol::Media::Type.parse(header.to_s) + @declared_media_type = Protocol::Media::Type.parse(header.to_s) + end + + @media_type = @declared_media_type + + # Fall back to the submitted filename when the declared type carries no useful classification: + if !@media_type || @media_type.name == GENERIC_MEDIA_TYPE + if record = Protocol::Media::Registry.for_path(self.filename) + if record.type.name != GENERIC_MEDIA_TYPE + @media_type = record.type + end + end end end @@ -40,7 +56,10 @@ def headers return @delegate.headers end - # The submitted media type, if declared. + # The media type declared by the submitting client, if present. + attr :declared_media_type + + # The declared media type, or the type inferred from the filename when the declaration is absent or generic. attr :media_type # The maximum accepted size, if configured. diff --git a/protocol-content.gemspec b/protocol-content.gemspec index dd56a05..2fc6a19 100644 --- a/protocol-content.gemspec +++ b/protocol-content.gemspec @@ -28,6 +28,7 @@ Gem::Specification.new do |spec| spec.add_dependency "json", "~> 2.0" spec.add_dependency "protocol-media", "~> 0.1" + spec.add_dependency "protocol-media-registry", "~> 0.1" spec.add_dependency "protocol-multipart", "~> 0.7" spec.add_dependency "protocol-url", "~> 0.10" end diff --git a/test/protocol/content/parameters.rb b/test/protocol/content/parameters.rb index 74c797c..0be8e56 100644 --- a/test/protocol/content/parameters.rb +++ b/test/protocol/content/parameters.rb @@ -415,6 +415,7 @@ def multipart_body(*parts) expect(name).to be == "user[avatar]" expect(upload).to be_a(subject::Upload) expect(upload.headers["content-type"].type).to be == "text/plain" + expect(upload.declared_media_type.name).to be == "text/plain" expect(upload.media_type.name).to be == "text/plain" content = upload.each.to_a.join expect(upload).to be(:ended?) @@ -443,7 +444,7 @@ def multipart_body(*parts) it "accepts uploads with compatible media types" do parameters = subject.build do - upload "avatar", media_types: ["image/*"] + upload "avatar", accept: "image/*" end body = multipart_body([{ "Content-Disposition" => 'form-data; name="avatar"; filename="avatar.png"', @@ -459,9 +460,56 @@ def multipart_body(*parts) expect(result.value).to be == {"avatar" => "image"} end + it "infers missing and generic media types from filenames" do + parameters = subject.build do + upload "pictures", multiple: true, accept: "image/*" + end + body = multipart_body( + [{ + "Content-Disposition" => 'form-data; name="pictures[]"; filename="first.png"' + }, "first"], + [{ + "Content-Disposition" => 'form-data; name="pictures[]"; filename="second.jpg"', + "Content-Type" => "application/octet-stream" + }, "second"] + ) + media_type = "multipart/form-data; boundary=#{BOUNDARY}" + declarations = [] + inferences = [] + + result = parameters.parse(media_type, StringIO.new(body)) do |_name, upload| + declarations << upload.declared_media_type&.name + inferences << upload.media_type.name + upload.each.to_a.join + end + + expect(result).to be(:valid?) + expect(result.value).to be == {"pictures" => ["first", "second"]} + expect(declarations).to be == [nil, "application/octet-stream"] + expect(inferences).to be == ["image/png", "image/jpeg"] + end + + it "prefers a specific declared media type over the filename" do + parameters = subject.build do + upload "avatar", accept: "image/*" + end + body = multipart_body([{ + "Content-Disposition" => 'form-data; name="avatar"; filename="avatar.png"', + "Content-Type" => "text/plain" + }, "not an image"]) + media_type = "multipart/form-data; boundary=#{BOUNDARY}" + + result = parameters.parse(media_type, StringIO.new(body)) do + raise "The rejected upload should not be yielded!" + end + + expect(result.errors.map(&:code)).to be == [:unsupported_media_type] + expect(result.errors.first.details[:media_type].name).to be == "text/plain" + end + it "rejects missing and unsupported upload media types" do parameters = subject.build do - upload "pictures", required: true, multiple: true, media_types: ["image/png"] + upload "pictures", required: true, multiple: true, accept: ["image/png"] end body = multipart_body( [{ From f556caf2e01ce432424a2a539270c15f93ec08b6 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 8 Aug 2026 21:42:16 +1200 Subject: [PATCH 4/9] Use media sets for upload acceptance. --- lib/protocol/content/parameters/builder.rb | 10 ++++------ lib/protocol/content/parameters/field.rb | 23 ++++++++++------------ lib/protocol/content/parser.rb | 22 +++++++++++---------- protocol-content.gemspec | 2 +- test/protocol/content/parameters.rb | 8 +++++--- test/protocol/content/parser.rb | 4 ++-- 6 files changed, 34 insertions(+), 35 deletions(-) diff --git a/lib/protocol/content/parameters/builder.rb b/lib/protocol/content/parameters/builder.rb index e5589fd..3a19862 100644 --- a/lib/protocol/content/parameters/builder.rb +++ b/lib/protocol/content/parameters/builder.rb @@ -3,6 +3,8 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. +require "protocol/media/set" + module Protocol module Content module Parameters @@ -41,7 +43,7 @@ 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 [String, #match?, Array(String, #match?) | Nil] The accepted media ranges. + # @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, accept: nil, size_limit: nil) @@ -50,11 +52,7 @@ def upload(name, required: false, multiple: false, accept: nil, size_limit: nil) end if accept - unless accept.is_a?(Array) - accept = [accept] - end - - accept = accept.map{|media_range| Protocol::Media::Range.for(media_range)} + accept = Protocol::Media::Set.for(accept) end return add(UploadField.new(name, required:, multiple:, accept:, size_limit:)) diff --git a/lib/protocol/content/parameters/field.rb b/lib/protocol/content/parameters/field.rb index 63170ff..a303c76 100644 --- a/lib/protocol/content/parameters/field.rb +++ b/lib/protocol/content/parameters/field.rb @@ -65,8 +65,16 @@ def initialize(name, required:, multiple:, accept:, size_limit:) def prepare(upload) upload = Upload.new(upload, size_limit: @size_limit) - if @accept && (!upload.media_type || !@accept.any?{|media_range| media_range.match?(upload.media_type)}) - return Value::Invalid.new(:unsupported_media_type, media_type: upload.media_type, accepted: @accept) + 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 return upload @@ -105,17 +113,6 @@ def apply(value, output, errors, path) end end - def freeze - return self if self.frozen? - - if @accept - @accept.each(&:freeze) - @accept.freeze - end - - super - end - private def apply_multiple(value, output, errors, path) diff --git a/lib/protocol/content/parser.rb b/lib/protocol/content/parser.rb index 1744d09..ee0cd71 100644 --- a/lib/protocol/content/parser.rb +++ b/lib/protocol/content/parser.rb @@ -3,7 +3,6 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -require "protocol/media/map" require "protocol/media/type" require_relative "error" @@ -24,17 +23,17 @@ def self.build # Initialize an empty parser. def initialize - @handlers = Protocol::Media::Map.new + @handlers = {} end - # Register a handler for a media type or range. - # @parameter media_range [String | Protocol::Media::Range] The accepted media type or range. + # Register a handler for a media type. + # @parameter media_type [String | Protocol::Media::Type] The accepted media type. # @parameter handler [#call | Nil] The content handler. # @yields {|input, media_type| ...} The content to parse. # @parameter input [Object] The readable input. # @parameter media_type [Protocol::Media::Type] The parsed media type. # @returns [#call] The registered handler. - def register(media_range, handler = nil, &block) + def register(media_type, handler = nil, &block) if handler && block raise ArgumentError, "Provide either a handler or a block!" end @@ -45,7 +44,8 @@ def register(media_range, handler = nil, &block) raise ArgumentError, "A content handler must respond to #call!" end - @handlers[media_range] = handler + media_type = Protocol::Media::Type.for(media_type) + @handlers[media_type.name] = handler return handler end @@ -55,10 +55,12 @@ def register(media_range, handler = nil, &block) # @yields {...} An optional block forwarded to the selected content handler. # @returns [Object] The parsed content value. def parse(media_type, input, &block) - media_type = Protocol::Media::Type.for(media_type) - - if media_type && handler = @handlers[media_type] - return handler.call(input, media_type, &block) + if media_type + media_type = Protocol::Media::Type.for(media_type) + + if handler = @handlers[media_type.name] + return handler.call(input, media_type, &block) + end end raise UnsupportedMediaTypeError, media_type diff --git a/protocol-content.gemspec b/protocol-content.gemspec index 2fc6a19..d7bbc32 100644 --- a/protocol-content.gemspec +++ b/protocol-content.gemspec @@ -27,7 +27,7 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" spec.add_dependency "json", "~> 2.0" - spec.add_dependency "protocol-media", "~> 0.1" + spec.add_dependency "protocol-media", "~> 0.2" spec.add_dependency "protocol-media-registry", "~> 0.1" spec.add_dependency "protocol-multipart", "~> 0.7" spec.add_dependency "protocol-url", "~> 0.10" diff --git a/test/protocol/content/parameters.rb b/test/protocol/content/parameters.rb index 0be8e56..71c5e91 100644 --- a/test/protocol/content/parameters.rb +++ b/test/protocol/content/parameters.rb @@ -443,8 +443,10 @@ def multipart_body(*parts) end it "accepts uploads with compatible media types" do + accept = Protocol::Media::Set.for(["image/*"]) + parameters = subject.build do - upload "avatar", accept: "image/*" + upload "avatar", accept: end body = multipart_body([{ "Content-Disposition" => 'form-data; name="avatar"; filename="avatar.png"', @@ -462,7 +464,7 @@ def multipart_body(*parts) it "infers missing and generic media types from filenames" do parameters = subject.build do - upload "pictures", multiple: true, accept: "image/*" + upload "pictures", multiple: true, accept: ["image/*"] end body = multipart_body( [{ @@ -491,7 +493,7 @@ def multipart_body(*parts) it "prefers a specific declared media type over the filename" do parameters = subject.build do - upload "avatar", accept: "image/*" + upload "avatar", accept: ["image/*"] end body = multipart_body([{ "Content-Disposition" => 'form-data; name="avatar"; filename="avatar.png"', diff --git a/test/protocol/content/parser.rb b/test/protocol/content/parser.rb index b5d5839..7e60aa6 100644 --- a/test/protocol/content/parser.rb +++ b/test/protocol/content/parser.rb @@ -34,11 +34,11 @@ end.to raise_exception(FrozenError) end - it "parses content using a compatible handler" do + it "parses content using a matching handler" do media_type = nil input = Object.new parser = subject.build do |parser| - parser.register("text/*") do |candidate, parsed_media_type| + parser.register("text/plain") do |candidate, parsed_media_type| media_type = parsed_media_type candidate end From 36eb80339dc299a79790f9c7f4f80ca90c4189a3 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 8 Aug 2026 22:14:29 +1200 Subject: [PATCH 5/9] Introduce an upload outcome interface. --- lib/protocol/content/parameters/field.rb | 31 ++++++------------------ lib/protocol/content/parameters/value.rb | 28 ++++++++++++++++++++- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/lib/protocol/content/parameters/field.rb b/lib/protocol/content/parameters/field.rb index a303c76..eeaf5fe 100644 --- a/lib/protocol/content/parameters/field.rb +++ b/lib/protocol/content/parameters/field.rb @@ -96,20 +96,12 @@ def upload_field(path) end def apply(value, output, errors, path) - if value.is_a?(Value::Invalid) - errors << Error.new(path, value.code, **value.details) - return - end - 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)) + Value.apply_upload(value, errors, path) do |stored| + output[@name] = stored end end @@ -132,25 +124,16 @@ def apply_multiple(value, output, errors, path) end result = [] - invalid = false + submitted = false value.each_with_index do |item, index| - case item - when Value::Uploaded - result << item.value - when Value::Invalid - invalid = true - errors << Error.new(path + [index], item.code, **item.details) - 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 Value.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? && !invalid + # Avoid reporting a required error when an upload was submitted but rejected: + if @required && result.empty? && !submitted errors << Error.new(path, :required) end diff --git a/lib/protocol/content/parameters/value.rb b/lib/protocol/content/parameters/value.rb index 111b0c2..84062e7 100644 --- a/lib/protocol/content/parameters/value.rb +++ b/lib/protocol/content/parameters/value.rb @@ -7,7 +7,13 @@ module Protocol module Content module Parameters module Value - OMITTED = Object.new.freeze + class Omitted + def apply_upload(errors, path) + return false + end + end + + OMITTED = Omitted.new.freeze class Invalid def initialize(code, **details) @@ -17,6 +23,11 @@ def initialize(code, **details) attr :code attr :details + + def apply_upload(errors, path) + errors << Error.new(path, @code, **@details) + return true + end end class Uploaded @@ -25,6 +36,21 @@ def initialize(value) end attr :value + + def apply_upload(errors, path) + yield(@value) + return true + end + end + + # Apply an upload outcome, rejecting values which do not implement the outcome interface: + def self.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: materialize(value)) + return false end def self.materialize(value) From a7d6986ec898366b2be5a02e24f2bcf1ec819ebf Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 8 Aug 2026 22:14:59 +1200 Subject: [PATCH 6/9] Move upload processing into fields. --- lib/protocol/content/parameters/field.rb | 10 ++++++++-- lib/protocol/content/parameters/model.rb | 14 +------------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/lib/protocol/content/parameters/field.rb b/lib/protocol/content/parameters/field.rb index eeaf5fe..0c46683 100644 --- a/lib/protocol/content/parameters/field.rb +++ b/lib/protocol/content/parameters/field.rb @@ -62,7 +62,7 @@ def initialize(name, required:, multiple:, accept:, size_limit:) @size_limit = size_limit end - def prepare(upload) + def process(name, upload) upload = Upload.new(upload, size_limit: @size_limit) if @accept @@ -77,7 +77,13 @@ def prepare(upload) end end - return upload + 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 upload_field(path) diff --git a/lib/protocol/content/parameters/model.rb b/lib/protocol/content/parameters/model.rb index 68a0291..6fa3984 100644 --- a/lib/protocol/content/parameters/model.rb +++ b/lib/protocol/content/parameters/model.rb @@ -36,19 +36,7 @@ def parse(media_type, input, &upload_handler) # Only process uploads accepted by an explicit field: if upload_handler && field = upload_field(path) - upload = field.prepare(item) - - if upload.is_a?(Value::Invalid) - upload - else - begin - stored = upload_handler.call(name, upload) - upload.discard - Value::Uploaded.new(stored) - rescue Upload::LimitError - Value::Invalid.new(:too_large, limit: upload.size_limit, size: upload.size) - end - end + field.process(name, item, &upload_handler) else Value::OMITTED end From 41c8bdbba3fc30c911eb64a64bcfac4cdf3988a3 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 8 Aug 2026 22:47:24 +1200 Subject: [PATCH 7/9] Require string parameter keys. --- lib/protocol/content/parameters/model.rb | 5 ++--- test/protocol/content/parameters.rb | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/protocol/content/parameters/model.rb b/lib/protocol/content/parameters/model.rb index 6fa3984..7e7df2f 100644 --- a/lib/protocol/content/parameters/model.rb +++ b/lib/protocol/content/parameters/model.rb @@ -78,9 +78,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: diff --git a/test/protocol/content/parameters.rb b/test/protocol/content/parameters.rb index 71c5e91..c52c73e 100644 --- a/test/protocol/content/parameters.rb +++ b/test/protocol/content/parameters.rb @@ -381,6 +381,20 @@ def multipart_body(*parts) expect(result.errors.first.code).to be == :invalid_type end + it "requires string keys without modifying the input" do + parameters = subject.build do + field "name", String, required: true + end + input = {name: "Samuel"} + errors = [] + + value = parameters.apply(input, errors) + + expect(input).to be == {name: "Samuel"} + expect(value).to be == {} + expect(errors.map(&:code)).to be == [:required, :unknown] + end + it "returns an empty valid result for empty form content" do parameters = subject.build do field "name", String From da417ec581437de6a9e50325d5c97d11c13d0327 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 8 Aug 2026 22:49:51 +1200 Subject: [PATCH 8/9] Move upload outcome dispatch into fields. --- lib/protocol/content/parameters/field.rb | 14 ++++++++++++-- lib/protocol/content/parameters/value.rb | 10 ---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/protocol/content/parameters/field.rb b/lib/protocol/content/parameters/field.rb index 0c46683..b2e8a36 100644 --- a/lib/protocol/content/parameters/field.rb +++ b/lib/protocol/content/parameters/field.rb @@ -106,13 +106,23 @@ def apply(value, output, errors, path) return apply_multiple(value, output, errors, path) end - Value.apply_upload(value, errors, path) do |stored| + apply_upload(value, errors, path) do |stored| output[@name] = stored end end private + # Apply an upload outcome, rejecting values which do not implement the outcome interface: + 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) @@ -133,7 +143,7 @@ def apply_multiple(value, output, errors, path) submitted = false value.each_with_index do |item, index| - if Value.apply_upload(item, errors, path + [index]){|stored| result << stored} + if apply_upload(item, errors, path + [index]){|stored| result << stored} submitted = true end end diff --git a/lib/protocol/content/parameters/value.rb b/lib/protocol/content/parameters/value.rb index 84062e7..5d60dde 100644 --- a/lib/protocol/content/parameters/value.rb +++ b/lib/protocol/content/parameters/value.rb @@ -43,16 +43,6 @@ def apply_upload(errors, path) end end - # Apply an upload outcome, rejecting values which do not implement the outcome interface: - def self.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: materialize(value)) - return false - end - def self.materialize(value) case value when Hash From 0ae7de2780d6bb7c86afd838243c214869940c3f Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 8 Aug 2026 23:06:21 +1200 Subject: [PATCH 9/9] Explain the upload processing flow. --- lib/protocol/content/parameters/field.rb | 6 +++++- lib/protocol/content/parameters/model.rb | 2 ++ lib/protocol/content/parameters/value.rb | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/protocol/content/parameters/field.rb b/lib/protocol/content/parameters/field.rb index b2e8a36..340b473 100644 --- a/lib/protocol/content/parameters/field.rb +++ b/lib/protocol/content/parameters/field.rb @@ -62,6 +62,9 @@ def initialize(name, required:, multiple:, accept:, size_limit:) @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) @@ -102,6 +105,7 @@ def upload_field(path) 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 @@ -113,7 +117,7 @@ def apply(value, output, errors, path) private - # Apply an upload outcome, rejecting values which do not implement the outcome interface: + # 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) diff --git a/lib/protocol/content/parameters/model.rb b/lib/protocol/content/parameters/model.rb index 7e7df2f..f9c354b 100644 --- a/lib/protocol/content/parameters/model.rb +++ b/lib/protocol/content/parameters/model.rb @@ -30,6 +30,7 @@ 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) @@ -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) diff --git a/lib/protocol/content/parameters/value.rb b/lib/protocol/content/parameters/value.rb index 5d60dde..a4cf4b7 100644 --- a/lib/protocol/content/parameters/value.rb +++ b/lib/protocol/content/parameters/value.rb @@ -6,6 +6,7 @@ module Protocol module Content module Parameters + # Internal values carry upload outcomes from streaming parsing into field validation. module Value class Omitted def apply_upload(errors, path)