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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -7
  3. data/README.md +29 -88
  4. data/artifacts/context.jsonld +50 -0
  5. data/artifacts/contract.schema.json +206 -0
  6. data/artifacts/core.schema.json +210 -0
  7. data/artifacts/implementation.schema.json +173 -0
  8. data/artifacts/profile.json +27 -0
  9. data/lib/mailschema/artifacts.rb +32 -75
  10. data/lib/mailschema/authentication.rb +77 -0
  11. data/lib/mailschema/contract.rb +184 -281
  12. data/lib/mailschema/description.rb +56 -0
  13. data/lib/mailschema/formats.rb +77 -0
  14. data/lib/mailschema/header.rb +75 -0
  15. data/lib/mailschema/implementation.rb +27 -0
  16. data/lib/mailschema/jcs.rb +11 -10
  17. data/lib/mailschema/json.rb +190 -0
  18. data/lib/mailschema/message.rb +127 -12
  19. data/lib/mailschema/origin.rb +18 -0
  20. data/lib/mailschema/patterns.rb +173 -0
  21. data/lib/mailschema/subschemas.rb +70 -0
  22. data/lib/mailschema/version.rb +1 -1
  23. data/lib/mailschema.rb +16 -26
  24. data/sig/mailschema.rbs +76 -68
  25. metadata +29 -54
  26. data/contexts/map-0.2.jsonld +0 -91
  27. data/lib/mailschema/capability.rb +0 -41
  28. data/lib/mailschema/document.rb +0 -79
  29. data/lib/mailschema/documents.rb +0 -127
  30. data/lib/mailschema/forms.rb +0 -41
  31. data/lib/mailschema/http.rb +0 -25
  32. data/lib/mailschema/limits.rb +0 -56
  33. data/lib/mailschema/pointer.rb +0 -46
  34. data/lib/mailschema/references.rb +0 -62
  35. data/lib/mailschema/schema_walk.rb +0 -29
  36. data/schemas/contribution.schema.json +0 -874
  37. data/schemas/forms-0.1.schema.json +0 -329
  38. data/schemas/map-0.2.schema.json +0 -1033
  39. data/schemas/type-contract-0.2.schema.json +0 -179
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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