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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/lib/protocol/content/parameters/builder.rb +138 -0
- data/lib/protocol/content/parameters/enumeration.rb +44 -0
- data/lib/protocol/content/parameters/error.rb +47 -0
- data/lib/protocol/content/parameters/field.rb +305 -0
- data/lib/protocol/content/parameters/model.rb +150 -0
- data/lib/protocol/content/parameters/result.rb +47 -0
- data/lib/protocol/content/parameters/type.rb +57 -0
- data/lib/protocol/content/parameters/upload.rb +106 -0
- data/lib/protocol/content/parameters/value.rb +74 -0
- data/lib/protocol/content/parameters.rb +45 -0
- data/lib/protocol/content/parser.rb +14 -10
- data/lib/protocol/content/version.rb +1 -1
- data/lib/protocol/content.rb +1 -0
- data/readme.md +8 -0
- data/releases.md +6 -0
- data.tar.gz.sig +0 -0
- metadata +27 -3
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f4a6369c7d76fff09f34f7fb56203e92dfe32ae5c2346c542124e7b9006fca84
|
|
4
|
+
data.tar.gz: '0686b2a1d3f10dcc779e4f32ef78d342f016779cdafd458ec13ecec60983c7ed'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
26
|
+
@handlers = {}
|
|
28
27
|
end
|
|
29
28
|
|
|
30
|
-
# Register a handler for a media type
|
|
31
|
-
# @parameter
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
data/lib/protocol/content.rb
CHANGED
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.
|
|
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.
|
|
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.
|
|
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
|