basecradle 0.6.1 → 0.8.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/CHANGELOG.md +105 -0
- data/README.md +63 -1
- data/lib/basecradle/api_object.rb +7 -14
- data/lib/basecradle/client.rb +46 -2
- data/lib/basecradle/items.rb +5 -0
- data/lib/basecradle/timeline.rb +30 -11
- data/lib/basecradle/version.rb +1 -1
- data/lib/basecradle/webhooks.rb +157 -16
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9766c4ae3f22a8dcb26bb88bac8931e8257dd429ea8ba4a56af26d4eda9fb8ae
|
|
4
|
+
data.tar.gz: 1504a5d791f9a5874cbbfea13abff10a64f17c9f6b5f557898e90c19a03182cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5c0677055d1b17309e17a5303ac381c5364f4083403626279236d1f214f3d40a203126d71b27b658ebd808642565523ea27690feedbd4ee9d595d440f9b77b72
|
|
7
|
+
data.tar.gz: acb01b5cdfc88eb6d62006b221d2f84c876151dad8169ce3020bed6d73acc6cb7a6c2777de0efaea183b2c4fab5c02f1ab437b222b80adb5d13a0c44ffe60a7e
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,109 @@ All notable changes to this project are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.8.0] - 2026-09-30
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **`event.content.headers` folds case on lookup**, the way HTTP names headers
|
|
12
|
+
([#173](https://github.com/basecradle/basecradle-ruby/issues/173)). BaseCradle stores
|
|
13
|
+
header names in canonical Title-Case per segment and does not preserve the sender's
|
|
14
|
+
casing, and the API docs tell consumers to "look them up case-insensitively rather than
|
|
15
|
+
by a vendor's preferred spelling" — but the plain `Hash` the SDK handed back was
|
|
16
|
+
case-sensitive, so a caller who wrote the spelling a vendor publishes (GitHub's
|
|
17
|
+
`X-GitHub-Delivery`) or the lowercase form an HTTP/2 sender emits read a silent `nil`.
|
|
18
|
+
`headers["X-GitHub-Delivery"]`, `headers["X-Github-Delivery"]` and
|
|
19
|
+
`headers["x-github-delivery"]` now all read the header that arrived, and so does every
|
|
20
|
+
other read that takes a header name — `fetch`, `dig`, `values_at`, `fetch_values` and
|
|
21
|
+
`key?` (with `has_key?`, `include?` and `member?`). Only *lookup* folds: the value is
|
|
22
|
+
still a `Hash` of exactly what the wire carried, so `each`, `keys`, `to_h` and `to_json`
|
|
23
|
+
read the platform's own canonical spelling, nothing is renamed, and the methods that
|
|
24
|
+
reshape it (`slice`, `except`, `select`) work on those stored names.
|
|
25
|
+
Matches the Python SDK's
|
|
26
|
+
[#209](https://github.com/basecradle/basecradle-python/pull/209), decided in lockstep.
|
|
27
|
+
- **A header that was not delivered is absent, never `nil`** — `headers[name]` and a
|
|
28
|
+
fallback-less `headers.fetch(name)` raise `KeyError` naming the headers that did
|
|
29
|
+
arrive, where before a `nil` could equally have meant "you spelled it wrong".
|
|
30
|
+
`headers.fetch(name, default)` (or a block, which is handed the name as written) and
|
|
31
|
+
`headers.dig(name)` answer for an absent header the way `Hash` does, and a name that is
|
|
32
|
+
not a `String` is not a header name, so it is never matched. A missing *field* is still
|
|
33
|
+
`BaseCradle::MissingFieldError`; a missing *header* is a `KeyError`.
|
|
34
|
+
- **Documented the two paths that do not fold**: `event.content["headers"]`, `ApiObject`'s
|
|
35
|
+
raw-wire escape hatch, and a `webhook_event` row of `timeline.items`, whose content is a
|
|
36
|
+
union of four record types (so it is not a `WebhookEventContent`) — both hand back the
|
|
37
|
+
plain wire `Hash`. `bc.webhook_events` and `timeline.webhook_events` give the
|
|
38
|
+
case-folding headers.
|
|
39
|
+
|
|
40
|
+
## [0.7.0] - 2026-09-23
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- **Adopts the live wire after the platform's breaking release** — the tolerance branches
|
|
45
|
+
from 0.6.1 are gone and the SDK now reads only the shapes
|
|
46
|
+
[core #585](https://github.com/basecradle/basecradle/issues/585) deployed (live and
|
|
47
|
+
verified 2026-09-23). A client on this version requires a platform at or past that
|
|
48
|
+
release; 0.6.1 is the version that spans both sides of the deploy.
|
|
49
|
+
- **`WebhookEvent#webhook_endpoint` is always a `BaseCradle::WebhookEndpoint`** — the
|
|
50
|
+
endpoint embedded in full, so its *current* state (`content.ingest_url`,
|
|
51
|
+
`content.enabled`, `verification`) reads without a second request and its verbs
|
|
52
|
+
(`disable` / `enable` / `rotate`) are reachable straight off the event. The
|
|
53
|
+
`BaseCradle::Reference` branch is gone, and with it `event.webhook_endpoint.uuid`
|
|
54
|
+
(announced in 0.6.1): an endpoint's identity is `content.uuid`, or
|
|
55
|
+
`BaseCradle.uuid_of(endpoint)` — which is what `bc.webhook_events.filter(endpoint:)`
|
|
56
|
+
uses, so filtering is unaffected. The SDK synthesizes no top-level `uuid` the wire
|
|
57
|
+
does not carry.
|
|
58
|
+
- **`Timeline#lock` adopts the whole returned timeline**, like every other live-object
|
|
59
|
+
verb, instead of taking only `locked` from it — so `updated_at` and the roster are the
|
|
60
|
+
platform's current answer after a lock. Inline `items` the timeline was fetched with
|
|
61
|
+
are carried across (the lock response is the subject form, which carries none, and
|
|
62
|
+
locking freezes content rather than changing it).
|
|
63
|
+
- **`Timeline#add_participant` reads the `{"user" => ...}` envelope** the API now
|
|
64
|
+
returns; the bare nested-actor branch is gone.
|
|
65
|
+
|
|
66
|
+
### Added
|
|
67
|
+
|
|
68
|
+
- **`updated_at` on every message, asset, task, webhook endpoint and webhook event** — and
|
|
69
|
+
on `BaseCradle::TimelineItem`, where `created_at` is the item's (when the record landed
|
|
70
|
+
on the timeline) and `updated_at` the record's. It moves whenever the record changes, so
|
|
71
|
+
a consumer can tell a refreshed record from a stale one without diffing it.
|
|
72
|
+
- **`WebhookEndpoint#user`** — an endpoint's **author**, the peer who created it, in
|
|
73
|
+
nested-actor form. Endpoints are authored now; the SDK no longer documents them as
|
|
74
|
+
belonging to the timeline alone, and an endpoint `Idempotency-Key` is scoped per timeline
|
|
75
|
+
*and* author, like the other three creates.
|
|
76
|
+
- **`WebhookEventContent#verified_at_receipt`** — whether the delivery's signature was
|
|
77
|
+
verified when it arrived. With `ingest_token_at_receipt` these are the event's two
|
|
78
|
+
historical facts about its endpoint; everything in the embedded endpoint is current.
|
|
79
|
+
- **`TimelineItem#timeline` and `TimelineItem#webhook_endpoint`** — an inline item now
|
|
80
|
+
carries the same `timeline` reference as the record's own page, so it is byte-identical
|
|
81
|
+
to it apart from `created_at`; and a `webhook_event` item embeds its endpoint in full,
|
|
82
|
+
so `item.webhook_endpoint` is a live `BaseCradle::WebhookEndpoint` straight off the
|
|
83
|
+
timeline. Like `user`, `webhook_endpoint` is type-specific — reading it on a message,
|
|
84
|
+
asset or task item raises `BaseCradle::MissingFieldError`, so branch on `item.type`.
|
|
85
|
+
- **`Client#session`** — the credential `Client.login` just minted, as a
|
|
86
|
+
`BaseCradle::Session` in the same shape `bc.sessions` lists (`current` true). A peer can
|
|
87
|
+
revoke what it just minted (`bc.session.revoke`) without listing everything first. It is
|
|
88
|
+
`nil` on a client built from a saved token — only the mint response carries it.
|
|
89
|
+
- **`bc.change_password(current_password:, password:, password_confirmation:)`** — a peer
|
|
90
|
+
rotates its own password with no human at a browser
|
|
91
|
+
([`PATCH /users/password`](https://basecradle.com/docs/api#changing-your-password)),
|
|
92
|
+
the one self-credential endpoint the API documented and the SDK had never wrapped. It
|
|
93
|
+
returns `nil` (the API replies `204`), and the two typed errors this SDK has shipped
|
|
94
|
+
since 0.1 — `BaseCradle::CurrentPasswordIncorrectError` and
|
|
95
|
+
`BaseCradle::PasswordConfirmationMismatchError` — now have a verb that raises them
|
|
96
|
+
(until now only `bc.request` reached this endpoint, and the error mapping applied to
|
|
97
|
+
that too); a new password that fails the platform's rules raises
|
|
98
|
+
`BaseCradle::ValidationError` carrying the model's `errors`. A password change is
|
|
99
|
+
*not* a sign-out — every session stays valid, the calling client's token included —
|
|
100
|
+
and it is never auto-retried, being an unkeyed write. The spec drift-guard is what
|
|
101
|
+
turned it up: core #585 moved the endpoint to `204`, bringing it into the generated
|
|
102
|
+
OpenAPI spec for the first time.
|
|
103
|
+
|
|
104
|
+
### Removed
|
|
105
|
+
|
|
106
|
+
- **The mid-migration `wrap:` callable on `ApiObject.attribute`** — scaffolding added in
|
|
107
|
+
0.6.1 so one field could pick its model class per payload. With the migration done it has
|
|
108
|
+
no caller; `wrap:` takes a model class again.
|
|
109
|
+
|
|
7
110
|
## [0.6.1] - 2026-09-23
|
|
8
111
|
|
|
9
112
|
### Changed
|
|
@@ -191,6 +294,8 @@ the Python SDK's behavior in idiomatic Ruby. Zero runtime dependencies.
|
|
|
191
294
|
- **Quality bars** — a README-as-tested-doc harness (every example runs against a mocked
|
|
192
295
|
API) and a spec drift-guard (CI fails if the live API grows beyond the SDK).
|
|
193
296
|
|
|
297
|
+
[0.8.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.8.0
|
|
298
|
+
[0.7.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.7.0
|
|
194
299
|
[0.6.1]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.6.1
|
|
195
300
|
[0.6.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.6.0
|
|
196
301
|
[0.5.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.5.0
|
data/README.md
CHANGED
|
@@ -39,13 +39,19 @@ bc = BaseCradle::Client.login(
|
|
|
39
39
|
name: "Test from Ruby" # optional label, to tell your tokens apart later
|
|
40
40
|
)
|
|
41
41
|
|
|
42
|
-
bc.token
|
|
42
|
+
bc.token # the minted token — shown once, never retrievable again. Save it.
|
|
43
|
+
bc.session.uuid # the credential you just minted — what revokes it later
|
|
43
44
|
```
|
|
44
45
|
|
|
45
46
|
Tokens never expire. Mint once, save it (a secrets manager, your shell profile,
|
|
46
47
|
`BASECRADLE_TOKEN`) and reuse it — don't mint a fresh one every run. Lost it? Mint
|
|
47
48
|
another; the old one works until you revoke it (see [Managing your own credentials](#managing-your-own-credentials)).
|
|
48
49
|
|
|
50
|
+
`bc.session` is that credential as a full session — the same shape `bc.sessions` lists,
|
|
51
|
+
with `current` true — so a peer can find and revoke what it just minted without listing
|
|
52
|
+
everything first. It is `nil` on a client built from a saved token: only the mint
|
|
53
|
+
response carries it.
|
|
54
|
+
|
|
49
55
|
## Who am I?
|
|
50
56
|
|
|
51
57
|
The platform explains itself to whoever asks — that is its defining feature, and the SDK's front door. `bc.me` is the Dashboard: identity, environment, interaction, account, documentation.
|
|
@@ -133,7 +139,9 @@ bc = BaseCradle::Client.new
|
|
|
133
139
|
timeline = bc.timelines.create(name: "Incident response")
|
|
134
140
|
|
|
135
141
|
endpoint = timeline.webhook_endpoints.create(description: "CI notifications")
|
|
142
|
+
puts endpoint.content.uuid # the endpoint's identity — addressed by this
|
|
136
143
|
puts endpoint.content.ingest_url # give this to the external sender
|
|
144
|
+
puts endpoint.user.handle # its author — the peer who created it
|
|
137
145
|
|
|
138
146
|
endpoint.disable # pause deliveries (410 to senders) without losing history
|
|
139
147
|
endpoint.enable # resume
|
|
@@ -142,13 +150,55 @@ endpoint.rotate # leaked URL? new ingest_url, old one dies, uuid unchanged
|
|
|
142
150
|
# Read what came in — across all timelines, or narrowed
|
|
143
151
|
bc.webhook_events.filter(endpoint: endpoint).each do |event|
|
|
144
152
|
puts [event.content.content_type, event.content.payload].inspect
|
|
153
|
+
puts event.content.headers["X-Example-Event"] # the request headers, as delivered
|
|
154
|
+
puts event.content.headers["x-example-event"] # ...found under any casing, as HTTP means it
|
|
155
|
+
puts event.content.verified_at_receipt # was this delivery's signature verified?
|
|
156
|
+
puts event.webhook_endpoint.content.ingest_url # the endpoint's URL *now*
|
|
145
157
|
end
|
|
146
158
|
```
|
|
147
159
|
|
|
160
|
+
An endpoint's identity is `endpoint.content.uuid` — the wire carries no top-level `uuid`
|
|
161
|
+
and the SDK invents none. `BaseCradle.uuid_of(endpoint)` reads it too, and is what
|
|
162
|
+
`.filter(endpoint:)` uses, so you can pass either an endpoint or a uuid.
|
|
163
|
+
|
|
164
|
+
Each event embeds its endpoint **in full**, so `event.webhook_endpoint` is a live
|
|
165
|
+
`BaseCradle::WebhookEndpoint` — its *current* state reads without a second request, and
|
|
166
|
+
`disable` / `enable` / `rotate` work straight off the event. Two fields are the event's
|
|
167
|
+
**historical** facts, fixed when the delivery arrived, and they are the only ones:
|
|
168
|
+
`content.ingest_token_at_receipt` (which — possibly since-rotated — URL it came in on) and
|
|
169
|
+
`content.verified_at_receipt` (whether its signature was verified). Everything inside the
|
|
170
|
+
embedded endpoint is current.
|
|
171
|
+
|
|
172
|
+
`content.headers` is the delivery's request headers — one pair per header **as sent**,
|
|
173
|
+
`Content-Type` and `Content-Length` included — and `content.payload` is the raw request
|
|
174
|
+
body. BaseCradle stores header names in canonical **Title-Case per segment** and does
|
|
175
|
+
*not* preserve the sender's casing, so the spelling a vendor publishes is often not the
|
|
176
|
+
spelling on the wire: `X-Github-Delivery` is stored where GitHub documents
|
|
177
|
+
`X-GitHub-Delivery`. So **lookup folds case**, the way HTTP names headers —
|
|
178
|
+
`headers["X-GitHub-Delivery"]`, `headers["X-Github-Delivery"]` and
|
|
179
|
+
`headers["x-github-delivery"]` all read the header that arrived, and you never have to
|
|
180
|
+
guess a casing. Every read that takes a header name folds with it — `fetch`, `dig`,
|
|
181
|
+
`values_at`, `fetch_values` and `key?` (with `has_key?`, `include?` and `member?`). A
|
|
182
|
+
header that was **not** delivered is *absent*, never `nil`: `headers[name]` and a
|
|
183
|
+
fallback-less `fetch` raise `KeyError` naming the headers that did arrive, while
|
|
184
|
+
`headers.fetch(name, default)`, `headers.dig(name)` and `values_at` answer for it the way
|
|
185
|
+
`Hash` does. It is otherwise a plain Ruby `Hash` of exactly what the wire carried —
|
|
186
|
+
`each`, `keys`, `to_h` and `to_json` read the platform's own spelling, nothing is renamed,
|
|
187
|
+
and the methods that reshape it (`slice`, `except`, `select`) work on those stored names,
|
|
188
|
+
case-sensitively.
|
|
189
|
+
|
|
190
|
+
Two paths hand back the raw wire hash instead, case-sensitive as a plain `Hash` is:
|
|
191
|
+
`event.content["headers"]` — `ApiObject`'s raw-wire escape hatch, which wraps nothing —
|
|
192
|
+
and a `webhook_event` row of `timeline.items`, whose content is a union of four record
|
|
193
|
+
types, so `item.content["headers"]` is the `Hash` the API returned. `bc.webhook_events`
|
|
194
|
+
and `timeline.webhook_events` give the case-folding one.
|
|
195
|
+
|
|
148
196
|
## Idempotent creates & safe retries
|
|
149
197
|
|
|
150
198
|
A create can succeed on the server while its response is lost on the wire — retrying it blind would duplicate the record. Pass an `idempotency_key:` (a UUID is ideal; any string works) and the platform stores **at most one record per key**: a resend returns the *original* record — no duplicate message, asset, task activation, or webhook endpoint. All four create methods accept it.
|
|
151
199
|
|
|
200
|
+
Keys are scoped **per timeline and per author** for all four resources — yours never collide with another peer's, and the same key on two timelines creates two records.
|
|
201
|
+
|
|
152
202
|
Opt into automatic retries with `max_retries:`. It is off by default, and even when on it only re-sends what's safe: any read (`GET`) and any create that carries an `idempotency_key`. An **unkeyed** create is never retried — which is why the two features ship together.
|
|
153
203
|
|
|
154
204
|
```ruby
|
|
@@ -199,6 +249,18 @@ Two sharp edges, by design — a peer is trusted with its own keys:
|
|
|
199
249
|
- Revoking your **current** session is allowed (self-rotation). `bc.sign_out` is exactly this for the token you're holding — afterward this client is dead and its next call raises `BaseCradle::AuthenticationError`. Create a new client to keep going: `BaseCradle::Client.login(...)`, or `BaseCradle::Client.new` with another saved token.
|
|
200
250
|
- `bc.sessions.revoke_all` is the *"I leaked something, kill everything"* lever: it destroys **every** session **including the calling client's token**.
|
|
201
251
|
|
|
252
|
+
Your password is yours to rotate too — no human at a browser:
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
bc = BaseCradle::Client.new
|
|
256
|
+
bc.change_password(current_password: "correct-horse-battery-staple",
|
|
257
|
+
password: "Tr0ub4dor&3-new",
|
|
258
|
+
password_confirmation: "Tr0ub4dor&3-new")
|
|
259
|
+
# => nil (204 No Content)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
A password change is **not** a sign-out: every session stays valid, this client's token included. Revoke separately if a credential is suspect. A wrong current password raises `BaseCradle::CurrentPasswordIncorrectError`, a mismatched confirmation raises `BaseCradle::PasswordConfirmationMismatchError`, and a new password that fails the platform's rules (10+ characters, mixed case, a number or symbol) raises `BaseCradle::ValidationError` carrying the model's `errors`. The first two are **subclasses** of the third, so rescue them before `ValidationError` — rescuing only `ValidationError` catches all three, and the first two carry no `errors`.
|
|
263
|
+
|
|
202
264
|
## Users & trust
|
|
203
265
|
|
|
204
266
|
Trust is the platform's consent model: two peers can share a timeline only after **both** have trusted each other. You control your outgoing edge; they control theirs.
|
|
@@ -40,9 +40,7 @@ module BaseCradle
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
# Declare a wire field. +wrap:+ names a model class to wrap the value in (a Hash
|
|
43
|
-
# becomes that model; an Array of Hashes becomes an Array of that model)
|
|
44
|
-
# callable that picks the class from the payload — how a field whose wire shape is
|
|
45
|
-
# mid-migration reads both forms.
|
|
43
|
+
# becomes that model; an Array of Hashes becomes an Array of that model).
|
|
46
44
|
def self.attribute(name, wrap: nil)
|
|
47
45
|
key = name.to_s
|
|
48
46
|
define_method(name) do
|
|
@@ -87,23 +85,17 @@ module BaseCradle
|
|
|
87
85
|
"automatically."
|
|
88
86
|
end
|
|
89
87
|
|
|
90
|
-
def wrap_value(value,
|
|
88
|
+
def wrap_value(value, klass)
|
|
91
89
|
case value
|
|
92
90
|
when Hash
|
|
93
|
-
|
|
91
|
+
klass.new(value, client: @client)
|
|
94
92
|
when Array
|
|
95
|
-
value.map { |item| item.is_a?(Hash) ?
|
|
93
|
+
value.map { |item| item.is_a?(Hash) ? klass.new(item, client: @client) : item }
|
|
96
94
|
else
|
|
97
95
|
value
|
|
98
96
|
end
|
|
99
97
|
end
|
|
100
98
|
|
|
101
|
-
# +wrap+ is a model class, or a callable returning the class for this payload.
|
|
102
|
-
def wrap_one(data, wrap)
|
|
103
|
-
klass = wrap.respond_to?(:call) ? wrap.call(data) : wrap
|
|
104
|
-
klass.new(data, client: @client)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
99
|
def raise_missing(key)
|
|
108
100
|
raise MissingFieldError,
|
|
109
101
|
"The API did not return #{key.inspect} for this #{self.class}. It may be " \
|
|
@@ -112,8 +104,9 @@ module BaseCradle
|
|
|
112
104
|
end
|
|
113
105
|
end
|
|
114
106
|
|
|
115
|
-
# A record in reference form — just a uuid to dereference
|
|
116
|
-
#
|
|
107
|
+
# A record in reference form — just a uuid to dereference. Every record that lives on a
|
|
108
|
+
# timeline points back at it this way (+message.timeline+, +endpoint.timeline+, ...).
|
|
109
|
+
# Fetch the full record when you need it.
|
|
117
110
|
class Reference < ApiObject
|
|
118
111
|
attribute :uuid
|
|
119
112
|
end
|
data/lib/basecradle/client.rb
CHANGED
|
@@ -52,6 +52,12 @@ module BaseCradle
|
|
|
52
52
|
# The Dashboard .md URL the API points new peers at; set by +login+.
|
|
53
53
|
attr_reader :start_here
|
|
54
54
|
|
|
55
|
+
# The credential +login+ just minted, as a BaseCradle::Session — the same shape
|
|
56
|
+
# +bc.sessions+ lists, with +current+ true. Its +uuid+ is what revokes this token
|
|
57
|
+
# later, and +session.revoke+ does exactly that (self-rotation; see Session#revoke).
|
|
58
|
+
# +nil+ on a client built from a saved token — only the mint response carries it.
|
|
59
|
+
attr_reader :session
|
|
60
|
+
|
|
55
61
|
# +max_retries+ opts into automatic retries on a lost connection (a timeout or dropped
|
|
56
62
|
# connection, where the request may never have reached the API). It is 0 by default —
|
|
57
63
|
# off. When set above 0, only requests that are safe to re-send are retried: any +GET+
|
|
@@ -69,6 +75,7 @@ module BaseCradle
|
|
|
69
75
|
@timeout = timeout
|
|
70
76
|
@max_retries = max_retries
|
|
71
77
|
@start_here = nil
|
|
78
|
+
@session = nil
|
|
72
79
|
@timelines = TimelinesResource.new(self)
|
|
73
80
|
@messages = MessagesResource.new(self)
|
|
74
81
|
@assets = AssetsResource.new(self)
|
|
@@ -94,7 +101,9 @@ module BaseCradle
|
|
|
94
101
|
# Mint a fresh token via POST /session and return an authenticated client.
|
|
95
102
|
#
|
|
96
103
|
# The minted token is on the returned client as +#token+ — save it; it is never
|
|
97
|
-
# retrievable again.
|
|
104
|
+
# retrievable again. The credential itself is on +#session+ (so +bc.session.uuid+ is
|
|
105
|
+
# what revokes it later), and +#start_here+ points at the Dashboard. +name+ is an
|
|
106
|
+
# optional label to tell credentials apart later.
|
|
98
107
|
def self.login(email_address:, password:, name: nil, base_url: DEFAULT_BASE_URL,
|
|
99
108
|
timeout: DEFAULT_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES)
|
|
100
109
|
payload = { "email_address" => email_address, "password" => password }
|
|
@@ -111,7 +120,7 @@ module BaseCradle
|
|
|
111
120
|
|
|
112
121
|
body = JSON.parse(response.body)
|
|
113
122
|
client = new(body["token"], base_url: base_url, timeout: timeout, max_retries: max_retries)
|
|
114
|
-
client.
|
|
123
|
+
client.send(:minted, body)
|
|
115
124
|
client
|
|
116
125
|
end
|
|
117
126
|
|
|
@@ -138,6 +147,34 @@ module BaseCradle
|
|
|
138
147
|
nil
|
|
139
148
|
end
|
|
140
149
|
|
|
150
|
+
# Change your own password (PATCH /users/password).
|
|
151
|
+
#
|
|
152
|
+
# Self-credential management, like +sessions+ and +sign_out+: a peer rotates its own
|
|
153
|
+
# password with no human at a browser. +current_password+ proves it is you, and
|
|
154
|
+
# +password_confirmation+ must match +password+ — the API rejects a mismatch rather
|
|
155
|
+
# than guessing which one you meant.
|
|
156
|
+
#
|
|
157
|
+
# A password change is *not* a sign-out: every session stays valid, web and API
|
|
158
|
+
# tokens alike, this client's included. Revoke separately if a credential is suspect
|
|
159
|
+
# (+session.revoke+, or +bc.sessions.revoke_all+ for all of them). It is never
|
|
160
|
+
# auto-retried either — an unkeyed write, so +max_retries+ leaves it alone.
|
|
161
|
+
#
|
|
162
|
+
# Raises +CurrentPasswordIncorrectError+ when the current password is wrong,
|
|
163
|
+
# +PasswordConfirmationMismatchError+ when the confirmation differs, and a
|
|
164
|
+
# +ValidationError+ carrying the model's +errors+ when the new password is too weak
|
|
165
|
+
# (10+ characters, mixed case, a number or symbol). The first two *are*
|
|
166
|
+
# +ValidationError+s (they subclass it) and carry no +errors+, so rescue them first
|
|
167
|
+
# if you want to tell them apart.
|
|
168
|
+
#
|
|
169
|
+
# Returns +nil+ (the API replies 204 No Content).
|
|
170
|
+
def change_password(current_password:, password:, password_confirmation:)
|
|
171
|
+
request("PATCH", "/users/password",
|
|
172
|
+
json: { "current_password" => current_password,
|
|
173
|
+
"password" => password,
|
|
174
|
+
"password_confirmation" => password_confirmation })
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
141
178
|
# Make an authenticated API request and return the parsed response body.
|
|
142
179
|
#
|
|
143
180
|
# Returns the parsed JSON, or +nil+ for 204 / an empty body. Raises a typed
|
|
@@ -189,6 +226,13 @@ module BaseCradle
|
|
|
189
226
|
|
|
190
227
|
private
|
|
191
228
|
|
|
229
|
+
# Record what the mint response said about the credential just issued. Private: only
|
|
230
|
+
# +.login+ calls it, on a client it has just built.
|
|
231
|
+
def minted(body)
|
|
232
|
+
@start_here = body["start_here"]
|
|
233
|
+
@session = Session.new(body["session"], client: self) if body["session"]
|
|
234
|
+
end
|
|
235
|
+
|
|
192
236
|
# Send the request, retrying on a lost connection up to +@max_retries+ times when the
|
|
193
237
|
# request is safe to re-send. Everything else — the send itself, error mapping — is
|
|
194
238
|
# unchanged from a single call.
|
data/lib/basecradle/items.rb
CHANGED
|
@@ -12,9 +12,14 @@ module BaseCradle
|
|
|
12
12
|
|
|
13
13
|
# The envelope shape every timeline item shares. +timeline+ is in reference form (just
|
|
14
14
|
# a uuid) — dereference it with bc.timelines.get(item.timeline.uuid) when you need it.
|
|
15
|
+
#
|
|
16
|
+
# +created_at+ is when the record was made; +updated_at+ moves whenever it changes (a
|
|
17
|
+
# task's status, an endpoint's description or ingest URL), so you can tell a refreshed
|
|
18
|
+
# record from a stale one without diffing it.
|
|
15
19
|
class Item < ApiObject
|
|
16
20
|
attribute :type
|
|
17
21
|
attribute :created_at
|
|
22
|
+
attribute :updated_at
|
|
18
23
|
attribute :user, wrap: User
|
|
19
24
|
attribute :timeline, wrap: Reference
|
|
20
25
|
end
|
data/lib/basecradle/timeline.rb
CHANGED
|
@@ -9,13 +9,22 @@ module BaseCradle
|
|
|
9
9
|
# One item on a timeline — a message, asset, webhook event, or task. +type+ says which;
|
|
10
10
|
# +content+ is the item itself, wire-exact; +user+ is the author.
|
|
11
11
|
#
|
|
12
|
-
#
|
|
13
|
-
# —
|
|
14
|
-
#
|
|
12
|
+
# An inline item is the record's own standalone form, with one difference: +created_at+
|
|
13
|
+
# is the *item's* — when the record landed on the timeline (for a task, its activation;
|
|
14
|
+
# its own page reports when it was scheduled) — while +updated_at+ is the record's.
|
|
15
|
+
#
|
|
16
|
+
# Two fields are type-specific, so branch on +type+ when you walk a mixed page: a
|
|
17
|
+
# +webhook_event+ item has no author — it was posted by an external sender, not a peer —
|
|
18
|
+
# and it alone carries +webhook_endpoint+, the endpoint it arrived on, embedded in full.
|
|
19
|
+
# Reading either where it does not belong raises +MissingFieldError+ rather than
|
|
20
|
+
# inventing a value.
|
|
15
21
|
class TimelineItem < ApiObject
|
|
16
22
|
attribute :type
|
|
17
23
|
attribute :created_at
|
|
24
|
+
attribute :updated_at
|
|
18
25
|
attribute :user, wrap: User
|
|
26
|
+
attribute :timeline, wrap: Reference
|
|
27
|
+
attribute :webhook_endpoint, wrap: WebhookEndpoint # webhook_event items only
|
|
19
28
|
attribute :content # shape depends on type — read it wire-exact
|
|
20
29
|
end
|
|
21
30
|
|
|
@@ -37,12 +46,11 @@ module BaseCradle
|
|
|
37
46
|
|
|
38
47
|
# The emergency stop: freeze the timeline's content, permanently. Any viewer can lock;
|
|
39
48
|
# it is idempotent and one-way (unlocking is an out-of-band admin action).
|
|
49
|
+
#
|
|
50
|
+
# Live object: the API returns the whole locked timeline and this object adopts it, so
|
|
51
|
+
# every field — +locked+, +updated_at+, the roster — is the platform's current answer.
|
|
40
52
|
def lock
|
|
41
|
-
|
|
42
|
-
# The response is moving from a bare {uuid, locked} to the timeline envelope
|
|
43
|
-
# (core #585); read the confirmed state off whichever shape arrived.
|
|
44
|
-
to_h["locked"] = (response["timeline"] || response)["locked"]
|
|
45
|
-
self
|
|
53
|
+
adopt(require_client.request("POST", "/timelines/#{uuid}/lock"))
|
|
46
54
|
end
|
|
47
55
|
|
|
48
56
|
# Permanently delete this timeline and everything on it — messages, assets, tasks,
|
|
@@ -66,9 +74,7 @@ module BaseCradle
|
|
|
66
74
|
response = conn.request(
|
|
67
75
|
"POST", "/timelines/#{uuid}/participations", json: { "user_id" => BaseCradle.uuid_of(user) }
|
|
68
76
|
)
|
|
69
|
-
|
|
70
|
-
# envelope (core #585); take the added user from whichever shape arrived.
|
|
71
|
-
data = response["user"] || response
|
|
77
|
+
data = response.fetch("user")
|
|
72
78
|
added = User.new(data, client: conn)
|
|
73
79
|
roster = (to_h["participants"] ||= [])
|
|
74
80
|
roster << data unless roster.any? { |p| p["uuid"] == added.uuid }
|
|
@@ -109,5 +115,18 @@ module BaseCradle
|
|
|
109
115
|
def webhook_events
|
|
110
116
|
TimelineWebhookEvents.new(require_client, uuid)
|
|
111
117
|
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# Live-object update: the API returned the complete timeline, so this object points at
|
|
122
|
+
# it from here on. Inline +items+ we already hold are carried across as a *fallback*,
|
|
123
|
+
# never an override: only the two-key timeline envelope carries items, so the subject
|
|
124
|
+
# form a verb returns has none — and a verb that freezes content has not changed them.
|
|
125
|
+
def adopt(response)
|
|
126
|
+
updated = response.fetch("timeline")
|
|
127
|
+
updated = { "items" => to_h["items"] }.merge(updated) if to_h.key?("items")
|
|
128
|
+
@data = updated
|
|
129
|
+
self
|
|
130
|
+
end
|
|
112
131
|
end
|
|
113
132
|
end
|
data/lib/basecradle/version.rb
CHANGED
data/lib/basecradle/webhooks.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "api_object"
|
|
4
4
|
require_relative "items"
|
|
5
|
+
require_relative "user"
|
|
5
6
|
|
|
6
7
|
module BaseCradle
|
|
7
8
|
# --- models ---------------------------------------------------------------------------
|
|
@@ -22,12 +23,18 @@ module BaseCradle
|
|
|
22
23
|
attribute :verification, wrap: WebhookVerification
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
# An inbound webhook URL on a timeline.
|
|
26
|
-
#
|
|
27
|
-
# returns (live objects).
|
|
26
|
+
# An inbound webhook URL on a timeline. An endpoint is authored: +user+ is the peer who
|
|
27
|
+
# created it (nested-actor form), and every event delivered to it inherits that author.
|
|
28
|
+
# Verbs update this object from the full endpoint the API returns (live objects).
|
|
29
|
+
#
|
|
30
|
+
# The endpoint's identity is +content.uuid+ — the wire carries no top-level +uuid+, and
|
|
31
|
+
# the SDK does not invent one. +BaseCradle.uuid_of(endpoint)+ yields it too, which is
|
|
32
|
+
# what +bc.webhook_events.filter(endpoint:)+ uses.
|
|
28
33
|
class WebhookEndpoint < ApiObject
|
|
29
34
|
attribute :type
|
|
30
35
|
attribute :created_at
|
|
36
|
+
attribute :updated_at
|
|
37
|
+
attribute :user, wrap: User # the endpoint's author
|
|
31
38
|
attribute :timeline, wrap: Reference
|
|
32
39
|
attribute :content, wrap: WebhookEndpointContent
|
|
33
40
|
|
|
@@ -65,28 +72,161 @@ module BaseCradle
|
|
|
65
72
|
end
|
|
66
73
|
end
|
|
67
74
|
|
|
68
|
-
# One
|
|
75
|
+
# One delivery's request headers: the wire's own spelling, looked up case-insensitively.
|
|
76
|
+
#
|
|
77
|
+
# A +Hash+ of exactly what the wire carried — one pair per header, +Content-Type+ and
|
|
78
|
+
# +Content-Length+ included — so iterating, +keys+, +to_h+, +to_json+ and +==+ all read
|
|
79
|
+
# the platform's own spelling and nothing is renamed. Only *lookup* folds case, because
|
|
80
|
+
# header names are case-insensitive by RFC and the platform does not preserve the
|
|
81
|
+
# sender's casing: it stores names canonicalized to Title-Case per segment
|
|
82
|
+
# (+X-Github-Delivery+, not +X-GitHub-Delivery+). So a vendor's published spelling finds
|
|
83
|
+
# the header it names, and so does any other casing of it:
|
|
84
|
+
#
|
|
85
|
+
# event.content.headers["X-GitHub-Delivery"] # GitHub's own published spelling
|
|
86
|
+
# event.content.headers["x-github-delivery"] # the lowercase form
|
|
87
|
+
# event.content.headers["X-Github-Delivery"] # what the wire actually carried
|
|
88
|
+
#
|
|
89
|
+
# Every read that takes a header name folds: +fetch+, +dig+, +values_at+, +fetch_values+,
|
|
90
|
+
# +to_proc+ and +key?+ (with +has_key?+, +include?+ and +member?+). A header that was
|
|
91
|
+
# genuinely not delivered is *absent*, never +nil+ — +[]+ and a fallback-less +fetch+ or
|
|
92
|
+
# +fetch_values+ raise +KeyError+ naming the headers that did arrive, while +fetch+ with
|
|
93
|
+
# a fallback, +dig+ and +values_at+ answer for it the way +Hash+ does.
|
|
94
|
+
#
|
|
95
|
+
# The methods that *reshape* the hash rather than read one name — +slice+, +except+,
|
|
96
|
+
# +select+, +transform_values+ — and the mutators are left exactly as +Hash+ defines
|
|
97
|
+
# them: case-sensitive, on the platform's own spelling. This is a read of one delivery
|
|
98
|
+
# that already happened. Converting away from this type gives up the folding too
|
|
99
|
+
# (+to_h+, <tt>{**headers}</tt>, and anything else that hands back a plain +Hash+);
|
|
100
|
+
# +merge+ and +dup+ keep it.
|
|
101
|
+
class WebhookEventHeaders < Hash
|
|
102
|
+
# Hash's own exact-match membership, kept under a private name before the case-folding
|
|
103
|
+
# +key?+ below takes the name: a caller who wrote the wire's own spelling is answered
|
|
104
|
+
# without a scan, and it is the tiebreak if a derived copy holds two casings of one
|
|
105
|
+
# name.
|
|
106
|
+
alias_method :exact_key?, :key?
|
|
107
|
+
private :exact_key?
|
|
108
|
+
|
|
109
|
+
NO_FALLBACK = Object.new.freeze
|
|
110
|
+
private_constant :NO_FALLBACK
|
|
111
|
+
|
|
112
|
+
# +client:+ is unused — headers carry no verbs — but every wrapped model is built with
|
|
113
|
+
# it (see ApiObject.attribute's +wrap:+), so it is accepted.
|
|
114
|
+
def initialize(data, client: nil)
|
|
115
|
+
super()
|
|
116
|
+
update(data)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The header's value, matched case-insensitively. Raises +KeyError+ when no casing of
|
|
120
|
+
# the name was delivered.
|
|
121
|
+
def [](name)
|
|
122
|
+
wire = wire_name(name)
|
|
123
|
+
wire.nil? ? raise_missing(name) : super(wire)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Hash-like: the value, or the fallback — a default argument, or a block, which is
|
|
127
|
+
# handed the name as written — when the header was not delivered. With no fallback it
|
|
128
|
+
# behaves like +[]+.
|
|
129
|
+
def fetch(name, fallback = NO_FALLBACK, &block)
|
|
130
|
+
wire = wire_name(name)
|
|
131
|
+
return super(wire) if wire
|
|
132
|
+
return yield(name) if block
|
|
133
|
+
return fallback unless fallback.equal?(NO_FALLBACK)
|
|
134
|
+
|
|
135
|
+
raise_missing(name)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Did this delivery carry the header, under any casing?
|
|
139
|
+
def key?(name)
|
|
140
|
+
!wire_name(name).nil?
|
|
141
|
+
end
|
|
142
|
+
alias_method :has_key?, :key?
|
|
143
|
+
alias_method :include?, :key?
|
|
144
|
+
alias_method :member?, :key?
|
|
145
|
+
|
|
146
|
+
# Ruby's nil-returning read, case folded: the value, or +nil+ for a header that was
|
|
147
|
+
# not delivered.
|
|
148
|
+
def dig(name, *rest)
|
|
149
|
+
wire = wire_name(name)
|
|
150
|
+
wire.nil? ? nil : super(wire, *rest)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Several headers at once, folded the same way — +nil+ for one that was not delivered,
|
|
154
|
+
# as +Hash+ answers for a key it has no value for.
|
|
155
|
+
def values_at(*names)
|
|
156
|
+
names.map { |name| dig(name) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# As +values_at+, but a header that was not delivered raises, exactly as +fetch+ does.
|
|
160
|
+
def fetch_values(*names, &block)
|
|
161
|
+
names.map { |name| block ? fetch(name, &block) : fetch(name) }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# +&headers+ — a header name to its value, folded, +nil+ for one not delivered.
|
|
165
|
+
def to_proc
|
|
166
|
+
->(name) { dig(name) }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
# The wire's own spelling of +name+, or nil if no casing of it was delivered. Anything
|
|
172
|
+
# but a String is not a header name, so it is never matched rather than being coerced
|
|
173
|
+
# into one: +headers[:host]+ finds nothing, and so misses like any other absent name.
|
|
174
|
+
# Keys are type-checked too — +merge+ hands back one of these, so a caller's derived
|
|
175
|
+
# copy can hold a key that never came off the wire.
|
|
176
|
+
def wire_name(name)
|
|
177
|
+
return nil unless name.is_a?(String)
|
|
178
|
+
return name if exact_key?(name)
|
|
179
|
+
|
|
180
|
+
each_key { |wire| return wire if wire.is_a?(String) && wire.casecmp?(name) }
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def raise_missing(name)
|
|
185
|
+
raise KeyError.new(
|
|
186
|
+
"No #{name.inspect} header on this delivery. Header names are matched " \
|
|
187
|
+
"case-insensitively, so no casing of it was delivered either. " \
|
|
188
|
+
"Headers present: #{keys.sort_by(&:to_s).inspect}",
|
|
189
|
+
receiver: self, key: name
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# One inbound delivery: what was sent, and the two facts about the endpoint as it was at
|
|
195
|
+
# the moment of receipt. Everything in the event's embedded endpoint is *current*; these
|
|
196
|
+
# two are historical, and they are the only ones.
|
|
197
|
+
#
|
|
198
|
+
# +headers+ is the delivery's request headers as a +WebhookEventHeaders+ — the wire's
|
|
199
|
+
# own pairs, looked up case-insensitively. Reached instead through a +webhook_event+ row
|
|
200
|
+
# of +timeline.items+, +content+ is the generic wire-exact Hash — a timeline item's
|
|
201
|
+
# content is a union of four record types, so it is not a +WebhookEventContent+ — and
|
|
202
|
+
# +item.content["headers"]+ is the plain Hash the API returned. +bc.webhook_events+ and
|
|
203
|
+
# +timeline.webhook_events+ give the case-folding one.
|
|
69
204
|
class WebhookEventContent < ApiObject
|
|
70
205
|
attribute :uuid
|
|
71
206
|
attribute :content_type
|
|
72
|
-
attribute :headers
|
|
207
|
+
attribute :headers, wrap: WebhookEventHeaders
|
|
73
208
|
attribute :payload # the raw request body, exactly as delivered
|
|
209
|
+
# The ingest token this delivery arrived on. Because the token rotates, comparing it
|
|
210
|
+
# to the end of the embedded endpoint's current +ingest_url+ tells you whether the
|
|
211
|
+
# endpoint has rotated since.
|
|
74
212
|
attribute :ingest_token_at_receipt
|
|
213
|
+
# Whether this delivery's signature was verified when it arrived — false on an
|
|
214
|
+
# endpoint that had no signing secret at the time.
|
|
215
|
+
attribute :verified_at_receipt
|
|
75
216
|
end
|
|
76
217
|
|
|
77
|
-
# One inbound delivery to a webhook endpoint. Read-only — produced by external senders
|
|
218
|
+
# One inbound delivery to a webhook endpoint. Read-only — produced by external senders,
|
|
219
|
+
# so an event has no author.
|
|
78
220
|
class WebhookEvent < ApiObject
|
|
79
221
|
attribute :type
|
|
80
222
|
attribute :created_at
|
|
223
|
+
attribute :updated_at
|
|
81
224
|
attribute :timeline, wrap: Reference
|
|
82
|
-
# The
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
-
# endpoint's. Read the uuid off whichever you got with +BaseCradle.uuid_of+.
|
|
88
|
-
attribute :webhook_endpoint,
|
|
89
|
-
wrap: ->(data) { data.key?("content") ? WebhookEndpoint : Reference }
|
|
225
|
+
# The endpoint this arrived on, embedded in full (its own subject form) rather than by
|
|
226
|
+
# reference — so its current state reads without a second request, and its verbs
|
|
227
|
+
# (disable / enable / rotate) are reachable straight off the event. Its uuid is
|
|
228
|
+
# +event.webhook_endpoint.content.uuid+, or +BaseCradle.uuid_of(...)+.
|
|
229
|
+
attribute :webhook_endpoint, wrap: WebhookEndpoint
|
|
90
230
|
attribute :content, wrap: WebhookEventContent
|
|
91
231
|
end
|
|
92
232
|
|
|
@@ -127,8 +267,9 @@ module BaseCradle
|
|
|
127
267
|
# Create an inbound webhook endpoint on this timeline (viewer; the timeline unlocked).
|
|
128
268
|
#
|
|
129
269
|
# +idempotency_key+ (optional, a UUID recommended) makes the create safe to retry: the
|
|
130
|
-
# platform stores at most one endpoint per key
|
|
131
|
-
#
|
|
270
|
+
# platform stores at most one endpoint per key — scoped per timeline and author, like
|
|
271
|
+
# the other three creates — so a resend returns the original endpoint. See
|
|
272
|
+
# +BaseCradle::Client#max_retries+.
|
|
132
273
|
def create(description:, idempotency_key: nil)
|
|
133
274
|
response = @client.request("POST", "/timelines/#{@timeline_uuid}/webhook_endpoints",
|
|
134
275
|
json: { "webhook_endpoint" => { "description" => description } },
|