hub_kernel-api 0.9.0 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db1ce3a434c4f5c216ee183a431c31b983481255e1b277d74b061e9c6fbd57bd
4
- data.tar.gz: 2e70a8aa06c9602d9b30cc9b9946bd432b5f58fefb54368e05829f4f4b20f66d
3
+ metadata.gz: d449e53f3b0c99bed3034c73374ef10b6136d6c6043b086ac0521b893dc47fac
4
+ data.tar.gz: eafda1fe30594c65ad7dcc7d79c5b13c57886c394ca0bb697951a49de7f6a9fb
5
5
  SHA512:
6
- metadata.gz: d872502bd39dd53ccad7391f327c1184aad52334ee0779d6f1491d1fb336118d0f0ee34f93164f70c0dda48a480b890173a46c500ef0731971ecb41ee4b79793
7
- data.tar.gz: 5c372f040e56f64de05b83f1752928083105cfae076c20dd1ad09850152a990d918698e51cdd20c55c6495bc8890f79ec4b59557e2561306dc0dcae28a3cc162
6
+ metadata.gz: cef4c91d77d71525529b67f6c41b333867fe5cbc9e2653d138c024c984ff603da50a69bf9ab22eec86a3f8c0d0c8bb5495f0223f26bef55f7153d109c68c85c9
7
+ data.tar.gz: 589cb4b0125b5289c5e1f1e28043b9bb456d924933b6309ea6493448e9bb57509b1bf92b16b561551b40a36ed3a61fdf9746aa9a1b7eb00580619855935f6146
data/README.md CHANGED
@@ -29,8 +29,9 @@ JSON API's own setting is used when it is set.
29
29
 
30
30
  `HubKernel::Api.check!` raises `HubKernel::Api::UnservableHubError` when a served entry
31
31
  exposes no methods, when two served hubs answer at the same address, or when a served
32
- hub's exposed list has a problem, naming each. Inside `to_prepare` it runs again after
33
- every code reload.
32
+ hub's exposed list has a problem, naming each. It also names each mistake in the host's
33
+ versions, as [Versions](#versions) describes, in the same error, so every problem is named
34
+ at once. Inside `to_prepare` it runs again after every code reload.
34
35
 
35
36
  `HubKernel::Api.hubs` reads and writes hub_kernel-interface's one served list,
36
37
  `HubKernel::Interface.hubs`, so every interface gem in the host, such as hub_kernel-mcp,
@@ -73,6 +74,234 @@ is shown nothing.
73
74
  | A permitted call sends a value the method is not listed with | 422 | `{"error": "<method> does not take <values>"}` |
74
75
  | A record the call names does not exist | 404 | `{"error": "No <record> has the id <id>"}` |
75
76
 
77
+ ## Versions
78
+
79
+ A host lists the major versions it serves, each by its full version in major.minor.patch
80
+ form. The major is the first part of that version:
81
+
82
+ ```ruby
83
+ HubKernel::Api.versions = [
84
+ HubKernel::Api::MajorVersion.new(version: "1.4.0"),
85
+ HubKernel::Api::MajorVersion.new(version: "2.0.1")
86
+ ]
87
+ ```
88
+
89
+ A host marks a major it no longer serves retired, and keeps it listed:
90
+
91
+ ```ruby
92
+ HubKernel::Api::MajorVersion.new(version: "1.4.0", retired: true)
93
+ ```
94
+
95
+ A method answers at the same address at every major. A caller names the major it was built
96
+ for in an `Accept-Version` request header, such as `Accept-Version: 1`. A call that sends no
97
+ `Accept-Version` header is answered at the oldest major listed that is not retired.
98
+
99
+ A hub method answers in the newest major's shape. A method whose answer changed in a newer
100
+ major declares its earlier answers with `exposes`, as hub_kernel-interface's readme
101
+ describes:
102
+
103
+ ```ruby
104
+ exposes :shelf_of, takes: %i[item], writes: false, earlier_answers: {
105
+ 1 => ->(answer) { answer.transform_keys(aisle: :shelf) }
106
+ }
107
+ ```
108
+
109
+ A call naming an older major answers with the method's answer passed through each earlier
110
+ answer the method declares for the majors from the newest down to the one named, newest
111
+ first. A call naming the newest major answers with the method's own answer.
112
+
113
+ Every answer at a listed major, including a hub listing and a hub's refusal, carries an
114
+ `Api-Version` header giving the full version it was answered at. Every answer, including
115
+ the refusal below, carries a `Vary: Accept-Version` header.
116
+
117
+ A host marks a major it still serves but plans to stop serving deprecated, and gives the
118
+ date it was deprecated and the date it stops being served:
119
+
120
+ ```ruby
121
+ HubKernel::Api::MajorVersion.new(version: "1.4.0", deprecated: true, deprecated_on: Date.new(2026, 9, 30), sunset: Date.new(2027, 3, 31))
122
+ ```
123
+
124
+ Every answer at a major older than the newest listed carries an `Api-Newest-Version` header
125
+ giving the newest major's full version. Every answer at a deprecated major also carries a
126
+ `Deprecation` header giving the date it was deprecated as `@` and the Unix seconds of that
127
+ date's midnight UTC, as RFC 9745 defines it, and a `Sunset` header giving the date it stops
128
+ being served as an HTTP date:
129
+
130
+ ```
131
+ Api-Version: 1.4.0
132
+ Api-Newest-Version: 2.0.1
133
+ Deprecation: @1790726400
134
+ Sunset: Wed, 31 Mar 2027 00:00:00 GMT
135
+ ```
136
+
137
+ An answer at the newest
138
+ major carries no `Api-Newest-Version` header, and carries no `Deprecation` or `Sunset`
139
+ header unless the newest major is itself deprecated. The headers never change an answer's
140
+ body.
141
+
142
+ A call naming a major the host does not list is answered with status 406 and no
143
+ `Api-Version` header. The controller raises `HubKernel::Api::UnservedMajorError` and
144
+ renders its message, which names every listed major that is not retired:
145
+
146
+ ```json
147
+ { "error": "Major 7 is not served, the majors served are 1 and 2" }
148
+ ```
149
+
150
+ A call or a listing naming a retired major is answered with status 410 and no
151
+ `Api-Version` header, whether or not the hub it names is served. The controller raises
152
+ `HubKernel::Api::RetiredMajorError` and renders its message:
153
+
154
+ ```json
155
+ { "error": "Major 1 is no longer served, the newest major is 2" }
156
+ ```
157
+
158
+ A host that lists no majors ignores any `Accept-Version` header, answers every call with
159
+ the method's own answer, and sends no version headers.
160
+
161
+ `HubKernel::Api.check!` refuses to start a host whose versions hold a mistake, and names
162
+ each of these:
163
+
164
+ - An earlier answer declared for a major the host does not list.
165
+ - An earlier answer declared for the newest major, which has no newer answer to translate
166
+ from.
167
+ - A listed version not a string in major.minor.patch form, such as `"2.1"` or the number
168
+ `2`.
169
+ - A major listed more than once.
170
+ - A deprecated major with no `deprecated_on:` date or no `sunset:` date.
171
+ - A `deprecated_on:` or `sunset:` date given as something other than a `Date`.
172
+ - A version list in which every major is retired, since a call with no `Accept-Version`
173
+ header would have no major to be answered at.
174
+
175
+ ```
176
+ Major 1 is deprecated with no sunset: date it stops being served
177
+ Supplies declares an earlier answer to shelf_of for major 3, the newest major, which has no newer answer to translate from
178
+ ```
179
+
180
+ A host that lists no majors has none of these checked.
181
+
182
+ ## Answer shapes
183
+
184
+ A method can declare the JSON Schema of its answer, and each earlier answer can declare
185
+ the shape of the answer at its major, with `exposes` as hub_kernel-interface's readme
186
+ describes:
187
+
188
+ ```ruby
189
+ exposes :shelf_of, takes: %i[item], writes: false, answer_shape: { "type" => "object", "required" => [ "aisle" ] }, earlier_answers: {
190
+ 1 => { translation: ->(answer) { answer.transform_keys(aisle: :shelf) }, answer_shape: { "type" => "object", "required" => [ "shelf" ] } }
191
+ }
192
+ ```
193
+
194
+ A hub listing, `/<hub>`, gives each method's `answer_shape` at the major the caller names
195
+ in `Accept-Version`, or at the oldest major listed that is not retired when it names none:
196
+
197
+ ```json
198
+ [{ "name": "shelf_of", "takes": ["item"], "verb": "GET", "answer_shape": { "type": "object", "required": ["shelf"] } }]
199
+ ```
200
+
201
+ A method's shape at the newest major is its own `answer_shape`. Its shape at an older major
202
+ is the shape of the earlier answer declared for that major, or else its shape at the major
203
+ after it. A method with no declared shape at that major, including one whose earlier answer
204
+ for it declares none, is listed with no `answer_shape` key. A host that lists no majors
205
+ lists each method's own shape.
206
+
207
+ A GET at the API's root address, `/`, lists every served hub's methods the caller may call,
208
+ keyed by the hub's address, each as a hub listing gives it, together with the full version
209
+ of the major it describes:
210
+
211
+ ```json
212
+ { "version": "1.4.0", "hubs": { "supplies": [{ "name": "shelf_of", "takes": ["item"], "verb": "GET", "answer_shape": { "type": "object", "required": ["shelf"] } }] } }
213
+ ```
214
+
215
+ A host that lists no majors gives no `version` key. The root address listing refuses a
216
+ major the host does not list with status 406 and a retired major with status 410, as a call
217
+ does.
218
+
219
+ ## Description files
220
+
221
+ A host writes one JSON description file for each major it lists that is not retired:
222
+
223
+ ```sh
224
+ bin/rails hub_kernel_api:describe
225
+ ```
226
+
227
+ The task writes each major's file into the host's `api_descriptions` folder, named after
228
+ the major, such as `api_descriptions/v1.json`. A file gives the major's full version and
229
+ every served hub's methods, keyed by the hub's address, each with its values, its verb and
230
+ its answer shape at that major, as the root address listing gives them:
231
+
232
+ ```json
233
+ { "version": "1.4.0", "hubs": { "supplies": [{ "name": "shelf_of", "takes": ["item"], "verb": "GET", "answer_shape": { "type": "object", "required": ["shelf"] } }] } }
234
+ ```
235
+
236
+ Unlike a listing, a file holds every served method, whatever any caller may call, and it
237
+ is written without a request, so it names no caller. Running the task again with no change
238
+ to any hub writes identical files, so the host can commit them. A client repo copies a
239
+ major's file in and checks a recorded answer against a method's `answer_shape` with a JSON
240
+ Schema library, without reaching a running host. The task writes no file for a retired
241
+ major, and a host that lists no majors writes no files.
242
+
243
+ ### Checking for breaking changes
244
+
245
+ A host's CI compares each major's current description with its committed file:
246
+
247
+ ```sh
248
+ bin/rails hub_kernel_api:check_descriptions
249
+ ```
250
+
251
+ The task raises `HubKernel::Api::BreakingChangeError`, naming every problem at once, when a
252
+ major that is not retired and already has a committed file breaks it in either of two ways:
253
+
254
+ - Its current description no longer holds something the file holds: a hub, a method, a
255
+ value the method takes, its verb, or a key or value in its answer shape. A rename shows
256
+ as the old name removed. An object in a list that has no `name` key, such as a choice
257
+ in an `anyOf`, is matched by its position in the list.
258
+ - Its current description adds something the file does not hold, and the minor of its full
259
+ version is not higher than the minor in the file.
260
+
261
+ ```
262
+ Major 2 removes or renames supplies.shelf_of.answer_shape.required.shelf, which its committed file holds
263
+ Major 2 adds supplies.shelf_of.takes.size without raising its minor above 2.1.0
264
+ ```
265
+
266
+ A change that keeps every key and value but changes what a value means passes. A retired
267
+ major's file is not compared, and a major with no committed file has nothing to compare.
268
+ Once a major only adds and its minor is raised, the check passes and
269
+ `bin/rails hub_kernel_api:describe` rewrites its file. The describe task refuses to write
270
+ any file while the check would fail, raising the same error naming each problem.
271
+
272
+ ## Testing an answer against its shape
273
+
274
+ A call never checks its answer against a declared shape, so a shape can stop describing what
275
+ its method answers. A host's test catches that with `assert_answer_shape`, which needs the
276
+ json_schemer gem in the host's test group:
277
+
278
+ ```ruby
279
+ group :test do
280
+ gem "json_schemer"
281
+ end
282
+ ```
283
+
284
+ ```ruby
285
+ require "hub_kernel/api/test_helper"
286
+
287
+ class SuppliesShapeTest < ActiveSupport::TestCase
288
+ include HubKernel::Api::TestHelper
289
+
290
+ test "shelf_of answers in its shape at major 1" do
291
+ assert_answer_shape(Supplies, :shelf_of, Supplies.shelf_of(item: "flour"), major: 1)
292
+ end
293
+ end
294
+ ```
295
+
296
+ The assertion takes the hub, the method's name, the method's own answer and a major the
297
+ host lists. It translates the answer down to that major through the earlier answers the hub
298
+ declares, as a call does, and checks it against the method's shape at that major, as a hub
299
+ listing gives it. A failing assertion names the method and the major, then each place the
300
+ answer differs from the shape, one to a line. It also fails naming the major and the method
301
+ when the host lists no such major, or when the method declares no shape at it. Used without
302
+ json_schemer installed, it raises a `LoadError` naming the gem to add. hub_kernel-api loads
303
+ json_schemer only when the assertion runs, so a host without it still loads the gem.
304
+
76
305
  ## Installation
77
306
 
78
307
  ```ruby
@@ -10,13 +10,19 @@ module HubKernel
10
10
  raise ActionController::RoutingError, "Not found" unless asked_with_the_right_verb?
11
11
 
12
12
  HubKernel::Interface::CallReasons.refuse_unlisted_values(hub, params[:name], values: values, person: caller_person, account: caller_account)
13
- render json: { answer: hub.call_exposed(params[:name], values: values, person: caller_person, account: caller_account) }
13
+ render json: { answer: at_major(hub.call_exposed(params[:name], values: values, person: caller_person, account: caller_account)) }
14
14
  end
15
15
 
16
16
  private
17
17
 
18
18
  def asked_with_the_right_verb? = hub.exposed(params[:name])&.writes == request.post?
19
19
 
20
+ def at_major(answer)
21
+ return answer if HubKernel::Api.versions.empty?
22
+
23
+ HubKernel::Api::EarlierAnswers.translate(answer, hub.exposed(params[:name]), major)
24
+ end
25
+
20
26
  def values = request.query_parameters.merge(request.request_parameters).deep_symbolize_keys
21
27
 
22
28
  def refused(refusal) = render(json: { error: refusal.message }, status: :unprocessable_content)
@@ -1,17 +1,61 @@
1
+ require "date"
2
+ require "active_support/core_ext/array/conversions"
3
+
1
4
  module HubKernel
2
5
  module Api
3
6
  class HubController < HubKernel::Api.base_controller.constantize
4
7
  rescue_from ActionController::RoutingError, HubKernel::NotAllowed, with: :not_found
8
+ rescue_from HubKernel::Api::UnservedMajorError, with: :unserved_major
9
+ rescue_from HubKernel::Api::RetiredMajorError, with: :retired_major
10
+
11
+ before_action :report_major, if: -> { HubKernel::Api.versions.any? }
5
12
 
6
13
  private
7
14
 
8
15
  def hub = @hub ||= HubKernel::Api.find(params[:hub]) || raise(ActionController::RoutingError, "Not found")
9
16
 
17
+ def major = @major ||= named_version ? named_major : HubKernel::Api.versions.reject(&:retired?).min_by(&:major)
18
+
19
+ def named_version = request.headers["Accept-Version"]
20
+
21
+ def named_major
22
+ listed = HubKernel::Api.versions.find { |version| version.major.to_s == named_version } || raise(HubKernel::Api::UnservedMajorError, "Major #{named_version} is not served, the majors served are #{served_majors}")
23
+ raise HubKernel::Api::RetiredMajorError, "Major #{named_version} is no longer served, the newest major is #{newest_major}" if listed.retired?
24
+
25
+ listed
26
+ end
27
+
28
+ def newest = HubKernel::Api.versions.max_by(&:major)
29
+
30
+ def newest_major = newest.major
31
+
32
+ def served_majors = HubKernel::Api.versions.reject(&:retired?).map(&:major).to_sentence(last_word_connector: " and ")
33
+
34
+ def report_major
35
+ response.headers["Vary"] = "Accept-Version"
36
+ response.headers["Api-Version"] = major.version
37
+ response.headers["Api-Newest-Version"] = newest.version unless major == newest
38
+ report_deprecation if major.deprecated?
39
+ end
40
+
41
+ def report_deprecation
42
+ response.headers["Deprecation"] = "@#{Time.utc(major.deprecated_on.year, major.deprecated_on.month, major.deprecated_on.day).to_i}" if major.deprecated_on
43
+ response.headers["Sunset"] = major.sunset.httpdate if major.sunset
44
+ end
45
+
46
+ def listing_of(hub) = hub.exposures_for(person: caller_person, account: caller_account).map { |exposure| HubKernel::Api::Description.described(exposure, listed_major) }
47
+
48
+ def listed_major = (major if HubKernel::Api.versions.any?)
49
+
10
50
  def caller_person = send(HubKernel::Api.person_method || HubKernel::Interface.person_method)
11
51
 
12
52
  def caller_account = send(HubKernel::Api.account_method || HubKernel::Interface.account_method)
13
53
 
14
54
  def not_found = render(json: { error: "Not found" }, status: :not_found)
55
+
56
+ def unserved_major(refusal) = render(json: { error: refusal.message }, status: :not_acceptable)
57
+
58
+ def retired_major(refusal) = render(json: { error: refusal.message }, status: :gone)
15
59
  end
16
60
  end
17
61
  end
@@ -2,12 +2,8 @@ module HubKernel
2
2
  module Api
3
3
  class HubsController < HubController
4
4
  def show
5
- render json: hub.exposures_for(person: caller_person, account: caller_account).map { |exposure| listed(exposure) }
5
+ render json: listing_of(hub)
6
6
  end
7
-
8
- private
9
-
10
- def listed(exposure) = { name: exposure.name, takes: exposure.takes, verb: exposure.writes ? "POST" : "GET" }
11
7
  end
12
8
  end
13
9
  end
@@ -0,0 +1,15 @@
1
+ module HubKernel
2
+ module Api
3
+ class VersionsController < HubController
4
+ def show
5
+ render json: { version: described_version, hubs: HubKernel::Interface.served.transform_values { |served| listing_of(served) } }.compact
6
+ end
7
+
8
+ private
9
+
10
+ def described_version
11
+ major.version if HubKernel::Api.versions.any?
12
+ end
13
+ end
14
+ end
15
+ end
data/config/routes.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  HubKernel::Api::Engine.routes.draw do
2
+ get "/", to: "versions#show"
2
3
  get ":hub", to: "hubs#show"
3
4
  match ":hub/:name", to: "hub_calls#answer", via: [ :get, :post ]
4
5
  end
@@ -0,0 +1,17 @@
1
+ module HubKernel
2
+ module Api
3
+ module Description
4
+ def self.at(major) = { version: major.version, hubs: HubKernel::Interface.served.transform_values { |hub| hub.exposures.map { |exposure| described(exposure, major) } } }
5
+
6
+ def self.described(exposure, major) = { name: exposure.name, takes: exposure.takes, verb: exposure.writes ? "POST" : "GET", answer_shape: answer_shape_at(exposure, major) }.compact
7
+
8
+ def self.answer_shape_at(exposure, major)
9
+ return exposure.answer_shape if major.nil?
10
+
11
+ (exposure.earlier_answers.select { |earlier| earlier.major >= major.major }.min_by(&:major) || exposure).answer_shape
12
+ end
13
+
14
+ private_class_method :answer_shape_at
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,54 @@
1
+ require "json"
2
+
3
+ module HubKernel
4
+ module Api
5
+ class BreakingChangeError < StandardError; end
6
+
7
+ module DescriptionCheck
8
+ def self.check!(directory)
9
+ problems = removals(directory) + unraised_additions(directory)
10
+ raise BreakingChangeError, problems.join("\n") if problems.any?
11
+ end
12
+
13
+ def self.removals(directory)
14
+ committed_majors(directory).flat_map do |major|
15
+ missing(keyed(committed_hubs(directory, major)), keyed(current_hubs(major))).map { |path| "Major #{major.major} removes or renames #{path.join(".")}, which its committed file holds" }
16
+ end
17
+ end
18
+
19
+ def self.unraised_additions(directory)
20
+ committed_majors(directory).reject { |major| minor(major.version) > minor(committed(directory, major)["version"]) }.flat_map do |major|
21
+ missing(keyed(current_hubs(major)), keyed(committed_hubs(directory, major))).map do |path|
22
+ "Major #{major.major} adds #{path.join(".")} without raising its minor above #{committed(directory, major)["version"]}"
23
+ end
24
+ end
25
+ end
26
+
27
+ def self.committed_majors(directory) = HubKernel::Api.versions.reject(&:retired?).select { |major| File.exist?(file(directory, major)) }
28
+
29
+ def self.file(directory, major) = File.join(directory, "v#{major.major}.json")
30
+
31
+ def self.committed(directory, major) = JSON.parse(File.read(file(directory, major)))
32
+
33
+ def self.committed_hubs(directory, major) = committed(directory, major)["hubs"]
34
+
35
+ def self.current_hubs(major) = JSON.parse(Description.at(major).to_json)["hubs"]
36
+
37
+ def self.minor(version) = version.split(".").second.to_i
38
+
39
+ def self.keyed(value)
40
+ case value
41
+ when Hash then value.transform_values { |inner| keyed(inner) }
42
+ when Array then value.each_with_index.to_h { |element, position| element.is_a?(Hash) ? [ element.fetch("name", position), keyed(element) ] : [ element, {} ] }
43
+ else { value => {} }
44
+ end
45
+ end
46
+
47
+ def self.missing(held, current, path = [])
48
+ held.flat_map { |key, inner| current.key?(key) ? missing(inner, current[key], path + [ key ]) : [ path + [ key ] ] }
49
+ end
50
+
51
+ private_class_method :unraised_additions, :committed_majors, :file, :committed, :committed_hubs, :current_hubs, :minor, :keyed, :missing
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,13 @@
1
+ require "json"
2
+
3
+ module HubKernel
4
+ module Api
5
+ module DescriptionFiles
6
+ def self.write(directory)
7
+ DescriptionCheck.check!(directory)
8
+
9
+ HubKernel::Api.versions.reject(&:retired?).each { |major| File.write(File.join(directory, "v#{major.major}.json"), JSON.pretty_generate(Description.at(major))) }
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,11 @@
1
+ module HubKernel
2
+ module Api
3
+ module EarlierAnswers
4
+ def self.translate(answer, exposure, major) = down_to(exposure, major).reduce(answer) { |translated, earlier| earlier.translate(translated) }
5
+
6
+ def self.down_to(exposure, major) = exposure.earlier_answers.select { |earlier| earlier.major >= major.major }.sort_by(&:major).reverse
7
+
8
+ private_class_method :down_to
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ module HubKernel
2
+ module Api
3
+ MajorVersion = Data.define(:version, :retired, :deprecated, :deprecated_on, :sunset) do
4
+ def initialize(version:, retired: false, deprecated: false, deprecated_on: nil, sunset: nil) = super
5
+
6
+ def major = version.split(".").first.to_i
7
+
8
+ def retired? = retired
9
+
10
+ def deprecated? = deprecated
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,24 @@
1
+ module HubKernel
2
+ module Api
3
+ module TestHelper
4
+ def assert_answer_shape(hub, name, answer, major:)
5
+ require_json_schemer
6
+
7
+ exposure = hub.exposed(name)
8
+ listed = HubKernel::Api.versions.find { |version| version.major == major } || flunk("The host lists no major #{major} to check #{name} at")
9
+ declared = HubKernel::Api::Description.described(exposure, listed)[:answer_shape] || flunk("#{name} declares no answer shape at major #{major}")
10
+ shape = JSONSchemer.schema(declared)
11
+ differences = shape.validate(HubKernel::Api::EarlierAnswers.translate(answer, exposure, listed)).map { |difference| difference["error"] }
12
+ assert differences.empty?, [ "The answer to #{name} at major #{major} does not match its shape", *differences ].join("\n")
13
+ end
14
+
15
+ private
16
+
17
+ def require_json_schemer
18
+ require "json_schemer"
19
+ rescue LoadError
20
+ raise LoadError, 'assert_answer_shape needs the json_schemer gem, add gem "json_schemer" to the test group of your Gemfile'
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,5 +1,5 @@
1
1
  module HubKernel
2
2
  module Api
3
- VERSION = "0.9.0"
3
+ VERSION = "0.10.0"
4
4
  end
5
5
  end
@@ -1,14 +1,23 @@
1
+ require "date"
1
2
  require "hub_kernel-interface"
2
3
  require "hub_kernel/api/version"
4
+ require "hub_kernel/api/major_version"
5
+ require "hub_kernel/api/earlier_answers"
6
+ require "hub_kernel/api/description"
7
+ require "hub_kernel/api/description_files"
8
+ require "hub_kernel/api/description_check"
3
9
  require "hub_kernel/api/engine"
4
10
 
5
11
  module HubKernel
6
12
  module Api
7
13
  UnservableHubError = HubKernel::Interface::UnservableHubError
14
+ class UnservedMajorError < StandardError; end
15
+ class RetiredMajorError < StandardError; end
8
16
 
9
17
  mattr_accessor :base_controller, default: "ActionController::API"
10
18
  mattr_accessor :person_method
11
19
  mattr_accessor :account_method
20
+ mattr_accessor :versions, default: []
12
21
 
13
22
  def self.hubs = HubKernel::Interface.hubs
14
23
 
@@ -18,6 +27,68 @@ module HubKernel
18
27
 
19
28
  def self.find(name) = HubKernel::Interface.find(name)
20
29
 
21
- def self.check! = HubKernel::Interface.check!
30
+ def self.check!
31
+ problems = served_list_problems + version_problems
32
+ raise UnservableHubError, problems.join("\n") if problems.any?
33
+ end
34
+
35
+ def self.version_problems
36
+ return [] if versions.empty?
37
+
38
+ unlisted_earlier_answers + newest_earlier_answers + unformed_versions + doubled_majors + undated(:deprecated_on, "it was deprecated") + undated(:sunset, "it stops being served") +
39
+ non_dates(:deprecated_on) + non_dates(:sunset) + all_retired
40
+ end
41
+
42
+ def self.served_list_problems
43
+ HubKernel::Interface.check!
44
+ []
45
+ rescue UnservableHubError => error
46
+ error.message.split("\n")
47
+ end
48
+
49
+ def self.unlisted_earlier_answers
50
+ earlier_answers.reject { |_hub, _exposure, earlier| formed_versions.map(&:major).include?(earlier.major) }.map do |hub, exposure, earlier|
51
+ "#{hub.name} declares an earlier answer to #{exposure.name} for major #{earlier.major}, which the host does not list"
52
+ end
53
+ end
54
+
55
+ def self.newest_earlier_answers
56
+ earlier_answers.select { |_hub, _exposure, earlier| earlier.major == formed_versions.map(&:major).max }.map do |hub, exposure, earlier|
57
+ "#{hub.name} declares an earlier answer to #{exposure.name} for major #{earlier.major}, the newest major, which has no newer answer to translate from"
58
+ end
59
+ end
60
+
61
+ def self.unformed_versions
62
+ (versions - formed_versions).map { |listed| "The listed version #{listed.version} is not in major.minor.patch form" }
63
+ end
64
+
65
+ def self.formed_versions = versions.select { |listed| listed.version.to_s.match?(/\A\d+\.\d+\.\d+\z/) }
66
+
67
+ def self.doubled_majors
68
+ formed_versions.group_by(&:major).select { |_major, listed| listed.size > 1 }.keys.map { |major| "Major #{major} is listed more than once" }
69
+ end
70
+
71
+ def self.undated(field, meaning)
72
+ formed_versions.select { |listed| listed.deprecated? && listed.public_send(field).nil? }.map { |listed| "Major #{listed.major} is deprecated with no #{field}: date #{meaning}" }
73
+ end
74
+
75
+ def self.all_retired
76
+ return [] unless versions.all?(&:retired?)
77
+
78
+ [ "Every listed major is retired, so a call with no Accept-Version has no major to be answered at" ]
79
+ end
80
+
81
+ def self.non_dates(field)
82
+ formed_versions.reject { |listed| listed.public_send(field).nil? || listed.public_send(field).is_a?(Date) }.map do |listed|
83
+ "Major #{listed.major} gives #{field}: as #{listed.public_send(field).inspect}, which is not a Date"
84
+ end
85
+ end
86
+
87
+ def self.earlier_answers
88
+ exposing_hubs.flat_map { |hub| hub.exposures.flat_map { |exposure| exposure.earlier_answers.map { |earlier| [ hub, exposure, earlier ] } } }
89
+ end
90
+
91
+ def self.exposing_hubs = HubKernel::Interface.served.values.uniq.select { |hub| hub.respond_to?(:exposures) }
92
+ private_class_method :served_list_problems, :version_problems, :unlisted_earlier_answers, :newest_earlier_answers, :unformed_versions, :formed_versions, :doubled_majors, :undated, :all_retired, :non_dates, :earlier_answers, :exposing_hubs
22
93
  end
23
94
  end
@@ -0,0 +1,14 @@
1
+ require "fileutils"
2
+
3
+ namespace :hub_kernel_api do
4
+ desc "Write one JSON description file for each listed major that is not retired into the api_descriptions folder, refusing while a major removes or renames something its committed file holds, or adds something without raising its minor"
5
+ task describe: :environment do
6
+ FileUtils.mkdir_p(Rails.root.join("api_descriptions"))
7
+ HubKernel::Api::DescriptionFiles.write(Rails.root.join("api_descriptions"))
8
+ end
9
+
10
+ desc "Fail when a listed major that is not retired removes or renames something its committed file in the api_descriptions folder holds, or adds something without raising its minor"
11
+ task check_descriptions: :environment do
12
+ HubKernel::Api::DescriptionCheck.check!(Rails.root.join("api_descriptions"))
13
+ end
14
+ end