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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: cd3f1015f63294ef11a1d3a9ff43bcc18af342fd1a55e13e3d04d94b2091302e
|
|
4
|
+
data.tar.gz: 1f697fbcc3877ad1ea40d70c2ca33f2adf4aa6412ebcef98ea0d26968708cead
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e5a64d0eb6e599247ecd12397b3f370e55ac628d425dfc97f0a3b0450ecc1abf8496601b57ec25eae53d45d65c68ef550459d22972fe3db9065c902bdf0f0742
|
|
7
|
+
data.tar.gz: 0dd2ea492f2280c0dc443e46b655ad244e363243613599e61eab58b06f6145d03e794d0db9d59871f91c1686a15d42695a4c27394426f6c4357486c498997647
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.2.0] - 2026-09-26
|
|
6
|
+
|
|
7
|
+
- First release, for Mail Action Protocol 0.2.
|
|
8
|
+
- Parse MAP documents as I-JSON within the core limits, and canonicalize and digest them with RFC 8785.
|
|
9
|
+
- Verify a vendored type contract against its pinned digest, and check descriptions, requests, inputs and results against it.
|
|
10
|
+
- Build results, state transitions and problems, and apply result retention and approval expiry.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MailSchema contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# mailschema for Ruby
|
|
2
|
+
|
|
3
|
+
Mail Action Protocol 0.2 tooling for Ruby. The gem parses, canonicalizes, digests and validates MAP documents, verifies the type contracts an implementation vendors, and builds results and problems.
|
|
4
|
+
|
|
5
|
+
It does not establish endpoint trust, verify email authentication, grant authority or send email. Possession mode needs DKIM and DMARC checks on the raw message, which this gem leaves to the client.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
gem install mailschema
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Ruby 3.3 or later.
|
|
12
|
+
|
|
13
|
+
## A service
|
|
14
|
+
|
|
15
|
+
Vendor the exact contract and request schema your service implements, and pin the contract by the digest you reviewed. The gem refuses any other contract.
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require "mailschema"
|
|
19
|
+
|
|
20
|
+
CONTRACT = Mailschema::Contract.new(
|
|
21
|
+
JSON.parse(File.read("config/mailschema/content-review-0.3.json")),
|
|
22
|
+
JSON.parse(File.read("config/mailschema/content-review-0.3.schema.json")),
|
|
23
|
+
digest: "sha-256:…"
|
|
24
|
+
)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Before sending a description, check it against every rule of the core and the contract, and store its digest or keep it rebuildable from the interaction:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
CONTRACT.description_errors(description) # => []
|
|
31
|
+
Mailschema.digest(description) # => "sha-256:…"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
When a request arrives, check it in this order. No step applies an effect for a problem, and a refusal before the description is resolved and its digest matched leaves the request identifier unclaimed:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
# Answer 415 unless Mailschema.json_request?(content_type). Under possession authority,
|
|
38
|
+
# answer an unknown capability with a plain 404 and a lapsed one with a plain 410
|
|
39
|
+
# before reading the body; under credential authority, authenticate the principal.
|
|
40
|
+
request = Mailschema.parse(body) # raises Mailschema::InvalidDocument
|
|
41
|
+
Mailschema.request_errors(request) # any errors: invalid-request
|
|
42
|
+
|
|
43
|
+
# Resolve the description you issued for request["interactionId"]. Refuse an unknown
|
|
44
|
+
# interaction, or a request whose descriptionDigest is not Mailschema.digest(description).
|
|
45
|
+
|
|
46
|
+
# Request identifiers are claimed within your tenant: the account, or the capability.
|
|
47
|
+
# If request["requestId"] is already claimed there, refuse another principal, and a
|
|
48
|
+
# principal whose permission has been revoked, with an uncorrelated refused problem
|
|
49
|
+
# (Mailschema.problem without request_id): the refusal says nothing about the request.
|
|
50
|
+
# Answer any change to the request, compared by Mailschema.digest, with
|
|
51
|
+
# idempotency-conflict. Otherwise settle the recorded result with Mailschema.settle and
|
|
52
|
+
# return it, or expired-interaction once Mailschema.retain_until has passed. Nothing is
|
|
53
|
+
# applied again.
|
|
54
|
+
|
|
55
|
+
problem = CONTRACT.request_problem(description, request, now: Time.now)
|
|
56
|
+
# problem.code is unsupported-type, unsupported-operation or expired-interaction,
|
|
57
|
+
# with the title and detail Mailschema.problem takes. Then your own state:
|
|
58
|
+
# already-decided, or stale-target.
|
|
59
|
+
|
|
60
|
+
CONTRACT.input_errors(description, request)
|
|
61
|
+
# => [{ "detail" => "...", "pointer" => "/feedback" }], a claimed invalid-request
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Then apply the approval lifecycle or the effect, and record the result:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
Mailschema.result(
|
|
68
|
+
request,
|
|
69
|
+
state: "accepted",
|
|
70
|
+
target: description["target"],
|
|
71
|
+
result_url: Mailschema.result_url(description, request["requestId"]),
|
|
72
|
+
recorded_at: Time.now,
|
|
73
|
+
output: { "feedbackRecorded" => true, "feedbackId" => "fb-1" }
|
|
74
|
+
)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## API
|
|
78
|
+
|
|
79
|
+
| Call | Does |
|
|
80
|
+
| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
81
|
+
| `Mailschema.parse(json)` | Parses a MAP document as I-JSON within the core limits, integral numbers as Integers, or raises `Mailschema::InvalidDocument` |
|
|
82
|
+
| `Mailschema.canonicalize(value)`, `Mailschema.digest(value)` | RFC 8785 canonical JSON, and `sha-256:` over it |
|
|
83
|
+
| `Mailschema.map_errors(document)` | Checks a description, request, result or problem against the core schema |
|
|
84
|
+
| `Mailschema.description_errors`, `Mailschema.request_errors` | Check a description or a request against its core definition only |
|
|
85
|
+
| `Mailschema.result_errors`, `Mailschema.problem_errors` | Check a result or a problem against its core definition only |
|
|
86
|
+
| `Mailschema::Contract.new(contract, request_schema, digest:, dependencies: {})` | Checks the contract's format and profile, refuses any contract but the one pinned by `digest`, checks its pinned dependencies and request schema digest, validates every schema against JSON Schema 2020-12 and compiles every validator once, or raises `Mailschema::InvalidContract`; `dependencies:` supplies, by URL, any pinned schema the gem does not bundle |
|
|
87
|
+
| `contract.description_errors(description)` | Checks a description against every rule of the core and the contract |
|
|
88
|
+
| `contract.request_problem(description, request, now:)` | The exact type, an offered operation the authority permits, and expiry, as a `Contract::Problem` or nil |
|
|
89
|
+
| `contract.input_errors(description, request)` | Checks an operation's input and field bindings, with JSON Pointers into `input` |
|
|
90
|
+
| `contract.result_errors(result)` | Checks a result against the core, the declared output schema and the declared reason |
|
|
91
|
+
| `contract.type_reference`, `contract.operation(id)`, `contract.decision?(id)` | The type reference descriptions and requests name, one of its operations, and whether completing it decides the interaction |
|
|
92
|
+
| `contract.id`, `contract.version`, `contract.digest`, `contract.document`, `contract.request_schema` | The contract's identity, and frozen copies of the documents it verified |
|
|
93
|
+
| `Mailschema.result`, `Mailschema.transition`, `Mailschema.problem` | Build results, state transitions and problems with their correlation members, or raise `ArgumentError` rather than return a document the core refuses. A problem stays within the core limits: a title of at most 240 characters, a detail cut at 4000, and as many of the first 100 input errors, each bounded, as fit in 64 KiB as `JSON.generate` writes them, so send documents written that way |
|
|
94
|
+
| `Mailschema.result_url`, `Mailschema.result_status`, `Mailschema.request_id?` | The result resource of a request, a result's HTTP status, and whether a value is a request identifier, the only thing a result URL names |
|
|
95
|
+
| `Mailschema.json_request?(content_type)` | Whether a `Content-Type` admits a request rather than a 415 |
|
|
96
|
+
| `Mailschema::DESCRIPTION_MEDIA_TYPE`, `Mailschema.description_part?(media_type, profile_parameter)` | The `Content-Type` of the part that carries a description, and whether a designated part is labelled with this profile; a client processes the one such part outside any attached message and ignores every other structured part |
|
|
97
|
+
| `Mailschema.retain_until`, `Mailschema.settle`, `Mailschema.reached?` | Result retention, the expiry of an undecided approval, and whether a deadline has passed |
|
|
98
|
+
| `Mailschema.capability(description)`, `Mailschema.written_path(url)` | The possession capability: the last segment of the execution URL's path as written |
|
|
99
|
+
| `Mailschema.artifact(url)`, `Mailschema.document(url)` | The bundled core artifacts, as exact bytes or as a fresh parsed copy |
|
|
100
|
+
|
|
101
|
+
Type rules a contract cannot express, such as a meeting slot being one of the slots offered, belong to each type's implementation.
|
|
102
|
+
|
|
103
|
+
## Bundled artifacts
|
|
104
|
+
|
|
105
|
+
The gem carries the MAP 0.2 core schema, its JSON-LD context, the type contract format and the form fields block, and the Registry contribution schema that every MailSchema package binds. Each is byte-identical to the file [mailschema.org](https://mailschema.org) publishes. The gem does not validate contributions; the JavaScript and Python packages do. Type contracts are not bundled: an implementation vendors the contracts it supports, and `Mailschema::Contract` verifies them by digest.
|
|
106
|
+
|
|
107
|
+
JSON Schema validation uses `json_schemer` with ECMA-262 regular expressions. Lexical forms such as date-times and addresses are core schema patterns, so `format` is never asserted. References resolve only against pinned schemas, never over the network.
|
|
108
|
+
|
|
109
|
+
## Links
|
|
110
|
+
|
|
111
|
+
- [Specification](https://mailschema.org/specification/profile)
|
|
112
|
+
- [Source](https://github.com/mailschema/ruby)
|
|
113
|
+
|
|
114
|
+
MIT License.
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The bundled core artifacts and the validators built from them.
|
|
4
|
+
module Mailschema
|
|
5
|
+
PROFILE = "https://mailschema.org/profiles/map/0.2"
|
|
6
|
+
CORE_SCHEMA = "https://mailschema.org/schemas/map-0.2.schema.json"
|
|
7
|
+
FORMS_SCHEMA = "https://mailschema.org/schemas/forms-0.1.schema.json"
|
|
8
|
+
CONTRACT_FORMAT = "https://mailschema.org/schemas/type-contract-0.2.schema.json"
|
|
9
|
+
CONTEXT = "https://mailschema.org/contexts/map-0.2.jsonld"
|
|
10
|
+
# The Registry contribution schema every MailSchema package binds, carried as bytes.
|
|
11
|
+
CONTRIBUTION_SCHEMA = "https://mailschema.org/schemas/contribution.schema.json"
|
|
12
|
+
|
|
13
|
+
ROOT = File.expand_path("../..", __dir__)
|
|
14
|
+
# Canonical artifacts, byte for byte, by the URL MailSchema publishes them at.
|
|
15
|
+
ARTIFACTS = {
|
|
16
|
+
CORE_SCHEMA => "schemas/map-0.2.schema.json",
|
|
17
|
+
FORMS_SCHEMA => "schemas/forms-0.1.schema.json",
|
|
18
|
+
CONTRACT_FORMAT => "schemas/type-contract-0.2.schema.json",
|
|
19
|
+
CONTEXT => "contexts/map-0.2.jsonld",
|
|
20
|
+
CONTRIBUTION_SCHEMA => "schemas/contribution.schema.json"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# The exact bytes of a bundled artifact.
|
|
24
|
+
def self.artifact(url) = File.binread(File.join(ROOT, ARTIFACTS.fetch(url))).freeze
|
|
25
|
+
|
|
26
|
+
# A fresh parsed copy of a bundled artifact.
|
|
27
|
+
def self.document(url) = JSON.parse(artifact(url))
|
|
28
|
+
|
|
29
|
+
# The schemas a contract may depend on without supplying them itself.
|
|
30
|
+
BUNDLED = [CORE_SCHEMA, FORMS_SCHEMA].to_h { |url| [url, JSON.parse(artifact(url)).freeze] }.freeze
|
|
31
|
+
|
|
32
|
+
# JSON Schema 2020-12 as MAP reads it. Lexical forms are the core schema's patterns,
|
|
33
|
+
# so `format` stays an annotation: format checkers differ between validators. MAP
|
|
34
|
+
# patterns use a subset ECMA-262 and Ruby read alike, and references resolve only
|
|
35
|
+
# against schemas already in hand, never over the network.
|
|
36
|
+
module Validation
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
def schema(value, references = BUNDLED)
|
|
40
|
+
JSONSchemer.schema(
|
|
41
|
+
value,
|
|
42
|
+
regexp_resolver: "ecma",
|
|
43
|
+
format: false,
|
|
44
|
+
ref_resolver: proc { |uri| references[uri.to_s.delete_suffix("#")] }
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The first way a schema is not valid JSON Schema 2020-12, or nil.
|
|
49
|
+
def invalid_schema(value) = JSONSchemer.validate_schema(value).first&.fetch("error")
|
|
50
|
+
|
|
51
|
+
# Errors as `pointer message` lines, as the other MailSchema packages report them.
|
|
52
|
+
# `prefix` places the pointers inside an enclosing document.
|
|
53
|
+
def errors(schema, value, prefix = "")
|
|
54
|
+
schema.validate(value).first(100).map do |error|
|
|
55
|
+
pointer = "#{prefix}#{error.fetch("data_pointer")}"
|
|
56
|
+
"#{pointer.empty? ? "/" : pointer} #{error.fetch("error")}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
CORE = Validation.schema(BUNDLED.fetch(CORE_SCHEMA))
|
|
62
|
+
DEFINITIONS = %w[description request result problem].to_h { |name| [name, CORE.ref("#/$defs/#{name}")] }.freeze
|
|
63
|
+
DATE_TIME = CORE.ref("#/$defs/dateTime")
|
|
64
|
+
REQUEST_ID = CORE.ref("#/$defs/uuidUrn")
|
|
65
|
+
CONTRACTS = Validation.schema(document(CONTRACT_FORMAT))
|
|
66
|
+
|
|
67
|
+
private_constant :ROOT, :BUNDLED, :Validation, :CORE, :DEFINITIONS, :DATE_TIME, :REQUEST_ID, :CONTRACTS
|
|
68
|
+
|
|
69
|
+
# Whether a value is a request identifier: the core UUID URN form. Only one names a
|
|
70
|
+
# result resource. Anything else, bytes that are not text included, is not one.
|
|
71
|
+
def self.request_id?(value)
|
|
72
|
+
return false if value.is_a?(String) && !value.valid_encoding?
|
|
73
|
+
|
|
74
|
+
REQUEST_ID.valid?(value)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Checks any MAP 0.2 description, request, result or problem against the core schema.
|
|
78
|
+
def self.map_errors(document) = Validation.errors(CORE, document)
|
|
79
|
+
|
|
80
|
+
# Checks a description against the core description definition only. A description
|
|
81
|
+
# of a type the caller has no contract for can be checked this far.
|
|
82
|
+
def self.description_errors(description) = Validation.errors(DEFINITIONS.fetch("description"), description)
|
|
83
|
+
|
|
84
|
+
# Checks a request against the core request definition. A request that fails here
|
|
85
|
+
# is not claimed: its identifier stays free.
|
|
86
|
+
def self.request_errors(request) = Validation.errors(DEFINITIONS.fetch("request"), request)
|
|
87
|
+
|
|
88
|
+
# Checks a result against the core result definition only.
|
|
89
|
+
def self.result_errors(result) = Validation.errors(DEFINITIONS.fetch("result"), result)
|
|
90
|
+
|
|
91
|
+
# Checks a problem against the core problem definition, including the agreement of
|
|
92
|
+
# its type, status and code.
|
|
93
|
+
def self.problem_errors(problem) = Validation.errors(DEFINITIONS.fetch("problem"), problem)
|
|
94
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|