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,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailschema
|
|
4
|
+
# The JSON Canonicalization Scheme of RFC 8785: members sorted by their UTF-16
|
|
5
|
+
# code units, the minimal string escapes of ECMAScript and its number format.
|
|
6
|
+
module JCS
|
|
7
|
+
ESCAPES = {
|
|
8
|
+
'"' => '\\"', "\\" => "\\\\", "\b" => "\\b", "\f" => "\\f",
|
|
9
|
+
"\n" => "\\n", "\r" => "\\r", "\t" => "\\t"
|
|
10
|
+
}.freeze
|
|
11
|
+
# Integers up to this magnitude are exact as IEEE 754 doubles.
|
|
12
|
+
EXACT = 2**53
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def canonicalize(value)
|
|
17
|
+
case value
|
|
18
|
+
when Hash then object(value)
|
|
19
|
+
when Array then "[#{value.map { |item| canonicalize(item) }.join(",")}]"
|
|
20
|
+
when String then quote(value)
|
|
21
|
+
when Integer then value.abs <= EXACT ? value.to_s : number(value.to_f)
|
|
22
|
+
when Float then number(value)
|
|
23
|
+
when true then "true"
|
|
24
|
+
when false then "false"
|
|
25
|
+
when nil then "null"
|
|
26
|
+
else raise ArgumentError, "#{value.class} is not a JSON value"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def object(value)
|
|
31
|
+
members = value.map do |name, member|
|
|
32
|
+
raise ArgumentError, "object member names must be strings" unless name.is_a?(String)
|
|
33
|
+
|
|
34
|
+
[utf8(name).encode(Encoding::UTF_16BE).unpack("n*"), name, member]
|
|
35
|
+
end
|
|
36
|
+
pairs = members.sort_by(&:first).map { |_, name, member| "#{quote(name)}:#{canonicalize(member)}" }
|
|
37
|
+
"{#{pairs.join(",")}}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def quote(value)
|
|
41
|
+
escaped = utf8(value).gsub(/["\\\x00-\x1f]/) { |char| ESCAPES.fetch(char) { format("\\u%04x", char.ord) } }
|
|
42
|
+
%("#{escaped}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def utf8(value)
|
|
46
|
+
string = value.encoding == Encoding::UTF_8 ? value : value.encode(Encoding::UTF_8)
|
|
47
|
+
raise ArgumentError, "strings must be valid UTF-8" unless string.valid_encoding?
|
|
48
|
+
|
|
49
|
+
string
|
|
50
|
+
rescue EncodingError
|
|
51
|
+
raise ArgumentError, "strings must be valid UTF-8"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# ECMA-262 Number::toString, from the shortest round-trip digits Float#to_s gives.
|
|
55
|
+
def number(value)
|
|
56
|
+
raise ArgumentError, "numbers must be finite" unless value.finite?
|
|
57
|
+
return "0" if value.zero?
|
|
58
|
+
return "-#{number(-value)}" if value.negative?
|
|
59
|
+
|
|
60
|
+
digits, point = shortest(value)
|
|
61
|
+
count = digits.length
|
|
62
|
+
if point.between?(count, 21)
|
|
63
|
+
digits + ("0" * (point - count))
|
|
64
|
+
elsif point.between?(1, 21)
|
|
65
|
+
"#{digits[0, point]}.#{digits[point..]}"
|
|
66
|
+
elsif point.between?(-5, 0)
|
|
67
|
+
"0.#{"0" * -point}#{digits}"
|
|
68
|
+
else
|
|
69
|
+
significand = count == 1 ? digits : "#{digits[0]}.#{digits[1..]}"
|
|
70
|
+
"#{significand}e#{point.positive? ? "+" : "-"}#{(point - 1).abs}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The significant digits of a positive double, and where its decimal point falls:
|
|
75
|
+
# the value is 0.digits × 10^point.
|
|
76
|
+
def shortest(value)
|
|
77
|
+
mantissa, exponent = value.to_s.split("e")
|
|
78
|
+
whole, fraction = mantissa.split(".")
|
|
79
|
+
digits = "#{whole}#{fraction}"
|
|
80
|
+
leading = digits[/\A0*/].length
|
|
81
|
+
[digits[leading..].sub(/0+\z/, ""), whole.length + exponent.to_i - leading]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private_class_method :object, :quote, :utf8, :number, :shortest
|
|
85
|
+
end
|
|
86
|
+
private_constant :JCS
|
|
87
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The Structured Email binding: how a message carries a description.
|
|
4
|
+
module Mailschema
|
|
5
|
+
# The Content-Type of the part that carries a description: JSON-LD labelled with the
|
|
6
|
+
# profile through the media type's profile parameter.
|
|
7
|
+
DESCRIPTION_MEDIA_TYPE = %(application/ld+json; profile="#{PROFILE}").freeze
|
|
8
|
+
|
|
9
|
+
# Whether a designated part carries a description of this profile: its media type is
|
|
10
|
+
# application/ld+json, compared case-insensitively, and its profile parameter lists
|
|
11
|
+
# the profile among its space-separated URIs. A client processes the one such part
|
|
12
|
+
# outside any attached message and ignores every other structured part.
|
|
13
|
+
def self.description_part?(media_type, profile_parameter)
|
|
14
|
+
media_type.to_s.casecmp?("application/ld+json") &&
|
|
15
|
+
profile_parameter.to_s.split(/[ \t]+/).include?(PROFILE)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
data/lib/mailschema.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
require "json_schemer"
|
|
7
|
+
require "time"
|
|
8
|
+
|
|
9
|
+
require_relative "mailschema/version"
|
|
10
|
+
require_relative "mailschema/limits"
|
|
11
|
+
require_relative "mailschema/jcs"
|
|
12
|
+
require_relative "mailschema/document"
|
|
13
|
+
require_relative "mailschema/artifacts"
|
|
14
|
+
require_relative "mailschema/pointer"
|
|
15
|
+
require_relative "mailschema/schema_walk"
|
|
16
|
+
require_relative "mailschema/references"
|
|
17
|
+
require_relative "mailschema/forms"
|
|
18
|
+
require_relative "mailschema/capability"
|
|
19
|
+
require_relative "mailschema/contract"
|
|
20
|
+
require_relative "mailschema/documents"
|
|
21
|
+
require_relative "mailschema/http"
|
|
22
|
+
require_relative "mailschema/message"
|
|
23
|
+
|
|
24
|
+
# Mail Action Protocol 0.2 tooling. The module parses,
|
|
25
|
+
# canonicalizes, digests and validates MAP documents, checks the type contracts an
|
|
26
|
+
# implementation vendors, and builds results and problems. It does not establish
|
|
27
|
+
# endpoint trust, verify email authentication, grant authority or send email.
|
|
28
|
+
module Mailschema
|
|
29
|
+
# RFC 8785 canonical JSON of a value.
|
|
30
|
+
def self.canonicalize(value) = JCS.canonicalize(value)
|
|
31
|
+
|
|
32
|
+
# `sha-256:` and the SHA-256 of the RFC 8785 canonical form: the digest MAP uses
|
|
33
|
+
# for descriptions, contracts and pinned schemas.
|
|
34
|
+
def self.digest(value) = "sha-256:#{Digest::SHA256.hexdigest(JCS.canonicalize(value))}"
|
|
35
|
+
|
|
36
|
+
# Whether a deadline, a core date-time plus `after` seconds, has been reached.
|
|
37
|
+
# Anything but a core date-time counts as reached, so a mistake fails closed.
|
|
38
|
+
def self.reached?(now, deadline, after: 0)
|
|
39
|
+
!(DATE_TIME.valid?(deadline) && now < Time.iso8601(deadline) + after)
|
|
40
|
+
end
|
|
41
|
+
end
|