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..9857c9c --- /dev/null +++ b/guides/parameters/readme.md @@ -0,0 +1,140 @@ +# 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 + +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" + +parameters = Protocol::Content::Parameters.build do + nested "user", required: true do + field "name", String + field "age", Integer + upload "avatar" + 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 +``` + +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 + +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::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) + +if result.valid? + user.update(result["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::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) +user.update(arguments["user"]) +``` + +## Convert Fields + +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" + +date = ->(value){Date.iso8601(value)} + +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. + +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: + +``` ruby +parameters = Protocol::Content::Parameters.build do + nested "user" do + upload "avatar", required: true + end + + uploads "pictures" +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| + 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.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.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..96c445b --- /dev/null +++ b/lib/protocol/content/parameters.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "default" +require_relative "parameters/type" +require_relative "parameters/error" +require_relative "parameters/result" +require_relative "parameters/value" +require_relative "parameters/field" +require_relative "parameters/model" +require_relative "parameters/builder" + +module Protocol + module Content + # 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 + + # 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. + # @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) + end + 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..38b6ae5 --- /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 field 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: true) + @parser = parser + @types = types + @strict = strict + @fields = {} + end + + # 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, @fields, 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 [Field] The field. + def field(name, type = Object, required: false, nullable: false) + name = name.to_s + 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 [Field] The upload field. + def upload(name, required: false) + name = name.to_s + 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 [Field] The upload collection field. + def uploads(name, required: false) + name = name.to_s + return add(UploadField.new(name, required:, multiple: true)) + 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 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 + + 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 fields. + # @returns [Field] The nested field. + def nested(name, required: false, nullable: false, strict: @strict, &block) + name = name.to_s + + if block + model = nested_model(strict:, &block) + end + + return add(NestedField.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(field) + # Reject ambiguous fields for the same input name: + if @fields.key?(field.name) + raise ArgumentError, "Parameter #{field.name.inspect} is already declared!" + end + + @fields[field.name] = field + return field + end + end + end + end +end 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/field.rb b/lib/protocol/content/parameters/field.rb new file mode 100644 index 0000000..9a36d7c --- /dev/null +++ b/lib/protocol/content/parameters/field.rb @@ -0,0 +1,258 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Protocol + module Content + module Parameters + # Common behavior for fields in a parameter model. + class Field + def initialize(name, required:) + @name = name + @required = required + end + + attr :name + + 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 = 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: Type.expected(@type), value: value) + end + + return + end + + # Treat input conversion failures as validation errors: + output[@name] = @type.call(value) + rescue ArgumentError, TypeError + errors << Error.new(path, :invalid_type, expected: Type.expected(@type), value: value) + end + + end + + class UploadField < Field + def initialize(name, required:, multiple:) + super(name, required:) + @multiple = multiple + end + + def accepts_upload?(path) + 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?(Value::Uploaded) + output[@name] = value.value + else + errors << Error.new(path, :invalid_type, expected: :upload, value: Value.materialize(value)) + end + end + + private + + 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: Value.materialize(value)) + return + end + + result = [] + + value.each_with_index do |item, index| + case item + when Value::Uploaded + result << item.value + when Value::OMITTED + # Unhandled uploads are consumed by the parser and omitted here: + next + else + errors << Error.new(path + [index], :invalid_type, expected: :upload, value: Value.materialize(item)) + 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 + + end + + class ArrayField < Field + def initialize(name, type, model, required:, nullable:) + super(name, required:) + @type = type + @model = model + @nullable = nullable + end + + def accepts_upload?(path) + # Uploads in arrays must target a declared field on an anonymous element: + unless @model + return false + end + + index, *remaining = path + + unless index&.empty? + return false + end + + return @model.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 + 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] + + # Ignore uploads which were not accepted by the field: + if item.equal?(Value::OMITTED) + next + end + + # Nested arrays validate each element as its own argument hierarchy: + if @model + result << @model.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: Type.expected(@type), value: item) + next + end + + begin + item = Value.materialize(item) + item = @type.call(item) + result << item + rescue ArgumentError, TypeError + errors << Error.new(item_path, :invalid_type, expected: Type.expected(@type), value: item) + end + else + result << Value.materialize(item) + end + end + + output[@name] = result + end + + def freeze + if @model + @model.freeze + end + + super + end + + end + + class NestedField < Field + def initialize(name, model, required:, nullable:) + super(name, required:) + @model = model + @nullable = nullable + end + + def accepts_upload?(path) + unless @model + return false + end + + return @model.accepts_upload?(path) + end + + def apply(value, output, errors, path) + # Nested fields require a key/value hierarchy: + 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 @model + output[@name] = @model.apply(value, errors, path) + else + output[@name] = Value.materialize(value) + end + end + + def freeze + if @model + @model.freeze + end + + super + end + end + + 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 new file mode 100644 index 0000000..8587ebe --- /dev/null +++ b/lib/protocol/content/parameters/model.rb @@ -0,0 +1,140 @@ +# 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 fields [Hash] The parameter fields. + # @parameter strict [Boolean] Whether unknown fields should produce validation errors. + def initialize(parser, fields, strict: true) + @parser = parser + @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. + # @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) + value = @parser.parse(media_type, input) do |name, item| + if item.is_a?(Protocol::Multipart::FormData::Upload) + path = Protocol::URL::Encoding.split(name) + + # Only process uploads accepted by an explicit field: + if upload_handler && accepts_upload?(path) + Value::Uploaded.new(upload_handler.call(name, item)) + else + Value::OMITTED + end + else + item + end + end + + 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 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.value + end + + raise ValidationError, result + end + + # Apply this model to an existing 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 value. + def apply(value, errors, path = []) + # 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 fields: + input = {} + value.each{|key, item| input[key.to_s] = item} + output = {} + + # Apply declared values and collect missing required parameters: + @fields.each do |name, field| + item_path = path + [name] + + if input.key?(name) + item = input.delete(name) + + if item.equal?(Value::OMITTED) + if field.required? + errors << Error.new(item_path, :required) + end + else + field.apply(item, output, errors, item_path) + end + elsif field.required? + errors << Error.new(item_path, :required) + end + end + + # Reject remaining undeclared values when strict validation is enabled: + if @strict + input.each_key do |name| + 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 fields using the decoded components of the form name: + name, *remaining = path + + unless field = @fields[name] + return false + end + + return field.accepts_upload?(remaining) + end + + # Freeze this model and its fields. + # @returns [self] The frozen model. + def freeze + @parser.freeze + @fields.each_value(&:freeze) + @fields.freeze + super + end + end + 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..6049bf2 --- /dev/null +++ b/lib/protocol/content/parameters/result.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Protocol + module Content + module Parameters + # The result of parsing and validating content parameters. + class Result + # Initialize the result. + # @parameter value [Hash] The converted and filtered value. + # @parameter errors [Array(Error)] The validation errors. + def initialize(value, errors) + @value = value + @errors = errors.freeze + end + + # 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? + return @errors.empty? + end + 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..7625e0a --- /dev/null +++ b/lib/protocol/content/parameters/type.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Protocol + 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. + 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 call(value) + # Preserve values which already have the expected type: + if @type === value + return value + end + + if @converter + value = @converter.call(value) + + # Ensure converters produce the type they declare: + if @type === value + return value + end + end + + raise TypeError, "Could not convert #{value.inspect} to #{@type}!" + end + end + + private_constant :Type + end + end +end diff --git a/lib/protocol/content/parameters/value.rb b/lib/protocol/content/parameters/value.rb new file mode 100644 index 0000000..90c7e6c --- /dev/null +++ b/lib/protocol/content/parameters/value.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Protocol + module Content + module Parameters + module Value + OMITTED = Object.new.freeze + + class Uploaded + def initialize(value) + @value = value + end + + attr :value + 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 :Value + 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..0228648 --- /dev/null +++ b/test/protocol/content/parameters.rb @@ -0,0 +1,631 @@ +# 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 models" do + parameters = subject.build do + field "name", String + end + + 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 + parameters = subject.build(strict: false) 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.value).to be == {"name" => "Samuel", "age" => 42} + end + + it "collects required, conversion, and unknown field errors" do + parameters = subject.build 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.value).to be == {"nullable" => nil} + expect(result.errors.map(&:path)).to be == [["required"]] + end + + it "matches string fields and converts 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.value).to be == {"ratio" => 1.5} + expect(result.errors.map(&:path)).to be == [["name"]] + 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(strict: false) 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.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 + parameters = subject.build 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.value).to be == { + "metadata" => {"count" => 1, "labels" => ["a", "b"]} + } + 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.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 + + 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 "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.value).to be == { + "tags" => ["one"], + "metadata" => [{"enabled" => true}, [1, 2]], + } + expect(result.errors.map(&:path)).to be == [["tags", 1]] + 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.value).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.value).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.value).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 + 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 "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} + + parameters = subject.build do + field "code", converter + end + result = parse_json(parameters, '{"code":"abc"}') + + expect(result.value).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["value"]).to be_a(type) + 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.value).to be == {} + 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.value).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 + end + result = parse_json(parameters, "[]") + + expect(result.value).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.value).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 + upload "avatar" + 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.value).to be == { + "user" => { + "name" => "Samuel", + "avatar" => {name: "avatar.txt", content: "avatar"} + } + } + 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.value).to be == { + "users" => [{ + "name" => "Samuel", + "avatar" => {name: "avatar.txt", content: "avatar"}, + }], + } + 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.value).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.value).to be == {"gallery" => {"pictures" => ["picture"]}} + end + + it "preserves nil returned by the upload handler" do + parameters = subject.build do + upload "avatar" + 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)) do |_name, upload| + upload.discard + nil + end + + expect(result.value).to be == {"avatar" => nil} + end + + 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.value).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.value).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 "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.value).to be == {} + 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.value).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.value).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 + 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.value).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 + upload "avatar" + 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.value).to be == {"name" => "Samuel"} + end +end