protocol-content 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0631b08562d204932666cba81cddae2505c9dbf9d9c53414493e156aee864c91
4
- data.tar.gz: 7c7c22c2fb998b2b6839dcf11e8c20e52de69ef1c11eed26c12014bc27f03401
3
+ metadata.gz: f4a6369c7d76fff09f34f7fb56203e92dfe32ae5c2346c542124e7b9006fca84
4
+ data.tar.gz: '0686b2a1d3f10dcc779e4f32ef78d342f016779cdafd458ec13ecec60983c7ed'
5
5
  SHA512:
6
- metadata.gz: a9d13f6dc00e82e2108083e9069577a4f6a910789bdbe1cc9ee7701a077359ed052a1ad06cd55f0112ff5f833bda4b3581d041934e594d94d5e56405d31a33ee
7
- data.tar.gz: 01ac8df9eefddbdde84e412a77a7c8b7848dac3116b04225cc097ccf6903f7644daee17a962dd5e7be9d4000fb86b38083dd803453758c0612034bd0f3d764ca
6
+ metadata.gz: b06e51d78c61bbc4601bac53715404c8a603155cca03e32cb183026ebb114d9c94424c594f325bcec334cb73a8c2d588e6c11697c6547315219a74273932389c
7
+ data.tar.gz: 2f97656018bb2e0203593138ce0b0c92c00c3a26667bf16a4c14e782b640e485c2e2d95e21a3ac07f25016b599faaab099ab9304714eda2e6021a0e2d099ad55
checksums.yaml.gz.sig CHANGED
Binary file
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "protocol/media/set"
7
+
8
+ module Protocol
9
+ module Content
10
+ module Parameters
11
+ # Builds immutable parameter models using a field DSL.
12
+ class Builder
13
+ # Initialize a parameter model builder.
14
+ # @parameter parser [Parser] The content parser.
15
+ # @parameter types [Hash] The available type conversions.
16
+ # @parameter strict [Boolean] Whether unknown fields should produce validation errors.
17
+ def initialize(parser: Parser.default, types: TYPES, strict: true)
18
+ @parser = parser
19
+ @types = types
20
+ @strict = strict
21
+ @fields = {}
22
+ end
23
+
24
+ # Evaluate fields and construct an immutable parameter model.
25
+ # @yields The parameter fields.
26
+ # @returns [Model] The frozen parameter model.
27
+ def build(&block)
28
+ instance_eval(&block)
29
+ return Model.new(@parser, @fields, strict: @strict).freeze
30
+ end
31
+
32
+ # Declare a scalar field.
33
+ # @parameter name [String] The field name.
34
+ # @parameter type [Module | #call] The expected value type or converter.
35
+ # @parameter required [Boolean] Whether the field must be present.
36
+ # @parameter nullable [Boolean] Whether the field may be nil.
37
+ # @returns [Field] The field.
38
+ def field(name, type = Object, required: false, nullable: false)
39
+ return add(ValueField.new(name, resolve(type), required:, nullable:))
40
+ end
41
+
42
+ # Declare a streaming file upload.
43
+ # @parameter name [String] The upload field name.
44
+ # @parameter required [Boolean] Whether at least one handled upload must be present.
45
+ # @parameter multiple [Boolean] Whether the field accepts multiple uploads using anonymous array notation.
46
+ # @parameter accept [Protocol::Media::Set | Array(String | Protocol::Media::Range) | Nil] The accepted media ranges.
47
+ # @parameter size_limit [Integer | Nil] The maximum accepted upload size.
48
+ # @returns [Field] The upload field.
49
+ def upload(name, required: false, multiple: false, accept: nil, size_limit: nil)
50
+ if size_limit && size_limit < 0
51
+ raise ArgumentError, "Upload size limit must be non-negative!"
52
+ end
53
+
54
+ if accept
55
+ accept = Protocol::Media::Set.for(accept)
56
+ end
57
+
58
+ return add(UploadField.new(name, required:, multiple:, accept:, size_limit:))
59
+ end
60
+
61
+ # Construct an enumeration converter from accepted values or an input-to-output mapping.
62
+ # @parameter values [Array(Object)] The accepted values.
63
+ # @parameter options [Hash] Additional input-to-output mappings.
64
+ # @returns [Enumeration] The enumeration converter.
65
+ def enumeration(*values, **options)
66
+ return Enumeration.build(*values, **options)
67
+ end
68
+
69
+ # Declare an array of scalar values or nested argument hierarchies.
70
+ # @parameter name [String] The array field name.
71
+ # @parameter type [Module | #call | Nil] The expected element type or converter.
72
+ # @parameter required [Boolean] Whether the array must be present.
73
+ # @parameter nullable [Boolean] Whether the array may be nil.
74
+ # @parameter strict [Boolean] Whether unknown nested fields should produce validation errors.
75
+ # @yields The nested parameter fields for each array element.
76
+ # @returns [Field] The array field.
77
+ def array(name, type = nil, required: false, nullable: false, strict: @strict, &block)
78
+ if block
79
+ # A block defines the element shape and cannot be combined with conversion:
80
+ if type
81
+ raise ArgumentError, "An array cannot declare both an element type and nested fields!"
82
+ end
83
+
84
+ model = nested_model(strict:, &block)
85
+ elsif type
86
+ type = resolve(type)
87
+ end
88
+
89
+ return add(ArrayField.new(name, type, model, required:, nullable:))
90
+ end
91
+
92
+ # Declare a nested argument hierarchy. Without a block, all nested values are accepted.
93
+ # @parameter name [String] The nested field name.
94
+ # @parameter required [Boolean] Whether the field must be present.
95
+ # @parameter nullable [Boolean] Whether the field may be nil.
96
+ # @parameter strict [Boolean] Whether unknown nested fields should produce validation errors.
97
+ # @yields The nested parameter fields.
98
+ # @returns [Field] The nested field.
99
+ def nested(name, required: false, nullable: false, strict: @strict, &block)
100
+ if block
101
+ model = nested_model(strict:, &block)
102
+ end
103
+
104
+ return add(NestedField.new(name, model, required:, nullable:))
105
+ end
106
+
107
+ private
108
+
109
+ def resolve(type)
110
+ # Preserve custom converters without wrapping them:
111
+ if type.respond_to?(:call)
112
+ return type
113
+ end
114
+
115
+ if converter = @types[type]
116
+ return Type.new(type, &converter)
117
+ end
118
+
119
+ return Type.new(type)
120
+ end
121
+
122
+ def nested_model(strict:, &block)
123
+ return self.class.new(parser: @parser, types: @types, strict:).build(&block)
124
+ end
125
+
126
+ def add(field)
127
+ # Reject ambiguous fields for the same input name:
128
+ if @fields.key?(field.name)
129
+ raise ArgumentError, "Parameter #{field.name.inspect} is already declared!"
130
+ end
131
+
132
+ @fields[field.name] = field
133
+ return field
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Protocol
7
+ module Content
8
+ module Parameters
9
+ # Converts an exact set of input values to corresponding output values.
10
+ class Enumeration
11
+ # Construct an enumeration from accepted values or an input-to-output mapping.
12
+ # @parameter values [Array(Object)] The accepted values.
13
+ # @parameter options [Hash] Additional input-to-output mappings.
14
+ # @returns [Enumeration] The enumeration converter.
15
+ def self.build(*values, **options)
16
+ mapping = values.to_h{|value| [value, value]}
17
+ mapping.update(options)
18
+ return new(mapping)
19
+ end
20
+
21
+ # Initialize an enumeration from an input-to-output mapping.
22
+ # @parameter mapping [Hash] The accepted inputs and corresponding outputs.
23
+ def initialize(mapping)
24
+ @mapping = mapping.freeze
25
+ end
26
+
27
+ # The accepted input values and corresponding output values.
28
+ attr :mapping
29
+
30
+ # Convert an accepted input value.
31
+ # @parameter value [Object] The input value.
32
+ # @returns [Object] The corresponding output value.
33
+ # @raises [ArgumentError] If the input value is not accepted.
34
+ def call(value)
35
+ if @mapping.key?(value)
36
+ return @mapping[value]
37
+ end
38
+
39
+ raise ArgumentError, "Invalid enumeration value: #{value.inspect}!"
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "../error"
7
+
8
+ module Protocol
9
+ module Content
10
+ module Parameters
11
+ # A validation error associated with a specific argument path.
12
+ class Error
13
+ # Initialize the validation error.
14
+ # @parameter path [Array(String | Integer)] The path to the invalid argument.
15
+ # @parameter code [Symbol] The machine-readable error code.
16
+ # @parameter details [Hash] Additional error details.
17
+ def initialize(path, code, **details)
18
+ @path = path
19
+ @code = code
20
+ @details = details.freeze
21
+ end
22
+
23
+ # The path to the invalid argument.
24
+ attr :path
25
+
26
+ # The machine-readable error code.
27
+ attr :code
28
+
29
+ # Additional error details.
30
+ attr :details
31
+ end
32
+
33
+ # Raised when parsed parameters are invalid.
34
+ class ValidationError < Protocol::Content::Error
35
+ # Initialize the validation error.
36
+ # @parameter result [Result] The invalid parameters result.
37
+ def initialize(result)
38
+ @result = result
39
+ super("Content parameters are invalid!")
40
+ end
41
+
42
+ # The invalid parameters result.
43
+ attr :result
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,305 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Protocol
7
+ module Content
8
+ module Parameters
9
+ # Common behavior for fields in a parameter model.
10
+ class Field
11
+ def initialize(name, required:)
12
+ @name = -name.to_s
13
+ @required = required
14
+ end
15
+
16
+ attr :name
17
+
18
+ def required?
19
+ return @required
20
+ end
21
+
22
+ def upload_field(path)
23
+ return nil
24
+ end
25
+
26
+ end
27
+
28
+ class ValueField < Field
29
+ def initialize(name, type, required:, nullable:)
30
+ super(name, required:)
31
+ @type = type
32
+ @nullable = nullable
33
+ end
34
+
35
+ def apply(value, output, errors, path)
36
+ value = Value.materialize(value)
37
+
38
+ # Reject nil unless the field is explicitly nullable:
39
+ if value.nil?
40
+ if @nullable
41
+ output[@name] = nil
42
+ else
43
+ errors << Error.new(path, :invalid_type, expected: Type.expected(@type), value: value)
44
+ end
45
+
46
+ return
47
+ end
48
+
49
+ # Treat input conversion failures as validation errors:
50
+ output[@name] = @type.call(value)
51
+ rescue ArgumentError, TypeError
52
+ errors << Error.new(path, :invalid_type, expected: Type.expected(@type), value: value)
53
+ end
54
+
55
+ end
56
+
57
+ class UploadField < Field
58
+ def initialize(name, required:, multiple:, accept:, size_limit:)
59
+ super(name, required:)
60
+ @multiple = multiple
61
+ @accept = accept
62
+ @size_limit = size_limit
63
+ end
64
+
65
+ # Process an upload while its multipart input is available.
66
+ #
67
+ # The upload handler must consume or store the streaming upload before parsing can continue. Its return value, or any validation failure, is preserved as an internal value for the later field validation phase.
68
+ def process(name, upload)
69
+ upload = Upload.new(upload, size_limit: @size_limit)
70
+
71
+ if @accept
72
+ media_type = upload.media_type
73
+
74
+ unless media_type
75
+ return Value::Invalid.new(:unsupported_media_type, media_type:, accepted: @accept)
76
+ end
77
+
78
+ unless @accept.include?(media_type)
79
+ return Value::Invalid.new(:unsupported_media_type, media_type:, accepted: @accept)
80
+ end
81
+ end
82
+
83
+ begin
84
+ stored = yield(name, upload)
85
+ upload.discard
86
+ return Value::Uploaded.new(stored)
87
+ rescue Upload::LimitError
88
+ return Value::Invalid.new(:too_large, limit: upload.size_limit, size: upload.size)
89
+ end
90
+ end
91
+
92
+ def upload_field(path)
93
+ if @multiple
94
+ # Upload collections require anonymous array notation:
95
+ accepted = (path == [""])
96
+ else
97
+ accepted = path.empty?
98
+ end
99
+
100
+ if accepted
101
+ return self
102
+ end
103
+
104
+ return nil
105
+ end
106
+
107
+ def apply(value, output, errors, path)
108
+ # Resolve the outcome produced while the upload was streamed:
109
+ if @multiple
110
+ return apply_multiple(value, output, errors, path)
111
+ end
112
+
113
+ apply_upload(value, errors, path) do |stored|
114
+ output[@name] = stored
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ # Apply a streaming upload outcome, rejecting ordinary parameter values:
121
+ def apply_upload(value, errors, path, &block)
122
+ if value.respond_to?(:apply_upload)
123
+ return value.apply_upload(errors, path, &block)
124
+ end
125
+
126
+ errors << Error.new(path, :invalid_type, expected: :upload, value: Value.materialize(value))
127
+ return false
128
+ end
129
+
130
+ def apply_multiple(value, output, errors, path)
131
+ # Upload collections must be represented as arrays by the content parser:
132
+ unless value.is_a?(Array)
133
+ errors << Error.new(path, :invalid_type, expected: Array, value: Value.materialize(value))
134
+ return
135
+ end
136
+
137
+ # Omit collections containing only uploads which were not handled:
138
+ if value.any? && value.all?{|item| item.equal?(Value::OMITTED)}
139
+ if @required
140
+ errors << Error.new(path, :required)
141
+ end
142
+
143
+ return
144
+ end
145
+
146
+ result = []
147
+ submitted = false
148
+
149
+ value.each_with_index do |item, index|
150
+ if apply_upload(item, errors, path + [index]){|stored| result << stored}
151
+ submitted = true
152
+ end
153
+ end
154
+
155
+ # Avoid reporting a required error when an upload was submitted but rejected:
156
+ if @required && result.empty? && !submitted
157
+ errors << Error.new(path, :required)
158
+ end
159
+
160
+ output[@name] = result
161
+ end
162
+
163
+ end
164
+
165
+ class ArrayField < Field
166
+ def initialize(name, type, model, required:, nullable:)
167
+ super(name, required:)
168
+ @type = type
169
+ @model = model
170
+ @nullable = nullable
171
+ end
172
+
173
+ def upload_field(path)
174
+ # Uploads in arrays must target a declared field on an anonymous element:
175
+ unless @model
176
+ return nil
177
+ end
178
+
179
+ index, *remaining = path
180
+
181
+ unless index&.empty?
182
+ return nil
183
+ end
184
+
185
+ return @model.upload_field(remaining)
186
+ end
187
+
188
+ def apply(value, output, errors, path)
189
+ # Validate the array itself before processing its elements:
190
+ if value.nil?
191
+ if @nullable
192
+ output[@name] = nil
193
+ else
194
+ errors << Error.new(path, :invalid_type, expected: Array, value: value)
195
+ end
196
+
197
+ return
198
+ end
199
+
200
+ unless value.is_a?(Array)
201
+ errors << Error.new(path, :invalid_type, expected: Array, value: value)
202
+ return
203
+ end
204
+
205
+ result = []
206
+
207
+ value.each_with_index do |item, index|
208
+ item_path = path + [index]
209
+
210
+ # Ignore uploads which were not accepted by the field:
211
+ if item.equal?(Value::OMITTED)
212
+ next
213
+ end
214
+
215
+ # Nested arrays validate each element as its own argument hierarchy:
216
+ if @model
217
+ result << @model.apply(item, errors, item_path)
218
+ elsif @type
219
+ # Typed arrays reject nil rather than passing it to coercion:
220
+ if item.nil?
221
+ errors << Error.new(item_path, :invalid_type, expected: Type.expected(@type), value: item)
222
+ next
223
+ end
224
+
225
+ begin
226
+ item = Value.materialize(item)
227
+ item = @type.call(item)
228
+ result << item
229
+ rescue ArgumentError, TypeError
230
+ errors << Error.new(item_path, :invalid_type, expected: Type.expected(@type), value: item)
231
+ end
232
+ else
233
+ result << Value.materialize(item)
234
+ end
235
+ end
236
+
237
+ output[@name] = result
238
+ end
239
+
240
+ def freeze
241
+ return self if self.frozen?
242
+
243
+ if @model
244
+ @model.freeze
245
+ end
246
+
247
+ super
248
+ end
249
+
250
+ end
251
+
252
+ class NestedField < Field
253
+ def initialize(name, model, required:, nullable:)
254
+ super(name, required:)
255
+ @model = model
256
+ @nullable = nullable
257
+ end
258
+
259
+ def upload_field(path)
260
+ unless @model
261
+ return nil
262
+ end
263
+
264
+ return @model.upload_field(path)
265
+ end
266
+
267
+ def apply(value, output, errors, path)
268
+ # Nested fields require a key/value hierarchy:
269
+ if value.nil?
270
+ if @nullable
271
+ output[@name] = nil
272
+ else
273
+ errors << Error.new(path, :invalid_type, expected: Hash, value: value)
274
+ end
275
+
276
+ return
277
+ end
278
+
279
+ unless value.is_a?(Hash)
280
+ errors << Error.new(path, :invalid_type, expected: Hash, value: value)
281
+ return
282
+ end
283
+
284
+ if @model
285
+ output[@name] = @model.apply(value, errors, path)
286
+ else
287
+ output[@name] = Value.materialize(value)
288
+ end
289
+ end
290
+
291
+ def freeze
292
+ return self if self.frozen?
293
+
294
+ if @model
295
+ @model.freeze
296
+ end
297
+
298
+ super
299
+ end
300
+ end
301
+
302
+ private_constant :Field, :ValueField, :UploadField, :ArrayField, :NestedField
303
+ end
304
+ end
305
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "protocol/multipart/form_data"
7
+ require "protocol/url/encoding"
8
+
9
+ module Protocol
10
+ module Content
11
+ module Parameters
12
+ # An immutable model for parsing, filtering, and validating parameters.
13
+ class Model
14
+ # Initialize a parameter model.
15
+ # @parameter parser [Parser] The content parser.
16
+ # @parameter fields [Hash] The parameter fields.
17
+ # @parameter strict [Boolean] Whether unknown fields should produce validation errors.
18
+ def initialize(parser, fields, strict: true)
19
+ @parser = parser
20
+ @fields = fields
21
+ @strict = strict
22
+ end
23
+
24
+ # The fields in this model, indexed by name.
25
+ attr :fields
26
+
27
+ # Parse, filter, and validate content parameters.
28
+ # @parameter media_type [String | Protocol::Media::Type | Nil] The content media type.
29
+ # @parameter input [Object] The readable content input.
30
+ # @yields {|name, upload| ...} Each streaming upload. Its return value is inserted into the parsed value.
31
+ # @returns [Result] The parsed value and validation errors.
32
+ def parse(media_type, input, &upload_handler)
33
+ # Replace ephemeral multipart uploads with outcomes which can survive until validation:
34
+ value = @parser.parse(media_type, input) do |name, item|
35
+ if item.is_a?(Protocol::Multipart::FormData::Upload)
36
+ path = Protocol::URL::Encoding.split(name)
37
+
38
+ # Only process uploads accepted by an explicit field:
39
+ if upload_handler && field = upload_field(path)
40
+ field.process(name, item, &upload_handler)
41
+ else
42
+ Value::OMITTED
43
+ end
44
+ else
45
+ item
46
+ end
47
+ end
48
+
49
+ # Apply the model after parsing so ordinary values and upload outcomes follow the same hierarchy:
50
+ errors = []
51
+ value = apply(value, errors)
52
+ return Result.new(value, errors)
53
+ end
54
+
55
+ # Parse content parameters, raising when validation fails.
56
+ # @parameter media_type [String | Protocol::Media::Type | Nil] The content media type.
57
+ # @parameter input [Object] The readable content input.
58
+ # @yields {|name, upload| ...} Each streaming upload. Its return value is inserted into the parsed value.
59
+ # @returns [Hash] The valid value.
60
+ # @raises [ValidationError] If validation fails.
61
+ def parse!(media_type, input, &block)
62
+ result = parse(media_type, input, &block)
63
+
64
+ if result.valid?
65
+ return result.value
66
+ end
67
+
68
+ raise ValidationError, result
69
+ end
70
+
71
+ # Apply this model to an existing argument hierarchy.
72
+ # @parameter value [Object] The parameter hierarchy.
73
+ # @parameter errors [Array(Error)] The validation error destination.
74
+ # @parameter path [Array(String | Integer)] The current argument path.
75
+ # @returns [Hash] The filtered and converted value.
76
+ def apply(value, errors, path = [])
77
+ # Parameter models always apply to a key/value hierarchy:
78
+ unless value.is_a?(Hash)
79
+ errors << Error.new(path, :invalid_type, expected: Hash, value: value)
80
+ return {}
81
+ end
82
+
83
+ # Copy the input so declared fields can be removed without modifying caller-owned data:
84
+ input = value.dup
85
+ output = {}
86
+
87
+ # Apply declared values and collect missing required parameters:
88
+ @fields.each do |name, field|
89
+ item_path = path + [name]
90
+
91
+ if input.key?(name)
92
+ item = input.delete(name)
93
+
94
+ if item.equal?(Value::OMITTED)
95
+ if field.required?
96
+ errors << Error.new(item_path, :required)
97
+ end
98
+ else
99
+ field.apply(item, output, errors, item_path)
100
+ end
101
+ elsif field.required?
102
+ errors << Error.new(item_path, :required)
103
+ end
104
+ end
105
+
106
+ # Reject remaining undeclared values when strict validation is enabled:
107
+ if @strict
108
+ input.each_key do |name|
109
+ errors << Error.new(path + [name], :unknown)
110
+ end
111
+ end
112
+
113
+ return output
114
+ end
115
+
116
+ # Whether an upload path is explicitly accepted by this model.
117
+ # @parameter path [Array(String)] The decoded upload path.
118
+ # @returns [Boolean] Whether the upload is accepted.
119
+ def accepts_upload?(path)
120
+ return !!upload_field(path)
121
+ end
122
+
123
+ # Find the upload field which accepts the given decoded path.
124
+ # @parameter path [Array(String)] The decoded upload path.
125
+ # @returns [UploadField | Nil] The accepting upload field.
126
+ def upload_field(path)
127
+ # Walk fields using the decoded components of the form name:
128
+ name, *remaining = path
129
+
130
+ unless field = @fields[name]
131
+ return nil
132
+ end
133
+
134
+ return field.upload_field(remaining)
135
+ end
136
+
137
+ # Freeze this model and its fields.
138
+ # @returns [self] The frozen model.
139
+ def freeze
140
+ return self if self.frozen?
141
+
142
+ @parser.freeze
143
+ @fields.each_value(&:freeze)
144
+ @fields.freeze
145
+ super
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Protocol
7
+ module Content
8
+ module Parameters
9
+ # The result of parsing and validating content parameters.
10
+ class Result
11
+ # Initialize the result.
12
+ # @parameter value [Hash] The converted and filtered value.
13
+ # @parameter errors [Array(Error)] The validation errors.
14
+ def initialize(value, errors)
15
+ @value = value
16
+ @errors = errors.freeze
17
+ end
18
+
19
+ # The converted and filtered value.
20
+ attr :value
21
+
22
+ # The validation errors.
23
+ attr :errors
24
+
25
+ # Fetch an entry from the result value.
26
+ # @parameter key [Object] The value key.
27
+ # @returns [Object | Nil] The corresponding value.
28
+ def [](key)
29
+ return @value[key]
30
+ end
31
+
32
+ # Fetch an entry nested within the result value.
33
+ # @parameter path [Array(Object)] The nested value path.
34
+ # @returns [Object | Nil] The corresponding value.
35
+ def dig(*path)
36
+ return @value.dig(*path)
37
+ end
38
+
39
+ # Whether the parameters are valid.
40
+ # @returns [Boolean] True when there are no validation errors.
41
+ def valid?
42
+ return @errors.empty?
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Protocol
7
+ module Content
8
+ module Parameters
9
+ # Converts input values to a specific application type.
10
+ class Type
11
+ # Resolve the expected output type of a converter.
12
+ def self.expected(type)
13
+ if type.respond_to?(:type)
14
+ return type.type
15
+ else
16
+ return type
17
+ end
18
+ end
19
+
20
+ # Initialize a type converter.
21
+ # @parameter type [Object] The expected converted type.
22
+ # @yields {|value| ...} The conversion operation.
23
+ def initialize(type, &converter)
24
+ @type = type
25
+ @converter = converter
26
+ end
27
+
28
+ # The expected converted type.
29
+ attr :type
30
+
31
+ # Convert a value to the declared type.
32
+ # @parameter value [Object] The input value.
33
+ # @returns [Object] The converted value.
34
+ # @raises [TypeError] If the value cannot be converted.
35
+ def call(value)
36
+ # Preserve values which already have the expected type:
37
+ if @type === value
38
+ return value
39
+ end
40
+
41
+ if @converter
42
+ value = @converter.call(value)
43
+
44
+ # Ensure converters produce the type they declare:
45
+ if @type === value
46
+ return value
47
+ end
48
+ end
49
+
50
+ raise TypeError, "Could not convert #{value.inspect} to #{@type}!"
51
+ end
52
+ end
53
+
54
+ private_constant :Type
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "protocol/media/type"
7
+ require "protocol/media/registry"
8
+ require "protocol/multipart/readable"
9
+
10
+ module Protocol
11
+ module Content
12
+ module Parameters
13
+ # A field-constrained streaming upload.
14
+ class Upload
15
+ include Protocol::Multipart::Readable
16
+
17
+ GENERIC_MEDIA_TYPE = "application/octet-stream"
18
+ private_constant :GENERIC_MEDIA_TYPE
19
+
20
+ # Raised when a streaming upload exceeds its field size limit.
21
+ class LimitError < StandardError
22
+ end
23
+
24
+ # Initialize a constrained upload.
25
+ # @parameter delegate [Object] The underlying streaming upload.
26
+ # @parameter size_limit [Integer | Nil] The maximum accepted size.
27
+ def initialize(delegate, size_limit: nil)
28
+ @delegate = delegate
29
+ @size_limit = size_limit
30
+ @size = 0
31
+ @declared_media_type = nil
32
+
33
+ if header = delegate.headers["content-type"]
34
+ @declared_media_type = Protocol::Media::Type.parse(header.to_s)
35
+ end
36
+
37
+ @media_type = @declared_media_type
38
+
39
+ # Fall back to the submitted filename when the declared type carries no useful classification:
40
+ if !@media_type || @media_type.name == GENERIC_MEDIA_TYPE
41
+ if record = Protocol::Media::Registry.for_path(self.filename)
42
+ if record.type.name != GENERIC_MEDIA_TYPE
43
+ @media_type = record.type
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ # The submitted filename.
50
+ def filename
51
+ return @delegate.filename
52
+ end
53
+
54
+ # The multipart headers associated with this upload.
55
+ def headers
56
+ return @delegate.headers
57
+ end
58
+
59
+ # The media type declared by the submitting client, if present.
60
+ attr :declared_media_type
61
+
62
+ # The declared media type, or the type inferred from the filename when the declaration is absent or generic.
63
+ attr :media_type
64
+
65
+ # The maximum accepted size, if configured.
66
+ attr :size_limit
67
+
68
+ # The number of bytes consumed through this constrained upload.
69
+ attr :size
70
+
71
+ # Whether the complete upload has been consumed.
72
+ def ended?
73
+ return @delegate.ended?
74
+ end
75
+
76
+ # Iterate over the upload while enforcing its field size limit.
77
+ # @parameter chunk_size [Integer] The maximum chunk size.
78
+ # @yields {|chunk| ...} Each upload chunk.
79
+ # @returns [self] The upload.
80
+ # @raises [LimitError] If the upload exceeds its field size limit.
81
+ def each(chunk_size = 8192)
82
+ return to_enum(:each, chunk_size) unless block_given?
83
+
84
+ @delegate.each(chunk_size) do |chunk|
85
+ @size += chunk.bytesize
86
+
87
+ if @size_limit && @size > @size_limit
88
+ raise LimitError, "Upload size exceeded field limit of #{@size_limit}!"
89
+ end
90
+
91
+ yield chunk
92
+ end
93
+
94
+ return self
95
+ end
96
+
97
+ # Consume any unread upload content while enforcing the field size limit.
98
+ # @returns [Nil] The upload content is discarded.
99
+ def discard
100
+ each {|_chunk|}
101
+ return nil
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Protocol
7
+ module Content
8
+ module Parameters
9
+ # Internal values carry upload outcomes from streaming parsing into field validation.
10
+ module Value
11
+ class Omitted
12
+ def apply_upload(errors, path)
13
+ return false
14
+ end
15
+ end
16
+
17
+ OMITTED = Omitted.new.freeze
18
+
19
+ class Invalid
20
+ def initialize(code, **details)
21
+ @code = code
22
+ @details = details
23
+ end
24
+
25
+ attr :code
26
+ attr :details
27
+
28
+ def apply_upload(errors, path)
29
+ errors << Error.new(path, @code, **@details)
30
+ return true
31
+ end
32
+ end
33
+
34
+ class Uploaded
35
+ def initialize(value)
36
+ @value = value
37
+ end
38
+
39
+ attr :value
40
+
41
+ def apply_upload(errors, path)
42
+ yield(@value)
43
+ return true
44
+ end
45
+ end
46
+
47
+ def self.materialize(value)
48
+ case value
49
+ when Hash
50
+ result = {}
51
+ value.each do |key, item|
52
+ # Remove omitted uploads while preserving the surrounding hierarchy:
53
+ unless item.equal?(OMITTED)
54
+ result[key.to_s] = materialize(item)
55
+ end
56
+ end
57
+ return result
58
+ when Array
59
+ # Remove omitted uploads while preserving accepted array values:
60
+ return value.filter_map do |item|
61
+ unless item.equal?(OMITTED)
62
+ materialize(item)
63
+ end
64
+ end
65
+ else
66
+ return value
67
+ end
68
+ end
69
+ end
70
+
71
+ private_constant :Value
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "default"
7
+ require_relative "parameters/type"
8
+ require_relative "parameters/enumeration"
9
+ require_relative "parameters/error"
10
+ require_relative "parameters/result"
11
+ require_relative "parameters/value"
12
+ require_relative "parameters/upload"
13
+ require_relative "parameters/field"
14
+ require_relative "parameters/model"
15
+ require_relative "parameters/builder"
16
+
17
+ module Protocol
18
+ module Content
19
+ # Builds parameter models for filtering, conversion, and validation.
20
+ module Parameters
21
+ # The built-in parameter type conversions.
22
+ TYPES = {
23
+ Integer => ->(value) do
24
+ # Reject non-string values rather than relying on implicit numeric coercion:
25
+ unless value.is_a?(String)
26
+ raise TypeError
27
+ end
28
+
29
+ Integer(value, 10)
30
+ end,
31
+ Float => ->(value){Float(value)},
32
+ }.freeze
33
+
34
+ # Build an immutable parameter model.
35
+ # @parameter parser [Parser] The content parser.
36
+ # @parameter types [Hash] The available type conversions.
37
+ # @parameter strict [Boolean] Whether unknown fields should produce validation errors.
38
+ # @yields The parameter fields.
39
+ # @returns [Model] The frozen parameter model.
40
+ def self.build(parser: Parser.default, types: TYPES, strict: true, &block)
41
+ return Builder.new(parser:, types:, strict:).build(&block)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -3,7 +3,6 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
- require "protocol/media/map"
7
6
  require "protocol/media/type"
8
7
 
9
8
  require_relative "error"
@@ -24,17 +23,17 @@ module Protocol
24
23
 
25
24
  # Initialize an empty parser.
26
25
  def initialize
27
- @handlers = Protocol::Media::Map.new
26
+ @handlers = {}
28
27
  end
29
28
 
30
- # Register a handler for a media type or range.
31
- # @parameter media_range [String | Protocol::Media::Range] The accepted media type or range.
29
+ # Register a handler for a media type.
30
+ # @parameter media_type [String | Protocol::Media::Type] The accepted media type.
32
31
  # @parameter handler [#call | Nil] The content handler.
33
32
  # @yields {|input, media_type| ...} The content to parse.
34
33
  # @parameter input [Object] The readable input.
35
34
  # @parameter media_type [Protocol::Media::Type] The parsed media type.
36
35
  # @returns [#call] The registered handler.
37
- def register(media_range, handler = nil, &block)
36
+ def register(media_type, handler = nil, &block)
38
37
  if handler && block
39
38
  raise ArgumentError, "Provide either a handler or a block!"
40
39
  end
@@ -45,7 +44,8 @@ module Protocol
45
44
  raise ArgumentError, "A content handler must respond to #call!"
46
45
  end
47
46
 
48
- @handlers[media_range] = handler
47
+ media_type = Protocol::Media::Type.for(media_type)
48
+ @handlers[media_type.name] = handler
49
49
  return handler
50
50
  end
51
51
 
@@ -55,10 +55,12 @@ module Protocol
55
55
  # @yields {...} An optional block forwarded to the selected content handler.
56
56
  # @returns [Object] The parsed content value.
57
57
  def parse(media_type, input, &block)
58
- media_type = Protocol::Media::Type.for(media_type)
59
-
60
- if media_type && handler = @handlers[media_type]
61
- return handler.call(input, media_type, &block)
58
+ if media_type
59
+ media_type = Protocol::Media::Type.for(media_type)
60
+
61
+ if handler = @handlers[media_type.name]
62
+ return handler.call(input, media_type, &block)
63
+ end
62
64
  end
63
65
 
64
66
  raise UnsupportedMediaTypeError, media_type
@@ -67,6 +69,8 @@ module Protocol
67
69
  # Freeze the parser and its handler registry.
68
70
  # @returns [self] The frozen parser.
69
71
  def freeze
72
+ return self if self.frozen?
73
+
70
74
  @handlers.freeze
71
75
  super
72
76
  end
@@ -7,6 +7,6 @@
7
7
  module Protocol
8
8
  # Models media-typed content carried by protocol messages.
9
9
  module Content
10
- VERSION = "0.1.0"
10
+ VERSION = "0.2.0"
11
11
  end
12
12
  end
@@ -6,6 +6,7 @@
6
6
  require_relative "content/version"
7
7
  require_relative "content/error"
8
8
  require_relative "content/parser"
9
+ require_relative "content/parameters"
9
10
 
10
11
  module Protocol
11
12
  # @namespace
data/readme.md CHANGED
@@ -10,10 +10,18 @@ Please see the [project documentation](https://socketry.github.io/protocol-conte
10
10
 
11
11
  - [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.
12
12
 
13
+ - [Content Parameters](https://socketry.github.io/protocol-content/guides/parameters/index) - This guide explains how to build a parameter model that interprets parsed content as operation-specific arguments using <code class="language-ruby">Protocol::Content::Parameters</code>.
14
+
13
15
  ## Releases
14
16
 
15
17
  Please see the [project releases](https://socketry.github.io/protocol-content/releases/index) for all releases.
16
18
 
19
+ ### v0.2.0
20
+
21
+ - Add declarative content parameter filtering, conversion, validation, and upload handling.
22
+ - Add exact enumeration validation and input mapping for content parameters.
23
+ - Add field-specific upload media type and size constraints.
24
+
17
25
  ### v0.1.0
18
26
 
19
27
  - Add media-type parser dispatch for readable content.
data/releases.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Releases
2
2
 
3
+ ## v0.2.0
4
+
5
+ - Add declarative content parameter filtering, conversion, validation, and upload handling.
6
+ - Add exact enumeration validation and input mapping for content parameters.
7
+ - Add field-specific upload media type and size constraints.
8
+
3
9
  ## v0.1.0
4
10
 
5
11
  - Add media-type parser dispatch for readable content.
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: protocol-content
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -54,6 +54,20 @@ dependencies:
54
54
  version: '2.0'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: protocol-media
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.2'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.2'
69
+ - !ruby/object:Gem::Dependency
70
+ name: protocol-media-registry
57
71
  requirement: !ruby/object:Gem::Requirement
58
72
  requirements:
59
73
  - - "~>"
@@ -72,14 +86,14 @@ dependencies:
72
86
  requirements:
73
87
  - - "~>"
74
88
  - !ruby/object:Gem::Version
75
- version: '0.6'
89
+ version: '0.7'
76
90
  type: :runtime
77
91
  prerelease: false
78
92
  version_requirements: !ruby/object:Gem::Requirement
79
93
  requirements:
80
94
  - - "~>"
81
95
  - !ruby/object:Gem::Version
82
- version: '0.6'
96
+ version: '0.7'
83
97
  - !ruby/object:Gem::Dependency
84
98
  name: protocol-url
85
99
  requirement: !ruby/object:Gem::Requirement
@@ -102,6 +116,16 @@ files:
102
116
  - lib/protocol/content/default.rb
103
117
  - lib/protocol/content/error.rb
104
118
  - lib/protocol/content/json_parser.rb
119
+ - lib/protocol/content/parameters.rb
120
+ - lib/protocol/content/parameters/builder.rb
121
+ - lib/protocol/content/parameters/enumeration.rb
122
+ - lib/protocol/content/parameters/error.rb
123
+ - lib/protocol/content/parameters/field.rb
124
+ - lib/protocol/content/parameters/model.rb
125
+ - lib/protocol/content/parameters/result.rb
126
+ - lib/protocol/content/parameters/type.rb
127
+ - lib/protocol/content/parameters/upload.rb
128
+ - lib/protocol/content/parameters/value.rb
105
129
  - lib/protocol/content/parser.rb
106
130
  - lib/protocol/content/version.rb
107
131
  - license.md
metadata.gz.sig CHANGED
Binary file