basecradle 0.7.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c2a044c2738074a7dab0cb39dc2d311324fd589bca99aa27cfe62fc0c844feea
4
- data.tar.gz: 4df34781e48954ce10bdf0bd4107cc30036f20b2652c7735f4e942964c29d2ef
3
+ metadata.gz: 9766c4ae3f22a8dcb26bb88bac8931e8257dd429ea8ba4a56af26d4eda9fb8ae
4
+ data.tar.gz: 1504a5d791f9a5874cbbfea13abff10a64f17c9f6b5f557898e90c19a03182cb
5
5
  SHA512:
6
- metadata.gz: 7f1b9755e5cfb944f65283d460ea364044e2d0278c786b81d3adf3a8da49c2173515daa61c0bcedd17975b0378ed1ec0b19fdccfcd3dfb15ac772926ba80c540
7
- data.tar.gz: 21a11fbcccae45b4dab82e31eab89e963016b1fd4d54c98548a8064294bd9e2e68dfac4e9521d7ff6a0cef3db8fdf5d4a731c0288d5cbf69a00d63093379f1ca
6
+ metadata.gz: 5c0677055d1b17309e17a5303ac381c5364f4083403626279236d1f214f3d40a203126d71b27b658ebd808642565523ea27690feedbd4ee9d595d440f9b77b72
7
+ data.tar.gz: acb01b5cdfc88eb6d62006b221d2f84c876151dad8169ce3020bed6d73acc6cb7a6c2777de0efaea183b2c4fab5c02f1ab437b222b80adb5d13a0c44ffe60a7e
data/CHANGELOG.md CHANGED
@@ -4,6 +4,39 @@ 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
+
7
40
  ## [0.7.0] - 2026-09-23
8
41
 
9
42
  ### Changed
@@ -261,6 +294,7 @@ the Python SDK's behavior in idiomatic Ruby. Zero runtime dependencies.
261
294
  - **Quality bars** — a README-as-tested-doc harness (every example runs against a mocked
262
295
  API) and a spec drift-guard (CI fails if the live API grows beyond the SDK).
263
296
 
297
+ [0.8.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.8.0
264
298
  [0.7.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.7.0
265
299
  [0.6.1]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.6.1
266
300
  [0.6.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.6.0
data/README.md CHANGED
@@ -150,6 +150,8 @@ endpoint.rotate # leaked URL? new ingest_url, old one dies, uuid unchanged
150
150
  # Read what came in — across all timelines, or narrowed
151
151
  bc.webhook_events.filter(endpoint: endpoint).each do |event|
152
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
153
155
  puts event.content.verified_at_receipt # was this delivery's signature verified?
154
156
  puts event.webhook_endpoint.content.ingest_url # the endpoint's URL *now*
155
157
  end
@@ -167,6 +169,30 @@ Each event embeds its endpoint **in full**, so `event.webhook_endpoint` is a liv
167
169
  `content.verified_at_receipt` (whether its signature was verified). Everything inside the
168
170
  embedded endpoint is current.
169
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
+
170
196
  ## Idempotent creates & safe retries
171
197
 
172
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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BaseCradle
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -72,13 +72,139 @@ 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. 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.
78
204
  class WebhookEventContent < ApiObject
79
205
  attribute :uuid
80
206
  attribute :content_type
81
- attribute :headers
207
+ attribute :headers, wrap: WebhookEventHeaders
82
208
  attribute :payload # the raw request body, exactly as delivered
83
209
  # The ingest token this delivery arrived on. Because the token rotates, comparing it
84
210
  # 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.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Drawk Kwast