basecradle 0.0.1 → 0.1.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.
@@ -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,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ require_relative "api_object"
6
+ require_relative "pagination"
7
+ require_relative "user"
8
+
9
+ module BaseCradle
10
+ # --- models ---------------------------------------------------------------------------
11
+
12
+ # The envelope shape every timeline item shares. +timeline+ is in reference form (just
13
+ # a uuid) — dereference it with bc.timelines.get(item.timeline.uuid) when you need it.
14
+ class Item < ApiObject
15
+ attribute :type
16
+ attribute :created_at
17
+ attribute :user, wrap: User
18
+ attribute :timeline, wrap: Reference
19
+ end
20
+
21
+ # A message's content: its uuid and body.
22
+ class MessageContent < ApiObject
23
+ attribute :uuid
24
+ attribute :body
25
+ end
26
+
27
+ # A text post on a timeline.
28
+ class Message < Item
29
+ attribute :content, wrap: MessageContent
30
+ end
31
+
32
+ # An asset's attached file: metadata plus a dereferenceable download URL.
33
+ class AssetFile < ApiObject
34
+ attribute :filename
35
+ attribute :byte_size
36
+ attribute :content_type
37
+ attribute :checksum # base64 MD5 of the blob
38
+ attribute :url
39
+ end
40
+
41
+ # An asset's content: description and the attached file.
42
+ class AssetContent < ApiObject
43
+ attribute :uuid
44
+ attribute :description
45
+ attribute :file, wrap: AssetFile
46
+ end
47
+
48
+ # A file (with optional description) posted to a timeline.
49
+ class Asset < Item
50
+ attribute :content, wrap: AssetContent
51
+ end
52
+
53
+ # A task's content: instructions, schedule, and status.
54
+ class TaskContent < ApiObject
55
+ attribute :uuid
56
+ attribute :instructions
57
+ attribute :activate_at
58
+ attribute :status # "pending" | "activated" | "blocked_timeline_locked"
59
+ end
60
+
61
+ # An instruction with a scheduled activation time.
62
+ class Task < Item
63
+ attribute :content, wrap: TaskContent
64
+ end
65
+
66
+ # --- cross-timeline list + get + filter -----------------------------------------------
67
+
68
+ # The shared cross-timeline pattern: iterate everything you can see (newest first,
69
+ # auto-paginating), narrow with .filter, or fetch one by uuid. Subclasses set PATH /
70
+ # PLURAL / SINGULAR / MODEL.
71
+ class ItemsResource
72
+ include Enumerable
73
+
74
+ def initialize(client, filters: {})
75
+ @client = client
76
+ @filters = filters
77
+ end
78
+
79
+ def each(&block)
80
+ return enum_for(:each) unless block_given?
81
+
82
+ Paginator.new(@client, self.class::PATH, envelope_key: self.class::PLURAL,
83
+ model: self.class::MODEL, params: @filters).each(&block)
84
+ end
85
+
86
+ # A new lazy resource narrowed to one timeline (a Timeline or a uuid). Filters compose.
87
+ def filter(timeline: nil)
88
+ self.class.new(@client, filters: merge_filters(timeline: timeline))
89
+ end
90
+
91
+ # Fetch one item by its own uuid (you must be a viewer of its timeline).
92
+ def get(uuid)
93
+ response = @client.request("GET", "#{self.class::PATH}/#{uuid}")
94
+ self.class::MODEL.new(response.fetch(self.class::SINGULAR), client: @client)
95
+ end
96
+
97
+ private
98
+
99
+ def merge_filters(**values)
100
+ merged = @filters.dup
101
+ values.each { |key, value| merged[key.to_s] = BaseCradle.uuid_of(value) unless value.nil? }
102
+ merged
103
+ end
104
+ end
105
+
106
+ # Messages from every timeline you can view, newest first.
107
+ class MessagesResource < ItemsResource
108
+ PATH = "/messages"
109
+ PLURAL = "messages"
110
+ SINGULAR = "message"
111
+ MODEL = Message
112
+ end
113
+
114
+ # Assets from every timeline you can view, newest first.
115
+ class AssetsResource < ItemsResource
116
+ PATH = "/assets"
117
+ PLURAL = "assets"
118
+ SINGULAR = "asset"
119
+ MODEL = Asset
120
+ end
121
+
122
+ # Tasks from every timeline you can view, newest first.
123
+ class TasksResource < ItemsResource
124
+ PATH = "/tasks"
125
+ PLURAL = "tasks"
126
+ SINGULAR = "task"
127
+ MODEL = Task
128
+
129
+ # Narrow by timeline and/or status ("pending" | "activated" | "blocked_timeline_locked").
130
+ def filter(timeline: nil, status: nil)
131
+ filters = merge_filters(timeline: timeline)
132
+ filters["status"] = status unless status.nil?
133
+ self.class.new(@client, filters: filters)
134
+ end
135
+ end
136
+
137
+ # --- nested creators on a Timeline (timeline.messages / .assets / .tasks) --------------
138
+
139
+ # One timeline's messages: create here, or iterate (newest first).
140
+ class TimelineMessages
141
+ include Enumerable
142
+
143
+ def initialize(client, timeline_uuid)
144
+ @client = client
145
+ @timeline_uuid = timeline_uuid
146
+ end
147
+
148
+ # Post a message to this timeline (you must be a viewer; the timeline must be unlocked).
149
+ def create(body:)
150
+ response = @client.request("POST", "/timelines/#{@timeline_uuid}/messages",
151
+ json: { "message" => { "body" => body } })
152
+ Message.new(response.fetch("message"), client: @client)
153
+ end
154
+
155
+ def each(&block)
156
+ MessagesResource.new(@client).filter(timeline: @timeline_uuid).each(&block)
157
+ end
158
+ end
159
+
160
+ # One timeline's assets: upload here (multipart), or iterate (newest first).
161
+ class TimelineAssets
162
+ include Enumerable
163
+
164
+ def initialize(client, timeline_uuid)
165
+ @client = client
166
+ @timeline_uuid = timeline_uuid
167
+ end
168
+
169
+ # Upload a file to this timeline. +file+ is a path or a binary IO; +description+ optional.
170
+ def create(file:, description: nil)
171
+ filename, io, opened = open_upload(file)
172
+ parts = [ [ "asset[file]", io, { filename: filename } ] ]
173
+ parts << [ "asset[description]", description ] unless description.nil?
174
+ begin
175
+ response = @client.request("POST", "/timelines/#{@timeline_uuid}/assets", form: parts)
176
+ ensure
177
+ io.close if opened
178
+ end
179
+ Asset.new(response.fetch("asset"), client: @client)
180
+ end
181
+
182
+ def each(&block)
183
+ AssetsResource.new(@client).filter(timeline: @timeline_uuid).each(&block)
184
+ end
185
+
186
+ private
187
+
188
+ def open_upload(file)
189
+ if file.is_a?(String) || file.is_a?(Pathname)
190
+ path = file.to_s
191
+ [ File.basename(path), File.open(path, "rb"), true ]
192
+ else
193
+ name = file.respond_to?(:path) ? File.basename(file.path) : "file"
194
+ [ name, file, false ]
195
+ end
196
+ end
197
+ end
198
+
199
+ # One timeline's tasks: create here, or iterate (newest first).
200
+ class TimelineTasks
201
+ include Enumerable
202
+
203
+ def initialize(client, timeline_uuid)
204
+ @client = client
205
+ @timeline_uuid = timeline_uuid
206
+ end
207
+
208
+ # Schedule a task on this timeline. +activate_at+ accepts a Time/DateTime (serialized
209
+ # to ISO 8601 — make it timezone-aware to be unambiguous) or an ISO 8601 string.
210
+ def create(instructions:, activate_at:)
211
+ activate_at = activate_at.iso8601 if activate_at.respond_to?(:iso8601)
212
+ response = @client.request(
213
+ "POST", "/timelines/#{@timeline_uuid}/tasks",
214
+ json: { "task" => { "instructions" => instructions, "activate_at" => activate_at } }
215
+ )
216
+ Task.new(response.fetch("task"), client: @client)
217
+ end
218
+
219
+ def each(&block)
220
+ TasksResource.new(@client).filter(timeline: @timeline_uuid).each(&block)
221
+ end
222
+ end
223
+ 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