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
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Oauth
5
+ # A terminal RFC 8628 device-flow outcome. Carries a +reason+; the parent
6
+ # {OauthError} +type+ is DERIVED from that reason (SPEC.md §16) so callers can
7
+ # branch on either the precise +reason+ or the coarse +type+.
8
+ #
9
+ # | reason | parent type |
10
+ # |------------------|----------------------|
11
+ # | +:access_denied+ | +auth+ |
12
+ # | +:expired+ | +auth+ |
13
+ # | +:transport+ | +network+ (retryable)|
14
+ # | +:unavailable+ | +validation+ |
15
+ # | +:cancelled+ | +usage+ |
16
+ #
17
+ # @attr reason [Symbol] the device-flow termination reason
18
+ class DeviceFlowError < OauthError
19
+ # Maps a device-flow reason to its parent {OauthError} type.
20
+ REASON_TYPES = {
21
+ access_denied: "auth",
22
+ expired: "auth",
23
+ transport: "network",
24
+ unavailable: "validation",
25
+ cancelled: "usage"
26
+ }.freeze
27
+
28
+ attr_reader :reason
29
+
30
+ # @param reason [Symbol] the device-flow termination reason
31
+ # @param message [String] human-readable description
32
+ # @param http_status [Integer, nil] HTTP status code, if applicable
33
+ # @param hint [String, nil] helpful hint for resolving the error
34
+ def initialize(reason, message, http_status: nil, hint: nil)
35
+ super(
36
+ REASON_TYPES.fetch(reason, "api_error"),
37
+ message,
38
+ http_status: http_status,
39
+ hint: hint,
40
+ retryable: reason == :transport
41
+ )
42
+ @reason = reason
43
+ end
44
+ end
45
+ end
46
+ end
@@ -18,26 +18,28 @@ module Basecamp
18
18
  end
19
19
  end
20
20
 
21
- # @param http_client [Faraday::Connection, nil] HTTP client (SSRF-hardened default if nil)
22
- # @param timeout [Integer] request timeout in seconds (default: 10)
21
+ # @param http_client [Faraday::Connection, nil] injected Faraday client
22
+ # (kept, verified redirect-free); nil selects the default headers-first
23
+ # Net::HTTP transport ({Fetcher.stream_http})
24
+ # @param timeout [Numeric] request timeout in seconds (default: 10); an
25
+ # invalid value falls back via {Fetcher.normalize_timeout}
23
26
  # @param max_body_bytes [Integer] bounded read cap in bytes
24
27
  def initialize(http_client: nil, timeout: 10, max_body_bytes: Fetcher::DEFAULT_MAX_BODY_BYTES)
25
28
  Fetcher.ensure_redirects_suppressed!(http_client) if http_client
26
- # Normalize before building the client and before the fetch computes its
29
+ # Normalize before storing (nil selects the headers-first Net::HTTP
30
+ # transport; an injected client is kept) and before the fetch computes its
27
31
  # wall-clock deadline: a non-finite/non-positive timeout must not disable
28
32
  # either bound (see Fetcher.normalize_timeout).
29
33
  @timeout = Fetcher.normalize_timeout(timeout)
30
- @http_client = http_client || Fetcher.build_client(@timeout)
34
+ # nil selects the headers-first {Fetcher.stream_http} transport (total
35
+ # wall-clock bound incl. the header phase); an injected connection keeps
36
+ # the Faraday path, verified redirect-free above.
37
+ @http_client = http_client
31
38
  # Normalize the public cap to a finite non-negative Integer: a nil, float,
32
39
  # or Float::INFINITY would otherwise disable the streaming memory bound
33
40
  # (an infinite/undefined cap never trips +total > max_body_bytes+),
34
- # reintroducing an SSRF/OOM risk. Mirrors Resource#initialize.
35
- @max_body_bytes =
36
- if max_body_bytes.is_a?(Integer) && max_body_bytes >= 0
37
- max_body_bytes
38
- else
39
- Fetcher::DEFAULT_MAX_BODY_BYTES
40
- end
41
+ # reintroducing an SSRF/OOM risk. Shared with Resource and the device flow.
42
+ @max_body_bytes = Fetcher.normalize_body_cap(max_body_bytes)
41
43
  end
42
44
 
43
45
  # Discovers OAuth configuration from
@@ -134,6 +134,10 @@ module Basecamp
134
134
  params["refresh_token"] = request.refresh_token
135
135
  params["client_id"] = request.client_id if request.client_id
136
136
  params["client_secret"] = request.client_secret if request.client_secret
137
+ # An empty string is truthy in Ruby but an empty resource is not a
138
+ # binding — treat it as unset (omit) per the send-only-when-set
139
+ # contract, matching Go/TS/Kotlin.
140
+ params["resource"] = request.resource unless request.resource.to_s.empty?
137
141
 
138
142
  params
139
143
  end
@@ -161,21 +165,56 @@ module Basecamp
161
165
 
162
166
  handle_error_response(response.status, data) unless response.success?
163
167
 
164
- raise OauthError.new("api_error", "Token response missing access_token") unless data["access_token"]
168
+ unless data["access_token"].is_a?(String) && !data["access_token"].empty?
169
+ raise OauthError.new(
170
+ "api_error", "Token response missing or non-string access_token",
171
+ http_status: response.status
172
+ )
173
+ end
174
+
175
+ # resource: absent and JSON null are unset; when present it must be a
176
+ # non-empty string (SPEC §16) — an empty binding is not a binding.
177
+ resource = data["resource"]
178
+ unless resource.nil? || (resource.is_a?(String) && !resource.empty?)
179
+ raise OauthError.new(
180
+ "api_error",
181
+ "Token response resource must be a non-empty string when present",
182
+ http_status: response.status
183
+ )
184
+ end
185
+
186
+ # token_type: absent/JSON-null defaults to Bearer; present must be a
187
+ # non-empty String ("" is truthy in Ruby, so || alone would admit it) —
188
+ # matching the device-flow parser and SPEC §16.
189
+ token_type = data["token_type"]
190
+ unless token_type.nil? || (token_type.is_a?(String) && !token_type.empty?)
191
+ raise OauthError.new(
192
+ "api_error",
193
+ "Token response token_type must be a non-empty string when present",
194
+ http_status: response.status
195
+ )
196
+ end
165
197
 
166
198
  Token.new(
167
199
  access_token: data["access_token"],
168
200
  refresh_token: data["refresh_token"],
169
- token_type: data["token_type"] || "Bearer",
201
+ token_type: token_type || "Bearer",
170
202
  expires_in: data["expires_in"],
171
- scope: data["scope"]
203
+ scope: data["scope"],
204
+ resource: resource
172
205
  )
173
206
  rescue JSON::ParserError
207
+ # A token response that fails to parse may still contain credential
208
+ # material (a syntactically-broken body carrying an access_token) —
209
+ # never echo ANY of it into an error message, where it would reach
210
+ # logs and exception telemetry. The status is diagnosis enough.
211
+ # cause: nil — the parser error's message embeds the offending input,
212
+ # so the implicit cause chain would leak it via full_message.
174
213
  raise OauthError.new(
175
214
  "api_error",
176
- "Failed to parse token response: #{Basecamp::Security.truncate(response.body)}",
215
+ "Failed to parse token response",
177
216
  http_status: response.status
178
- )
217
+ ), cause: nil
179
218
  end
180
219
 
181
220
  def handle_error_response(status, data)