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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +143 -1
- data/lib/basecradle/api_object.rb +105 -0
- data/lib/basecradle/client.rb +195 -0
- data/lib/basecradle/dashboard.rb +69 -0
- data/lib/basecradle/errors.rb +184 -0
- data/lib/basecradle/items.rb +223 -0
- data/lib/basecradle/pagination.rb +40 -0
- data/lib/basecradle/sessions.rb +64 -0
- data/lib/basecradle/timeline.rb +90 -0
- data/lib/basecradle/timelines.rb +43 -0
- data/lib/basecradle/user.rb +104 -0
- data/lib/basecradle/version.rb +1 -1
- data/lib/basecradle/webhooks.rb +141 -0
- data/lib/basecradle.rb +15 -3
- metadata +15 -7
|
@@ -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
|