feat(api): HTTP API client

This commit is contained in:
Paul Chavard 2023-05-30 11:41:51 +02:00
parent a9f431caa5
commit c6d728d44f
3 changed files with 106 additions and 0 deletions

View file

@ -30,6 +30,7 @@ gem 'devise-i18n'
gem 'devise-two-factor' gem 'devise-two-factor'
gem 'discard' gem 'discard'
gem 'dotenv-rails', require: 'dotenv/rails-now' # dotenv should always be loaded before rails gem 'dotenv-rails', require: 'dotenv/rails-now' # dotenv should always be loaded before rails
gem 'dry-monads'
gem 'elastic-apm' gem 'elastic-apm'
gem 'flipper' gem 'flipper'
gem 'flipper-active_record' gem 'flipper-active_record'

View file

@ -218,7 +218,14 @@ GEM
dotenv (= 2.7.6) dotenv (= 2.7.6)
railties (>= 3.2) railties (>= 3.2)
dry-cli (1.0.0) dry-cli (1.0.0)
dry-core (1.0.0)
concurrent-ruby (~> 1.0)
zeitwerk (~> 2.6)
dry-inflector (0.2.0) dry-inflector (0.2.0)
dry-monads (1.6.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2)
zeitwerk (~> 2.6)
dumb_delegator (1.0.0) dumb_delegator (1.0.0)
ecma-re-validator (0.3.0) ecma-re-validator (0.3.0)
regexp_parser (~> 2.0) regexp_parser (~> 2.0)
@ -828,6 +835,7 @@ DEPENDENCIES
devise-two-factor devise-two-factor
discard discard
dotenv-rails dotenv-rails
dry-monads
elastic-apm elastic-apm
factory_bot factory_bot
flipper flipper

97
app/lib/api/client.rb Normal file
View file

@ -0,0 +1,97 @@
class API::Client
include Dry::Monads[:result]
TIMEOUT = 10
def call(url:, params: nil, body: nil, json: nil, headers: nil, method: :get, authorization_token: nil, schema: nil, timeout: TIMEOUT)
response = case method
when :get
Typhoeus.get(url,
headers: headers_with_authorization(headers, authorization_token),
params:,
timeout: TIMEOUT)
when :post
Typhoeus.post(url,
headers: headers_with_authorization(headers, json, authorization_token),
body: json.nil? ? body : json.to_json,
timeout: TIMEOUT)
end
handle_response(response, schema:)
end
private
def headers_with_authorization(headers, json, authorization_token)
headers = headers || {}
headers['authorization'] = "Bearer #{authorization_token}" if authorization_token.present?
headers['content-type'] = 'application/json' if json.present?
headers
end
OK = Data.define(:body, :response)
Error = Data.define(:type, :code, :retryable, :reason)
def handle_response(response, schema:)
if response.success?
scope = Sentry.get_current_scope
if scope.extra.key?(:external_id)
scope.set_extras(raw_body: response.body.to_s)
end
body = parse_body(response.body)
case body
in Success(body)
if !schema || schema.valid?(body)
Success(OK[body.deep_symbolize_keys, response])
else
Failure(Error[:schema, response.code, false, SchemaError.new(schema.validate(body))])
end
in Failure(reason)
Failure(Error[:json, response.code, false, reason])
end
elsif response.timed_out?
Failure(Error[:timeout, response.code, true, HTTPError.new(response)])
elsif response.code != 0
Failure(Error[:http, response.code, true, HTTPError.new(response)])
else
Failure(Error[:network, response.code, true, HTTPError.new(response)])
end
end
def parse_body(body)
Success(JSON.parse(body))
rescue JSON::ParserError => error
Failure(error)
end
class SchemaError < StandardError
attr_reader :errors
def initialize(errors)
@errors = errors.to_a
super(@errors.map(&:to_json).join("\n"))
end
end
class HTTPError < StandardError
attr_reader :response
def initialize(response)
@response = response
uri = URI.parse(response.effective_url)
msg = <<~TEXT
url: #{uri.host}#{uri.path}
HTTP error code: #{response.code}
body: #{CGI.escape(response.body)}
curl message: #{response.return_message}
total time: #{response.total_time}
connect time: #{response.connect_time}
response headers: #{response.headers}
TEXT
super(msg)
end
end
end