mailschema 0.2.0 → 0.3.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
- data/CHANGELOG.md +4 -7
- data/README.md +29 -88
- data/artifacts/context.jsonld +50 -0
- data/artifacts/contract.schema.json +206 -0
- data/artifacts/core.schema.json +210 -0
- data/artifacts/implementation.schema.json +173 -0
- data/artifacts/profile.json +27 -0
- data/lib/mailschema/artifacts.rb +32 -75
- data/lib/mailschema/authentication.rb +77 -0
- data/lib/mailschema/contract.rb +184 -281
- data/lib/mailschema/description.rb +56 -0
- data/lib/mailschema/formats.rb +77 -0
- data/lib/mailschema/header.rb +75 -0
- data/lib/mailschema/implementation.rb +27 -0
- data/lib/mailschema/jcs.rb +11 -10
- data/lib/mailschema/json.rb +190 -0
- data/lib/mailschema/message.rb +127 -12
- data/lib/mailschema/origin.rb +18 -0
- data/lib/mailschema/patterns.rb +173 -0
- data/lib/mailschema/subschemas.rb +70 -0
- data/lib/mailschema/version.rb +1 -1
- data/lib/mailschema.rb +16 -26
- data/sig/mailschema.rbs +76 -68
- metadata +29 -54
- data/contexts/map-0.2.jsonld +0 -91
- data/lib/mailschema/capability.rb +0 -41
- data/lib/mailschema/document.rb +0 -79
- data/lib/mailschema/documents.rb +0 -127
- data/lib/mailschema/forms.rb +0 -41
- data/lib/mailschema/http.rb +0 -25
- data/lib/mailschema/limits.rb +0 -56
- data/lib/mailschema/pointer.rb +0 -46
- data/lib/mailschema/references.rb +0 -62
- data/lib/mailschema/schema_walk.rb +0 -29
- data/schemas/contribution.schema.json +0 -874
- data/schemas/forms-0.1.schema.json +0 -329
- data/schemas/map-0.2.schema.json +0 -1033
- data/schemas/type-contract-0.2.schema.json +0 -179
data/contexts/map-0.2.jsonld
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"@context": {
|
|
3
|
-
"@version": 1.1,
|
|
4
|
-
"map": "https://mailschema.org/ns/map#",
|
|
5
|
-
"schema": "https://schema.org/",
|
|
6
|
-
"MailAction": "map:MailAction",
|
|
7
|
-
"profile": {
|
|
8
|
-
"@id": "map:profile",
|
|
9
|
-
"@type": "@id"
|
|
10
|
-
},
|
|
11
|
-
"type": {
|
|
12
|
-
"@id": "map:interactionType",
|
|
13
|
-
"@context": {
|
|
14
|
-
"id": {
|
|
15
|
-
"@id": "map:id",
|
|
16
|
-
"@type": "@id"
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
},
|
|
20
|
-
"version": "schema:version",
|
|
21
|
-
"contractDigest": "map:contractDigest",
|
|
22
|
-
"describedAt": {
|
|
23
|
-
"@id": "schema:dateCreated",
|
|
24
|
-
"@type": "http://www.w3.org/2001/XMLSchema#dateTime"
|
|
25
|
-
},
|
|
26
|
-
"expiresAt": {
|
|
27
|
-
"@id": "schema:expires",
|
|
28
|
-
"@type": "http://www.w3.org/2001/XMLSchema#dateTime"
|
|
29
|
-
},
|
|
30
|
-
"service": {
|
|
31
|
-
"@id": "schema:provider",
|
|
32
|
-
"@context": {
|
|
33
|
-
"id": {
|
|
34
|
-
"@id": "map:id",
|
|
35
|
-
"@type": "@id"
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
},
|
|
39
|
-
"name": "schema:name",
|
|
40
|
-
"authority": "map:authority",
|
|
41
|
-
"onBehalfOf": {
|
|
42
|
-
"@id": "map:onBehalfOf",
|
|
43
|
-
"@context": {
|
|
44
|
-
"id": {
|
|
45
|
-
"@id": "map:id",
|
|
46
|
-
"@type": "@id"
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
},
|
|
50
|
-
"resource": {
|
|
51
|
-
"@id": "map:resource",
|
|
52
|
-
"@type": "@id"
|
|
53
|
-
},
|
|
54
|
-
"execution": "map:execution",
|
|
55
|
-
"url": {
|
|
56
|
-
"@id": "schema:url",
|
|
57
|
-
"@type": "@id"
|
|
58
|
-
},
|
|
59
|
-
"resultUrlTemplate": "map:resultUrlTemplate",
|
|
60
|
-
"resultRetentionSeconds": "map:resultRetentionSeconds",
|
|
61
|
-
"humanUrl": {
|
|
62
|
-
"@id": "map:humanUrl",
|
|
63
|
-
"@type": "@id"
|
|
64
|
-
},
|
|
65
|
-
"target": {
|
|
66
|
-
"@id": "schema:object",
|
|
67
|
-
"@context": {
|
|
68
|
-
"id": {
|
|
69
|
-
"@id": "map:id",
|
|
70
|
-
"@type": "@id"
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
"recipient": "schema:recipient",
|
|
75
|
-
"details": {
|
|
76
|
-
"@id": "map:details",
|
|
77
|
-
"@type": "@json"
|
|
78
|
-
},
|
|
79
|
-
"revision": "schema:version",
|
|
80
|
-
"title": "schema:name",
|
|
81
|
-
"digest": "map:digest",
|
|
82
|
-
"operations": {
|
|
83
|
-
"@id": "schema:potentialAction",
|
|
84
|
-
"@container": "@set",
|
|
85
|
-
"@context": {
|
|
86
|
-
"id": "map:operationId"
|
|
87
|
-
}
|
|
88
|
-
},
|
|
89
|
-
"description": "schema:description"
|
|
90
|
-
}
|
|
91
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
# The possession capability and the rules its URLs meet.
|
|
4
|
-
module Mailschema
|
|
5
|
-
# The possession capability: the last segment of the execution URL path as written.
|
|
6
|
-
def self.capability(description)
|
|
7
|
-
written_path(description.dig("service", "execution", "url")).split("/", -1).last.to_s
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
# The path of an absolute URL exactly as written, with no dot segments removed and
|
|
11
|
-
# nothing decoded.
|
|
12
|
-
def self.written_path(url) = url.to_s[%r{\A[A-Za-z][A-Za-z0-9+.-]*://[^/?#]*([^?#]*)}, 1].to_s
|
|
13
|
-
|
|
14
|
-
# The rules a possession description's capability URLs must meet when it is issued.
|
|
15
|
-
module Capability
|
|
16
|
-
UNGUESSABLE = /\A[A-Za-z0-9_-]{22,}\z/
|
|
17
|
-
DOT_SEGMENT = /\A(?:\.|%2e){1,2}\z/i
|
|
18
|
-
|
|
19
|
-
module_function
|
|
20
|
-
|
|
21
|
-
def problems(description)
|
|
22
|
-
execution = description.dig("service", "execution")
|
|
23
|
-
capability = Mailschema.capability(description)
|
|
24
|
-
problems = []
|
|
25
|
-
problems << "The capability is too short to be unguessable." unless UNGUESSABLE.match?(capability)
|
|
26
|
-
if [execution.fetch("url"), execution.fetch("resultUrlTemplate")].any? { |url| dot_segment?(url) }
|
|
27
|
-
problems << "A capability URL must not contain dot segments."
|
|
28
|
-
end
|
|
29
|
-
unless execution.fetch("resultUrlTemplate").start_with?("#{execution.fetch("url")}/")
|
|
30
|
-
problems << "The result template must extend the execution URL and its capability."
|
|
31
|
-
end
|
|
32
|
-
if description.dig("service", "humanUrl").include?(capability)
|
|
33
|
-
problems << "The human route must not carry the capability."
|
|
34
|
-
end
|
|
35
|
-
problems
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def dot_segment?(url) = Mailschema.written_path(url).split("/", -1).any? { |segment| DOT_SEGMENT.match?(segment) }
|
|
39
|
-
end
|
|
40
|
-
private_constant :Capability
|
|
41
|
-
end
|
data/lib/mailschema/document.rb
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
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
|
data/lib/mailschema/documents.rb
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
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
|
data/lib/mailschema/forms.rb
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
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
|
data/lib/mailschema/http.rb
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
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
|
data/lib/mailschema/limits.rb
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Mailschema
|
|
4
|
-
# MAP documents within the core limits, with lengths counted in code points as JSON
|
|
5
|
-
# Schema counts them.
|
|
6
|
-
module Limits
|
|
7
|
-
TITLE = 240
|
|
8
|
-
DETAIL = 4000
|
|
9
|
-
POINTER = 1000
|
|
10
|
-
ERROR_DETAIL = 2000
|
|
11
|
-
ERRORS = 100
|
|
12
|
-
|
|
13
|
-
module_function
|
|
14
|
-
|
|
15
|
-
# The text, or its first limit - 1 code points and an ellipsis.
|
|
16
|
-
def cut(text, limit) = text.length > limit ? "#{text[0, limit - 1]}…" : text
|
|
17
|
-
|
|
18
|
-
# An input error within the core problem's limits. A pointer too long to report
|
|
19
|
-
# names its nearest ancestor that fits, with a detail that says so, and a detail
|
|
20
|
-
# too long is cut.
|
|
21
|
-
def input_error(detail, pointer)
|
|
22
|
-
if pointer.length > POINTER
|
|
23
|
-
pointer = Pointer.within(pointer, POINTER)
|
|
24
|
-
detail = "A member within this value does not satisfy the contract."
|
|
25
|
-
end
|
|
26
|
-
{ "detail" => cut(detail, ERROR_DETAIL), "pointer" => pointer }
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# A problem's title, detail and input errors within the core limits. An empty or
|
|
30
|
-
# overlong title, an empty detail or an empty error list is the caller's mistake.
|
|
31
|
-
def problem_members(title, detail, errors)
|
|
32
|
-
raise ArgumentError, "a problem title is 1 to #{TITLE} characters" unless title.length.between?(1, TITLE)
|
|
33
|
-
raise ArgumentError, "a problem detail is not empty" if detail.empty?
|
|
34
|
-
|
|
35
|
-
[title, cut(detail, DETAIL), errors && bounded_errors(errors)]
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def bounded_errors(errors)
|
|
39
|
-
raise ArgumentError, "errors, when given, name at least one input error" if errors.empty?
|
|
40
|
-
raise ArgumentError, "an input error has a detail" if errors.any? { |error| error.fetch("detail").empty? }
|
|
41
|
-
|
|
42
|
-
errors.first(ERRORS).map { |error| input_error(error.fetch("detail"), error.fetch("pointer")) }
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# The problem with as many of its errors, in order, as fit within the document limit.
|
|
46
|
-
def within_document(problem)
|
|
47
|
-
errors = problem["errors"]
|
|
48
|
-
return problem unless errors
|
|
49
|
-
|
|
50
|
-
errors = errors.dup
|
|
51
|
-
errors.pop while errors.size > 1 && JSON.generate(problem.merge("errors" => errors)).bytesize > MAX_BYTES
|
|
52
|
-
problem.merge("errors" => errors)
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
private_constant :Limits
|
|
56
|
-
end
|
data/lib/mailschema/pointer.rb
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Mailschema
|
|
4
|
-
# JSON Pointer (RFC 6901).
|
|
5
|
-
module Pointer
|
|
6
|
-
# What a pointer names when nothing is there, as distinct from null.
|
|
7
|
-
MISSING = Object.new.freeze
|
|
8
|
-
INDEX = /\A(?:0|[1-9][0-9]*)\z/
|
|
9
|
-
|
|
10
|
-
module_function
|
|
11
|
-
|
|
12
|
-
def escape(name) = name.to_s.gsub("~", "~0").gsub("/", "~1")
|
|
13
|
-
|
|
14
|
-
def segments(pointer) = pointer.split("/", -1).drop(1).map { |segment| segment.gsub("~1", "/").gsub("~0", "~") }
|
|
15
|
-
|
|
16
|
-
# The pointer, or its nearest ancestor no longer than limit characters.
|
|
17
|
-
def within(pointer, limit)
|
|
18
|
-
tokens = pointer.split("/", -1)
|
|
19
|
-
tokens.pop while tokens.join("/").length > limit
|
|
20
|
-
tokens.join("/")
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# The value a pointer names through objects and arrays, or MISSING.
|
|
24
|
-
def resolve(value, pointer)
|
|
25
|
-
segments(pointer).reduce(value) do |current, name|
|
|
26
|
-
case current
|
|
27
|
-
when Hash then current.fetch(name) { return MISSING }
|
|
28
|
-
when Array then INDEX.match?(name) && name.to_i < current.size ? current[name.to_i] : (return MISSING)
|
|
29
|
-
else return MISSING
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# The value a pointer names through object members only, or MISSING. The contract
|
|
35
|
-
# rules bind form fields only through object properties, so no binding indexes an
|
|
36
|
-
# array.
|
|
37
|
-
def member(value, pointer)
|
|
38
|
-
segments(pointer).reduce(value) do |current, name|
|
|
39
|
-
return MISSING unless current.is_a?(Hash) && current.key?(name)
|
|
40
|
-
|
|
41
|
-
current[name]
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
private_constant :Pointer
|
|
46
|
-
end
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Mailschema
|
|
4
|
-
# The references of a contract's schemas. Each must name a schema inside a pinned
|
|
5
|
-
# dependency when the contract loads, so none can fail when a request arrives.
|
|
6
|
-
module References
|
|
7
|
-
# A plain JSON Pointer fragment: nothing percent-encoded, nothing to decode.
|
|
8
|
-
FRAGMENT = %r{\A(?:/[A-Za-z0-9._~!$&'()*+,;=:@-]*)*\z}
|
|
9
|
-
SCHEMA = [Hash, TrueClass, FalseClass].freeze
|
|
10
|
-
# Keywords that resolve by scope rather than by a pinned address.
|
|
11
|
-
UNPINNED = %w[$dynamicRef $recursiveRef $dynamicAnchor $recursiveAnchor $anchor].freeze
|
|
12
|
-
|
|
13
|
-
module_function
|
|
14
|
-
|
|
15
|
-
# Raises InvalidContract for the first keyword in the contract's schemas, or the
|
|
16
|
-
# pinned schemas the gem does not bundle, that resolves by scope. In the contract's
|
|
17
|
-
# own schemas, it raises for an $id or $schema anywhere but the request schema's
|
|
18
|
-
# root, which would move references or change the dialect a subschema is read in;
|
|
19
|
-
# for a type list; for autocomplete, the form fields block's annotation; and for a
|
|
20
|
-
# reference that is not a string or names no schema in the pinned documents, by URL.
|
|
21
|
-
def verify(inline, request_schema, pinned, unbundled)
|
|
22
|
-
[*inline, request_schema, *unbundled].each do |schema|
|
|
23
|
-
found = UNPINNED.find { |keyword| SchemaWalk.values(schema, keyword).any? }
|
|
24
|
-
raise InvalidContract, "#{found} is not pinned by any digest" if found
|
|
25
|
-
end
|
|
26
|
-
[*inline, request_schema].each do |schema|
|
|
27
|
-
nodes = SchemaWalk.nodes(schema)
|
|
28
|
-
verify_keywords(schema.equal?(request_schema) ? nodes.drop(1) : nodes, nodes)
|
|
29
|
-
refs = SchemaWalk.values(schema, "$ref")
|
|
30
|
-
raise InvalidContract, "a $ref is not a string" unless refs.all?(String)
|
|
31
|
-
|
|
32
|
-
refs.each { |ref| verify_one(ref, pinned) }
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# `nested` are the schema objects that may not declare $id or $schema.
|
|
37
|
-
def verify_keywords(nested, nodes)
|
|
38
|
-
raise InvalidContract, "a nested $id would move references" if nested.any? { _1.key?("$id") }
|
|
39
|
-
raise InvalidContract, "a nested $schema would change the dialect" if nested.any? { _1.key?("$schema") }
|
|
40
|
-
raise InvalidContract, "type names one type, never a list" if nodes.any? { _1["type"].is_a?(Array) }
|
|
41
|
-
return unless nodes.any? { _1.key?("autocomplete") }
|
|
42
|
-
|
|
43
|
-
raise InvalidContract, "autocomplete belongs to the form fields block"
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def verify_one(ref, pinned)
|
|
47
|
-
base, fragment = ref.split("#", 2)
|
|
48
|
-
raise InvalidContract, "#{ref} is not a pinned dependency" unless pinned.key?(base)
|
|
49
|
-
|
|
50
|
-
fragment = fragment.to_s
|
|
51
|
-
unless fragment.empty? || fragment.start_with?("/")
|
|
52
|
-
raise InvalidContract, "#{ref} names an anchor, not a JSON Pointer"
|
|
53
|
-
end
|
|
54
|
-
raise InvalidContract, "#{ref} is not a plain JSON Pointer" unless FRAGMENT.match?(fragment)
|
|
55
|
-
|
|
56
|
-
target = Pointer.resolve(pinned[base], fragment)
|
|
57
|
-
raise InvalidContract, "#{ref} does not name a schema" unless SCHEMA.include?(target.class)
|
|
58
|
-
end
|
|
59
|
-
private_class_method :verify_one
|
|
60
|
-
end
|
|
61
|
-
private_constant :References
|
|
62
|
-
end
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Mailschema
|
|
4
|
-
# The schema objects within a schema, as JSON Schema 2020-12 nests them.
|
|
5
|
-
module SchemaWalk
|
|
6
|
-
# The keywords whose value is a schema, a list of schemas, or a map from names to
|
|
7
|
-
# schemas. A name in a map is never a keyword, whatever it is called.
|
|
8
|
-
SUBSCHEMA = %w[additionalProperties propertyNames items contains not if then else unevaluatedItems
|
|
9
|
-
unevaluatedProperties contentSchema].freeze
|
|
10
|
-
LISTS = %w[allOf anyOf oneOf prefixItems].freeze
|
|
11
|
-
MAPS = %w[properties patternProperties $defs dependentSchemas].freeze
|
|
12
|
-
|
|
13
|
-
module_function
|
|
14
|
-
|
|
15
|
-
# Every schema object within a schema, the schema itself first.
|
|
16
|
-
def nodes(schema)
|
|
17
|
-
return [] unless schema.is_a?(Hash)
|
|
18
|
-
|
|
19
|
-
[schema] +
|
|
20
|
-
SUBSCHEMA.flat_map { |keyword| nodes(schema[keyword]) } +
|
|
21
|
-
LISTS.flat_map { |keyword| schema[keyword].is_a?(Array) ? schema[keyword].flat_map { nodes(_1) } : [] } +
|
|
22
|
-
MAPS.flat_map { |keyword| schema[keyword].is_a?(Hash) ? schema[keyword].values.flat_map { nodes(_1) } : [] }
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# Every value a keyword has in the schema objects within a schema.
|
|
26
|
-
def values(schema, keyword) = nodes(schema).filter_map { |node| node[keyword] if node.key?(keyword) }
|
|
27
|
-
end
|
|
28
|
-
private_constant :SchemaWalk
|
|
29
|
-
end
|