basecamp-sdk 0.10.0 → 0.12.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +106 -10
  3. data/lib/basecamp/client.rb +28 -8
  4. data/lib/basecamp/config.rb +4 -3
  5. data/lib/basecamp/generated/metadata.json +178 -17
  6. data/lib/basecamp/generated/services/account_service.rb +1 -1
  7. data/lib/basecamp/generated/services/automation_service.rb +1 -1
  8. data/lib/basecamp/generated/services/bookmarks_service.rb +49 -0
  9. data/lib/basecamp/generated/services/boosts_service.rb +3 -3
  10. data/lib/basecamp/generated/services/calendars_service.rb +30 -0
  11. data/lib/basecamp/generated/services/campfires_service.rb +7 -7
  12. data/lib/basecamp/generated/services/card_columns_service.rb +1 -1
  13. data/lib/basecamp/generated/services/card_steps_service.rb +1 -1
  14. data/lib/basecamp/generated/services/card_tables_service.rb +1 -1
  15. data/lib/basecamp/generated/services/cards_service.rb +2 -2
  16. data/lib/basecamp/generated/services/checkins_service.rb +8 -8
  17. data/lib/basecamp/generated/services/client_approvals_service.rb +2 -2
  18. data/lib/basecamp/generated/services/client_correspondences_service.rb +2 -2
  19. data/lib/basecamp/generated/services/client_replies_service.rb +2 -2
  20. data/lib/basecamp/generated/services/comments_service.rb +2 -2
  21. data/lib/basecamp/generated/services/documents_service.rb +2 -2
  22. data/lib/basecamp/generated/services/drafts_service.rb +21 -0
  23. data/lib/basecamp/generated/services/events_service.rb +1 -1
  24. data/lib/basecamp/generated/services/everything_service.rb +69 -46
  25. data/lib/basecamp/generated/services/forwards_service.rb +5 -5
  26. data/lib/basecamp/generated/services/gauges_service.rb +3 -3
  27. data/lib/basecamp/generated/services/hill_charts_service.rb +1 -1
  28. data/lib/basecamp/generated/services/message_boards_service.rb +1 -1
  29. data/lib/basecamp/generated/services/message_types_service.rb +2 -2
  30. data/lib/basecamp/generated/services/messages_service.rb +2 -2
  31. data/lib/basecamp/generated/services/my_assignments_service.rb +34 -3
  32. data/lib/basecamp/generated/services/my_notes_service.rb +28 -0
  33. data/lib/basecamp/generated/services/my_notifications_service.rb +2 -2
  34. data/lib/basecamp/generated/services/people_service.rb +8 -8
  35. data/lib/basecamp/generated/services/projects_service.rb +2 -2
  36. data/lib/basecamp/generated/services/recordings_service.rb +2 -2
  37. data/lib/basecamp/generated/services/reports_service.rb +5 -5
  38. data/lib/basecamp/generated/services/schedules_service.rb +4 -4
  39. data/lib/basecamp/generated/services/search_service.rb +2 -2
  40. data/lib/basecamp/generated/services/subscriptions_service.rb +1 -1
  41. data/lib/basecamp/generated/services/templates_service.rb +3 -3
  42. data/lib/basecamp/generated/services/timeline_service.rb +1 -1
  43. data/lib/basecamp/generated/services/timesheets_service.rb +4 -4
  44. data/lib/basecamp/generated/services/todolist_groups_service.rb +1 -1
  45. data/lib/basecamp/generated/services/todolists_service.rb +2 -2
  46. data/lib/basecamp/generated/services/todos_service.rb +19 -2
  47. data/lib/basecamp/generated/services/todosets_service.rb +1 -1
  48. data/lib/basecamp/generated/services/tools_service.rb +1 -1
  49. data/lib/basecamp/generated/services/uploads_service.rb +3 -3
  50. data/lib/basecamp/generated/services/vaults_service.rb +2 -2
  51. data/lib/basecamp/generated/services/webhooks_service.rb +2 -2
  52. data/lib/basecamp/generated/types.rb +313 -34
  53. data/lib/basecamp/http.rb +47 -19
  54. data/lib/basecamp/oauth/device_authorization.rb +37 -0
  55. data/lib/basecamp/oauth/device_flow.rb +873 -0
  56. data/lib/basecamp/oauth/device_flow_error.rb +46 -0
  57. data/lib/basecamp/oauth/discovery.rb +13 -11
  58. data/lib/basecamp/oauth/exchange.rb +44 -5
  59. data/lib/basecamp/oauth/fetcher.rb +626 -56
  60. data/lib/basecamp/oauth/oauth_error.rb +3 -1
  61. data/lib/basecamp/oauth/refresh_request.rb +8 -2
  62. data/lib/basecamp/oauth/resource.rb +13 -10
  63. data/lib/basecamp/oauth/token.rb +10 -3
  64. data/lib/basecamp/oauth.rb +47 -2
  65. data/lib/basecamp/version.rb +2 -2
  66. data/scripts/generate-services.rb +10 -7
  67. metadata +9 -2
@@ -4,7 +4,9 @@ module Basecamp
4
4
  module Oauth
5
5
  # OAuth-specific error class.
6
6
  #
7
- # @attr type [String] Error type ("validation", "auth", "network", "api_error")
7
+ # @attr type [String] Error type ("validation", "auth", "network",
8
+ # "api_error", or "usage" — the parent type {DeviceFlowError} derives for
9
+ # a cancelled device flow)
8
10
  # @attr http_status [Integer, nil] HTTP status code if applicable
9
11
  # @attr hint [String, nil] Helpful hint for resolving the error
10
12
  # @attr retryable [Boolean] Whether the request can be retried
@@ -9,19 +9,25 @@ module Basecamp
9
9
  # @attr client_id [String, nil] The client identifier (optional)
10
10
  # @attr client_secret [String, nil] The client secret (optional)
11
11
  # @attr use_legacy_format [Boolean] Use Launchpad's non-standard token format
12
+ # @attr resource [String, nil] RFC 8707 resource indicator to bind the
13
+ # refreshed token to, sent only when set. Echo the stored token's
14
+ # resource: BC5 multi-account refresh tokens hard-require it (SPEC §16).
15
+ # Appended last so earlier members keep their positions.
12
16
  RefreshRequest = Data.define(
13
17
  :token_endpoint,
14
18
  :refresh_token,
15
19
  :client_id,
16
20
  :client_secret,
17
- :use_legacy_format
21
+ :use_legacy_format,
22
+ :resource
18
23
  ) do
19
24
  def initialize(
20
25
  token_endpoint:,
21
26
  refresh_token:,
22
27
  client_id: nil,
23
28
  client_secret: nil,
24
- use_legacy_format: false
29
+ use_legacy_format: false,
30
+ resource: nil
25
31
  )
26
32
  super
27
33
  end
@@ -5,25 +5,28 @@ module Basecamp
5
5
  # Fetches RFC 9728 protected-resource metadata (hop 1 of resource-first
6
6
  # discovery) and binds the returned +resource+ to the requested origin.
7
7
  class Resource
8
- # @param http_client [Faraday::Connection, nil] HTTP client (SSRF-hardened default if nil)
9
- # @param timeout [Integer] request timeout in seconds (default: 10)
8
+ # @param http_client [Faraday::Connection, nil] injected Faraday client
9
+ # (kept, verified redirect-free); nil selects the default headers-first
10
+ # Net::HTTP transport ({Fetcher.stream_http})
11
+ # @param timeout [Numeric] request timeout in seconds (default: 10); an
12
+ # invalid value falls back via {Fetcher.normalize_timeout}
10
13
  # @param max_body_bytes [Integer] bounded read cap in bytes
11
14
  def initialize(http_client: nil, timeout: 10, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES)
12
15
  Fetcher.ensure_redirects_suppressed!(http_client) if http_client
13
- # Normalize before building the client and before the fetch computes its
16
+ # Normalize before storing (nil selects the headers-first Net::HTTP
17
+ # transport; an injected client is kept) and before the fetch computes its
14
18
  # wall-clock deadline: a non-finite/non-positive timeout must not disable
15
19
  # either bound (see Fetcher.normalize_timeout).
16
20
  @timeout = Fetcher.normalize_timeout(timeout)
17
- @http_client = http_client || Fetcher.build_client(@timeout)
21
+ # nil selects the headers-first {Fetcher.stream_http} transport (total
22
+ # wall-clock bound incl. the header phase); an injected connection keeps
23
+ # the Faraday path, verified redirect-free above.
24
+ @http_client = http_client
18
25
  # Normalize the public cap to a finite non-negative Integer: a nil, float,
19
26
  # or Float::INFINITY would otherwise disable the streaming memory bound
20
27
  # (an infinite/undefined cap never trips), reintroducing an SSRF/OOM risk.
21
- @max_body_bytes =
22
- if max_body_bytes.is_a?(Integer) && max_body_bytes >= 0
23
- max_body_bytes
24
- else
25
- Fetcher::DEFAULT_MAX_BODY_BYTES
26
- end
28
+ # Shared with Discovery and the device flow.
29
+ @max_body_bytes = Fetcher.normalize_body_cap(max_body_bytes)
27
30
  end
28
31
 
29
32
  # Discovers protected-resource metadata from
@@ -10,13 +10,18 @@ module Basecamp
10
10
  # @attr expires_in [Integer, nil] Lifetime of the access token in seconds
11
11
  # @attr expires_at [Time, nil] Calculated expiration time
12
12
  # @attr scope [String, nil] OAuth scope granted
13
+ # @attr resource [String, nil] RFC 8707 resource indicator the token is
14
+ # bound to (BC5: +urn:bc:account:<id>+). Echo it when refreshing —
15
+ # BC5 multi-account refresh tokens reject a refresh without it
16
+ # (SPEC §16). Appended last so earlier members keep their positions.
13
17
  Token = Data.define(
14
18
  :access_token,
15
19
  :token_type,
16
20
  :refresh_token,
17
21
  :expires_in,
18
22
  :expires_at,
19
- :scope
23
+ :scope,
24
+ :resource
20
25
  ) do
21
26
  def initialize(
22
27
  access_token:,
@@ -24,7 +29,8 @@ module Basecamp
24
29
  refresh_token: nil,
25
30
  expires_in: nil,
26
31
  expires_at: nil,
27
- scope: nil
32
+ scope: nil,
33
+ resource: nil
28
34
  )
29
35
  # Calculate expires_at from expires_in if not provided
30
36
  calculated_expires_at = expires_at || (expires_in ? Time.now + expires_in : nil)
@@ -34,7 +40,8 @@ module Basecamp
34
40
  refresh_token: refresh_token,
35
41
  expires_in: expires_in,
36
42
  expires_at: calculated_expires_at,
37
- scope: scope
43
+ scope: scope,
44
+ resource: resource
38
45
  )
39
46
  end
40
47
 
@@ -133,12 +133,12 @@ module Basecamp
133
133
  def self.refresh_token(
134
134
  token_endpoint:, refresh_token:,
135
135
  client_id: nil, client_secret: nil,
136
- use_legacy_format: false, timeout: 30
136
+ use_legacy_format: false, timeout: 30, resource: nil
137
137
  )
138
138
  request = RefreshRequest.new(
139
139
  token_endpoint: token_endpoint, refresh_token: refresh_token,
140
140
  client_id: client_id, client_secret: client_secret,
141
- use_legacy_format: use_legacy_format
141
+ use_legacy_format: use_legacy_format, resource: resource
142
142
  )
143
143
  Exchange.new(timeout: timeout).refresh(request)
144
144
  end
@@ -147,6 +147,51 @@ module Basecamp
147
147
  token.expired?(buffer_seconds)
148
148
  end
149
149
 
150
+ # Requests an RFC 8628 device/user code pair. Thin delegator to
151
+ # {DeviceFlow.request_device_authorization}.
152
+ #
153
+ # @return [DeviceAuthorization]
154
+ def self.request_device_authorization(device_authorization_endpoint:, client_id:, scope: nil, timeout: DeviceFlow::DEVICE_REQUEST_TIMEOUT)
155
+ DeviceFlow.request_device_authorization(
156
+ device_authorization_endpoint: device_authorization_endpoint,
157
+ client_id: client_id, scope: scope, timeout: timeout
158
+ )
159
+ end
160
+
161
+ # Polls the token endpoint for a device grant (RFC 8628 §3.5). Thin delegator
162
+ # to {DeviceFlow.poll_device_token}; +clock+, +sleeper+, and +cancelled+ are
163
+ # injectable for tests.
164
+ #
165
+ # @return [Token]
166
+ def self.poll_device_token(
167
+ token_endpoint:, client_id:, device_code:, interval:, expires_in:,
168
+ clock: DeviceFlow::DEFAULT_CLOCK, sleeper: DeviceFlow::DEFAULT_SLEEPER,
169
+ cancelled: DeviceFlow::DEFAULT_CANCELLED, timeout: DeviceFlow::DEVICE_REQUEST_TIMEOUT,
170
+ deadline_at: nil
171
+ )
172
+ DeviceFlow.poll_device_token(
173
+ token_endpoint: token_endpoint, client_id: client_id,
174
+ device_code: device_code, interval: interval, expires_in: expires_in,
175
+ clock: clock, sleeper: sleeper, cancelled: cancelled, timeout: timeout,
176
+ deadline_at: deadline_at
177
+ )
178
+ end
179
+
180
+ # Runs the full device authorization grant against an already-selected
181
+ # {Config} (RFC 8628). Thin delegator to {DeviceFlow.perform_device_login}.
182
+ #
183
+ # @return [Token]
184
+ def self.perform_device_login(
185
+ config:, client_id:, display:, scope: nil,
186
+ clock: DeviceFlow::DEFAULT_CLOCK, sleeper: DeviceFlow::DEFAULT_SLEEPER,
187
+ cancelled: DeviceFlow::DEFAULT_CANCELLED, timeout: DeviceFlow::DEVICE_REQUEST_TIMEOUT
188
+ )
189
+ DeviceFlow.perform_device_login(
190
+ config: config, client_id: client_id, display: display, scope: scope,
191
+ clock: clock, sleeper: sleeper, cancelled: cancelled, timeout: timeout
192
+ )
193
+ end
194
+
150
195
  # Selects an issuer from the advertised set and — once a BC5 issuer is
151
196
  # committed — binds its AS metadata. From this point on, every failure is
152
197
  # fatal: no Launchpad request may be issued.
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basecamp
4
- VERSION = "0.10.0"
5
- API_VERSION = "2026-07-28"
4
+ VERSION = "0.12.0"
5
+ API_VERSION = "2026-07-31"
6
6
  end
@@ -116,7 +116,7 @@ class ServiceGenerator
116
116
  'ClientVisibility' => %w[SetClientVisibility]
117
117
  },
118
118
  'Todos' => {
119
- 'Todos' => %w[ListTodos CreateTodo GetTodo ReplaceTodo CompleteTodo UncompleteTodo TrashTodo],
119
+ 'Todos' => %w[ListTodos CreateTodo CreateTodosetTodo GetTodo ReplaceTodo CompleteTodo UncompleteTodo TrashTodo],
120
120
  'Todolists' => %w[GetTodolistOrGroup UpdateTodolistOrGroup ListTodolists CreateTodolist RepositionTodolist],
121
121
  'Todosets' => %w[GetTodoset],
122
122
  'HillCharts' => %w[GetHillChart UpdateHillChartSettings],
@@ -818,9 +818,9 @@ class ServiceGenerator
818
818
  if op[:query_params].any?
819
819
  param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
820
820
  lines << " params = compact_query_params(#{param_names.join(', ')})"
821
- lines << " paginate(#{path_expr}, params: params)"
821
+ lines << " paginate(#{path_expr}, params: params, operation: \"#{op[:operation_id]}\")"
822
822
  else
823
- lines << " paginate(#{path_expr})"
823
+ lines << " paginate(#{path_expr}, operation: \"#{op[:operation_id]}\")"
824
824
  end
825
825
 
826
826
  lines
@@ -832,9 +832,9 @@ class ServiceGenerator
832
832
  if op[:query_params].any?
833
833
  param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
834
834
  lines << " params = compact_query_params(#{param_names.join(', ')})"
835
- lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\", params: params)"
835
+ lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\", params: params, operation: \"#{op[:operation_id]}\")"
836
836
  else
837
- lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\")"
837
+ lines << " paginate_wrapped(#{path_expr}, key: \"#{pagination_key}\", operation: \"#{op[:operation_id]}\")"
838
838
  end
839
839
 
840
840
  lines
@@ -843,6 +843,9 @@ class ServiceGenerator
843
843
  def generate_get_method_body(op, path_expr)
844
844
  lines = []
845
845
  http_method = op[:http_method].downcase
846
+ # Only GETs are retry-governed in Ruby, and only Http#get accepts the
847
+ # operation keyword — mutations go through single_request with no retry.
848
+ operation_arg = http_method == 'get' ? ", operation: \"#{op[:operation_id]}\"" : ''
846
849
 
847
850
  if op[:has_binary_body]
848
851
  # Binary upload - use raw body and set Content-Type header
@@ -863,9 +866,9 @@ class ServiceGenerator
863
866
  lines << " http_#{http_method}(#{path_expr}, body: #{body_expr}).json"
864
867
  elsif op[:query_params].any?
865
868
  param_names = op[:query_params].map { |q| "#{to_snake_case(q[:name])}: #{to_snake_case(q[:name])}" }
866
- lines << " http_#{http_method}(#{path_expr}, params: compact_query_params(#{param_names.join(', ')})).json"
869
+ lines << " http_#{http_method}(#{path_expr}, params: compact_query_params(#{param_names.join(', ')})#{operation_arg}).json"
867
870
  else
868
- lines << " http_#{http_method}(#{path_expr}).json"
871
+ lines << " http_#{http_method}(#{path_expr}#{operation_arg}).json"
869
872
  end
870
873
 
871
874
  lines
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.10.0
4
+ version: 0.12.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-07-29 00:00:00.000000000 Z
11
+ date: 2026-08-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -182,7 +182,9 @@ files:
182
182
  - lib/basecamp/generated/services/attachments_service.rb
183
183
  - lib/basecamp/generated/services/automation_service.rb
184
184
  - lib/basecamp/generated/services/base_service.rb
185
+ - lib/basecamp/generated/services/bookmarks_service.rb
185
186
  - lib/basecamp/generated/services/boosts_service.rb
187
+ - lib/basecamp/generated/services/calendars_service.rb
186
188
  - lib/basecamp/generated/services/campfires_service.rb
187
189
  - lib/basecamp/generated/services/card_columns_service.rb
188
190
  - lib/basecamp/generated/services/card_steps_service.rb
@@ -195,6 +197,7 @@ files:
195
197
  - lib/basecamp/generated/services/client_visibility_service.rb
196
198
  - lib/basecamp/generated/services/comments_service.rb
197
199
  - lib/basecamp/generated/services/documents_service.rb
200
+ - lib/basecamp/generated/services/drafts_service.rb
198
201
  - lib/basecamp/generated/services/events_service.rb
199
202
  - lib/basecamp/generated/services/everything_service.rb
200
203
  - lib/basecamp/generated/services/forwards_service.rb
@@ -205,6 +208,7 @@ files:
205
208
  - lib/basecamp/generated/services/message_types_service.rb
206
209
  - lib/basecamp/generated/services/messages_service.rb
207
210
  - lib/basecamp/generated/services/my_assignments_service.rb
211
+ - lib/basecamp/generated/services/my_notes_service.rb
208
212
  - lib/basecamp/generated/services/my_notifications_service.rb
209
213
  - lib/basecamp/generated/services/people_service.rb
210
214
  - lib/basecamp/generated/services/projects_service.rb
@@ -234,6 +238,9 @@ files:
234
238
  - lib/basecamp/not_found_error.rb
235
239
  - lib/basecamp/oauth.rb
236
240
  - lib/basecamp/oauth/config.rb
241
+ - lib/basecamp/oauth/device_authorization.rb
242
+ - lib/basecamp/oauth/device_flow.rb
243
+ - lib/basecamp/oauth/device_flow_error.rb
237
244
  - lib/basecamp/oauth/discovery.rb
238
245
  - lib/basecamp/oauth/discovery_result.rb
239
246
  - lib/basecamp/oauth/discovery_selection_error.rb