Class: Basecamp::Http
- Inherits:
-
Object
- Object
- Basecamp::Http
- 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
-
.normalize_person_ids(obj) ⇒ Object
Normalizes Person-shaped objects in parsed JSON.
Instance Method Summary collapse
-
#base_url ⇒ String
The configured base URL.
-
#delete(path) ⇒ Response
Performs a DELETE request.
-
#get(path, params: {}) ⇒ Response
Performs a GET request.
-
#get_absolute(url, params: {}) ⇒ Response
Performs a GET request to an absolute URL.
-
#get_authorization_document ⇒ Response
Fetches the credentialed authorization document (the fixed
authorization.jsonpath). -
#get_no_retry(url) ⇒ Response
Performs a GET request without retry logic.
-
#initialize(config:, token_provider: nil, auth_strategy: nil, hooks: nil) ⇒ Http
constructor
A new instance of Http.
-
#paginate(path, params: {}) {|Hash| ... } ⇒ Enumerator
Fetches all pages of a paginated resource.
-
#paginate_key(path, key:, params: {}) {|Hash| ... } ⇒ Enumerator
Fetches all pages of a paginated resource, extracting items from a key.
-
#paginate_wrapped(path, key:, params: {}) ⇒ Hash
Fetches a wrapped paginated resource, returning wrapper fields + lazy paginated items.
-
#post(path, body: nil) ⇒ Response
Performs a POST request.
-
#post_raw(path, body:, content_type:) ⇒ Response
Performs a POST request with raw binary data.
-
#put(path, body: nil) ⇒ Response
Performs a PUT request.
-
#put_raw(path, body:, content_type:) ⇒ Response
Performs a PUT request with raw binary data.
Constructor Details
#initialize(config:, token_provider: nil, auth_strategy: nil, hooks: nil) ⇒ Http
Returns a new instance of Http.
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_url ⇒ String
Returns 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.
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.
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.
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_document ⇒ Response
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.
106 107 108 109 110 111 112 113 114 |
# File 'lib/basecamp/http.rb', line 106 def 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.
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.
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.)}") 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": [...] }.
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.)}") 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.
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.)}" ) 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.)}" ) 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.
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).
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.
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).
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 |