basecamp-sdk 0.5.0 → 0.7.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/basecamp/ambiguous_error.rb +23 -0
  4. data/lib/basecamp/api_error.rb +27 -0
  5. data/lib/basecamp/auth_error.rb +16 -0
  6. data/lib/basecamp/auth_strategy.rb +0 -18
  7. data/lib/basecamp/bearer_auth.rb +21 -0
  8. data/lib/basecamp/client.rb +162 -0
  9. data/lib/basecamp/download_result.rb +10 -0
  10. data/lib/basecamp/error.rb +86 -0
  11. data/lib/basecamp/error_code.rb +16 -0
  12. data/lib/basecamp/exit_code.rb +17 -0
  13. data/lib/basecamp/forbidden_error.rb +20 -0
  14. data/lib/basecamp/generated/metadata.json +316 -1
  15. data/lib/basecamp/generated/services/account_service.rb +49 -0
  16. data/lib/basecamp/{services → generated/services}/authorization_service.rb +1 -1
  17. data/lib/basecamp/generated/services/automation_service.rb +19 -0
  18. data/lib/basecamp/{services → generated/services}/base_service.rb +33 -2
  19. data/lib/basecamp/generated/services/campfires_service.rb +10 -4
  20. data/lib/basecamp/generated/services/card_steps_service.rb +9 -0
  21. data/lib/basecamp/generated/services/cards_service.rb +3 -2
  22. data/lib/basecamp/generated/services/client_approvals_service.rb +5 -2
  23. data/lib/basecamp/generated/services/client_correspondences_service.rb +5 -2
  24. data/lib/basecamp/generated/services/forwards_service.rb +5 -2
  25. data/lib/basecamp/generated/services/gauges_service.rb +83 -0
  26. data/lib/basecamp/generated/services/hill_charts_service.rb +31 -0
  27. data/lib/basecamp/generated/services/my_assignments_service.rb +37 -0
  28. data/lib/basecamp/generated/services/my_notifications_service.rb +30 -0
  29. data/lib/basecamp/generated/services/people_service.rb +63 -0
  30. data/lib/basecamp/generated/services/search_service.rb +1 -1
  31. data/lib/basecamp/generated/services/tools_service.rb +3 -2
  32. data/lib/basecamp/generated/types.rb +962 -110
  33. data/lib/basecamp/http.rb +33 -13
  34. data/lib/basecamp/network_error.rb +16 -0
  35. data/lib/basecamp/not_found_error.rb +20 -0
  36. data/lib/basecamp/oauth/config.rb +30 -0
  37. data/lib/basecamp/oauth/discovery.rb +9 -41
  38. data/lib/basecamp/oauth/exchange.rb +20 -114
  39. data/lib/basecamp/oauth/exchange_request.rb +36 -0
  40. data/lib/basecamp/oauth/{errors.rb → oauth_error.rb} +1 -1
  41. data/lib/basecamp/oauth/refresh_request.rb +30 -0
  42. data/lib/basecamp/oauth/token.rb +52 -0
  43. data/lib/basecamp/oauth.rb +40 -8
  44. data/lib/basecamp/operation_info.rb +0 -7
  45. data/lib/basecamp/operation_result.rb +10 -0
  46. data/lib/basecamp/rate_limit_error.rb +19 -0
  47. data/lib/basecamp/security.rb +1 -1
  48. data/lib/basecamp/usage_error.rb +10 -0
  49. data/lib/basecamp/validation_error.rb +15 -0
  50. data/lib/basecamp/version.rb +1 -1
  51. data/lib/basecamp/webhooks/rack_middleware.rb +0 -2
  52. data/lib/basecamp/webhooks/receiver.rb +0 -4
  53. data/lib/basecamp/webhooks/verification_error.rb +7 -0
  54. data/lib/basecamp.rb +62 -22
  55. data/scripts/generate-services.rb +42 -7
  56. metadata +31 -43
  57. data/lib/basecamp/errors.rb +0 -294
  58. data/lib/basecamp/oauth/types.rb +0 -133
  59. data/lib/basecamp/services/attachments_service.rb +0 -33
  60. data/lib/basecamp/services/campfires_service.rb +0 -141
  61. data/lib/basecamp/services/card_columns_service.rb +0 -106
  62. data/lib/basecamp/services/card_steps_service.rb +0 -86
  63. data/lib/basecamp/services/card_tables_service.rb +0 -23
  64. data/lib/basecamp/services/cards_service.rb +0 -93
  65. data/lib/basecamp/services/checkins_service.rb +0 -127
  66. data/lib/basecamp/services/client_approvals_service.rb +0 -33
  67. data/lib/basecamp/services/client_correspondences_service.rb +0 -33
  68. data/lib/basecamp/services/client_replies_service.rb +0 -35
  69. data/lib/basecamp/services/comments_service.rb +0 -63
  70. data/lib/basecamp/services/documents_service.rb +0 -74
  71. data/lib/basecamp/services/events_service.rb +0 -27
  72. data/lib/basecamp/services/forwards_service.rb +0 -80
  73. data/lib/basecamp/services/lineup_service.rb +0 -67
  74. data/lib/basecamp/services/message_boards_service.rb +0 -24
  75. data/lib/basecamp/services/message_types_service.rb +0 -79
  76. data/lib/basecamp/services/messages_service.rb +0 -133
  77. data/lib/basecamp/services/people_service.rb +0 -73
  78. data/lib/basecamp/services/projects_service.rb +0 -67
  79. data/lib/basecamp/services/recordings_service.rb +0 -127
  80. data/lib/basecamp/services/reports_service.rb +0 -80
  81. data/lib/basecamp/services/schedules_service.rb +0 -156
  82. data/lib/basecamp/services/search_service.rb +0 -36
  83. data/lib/basecamp/services/subscriptions_service.rb +0 -67
  84. data/lib/basecamp/services/templates_service.rb +0 -96
  85. data/lib/basecamp/services/timeline_service.rb +0 -62
  86. data/lib/basecamp/services/timesheet_service.rb +0 -68
  87. data/lib/basecamp/services/todolist_groups_service.rb +0 -100
  88. data/lib/basecamp/services/todolists_service.rb +0 -104
  89. data/lib/basecamp/services/todos_service.rb +0 -156
  90. data/lib/basecamp/services/todosets_service.rb +0 -23
  91. data/lib/basecamp/services/tools_service.rb +0 -89
  92. data/lib/basecamp/services/uploads_service.rb +0 -84
  93. data/lib/basecamp/services/vaults_service.rb +0 -84
  94. data/lib/basecamp/services/webhooks_service.rb +0 -88
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 39a8cc069aaa9ee04e3d91d46e8c06932aaac5947ccbb98da863ebca3a46eadd
4
- data.tar.gz: c5d16c70981ddb8a784636578a211f3ae2e58636b664fe8d2be008604e95fa61
3
+ metadata.gz: 67da09be280f8e08a9639f77ea7d08f70e5327609cac6a46812bf76069a09a18
4
+ data.tar.gz: 98f22496c4a83e17e076e2339848ee6c1738b4f5382889114acb053ea010ffe1
5
5
  SHA512:
6
- metadata.gz: 8cde71ee5f4b3957b62b281302e66ec5cf2eefd8a5ce91bcc658388933a2b3250b913ccb6081c1f6d9adb47c5e874f0341c5b2df1420d4e0550594d7ad5656e2
7
- data.tar.gz: 486db1ccd6dfb83d41277f5c742306e7f7bfd13d3987a6fef5184a2b154c1025c5c5b5f94826dbb188c5c71c02390e18fa66156d31761f6fc829dd20e9482bf3
6
+ metadata.gz: 27cae3068542c51e3a02a994deaf8b68d6a91e8ddae6d5ce1ac7b98123718e5aa884879ee5ee679b432b30074d5f1c00d62ca2bd714ca144a2d3b352b398c961
7
+ data.tar.gz: 8da2274e9fcaa49289e5e6c0587dd396f2f56012146fa490decef7fb195be3223cd34f23c1ba3e227a6538b9620473ac98dbf856aeabfa845de59ff0bc984d1a
data/README.md CHANGED
@@ -215,7 +215,7 @@ rescue Basecamp::RateLimitError => e
215
215
  puts "Rate limited, retry after: #{e.retry_after} seconds"
216
216
  rescue Basecamp::AuthError => e
217
217
  puts "Authentication failed: #{e.message}"
218
- rescue Basecamp::APIError => e
218
+ rescue Basecamp::ApiError => e
219
219
  puts "API error (#{e.http_status}): #{e.message}"
220
220
  end
221
221
  ```
@@ -224,7 +224,7 @@ end
224
224
 
225
225
  | Error | Description |
226
226
  |-------|-------------|
227
- | `APIError` | Base error class for all API errors |
227
+ | `ApiError` | Base error class for all API errors |
228
228
  | `AuthError` | Authentication failures (401) |
229
229
  | `ForbiddenError` | Access denied (403) |
230
230
  | `NotFoundError` | Resource not found (404) |
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Raised when a name/identifier matches multiple resources.
5
+ class AmbiguousError < Error
6
+ # @return [Array<String>] list of matching resources
7
+ attr_reader :matches
8
+
9
+ def initialize(resource, matches: [])
10
+ @matches = matches
11
+ hint = if matches.any? && matches.length <= 5
12
+ "Did you mean: #{matches.join(", ")}"
13
+ else
14
+ "Be more specific"
15
+ end
16
+ super(
17
+ code: ErrorCode::AMBIGUOUS,
18
+ message: "Ambiguous #{resource}",
19
+ hint: hint
20
+ )
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Raised for generic API errors.
5
+ class ApiError < Error
6
+ def initialize(message, http_status: nil, hint: nil, retryable: false, cause: nil)
7
+ super(
8
+ code: ErrorCode::API,
9
+ message: message,
10
+ hint: hint,
11
+ http_status: http_status,
12
+ retryable: retryable,
13
+ cause: cause
14
+ )
15
+ end
16
+
17
+ # Creates an ApiError from an HTTP status code.
18
+ # @param status [Integer] HTTP status code
19
+ # @param message [String, nil] optional error message
20
+ # @return [ApiError]
21
+ def self.from_status(status, message = nil)
22
+ message ||= "Request failed (HTTP #{status})"
23
+ retryable = status >= 500 && status < 600
24
+ new(message, http_status: status, retryable: retryable)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Raised when authentication fails (401).
5
+ class AuthError < Error
6
+ def initialize(message = "Authentication required", hint: nil, cause: nil)
7
+ super(
8
+ code: ErrorCode::AUTH,
9
+ message: message,
10
+ hint: hint || "Check your access token or refresh it if expired",
11
+ http_status: 401,
12
+ cause: cause
13
+ )
14
+ end
15
+ end
16
+ end
@@ -17,22 +17,4 @@ module Basecamp
17
17
  raise NotImplementedError, "#{self.class} must implement #authenticate"
18
18
  end
19
19
  end
20
-
21
- # Bearer token authentication strategy (default).
22
- # Sets the Authorization header with "Bearer {token}".
23
- class BearerAuth
24
- include AuthStrategy
25
-
26
- # @param token_provider [TokenProvider] provides access tokens
27
- def initialize(token_provider)
28
- @token_provider = token_provider
29
- end
30
-
31
- # @return [TokenProvider] the underlying token provider
32
- attr_reader :token_provider
33
-
34
- def authenticate(headers)
35
- headers["Authorization"] = "Bearer #{@token_provider.access_token}"
36
- end
37
- end
38
20
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Bearer token authentication strategy (default).
5
+ # Sets the Authorization header with "Bearer {token}".
6
+ class BearerAuth
7
+ include AuthStrategy
8
+
9
+ # @param token_provider [TokenProvider] provides access tokens
10
+ def initialize(token_provider)
11
+ @token_provider = token_provider
12
+ end
13
+
14
+ # @return [TokenProvider] the underlying token provider
15
+ attr_reader :token_provider
16
+
17
+ def authenticate(headers)
18
+ headers["Authorization"] = "Bearer #{@token_provider.access_token}"
19
+ end
20
+ end
21
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+
3
4
  module Basecamp
4
5
  # Main client for the Basecamp API.
5
6
  #
@@ -188,6 +189,16 @@ module Basecamp
188
189
  @parent.http.post_raw(account_path(path), body: body, content_type: content_type)
189
190
  end
190
191
 
192
+ # Performs a PUT request with raw binary data scoped to this account.
193
+ # Used for multipart uploads (e.g., account logo).
194
+ # @param path [String] URL path (without account prefix)
195
+ # @param body [String, IO] raw binary data
196
+ # @param content_type [String] MIME content type
197
+ # @return [Response]
198
+ def put_raw(path, body:, content_type:)
199
+ @parent.http.put_raw(account_path(path), body: body, content_type: content_type)
200
+ end
201
+
191
202
  # Fetches all pages of a paginated resource.
192
203
  # @param path [String] URL path (without account prefix)
193
204
  # @param params [Hash] query parameters
@@ -217,6 +228,92 @@ module Basecamp
217
228
  @parent.http.paginate_wrapped(account_path(path), key: key, params: params)
218
229
  end
219
230
 
231
+ # Downloads file content from any API-routable download URL.
232
+ #
233
+ # Handles the full download flow: URL rewriting to the configured API host,
234
+ # authenticated first hop (which typically 302s to a signed download URL),
235
+ # and unauthenticated second hop to fetch the actual file content.
236
+ #
237
+ # @param raw_url [String] absolute download URL (e.g., from bc-attachment elements)
238
+ # @return [DownloadResult] the download result with body, content_type, content_length, filename
239
+ # @raise [UsageError] if raw_url is empty or not absolute
240
+ # @raise [NetworkError] if a network error occurs
241
+ # @raise [ApiError] if the API or download returns an error
242
+ def download_url(raw_url)
243
+ # Validation
244
+ raise UsageError.new("download URL is required") if raw_url.nil? || raw_url.to_s.empty?
245
+
246
+ begin
247
+ parsed = URI.parse(raw_url)
248
+ rescue URI::InvalidURIError
249
+ raise UsageError.new("download URL must be an absolute URL")
250
+ end
251
+ raise UsageError.new("download URL must be an absolute URL") unless parsed.is_a?(URI::HTTP)
252
+
253
+ # Operation hooks
254
+ op = OperationInfo.new(
255
+ service: "Account", operation: "DownloadURL",
256
+ resource_type: "download", is_mutation: false
257
+ )
258
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
259
+ safe_hook { hooks.on_operation_start(op) }
260
+
261
+ begin
262
+ # URL rewriting: replace scheme+host with config.base_url origin, preserve path+query+fragment
263
+ base = URI.parse(config.base_url)
264
+ rewritten = parsed.dup
265
+ rewritten.scheme = base.scheme
266
+ rewritten.host = base.host
267
+ rewritten.port = base.port
268
+ rewritten_url = rewritten.to_s
269
+
270
+ # Hop 1: Authenticated API request (no retry, captures redirect)
271
+ response = http.get_no_retry(rewritten_url)
272
+
273
+ result = case response.status
274
+ when 301, 302, 303, 307, 308
275
+ # Redirect — extract Location, proceed to hop 2
276
+ location = response.headers["Location"] || response.headers["location"]
277
+ raise ApiError.new("redirect #{response.status} with no Location header") if location.nil? || location.empty?
278
+
279
+ # Resolve relative Location against the rewritten API URL
280
+ resolved_url = Security.resolve_url(rewritten_url, location)
281
+
282
+ # Hop 2: fetch from signed URL (no auth, no hooks)
283
+ signed_response = fetch_signed_download(resolved_url)
284
+
285
+ DownloadResult.new(
286
+ body: signed_response.body,
287
+ content_type: signed_response["Content-Type"] || "",
288
+ content_length: parse_content_length(signed_response["Content-Length"]),
289
+ filename: Basecamp.filename_from_url(raw_url)
290
+ )
291
+
292
+ when 200..299
293
+ # Direct download — no second hop
294
+ DownloadResult.new(
295
+ body: response.body,
296
+ content_type: response.headers["Content-Type"] || response.headers["content-type"] || "",
297
+ content_length: parse_content_length(response.headers["Content-Length"] || response.headers["content-length"]),
298
+ filename: Basecamp.filename_from_url(raw_url)
299
+ )
300
+
301
+ else
302
+ # This shouldn't happen because Faraday's raise_error middleware
303
+ # handles 4xx/5xx, but handle it defensively
304
+ raise Basecamp.error_from_response(response.status, response.body)
305
+ end
306
+ rescue => e
307
+ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
308
+ safe_hook { hooks.on_operation_end(op, OperationResult.new(duration_ms: duration, error: e)) }
309
+ raise
310
+ else
311
+ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
312
+ safe_hook { hooks.on_operation_end(op, OperationResult.new(duration_ms: duration, error: nil)) }
313
+ result
314
+ end
315
+ end
316
+
220
317
  # @!group Services
221
318
 
222
319
  # @return [Services::ProjectsService]
@@ -234,6 +331,11 @@ module Basecamp
234
331
  service(:todosets) { Services::TodosetsService.new(self) }
235
332
  end
236
333
 
334
+ # @return [Services::HillChartsService]
335
+ def hill_charts
336
+ service(:hill_charts) { Services::HillChartsService.new(self) }
337
+ end
338
+
237
339
  # @return [Services::TodolistsService]
238
340
  def todolists
239
341
  service(:todolists) { Services::TodolistsService.new(self) }
@@ -359,6 +461,11 @@ module Basecamp
359
461
  service(:lineup) { Services::LineupService.new(self) }
360
462
  end
361
463
 
464
+ # @return [Services::AutomationService]
465
+ def automation
466
+ service(:automation) { Services::AutomationService.new(self) }
467
+ end
468
+
362
469
  # @return [Services::MessageTypesService]
363
470
  def message_types
364
471
  service(:message_types) { Services::MessageTypesService.new(self) }
@@ -409,6 +516,26 @@ module Basecamp
409
516
  service(:boosts) { Services::BoostsService.new(self) }
410
517
  end
411
518
 
519
+ # @return [Services::AccountService]
520
+ def account
521
+ service(:account) { Services::AccountService.new(self) }
522
+ end
523
+
524
+ # @return [Services::GaugesService]
525
+ def gauges
526
+ service(:gauges) { Services::GaugesService.new(self) }
527
+ end
528
+
529
+ # @return [Services::MyAssignmentsService]
530
+ def my_assignments
531
+ service(:my_assignments) { Services::MyAssignmentsService.new(self) }
532
+ end
533
+
534
+ # @return [Services::MyNotificationsService]
535
+ def my_notifications
536
+ service(:my_notifications) { Services::MyNotificationsService.new(self) }
537
+ end
538
+
412
539
  # @!endgroup
413
540
 
414
541
  private
@@ -433,5 +560,40 @@ module Basecamp
433
560
  @services[name] ||= yield
434
561
  end
435
562
  end
563
+
564
+ def fetch_signed_download(url)
565
+ uri = URI.parse(url)
566
+ http_client = Net::HTTP.new(uri.host, uri.port)
567
+ http_client.use_ssl = (uri.scheme == "https")
568
+ http_client.open_timeout = config.timeout
569
+ http_client.read_timeout = config.timeout
570
+
571
+ request = Net::HTTP::Get.new(uri)
572
+
573
+ begin
574
+ response = http_client.request(request)
575
+ rescue StandardError => e
576
+ raise NetworkError.new("Download failed: #{e.message}", cause: e)
577
+ end
578
+
579
+ unless response.is_a?(Net::HTTPSuccess)
580
+ raise ApiError.new("download failed with status #{response.code}", http_status: response.code.to_i)
581
+ end
582
+
583
+ response
584
+ end
585
+
586
+ def safe_hook
587
+ yield
588
+ rescue => e
589
+ warn "Basecamp hook error: #{e.class}: #{e.message}"
590
+ end
591
+
592
+ def parse_content_length(value)
593
+ return -1 if value.nil? || value.to_s.empty?
594
+
595
+ parsed = value.to_i
596
+ parsed >= 0 ? parsed : -1
597
+ end
436
598
  end
437
599
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Result of downloading file content from a URL.
5
+ DownloadResult = Data.define(:body, :content_type, :content_length, :filename) do
6
+ def initialize(body:, content_type: "", content_length: -1, filename: "download")
7
+ super
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Base error class for all Basecamp SDK errors.
5
+ # Provides structured error handling with codes, hints, and CLI exit codes.
6
+ #
7
+ # @example Catching errors
8
+ # begin
9
+ # client.projects.list
10
+ # rescue Basecamp::Error => e
11
+ # puts "#{e.code}: #{e.message}"
12
+ # puts "Hint: #{e.hint}" if e.hint
13
+ # exit e.exit_code
14
+ # end
15
+ class Error < StandardError
16
+ # @return [String] error category code
17
+ attr_reader :code
18
+
19
+ # @return [String, nil] user-friendly hint for resolving the error
20
+ attr_reader :hint
21
+
22
+ # @return [Integer, nil] HTTP status code that caused the error
23
+ attr_reader :http_status
24
+
25
+ # @return [Boolean] whether the operation can be retried
26
+ attr_reader :retryable
27
+
28
+ # @return [Integer, nil] seconds to wait before retrying (for rate limits)
29
+ attr_reader :retry_after
30
+
31
+ # @return [String, nil] X-Request-Id from the response
32
+ attr_reader :request_id
33
+
34
+ # @return [Exception, nil] original error that caused this error
35
+ attr_reader :cause
36
+
37
+ # @param code [String] error category code
38
+ # @param message [String] error message
39
+ # @param hint [String, nil] user-friendly hint
40
+ # @param http_status [Integer, nil] HTTP status code
41
+ # @param retryable [Boolean] whether operation can be retried
42
+ # @param retry_after [Integer, nil] seconds to wait before retry
43
+ # @param request_id [String, nil] X-Request-Id from response
44
+ # @param cause [Exception, nil] underlying cause
45
+ def initialize(code:, message:, hint: nil, http_status: nil, retryable: false, retry_after: nil, request_id: nil, cause: nil)
46
+ super(message)
47
+ @code = code
48
+ @hint = hint
49
+ @http_status = http_status
50
+ @retryable = retryable
51
+ @retry_after = retry_after
52
+ @request_id = request_id
53
+ @cause = cause
54
+ end
55
+
56
+ # Returns the exit code for CLI applications.
57
+ # @return [Integer]
58
+ def exit_code
59
+ self.class.exit_code_for(@code)
60
+ end
61
+
62
+ # Returns whether this error can be retried.
63
+ # @return [Boolean]
64
+ def retryable?
65
+ @retryable
66
+ end
67
+
68
+ # Maps error codes to exit codes.
69
+ # @param code [String]
70
+ # @return [Integer]
71
+ def self.exit_code_for(code)
72
+ case code
73
+ when ErrorCode::USAGE then ExitCode::USAGE
74
+ when ErrorCode::NOT_FOUND then ExitCode::NOT_FOUND
75
+ when ErrorCode::AUTH then ExitCode::AUTH
76
+ when ErrorCode::FORBIDDEN then ExitCode::FORBIDDEN
77
+ when ErrorCode::RATE_LIMIT then ExitCode::RATE_LIMIT
78
+ when ErrorCode::NETWORK then ExitCode::NETWORK
79
+ when ErrorCode::API then ExitCode::API
80
+ when ErrorCode::AMBIGUOUS then ExitCode::AMBIGUOUS
81
+ when ErrorCode::VALIDATION then ExitCode::VALIDATION
82
+ else ExitCode::API
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Error codes for API responses
5
+ module ErrorCode
6
+ USAGE = "usage"
7
+ NOT_FOUND = "not_found"
8
+ AUTH = "auth_required"
9
+ FORBIDDEN = "forbidden"
10
+ RATE_LIMIT = "rate_limit"
11
+ NETWORK = "network"
12
+ API = "api_error"
13
+ AMBIGUOUS = "ambiguous"
14
+ VALIDATION = "validation"
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Exit codes for CLI tools
5
+ module ExitCode
6
+ OK = 0
7
+ USAGE = 1
8
+ NOT_FOUND = 2
9
+ AUTH = 3
10
+ FORBIDDEN = 4
11
+ RATE_LIMIT = 5
12
+ NETWORK = 6
13
+ API = 7
14
+ AMBIGUOUS = 8
15
+ VALIDATION = 9
16
+ end
17
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Raised when access is denied (403).
5
+ class ForbiddenError < Error
6
+ def initialize(message = "Access denied", hint: nil)
7
+ super(
8
+ code: ErrorCode::FORBIDDEN,
9
+ message: message,
10
+ hint: hint || "You do not have permission to access this resource",
11
+ http_status: 403
12
+ )
13
+ end
14
+
15
+ # Creates a forbidden error due to insufficient OAuth scope.
16
+ def self.insufficient_scope
17
+ new("Access denied: insufficient scope", hint: "Re-authenticate with full scope")
18
+ end
19
+ end
20
+ end