basecamp-sdk 0.2.1

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 (116) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +14 -0
  3. data/.yardopts +6 -0
  4. data/README.md +293 -0
  5. data/Rakefile +26 -0
  6. data/basecamp-sdk.gemspec +46 -0
  7. data/lib/basecamp/auth_strategy.rb +38 -0
  8. data/lib/basecamp/chain_hooks.rb +45 -0
  9. data/lib/basecamp/client.rb +428 -0
  10. data/lib/basecamp/config.rb +143 -0
  11. data/lib/basecamp/errors.rb +289 -0
  12. data/lib/basecamp/generated/metadata.json +2281 -0
  13. data/lib/basecamp/generated/services/attachments_service.rb +24 -0
  14. data/lib/basecamp/generated/services/boosts_service.rb +70 -0
  15. data/lib/basecamp/generated/services/campfires_service.rb +122 -0
  16. data/lib/basecamp/generated/services/card_columns_service.rb +103 -0
  17. data/lib/basecamp/generated/services/card_steps_service.rb +57 -0
  18. data/lib/basecamp/generated/services/card_tables_service.rb +20 -0
  19. data/lib/basecamp/generated/services/cards_service.rb +66 -0
  20. data/lib/basecamp/generated/services/checkins_service.rb +157 -0
  21. data/lib/basecamp/generated/services/client_approvals_service.rb +28 -0
  22. data/lib/basecamp/generated/services/client_correspondences_service.rb +28 -0
  23. data/lib/basecamp/generated/services/client_replies_service.rb +30 -0
  24. data/lib/basecamp/generated/services/client_visibility_service.rb +21 -0
  25. data/lib/basecamp/generated/services/comments_service.rb +49 -0
  26. data/lib/basecamp/generated/services/documents_service.rb +52 -0
  27. data/lib/basecamp/generated/services/events_service.rb +20 -0
  28. data/lib/basecamp/generated/services/forwards_service.rb +67 -0
  29. data/lib/basecamp/generated/services/lineup_service.rb +44 -0
  30. data/lib/basecamp/generated/services/message_boards_service.rb +20 -0
  31. data/lib/basecamp/generated/services/message_types_service.rb +59 -0
  32. data/lib/basecamp/generated/services/messages_service.rb +75 -0
  33. data/lib/basecamp/generated/services/people_service.rb +73 -0
  34. data/lib/basecamp/generated/services/projects_service.rb +63 -0
  35. data/lib/basecamp/generated/services/recordings_service.rb +64 -0
  36. data/lib/basecamp/generated/services/reports_service.rb +56 -0
  37. data/lib/basecamp/generated/services/schedules_service.rb +92 -0
  38. data/lib/basecamp/generated/services/search_service.rb +31 -0
  39. data/lib/basecamp/generated/services/subscriptions_service.rb +50 -0
  40. data/lib/basecamp/generated/services/templates_service.rb +82 -0
  41. data/lib/basecamp/generated/services/timeline_service.rb +20 -0
  42. data/lib/basecamp/generated/services/timesheets_service.rb +81 -0
  43. data/lib/basecamp/generated/services/todolist_groups_service.rb +41 -0
  44. data/lib/basecamp/generated/services/todolists_service.rb +53 -0
  45. data/lib/basecamp/generated/services/todos_service.rb +106 -0
  46. data/lib/basecamp/generated/services/todosets_service.rb +20 -0
  47. data/lib/basecamp/generated/services/tools_service.rb +80 -0
  48. data/lib/basecamp/generated/services/uploads_service.rb +61 -0
  49. data/lib/basecamp/generated/services/vaults_service.rb +49 -0
  50. data/lib/basecamp/generated/services/webhooks_service.rb +63 -0
  51. data/lib/basecamp/generated/types.rb +3196 -0
  52. data/lib/basecamp/hooks.rb +70 -0
  53. data/lib/basecamp/http.rb +440 -0
  54. data/lib/basecamp/logger_hooks.rb +46 -0
  55. data/lib/basecamp/noop_hooks.rb +9 -0
  56. data/lib/basecamp/oauth/discovery.rb +123 -0
  57. data/lib/basecamp/oauth/errors.rb +35 -0
  58. data/lib/basecamp/oauth/exchange.rb +291 -0
  59. data/lib/basecamp/oauth/pkce.rb +68 -0
  60. data/lib/basecamp/oauth/types.rb +133 -0
  61. data/lib/basecamp/oauth.rb +56 -0
  62. data/lib/basecamp/oauth_token_provider.rb +108 -0
  63. data/lib/basecamp/operation_info.rb +17 -0
  64. data/lib/basecamp/request_info.rb +10 -0
  65. data/lib/basecamp/request_result.rb +14 -0
  66. data/lib/basecamp/security.rb +112 -0
  67. data/lib/basecamp/services/attachments_service.rb +33 -0
  68. data/lib/basecamp/services/authorization_service.rb +47 -0
  69. data/lib/basecamp/services/base_service.rb +146 -0
  70. data/lib/basecamp/services/campfires_service.rb +141 -0
  71. data/lib/basecamp/services/card_columns_service.rb +106 -0
  72. data/lib/basecamp/services/card_steps_service.rb +86 -0
  73. data/lib/basecamp/services/card_tables_service.rb +23 -0
  74. data/lib/basecamp/services/cards_service.rb +93 -0
  75. data/lib/basecamp/services/checkins_service.rb +127 -0
  76. data/lib/basecamp/services/client_approvals_service.rb +33 -0
  77. data/lib/basecamp/services/client_correspondences_service.rb +33 -0
  78. data/lib/basecamp/services/client_replies_service.rb +35 -0
  79. data/lib/basecamp/services/comments_service.rb +63 -0
  80. data/lib/basecamp/services/documents_service.rb +74 -0
  81. data/lib/basecamp/services/events_service.rb +27 -0
  82. data/lib/basecamp/services/forwards_service.rb +80 -0
  83. data/lib/basecamp/services/lineup_service.rb +67 -0
  84. data/lib/basecamp/services/message_boards_service.rb +24 -0
  85. data/lib/basecamp/services/message_types_service.rb +79 -0
  86. data/lib/basecamp/services/messages_service.rb +133 -0
  87. data/lib/basecamp/services/people_service.rb +73 -0
  88. data/lib/basecamp/services/projects_service.rb +67 -0
  89. data/lib/basecamp/services/recordings_service.rb +127 -0
  90. data/lib/basecamp/services/reports_service.rb +80 -0
  91. data/lib/basecamp/services/schedules_service.rb +156 -0
  92. data/lib/basecamp/services/search_service.rb +36 -0
  93. data/lib/basecamp/services/subscriptions_service.rb +67 -0
  94. data/lib/basecamp/services/templates_service.rb +96 -0
  95. data/lib/basecamp/services/timeline_service.rb +62 -0
  96. data/lib/basecamp/services/timesheet_service.rb +68 -0
  97. data/lib/basecamp/services/todolist_groups_service.rb +100 -0
  98. data/lib/basecamp/services/todolists_service.rb +104 -0
  99. data/lib/basecamp/services/todos_service.rb +156 -0
  100. data/lib/basecamp/services/todosets_service.rb +23 -0
  101. data/lib/basecamp/services/tools_service.rb +89 -0
  102. data/lib/basecamp/services/uploads_service.rb +84 -0
  103. data/lib/basecamp/services/vaults_service.rb +84 -0
  104. data/lib/basecamp/services/webhooks_service.rb +88 -0
  105. data/lib/basecamp/static_token_provider.rb +24 -0
  106. data/lib/basecamp/token_provider.rb +42 -0
  107. data/lib/basecamp/version.rb +6 -0
  108. data/lib/basecamp/webhooks/event.rb +52 -0
  109. data/lib/basecamp/webhooks/rack_middleware.rb +49 -0
  110. data/lib/basecamp/webhooks/receiver.rb +161 -0
  111. data/lib/basecamp/webhooks/verify.rb +36 -0
  112. data/lib/basecamp.rb +107 -0
  113. data/scripts/generate-metadata.rb +106 -0
  114. data/scripts/generate-services.rb +778 -0
  115. data/scripts/generate-types.rb +191 -0
  116. metadata +316 -0
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Interface for observability hooks.
5
+ # Implement this to add logging, metrics, or tracing to HTTP requests.
6
+ #
7
+ # @example Custom hooks with logging
8
+ # class LoggingHooks
9
+ # include Basecamp::Hooks
10
+ #
11
+ # def on_request_start(info)
12
+ # puts "Starting #{info.method} #{info.url}"
13
+ # end
14
+ #
15
+ # def on_request_end(info, result)
16
+ # puts "Completed #{info.method} #{info.url} - #{result.status_code} (#{result.duration}s)"
17
+ # end
18
+ # end
19
+ #
20
+ # client = Basecamp::Client.new(config: config, token_provider: provider, hooks: LoggingHooks.new)
21
+ module Hooks
22
+ # Called when a service operation starts (e.g., projects.list, todos.create).
23
+ # @param info [OperationInfo] operation information
24
+ # @return [void]
25
+ def on_operation_start(info)
26
+ # Override in implementation
27
+ end
28
+
29
+ # Called when a service operation completes (success or failure).
30
+ # @param info [OperationInfo] operation information
31
+ # @param result [OperationResult] result information
32
+ # @return [void]
33
+ def on_operation_end(info, result)
34
+ # Override in implementation
35
+ end
36
+
37
+ # Called when an HTTP request starts.
38
+ # @param info [RequestInfo] request information
39
+ # @return [void]
40
+ def on_request_start(info)
41
+ # Override in implementation
42
+ end
43
+
44
+ # Called when an HTTP request completes (success or failure).
45
+ # @param info [RequestInfo] request information
46
+ # @param result [RequestResult] result information
47
+ # @return [void]
48
+ def on_request_end(info, result)
49
+ # Override in implementation
50
+ end
51
+
52
+ # Called when a request is retried.
53
+ # @param info [RequestInfo] request information
54
+ # @param attempt [Integer] the next attempt number
55
+ # @param error [Exception] the error that triggered the retry
56
+ # @param delay [Float] seconds until retry
57
+ # @return [void]
58
+ def on_retry(info, attempt, error, delay)
59
+ # Override in implementation
60
+ end
61
+
62
+ # Called when pagination fetches the next page.
63
+ # @param url [String] the next page URL
64
+ # @param page [Integer] the page number
65
+ # @return [void]
66
+ def on_paginate(url, page)
67
+ # Override in implementation
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,440 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require "time"
6
+ require "uri"
7
+
8
+ module Basecamp
9
+ # HTTP client layer with retry, backoff, and caching support.
10
+ # This is an internal class used by Client; you typically don't use it directly.
11
+ class Http
12
+ # Default User-Agent header
13
+ USER_AGENT = "basecamp-sdk-ruby/#{VERSION} (api:#{API_VERSION})".freeze
14
+
15
+ # @param config [Config] configuration settings
16
+ # @param token_provider [TokenProvider, nil] OAuth token provider (deprecated, use auth_strategy)
17
+ # @param auth_strategy [AuthStrategy, nil] authentication strategy
18
+ # @param hooks [Hooks] observability hooks
19
+ def initialize(config:, token_provider: nil, auth_strategy: nil, hooks: nil)
20
+ @config = config
21
+ @auth_strategy = auth_strategy || BearerAuth.new(token_provider)
22
+ @token_provider = token_provider || (@auth_strategy.is_a?(BearerAuth) ? @auth_strategy.token_provider : nil)
23
+ @hooks = hooks || NoopHooks.new
24
+ @faraday = build_faraday_client
25
+ end
26
+
27
+ # @return [String] the configured base URL
28
+ def base_url
29
+ @config.base_url
30
+ end
31
+
32
+ # Performs a GET request.
33
+ # @param path [String] URL path
34
+ # @param params [Hash] query parameters
35
+ # @return [Response]
36
+ def get(path, params: {})
37
+ request(:get, path, params: params)
38
+ end
39
+
40
+ # Performs a GET request to an absolute URL.
41
+ # Used for endpoints not on the base API (e.g., Launchpad).
42
+ # @param url [String] absolute URL
43
+ # @param params [Hash] query parameters
44
+ # @return [Response]
45
+ def get_absolute(url, params: {})
46
+ request(:get, url, params: params)
47
+ end
48
+
49
+ # Performs a POST request.
50
+ # @param path [String] URL path
51
+ # @param body [Hash, nil] request body
52
+ # @return [Response]
53
+ def post(path, body: nil)
54
+ request(:post, path, body: body)
55
+ end
56
+
57
+ # Performs a PUT request.
58
+ # @param path [String] URL path
59
+ # @param body [Hash, nil] request body
60
+ # @return [Response]
61
+ def put(path, body: nil)
62
+ request(:put, path, body: body)
63
+ end
64
+
65
+ # Performs a DELETE request.
66
+ # @param path [String] URL path
67
+ # @return [Response]
68
+ def delete(path)
69
+ request(:delete, path)
70
+ end
71
+
72
+ # Performs a POST request with raw binary data.
73
+ # Used for file uploads (attachments).
74
+ # @param path [String] URL path
75
+ # @param body [String, IO] raw binary data
76
+ # @param content_type [String] MIME content type
77
+ # @return [Response]
78
+ def post_raw(path, body:, content_type:)
79
+ url = build_url(path)
80
+ single_request_raw(:post, url, body: body, content_type: content_type, attempt: 1)
81
+ end
82
+
83
+ # Fetches all pages of a paginated resource.
84
+ # @param path [String] initial URL path
85
+ # @param params [Hash] query parameters
86
+ # @yield [Hash] each item from the response
87
+ # @return [Enumerator] if no block given
88
+ def paginate(path, params: {}, &block)
89
+ return to_enum(:paginate, path, params: params) unless block
90
+
91
+ base_url = build_url(path)
92
+ url = base_url
93
+ page = 0
94
+
95
+ loop do
96
+ page += 1
97
+ break if page > @config.max_pages
98
+
99
+ @hooks.on_paginate(url, page)
100
+ response = get(url, params: page == 1 ? params : {})
101
+
102
+ Security.check_body_size!(response.body, Security::MAX_RESPONSE_BODY_BYTES)
103
+
104
+ begin
105
+ items = JSON.parse(response.body)
106
+ rescue JSON::ParserError => e
107
+ raise Basecamp::APIError.new("Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}")
108
+ end
109
+ items.each(&block)
110
+
111
+ next_url = parse_next_link(response.headers["Link"])
112
+ break if next_url.nil?
113
+
114
+ next_url = Security.resolve_url(url, next_url)
115
+
116
+ unless Security.same_origin?(next_url, base_url)
117
+ raise Basecamp::APIError.new(
118
+ "Pagination Link header points to different origin: #{Security.truncate(next_url)}"
119
+ )
120
+ end
121
+
122
+ url = next_url
123
+ end
124
+ end
125
+
126
+ # Fetches all pages of a paginated resource, extracting items from a key.
127
+ # Use this for endpoints that return objects like { "events": [...] }.
128
+ # @param path [String] initial URL path
129
+ # @param key [String] the key containing the array of items
130
+ # @param params [Hash] query parameters
131
+ # @yield [Hash] each item from the response
132
+ # @return [Enumerator] if no block given
133
+ def paginate_key(path, key:, params: {}, &block)
134
+ return to_enum(:paginate_key, path, key: key, params: params) unless block
135
+
136
+ base_url = build_url(path)
137
+ url = base_url
138
+ page = 0
139
+
140
+ loop do
141
+ page += 1
142
+ break if page > @config.max_pages
143
+
144
+ @hooks.on_paginate(url, page)
145
+ response = get(url, params: page == 1 ? params : {})
146
+
147
+ Security.check_body_size!(response.body, Security::MAX_RESPONSE_BODY_BYTES)
148
+
149
+ begin
150
+ data = JSON.parse(response.body)
151
+ rescue JSON::ParserError => e
152
+ raise Basecamp::APIError.new("Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}")
153
+ end
154
+ unless data.key?(key)
155
+ warn "[Basecamp SDK] paginate_key: expected key '#{key}' not found in response (page #{page})"
156
+ end
157
+ items = data[key] || []
158
+ items.each(&block)
159
+
160
+ next_url = parse_next_link(response.headers["Link"])
161
+ break if next_url.nil?
162
+
163
+ next_url = Security.resolve_url(url, next_url)
164
+
165
+ unless Security.same_origin?(next_url, base_url)
166
+ raise Basecamp::APIError.new(
167
+ "Pagination Link header points to different origin: #{Security.truncate(next_url)}"
168
+ )
169
+ end
170
+
171
+ url = next_url
172
+ end
173
+ end
174
+
175
+ private
176
+
177
+ def build_faraday_client
178
+ Faraday.new(url: @config.base_url) do |f|
179
+ f.options.timeout = @config.timeout
180
+ f.options.open_timeout = 10
181
+ f.request :json
182
+ f.response :raise_error
183
+ f.adapter Faraday.default_adapter
184
+ end
185
+ end
186
+
187
+ def request(method, path, params: {}, body: nil)
188
+ url = build_url(path)
189
+
190
+ # Mutations don't retry on 429/5xx to avoid duplicating data
191
+ if method == :get
192
+ request_with_retry(method, url, params: params)
193
+ else
194
+ single_request(method, url, params: params, body: body, attempt: 1)
195
+ end
196
+ end
197
+
198
+ def request_with_retry(method, url, params: {})
199
+ attempt = 0
200
+ last_error = nil
201
+
202
+ loop do
203
+ attempt += 1
204
+ break if attempt > @config.max_retries
205
+
206
+ begin
207
+ return single_request(method, url, params: params, body: nil, attempt: attempt)
208
+ rescue Basecamp::RateLimitError, Basecamp::NetworkError, Basecamp::APIError => e
209
+ raise e unless e.retryable?
210
+
211
+ last_error = e
212
+
213
+ # Don't sleep if this was the last attempt
214
+ break if attempt >= @config.max_retries
215
+
216
+ delay = calculate_delay(attempt, e.retry_after)
217
+
218
+ @hooks.on_retry(RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt), attempt + 1, e,
219
+ delay)
220
+ sleep(delay)
221
+ end
222
+ end
223
+
224
+ raise last_error || Basecamp::APIError.new("Request failed after #{@config.max_retries} retries")
225
+ end
226
+
227
+ def single_request(method, url, params:, body:, attempt:, retry_count: 0)
228
+ info = RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt)
229
+ @hooks.on_request_start(info)
230
+
231
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
232
+
233
+ begin
234
+ response = @faraday.run_request(method, url, body, request_headers) do |req|
235
+ req.params.merge!(params) if params.any?
236
+ end
237
+
238
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
239
+ result = RequestResult.new(status_code: response.status, duration: duration)
240
+ @hooks.on_request_end(info, result)
241
+
242
+ Response.new(
243
+ body: response.body,
244
+ status: response.status,
245
+ headers: response.headers
246
+ )
247
+ rescue Faraday::ClientError => e
248
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
249
+ error = handle_error(e)
250
+ result = RequestResult.new(
251
+ status_code: e.response&.dig(:status),
252
+ duration: duration,
253
+ error: error,
254
+ retry_after: error.respond_to?(:retry_after) ? error.retry_after : nil
255
+ )
256
+ @hooks.on_request_end(info, result)
257
+
258
+ # After a successful token refresh on 401, retry the request once
259
+ if error.is_a?(Basecamp::AuthError) && error.http_status == 401 && retry_count < 1 && @token_refreshed
260
+ @token_refreshed = false
261
+ return single_request(method, url, params: params, body: body, attempt: attempt, retry_count: retry_count + 1)
262
+ end
263
+
264
+ raise error
265
+ rescue Faraday::Error => e
266
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
267
+ error = Basecamp::NetworkError.new("Connection failed", cause: e)
268
+ result = RequestResult.new(duration: duration, error: error)
269
+ @hooks.on_request_end(info, result)
270
+ raise error
271
+ end
272
+ end
273
+
274
+ def request_headers
275
+ headers = {
276
+ "User-Agent" => USER_AGENT,
277
+ "Accept" => "application/json"
278
+ }
279
+ @auth_strategy.authenticate(headers)
280
+ headers
281
+ end
282
+
283
+ def single_request_raw(method, url, body:, content_type:, attempt:)
284
+ info = RequestInfo.new(method: method.to_s.upcase, url: url, attempt: attempt)
285
+ @hooks.on_request_start(info)
286
+
287
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
288
+
289
+ begin
290
+ headers = request_headers.merge("Content-Type" => content_type)
291
+ response = @faraday.run_request(method, url, body, headers)
292
+
293
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
294
+ result = RequestResult.new(status_code: response.status, duration: duration)
295
+ @hooks.on_request_end(info, result)
296
+
297
+ Response.new(
298
+ body: response.body,
299
+ status: response.status,
300
+ headers: response.headers
301
+ )
302
+ rescue Faraday::ClientError => e
303
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
304
+ error = handle_error(e)
305
+ result = RequestResult.new(
306
+ status_code: e.response&.dig(:status),
307
+ duration: duration,
308
+ error: error,
309
+ retry_after: error.respond_to?(:retry_after) ? error.retry_after : nil
310
+ )
311
+ @hooks.on_request_end(info, result)
312
+ raise error
313
+ rescue Faraday::Error => e
314
+ duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
315
+ error = Basecamp::NetworkError.new("Connection failed", cause: e)
316
+ result = RequestResult.new(duration: duration, error: error)
317
+ @hooks.on_request_end(info, result)
318
+ raise error
319
+ end
320
+ end
321
+
322
+ def handle_error(error)
323
+ status = error.response&.dig(:status)
324
+ body = error.response&.dig(:body)
325
+ headers = error.response&.dig(:headers) || {}
326
+
327
+ retry_after = parse_retry_after(headers["Retry-After"] || headers["retry-after"])
328
+
329
+ case status
330
+ when 401
331
+ # Try token refresh; flag for caller to retry
332
+ @token_refreshed = @token_provider&.refreshable? && @token_provider.refresh
333
+
334
+ Basecamp::AuthError.new("Authentication failed")
335
+ when 403
336
+ Basecamp::ForbiddenError.new("Access denied")
337
+ when 404
338
+ Basecamp::NotFoundError.new("Resource", "unknown")
339
+ when 429
340
+ Basecamp::RateLimitError.new(retry_after: retry_after)
341
+ when 400, 422
342
+ message = Security.truncate(Basecamp.parse_error_message(body) || "Validation failed")
343
+ Basecamp::ValidationError.new(message, http_status: status)
344
+ when 500
345
+ Basecamp::APIError.new("Server error (500)", http_status: 500)
346
+ when 502, 503, 504
347
+ Basecamp::APIError.new("Gateway error (#{status})", http_status: status, retryable: true)
348
+ else
349
+ message = Security.truncate(Basecamp.parse_error_message(body) || "Request failed (HTTP #{status})")
350
+ Basecamp::APIError.from_status(status || 0, message)
351
+ end
352
+ end
353
+
354
+ def build_url(path)
355
+ if path.start_with?("https://")
356
+ return path
357
+ elsif path.start_with?("http://")
358
+ raise Basecamp::UsageError.new("URL must use HTTPS: #{path}")
359
+ end
360
+
361
+ path = "/#{path}" unless path.start_with?("/")
362
+ "#{@config.base_url}#{path}"
363
+ end
364
+
365
+ def calculate_delay(attempt, server_retry_after)
366
+ return server_retry_after if server_retry_after&.positive?
367
+
368
+ # Exponential backoff: base_delay * 2^(attempt-1) + jitter
369
+ base = @config.base_delay * (2**(attempt - 1))
370
+ jitter = rand * @config.max_jitter
371
+ base + jitter
372
+ end
373
+
374
+ def parse_retry_after(value)
375
+ return nil if value.nil? || value.empty?
376
+
377
+ # Try parsing as seconds (integer)
378
+ seconds = Integer(value, exception: false)
379
+ return seconds if seconds&.positive?
380
+
381
+ # Try parsing as HTTP-date
382
+ begin
383
+ date = Time.httpdate(value)
384
+ diff = (date - Time.now).to_i
385
+ return diff if diff.positive?
386
+ rescue ArgumentError
387
+ # Not a valid HTTP-date
388
+ end
389
+
390
+ nil
391
+ end
392
+
393
+ def parse_next_link(link_header)
394
+ return nil if link_header.nil? || link_header.empty?
395
+
396
+ link_header.split(",").each do |part|
397
+ part = part.strip
398
+ next unless part.include?('rel="next"')
399
+
400
+ match = part.match(/<([^>]+)>/)
401
+ return match[1] if match
402
+ end
403
+
404
+ nil
405
+ end
406
+ end
407
+
408
+ # Wraps an HTTP response.
409
+ class Response
410
+ # @return [String] response body
411
+ attr_reader :body
412
+
413
+ # @return [Integer] HTTP status code
414
+ attr_reader :status
415
+
416
+ # @return [Hash] response headers
417
+ attr_reader :headers
418
+
419
+ def initialize(body:, status:, headers:)
420
+ @body = body
421
+ @status = status
422
+ @headers = headers
423
+ end
424
+
425
+ # Parses the response body as JSON.
426
+ # @return [Hash, Array]
427
+ def json
428
+ @json ||= begin
429
+ Security.check_body_size!(@body, Security::MAX_RESPONSE_BODY_BYTES)
430
+ JSON.parse(@body)
431
+ end
432
+ end
433
+
434
+ # Returns whether the response was successful (2xx).
435
+ # @return [Boolean]
436
+ def success?
437
+ status >= 200 && status < 300
438
+ end
439
+ end
440
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Hooks implementation that logs to Ruby's Logger.
5
+ #
6
+ # @example
7
+ # require "logger"
8
+ # logger = Logger.new($stdout)
9
+ # hooks = Basecamp::LoggerHooks.new(logger)
10
+ # client = Basecamp::Client.new(config: config, token_provider: provider, hooks: hooks)
11
+ class LoggerHooks
12
+ include Hooks
13
+
14
+ # @param logger [Logger] Ruby logger instance
15
+ # @param level [Symbol] log level (:debug, :info, :warn, :error)
16
+ def initialize(logger, level: :debug)
17
+ @logger = logger
18
+ @level = level
19
+ end
20
+
21
+ def on_request_start(info)
22
+ @logger.send(@level, "HTTP #{info.method} #{info.url} (attempt #{info.attempt})")
23
+ end
24
+
25
+ def on_request_end(info, result)
26
+ if result.error
27
+ @logger.send(@level, "HTTP #{info.method} #{info.url} failed: #{result.error.message}")
28
+ else
29
+ cache_info = result.from_cache ? " (cached)" : ""
30
+ @logger.send(@level,
31
+ "HTTP #{info.method} #{info.url} -> #{result.status_code}#{cache_info} (#{format("%.3f",
32
+ result.duration)}s)")
33
+ end
34
+ end
35
+
36
+ def on_retry(info, attempt, error, delay)
37
+ @logger.send(@level,
38
+ "Retrying #{info.method} #{info.url} (attempt #{attempt}) in #{format("%.2f",
39
+ delay)}s: #{error.message}")
40
+ end
41
+
42
+ def on_paginate(url, page)
43
+ @logger.send(@level, "Fetching page #{page}: #{url}")
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # No-op implementation of Hooks.
5
+ # Used as the default when no hooks are configured.
6
+ class NoopHooks
7
+ include Hooks
8
+ end
9
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Basecamp
7
+ # OAuth 2 helpers for Basecamp authentication.
8
+ 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
14
+ # @param http_client [Faraday::Connection, nil] HTTP client (uses default if nil)
15
+ # @param timeout [Integer] Request timeout in seconds (default: 10)
16
+ def initialize(http_client: nil, timeout: 10)
17
+ @http_client = http_client || build_default_client(timeout)
18
+ end
19
+
20
+ # Discovers OAuth configuration from the well-known endpoint.
21
+ #
22
+ # Fetches the OAuth 2 Authorization Server Metadata from:
23
+ # `{base_url}/.well-known/oauth-authorization-server`
24
+ #
25
+ # @param base_url [String] The OAuth server's base URL (e.g., "https://launchpad.37signals.com")
26
+ # @return [Config] The OAuth server configuration
27
+ # @raise [OAuthError] on network or parsing errors
28
+ #
29
+ # @example
30
+ # discoverer = Basecamp::Oauth::Discoverer.new
31
+ # config = discoverer.discover("https://launchpad.37signals.com")
32
+ # puts config.token_endpoint
33
+ # # => "https://launchpad.37signals.com/authorization/token"
34
+ def discover(base_url)
35
+ Basecamp::Security.require_https_unless_localhost!(base_url, "discovery base URL")
36
+
37
+ normalized_base = base_url.chomp("/")
38
+ discovery_url = "#{normalized_base}/.well-known/oauth-authorization-server"
39
+
40
+ response = @http_client.get(discovery_url) do |req|
41
+ req.headers["Accept"] = "application/json"
42
+ end
43
+
44
+ unless response.success?
45
+ raise OAuthError.new(
46
+ "network",
47
+ "OAuth discovery failed with status #{response.status}: #{Basecamp::Security.truncate(response.body)}",
48
+ http_status: response.status
49
+ )
50
+ end
51
+
52
+ Basecamp::Security.check_body_size!(response.body, Basecamp::Security::MAX_ERROR_BODY_BYTES, "Discovery")
53
+
54
+ data = JSON.parse(response.body)
55
+ validate_discovery_response!(data)
56
+
57
+ Config.new(
58
+ issuer: data["issuer"],
59
+ authorization_endpoint: data["authorization_endpoint"],
60
+ token_endpoint: data["token_endpoint"],
61
+ registration_endpoint: data["registration_endpoint"],
62
+ scopes_supported: data["scopes_supported"]
63
+ )
64
+ rescue Faraday::Error => e
65
+ raise OAuthError.new("network", "OAuth discovery failed: #{e.message}", retryable: true)
66
+ rescue JSON::ParserError => e
67
+ raise OAuthError.new("api_error", "Failed to parse discovery response: #{e.message}")
68
+ end
69
+
70
+ private
71
+
72
+ def build_default_client(timeout)
73
+ Faraday.new do |conn|
74
+ conn.options.timeout = timeout
75
+ conn.options.open_timeout = timeout
76
+ conn.adapter Faraday.default_adapter
77
+ end
78
+ end
79
+
80
+ def validate_discovery_response!(data)
81
+ missing = []
82
+ missing << "issuer" unless data["issuer"]
83
+ missing << "authorization_endpoint" unless data["authorization_endpoint"]
84
+ missing << "token_endpoint" unless data["token_endpoint"]
85
+
86
+ return if missing.empty?
87
+
88
+ raise OAuthError.new(
89
+ "api_error",
90
+ "Invalid OAuth discovery response: missing required fields: #{missing.join(", ")}"
91
+ )
92
+ end
93
+ 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
+ end
123
+ end