Add HTTP core: Faraday connection, error mapping, response wrapper, pagination, and OAuth2
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Fiken
|
||||
# An enumerable page of list results plus the pagination metadata Fiken
|
||||
# returns in `Fiken-Api-*` response headers. `#auto_paging_each` transparently
|
||||
# walks every remaining page.
|
||||
class Collection
|
||||
include Enumerable
|
||||
|
||||
attr_reader :data, :page, :page_size, :page_count, :result_count
|
||||
|
||||
def initialize(result, fetch_page:)
|
||||
@data = Array(result.body).map { |item| Object.new(item) }
|
||||
@page = header_int(result, "Fiken-Api-Page")
|
||||
@page_size = header_int(result, "Fiken-Api-Page-Size")
|
||||
@page_count = header_int(result, "Fiken-Api-Page-Count")
|
||||
@result_count = header_int(result, "Fiken-Api-Result-Count")
|
||||
@fetch_page = fetch_page
|
||||
end
|
||||
|
||||
def each(&)
|
||||
@data.each(&)
|
||||
end
|
||||
|
||||
# Iterates every element across all remaining pages, fetching them lazily.
|
||||
def auto_paging_each(&block)
|
||||
return enum_for(:auto_paging_each) unless block_given?
|
||||
|
||||
current = self
|
||||
loop do
|
||||
current.each(&block)
|
||||
break if current.last_page?
|
||||
|
||||
current = current.next_page
|
||||
end
|
||||
end
|
||||
|
||||
def last_page?
|
||||
return true if page.nil? || page_count.nil?
|
||||
|
||||
page >= page_count - 1
|
||||
end
|
||||
|
||||
def next_page
|
||||
raise Error, "no more pages" if last_page?
|
||||
|
||||
@fetch_page.call(page + 1)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def header_int(result, name)
|
||||
value = result.headers[name]
|
||||
value && !value.to_s.empty? ? value.to_i : nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,110 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "faraday"
|
||||
require "faraday/retry"
|
||||
require "faraday/multipart"
|
||||
|
||||
module Fiken
|
||||
# Wraps a Faraday connection: bearer auth, JSON encode/decode, retry on
|
||||
# rate-limit/server errors, and mapping of HTTP error statuses to Fiken errors.
|
||||
class Connection
|
||||
DEFAULT_BASE_URL = "https://api.fiken.no/api/v2"
|
||||
|
||||
Result = Struct.new(:status, :body, :headers, keyword_init: true)
|
||||
|
||||
DEFAULT_RETRY = {
|
||||
max: 2,
|
||||
interval: 0.5,
|
||||
backoff_factor: 2,
|
||||
retry_statuses: [429, 500, 502, 503, 504],
|
||||
methods: [] # retry regardless of method; Fiken's 429 means "not processed"
|
||||
}.freeze
|
||||
|
||||
def initialize(token:, base_url: DEFAULT_BASE_URL, user_agent: nil,
|
||||
retry_options: {}, logger: nil, adapter: Faraday.default_adapter)
|
||||
@token = token
|
||||
# Faraday joins relative paths against the base URL, which only behaves
|
||||
# predictably when the base ends in "/" and request paths do not start with one.
|
||||
@base_url = base_url.end_with?("/") ? base_url : "#{base_url}/"
|
||||
@user_agent = user_agent || "fiken-ruby/#{Fiken::VERSION}"
|
||||
@retry_options = DEFAULT_RETRY.merge(retry_options)
|
||||
@logger = logger
|
||||
@adapter = adapter
|
||||
end
|
||||
|
||||
def get(path, params = nil, headers = nil)
|
||||
run(:get, path, params: params, headers: headers)
|
||||
end
|
||||
|
||||
def post(path, body = nil, headers = nil)
|
||||
run(:post, path, body: body, headers: headers)
|
||||
end
|
||||
|
||||
def put(path, body = nil, headers = nil)
|
||||
run(:put, path, body: body, headers: headers)
|
||||
end
|
||||
|
||||
def patch(path, body = nil, headers = nil)
|
||||
run(:patch, path, body: body, headers: headers)
|
||||
end
|
||||
|
||||
def delete(path, params = nil, headers = nil)
|
||||
run(:delete, path, params: params, headers: headers)
|
||||
end
|
||||
|
||||
# Multipart upload (attachments). `parts` is a hash of field name => value,
|
||||
# where a value may be a Faraday::Multipart::FilePart for the file itself.
|
||||
def post_multipart(path, parts)
|
||||
response = multipart_faraday.post(path.sub(%r{\A/}, ""), parts)
|
||||
raise_on_error(response)
|
||||
Result.new(status: response.status, body: response.body, headers: response.headers)
|
||||
rescue Faraday::Error => e
|
||||
raise ConnectionError, e.message
|
||||
end
|
||||
|
||||
def faraday
|
||||
@faraday ||= Faraday.new(url: @base_url) do |f|
|
||||
f.request :authorization, "Bearer", @token
|
||||
f.request :json
|
||||
f.request :retry, @retry_options
|
||||
f.response :json, content_type: /\bjson$/, parser_options: { symbolize_names: false }
|
||||
f.headers["User-Agent"] = @user_agent
|
||||
f.headers["Accept"] = "application/json"
|
||||
f.response :logger, @logger if @logger
|
||||
f.adapter @adapter
|
||||
end
|
||||
end
|
||||
|
||||
def multipart_faraday
|
||||
@multipart_faraday ||= Faraday.new(url: @base_url) do |f|
|
||||
f.request :authorization, "Bearer", @token
|
||||
f.request :multipart
|
||||
f.request :url_encoded
|
||||
f.response :json, content_type: /\bjson$/
|
||||
f.headers["User-Agent"] = @user_agent
|
||||
f.headers["Accept"] = "application/json"
|
||||
f.adapter @adapter
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def run(method, path, params: nil, body: nil, headers: nil)
|
||||
response = faraday.public_send(method, path.sub(%r{\A/}, "")) do |req|
|
||||
req.params.update(params) if params
|
||||
req.body = body unless body.nil?
|
||||
req.headers.update(headers) if headers
|
||||
end
|
||||
raise_on_error(response)
|
||||
Result.new(status: response.status, body: response.body, headers: response.headers)
|
||||
rescue Faraday::Error => e
|
||||
raise ConnectionError, e.message
|
||||
end
|
||||
|
||||
def raise_on_error(response)
|
||||
return if response.status < 400
|
||||
|
||||
raise Error.from_response(response.status, response.body, response)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,72 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Fiken
|
||||
# Base class for all errors raised by the gem.
|
||||
class Error < StandardError
|
||||
attr_reader :status, :body, :response
|
||||
|
||||
def initialize(message = nil, status: nil, body: nil, response: nil)
|
||||
@status = status
|
||||
@body = body
|
||||
@response = response
|
||||
super(message || "Fiken API error (HTTP #{status})")
|
||||
end
|
||||
|
||||
# Builds the most specific error subclass for an HTTP response.
|
||||
def self.from_response(status, body, response = nil)
|
||||
klass = STATUS_MAP[status] || (status >= 500 ? ServerError : self)
|
||||
klass.new(extract_message(body), status: status, body: body, response: response)
|
||||
end
|
||||
|
||||
def self.extract_message(body)
|
||||
case body
|
||||
when String then body.empty? ? nil : body
|
||||
when Hash then hash_message(body)
|
||||
end
|
||||
end
|
||||
|
||||
def self.hash_message(body)
|
||||
%w[message error_description error].each do |key|
|
||||
value = body[key]
|
||||
return value if value
|
||||
end
|
||||
messages = Array(body["validation_messages"])
|
||||
messages.join(", ") unless messages.empty?
|
||||
end
|
||||
end
|
||||
|
||||
# Raised when the request never reached Fiken (timeout, DNS, connection reset).
|
||||
class ConnectionError < Error; end
|
||||
|
||||
# 400
|
||||
class BadRequest < Error; end
|
||||
# 401
|
||||
class Unauthorized < Error; end
|
||||
# 403
|
||||
class Forbidden < Error; end
|
||||
# 404
|
||||
class NotFound < Error; end
|
||||
# 406
|
||||
class NotAcceptable < Error; end
|
||||
# 409
|
||||
class Conflict < Error; end
|
||||
# 422
|
||||
class UnprocessableEntity < Error; end
|
||||
# 429
|
||||
class RateLimited < Error; end
|
||||
# 5xx
|
||||
class ServerError < Error; end
|
||||
|
||||
class Error
|
||||
STATUS_MAP = {
|
||||
400 => BadRequest,
|
||||
401 => Unauthorized,
|
||||
403 => Forbidden,
|
||||
404 => NotFound,
|
||||
406 => NotAcceptable,
|
||||
409 => Conflict,
|
||||
422 => UnprocessableEntity,
|
||||
429 => RateLimited
|
||||
}.freeze
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,66 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "uri"
|
||||
require "faraday"
|
||||
|
||||
module Fiken
|
||||
# OAuth2 authorization-code helper.
|
||||
#
|
||||
# oauth = Fiken::OAuth.new(client_id:, client_secret:, redirect_uri:)
|
||||
# redirect_to oauth.authorize_url(state: "abc")
|
||||
# token = oauth.exchange_code(params[:code])
|
||||
# client = Fiken::Client.new(access_token: token.access_token)
|
||||
# refreshed = oauth.refresh(token.refresh_token)
|
||||
class OAuth
|
||||
AUTHORIZE_URL = "https://fiken.no/oauth/authorize"
|
||||
TOKEN_URL = "https://fiken.no/oauth/token"
|
||||
|
||||
attr_reader :client_id, :client_secret, :redirect_uri
|
||||
|
||||
def initialize(client_id:, client_secret:, redirect_uri: nil)
|
||||
@client_id = client_id
|
||||
@client_secret = client_secret
|
||||
@redirect_uri = redirect_uri
|
||||
end
|
||||
|
||||
# Build the URL to send the user to in order to authorize the app.
|
||||
def authorize_url(state:, redirect_uri: self.redirect_uri, scope: nil)
|
||||
params = {
|
||||
response_type: "code",
|
||||
client_id: client_id,
|
||||
redirect_uri: redirect_uri,
|
||||
state: state,
|
||||
scope: scope
|
||||
}.compact
|
||||
"#{AUTHORIZE_URL}?#{URI.encode_www_form(params)}"
|
||||
end
|
||||
|
||||
# Exchange the authorization code from the callback for tokens.
|
||||
def exchange_code(code, redirect_uri: self.redirect_uri)
|
||||
token_request(grant_type: "authorization_code", code: code, redirect_uri: redirect_uri)
|
||||
end
|
||||
|
||||
# Use a refresh token to obtain a fresh access token.
|
||||
def refresh(refresh_token)
|
||||
token_request(grant_type: "refresh_token", refresh_token: refresh_token)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def token_request(params)
|
||||
response = token_connection.post(TOKEN_URL, params.compact)
|
||||
raise Error.from_response(response.status, response.body) if response.status >= 400
|
||||
|
||||
Object.new(response.body)
|
||||
end
|
||||
|
||||
def token_connection
|
||||
@token_connection ||= Faraday.new do |f|
|
||||
f.request :url_encoded
|
||||
f.request :authorization, :basic, client_id, client_secret
|
||||
f.response :json, content_type: /\bjson$/
|
||||
f.adapter Faraday.default_adapter
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,82 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Fiken
|
||||
# A lightweight, read-only wrapper around a JSON response body.
|
||||
#
|
||||
# Gives both dot-access and hash-access, built recursively:
|
||||
#
|
||||
# invoice = Fiken::Object.new("invoiceNumber" => 5, "lines" => [{ "vat" => 25 }])
|
||||
# invoice.invoice_number # => 5 (also: invoice.invoiceNumber)
|
||||
# invoice[:invoiceNumber] # => 5
|
||||
# invoice.lines.first.vat # => 25
|
||||
class Object
|
||||
def initialize(attributes = {})
|
||||
@attributes = {}
|
||||
(attributes || {}).each { |key, value| @attributes[key.to_sym] = wrap(value) }
|
||||
end
|
||||
|
||||
def [](key)
|
||||
@attributes[normalize(key)]
|
||||
end
|
||||
|
||||
def key?(key)
|
||||
@attributes.key?(normalize(key))
|
||||
end
|
||||
|
||||
def keys
|
||||
@attributes.keys
|
||||
end
|
||||
|
||||
def to_h
|
||||
@attributes.transform_values { |value| unwrap(value) }
|
||||
end
|
||||
alias to_hash to_h
|
||||
|
||||
def ==(other)
|
||||
other.is_a?(Object) && other.to_h == to_h
|
||||
end
|
||||
|
||||
def respond_to_missing?(name, include_private = false)
|
||||
@attributes.key?(normalize(name)) || super
|
||||
end
|
||||
|
||||
def method_missing(name, *args)
|
||||
key = normalize(name)
|
||||
return @attributes[key] if @attributes.key?(key)
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def inspect
|
||||
"#<Fiken::Object #{@attributes.map { |k, v| "#{k}=#{v.inspect}" }.join(', ')}>"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Accepts both :invoiceNumber and :invoice_number, mapping snake_case to the
|
||||
# camelCase keys Fiken returns.
|
||||
def normalize(key)
|
||||
sym = key.to_sym
|
||||
return sym if @attributes.key?(sym)
|
||||
|
||||
camel = key.to_s.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }.to_sym
|
||||
@attributes.key?(camel) ? camel : sym
|
||||
end
|
||||
|
||||
def wrap(value)
|
||||
case value
|
||||
when Hash then Object.new(value)
|
||||
when Array then value.map { |element| wrap(element) }
|
||||
else value
|
||||
end
|
||||
end
|
||||
|
||||
def unwrap(value)
|
||||
case value
|
||||
when Object then value.to_h
|
||||
when Array then value.map { |element| unwrap(element) }
|
||||
else value
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user