basecamp-sdk 0.4.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.
Files changed (84) 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 +126 -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 +12 -1
  15. data/lib/basecamp/{services → generated/services}/authorization_service.rb +1 -1
  16. data/lib/basecamp/generated/services/automation_service.rb +19 -0
  17. data/lib/basecamp/{services → generated/services}/base_service.rb +0 -1
  18. data/lib/basecamp/generated/services/card_steps_service.rb +9 -0
  19. data/lib/basecamp/generated/services/messages_service.rb +5 -2
  20. data/lib/basecamp/generated/services/search_service.rb +1 -1
  21. data/lib/basecamp/generated/services/tools_service.rb +3 -2
  22. data/lib/basecamp/generated/types.rb +3 -3
  23. data/lib/basecamp/http.rb +22 -13
  24. data/lib/basecamp/network_error.rb +16 -0
  25. data/lib/basecamp/not_found_error.rb +20 -0
  26. data/lib/basecamp/oauth/config.rb +30 -0
  27. data/lib/basecamp/oauth/discovery.rb +9 -41
  28. data/lib/basecamp/oauth/exchange.rb +20 -114
  29. data/lib/basecamp/oauth/exchange_request.rb +36 -0
  30. data/lib/basecamp/oauth/{errors.rb → oauth_error.rb} +1 -1
  31. data/lib/basecamp/oauth/refresh_request.rb +30 -0
  32. data/lib/basecamp/oauth/token.rb +52 -0
  33. data/lib/basecamp/oauth.rb +40 -8
  34. data/lib/basecamp/operation_info.rb +0 -7
  35. data/lib/basecamp/operation_result.rb +10 -0
  36. data/lib/basecamp/rate_limit_error.rb +19 -0
  37. data/lib/basecamp/security.rb +1 -1
  38. data/lib/basecamp/usage_error.rb +10 -0
  39. data/lib/basecamp/validation_error.rb +15 -0
  40. data/lib/basecamp/version.rb +1 -1
  41. data/lib/basecamp/webhooks/rack_middleware.rb +0 -2
  42. data/lib/basecamp/webhooks/receiver.rb +0 -4
  43. data/lib/basecamp/webhooks/verification_error.rb +7 -0
  44. data/lib/basecamp.rb +62 -22
  45. data/scripts/generate-services.rb +3 -3
  46. metadata +26 -43
  47. data/lib/basecamp/errors.rb +0 -294
  48. data/lib/basecamp/oauth/types.rb +0 -133
  49. data/lib/basecamp/services/attachments_service.rb +0 -33
  50. data/lib/basecamp/services/campfires_service.rb +0 -141
  51. data/lib/basecamp/services/card_columns_service.rb +0 -106
  52. data/lib/basecamp/services/card_steps_service.rb +0 -86
  53. data/lib/basecamp/services/card_tables_service.rb +0 -23
  54. data/lib/basecamp/services/cards_service.rb +0 -93
  55. data/lib/basecamp/services/checkins_service.rb +0 -127
  56. data/lib/basecamp/services/client_approvals_service.rb +0 -33
  57. data/lib/basecamp/services/client_correspondences_service.rb +0 -33
  58. data/lib/basecamp/services/client_replies_service.rb +0 -35
  59. data/lib/basecamp/services/comments_service.rb +0 -63
  60. data/lib/basecamp/services/documents_service.rb +0 -74
  61. data/lib/basecamp/services/events_service.rb +0 -27
  62. data/lib/basecamp/services/forwards_service.rb +0 -80
  63. data/lib/basecamp/services/lineup_service.rb +0 -67
  64. data/lib/basecamp/services/message_boards_service.rb +0 -24
  65. data/lib/basecamp/services/message_types_service.rb +0 -79
  66. data/lib/basecamp/services/messages_service.rb +0 -133
  67. data/lib/basecamp/services/people_service.rb +0 -73
  68. data/lib/basecamp/services/projects_service.rb +0 -67
  69. data/lib/basecamp/services/recordings_service.rb +0 -127
  70. data/lib/basecamp/services/reports_service.rb +0 -80
  71. data/lib/basecamp/services/schedules_service.rb +0 -156
  72. data/lib/basecamp/services/search_service.rb +0 -36
  73. data/lib/basecamp/services/subscriptions_service.rb +0 -67
  74. data/lib/basecamp/services/templates_service.rb +0 -96
  75. data/lib/basecamp/services/timeline_service.rb +0 -62
  76. data/lib/basecamp/services/timesheet_service.rb +0 -68
  77. data/lib/basecamp/services/todolist_groups_service.rb +0 -100
  78. data/lib/basecamp/services/todolists_service.rb +0 -104
  79. data/lib/basecamp/services/todos_service.rb +0 -156
  80. data/lib/basecamp/services/todosets_service.rb +0 -23
  81. data/lib/basecamp/services/tools_service.rb +0 -89
  82. data/lib/basecamp/services/uploads_service.rb +0 -84
  83. data/lib/basecamp/services/vaults_service.rb +0 -84
  84. data/lib/basecamp/services/webhooks_service.rb +0 -88
@@ -1,10 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "oauth/errors"
4
- require_relative "oauth/types"
5
- require_relative "oauth/discovery"
6
- require_relative "oauth/exchange"
7
-
8
3
  module Basecamp
9
4
  # OAuth 2 module for Basecamp SDK.
10
5
  #
@@ -49,8 +44,45 @@ module Basecamp
49
44
  #
50
45
  # @see https://github.com/basecamp/api/blob/master/sections/authentication.md
51
46
  module Oauth
52
- # Re-export constants
53
- # @return [String] Default Launchpad base URL
54
- # LAUNCHPAD_BASE_URL is defined in discovery.rb
47
+ LAUNCHPAD_BASE_URL = "https://launchpad.37signals.com"
48
+
49
+ def self.discover(base_url, timeout: 10)
50
+ Discovery.new(timeout: timeout).discover(base_url)
51
+ end
52
+
53
+ def self.discover_launchpad(timeout: 10)
54
+ discover(LAUNCHPAD_BASE_URL, timeout: timeout)
55
+ end
56
+
57
+ def self.exchange_code(
58
+ token_endpoint:, code:, redirect_uri:, client_id:,
59
+ client_secret: nil, code_verifier: nil,
60
+ use_legacy_format: false, timeout: 30
61
+ )
62
+ request = ExchangeRequest.new(
63
+ token_endpoint: token_endpoint, code: code,
64
+ redirect_uri: redirect_uri, client_id: client_id,
65
+ client_secret: client_secret, code_verifier: code_verifier,
66
+ use_legacy_format: use_legacy_format
67
+ )
68
+ Exchange.new(timeout: timeout).exchange(request)
69
+ end
70
+
71
+ def self.refresh_token(
72
+ token_endpoint:, refresh_token:,
73
+ client_id: nil, client_secret: nil,
74
+ use_legacy_format: false, timeout: 30
75
+ )
76
+ request = RefreshRequest.new(
77
+ token_endpoint: token_endpoint, refresh_token: refresh_token,
78
+ client_id: client_id, client_secret: client_secret,
79
+ use_legacy_format: use_legacy_format
80
+ )
81
+ Exchange.new(timeout: timeout).refresh(request)
82
+ end
83
+
84
+ def self.token_expired?(token, buffer_seconds = 60)
85
+ token.expired?(buffer_seconds)
86
+ end
55
87
  end
56
88
  end
@@ -7,11 +7,4 @@ module Basecamp
7
7
  super
8
8
  end
9
9
  end
10
-
11
- # Result information for completed service operations.
12
- OperationResult = Data.define(:duration_ms, :error) do
13
- def initialize(duration_ms: 0, error: nil)
14
- super
15
- end
16
- end
17
10
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Result information for completed service operations.
5
+ OperationResult = Data.define(:duration_ms, :error) do
6
+ def initialize(duration_ms: 0, error: nil)
7
+ super
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Raised when rate limited (429).
5
+ class RateLimitError < Error
6
+ def initialize(retry_after: nil, cause: nil)
7
+ hint = retry_after ? "Try again in #{retry_after} seconds" : "Please slow down requests"
8
+ super(
9
+ code: ErrorCode::RATE_LIMIT,
10
+ message: "Rate limit exceeded",
11
+ hint: hint,
12
+ http_status: 429,
13
+ retryable: true,
14
+ retry_after: retry_after,
15
+ cause: cause
16
+ )
17
+ end
18
+ end
19
+ end
@@ -54,7 +54,7 @@ module Basecamp
54
54
  return if body.nil?
55
55
 
56
56
  if body.bytesize > max
57
- raise Basecamp::APIError.new(
57
+ raise Basecamp::ApiError.new(
58
58
  "#{label} body too large (#{body.bytesize} bytes, max #{max})"
59
59
  )
60
60
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Raised when there's a usage error (invalid arguments, missing config).
5
+ class UsageError < Error
6
+ def initialize(message, hint: nil)
7
+ super(code: ErrorCode::USAGE, message: message, hint: hint)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Raised for validation errors (400, 422).
5
+ class ValidationError < Error
6
+ def initialize(message, hint: nil, http_status: 400)
7
+ super(
8
+ code: ErrorCode::VALIDATION,
9
+ message: message,
10
+ hint: hint,
11
+ http_status: http_status
12
+ )
13
+ end
14
+ end
15
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basecamp
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  API_VERSION = "2026-01-26"
6
6
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "receiver"
4
-
5
3
  module Basecamp
6
4
  module Webhooks
7
5
  # Rack middleware that intercepts POST requests to a configurable path
@@ -1,13 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require_relative "event"
5
- require_relative "verify"
6
4
 
7
5
  module Basecamp
8
6
  module Webhooks
9
- class VerificationError < StandardError; end
10
-
11
7
  # Receives and routes webhook events from Basecamp.
12
8
  # Framework-agnostic: works with raw body strings and a header accessor.
13
9
  class Receiver
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Webhooks
5
+ class VerificationError < StandardError; end
6
+ end
7
+ end
data/lib/basecamp.rb CHANGED
@@ -2,32 +2,10 @@
2
2
 
3
3
  require "zeitwerk"
4
4
 
5
- # Set up Zeitwerk loader
6
5
  loader = Zeitwerk::Loader.for_gem
7
- # No custom inflections - use standard Ruby camelcase (Http, Oauth, etc.)
8
-
9
- # Ignore hand-written services - we use generated services instead (spec-conformant)
10
- # EXCEPT: base_service.rb (infrastructure) and authorization_service.rb (OAuth, not in spec)
11
- loader.ignore("#{__dir__}/basecamp/services")
12
-
13
- # Collapse the generated directory so Basecamp::Generated::Services becomes Basecamp::Services
14
6
  loader.collapse("#{__dir__}/basecamp/generated")
15
-
16
- # Ignore errors.rb - it defines multiple classes, loaded explicitly below
17
- loader.ignore("#{__dir__}/basecamp/errors.rb")
18
- # Ignore auth_strategy.rb - defines both AuthStrategy and BearerAuth
19
- loader.ignore("#{__dir__}/basecamp/auth_strategy.rb")
20
- # Ignore operation_info.rb - defines both OperationInfo and OperationResult
21
- loader.ignore("#{__dir__}/basecamp/operation_info.rb")
22
7
  loader.setup
23
8
 
24
- # Load infrastructure that generated services depend on
25
- require_relative "basecamp/errors"
26
- require_relative "basecamp/auth_strategy"
27
- require_relative "basecamp/operation_info"
28
- require_relative "basecamp/services/base_service"
29
- require_relative "basecamp/services/authorization_service"
30
-
31
9
  # Load generated types if available
32
10
  begin
33
11
  require_relative "basecamp/generated/types"
@@ -104,4 +82,66 @@ module Basecamp
104
82
 
105
83
  account_id ? client.for_account(account_id) : client
106
84
  end
85
+
86
+ # Maps an HTTP response to the appropriate error class.
87
+ #
88
+ # @param status [Integer] HTTP status code
89
+ # @param body [String, nil] response body (will attempt JSON parse)
90
+ # @param retry_after [Integer, nil] Retry-After header value
91
+ # @return [Error]
92
+ def self.error_from_response(status, body = nil, retry_after: nil)
93
+ message = parse_error_message(body) || "Request failed"
94
+
95
+ case status
96
+ when 400, 422
97
+ ValidationError.new(message, http_status: status)
98
+ when 401
99
+ AuthError.new(message)
100
+ when 403
101
+ ForbiddenError.new(message)
102
+ when 404
103
+ NotFoundError.new(message: message)
104
+ when 429
105
+ RateLimitError.new(retry_after: retry_after)
106
+ when 500
107
+ ApiError.new("Server error (500)", http_status: 500, retryable: true)
108
+ when 502, 503, 504
109
+ ApiError.new("Gateway error (#{status})", http_status: status, retryable: true)
110
+ else
111
+ ApiError.from_status(status, message)
112
+ end
113
+ end
114
+
115
+ # Extracts a filename from the last path segment of a URL.
116
+ # Falls back to "download" if the URL is unparseable or has no path segments.
117
+ def self.filename_from_url(raw_url)
118
+ uri = URI.parse(raw_url)
119
+ path = uri.path
120
+ return "download" if path.nil? || path.empty? || path == "/" || path.end_with?("/")
121
+
122
+ segments = path.split("/").reject(&:empty?)
123
+ return "download" if segments.empty?
124
+
125
+ last = segments.last
126
+ return "download" if last.nil? || last.empty? || last == "." || last == "/"
127
+
128
+ URI::RFC2396_PARSER.unescape(last)
129
+ rescue URI::InvalidURIError
130
+ "download"
131
+ end
132
+
133
+ # Parses error message from response body.
134
+ # @param body [String, nil]
135
+ # @return [String, nil]
136
+ def self.parse_error_message(body)
137
+ return nil if body.nil? || body.empty?
138
+
139
+ Security.check_body_size!(body, Security::MAX_ERROR_BODY_BYTES, "Error")
140
+
141
+ data = JSON.parse(body)
142
+ msg = data["error"] || data["message"]
143
+ msg ? Security.truncate(msg) : nil
144
+ rescue JSON::ParserError, ApiError
145
+ nil
146
+ end
107
147
  end
@@ -57,7 +57,7 @@ class ServiceGenerator
57
57
  CreateCardColumn MoveCardColumn
58
58
  ],
59
59
  'CardSteps' => %w[
60
- CreateCardStep UpdateCardStep SetCardStepCompletion
60
+ GetCardStep CreateCardStep UpdateCardStep SetCardStepCompletion
61
61
  RepositionCardStep
62
62
  ]
63
63
  },
@@ -559,7 +559,7 @@ class ServiceGenerator
559
559
  ruby_name = to_snake_case(b[:name])
560
560
  type = b[:type] || 'Object'
561
561
  type = "#{type}, nil" unless b[:required]
562
- desc = b[:description] || ruby_name.gsub('_', ' ')
562
+ desc = (b[:description] || ruby_name.gsub('_', ' ')).gsub("\n", "\n # ")
563
563
  format_hint = b[:format_hint] ? " (#{b[:format_hint]})" : ''
564
564
  lines << " # @param #{ruby_name} [#{type}] #{desc}#{format_hint}"
565
565
  end
@@ -570,7 +570,7 @@ class ServiceGenerator
570
570
  ruby_name = to_snake_case(q[:name])
571
571
  type = q[:type] || 'String'
572
572
  type = "#{type}, nil" unless q[:required]
573
- desc = q[:description] || ruby_name.gsub('_', ' ')
573
+ desc = (q[:description] || ruby_name.gsub('_', ' ')).gsub("\n", "\n # ")
574
574
  lines << " # @param #{ruby_name} [#{type}] #{desc}"
575
575
  end
576
576
 
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.4.0
4
+ version: 0.6.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-03-11 00:00:00.000000000 Z
11
+ date: 2026-03-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -178,13 +178,24 @@ files:
178
178
  - Rakefile
179
179
  - basecamp-sdk.gemspec
180
180
  - lib/basecamp.rb
181
+ - lib/basecamp/ambiguous_error.rb
182
+ - lib/basecamp/api_error.rb
183
+ - lib/basecamp/auth_error.rb
181
184
  - lib/basecamp/auth_strategy.rb
185
+ - lib/basecamp/bearer_auth.rb
182
186
  - lib/basecamp/chain_hooks.rb
183
187
  - lib/basecamp/client.rb
184
188
  - lib/basecamp/config.rb
185
- - lib/basecamp/errors.rb
189
+ - lib/basecamp/download_result.rb
190
+ - lib/basecamp/error.rb
191
+ - lib/basecamp/error_code.rb
192
+ - lib/basecamp/exit_code.rb
193
+ - lib/basecamp/forbidden_error.rb
186
194
  - lib/basecamp/generated/metadata.json
187
195
  - lib/basecamp/generated/services/attachments_service.rb
196
+ - lib/basecamp/generated/services/authorization_service.rb
197
+ - lib/basecamp/generated/services/automation_service.rb
198
+ - lib/basecamp/generated/services/base_service.rb
188
199
  - lib/basecamp/generated/services/boosts_service.rb
189
200
  - lib/basecamp/generated/services/campfires_service.rb
190
201
  - lib/basecamp/generated/services/card_columns_service.rb
@@ -226,62 +237,34 @@ files:
226
237
  - lib/basecamp/hooks.rb
227
238
  - lib/basecamp/http.rb
228
239
  - lib/basecamp/logger_hooks.rb
240
+ - lib/basecamp/network_error.rb
229
241
  - lib/basecamp/noop_hooks.rb
242
+ - lib/basecamp/not_found_error.rb
230
243
  - lib/basecamp/oauth.rb
244
+ - lib/basecamp/oauth/config.rb
231
245
  - lib/basecamp/oauth/discovery.rb
232
- - lib/basecamp/oauth/errors.rb
233
246
  - lib/basecamp/oauth/exchange.rb
247
+ - lib/basecamp/oauth/exchange_request.rb
248
+ - lib/basecamp/oauth/oauth_error.rb
234
249
  - lib/basecamp/oauth/pkce.rb
235
- - lib/basecamp/oauth/types.rb
250
+ - lib/basecamp/oauth/refresh_request.rb
251
+ - lib/basecamp/oauth/token.rb
236
252
  - lib/basecamp/oauth_token_provider.rb
237
253
  - lib/basecamp/operation_info.rb
254
+ - lib/basecamp/operation_result.rb
255
+ - lib/basecamp/rate_limit_error.rb
238
256
  - lib/basecamp/request_info.rb
239
257
  - lib/basecamp/request_result.rb
240
258
  - lib/basecamp/security.rb
241
- - lib/basecamp/services/attachments_service.rb
242
- - lib/basecamp/services/authorization_service.rb
243
- - lib/basecamp/services/base_service.rb
244
- - lib/basecamp/services/campfires_service.rb
245
- - lib/basecamp/services/card_columns_service.rb
246
- - lib/basecamp/services/card_steps_service.rb
247
- - lib/basecamp/services/card_tables_service.rb
248
- - lib/basecamp/services/cards_service.rb
249
- - lib/basecamp/services/checkins_service.rb
250
- - lib/basecamp/services/client_approvals_service.rb
251
- - lib/basecamp/services/client_correspondences_service.rb
252
- - lib/basecamp/services/client_replies_service.rb
253
- - lib/basecamp/services/comments_service.rb
254
- - lib/basecamp/services/documents_service.rb
255
- - lib/basecamp/services/events_service.rb
256
- - lib/basecamp/services/forwards_service.rb
257
- - lib/basecamp/services/lineup_service.rb
258
- - lib/basecamp/services/message_boards_service.rb
259
- - lib/basecamp/services/message_types_service.rb
260
- - lib/basecamp/services/messages_service.rb
261
- - lib/basecamp/services/people_service.rb
262
- - lib/basecamp/services/projects_service.rb
263
- - lib/basecamp/services/recordings_service.rb
264
- - lib/basecamp/services/reports_service.rb
265
- - lib/basecamp/services/schedules_service.rb
266
- - lib/basecamp/services/search_service.rb
267
- - lib/basecamp/services/subscriptions_service.rb
268
- - lib/basecamp/services/templates_service.rb
269
- - lib/basecamp/services/timeline_service.rb
270
- - lib/basecamp/services/timesheet_service.rb
271
- - lib/basecamp/services/todolist_groups_service.rb
272
- - lib/basecamp/services/todolists_service.rb
273
- - lib/basecamp/services/todos_service.rb
274
- - lib/basecamp/services/todosets_service.rb
275
- - lib/basecamp/services/tools_service.rb
276
- - lib/basecamp/services/uploads_service.rb
277
- - lib/basecamp/services/vaults_service.rb
278
- - lib/basecamp/services/webhooks_service.rb
279
259
  - lib/basecamp/static_token_provider.rb
280
260
  - lib/basecamp/token_provider.rb
261
+ - lib/basecamp/usage_error.rb
262
+ - lib/basecamp/validation_error.rb
281
263
  - lib/basecamp/version.rb
282
264
  - lib/basecamp/webhooks/event.rb
283
265
  - lib/basecamp/webhooks/rack_middleware.rb
284
266
  - lib/basecamp/webhooks/receiver.rb
267
+ - lib/basecamp/webhooks/verification_error.rb
285
268
  - lib/basecamp/webhooks/verify.rb
286
269
  - scripts/generate-metadata.rb
287
270
  - scripts/generate-services.rb