From 7ca2d76e5131b06d41e4532c2d61787ad6040c78 Mon Sep 17 00:00:00 2001 From: Runar Ingebrigtsen Date: Fri, 29 May 2026 15:00:57 +0200 Subject: [PATCH] Add HTTP core: Faraday connection, error mapping, response wrapper, pagination, and OAuth2 --- lib/fiken/collection.rb | 57 +++++++++++++++++++++ lib/fiken/connection.rb | 110 ++++++++++++++++++++++++++++++++++++++++ lib/fiken/error.rb | 72 ++++++++++++++++++++++++++ lib/fiken/oauth.rb | 66 ++++++++++++++++++++++++ lib/fiken/object.rb | 82 ++++++++++++++++++++++++++++++ 5 files changed, 387 insertions(+) create mode 100644 lib/fiken/collection.rb create mode 100644 lib/fiken/connection.rb create mode 100644 lib/fiken/error.rb create mode 100644 lib/fiken/oauth.rb create mode 100644 lib/fiken/object.rb diff --git a/lib/fiken/collection.rb b/lib/fiken/collection.rb new file mode 100644 index 0000000..8812b58 --- /dev/null +++ b/lib/fiken/collection.rb @@ -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 diff --git a/lib/fiken/connection.rb b/lib/fiken/connection.rb new file mode 100644 index 0000000..8049e8f --- /dev/null +++ b/lib/fiken/connection.rb @@ -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 diff --git a/lib/fiken/error.rb b/lib/fiken/error.rb new file mode 100644 index 0000000..50a15c2 --- /dev/null +++ b/lib/fiken/error.rb @@ -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 diff --git a/lib/fiken/oauth.rb b/lib/fiken/oauth.rb new file mode 100644 index 0000000..0374c61 --- /dev/null +++ b/lib/fiken/oauth.rb @@ -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 diff --git a/lib/fiken/object.rb b/lib/fiken/object.rb new file mode 100644 index 0000000..84950a9 --- /dev/null +++ b/lib/fiken/object.rb @@ -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 + "#" + 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