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,428 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ # Main client for the Basecamp API.
5
+ #
6
+ # Client holds shared resources and is used to create AccountClient instances
7
+ # for specific Basecamp accounts via the {#for_account} method.
8
+ #
9
+ # @example Basic usage
10
+ # config = Basecamp::Config.from_env
11
+ # token_provider = Basecamp::StaticTokenProvider.new(ENV["BASECAMP_ACCESS_TOKEN"])
12
+ # client = Basecamp::Client.new(config: config, token_provider: token_provider)
13
+ #
14
+ # # Get authorization info (account-independent)
15
+ # auth = client.authorization.get
16
+ #
17
+ # # Work with a specific account
18
+ # account = client.for_account("12345")
19
+ # projects = account.projects.list
20
+ #
21
+ # @example With custom hooks
22
+ # require "logger"
23
+ # logger = Logger.new($stdout)
24
+ # hooks = Basecamp::LoggerHooks.new(logger)
25
+ #
26
+ # client = Basecamp::Client.new(
27
+ # config: config,
28
+ # token_provider: token_provider,
29
+ # hooks: hooks
30
+ # )
31
+ class Client
32
+ # @return [Config] client configuration
33
+ attr_reader :config
34
+
35
+ # Creates a new Basecamp API client.
36
+ #
37
+ # @param config [Config] configuration settings
38
+ # @param token_provider [TokenProvider, nil] OAuth token provider (deprecated, use auth_strategy)
39
+ # @param auth_strategy [AuthStrategy, nil] authentication strategy
40
+ # @param hooks [Hooks, nil] observability hooks
41
+ def initialize(config:, token_provider: nil, auth_strategy: nil, hooks: nil)
42
+ raise ArgumentError, "provide either token_provider or auth_strategy, not both" if token_provider && auth_strategy
43
+ raise ArgumentError, "provide token_provider or auth_strategy" if !token_provider && !auth_strategy
44
+
45
+ @config = config
46
+ @hooks = hooks || NoopHooks.new
47
+ @http = Http.new(config: config, token_provider: token_provider, auth_strategy: auth_strategy, hooks: @hooks)
48
+ @mutex = Mutex.new
49
+ end
50
+
51
+ # Returns an AccountClient bound to the specified Basecamp account.
52
+ #
53
+ # The Basecamp API requires an account ID in the URL path
54
+ # (e.g., https://3.basecampapi.com/12345/projects.json).
55
+ #
56
+ # @param account_id [String, Integer] the Basecamp account ID
57
+ # @return [AccountClient]
58
+ # @raise [ArgumentError] if account_id is empty or non-numeric
59
+ #
60
+ # @example
61
+ # account = client.for_account("12345")
62
+ # projects = account.projects.list
63
+ def for_account(account_id)
64
+ account_id = account_id.to_s
65
+ raise ArgumentError, "account_id cannot be empty" if account_id.empty?
66
+ raise ArgumentError, "account_id must be numeric, got: #{account_id}" unless account_id.match?(/\A\d+\z/)
67
+
68
+ AccountClient.new(parent: self, account_id: account_id)
69
+ end
70
+
71
+ # Returns the AuthorizationService for authorization operations.
72
+ # This is the only service available directly on Client, as it doesn't require
73
+ # an account context. All other services require an AccountClient via {#for_account}.
74
+ #
75
+ # @return [Services::AuthorizationService]
76
+ def authorization
77
+ @mutex.synchronize do
78
+ @authorization ||= Services::AuthorizationService.new(self)
79
+ end
80
+ end
81
+
82
+ # @api private
83
+ # Returns the HTTP client for making requests.
84
+ # @return [Http]
85
+ attr_reader :http
86
+
87
+ # @api private
88
+ # Returns the observability hooks.
89
+ # @return [Hooks]
90
+ attr_reader :hooks
91
+
92
+ # @api private
93
+ # Returns nil since Client is not bound to an account.
94
+ # @return [nil]
95
+ def account_id
96
+ nil
97
+ end
98
+ end
99
+
100
+ # HTTP client bound to a specific Basecamp account.
101
+ #
102
+ # Create an AccountClient using {Client#for_account}.
103
+ # All API operations that require an account context use this class.
104
+ #
105
+ # @example
106
+ # account = client.for_account("12345")
107
+ #
108
+ # # List projects
109
+ # account.projects.list.each do |project|
110
+ # puts project["name"]
111
+ # end
112
+ #
113
+ # # Create a todo
114
+ # account.todos.create(
115
+ # project_id: 123,
116
+ # todolist_id: 456,
117
+ # content: "New task"
118
+ # )
119
+ class AccountClient
120
+ # @return [String] the account ID this client is bound to
121
+ attr_reader :account_id
122
+
123
+ # @api private
124
+ # @param parent [Client] the parent client
125
+ # @param account_id [String] the account ID
126
+ def initialize(parent:, account_id:)
127
+ @parent = parent
128
+ @account_id = account_id
129
+ @services = {}
130
+ @mutex = Mutex.new
131
+ end
132
+
133
+ # @return [Config] client configuration
134
+ def config
135
+ @parent.config
136
+ end
137
+
138
+ # @api private
139
+ # @return [Http] the HTTP client
140
+ def http
141
+ @parent.http
142
+ end
143
+
144
+ # @api private
145
+ # @return [Hooks] the observability hooks
146
+ def hooks
147
+ @parent.hooks
148
+ end
149
+
150
+ # Performs a GET request scoped to this account.
151
+ # @param path [String] URL path (without account prefix)
152
+ # @param params [Hash] query parameters
153
+ # @return [Response]
154
+ def get(path, params: {})
155
+ @parent.http.get(account_path(path), params: params)
156
+ end
157
+
158
+ # Performs a POST request scoped to this account.
159
+ # @param path [String] URL path (without account prefix)
160
+ # @param body [Hash, nil] request body
161
+ # @return [Response]
162
+ def post(path, body: nil)
163
+ @parent.http.post(account_path(path), body: body)
164
+ end
165
+
166
+ # Performs a PUT request scoped to this account.
167
+ # @param path [String] URL path (without account prefix)
168
+ # @param body [Hash, nil] request body
169
+ # @return [Response]
170
+ def put(path, body: nil)
171
+ @parent.http.put(account_path(path), body: body)
172
+ end
173
+
174
+ # Performs a DELETE request scoped to this account.
175
+ # @param path [String] URL path (without account prefix)
176
+ # @return [Response]
177
+ def delete(path)
178
+ @parent.http.delete(account_path(path))
179
+ end
180
+
181
+ # Performs a POST request with raw binary data scoped to this account.
182
+ # Used for file uploads (attachments).
183
+ # @param path [String] URL path (without account prefix)
184
+ # @param body [String, IO] raw binary data
185
+ # @param content_type [String] MIME content type
186
+ # @return [Response]
187
+ def post_raw(path, body:, content_type:)
188
+ @parent.http.post_raw(account_path(path), body: body, content_type: content_type)
189
+ end
190
+
191
+ # Fetches all pages of a paginated resource.
192
+ # @param path [String] URL path (without account prefix)
193
+ # @param params [Hash] query parameters
194
+ # @yield [Hash] each item from the response
195
+ # @return [Enumerator] if no block given
196
+ def paginate(path, params: {}, &)
197
+ @parent.http.paginate(account_path(path), params: params, &)
198
+ end
199
+
200
+ # Fetches all pages of a paginated resource, extracting items from a key.
201
+ # Use this for endpoints that return objects like { "events": [...] }.
202
+ # @param path [String] URL path (without account prefix)
203
+ # @param key [String] the key containing the array of items
204
+ # @param params [Hash] query parameters
205
+ # @yield [Hash] each item from the response
206
+ # @return [Enumerator] if no block given
207
+ def paginate_key(path, key:, params: {}, &)
208
+ @parent.http.paginate_key(account_path(path), key: key, params: params, &)
209
+ end
210
+
211
+ # @!group Services
212
+
213
+ # @return [Services::ProjectsService]
214
+ def projects
215
+ service(:projects) { Services::ProjectsService.new(self) }
216
+ end
217
+
218
+ # @return [Services::TodosService]
219
+ def todos
220
+ service(:todos) { Services::TodosService.new(self) }
221
+ end
222
+
223
+ # @return [Services::TodosetsService]
224
+ def todosets
225
+ service(:todosets) { Services::TodosetsService.new(self) }
226
+ end
227
+
228
+ # @return [Services::TodolistsService]
229
+ def todolists
230
+ service(:todolists) { Services::TodolistsService.new(self) }
231
+ end
232
+
233
+ # @return [Services::PeopleService]
234
+ def people
235
+ service(:people) { Services::PeopleService.new(self) }
236
+ end
237
+
238
+ # @return [Services::CommentsService]
239
+ def comments
240
+ service(:comments) { Services::CommentsService.new(self) }
241
+ end
242
+
243
+ # @return [Services::MessagesService]
244
+ def messages
245
+ service(:messages) { Services::MessagesService.new(self) }
246
+ end
247
+
248
+ # @return [Services::MessageBoardsService]
249
+ def message_boards
250
+ service(:message_boards) { Services::MessageBoardsService.new(self) }
251
+ end
252
+
253
+ # @return [Services::WebhooksService]
254
+ def webhooks
255
+ service(:webhooks) { Services::WebhooksService.new(self) }
256
+ end
257
+
258
+ # @return [Services::CampfiresService]
259
+ def campfires
260
+ service(:campfires) { Services::CampfiresService.new(self) }
261
+ end
262
+
263
+ # @return [Services::SchedulesService]
264
+ def schedules
265
+ service(:schedules) { Services::SchedulesService.new(self) }
266
+ end
267
+
268
+ # @return [Services::VaultsService]
269
+ def vaults
270
+ service(:vaults) { Services::VaultsService.new(self) }
271
+ end
272
+
273
+ # @return [Services::RecordingsService]
274
+ def recordings
275
+ service(:recordings) { Services::RecordingsService.new(self) }
276
+ end
277
+
278
+ # @return [Services::DocumentsService]
279
+ def documents
280
+ service(:documents) { Services::DocumentsService.new(self) }
281
+ end
282
+
283
+ # @return [Services::UploadsService]
284
+ def uploads
285
+ service(:uploads) { Services::UploadsService.new(self) }
286
+ end
287
+
288
+ # @return [Services::AttachmentsService]
289
+ def attachments
290
+ service(:attachments) { Services::AttachmentsService.new(self) }
291
+ end
292
+
293
+ # @return [Services::CheckinsService]
294
+ def checkins
295
+ service(:checkins) { Services::CheckinsService.new(self) }
296
+ end
297
+
298
+ # @return [Services::ForwardsService]
299
+ def forwards
300
+ service(:forwards) { Services::ForwardsService.new(self) }
301
+ end
302
+
303
+ # @return [Services::CardTablesService]
304
+ def card_tables
305
+ service(:card_tables) { Services::CardTablesService.new(self) }
306
+ end
307
+
308
+ # @return [Services::CardsService]
309
+ def cards
310
+ service(:cards) { Services::CardsService.new(self) }
311
+ end
312
+
313
+ # @return [Services::CardColumnsService]
314
+ def card_columns
315
+ service(:card_columns) { Services::CardColumnsService.new(self) }
316
+ end
317
+
318
+ # @return [Services::CardStepsService]
319
+ def card_steps
320
+ service(:card_steps) { Services::CardStepsService.new(self) }
321
+ end
322
+
323
+ # @return [Services::TemplatesService]
324
+ def templates
325
+ service(:templates) { Services::TemplatesService.new(self) }
326
+ end
327
+
328
+ # @return [Services::EventsService]
329
+ def events
330
+ service(:events) { Services::EventsService.new(self) }
331
+ end
332
+
333
+ # @return [Services::ClientApprovalsService]
334
+ def client_approvals
335
+ service(:client_approvals) { Services::ClientApprovalsService.new(self) }
336
+ end
337
+
338
+ # @return [Services::ClientCorrespondencesService]
339
+ def client_correspondences
340
+ service(:client_correspondences) { Services::ClientCorrespondencesService.new(self) }
341
+ end
342
+
343
+ # @return [Services::ClientRepliesService]
344
+ def client_replies
345
+ service(:client_replies) { Services::ClientRepliesService.new(self) }
346
+ end
347
+
348
+ # @return [Services::LineupService]
349
+ def lineup
350
+ service(:lineup) { Services::LineupService.new(self) }
351
+ end
352
+
353
+ # @return [Services::MessageTypesService]
354
+ def message_types
355
+ service(:message_types) { Services::MessageTypesService.new(self) }
356
+ end
357
+
358
+ # @return [Services::ToolsService]
359
+ def tools
360
+ service(:tools) { Services::ToolsService.new(self) }
361
+ end
362
+
363
+ # @return [Services::SubscriptionsService]
364
+ def subscriptions
365
+ service(:subscriptions) { Services::SubscriptionsService.new(self) }
366
+ end
367
+
368
+ # @return [Services::SearchService]
369
+ def search
370
+ service(:search) { Services::SearchService.new(self) }
371
+ end
372
+
373
+ # @return [Services::ReportsService]
374
+ def reports
375
+ service(:reports) { Services::ReportsService.new(self) }
376
+ end
377
+
378
+ # @return [Services::TimelineService]
379
+ def timeline
380
+ service(:timeline) { Services::TimelineService.new(self) }
381
+ end
382
+
383
+ # @return [Services::TimesheetsService]
384
+ def timesheets
385
+ service(:timesheets) { Services::TimesheetsService.new(self) }
386
+ end
387
+
388
+ # @return [Services::ClientVisibilityService]
389
+ def client_visibility
390
+ service(:client_visibility) { Services::ClientVisibilityService.new(self) }
391
+ end
392
+
393
+ # @return [Services::TodolistGroupsService]
394
+ def todolist_groups
395
+ service(:todolist_groups) { Services::TodolistGroupsService.new(self) }
396
+ end
397
+
398
+ # @return [Services::BoostsService]
399
+ def boosts
400
+ service(:boosts) { Services::BoostsService.new(self) }
401
+ end
402
+
403
+ # @!endgroup
404
+
405
+ private
406
+
407
+ def account_path(path)
408
+ return path if path.start_with?("http://", "https://")
409
+
410
+ path = "/#{path}" unless path.start_with?("/")
411
+
412
+ # Guard against double-prefixing
413
+ prefix = "/#{@account_id}"
414
+ if path.start_with?(prefix)
415
+ rest = path[prefix.length..]
416
+ return path if rest.empty? || rest.start_with?("/", "?")
417
+ end
418
+
419
+ "/#{@account_id}#{path}"
420
+ end
421
+
422
+ def service(name)
423
+ @mutex.synchronize do
424
+ @services[name] ||= yield
425
+ end
426
+ end
427
+ end
428
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Basecamp
6
+ # Configuration for the Basecamp API client.
7
+ #
8
+ # @example Creating config with defaults
9
+ # config = Basecamp::Config.new
10
+ #
11
+ # @example Creating config with custom values
12
+ # config = Basecamp::Config.new(
13
+ # base_url: "https://3.basecampapi.com",
14
+ # timeout: 60,
15
+ # max_retries: 3
16
+ # )
17
+ #
18
+ # @example Loading config from environment
19
+ # config = Basecamp::Config.from_env
20
+ class Config
21
+ # @return [String] API base URL
22
+ attr_accessor :base_url
23
+
24
+ # @return [Integer] request timeout in seconds
25
+ attr_accessor :timeout
26
+
27
+ # @return [Integer] maximum retry attempts for GET requests
28
+ attr_accessor :max_retries
29
+
30
+ # @return [Float] initial backoff delay in seconds
31
+ attr_accessor :base_delay
32
+
33
+ # @return [Float] maximum jitter to add to delays in seconds
34
+ attr_accessor :max_jitter
35
+
36
+ # @return [Integer] maximum pages to fetch in paginated requests
37
+ attr_accessor :max_pages
38
+
39
+ # Default values
40
+ DEFAULT_BASE_URL = "https://3.basecampapi.com"
41
+ DEFAULT_TIMEOUT = 30
42
+ DEFAULT_MAX_RETRIES = 3
43
+ DEFAULT_BASE_DELAY = 1.0
44
+ DEFAULT_MAX_JITTER = 0.1
45
+ DEFAULT_MAX_PAGES = 10_000
46
+
47
+ # Creates a new configuration with the given options.
48
+ #
49
+ # @param base_url [String] API base URL
50
+ # @param timeout [Integer] request timeout in seconds
51
+ # @param max_retries [Integer] maximum retry attempts
52
+ # @param base_delay [Float] initial backoff delay
53
+ # @param max_jitter [Float] maximum jitter
54
+ # @param max_pages [Integer] maximum pages to fetch
55
+ def initialize(
56
+ base_url: DEFAULT_BASE_URL,
57
+ timeout: DEFAULT_TIMEOUT,
58
+ max_retries: DEFAULT_MAX_RETRIES,
59
+ base_delay: DEFAULT_BASE_DELAY,
60
+ max_jitter: DEFAULT_MAX_JITTER,
61
+ max_pages: DEFAULT_MAX_PAGES
62
+ )
63
+ @base_url = normalize_url(base_url)
64
+ @timeout = timeout
65
+ @max_retries = max_retries
66
+ @base_delay = base_delay
67
+ @max_jitter = max_jitter
68
+ @max_pages = max_pages
69
+
70
+ unless @base_url == normalize_url(DEFAULT_BASE_URL) || localhost?(@base_url)
71
+ Basecamp::Security.require_https!(@base_url, "base URL")
72
+ end
73
+ validate!
74
+ end
75
+
76
+ # Creates a Config from environment variables.
77
+ #
78
+ # Environment variables:
79
+ # - BASECAMP_BASE_URL: API base URL
80
+ # - BASECAMP_TIMEOUT: Request timeout in seconds
81
+ # - BASECAMP_MAX_RETRIES: Maximum retry attempts
82
+ #
83
+ # @return [Config]
84
+ def self.from_env
85
+ new(
86
+ base_url: ENV.fetch("BASECAMP_BASE_URL", DEFAULT_BASE_URL),
87
+ timeout: ENV.fetch("BASECAMP_TIMEOUT", DEFAULT_TIMEOUT).to_i,
88
+ max_retries: ENV.fetch("BASECAMP_MAX_RETRIES", DEFAULT_MAX_RETRIES).to_i
89
+ )
90
+ end
91
+
92
+ # Loads configuration from a JSON file, with environment overrides.
93
+ #
94
+ # @param path [String] path to JSON config file
95
+ # @return [Config]
96
+ def self.from_file(path)
97
+ data = JSON.parse(File.read(path))
98
+ config = new(
99
+ base_url: data["base_url"] || DEFAULT_BASE_URL,
100
+ timeout: data["timeout"] || DEFAULT_TIMEOUT,
101
+ max_retries: data["max_retries"] || DEFAULT_MAX_RETRIES
102
+ )
103
+ config.load_from_env
104
+ config
105
+ rescue Errno::ENOENT
106
+ from_env
107
+ end
108
+
109
+ # Loads environment variable overrides into this config.
110
+ # @return [self]
111
+ def load_from_env
112
+ @base_url = normalize_url(ENV["BASECAMP_BASE_URL"]) if ENV["BASECAMP_BASE_URL"]
113
+ @timeout = ENV["BASECAMP_TIMEOUT"].to_i if ENV["BASECAMP_TIMEOUT"]
114
+ @max_retries = ENV["BASECAMP_MAX_RETRIES"].to_i if ENV["BASECAMP_MAX_RETRIES"]
115
+ Basecamp::Security.require_https!(@base_url, "base URL") unless localhost?(@base_url)
116
+ validate!
117
+ self
118
+ end
119
+
120
+ # Returns the default global config directory.
121
+ # @return [String]
122
+ def self.global_config_dir
123
+ config_dir = ENV["XDG_CONFIG_HOME"] || File.join(Dir.home, ".config")
124
+ File.join(config_dir, "basecamp")
125
+ end
126
+
127
+ private
128
+
129
+ def validate!
130
+ raise ArgumentError, "timeout must be positive" unless @timeout.is_a?(Numeric) && @timeout > 0
131
+ raise ArgumentError, "max_retries must be non-negative" unless @max_retries.is_a?(Integer) && @max_retries >= 0
132
+ raise ArgumentError, "max_pages must be positive" unless @max_pages.is_a?(Integer) && @max_pages > 0
133
+ end
134
+
135
+ def normalize_url(url)
136
+ url&.chomp("/")
137
+ end
138
+
139
+ def localhost?(url)
140
+ Basecamp::Security.localhost?(url)
141
+ end
142
+ end
143
+ end