basecradle 0.0.1 → 0.1.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.
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BaseCradle
4
+ # Root of every exception this SDK raises. Rescue +BaseCradle::Error+ to catch
5
+ # everything.
6
+ #
7
+ # For errors that came from an API response, the problem document is exposed:
8
+ # +status+, +code+, +title+, +detail+, +instance+, and +problem+ (the full parsed
9
+ # document). For errors that never reached the API (missing token, connection
10
+ # failure), those are +nil+.
11
+ class Error < StandardError
12
+ attr_reader :status, :code, :title, :detail, :instance, :problem
13
+
14
+ def initialize(message = nil, status: nil, code: nil, title: nil, detail: nil, instance: nil,
15
+ problem: nil)
16
+ super(message)
17
+ @status = status
18
+ @code = code
19
+ @title = title
20
+ @detail = detail
21
+ @instance = instance
22
+ @problem = problem
23
+ end
24
+ end
25
+
26
+ # No token was provided and BASECRADLE_TOKEN is not set.
27
+ class MissingTokenError < Error; end
28
+
29
+ # A model field was accessed that the API did not return (access-gated, or not part of
30
+ # this response form). The SDK raises rather than return an ambiguous nil.
31
+ class MissingFieldError < Error; end
32
+
33
+ # The request never got an API response (DNS failure, refused connection, timeout).
34
+ # The underlying exception is preserved as +cause+.
35
+ class APIConnectionError < Error; end
36
+
37
+ # --- 401: authentication ---------------------------------------------------------------
38
+
39
+ # Authentication failed (HTTP 401).
40
+ class AuthenticationError < Error; end
41
+
42
+ # +unauthorized+ — the Bearer token is missing or invalid.
43
+ class UnauthorizedError < AuthenticationError; end
44
+
45
+ # +invalid_credentials+ — sign-in failed: the email address or password is wrong.
46
+ class InvalidCredentialsError < AuthenticationError; end
47
+
48
+ # +invalid_signature+ — webhook ingest: the signature is missing or does not match.
49
+ class InvalidSignatureError < AuthenticationError; end
50
+
51
+ # --- 403: account / permissions --------------------------------------------------------
52
+
53
+ # +account_suspended+ — credentials were valid but the account is suspended.
54
+ class AccountSuspendedError < Error; end
55
+
56
+ # Authenticated but not allowed (HTTP 403).
57
+ class ForbiddenError < Error; end
58
+
59
+ # +not_a_viewer+ — you are not a viewer (owner or participant) of the timeline.
60
+ class NotAViewerError < ForbiddenError; end
61
+
62
+ # +not_timeline_owner+ — the action requires being the timeline's owner.
63
+ class NotTimelineOwnerError < ForbiddenError; end
64
+
65
+ # +timeline_locked+ — the timeline is locked and not accepting new content.
66
+ class TimelineLockedError < ForbiddenError; end
67
+
68
+ # --- 404 --------------------------------------------------------------------------------
69
+
70
+ # +not_found+ — no record exists for the given UUID (or it is hidden from you).
71
+ class NotFoundError < Error; end
72
+
73
+ # --- 422: validation --------------------------------------------------------------------
74
+
75
+ # A submitted record failed validation (HTTP 422). +errors+ maps attribute name to a
76
+ # list of messages (empty when the API sent none).
77
+ class ValidationError < Error
78
+ attr_reader :errors
79
+
80
+ def initialize(message = nil, errors: nil, **kwargs)
81
+ super(message, **kwargs)
82
+ @errors = errors || {}
83
+ end
84
+ end
85
+
86
+ # +current_password_incorrect+ — password change: the current password is incorrect.
87
+ class CurrentPasswordIncorrectError < ValidationError; end
88
+
89
+ # +password_confirmation_mismatch+ — the new password and its confirmation differ.
90
+ class PasswordConfirmationMismatchError < ValidationError; end
91
+
92
+ # --- 429 --------------------------------------------------------------------------------
93
+
94
+ # +rate_limited+ — too many requests in the window. +retry_after+ is the number of
95
+ # seconds to wait (from the +Retry-After+ header), or +nil+ if the header was absent.
96
+ class RateLimitedError < Error
97
+ attr_reader :retry_after
98
+
99
+ def initialize(message = nil, retry_after: nil, **kwargs)
100
+ super(message, **kwargs)
101
+ @retry_after = retry_after
102
+ end
103
+ end
104
+
105
+ # --- 400: malformed requests ------------------------------------------------------------
106
+
107
+ # The request was malformed (HTTP 400).
108
+ class InvalidRequestError < Error; end
109
+
110
+ # +invalid_cursor+ — the +before+ pagination cursor is not a valid record UUID.
111
+ class InvalidCursorError < InvalidRequestError; end
112
+
113
+ # +invalid_filter+ — a list filter value is malformed.
114
+ class InvalidFilterError < InvalidRequestError; end
115
+
116
+ # --- webhook ingest ----------------------------------------------------------------------
117
+
118
+ # +endpoint_disabled+ — webhook ingest: the endpoint is not accepting deliveries.
119
+ class EndpointDisabledError < Error; end
120
+
121
+ # +payload_too_large+ — webhook ingest: the request body exceeds the maximum size.
122
+ class PayloadTooLargeError < Error; end
123
+
124
+ # --- the code => class registry ----------------------------------------------------------
125
+
126
+ # Every API error is an RFC 9457 application/problem+json document with a stable,
127
+ # machine-readable +code+. Each code maps to its own exception class.
128
+ CODE_TO_ERROR = {
129
+ "unauthorized" => UnauthorizedError,
130
+ "invalid_credentials" => InvalidCredentialsError,
131
+ "invalid_signature" => InvalidSignatureError,
132
+ "account_suspended" => AccountSuspendedError,
133
+ "not_a_viewer" => NotAViewerError,
134
+ "not_timeline_owner" => NotTimelineOwnerError,
135
+ "timeline_locked" => TimelineLockedError,
136
+ "not_found" => NotFoundError,
137
+ "validation_failed" => ValidationError,
138
+ "current_password_incorrect" => CurrentPasswordIncorrectError,
139
+ "password_confirmation_mismatch" => PasswordConfirmationMismatchError,
140
+ "rate_limited" => RateLimitedError,
141
+ "invalid_cursor" => InvalidCursorError,
142
+ "invalid_filter" => InvalidFilterError,
143
+ "endpoint_disabled" => EndpointDisabledError,
144
+ "payload_too_large" => PayloadTooLargeError
145
+ }.freeze
146
+
147
+ class Error
148
+ # Build the right exception for a non-2xx API response.
149
+ #
150
+ # +problem+ is the parsed problem+json document (a Hash) or +nil+. Unknown codes
151
+ # fall back to +BaseCradle::Error+ (the API is additive-only; a new error code must
152
+ # never crash the SDK), and non-problem+json bodies produce a bare +Error+ carrying
153
+ # just the HTTP status.
154
+ def self.from_response(status:, problem:, retry_after: nil)
155
+ unless problem.is_a?(Hash) && problem.key?("code")
156
+ return new("API request failed with HTTP #{status}", status: status,
157
+ problem: problem.is_a?(Hash) ? problem : nil)
158
+ end
159
+
160
+ code = problem["code"]
161
+ detail = problem["detail"]
162
+ title = problem["title"]
163
+ message = detail || title || "API request failed with HTTP #{status}"
164
+ common = {
165
+ status: problem.fetch("status", status),
166
+ code: code,
167
+ title: title,
168
+ detail: detail,
169
+ instance: problem["instance"],
170
+ problem: problem
171
+ }
172
+
173
+ error_class = CODE_TO_ERROR.fetch(code, self)
174
+
175
+ if error_class <= ValidationError
176
+ error_class.new(message, errors: problem["errors"], **common)
177
+ elsif error_class <= RateLimitedError
178
+ error_class.new(message, retry_after: retry_after, **common)
179
+ else
180
+ error_class.new(message, **common)
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "time"
5
+
6
+ require_relative "api_object"
7
+ require_relative "pagination"
8
+ require_relative "user"
9
+
10
+ module BaseCradle
11
+ # --- models ---------------------------------------------------------------------------
12
+
13
+ # The envelope shape every timeline item shares. +timeline+ is in reference form (just
14
+ # a uuid) — dereference it with bc.timelines.get(item.timeline.uuid) when you need it.
15
+ class Item < ApiObject
16
+ attribute :type
17
+ attribute :created_at
18
+ attribute :user, wrap: User
19
+ attribute :timeline, wrap: Reference
20
+ end
21
+
22
+ # A message's content: its uuid and body.
23
+ class MessageContent < ApiObject
24
+ attribute :uuid
25
+ attribute :body
26
+ end
27
+
28
+ # A text post on a timeline.
29
+ class Message < Item
30
+ attribute :content, wrap: MessageContent
31
+ end
32
+
33
+ # An asset's attached file: metadata plus a dereferenceable download URL.
34
+ class AssetFile < ApiObject
35
+ attribute :filename
36
+ attribute :byte_size
37
+ attribute :content_type
38
+ attribute :checksum # base64 MD5 of the blob
39
+ attribute :url
40
+ end
41
+
42
+ # An asset's content: description and the attached file.
43
+ class AssetContent < ApiObject
44
+ attribute :uuid
45
+ attribute :description
46
+ attribute :file, wrap: AssetFile
47
+ end
48
+
49
+ # A file (with optional description) posted to a timeline.
50
+ class Asset < Item
51
+ attribute :content, wrap: AssetContent
52
+ end
53
+
54
+ # A task's content: instructions, schedule, and status.
55
+ class TaskContent < ApiObject
56
+ attribute :uuid
57
+ attribute :instructions
58
+ attribute :activate_at
59
+ attribute :status # "pending" | "activated" | "blocked_timeline_locked"
60
+ end
61
+
62
+ # An instruction with a scheduled activation time.
63
+ class Task < Item
64
+ attribute :content, wrap: TaskContent
65
+ end
66
+
67
+ # --- cross-timeline list + get + filter -----------------------------------------------
68
+
69
+ # The shared cross-timeline pattern: iterate everything you can see (newest first,
70
+ # auto-paginating), narrow with .filter, or fetch one by uuid. Subclasses set PATH /
71
+ # PLURAL / SINGULAR / MODEL.
72
+ class ItemsResource
73
+ include Enumerable
74
+
75
+ def initialize(client, filters: {})
76
+ @client = client
77
+ @filters = filters
78
+ end
79
+
80
+ def each(&block)
81
+ return enum_for(:each) unless block_given?
82
+
83
+ Paginator.new(@client, self.class::PATH, envelope_key: self.class::PLURAL,
84
+ model: self.class::MODEL, params: @filters).each(&block)
85
+ end
86
+
87
+ # A new lazy resource narrowed to one timeline (a Timeline or a uuid). Filters compose.
88
+ def filter(timeline: nil)
89
+ self.class.new(@client, filters: merge_filters(timeline: timeline))
90
+ end
91
+
92
+ # Fetch one item by its own uuid (you must be a viewer of its timeline).
93
+ def get(uuid)
94
+ response = @client.request("GET", "#{self.class::PATH}/#{uuid}")
95
+ self.class::MODEL.new(response.fetch(self.class::SINGULAR), client: @client)
96
+ end
97
+
98
+ private
99
+
100
+ def merge_filters(**values)
101
+ merged = @filters.dup
102
+ values.each { |key, value| merged[key.to_s] = BaseCradle.uuid_of(value) unless value.nil? }
103
+ merged
104
+ end
105
+ end
106
+
107
+ # Messages from every timeline you can view, newest first.
108
+ class MessagesResource < ItemsResource
109
+ PATH = "/messages"
110
+ PLURAL = "messages"
111
+ SINGULAR = "message"
112
+ MODEL = Message
113
+ end
114
+
115
+ # Assets from every timeline you can view, newest first.
116
+ class AssetsResource < ItemsResource
117
+ PATH = "/assets"
118
+ PLURAL = "assets"
119
+ SINGULAR = "asset"
120
+ MODEL = Asset
121
+ end
122
+
123
+ # Tasks from every timeline you can view, newest first.
124
+ class TasksResource < ItemsResource
125
+ PATH = "/tasks"
126
+ PLURAL = "tasks"
127
+ SINGULAR = "task"
128
+ MODEL = Task
129
+
130
+ # Narrow by timeline and/or status ("pending" | "activated" | "blocked_timeline_locked").
131
+ def filter(timeline: nil, status: nil)
132
+ filters = merge_filters(timeline: timeline)
133
+ filters["status"] = status unless status.nil?
134
+ self.class.new(@client, filters: filters)
135
+ end
136
+ end
137
+
138
+ # --- nested creators on a Timeline (timeline.messages / .assets / .tasks) --------------
139
+
140
+ # One timeline's messages: create here, or iterate (newest first).
141
+ class TimelineMessages
142
+ include Enumerable
143
+
144
+ def initialize(client, timeline_uuid)
145
+ @client = client
146
+ @timeline_uuid = timeline_uuid
147
+ end
148
+
149
+ # Post a message to this timeline (you must be a viewer; the timeline must be unlocked).
150
+ def create(body:)
151
+ response = @client.request("POST", "/timelines/#{@timeline_uuid}/messages",
152
+ json: { "message" => { "body" => body } })
153
+ Message.new(response.fetch("message"), client: @client)
154
+ end
155
+
156
+ def each(&block)
157
+ MessagesResource.new(@client).filter(timeline: @timeline_uuid).each(&block)
158
+ end
159
+ end
160
+
161
+ # One timeline's assets: upload here (multipart), or iterate (newest first).
162
+ class TimelineAssets
163
+ include Enumerable
164
+
165
+ def initialize(client, timeline_uuid)
166
+ @client = client
167
+ @timeline_uuid = timeline_uuid
168
+ end
169
+
170
+ # Upload a file to this timeline. +file+ is a path or a binary IO; +description+ optional.
171
+ def create(file:, description: nil)
172
+ filename, io, opened = open_upload(file)
173
+ parts = [ [ "asset[file]", io, { filename: filename } ] ]
174
+ parts << [ "asset[description]", description ] unless description.nil?
175
+ begin
176
+ response = @client.request("POST", "/timelines/#{@timeline_uuid}/assets", form: parts)
177
+ ensure
178
+ io.close if opened
179
+ end
180
+ Asset.new(response.fetch("asset"), client: @client)
181
+ end
182
+
183
+ def each(&block)
184
+ AssetsResource.new(@client).filter(timeline: @timeline_uuid).each(&block)
185
+ end
186
+
187
+ private
188
+
189
+ def open_upload(file)
190
+ if file.is_a?(String) || file.is_a?(Pathname)
191
+ path = file.to_s
192
+ [ File.basename(path), File.open(path, "rb"), true ]
193
+ else
194
+ name = file.respond_to?(:path) ? File.basename(file.path) : "file"
195
+ [ name, file, false ]
196
+ end
197
+ end
198
+ end
199
+
200
+ # One timeline's tasks: create here, or iterate (newest first).
201
+ class TimelineTasks
202
+ include Enumerable
203
+
204
+ def initialize(client, timeline_uuid)
205
+ @client = client
206
+ @timeline_uuid = timeline_uuid
207
+ end
208
+
209
+ # Schedule a task on this timeline. +activate_at+ accepts a Time/DateTime (serialized
210
+ # to ISO 8601 — make it timezone-aware to be unambiguous) or an ISO 8601 string.
211
+ def create(instructions:, activate_at:)
212
+ activate_at = activate_at.iso8601 if activate_at.respond_to?(:iso8601)
213
+ response = @client.request(
214
+ "POST", "/timelines/#{@timeline_uuid}/tasks",
215
+ json: { "task" => { "instructions" => instructions, "activate_at" => activate_at } }
216
+ )
217
+ Task.new(response.fetch("task"), client: @client)
218
+ end
219
+
220
+ def each(&block)
221
+ TasksResource.new(@client).filter(timeline: @timeline_uuid).each(&block)
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BaseCradle
4
+ # The shared cursor-pagination engine. Every list endpoint paginates the same way:
5
+ # newest first, up to 50 per page, +next_cursor+ in the response passed back as
6
+ # +?before=+ for the next (older) page, a +null+ cursor meaning the end.
7
+ #
8
+ # Lazy and +Enumerable+: the first page is fetched when iteration starts, and page N+1
9
+ # only when iteration crosses the page boundary — so +first+/+take+/+find+ stop early
10
+ # and cursors never appear in calling code.
11
+ class Paginator
12
+ include Enumerable
13
+
14
+ def initialize(client, path, envelope_key:, model:, params: nil)
15
+ @client = client
16
+ @path = path
17
+ @envelope_key = envelope_key
18
+ @model = model
19
+ @params = params || {}
20
+ end
21
+
22
+ def each
23
+ return enum_for(:each) unless block_given?
24
+
25
+ cursor = nil
26
+ loop do
27
+ page = @client.request("GET", @path, params: page_params(cursor))
28
+ page.fetch(@envelope_key).each { |data| yield @model.new(data, client: @client) }
29
+ cursor = page["next_cursor"]
30
+ break if cursor.nil?
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def page_params(cursor)
37
+ cursor ? @params.merge("before" => cursor) : @params
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "api_object"
4
+ require_relative "pagination"
5
+
6
+ module BaseCradle
7
+ # One credential you hold — a web sign-in or a bc_uat_ API token.
8
+ #
9
+ # +current+ is true on exactly one session: the one making this request. Check it
10
+ # before revoking, so you don't kill your own credential by accident — unless that is
11
+ # exactly what you mean to do (legitimate self-rotation; see +revoke+).
12
+ class Session < ApiObject
13
+ attribute :uuid
14
+ attribute :name # the label given at mint time ("ci runner", "production agent", ...)
15
+ attribute :ip_address
16
+ attribute :user_agent
17
+ attribute :created_at
18
+ attribute :last_used_at # nil if never used; tracked at up to an hour of granularity
19
+ attribute :kind # "api" (Bearer token) | "web" (browser cookie session)
20
+ attribute :current # true on exactly one row: the session making this request
21
+
22
+ # Revoke this credential. It stops working **instantly** — its next request is a 401.
23
+ #
24
+ # WARNING: revoking your own *current* session is allowed (legitimate self-rotation),
25
+ # and it kills the very token this client is using — after it, this client's next call
26
+ # raises AuthenticationError. If you need continuity, mint a replacement with
27
+ # BaseCradle::Client.login(...) *before* revoking this one. A lost token cannot be
28
+ # recovered, only revoked and re-minted.
29
+ def revoke
30
+ require_client.request("DELETE", "/users/sessions/#{uuid}")
31
+ nil
32
+ end
33
+ end
34
+
35
+ # Every credential you hold — iterable, newest first, auto-paginating.
36
+ #
37
+ # bc.sessions.each do |session|
38
+ # session.revoke if session.kind == "api" && !session.current
39
+ # end
40
+ class SessionsResource
41
+ include Enumerable
42
+
43
+ def initialize(client)
44
+ @client = client
45
+ end
46
+
47
+ def each(&block)
48
+ return enum_for(:each) unless block_given?
49
+
50
+ Paginator.new(@client, "/users/sessions", envelope_key: "sessions", model: Session).each(&block)
51
+ end
52
+
53
+ # Destroy **every** session you hold — web sign-ins and API tokens alike.
54
+ #
55
+ # WARNING: this is the "I leaked something, kill everything" lever, and it includes
56
+ # **the token this client is using**. After it returns, this client is dead: its next
57
+ # call raises AuthenticationError. Mint a fresh token with
58
+ # BaseCradle::Client.login(email_address:, password:) to continue.
59
+ def revoke_all
60
+ @client.request("DELETE", "/users/sessions")
61
+ nil
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "api_object"
4
+ require_relative "items"
5
+ require_relative "user"
6
+ require_relative "webhooks"
7
+
8
+ module BaseCradle
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.
11
+ class TimelineItem < ApiObject
12
+ attribute :type
13
+ attribute :created_at
14
+ attribute :user, wrap: User
15
+ attribute :content # shape depends on type — read it wire-exact
16
+ end
17
+
18
+ # A timeline: its metadata, owner, participants, lock state — and its verbs.
19
+ #
20
+ # Verbs update this object with exactly what the API confirmed changed (live objects,
21
+ # Rails-style) and return +self+, except +add_participant+ which returns the added user.
22
+ class Timeline < ApiObject
23
+ attribute :uuid
24
+ attribute :name
25
+ attribute :locked
26
+ attribute :created_at
27
+ attribute :updated_at
28
+ attribute :owner, wrap: User
29
+ attribute :participants, wrap: User
30
+ # Present when the timeline is the subject of the response (get / create). List rows
31
+ # don't carry items — fetch the timeline to get them (reading this raises otherwise).
32
+ attribute :items, wrap: TimelineItem
33
+
34
+ # The emergency stop: freeze the timeline's content, permanently. Any viewer can lock;
35
+ # it is idempotent and one-way (unlocking is an out-of-band admin action).
36
+ def lock
37
+ response = require_client.request("POST", "/timelines/#{uuid}/lock")
38
+ to_h["locked"] = response["locked"]
39
+ self
40
+ end
41
+
42
+ # Add a peer to this timeline (owner or admin only; mutual trust required). Accepts a
43
+ # User or a uuid. Idempotent. Returns the added user (also appended to +participants+).
44
+ def add_participant(user)
45
+ conn = require_client
46
+ response = conn.request(
47
+ "POST", "/timelines/#{uuid}/participations", json: { "user_id" => BaseCradle.uuid_of(user) }
48
+ )
49
+ added = User.new(response, client: conn)
50
+ roster = (to_h["participants"] ||= [])
51
+ roster << response unless roster.any? { |p| p["uuid"] == added.uuid }
52
+ added
53
+ end
54
+
55
+ # Remove a participant from this timeline (owner or admin only). Idempotent.
56
+ def remove_participant(user)
57
+ removed_uuid = BaseCradle.uuid_of(user)
58
+ require_client.request("DELETE", "/timelines/#{uuid}/participations/#{removed_uuid}")
59
+ if to_h.key?("participants")
60
+ to_h["participants"] = to_h["participants"].reject { |p| p["uuid"] == removed_uuid }
61
+ end
62
+ self
63
+ end
64
+
65
+ # This timeline's messages: .create(body:) or iterate (newest first).
66
+ def messages
67
+ TimelineMessages.new(require_client, uuid)
68
+ end
69
+
70
+ # This timeline's assets: .create(file:, description:) (multipart) or iterate.
71
+ def assets
72
+ TimelineAssets.new(require_client, uuid)
73
+ end
74
+
75
+ # This timeline's tasks: .create(instructions:, activate_at:) or iterate.
76
+ def tasks
77
+ TimelineTasks.new(require_client, uuid)
78
+ end
79
+
80
+ # This timeline's inbound webhook endpoints: .create(description:) or iterate.
81
+ def webhook_endpoints
82
+ TimelineWebhookEndpoints.new(require_client, uuid)
83
+ end
84
+
85
+ # This timeline's webhook events (read-only) — iterate, newest first.
86
+ def webhook_events
87
+ TimelineWebhookEvents.new(require_client, uuid)
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pagination"
4
+ require_relative "timeline"
5
+
6
+ module BaseCradle
7
+ # Your timelines — the ones you own plus the ones you participate in.
8
+ #
9
+ # Iterable (auto-paginating, newest first): +bc.timelines.each+, or any Enumerable
10
+ # method (+map+, +find+, +first+ — which stop early without fetching every page).
11
+ class TimelinesResource
12
+ include Enumerable
13
+
14
+ def initialize(client)
15
+ @client = client
16
+ end
17
+
18
+ def each(&block)
19
+ return enum_for(:each) unless block_given?
20
+
21
+ Paginator.new(@client, "/timelines", envelope_key: "timelines", model: Timeline).each(&block)
22
+ end
23
+
24
+ # Create a timeline owned by you (subject to your max_timelines cap).
25
+ def create(name:)
26
+ response = @client.request("POST", "/timelines", json: { "timeline" => { "name" => name } })
27
+ subject_timeline(response)
28
+ end
29
+
30
+ # Fetch one timeline with its items inline (you must be a viewer).
31
+ def get(uuid)
32
+ subject_timeline(@client.request("GET", "/timelines/#{uuid}"))
33
+ end
34
+
35
+ private
36
+
37
+ # The API returns a two-key envelope ({"timeline" => ..., "items" => ...}); merge it
38
+ # into one Timeline so timeline.items reads through.
39
+ def subject_timeline(response)
40
+ Timeline.new(response.fetch("timeline").merge("items" => response["items"]), client: @client)
41
+ end
42
+ end
43
+ end