basecradle 0.7.0 → 0.9.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: c2a044c2738074a7dab0cb39dc2d311324fd589bca99aa27cfe62fc0c844feea
4
- data.tar.gz: 4df34781e48954ce10bdf0bd4107cc30036f20b2652c7735f4e942964c29d2ef
3
+ metadata.gz: 52b94c60f28a4c6e40decde0c297a0129a7f772a3b418f1a92fca3f380278982
4
+ data.tar.gz: 107fd61b78588132abba3e3d958714f65dd572a5de40f5c690abd58d35f658e1
5
5
  SHA512:
6
- metadata.gz: 7f1b9755e5cfb944f65283d460ea364044e2d0278c786b81d3adf3a8da49c2173515daa61c0bcedd17975b0378ed1ec0b19fdccfcd3dfb15ac772926ba80c540
7
- data.tar.gz: 21a11fbcccae45b4dab82e31eab89e963016b1fd4d54c98548a8064294bd9e2e68dfac4e9521d7ff6a0cef3db8fdf5d4a731c0288d5cbf69a00d63093379f1ca
6
+ metadata.gz: 56a8ab9c7774f5765cfb2b08096488c1784558095e8ae4e433a13ebd6ed502afee0871a571a0e01db81b77adbf8399a8466d351c4c5c43794dc9ea24c6b6f74c
7
+ data.tar.gz: d15b3da9c230433eeaddd427e4298dddce8042d37e1a4e6de5dd1e8a4b7384901ed7b41db5ef8d1f804787b21aef63bdf2db05757da50a702768f28fe45a81fa
data/CHANGELOG.md CHANGED
@@ -4,6 +4,87 @@ 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.9.0] - 2026-09-30
8
+
9
+ ### Changed
10
+
11
+ - **A timeline item's `content` is typed by the item's `type`**
12
+ ([#189](https://github.com/basecradle/basecradle-ruby/issues/189)). `item.content` now
13
+ reads as the same content model the record's own resource returns — `MessageContent`,
14
+ `AssetContent`, `TaskContent` or `WebhookEventContent` — where it was the raw wire
15
+ `Hash`. One record has one shape however you reach it, so everything those models add
16
+ reads down both paths: `timeline.items` gives an `AssetContent` whose `file` is an
17
+ `AssetFile`, and a `webhook_event` row's `content.headers` is the case-folding
18
+ `WebhookEventHeaders` 0.8.0 shipped, so `item.content.headers["X-GitHub-Delivery"]`
19
+ now finds the header it names. The previous release documented that split as a
20
+ boundary; it is now closed, and every future enrichment of a content model arrives on
21
+ both paths at once. The README documents `timeline.items` with a worked example for
22
+ the first time.
23
+ - **An item `type` this release does not know keeps reading.** The API is additive-only,
24
+ so an unrecognized `type` is never an error: its content comes back as a plain
25
+ `ApiObject`, and `content["any_field"]` still returns the wire value untouched.
26
+ Upgrading the SDK is what types it. An item carrying *no* `type` is a different thing
27
+ — a malformed response, not a new record kind — and raises `MissingFieldError` the way
28
+ `item.type` itself does, rather than being guessed into the generic case.
29
+ - The wire is untouched — `item["content"]`, `ApiObject`'s raw escape hatch, still hands
30
+ back exactly the `Hash` the API sent, and no field is renamed.
31
+ Decided in lockstep with the Python SDK's
32
+ [#210](https://github.com/basecradle/basecradle-python/issues/210); that port is still
33
+ in flight, so the two SDKs reach parity on this when it ships.
34
+
35
+ ### Migrating
36
+
37
+ `item.content` was a `Hash` and is now an `ApiObject`. Reads of *named* fields get
38
+ better — `item.content.body` instead of `item.content["body"]`, and the models' own
39
+ enrichments come with them — but three things that worked on a `Hash` no longer do:
40
+
41
+ - **The `Hash` protocol is gone.** `item.content.fetch("body")`, `.each`, `.keys`,
42
+ `.dig(...)` and `.key?(...)` raise `NoMethodError`. Use the field reader, or
43
+ `item.content.to_h` for the wire `Hash` itself (`item["content"]` gives the same).
44
+ - **`to_json` no longer serializes the record.** `item.content.to_json` emits Ruby's
45
+ default `to_s` — `"#<BaseCradle::MessageContent:0x…>"`, an object address with no
46
+ field names — because `ApiObject` defines no `to_json`. That is long-standing for
47
+ every other model in the SDK and now reaches this path too. Serialize
48
+ `item.content.to_h` instead.
49
+ - **`inspect` and `==` change, both for the better.** `inspect` names the model and its
50
+ wire fields (`#<BaseCradle::MessageContent body, uuid>`) rather than printing a `Hash`;
51
+ and equality now *holds* between an item's content and the same record fetched
52
+ directly (`timeline.items.first.content == bc.messages.get(uuid).content`), where
53
+ comparing a `Hash` to an `ApiObject` was always false.
54
+
55
+ ## [0.8.0] - 2026-09-30
56
+
57
+ ### Changed
58
+
59
+ - **`event.content.headers` folds case on lookup**, the way HTTP names headers
60
+ ([#173](https://github.com/basecradle/basecradle-ruby/issues/173)). BaseCradle stores
61
+ header names in canonical Title-Case per segment and does not preserve the sender's
62
+ casing, and the API docs tell consumers to "look them up case-insensitively rather than
63
+ by a vendor's preferred spelling" — but the plain `Hash` the SDK handed back was
64
+ case-sensitive, so a caller who wrote the spelling a vendor publishes (GitHub's
65
+ `X-GitHub-Delivery`) or the lowercase form an HTTP/2 sender emits read a silent `nil`.
66
+ `headers["X-GitHub-Delivery"]`, `headers["X-Github-Delivery"]` and
67
+ `headers["x-github-delivery"]` now all read the header that arrived, and so does every
68
+ other read that takes a header name — `fetch`, `dig`, `values_at`, `fetch_values` and
69
+ `key?` (with `has_key?`, `include?` and `member?`). Only *lookup* folds: the value is
70
+ still a `Hash` of exactly what the wire carried, so `each`, `keys`, `to_h` and `to_json`
71
+ read the platform's own canonical spelling, nothing is renamed, and the methods that
72
+ reshape it (`slice`, `except`, `select`) work on those stored names.
73
+ Matches the Python SDK's
74
+ [#209](https://github.com/basecradle/basecradle-python/pull/209), decided in lockstep.
75
+ - **A header that was not delivered is absent, never `nil`** — `headers[name]` and a
76
+ fallback-less `headers.fetch(name)` raise `KeyError` naming the headers that did
77
+ arrive, where before a `nil` could equally have meant "you spelled it wrong".
78
+ `headers.fetch(name, default)` (or a block, which is handed the name as written) and
79
+ `headers.dig(name)` answer for an absent header the way `Hash` does, and a name that is
80
+ not a `String` is not a header name, so it is never matched. A missing *field* is still
81
+ `BaseCradle::MissingFieldError`; a missing *header* is a `KeyError`.
82
+ - **Documented the two paths that do not fold**: `event.content["headers"]`, `ApiObject`'s
83
+ raw-wire escape hatch, and a `webhook_event` row of `timeline.items`, whose content is a
84
+ union of four record types (so it is not a `WebhookEventContent`) — both hand back the
85
+ plain wire `Hash`. `bc.webhook_events` and `timeline.webhook_events` give the
86
+ case-folding headers.
87
+
7
88
  ## [0.7.0] - 2026-09-23
8
89
 
9
90
  ### Changed
@@ -261,6 +342,8 @@ the Python SDK's behavior in idiomatic Ruby. Zero runtime dependencies.
261
342
  - **Quality bars** — a README-as-tested-doc harness (every example runs against a mocked
262
343
  API) and a spec drift-guard (CI fails if the live API grows beyond the SDK).
263
344
 
345
+ [0.9.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.9.0
346
+ [0.8.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.8.0
264
347
  [0.7.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.7.0
265
348
  [0.6.1]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.6.1
266
349
  [0.6.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.6.0
data/README.md CHANGED
@@ -128,6 +128,39 @@ end
128
128
 
129
129
  `cancel` withdraws a still-*pending* task — the scheduled-work equivalent of `timeline.lock`: its alarm never fires and the slot it held under your `max_pending_tasks` cap is freed at once. It is author-only (an admin may cancel any task) and works even on a locked timeline (cancellation is cleanup, not new content). Cancelling a task you did not author raises `BaseCradle::NotTaskAuthorError`; cancelling one that is no longer pending — already activated, blocked, or cancelled — raises `BaseCradle::TaskNotPendingError`. Create-then-cancel-and-reschedule gives you a rolling **dead man's switch**: a task that fires only if you stop renewing it.
130
130
 
131
+ ## Reading a timeline's items
132
+
133
+ Fetching one timeline gives you everything on it inline — a single mixed list, **oldest
134
+ first** (it is the timeline as read, not a paginated feed). `item.type` says which record
135
+ each row is, and `item.content` is that record's own content model — the very object the
136
+ record's own resource returns:
137
+
138
+ ```ruby
139
+ require "basecradle"
140
+
141
+ bc = BaseCradle::Client.new
142
+
143
+ bc.timelines.get("019e7750-66ee-7f53-829f-13a8a710b6da").items.each do |item|
144
+ case item.type
145
+ when "message" then puts item.content.body
146
+ when "asset" then puts item.content.file.filename
147
+ when "task" then puts item.content.status
148
+ end
149
+ end
150
+ ```
151
+
152
+ Two things the list does not carry. A **pending** task is not on it — a task appears only
153
+ once it activates — so survey scheduled work with `bc.tasks.filter(status: "pending")`
154
+ instead. And an item `type` this SDK release does not know is not an error: the API is
155
+ additive-only, so its content reads as a plain `BaseCradle::ApiObject` and
156
+ `content["any_field"]` still returns the wire value untouched.
157
+
158
+ Two fields are type-specific, so branch on `type` before reaching for them: a
159
+ `webhook_event` item has no author (it came from an external sender, not a peer), and it
160
+ alone carries `webhook_endpoint`, the endpoint it arrived on, embedded in full. Reading
161
+ either where it does not belong raises `BaseCradle::MissingFieldError` rather than
162
+ inventing a value.
163
+
131
164
  ## Webhooks
132
165
 
133
166
  External services deliver into a timeline by POSTing to an endpoint's secret ingest URL. Each delivery becomes a readable event. This is the **inbound** direction — data arriving at BaseCradle. Its outbound counterpart is Event Delivery, the platform's push through your integration, which the SDK does not model.
@@ -150,6 +183,8 @@ endpoint.rotate # leaked URL? new ingest_url, old one dies, uuid unchanged
150
183
  # Read what came in — across all timelines, or narrowed
151
184
  bc.webhook_events.filter(endpoint: endpoint).each do |event|
152
185
  puts [event.content.content_type, event.content.payload].inspect
186
+ puts event.content.headers["X-Example-Event"] # the request headers, as delivered
187
+ puts event.content.headers["x-example-event"] # ...found under any casing, as HTTP means it
153
188
  puts event.content.verified_at_receipt # was this delivery's signature verified?
154
189
  puts event.webhook_endpoint.content.ingest_url # the endpoint's URL *now*
155
190
  end
@@ -167,6 +202,31 @@ Each event embeds its endpoint **in full**, so `event.webhook_endpoint` is a liv
167
202
  `content.verified_at_receipt` (whether its signature was verified). Everything inside the
168
203
  embedded endpoint is current.
169
204
 
205
+ `content.headers` is the delivery's request headers — one pair per header **as sent**,
206
+ `Content-Type` and `Content-Length` included — and `content.payload` is the raw request
207
+ body. BaseCradle stores header names in canonical **Title-Case per segment** and does
208
+ *not* preserve the sender's casing, so the spelling a vendor publishes is often not the
209
+ spelling on the wire: `X-Github-Delivery` is stored where GitHub documents
210
+ `X-GitHub-Delivery`. So **lookup folds case**, the way HTTP names headers —
211
+ `headers["X-GitHub-Delivery"]`, `headers["X-Github-Delivery"]` and
212
+ `headers["x-github-delivery"]` all read the header that arrived, and you never have to
213
+ guess a casing. Every read that takes a header name folds with it — `fetch`, `dig`,
214
+ `values_at`, `fetch_values` and `key?` (with `has_key?`, `include?` and `member?`). A
215
+ header that was **not** delivered is *absent*, never `nil`: `headers[name]` and a
216
+ fallback-less `fetch` raise `KeyError` naming the headers that did arrive, while
217
+ `headers.fetch(name, default)`, `headers.dig(name)` and `values_at` answer for it the way
218
+ `Hash` does. It is otherwise a plain Ruby `Hash` of exactly what the wire carried —
219
+ `each`, `keys`, `to_h` and `to_json` read the platform's own spelling, nothing is renamed,
220
+ and the methods that reshape it (`slice`, `except`, `select`) work on those stored names,
221
+ case-sensitively.
222
+
223
+ This holds however you reach the record. A timeline item's `content` is typed by the
224
+ item's own `type`, so a `webhook_event` row of `timeline.items` is a `WebhookEventContent`
225
+ just like one from `bc.webhook_events` — `item.content.headers["X-GitHub-Delivery"]` folds
226
+ the same way. What gives the folding up is converting away from the type: both
227
+ `event.content["headers"]` (`ApiObject`'s raw-wire escape hatch, which wraps nothing) and
228
+ `headers.to_h` hand back a plain, case-sensitive `Hash`.
229
+
170
230
  ## Idempotent creates & safe retries
171
231
 
172
232
  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.
@@ -272,8 +332,14 @@ bundle exec rake # lint + tests (offline — the default)
272
332
  bundle exec rake test:live # the spec drift-guard (one network call to the live spec)
273
333
  bundle exec rubocop # lint only
274
334
  gem build basecradle.gemspec # build the gem
335
+ actionlint # lint the GitHub workflows (CI runs this; see below)
275
336
  ```
276
337
 
338
+ CI additionally lints the workflow files with [actionlint](https://github.com/rhysd/actionlint)
339
+ (which runs `shellcheck` over each `run:` block). To reproduce that check locally, install
340
+ both and run `actionlint` from the repo root — CI pins actionlint by version and SHA256 in
341
+ `.github/workflows/ci.yml`; match that version to see what CI sees.
342
+
277
343
  ## Contributing
278
344
 
279
345
  Human and AI contributors work under identical rules here: branch → PR → green CI → merge. See [`CLAUDE.md`](CLAUDE.md) for the project conventions and the issues for the roadmap.
@@ -7,7 +7,7 @@ require_relative "webhooks"
7
7
 
8
8
  module BaseCradle
9
9
  # One item on a timeline — a message, asset, webhook event, or task. +type+ says which;
10
- # +content+ is the item itself, wire-exact; +user+ is the author.
10
+ # +content+ is the item itself, typed by that +type+; +user+ is the author.
11
11
  #
12
12
  # An inline item is the record's own standalone form, with one difference: +created_at+
13
13
  # is the *item's* — when the record landed on the timeline (for a task, its activation;
@@ -19,13 +19,59 @@ module BaseCradle
19
19
  # Reading either where it does not belong raises +MissingFieldError+ rather than
20
20
  # inventing a value.
21
21
  class TimelineItem < ApiObject
22
+ # The content model each item +type+ reads as — the very class that +type+'s own
23
+ # resource returns, so one record has one shape however you reach it. A +type+ this
24
+ # release does not know is not in here on purpose; see +content+. Internal: the
25
+ # dispatch table is not API surface, so re-keying it is never a breaking change.
26
+ CONTENT_MODELS = {
27
+ "message" => MessageContent,
28
+ "asset" => AssetContent,
29
+ "task" => TaskContent,
30
+ "webhook_event" => WebhookEventContent
31
+ }.freeze
32
+ private_constant :CONTENT_MODELS
33
+
22
34
  attribute :type
23
35
  attribute :created_at
24
36
  attribute :updated_at
25
37
  attribute :user, wrap: User
26
38
  attribute :timeline, wrap: Reference
27
39
  attribute :webhook_endpoint, wrap: WebhookEndpoint # webhook_event items only
28
- attribute :content # shape depends on type — read it wire-exact
40
+
41
+ # The item itself, as the content model its +type+ names: a MessageContent,
42
+ # AssetContent, TaskContent or WebhookEventContent — the same object the record's own
43
+ # page hands back. So a record reads identically down either path, enrichments
44
+ # included (+item.content.headers+ folds case exactly as
45
+ # +bc.webhook_events.get(uuid).content.headers+ does), and an item's content +==+ the
46
+ # same record fetched directly.
47
+ #
48
+ # The API is additive-only, so an item +type+ newer than this SDK release must keep
49
+ # reading rather than raise: its content comes back as a plain ApiObject, whose +[]+
50
+ # still returns every wire field untouched. Upgrading the SDK is what types it.
51
+ def content
52
+ raise_missing("content") unless to_h.key?("content")
53
+ # The two ways a +type+ can be unusable are different answers, not one. A type the
54
+ # API sent that this release has no model for is the forward-compatible case and
55
+ # falls back below; an item carrying no +type+ at all is a malformed response (the
56
+ # spec marks it required), and the SDK raises rather than guessing which — the same
57
+ # promise ApiObject makes for every other withheld field.
58
+ raise_untypable unless to_h.key?("type")
59
+
60
+ wrap_value(to_h["content"], CONTENT_MODELS.fetch(type, ApiObject))
61
+ end
62
+
63
+ private
64
+
65
+ # Reading +type+ here would raise about a field the caller never asked for, on an
66
+ # object whose "fields present" list includes the +content+ they did ask for. Name
67
+ # the dependency between the two instead, and the escape hatch that still works.
68
+ def raise_untypable
69
+ raise MissingFieldError,
70
+ "This #{self.class}'s content cannot be typed: the API did not return " \
71
+ "\"type\" for it, and an item's content model is chosen by its type. The " \
72
+ "wire content is still readable with item[\"content\"]. " \
73
+ "Fields present: #{to_h.keys.sort.inspect}"
74
+ end
29
75
  end
30
76
 
31
77
  # A timeline: its metadata, owner, participants, lock state — and its verbs.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BaseCradle
4
- VERSION = "0.7.0"
4
+ VERSION = "0.9.0"
5
5
  end
@@ -72,13 +72,141 @@ module BaseCradle
72
72
  end
73
73
  end
74
74
 
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
+
75
194
  # One inbound delivery: what was sent, and the two facts about the endpoint as it was at
76
195
  # the moment of receipt. Everything in the event's embedded endpoint is *current*; these
77
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. This is the content model every read of the
200
+ # record returns, +timeline.items+ included: a +webhook_event+ row's +content+ is one of
201
+ # these (TimelineItem types content by the item's +type+), so the folding lookup is
202
+ # there too. What gives it up is converting away from the type — +content["headers"]+,
203
+ # ApiObject's raw-wire escape hatch, which wraps nothing, and the conversions
204
+ # WebhookEventHeaders names above (+to_h+ and friends) all hand back a plain,
205
+ # case-sensitive Hash.
78
206
  class WebhookEventContent < ApiObject
79
207
  attribute :uuid
80
208
  attribute :content_type
81
- attribute :headers
209
+ attribute :headers, wrap: WebhookEventHeaders
82
210
  attribute :payload # the raw request body, exactly as delivered
83
211
  # The ingest token this delivery arrived on. Because the token rotates, comparing it
84
212
  # to the end of the embedded endpoint's current +ingest_url+ tells you whether the
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: basecradle
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Drawk Kwast