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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailschema
4
+ VERSION = "0.2.0"
5
+ 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