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.
@@ -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