basecamp-sdk 0.9.0 → 0.11.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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +106 -10
  3. data/lib/basecamp/client.rb +13 -8
  4. data/lib/basecamp/config.rb +4 -3
  5. data/lib/basecamp/generated/metadata.json +280 -12
  6. data/lib/basecamp/generated/services/account_service.rb +1 -1
  7. data/lib/basecamp/generated/services/automation_service.rb +2 -2
  8. data/lib/basecamp/generated/services/boosts_service.rb +3 -3
  9. data/lib/basecamp/generated/services/campfires_service.rb +7 -7
  10. data/lib/basecamp/generated/services/card_columns_service.rb +1 -1
  11. data/lib/basecamp/generated/services/card_steps_service.rb +1 -1
  12. data/lib/basecamp/generated/services/card_tables_service.rb +1 -1
  13. data/lib/basecamp/generated/services/cards_service.rb +4 -4
  14. data/lib/basecamp/generated/services/checkins_service.rb +8 -8
  15. data/lib/basecamp/generated/services/client_approvals_service.rb +2 -2
  16. data/lib/basecamp/generated/services/client_correspondences_service.rb +2 -2
  17. data/lib/basecamp/generated/services/client_replies_service.rb +2 -2
  18. data/lib/basecamp/generated/services/comments_service.rb +2 -2
  19. data/lib/basecamp/generated/services/documents_service.rb +2 -2
  20. data/lib/basecamp/generated/services/events_service.rb +1 -1
  21. data/lib/basecamp/generated/services/everything_service.rb +169 -0
  22. data/lib/basecamp/generated/services/forwards_service.rb +5 -5
  23. data/lib/basecamp/generated/services/gauges_service.rb +3 -3
  24. data/lib/basecamp/generated/services/hill_charts_service.rb +1 -1
  25. data/lib/basecamp/generated/services/message_boards_service.rb +1 -1
  26. data/lib/basecamp/generated/services/message_types_service.rb +20 -15
  27. data/lib/basecamp/generated/services/messages_service.rb +2 -2
  28. data/lib/basecamp/generated/services/my_assignments_service.rb +5 -5
  29. data/lib/basecamp/generated/services/my_notifications_service.rb +16 -2
  30. data/lib/basecamp/generated/services/people_service.rb +9 -9
  31. data/lib/basecamp/generated/services/projects_service.rb +2 -2
  32. data/lib/basecamp/generated/services/recordings_service.rb +3 -3
  33. data/lib/basecamp/generated/services/reports_service.rb +5 -5
  34. data/lib/basecamp/generated/services/schedules_service.rb +13 -5
  35. data/lib/basecamp/generated/services/search_service.rb +2 -2
  36. data/lib/basecamp/generated/services/subscriptions_service.rb +1 -1
  37. data/lib/basecamp/generated/services/templates_service.rb +3 -3
  38. data/lib/basecamp/generated/services/timeline_service.rb +1 -1
  39. data/lib/basecamp/generated/services/timesheets_service.rb +5 -5
  40. data/lib/basecamp/generated/services/todolist_groups_service.rb +1 -1
  41. data/lib/basecamp/generated/services/todolists_service.rb +2 -2
  42. data/lib/basecamp/generated/services/todos_service.rb +2 -2
  43. data/lib/basecamp/generated/services/todosets_service.rb +1 -1
  44. data/lib/basecamp/generated/services/tools_service.rb +4 -3
  45. data/lib/basecamp/generated/services/uploads_service.rb +3 -3
  46. data/lib/basecamp/generated/services/vaults_service.rb +2 -2
  47. data/lib/basecamp/generated/services/webhooks_service.rb +2 -2
  48. data/lib/basecamp/generated/types.rb +374 -18
  49. data/lib/basecamp/http.rb +47 -18
  50. data/lib/basecamp/oauth/device_authorization.rb +37 -0
  51. data/lib/basecamp/oauth/device_flow.rb +873 -0
  52. data/lib/basecamp/oauth/device_flow_error.rb +46 -0
  53. data/lib/basecamp/oauth/discovery.rb +13 -11
  54. data/lib/basecamp/oauth/exchange.rb +44 -5
  55. data/lib/basecamp/oauth/fetcher.rb +626 -56
  56. data/lib/basecamp/oauth/oauth_error.rb +3 -1
  57. data/lib/basecamp/oauth/refresh_request.rb +8 -2
  58. data/lib/basecamp/oauth/resource.rb +13 -10
  59. data/lib/basecamp/oauth/token.rb +10 -3
  60. data/lib/basecamp/oauth.rb +47 -2
  61. data/lib/basecamp/services/cards_extensions.rb +67 -0
  62. data/lib/basecamp/version.rb +2 -2
  63. data/lib/basecamp.rb +5 -0
  64. data/scripts/generate-services.rb +24 -6
  65. data/scripts/generate-types.rb +29 -2
  66. metadata +7 -2
data/lib/basecamp/http.rb CHANGED
@@ -55,9 +55,12 @@ module Basecamp
55
55
  # Performs a GET request.
56
56
  # @param path [String] URL path
57
57
  # @param params [Hash] query parameters
58
+ # @param operation [String, nil] canonical operation ID; when given, the
59
+ # operation's declared retry block governs attempts (max as a ceiling on
60
+ # the configured cap) and retryable statuses (the declared retryOn set)
58
61
  # @return [Response]
59
- def get(path, params: {})
60
- request(:get, path, params: params)
62
+ def get(path, params: {}, operation: nil)
63
+ request(:get, path, params: params, operation: operation)
61
64
  end
62
65
 
63
66
  # Performs a GET request to an absolute URL.
@@ -171,8 +174,8 @@ module Basecamp
171
174
  # @param params [Hash] query parameters
172
175
  # @yield [Hash] each item from the response
173
176
  # @return [Enumerator] if no block given
174
- def paginate(path, params: {}, &block)
175
- return to_enum(:paginate, path, params: params) unless block
177
+ def paginate(path, params: {}, operation: nil, &block)
178
+ return to_enum(:paginate, path, params: params, operation: operation) unless block
176
179
 
177
180
  base_url = build_url(path)
178
181
  url = base_url
@@ -183,7 +186,7 @@ module Basecamp
183
186
  break if page > @config.max_pages
184
187
 
185
188
  @hooks.on_paginate(url, page)
186
- response = get(url, params: page == 1 ? params : {})
189
+ response = get(url, params: page == 1 ? params : {}, operation: operation)
187
190
 
188
191
  Security.check_body_size!(response.body, Security::MAX_RESPONSE_BODY_BYTES)
189
192
 
@@ -217,8 +220,8 @@ module Basecamp
217
220
  # @param params [Hash] query parameters
218
221
  # @yield [Hash] each item from the response
219
222
  # @return [Enumerator] if no block given
220
- def paginate_key(path, key:, params: {}, &block)
221
- return to_enum(:paginate_key, path, key: key, params: params) unless block
223
+ def paginate_key(path, key:, params: {}, operation: nil, &block)
224
+ return to_enum(:paginate_key, path, key: key, params: params, operation: operation) unless block
222
225
 
223
226
  base_url = build_url(path)
224
227
  url = base_url
@@ -229,7 +232,7 @@ module Basecamp
229
232
  break if page > @config.max_pages
230
233
 
231
234
  @hooks.on_paginate(url, page)
232
- response = get(url, params: page == 1 ? params : {})
235
+ response = get(url, params: page == 1 ? params : {}, operation: operation)
233
236
 
234
237
  Security.check_body_size!(response.body, Security::MAX_RESPONSE_BODY_BYTES)
235
238
 
@@ -266,11 +269,11 @@ module Basecamp
266
269
  # @param key [String] the key containing the array of paginated items
267
270
  # @param params [Hash] query parameters
268
271
  # @return [Hash] wrapper fields merged with key => Enumerator of all items
269
- def paginate_wrapped(path, key:, params: {})
272
+ def paginate_wrapped(path, key:, params: {}, operation: nil)
270
273
  base_url = build_url(path)
271
274
 
272
275
  @hooks.on_paginate(base_url, 1)
273
- first_response = get(base_url, params: params)
276
+ first_response = get(base_url, params: params, operation: operation)
274
277
  Security.check_body_size!(first_response.body, Security::MAX_RESPONSE_BODY_BYTES)
275
278
 
276
279
  begin
@@ -304,7 +307,7 @@ module Basecamp
304
307
  end
305
308
 
306
309
  @hooks.on_paginate(next_url, page)
307
- response = get(next_url)
310
+ response = get(next_url, operation: operation)
308
311
  Security.check_body_size!(response.body, Security::MAX_RESPONSE_BODY_BYTES)
309
312
 
310
313
  begin
@@ -340,34 +343,37 @@ module Basecamp
340
343
  end
341
344
  end
342
345
 
343
- def request(method, path, params: {}, body: nil, allow_cross_origin: false)
346
+ def request(method, path, params: {}, body: nil, allow_cross_origin: false, operation: nil)
344
347
  url = build_url(path, allow_cross_origin: allow_cross_origin)
345
348
 
346
349
  # Mutations don't retry on 429/5xx to avoid duplicating data
347
350
  if method == :get
348
- request_with_retry(method, url, params: params, allow_cross_origin: allow_cross_origin)
351
+ request_with_retry(method, url, params: params, allow_cross_origin: allow_cross_origin, operation: operation)
349
352
  else
350
353
  single_request(method, url, params: params, body: body, attempt: 1, allow_cross_origin: allow_cross_origin)
351
354
  end
352
355
  end
353
356
 
354
- def request_with_retry(method, url, params: {}, allow_cross_origin: false)
357
+ def request_with_retry(method, url, params: {}, allow_cross_origin: false, operation: nil)
358
+ op_retry = operation && Http.operation_retry(operation)
359
+ caller_cap = [ @config.max_retries, 1 ].max
360
+ max_attempts = op_retry ? [ caller_cap, op_retry.fetch("maxAttempts") ].min : @config.max_retries
355
361
  attempt = 0
356
362
  last_error = nil
357
363
 
358
364
  loop do
359
365
  attempt += 1
360
- break if attempt > @config.max_retries
366
+ break if attempt > max_attempts
361
367
 
362
368
  begin
363
369
  return single_request(method, url, params: params, body: nil, attempt: attempt, allow_cross_origin: allow_cross_origin)
364
370
  rescue Basecamp::RateLimitError, Basecamp::NetworkError, Basecamp::ApiError => e
365
- raise e unless e.retryable?
371
+ raise e unless retry_eligible?(e, op_retry)
366
372
 
367
373
  last_error = e
368
374
 
369
375
  # Don't sleep if this was the last attempt
370
- break if attempt >= @config.max_retries
376
+ break if attempt >= max_attempts
371
377
 
372
378
  delay = calculate_delay(attempt, e.retry_after)
373
379
 
@@ -377,7 +383,30 @@ module Basecamp
377
383
  end
378
384
  end
379
385
 
380
- raise last_error || Basecamp::ApiError.new("Request failed after #{@config.max_retries} retries")
386
+ noun = max_attempts == 1 ? "attempt" : "attempts"
387
+ raise last_error || Basecamp::ApiError.new("Request failed after #{max_attempts} #{noun}")
388
+ end
389
+
390
+ # For a governed request (operation given), a status-bearing error retries
391
+ # exactly when the operation's declared retryOn set says so — the error
392
+ # taxonomy's retryable flag neither widens the set (500 is retryable in
393
+ # errors.rb but not declared) nor vetoes it. Status-less errors (network
394
+ # failures) and all ungoverned traffic keep the taxonomy's judgment.
395
+ def retry_eligible?(error, op_retry)
396
+ if op_retry && error.http_status
397
+ op_retry.fetch("retryOn").include?(error.http_status)
398
+ else
399
+ error.retryable?
400
+ end
401
+ end
402
+
403
+ # Memoized per-operation retry metadata, keyed by canonical operation ID.
404
+ # Benign-race memoization: concurrent first loads compute identical values.
405
+ def self.operation_retry(operation)
406
+ @operation_metadata ||= JSON.parse(
407
+ File.read(File.join(__dir__, "generated", "metadata.json"))
408
+ ).fetch("operations").freeze
409
+ @operation_metadata.dig(operation, "retry")
381
410
  end
382
411
 
383
412
  def single_request(method, url, params:, body:, attempt:, retry_count: 0, allow_cross_origin: false)
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Oauth
5
+ # RFC 8628 device authorization response (the device/user code pair the user
6
+ # approves out of band).
7
+ #
8
+ # @attr device_code [String] The device verification code polled at the token
9
+ # endpoint.
10
+ # @attr user_code [String] The code the user enters at the verification URI.
11
+ # @attr verification_uri [String] Where the user goes to approve the request.
12
+ # @attr verification_uri_complete [String, nil] The verification URI with the
13
+ # user code pre-filled (optional; absent when the server omits it).
14
+ # @attr expires_in [Integer] Lifetime of the device/user codes in seconds.
15
+ # @attr interval [Integer] Minimum seconds to wait between token polls
16
+ # (defaults to 5 when the server omits it).
17
+ DeviceAuthorization = Data.define(
18
+ :device_code,
19
+ :user_code,
20
+ :verification_uri,
21
+ :verification_uri_complete,
22
+ :expires_in,
23
+ :interval
24
+ ) do
25
+ def initialize(
26
+ device_code:,
27
+ user_code:,
28
+ verification_uri:,
29
+ expires_in:,
30
+ interval:,
31
+ verification_uri_complete: nil
32
+ )
33
+ super
34
+ end
35
+ end
36
+ end
37
+ end