async-matrix 4.0.0 → 4.0.1

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,466 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the Apache License, Version 2.0.
4
+ # Copyright, 2026, by General Intelligence Systems.
5
+
6
+ require "json_schemer"
7
+ require "yaml"
8
+
9
+ require_relative "error"
10
+
11
+ module Async
12
+ module Matrix
13
+ # Raised where a document does not answer what the specification asks of it.
14
+ class InvalidDocumentError < Error
15
+ # @parameter operation [String] the operation ID, e.g. `login`.
16
+ # @parameter version [String] the specification version the document was read against.
17
+ # @parameter errors [Array(String)] what was wrong with it.
18
+ def initialize(operation, version, errors)
19
+ super("#{operation} does not answer what #{version} asks of it: #{errors.join("; ")}!")
20
+
21
+ @operation = operation
22
+ @version = version
23
+ @errors = errors
24
+ end
25
+
26
+ # @attribute [String] the operation ID.
27
+ attr :operation
28
+
29
+ # @attribute [String] the specification version.
30
+ attr :version
31
+
32
+ # @attribute [Array(String)] what was wrong with the document.
33
+ attr :errors
34
+ end
35
+
36
+ # Raised where a path names more than one operation, so that nothing can say which was called.
37
+ class AmbiguousPathError < Error
38
+ # @parameter path [String] the path which was asked about.
39
+ # @parameter operations [Array(String)] the operation IDs which share it.
40
+ def initialize(path, operations)
41
+ super("#{path} is the path of #{operations.join(", ")}: name the one to check against!")
42
+
43
+ @path = path
44
+ @operations = operations
45
+ end
46
+
47
+ # @attribute [String] the path which was asked about.
48
+ attr :path
49
+
50
+ # @attribute [Array(String)] the operation IDs which share it.
51
+ attr :operations
52
+ end
53
+
54
+ # What the specification asks of one concept's documents, at one version.
55
+ class Schema
56
+ # The schemas this gem ships are generated from the specification: each names its operations, and each operation the fields of its request and of every response it describes.
57
+ #
58
+ # This reads one and answers a schema a document can be checked against, so that `required` is enforced rather than merely recorded.
59
+
60
+ # Where the generated schemas are, which ship with the gem.
61
+ ROOT = File.expand_path("abstract", __dir__)
62
+
63
+ # The keys the generated schemas use to describe a field, as against the names of the fields themselves.
64
+ #
65
+ # A field named `pattern` or `format` is a field, not a keyword: a descriptor is what carries a `type`.
66
+ STRUCTURE = ["type", "required", "description", "fields", "enum", "keyed_by"].freeze
67
+
68
+ class << self
69
+ # The schema for one concept at one version, built once and cached.
70
+ #
71
+ # @parameter api [String] the API, e.g. `client_server`, or `events`.
72
+ # @parameter concept [String] the concept, e.g. `session`.
73
+ # @parameter version [String] the specification version, e.g. `v1.19`.
74
+ # @returns [Schema | nil] the schema, or nil where the version describes no such concept.
75
+ def for(api, concept, version)
76
+ @schemas ||= {}
77
+
78
+ if @schemas.key?([api, concept, version])
79
+ @schemas[[api, concept, version]]
80
+ else
81
+ @schemas[[api, concept, version]] = self.load(api, concept, version)
82
+ end
83
+ end
84
+
85
+ # Every concept the given API describes at the given version.
86
+ #
87
+ # @parameter api [String] the API, e.g. `client_server`.
88
+ # @parameter version [String] the specification version.
89
+ # @returns [Array(String)] the concepts, named as a Ruby file would be.
90
+ def concepts(api, version)
91
+ Dir.glob(File.join(self.directory(api), "*-#{version}.yaml")).filter_map do |path|
92
+ unless path.end_with?(".deltas.yaml")
93
+ File.basename(path).delete_suffix("-#{version}.yaml").tr("-", "_")
94
+ end
95
+ end.sort
96
+ end
97
+
98
+ # The operation a path names, which is how a document says what to check itself against.
99
+ #
100
+ # The operation a path names, where only one method answers it.
101
+ #
102
+ # @parameter api [String] the API, e.g. `client_server`.
103
+ # @parameter version [String] the specification version.
104
+ # @parameter path [String] the path the request was made against.
105
+ # @parameter method [String | nil] the HTTP method, where the path names more than one operation.
106
+ # @returns [Array(Schema, String)] the schema and the operation ID, or nil where no operation has that path.
107
+ def operation_for(api, version, path, method: nil)
108
+ # A path is shared by every method which answers it, so the `GET` and the `PUT` of one path are two operations.
109
+ matched = self.index(api, version).select{|pattern, _| pattern.match?(path)}.values.flatten(1)
110
+
111
+ found = matched.select do |concept, operation|
112
+ method.nil? || self.for(api, concept, version).operations.dig(operation, "method") == method.to_s.upcase
113
+ end
114
+
115
+ case found.uniq
116
+ in [[String => concept, String => operation]]
117
+ [self.for(api, concept, version), operation]
118
+ in [[String, String], *] => several
119
+ raise AmbiguousPathError.new(path, several.map(&:last))
120
+ else
121
+ nil
122
+ end
123
+ end
124
+
125
+ # Every operation of an API at a version, by the pattern its path makes.
126
+ #
127
+ # @parameter api [String] the API.
128
+ # @parameter version [String] the specification version.
129
+ # @returns [Hash] the concepts and operation IDs, by pattern.
130
+ def index(api, version)
131
+ @indexes ||= {}
132
+ @indexes[[api, version]] ||= self.concepts(api, version).each_with_object({}) do |concept, index|
133
+ self.for(api, concept, version).operations.each do |operation, described|
134
+ if described["path"]
135
+ # Every method of a path is its own operation, so one pattern may name several.
136
+ (index[self.pattern(described["path"])] ||= []) << [concept, operation]
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ # What a path template matches, which is one segment per parameter it names.
143
+ #
144
+ # A state key may be empty, so a segment is allowed to be: `/state/m.room.name/` names one.
145
+ #
146
+ # @parameter template [String] the path as the specification writes it, e.g. `/rooms/{roomId}/state`.
147
+ # @returns [Regexp] what that path matches.
148
+ def pattern(template)
149
+ literal = template.split(/(\{[^}]+\})/).map do |part|
150
+ if part.start_with?("{")
151
+ "[^/]*"
152
+ else
153
+ Regexp.escape(part)
154
+ end
155
+ end
156
+
157
+ /\A#{literal.join}\z/
158
+ end
159
+
160
+ # Where one API's schemas are.
161
+ #
162
+ # @parameter api [String] the API, e.g. `client_server`, or `events`.
163
+ # @returns [String] the directory.
164
+ def directory(api)
165
+ case api.to_s
166
+ in "events"
167
+ File.join(ROOT, "events", "schemas")
168
+ in String => named
169
+ File.join(ROOT, "api", named, "schemas")
170
+ end
171
+ end
172
+
173
+ private
174
+
175
+ # @parameter api [String] the API.
176
+ # @parameter concept [String] the concept.
177
+ # @parameter version [String] the specification version.
178
+ # @returns [Schema | nil] the schema, or nil where there is no such file.
179
+ def load(api, concept, version)
180
+ # The schema keeps the specification's own name, which may be hyphenated where a Ruby file cannot be.
181
+ path = [concept.to_s, concept.to_s.tr("_", "-")].uniq.lazy.map do |name|
182
+ File.join(self.directory(api), "#{name}-#{version}.yaml")
183
+ end.find{|candidate| File.exist?(candidate)}
184
+
185
+ if path
186
+ self.new(YAML.safe_load_file(path), version: version)
187
+ else
188
+ nil
189
+ end
190
+ end
191
+ end
192
+
193
+ # @parameter document [Hash] the schema as it was generated.
194
+ # @parameter version [String] the specification version it describes.
195
+ def initialize(document, version:)
196
+ @document = document
197
+ @version = version
198
+ @validators = {}
199
+ end
200
+
201
+ # @attribute [String] the specification version this describes.
202
+ attr :version
203
+
204
+ # @returns [Hash] the operations this concept has, by ID.
205
+ def operations
206
+ @document["operations"] || {}
207
+ end
208
+
209
+ # @returns [Hash] the events this schema describes, by type, where it describes events.
210
+ def events
211
+ @document["events"] || {}
212
+ end
213
+
214
+ # Whether a document answers what an operation's response asks of it.
215
+ #
216
+ # @parameter operation [String] the operation ID, e.g. `login`.
217
+ # @parameter value [Hash] the document the server answered with.
218
+ # @parameter status [Integer | String] which response it is, which is the 200 unless said otherwise.
219
+ # @returns [Array(String)] what was wrong with it, which is empty where nothing was.
220
+ def errors_in_response(operation, value, status: 200)
221
+ self.errors_against(self.response(operation, status: status), value)
222
+ end
223
+
224
+ # Whether a document answers what an operation's request asks of it.
225
+ #
226
+ # @parameter operation [String] the operation ID.
227
+ # @parameter value [Hash] the document to send.
228
+ # @returns [Array(String)] what was wrong with it, which is empty where nothing was.
229
+ def errors_in_request(operation, value)
230
+ self.errors_against(self.request(operation), value)
231
+ end
232
+
233
+ # The same as {#errors_in_response}, raising rather than answering.
234
+ #
235
+ # @parameter operation [String] the operation ID.
236
+ # @parameter value [Hash] the document the server answered with.
237
+ # @parameter status [Integer | String] which response it is.
238
+ # @returns [Hash] the document, so that this can be used where it is read.
239
+ def validate_response(operation, value, status: 200)
240
+ errors = self.errors_in_response(operation, value, status: status)
241
+
242
+ unless errors.empty?
243
+ raise InvalidDocumentError.new(operation, @version, errors)
244
+ end
245
+
246
+ value
247
+ end
248
+
249
+ # The same as {#errors_in_request}, raising rather than answering.
250
+ #
251
+ # @parameter operation [String] the operation ID.
252
+ # @parameter value [Hash] the document to send.
253
+ # @returns [Hash] the document, so that this can be used where it is sent.
254
+ def validate_request(operation, value)
255
+ errors = self.errors_in_request(operation, value)
256
+
257
+ unless errors.empty?
258
+ raise InvalidDocumentError.new(operation, @version, errors)
259
+ end
260
+
261
+ value
262
+ end
263
+
264
+ # The fields an operation's response always carries, which is what `required` records.
265
+ #
266
+ # @parameter operation [String] the operation ID.
267
+ # @parameter status [Integer | String] which response it is.
268
+ # @returns [Array(Symbol)] the names, which is empty where the response asks for none.
269
+ def required_in_response(operation, status: 200)
270
+ self.required_of(self.fields_of_response(operation, status: status))
271
+ end
272
+
273
+ # The fields an operation's request always carries.
274
+ #
275
+ # @parameter operation [String] the operation ID.
276
+ # @returns [Array(Symbol)] the names.
277
+ def required_in_request(operation)
278
+ self.required_of(self.fields_of_request(operation))
279
+ end
280
+
281
+ # One response as a JSON Schema document.
282
+ #
283
+ # @parameter operation [String] the operation ID.
284
+ # @parameter status [Integer | String] which response it is.
285
+ # @returns [Hash | nil] the schema, or nil where the version describes no such response.
286
+ def response(operation, status: 200)
287
+ self.object_schema(self.fields_of_response(operation, status: status))
288
+ end
289
+
290
+ # One request as a JSON Schema document.
291
+ #
292
+ # @parameter operation [String] the operation ID.
293
+ # @returns [Hash | nil] the schema, or nil where the operation sends no body.
294
+ def request(operation)
295
+ self.object_schema(self.fields_of_request(operation))
296
+ end
297
+
298
+ def inspect
299
+ "\#<#{self.class} #{@version} #{self.operations.keys.size} operations>"
300
+ end
301
+
302
+ private
303
+
304
+ # @parameter operation [String] the operation ID.
305
+ # @parameter status [Integer | String] which response it is.
306
+ # @returns [Hash | nil] the field map, or nil where there is no such response.
307
+ def fields_of_response(operation, status: 200)
308
+ (self.operations.dig(operation, "responses") || {})[status.to_s]
309
+ end
310
+
311
+ # @parameter operation [String] the operation ID.
312
+ # @returns [Hash | nil] the field map, or nil where the operation sends no body.
313
+ def fields_of_request(operation)
314
+ self.operations.dig(operation, "request")
315
+ end
316
+
317
+ # @parameter fields [Hash | nil] the field map.
318
+ # @returns [Array(Symbol)] the names the map requires.
319
+ def required_of(fields)
320
+ case fields
321
+ in Hash => named
322
+ named.select{|_, field| self.descriptor?(field) && field["required"]}.keys.map(&:to_sym)
323
+ else
324
+ []
325
+ end
326
+ end
327
+
328
+ # Check a document against a schema, which is nothing to check where the version describes none.
329
+ #
330
+ # @parameter schema [Hash | nil] the JSON Schema document.
331
+ # @parameter value [Hash] the document to check.
332
+ # @returns [Array(String)] what was wrong with it.
333
+ def errors_against(schema, value)
334
+ case schema
335
+ in Hash => against
336
+ self.validator(against).validate(self.stringify(value)).map do |error|
337
+ self.describe(error)
338
+ end
339
+ else
340
+ []
341
+ end
342
+ end
343
+
344
+ # @parameter schema [Hash] the JSON Schema document.
345
+ # @returns [JSONSchemer::Schema] the validator, built once per schema.
346
+ def validator(schema)
347
+ @validators[schema] ||= JSONSchemer.schema(schema)
348
+ end
349
+
350
+ # What json_schemer reported, as a line a person can read.
351
+ #
352
+ # @parameter error [Hash] one of json_schemer's errors.
353
+ # @returns [String] the description.
354
+ def describe(error)
355
+ case error["data_pointer"]
356
+ in ""
357
+ where = "the document"
358
+ in String => pointer
359
+ where = pointer
360
+ end
361
+
362
+ case error["type"]
363
+ in "required"
364
+ "#{where} is missing #{(error.dig("details", "missing_keys") || []).join(", ")}"
365
+ in String => type
366
+ "#{where} is not #{type}"
367
+ end
368
+ end
369
+
370
+ # A field map as a JSON Schema object, which is open rather than closed.
371
+ #
372
+ # @parameter fields [Hash | nil] the field map.
373
+ # @returns [Hash | nil] the schema, or nil where there is no map to build one from.
374
+ def object_schema(fields)
375
+ case fields
376
+ in Hash => named
377
+ # The specification lets a server send fields a version does not describe, and a client which refused them would break on the next version rather than on a fault.
378
+ properties = named.reject{|name, _| name == "(*)"}
379
+ wildcard = named["(*)"]
380
+
381
+ {"type" => "object"}.tap do |schema|
382
+ schema["properties"] = properties.to_h{|name, field| [name, self.field_schema(field)]}
383
+
384
+ required = self.required_of(named).map(&:to_s)
385
+
386
+ unless required.empty?
387
+ schema["required"] = required
388
+ end
389
+
390
+ if wildcard
391
+ schema["additionalProperties"] = self.field_schema(wildcard)
392
+ end
393
+ end
394
+ else
395
+ nil
396
+ end
397
+ end
398
+
399
+ # One field as a JSON Schema document.
400
+ #
401
+ # @parameter field [Hash] the descriptor.
402
+ # @returns [Hash] the schema.
403
+ def field_schema(field)
404
+ case self.descriptor?(field) && field["type"]
405
+ in "object"
406
+ self.object_schema(field["fields"]) || {"type" => "object"}
407
+ in "array"
408
+ {"type" => "array"}.tap do |schema|
409
+ items = self.items_schema(field["fields"])
410
+
411
+ if items
412
+ schema["items"] = items
413
+ end
414
+ end
415
+ in String => type
416
+ {"type" => type}.tap do |schema|
417
+ if field["enum"]
418
+ schema["enum"] = field["enum"]
419
+ end
420
+ end
421
+ else
422
+ {}
423
+ end
424
+ end
425
+
426
+ # What an array's members are, which the schemas write as a map in a list of one.
427
+ #
428
+ # @parameter fields [Array | Hash | nil] what the descriptor said its members are.
429
+ # @returns [Hash | nil] the schema for one member, or nil where the members are unconstrained.
430
+ def items_schema(fields)
431
+ case fields
432
+ in [Hash => first, *]
433
+ self.object_schema(first)
434
+ in Hash => named
435
+ self.object_schema(named)
436
+ else
437
+ nil
438
+ end
439
+ end
440
+
441
+ # Whether this is a field's descriptor rather than a field map, which is what carrying a type says.
442
+ #
443
+ # @parameter field [Object] what the schema held.
444
+ # @returns [Boolean] whether it describes one field.
445
+ def descriptor?(field)
446
+ field.is_a?(Hash) && field["type"].is_a?(String)
447
+ end
448
+
449
+ # A document as json_schemer reads it, which is names as strings.
450
+ #
451
+ # @parameter value [Object] the document.
452
+ # @returns [Object] the same, with every name a string.
453
+ def stringify(value)
454
+ # The library answers symbol keys, because a Matrix document is read by pattern matching and a pattern names a symbol.
455
+ case value
456
+ in Hash => hash
457
+ hash.to_h{|name, held| [name.to_s, self.stringify(held)]}
458
+ in Array => array
459
+ array.map{|held| self.stringify(held)}
460
+ else
461
+ value
462
+ end
463
+ end
464
+ end
465
+ end
466
+ end
@@ -5,6 +5,6 @@
5
5
 
6
6
  module Async
7
7
  module Matrix
8
- VERSION = "4.0.0"
8
+ VERSION = "4.0.1"
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-matrix
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0
4
+ version: 4.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Kidd
@@ -79,6 +79,20 @@ dependencies:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0.2'
82
+ - !ruby/object:Gem::Dependency
83
+ name: json_schemer
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '2.5'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '2.5'
82
96
  - !ruby/object:Gem::Dependency
83
97
  name: lefthook
84
98
  requirement: !ruby/object:Gem::Requirement
@@ -104,8 +118,13 @@ extra_rdoc_files: []
104
118
  files:
105
119
  - CHANGELOG.md
106
120
  - LICENSE
107
- - NOTICE
108
121
  - README.md
122
+ - context/design.md
123
+ - context/getting-started.md
124
+ - context/index.yaml
125
+ - context/overview.md
126
+ - context/server-server.md
127
+ - context/testing.md
109
128
  - lib/async/matrix.rb
110
129
  - lib/async/matrix/abstract/api/application_service/client.rb
111
130
  - lib/async/matrix/abstract/api/application_service/ping.rb
@@ -6372,6 +6391,7 @@ files:
6372
6391
  - lib/async/matrix/redacted.rb
6373
6392
  - lib/async/matrix/removals.rb
6374
6393
  - lib/async/matrix/representation.rb
6394
+ - lib/async/matrix/schema.rb
6375
6395
  - lib/async/matrix/server_key.rb
6376
6396
  - lib/async/matrix/signatures.rb
6377
6397
  - lib/async/matrix/signatures/keyring.rb
data/NOTICE DELETED
@@ -1,56 +0,0 @@
1
- Async::Matrix
2
- Copyright 2026 General Intelligence Systems
3
-
4
- This product includes software developed at The Matrix.org Foundation C.I.C.
5
-
6
- --------------------------------------------------------------------------------
7
- vodozemac
8
- --------------------------------------------------------------------------------
9
-
10
- Encryption here is driven through the vodozemac gem, which is Ruby bindings for
11
- vodozemac, the Rust implementation of the Olm and Megolm cryptographic ratchets.
12
- The bindings were extracted from this project and are distributed separately.
13
-
14
- https://github.com/matrix-org/vodozemac
15
- Copyright 2021-2026 The Matrix.org Foundation C.I.C.
16
- Licensed under the Apache License, Version 2.0
17
-
18
- --------------------------------------------------------------------------------
19
- matrix-rust-sdk
20
- --------------------------------------------------------------------------------
21
-
22
- Parts of the encryption layer here follow matrix-rust-sdk, which is the
23
- reference client-side implementation of Matrix end-to-end encryption and the
24
- primary consumer of vodozemac. Where logic is derived from it, the derivation is
25
- noted at the point it appears.
26
-
27
- https://github.com/matrix-org/matrix-rust-sdk
28
- Copyright 2020-2026 The Matrix.org Foundation C.I.C.
29
- Licensed under the Apache License, Version 2.0
30
-
31
- matrix-rust-sdk carries no NOTICE file of its own, so there is nothing further
32
- to propagate under section 4(d) of the License.
33
-
34
- --------------------------------------------------------------------------------
35
- The Matrix specification
36
- --------------------------------------------------------------------------------
37
-
38
- The specification vendored under lib/async/matrix/e2ee/schema, and the schemas
39
- the classes in lib/async/matrix are generated from, are copied from the
40
- specification itself.
41
-
42
- https://github.com/matrix-org/matrix-spec
43
- Copyright 2014-2026 The Matrix.org Foundation C.I.C. and the contributors to
44
- the Matrix specification
45
- Licensed under the Apache License, Version 2.0
46
-
47
- --------------------------------------------------------------------------------
48
- On Fractal
49
- --------------------------------------------------------------------------------
50
-
51
- Fractal is a GTK Matrix client which is useful to read for how a client drives
52
- encryption, and it appears in no part of this product. It is licensed
53
- GPL-3.0-or-later, which is incompatible with the Apache License this work is
54
- distributed under, so no code or comment may be copied from it. Reading it to
55
- learn which operations a flow needs is fine; carrying its expression across is
56
- not.