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 +4 -4
- data/README.md +231 -2
- data/app/controllers/hub_kernel/api/hub_calls_controller.rb +7 -1
- data/app/controllers/hub_kernel/api/hub_controller.rb +44 -0
- data/app/controllers/hub_kernel/api/hubs_controller.rb +1 -5
- data/app/controllers/hub_kernel/api/versions_controller.rb +15 -0
- data/config/routes.rb +1 -0
- data/lib/hub_kernel/api/description.rb +17 -0
- data/lib/hub_kernel/api/description_check.rb +54 -0
- data/lib/hub_kernel/api/description_files.rb +13 -0
- data/lib/hub_kernel/api/earlier_answers.rb +11 -0
- data/lib/hub_kernel/api/major_version.rb +13 -0
- data/lib/hub_kernel/api/test_helper.rb +24 -0
- data/lib/hub_kernel/api/version.rb +1 -1
- data/lib/hub_kernel/api.rb +72 -1
- data/lib/tasks/hub_kernel_api.rake +14 -0
- data/the_local/agents/hub_kernel-api-develop.md +343 -41
- data/the_local/agents/hub_kernel-api-info.md +102 -13
- data/the_local/agents/hub_kernel-api-install.md +118 -17
- data/the_local/interface.yml +26 -0
- metadata +11 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d449e53f3b0c99bed3034c73374ef10b6136d6c6043b086ac0521b893dc47fac
|
|
4
|
+
data.tar.gz: eafda1fe30594c65ad7dcc7d79c5b13c57886c394ca0bb697951a49de7f6a9fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
33
|
-
every
|
|
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
|
|
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
|
@@ -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
|
data/lib/hub_kernel/api.rb
CHANGED
|
@@ -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!
|
|
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
|