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.
Files changed (83) 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/search_service.rb +1 -1
  20. data/lib/basecamp/generated/services/tools_service.rb +3 -2
  21. data/lib/basecamp/generated/types.rb +1 -1
  22. data/lib/basecamp/http.rb +22 -13
  23. data/lib/basecamp/network_error.rb +16 -0
  24. data/lib/basecamp/not_found_error.rb +20 -0
  25. data/lib/basecamp/oauth/config.rb +30 -0
  26. data/lib/basecamp/oauth/discovery.rb +9 -41
  27. data/lib/basecamp/oauth/exchange.rb +20 -114
  28. data/lib/basecamp/oauth/exchange_request.rb +36 -0
  29. data/lib/basecamp/oauth/{errors.rb → oauth_error.rb} +1 -1
  30. data/lib/basecamp/oauth/refresh_request.rb +30 -0
  31. data/lib/basecamp/oauth/token.rb +52 -0
  32. data/lib/basecamp/oauth.rb +40 -8
  33. data/lib/basecamp/operation_info.rb +0 -7
  34. data/lib/basecamp/operation_result.rb +10 -0
  35. data/lib/basecamp/rate_limit_error.rb +19 -0
  36. data/lib/basecamp/security.rb +1 -1
  37. data/lib/basecamp/usage_error.rb +10 -0
  38. data/lib/basecamp/validation_error.rb +15 -0
  39. data/lib/basecamp/version.rb +1 -1
  40. data/lib/basecamp/webhooks/rack_middleware.rb +0 -2
  41. data/lib/basecamp/webhooks/receiver.rb +0 -4
  42. data/lib/basecamp/webhooks/verification_error.rb +7 -0
  43. data/lib/basecamp.rb +62 -22
  44. data/scripts/generate-services.rb +3 -3
  45. metadata +26 -43
  46. data/lib/basecamp/errors.rb +0 -294
  47. data/lib/basecamp/oauth/types.rb +0 -133
  48. data/lib/basecamp/services/attachments_service.rb +0 -33
  49. data/lib/basecamp/services/campfires_service.rb +0 -141
  50. data/lib/basecamp/services/card_columns_service.rb +0 -106
  51. data/lib/basecamp/services/card_steps_service.rb +0 -86
  52. data/lib/basecamp/services/card_tables_service.rb +0 -23
  53. data/lib/basecamp/services/cards_service.rb +0 -93
  54. data/lib/basecamp/services/checkins_service.rb +0 -127
  55. data/lib/basecamp/services/client_approvals_service.rb +0 -33
  56. data/lib/basecamp/services/client_correspondences_service.rb +0 -33
  57. data/lib/basecamp/services/client_replies_service.rb +0 -35
  58. data/lib/basecamp/services/comments_service.rb +0 -63
  59. data/lib/basecamp/services/documents_service.rb +0 -74
  60. data/lib/basecamp/services/events_service.rb +0 -27
  61. data/lib/basecamp/services/forwards_service.rb +0 -80
  62. data/lib/basecamp/services/lineup_service.rb +0 -67
  63. data/lib/basecamp/services/message_boards_service.rb +0 -24
  64. data/lib/basecamp/services/message_types_service.rb +0 -79
  65. data/lib/basecamp/services/messages_service.rb +0 -133
  66. data/lib/basecamp/services/people_service.rb +0 -73
  67. data/lib/basecamp/services/projects_service.rb +0 -67
  68. data/lib/basecamp/services/recordings_service.rb +0 -127
  69. data/lib/basecamp/services/reports_service.rb +0 -80
  70. data/lib/basecamp/services/schedules_service.rb +0 -156
  71. data/lib/basecamp/services/search_service.rb +0 -36
  72. data/lib/basecamp/services/subscriptions_service.rb +0 -67
  73. data/lib/basecamp/services/templates_service.rb +0 -96
  74. data/lib/basecamp/services/timeline_service.rb +0 -62
  75. data/lib/basecamp/services/timesheet_service.rb +0 -68
  76. data/lib/basecamp/services/todolist_groups_service.rb +0 -100
  77. data/lib/basecamp/services/todolists_service.rb +0 -104
  78. data/lib/basecamp/services/todos_service.rb +0 -156
  79. data/lib/basecamp/services/todosets_service.rb +0 -23
  80. data/lib/basecamp/services/tools_service.rb +0 -89
  81. data/lib/basecamp/services/uploads_service.rb +0 -84
  82. data/lib/basecamp/services/vaults_service.rb +0 -84
  83. data/lib/basecamp/services/webhooks_service.rb +0 -88
data/lib/basecamp/http.rb CHANGED
@@ -80,6 +80,14 @@ module Basecamp
80
80
  single_request_raw(:post, url, body: body, content_type: content_type, attempt: 1)
81
81
  end
82
82
 
83
+ # Performs a GET request without retry logic.
84
+ # Used for the download flow where retry is not appropriate.
85
+ # @param url [String] absolute URL
86
+ # @return [Response]
87
+ def get_no_retry(url)
88
+ single_request(:get, url, params: {}, body: nil, attempt: 1)
89
+ end
90
+
83
91
  # Fetches all pages of a paginated resource.
84
92
  # @param path [String] initial URL path
85
93
  # @param params [Hash] query parameters
@@ -104,7 +112,7 @@ module Basecamp
104
112
  begin
105
113
  items = JSON.parse(response.body)
106
114
  rescue JSON::ParserError => e
107
- raise Basecamp::APIError.new("Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}")
115
+ raise Basecamp::ApiError.new("Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}")
108
116
  end
109
117
  items.each(&block)
110
118
 
@@ -114,7 +122,7 @@ module Basecamp
114
122
  next_url = Security.resolve_url(url, next_url)
115
123
 
116
124
  unless Security.same_origin?(next_url, base_url)
117
- raise Basecamp::APIError.new(
125
+ raise Basecamp::ApiError.new(
118
126
  "Pagination Link header points to different origin: #{Security.truncate(next_url)}"
119
127
  )
120
128
  end
@@ -149,7 +157,7 @@ module Basecamp
149
157
  begin
150
158
  data = JSON.parse(response.body)
151
159
  rescue JSON::ParserError => e
152
- raise Basecamp::APIError.new("Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}")
160
+ raise Basecamp::ApiError.new("Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}")
153
161
  end
154
162
  unless data.key?(key)
155
163
  warn "[Basecamp SDK] paginate_key: expected key '#{key}' not found in response (page #{page})"
@@ -163,7 +171,7 @@ module Basecamp
163
171
  next_url = Security.resolve_url(url, next_url)
164
172
 
165
173
  unless Security.same_origin?(next_url, base_url)
166
- raise Basecamp::APIError.new(
174
+ raise Basecamp::ApiError.new(
167
175
  "Pagination Link header points to different origin: #{Security.truncate(next_url)}"
168
176
  )
169
177
  end
@@ -188,7 +196,7 @@ module Basecamp
188
196
  begin
189
197
  first_data = JSON.parse(first_response.body)
190
198
  rescue JSON::ParserError => e
191
- raise Basecamp::APIError.new(
199
+ raise Basecamp::ApiError.new(
192
200
  "Failed to parse paginated response (page 1): #{Security.truncate(e.message)}"
193
201
  )
194
202
  end
@@ -208,7 +216,7 @@ module Basecamp
208
216
  next_url = Security.resolve_url(url, next_link)
209
217
 
210
218
  unless Security.same_origin?(next_url, base_url)
211
- raise Basecamp::APIError.new(
219
+ raise Basecamp::ApiError.new(
212
220
  "Pagination Link header points to different origin: " \
213
221
  "#{Security.truncate(next_url)}"
214
222
  )
@@ -221,7 +229,7 @@ module Basecamp
221
229
  begin
222
230
  data = JSON.parse(response.body)
223
231
  rescue JSON::ParserError => e
224
- raise Basecamp::APIError.new(
232
+ raise Basecamp::ApiError.new(
225
233
  "Failed to parse paginated response (page #{page}): " \
226
234
  "#{Security.truncate(e.message)}"
227
235
  )
@@ -271,7 +279,7 @@ module Basecamp
271
279
 
272
280
  begin
273
281
  return single_request(method, url, params: params, body: nil, attempt: attempt)
274
- rescue Basecamp::RateLimitError, Basecamp::NetworkError, Basecamp::APIError => e
282
+ rescue Basecamp::RateLimitError, Basecamp::NetworkError, Basecamp::ApiError => e
275
283
  raise e unless e.retryable?
276
284
 
277
285
  last_error = e
@@ -287,7 +295,7 @@ module Basecamp
287
295
  end
288
296
  end
289
297
 
290
- raise last_error || Basecamp::APIError.new("Request failed after #{@config.max_retries} retries")
298
+ raise last_error || Basecamp::ApiError.new("Request failed after #{@config.max_retries} retries")
291
299
  end
292
300
 
293
301
  def single_request(method, url, params:, body:, attempt:, retry_count: 0)
@@ -401,19 +409,20 @@ module Basecamp
401
409
  when 403
402
410
  Basecamp::ForbiddenError.new("Access denied")
403
411
  when 404
404
- Basecamp::NotFoundError.new("Resource", "unknown")
412
+ message = Security.truncate(Basecamp.parse_error_message(body) || "Not found")
413
+ Basecamp::NotFoundError.new(message: message)
405
414
  when 429
406
415
  Basecamp::RateLimitError.new(retry_after: retry_after)
407
416
  when 400, 422
408
417
  message = Security.truncate(Basecamp.parse_error_message(body) || "Validation failed")
409
418
  Basecamp::ValidationError.new(message, http_status: status)
410
419
  when 500
411
- Basecamp::APIError.new("Server error (500)", http_status: 500, retryable: true)
420
+ Basecamp::ApiError.new("Server error (500)", http_status: 500, retryable: true)
412
421
  when 502, 503, 504
413
- Basecamp::APIError.new("Gateway error (#{status})", http_status: status, retryable: true)
422
+ Basecamp::ApiError.new("Gateway error (#{status})", http_status: status, retryable: true)
414
423
  else
415
424
  message = Security.truncate(Basecamp.parse_error_message(body) || "Request failed (HTTP #{status})")
416
- Basecamp::APIError.from_status(status || 0, message)
425
+ Basecamp::ApiError.from_status(status || 0, message)
417
426
  end
418
427
 
419
428
  err.instance_variable_set(:@request_id, request_id) if request_id
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Raised when there's a network error (connection, timeout, DNS).
5
+ class NetworkError < Error
6
+ def initialize(message = "Network error", cause: nil)
7
+ super(
8
+ code: ErrorCode::NETWORK,
9
+ message: message,
10
+ hint: cause&.message || "Check your network connection",
11
+ retryable: true,
12
+ cause: cause
13
+ )
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Raised when a resource is not found (404).
5
+ class NotFoundError < Error
6
+ def initialize(resource = nil, identifier = nil, message: nil, hint: nil)
7
+ message ||= if resource
8
+ "#{resource} not found: #{identifier}"
9
+ else
10
+ "Not found"
11
+ end
12
+ super(
13
+ code: ErrorCode::NOT_FOUND,
14
+ message: message,
15
+ hint: hint,
16
+ http_status: 404
17
+ )
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Oauth
5
+ # OAuth 2 server configuration from discovery endpoint.
6
+ #
7
+ # @attr issuer [String] The authorization server's issuer identifier
8
+ # @attr authorization_endpoint [String] URL of the authorization endpoint
9
+ # @attr token_endpoint [String] URL of the token endpoint
10
+ # @attr registration_endpoint [String, nil] URL of the dynamic client registration endpoint
11
+ # @attr scopes_supported [Array<String>, nil] List of OAuth 2 scopes supported
12
+ Config = Data.define(
13
+ :issuer,
14
+ :authorization_endpoint,
15
+ :token_endpoint,
16
+ :registration_endpoint,
17
+ :scopes_supported
18
+ ) do
19
+ def initialize(
20
+ issuer:,
21
+ authorization_endpoint:,
22
+ token_endpoint:,
23
+ registration_endpoint: nil,
24
+ scopes_supported: nil
25
+ )
26
+ super
27
+ end
28
+ end
29
+ end
30
+ end
@@ -4,13 +4,9 @@ require "faraday"
4
4
  require "json"
5
5
 
6
6
  module Basecamp
7
- # OAuth 2 helpers for Basecamp authentication.
8
7
  module Oauth
9
- # Default Basecamp/Launchpad OAuth server URL.
10
- LAUNCHPAD_BASE_URL = "https://launchpad.37signals.com"
11
-
12
- # Discoverer fetches OAuth 2 server configuration from discovery endpoints.
13
- class Discoverer
8
+ # Fetches OAuth 2 server configuration from discovery endpoints.
9
+ class Discovery
14
10
  # @param http_client [Faraday::Connection, nil] HTTP client (uses default if nil)
15
11
  # @param timeout [Integer] Request timeout in seconds (default: 10)
16
12
  def initialize(http_client: nil, timeout: 10)
@@ -24,11 +20,11 @@ module Basecamp
24
20
  #
25
21
  # @param base_url [String] The OAuth server's base URL (e.g., "https://launchpad.37signals.com")
26
22
  # @return [Config] The OAuth server configuration
27
- # @raise [OAuthError] on network or parsing errors
23
+ # @raise [OauthError] on network or parsing errors
28
24
  #
29
25
  # @example
30
- # discoverer = Basecamp::Oauth::Discoverer.new
31
- # config = discoverer.discover("https://launchpad.37signals.com")
26
+ # discovery = Basecamp::Oauth::Discovery.new
27
+ # config = discovery.discover("https://launchpad.37signals.com")
32
28
  # puts config.token_endpoint
33
29
  # # => "https://launchpad.37signals.com/authorization/token"
34
30
  def discover(base_url)
@@ -42,7 +38,7 @@ module Basecamp
42
38
  end
43
39
 
44
40
  unless response.success?
45
- raise OAuthError.new(
41
+ raise OauthError.new(
46
42
  "network",
47
43
  "OAuth discovery failed with status #{response.status}: #{Basecamp::Security.truncate(response.body)}",
48
44
  http_status: response.status
@@ -62,9 +58,9 @@ module Basecamp
62
58
  scopes_supported: data["scopes_supported"]
63
59
  )
64
60
  rescue Faraday::Error => e
65
- raise OAuthError.new("network", "OAuth discovery failed: #{e.message}", retryable: true)
61
+ raise OauthError.new("network", "OAuth discovery failed: #{e.message}", retryable: true)
66
62
  rescue JSON::ParserError => e
67
- raise OAuthError.new("api_error", "Failed to parse discovery response: #{e.message}")
63
+ raise OauthError.new("api_error", "Failed to parse discovery response: #{e.message}")
68
64
  end
69
65
 
70
66
  private
@@ -85,39 +81,11 @@ module Basecamp
85
81
 
86
82
  return if missing.empty?
87
83
 
88
- raise OAuthError.new(
84
+ raise OauthError.new(
89
85
  "api_error",
90
86
  "Invalid OAuth discovery response: missing required fields: #{missing.join(", ")}"
91
87
  )
92
88
  end
93
89
  end
94
-
95
- module_function
96
-
97
- # Discovers OAuth configuration from the well-known endpoint.
98
- #
99
- # @param base_url [String] The OAuth server's base URL
100
- # @param timeout [Integer] Request timeout in seconds (default: 10)
101
- # @return [Config] The OAuth server configuration
102
- #
103
- # @example
104
- # config = Basecamp::Oauth.discover("https://launchpad.37signals.com")
105
- def discover(base_url, timeout: 10)
106
- Discoverer.new(timeout: timeout).discover(base_url)
107
- end
108
-
109
- # Discovers OAuth configuration from Basecamp's Launchpad server.
110
- #
111
- # Convenience function that calls discover() with the Launchpad base URL.
112
- #
113
- # @param timeout [Integer] Request timeout in seconds (default: 10)
114
- # @return [Config] The OAuth server configuration
115
- #
116
- # @example
117
- # config = Basecamp::Oauth.discover_launchpad
118
- # # Use config.authorization_endpoint to start OAuth flow
119
- def discover_launchpad(timeout: 10)
120
- discover(LAUNCHPAD_BASE_URL, timeout: timeout)
121
- end
122
90
  end
123
91
  end
@@ -5,10 +5,9 @@ require "json"
5
5
  require "uri"
6
6
 
7
7
  module Basecamp
8
- # OAuth 2 helpers for Basecamp authentication.
9
8
  module Oauth
10
- # Exchanger handles OAuth 2 token exchange and refresh operations.
11
- class Exchanger
9
+ # Handles OAuth 2 token exchange and refresh operations.
10
+ class Exchange
12
11
  # @param http_client [Faraday::Connection, nil] HTTP client (uses default if nil)
13
12
  # @param timeout [Integer] Request timeout in seconds (default: 30)
14
13
  def initialize(http_client: nil, timeout: 30)
@@ -22,10 +21,10 @@ module Basecamp
22
21
  #
23
22
  # @param request [ExchangeRequest] Exchange request parameters
24
23
  # @return [Token] The token response
25
- # @raise [OAuthError] on validation, network, or authentication errors
24
+ # @raise [OauthError] on validation, network, or authentication errors
26
25
  #
27
26
  # @example Standard OAuth 2
28
- # token = exchanger.exchange(ExchangeRequest.new(
27
+ # token = exchange.exchange(ExchangeRequest.new(
29
28
  # token_endpoint: config.token_endpoint,
30
29
  # code: "auth_code_from_callback",
31
30
  # redirect_uri: "https://myapp.com/callback",
@@ -34,7 +33,7 @@ module Basecamp
34
33
  # ))
35
34
  #
36
35
  # @example Launchpad legacy format
37
- # token = exchanger.exchange(ExchangeRequest.new(
36
+ # token = exchange.exchange(ExchangeRequest.new(
38
37
  # token_endpoint: config.token_endpoint,
39
38
  # code: "auth_code",
40
39
  # redirect_uri: "https://myapp.com/callback",
@@ -56,10 +55,10 @@ module Basecamp
56
55
  #
57
56
  # @param request [RefreshRequest] Refresh request parameters
58
57
  # @return [Token] The new token response
59
- # @raise [OAuthError] on validation, network, or authentication errors
58
+ # @raise [OauthError] on validation, network, or authentication errors
60
59
  #
61
60
  # @example Standard OAuth 2
62
- # new_token = exchanger.refresh(RefreshRequest.new(
61
+ # new_token = exchange.refresh(RefreshRequest.new(
63
62
  # token_endpoint: config.token_endpoint,
64
63
  # refresh_token: old_token.refresh_token,
65
64
  # client_id: "my_client_id",
@@ -67,7 +66,7 @@ module Basecamp
67
66
  # ))
68
67
  #
69
68
  # @example Launchpad legacy format
70
- # new_token = exchanger.refresh(RefreshRequest.new(
69
+ # new_token = exchange.refresh(RefreshRequest.new(
71
70
  # token_endpoint: config.token_endpoint,
72
71
  # refresh_token: old_token.refresh_token,
73
72
  # use_legacy_format: true
@@ -90,15 +89,15 @@ module Basecamp
90
89
  end
91
90
 
92
91
  def validate_exchange_request!(request)
93
- raise OAuthError.new("validation", "Token endpoint is required") if request.token_endpoint.to_s.empty?
94
- raise OAuthError.new("validation", "Authorization code is required") if request.code.to_s.empty?
95
- raise OAuthError.new("validation", "Redirect URI is required") if request.redirect_uri.to_s.empty?
96
- raise OAuthError.new("validation", "Client ID is required") if request.client_id.to_s.empty?
92
+ raise OauthError.new("validation", "Token endpoint is required") if request.token_endpoint.to_s.empty?
93
+ raise OauthError.new("validation", "Authorization code is required") if request.code.to_s.empty?
94
+ raise OauthError.new("validation", "Redirect URI is required") if request.redirect_uri.to_s.empty?
95
+ raise OauthError.new("validation", "Client ID is required") if request.client_id.to_s.empty?
97
96
  end
98
97
 
99
98
  def validate_refresh_request!(request)
100
- raise OAuthError.new("validation", "Token endpoint is required") if request.token_endpoint.to_s.empty?
101
- raise OAuthError.new("validation", "Refresh token is required") if request.refresh_token.to_s.empty?
99
+ raise OauthError.new("validation", "Token endpoint is required") if request.token_endpoint.to_s.empty?
100
+ raise OauthError.new("validation", "Refresh token is required") if request.refresh_token.to_s.empty?
102
101
  end
103
102
 
104
103
  def build_exchange_params(request)
@@ -150,9 +149,9 @@ module Basecamp
150
149
 
151
150
  parse_token_response(response)
152
151
  rescue Faraday::TimeoutError
153
- raise OAuthError.new("network", "Token request timed out", retryable: true)
152
+ raise OauthError.new("network", "Token request timed out", retryable: true)
154
153
  rescue Faraday::Error => e
155
- raise OAuthError.new("network", "Token request failed: #{e.message}", retryable: true)
154
+ raise OauthError.new("network", "Token request failed: #{e.message}", retryable: true)
156
155
  end
157
156
 
158
157
  def parse_token_response(response)
@@ -162,7 +161,7 @@ module Basecamp
162
161
 
163
162
  handle_error_response(response.status, data) unless response.success?
164
163
 
165
- raise OAuthError.new("api_error", "Token response missing access_token") unless data["access_token"]
164
+ raise OauthError.new("api_error", "Token response missing access_token") unless data["access_token"]
166
165
 
167
166
  Token.new(
168
167
  access_token: data["access_token"],
@@ -172,7 +171,7 @@ module Basecamp
172
171
  scope: data["scope"]
173
172
  )
174
173
  rescue JSON::ParserError
175
- raise OAuthError.new(
174
+ raise OauthError.new(
176
175
  "api_error",
177
176
  "Failed to parse token response: #{Basecamp::Security.truncate(response.body)}",
178
177
  http_status: response.status
@@ -183,7 +182,7 @@ module Basecamp
183
182
  error_msg = Basecamp::Security.truncate(data["error_description"] || data["error"] || "Token request failed")
184
183
 
185
184
  if status == 401 || data["error"] == "invalid_grant"
186
- raise OAuthError.new(
185
+ raise OauthError.new(
187
186
  "auth",
188
187
  error_msg,
189
188
  http_status: status,
@@ -191,101 +190,8 @@ module Basecamp
191
190
  )
192
191
  end
193
192
 
194
- raise OAuthError.new("api_error", error_msg, http_status: status)
193
+ raise OauthError.new("api_error", error_msg, http_status: status)
195
194
  end
196
195
  end
197
-
198
- module_function
199
-
200
- # Exchanges an authorization code for access and refresh tokens.
201
- #
202
- # @param token_endpoint [String] URL of the token endpoint
203
- # @param code [String] The authorization code
204
- # @param redirect_uri [String] The redirect URI used in the authorization request
205
- # @param client_id [String] The client identifier
206
- # @param client_secret [String, nil] The client secret
207
- # @param code_verifier [String, nil] PKCE code verifier
208
- # @param use_legacy_format [Boolean] Use Launchpad's non-standard format
209
- # @param timeout [Integer] Request timeout in seconds (default: 30)
210
- # @return [Token] The token response
211
- #
212
- # @example
213
- # token = Basecamp::Oauth.exchange_code(
214
- # token_endpoint: config.token_endpoint,
215
- # code: "auth_code",
216
- # redirect_uri: "https://myapp.com/callback",
217
- # client_id: ENV["BASECAMP_CLIENT_ID"],
218
- # client_secret: ENV["BASECAMP_CLIENT_SECRET"],
219
- # use_legacy_format: true
220
- # )
221
- def exchange_code(
222
- token_endpoint:,
223
- code:,
224
- redirect_uri:,
225
- client_id:,
226
- client_secret: nil,
227
- code_verifier: nil,
228
- use_legacy_format: false,
229
- timeout: 30
230
- )
231
- request = ExchangeRequest.new(
232
- token_endpoint: token_endpoint,
233
- code: code,
234
- redirect_uri: redirect_uri,
235
- client_id: client_id,
236
- client_secret: client_secret,
237
- code_verifier: code_verifier,
238
- use_legacy_format: use_legacy_format
239
- )
240
- Exchanger.new(timeout: timeout).exchange(request)
241
- end
242
-
243
- # Refreshes an access token using a refresh token.
244
- #
245
- # @param token_endpoint [String] URL of the token endpoint
246
- # @param refresh_token [String] The refresh token
247
- # @param client_id [String, nil] The client identifier
248
- # @param client_secret [String, nil] The client secret
249
- # @param use_legacy_format [Boolean] Use Launchpad's non-standard format
250
- # @param timeout [Integer] Request timeout in seconds (default: 30)
251
- # @return [Token] The new token response
252
- #
253
- # @example
254
- # new_token = Basecamp::Oauth.refresh_token(
255
- # token_endpoint: config.token_endpoint,
256
- # refresh_token: old_token.refresh_token,
257
- # use_legacy_format: true
258
- # )
259
- def refresh_token(
260
- token_endpoint:,
261
- refresh_token:,
262
- client_id: nil,
263
- client_secret: nil,
264
- use_legacy_format: false,
265
- timeout: 30
266
- )
267
- request = RefreshRequest.new(
268
- token_endpoint: token_endpoint,
269
- refresh_token: refresh_token,
270
- client_id: client_id,
271
- client_secret: client_secret,
272
- use_legacy_format: use_legacy_format
273
- )
274
- Exchanger.new(timeout: timeout).refresh(request)
275
- end
276
-
277
- # Checks if a token is expired or about to expire.
278
- #
279
- # @param token [Token] The token to check
280
- # @param buffer_seconds [Integer] Buffer time before actual expiration (default: 60)
281
- # @return [Boolean] true if expired or will expire within buffer time
282
- #
283
- # @example
284
- # if Basecamp::Oauth.token_expired?(token)
285
- # token = Basecamp::Oauth.refresh_token(...)
286
- # end
287
- def token_expired?(token, buffer_seconds = 60)
288
- token.expired?(buffer_seconds)
289
- end
290
196
  end
291
197
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Oauth
5
+ # Parameters for exchanging an authorization code for tokens.
6
+ #
7
+ # @attr token_endpoint [String] URL of the token endpoint
8
+ # @attr code [String] The authorization code received from the authorization server
9
+ # @attr redirect_uri [String] The redirect URI used in the authorization request
10
+ # @attr client_id [String] The client identifier
11
+ # @attr client_secret [String, nil] The client secret (optional for public clients)
12
+ # @attr code_verifier [String, nil] PKCE code verifier (optional)
13
+ # @attr use_legacy_format [Boolean] Use Launchpad's non-standard token format
14
+ ExchangeRequest = Data.define(
15
+ :token_endpoint,
16
+ :code,
17
+ :redirect_uri,
18
+ :client_id,
19
+ :client_secret,
20
+ :code_verifier,
21
+ :use_legacy_format
22
+ ) do
23
+ def initialize(
24
+ token_endpoint:,
25
+ code:,
26
+ redirect_uri:,
27
+ client_id:,
28
+ client_secret: nil,
29
+ code_verifier: nil,
30
+ use_legacy_format: false
31
+ )
32
+ super
33
+ end
34
+ end
35
+ end
36
+ end
@@ -8,7 +8,7 @@ module Basecamp
8
8
  # @attr http_status [Integer, nil] HTTP status code if applicable
9
9
  # @attr hint [String, nil] Helpful hint for resolving the error
10
10
  # @attr retryable [Boolean] Whether the request can be retried
11
- class OAuthError < StandardError
11
+ class OauthError < StandardError
12
12
  attr_reader :type, :http_status, :hint, :retryable
13
13
 
14
14
  # @param type [String] Error type
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Oauth
5
+ # Parameters for refreshing an access token.
6
+ #
7
+ # @attr token_endpoint [String] URL of the token endpoint
8
+ # @attr refresh_token [String] The refresh token
9
+ # @attr client_id [String, nil] The client identifier (optional)
10
+ # @attr client_secret [String, nil] The client secret (optional)
11
+ # @attr use_legacy_format [Boolean] Use Launchpad's non-standard token format
12
+ RefreshRequest = Data.define(
13
+ :token_endpoint,
14
+ :refresh_token,
15
+ :client_id,
16
+ :client_secret,
17
+ :use_legacy_format
18
+ ) do
19
+ def initialize(
20
+ token_endpoint:,
21
+ refresh_token:,
22
+ client_id: nil,
23
+ client_secret: nil,
24
+ use_legacy_format: false
25
+ )
26
+ super
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Oauth
5
+ # OAuth 2 access token response.
6
+ #
7
+ # @attr access_token [String] The access token string
8
+ # @attr token_type [String] Token type (usually "Bearer")
9
+ # @attr refresh_token [String, nil] The refresh token string
10
+ # @attr expires_in [Integer, nil] Lifetime of the access token in seconds
11
+ # @attr expires_at [Time, nil] Calculated expiration time
12
+ # @attr scope [String, nil] OAuth scope granted
13
+ Token = Data.define(
14
+ :access_token,
15
+ :token_type,
16
+ :refresh_token,
17
+ :expires_in,
18
+ :expires_at,
19
+ :scope
20
+ ) do
21
+ def initialize(
22
+ access_token:,
23
+ token_type: "Bearer",
24
+ refresh_token: nil,
25
+ expires_in: nil,
26
+ expires_at: nil,
27
+ scope: nil
28
+ )
29
+ # Calculate expires_at from expires_in if not provided
30
+ calculated_expires_at = expires_at || (expires_in ? Time.now + expires_in : nil)
31
+ super(
32
+ access_token: access_token,
33
+ token_type: token_type,
34
+ refresh_token: refresh_token,
35
+ expires_in: expires_in,
36
+ expires_at: calculated_expires_at,
37
+ scope: scope
38
+ )
39
+ end
40
+
41
+ # Checks if the token is expired or about to expire.
42
+ #
43
+ # @param buffer_seconds [Integer] Buffer time before actual expiration (default: 60)
44
+ # @return [Boolean] true if expired or will expire within buffer time
45
+ def expired?(buffer_seconds = 60)
46
+ return false unless expires_at
47
+
48
+ Time.now + buffer_seconds >= expires_at
49
+ end
50
+ end
51
+ end
52
+ end