Module: Basecamp

Defined in:
lib/basecamp.rb,
lib/basecamp/http.rb,
lib/basecamp/error.rb,
lib/basecamp/hooks.rb,
lib/basecamp/oauth.rb,
lib/basecamp/client.rb,
lib/basecamp/config.rb,
lib/basecamp/version.rb,
lib/basecamp/security.rb,
lib/basecamp/api_error.rb,
lib/basecamp/exit_code.rb,
lib/basecamp/list_meta.rb,
lib/basecamp/auth_error.rb,
lib/basecamp/error_code.rb,
lib/basecamp/noop_hooks.rb,
lib/basecamp/oauth/pkce.rb,
lib/basecamp/bearer_auth.rb,
lib/basecamp/chain_hooks.rb,
lib/basecamp/oauth/token.rb,
lib/basecamp/usage_error.rb,
lib/basecamp/logger_hooks.rb,
lib/basecamp/oauth/config.rb,
lib/basecamp/request_info.rb,
lib/basecamp/auth_strategy.rb,
lib/basecamp/network_error.rb,
lib/basecamp/oauth/fetcher.rb,
lib/basecamp/oauth/exchange.rb,
lib/basecamp/oauth/resource.rb,
lib/basecamp/operation_info.rb,
lib/basecamp/request_result.rb,
lib/basecamp/token_provider.rb,
lib/basecamp/webhooks/event.rb,
lib/basecamp/ambiguous_error.rb,
lib/basecamp/download_result.rb,
lib/basecamp/forbidden_error.rb,
lib/basecamp/generated/types.rb,
lib/basecamp/list_enumerator.rb,
lib/basecamp/not_found_error.rb,
lib/basecamp/oauth/discovery.rb,
lib/basecamp/webhooks/verify.rb,
lib/basecamp/operation_result.rb,
lib/basecamp/rate_limit_error.rb,
lib/basecamp/validation_error.rb,
lib/basecamp/oauth/device_flow.rb,
lib/basecamp/oauth/oauth_error.rb,
lib/basecamp/webhooks/receiver.rb,
lib/basecamp/services/merge_safe.rb,
lib/basecamp/oauth_token_provider.rb,
lib/basecamp/oauth/refresh_request.rb,
lib/basecamp/static_token_provider.rb,
lib/basecamp/oauth/discovery_result.rb,
lib/basecamp/oauth/exchange_request.rb,
lib/basecamp/oauth/device_flow_error.rb,
lib/basecamp/webhooks/rack_middleware.rb,
lib/basecamp/services/cards_extensions.rb,
lib/basecamp/services/todos_extensions.rb,
lib/basecamp/oauth/device_authorization.rb,
lib/basecamp/webhooks/verification_error.rb,
lib/basecamp/services/documents_extensions.rb,
lib/basecamp/services/schedules_extensions.rb,
lib/basecamp/services/todolists_extensions.rb,
lib/basecamp/services/authorization_service.rb,
lib/basecamp/generated/services/base_service.rb,
lib/basecamp/oauth/discovery_selection_error.rb,
lib/basecamp/generated/services/cards_service.rb,
lib/basecamp/generated/services/todos_service.rb,
lib/basecamp/generated/services/tools_service.rb,
lib/basecamp/generated/services/boosts_service.rb,
lib/basecamp/generated/services/drafts_service.rb,
lib/basecamp/generated/services/events_service.rb,
lib/basecamp/generated/services/gauges_service.rb,
lib/basecamp/generated/services/lineup_service.rb,
lib/basecamp/generated/services/people_service.rb,
lib/basecamp/generated/services/search_service.rb,
lib/basecamp/generated/services/vaults_service.rb,
lib/basecamp/oauth/protected_resource_metadata.rb,
lib/basecamp/generated/services/account_service.rb,
lib/basecamp/generated/services/folders_service.rb,
lib/basecamp/generated/services/reports_service.rb,
lib/basecamp/generated/services/uploads_service.rb,
lib/basecamp/generated/services/checkins_service.rb,
lib/basecamp/generated/services/comments_service.rb,
lib/basecamp/generated/services/forwards_service.rb,
lib/basecamp/generated/services/messages_service.rb,
lib/basecamp/generated/services/my_notes_service.rb,
lib/basecamp/generated/services/projects_service.rb,
lib/basecamp/generated/services/timeline_service.rb,
lib/basecamp/generated/services/todosets_service.rb,
lib/basecamp/generated/services/webhooks_service.rb,
lib/basecamp/generated/services/bookmarks_service.rb,
lib/basecamp/generated/services/calendars_service.rb,
lib/basecamp/generated/services/campfires_service.rb,
lib/basecamp/generated/services/documents_service.rb,
lib/basecamp/generated/services/schedules_service.rb,
lib/basecamp/generated/services/templates_service.rb,
lib/basecamp/generated/services/todolists_service.rb,
lib/basecamp/generated/services/wormholes_service.rb,
lib/basecamp/generated/services/automation_service.rb,
lib/basecamp/generated/services/card_steps_service.rb,
lib/basecamp/generated/services/everything_service.rb,
lib/basecamp/generated/services/recordings_service.rb,
lib/basecamp/generated/services/timesheets_service.rb,
lib/basecamp/generated/services/attachments_service.rb,
lib/basecamp/generated/services/card_tables_service.rb,
lib/basecamp/generated/services/cloud_files_service.rb,
lib/basecamp/generated/services/hill_charts_service.rb,
lib/basecamp/generated/services/card_columns_service.rb,
lib/basecamp/generated/services/message_types_service.rb,
lib/basecamp/generated/services/subscriptions_service.rb,
lib/basecamp/generated/services/client_replies_service.rb,
lib/basecamp/generated/services/message_boards_service.rb,
lib/basecamp/generated/services/my_assignments_service.rb,
lib/basecamp/generated/services/todolist_groups_service.rb,
lib/basecamp/generated/services/client_approvals_service.rb,
lib/basecamp/generated/services/google_documents_service.rb,
lib/basecamp/generated/services/my_notifications_service.rb,
lib/basecamp/generated/services/client_visibility_service.rb,
lib/basecamp/generated/services/client_correspondences_service.rb

Overview

Main entry point for the Basecamp SDK.

The SDK follows a Client -> AccountClient pattern:

  • Client: Holds shared resources (HTTP client, token provider, hooks)
  • AccountClient: Bound to a specific account ID, provides service accessors

Examples:

Basic usage

config = Basecamp::Config.new(base_url: "https://3.basecampapi.com")
token = Basecamp::StaticTokenProvider.new(ENV["BASECAMP_TOKEN"])

client = Basecamp::Client.new(config: config, token_provider: token)
 = client.("12345")

# Use services (returns lazy Enumerator)
projects = .projects.list.to_a

With hooks for logging

class MyHooks
  include Basecamp::Hooks

  def on_request_start(info)
    puts "Starting #{info.method} #{info.url}"
  end

  def on_request_end(info, result)
    puts "Completed in #{result.duration}s"
  end
end

client = Basecamp::Client.new(config: config, token_provider: token, hooks: MyHooks.new)

Defined Under Namespace

Modules: AuthStrategy, ErrorCode, ExitCode, Hooks, Oauth, Security, Services, TokenProvider, Types, Webhooks Classes: AccountClient, AmbiguousError, ApiError, AuthError, BearerAuth, ChainHooks, Client, Config, DownloadResult, Error, ForbiddenError, Http, ListEnumerator, ListMeta, LoggerHooks, NetworkError, NoopHooks, NotFoundError, OauthTokenProvider, OperationInfo, OperationResult, RateLimitError, RequestInfo, RequestResult, Response, StaticTokenProvider, UsageError, ValidationError

Constant Summary collapse

VERSION =
"0.13.0"
API_VERSION =
"2026-08-05"

Class Method Summary collapse

Class Method Details

.client(access_token: nil, auth: nil, account_id: nil, base_url: Config::DEFAULT_BASE_URL, hooks: nil) ⇒ Client, AccountClient

Creates a new Basecamp client.

This is a convenience method that creates a Client with the given options.

Examples:

With access token

client = Basecamp.client(access_token: "abc123", account_id: "12345")
projects = client.projects.list.to_a

With custom auth strategy

client = Basecamp.client(auth: MyCustomAuth.new, account_id: "12345")

Parameters:

  • access_token (String, nil) (defaults to: nil)

    OAuth access token

  • auth (AuthStrategy, nil) (defaults to: nil)

    custom authentication strategy

  • account_id (String, nil) (defaults to: nil)

    Basecamp account ID (optional)

  • base_url (String) (defaults to: Config::DEFAULT_BASE_URL)

    Base URL for API requests

  • hooks (Hooks, nil) (defaults to: nil)

    Observability hooks

Returns:

Raises:

  • (ArgumentError)


91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/basecamp.rb', line 91

def self.client(
  access_token: nil,
  auth: nil,
  account_id: nil,
  base_url: Config::DEFAULT_BASE_URL,
  hooks: nil
)
  raise ArgumentError, "provide either access_token or auth, not both" if access_token && auth
  raise ArgumentError, "provide access_token or auth" if !access_token && !auth

  config = Config.new(base_url: base_url)

  client = if auth
    Client.new(config: config, auth_strategy: auth, hooks: hooks)
  else
    token_provider = StaticTokenProvider.new(access_token)
    Client.new(config: config, token_provider: token_provider, hooks: hooks)
  end

   ? client.() : client
end

.compose_validation_message(message, field_errors) ⇒ String?

Merges the top-level error message with the flattened field-keyed errors: appended in parentheses when both are present, standing alone when only the field errors are. The flattened shape — fields sorted lexicographically, a field's messages joined with "; ", fields joined with ", " — is shared by all six SDKs; change it everywhere or nowhere. Callers truncate the composed result so the appended tail is capped too.

Parameters:

  • message (String, nil)
  • field_errors (Hash{String => Array<String>}, nil)

Returns:

  • (String, nil)


238
239
240
241
242
243
244
245
246
247
# File 'lib/basecamp.rb', line 238

def self.compose_validation_message(message, field_errors)
  if field_errors.nil?
    message
  else
    flat = field_errors.keys.sort \
      .map { |field| "#{field}: #{field_errors[field].join("; ")}" } \
      .join(", ")
    message ? "#{message} (#{flat})" : flat
  end
end

.error_from_response(status, body = nil, retry_after: nil) ⇒ Error

Maps an HTTP response to the appropriate error class.

Parameters:

  • status (Integer)

    HTTP status code

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

    response body (will attempt JSON parse)

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

    Retry-After header value

Returns:



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'lib/basecamp.rb', line 119

def self.error_from_response(status, body = nil, retry_after: nil)
  message = parse_error_message(body) || "Request failed"

  case status
  when 400, 422
    field_errors = parse_field_errors(body)
    message = Security.truncate(compose_validation_message(parse_error_message(body), field_errors) || "Request failed")
    ValidationError.new(message, http_status: status, field_errors: field_errors)
  when 401
    AuthError.new(message)
  when 403
    ForbiddenError.new(message)
  when 404
    NotFoundError.new(message: message)
  when 429
    RateLimitError.new(retry_after: retry_after)
  when 500
    ApiError.new("Server error (500)", http_status: 500, retryable: true)
  when 502, 503, 504
    ApiError.new("Gateway error (#{status})", http_status: status, retryable: true)
  else
    ApiError.from_status(status, message)
  end
end

.filename_from_url(raw_url) ⇒ Object

Extracts a filename from the last path segment of a URL. Falls back to "download" if the URL is unparseable or has no path segments.



146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/basecamp.rb', line 146

def self.filename_from_url(raw_url)
  uri = URI.parse(raw_url)
  path = uri.path
  return "download" if path.nil? || path.empty? || path == "/" || path.end_with?("/")

  segments = path.split("/").reject(&:empty?)
  return "download" if segments.empty?

  last = segments.last
  return "download" if last.nil? || last.empty? || last == "." || last == "/"

  URI::RFC2396_PARSER.unescape(last)
rescue URI::InvalidURIError
  "download"
end

.parse_bare_field_errors(data) ⇒ Hash{String => Array<String>}?

Extracts an unwrapped field map — the render json: @webhook.errors rendering, where the whole body is => ["msg", ...]. The gate is all-or-nothing by design (SPEC section 6 step 2): with no "errors" key to declare intent, only shape distinguishes a field map from any other JSON object, so a single non-conforming member means this is not one.

Parameters:

  • data (Object)

    the parsed body

Returns:

  • (Hash{String => Array<String>}, nil)


214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/basecamp.rb', line 214

def self.parse_bare_field_errors(data)
  return nil unless data.is_a?(Hash) && !data.empty?
  # Only "errors" is structurally reserved (it belongs to the wrapped path).
  # "error" and "message" are not excluded by name: a flat body carries them
  # as strings, which the shape gate below already rejects.
  return nil if data.key?("errors")

  data.each_with_object({}) do |(field, values), result|
    return nil unless values.is_a?(Array) && !values.empty?
    return nil unless values.all? { |message| message.is_a?(String) && !message.empty? }

    result[field.to_s] = values
  end
end

.parse_error_message(body) ⇒ String?

Parses error message from response body. A key is used only when its value is a String (SPEC section 6), so a malformed scalar member such as {} cannot raise or leak a non-string into the message.

Parameters:

  • body (String, nil)

Returns:

  • (String, nil)


167
168
169
170
171
172
173
174
175
176
177
# File 'lib/basecamp.rb', line 167

def self.parse_error_message(body)
  return nil if body.nil? || body.empty?

  Security.check_body_size!(body, Security::MAX_ERROR_BODY_BYTES, "Error")

  data = JSON.parse(body)
  msg = data.is_a?(Hash) ? [ data["error"], data["message"] ].find { |value| value.is_a?(String) } : nil
  msg ? Security.truncate(msg) : nil
rescue JSON::ParserError, ApiError
  nil
end

.parse_field_errors(body) ⇒ Hash{String => Array<String>}?

Extracts the field-keyed validation errors map from a response body — the Rails RecordInvalid rendering => {"field" => ["msg", ...]}. Entries whose value is not an array are skipped, non-string elements are dropped, and a map with no usable entries is treated as absent (nil).

Parameters:

  • body (String, nil)

Returns:

  • (Hash{String => Array<String>}, nil)


185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
# File 'lib/basecamp.rb', line 185

def self.parse_field_errors(body)
  return nil if body.nil? || body.empty?

  Security.check_body_size!(body, Security::MAX_ERROR_BODY_BYTES, "Error")

  data = JSON.parse(body)
  errors = data.is_a?(Hash) ? data["errors"] : nil
  if errors.is_a?(Hash)
    field_errors = errors.each_with_object({}) do |(field, values), result|
      next unless values.is_a?(Array)

      messages = values.grep(String)
      result[field.to_s] = messages unless messages.empty?
    end
    field_errors.empty? ? nil : field_errors
  else
    parse_bare_field_errors(data)
  end
rescue JSON::ParserError, ApiError
  nil
end