basecamp-sdk 0.15.0 → 0.17.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/lib/basecamp/api_error.rb +3 -2
- data/lib/basecamp/client.rb +21 -3
- data/lib/basecamp/generated/metadata.json +156 -1
- data/lib/basecamp/generated/services/bubble_ups_service.rb +36 -0
- data/lib/basecamp/generated/services/people_service.rb +30 -0
- data/lib/basecamp/generated/services/projects_service.rb +18 -0
- data/lib/basecamp/generated/services/recordings_service.rb +19 -0
- data/lib/basecamp/generated/services/templates_service.rb +28 -0
- data/lib/basecamp/generated/types.rb +207 -2
- data/lib/basecamp/http.rb +55 -23
- data/lib/basecamp/oauth/exchange.rb +152 -25
- data/lib/basecamp/oauth/fetcher.rb +1 -1
- data/lib/basecamp/oauth_token_provider.rb +50 -13
- data/lib/basecamp/people_confirmation_required_error.rb +14 -0
- data/lib/basecamp/rate_limit_error.rb +4 -2
- data/lib/basecamp/security.rb +20 -0
- data/lib/basecamp/version.rb +2 -2
- data/lib/basecamp.rb +95 -12
- data/scripts/generate-services.rb +7 -1
- metadata +4 -2
|
@@ -16,6 +16,22 @@ module Basecamp
|
|
|
16
16
|
# Token endpoint for Basecamp OAuth
|
|
17
17
|
TOKEN_URL = "https://launchpad.37signals.com/authorization/token"
|
|
18
18
|
|
|
19
|
+
# The redirect statuses a token endpoint response is refused for
|
|
20
|
+
# (SPEC §16 "Token-Endpoint Transport Policy") — the refresh POST carries
|
|
21
|
+
# the refresh token and client secret, and a redirect must surface as a
|
|
22
|
+
# typed fault rather than re-issue those credentials toward Location.
|
|
23
|
+
# 304 stays on the generic non-success path (a cache validator, not a
|
|
24
|
+
# redirect-with-Location).
|
|
25
|
+
REDIRECT_STATUSES = [ 301, 302, 303, 307, 308 ].freeze
|
|
26
|
+
|
|
27
|
+
# Whole-request bound in seconds for the refresh POST — the shared
|
|
28
|
+
# credential-POST default (SPEC §16). Enforced as socket timeouts AND a
|
|
29
|
+
# monotonic wall-clock deadline by the transport below.
|
|
30
|
+
REFRESH_TIMEOUT = 30
|
|
31
|
+
|
|
32
|
+
# Cap on a refresh response body (1 MiB), matching the exchange path.
|
|
33
|
+
MAX_RESPONSE_BYTES = 1 * 1024 * 1024
|
|
34
|
+
|
|
19
35
|
# @return [String, nil] the current refresh token
|
|
20
36
|
attr_reader :refresh_token
|
|
21
37
|
|
|
@@ -77,30 +93,51 @@ module Basecamp
|
|
|
77
93
|
perform_refresh if expired? && refreshable?
|
|
78
94
|
end
|
|
79
95
|
|
|
96
|
+
# The refresh POST runs on the headers-first {Oauth::Fetcher.stream_http}
|
|
97
|
+
# primitive — the same transport as the exchange and device paths — so it
|
|
98
|
+
# gets the full SPEC §16 discipline rather than a bare Faraday.post:
|
|
99
|
+
# redirects structurally never followed and classified at header time,
|
|
100
|
+
# socket timeouts plus a monotonic whole-request watchdog (a slow-drip
|
|
101
|
+
# peer cannot hold the refresh open past REFRESH_TIMEOUT), and a bounded
|
|
102
|
+
# streaming body read.
|
|
80
103
|
def perform_refresh
|
|
81
104
|
require "faraday"
|
|
82
105
|
require "json"
|
|
83
|
-
require "uri"
|
|
84
|
-
|
|
85
|
-
response = Faraday.post(TOKEN_URL) do |req|
|
|
86
|
-
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
87
|
-
req.body = URI.encode_www_form(
|
|
88
|
-
type: "refresh",
|
|
89
|
-
refresh_token: @refresh_token,
|
|
90
|
-
client_id: @client_id,
|
|
91
|
-
client_secret: @client_secret
|
|
92
|
-
)
|
|
93
|
-
end
|
|
94
106
|
|
|
95
|
-
|
|
107
|
+
status, body = Oauth::Fetcher.stream_http(
|
|
108
|
+
:post, TOKEN_URL,
|
|
109
|
+
headers: { "Content-Type" => "application/x-www-form-urlencoded" },
|
|
110
|
+
form: {
|
|
111
|
+
"type" => "refresh",
|
|
112
|
+
"refresh_token" => @refresh_token,
|
|
113
|
+
"client_id" => @client_id,
|
|
114
|
+
"client_secret" => @client_secret
|
|
115
|
+
},
|
|
116
|
+
timeout: REFRESH_TIMEOUT,
|
|
117
|
+
max_body_bytes: MAX_RESPONSE_BYTES,
|
|
118
|
+
skip_status: ->(s) { REDIRECT_STATUSES.include?(s) }
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# A refused redirect is a typed api fault carrying the real status —
|
|
122
|
+
# not the generic AuthError below, which would imply the credentials
|
|
123
|
+
# were judged and rejected when no such judgement happened.
|
|
124
|
+
if REDIRECT_STATUSES.include?(status)
|
|
125
|
+
raise ApiError.new("redirect #{status} on the token endpoint is not followed", http_status: status)
|
|
126
|
+
end
|
|
127
|
+
raise AuthError.new("Token refresh failed: #{status}") unless (200..299).cover?(status)
|
|
96
128
|
|
|
97
|
-
data = JSON.parse(
|
|
129
|
+
data = JSON.parse(body)
|
|
98
130
|
@access_token = data["access_token"]
|
|
99
131
|
@expires_at = Time.now + data["expires_in"].to_i if data["expires_in"]
|
|
100
132
|
|
|
101
133
|
@on_refresh&.call(@access_token, @refresh_token, @expires_at)
|
|
102
134
|
|
|
103
135
|
true
|
|
136
|
+
rescue Oauth::Fetcher::BodyTooLarge
|
|
137
|
+
raise ApiError.new("Token refresh response exceeds size cap")
|
|
138
|
+
rescue Oauth::Fetcher::ReadDeadlineExceeded => e
|
|
139
|
+
# A slow-drip read past the deadline is a transport timeout.
|
|
140
|
+
raise NetworkError.new("Token refresh network error", cause: e)
|
|
104
141
|
rescue Faraday::Error => e
|
|
105
142
|
raise NetworkError.new("Token refresh network error", cause: e)
|
|
106
143
|
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
# Raised when a template copy requires confirmation before granting project access.
|
|
5
|
+
class PeopleConfirmationRequiredError < ValidationError
|
|
6
|
+
# @return [Array<Basecamp::Types::TemplateLibraryConfirmationPerson>]
|
|
7
|
+
attr_reader :people
|
|
8
|
+
|
|
9
|
+
def initialize(message, people:, hint: nil, http_status: 422, field_errors: nil)
|
|
10
|
+
super(message, hint: hint, http_status: http_status, field_errors: field_errors)
|
|
11
|
+
@people = people
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
module Basecamp
|
|
4
4
|
# Raised when rate limited (429).
|
|
5
5
|
class RateLimitError < Error
|
|
6
|
-
def initialize(retry_after: nil, cause: nil)
|
|
7
|
-
|
|
6
|
+
def initialize(retry_after: nil, hint: nil, cause: nil)
|
|
7
|
+
# A concrete Retry-After beats a body-derived hint; the class default
|
|
8
|
+
# fills in when neither is present.
|
|
9
|
+
hint = retry_after ? "Try again in #{retry_after} seconds" : (hint || "Please slow down requests")
|
|
8
10
|
super(
|
|
9
11
|
code: ErrorCode::RATE_LIMIT,
|
|
10
12
|
message: "Rate limit exceeded",
|
data/lib/basecamp/security.rb
CHANGED
|
@@ -32,6 +32,26 @@ module Basecamp
|
|
|
32
32
|
raise UsageError.new("Invalid #{label}: #{url}")
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# Renders a URL for hooks as origin+path only (SPEC section 9): no
|
|
36
|
+
# userinfo, query or fragment. The download flow's hop-1 URL can carry a
|
|
37
|
+
# signed credential in its query; the wire request keeps the whole URL. A
|
|
38
|
+
# URL with no complete origin renders as the fixed token, never as any of
|
|
39
|
+
# its own text.
|
|
40
|
+
# @param url [String]
|
|
41
|
+
# @return [String]
|
|
42
|
+
def self.display_url(url)
|
|
43
|
+
uri = URI.parse(url)
|
|
44
|
+
return "unparsable" if uri.scheme.nil? || uri.host.nil? || uri.host.empty?
|
|
45
|
+
|
|
46
|
+
uri.password = nil
|
|
47
|
+
uri.user = nil
|
|
48
|
+
uri.query = nil
|
|
49
|
+
uri.fragment = nil
|
|
50
|
+
uri.to_s
|
|
51
|
+
rescue URI::Error
|
|
52
|
+
"unparsable"
|
|
53
|
+
end
|
|
54
|
+
|
|
35
55
|
def self.same_origin?(a, b)
|
|
36
56
|
ua = URI.parse(a)
|
|
37
57
|
ub = URI.parse(b)
|
data/lib/basecamp/version.rb
CHANGED
data/lib/basecamp.rb
CHANGED
|
@@ -117,31 +117,43 @@ module Basecamp
|
|
|
117
117
|
# @param retry_after [Integer, nil] Retry-After header value
|
|
118
118
|
# @return [Error]
|
|
119
119
|
def self.error_from_response(status, body = nil, retry_after: nil)
|
|
120
|
-
|
|
120
|
+
# SPEC §6 step 3: a body's error_description becomes the hint. Step 5:
|
|
121
|
+
# with no body message, the else arm falls back (via from_status) to the
|
|
122
|
+
# fixed code-bearing phrase, never a reason phrase.
|
|
123
|
+
hint = parse_error_hint(body)
|
|
124
|
+
server_message = parse_error_message(body)
|
|
125
|
+
message = server_message || "Request failed"
|
|
121
126
|
|
|
122
127
|
case status
|
|
123
128
|
when 400, 422
|
|
124
129
|
field_errors = parse_field_errors(body)
|
|
125
|
-
message = Security.truncate(compose_validation_message(
|
|
126
|
-
|
|
130
|
+
message = Security.truncate(compose_validation_message(server_message, field_errors) || "Request failed")
|
|
131
|
+
people = status == 422 ? parse_template_library_confirmation_people(body) : nil
|
|
132
|
+
if people
|
|
133
|
+
PeopleConfirmationRequiredError.new(
|
|
134
|
+
message, people: people, hint: hint, http_status: status, field_errors: field_errors
|
|
135
|
+
)
|
|
136
|
+
else
|
|
137
|
+
ValidationError.new(message, hint: hint, http_status: status, field_errors: field_errors)
|
|
138
|
+
end
|
|
127
139
|
when 401
|
|
128
|
-
AuthError.new(message)
|
|
140
|
+
AuthError.new(message, hint: hint)
|
|
129
141
|
when 403
|
|
130
|
-
ForbiddenError.new(message)
|
|
142
|
+
ForbiddenError.new(message, hint: hint)
|
|
131
143
|
when 404
|
|
132
|
-
NotFoundError.new(message: message)
|
|
144
|
+
NotFoundError.new(message: message, hint: hint)
|
|
133
145
|
when 429
|
|
134
|
-
RateLimitError.new(retry_after: retry_after)
|
|
146
|
+
RateLimitError.new(retry_after: retry_after, hint: hint)
|
|
135
147
|
when 507
|
|
136
148
|
# Decided before the 5xx arms: a 507 is an account limit, not a
|
|
137
149
|
# transient server failure, and no retry can satisfy it.
|
|
138
|
-
LimitExceededError.new(Security.truncate(message))
|
|
150
|
+
LimitExceededError.new(Security.truncate(message), hint: hint)
|
|
139
151
|
when 500
|
|
140
|
-
ApiError.new("Server error (500)", http_status: 500, retryable: true)
|
|
152
|
+
ApiError.new("Server error (500)", http_status: 500, retryable: true, hint: hint)
|
|
141
153
|
when 502, 503, 504
|
|
142
|
-
ApiError.new("Gateway error (#{status})", http_status: status, retryable: true)
|
|
154
|
+
ApiError.new("Gateway error (#{status})", http_status: status, retryable: true, hint: hint)
|
|
143
155
|
else
|
|
144
|
-
ApiError.from_status(status,
|
|
156
|
+
ApiError.from_status(status, server_message, hint: hint)
|
|
145
157
|
end
|
|
146
158
|
end
|
|
147
159
|
|
|
@@ -180,6 +192,46 @@ module Basecamp
|
|
|
180
192
|
nil
|
|
181
193
|
end
|
|
182
194
|
|
|
195
|
+
# Parses the SPEC section 6 step-3 hint from a response body: the
|
|
196
|
+
# "error_description" key, used only when its value is a non-empty String,
|
|
197
|
+
# truncated like the message.
|
|
198
|
+
# @param body [String, nil]
|
|
199
|
+
# @return [String, nil]
|
|
200
|
+
def self.parse_error_hint(body)
|
|
201
|
+
return nil if body.nil? || body.empty?
|
|
202
|
+
|
|
203
|
+
Security.check_body_size!(body, Security::MAX_ERROR_BODY_BYTES, "Error")
|
|
204
|
+
|
|
205
|
+
data = JSON.parse(body)
|
|
206
|
+
hint = data.is_a?(Hash) ? data["error_description"] : nil
|
|
207
|
+
hint.is_a?(String) && !hint.empty? ? Security.truncate(hint) : nil
|
|
208
|
+
rescue JSON::ParserError, ApiError
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Extracts the people whose destination-project access requires confirmation.
|
|
213
|
+
# @param body [String, nil]
|
|
214
|
+
# @return [Array<Basecamp::Types::TemplateLibraryConfirmationPerson>, nil]
|
|
215
|
+
def self.parse_template_library_confirmation_people(body)
|
|
216
|
+
return nil if body.nil? || body.empty?
|
|
217
|
+
|
|
218
|
+
Security.check_body_size!(body, Security::MAX_ERROR_BODY_BYTES, "Error")
|
|
219
|
+
data = JSON.parse(body)
|
|
220
|
+
people = data.is_a?(Hash) ? data["people"] : nil
|
|
221
|
+
return nil unless people.is_a?(Array) && !people.empty?
|
|
222
|
+
|
|
223
|
+
valid = people.all? do |person|
|
|
224
|
+
person.is_a?(Hash) && person["id"].is_a?(Integer) && person["id"].positive? &&
|
|
225
|
+
person["name"].is_a?(String) && !person["name"].empty? &&
|
|
226
|
+
person["avatar_url"].is_a?(String) && !person["avatar_url"].empty?
|
|
227
|
+
end
|
|
228
|
+
return nil unless valid
|
|
229
|
+
|
|
230
|
+
people.map { |person| Types::TemplateLibraryConfirmationPerson.new(person) }
|
|
231
|
+
rescue JSON::ParserError, ApiError
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
183
235
|
# Extracts the field-keyed validation errors map from a response body — the
|
|
184
236
|
# Rails RecordInvalid rendering {"errors" => {"field" => ["msg", ...]}}.
|
|
185
237
|
# Entries whose value is not an array are skipped, non-string elements are
|
|
@@ -193,7 +245,9 @@ module Basecamp
|
|
|
193
245
|
|
|
194
246
|
data = JSON.parse(body)
|
|
195
247
|
errors = data.is_a?(Hash) ? data["errors"] : nil
|
|
196
|
-
if errors.is_a?(
|
|
248
|
+
if errors.is_a?(Array)
|
|
249
|
+
parse_row_errors(errors)
|
|
250
|
+
elsif errors.is_a?(Hash)
|
|
197
251
|
field_errors = errors.each_with_object({}) do |(field, values), result|
|
|
198
252
|
next unless values.is_a?(Array)
|
|
199
253
|
|
|
@@ -208,6 +262,35 @@ module Basecamp
|
|
|
208
262
|
nil
|
|
209
263
|
end
|
|
210
264
|
|
|
265
|
+
# Extracts a row-keyed errors list — the batch-invite rendering
|
|
266
|
+
# {"errors" => [{"email_address" => "...", "messages" => ["..."]}, ...]}
|
|
267
|
+
# (SPEC section 6 step 1b), one element per rejected row. Rows are keyed by
|
|
268
|
+
# their email_address when it is a non-empty string, else by an integer
|
|
269
|
+
# index, else by their position in the list; a repeated key appends.
|
|
270
|
+
# All-or-nothing: one element without a usable messages array means this is
|
|
271
|
+
# some other list, and the slot stays absent.
|
|
272
|
+
# @param rows [Array] the parsed "errors" array
|
|
273
|
+
# @return [Hash{String => Array<String>}, nil]
|
|
274
|
+
def self.parse_row_errors(rows)
|
|
275
|
+
return nil if rows.empty?
|
|
276
|
+
|
|
277
|
+
rows.each_with_index.each_with_object({}) do |(row, position), result|
|
|
278
|
+
return nil unless row.is_a?(Hash) && row["messages"].is_a?(Array)
|
|
279
|
+
|
|
280
|
+
messages = row["messages"].select { |message| message.is_a?(String) && !message.empty? }
|
|
281
|
+
return nil if messages.empty?
|
|
282
|
+
|
|
283
|
+
key = if row["email_address"].is_a?(String) && !row["email_address"].empty?
|
|
284
|
+
row["email_address"]
|
|
285
|
+
elsif row["index"].is_a?(Integer)
|
|
286
|
+
row["index"].to_s
|
|
287
|
+
else
|
|
288
|
+
position.to_s
|
|
289
|
+
end
|
|
290
|
+
(result[key] ||= []).concat(messages)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
211
294
|
# Extracts an unwrapped field map — the `render json: @webhook.errors`
|
|
212
295
|
# rendering, where the whole body is {"field" => ["msg", ...]}. The gate is
|
|
213
296
|
# all-or-nothing by design (SPEC section 6 step 2): with no "errors" key to
|
|
@@ -73,7 +73,7 @@ class ServiceGenerator
|
|
|
73
73
|
},
|
|
74
74
|
'Automation' => {
|
|
75
75
|
'Tools' => %w[GetTool UpdateTool DeleteTool CreateTool EnableTool DisableTool RepositionTool],
|
|
76
|
-
'Recordings' => %w[ArchiveRecording UnarchiveRecording TrashRecording ListRecordings],
|
|
76
|
+
'Recordings' => %w[ArchiveRecording UnarchiveRecording TrashRecording ListRecordings SpotlightRecording UnspotlightRecording],
|
|
77
77
|
'Webhooks' => %w[ListWebhooks CreateWebhook GetWebhook UpdateWebhook DeleteWebhook],
|
|
78
78
|
'Events' => %w[ListEvents],
|
|
79
79
|
'Lineup' => %w[CreateLineupMarker UpdateLineupMarker DeleteLineupMarker],
|
|
@@ -81,6 +81,7 @@ class ServiceGenerator
|
|
|
81
81
|
'Templates' => %w[
|
|
82
82
|
ListTemplates CreateTemplate GetTemplate UpdateTemplate
|
|
83
83
|
DeleteTemplate CreateProjectFromTemplate GetProjectConstruction
|
|
84
|
+
GetTemplateLibrary CreateTemplateLibraryCopy GetTemplateLibraryCopy
|
|
84
85
|
],
|
|
85
86
|
'Checkins' => %w[
|
|
86
87
|
GetQuestionnaire ListQuestions CreateQuestion GetQuestion
|
|
@@ -139,6 +140,8 @@ class ServiceGenerator
|
|
|
139
140
|
|
|
140
141
|
# Method name overrides
|
|
141
142
|
METHOD_NAME_OVERRIDES = {
|
|
143
|
+
'SpotlightRecording' => 'spotlight',
|
|
144
|
+
'UnspotlightRecording' => 'unspotlight',
|
|
142
145
|
'GetMyProfile' => 'my_profile',
|
|
143
146
|
'GetTodolistOrGroup' => 'get',
|
|
144
147
|
# The plain `update` name belongs to the merge-safe composite; the raw
|
|
@@ -175,6 +178,9 @@ class ServiceGenerator
|
|
|
175
178
|
'Search' => 'search',
|
|
176
179
|
'CreateProjectFromTemplate' => 'create_project',
|
|
177
180
|
'GetProjectConstruction' => 'get_construction',
|
|
181
|
+
'GetTemplateLibrary' => 'get_library',
|
|
182
|
+
'CreateTemplateLibraryCopy' => 'create_library_copy',
|
|
183
|
+
'GetTemplateLibraryCopy' => 'get_library_copy',
|
|
178
184
|
'GetRecordingTimesheet' => 'for_recording',
|
|
179
185
|
'GetProjectTimesheet' => 'for_project',
|
|
180
186
|
'GetTimesheetReport' => 'report',
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: basecamp-sdk
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.17.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Basecamp
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -184,6 +184,7 @@ files:
|
|
|
184
184
|
- lib/basecamp/generated/services/base_service.rb
|
|
185
185
|
- lib/basecamp/generated/services/bookmarks_service.rb
|
|
186
186
|
- lib/basecamp/generated/services/boosts_service.rb
|
|
187
|
+
- lib/basecamp/generated/services/bubble_ups_service.rb
|
|
187
188
|
- lib/basecamp/generated/services/calendars_service.rb
|
|
188
189
|
- lib/basecamp/generated/services/campfires_service.rb
|
|
189
190
|
- lib/basecamp/generated/services/card_columns_service.rb
|
|
@@ -262,6 +263,7 @@ files:
|
|
|
262
263
|
- lib/basecamp/oauth_token_provider.rb
|
|
263
264
|
- lib/basecamp/operation_info.rb
|
|
264
265
|
- lib/basecamp/operation_result.rb
|
|
266
|
+
- lib/basecamp/people_confirmation_required_error.rb
|
|
265
267
|
- lib/basecamp/rate_limit_error.rb
|
|
266
268
|
- lib/basecamp/request_info.rb
|
|
267
269
|
- lib/basecamp/request_result.rb
|