mailschema 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 +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE +21 -0
- data/README.md +114 -0
- data/contexts/map-0.2.jsonld +91 -0
- data/lib/mailschema/artifacts.rb +94 -0
- data/lib/mailschema/capability.rb +41 -0
- data/lib/mailschema/contract.rb +331 -0
- data/lib/mailschema/document.rb +79 -0
- data/lib/mailschema/documents.rb +127 -0
- data/lib/mailschema/forms.rb +41 -0
- data/lib/mailschema/http.rb +25 -0
- data/lib/mailschema/jcs.rb +87 -0
- data/lib/mailschema/limits.rb +56 -0
- data/lib/mailschema/message.rb +17 -0
- data/lib/mailschema/pointer.rb +46 -0
- data/lib/mailschema/references.rb +62 -0
- data/lib/mailschema/schema_walk.rb +29 -0
- data/lib/mailschema/version.rb +5 -0
- data/lib/mailschema.rb +41 -0
- data/schemas/contribution.schema.json +874 -0
- data/schemas/forms-0.1.schema.json +329 -0
- data/schemas/map-0.2.schema.json +1033 -0
- data/schemas/type-contract-0.2.schema.json +179 -0
- data/sig/mailschema.rbs +92 -0
- metadata +123 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Type contracts an implementation vendors, verified by digest and compiled once.
|
|
4
|
+
module Mailschema
|
|
5
|
+
# A type contract that fails its own checks or its pinned digest.
|
|
6
|
+
class InvalidContract < Error; end
|
|
7
|
+
|
|
8
|
+
# Reasons the core assigns to the approval lifecycle.
|
|
9
|
+
APPROVAL_REASONS = %w[declined stale-target expired superseded].freeze
|
|
10
|
+
|
|
11
|
+
# A type contract an implementation vendors, verified against the digest it was
|
|
12
|
+
# pinned by and compiled once. It checks the descriptions, requests, inputs and
|
|
13
|
+
# results of that exact type. The Registry has already applied the contract rules,
|
|
14
|
+
# such as portable patterns, to the contract that digest names; loading refuses
|
|
15
|
+
# anything that would otherwise fail at request time.
|
|
16
|
+
class Contract
|
|
17
|
+
# A MAP problem a request earns before the service's own state is consulted.
|
|
18
|
+
Problem = Data.define(:code, :title, :detail)
|
|
19
|
+
|
|
20
|
+
FIELD_VALIDATORS = 128
|
|
21
|
+
private_constant :FIELD_VALIDATORS
|
|
22
|
+
|
|
23
|
+
attr_reader :document, :digest, :request_schema
|
|
24
|
+
|
|
25
|
+
# `digest` is the contract digest the implementation pinned; `dependencies`
|
|
26
|
+
# supplies, by URL, any pinned schema the gem does not bundle.
|
|
27
|
+
def initialize(contract, request_schema, digest:, dependencies: {})
|
|
28
|
+
errors = Validation.errors(CONTRACTS, contract)
|
|
29
|
+
raise InvalidContract, "invalid type contract: #{errors.join("; ")}" if errors.any?
|
|
30
|
+
raise InvalidContract, "the contract is for another MAP profile" unless contract["profile"] == PROFILE
|
|
31
|
+
|
|
32
|
+
@document = frozen_copy(contract)
|
|
33
|
+
@digest = Mailschema.digest(@document)
|
|
34
|
+
raise InvalidContract, "the contract digest is #{@digest}, not the pinned #{digest}" unless @digest == digest
|
|
35
|
+
|
|
36
|
+
@request_schema = frozen_copy(request_schema)
|
|
37
|
+
@references = pin(@document.fetch("dependencies", []), dependencies)
|
|
38
|
+
verify_structure
|
|
39
|
+
compile
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def id = document.fetch("id")
|
|
43
|
+
def version = document.fetch("version")
|
|
44
|
+
|
|
45
|
+
# The type reference a description and a request of this contract name exactly.
|
|
46
|
+
def type_reference = { "id" => id, "version" => version, "contractDigest" => digest }
|
|
47
|
+
|
|
48
|
+
def operation(id) = document.fetch("operations").find { |operation| operation.fetch("id") == id }
|
|
49
|
+
|
|
50
|
+
# Whether completing the operation decides the interaction.
|
|
51
|
+
def decision?(id)
|
|
52
|
+
found = operation(id) or raise ArgumentError, "#{id} is not an operation of this type"
|
|
53
|
+
!found.fetch("repeatable", false)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Every rule a description must satisfy beyond the core schema, for the service
|
|
57
|
+
# that issues it and the client that receives it.
|
|
58
|
+
def description_errors(description)
|
|
59
|
+
errors = Mailschema.description_errors(description)
|
|
60
|
+
return errors if errors.any?
|
|
61
|
+
return ["The description names another type contract."] unless description.fetch("type") == type_reference
|
|
62
|
+
|
|
63
|
+
problems = details_problems(description)
|
|
64
|
+
problems.concat(operation_problems(description))
|
|
65
|
+
unless Time.iso8601(description.fetch("describedAt")) < Time.iso8601(description.fetch("expiresAt"))
|
|
66
|
+
problems << "The interaction expires before it was described."
|
|
67
|
+
end
|
|
68
|
+
problems.concat(Capability.problems(description)) if description.dig("service", "authority") == "possession"
|
|
69
|
+
problems
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The checks a service makes on a request once it has resolved the description it
|
|
73
|
+
# issued, compared the description digest and established the caller: the exact
|
|
74
|
+
# type, an offered operation the authority permits, and expiry. Nil when they
|
|
75
|
+
# pass. The service then applies its own state (a decided interaction, a stale
|
|
76
|
+
# target) and `input_errors`, in that order, before any effect.
|
|
77
|
+
def request_problem(description, request, now:)
|
|
78
|
+
unless request["type"] == type_reference && description["type"] == type_reference
|
|
79
|
+
return Problem.new("unsupported-type", "Unsupported interaction type",
|
|
80
|
+
"The service does not implement this exact interaction type contract.")
|
|
81
|
+
end
|
|
82
|
+
unless offered?(description, request["operation"])
|
|
83
|
+
return Problem.new("unsupported-operation", "Unsupported operation",
|
|
84
|
+
"The operation was not offered in this interaction.")
|
|
85
|
+
end
|
|
86
|
+
return unless Mailschema.reached?(now, description.fetch("expiresAt"))
|
|
87
|
+
|
|
88
|
+
Problem.new("expired-interaction", "Interaction expired",
|
|
89
|
+
"The interaction expired before the request was processed.")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Input problems for one request, each with a detail and a JSON Pointer into its
|
|
93
|
+
# input: the operation's input schema, then its field bindings against the
|
|
94
|
+
# description's details. Type rules a contract cannot express are the caller's.
|
|
95
|
+
def input_errors(description, request)
|
|
96
|
+
found = operation(request["operation"])
|
|
97
|
+
schema = @inputs[request["operation"]]
|
|
98
|
+
return [problem("The operation is not part of this type.", "")] unless found && schema
|
|
99
|
+
|
|
100
|
+
errors = pointer_errors(schema, request["input"])
|
|
101
|
+
return errors.first(100) if errors.any?
|
|
102
|
+
|
|
103
|
+
found.fetch("fieldBindings", [])
|
|
104
|
+
.flat_map { |binding| binding_errors(binding, description["details"], request["input"]) }
|
|
105
|
+
.first(100)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The core result definition, then the output schema and reason this contract
|
|
109
|
+
# declares for the result's operation and state.
|
|
110
|
+
def result_errors(result)
|
|
111
|
+
errors = Mailschema.result_errors(result)
|
|
112
|
+
return errors if errors.any?
|
|
113
|
+
return ["The result names another type contract."] unless result.fetch("type") == type_reference
|
|
114
|
+
|
|
115
|
+
found = operation(result.fetch("operation"))
|
|
116
|
+
return ["#{result["operation"]} is not an operation of this type."] unless found
|
|
117
|
+
|
|
118
|
+
declared = found.fetch("results").find { |entry| entry.fetch("state") == result.fetch("state") }
|
|
119
|
+
return ["#{found["id"]} does not declare the state #{result["state"]}."] unless declared
|
|
120
|
+
|
|
121
|
+
problems = Validation.errors(@outputs.fetch([found["id"], declared["state"]]), result.fetch("output"), "/output")
|
|
122
|
+
if result["state"] == "failed" && !declared.fetch("reasons", []).include?(result["reason"])
|
|
123
|
+
problems << "#{found["id"]} does not declare the reason #{result["reason"]}."
|
|
124
|
+
end
|
|
125
|
+
problems
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def frozen_copy(value) = deep_freeze(JSON.parse(JSON.generate(value)))
|
|
131
|
+
|
|
132
|
+
def deep_freeze(value)
|
|
133
|
+
case value
|
|
134
|
+
when Hash then value.each_value { |member| deep_freeze(member) }
|
|
135
|
+
when Array then value.each { |item| deep_freeze(item) }
|
|
136
|
+
end
|
|
137
|
+
value.freeze
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def pin(dependencies, supplied)
|
|
141
|
+
references = dependencies.to_h do |dependency|
|
|
142
|
+
url = dependency.fetch("url")
|
|
143
|
+
schema = BUNDLED[url] || supplied[url]
|
|
144
|
+
raise InvalidContract, "unknown dependency #{url}" unless schema
|
|
145
|
+
unless Mailschema.digest(schema) == dependency.fetch("canonicalDigest")
|
|
146
|
+
raise InvalidContract, "the pinned digest of #{url} differs"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
[url, schema.frozen? ? schema : frozen_copy(schema)]
|
|
150
|
+
end
|
|
151
|
+
raise InvalidContract, "the core schema must be pinned" unless references.key?(CORE_SCHEMA)
|
|
152
|
+
|
|
153
|
+
references.freeze
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def verify_structure
|
|
157
|
+
verify_schemas
|
|
158
|
+
verify_request_schema
|
|
159
|
+
verify_operations
|
|
160
|
+
References.verify(inline_schemas, request_schema, @references, unbundled)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The schemas the contract carries itself: its details and every output.
|
|
164
|
+
def inline_schemas
|
|
165
|
+
outputs = document.fetch("operations").flat_map do |operation|
|
|
166
|
+
operation.fetch("results").map { |result| result["outputSchema"] }
|
|
167
|
+
end
|
|
168
|
+
[document["detailsSchema"], *outputs].compact
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Every schema is valid JSON Schema 2020-12, so no malformed keyword fails when a
|
|
172
|
+
# request arrives.
|
|
173
|
+
def verify_schemas
|
|
174
|
+
[*inline_schemas, request_schema, *unbundled].each do |schema|
|
|
175
|
+
problem = Validation.invalid_schema(schema)
|
|
176
|
+
raise InvalidContract, "a schema is not valid JSON Schema 2020-12: #{problem}" if problem
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def verify_request_schema
|
|
181
|
+
reference = document.fetch("requestSchema")
|
|
182
|
+
unless request_schema["$schema"] == "https://json-schema.org/draft/2020-12/schema"
|
|
183
|
+
raise InvalidContract, "the request schema must declare JSON Schema 2020-12"
|
|
184
|
+
end
|
|
185
|
+
unless request_schema["$id"] == reference["url"]
|
|
186
|
+
raise InvalidContract, "the request schema $id differs from the contract"
|
|
187
|
+
end
|
|
188
|
+
unless Mailschema.digest(request_schema) == reference["canonicalDigest"]
|
|
189
|
+
raise InvalidContract, "the request schema digest differs from the contract"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
constants = SchemaWalk.values(request_schema, "const").grep(String)
|
|
193
|
+
[id, version, *operation_ids].each do |expected|
|
|
194
|
+
raise InvalidContract, "the request schema does not bind #{expected}" unless constants.include?(expected)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def verify_operations
|
|
199
|
+
raise InvalidContract, "duplicate operation identifiers" unless operation_ids.uniq == operation_ids
|
|
200
|
+
|
|
201
|
+
document.fetch("operations").each do |operation|
|
|
202
|
+
states = operation.fetch("results").map { |result| result.fetch("state") }
|
|
203
|
+
raise InvalidContract, "duplicate #{operation["id"]} result states" unless states.uniq == states
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def operation_ids = document.fetch("operations").map { |operation| operation.fetch("id") }
|
|
208
|
+
|
|
209
|
+
# Every validator now, so a pattern that does not compile refuses the contract
|
|
210
|
+
# rather than a request.
|
|
211
|
+
def compile
|
|
212
|
+
references = @references.merge(request_schema.fetch("$id") => request_schema)
|
|
213
|
+
@schema = ->(value) { Validation.schema(value, references) }
|
|
214
|
+
compile_deferred
|
|
215
|
+
@details = @schema.call(document["detailsSchema"]) if document.key?("detailsSchema")
|
|
216
|
+
@inputs = branches.to_h { |operation, input| [operation, @schema.call(input)] }
|
|
217
|
+
@outputs = document.fetch("operations").flat_map do |operation|
|
|
218
|
+
operation.fetch("results").map do |result|
|
|
219
|
+
[[operation["id"], result["state"]], @schema.call(result.fetch("outputSchema"))]
|
|
220
|
+
end
|
|
221
|
+
end.to_h
|
|
222
|
+
resolve_references
|
|
223
|
+
@fields = {}
|
|
224
|
+
rescue RegexpError, JSONSchemer::InvalidEcmaRegexp => e
|
|
225
|
+
raise InvalidContract, "a pattern does not compile: #{e.message}"
|
|
226
|
+
rescue JSONSchemer::InvalidRefPointer, JSONSchemer::InvalidRefResolution, JSONSchemer::UnknownRef => e
|
|
227
|
+
raise InvalidContract, "a reference does not resolve: #{e.message}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The pinned schemas the gem does not bundle.
|
|
231
|
+
def unbundled = @references.reject { |url, _| BUNDLED.key?(url) }.values
|
|
232
|
+
|
|
233
|
+
# What the validator would otherwise compile at first use: pinned schemas the gem
|
|
234
|
+
# does not bundle, and the names of patternProperties.
|
|
235
|
+
def compile_deferred
|
|
236
|
+
unbundled.each { |schema| @schema.call(schema) }
|
|
237
|
+
[document, request_schema, *unbundled]
|
|
238
|
+
.flat_map { |schema| SchemaWalk.values(schema, "patternProperties").grep(Hash).flat_map(&:keys) }
|
|
239
|
+
.each { |pattern| @schema.call({ "pattern" => pattern }) }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Every reference resolved now, into every schema it reaches, as a request would
|
|
243
|
+
# otherwise resolve it first.
|
|
244
|
+
def resolve_references = [@details, *@inputs.values, *@outputs.values].compact.each(&:bundle)
|
|
245
|
+
|
|
246
|
+
# Each operation's branch of the request schema, with its input schema.
|
|
247
|
+
def branches
|
|
248
|
+
last = request_schema.fetch("allOf", []).last || {}
|
|
249
|
+
(last["oneOf"] || [last]).to_h do |branch|
|
|
250
|
+
properties = branch["properties"] || {}
|
|
251
|
+
[properties.dig("operation", "const"), properties.fetch("input", {})]
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def offered?(description, id)
|
|
256
|
+
authority = description.dig("service", "authority")
|
|
257
|
+
description.fetch("operations").any? { |offered| offered["id"] == id } &&
|
|
258
|
+
operation(id)&.fetch("authority")&.include?(authority)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def operation_problems(description)
|
|
262
|
+
ids = description.fetch("operations").map { |offered| offered.fetch("id") }
|
|
263
|
+
authority = description.dig("service", "authority")
|
|
264
|
+
problems = ids.uniq.size == ids.size ? [] : ["An operation is offered twice."]
|
|
265
|
+
ids.each do |offered|
|
|
266
|
+
unless operation(offered)&.fetch("authority")&.include?(authority)
|
|
267
|
+
problems << "#{offered} is not a #{authority} operation of this type."
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
problems
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def details_problems(description)
|
|
274
|
+
details = description.key?("details") ? description["details"] : Pointer::MISSING
|
|
275
|
+
missing = details.equal?(Pointer::MISSING)
|
|
276
|
+
valid = @details ? !missing && @details.valid?(details) : missing
|
|
277
|
+
return ["The details do not satisfy the type contract."] unless valid
|
|
278
|
+
|
|
279
|
+
blocks = document.fetch("operations").flat_map do |operation|
|
|
280
|
+
operation.fetch("fieldBindings", []).map { |binding| binding["fields"] }
|
|
281
|
+
end
|
|
282
|
+
blocks.uniq.flat_map do |pointer|
|
|
283
|
+
fields = Pointer.member(details, pointer)
|
|
284
|
+
fields.equal?(Pointer::MISSING) ? [] : Forms.problems(fields).map { |found| "#{pointer}: #{found}" }
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# A form-shaped input holds only the fields the details define, and satisfies them.
|
|
289
|
+
def binding_errors(binding, details, input)
|
|
290
|
+
fields = Pointer.member(details, binding.fetch("fields"))
|
|
291
|
+
values = Pointer.member(input, binding.fetch("input"))
|
|
292
|
+
if fields.equal?(Pointer::MISSING)
|
|
293
|
+
return [] if values.equal?(Pointer::MISSING)
|
|
294
|
+
|
|
295
|
+
[problem("This interaction defines no fields for these values.", binding["input"])]
|
|
296
|
+
elsif values.equal?(Pointer::MISSING)
|
|
297
|
+
return [] if fields.fetch("required", []).empty?
|
|
298
|
+
|
|
299
|
+
[problem("Values for the required fields are missing.", binding["input"])]
|
|
300
|
+
else
|
|
301
|
+
pointer_errors(fields_schema(fields), values, binding["input"])
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# The compiled schema of a fields block's values. Least recently used blocks leave
|
|
306
|
+
# first, so a long-running service stays bounded.
|
|
307
|
+
def fields_schema(fields)
|
|
308
|
+
key = Mailschema.digest(fields)
|
|
309
|
+
schema = @fields.delete(key) || @schema.call(Forms.values_schema(fields))
|
|
310
|
+
@fields[key] = schema
|
|
311
|
+
@fields.shift while @fields.size > FIELD_VALIDATORS
|
|
312
|
+
schema
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Validation errors as a detail and a JSON Pointer, naming a missing member itself.
|
|
316
|
+
def pointer_errors(schema, value, prefix = "")
|
|
317
|
+
schema.validate(value).flat_map do |error|
|
|
318
|
+
at = error.fetch("data_pointer")
|
|
319
|
+
if error["type"] == "required"
|
|
320
|
+
error.dig("details", "missing_keys").map do |name|
|
|
321
|
+
problem(error["error"], "#{prefix}#{at}/#{Pointer.escape(name)}")
|
|
322
|
+
end
|
|
323
|
+
else
|
|
324
|
+
[problem(error["error"], "#{prefix}#{at}")]
|
|
325
|
+
end
|
|
326
|
+
end.uniq
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def problem(detail, pointer) = Limits.input_error(detail, pointer)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Parsing MAP documents as I-JSON within the core limits.
|
|
4
|
+
module Mailschema
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# A MAP document that is not I-JSON within the core limits.
|
|
8
|
+
class InvalidDocument < Error; end
|
|
9
|
+
|
|
10
|
+
MAX_BYTES = 64 * 1024
|
|
11
|
+
# Unicode's noncharacters: U+FDD0 to U+FDEF, and the last two code points of every plane.
|
|
12
|
+
NONCHARACTER = Regexp.new(
|
|
13
|
+
"[\u{FDD0}-\u{FDEF}#{(0..16).flat_map { |plane| [0xFFFE, 0xFFFF].map { (plane << 16) + _1 } }.pack("U*")}]"
|
|
14
|
+
)
|
|
15
|
+
private_constant :NONCHARACTER
|
|
16
|
+
MAX_DEPTH = 32
|
|
17
|
+
EXACT_NUMBER = (2**53) - 1
|
|
18
|
+
|
|
19
|
+
# Parse a MAP document as I-JSON (RFC 7493) within the core limits: valid UTF-8,
|
|
20
|
+
# at most 64 KiB, no duplicate member names, no lone surrogates, noncharacters or
|
|
21
|
+
# U+0000 in any string, every number within ±(2^53−1), and arrays and objects nested at most 32
|
|
22
|
+
# deep, the outermost counting as one. Comments and every other leniency RFC 8259 forbids are refused.
|
|
23
|
+
# A number with no fractional part parses as an Integer: I-JSON numbers are
|
|
24
|
+
# doubles, so 1 and 1.0 are one number, as they are to JavaScript.
|
|
25
|
+
def self.parse(json)
|
|
26
|
+
raise InvalidDocument, "a MAP document is JSON text" unless json.is_a?(String)
|
|
27
|
+
|
|
28
|
+
text = json.b.force_encoding(Encoding::UTF_8)
|
|
29
|
+
raise InvalidDocument, "the MAP document exceeds #{MAX_BYTES} bytes" if text.bytesize > MAX_BYTES
|
|
30
|
+
raise InvalidDocument, "the MAP document is not valid UTF-8" unless text.valid_encoding?
|
|
31
|
+
|
|
32
|
+
i_json(
|
|
33
|
+
JSON.parse(
|
|
34
|
+
text,
|
|
35
|
+
allow_duplicate_key: false, allow_comments: false, allow_nan: false,
|
|
36
|
+
allow_trailing_comma: false, max_nesting: MAX_DEPTH, decimal_class: BigDecimal
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
rescue JSON::ParserError => e
|
|
40
|
+
raise InvalidDocument, e.message
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The parsed value, checked for what I-JSON forbids and the parser allows. A lone
|
|
44
|
+
# surrogate escape parses into invalid UTF-8, and a number beyond the exact range
|
|
45
|
+
# parses as a Ruby Integer or decimal. Each decimal becomes the double nearest its
|
|
46
|
+
# exact value, as ECMAScript reads it; the parser's own float conversion misreads
|
|
47
|
+
# long exponents. Depth is counted here because the parser's nesting limit does not
|
|
48
|
+
# count empty arrays.
|
|
49
|
+
def self.i_json(value, depth = 0)
|
|
50
|
+
case value
|
|
51
|
+
when Hash, Array
|
|
52
|
+
raise InvalidDocument, "arrays and objects nest deeper than #{MAX_DEPTH}" if depth >= MAX_DEPTH
|
|
53
|
+
|
|
54
|
+
if value.is_a?(Hash)
|
|
55
|
+
value.to_h { |name, member| [i_json(name), i_json(member, depth + 1)] }
|
|
56
|
+
else
|
|
57
|
+
value.map { |item| i_json(item, depth + 1) }
|
|
58
|
+
end
|
|
59
|
+
when String
|
|
60
|
+
raise InvalidDocument, "the MAP document contains a lone surrogate" unless value.valid_encoding?
|
|
61
|
+
raise InvalidDocument, "the MAP document contains U+0000" if value.include?("\0")
|
|
62
|
+
raise InvalidDocument, "the MAP document contains a noncharacter" if NONCHARACTER.match?(value)
|
|
63
|
+
|
|
64
|
+
value
|
|
65
|
+
when Numeric then number(value)
|
|
66
|
+
else value
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A number as ECMAScript reads it: a decimal becomes the nearest double, and a
|
|
71
|
+
# number with no fractional part an Integer.
|
|
72
|
+
def self.number(value)
|
|
73
|
+
number = value.is_a?(BigDecimal) ? value.to_f : value
|
|
74
|
+
raise InvalidDocument, "a number is outside ±(2^53−1)" unless number.finite? && number.abs <= EXACT_NUMBER
|
|
75
|
+
|
|
76
|
+
number.to_i == number ? number.to_i : number
|
|
77
|
+
end
|
|
78
|
+
private_class_method :i_json, :number
|
|
79
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Building MAP results and problems, and the lifecycle rules every service applies.
|
|
4
|
+
module Mailschema
|
|
5
|
+
# The HTTP status of every MAP problem code, as the core schema fixes it.
|
|
6
|
+
PROBLEM_STATUS = {
|
|
7
|
+
"invalid-request" => 400,
|
|
8
|
+
"authentication-required" => 401,
|
|
9
|
+
"refused" => 403,
|
|
10
|
+
"result-not-found" => 404,
|
|
11
|
+
"stale-target" => 409,
|
|
12
|
+
"idempotency-conflict" => 409,
|
|
13
|
+
"request-in-progress" => 409,
|
|
14
|
+
"already-decided" => 409,
|
|
15
|
+
"expired-interaction" => 410,
|
|
16
|
+
"unsupported-type" => 422,
|
|
17
|
+
"unsupported-operation" => 422
|
|
18
|
+
}.freeze
|
|
19
|
+
PROBLEM_TYPES = "https://mailschema.org/problems/"
|
|
20
|
+
NON_TERMINAL_STATES = %w[pending approval-required].freeze
|
|
21
|
+
|
|
22
|
+
# A result for a request, with every correlation member. `approval_url` is given
|
|
23
|
+
# exactly for approval-required and `reason` exactly for failed. Like every builder
|
|
24
|
+
# here, it raises ArgumentError rather than return a document the core refuses.
|
|
25
|
+
def self.result(request, state:, target:, result_url:, recorded_at:, output: {}, reason: nil, approval_url: nil,
|
|
26
|
+
actor: nil)
|
|
27
|
+
if (state == "approval-required") == approval_url.nil?
|
|
28
|
+
raise ArgumentError, "approval_url is given exactly when the state is approval-required"
|
|
29
|
+
end
|
|
30
|
+
raise ArgumentError, "reason is given exactly when the state is failed" if (state == "failed") == reason.nil?
|
|
31
|
+
|
|
32
|
+
built({
|
|
33
|
+
"kind" => "MapResult",
|
|
34
|
+
"profile" => PROFILE,
|
|
35
|
+
"requestId" => request.fetch("requestId"),
|
|
36
|
+
"interactionId" => request.fetch("interactionId"),
|
|
37
|
+
"descriptionDigest" => request.fetch("descriptionDigest"),
|
|
38
|
+
"type" => request.fetch("type"),
|
|
39
|
+
"operation" => request.fetch("operation"),
|
|
40
|
+
"state" => state,
|
|
41
|
+
"target" => target,
|
|
42
|
+
"recordedAt" => timestamp(recorded_at),
|
|
43
|
+
"resultUrl" => result_url,
|
|
44
|
+
"actor" => actor,
|
|
45
|
+
"approvalUrl" => approval_url,
|
|
46
|
+
"reason" => reason,
|
|
47
|
+
"output" => output
|
|
48
|
+
}.compact, :result_errors)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The next state of a pending or approval-required result. Every correlation member
|
|
52
|
+
# and the actor stay; the state, reason, output and recording time change.
|
|
53
|
+
def self.transition(result, state:, recorded_at:, reason: nil, output: {})
|
|
54
|
+
unless NON_TERMINAL_STATES.include?(result.fetch("state"))
|
|
55
|
+
raise ArgumentError, "a #{result["state"]} result is terminal"
|
|
56
|
+
end
|
|
57
|
+
raise ArgumentError, "a transition ends pending work" if NON_TERMINAL_STATES.include?(state)
|
|
58
|
+
raise ArgumentError, "reason is given exactly when the state is failed" if (state == "failed") == reason.nil?
|
|
59
|
+
|
|
60
|
+
built(result.except("approvalUrl", "reason")
|
|
61
|
+
.merge("state" => state, "reason" => reason, "output" => output, "recordedAt" => timestamp(recorded_at))
|
|
62
|
+
.compact, :result_errors)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A problem. With `request_id` it is correlated and carries `instance`, `profile`,
|
|
66
|
+
# `requestId`, `interactionId` and `code`; without, it is plain RFC 9457. It stays
|
|
67
|
+
# within the core limits: a long detail is cut, each input error is bounded, and
|
|
68
|
+
# only as many of the first 100 errors as fit within 64 KiB are kept.
|
|
69
|
+
def self.problem(code, title:, detail:, request_id: nil, interaction_id: nil, result_url: nil, target: nil,
|
|
70
|
+
errors: nil)
|
|
71
|
+
status = PROBLEM_STATUS.fetch(code)
|
|
72
|
+
title, detail, errors = Limits.problem_members(title, detail, errors)
|
|
73
|
+
body = { "type" => "#{PROBLEM_TYPES}#{code}", "title" => title, "status" => status, "detail" => detail }
|
|
74
|
+
raise ArgumentError, "an interaction is named only with its request" if interaction_id && !request_id
|
|
75
|
+
if request_id && code == "authentication-required"
|
|
76
|
+
raise ArgumentError, "authentication-required is never correlated"
|
|
77
|
+
end
|
|
78
|
+
if errors && !(request_id && code == "invalid-request")
|
|
79
|
+
raise ArgumentError, "errors belong to a correlated invalid-request"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
body.merge!(correlation(code, request_id, interaction_id, result_url, target)) if request_id
|
|
83
|
+
built(Limits.within_document(body.merge("target" => target, "errors" => errors).compact), :problem_errors)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.correlation(code, request_id, interaction_id, result_url, target)
|
|
87
|
+
raise ArgumentError, "a correlated problem names its result URL" unless result_url
|
|
88
|
+
if interaction_id.nil? && code != "result-not-found"
|
|
89
|
+
raise ArgumentError, "a correlated problem names its interaction"
|
|
90
|
+
end
|
|
91
|
+
raise ArgumentError, "a stale-target problem carries the current target" if code == "stale-target" && target.nil?
|
|
92
|
+
|
|
93
|
+
{ "instance" => result_url, "profile" => PROFILE, "requestId" => request_id,
|
|
94
|
+
"interactionId" => interaction_id, "code" => code }
|
|
95
|
+
end
|
|
96
|
+
private_class_method :correlation
|
|
97
|
+
|
|
98
|
+
# The HTTP status of a result: 202 while work is pending, otherwise 200.
|
|
99
|
+
def self.result_status(result) = NON_TERMINAL_STATES.include?(result.fetch("state")) ? 202 : 200
|
|
100
|
+
|
|
101
|
+
# Results stay retrievable until the later of the interaction's expiry and the
|
|
102
|
+
# retention interval measured from the latest recorded state.
|
|
103
|
+
def self.retain_until(description, recorded_at)
|
|
104
|
+
retention = description.dig("service", "execution", "resultRetentionSeconds")
|
|
105
|
+
[Time.iso8601(description.fetch("expiresAt")), recorded_at + retention].max
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# An undecided approval ends as failed, with reason expired, at the interaction's
|
|
109
|
+
# expiry, whether or not anyone looks. The settled result, or nil when nothing changes.
|
|
110
|
+
def self.settle(result, description, now)
|
|
111
|
+
return unless result.fetch("state") == "approval-required" && reached?(now, description.fetch("expiresAt"))
|
|
112
|
+
|
|
113
|
+
transition(result, state: "failed", reason: "expired", recorded_at: Time.iso8601(description.fetch("expiresAt")))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.timestamp(time) = time.to_time.utc.iso8601(3)
|
|
117
|
+
private_class_method :timestamp
|
|
118
|
+
|
|
119
|
+
# The document, once the core definition `check` names accepts it.
|
|
120
|
+
def self.built(document, check)
|
|
121
|
+
refused = public_send(check, document)
|
|
122
|
+
raise ArgumentError, "the core refuses this document: #{refused.first(3).join("; ")}" if refused.any?
|
|
123
|
+
|
|
124
|
+
document
|
|
125
|
+
end
|
|
126
|
+
private_class_method :built
|
|
127
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailschema
|
|
4
|
+
# The form fields block of forms 0.1: the rules JSON Schema cannot state, and the
|
|
5
|
+
# schema the values of a block satisfy.
|
|
6
|
+
module Forms
|
|
7
|
+
# The core definitions that give a text field's `format` its lexical form.
|
|
8
|
+
FORMATS = { "email" => "address", "uri" => "identifier", "date" => "date", "date-time" => "dateTime" }.freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Every required field exists, choices are distinct, and defaults are among the
|
|
13
|
+
# choices.
|
|
14
|
+
def problems(fields)
|
|
15
|
+
properties = fields.fetch("properties", {})
|
|
16
|
+
undefined = fields.fetch("required", []).reject { |name| properties.key?(name) }
|
|
17
|
+
problems = undefined.map { |name| "required field #{name} is not defined" }
|
|
18
|
+
properties.each do |name, field|
|
|
19
|
+
choices = (field["oneOf"] || field.dig("items", "anyOf") || []).map { |choice| choice["const"] }
|
|
20
|
+
problems << "field #{name} repeats a choice" unless choices.uniq.size == choices.size
|
|
21
|
+
defaults = field.key?("default") ? Array(field["default"]) : []
|
|
22
|
+
if choices.any? && defaults.any? { |value| !choices.include?(value) }
|
|
23
|
+
problems << "field #{name} defaults to a value it does not offer"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
problems
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The schema the values of a fields block satisfy: only its fields, each with a
|
|
30
|
+
# text field's `format` read as the core lexical form of that name.
|
|
31
|
+
def values_schema(fields)
|
|
32
|
+
properties = fields.fetch("properties", {}).transform_values do |field|
|
|
33
|
+
next field unless field.key?("format")
|
|
34
|
+
|
|
35
|
+
field.except("format").merge("$ref" => "#{CORE_SCHEMA}#/$defs/#{FORMATS.fetch(field["format"])}")
|
|
36
|
+
end
|
|
37
|
+
fields.merge("properties" => properties, "additionalProperties" => false)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
private_constant :Forms
|
|
41
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The HTTP binding: which requests the execution URL admits, and where results live.
|
|
4
|
+
module Mailschema
|
|
5
|
+
# Whether a Content-Type admits a request rather than a 415: the media type
|
|
6
|
+
# application/json, compared case-insensitively, with no parameter other than a
|
|
7
|
+
# UTF-8 charset. Only HTTP's optional whitespace, spaces and tabs, may surround
|
|
8
|
+
# each part, and empty parameter slots are ignored, as RFC 9110 permits. Bytes that
|
|
9
|
+
# are not text admit nothing.
|
|
10
|
+
def self.json_request?(content_type)
|
|
11
|
+
text = content_type.to_s
|
|
12
|
+
return false unless text.valid_encoding?
|
|
13
|
+
|
|
14
|
+
type, *parameters = text.split(";", -1).map { |part| part.gsub(/\A[ \t]+|[ \t]+\z/, "").downcase }
|
|
15
|
+
type == "application/json" &&
|
|
16
|
+
parameters.all? { |parameter| parameter.empty? || parameter.match?(/\Acharset=(?:utf-8|"utf-8")\z/) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The result resource of a request: the description's template, with the request
|
|
20
|
+
# identifier encoded as ECMAScript's encodeURIComponent encodes it.
|
|
21
|
+
def self.result_url(description, request_id)
|
|
22
|
+
encoded = request_id.to_s.b.gsub(/[^A-Za-z0-9\-_.!~*'()]/n) { |byte| format("%%%02X", byte.ord) }
|
|
23
|
+
description.dig("service", "execution", "resultUrlTemplate").sub("{requestId}") { encoded }
|
|
24
|
+
end
|
|
25
|
+
end
|