Class: Inttegro::HTTPClient

Inherits:
Object
  • Object
show all
Extended by:
T::Sig
Defined in:
lib/inttegro/http_client.rb

Overview

Minimal HTTP client that wraps Net::HTTP with JSON parsing and API-friendly errors.

Constant Summary collapse

USER_AGENT =
T.let("inttegro-sdk-ruby/#{Inttegro::VERSION}", String)
ResponseValue =
T.type_alias do
  T.any(T::Struct, ResponseObject, Types::Scalar, T::Array[Object])
end
ErrorPayload =
T.type_alias { T::Hash[Symbol, T.nilable(String)] }

Instance Method Summary collapse

Constructor Details

#initialize(api_key:, base_url: DEFAULT_BASE_URL, read_timeout: 30, open_timeout: 10, adapter: nil, telemetry_enabled: true, tracer_provider: nil, error_reporter: nil, error_reporting_policy: :unexpected) ⇒ HTTPClient

Returns a new instance of HTTPClient.

Raises:

  • (ArgumentError)


47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/inttegro/http_client.rb', line 47

def initialize(
  api_key:,
  base_url: DEFAULT_BASE_URL,
  read_timeout: 30,
  open_timeout: 10,
  adapter: nil,
  telemetry_enabled: true,
  tracer_provider: nil,
  error_reporter: nil,
  error_reporting_policy: :unexpected
)
  @api_key = T.let(api_key || "", String)
  raise ArgumentError, "api_key is required" if @api_key.strip.empty?

  normalized_base_url = T.let(base_url || DEFAULT_BASE_URL, String)
  end_index = T.let(normalized_base_url.length, Integer)
  end_index -= 1 while end_index.positive? && normalized_base_url.getbyte(end_index - 1) == 47
  @base_url = T.let(T.must(normalized_base_url[0, end_index]), String)
  @read_timeout = T.let(read_timeout, T.nilable(Numeric))
  @open_timeout = T.let(open_timeout, T.nilable(Numeric))
  @adapter = T.let(adapter, T.nilable(Types::Adapter))
  @telemetry = T.let(
    Telemetry.new(
      Inttegro::VERSION,
      enabled: telemetry_enabled,
      tracer_provider: tracer_provider,
      error_reporter: error_reporter,
      error_reporting_policy: error_reporting_policy
    ),
    Telemetry
  )
end

Instance Method Details

#get(path, headers: {}, query: nil) ⇒ Object



81
82
83
# File 'lib/inttegro/http_client.rb', line 81

def get(path, headers: {}, query: nil)
  request(:get, path, headers: headers, query: query)
end

#get_binary_public(url) ⇒ Object



331
332
333
334
335
336
337
338
339
340
341
342
343
344
# File 'lib/inttegro/http_client.rb', line 331

def get_binary_public(url)
  @telemetry.in_span(url, :get, @base_url, Inttegro::VERSION, "file_links.download") do |span|
    uri = build_uri(url, nil)
    request = Net::HTTP::Get.new(uri)
    request["User-Agent"] = USER_AGENT
    @telemetry.prepare(span, request)
    raw_response = @adapter ? @adapter.call(uri, request) : build_http(uri).request(request)
    response = TransportResponse.new(raw_response)
    @telemetry.response(span, response, decoded: false)
    return FileDownload.new(response.body, response.headers) if response.code.to_i < 400

    raise_response_error(response)
  end
end

#perform_multipart(path, fields, files, headers, authenticated, model, operation = nil) ⇒ Object



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
# File 'lib/inttegro/http_client.rb', line 278

def perform_multipart(path, fields, files, headers, authenticated, model, operation = nil)
  @telemetry.in_span(path, :post, @base_url, Inttegro::VERSION, operation) do |span|
    uri = build_uri(path, nil)
    request = Net::HTTP::Post.new(uri)
    request["Accept"] = "application/json"
    request["User-Agent"] = USER_AGENT
    request["Authorization"] = "Bearer #{@api_key}" if authenticated
    headers = headers.dup
    if authenticated && idempotent_mutation_path?(path) && !header_present?(headers, "Idempotency-Key")
      headers["Idempotency-Key"] = generate_idempotency_key
    end
    headers.each { |key, value| request[key] = value }

    form = []
    fields.each do |key, value|
      next if value.nil?

      form << [key.to_s, value.is_a?(Hash) || value.is_a?(Array) ? JSON.generate(value) : value.to_s]
    end
    files.each do |key, file_path|
      raise ArgumentError, "file path for #{key} must be a String" unless file_path.is_a?(String)

      form << [key.to_s, File.open(file_path)]
    end
    request.set_form(form, "multipart/form-data")
    @telemetry.prepare(span, request)

    raw_response = @adapter ? @adapter.call(uri, request) : build_http(uri).request(request)
    response = TransportResponse.new(raw_response)
    @telemetry.response(span, response, decoded: false)
    result = handle_response(path, response, model)
    @telemetry.decoded(span)
    result
  end
end

#post(path, body = nil, headers: {}, query: nil, **body_keywords) ⇒ Object



94
95
96
97
# File 'lib/inttegro/http_client.rb', line 94

def post(path, body = nil, headers: {}, query: nil, **body_keywords)
  body = body_keywords unless body_keywords.empty?
  request(:post, path, body: body, headers: headers, query: query)
end

#post_binary_json(path, body) ⇒ Object



315
316
317
318
319
320
321
322
323
324
325
326
327
328
# File 'lib/inttegro/http_client.rb', line 315

def post_binary_json(path, body)
  @telemetry.in_span(path, :post, @base_url, Inttegro::VERSION) do |span|
    payload = with_request_meta_idempotency(:post, path, coerce_body(body), {})
    response = raw_request(:post, path, JSON.dump(payload), {
      "Accept" => "application/octet-stream",
      "Content-Type" => "application/json",
      "Authorization" => "Bearer #{@api_key}"
    }, span)
    @telemetry.response(span, response, decoded: false)
    return FileDownload.new(response.body, response.headers) if response.code.to_i < 400

    raise_response_error(response)
  end
end

#post_model(path, model, body, headers: {}, query: nil) ⇒ Object

Raises:

  • (TypeError)


110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/inttegro/http_client.rb', line 110

def post_model(path, model, body, headers: {}, query: nil)
  unless model <= T::Struct
    raise TypeError, "response model for #{path} must inherit from T::Struct"
  end

  response = request(
    :post,
    path,
    body: body,
    headers: headers,
    query: query,
    response_model: model
  )
  return T.cast(response, T.type_parameter(:Model)) if response.is_a?(model)

  raise TypeError, "expected #{model} from #{path}, got #{response.class}"
end

#post_multipart(path, fields: {}, files: {}, headers: {}, authenticated: true) ⇒ Object



205
206
207
208
209
210
# File 'lib/inttegro/http_client.rb', line 205

def post_multipart(path, fields: {}, files: {}, headers: {}, authenticated: true)
  expect_response_object(
    perform_multipart(path, fields, files, headers, authenticated, nil),
    path
  )
end

#post_multipart_model(path, model, fields: {}, files: {}, headers: {}, authenticated: true, operation: nil) ⇒ Object

Raises:

  • (TypeError)


225
226
227
228
229
230
231
232
233
234
# File 'lib/inttegro/http_client.rb', line 225

def post_multipart_model(path, model, fields: {}, files: {}, headers: {}, authenticated: true, operation: nil)
  unless model <= T::Struct
    raise TypeError, "response model for #{path} must inherit from T::Struct"
  end

  response = perform_multipart(path, fields, files, headers, authenticated, model, operation)
  return T.cast(response, T.type_parameter(:Model)) if response.is_a?(model)

  raise TypeError, "expected #{model} from #{path}, got #{response.class}"
end

#post_multipart_resource(path, model, field, fields: {}, files: {}, headers: {}, authenticated: true) ⇒ Object

Raises:

  • (TypeError)


249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
# File 'lib/inttegro/http_client.rb', line 249

def post_multipart_resource(path, model, field, fields: {}, files: {}, headers: {}, authenticated: true)
  envelope = expect_response_object(perform_multipart(
    path,
    fields,
    files,
    headers,
    authenticated,
    nil
  ), path)
  value = envelope.public_send(field)
  return value if value.is_a?(model)

  decoded = Inttegro.deserialize(value, T.cast(model, T::Class[T::Struct]))
  return decoded if decoded.is_a?(model)

  raise TypeError, "expected #{model} in #{field} from #{path}, got #{decoded.class}"
end

#post_object(path, body, headers: {}, query: nil) ⇒ Object



185
186
187
# File 'lib/inttegro/http_client.rb', line 185

def post_object(path, body, headers: {}, query: nil)
  expect_response_object(request(:post, path, body: body, headers: headers, query: query), path)
end

#post_resource(path, model, field, body, headers: {}, query: nil) ⇒ Object

Raises:

  • (TypeError)


140
141
142
143
144
145
146
147
148
149
150
# File 'lib/inttegro/http_client.rb', line 140

def post_resource(path, model, field, body, headers: {}, query: nil)
  envelope = post_object(path, body, headers: headers, query: query)
  value = envelope.public_send(field)
  value = value.to_h if value.is_a?(ResponseObject)
  return value if value.is_a?(model)

  decoded = Inttegro.deserialize(value, T.cast(model, T::Class[T::Struct]))
  return decoded if decoded.is_a?(model)

  raise TypeError, "expected #{model} in #{field} from #{path}, got #{decoded.class}"
end

#post_resource_with_response(path, model, field, body, headers: {}, query: nil) ⇒ Object

Raises:

  • (TypeError)


164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/inttegro/http_client.rb', line 164

def post_resource_with_response(path, model, field, body, headers: {}, query: nil)
  response = request_with_response(:post, path, body: body, headers: headers, query: query)
  envelope = expect_response_object(T.cast(response.data, ResponseValue), path)
  value = envelope.public_send(field)
  value = value.to_h if value.is_a?(ResponseObject)
  return response.with_data(T.cast(value, Object)) if value.is_a?(model)

  decoded = Inttegro.deserialize(value, T.cast(model, T::Class[T::Struct]))
  return response.with_data(decoded) if decoded.is_a?(model)

  raise TypeError, "expected #{model} in #{field} from #{path}, got #{decoded.class}"
end

#post_with_headers(path, body, headers: {}) ⇒ Object



192
193
194
# File 'lib/inttegro/http_client.rb', line 192

def post_with_headers(path, body, headers: {})
  post_object(path, body, headers: headers)
end

#request(method, path, body: nil, headers: {}, query: nil, response_model: nil) ⇒ Object



356
357
358
359
360
361
362
363
364
365
366
367
368
# File 'lib/inttegro/http_client.rb', line 356

def request(method, path, body: nil, headers: {}, query: nil, response_model: nil)
  T.cast(
    request_with_response(
      method,
      path,
      body: body,
      headers: headers,
      query: query,
      response_model: response_model
    ).data,
    ResponseValue
  )
end

#request_with_response(method, path, body: nil, headers: {}, query: nil, response_model: nil) ⇒ Object



380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
# File 'lib/inttegro/http_client.rb', line 380

def request_with_response(method, path, body: nil, headers: {}, query: nil, response_model: nil)
  @telemetry.in_span(path, method, @base_url, Inttegro::VERSION) do |span|
    uri = build_uri(path, query)
    body = validate_body(path, coerce_body(body))
    body = with_request_meta_idempotency(method, path, body, headers)
    request = build_request(method, uri, body, headers)
    @telemetry.prepare(span, request)

    raw_response =
      if @adapter
        @adapter.call(uri, request)
      else
        http = build_http(uri)
        http.request(request)
      end
    response = TransportResponse.new(raw_response)
    @telemetry.response(span, response, decoded: false)
    result = handle_response(path, response, response_model)
    @telemetry.decoded(span)
    APIResponse.new(
      data: result,
      status: response.code.to_i,
      headers: response.headers,
      meta: response_meta(response.body)
    )
  end
rescue Timeout::Error, Errno::ETIMEDOUT => e
  raise TimeoutError.new("Request timed out", e)
rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET => e
  raise NetworkError.new("Network request failed", e)
end