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.
- checksums.yaml +7 -0
- data/.rubocop.yml +14 -0
- data/.yardopts +6 -0
- data/README.md +293 -0
- data/Rakefile +26 -0
- data/basecamp-sdk.gemspec +46 -0
- data/lib/basecamp/auth_strategy.rb +38 -0
- data/lib/basecamp/chain_hooks.rb +45 -0
- data/lib/basecamp/client.rb +428 -0
- data/lib/basecamp/config.rb +143 -0
- data/lib/basecamp/errors.rb +289 -0
- data/lib/basecamp/generated/metadata.json +2281 -0
- data/lib/basecamp/generated/services/attachments_service.rb +24 -0
- data/lib/basecamp/generated/services/boosts_service.rb +70 -0
- data/lib/basecamp/generated/services/campfires_service.rb +122 -0
- data/lib/basecamp/generated/services/card_columns_service.rb +103 -0
- data/lib/basecamp/generated/services/card_steps_service.rb +57 -0
- data/lib/basecamp/generated/services/card_tables_service.rb +20 -0
- data/lib/basecamp/generated/services/cards_service.rb +66 -0
- data/lib/basecamp/generated/services/checkins_service.rb +157 -0
- data/lib/basecamp/generated/services/client_approvals_service.rb +28 -0
- data/lib/basecamp/generated/services/client_correspondences_service.rb +28 -0
- data/lib/basecamp/generated/services/client_replies_service.rb +30 -0
- data/lib/basecamp/generated/services/client_visibility_service.rb +21 -0
- data/lib/basecamp/generated/services/comments_service.rb +49 -0
- data/lib/basecamp/generated/services/documents_service.rb +52 -0
- data/lib/basecamp/generated/services/events_service.rb +20 -0
- data/lib/basecamp/generated/services/forwards_service.rb +67 -0
- data/lib/basecamp/generated/services/lineup_service.rb +44 -0
- data/lib/basecamp/generated/services/message_boards_service.rb +20 -0
- data/lib/basecamp/generated/services/message_types_service.rb +59 -0
- data/lib/basecamp/generated/services/messages_service.rb +75 -0
- data/lib/basecamp/generated/services/people_service.rb +73 -0
- data/lib/basecamp/generated/services/projects_service.rb +63 -0
- data/lib/basecamp/generated/services/recordings_service.rb +64 -0
- data/lib/basecamp/generated/services/reports_service.rb +56 -0
- data/lib/basecamp/generated/services/schedules_service.rb +92 -0
- data/lib/basecamp/generated/services/search_service.rb +31 -0
- data/lib/basecamp/generated/services/subscriptions_service.rb +50 -0
- data/lib/basecamp/generated/services/templates_service.rb +82 -0
- data/lib/basecamp/generated/services/timeline_service.rb +20 -0
- data/lib/basecamp/generated/services/timesheets_service.rb +81 -0
- data/lib/basecamp/generated/services/todolist_groups_service.rb +41 -0
- data/lib/basecamp/generated/services/todolists_service.rb +53 -0
- data/lib/basecamp/generated/services/todos_service.rb +106 -0
- data/lib/basecamp/generated/services/todosets_service.rb +20 -0
- data/lib/basecamp/generated/services/tools_service.rb +80 -0
- data/lib/basecamp/generated/services/uploads_service.rb +61 -0
- data/lib/basecamp/generated/services/vaults_service.rb +49 -0
- data/lib/basecamp/generated/services/webhooks_service.rb +63 -0
- data/lib/basecamp/generated/types.rb +3196 -0
- data/lib/basecamp/hooks.rb +70 -0
- data/lib/basecamp/http.rb +440 -0
- data/lib/basecamp/logger_hooks.rb +46 -0
- data/lib/basecamp/noop_hooks.rb +9 -0
- data/lib/basecamp/oauth/discovery.rb +123 -0
- data/lib/basecamp/oauth/errors.rb +35 -0
- data/lib/basecamp/oauth/exchange.rb +291 -0
- data/lib/basecamp/oauth/pkce.rb +68 -0
- data/lib/basecamp/oauth/types.rb +133 -0
- data/lib/basecamp/oauth.rb +56 -0
- data/lib/basecamp/oauth_token_provider.rb +108 -0
- data/lib/basecamp/operation_info.rb +17 -0
- data/lib/basecamp/request_info.rb +10 -0
- data/lib/basecamp/request_result.rb +14 -0
- data/lib/basecamp/security.rb +112 -0
- data/lib/basecamp/services/attachments_service.rb +33 -0
- data/lib/basecamp/services/authorization_service.rb +47 -0
- data/lib/basecamp/services/base_service.rb +146 -0
- data/lib/basecamp/services/campfires_service.rb +141 -0
- data/lib/basecamp/services/card_columns_service.rb +106 -0
- data/lib/basecamp/services/card_steps_service.rb +86 -0
- data/lib/basecamp/services/card_tables_service.rb +23 -0
- data/lib/basecamp/services/cards_service.rb +93 -0
- data/lib/basecamp/services/checkins_service.rb +127 -0
- data/lib/basecamp/services/client_approvals_service.rb +33 -0
- data/lib/basecamp/services/client_correspondences_service.rb +33 -0
- data/lib/basecamp/services/client_replies_service.rb +35 -0
- data/lib/basecamp/services/comments_service.rb +63 -0
- data/lib/basecamp/services/documents_service.rb +74 -0
- data/lib/basecamp/services/events_service.rb +27 -0
- data/lib/basecamp/services/forwards_service.rb +80 -0
- data/lib/basecamp/services/lineup_service.rb +67 -0
- data/lib/basecamp/services/message_boards_service.rb +24 -0
- data/lib/basecamp/services/message_types_service.rb +79 -0
- data/lib/basecamp/services/messages_service.rb +133 -0
- data/lib/basecamp/services/people_service.rb +73 -0
- data/lib/basecamp/services/projects_service.rb +67 -0
- data/lib/basecamp/services/recordings_service.rb +127 -0
- data/lib/basecamp/services/reports_service.rb +80 -0
- data/lib/basecamp/services/schedules_service.rb +156 -0
- data/lib/basecamp/services/search_service.rb +36 -0
- data/lib/basecamp/services/subscriptions_service.rb +67 -0
- data/lib/basecamp/services/templates_service.rb +96 -0
- data/lib/basecamp/services/timeline_service.rb +62 -0
- data/lib/basecamp/services/timesheet_service.rb +68 -0
- data/lib/basecamp/services/todolist_groups_service.rb +100 -0
- data/lib/basecamp/services/todolists_service.rb +104 -0
- data/lib/basecamp/services/todos_service.rb +156 -0
- data/lib/basecamp/services/todosets_service.rb +23 -0
- data/lib/basecamp/services/tools_service.rb +89 -0
- data/lib/basecamp/services/uploads_service.rb +84 -0
- data/lib/basecamp/services/vaults_service.rb +84 -0
- data/lib/basecamp/services/webhooks_service.rb +88 -0
- data/lib/basecamp/static_token_provider.rb +24 -0
- data/lib/basecamp/token_provider.rb +42 -0
- data/lib/basecamp/version.rb +6 -0
- data/lib/basecamp/webhooks/event.rb +52 -0
- data/lib/basecamp/webhooks/rack_middleware.rb +49 -0
- data/lib/basecamp/webhooks/receiver.rb +161 -0
- data/lib/basecamp/webhooks/verify.rb +36 -0
- data/lib/basecamp.rb +107 -0
- data/scripts/generate-metadata.rb +106 -0
- data/scripts/generate-services.rb +778 -0
- data/scripts/generate-types.rb +191 -0
- 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
|