Class: Basecamp::AccountClient

Inherits:
Object
  • Object
show all
Defined in:
lib/basecamp/client.rb

Overview

HTTP client bound to a specific Basecamp account.

Create an AccountClient using Client#for_account. All API operations that require an account context use this class.

Examples:

 = client.("12345")

# List projects
.projects.list.each do |project|
  puts project["name"]
end

# Create a todo
.todos.create(
  project_id: 123,
  todolist_id: 456,
  content: "New task"
)

Instance Attribute Summary collapse

Services collapse

Instance Method Summary collapse

Constructor Details

#initialize(parent:, account_id:) ⇒ AccountClient

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns a new instance of AccountClient.

Parameters:

  • parent (Client)

    the parent client

  • account_id (String)

    the account ID



127
128
129
130
131
132
# File 'lib/basecamp/client.rb', line 127

def initialize(parent:, account_id:)
  @parent = parent
  @account_id = 
  @services = {}
  @mutex = Mutex.new
end

Instance Attribute Details

#account_idString (readonly)

Returns the account ID this client is bound to.

Returns:

  • (String)

    the account ID this client is bound to



122
123
124
# File 'lib/basecamp/client.rb', line 122

def 
  @account_id
end

Instance Method Details

#accountServices::AccountService



574
575
576
# File 'lib/basecamp/client.rb', line 574

def 
  service(:account) { Services::AccountService.new(self) }
end

#attachmentsServices::AttachmentsService



419
420
421
# File 'lib/basecamp/client.rb', line 419

def attachments
  service(:attachments) { Services::AttachmentsService.new(self) }
end

#automationServices::AutomationService



489
490
491
# File 'lib/basecamp/client.rb', line 489

def automation
  service(:automation) { Services::AutomationService.new(self) }
end

#bookmarksServices::BookmarksService



504
505
506
# File 'lib/basecamp/client.rb', line 504

def bookmarks
  service(:bookmarks) { Services::BookmarksService.new(self) }
end

#boostsServices::BoostsService



569
570
571
# File 'lib/basecamp/client.rb', line 569

def boosts
  service(:boosts) { Services::BoostsService.new(self) }
end

#calendarsServices::CalendarsService



519
520
521
# File 'lib/basecamp/client.rb', line 519

def calendars
  service(:calendars) { Services::CalendarsService.new(self) }
end

#campfiresServices::CampfiresService



379
380
381
# File 'lib/basecamp/client.rb', line 379

def campfires
  service(:campfires) { Services::CampfiresService.new(self) }
end

#card_columnsServices::CardColumnsService



444
445
446
# File 'lib/basecamp/client.rb', line 444

def card_columns
  service(:card_columns) { Services::CardColumnsService.new(self) }
end

#card_stepsServices::CardStepsService



449
450
451
# File 'lib/basecamp/client.rb', line 449

def card_steps
  service(:card_steps) { Services::CardStepsService.new(self) }
end

#card_tablesServices::CardTablesService



434
435
436
# File 'lib/basecamp/client.rb', line 434

def card_tables
  service(:card_tables) { Services::CardTablesService.new(self) }
end

#cardsServices::CardsService



439
440
441
# File 'lib/basecamp/client.rb', line 439

def cards
  service(:cards) { Services::CardsService.new(self) }
end

#checkinsServices::CheckinsService



424
425
426
# File 'lib/basecamp/client.rb', line 424

def checkins
  service(:checkins) { Services::CheckinsService.new(self) }
end

#client_approvalsServices::ClientApprovalsService



469
470
471
# File 'lib/basecamp/client.rb', line 469

def client_approvals
  service(:client_approvals) { Services::ClientApprovalsService.new(self) }
end

#client_correspondencesServices::ClientCorrespondencesService



474
475
476
# File 'lib/basecamp/client.rb', line 474

def client_correspondences
  service(:client_correspondences) { Services::ClientCorrespondencesService.new(self) }
end

#client_repliesServices::ClientRepliesService



479
480
481
# File 'lib/basecamp/client.rb', line 479

def client_replies
  service(:client_replies) { Services::ClientRepliesService.new(self) }
end

#client_visibilityServices::ClientVisibilityService



559
560
561
# File 'lib/basecamp/client.rb', line 559

def client_visibility
  service(:client_visibility) { Services::ClientVisibilityService.new(self) }
end

#cloud_filesServices::CloudFilesService



404
405
406
# File 'lib/basecamp/client.rb', line 404

def cloud_files
  service(:cloud_files) { Services::CloudFilesService.new(self) }
end

#commentsServices::CommentsService



359
360
361
# File 'lib/basecamp/client.rb', line 359

def comments
  service(:comments) { Services::CommentsService.new(self) }
end

#configConfig

Returns client configuration.

Returns:

  • (Config)

    client configuration



135
136
137
# File 'lib/basecamp/client.rb', line 135

def config
  @parent.config
end

#delete(path) ⇒ Response

Performs a DELETE request scoped to this account.

Parameters:

  • path (String)

    URL path (without account prefix)

Returns:



178
179
180
# File 'lib/basecamp/client.rb', line 178

def delete(path)
  @parent.http.delete((path))
end

#documentsServices::DocumentsService



399
400
401
# File 'lib/basecamp/client.rb', line 399

def documents
  service(:documents) { Services::DocumentsService.new(self) }
end

#download_url(raw_url) ⇒ DownloadResult

Downloads file content from any API-routable download URL.

Handles the full download flow: URL rewriting to the configured API host, authenticated first hop (which typically 302s to a signed download URL), and unauthenticated second hop to fetch the actual file content.

Parameters:

  • raw_url (String)

    absolute download URL (e.g., from bc-attachment elements)

Returns:

  • (DownloadResult)

    the download result with body, content_type, content_length, filename

Raises:

  • (UsageError)

    if raw_url is empty or not absolute

  • (NetworkError)

    if a network error occurs

  • (ApiError)

    if the API or download returns an error



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
# File 'lib/basecamp/client.rb', line 250

def download_url(raw_url)
  # Validation
  raise UsageError.new("download URL is required") if raw_url.nil? || raw_url.to_s.empty?

  begin
    parsed = URI.parse(raw_url)
  rescue URI::InvalidURIError
    raise UsageError.new("download URL must be an absolute URL")
  end
  raise UsageError.new("download URL must be an absolute URL") unless parsed.is_a?(URI::HTTP)

  # Operation hooks
  op = OperationInfo.new(
    service: "Account", operation: "DownloadURL",
    resource_type: "download", is_mutation: false
  )
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  safe_hook { hooks.on_operation_start(op) }

  begin
    # URL rewriting: replace scheme+host with config.base_url origin, preserve path+query+fragment
    base = URI.parse(config.base_url)
    rewritten = parsed.dup
    rewritten.scheme = base.scheme
    rewritten.host = base.host
    rewritten.port = base.port
    rewritten_url = rewritten.to_s

    # Hop 1: Authenticated API request under the SPEC §14 hop-1 retry
    # policy (captures redirect; every attempt is authenticated)
    response = http.get_download(rewritten_url)

    result = case response.status
    when 301, 302, 303, 307, 308
      # Redirect — extract Location, proceed to hop 2
      location = response.headers["Location"] || response.headers["location"]
      raise ApiError.new("redirect #{response.status} with no Location header") if location.nil? || location.empty?

      # Resolve relative Location against the rewritten API URL
      resolved_url = Security.resolve_url(rewritten_url, location)

      # Hop 2: fetch from signed URL (no auth, no hooks)
      signed_response = fetch_signed_download(resolved_url)

      DownloadResult.new(
        body: signed_response.body,
        content_type: signed_response["Content-Type"] || "",
        content_length: parse_content_length(signed_response["Content-Length"]),
        filename: Basecamp.filename_from_url(raw_url)
      )

    when 200..299
      # Direct download — no second hop
      DownloadResult.new(
        body: response.body,
        content_type: response.headers["Content-Type"] || response.headers["content-type"] || "",
        content_length: parse_content_length(response.headers["Content-Length"] || response.headers["content-length"]),
        filename: Basecamp.filename_from_url(raw_url)
      )

    else
      # This shouldn't happen because Faraday's raise_error middleware
      # handles 4xx/5xx, but handle it defensively
      raise Basecamp.error_from_response(response.status, response.body)
    end
  rescue => e
    duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
    safe_hook { hooks.on_operation_end(op, OperationResult.new(duration_ms: duration, error: e)) }
    raise
  else
    duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
    safe_hook { hooks.on_operation_end(op, OperationResult.new(duration_ms: duration, error: nil)) }
    result
  end
end

#draftsServices::DraftsService



514
515
516
# File 'lib/basecamp/client.rb', line 514

def drafts
  service(:drafts) { Services::DraftsService.new(self) }
end

#eventsServices::EventsService



464
465
466
# File 'lib/basecamp/client.rb', line 464

def events
  service(:events) { Services::EventsService.new(self) }
end

#everythingServices::EverythingService



549
550
551
# File 'lib/basecamp/client.rb', line 549

def everything
  service(:everything) { Services::EverythingService.new(self) }
end

#foldersServices::FoldersService



509
510
511
# File 'lib/basecamp/client.rb', line 509

def folders
  service(:folders) { Services::FoldersService.new(self) }
end

#forwardsServices::ForwardsService



429
430
431
# File 'lib/basecamp/client.rb', line 429

def forwards
  service(:forwards) { Services::ForwardsService.new(self) }
end

#gaugesServices::GaugesService



579
580
581
# File 'lib/basecamp/client.rb', line 579

def gauges
  service(:gauges) { Services::GaugesService.new(self) }
end

#get(path, params: {}, operation: nil) ⇒ Response

Performs a GET request scoped to this account.

Parameters:

  • path (String)

    URL path (without account prefix)

  • params (Hash) (defaults to: {})

    query parameters

Returns:



155
156
157
# File 'lib/basecamp/client.rb', line 155

def get(path, params: {}, operation: nil)
  @parent.http.get((path), params: params, operation: operation)
end

#google_documentsServices::GoogleDocumentsService



409
410
411
# File 'lib/basecamp/client.rb', line 409

def google_documents
  service(:google_documents) { Services::GoogleDocumentsService.new(self) }
end

#hill_chartsServices::HillChartsService



344
345
346
# File 'lib/basecamp/client.rb', line 344

def hill_charts
  service(:hill_charts) { Services::HillChartsService.new(self) }
end

#hooksHooks

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns the observability hooks.

Returns:

  • (Hooks)

    the observability hooks



147
148
149
# File 'lib/basecamp/client.rb', line 147

def hooks
  @parent.hooks
end

#httpHttp

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns the HTTP client.

Returns:

  • (Http)

    the HTTP client



141
142
143
# File 'lib/basecamp/client.rb', line 141

def http
  @parent.http
end

#lineupServices::LineupService



484
485
486
# File 'lib/basecamp/client.rb', line 484

def lineup
  service(:lineup) { Services::LineupService.new(self) }
end

#message_boardsServices::MessageBoardsService



369
370
371
# File 'lib/basecamp/client.rb', line 369

def message_boards
  service(:message_boards) { Services::MessageBoardsService.new(self) }
end

#message_typesServices::MessageTypesService



494
495
496
# File 'lib/basecamp/client.rb', line 494

def message_types
  service(:message_types) { Services::MessageTypesService.new(self) }
end

#messagesServices::MessagesService



364
365
366
# File 'lib/basecamp/client.rb', line 364

def messages
  service(:messages) { Services::MessagesService.new(self) }
end

#my_assignmentsServices::MyAssignmentsService



584
585
586
# File 'lib/basecamp/client.rb', line 584

def my_assignments
  service(:my_assignments) { Services::MyAssignmentsService.new(self) }
end

#my_notesServices::MyNotesService



524
525
526
# File 'lib/basecamp/client.rb', line 524

def my_notes
  service(:my_notes) { Services::MyNotesService.new(self) }
end

#my_notificationsServices::MyNotificationsService



589
590
591
# File 'lib/basecamp/client.rb', line 589

def my_notifications
  service(:my_notifications) { Services::MyNotificationsService.new(self) }
end

#paginate(path, params: {}, operation: nil, max_items: nil) {|Hash| ... } ⇒ ListEnumerator

Fetches all pages of a paginated resource.

Parameters:

  • path (String)

    URL path (without account prefix)

  • params (Hash) (defaults to: {})

    query parameters

  • max_items (Integer, nil) (defaults to: nil)

    cap on items yielded across pages; nil or non-positive means no cap

Yields:

  • (Hash)

    each item from the response

Returns:



209
210
211
# File 'lib/basecamp/client.rb', line 209

def paginate(path, params: {}, operation: nil, max_items: nil, &)
  @parent.http.paginate((path), params: params, operation: operation, max_items: max_items, &)
end

#paginate_key(path, key:, params: {}, operation: nil, max_items: nil) {|Hash| ... } ⇒ ListEnumerator

Fetches all pages of a paginated resource, extracting items from a key. Use this for endpoints that return objects like { "events": [...] }.

Parameters:

  • path (String)

    URL path (without account prefix)

  • key (String)

    the key containing the array of items

  • params (Hash) (defaults to: {})

    query parameters

  • max_items (Integer, nil) (defaults to: nil)

    cap on items yielded across pages; nil or non-positive means no cap

Yields:

  • (Hash)

    each item from the response

Returns:



222
223
224
225
# File 'lib/basecamp/client.rb', line 222

def paginate_key(path, key:, params: {}, operation: nil, max_items: nil, &)
  @parent.http.paginate_key((path), key: key, params: params, operation: operation, \
    max_items: max_items, &)
end

#paginate_wrapped(path, key:, params: {}, operation: nil, max_items: nil) ⇒ Hash

Fetches a wrapped paginated resource, returning wrapper fields + lazy paginated items.

Parameters:

  • path (String)

    URL path (without account prefix)

  • key (String)

    the key containing the array of paginated items

  • params (Hash) (defaults to: {})

    query parameters

  • max_items (Integer, nil) (defaults to: nil)

    cap on items yielded across pages; nil or non-positive means no cap

Returns:

  • (Hash)

    wrapper fields merged with key => ListEnumerator of all items



234
235
236
237
# File 'lib/basecamp/client.rb', line 234

def paginate_wrapped(path, key:, params: {}, operation: nil, max_items: nil)
  @parent.http.paginate_wrapped((path), key: key, params: params, operation: operation, \
    max_items: max_items)
end

#peopleServices::PeopleService



354
355
356
# File 'lib/basecamp/client.rb', line 354

def people
  service(:people) { Services::PeopleService.new(self) }
end

#post(path, body: nil) ⇒ Response

Performs a POST request scoped to this account.

Parameters:

  • path (String)

    URL path (without account prefix)

  • body (Hash, nil) (defaults to: nil)

    request body

Returns:



163
164
165
# File 'lib/basecamp/client.rb', line 163

def post(path, body: nil)
  @parent.http.post((path), body: body)
end

#post_raw(path, body:, content_type:) ⇒ Response

Performs a POST request with raw binary data scoped to this account. Used for file uploads (attachments).

Parameters:

  • path (String)

    URL path (without account prefix)

  • body (String, IO)

    raw binary data

  • content_type (String)

    MIME content type

Returns:



188
189
190
# File 'lib/basecamp/client.rb', line 188

def post_raw(path, body:, content_type:)
  @parent.http.post_raw((path), body: body, content_type: content_type)
end

#projectsServices::ProjectsService



329
330
331
# File 'lib/basecamp/client.rb', line 329

def projects
  service(:projects) { Services::ProjectsService.new(self) }
end

#put(path, body: nil) ⇒ Response

Performs a PUT request scoped to this account.

Parameters:

  • path (String)

    URL path (without account prefix)

  • body (Hash, nil) (defaults to: nil)

    request body

Returns:



171
172
173
# File 'lib/basecamp/client.rb', line 171

def put(path, body: nil)
  @parent.http.put((path), body: body)
end

#put_raw(path, body:, content_type:) ⇒ Response

Performs a PUT request with raw binary data scoped to this account. Used for multipart uploads (e.g., account logo).

Parameters:

  • path (String)

    URL path (without account prefix)

  • body (String, IO)

    raw binary data

  • content_type (String)

    MIME content type

Returns:



198
199
200
# File 'lib/basecamp/client.rb', line 198

def put_raw(path, body:, content_type:)
  @parent.http.put_raw((path), body: body, content_type: content_type)
end

#recordingsServices::RecordingsService



394
395
396
# File 'lib/basecamp/client.rb', line 394

def recordings
  service(:recordings) { Services::RecordingsService.new(self) }
end

#reportsServices::ReportsService



539
540
541
# File 'lib/basecamp/client.rb', line 539

def reports
  service(:reports) { Services::ReportsService.new(self) }
end

#schedulesServices::SchedulesService



384
385
386
# File 'lib/basecamp/client.rb', line 384

def schedules
  service(:schedules) { Services::SchedulesService.new(self) }
end

#searchServices::SearchService



534
535
536
# File 'lib/basecamp/client.rb', line 534

def search
  service(:search) { Services::SearchService.new(self) }
end

#subscriptionsServices::SubscriptionsService



529
530
531
# File 'lib/basecamp/client.rb', line 529

def subscriptions
  service(:subscriptions) { Services::SubscriptionsService.new(self) }
end

#templatesServices::TemplatesService



459
460
461
# File 'lib/basecamp/client.rb', line 459

def templates
  service(:templates) { Services::TemplatesService.new(self) }
end

#timelineServices::TimelineService



544
545
546
# File 'lib/basecamp/client.rb', line 544

def timeline
  service(:timeline) { Services::TimelineService.new(self) }
end

#timesheetsServices::TimesheetsService



554
555
556
# File 'lib/basecamp/client.rb', line 554

def timesheets
  service(:timesheets) { Services::TimesheetsService.new(self) }
end

#todolist_groupsServices::TodolistGroupsService



564
565
566
# File 'lib/basecamp/client.rb', line 564

def todolist_groups
  service(:todolist_groups) { Services::TodolistGroupsService.new(self) }
end

#todolistsServices::TodolistsService



349
350
351
# File 'lib/basecamp/client.rb', line 349

def todolists
  service(:todolists) { Services::TodolistsService.new(self) }
end

#todosServices::TodosService



334
335
336
# File 'lib/basecamp/client.rb', line 334

def todos
  service(:todos) { Services::TodosService.new(self) }
end

#todosetsServices::TodosetsService



339
340
341
# File 'lib/basecamp/client.rb', line 339

def todosets
  service(:todosets) { Services::TodosetsService.new(self) }
end

#toolsServices::ToolsService



499
500
501
# File 'lib/basecamp/client.rb', line 499

def tools
  service(:tools) { Services::ToolsService.new(self) }
end

#uploadsServices::UploadsService



414
415
416
# File 'lib/basecamp/client.rb', line 414

def uploads
  service(:uploads) { Services::UploadsService.new(self) }
end

#vaultsServices::VaultsService



389
390
391
# File 'lib/basecamp/client.rb', line 389

def vaults
  service(:vaults) { Services::VaultsService.new(self) }
end

#webhooksServices::WebhooksService



374
375
376
# File 'lib/basecamp/client.rb', line 374

def webhooks
  service(:webhooks) { Services::WebhooksService.new(self) }
end

#wormholesServices::WormholesService



454
455
456
# File 'lib/basecamp/client.rb', line 454

def wormholes
  service(:wormholes) { Services::WormholesService.new(self) }
end