Class: Basecamp::Http

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

Overview

HTTP client layer with retry, backoff, and caching support. This is an internal class used by Client; you typically don't use it directly.

Constant Summary collapse

USER_AGENT =

Default User-Agent header

"basecamp-sdk-ruby/#{VERSION} (api:#{API_VERSION})".freeze
DOWNLOAD_RETRY_ON =

SPEC §14's declared hop-1 retry set for downloads: a carve-out from the ungoverned GET taxonomy (which retries all retryable 5xx, including 500). Authoritative in BOTH directions, like an operation's declared retryOn.

[ 429, 502, 503, 504 ].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(config:, token_provider: nil, auth_strategy: nil, hooks: nil) ⇒ Http

Returns a new instance of Http.

Parameters:

  • config (Config)

    configuration settings

  • token_provider (TokenProvider, nil) (defaults to: nil)

    OAuth token provider (deprecated, use auth_strategy)

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

    authentication strategy

  • hooks (Hooks) (defaults to: nil)

    observability hooks



42
43
44
45
46
47
48
# File 'lib/basecamp/http.rb', line 42

def initialize(config:, token_provider: nil, auth_strategy: nil, hooks: nil)
  @config = config
  @auth_strategy = auth_strategy || BearerAuth.new(token_provider)
  @token_provider = token_provider || (@auth_strategy.is_a?(BearerAuth) ? @auth_strategy.token_provider : nil)
  @hooks = hooks || NoopHooks.new
  @faraday = build_faraday_client
end

Class Method Details

.normalize_person_ids(obj) ⇒ Object

Normalizes Person-shaped objects in parsed JSON. For objects with personable_type and a string id:

  • Numeric strings: coerced to Integer, no system_label
  • Non-numeric sentinels (e.g. "basecamp"): id becomes 0, system_label preserves original


19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'lib/basecamp/http.rb', line 19

def self.normalize_person_ids(obj)
  case obj
  when Hash
    if obj.key?("personable_type") && obj["id"].is_a?(String)
      raw_id = obj["id"]
      numeric = Integer(raw_id, exception: false)
      if numeric
        obj["id"] = numeric
      else
        obj["system_label"] = raw_id
        obj["id"] = 0
      end
    end
    obj.each_value { |v| normalize_person_ids(v) }
  when Array
    obj.each { |item| normalize_person_ids(item) }
  end
end

.operation_retry(operation) ⇒ Object

Memoized per-operation retry metadata, keyed by canonical operation ID. Benign-race memoization: concurrent first loads compute identical values.



495
496
497
498
499
500
# File 'lib/basecamp/http.rb', line 495

def self.operation_retry(operation)
  @operation_metadata ||= JSON.parse(
    File.read(File.join(__dir__, "generated", "metadata.json"))
  ).fetch("operations").freeze
  @operation_metadata.dig(operation, "retry")
end

Instance Method Details

#base_urlString

Returns the configured base URL.

Returns:

  • (String)

    the configured base URL



51
52
53
# File 'lib/basecamp/http.rb', line 51

def base_url
  @config.base_url
end

#delete(path) ⇒ Response

Performs a DELETE request.

Parameters:

  • path (String)

    URL path

Returns:



138
139
140
# File 'lib/basecamp/http.rb', line 138

def delete(path)
  request(:delete, path)
end

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

Performs a GET request.

Parameters:

  • path (String)

    URL path

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

    query parameters

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

    canonical operation ID; when given, the operation's declared retry block governs attempts (max as a ceiling on the configured cap) and retryable statuses (the declared retryOn set)

Returns:



62
63
64
# File 'lib/basecamp/http.rb', line 62

def get(path, params: {}, operation: nil)
  request(:get, path, params: params, operation: operation)
end

#get_absolute(url, params: {}) ⇒ Response

Performs a GET request to an absolute URL. Used for endpoints not on the base API.

This is the PUBLIC, general path and it credentials cross-origin for ONE destination only: the exact Launchpad authorization URL (Security::LAUNCHPAD_AUTHORIZATION_URL). Every other foreign origin — including an endpoint-shaped URL such as https://evil.example/authorization.json — trips the same-origin guard, so the bearer token only ever reaches Launchpad, the configured base URL, or localhost. There is deliberately NO raw-string trusted-origin parameter: a syntactically valid origin does not prove discovery provenance, so the ONE legitimate cross-origin discovery destination goes through the narrow #get_authorization_document, which derives its issuer from internal discovery of the configured base URL rather than any caller argument.

Parameters:

  • url (String)

    absolute URL

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

    query parameters

Returns:



84
85
86
87
88
89
# File 'lib/basecamp/http.rb', line 84

def get_absolute(url, params: {})
  Security.require_https_unless_localhost!(url, "absolute URL")

  allow_cross_origin = url == Security::LAUNCHPAD_AUTHORIZATION_URL
  request(:get, url, params: params, allow_cross_origin: allow_cross_origin)
end

#get_authorization_documentResponse

Fetches the credentialed authorization document (the fixed authorization.json path). This is the ONE sanctioned cross-origin credential path besides Launchpad, and the origin that receives the bearer token is NOT caller-supplied.

The issuer is derived HERE by running resource-first discovery (SPEC.md §16) against this client's OWN configured base URL, then binding to whatever issuer discovery selects and validates (RFC 8414 issuer binding). A soft fallback fetches Launchpad's fixed URL; a hard discovery failure raises. The request URL is CONSTRUCTED from the discovered issuer origin + the fixed path (string concatenation, never URL re-parsing). Because no caller-supplied config, origin, or path reaches this method, there is no public API through which a forged issuer could redirect the credential to a foreign host — discovery provenance is structural, not a claim about a passed-in object.

Returns:

Raises:



109
110
111
112
113
114
115
116
117
# File 'lib/basecamp/http.rb', line 109

def get_authorization_document
  result = Oauth.discover_from_resource(@config.base_url)
  if result.selected?
    issuer_origin = Security.require_origin_root!(result.issuer, "selected issuer origin")
    request(:get, "#{issuer_origin}/authorization.json", allow_cross_origin: true)
  else
    get_absolute(Security::LAUNCHPAD_AUTHORIZATION_URL)
  end
end

#get_download(url) ⇒ Response

Performs the authenticated hop-1 GET for the download flow (SPEC §14).

Retries network errors plus the declared DOWNLOAD_RETRY_ON statuses — never 500 — under the public max_retries total-attempt cap, which is floored at one attempt on every path, not just this one (+max_retries: 0+ still sends one request). DownloadURL has no behavior-model entry, so the policy is passed directly rather than looked up by operation.

Parameters:

  • url (String)

    absolute URL

Returns:



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

def get_download(url)
  request_with_retry(:get, url, retry_on: DOWNLOAD_RETRY_ON, accept: nil)
end

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

Fetches all pages of a paginated resource. The first page is fetched eagerly, so pagination metadata (and any page-1 error) surfaces at call time; later pages are fetched lazily as enumeration demands them.

Parameters:

  • path (String)

    initial URL path

  • 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:



192
193
194
195
# File 'lib/basecamp/http.rb', line 192

def paginate(path, params: {}, operation: nil, max_items: nil, &block)
  enum = paginated_enumerator(path, params: params, operation: operation, max_items: max_items)
  block ? enum.each(&block) : enum
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": [...] }. The first page is fetched eagerly; later pages are fetched lazily.

Parameters:

  • path (String)

    initial URL path

  • 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:



207
208
209
210
# File 'lib/basecamp/http.rb', line 207

def paginate_key(path, key:, params: {}, operation: nil, max_items: nil, &block)
  enum = paginated_enumerator(path, key: key, params: params, operation: operation, max_items: max_items)
  block ? enum.each(&block) : enum
end

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

Fetches a wrapped paginated resource, returning wrapper fields + lazy paginated items. Use this for endpoints that return ..., key: [items] on every page.

Parameters:

  • path (String)

    initial URL path

  • 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



220
221
222
223
224
225
226
227
# File 'lib/basecamp/http.rb', line 220

def paginate_wrapped(path, key:, params: {}, operation: nil, max_items: nil)
  wrapper = nil
  events = paginated_enumerator(path, key: key, params: params, operation: operation, \
    max_items: max_items) do |first_data|
    wrapper = first_data.reject { |k, _| k == key }
  end
  wrapper.merge(key => events)
end

#post(path, body: nil) ⇒ Response

Performs a POST request.

Parameters:

  • path (String)

    URL path

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

    request body

Returns:



123
124
125
# File 'lib/basecamp/http.rb', line 123

def post(path, body: nil)
  request(:post, path, body: body)
end

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

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

Parameters:

  • path (String)

    URL path

  • body (String, IO)

    raw binary data

  • content_type (String)

    MIME content type

Returns:



148
149
150
151
# File 'lib/basecamp/http.rb', line 148

def post_raw(path, body:, content_type:)
  url = build_url(path)
  single_request_raw(:post, url, body: body, content_type: content_type, attempt: 1)
end

#put(path, body: nil) ⇒ Response

Performs a PUT request.

Parameters:

  • path (String)

    URL path

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

    request body

Returns:



131
132
133
# File 'lib/basecamp/http.rb', line 131

def put(path, body: nil)
  request(:put, path, body: body)
end

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

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

Parameters:

  • path (String)

    URL path

  • body (String, IO)

    raw binary data

  • content_type (String)

    MIME content type

Returns:



159
160
161
162
# File 'lib/basecamp/http.rb', line 159

def put_raw(path, body:, content_type:)
  url = build_url(path)
  single_request_raw(:put, url, body: body, content_type: content_type, attempt: 1)
end