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

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

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:



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

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

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

Performs a GET request.

Parameters:

  • path (String)

    URL path

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

    query parameters

Returns:



59
60
61
# File 'lib/basecamp/http.rb', line 59

def get(path, params: {})
  request(:get, path, params: params)
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:



81
82
83
84
85
86
# File 'lib/basecamp/http.rb', line 81

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:



106
107
108
109
110
111
112
113
114
# File 'lib/basecamp/http.rb', line 106

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_no_retry(url) ⇒ Response

Performs a GET request without retry logic. Used for the download flow where retry is not appropriate.

Parameters:

  • url (String)

    absolute URL

Returns:



165
166
167
# File 'lib/basecamp/http.rb', line 165

def get_no_retry(url)
  single_request(:get, url, params: {}, body: nil, attempt: 1)
end

#paginate(path, params: {}) {|Hash| ... } ⇒ Enumerator

Fetches all pages of a paginated resource.

Parameters:

  • path (String)

    initial URL path

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

    query parameters

Yields:

  • (Hash)

    each item from the response

Returns:

  • (Enumerator)

    if no block given



174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/basecamp/http.rb', line 174

def paginate(path, params: {}, &block)
  return to_enum(:paginate, path, params: params) unless block

  base_url = build_url(path)
  url = base_url
  page = 0

  loop do
    page += 1
    break if page > @config.max_pages

    @hooks.on_paginate(url, page)
    response = get(url, params: page == 1 ? params : {})

    Security.check_body_size!(response.body, Security::MAX_RESPONSE_BODY_BYTES)

    begin
      items = JSON.parse(response.body)
      Http.normalize_person_ids(items)
    rescue JSON::ParserError => e
      raise Basecamp::ApiError.new("Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}")
    end
    items.each(&block)

    next_url = parse_next_link(response.headers["Link"])
    break if next_url.nil?

    next_url = Security.resolve_url(url, next_url)

    unless Security.same_origin?(next_url, base_url)
      raise Basecamp::ApiError.new(
        "Pagination Link header points to different origin: #{Security.truncate(next_url)}"
      )
    end

    url = next_url
  end
end

#paginate_key(path, key:, params: {}) {|Hash| ... } ⇒ Enumerator

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

Parameters:

  • path (String)

    initial URL path

  • key (String)

    the key containing the array of items

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

    query parameters

Yields:

  • (Hash)

    each item from the response

Returns:

  • (Enumerator)

    if no block given



220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'lib/basecamp/http.rb', line 220

def paginate_key(path, key:, params: {}, &block)
  return to_enum(:paginate_key, path, key: key, params: params) unless block

  base_url = build_url(path)
  url = base_url
  page = 0

  loop do
    page += 1
    break if page > @config.max_pages

    @hooks.on_paginate(url, page)
    response = get(url, params: page == 1 ? params : {})

    Security.check_body_size!(response.body, Security::MAX_RESPONSE_BODY_BYTES)

    begin
      data = JSON.parse(response.body)
      Http.normalize_person_ids(data)
    rescue JSON::ParserError => e
      raise Basecamp::ApiError.new("Failed to parse paginated response (page #{page}): #{Security.truncate(e.message)}")
    end
    unless data.key?(key)
      warn "[Basecamp SDK] paginate_key: expected key '#{key}' not found in response (page #{page})"
    end
    items = data[key] || []
    items.each(&block)

    next_url = parse_next_link(response.headers["Link"])
    break if next_url.nil?

    next_url = Security.resolve_url(url, next_url)

    unless Security.same_origin?(next_url, base_url)
      raise Basecamp::ApiError.new(
        "Pagination Link header points to different origin: #{Security.truncate(next_url)}"
      )
    end

    url = next_url
  end
end

#paginate_wrapped(path, key:, params: {}) ⇒ 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

Returns:

  • (Hash)

    wrapper fields merged with key => Enumerator of all items



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
325
326
327
328
329
# File 'lib/basecamp/http.rb', line 269

def paginate_wrapped(path, key:, params: {})
  base_url = build_url(path)

  @hooks.on_paginate(base_url, 1)
  first_response = get(base_url, params: params)
  Security.check_body_size!(first_response.body, Security::MAX_RESPONSE_BODY_BYTES)

  begin
    first_data = JSON.parse(first_response.body)
    Http.normalize_person_ids(first_data)
  rescue JSON::ParserError => e
    raise Basecamp::ApiError.new(
      "Failed to parse paginated response (page 1): #{Security.truncate(e.message)}"
    )
  end

  wrapper = first_data.reject { |k, _| k == key }
  first_items = first_data[key] || []

  events = Enumerator.new do |yielder|
    first_items.each { |item| yielder << item }

    next_link = parse_next_link(first_response.headers["Link"])
    url = base_url
    page = 1

    while next_link && page < @config.max_pages
      page += 1
      next_url = Security.resolve_url(url, next_link)

      unless Security.same_origin?(next_url, base_url)
        raise Basecamp::ApiError.new(
          "Pagination Link header points to different origin: " \
          "#{Security.truncate(next_url)}"
        )
      end

      @hooks.on_paginate(next_url, page)
      response = get(next_url)
      Security.check_body_size!(response.body, Security::MAX_RESPONSE_BODY_BYTES)

      begin
        data = JSON.parse(response.body)
        Http.normalize_person_ids(data)
      rescue JSON::ParserError => e
        raise Basecamp::ApiError.new(
          "Failed to parse paginated response (page #{page}): " \
          "#{Security.truncate(e.message)}"
        )
      end

      items = data[key] || []
      items.each { |item| yielder << item }

      next_link = parse_next_link(response.headers["Link"])
      url = next_url
    end
  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:



120
121
122
# File 'lib/basecamp/http.rb', line 120

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:



145
146
147
148
# File 'lib/basecamp/http.rb', line 145

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:



128
129
130
# File 'lib/basecamp/http.rb', line 128

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:



156
157
158
159
# File 'lib/basecamp/http.rb', line 156

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