Add HTTP core: Faraday connection, error mapping, response wrapper, pagination, and OAuth2

This commit is contained in:
2026-05-29 15:00:57 +02:00
parent 9eec49aec4
commit 7ca2d76e51
5 changed files with 387 additions and 0 deletions
+57
View File
@@ -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
+110
View File
@@ -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
+72
View File
@@ -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
+66
View File
@@ -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
+82
View File
@@ -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