basecradle 0.9.0 → 0.10.1

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: 52b94c60f28a4c6e40decde0c297a0129a7f772a3b418f1a92fca3f380278982
4
- data.tar.gz: 107fd61b78588132abba3e3d958714f65dd572a5de40f5c690abd58d35f658e1
3
+ metadata.gz: 0a805499915d7672ddf23664f7a5147b41915446a488a122c018cb46ac65f076
4
+ data.tar.gz: 5739cfa3f912b9d9d29d03a9a083c58371175b0bd7dd75dbad1ee6209d426288
5
5
  SHA512:
6
- metadata.gz: 56a8ab9c7774f5765cfb2b08096488c1784558095e8ae4e433a13ebd6ed502afee0871a571a0e01db81b77adbf8399a8466d351c4c5c43794dc9ea24c6b6f74c
7
- data.tar.gz: d15b3da9c230433eeaddd427e4298dddce8042d37e1a4e6de5dd1e8a4b7384901ed7b41db5ef8d1f804787b21aef63bdf2db05757da50a702768f28fe45a81fa
6
+ metadata.gz: 3836f2b3204ca798e3d2c4bd17dcc43f59faf0dc86c7097504f05e01519e5d91cc8c10f0889852899c64a816ef7c33da009cd3a2948f0f9cd8019ee279246870
7
+ data.tar.gz: 44894880f4119664ffdceff8d9550b8d69e715073a55ad31287bfbd507ba8a7758bc92003de5fb0f1e8b17919ddf3f0968ff4c6344c988c53f91360aca5c685d
data/CHANGELOG.md CHANGED
@@ -4,6 +4,111 @@ 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
+ One deliberate departure from that format: this changelog keeps **no `Unreleased`
8
+ section**. The newest heading is always the version `lib/basecradle/version.rb` builds —
9
+ a release writes its entry and its version in the same PR — and `test/changelog_test.rb`
10
+ fails CI if the two ever disagree.
11
+
12
+ ## [0.10.1] - 2026-09-30
13
+
14
+ ### Security
15
+
16
+ - **A `Client` no longer serializes, and never emits its token**
17
+ ([#198](https://github.com/basecradle/basecradle-ruby/issues/198)). A client is a
18
+ connection, not a record, and it defined no `as_json`/`to_json` — so with ActiveSupport
19
+ loaded it fell through to `Object#as_json`, which is `instance_values`, walked
20
+ recursively. Its instance variables include the raw `bc_uat_` token, so
21
+ `render json: { conn: bc }` or `logger.info({ conn: bc }.to_json)` wrote the live
22
+ credential into a response body or a log line, and **a token in a log is a token to
23
+ rotate**. `bc.to_json` now raises `BaseCradle::NotSerializableError`, a
24
+ `BaseCradle::Error`, naming what to serialize instead. Long-standing and latent; no
25
+ token is known to have been emitted.
26
+ - **`Client#inspect` and `#to_s` redact the token** — the same exposure through a
27
+ different door. Ruby's default `inspect` dumps every instance variable, so the
28
+ credential printed into every exception message, REPL transcript and `p` call that
29
+ touched a client. Both now read
30
+ `#<BaseCradle::Client base_url="https://basecradle.com" token=[REDACTED]>`, with `to_s`
31
+ the same string because interpolation is the door people reach for without thinking.
32
+
33
+ ### Fixed
34
+
35
+ - **A collection resource no longer serializes either.** `bc.timelines`, `bc.messages`,
36
+ `timeline.tasks`, any `.filter(...)`, and the `Paginator` behind them are lazy queries,
37
+ not records. ActiveSupport's `Enumerable#as_json` calls `to_a`, so serializing one ran
38
+ a page-by-page GET loop over the whole resource from **inside a view render** and
39
+ emitted every record it fetched. It did *not* reach the token — `Enumerable#as_json`
40
+ shadows the `instance_values` walk above — but a client's instance variables are eight
41
+ collections, so serializing a client fired those loops on its way to the credential.
42
+ All of them now raise the same `NotSerializableError`, before any HTTP, pointing at
43
+ `.to_a` so how much you fetch is a visible act in your own code.
44
+ Without ActiveSupport these calls were useless rather than dangerous
45
+ (`"#<BaseCradle::MessagesResource:0x…>"`, the heap address 0.10.0 removed for models);
46
+ that is closed too.
47
+ - **The collection resources' `inspect` is unchanged** and still shows Ruby's default
48
+ ivar dump — which renders the client through the redacting `Client#inspect` above, so
49
+ no token reaches it. Pinned by test so it stays that way.
50
+
51
+ ### Known limitation
52
+
53
+ The refusals are on the SDK's own objects. An `Enumerator` built from a collection —
54
+ `bc.messages.each`, `bc.messages.lazy` — is a plain Ruby object this SDK does not own,
55
+ and ActiveSupport gives it an `as_json` that calls `to_a`, so
56
+ `render json: { recent: bc.messages.lazy }` still pages the whole resource. No token is
57
+ exposed; call `.first(n)` or `.to_a` before handing a query to a renderer.
58
+
59
+ ### Migrating
60
+
61
+ Nothing to change unless you were serializing a `Client` or a collection, which never
62
+ emitted anything useful and, for a client under ActiveSupport, emitted your token. If you
63
+ were: serialize the record you meant (`bc.me`, a timeline, a message), or call `.to_a` /
64
+ `.first(n)` on a collection first.
65
+
66
+ ## [0.10.0] - 2026-09-30
67
+
68
+ ### Fixed
69
+
70
+ - **A model serializes as the record it stands for**
71
+ ([#191](https://github.com/basecradle/basecradle-ruby/issues/191)). `ApiObject` defined
72
+ no `to_json`, so serializing *any* model — `bc.me`, a `Timeline`, a `Message`, any
73
+ content object — fell through to Ruby's default and emitted a heap address:
74
+ `"#<BaseCradle::MessageContent:0x000072be94b09…>"`. Silently, with no field names and a
75
+ different value every run. `model.to_json` and `JSON.generate(model)` now emit the
76
+ record, and so does a model nested in an array or a hash. What `to_h` holds is exactly
77
+ what goes out: nothing renamed, nothing dropped, and a field newer than this SDK
78
+ release serialized like any other.
79
+ `WebhookEventHeaders` is unchanged — it subclasses `Hash`, so it already serialized as
80
+ the delivery's headers.
81
+ Long-standing; 0.9.0's typing of `timeline.items` content only widened which read path
82
+ reached it. **0.9.0's Migrating note on `to_json` is superseded by this release.**
83
+
84
+ ### Added
85
+
86
+ - **`as_json`**, ActiveSupport's serialization hook, returning the same wire `Hash` as
87
+ `to_h`. It is what reaches a model nested inside a structure Rails renders
88
+ (`render json: { user: bc.me }`); a bare `render json: model` calls `to_json` directly
89
+ and never reaches it. Two things to know, neither of which a Rails habit expects:
90
+ - **It returns the same `Hash` as `to_h`, not a copy** — read-only by the same
91
+ convention, where every ActiveSupport `as_json` builds a fresh one. So
92
+ `model.as_json.merge!(extra)` rewrites the model's wire record, and for a wrapped
93
+ child its parent's too. Use `model.to_h.dup` (shallow) if you need to touch it.
94
+ - **Options are accepted and ignored, `only:` and `except:` included** — a model is a
95
+ read of one record, not a presenter. `as_json(except: ["ingest_url"])` returns the
96
+ whole record, silently, and it *disagrees* with `to_json`: with ActiveSupport loaded
97
+ `model.to_json(except: [...])` does redact, because the option reaches `Hash#to_json`.
98
+ Do not route redaction through either — build the subset with
99
+ `model.to_h.except("ingest_url")`.
100
+
101
+ `to_json` forwards its argument to `Hash#to_json` rather than swallowing it, so
102
+ `JSON.pretty_generate` pretty-prints; what an unknown option does is the host app's
103
+ `json` version's business (3.x raises, the 2.x older Rubies ship ignores it).
104
+
105
+ `to_s` is untouched, so interpolating a model (`"#{model}"`, `puts model`) still shows
106
+ the default object form. `inspect` names the model and its fields; `to_json` / `to_h`
107
+ give the record.
108
+
109
+ Minor rather than patch: the `to_json` output is a fix, but `as_json` is new public
110
+ surface and 0.9.0 documented the old behavior as expected — semver takes the higher.
111
+
7
112
  ## [0.9.0] - 2026-09-30
8
113
 
9
114
  ### Changed
@@ -46,6 +151,9 @@ enrichments come with them — but three things that worked on a `Hash` no longe
46
151
  field names — because `ApiObject` defines no `to_json`. That is long-standing for
47
152
  every other model in the SDK and now reaches this path too. Serialize
48
153
  `item.content.to_h` instead.
154
+ > ⚠️ **Superseded in [0.10.0](#0100---2026-09-30)**, which gives `ApiObject` a
155
+ > `to_json`. `item.content.to_json` emits the record; the `to_h` workaround above is
156
+ > no longer needed. Left as written for the historical record.
49
157
  - **`inspect` and `==` change, both for the better.** `inspect` names the model and its
50
158
  wire fields (`#<BaseCradle::MessageContent body, uuid>`) rather than printing a `Hash`;
51
159
  and equality now *holds* between an item's content and the same record fetched
@@ -342,6 +450,8 @@ the Python SDK's behavior in idiomatic Ruby. Zero runtime dependencies.
342
450
  - **Quality bars** — a README-as-tested-doc harness (every example runs against a mocked
343
451
  API) and a spec drift-guard (CI fails if the live API grows beyond the SDK).
344
452
 
453
+ [0.10.1]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.1
454
+ [0.10.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.10.0
345
455
  [0.9.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.9.0
346
456
  [0.8.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.8.0
347
457
  [0.7.0]: https://github.com/basecradle/basecradle-ruby/releases/tag/v0.7.0
data/README.md CHANGED
@@ -324,6 +324,92 @@ timeline = bc.timelines.create(name: "Incident response")
324
324
  timeline.add_participant(nova)
325
325
  ```
326
326
 
327
+ ## Reading models, and serializing them
328
+
329
+ Every model is a read-only, wire-exact view of one API JSON object. Field readers mirror
330
+ the wire names; `model[key]` is the raw escape hatch for a field newer than your SDK
331
+ release; `model.to_h` is the wire `Hash` itself.
332
+
333
+ Models serialize as the record they stand for — what `to_h` holds is what goes out, so
334
+ logging or re-emitting one gives you the wire record rather than an object address:
335
+
336
+ ```ruby
337
+ require "basecradle"
338
+ require "json"
339
+
340
+ bc = BaseCradle::Client.new
341
+ message = bc.messages.first
342
+
343
+ puts message.content.to_json # {"uuid":"019e...","body":"Hello from a peer."}
344
+ puts JSON.generate([message.content]) # nested in an array
345
+ puts({ latest: message.content }.to_json) # nested in a hash
346
+ ```
347
+
348
+ `as_json` is ActiveSupport's hook and returns the same wire `Hash`, so a model nested in
349
+ something Rails renders comes out right too. Three caveats worth knowing:
350
+
351
+ - **`as_json` returns the same `Hash` as `to_h`, not a copy** — read-only by the same
352
+ convention. Mutating it rewrites the model's record. Use `model.to_h.dup` (shallow).
353
+ - **Options are accepted and ignored, `only:` and `except:` included.** A model is a read
354
+ of one record, not a presenter, so `as_json(except: ["ingest_url"])` returns the whole
355
+ record, silently — and it *disagrees* with `to_json`, which under ActiveSupport does
356
+ redact, because the option reaches `Hash#to_json`. Do not route redaction through
357
+ either: build the subset with `model.to_h.except("ingest_url")`.
358
+ - **`to_json` forwards its argument** to `Hash#to_json`, so `JSON.pretty_generate`
359
+ pretty-prints. What an unknown option does is your `json` version's business — 3.x
360
+ raises, the 2.x older Rubies ship ignores it.
361
+
362
+ ## What does not serialize: clients and collections
363
+
364
+ Two things in this SDK are *not* records, and serializing either raises
365
+ `BaseCradle::NotSerializableError` rather than emitting something:
366
+
367
+ ```ruby
368
+ require "basecradle"
369
+
370
+ bc = BaseCradle::Client.new
371
+
372
+ begin
373
+ bc.to_json # and render json: { conn: bc }
374
+ rescue BaseCradle::NotSerializableError => e
375
+ puts e.message.include?("bc_uat_") # => true
376
+ end
377
+
378
+ begin
379
+ bc.messages.to_json # a lazy query, not a record
380
+ rescue BaseCradle::NotSerializableError => e
381
+ puts e.message.include?(".to_a") # => true
382
+ end
383
+
384
+ puts bc.messages.first(20).to_json # serialize the page you actually asked for
385
+ ```
386
+
387
+ A **client** holds your `bc_uat_` token, and under ActiveSupport `Object#as_json` is
388
+ `instance_values` — walked recursively. So `render json: { conn: bc }` or
389
+ `logger.info({ conn: bc }.to_json)` used to write the raw credential into a response body
390
+ or a log line, and a token in a log is a token to rotate.
391
+
392
+ A **collection** (`bc.timelines`, `bc.messages`, `timeline.tasks`, any `.filter(...)`) is
393
+ a lazy query. ActiveSupport's `Enumerable#as_json` calls `to_a`, so serializing one ran a
394
+ page-by-page GET loop over the whole resource from inside the render and emitted every
395
+ record it fetched. (It did *not* reach the token — `Enumerable#as_json` shadows the ivar
396
+ walk — but a client's instance variables are eight collections, so serializing a *client*
397
+ ran those loops too, on its way to the credential.) Call `.to_a` or `.first(n)` yourself
398
+ and serialize that, so how much you fetch is a visible act in your code.
399
+
400
+ Both refusals happen before any HTTP — but they are on the SDK's own objects. An
401
+ `Enumerator` you built from one is a plain Ruby object this SDK does not own, so
402
+ `render json: { recent: bc.messages.lazy }` and `bc.messages.each.to_json` still page the
403
+ whole resource. Call `.first(n)` or `.to_a` before handing a query to a renderer.
404
+
405
+ `bc.inspect` and `"#{bc}"` are redacted for the same reason — Ruby's default `inspect`
406
+ dumps every instance variable, which would print the token into every exception message
407
+ and REPL transcript that touched a client:
408
+
409
+ ```
410
+ #<BaseCradle::Client base_url="https://basecradle.com" token=[REDACTED]>
411
+ ```
412
+
327
413
  ## Development
328
414
 
329
415
  ```bash
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  require_relative "errors"
4
6
 
5
7
  module BaseCradle
@@ -61,6 +63,46 @@ module BaseCradle
61
63
  @data
62
64
  end
63
65
 
66
+ # The wire record, for a serializer — ActiveSupport's hook, and what reaches a model
67
+ # nested inside a structure Rails renders (<tt>render json: { user: bc.me }</tt>).
68
+ # A bare <tt>render json: model</tt> calls +to_json+ instead and never comes here.
69
+ #
70
+ # Returns +to_h+: the wire keys, nothing renamed, nothing dropped, and a field newer
71
+ # than this release carried like any other. (It is the record *as this object holds
72
+ # it* — for a Timeline fetched whole, that is the API's two-key envelope merged into
73
+ # one object, so it carries +items+ alongside the timeline's own fields.)
74
+ #
75
+ # Two traps, because neither is what a Rails habit expects:
76
+ #
77
+ # - **It is the same Hash +to_h+ returns, not a copy** — read-only by the same
78
+ # convention, where every ActiveSupport +as_json+ builds a fresh one. So
79
+ # <tt>model.as_json.merge!(extra)</tt> rewrites the model's wire record, and for a
80
+ # wrapped child its parent's too (a child wraps the parent's own nested Hash).
81
+ # +to_h.dup+ if you need to touch it; note that is shallow.
82
+ # - **Options are accepted and ignored, +only:+ and +except:+ included** — a model is
83
+ # a read of one record, not a presenter. +as_json(except: ["ingest_url"])+ returns
84
+ # the whole record, *silently*. Worse, it disagrees with +to_json+: with
85
+ # ActiveSupport loaded <tt>model.to_json(except: [...])</tt> *does* redact, because
86
+ # the option reaches +Hash#to_json+. Do not rely on either — build the subset
87
+ # yourself with <tt>model.to_h.except("ingest_url")</tt>.
88
+ def as_json(*)
89
+ to_h
90
+ end
91
+
92
+ # The wire record as JSON. Without this a model fell through to Ruby's default and
93
+ # emitted a heap address — silently, with no field names and a different value every
94
+ # run. +JSON.generate+ and +Array#to_json+ / +Hash#to_json+ call this, so a model
95
+ # nested in either serializes correctly too.
96
+ #
97
+ # The argument is forwarded to +Hash#to_json+ rather than swallowed, so
98
+ # +JSON.pretty_generate+ pretty-prints — and what an unknown option does is the host
99
+ # app's json version's business, not this SDK's: json 3.x raises on an unknown
100
+ # keyword, the json 2.x that older Rubies ship ignores it, and ActiveSupport acts on
101
+ # +only:+ / +except:+. See +as_json+ above: do not route redaction through either.
102
+ def to_json(*args)
103
+ to_h.to_json(*args)
104
+ end
105
+
64
106
  def ==(other)
65
107
  other.instance_of?(self.class) && other.to_h == @data
66
108
  end
@@ -7,6 +7,7 @@ require "uri"
7
7
  require_relative "dashboard"
8
8
  require_relative "errors"
9
9
  require_relative "items"
10
+ require_relative "serialization"
10
11
  require_relative "sessions"
11
12
  require_relative "timelines"
12
13
  require_relative "user"
@@ -23,6 +24,10 @@ module BaseCradle
23
24
  # Every resource is built on +#request+, which is also the escape hatch for API
24
25
  # endpoints added before the SDK wraps them (the API is additive-only).
25
26
  class Client
27
+ # A client is a connection holding a live credential, never a record: serializing one
28
+ # raises rather than write the token into a response body or a log line.
29
+ include NotSerializable
30
+
26
31
  DEFAULT_BASE_URL = "https://basecradle.com"
27
32
  DEFAULT_TIMEOUT = 30
28
33
 
@@ -192,8 +197,20 @@ module BaseCradle
192
197
  handle(response)
193
198
  end
194
199
 
200
+ # Never the token. Ruby's default +inspect+ dumps every ivar, which would print the
201
+ # credential into every exception message, REPL transcript and +p+ call that touched
202
+ # a client — the same leak as serializing one, through a different door. The
203
+ # redaction marker is fixed text, so what it stands for cannot be guessed from its
204
+ # length. +to_s+ is the same string: string interpolation is the door people reach
205
+ # for without thinking about it.
195
206
  def inspect
196
- "#<#{self.class} base_url=#{@base_url.inspect}>"
207
+ "#<#{self.class} base_url=#{@base_url.inspect} token=[REDACTED]>"
208
+ end
209
+
210
+ # Late-bound rather than `alias to_s inspect`, so a subclass that redacts more in
211
+ # inspect is not bypassed by interpolation.
212
+ def to_s
213
+ inspect
197
214
  end
198
215
 
199
216
  # The shared low-level send: returns the Net::HTTPResponse or raises APIConnectionError.
@@ -226,6 +243,16 @@ module BaseCradle
226
243
 
227
244
  private
228
245
 
246
+ # Why +to_json+ / +as_json+ refuse (see BaseCradle::NotSerializable). A client is not
247
+ # a record, and the thing it would emit is a live credential.
248
+ def serialization_refusal
249
+ "#{self.class} is a connection holding your bc_uat_ token, not a record. " \
250
+ "Serializing it writes the raw credential into whatever you were rendering or " \
251
+ "logging, and a token in a log is a token to rotate. Serialize the record you " \
252
+ "meant instead (bc.me, a timeline, a message), or bc.base_url to name the " \
253
+ "connection itself."
254
+ end
255
+
229
256
  # Record what the mint response said about the credential just issued. Private: only
230
257
  # +.login+ calls it, on a client it has just built.
231
258
  def minted(body)
@@ -30,6 +30,12 @@ module BaseCradle
30
30
  # this response form). The SDK raises rather than return an ambiguous nil.
31
31
  class MissingFieldError < Error; end
32
32
 
33
+ # A Client or a collection resource was serialized (+to_json+ / +as_json+). Neither is
34
+ # a record: a Client holds your bearer token, and a collection is a lazy query whose
35
+ # serialization would page the whole API from inside your renderer. The SDK refuses
36
+ # loudly rather than emit a credential or a heap address — see BaseCradle::NotSerializable.
37
+ class NotSerializableError < Error; end
38
+
33
39
  # The request never got an API response (DNS failure, refused connection, timeout).
34
40
  # The underlying exception is preserved as +cause+.
35
41
  class APIConnectionError < Error; end
@@ -5,6 +5,7 @@ require "time"
5
5
 
6
6
  require_relative "api_object"
7
7
  require_relative "pagination"
8
+ require_relative "serialization"
8
9
  require_relative "user"
9
10
 
10
11
  module BaseCradle
@@ -93,6 +94,7 @@ module BaseCradle
93
94
  # PLURAL / SINGULAR / MODEL.
94
95
  class ItemsResource
95
96
  include Enumerable
97
+ include NotSerializableCollection
96
98
 
97
99
  def initialize(client, filters: {})
98
100
  @client = client
@@ -163,6 +165,7 @@ module BaseCradle
163
165
  # One timeline's messages: create here, or iterate (newest first).
164
166
  class TimelineMessages
165
167
  include Enumerable
168
+ include NotSerializableCollection
166
169
 
167
170
  def initialize(client, timeline_uuid)
168
171
  @client = client
@@ -190,6 +193,7 @@ module BaseCradle
190
193
  # One timeline's assets: upload here (multipart), or iterate (newest first).
191
194
  class TimelineAssets
192
195
  include Enumerable
196
+ include NotSerializableCollection
193
197
 
194
198
  def initialize(client, timeline_uuid)
195
199
  @client = client
@@ -234,6 +238,7 @@ module BaseCradle
234
238
  # One timeline's tasks: create here, or iterate (newest first).
235
239
  class TimelineTasks
236
240
  include Enumerable
241
+ include NotSerializableCollection
237
242
 
238
243
  def initialize(client, timeline_uuid)
239
244
  @client = client
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "serialization"
4
+
3
5
  module BaseCradle
4
6
  # The shared cursor-pagination engine. Every list endpoint paginates the same way:
5
7
  # newest first, up to 50 per page, +next_cursor+ in the response passed back as
@@ -10,6 +12,7 @@ module BaseCradle
10
12
  # and cursors never appear in calling code.
11
13
  class Paginator
12
14
  include Enumerable
15
+ include NotSerializableCollection
13
16
 
14
17
  def initialize(client, path, envelope_key:, model:, params: nil)
15
18
  @client = client
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module BaseCradle
6
+ # Refuse to serialize an object that is a connection or a live query rather than a
7
+ # record, and say what to serialize instead.
8
+ #
9
+ # With ActiveSupport loaded, +Object#as_json+ is +instance_values+ — every instance
10
+ # variable, walked recursively. A Client's ivars include its raw +bc_uat_+ token, so
11
+ # <tt>render json: { conn: bc }</tt> or <tt>logger.info({ conn: bc }.to_json)</tt> put
12
+ # the credential in a response body or a log line, and **a token in a log is a token to
13
+ # rotate**. That same walk reaches the client's eight collection ivars, and
14
+ # ActiveSupport's +Enumerable#as_json+ calls +to_a+ — so serializing a client also fired
15
+ # a live, unbounded, page-by-page GET loop before it got to the token, and serializing a
16
+ # collection on its own fired one from inside the render for no stated reason.
17
+ #
18
+ # Without ActiveSupport the same calls were merely useless: +"#<BaseCradle::Client:0x…>"+,
19
+ # a heap address that differs every run.
20
+ #
21
+ # Includers supply +serialization_refusal+; the default below is the honest fallback
22
+ # for one that does not, so the failure is still a rescuable BaseCradle::Error rather
23
+ # than a NoMethodError out of a renderer.
24
+ module NotSerializable
25
+ # ActiveSupport's serialization hook — what reaches this object when it is nested in
26
+ # something Rails renders (<tt>render json: { conn: bc }</tt>).
27
+ def as_json(*)
28
+ raise NotSerializableError, serialization_refusal
29
+ end
30
+
31
+ # +JSON.generate+, +Hash#to_json+, +Array#to_json+ and a bare
32
+ # <tt>render json: bc</tt> all arrive here.
33
+ def to_json(*)
34
+ raise NotSerializableError, serialization_refusal
35
+ end
36
+
37
+ private
38
+
39
+ def serialization_refusal
40
+ "#{self.class} is not a record and cannot be serialized."
41
+ end
42
+ end
43
+
44
+ # What every lazy collection resource mixes in — the auto-paginating resources
45
+ # (+bc.timelines+, +bc.messages+, +timeline.tasks+, any +.filter(...)+) and the
46
+ # Paginator behind them.
47
+ #
48
+ # It includes +Enumerable+ itself, and that is load-bearing rather than tidy:
49
+ # ActiveSupport defines +as_json+ and +to_json+ on +Enumerable+, so the refusal only
50
+ # takes effect while it sits *ahead* of Enumerable in the ancestor chain. Pulling
51
+ # Enumerable in here makes that true whichever order an including class writes its
52
+ # +include+ lines — a module already in the chain is not moved by a later include — so
53
+ # the guarantee is structural instead of a rule in a comment that a future resource can
54
+ # forget. The classes still write +include Enumerable+ explicitly, because being
55
+ # Enumerable is part of what they are.
56
+ module NotSerializableCollection
57
+ include Enumerable
58
+ include NotSerializable
59
+
60
+ private
61
+
62
+ # Deliberately does *not* claim a token leak. Measured on this tree: ActiveSupport's
63
+ # +Enumerable#as_json+ shadows +Object#as_json+, so a collection serialized as its
64
+ # fetched records, never as its ivars — the client it holds was never walked. The
65
+ # harm is the unbounded fetch and the records that came out with it.
66
+ def serialization_refusal
67
+ "#{self.class} is a lazy, auto-paginating query, not a record, so there is no one " \
68
+ "record to serialize. Serializing it runs a page-by-page GET loop over the whole " \
69
+ "resource from inside your renderer, and emits every record it fetched. Call " \
70
+ ".to_a (or .first(n), or .filter(...).to_a) and serialize that — then how much you " \
71
+ "fetch is a visible act in your own code."
72
+ end
73
+ end
74
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "api_object"
4
4
  require_relative "pagination"
5
+ require_relative "serialization"
5
6
 
6
7
  module BaseCradle
7
8
  # One credential you hold — a web sign-in or a bc_uat_ API token.
@@ -39,6 +40,7 @@ module BaseCradle
39
40
  # end
40
41
  class SessionsResource
41
42
  include Enumerable
43
+ include NotSerializableCollection
42
44
 
43
45
  def initialize(client)
44
46
  @client = client
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "pagination"
4
+ require_relative "serialization"
4
5
  require_relative "timeline"
5
6
 
6
7
  module BaseCradle
@@ -10,6 +11,7 @@ module BaseCradle
10
11
  # method (+map+, +find+, +first+ — which stop early without fetching every page).
11
12
  class TimelinesResource
12
13
  include Enumerable
14
+ include NotSerializableCollection
13
15
 
14
16
  def initialize(client)
15
17
  @client = client
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "api_object"
4
+ require_relative "serialization"
4
5
 
5
6
  module BaseCradle
6
7
  # The trust relationship between you and another user, from your point of view.
@@ -98,6 +99,7 @@ module BaseCradle
98
99
  # bc.users.each { |user| puts [user.handle, user.kind, user.trust.mutual].inspect }
99
100
  class UsersResource
100
101
  include Enumerable
102
+ include NotSerializableCollection
101
103
 
102
104
  def initialize(client)
103
105
  @client = client
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BaseCradle
4
- VERSION = "0.9.0"
4
+ VERSION = "0.10.1"
5
5
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "api_object"
4
4
  require_relative "items"
5
+ require_relative "serialization"
5
6
  require_relative "user"
6
7
 
7
8
  module BaseCradle
@@ -260,6 +261,7 @@ module BaseCradle
260
261
  # One timeline's webhook endpoints: create here, or iterate (newest first).
261
262
  class TimelineWebhookEndpoints
262
263
  include Enumerable
264
+ include NotSerializableCollection
263
265
 
264
266
  def initialize(client, timeline_uuid)
265
267
  @client = client
@@ -287,6 +289,7 @@ module BaseCradle
287
289
  # One timeline's webhook events — read-only, so iterate is all there is.
288
290
  class TimelineWebhookEvents
289
291
  include Enumerable
292
+ include NotSerializableCollection
290
293
 
291
294
  def initialize(client, timeline_uuid)
292
295
  @client = client
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.9.0
4
+ version: 0.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Drawk Kwast
@@ -26,6 +26,7 @@ files:
26
26
  - lib/basecradle/errors.rb
27
27
  - lib/basecradle/items.rb
28
28
  - lib/basecradle/pagination.rb
29
+ - lib/basecradle/serialization.rb
29
30
  - lib/basecradle/sessions.rb
30
31
  - lib/basecradle/timeline.rb
31
32
  - lib/basecradle/timelines.rb