diff --git a/guides/parameters/readme.md b/guides/parameters/readme.md index e861d91..c4d01c5 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, + accept: ["image/jpeg", "image/png"], + size_limit: 5 * 1024 * 1024 end upload "pictures", multiple: true @@ -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. 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..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,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. diff --git a/lib/protocol/content/parameters/field.rb b/lib/protocol/content/parameters/field.rb index 1072121..340b473 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,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) @@ -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 @@ -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) @@ -200,6 +238,8 @@ def apply(value, output, errors, path) end def freeze + return self if self.frozen? + if @model @model.freeze end @@ -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) @@ -249,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 8587ebe..f9c354b 100644 --- a/lib/protocol/content/parameters/model.rb +++ b/lib/protocol/content/parameters/model.rb @@ -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 @@ -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) @@ -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: @@ -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 diff --git a/lib/protocol/content/parameters/upload.rb b/lib/protocol/content/parameters/upload.rb new file mode 100644 index 0000000..4dd40b3 --- /dev/null +++ b/lib/protocol/content/parameters/upload.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/media/type" +require "protocol/media/registry" +require "protocol/multipart/readable" + +module Protocol + module Content + module Parameters + # A field-constrained streaming upload. + 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 + + # 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 + @declared_media_type = nil + + if header = delegate.headers["content-type"] + @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 + + # The submitted filename. + def filename + return @delegate.filename + end + + # The multipart headers associated with this upload. + def headers + return @delegate.headers + end + + # 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. + 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..a4cf4b7 100644 --- a/lib/protocol/content/parameters/value.rb +++ b/lib/protocol/content/parameters/value.rb @@ -6,8 +6,30 @@ module Protocol module Content module Parameters + # Internal values carry upload outcomes from streaming parsing into field validation. 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) + @code = code + @details = details + end + + attr :code + attr :details + + def apply_upload(errors, path) + errors << Error.new(path, @code, **@details) + return true + end + end class Uploaded def initialize(value) @@ -15,6 +37,11 @@ def initialize(value) end attr :value + + def apply_upload(errors, path) + yield(@value) + return true + end end def self.materialize(value) diff --git a/lib/protocol/content/parser.rb b/lib/protocol/content/parser.rb index 6ce5665..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 @@ -67,6 +69,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 diff --git a/protocol-content.gemspec b/protocol-content.gemspec index 60ff49a..d7bbc32 100644 --- a/protocol-content.gemspec +++ b/protocol-content.gemspec @@ -27,7 +27,8 @@ 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-multipart", "~> 0.6" + 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" 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..c52c73e 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" @@ -380,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 @@ -412,7 +427,13 @@ 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.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?) + {name: upload.filename, content:} end expect(result).to be(:valid?) @@ -424,6 +445,189 @@ 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 + accept = Protocol::Media::Set.for(["image/*"]) + + parameters = subject.build do + upload "avatar", accept: + 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 "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, accept: ["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 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