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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/basecamp/ambiguous_error.rb +23 -0
- data/lib/basecamp/api_error.rb +27 -0
- data/lib/basecamp/auth_error.rb +16 -0
- data/lib/basecamp/auth_strategy.rb +0 -18
- data/lib/basecamp/bearer_auth.rb +21 -0
- data/lib/basecamp/client.rb +162 -0
- data/lib/basecamp/download_result.rb +10 -0
- data/lib/basecamp/error.rb +86 -0
- data/lib/basecamp/error_code.rb +16 -0
- data/lib/basecamp/exit_code.rb +17 -0
- data/lib/basecamp/forbidden_error.rb +20 -0
- data/lib/basecamp/generated/metadata.json +316 -1
- data/lib/basecamp/generated/services/account_service.rb +49 -0
- data/lib/basecamp/{services → generated/services}/authorization_service.rb +1 -1
- data/lib/basecamp/generated/services/automation_service.rb +19 -0
- data/lib/basecamp/{services → generated/services}/base_service.rb +33 -2
- data/lib/basecamp/generated/services/campfires_service.rb +10 -4
- data/lib/basecamp/generated/services/card_steps_service.rb +9 -0
- data/lib/basecamp/generated/services/cards_service.rb +3 -2
- data/lib/basecamp/generated/services/client_approvals_service.rb +5 -2
- data/lib/basecamp/generated/services/client_correspondences_service.rb +5 -2
- data/lib/basecamp/generated/services/forwards_service.rb +5 -2
- data/lib/basecamp/generated/services/gauges_service.rb +83 -0
- data/lib/basecamp/generated/services/hill_charts_service.rb +31 -0
- data/lib/basecamp/generated/services/my_assignments_service.rb +37 -0
- data/lib/basecamp/generated/services/my_notifications_service.rb +30 -0
- data/lib/basecamp/generated/services/people_service.rb +63 -0
- data/lib/basecamp/generated/services/search_service.rb +1 -1
- data/lib/basecamp/generated/services/tools_service.rb +3 -2
- data/lib/basecamp/generated/types.rb +962 -110
- data/lib/basecamp/http.rb +33 -13
- data/lib/basecamp/network_error.rb +16 -0
- data/lib/basecamp/not_found_error.rb +20 -0
- data/lib/basecamp/oauth/config.rb +30 -0
- data/lib/basecamp/oauth/discovery.rb +9 -41
- data/lib/basecamp/oauth/exchange.rb +20 -114
- data/lib/basecamp/oauth/exchange_request.rb +36 -0
- data/lib/basecamp/oauth/{errors.rb → oauth_error.rb} +1 -1
- data/lib/basecamp/oauth/refresh_request.rb +30 -0
- data/lib/basecamp/oauth/token.rb +52 -0
- data/lib/basecamp/oauth.rb +40 -8
- data/lib/basecamp/operation_info.rb +0 -7
- data/lib/basecamp/operation_result.rb +10 -0
- data/lib/basecamp/rate_limit_error.rb +19 -0
- data/lib/basecamp/security.rb +1 -1
- data/lib/basecamp/usage_error.rb +10 -0
- data/lib/basecamp/validation_error.rb +15 -0
- data/lib/basecamp/version.rb +1 -1
- data/lib/basecamp/webhooks/rack_middleware.rb +0 -2
- data/lib/basecamp/webhooks/receiver.rb +0 -4
- data/lib/basecamp/webhooks/verification_error.rb +7 -0
- data/lib/basecamp.rb +62 -22
- data/scripts/generate-services.rb +42 -7
- metadata +31 -43
- data/lib/basecamp/errors.rb +0 -294
- data/lib/basecamp/oauth/types.rb +0 -133
- data/lib/basecamp/services/attachments_service.rb +0 -33
- data/lib/basecamp/services/campfires_service.rb +0 -141
- data/lib/basecamp/services/card_columns_service.rb +0 -106
- data/lib/basecamp/services/card_steps_service.rb +0 -86
- data/lib/basecamp/services/card_tables_service.rb +0 -23
- data/lib/basecamp/services/cards_service.rb +0 -93
- data/lib/basecamp/services/checkins_service.rb +0 -127
- data/lib/basecamp/services/client_approvals_service.rb +0 -33
- data/lib/basecamp/services/client_correspondences_service.rb +0 -33
- data/lib/basecamp/services/client_replies_service.rb +0 -35
- data/lib/basecamp/services/comments_service.rb +0 -63
- data/lib/basecamp/services/documents_service.rb +0 -74
- data/lib/basecamp/services/events_service.rb +0 -27
- data/lib/basecamp/services/forwards_service.rb +0 -80
- data/lib/basecamp/services/lineup_service.rb +0 -67
- data/lib/basecamp/services/message_boards_service.rb +0 -24
- data/lib/basecamp/services/message_types_service.rb +0 -79
- data/lib/basecamp/services/messages_service.rb +0 -133
- data/lib/basecamp/services/people_service.rb +0 -73
- data/lib/basecamp/services/projects_service.rb +0 -67
- data/lib/basecamp/services/recordings_service.rb +0 -127
- data/lib/basecamp/services/reports_service.rb +0 -80
- data/lib/basecamp/services/schedules_service.rb +0 -156
- data/lib/basecamp/services/search_service.rb +0 -36
- data/lib/basecamp/services/subscriptions_service.rb +0 -67
- data/lib/basecamp/services/templates_service.rb +0 -96
- data/lib/basecamp/services/timeline_service.rb +0 -62
- data/lib/basecamp/services/timesheet_service.rb +0 -68
- data/lib/basecamp/services/todolist_groups_service.rb +0 -100
- data/lib/basecamp/services/todolists_service.rb +0 -104
- data/lib/basecamp/services/todos_service.rb +0 -156
- data/lib/basecamp/services/todosets_service.rb +0 -23
- data/lib/basecamp/services/tools_service.rb +0 -89
- data/lib/basecamp/services/uploads_service.rb +0 -84
- data/lib/basecamp/services/vaults_service.rb +0 -84
- data/lib/basecamp/services/webhooks_service.rb +0 -88
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 67da09be280f8e08a9639f77ea7d08f70e5327609cac6a46812bf76069a09a18
|
|
4
|
+
data.tar.gz: 98f22496c4a83e17e076e2339848ee6c1738b4f5382889114acb053ea010ffe1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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::
|
|
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
|
-
| `
|
|
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
|
data/lib/basecamp/client.rb
CHANGED
|
@@ -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
|