basecamp-sdk 0.5.0 → 0.6.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 +126 -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 +12 -1
- 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 +0 -1
- data/lib/basecamp/generated/services/card_steps_service.rb +9 -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 +1 -1
- data/lib/basecamp/http.rb +22 -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 +3 -3
- metadata +26 -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: da0e2d03c80b5801b886da56a59535f99cb521bd8233a146720ee117f76954b8
|
|
4
|
+
data.tar.gz: 4a3ea1075d88cac04a7dcc796f3c21d235aaffbb9d036be088321746c435c012
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e5d5298402d8239d525cff7aeac7815254cb7a1a3a967ed6f29fe9fea581432f0c2e7b272edc52a866b457ec988afc11f3ff6dbb48977b5e2ac716af8bbdb46a
|
|
7
|
+
data.tar.gz: f37ba99c63d4fb9836fe9fc5e6e717e0166f97892069da141f2be746ac95c8e15629319fb1deb4c23cdd4a7ee30d1ac84a83bf183e19930c9d394e47995704e6
|
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
|
@@ -217,6 +217,92 @@ module Basecamp
|
|
|
217
217
|
@parent.http.paginate_wrapped(account_path(path), key: key, params: params)
|
|
218
218
|
end
|
|
219
219
|
|
|
220
|
+
# Downloads file content from any API-routable download URL.
|
|
221
|
+
#
|
|
222
|
+
# Handles the full download flow: URL rewriting to the configured API host,
|
|
223
|
+
# authenticated first hop (which typically 302s to a signed download URL),
|
|
224
|
+
# and unauthenticated second hop to fetch the actual file content.
|
|
225
|
+
#
|
|
226
|
+
# @param raw_url [String] absolute download URL (e.g., from bc-attachment elements)
|
|
227
|
+
# @return [DownloadResult] the download result with body, content_type, content_length, filename
|
|
228
|
+
# @raise [UsageError] if raw_url is empty or not absolute
|
|
229
|
+
# @raise [NetworkError] if a network error occurs
|
|
230
|
+
# @raise [ApiError] if the API or download returns an error
|
|
231
|
+
def download_url(raw_url)
|
|
232
|
+
# Validation
|
|
233
|
+
raise UsageError.new("download URL is required") if raw_url.nil? || raw_url.to_s.empty?
|
|
234
|
+
|
|
235
|
+
begin
|
|
236
|
+
parsed = URI.parse(raw_url)
|
|
237
|
+
rescue URI::InvalidURIError
|
|
238
|
+
raise UsageError.new("download URL must be an absolute URL")
|
|
239
|
+
end
|
|
240
|
+
raise UsageError.new("download URL must be an absolute URL") unless parsed.is_a?(URI::HTTP)
|
|
241
|
+
|
|
242
|
+
# Operation hooks
|
|
243
|
+
op = OperationInfo.new(
|
|
244
|
+
service: "Account", operation: "DownloadURL",
|
|
245
|
+
resource_type: "download", is_mutation: false
|
|
246
|
+
)
|
|
247
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
248
|
+
safe_hook { hooks.on_operation_start(op) }
|
|
249
|
+
|
|
250
|
+
begin
|
|
251
|
+
# URL rewriting: replace scheme+host with config.base_url origin, preserve path+query+fragment
|
|
252
|
+
base = URI.parse(config.base_url)
|
|
253
|
+
rewritten = parsed.dup
|
|
254
|
+
rewritten.scheme = base.scheme
|
|
255
|
+
rewritten.host = base.host
|
|
256
|
+
rewritten.port = base.port
|
|
257
|
+
rewritten_url = rewritten.to_s
|
|
258
|
+
|
|
259
|
+
# Hop 1: Authenticated API request (no retry, captures redirect)
|
|
260
|
+
response = http.get_no_retry(rewritten_url)
|
|
261
|
+
|
|
262
|
+
result = case response.status
|
|
263
|
+
when 301, 302, 303, 307, 308
|
|
264
|
+
# Redirect — extract Location, proceed to hop 2
|
|
265
|
+
location = response.headers["Location"] || response.headers["location"]
|
|
266
|
+
raise ApiError.new("redirect #{response.status} with no Location header") if location.nil? || location.empty?
|
|
267
|
+
|
|
268
|
+
# Resolve relative Location against the rewritten API URL
|
|
269
|
+
resolved_url = Security.resolve_url(rewritten_url, location)
|
|
270
|
+
|
|
271
|
+
# Hop 2: fetch from signed URL (no auth, no hooks)
|
|
272
|
+
signed_response = fetch_signed_download(resolved_url)
|
|
273
|
+
|
|
274
|
+
DownloadResult.new(
|
|
275
|
+
body: signed_response.body,
|
|
276
|
+
content_type: signed_response["Content-Type"] || "",
|
|
277
|
+
content_length: parse_content_length(signed_response["Content-Length"]),
|
|
278
|
+
filename: Basecamp.filename_from_url(raw_url)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
when 200..299
|
|
282
|
+
# Direct download — no second hop
|
|
283
|
+
DownloadResult.new(
|
|
284
|
+
body: response.body,
|
|
285
|
+
content_type: response.headers["Content-Type"] || response.headers["content-type"] || "",
|
|
286
|
+
content_length: parse_content_length(response.headers["Content-Length"] || response.headers["content-length"]),
|
|
287
|
+
filename: Basecamp.filename_from_url(raw_url)
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
else
|
|
291
|
+
# This shouldn't happen because Faraday's raise_error middleware
|
|
292
|
+
# handles 4xx/5xx, but handle it defensively
|
|
293
|
+
raise Basecamp.error_from_response(response.status, response.body)
|
|
294
|
+
end
|
|
295
|
+
rescue => e
|
|
296
|
+
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
|
|
297
|
+
safe_hook { hooks.on_operation_end(op, OperationResult.new(duration_ms: duration, error: e)) }
|
|
298
|
+
raise
|
|
299
|
+
else
|
|
300
|
+
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
|
|
301
|
+
safe_hook { hooks.on_operation_end(op, OperationResult.new(duration_ms: duration, error: nil)) }
|
|
302
|
+
result
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
220
306
|
# @!group Services
|
|
221
307
|
|
|
222
308
|
# @return [Services::ProjectsService]
|
|
@@ -359,6 +445,11 @@ module Basecamp
|
|
|
359
445
|
service(:lineup) { Services::LineupService.new(self) }
|
|
360
446
|
end
|
|
361
447
|
|
|
448
|
+
# @return [Services::AutomationService]
|
|
449
|
+
def automation
|
|
450
|
+
service(:automation) { Services::AutomationService.new(self) }
|
|
451
|
+
end
|
|
452
|
+
|
|
362
453
|
# @return [Services::MessageTypesService]
|
|
363
454
|
def message_types
|
|
364
455
|
service(:message_types) { Services::MessageTypesService.new(self) }
|
|
@@ -433,5 +524,40 @@ module Basecamp
|
|
|
433
524
|
@services[name] ||= yield
|
|
434
525
|
end
|
|
435
526
|
end
|
|
527
|
+
|
|
528
|
+
def fetch_signed_download(url)
|
|
529
|
+
uri = URI.parse(url)
|
|
530
|
+
http_client = Net::HTTP.new(uri.host, uri.port)
|
|
531
|
+
http_client.use_ssl = (uri.scheme == "https")
|
|
532
|
+
http_client.open_timeout = config.timeout
|
|
533
|
+
http_client.read_timeout = config.timeout
|
|
534
|
+
|
|
535
|
+
request = Net::HTTP::Get.new(uri)
|
|
536
|
+
|
|
537
|
+
begin
|
|
538
|
+
response = http_client.request(request)
|
|
539
|
+
rescue StandardError => e
|
|
540
|
+
raise NetworkError.new("Download failed: #{e.message}", cause: e)
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
544
|
+
raise ApiError.new("download failed with status #{response.code}", http_status: response.code.to_i)
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
response
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def safe_hook
|
|
551
|
+
yield
|
|
552
|
+
rescue => e
|
|
553
|
+
warn "Basecamp hook error: #{e.class}: #{e.message}"
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def parse_content_length(value)
|
|
557
|
+
return -1 if value.nil? || value.to_s.empty?
|
|
558
|
+
|
|
559
|
+
parsed = value.to_i
|
|
560
|
+
parsed >= 0 ? parsed : -1
|
|
561
|
+
end
|
|
436
562
|
end
|
|
437
563
|
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
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://basecamp.com/schemas/sdk-metadata.json",
|
|
3
3
|
"version": "1.0.0",
|
|
4
|
-
"generated": "2026-03-
|
|
4
|
+
"generated": "2026-03-16T01:24:22Z",
|
|
5
5
|
"operations": {
|
|
6
6
|
"CreateAttachment": {
|
|
7
7
|
"retry": {
|
|
@@ -243,6 +243,17 @@
|
|
|
243
243
|
"natural": true
|
|
244
244
|
}
|
|
245
245
|
},
|
|
246
|
+
"GetCardStep": {
|
|
247
|
+
"retry": {
|
|
248
|
+
"maxAttempts": 3,
|
|
249
|
+
"baseDelayMs": 1000,
|
|
250
|
+
"backoff": "exponential",
|
|
251
|
+
"retryOn": [
|
|
252
|
+
429,
|
|
253
|
+
503
|
|
254
|
+
]
|
|
255
|
+
}
|
|
256
|
+
},
|
|
246
257
|
"UpdateCardStep": {
|
|
247
258
|
"retry": {
|
|
248
259
|
"maxAttempts": 3,
|
|
@@ -38,7 +38,7 @@ module Basecamp
|
|
|
38
38
|
config = Oauth.discover(http.base_url)
|
|
39
39
|
# Use issuer as base for authorization.json
|
|
40
40
|
"#{config.issuer.chomp("/")}/authorization.json"
|
|
41
|
-
rescue Oauth::
|
|
41
|
+
rescue Oauth::OauthError
|
|
42
42
|
# Fall back to Launchpad
|
|
43
43
|
LAUNCHPAD_AUTHORIZATION_URL
|
|
44
44
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
module Services
|
|
5
|
+
# Service for Automation operations
|
|
6
|
+
#
|
|
7
|
+
# @generated from OpenAPI spec
|
|
8
|
+
class AutomationService < BaseService
|
|
9
|
+
|
|
10
|
+
# List all lineup markers for the account
|
|
11
|
+
# @return [Hash] response data
|
|
12
|
+
def list_lineup_markers()
|
|
13
|
+
with_operation(service: "automation", operation: "list_lineup_markers", is_mutation: false) do
|
|
14
|
+
http_get("/lineup/markers.json").json
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -31,6 +31,15 @@ module Basecamp
|
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Get a step by ID
|
|
35
|
+
# @param step_id [Integer] step id ID
|
|
36
|
+
# @return [Hash] response data
|
|
37
|
+
def get(step_id:)
|
|
38
|
+
with_operation(service: "cardsteps", operation: "get", is_mutation: false, resource_id: step_id) do
|
|
39
|
+
http_get("/card_tables/steps/#{step_id}").json
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
34
43
|
# Update an existing step
|
|
35
44
|
# @param step_id [Integer] step id ID
|
|
36
45
|
# @param title [String, nil] title
|
|
@@ -9,7 +9,7 @@ module Basecamp
|
|
|
9
9
|
|
|
10
10
|
# Search for content across the account
|
|
11
11
|
# @param q [String] q
|
|
12
|
-
# @param sort [String, nil] created_at
|
|
12
|
+
# @param sort [String, nil] best_match|created_at
|
|
13
13
|
# @return [Enumerator<Hash>] paginated results
|
|
14
14
|
def search(q:, sort: nil)
|
|
15
15
|
wrap_paginated(service: "search", operation: "search", is_mutation: false) do
|
|
@@ -9,10 +9,11 @@ module Basecamp
|
|
|
9
9
|
|
|
10
10
|
# Clone an existing tool to create a new one
|
|
11
11
|
# @param source_recording_id [Integer] source recording id
|
|
12
|
+
# @param title [String, nil] title
|
|
12
13
|
# @return [Hash] response data
|
|
13
|
-
def clone(source_recording_id:)
|
|
14
|
+
def clone(source_recording_id:, title: nil)
|
|
14
15
|
with_operation(service: "tools", operation: "clone", is_mutation: true) do
|
|
15
|
-
http_post("/dock/tools.json", body: compact_params(source_recording_id: source_recording_id)).json
|
|
16
|
+
http_post("/dock/tools.json", body: compact_params(source_recording_id: source_recording_id, title: title)).json
|
|
16
17
|
end
|
|
17
18
|
end
|
|
18
19
|
|