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.
@@ -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
- raise AuthError.new("Token refresh failed: #{response.status}") unless response.success?
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(response.body)
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
- hint = retry_after ? "Try again in #{retry_after} seconds" : "Please slow down requests"
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",
@@ -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)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basecamp
4
- VERSION = "0.15.0"
5
- API_VERSION = "2026-08-11"
4
+ VERSION = "0.17.0"
5
+ API_VERSION = "2026-09-02"
6
6
  end
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
- message = parse_error_message(body) || "Request failed"
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(parse_error_message(body), field_errors) || "Request failed")
126
- ValidationError.new(message, http_status: status, field_errors: field_errors)
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, message)
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?(Hash)
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.15.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-08-22 00:00:00.000000000 Z
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