From 4bbb592cc4b690897a5ed81cfdfccf88b28866c2 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 5 Aug 2026 20:16:05 +1200 Subject: [PATCH 01/16] Introduce content parameters. --- guides/getting-started/readme.md | 2 +- guides/links.yaml | 2 + guides/parameters/readme.md | 95 +++++++ lib/protocol/content.rb | 1 + lib/protocol/content/parameters.rb | 101 +++++++ lib/protocol/content/parameters/definition.rb | 256 ++++++++++++++++++ lib/protocol/content/parameters/result.rb | 70 +++++ lib/protocol/content/parameters/type.rb | 70 +++++ readme.md | 1 + releases.md | 4 + test/protocol/content/parameters.rb | 236 ++++++++++++++++ 11 files changed, 837 insertions(+), 1 deletion(-) create mode 100644 guides/parameters/readme.md create mode 100644 lib/protocol/content/parameters.rb create mode 100644 lib/protocol/content/parameters/definition.rb create mode 100644 lib/protocol/content/parameters/result.rb create mode 100644 lib/protocol/content/parameters/type.rb create mode 100644 test/protocol/content/parameters.rb diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 8d1f186..db602ec 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -63,7 +63,7 @@ value = Protocol::Content::Parser.default.parse( ) ``` -The format libraries are included as dependencies, so these defaults are available from a normal installation. +The format libraries are included as dependencies, so these defaults are available from a normal installation. See the [Content Parameters](../parameters/index) guide for operation-specific filtering, conversion, validation, and upload handling. ## Configure Limits diff --git a/guides/links.yaml b/guides/links.yaml index 7f527b0..dc19a80 100644 --- a/guides/links.yaml +++ b/guides/links.yaml @@ -1,2 +1,4 @@ getting-started: order: 1 +parameters: + order: 2 diff --git a/guides/parameters/readme.md b/guides/parameters/readme.md new file mode 100644 index 0000000..2fcce50 --- /dev/null +++ b/guides/parameters/readme.md @@ -0,0 +1,95 @@ +# Content Parameters + +This guide explains how to interpret parsed content as operation-specific arguments using {ruby Protocol::Content::Parameters}. + +## Declare Parameters + +Parameter declarations define the input accepted by an operation without reproducing its database or domain model. Fields are optional by default, undeclared fields are omitted, and converted values are returned using string keys: + +``` ruby +require "protocol/content" + +parameters = Protocol::Content::Parameters.build do + nested "user", required: true do + field "name", String + field "age", Integer + end +end +``` + +`required: true` requires the key to be present. It does not imply that the value may be `nil`; use `nullable: true` when `nil` is valid. + +A nested declaration without a block accepts all key/value pairs beneath that key: + +``` ruby +parameters = Protocol::Content::Parameters.build do + nested "metadata" +end +``` + +Unknown fields can instead produce validation errors by building the parameters with `strict: true`. Strictness is inherited by constrained nested declarations unless explicitly disabled. + +## Parse Parameters + +{ruby Protocol::Content::Parameters#parse} selects a content parser according to the media type, then filters, converts, and validates the parsed value: + +``` ruby +result = parameters.parse(media_type, input) + +if result.valid? + user.update(result.arguments["user"]) +else + result.errors.each do |error| + warn "#{error.path.join(".")}: #{error.code}" + end +end +``` + +Validation errors are collected so an application can present all failures together. Each {ruby Protocol::Content::Parameters::Error} exposes a normalized `path`, machine-readable `code`, and additional `details`. + +Use {ruby Protocol::Content::Parameters#parse!} when invalid parameters should interrupt the operation. It returns the filtered argument hash or raises {ruby Protocol::Content::Parameters::ValidationError}, which retains the complete result: + +``` ruby +arguments = parameters.parse!(media_type, input) +user.update(arguments["user"]) +``` + +## Convert Fields + +Built-in converters support `String`, `Integer`, and `Float`. A custom converter can be supplied as any object responding to `#convert`: + +``` ruby +require "date" + +date = Object.new + +def date.convert(value) + Date.iso8601(value) +end + +parameters = Protocol::Content::Parameters.build do + field "date", date +end +``` + +A converter should return the converted value or raise `ArgumentError` or `TypeError`. Conversion failures are included in the result as `invalid_type` errors. + +## Handle Uploads + +Uploads do not need field declarations. When an upload handler is provided, its return value is inserted at the upload's nested form name: + +``` 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 + + stored +end +``` + +For an upload named `user[avatar]`, the stored object is available as `result.arguments["user"]["avatar"]`. 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.rb b/lib/protocol/content.rb index 51472e6..cf1ffef 100644 --- a/lib/protocol/content.rb +++ b/lib/protocol/content.rb @@ -6,6 +6,7 @@ require_relative "content/version" require_relative "content/error" require_relative "content/parser" +require_relative "content/parameters" module Protocol # @namespace diff --git a/lib/protocol/content/parameters.rb b/lib/protocol/content/parameters.rb new file mode 100644 index 0000000..e306a2f --- /dev/null +++ b/lib/protocol/content/parameters.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "default" +require_relative "parameters/definition" +require_relative "parameters/result" + +require "protocol/multipart/form_data" + +module Protocol + module Content + # Parses content into a filtered and validated argument hierarchy. + class Parameters + # Build and freeze a parameter definition. + # @parameter parser [Parser] The content parser. + # @parameter strict [Boolean] Whether unknown fields should produce validation errors. + # @yields The parameter declarations. + # @returns [Parameters] The frozen parameter definition. + def self.build(parser: Parser.default, strict: false, &block) + parameters = new(parser:, strict:) + parameters.instance_eval(&block) + return parameters.freeze + end + + # Initialize a mutable parameter definition. + # @parameter parser [Parser] The content parser. + # @parameter strict [Boolean] Whether unknown fields should produce validation errors. + def initialize(parser: Parser.default, strict: false) + @parser = parser + @definition = Definition.new(strict:) + end + + # Declare a scalar field. + # @parameter name [String] The field name. + # @parameter type [Module | #convert] The expected value type or converter. + # @parameter required [Boolean] Whether the field must be present. + # @parameter nullable [Boolean] Whether the field may be nil. + # @returns [Object] The field declaration. + def field(name, type = Object, required: false, nullable: false) + return @definition.field(name, type, required:, nullable:) + end + + # Declare a nested argument hierarchy. Without a block, all nested values are accepted. + # @parameter name [String] The nested field name. + # @parameter required [Boolean] Whether the field must be present. + # @parameter nullable [Boolean] Whether the field may be nil. + # @parameter strict [Boolean] Whether unknown nested fields should produce validation errors. + # @yields The nested parameter declarations. + # @returns [Object] The nested declaration. + def nested(name, required: false, nullable: false, strict: @definition.strict, &block) + return @definition.nested(name, required:, nullable:, strict:, &block) + end + + # Parse, filter, and validate content parameters. + # @parameter media_type [String | Protocol::Media::Type | Nil] The content media type. + # @parameter input [Object] The readable content input. + # @yields {|name, upload| ...} Each streaming upload. Its return value is inserted into the arguments. + # @returns [Result] The parsed arguments and validation errors. + def parse(media_type, input, &upload_handler) + arguments = @parser.parse(media_type, input) do |name, value| + if value.is_a?(Protocol::Multipart::FormData::Upload) + if upload_handler + UploadedValue.new(upload_handler.call(name, value)) + else + OMITTED + end + else + value + end + end + + errors = [] + arguments = @definition.apply(arguments, errors) + return Result.new(arguments, errors) + end + + # Parse content parameters, raising when validation fails. + # @parameter media_type [String | Protocol::Media::Type | Nil] The content media type. + # @parameter input [Object] The readable content input. + # @yields {|name, upload| ...} Each streaming upload. Its return value is inserted into the arguments. + # @returns [Hash] The valid arguments. + # @raises [ValidationError] If validation fails. + def parse!(media_type, input, &block) + result = parse(media_type, input, &block) + return result.arguments if result.valid? + + raise ValidationError, result + end + + # Freeze this parameter definition. + # @returns [self] The frozen parameter definition. + def freeze + @parser.freeze + @definition.freeze + super + end + end + end +end diff --git a/lib/protocol/content/parameters/definition.rb b/lib/protocol/content/parameters/definition.rb new file mode 100644 index 0000000..6d39948 --- /dev/null +++ b/lib/protocol/content/parameters/definition.rb @@ -0,0 +1,256 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "type" + +module Protocol + module Content + class Parameters + OMITTED = Object.new.freeze + + class UploadedValue + def initialize(value) + @value = value + end + + attr :value + end + + module Values + def self.materialize(value) + case value + when UploadedValue + return value.value + when Hash + result = {} + value.each do |key, item| + result[key.to_s] = materialize(item) unless item.equal?(OMITTED) + end + return result + when Array + return value.filter_map do |item| + materialize(item) unless item.equal?(OMITTED) + end + else + return value + end + end + + def self.extract_uploads(value) + case value + when UploadedValue + return value.value, false + when Hash + uploads = {} + regular = false + + value.each do |key, item| + extracted, item_regular = extract_uploads(item) + uploads[key.to_s] = extracted unless extracted.equal?(OMITTED) + regular ||= item_regular + end + + return uploads.empty? ? OMITTED : uploads, regular + when Array + uploads = [] + regular = false + + value.each do |item| + extracted, item_regular = extract_uploads(item) + uploads << extracted unless extracted.equal?(OMITTED) + regular ||= item_regular + end + + return uploads.empty? ? OMITTED : uploads, regular + when OMITTED + return OMITTED, false + else + return OMITTED, true + end + end + end + + class Field + def initialize(name, type, required:, nullable:) + @name = name + @type = Type.for(type) + @required = required + @nullable = nullable + end + + attr :name + + def required? + return @required + end + + def apply(value, output, errors, path) + if value.is_a?(UploadedValue) + output[@name] = value.value + return + end + + value = Values.materialize(value) + + if value.nil? + if @nullable + output[@name] = nil + else + errors << Error.new(path, :invalid_type, expected: expected_type, value: value) + end + + return + end + + output[@name] = @type.convert(value) + rescue ArgumentError, TypeError + errors << Error.new(path, :invalid_type, expected: expected_type, value: value) + end + + def freeze + @name.freeze + super + end + + private + + def expected_type + if @type.respond_to?(:type) + return @type.type + else + return @type + end + end + end + + class Nested + def initialize(name, definition, required:, nullable:) + @name = name + @definition = definition + @required = required + @nullable = nullable + end + + attr :name + + def required? + return @required + end + + def apply(value, output, errors, path) + if value.is_a?(UploadedValue) + output[@name] = value.value + return + end + + if value.nil? + if @nullable + output[@name] = nil + else + errors << Error.new(path, :invalid_type, expected: Hash, value: value) + end + + return + end + + unless value.is_a?(Hash) + errors << Error.new(path, :invalid_type, expected: Hash, value: value) + return + end + + if @definition + output[@name] = @definition.apply(value, errors, path) + else + output[@name] = Values.materialize(value) + end + end + + def freeze + @name.freeze + @definition&.freeze + super + end + end + + class Definition + def initialize(strict: false) + @strict = strict + @declarations = {} + end + + attr :strict + + def field(name, type = Object, required: false, nullable: false) + name = name.to_s + return add(Field.new(name, type, required:, nullable:)) + end + + def nested(name, required: false, nullable: false, strict: @strict, &block) + name = name.to_s + + if block + definition = Definition.new(strict:) + definition.instance_eval(&block) + end + + return add(Nested.new(name, definition, required:, nullable:)) + end + + def apply(value, errors, path = []) + unless value.is_a?(Hash) + errors << Error.new(path, :invalid_type, expected: Hash, value: value) + return {} + end + + input = {} + value.each{|key, item| input[key.to_s] = item} + output = {} + + @declarations.each do |name, declaration| + item_path = path + [name] + + if input.key?(name) && !input[name].equal?(OMITTED) + declaration.apply(input.delete(name), output, errors, item_path) + elsif declaration.required? + errors << Error.new(item_path, :required) + end + end + + input.each do |name, item| + next if item.equal?(OMITTED) + + uploads, regular = Values.extract_uploads(item) + output[name] = uploads unless uploads.equal?(OMITTED) + + if @strict && regular + errors << Error.new(path + [name], :unknown) + end + end + + return output + end + + def freeze + @declarations.each_value(&:freeze) + @declarations.freeze + super + end + + private + + def add(declaration) + if @declarations.key?(declaration.name) + raise ArgumentError, "Parameter #{declaration.name.inspect} is already declared!" + end + + @declarations[declaration.name] = declaration + return declaration + end + end + + private_constant :OMITTED, :UploadedValue, :Values, :Type, :Field, :Nested, :Definition + end + end +end diff --git a/lib/protocol/content/parameters/result.rb b/lib/protocol/content/parameters/result.rb new file mode 100644 index 0000000..c36c1ec --- /dev/null +++ b/lib/protocol/content/parameters/result.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "../error" + +module Protocol + module Content + class Parameters + # A validation error associated with a specific argument path. + class Error + # Initialize the validation error. + # @parameter path [Array(String)] The path to the invalid argument. + # @parameter code [Symbol] The machine-readable error code. + # @parameter details [Hash] Additional error details. + def initialize(path, code, **details) + @path = path.freeze + @code = code + @details = details.freeze + end + + # The path to the invalid argument. + attr :path + + # The machine-readable error code. + attr :code + + # Additional error details. + attr :details + end + + # The result of parsing and validating content parameters. + class Result + # Initialize the result. + # @parameter arguments [Hash] The converted and filtered arguments. + # @parameter errors [Array(Error)] The validation errors. + def initialize(arguments, errors) + @arguments = arguments + @errors = errors.freeze + end + + # The converted and filtered arguments. + attr :arguments + + # The validation errors. + attr :errors + + # Whether the parameters are valid. + # @returns [Boolean] True when there are no validation errors. + def valid? + return @errors.empty? + end + end + + # Raised when parsed parameters are invalid. + class ValidationError < Protocol::Content::Error + # Initialize the validation error. + # @parameter result [Result] The invalid parameters result. + def initialize(result) + @result = result + super("Content parameters are invalid!") + end + + # The invalid parameters result. + attr :result + end + end + end +end diff --git a/lib/protocol/content/parameters/type.rb b/lib/protocol/content/parameters/type.rb new file mode 100644 index 0000000..221fd40 --- /dev/null +++ b/lib/protocol/content/parameters/type.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Protocol + module Content + class Parameters + # Converts input values to a specific application type. + class Type + @types = {} + + # Register a converter for a type. + # @parameter type [Object] The declared type. + # @yields {|value| ...} The conversion operation. + # @returns [Type] The registered type converter. + def self.register(type, &converter) + return @types[type] = new(type, &converter) + end + + # Resolve a declared type to a converter. + # @parameter type [Object] The declared type or converter. + # @returns [Type | Object] A value responding to `#convert`. + def self.for(type) + return type if type.respond_to?(:convert) + return @types.fetch(type){new(type)} + end + + # Initialize a type converter. + # @parameter type [Object] The expected converted type. + # @yields {|value| ...} The conversion operation. + def initialize(type, &converter) + @type = type + @converter = converter + end + + # The expected converted type. + attr :type + + # Convert a value to the declared type. + # @parameter value [Object] The input value. + # @returns [Object] The converted value. + # @raises [TypeError] If the value cannot be converted. + def convert(value) + return value if @type === value + + if @converter + value = @converter.call(value) + return value if @type === value + end + + raise TypeError, "Could not convert #{value.inspect} to #{@type}!" + end + end + + Type.register(String) do |value| + String(value) + end + + Type.register(Integer) do |value| + raise TypeError unless value.is_a?(String) + Integer(value, 10) + end + + Type.register(Float) do |value| + Float(value) + end + end + end +end diff --git a/readme.md b/readme.md index 128b574..23c2305 100644 --- a/readme.md +++ b/readme.md @@ -9,6 +9,7 @@ Provides transport-independent parsing for media-typed content. Please see the [project documentation](https://socketry.github.io/protocol-content/) for more details. - [Getting Started](https://socketry.github.io/protocol-content/guides/getting-started/index) - This guide explains how to parse media-typed content using built-in and custom parsers. + - [Content Parameters](https://socketry.github.io/protocol-content/guides/parameters/index) - This guide explains how to interpret parsed content as operation-specific arguments. ## Releases diff --git a/releases.md b/releases.md index a7e66d2..048df2e 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Add declarative content parameter filtering, conversion, validation, and upload handling. + ## v0.1.0 - Add media-type parser dispatch for readable content. diff --git a/test/protocol/content/parameters.rb b/test/protocol/content/parameters.rb new file mode 100644 index 0000000..5a3ae6b --- /dev/null +++ b/test/protocol/content/parameters.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/content" + +require "stringio" + +describe Protocol::Content::Parameters do + BOUNDARY = "parameters-boundary" + + def parse_json(parameters, content) + return parameters.parse("application/json", StringIO.new(content)) + end + + def multipart_body(*parts) + body = parts.map do |headers, content| + serialized_headers = headers.map{|name, value| "#{name}: #{value}"}.join("\n") + "--#{BOUNDARY}\n#{serialized_headers}\n\n#{content}\n" + end + + return (body.join + "--#{BOUNDARY}--\n").gsub("\n", "\r\n") + end + + it "builds immutable parameter definitions" do + parameters = subject.build do + field "name", String + end + + expect(parameters).to be(:frozen?) + expect do + parameters.field("age", Integer) + end.to raise_exception(FrozenError) + end + + it "filters unknown fields and converts declared fields" do + parameters = subject.build do + field "name", String + field "age", Integer + end + + result = parse_json(parameters, '{"name":"Samuel","age":"42","admin":true}') + + expect(result).to be(:valid?) + expect(result.arguments).to be == {"name" => "Samuel", "age" => 42} + end + + it "collects required, conversion, and unknown field errors" do + parameters = subject.build(strict: true) do + field "name", String, required: true + field "age", Integer + end + + result = parse_json(parameters, '{"age":"old","admin":true}') + + expect(result).not.to be(:valid?) + expect(result.errors.map(&:path)).to be == [["name"], ["age"], ["admin"]] + expect(result.errors.map(&:code)).to be == [:required, :invalid_type, :unknown] + end + + it "distinguishes optional, required, and nullable fields" do + parameters = subject.build do + field "optional", String + field "required", String, required: true + field "nullable", String, nullable: true + end + + result = parse_json(parameters, '{"required":null,"nullable":null}') + + expect(result.arguments).to be == {"nullable" => nil} + expect(result.errors.map(&:path)).to be == [["required"]] + end + + it "filters constrained nested parameters" do + parameters = subject.build do + nested "user", required: true do + field "name", String + field "age", Integer + end + end + + result = parse_json(parameters, '{"user":{"name":"Samuel","age":"42","admin":true}}') + + expect(result).to be(:valid?) + expect(result.arguments).to be == { + "user" => {"name" => "Samuel", "age" => 42} + } + end + + it "inherits strict validation in nested declarations" do + parameters = subject.build(strict: true) do + nested "user" do + field "name", String + end + end + + result = parse_json(parameters, '{"user":{"name":"Samuel","admin":true}}') + + expect(result.errors.map(&:path)).to be == [["user", "admin"]] + expect(result.errors.map(&:code)).to be == [:unknown] + end + + it "accepts all values under an unconstrained nested parameter" do + parameters = subject.build do + nested "metadata" + end + + result = parse_json(parameters, '{"metadata":{"count":1,"labels":["a","b"]}}') + + expect(result.arguments).to be == { + "metadata" => {"count" => 1, "labels" => ["a", "b"]} + } + end + + it "raises an aggregate validation error" do + parameters = subject.build do + field "name", String, required: true + end + + expect do + parameters.parse!("application/json", StringIO.new("{}")) + end.to raise_exception(subject::ValidationError) do |error| + expect(error.result.errors.map(&:code)).to be == [:required] + end + end + + it "supports custom converters" do + converter = Object.new + def converter.convert(value) + return value.upcase + end + + parameters = subject.build do + field "code", converter + end + result = parse_json(parameters, '{"code":"abc"}') + + expect(result.arguments).to be == {"code" => "ABC"} + end + + it "reports a non-object content value" do + parameters = subject.build do + field "name", String + end + result = parse_json(parameters, "[]") + + expect(result.arguments).to be == {} + expect(result.errors.first.path).to be == [] + expect(result.errors.first.code).to be == :invalid_type + end + + it "returns an empty valid result for empty form content" do + parameters = subject.build do + field "name", String + end + + result = parameters.parse("application/x-www-form-urlencoded", StringIO.new) + + expect(result).to be(:valid?) + expect(result.arguments).to be == {} + end + + it "inserts handled uploads using their nested form names" do + parameters = subject.build(strict: true) do + nested "user" do + field "name", String + end + end + body = multipart_body( + [{"Content-Disposition" => 'form-data; name="user[name]"'}, "Samuel"], + [ + { + "Content-Disposition" => 'form-data; name="user[avatar]"; filename="avatar.txt"', + "Content-Type" => "text/plain" + }, + "avatar" + ] + ) + media_type = "multipart/form-data; boundary=#{BOUNDARY}" + + 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} + end + + expect(result).to be(:valid?) + expect(result.arguments).to be == { + "user" => { + "name" => "Samuel", + "avatar" => {name: "avatar.txt", content: "avatar"} + } + } + end + + it "preserves nil returned by the upload handler" do + parameters = subject.build{} + body = multipart_body([ + { + "Content-Disposition" => 'form-data; name="avatar"; filename="avatar.txt"', + "Content-Type" => "text/plain" + }, + "avatar" + ]) + media_type = "multipart/form-data; boundary=#{BOUNDARY}" + + result = parameters.parse(media_type, StringIO.new(body)) do |_name, upload| + upload.discard + nil + end + + expect(result.arguments).to be == {"avatar" => nil} + end + + it "discards and omits unhandled uploads" do + parameters = subject.build do + field "name", String + end + body = multipart_body( + [{"Content-Disposition" => 'form-data; name="name"'}, "Samuel"], + [ + { + "Content-Disposition" => 'form-data; name="avatar"; filename="avatar.txt"', + "Content-Type" => "text/plain" + }, + "avatar" + ] + ) + media_type = "multipart/form-data; boundary=#{BOUNDARY}" + + result = parameters.parse(media_type, StringIO.new(body)) + + expect(result).to be(:valid?) + expect(result.arguments).to be == {"name" => "Samuel"} + end +end From 5026fa8d55b3c0537e422324e4b076a8fce7524e Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 5 Aug 2026 20:28:03 +1200 Subject: [PATCH 02/16] Require explicit upload declarations. --- guides/parameters/readme.md | 21 ++- lib/protocol/content/parameters.rb | 15 +- lib/protocol/content/parameters/definition.rb | 121 ++++++++-------- lib/protocol/content/parameters/type.rb | 4 +- test/protocol/content/parameters.rb | 130 +++++++++++++++++- 5 files changed, 220 insertions(+), 71 deletions(-) diff --git a/guides/parameters/readme.md b/guides/parameters/readme.md index 2fcce50..167cd09 100644 --- a/guides/parameters/readme.md +++ b/guides/parameters/readme.md @@ -13,6 +13,7 @@ parameters = Protocol::Content::Parameters.build do nested "user", required: true do field "name", String field "age", Integer + upload "avatar" end end ``` @@ -56,16 +57,12 @@ user.update(arguments["user"]) ## Convert Fields -Built-in converters support `String`, `Integer`, and `Float`. A custom converter can be supplied as any object responding to `#convert`: +Built-in converters support `String`, `Integer`, and `Float`. A custom converter can be supplied as any object responding to `#call`: ``` ruby require "date" -date = Object.new - -def date.convert(value) - Date.iso8601(value) -end +date = ->(value){Date.iso8601(value)} parameters = Protocol::Content::Parameters.build do field "date", date @@ -76,7 +73,17 @@ A converter should return the converted value or raise `ArgumentError` or `TypeE ## Handle Uploads -Uploads do not need field declarations. When an upload handler is provided, its return value is inserted at the upload's nested form name: +Uploads must be declared explicitly. Undeclared uploads are consumed and omitted without invoking the upload handler: + +``` ruby +parameters = Protocol::Content::Parameters.build do + nested "user" do + upload "avatar", required: true + end +end +``` + +When an upload handler is provided, its return value is inserted at the upload's nested form name: ``` ruby result = parameters.parse(media_type, input) do |name, upload| diff --git a/lib/protocol/content/parameters.rb b/lib/protocol/content/parameters.rb index e306a2f..ab014b8 100644 --- a/lib/protocol/content/parameters.rb +++ b/lib/protocol/content/parameters.rb @@ -8,6 +8,7 @@ require_relative "parameters/result" require "protocol/multipart/form_data" +require "protocol/url/encoding" module Protocol module Content @@ -34,7 +35,7 @@ def initialize(parser: Parser.default, strict: false) # Declare a scalar field. # @parameter name [String] The field name. - # @parameter type [Module | #convert] The expected value type or converter. + # @parameter type [Module | #call] The expected value type or converter. # @parameter required [Boolean] Whether the field must be present. # @parameter nullable [Boolean] Whether the field may be nil. # @returns [Object] The field declaration. @@ -42,6 +43,14 @@ def field(name, type = Object, required: false, nullable: false) return @definition.field(name, type, required:, nullable:) end + # Declare a streaming file upload. + # @parameter name [String] The upload field name. + # @parameter required [Boolean] Whether the upload must be present. + # @returns [Object] The upload declaration. + def upload(name, required: false) + return @definition.upload(name, required:) + end + # Declare a nested argument hierarchy. Without a block, all nested values are accepted. # @parameter name [String] The nested field name. # @parameter required [Boolean] Whether the field must be present. @@ -61,7 +70,9 @@ def nested(name, required: false, nullable: false, strict: @definition.strict, & def parse(media_type, input, &upload_handler) arguments = @parser.parse(media_type, input) do |name, value| if value.is_a?(Protocol::Multipart::FormData::Upload) - if upload_handler + path = Protocol::URL::Encoding.split(name) + + if upload_handler && @definition.accepts_upload?(path) UploadedValue.new(upload_handler.call(name, value)) else OMITTED diff --git a/lib/protocol/content/parameters/definition.rb b/lib/protocol/content/parameters/definition.rb index 6d39948..462ee60 100644 --- a/lib/protocol/content/parameters/definition.rb +++ b/lib/protocol/content/parameters/definition.rb @@ -21,8 +21,6 @@ def initialize(value) module Values def self.materialize(value) case value - when UploadedValue - return value.value when Hash result = {} value.each do |key, item| @@ -37,39 +35,6 @@ def self.materialize(value) return value end end - - def self.extract_uploads(value) - case value - when UploadedValue - return value.value, false - when Hash - uploads = {} - regular = false - - value.each do |key, item| - extracted, item_regular = extract_uploads(item) - uploads[key.to_s] = extracted unless extracted.equal?(OMITTED) - regular ||= item_regular - end - - return uploads.empty? ? OMITTED : uploads, regular - when Array - uploads = [] - regular = false - - value.each do |item| - extracted, item_regular = extract_uploads(item) - uploads << extracted unless extracted.equal?(OMITTED) - regular ||= item_regular - end - - return uploads.empty? ? OMITTED : uploads, regular - when OMITTED - return OMITTED, false - else - return OMITTED, true - end - end end class Field @@ -87,11 +52,6 @@ def required? end def apply(value, output, errors, path) - if value.is_a?(UploadedValue) - output[@name] = value.value - return - end - value = Values.materialize(value) if value.nil? @@ -104,7 +64,11 @@ def apply(value, output, errors, path) return end - output[@name] = @type.convert(value) + output[@name] = if @type.respond_to?(:convert) + @type.convert(value) + else + @type.call(value) + end rescue ArgumentError, TypeError errors << Error.new(path, :invalid_type, expected: expected_type, value: value) end @@ -125,6 +89,36 @@ def expected_type end end + class Upload + def initialize(name, required:) + @name = name + @required = required + end + + attr :name + + def required? + return @required + end + + def accepts_upload?(path) + return path.empty? + end + + def apply(value, output, errors, path) + if value.is_a?(UploadedValue) + output[@name] = value.value + else + errors << Error.new(path, :invalid_type, expected: :upload, value: Values.materialize(value)) + end + end + + def freeze + @name.freeze + super + end + end + class Nested def initialize(name, definition, required:, nullable:) @name = name @@ -139,12 +133,12 @@ def required? return @required end + def accepts_upload?(path) + return false unless @definition + return @definition.accepts_upload?(path) + end + def apply(value, output, errors, path) - if value.is_a?(UploadedValue) - output[@name] = value.value - return - end - if value.nil? if @nullable output[@name] = nil @@ -187,6 +181,11 @@ def field(name, type = Object, required: false, nullable: false) return add(Field.new(name, type, required:, nullable:)) end + def upload(name, required: false) + name = name.to_s + return add(Upload.new(name, required:)) + end + def nested(name, required: false, nullable: false, strict: @strict, &block) name = name.to_s @@ -211,20 +210,26 @@ def apply(value, errors, path = []) @declarations.each do |name, declaration| item_path = path + [name] - if input.key?(name) && !input[name].equal?(OMITTED) - declaration.apply(input.delete(name), output, errors, item_path) + if input.key?(name) + item = input.delete(name) + + if item.equal?(OMITTED) + errors << Error.new(item_path, :required) if declaration.required? + else + declaration.apply(item, output, errors, item_path) + end elsif declaration.required? errors << Error.new(item_path, :required) end end input.each do |name, item| - next if item.equal?(OMITTED) - - uploads, regular = Values.extract_uploads(item) - output[name] = uploads unless uploads.equal?(OMITTED) + if item.equal?(OMITTED) + errors << Error.new(path + [name], :unknown) if @strict + next + end - if @strict && regular + if @strict errors << Error.new(path + [name], :unknown) end end @@ -232,6 +237,14 @@ def apply(value, errors, path = []) return output end + def accepts_upload?(path) + name, *remaining = path + return false unless name + return false unless declaration = @declarations[name] + return false unless declaration.respond_to?(:accepts_upload?) + return declaration.accepts_upload?(remaining) + end + def freeze @declarations.each_value(&:freeze) @declarations.freeze @@ -250,7 +263,7 @@ def add(declaration) end end - private_constant :OMITTED, :UploadedValue, :Values, :Type, :Field, :Nested, :Definition + private_constant :OMITTED, :UploadedValue, :Values, :Type, :Field, :Upload, :Nested, :Definition end end end diff --git a/lib/protocol/content/parameters/type.rb b/lib/protocol/content/parameters/type.rb index 221fd40..e6c9c2c 100644 --- a/lib/protocol/content/parameters/type.rb +++ b/lib/protocol/content/parameters/type.rb @@ -20,9 +20,9 @@ def self.register(type, &converter) # Resolve a declared type to a converter. # @parameter type [Object] The declared type or converter. - # @returns [Type | Object] A value responding to `#convert`. + # @returns [Type | Object] A value responding to `#call`. def self.for(type) - return type if type.respond_to?(:convert) + return type if type.respond_to?(:call) return @types.fetch(type){new(type)} end diff --git a/test/protocol/content/parameters.rb b/test/protocol/content/parameters.rb index 5a3ae6b..1534aa8 100644 --- a/test/protocol/content/parameters.rb +++ b/test/protocol/content/parameters.rb @@ -72,6 +72,28 @@ def multipart_body(*parts) expect(result.errors.map(&:path)).to be == [["required"]] end + it "converts string and floating point fields" do + parameters = subject.build do + field "name", String + field "ratio", Float + end + + result = parse_json(parameters, '{"name":123,"ratio":"1.5"}') + + expect(result.arguments).to be == {"name" => "123", "ratio" => 1.5} + end + + it "rejects values without a type conversion" do + type = Class.new + parameters = subject.build do + field "value", type + end + + result = parse_json(parameters, '{"value":"invalid"}') + + expect(result.errors.map(&:code)).to be == [:invalid_type] + end + it "filters constrained nested parameters" do parameters = subject.build do nested "user", required: true do @@ -113,6 +135,30 @@ def multipart_body(*parts) } end + it "validates required, nullable, and invalid nested parameters" do + parameters = subject.build do + nested "required", required: true + nested "nullable", nullable: true + nested "nonnullable" + nested "invalid" + end + + result = parse_json(parameters, '{"nullable":null,"nonnullable":null,"invalid":"value"}') + + expect(result.arguments).to be == {"nullable" => nil} + expect(result.errors.map(&:path)).to be == [["required"], ["nonnullable"], ["invalid"]] + expect(result.errors.map(&:code)).to be == [:required, :invalid_type, :invalid_type] + end + + it "rejects duplicate declarations" do + expect do + subject.build do + field "name", String + upload "name" + end + end.to raise_exception(ArgumentError, message: be =~ /already declared/) + end + it "raises an aggregate validation error" do parameters = subject.build do field "name", String, required: true @@ -126,10 +172,7 @@ def multipart_body(*parts) end it "supports custom converters" do - converter = Object.new - def converter.convert(value) - return value.upcase - end + converter = ->(value){value.upcase} parameters = subject.build do field "code", converter @@ -139,6 +182,18 @@ def converter.convert(value) expect(result.arguments).to be == {"code" => "ABC"} end + it "collects custom converter failures" do + converter = ->(_value){raise ArgumentError} + parameters = subject.build do + field "code", converter + end + + result = parse_json(parameters, '{"code":"abc"}') + + expect(result.arguments).to be == {} + expect(result.errors.map(&:code)).to be == [:invalid_type] + end + it "reports a non-object content value" do parameters = subject.build do field "name", String @@ -165,6 +220,7 @@ def converter.convert(value) parameters = subject.build(strict: true) do nested "user" do field "name", String + upload "avatar" end end body = multipart_body( @@ -194,7 +250,9 @@ def converter.convert(value) end it "preserves nil returned by the upload handler" do - parameters = subject.build{} + parameters = subject.build do + upload "avatar" + end body = multipart_body([ { "Content-Disposition" => 'form-data; name="avatar"; filename="avatar.txt"', @@ -212,9 +270,69 @@ def converter.convert(value) expect(result.arguments).to be == {"avatar" => nil} end - it "discards and omits unhandled uploads" do + it "does not pass undeclared uploads to the handler" do + parameters = subject.build(strict: true) do + field "name", String + end + body = multipart_body([ + { + "Content-Disposition" => 'form-data; name="avatar"; filename="avatar.txt"', + "Content-Type" => "text/plain" + }, + "avatar" + ]) + 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.arguments).to be == {} + expect(result.errors.map(&:path)).to be == [["avatar"]] + expect(result.errors.map(&:code)).to be == [:unknown] + end + + it "rejects undeclared nested uploads" do + parameters = subject.build(strict: true) do + nested "user" do + field "name", String + end + end + body = multipart_body([ + { + "Content-Disposition" => 'form-data; name="user[avatar]"; filename="avatar.txt"', + "Content-Type" => "text/plain" + }, + "avatar" + ]) + media_type = "multipart/form-data; boundary=#{BOUNDARY}" + + result = parameters.parse(media_type, StringIO.new(body)) do + raise "The handler should not be called!" + end + + expect(result.arguments).to be == {"user" => {}} + expect(result.errors.map(&:path)).to be == [["user", "avatar"]] + end + + it "validates upload declarations" do parameters = subject.build do + upload "avatar", required: true + end + + missing = parse_json(parameters, "{}") + invalid = parse_json(parameters, '{"avatar":"not an upload"}') + + expect(missing.errors.map(&:code)).to be == [:required] + expect(invalid.errors.map(&:code)).to be == [:invalid_type] + end + + it "discards and omits declared uploads without a handler" do + parameters = subject.build(strict: true) do field "name", String + upload "avatar" end body = multipart_body( [{"Content-Disposition" => 'form-data; name="name"'}, "Samuel"], From 686b5676f3ceaa7c81d5391bec091bf0ce39363b Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 5 Aug 2026 20:29:49 +1200 Subject: [PATCH 03/16] Remove cross-guide link. --- guides/getting-started/readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index db602ec..8d1f186 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -63,7 +63,7 @@ value = Protocol::Content::Parser.default.parse( ) ``` -The format libraries are included as dependencies, so these defaults are available from a normal installation. See the [Content Parameters](../parameters/index) guide for operation-specific filtering, conversion, validation, and upload handling. +The format libraries are included as dependencies, so these defaults are available from a normal installation. ## Configure Limits From 409564c83a0f7e4ea9bb99e74f8f8a53ac0df2db Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 5 Aug 2026 20:47:29 +1200 Subject: [PATCH 04/16] Support array parameters. --- guides/parameters/readme.md | 24 ++++ lib/protocol/content/parameters.rb | 12 ++ lib/protocol/content/parameters/definition.rb | 119 +++++++++++++++--- lib/protocol/content/parameters/result.rb | 2 +- test/protocol/content/parameters.rb | 78 ++++++++++++ 5 files changed, 217 insertions(+), 18 deletions(-) diff --git a/guides/parameters/readme.md b/guides/parameters/readme.md index 167cd09..0e65ba5 100644 --- a/guides/parameters/readme.md +++ b/guides/parameters/readme.md @@ -30,6 +30,30 @@ end Unknown fields can instead produce validation errors by building the parameters with `strict: true`. Strictness is inherited by constrained nested declarations unless explicitly disabled. +## Declare Arrays + +An array declaration without a block accepts and optionally converts each value: + +``` ruby +parameters = Protocol::Content::Parameters.build do + array "tags", String + array "metadata" +end +``` + +Use a block to declare the fields accepted by each array element: + +``` ruby +parameters = Protocol::Content::Parameters.build do + array "users" do + field "name", String, required: true + field "age", Integer + end +end +``` + +Validation errors for array elements include the element index in their path. + ## Parse Parameters {ruby Protocol::Content::Parameters#parse} selects a content parser according to the media type, then filters, converts, and validates the parsed value: diff --git a/lib/protocol/content/parameters.rb b/lib/protocol/content/parameters.rb index ab014b8..cd45129 100644 --- a/lib/protocol/content/parameters.rb +++ b/lib/protocol/content/parameters.rb @@ -51,6 +51,18 @@ def upload(name, required: false) return @definition.upload(name, required:) end + # Declare an array of scalar values or nested argument hierarchies. + # @parameter name [String] The array field name. + # @parameter type [Module | #call | Nil] The expected element type or converter. + # @parameter required [Boolean] Whether the array must be present. + # @parameter nullable [Boolean] Whether the array may be nil. + # @parameter strict [Boolean] Whether unknown nested fields should produce validation errors. + # @yields The nested parameter declarations for each array element. + # @returns [Object] The array declaration. + def array(name, type = nil, required: false, nullable: false, strict: @definition.strict, &block) + return @definition.array(name, type, required:, nullable:, strict:, &block) + end + # Declare a nested argument hierarchy. Without a block, all nested values are accepted. # @parameter name [String] The nested field name. # @parameter required [Boolean] Whether the field must be present. diff --git a/lib/protocol/content/parameters/definition.rb b/lib/protocol/content/parameters/definition.rb index 462ee60..b3f155b 100644 --- a/lib/protocol/content/parameters/definition.rb +++ b/lib/protocol/content/parameters/definition.rb @@ -19,6 +19,22 @@ def initialize(value) end module Values + def self.convert(type, value) + if type.respond_to?(:convert) + return type.convert(value) + else + return type.call(value) + end + end + + def self.expected_type(type) + if type.respond_to?(:type) + return type.type + else + return type + end + end + def self.materialize(value) case value when Hash @@ -58,19 +74,15 @@ def apply(value, output, errors, path) if @nullable output[@name] = nil else - errors << Error.new(path, :invalid_type, expected: expected_type, value: value) + errors << Error.new(path, :invalid_type, expected: Values.expected_type(@type), value: value) end return end - output[@name] = if @type.respond_to?(:convert) - @type.convert(value) - else - @type.call(value) - end + output[@name] = Values.convert(@type, value) rescue ArgumentError, TypeError - errors << Error.new(path, :invalid_type, expected: expected_type, value: value) + errors << Error.new(path, :invalid_type, expected: Values.expected_type(@type), value: value) end def freeze @@ -78,15 +90,6 @@ def freeze super end - private - - def expected_type - if @type.respond_to?(:type) - return @type.type - else - return @type - end - end end class Upload @@ -119,6 +122,73 @@ def freeze end end + class ArrayField + def initialize(name, type, definition, required:, nullable:) + @name = name + @type = Type.for(type) if type + @definition = definition + @required = required + @nullable = nullable + end + + attr :name + + def required? + return @required + end + + def apply(value, output, errors, path) + if value.nil? + if @nullable + output[@name] = nil + else + errors << Error.new(path, :invalid_type, expected: Array, value: value) + end + + return + end + + unless value.is_a?(Array) + errors << Error.new(path, :invalid_type, expected: Array, value: value) + return + end + + result = [] + + value.each_with_index do |item, index| + item_path = path + [index] + + if @definition + result << @definition.apply(item, errors, item_path) + elsif @type + if item.nil? + errors << Error.new(item_path, :invalid_type, expected: Values.expected_type(@type), value: item) + next + end + + begin + item = Values.materialize(item) + item = Values.convert(@type, item) + result << item + rescue ArgumentError, TypeError + errors << Error.new(item_path, :invalid_type, expected: Values.expected_type(@type), value: item) + end + else + result << Values.materialize(item) + end + end + + output[@name] = result + end + + def freeze + @name.freeze + @definition&.freeze + super + end + + end + class Nested def initialize(name, definition, required:, nullable:) @name = name @@ -186,6 +256,21 @@ def upload(name, required: false) return add(Upload.new(name, required:)) end + def array(name, type = nil, required: false, nullable: false, strict: @strict, &block) + name = name.to_s + + if block + if type + raise ArgumentError, "An array cannot declare both an element type and nested fields!" + end + + definition = Definition.new(strict:) + definition.instance_eval(&block) + end + + return add(ArrayField.new(name, type, definition, required:, nullable:)) + end + def nested(name, required: false, nullable: false, strict: @strict, &block) name = name.to_s @@ -263,7 +348,7 @@ def add(declaration) end end - private_constant :OMITTED, :UploadedValue, :Values, :Type, :Field, :Upload, :Nested, :Definition + private_constant :OMITTED, :UploadedValue, :Values, :Type, :Field, :Upload, :ArrayField, :Nested, :Definition end end end diff --git a/lib/protocol/content/parameters/result.rb b/lib/protocol/content/parameters/result.rb index c36c1ec..9c1aae5 100644 --- a/lib/protocol/content/parameters/result.rb +++ b/lib/protocol/content/parameters/result.rb @@ -11,7 +11,7 @@ class Parameters # A validation error associated with a specific argument path. class Error # Initialize the validation error. - # @parameter path [Array(String)] The path to the invalid argument. + # @parameter path [Array(String | Integer)] The path to the invalid argument. # @parameter code [Symbol] The machine-readable error code. # @parameter details [Hash] Additional error details. def initialize(path, code, **details) diff --git a/test/protocol/content/parameters.rb b/test/protocol/content/parameters.rb index 1534aa8..a478063 100644 --- a/test/protocol/content/parameters.rb +++ b/test/protocol/content/parameters.rb @@ -159,6 +159,84 @@ def multipart_body(*parts) end.to raise_exception(ArgumentError, message: be =~ /already declared/) end + it "accepts and converts array values" do + parameters = subject.build do + array "tags", String + array "metadata" + end + + result = parse_json(parameters, '{"tags":["one",2],"metadata":[{"enabled":true},[1,2]]}') + + expect(result.arguments).to be == { + "tags" => ["one", "2"], + "metadata" => [{"enabled" => true}, [1, 2]], + } + end + + it "validates nested array values" do + parameters = subject.build(strict: true) do + array "users", required: true do + field "name", String, required: true + field "age", Integer + end + end + + result = parse_json(parameters, '{"users":[{"name":"Samuel","age":"42"},{"age":"old","admin":true},null]}') + + expect(result.arguments).to be == { + "users" => [{"name" => "Samuel", "age" => 42}, {}, {}], + } + expect(result.errors.map(&:path)).to be == [ + ["users", 1, "name"], + ["users", 1, "age"], + ["users", 1, "admin"], + ["users", 2], + ] + end + + it "validates array shape, nullability, and element conversion" do + parameters = subject.build do + array "required", required: true + array "nullable", nullable: true + array "nonnullable" + array "invalid" + array "numbers", Integer + end + + result = parse_json(parameters, '{"nullable":null,"nonnullable":null,"invalid":{},"numbers":["1","bad",null]}') + + expect(result.arguments).to be == {"nullable" => nil, "numbers" => [1]} + expect(result.errors.map(&:path)).to be == [["required"], ["nonnullable"], ["invalid"], ["numbers", 1], ["numbers", 2]] + end + + it "parses URL-encoded arrays" do + parameters = subject.build do + array "tags", String + array "users" do + field "name", String + field "age", Integer + end + end + input = StringIO.new("tags[]=one&tags[]=two&users[][name]=Alice&users[][age]=30&users[][name]=Bob") + + result = parameters.parse("application/x-www-form-urlencoded", input) + + expect(result.arguments).to be == { + "tags" => ["one", "two"], + "users" => [{"name" => "Alice", "age" => 30}, {"name" => "Bob"}], + } + end + + it "rejects an array element type with nested fields" do + expect do + subject.build do + array "values", String do + field "name", String + end + end + end.to raise_exception(ArgumentError, message: be =~ /element type and nested fields/) + end + it "raises an aggregate validation error" do parameters = subject.build do field "name", String, required: true From fc3c2fffc8f6cdb91689faeb35bab71c447d9c03 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 5 Aug 2026 20:51:50 +1200 Subject: [PATCH 05/16] Support uploads in array elements. --- lib/protocol/content/parameters/definition.rb | 7 +++++ test/protocol/content/parameters.rb | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/lib/protocol/content/parameters/definition.rb b/lib/protocol/content/parameters/definition.rb index b3f155b..3b6c76c 100644 --- a/lib/protocol/content/parameters/definition.rb +++ b/lib/protocol/content/parameters/definition.rb @@ -137,6 +137,13 @@ def required? return @required end + def accepts_upload?(path) + return false unless @definition + index, *remaining = path + return false unless index&.empty? + return @definition.accepts_upload?(remaining) + end + def apply(value, output, errors, path) if value.nil? if @nullable diff --git a/test/protocol/content/parameters.rb b/test/protocol/content/parameters.rb index a478063..2e8cc69 100644 --- a/test/protocol/content/parameters.rb +++ b/test/protocol/content/parameters.rb @@ -327,6 +327,37 @@ def multipart_body(*parts) } end + it "inserts handled uploads into array elements" do + parameters = subject.build do + array "users" do + field "name", String + upload "avatar" + end + end + body = multipart_body( + [{"Content-Disposition" => 'form-data; name="users[][name]"'}, "Samuel"], + [ + { + "Content-Disposition" => 'form-data; name="users[][avatar]"; filename="avatar.txt"', + "Content-Type" => "text/plain" + }, + "avatar" + ] + ) + media_type = "multipart/form-data; boundary=#{BOUNDARY}" + + result = parameters.parse(media_type, StringIO.new(body)) do |_name, upload| + {name: upload.filename, content: upload.each.to_a.join} + end + + expect(result.arguments).to be == { + "users" => [{ + "name" => "Samuel", + "avatar" => {name: "avatar.txt", content: "avatar"}, + }], + } + end + it "preserves nil returned by the upload handler" do parameters = subject.build do upload "avatar" From b812a474651a5497bb89f995d8f2c2e717a8f9b3 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 5 Aug 2026 20:55:24 +1200 Subject: [PATCH 06/16] Clarify parameter validation flow. --- lib/protocol/content/parameters.rb | 6 +- lib/protocol/content/parameters/definition.rb | 85 ++++++++++++++++--- lib/protocol/content/parameters/type.rb | 23 ++++- test/protocol/content/parameters.rb | 75 ++++++++++++++++ 4 files changed, 171 insertions(+), 18 deletions(-) diff --git a/lib/protocol/content/parameters.rb b/lib/protocol/content/parameters.rb index cd45129..17ad7b4 100644 --- a/lib/protocol/content/parameters.rb +++ b/lib/protocol/content/parameters.rb @@ -84,6 +84,7 @@ def parse(media_type, input, &upload_handler) if value.is_a?(Protocol::Multipart::FormData::Upload) path = Protocol::URL::Encoding.split(name) + # Only process uploads accepted by an explicit declaration: if upload_handler && @definition.accepts_upload?(path) UploadedValue.new(upload_handler.call(name, value)) else @@ -107,7 +108,10 @@ def parse(media_type, input, &upload_handler) # @raises [ValidationError] If validation fails. def parse!(media_type, input, &block) result = parse(media_type, input, &block) - return result.arguments if result.valid? + + if result.valid? + return result.arguments + end raise ValidationError, result end diff --git a/lib/protocol/content/parameters/definition.rb b/lib/protocol/content/parameters/definition.rb index 3b6c76c..4b8dcf2 100644 --- a/lib/protocol/content/parameters/definition.rb +++ b/lib/protocol/content/parameters/definition.rb @@ -40,12 +40,18 @@ def self.materialize(value) when Hash result = {} value.each do |key, item| - result[key.to_s] = materialize(item) unless item.equal?(OMITTED) + # Remove omitted uploads while preserving the surrounding hierarchy: + unless item.equal?(OMITTED) + result[key.to_s] = materialize(item) + end end return result when Array + # Remove omitted uploads while preserving accepted array values: return value.filter_map do |item| - materialize(item) unless item.equal?(OMITTED) + unless item.equal?(OMITTED) + materialize(item) + end end else return value @@ -70,6 +76,7 @@ def required? def apply(value, output, errors, path) value = Values.materialize(value) + # Reject nil unless the field is explicitly nullable: if value.nil? if @nullable output[@name] = nil @@ -80,6 +87,7 @@ def apply(value, output, errors, path) return end + # Treat input conversion failures as validation errors: output[@name] = Values.convert(@type, value) rescue ArgumentError, TypeError errors << Error.new(path, :invalid_type, expected: Values.expected_type(@type), value: value) @@ -109,6 +117,7 @@ def accepts_upload?(path) end def apply(value, output, errors, path) + # Only values produced by an accepted upload handler are valid: if value.is_a?(UploadedValue) output[@name] = value.value else @@ -125,7 +134,11 @@ def freeze class ArrayField def initialize(name, type, definition, required:, nullable:) @name = name - @type = Type.for(type) if type + + if type + @type = Type.for(type) + end + @definition = definition @required = required @nullable = nullable @@ -138,13 +151,22 @@ def required? end def accepts_upload?(path) - return false unless @definition + # Uploads in arrays must target a declared field on an anonymous element: + unless @definition + return false + end + index, *remaining = path - return false unless index&.empty? + + unless index&.empty? + return false + end + return @definition.accepts_upload?(remaining) end def apply(value, output, errors, path) + # Validate the array itself before processing its elements: if value.nil? if @nullable output[@name] = nil @@ -165,9 +187,16 @@ def apply(value, output, errors, path) value.each_with_index do |item, index| item_path = path + [index] + # Ignore uploads which were not accepted by the declaration: + if item.equal?(OMITTED) + next + end + + # Nested arrays validate each element as its own argument hierarchy: if @definition result << @definition.apply(item, errors, item_path) elsif @type + # Typed arrays reject nil rather than passing it to coercion: if item.nil? errors << Error.new(item_path, :invalid_type, expected: Values.expected_type(@type), value: item) next @@ -190,7 +219,11 @@ def apply(value, output, errors, path) def freeze @name.freeze - @definition&.freeze + + if @definition + @definition.freeze + end + super end @@ -211,11 +244,15 @@ def required? end def accepts_upload?(path) - return false unless @definition + unless @definition + return false + end + return @definition.accepts_upload?(path) end def apply(value, output, errors, path) + # Nested declarations require a key/value hierarchy: if value.nil? if @nullable output[@name] = nil @@ -240,7 +277,11 @@ def apply(value, output, errors, path) def freeze @name.freeze - @definition&.freeze + + if @definition + @definition.freeze + end + super end end @@ -267,6 +308,7 @@ def array(name, type = nil, required: false, nullable: false, strict: @strict, & name = name.to_s if block + # A block defines the element shape and cannot be combined with conversion: if type raise ArgumentError, "An array cannot declare both an element type and nested fields!" end @@ -290,15 +332,18 @@ def nested(name, required: false, nullable: false, strict: @strict, &block) end def apply(value, errors, path = []) + # Parameter declarations always apply to a key/value hierarchy: unless value.is_a?(Hash) errors << Error.new(path, :invalid_type, expected: Hash, value: value) return {} end + # Normalize keys before matching them against declarations: input = {} value.each{|key, item| input[key.to_s] = item} output = {} + # Apply declared values and collect missing required parameters: @declarations.each do |name, declaration| item_path = path + [name] @@ -306,7 +351,9 @@ def apply(value, errors, path = []) item = input.delete(name) if item.equal?(OMITTED) - errors << Error.new(item_path, :required) if declaration.required? + if declaration.required? + errors << Error.new(item_path, :required) + end else declaration.apply(item, output, errors, item_path) end @@ -315,9 +362,13 @@ def apply(value, errors, path = []) end end + # Reject remaining undeclared values when strict validation is enabled: input.each do |name, item| if item.equal?(OMITTED) - errors << Error.new(path + [name], :unknown) if @strict + if @strict + errors << Error.new(path + [name], :unknown) + end + next end @@ -330,10 +381,17 @@ def apply(value, errors, path = []) end def accepts_upload?(path) + # Walk declarations using the decoded components of the form name: name, *remaining = path - return false unless name - return false unless declaration = @declarations[name] - return false unless declaration.respond_to?(:accepts_upload?) + + unless declaration = @declarations[name] + return false + end + + unless declaration.respond_to?(:accepts_upload?) + return false + end + return declaration.accepts_upload?(remaining) end @@ -346,6 +404,7 @@ def freeze private def add(declaration) + # Reject ambiguous declarations for the same input name: if @declarations.key?(declaration.name) raise ArgumentError, "Parameter #{declaration.name.inspect} is already declared!" end diff --git a/lib/protocol/content/parameters/type.rb b/lib/protocol/content/parameters/type.rb index e6c9c2c..b7e9d51 100644 --- a/lib/protocol/content/parameters/type.rb +++ b/lib/protocol/content/parameters/type.rb @@ -22,7 +22,11 @@ def self.register(type, &converter) # @parameter type [Object] The declared type or converter. # @returns [Type | Object] A value responding to `#call`. def self.for(type) - return type if type.respond_to?(:call) + # Preserve custom converters without wrapping them: + if type.respond_to?(:call) + return type + end + return @types.fetch(type){new(type)} end @@ -42,11 +46,18 @@ def initialize(type, &converter) # @returns [Object] The converted value. # @raises [TypeError] If the value cannot be converted. def convert(value) - return value if @type === value + # Preserve values which already have the expected type: + if @type === value + return value + end if @converter value = @converter.call(value) - return value if @type === value + + # Ensure converters produce the type they declare: + if @type === value + return value + end end raise TypeError, "Could not convert #{value.inspect} to #{@type}!" @@ -58,7 +69,11 @@ def convert(value) end Type.register(Integer) do |value| - raise TypeError unless value.is_a?(String) + # Reject non-string values rather than relying on implicit numeric coercion: + unless value.is_a?(String) + raise TypeError + end + Integer(value, 10) end diff --git a/test/protocol/content/parameters.rb b/test/protocol/content/parameters.rb index 2e8cc69..61ccc13 100644 --- a/test/protocol/content/parameters.rb +++ b/test/protocol/content/parameters.rb @@ -249,6 +249,14 @@ def multipart_body(*parts) end end + it "returns valid arguments from parse!" do + parameters = subject.build do + field "name", String + end + + expect(parameters.parse!("application/json", StringIO.new('{"name":"Samuel"}'))).to be == {"name" => "Samuel"} + end + it "supports custom converters" do converter = ->(value){value.upcase} @@ -272,6 +280,17 @@ def multipart_body(*parts) expect(result.errors.map(&:code)).to be == [:invalid_type] end + it "rejects implicit integer conversion" do + parameters = subject.build do + field "age", Integer + end + + result = parse_json(parameters, '{"age":true}') + + expect(result.arguments).to be == {} + expect(result.errors.map(&:code)).to be == [:invalid_type] + end + it "reports a non-object content value" do parameters = subject.build do field "name", String @@ -438,6 +457,62 @@ def multipart_body(*parts) expect(invalid.errors.map(&:code)).to be == [:invalid_type] end + it "requires handled uploads" do + parameters = subject.build do + upload "avatar", required: true + end + body = multipart_body([ + { + "Content-Disposition" => 'form-data; name="avatar"; filename="avatar.txt"', + "Content-Type" => "text/plain" + }, + "avatar" + ]) + media_type = "multipart/form-data; boundary=#{BOUNDARY}" + + result = parameters.parse(media_type, StringIO.new(body)) + + expect(result.arguments).to be == {} + expect(result.errors.map(&:code)).to be == [:required] + end + + it "rejects uploads targeting non-upload declarations" do + parameters = subject.build do + field "title", String + nested "metadata" + array "attachments" + array "users" do + upload "avatar" + end + end + body = multipart_body( + [{ + "Content-Disposition" => 'form-data; name="title"; filename="title.txt"', + "Content-Type" => "text/plain" + }, "title"], + [{ + "Content-Disposition" => 'form-data; name="metadata[avatar]"; filename="avatar.txt"', + "Content-Type" => "text/plain" + }, "avatar"], + [{ + "Content-Disposition" => 'form-data; name="attachments[]"; filename="attachment.txt"', + "Content-Type" => "text/plain" + }, "attachment"], + [{ + "Content-Disposition" => 'form-data; name="users[avatar]"; filename="avatar.txt"', + "Content-Type" => "text/plain" + }, "avatar"] + ) + media_type = "multipart/form-data; boundary=#{BOUNDARY}" + + result = parameters.parse(media_type, StringIO.new(body)) do + raise "The handler should not be called!" + end + + expect(result.arguments).to be == {"metadata" => {}, "attachments" => []} + expect(result.errors.map(&:path)).to be == [["users"]] + end + it "discards and omits declared uploads without a handler" do parameters = subject.build(strict: true) do field "name", String From 89e0bdacf75b9ba94f47a45e56470b8b5ed7004c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 5 Aug 2026 22:11:44 +1200 Subject: [PATCH 07/16] Require exact string values. --- guides/parameters/readme.md | 2 +- lib/protocol/content/parameters/type.rb | 4 ---- test/protocol/content/parameters.rb | 8 +++++--- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/guides/parameters/readme.md b/guides/parameters/readme.md index 0e65ba5..838e114 100644 --- a/guides/parameters/readme.md +++ b/guides/parameters/readme.md @@ -81,7 +81,7 @@ user.update(arguments["user"]) ## Convert Fields -Built-in converters support `String`, `Integer`, and `Float`. A custom converter can be supplied as any object responding to `#call`: +Built-in types match `String` values exactly and convert compatible values to `Integer` and `Float`. A custom converter can be supplied as any object responding to `#call`: ``` ruby require "date" diff --git a/lib/protocol/content/parameters/type.rb b/lib/protocol/content/parameters/type.rb index b7e9d51..af47dfb 100644 --- a/lib/protocol/content/parameters/type.rb +++ b/lib/protocol/content/parameters/type.rb @@ -64,10 +64,6 @@ def convert(value) end end - Type.register(String) do |value| - String(value) - end - Type.register(Integer) do |value| # Reject non-string values rather than relying on implicit numeric coercion: unless value.is_a?(String) diff --git a/test/protocol/content/parameters.rb b/test/protocol/content/parameters.rb index 61ccc13..346a4fd 100644 --- a/test/protocol/content/parameters.rb +++ b/test/protocol/content/parameters.rb @@ -72,7 +72,7 @@ def multipart_body(*parts) expect(result.errors.map(&:path)).to be == [["required"]] end - it "converts string and floating point fields" do + it "matches string fields and converts floating point fields" do parameters = subject.build do field "name", String field "ratio", Float @@ -80,7 +80,8 @@ def multipart_body(*parts) result = parse_json(parameters, '{"name":123,"ratio":"1.5"}') - expect(result.arguments).to be == {"name" => "123", "ratio" => 1.5} + expect(result.arguments).to be == {"ratio" => 1.5} + expect(result.errors.map(&:path)).to be == [["name"]] end it "rejects values without a type conversion" do @@ -168,9 +169,10 @@ def multipart_body(*parts) result = parse_json(parameters, '{"tags":["one",2],"metadata":[{"enabled":true},[1,2]]}') expect(result.arguments).to be == { - "tags" => ["one", "2"], + "tags" => ["one"], "metadata" => [{"enabled" => true}, [1, 2]], } + expect(result.errors.map(&:path)).to be == [["tags", 1]] end it "validates nested array values" do From 7547b13416992a69f65e8ad24e5c96ce666ee4c7 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 5 Aug 2026 22:24:52 +1200 Subject: [PATCH 08/16] Support upload collections. --- guides/parameters/readme.md | 4 +- lib/protocol/content/parameters.rb | 8 ++ lib/protocol/content/parameters/definition.rb | 59 +++++++++++++- test/protocol/content/parameters.rb | 78 +++++++++++++++++++ 4 files changed, 147 insertions(+), 2 deletions(-) diff --git a/guides/parameters/readme.md b/guides/parameters/readme.md index 838e114..b91a0fb 100644 --- a/guides/parameters/readme.md +++ b/guides/parameters/readme.md @@ -104,6 +104,8 @@ parameters = Protocol::Content::Parameters.build do nested "user" do upload "avatar", required: true end + + uploads "pictures" end ``` @@ -121,6 +123,6 @@ result = parameters.parse(media_type, input) do |name, upload| end ``` -For an upload named `user[avatar]`, the stored object is available as `result.arguments["user"]["avatar"]`. Without an upload handler, uploads are consumed and omitted from the resulting arguments. +For an upload named `user[avatar]`, the stored object is available as `result.arguments["user"]["avatar"]`. An `uploads "pictures"` declaration accepts `pictures[]` and collects each handler result in `result.arguments["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 17ad7b4..eb824b6 100644 --- a/lib/protocol/content/parameters.rb +++ b/lib/protocol/content/parameters.rb @@ -51,6 +51,14 @@ def upload(name, required: false) return @definition.upload(name, required:) end + # Declare a collection of streaming file uploads. + # @parameter name [String] The upload collection field name. + # @parameter required [Boolean] Whether at least one handled upload must be present. + # @returns [Object] The upload collection declaration. + def uploads(name, required: false) + return @definition.uploads(name, required:) + end + # Declare an array of scalar values or nested argument hierarchies. # @parameter name [String] The array field name. # @parameter type [Module | #call | Nil] The expected element type or converter. diff --git a/lib/protocol/content/parameters/definition.rb b/lib/protocol/content/parameters/definition.rb index 4b8dcf2..ae24034 100644 --- a/lib/protocol/content/parameters/definition.rb +++ b/lib/protocol/content/parameters/definition.rb @@ -131,6 +131,58 @@ def freeze end end + class Uploads + def initialize(name, required:) + @name = name + @required = required + end + + attr :name + + def required? + return @required + end + + def accepts_upload?(path) + # Upload collections require anonymous array notation: + return path == [""] + end + + def apply(value, output, errors, path) + # Upload collections must be represented as arrays by the content parser: + unless value.is_a?(Array) + errors << Error.new(path, :invalid_type, expected: Array, value: Values.materialize(value)) + return + end + + result = [] + + value.each_with_index do |item, index| + case item + when UploadedValue + result << item.value + when OMITTED + # Unhandled uploads are consumed by the parser and omitted here: + next + else + errors << Error.new(path + [index], :invalid_type, expected: :upload, value: Values.materialize(item)) + end + end + + # Required collections need at least one successfully handled upload: + if @required && result.empty? + errors << Error.new(path, :required) + end + + output[@name] = result + end + + def freeze + @name.freeze + super + end + end + class ArrayField def initialize(name, type, definition, required:, nullable:) @name = name @@ -304,6 +356,11 @@ def upload(name, required: false) return add(Upload.new(name, required:)) end + def uploads(name, required: false) + name = name.to_s + return add(Uploads.new(name, required:)) + end + def array(name, type = nil, required: false, nullable: false, strict: @strict, &block) name = name.to_s @@ -414,7 +471,7 @@ def add(declaration) end end - private_constant :OMITTED, :UploadedValue, :Values, :Type, :Field, :Upload, :ArrayField, :Nested, :Definition + private_constant :OMITTED, :UploadedValue, :Values, :Type, :Field, :Upload, :Uploads, :ArrayField, :Nested, :Definition end end end diff --git a/test/protocol/content/parameters.rb b/test/protocol/content/parameters.rb index 346a4fd..1c2f3e8 100644 --- a/test/protocol/content/parameters.rb +++ b/test/protocol/content/parameters.rb @@ -379,6 +379,53 @@ def multipart_body(*parts) } end + it "collects handled upload arrays" do + parameters = subject.build do + uploads "pictures" + end + body = multipart_body( + [{ + "Content-Disposition" => 'form-data; name="pictures[]"; filename="one.txt"', + "Content-Type" => "text/plain" + }, "one"], + [{ + "Content-Disposition" => 'form-data; name="pictures[]"; filename="two.txt"', + "Content-Type" => "text/plain" + }, "two"] + ) + media_type = "multipart/form-data; boundary=#{BOUNDARY}" + + result = parameters.parse(media_type, StringIO.new(body)) do |_name, upload| + {filename: upload.filename, content: upload.each.to_a.join} + end + + expect(result.arguments).to be == { + "pictures" => [ + {filename: "one.txt", content: "one"}, + {filename: "two.txt", content: "two"}, + ], + } + end + + it "supports nested upload arrays" do + parameters = subject.build do + nested "gallery" do + uploads "pictures" + end + end + body = multipart_body([{ + "Content-Disposition" => 'form-data; name="gallery[pictures][]"; filename="picture.txt"', + "Content-Type" => "text/plain" + }, "picture"]) + 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.arguments).to be == {"gallery" => {"pictures" => ["picture"]}} + end + it "preserves nil returned by the upload handler" do parameters = subject.build do upload "avatar" @@ -478,6 +525,37 @@ def multipart_body(*parts) expect(result.errors.map(&:code)).to be == [:required] end + it "requires at least one handled upload in a collection" do + parameters = subject.build do + uploads "pictures", required: true + end + body = multipart_body([{ + "Content-Disposition" => 'form-data; name="pictures[]"; filename="picture.txt"', + "Content-Type" => "text/plain" + }, "picture"]) + media_type = "multipart/form-data; boundary=#{BOUNDARY}" + + result = parameters.parse(media_type, StringIO.new(body)) + + expect(result.arguments).to be == {"pictures" => []} + expect(result.errors.map(&:code)).to be == [:required] + end + + it "rejects regular values in upload collections" do + parameters = subject.build do + uploads "pictures", required: true + end + + missing = parse_json(parameters, "{}") + invalid_shape = parse_json(parameters, '{"pictures":"picture"}') + invalid_item = parse_json(parameters, '{"pictures":["picture"]}') + + expect(missing.errors.map(&:code)).to be == [:required] + expect(invalid_shape.errors.map(&:path)).to be == [["pictures"]] + expect(invalid_item.arguments).to be == {"pictures" => []} + expect(invalid_item.errors.map(&:path)).to be == [["pictures", 0], ["pictures"]] + end + it "rejects uploads targeting non-upload declarations" do parameters = subject.build do field "title", String From f669f4947c9ecb9e826d15392f063af9a586b1aa Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 5 Aug 2026 22:40:03 +1200 Subject: [PATCH 09/16] Refine parameter model construction. --- guides/parameters/readme.md | 18 +- lib/protocol/content/parameters.rb | 143 +++----------- lib/protocol/content/parameters/builder.rb | 132 +++++++++++++ .../{definition.rb => declarations.rb} | 179 ++---------------- lib/protocol/content/parameters/model.rb | 149 +++++++++++++++ lib/protocol/content/parameters/result.rb | 2 +- lib/protocol/content/parameters/type.rb | 37 +--- test/protocol/content/parameters.rb | 18 +- 8 files changed, 357 insertions(+), 321 deletions(-) create mode 100644 lib/protocol/content/parameters/builder.rb rename lib/protocol/content/parameters/{definition.rb => declarations.rb} (60%) create mode 100644 lib/protocol/content/parameters/model.rb diff --git a/guides/parameters/readme.md b/guides/parameters/readme.md index b91a0fb..7364de0 100644 --- a/guides/parameters/readme.md +++ b/guides/parameters/readme.md @@ -1,6 +1,6 @@ # Content Parameters -This guide explains how to interpret parsed content as operation-specific arguments using {ruby Protocol::Content::Parameters}. +This guide explains how to build a parameter model that interprets parsed content as operation-specific arguments using {ruby Protocol::Content::Parameters}. ## Declare Parameters @@ -56,7 +56,7 @@ Validation errors for array elements include the element index in their path. ## Parse Parameters -{ruby Protocol::Content::Parameters#parse} selects a content parser according to the media type, then filters, converts, and validates the parsed value: +{ruby Protocol::Content::Parameters::Model#parse} selects a content parser according to the media type, then filters, converts, and validates the parsed value: ``` ruby result = parameters.parse(media_type, input) @@ -72,7 +72,7 @@ end Validation errors are collected so an application can present all failures together. Each {ruby Protocol::Content::Parameters::Error} exposes a normalized `path`, machine-readable `code`, and additional `details`. -Use {ruby Protocol::Content::Parameters#parse!} when invalid parameters should interrupt the operation. It returns the filtered argument hash or raises {ruby Protocol::Content::Parameters::ValidationError}, which retains the complete result: +Use {ruby Protocol::Content::Parameters::Model#parse!} when invalid parameters should interrupt the operation. It returns the filtered argument hash or raises {ruby Protocol::Content::Parameters::ValidationError}, which retains the complete result: ``` ruby arguments = parameters.parse!(media_type, input) @@ -95,6 +95,18 @@ end A converter should return the converted value or raise `ArgumentError` or `TypeError`. Conversion failures are included in the result as `invalid_type` errors. +Reusable type conversions can be supplied to the builder. Converted values must match the declared type: + +``` ruby +types = Protocol::Content::Parameters::TYPES.merge( + Date => ->(value){Date.iso8601(value)} +) + +parameters = Protocol::Content::Parameters.build(types:) do + field "date", Date +end +``` + ## Handle Uploads Uploads must be declared explicitly. Undeclared uploads are consumed and omitted without invoking the upload handler: diff --git a/lib/protocol/content/parameters.rb b/lib/protocol/content/parameters.rb index eb824b6..ca8832f 100644 --- a/lib/protocol/content/parameters.rb +++ b/lib/protocol/content/parameters.rb @@ -4,132 +4,37 @@ # Copyright, 2026, by Samuel Williams. require_relative "default" -require_relative "parameters/definition" +require_relative "parameters/type" require_relative "parameters/result" - -require "protocol/multipart/form_data" -require "protocol/url/encoding" +require_relative "parameters/declarations" +require_relative "parameters/model" +require_relative "parameters/builder" module Protocol module Content - # Parses content into a filtered and validated argument hierarchy. - class Parameters - # Build and freeze a parameter definition. - # @parameter parser [Parser] The content parser. - # @parameter strict [Boolean] Whether unknown fields should produce validation errors. - # @yields The parameter declarations. - # @returns [Parameters] The frozen parameter definition. - def self.build(parser: Parser.default, strict: false, &block) - parameters = new(parser:, strict:) - parameters.instance_eval(&block) - return parameters.freeze - end + # Builds parameter models for filtering, conversion, and validation. + module Parameters + # The built-in parameter type conversions. + TYPES = { + Integer => ->(value) do + # Reject non-string values rather than relying on implicit numeric coercion: + unless value.is_a?(String) + raise TypeError + end + + Integer(value, 10) + end, + Float => ->(value){Float(value)}, + }.freeze - # Initialize a mutable parameter definition. + # Build an immutable parameter model. # @parameter parser [Parser] The content parser. + # @parameter types [Hash] The available type conversions. # @parameter strict [Boolean] Whether unknown fields should produce validation errors. - def initialize(parser: Parser.default, strict: false) - @parser = parser - @definition = Definition.new(strict:) - end - - # Declare a scalar field. - # @parameter name [String] The field name. - # @parameter type [Module | #call] The expected value type or converter. - # @parameter required [Boolean] Whether the field must be present. - # @parameter nullable [Boolean] Whether the field may be nil. - # @returns [Object] The field declaration. - def field(name, type = Object, required: false, nullable: false) - return @definition.field(name, type, required:, nullable:) - end - - # Declare a streaming file upload. - # @parameter name [String] The upload field name. - # @parameter required [Boolean] Whether the upload must be present. - # @returns [Object] The upload declaration. - def upload(name, required: false) - return @definition.upload(name, required:) - end - - # Declare a collection of streaming file uploads. - # @parameter name [String] The upload collection field name. - # @parameter required [Boolean] Whether at least one handled upload must be present. - # @returns [Object] The upload collection declaration. - def uploads(name, required: false) - return @definition.uploads(name, required:) - end - - # Declare an array of scalar values or nested argument hierarchies. - # @parameter name [String] The array field name. - # @parameter type [Module | #call | Nil] The expected element type or converter. - # @parameter required [Boolean] Whether the array must be present. - # @parameter nullable [Boolean] Whether the array may be nil. - # @parameter strict [Boolean] Whether unknown nested fields should produce validation errors. - # @yields The nested parameter declarations for each array element. - # @returns [Object] The array declaration. - def array(name, type = nil, required: false, nullable: false, strict: @definition.strict, &block) - return @definition.array(name, type, required:, nullable:, strict:, &block) - end - - # Declare a nested argument hierarchy. Without a block, all nested values are accepted. - # @parameter name [String] The nested field name. - # @parameter required [Boolean] Whether the field must be present. - # @parameter nullable [Boolean] Whether the field may be nil. - # @parameter strict [Boolean] Whether unknown nested fields should produce validation errors. - # @yields The nested parameter declarations. - # @returns [Object] The nested declaration. - def nested(name, required: false, nullable: false, strict: @definition.strict, &block) - return @definition.nested(name, required:, nullable:, strict:, &block) - end - - # Parse, filter, and validate content parameters. - # @parameter media_type [String | Protocol::Media::Type | Nil] The content media type. - # @parameter input [Object] The readable content input. - # @yields {|name, upload| ...} Each streaming upload. Its return value is inserted into the arguments. - # @returns [Result] The parsed arguments and validation errors. - def parse(media_type, input, &upload_handler) - arguments = @parser.parse(media_type, input) do |name, value| - if value.is_a?(Protocol::Multipart::FormData::Upload) - path = Protocol::URL::Encoding.split(name) - - # Only process uploads accepted by an explicit declaration: - if upload_handler && @definition.accepts_upload?(path) - UploadedValue.new(upload_handler.call(name, value)) - else - OMITTED - end - else - value - end - end - - errors = [] - arguments = @definition.apply(arguments, errors) - return Result.new(arguments, errors) - end - - # Parse content parameters, raising when validation fails. - # @parameter media_type [String | Protocol::Media::Type | Nil] The content media type. - # @parameter input [Object] The readable content input. - # @yields {|name, upload| ...} Each streaming upload. Its return value is inserted into the arguments. - # @returns [Hash] The valid arguments. - # @raises [ValidationError] If validation fails. - def parse!(media_type, input, &block) - result = parse(media_type, input, &block) - - if result.valid? - return result.arguments - end - - raise ValidationError, result - end - - # Freeze this parameter definition. - # @returns [self] The frozen parameter definition. - def freeze - @parser.freeze - @definition.freeze - super + # @yields The parameter declarations. + # @returns [Model] The frozen parameter model. + def self.build(parser: Parser.default, types: TYPES, strict: false, &block) + return Builder.new(parser:, types:, strict:).build(&block) end end end diff --git a/lib/protocol/content/parameters/builder.rb b/lib/protocol/content/parameters/builder.rb new file mode 100644 index 0000000..b624098 --- /dev/null +++ b/lib/protocol/content/parameters/builder.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Protocol + module Content + module Parameters + # Builds immutable parameter models using a declaration DSL. + class Builder + # Initialize a parameter model builder. + # @parameter parser [Parser] The content parser. + # @parameter types [Hash] The available type conversions. + # @parameter strict [Boolean] Whether unknown fields should produce validation errors. + def initialize(parser: Parser.default, types: TYPES, strict: false) + @parser = parser + @types = types + @strict = strict + @declarations = {} + end + + # Evaluate declarations and construct an immutable parameter model. + # @yields The parameter declarations. + # @returns [Model] The frozen parameter model. + def build(&block) + instance_eval(&block) + return Model.new(@parser, @declarations, strict: @strict).freeze + end + + # Declare a scalar field. + # @parameter name [String] The field name. + # @parameter type [Module | #call] The expected value type or converter. + # @parameter required [Boolean] Whether the field must be present. + # @parameter nullable [Boolean] Whether the field may be nil. + # @returns [Object] The field declaration. + def field(name, type = Object, required: false, nullable: false) + name = name.to_s + return add(Field.new(name, resolve(type), required:, nullable:)) + end + + # Declare a streaming file upload. + # @parameter name [String] The upload field name. + # @parameter required [Boolean] Whether the upload must be present. + # @returns [Object] The upload declaration. + def upload(name, required: false) + name = name.to_s + return add(Upload.new(name, required:)) + end + + # Declare a collection of streaming file uploads. + # @parameter name [String] The upload collection field name. + # @parameter required [Boolean] Whether at least one handled upload must be present. + # @returns [Object] The upload collection declaration. + def uploads(name, required: false) + name = name.to_s + return add(Uploads.new(name, required:)) + end + + # Declare an array of scalar values or nested argument hierarchies. + # @parameter name [String] The array field name. + # @parameter type [Module | #call | Nil] The expected element type or converter. + # @parameter required [Boolean] Whether the array must be present. + # @parameter nullable [Boolean] Whether the array may be nil. + # @parameter strict [Boolean] Whether unknown nested fields should produce validation errors. + # @yields The nested parameter declarations for each array element. + # @returns [Object] The array declaration. + def array(name, type = nil, required: false, nullable: false, strict: @strict, &block) + name = name.to_s + + if block + # A block defines the element shape and cannot be combined with conversion: + if type + raise ArgumentError, "An array cannot declare both an element type and nested fields!" + end + + model = nested_model(strict:, &block) + elsif type + type = resolve(type) + end + + return add(ArrayField.new(name, type, model, required:, nullable:)) + end + + # Declare a nested argument hierarchy. Without a block, all nested values are accepted. + # @parameter name [String] The nested field name. + # @parameter required [Boolean] Whether the field must be present. + # @parameter nullable [Boolean] Whether the field may be nil. + # @parameter strict [Boolean] Whether unknown nested fields should produce validation errors. + # @yields The nested parameter declarations. + # @returns [Object] The nested declaration. + def nested(name, required: false, nullable: false, strict: @strict, &block) + name = name.to_s + + if block + model = nested_model(strict:, &block) + end + + return add(Nested.new(name, model, required:, nullable:)) + end + + private + + def resolve(type) + # Preserve custom converters without wrapping them: + if type.respond_to?(:call) + return type + end + + if converter = @types[type] + return Type.new(type, &converter) + end + + return Type.new(type) + end + + def nested_model(strict:, &block) + return self.class.new(parser: @parser, types: @types, strict:).build(&block) + end + + def add(declaration) + # Reject ambiguous declarations for the same input name: + if @declarations.key?(declaration.name) + raise ArgumentError, "Parameter #{declaration.name.inspect} is already declared!" + end + + @declarations[declaration.name] = declaration + return declaration + end + end + end + end +end diff --git a/lib/protocol/content/parameters/definition.rb b/lib/protocol/content/parameters/declarations.rb similarity index 60% rename from lib/protocol/content/parameters/definition.rb rename to lib/protocol/content/parameters/declarations.rb index ae24034..bdbe820 100644 --- a/lib/protocol/content/parameters/definition.rb +++ b/lib/protocol/content/parameters/declarations.rb @@ -3,11 +3,9 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -require_relative "type" - module Protocol module Content - class Parameters + module Parameters OMITTED = Object.new.freeze class UploadedValue @@ -62,7 +60,7 @@ def self.materialize(value) class Field def initialize(name, type, required:, nullable:) @name = name - @type = Type.for(type) + @type = type @required = required @nullable = nullable end @@ -184,14 +182,10 @@ def freeze end class ArrayField - def initialize(name, type, definition, required:, nullable:) + def initialize(name, type, model, required:, nullable:) @name = name - - if type - @type = Type.for(type) - end - - @definition = definition + @type = type + @model = model @required = required @nullable = nullable end @@ -204,7 +198,7 @@ def required? def accepts_upload?(path) # Uploads in arrays must target a declared field on an anonymous element: - unless @definition + unless @model return false end @@ -214,7 +208,7 @@ def accepts_upload?(path) return false end - return @definition.accepts_upload?(remaining) + return @model.accepts_upload?(remaining) end def apply(value, output, errors, path) @@ -245,8 +239,8 @@ def apply(value, output, errors, path) end # Nested arrays validate each element as its own argument hierarchy: - if @definition - result << @definition.apply(item, errors, item_path) + if @model + result << @model.apply(item, errors, item_path) elsif @type # Typed arrays reject nil rather than passing it to coercion: if item.nil? @@ -272,8 +266,8 @@ def apply(value, output, errors, path) def freeze @name.freeze - if @definition - @definition.freeze + if @model + @model.freeze end super @@ -282,9 +276,9 @@ def freeze end class Nested - def initialize(name, definition, required:, nullable:) + def initialize(name, model, required:, nullable:) @name = name - @definition = definition + @model = model @required = required @nullable = nullable end @@ -296,11 +290,11 @@ def required? end def accepts_upload?(path) - unless @definition + unless @model return false end - return @definition.accepts_upload?(path) + return @model.accepts_upload?(path) end def apply(value, output, errors, path) @@ -320,8 +314,8 @@ def apply(value, output, errors, path) return end - if @definition - output[@name] = @definition.apply(value, errors, path) + if @model + output[@name] = @model.apply(value, errors, path) else output[@name] = Values.materialize(value) end @@ -330,148 +324,15 @@ def apply(value, output, errors, path) def freeze @name.freeze - if @definition - @definition.freeze + if @model + @model.freeze end super end end - class Definition - def initialize(strict: false) - @strict = strict - @declarations = {} - end - - attr :strict - - def field(name, type = Object, required: false, nullable: false) - name = name.to_s - return add(Field.new(name, type, required:, nullable:)) - end - - def upload(name, required: false) - name = name.to_s - return add(Upload.new(name, required:)) - end - - def uploads(name, required: false) - name = name.to_s - return add(Uploads.new(name, required:)) - end - - def array(name, type = nil, required: false, nullable: false, strict: @strict, &block) - name = name.to_s - - if block - # A block defines the element shape and cannot be combined with conversion: - if type - raise ArgumentError, "An array cannot declare both an element type and nested fields!" - end - - definition = Definition.new(strict:) - definition.instance_eval(&block) - end - - return add(ArrayField.new(name, type, definition, required:, nullable:)) - end - - def nested(name, required: false, nullable: false, strict: @strict, &block) - name = name.to_s - - if block - definition = Definition.new(strict:) - definition.instance_eval(&block) - end - - return add(Nested.new(name, definition, required:, nullable:)) - end - - def apply(value, errors, path = []) - # Parameter declarations always apply to a key/value hierarchy: - unless value.is_a?(Hash) - errors << Error.new(path, :invalid_type, expected: Hash, value: value) - return {} - end - - # Normalize keys before matching them against declarations: - input = {} - value.each{|key, item| input[key.to_s] = item} - output = {} - - # Apply declared values and collect missing required parameters: - @declarations.each do |name, declaration| - item_path = path + [name] - - if input.key?(name) - item = input.delete(name) - - if item.equal?(OMITTED) - if declaration.required? - errors << Error.new(item_path, :required) - end - else - declaration.apply(item, output, errors, item_path) - end - elsif declaration.required? - errors << Error.new(item_path, :required) - end - end - - # Reject remaining undeclared values when strict validation is enabled: - input.each do |name, item| - if item.equal?(OMITTED) - if @strict - errors << Error.new(path + [name], :unknown) - end - - next - end - - if @strict - errors << Error.new(path + [name], :unknown) - end - end - - return output - end - - def accepts_upload?(path) - # Walk declarations using the decoded components of the form name: - name, *remaining = path - - unless declaration = @declarations[name] - return false - end - - unless declaration.respond_to?(:accepts_upload?) - return false - end - - return declaration.accepts_upload?(remaining) - end - - def freeze - @declarations.each_value(&:freeze) - @declarations.freeze - super - end - - private - - def add(declaration) - # Reject ambiguous declarations for the same input name: - if @declarations.key?(declaration.name) - raise ArgumentError, "Parameter #{declaration.name.inspect} is already declared!" - end - - @declarations[declaration.name] = declaration - return declaration - end - end - - private_constant :OMITTED, :UploadedValue, :Values, :Type, :Field, :Upload, :Uploads, :ArrayField, :Nested, :Definition + private_constant :OMITTED, :UploadedValue, :Values, :Field, :Upload, :Uploads, :ArrayField, :Nested end end end diff --git a/lib/protocol/content/parameters/model.rb b/lib/protocol/content/parameters/model.rb new file mode 100644 index 0000000..6024423 --- /dev/null +++ b/lib/protocol/content/parameters/model.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "protocol/multipart/form_data" +require "protocol/url/encoding" + +module Protocol + module Content + module Parameters + # An immutable model for parsing, filtering, and validating parameters. + class Model + # Initialize a parameter model. + # @parameter parser [Parser] The content parser. + # @parameter declarations [Hash] The parameter declarations. + # @parameter strict [Boolean] Whether unknown fields should produce validation errors. + def initialize(parser, declarations, strict: false) + @parser = parser + @declarations = declarations + @strict = strict + end + + # Parse, filter, and validate content parameters. + # @parameter media_type [String | Protocol::Media::Type | Nil] The content media type. + # @parameter input [Object] The readable content input. + # @yields {|name, upload| ...} Each streaming upload. Its return value is inserted into the arguments. + # @returns [Result] The parsed arguments and validation errors. + def parse(media_type, input, &upload_handler) + arguments = @parser.parse(media_type, input) do |name, value| + if value.is_a?(Protocol::Multipart::FormData::Upload) + path = Protocol::URL::Encoding.split(name) + + # Only process uploads accepted by an explicit declaration: + if upload_handler && accepts_upload?(path) + UploadedValue.new(upload_handler.call(name, value)) + else + OMITTED + end + else + value + end + end + + errors = [] + arguments = apply(arguments, errors) + return Result.new(arguments, errors) + end + + # Parse content parameters, raising when validation fails. + # @parameter media_type [String | Protocol::Media::Type | Nil] The content media type. + # @parameter input [Object] The readable content input. + # @yields {|name, upload| ...} Each streaming upload. Its return value is inserted into the arguments. + # @returns [Hash] The valid arguments. + # @raises [ValidationError] If validation fails. + def parse!(media_type, input, &block) + result = parse(media_type, input, &block) + + if result.valid? + return result.arguments + end + + raise ValidationError, result + end + + # Apply this model to an existing argument hierarchy. + # @parameter value [Object] The argument hierarchy. + # @parameter errors [Array(Error)] The validation error destination. + # @parameter path [Array(String | Integer)] The current argument path. + # @returns [Hash] The filtered and converted arguments. + def apply(value, errors, path = []) + # Parameter declarations always apply to a key/value hierarchy: + unless value.is_a?(Hash) + errors << Error.new(path, :invalid_type, expected: Hash, value: value) + return {} + end + + # Normalize keys before matching them against declarations: + input = {} + value.each{|key, item| input[key.to_s] = item} + output = {} + + # Apply declared values and collect missing required parameters: + @declarations.each do |name, declaration| + item_path = path + [name] + + if input.key?(name) + item = input.delete(name) + + if item.equal?(OMITTED) + if declaration.required? + errors << Error.new(item_path, :required) + end + else + declaration.apply(item, output, errors, item_path) + end + elsif declaration.required? + errors << Error.new(item_path, :required) + end + end + + # Reject remaining undeclared values when strict validation is enabled: + input.each do |name, item| + if item.equal?(OMITTED) + if @strict + errors << Error.new(path + [name], :unknown) + end + + next + end + + if @strict + errors << Error.new(path + [name], :unknown) + end + end + + return output + end + + # Whether an upload path is explicitly accepted by this model. + # @parameter path [Array(String)] The decoded upload path. + # @returns [Boolean] Whether the upload is accepted. + def accepts_upload?(path) + # Walk declarations using the decoded components of the form name: + name, *remaining = path + + unless declaration = @declarations[name] + return false + end + + unless declaration.respond_to?(:accepts_upload?) + return false + end + + return declaration.accepts_upload?(remaining) + end + + # Freeze this model and its declarations. + # @returns [self] The frozen model. + def freeze + @parser.freeze + @declarations.each_value(&:freeze) + @declarations.freeze + super + end + end + end + end +end diff --git a/lib/protocol/content/parameters/result.rb b/lib/protocol/content/parameters/result.rb index 9c1aae5..cf727c8 100644 --- a/lib/protocol/content/parameters/result.rb +++ b/lib/protocol/content/parameters/result.rb @@ -7,7 +7,7 @@ module Protocol module Content - class Parameters + module Parameters # A validation error associated with a specific argument path. class Error # Initialize the validation error. diff --git a/lib/protocol/content/parameters/type.rb b/lib/protocol/content/parameters/type.rb index af47dfb..2756c30 100644 --- a/lib/protocol/content/parameters/type.rb +++ b/lib/protocol/content/parameters/type.rb @@ -5,31 +5,9 @@ module Protocol module Content - class Parameters + module Parameters # Converts input values to a specific application type. class Type - @types = {} - - # Register a converter for a type. - # @parameter type [Object] The declared type. - # @yields {|value| ...} The conversion operation. - # @returns [Type] The registered type converter. - def self.register(type, &converter) - return @types[type] = new(type, &converter) - end - - # Resolve a declared type to a converter. - # @parameter type [Object] The declared type or converter. - # @returns [Type | Object] A value responding to `#call`. - def self.for(type) - # Preserve custom converters without wrapping them: - if type.respond_to?(:call) - return type - end - - return @types.fetch(type){new(type)} - end - # Initialize a type converter. # @parameter type [Object] The expected converted type. # @yields {|value| ...} The conversion operation. @@ -64,18 +42,7 @@ def convert(value) end end - Type.register(Integer) do |value| - # Reject non-string values rather than relying on implicit numeric coercion: - unless value.is_a?(String) - raise TypeError - end - - Integer(value, 10) - end - - Type.register(Float) do |value| - Float(value) - end + private_constant :Type end end end diff --git a/test/protocol/content/parameters.rb b/test/protocol/content/parameters.rb index 1c2f3e8..6753216 100644 --- a/test/protocol/content/parameters.rb +++ b/test/protocol/content/parameters.rb @@ -23,15 +23,13 @@ def multipart_body(*parts) return (body.join + "--#{BOUNDARY}--\n").gsub("\n", "\r\n") end - it "builds immutable parameter definitions" do + it "builds immutable parameter models" do parameters = subject.build do field "name", String end + expect(parameters).to be_a(subject::Model) expect(parameters).to be(:frozen?) - expect do - parameters.field("age", Integer) - end.to raise_exception(FrozenError) end it "filters unknown fields and converts declared fields" do @@ -270,6 +268,18 @@ def multipart_body(*parts) expect(result.arguments).to be == {"code" => "ABC"} end + it "supports custom type mappings" do + type = Class.new + types = subject::TYPES.merge(type => ->(_value){type.new}) + + parameters = subject.build(types:) do + field "value", type + end + result = parse_json(parameters, '{"value":"custom"}') + + expect(result.arguments["value"]).to be_a(type) + end + it "collects custom converter failures" do converter = ->(_value){raise ArgumentError} parameters = subject.build do From 6152355786d741956572e17a6d2fa02999e85a93 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 5 Aug 2026 22:47:23 +1200 Subject: [PATCH 10/16] Expose parameter result values. --- guides/parameters/readme.md | 4 +- lib/protocol/content/parameters/model.rb | 26 +++++------ lib/protocol/content/parameters/result.rb | 24 ++++++++-- test/protocol/content/parameters.rb | 57 ++++++++++++----------- 4 files changed, 63 insertions(+), 48 deletions(-) diff --git a/guides/parameters/readme.md b/guides/parameters/readme.md index 7364de0..6fe4a09 100644 --- a/guides/parameters/readme.md +++ b/guides/parameters/readme.md @@ -62,7 +62,7 @@ Validation errors for array elements include the element index in their path. result = parameters.parse(media_type, input) if result.valid? - user.update(result.arguments["user"]) + user.update(result["user"]) else result.errors.each do |error| warn "#{error.path.join(".")}: #{error.code}" @@ -135,6 +135,6 @@ result = parameters.parse(media_type, input) do |name, upload| end ``` -For an upload named `user[avatar]`, the stored object is available as `result.arguments["user"]["avatar"]`. An `uploads "pictures"` declaration accepts `pictures[]` and collects each handler result in `result.arguments["pictures"]`. Without an upload handler, uploads are consumed and omitted from the resulting arguments. +For an upload named `user[avatar]`, the stored object is available as `result.dig("user", "avatar")`. An `uploads "pictures"` 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/model.rb b/lib/protocol/content/parameters/model.rb index 6024423..abad468 100644 --- a/lib/protocol/content/parameters/model.rb +++ b/lib/protocol/content/parameters/model.rb @@ -24,50 +24,50 @@ def initialize(parser, declarations, strict: false) # Parse, filter, and validate content parameters. # @parameter media_type [String | Protocol::Media::Type | Nil] The content media type. # @parameter input [Object] The readable content input. - # @yields {|name, upload| ...} Each streaming upload. Its return value is inserted into the arguments. - # @returns [Result] The parsed arguments and validation errors. + # @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) - arguments = @parser.parse(media_type, input) do |name, value| - if value.is_a?(Protocol::Multipart::FormData::Upload) + 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 declaration: if upload_handler && accepts_upload?(path) - UploadedValue.new(upload_handler.call(name, value)) + UploadedValue.new(upload_handler.call(name, item)) else OMITTED end else - value + item end end errors = [] - arguments = apply(arguments, errors) - return Result.new(arguments, errors) + value = apply(value, errors) + return Result.new(value, errors) end # Parse content parameters, raising when validation fails. # @parameter media_type [String | Protocol::Media::Type | Nil] The content media type. # @parameter input [Object] The readable content input. - # @yields {|name, upload| ...} Each streaming upload. Its return value is inserted into the arguments. - # @returns [Hash] The valid arguments. + # @yields {|name, upload| ...} Each streaming upload. Its return value is inserted into the parsed value. + # @returns [Hash] The valid value. # @raises [ValidationError] If validation fails. def parse!(media_type, input, &block) result = parse(media_type, input, &block) if result.valid? - return result.arguments + return result.value end raise ValidationError, result end # Apply this model to an existing argument hierarchy. - # @parameter value [Object] The argument hierarchy. + # @parameter value [Object] The parameter hierarchy. # @parameter errors [Array(Error)] The validation error destination. # @parameter path [Array(String | Integer)] The current argument path. - # @returns [Hash] The filtered and converted arguments. + # @returns [Hash] The filtered and converted value. def apply(value, errors, path = []) # Parameter declarations always apply to a key/value hierarchy: unless value.is_a?(Hash) diff --git a/lib/protocol/content/parameters/result.rb b/lib/protocol/content/parameters/result.rb index cf727c8..32d36b9 100644 --- a/lib/protocol/content/parameters/result.rb +++ b/lib/protocol/content/parameters/result.rb @@ -33,19 +33,33 @@ def initialize(path, code, **details) # The result of parsing and validating content parameters. class Result # Initialize the result. - # @parameter arguments [Hash] The converted and filtered arguments. + # @parameter value [Hash] The converted and filtered value. # @parameter errors [Array(Error)] The validation errors. - def initialize(arguments, errors) - @arguments = arguments + def initialize(value, errors) + @value = value @errors = errors.freeze end - # The converted and filtered arguments. - attr :arguments + # The converted and filtered value. + attr :value # The validation errors. attr :errors + # Fetch an entry from the result value. + # @parameter key [Object] The value key. + # @returns [Object | Nil] The corresponding value. + def [](key) + return @value[key] + end + + # Fetch an entry nested within the result value. + # @parameter path [Array(Object)] The nested value path. + # @returns [Object | Nil] The corresponding value. + def dig(*path) + return @value.dig(*path) + end + # Whether the parameters are valid. # @returns [Boolean] True when there are no validation errors. def valid? diff --git a/test/protocol/content/parameters.rb b/test/protocol/content/parameters.rb index 6753216..8efc86c 100644 --- a/test/protocol/content/parameters.rb +++ b/test/protocol/content/parameters.rb @@ -41,7 +41,7 @@ def multipart_body(*parts) result = parse_json(parameters, '{"name":"Samuel","age":"42","admin":true}') expect(result).to be(:valid?) - expect(result.arguments).to be == {"name" => "Samuel", "age" => 42} + expect(result.value).to be == {"name" => "Samuel", "age" => 42} end it "collects required, conversion, and unknown field errors" do @@ -66,7 +66,7 @@ def multipart_body(*parts) result = parse_json(parameters, '{"required":null,"nullable":null}') - expect(result.arguments).to be == {"nullable" => nil} + expect(result.value).to be == {"nullable" => nil} expect(result.errors.map(&:path)).to be == [["required"]] end @@ -78,7 +78,7 @@ def multipart_body(*parts) result = parse_json(parameters, '{"name":123,"ratio":"1.5"}') - expect(result.arguments).to be == {"ratio" => 1.5} + expect(result.value).to be == {"ratio" => 1.5} expect(result.errors.map(&:path)).to be == [["name"]] end @@ -104,9 +104,10 @@ def multipart_body(*parts) result = parse_json(parameters, '{"user":{"name":"Samuel","age":"42","admin":true}}') expect(result).to be(:valid?) - expect(result.arguments).to be == { + expect(result.value).to be == { "user" => {"name" => "Samuel", "age" => 42} } + expect(result.dig("user", "age")).to be == 42 end it "inherits strict validation in nested declarations" do @@ -129,7 +130,7 @@ def multipart_body(*parts) result = parse_json(parameters, '{"metadata":{"count":1,"labels":["a","b"]}}') - expect(result.arguments).to be == { + expect(result.value).to be == { "metadata" => {"count" => 1, "labels" => ["a", "b"]} } end @@ -144,7 +145,7 @@ def multipart_body(*parts) result = parse_json(parameters, '{"nullable":null,"nonnullable":null,"invalid":"value"}') - expect(result.arguments).to be == {"nullable" => nil} + expect(result.value).to be == {"nullable" => nil} expect(result.errors.map(&:path)).to be == [["required"], ["nonnullable"], ["invalid"]] expect(result.errors.map(&:code)).to be == [:required, :invalid_type, :invalid_type] end @@ -166,7 +167,7 @@ def multipart_body(*parts) result = parse_json(parameters, '{"tags":["one",2],"metadata":[{"enabled":true},[1,2]]}') - expect(result.arguments).to be == { + expect(result.value).to be == { "tags" => ["one"], "metadata" => [{"enabled" => true}, [1, 2]], } @@ -183,7 +184,7 @@ def multipart_body(*parts) result = parse_json(parameters, '{"users":[{"name":"Samuel","age":"42"},{"age":"old","admin":true},null]}') - expect(result.arguments).to be == { + expect(result.value).to be == { "users" => [{"name" => "Samuel", "age" => 42}, {}, {}], } expect(result.errors.map(&:path)).to be == [ @@ -205,7 +206,7 @@ def multipart_body(*parts) result = parse_json(parameters, '{"nullable":null,"nonnullable":null,"invalid":{},"numbers":["1","bad",null]}') - expect(result.arguments).to be == {"nullable" => nil, "numbers" => [1]} + expect(result.value).to be == {"nullable" => nil, "numbers" => [1]} expect(result.errors.map(&:path)).to be == [["required"], ["nonnullable"], ["invalid"], ["numbers", 1], ["numbers", 2]] end @@ -221,7 +222,7 @@ def multipart_body(*parts) result = parameters.parse("application/x-www-form-urlencoded", input) - expect(result.arguments).to be == { + expect(result.value).to be == { "tags" => ["one", "two"], "users" => [{"name" => "Alice", "age" => 30}, {"name" => "Bob"}], } @@ -265,7 +266,7 @@ def multipart_body(*parts) end result = parse_json(parameters, '{"code":"abc"}') - expect(result.arguments).to be == {"code" => "ABC"} + expect(result.value).to be == {"code" => "ABC"} end it "supports custom type mappings" do @@ -277,7 +278,7 @@ def multipart_body(*parts) end result = parse_json(parameters, '{"value":"custom"}') - expect(result.arguments["value"]).to be_a(type) + expect(result["value"]).to be_a(type) end it "collects custom converter failures" do @@ -288,7 +289,7 @@ def multipart_body(*parts) result = parse_json(parameters, '{"code":"abc"}') - expect(result.arguments).to be == {} + expect(result.value).to be == {} expect(result.errors.map(&:code)).to be == [:invalid_type] end @@ -299,7 +300,7 @@ def multipart_body(*parts) result = parse_json(parameters, '{"age":true}') - expect(result.arguments).to be == {} + expect(result.value).to be == {} expect(result.errors.map(&:code)).to be == [:invalid_type] end @@ -309,7 +310,7 @@ def multipart_body(*parts) end result = parse_json(parameters, "[]") - expect(result.arguments).to be == {} + expect(result.value).to be == {} expect(result.errors.first.path).to be == [] expect(result.errors.first.code).to be == :invalid_type end @@ -322,7 +323,7 @@ def multipart_body(*parts) result = parameters.parse("application/x-www-form-urlencoded", StringIO.new) expect(result).to be(:valid?) - expect(result.arguments).to be == {} + expect(result.value).to be == {} end it "inserts handled uploads using their nested form names" do @@ -350,7 +351,7 @@ def multipart_body(*parts) end expect(result).to be(:valid?) - expect(result.arguments).to be == { + expect(result.value).to be == { "user" => { "name" => "Samuel", "avatar" => {name: "avatar.txt", content: "avatar"} @@ -381,7 +382,7 @@ def multipart_body(*parts) {name: upload.filename, content: upload.each.to_a.join} end - expect(result.arguments).to be == { + expect(result.value).to be == { "users" => [{ "name" => "Samuel", "avatar" => {name: "avatar.txt", content: "avatar"}, @@ -409,7 +410,7 @@ def multipart_body(*parts) {filename: upload.filename, content: upload.each.to_a.join} end - expect(result.arguments).to be == { + expect(result.value).to be == { "pictures" => [ {filename: "one.txt", content: "one"}, {filename: "two.txt", content: "two"}, @@ -433,7 +434,7 @@ def multipart_body(*parts) upload.each.to_a.join end - expect(result.arguments).to be == {"gallery" => {"pictures" => ["picture"]}} + expect(result.value).to be == {"gallery" => {"pictures" => ["picture"]}} end it "preserves nil returned by the upload handler" do @@ -454,7 +455,7 @@ def multipart_body(*parts) nil end - expect(result.arguments).to be == {"avatar" => nil} + expect(result.value).to be == {"avatar" => nil} end it "does not pass undeclared uploads to the handler" do @@ -476,7 +477,7 @@ def multipart_body(*parts) end expect(called).to be == false - expect(result.arguments).to be == {} + expect(result.value).to be == {} expect(result.errors.map(&:path)).to be == [["avatar"]] expect(result.errors.map(&:code)).to be == [:unknown] end @@ -500,7 +501,7 @@ def multipart_body(*parts) raise "The handler should not be called!" end - expect(result.arguments).to be == {"user" => {}} + expect(result.value).to be == {"user" => {}} expect(result.errors.map(&:path)).to be == [["user", "avatar"]] end @@ -531,7 +532,7 @@ def multipart_body(*parts) result = parameters.parse(media_type, StringIO.new(body)) - expect(result.arguments).to be == {} + expect(result.value).to be == {} expect(result.errors.map(&:code)).to be == [:required] end @@ -547,7 +548,7 @@ def multipart_body(*parts) result = parameters.parse(media_type, StringIO.new(body)) - expect(result.arguments).to be == {"pictures" => []} + expect(result.value).to be == {"pictures" => []} expect(result.errors.map(&:code)).to be == [:required] end @@ -562,7 +563,7 @@ def multipart_body(*parts) expect(missing.errors.map(&:code)).to be == [:required] expect(invalid_shape.errors.map(&:path)).to be == [["pictures"]] - expect(invalid_item.arguments).to be == {"pictures" => []} + expect(invalid_item.value).to be == {"pictures" => []} expect(invalid_item.errors.map(&:path)).to be == [["pictures", 0], ["pictures"]] end @@ -599,7 +600,7 @@ def multipart_body(*parts) raise "The handler should not be called!" end - expect(result.arguments).to be == {"metadata" => {}, "attachments" => []} + expect(result.value).to be == {"metadata" => {}, "attachments" => []} expect(result.errors.map(&:path)).to be == [["users"]] end @@ -623,6 +624,6 @@ def multipart_body(*parts) result = parameters.parse(media_type, StringIO.new(body)) expect(result).to be(:valid?) - expect(result.arguments).to be == {"name" => "Samuel"} + expect(result.value).to be == {"name" => "Samuel"} end end From 128c69eba13e4475e45ede231efbb50e8dea4538 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 5 Aug 2026 22:52:21 +1200 Subject: [PATCH 11/16] Unify parameter type conversion. --- lib/protocol/content/parameters/declarations.rb | 12 ++---------- lib/protocol/content/parameters/type.rb | 2 +- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/lib/protocol/content/parameters/declarations.rb b/lib/protocol/content/parameters/declarations.rb index bdbe820..72a6383 100644 --- a/lib/protocol/content/parameters/declarations.rb +++ b/lib/protocol/content/parameters/declarations.rb @@ -17,14 +17,6 @@ def initialize(value) end module Values - def self.convert(type, value) - if type.respond_to?(:convert) - return type.convert(value) - else - return type.call(value) - end - end - def self.expected_type(type) if type.respond_to?(:type) return type.type @@ -86,7 +78,7 @@ def apply(value, output, errors, path) end # Treat input conversion failures as validation errors: - output[@name] = Values.convert(@type, value) + output[@name] = @type.call(value) rescue ArgumentError, TypeError errors << Error.new(path, :invalid_type, expected: Values.expected_type(@type), value: value) end @@ -250,7 +242,7 @@ def apply(value, output, errors, path) begin item = Values.materialize(item) - item = Values.convert(@type, item) + item = @type.call(item) result << item rescue ArgumentError, TypeError errors << Error.new(item_path, :invalid_type, expected: Values.expected_type(@type), value: item) diff --git a/lib/protocol/content/parameters/type.rb b/lib/protocol/content/parameters/type.rb index 2756c30..5e81bd6 100644 --- a/lib/protocol/content/parameters/type.rb +++ b/lib/protocol/content/parameters/type.rb @@ -23,7 +23,7 @@ def initialize(type, &converter) # @parameter value [Object] The input value. # @returns [Object] The converted value. # @raises [TypeError] If the value cannot be converted. - def convert(value) + def call(value) # Preserve values which already have the expected type: if @type === value return value From 08bb1a512cbb964274cba512c20c971327644b70 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 5 Aug 2026 22:58:10 +1200 Subject: [PATCH 12/16] Separate parameter errors. --- lib/protocol/content/parameters.rb | 1 + lib/protocol/content/parameters/error.rb | 47 +++++++++++++++++++++++ lib/protocol/content/parameters/result.rb | 37 ------------------ 3 files changed, 48 insertions(+), 37 deletions(-) create mode 100644 lib/protocol/content/parameters/error.rb diff --git a/lib/protocol/content/parameters.rb b/lib/protocol/content/parameters.rb index ca8832f..b7c558a 100644 --- a/lib/protocol/content/parameters.rb +++ b/lib/protocol/content/parameters.rb @@ -5,6 +5,7 @@ require_relative "default" require_relative "parameters/type" +require_relative "parameters/error" require_relative "parameters/result" require_relative "parameters/declarations" require_relative "parameters/model" diff --git a/lib/protocol/content/parameters/error.rb b/lib/protocol/content/parameters/error.rb new file mode 100644 index 0000000..48f4f09 --- /dev/null +++ b/lib/protocol/content/parameters/error.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "../error" + +module Protocol + module Content + module Parameters + # A validation error associated with a specific argument path. + class Error + # Initialize the validation error. + # @parameter path [Array(String | Integer)] The path to the invalid argument. + # @parameter code [Symbol] The machine-readable error code. + # @parameter details [Hash] Additional error details. + def initialize(path, code, **details) + @path = path.freeze + @code = code + @details = details.freeze + end + + # The path to the invalid argument. + attr :path + + # The machine-readable error code. + attr :code + + # Additional error details. + attr :details + end + + # Raised when parsed parameters are invalid. + class ValidationError < Protocol::Content::Error + # Initialize the validation error. + # @parameter result [Result] The invalid parameters result. + def initialize(result) + @result = result + super("Content parameters are invalid!") + end + + # The invalid parameters result. + attr :result + end + end + end +end diff --git a/lib/protocol/content/parameters/result.rb b/lib/protocol/content/parameters/result.rb index 32d36b9..6049bf2 100644 --- a/lib/protocol/content/parameters/result.rb +++ b/lib/protocol/content/parameters/result.rb @@ -3,33 +3,9 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -require_relative "../error" - module Protocol module Content module Parameters - # A validation error associated with a specific argument path. - class Error - # Initialize the validation error. - # @parameter path [Array(String | Integer)] The path to the invalid argument. - # @parameter code [Symbol] The machine-readable error code. - # @parameter details [Hash] Additional error details. - def initialize(path, code, **details) - @path = path.freeze - @code = code - @details = details.freeze - end - - # The path to the invalid argument. - attr :path - - # The machine-readable error code. - attr :code - - # Additional error details. - attr :details - end - # The result of parsing and validating content parameters. class Result # Initialize the result. @@ -66,19 +42,6 @@ def valid? return @errors.empty? end end - - # Raised when parsed parameters are invalid. - class ValidationError < Protocol::Content::Error - # Initialize the validation error. - # @parameter result [Result] The invalid parameters result. - def initialize(result) - @result = result - super("Content parameters are invalid!") - end - - # The invalid parameters result. - attr :result - end end end end From dc7d7d0c005b29eb14e84b199b520ef243ddb131 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 5 Aug 2026 23:27:54 +1200 Subject: [PATCH 13/16] Reject unknown parameters by default. --- guides/parameters/readme.md | 4 ++-- lib/protocol/content/parameters.rb | 2 +- lib/protocol/content/parameters/builder.rb | 2 +- lib/protocol/content/parameters/model.rb | 14 +++----------- test/protocol/content/parameters.rb | 8 ++++---- 5 files changed, 11 insertions(+), 19 deletions(-) diff --git a/guides/parameters/readme.md b/guides/parameters/readme.md index 6fe4a09..9857c9c 100644 --- a/guides/parameters/readme.md +++ b/guides/parameters/readme.md @@ -4,7 +4,7 @@ This guide explains how to build a parameter model that interprets parsed conten ## Declare Parameters -Parameter declarations define the input accepted by an operation without reproducing its database or domain model. Fields are optional by default, undeclared fields are omitted, and converted values are returned using string keys: +Parameter declarations define the input accepted by an operation without reproducing its database or domain model. Fields are optional by default, undeclared fields produce validation errors, and converted values are returned using string keys: ``` ruby require "protocol/content" @@ -28,7 +28,7 @@ parameters = Protocol::Content::Parameters.build do end ``` -Unknown fields can instead produce validation errors by building the parameters with `strict: true`. Strictness is inherited by constrained nested declarations unless explicitly disabled. +Strictness is inherited by constrained nested declarations unless explicitly disabled. Build the parameters with `strict: false` when undeclared fields should instead be omitted. ## Declare Arrays diff --git a/lib/protocol/content/parameters.rb b/lib/protocol/content/parameters.rb index b7c558a..b8539d7 100644 --- a/lib/protocol/content/parameters.rb +++ b/lib/protocol/content/parameters.rb @@ -34,7 +34,7 @@ module Parameters # @parameter strict [Boolean] Whether unknown fields should produce validation errors. # @yields The parameter declarations. # @returns [Model] The frozen parameter model. - def self.build(parser: Parser.default, types: TYPES, strict: false, &block) + def self.build(parser: Parser.default, types: TYPES, strict: true, &block) return Builder.new(parser:, types:, strict:).build(&block) end end diff --git a/lib/protocol/content/parameters/builder.rb b/lib/protocol/content/parameters/builder.rb index b624098..6bad4a9 100644 --- a/lib/protocol/content/parameters/builder.rb +++ b/lib/protocol/content/parameters/builder.rb @@ -12,7 +12,7 @@ class Builder # @parameter parser [Parser] The content parser. # @parameter types [Hash] The available type conversions. # @parameter strict [Boolean] Whether unknown fields should produce validation errors. - def initialize(parser: Parser.default, types: TYPES, strict: false) + def initialize(parser: Parser.default, types: TYPES, strict: true) @parser = parser @types = types @strict = strict diff --git a/lib/protocol/content/parameters/model.rb b/lib/protocol/content/parameters/model.rb index abad468..b8f3117 100644 --- a/lib/protocol/content/parameters/model.rb +++ b/lib/protocol/content/parameters/model.rb @@ -15,7 +15,7 @@ class Model # @parameter parser [Parser] The content parser. # @parameter declarations [Hash] The parameter declarations. # @parameter strict [Boolean] Whether unknown fields should produce validation errors. - def initialize(parser, declarations, strict: false) + def initialize(parser, declarations, strict: true) @parser = parser @declarations = declarations @strict = strict @@ -100,16 +100,8 @@ def apply(value, errors, path = []) end # Reject remaining undeclared values when strict validation is enabled: - input.each do |name, item| - if item.equal?(OMITTED) - if @strict - errors << Error.new(path + [name], :unknown) - end - - next - end - - if @strict + if @strict + input.each_key do |name| errors << Error.new(path + [name], :unknown) end end diff --git a/test/protocol/content/parameters.rb b/test/protocol/content/parameters.rb index 8efc86c..989de5c 100644 --- a/test/protocol/content/parameters.rb +++ b/test/protocol/content/parameters.rb @@ -33,7 +33,7 @@ def multipart_body(*parts) end it "filters unknown fields and converts declared fields" do - parameters = subject.build do + parameters = subject.build(strict: false) do field "name", String field "age", Integer end @@ -45,7 +45,7 @@ def multipart_body(*parts) end it "collects required, conversion, and unknown field errors" do - parameters = subject.build(strict: true) do + parameters = subject.build do field "name", String, required: true field "age", Integer end @@ -94,7 +94,7 @@ def multipart_body(*parts) end it "filters constrained nested parameters" do - parameters = subject.build do + parameters = subject.build(strict: false) do nested "user", required: true do field "name", String field "age", Integer @@ -111,7 +111,7 @@ def multipart_body(*parts) end it "inherits strict validation in nested declarations" do - parameters = subject.build(strict: true) do + parameters = subject.build do nested "user" do field "name", String end From 14ee440d643e29b2762ee3a5e3eede7fe1bf4a02 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 5 Aug 2026 23:38:17 +1200 Subject: [PATCH 14/16] Model parameters as fields. --- lib/protocol/content/parameters.rb | 4 +- lib/protocol/content/parameters/builder.rb | 44 +++---- .../parameters/{declarations.rb => fields.rb} | 112 +++++++----------- lib/protocol/content/parameters/model.rb | 39 +++--- test/protocol/content/parameters.rb | 2 + 5 files changed, 86 insertions(+), 115 deletions(-) rename lib/protocol/content/parameters/{declarations.rb => fields.rb} (85%) diff --git a/lib/protocol/content/parameters.rb b/lib/protocol/content/parameters.rb index b8539d7..95b2cac 100644 --- a/lib/protocol/content/parameters.rb +++ b/lib/protocol/content/parameters.rb @@ -7,7 +7,7 @@ require_relative "parameters/type" require_relative "parameters/error" require_relative "parameters/result" -require_relative "parameters/declarations" +require_relative "parameters/fields" require_relative "parameters/model" require_relative "parameters/builder" @@ -32,7 +32,7 @@ module Parameters # @parameter parser [Parser] The content parser. # @parameter types [Hash] The available type conversions. # @parameter strict [Boolean] Whether unknown fields should produce validation errors. - # @yields The parameter declarations. + # @yields The parameter fields. # @returns [Model] The frozen parameter model. def self.build(parser: Parser.default, types: TYPES, strict: true, &block) return Builder.new(parser:, types:, strict:).build(&block) diff --git a/lib/protocol/content/parameters/builder.rb b/lib/protocol/content/parameters/builder.rb index 6bad4a9..38b6ae5 100644 --- a/lib/protocol/content/parameters/builder.rb +++ b/lib/protocol/content/parameters/builder.rb @@ -6,7 +6,7 @@ module Protocol module Content module Parameters - # Builds immutable parameter models using a declaration DSL. + # Builds immutable parameter models using a field DSL. class Builder # Initialize a parameter model builder. # @parameter parser [Parser] The content parser. @@ -16,15 +16,15 @@ def initialize(parser: Parser.default, types: TYPES, strict: true) @parser = parser @types = types @strict = strict - @declarations = {} + @fields = {} end - # Evaluate declarations and construct an immutable parameter model. - # @yields The parameter declarations. + # Evaluate fields and construct an immutable parameter model. + # @yields The parameter fields. # @returns [Model] The frozen parameter model. def build(&block) instance_eval(&block) - return Model.new(@parser, @declarations, strict: @strict).freeze + return Model.new(@parser, @fields, strict: @strict).freeze end # Declare a scalar field. @@ -32,28 +32,28 @@ def build(&block) # @parameter type [Module | #call] The expected value type or converter. # @parameter required [Boolean] Whether the field must be present. # @parameter nullable [Boolean] Whether the field may be nil. - # @returns [Object] The field declaration. + # @returns [Field] The field. def field(name, type = Object, required: false, nullable: false) name = name.to_s - return add(Field.new(name, resolve(type), required:, nullable:)) + return add(ValueField.new(name, resolve(type), required:, nullable:)) end # Declare a streaming file upload. # @parameter name [String] The upload field name. # @parameter required [Boolean] Whether the upload must be present. - # @returns [Object] The upload declaration. + # @returns [Field] The upload field. def upload(name, required: false) name = name.to_s - return add(Upload.new(name, required:)) + return add(UploadField.new(name, required:, multiple: false)) end # Declare a collection of streaming file uploads. # @parameter name [String] The upload collection field name. # @parameter required [Boolean] Whether at least one handled upload must be present. - # @returns [Object] The upload collection declaration. + # @returns [Field] The upload collection field. def uploads(name, required: false) name = name.to_s - return add(Uploads.new(name, required:)) + return add(UploadField.new(name, required:, multiple: true)) end # Declare an array of scalar values or nested argument hierarchies. @@ -62,8 +62,8 @@ def uploads(name, required: false) # @parameter required [Boolean] Whether the array must be present. # @parameter nullable [Boolean] Whether the array may be nil. # @parameter strict [Boolean] Whether unknown nested fields should produce validation errors. - # @yields The nested parameter declarations for each array element. - # @returns [Object] The array declaration. + # @yields The nested parameter fields for each array element. + # @returns [Field] The array field. def array(name, type = nil, required: false, nullable: false, strict: @strict, &block) name = name.to_s @@ -86,8 +86,8 @@ def array(name, type = nil, required: false, nullable: false, strict: @strict, & # @parameter required [Boolean] Whether the field must be present. # @parameter nullable [Boolean] Whether the field may be nil. # @parameter strict [Boolean] Whether unknown nested fields should produce validation errors. - # @yields The nested parameter declarations. - # @returns [Object] The nested declaration. + # @yields The nested parameter fields. + # @returns [Field] The nested field. def nested(name, required: false, nullable: false, strict: @strict, &block) name = name.to_s @@ -95,7 +95,7 @@ def nested(name, required: false, nullable: false, strict: @strict, &block) model = nested_model(strict:, &block) end - return add(Nested.new(name, model, required:, nullable:)) + return add(NestedField.new(name, model, required:, nullable:)) end private @@ -117,14 +117,14 @@ def nested_model(strict:, &block) return self.class.new(parser: @parser, types: @types, strict:).build(&block) end - def add(declaration) - # Reject ambiguous declarations for the same input name: - if @declarations.key?(declaration.name) - raise ArgumentError, "Parameter #{declaration.name.inspect} is already declared!" + def add(field) + # Reject ambiguous fields for the same input name: + if @fields.key?(field.name) + raise ArgumentError, "Parameter #{field.name.inspect} is already declared!" end - @declarations[declaration.name] = declaration - return declaration + @fields[field.name] = field + return field end end end diff --git a/lib/protocol/content/parameters/declarations.rb b/lib/protocol/content/parameters/fields.rb similarity index 85% rename from lib/protocol/content/parameters/declarations.rb rename to lib/protocol/content/parameters/fields.rb index 72a6383..97c6d47 100644 --- a/lib/protocol/content/parameters/declarations.rb +++ b/lib/protocol/content/parameters/fields.rb @@ -50,11 +50,9 @@ def self.materialize(value) end class Field - def initialize(name, type, required:, nullable:) + def initialize(name, required:) @name = name - @type = type @required = required - @nullable = nullable end attr :name @@ -63,6 +61,23 @@ def required? return @required end + def accepts_upload?(path) + return false + end + + def freeze + @name.freeze + super + end + end + + class ValueField < Field + def initialize(name, type, required:, nullable:) + super(name, required:) + @type = type + @nullable = nullable + end + def apply(value, output, errors, path) value = Values.materialize(value) @@ -83,30 +98,28 @@ def apply(value, output, errors, path) errors << Error.new(path, :invalid_type, expected: Values.expected_type(@type), value: value) end - def freeze - @name.freeze - super - end - end - class Upload - def initialize(name, required:) - @name = name - @required = required - end - - attr :name - - def required? - return @required + class UploadField < Field + def initialize(name, required:, multiple:) + super(name, required:) + @multiple = multiple end def accepts_upload?(path) - return path.empty? + if @multiple + # Upload collections require anonymous array notation: + return path == [""] + else + return path.empty? + end end def apply(value, output, errors, path) + if @multiple + return apply_multiple(value, output, errors, path) + end + # Only values produced by an accepted upload handler are valid: if value.is_a?(UploadedValue) output[@name] = value.value @@ -115,30 +128,9 @@ def apply(value, output, errors, path) end end - def freeze - @name.freeze - super - end - end - - class Uploads - def initialize(name, required:) - @name = name - @required = required - end - - attr :name - - def required? - return @required - end - - def accepts_upload?(path) - # Upload collections require anonymous array notation: - return path == [""] - end + private - def apply(value, output, errors, path) + def apply_multiple(value, output, errors, path) # Upload collections must be represented as arrays by the content parser: unless value.is_a?(Array) errors << Error.new(path, :invalid_type, expected: Array, value: Values.materialize(value)) @@ -167,27 +159,16 @@ def apply(value, output, errors, path) output[@name] = result end - def freeze - @name.freeze - super - end end - class ArrayField + class ArrayField < Field def initialize(name, type, model, required:, nullable:) - @name = name + super(name, required:) @type = type @model = model - @required = required @nullable = nullable end - attr :name - - def required? - return @required - end - def accepts_upload?(path) # Uploads in arrays must target a declared field on an anonymous element: unless @model @@ -225,7 +206,7 @@ def apply(value, output, errors, path) value.each_with_index do |item, index| item_path = path + [index] - # Ignore uploads which were not accepted by the declaration: + # Ignore uploads which were not accepted by the field: if item.equal?(OMITTED) next end @@ -256,8 +237,6 @@ def apply(value, output, errors, path) end def freeze - @name.freeze - if @model @model.freeze end @@ -267,20 +246,13 @@ def freeze end - class Nested + class NestedField < Field def initialize(name, model, required:, nullable:) - @name = name + super(name, required:) @model = model - @required = required @nullable = nullable end - attr :name - - def required? - return @required - end - def accepts_upload?(path) unless @model return false @@ -290,7 +262,7 @@ def accepts_upload?(path) end def apply(value, output, errors, path) - # Nested declarations require a key/value hierarchy: + # Nested fields require a key/value hierarchy: if value.nil? if @nullable output[@name] = nil @@ -314,8 +286,6 @@ def apply(value, output, errors, path) end def freeze - @name.freeze - if @model @model.freeze end @@ -324,7 +294,7 @@ def freeze end end - private_constant :OMITTED, :UploadedValue, :Values, :Field, :Upload, :Uploads, :ArrayField, :Nested + private_constant :OMITTED, :UploadedValue, :Values, :Field, :ValueField, :UploadField, :ArrayField, :NestedField end end end diff --git a/lib/protocol/content/parameters/model.rb b/lib/protocol/content/parameters/model.rb index b8f3117..3da1dd2 100644 --- a/lib/protocol/content/parameters/model.rb +++ b/lib/protocol/content/parameters/model.rb @@ -13,14 +13,17 @@ module Parameters class Model # Initialize a parameter model. # @parameter parser [Parser] The content parser. - # @parameter declarations [Hash] The parameter declarations. + # @parameter fields [Hash] The parameter fields. # @parameter strict [Boolean] Whether unknown fields should produce validation errors. - def initialize(parser, declarations, strict: true) + def initialize(parser, fields, strict: true) @parser = parser - @declarations = declarations + @fields = fields @strict = strict end + # The fields in this model, indexed by name. + attr :fields + # Parse, filter, and validate content parameters. # @parameter media_type [String | Protocol::Media::Type | Nil] The content media type. # @parameter input [Object] The readable content input. @@ -31,7 +34,7 @@ def parse(media_type, input, &upload_handler) if item.is_a?(Protocol::Multipart::FormData::Upload) path = Protocol::URL::Encoding.split(name) - # Only process uploads accepted by an explicit declaration: + # Only process uploads accepted by an explicit field: if upload_handler && accepts_upload?(path) UploadedValue.new(upload_handler.call(name, item)) else @@ -69,32 +72,32 @@ def parse!(media_type, input, &block) # @parameter path [Array(String | Integer)] The current argument path. # @returns [Hash] The filtered and converted value. def apply(value, errors, path = []) - # Parameter declarations always apply to a key/value hierarchy: + # Parameter models always apply to a key/value hierarchy: unless value.is_a?(Hash) errors << Error.new(path, :invalid_type, expected: Hash, value: value) return {} end - # Normalize keys before matching them against declarations: + # Normalize keys before matching them against fields: input = {} value.each{|key, item| input[key.to_s] = item} output = {} # Apply declared values and collect missing required parameters: - @declarations.each do |name, declaration| + @fields.each do |name, field| item_path = path + [name] if input.key?(name) item = input.delete(name) if item.equal?(OMITTED) - if declaration.required? + if field.required? errors << Error.new(item_path, :required) end else - declaration.apply(item, output, errors, item_path) + field.apply(item, output, errors, item_path) end - elsif declaration.required? + elsif field.required? errors << Error.new(item_path, :required) end end @@ -113,26 +116,22 @@ def apply(value, errors, path = []) # @parameter path [Array(String)] The decoded upload path. # @returns [Boolean] Whether the upload is accepted. def accepts_upload?(path) - # Walk declarations using the decoded components of the form name: + # Walk fields using the decoded components of the form name: name, *remaining = path - unless declaration = @declarations[name] - return false - end - - unless declaration.respond_to?(:accepts_upload?) + unless field = @fields[name] return false end - return declaration.accepts_upload?(remaining) + return field.accepts_upload?(remaining) end - # Freeze this model and its declarations. + # Freeze this model and its fields. # @returns [self] The frozen model. def freeze @parser.freeze - @declarations.each_value(&:freeze) - @declarations.freeze + @fields.each_value(&:freeze) + @fields.freeze super end end diff --git a/test/protocol/content/parameters.rb b/test/protocol/content/parameters.rb index 989de5c..0228648 100644 --- a/test/protocol/content/parameters.rb +++ b/test/protocol/content/parameters.rb @@ -30,6 +30,8 @@ def multipart_body(*parts) expect(parameters).to be_a(subject::Model) expect(parameters).to be(:frozen?) + expect(parameters.fields.keys).to be == ["name"] + expect(parameters.fields).to be(:frozen?) end it "filters unknown fields and converts declared fields" do From 219139d28d27c090ec9d0df20a864a02f694648d Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 5 Aug 2026 23:43:10 +1200 Subject: [PATCH 15/16] Extract parameter values. --- lib/protocol/content/parameters.rb | 3 +- .../parameters/{fields.rb => field.rb} | 54 ++---------------- lib/protocol/content/parameters/model.rb | 6 +- lib/protocol/content/parameters/values.rb | 55 +++++++++++++++++++ 4 files changed, 66 insertions(+), 52 deletions(-) rename lib/protocol/content/parameters/{fields.rb => field.rb} (84%) create mode 100644 lib/protocol/content/parameters/values.rb diff --git a/lib/protocol/content/parameters.rb b/lib/protocol/content/parameters.rb index 95b2cac..26d3ef4 100644 --- a/lib/protocol/content/parameters.rb +++ b/lib/protocol/content/parameters.rb @@ -7,7 +7,8 @@ require_relative "parameters/type" require_relative "parameters/error" require_relative "parameters/result" -require_relative "parameters/fields" +require_relative "parameters/values" +require_relative "parameters/field" require_relative "parameters/model" require_relative "parameters/builder" diff --git a/lib/protocol/content/parameters/fields.rb b/lib/protocol/content/parameters/field.rb similarity index 84% rename from lib/protocol/content/parameters/fields.rb rename to lib/protocol/content/parameters/field.rb index 97c6d47..1f90310 100644 --- a/lib/protocol/content/parameters/fields.rb +++ b/lib/protocol/content/parameters/field.rb @@ -6,49 +6,7 @@ module Protocol module Content module Parameters - OMITTED = Object.new.freeze - - class UploadedValue - def initialize(value) - @value = value - end - - attr :value - end - - module Values - def self.expected_type(type) - if type.respond_to?(:type) - return type.type - else - return type - end - end - - def self.materialize(value) - case value - when Hash - result = {} - value.each do |key, item| - # Remove omitted uploads while preserving the surrounding hierarchy: - unless item.equal?(OMITTED) - result[key.to_s] = materialize(item) - end - end - return result - when Array - # Remove omitted uploads while preserving accepted array values: - return value.filter_map do |item| - unless item.equal?(OMITTED) - materialize(item) - end - end - else - return value - end - end - end - + # Common behavior for fields in a parameter model. class Field def initialize(name, required:) @name = name @@ -121,7 +79,7 @@ def apply(value, output, errors, path) end # Only values produced by an accepted upload handler are valid: - if value.is_a?(UploadedValue) + if value.is_a?(Values::Uploaded) output[@name] = value.value else errors << Error.new(path, :invalid_type, expected: :upload, value: Values.materialize(value)) @@ -141,9 +99,9 @@ def apply_multiple(value, output, errors, path) value.each_with_index do |item, index| case item - when UploadedValue + when Values::Uploaded result << item.value - when OMITTED + when Values::OMITTED # Unhandled uploads are consumed by the parser and omitted here: next else @@ -207,7 +165,7 @@ def apply(value, output, errors, path) item_path = path + [index] # Ignore uploads which were not accepted by the field: - if item.equal?(OMITTED) + if item.equal?(Values::OMITTED) next end @@ -294,7 +252,7 @@ def freeze end end - private_constant :OMITTED, :UploadedValue, :Values, :Field, :ValueField, :UploadField, :ArrayField, :NestedField + private_constant :Field, :ValueField, :UploadField, :ArrayField, :NestedField end end end diff --git a/lib/protocol/content/parameters/model.rb b/lib/protocol/content/parameters/model.rb index 3da1dd2..88dcda3 100644 --- a/lib/protocol/content/parameters/model.rb +++ b/lib/protocol/content/parameters/model.rb @@ -36,9 +36,9 @@ def parse(media_type, input, &upload_handler) # Only process uploads accepted by an explicit field: if upload_handler && accepts_upload?(path) - UploadedValue.new(upload_handler.call(name, item)) + Values::Uploaded.new(upload_handler.call(name, item)) else - OMITTED + Values::OMITTED end else item @@ -90,7 +90,7 @@ def apply(value, errors, path = []) if input.key?(name) item = input.delete(name) - if item.equal?(OMITTED) + if item.equal?(Values::OMITTED) if field.required? errors << Error.new(item_path, :required) end diff --git a/lib/protocol/content/parameters/values.rb b/lib/protocol/content/parameters/values.rb new file mode 100644 index 0000000..e3df309 --- /dev/null +++ b/lib/protocol/content/parameters/values.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Protocol + module Content + module Parameters + module Values + OMITTED = Object.new.freeze + + class Uploaded + def initialize(value) + @value = value + end + + attr :value + end + + def self.expected_type(type) + if type.respond_to?(:type) + return type.type + else + return type + end + end + + def self.materialize(value) + case value + when Hash + result = {} + value.each do |key, item| + # Remove omitted uploads while preserving the surrounding hierarchy: + unless item.equal?(OMITTED) + result[key.to_s] = materialize(item) + end + end + return result + when Array + # Remove omitted uploads while preserving accepted array values: + return value.filter_map do |item| + unless item.equal?(OMITTED) + materialize(item) + end + end + else + return value + end + end + end + + private_constant :Values + end + end +end From 0a43abdf4a461995354ff03e036af4dcd35f414e Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 5 Aug 2026 23:46:28 +1200 Subject: [PATCH 16/16] Rename parameter value helpers. --- lib/protocol/content/parameters.rb | 2 +- lib/protocol/content/parameters/field.rb | 30 +++++++++---------- lib/protocol/content/parameters/model.rb | 6 ++-- lib/protocol/content/parameters/type.rb | 9 ++++++ .../parameters/{values.rb => value.rb} | 12 ++------ 5 files changed, 30 insertions(+), 29 deletions(-) rename lib/protocol/content/parameters/{values.rb => value.rb} (83%) diff --git a/lib/protocol/content/parameters.rb b/lib/protocol/content/parameters.rb index 26d3ef4..96c445b 100644 --- a/lib/protocol/content/parameters.rb +++ b/lib/protocol/content/parameters.rb @@ -7,7 +7,7 @@ require_relative "parameters/type" require_relative "parameters/error" require_relative "parameters/result" -require_relative "parameters/values" +require_relative "parameters/value" require_relative "parameters/field" require_relative "parameters/model" require_relative "parameters/builder" diff --git a/lib/protocol/content/parameters/field.rb b/lib/protocol/content/parameters/field.rb index 1f90310..9a36d7c 100644 --- a/lib/protocol/content/parameters/field.rb +++ b/lib/protocol/content/parameters/field.rb @@ -37,14 +37,14 @@ def initialize(name, type, required:, nullable:) end def apply(value, output, errors, path) - value = Values.materialize(value) + value = Value.materialize(value) # Reject nil unless the field is explicitly nullable: if value.nil? if @nullable output[@name] = nil else - errors << Error.new(path, :invalid_type, expected: Values.expected_type(@type), value: value) + errors << Error.new(path, :invalid_type, expected: Type.expected(@type), value: value) end return @@ -53,7 +53,7 @@ def apply(value, output, errors, path) # Treat input conversion failures as validation errors: output[@name] = @type.call(value) rescue ArgumentError, TypeError - errors << Error.new(path, :invalid_type, expected: Values.expected_type(@type), value: value) + errors << Error.new(path, :invalid_type, expected: Type.expected(@type), value: value) end end @@ -79,10 +79,10 @@ def apply(value, output, errors, path) end # Only values produced by an accepted upload handler are valid: - if value.is_a?(Values::Uploaded) + if value.is_a?(Value::Uploaded) output[@name] = value.value else - errors << Error.new(path, :invalid_type, expected: :upload, value: Values.materialize(value)) + errors << Error.new(path, :invalid_type, expected: :upload, value: Value.materialize(value)) end end @@ -91,7 +91,7 @@ def apply(value, output, errors, path) def apply_multiple(value, output, errors, path) # Upload collections must be represented as arrays by the content parser: unless value.is_a?(Array) - errors << Error.new(path, :invalid_type, expected: Array, value: Values.materialize(value)) + errors << Error.new(path, :invalid_type, expected: Array, value: Value.materialize(value)) return end @@ -99,13 +99,13 @@ def apply_multiple(value, output, errors, path) value.each_with_index do |item, index| case item - when Values::Uploaded + when Value::Uploaded result << item.value - when Values::OMITTED + 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: Values.materialize(item)) + errors << Error.new(path + [index], :invalid_type, expected: :upload, value: Value.materialize(item)) end end @@ -165,7 +165,7 @@ def apply(value, output, errors, path) item_path = path + [index] # Ignore uploads which were not accepted by the field: - if item.equal?(Values::OMITTED) + if item.equal?(Value::OMITTED) next end @@ -175,19 +175,19 @@ def apply(value, output, errors, path) elsif @type # Typed arrays reject nil rather than passing it to coercion: if item.nil? - errors << Error.new(item_path, :invalid_type, expected: Values.expected_type(@type), value: item) + errors << Error.new(item_path, :invalid_type, expected: Type.expected(@type), value: item) next end begin - item = Values.materialize(item) + item = Value.materialize(item) item = @type.call(item) result << item rescue ArgumentError, TypeError - errors << Error.new(item_path, :invalid_type, expected: Values.expected_type(@type), value: item) + errors << Error.new(item_path, :invalid_type, expected: Type.expected(@type), value: item) end else - result << Values.materialize(item) + result << Value.materialize(item) end end @@ -239,7 +239,7 @@ def apply(value, output, errors, path) if @model output[@name] = @model.apply(value, errors, path) else - output[@name] = Values.materialize(value) + output[@name] = Value.materialize(value) end end diff --git a/lib/protocol/content/parameters/model.rb b/lib/protocol/content/parameters/model.rb index 88dcda3..8587ebe 100644 --- a/lib/protocol/content/parameters/model.rb +++ b/lib/protocol/content/parameters/model.rb @@ -36,9 +36,9 @@ def parse(media_type, input, &upload_handler) # Only process uploads accepted by an explicit field: if upload_handler && accepts_upload?(path) - Values::Uploaded.new(upload_handler.call(name, item)) + Value::Uploaded.new(upload_handler.call(name, item)) else - Values::OMITTED + Value::OMITTED end else item @@ -90,7 +90,7 @@ def apply(value, errors, path = []) if input.key?(name) item = input.delete(name) - if item.equal?(Values::OMITTED) + if item.equal?(Value::OMITTED) if field.required? errors << Error.new(item_path, :required) end diff --git a/lib/protocol/content/parameters/type.rb b/lib/protocol/content/parameters/type.rb index 5e81bd6..7625e0a 100644 --- a/lib/protocol/content/parameters/type.rb +++ b/lib/protocol/content/parameters/type.rb @@ -8,6 +8,15 @@ module Content module Parameters # Converts input values to a specific application type. class Type + # Resolve the expected output type of a converter. + def self.expected(type) + if type.respond_to?(:type) + return type.type + else + return type + end + end + # Initialize a type converter. # @parameter type [Object] The expected converted type. # @yields {|value| ...} The conversion operation. diff --git a/lib/protocol/content/parameters/values.rb b/lib/protocol/content/parameters/value.rb similarity index 83% rename from lib/protocol/content/parameters/values.rb rename to lib/protocol/content/parameters/value.rb index e3df309..90c7e6c 100644 --- a/lib/protocol/content/parameters/values.rb +++ b/lib/protocol/content/parameters/value.rb @@ -6,7 +6,7 @@ module Protocol module Content module Parameters - module Values + module Value OMITTED = Object.new.freeze class Uploaded @@ -17,14 +17,6 @@ def initialize(value) attr :value end - def self.expected_type(type) - if type.respond_to?(:type) - return type.type - else - return type - end - end - def self.materialize(value) case value when Hash @@ -49,7 +41,7 @@ def self.materialize(value) end end - private_constant :Values + private_constant :Value end end end