Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions guides/links.yaml
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
getting-started:
order: 1
parameters:
order: 2
140 changes: 140 additions & 0 deletions guides/parameters/readme.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions lib/protocol/content.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require_relative "content/version"
require_relative "content/error"
require_relative "content/parser"
require_relative "content/parameters"

module Protocol
# @namespace
Expand Down
43 changes: 43 additions & 0 deletions lib/protocol/content/parameters.rb
Original file line number Diff line number Diff line change
@@ -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
132 changes: 132 additions & 0 deletions lib/protocol/content/parameters/builder.rb
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions lib/protocol/content/parameters/error.rb
Original file line number Diff line number Diff line change
@@ -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
Loading