From 8185659f9ca8d820bfab7e4fbdb2b887b13aff5a Mon Sep 17 00:00:00 2001 From: Runar Ingebrigtsen Date: Fri, 29 May 2026 15:01:07 +0200 Subject: [PATCH] Add resource framework: Base, CRUD mixins, and shared drafts/attachments/payments concerns --- lib/fiken/resource.rb | 179 ++++++++++++++++++++ lib/fiken/resources/concerns/attachments.rb | 36 ++++ lib/fiken/resources/concerns/drafts.rb | 36 ++++ lib/fiken/resources/concerns/payments.rb | 23 +++ 4 files changed, 274 insertions(+) create mode 100644 lib/fiken/resource.rb create mode 100644 lib/fiken/resources/concerns/attachments.rb create mode 100644 lib/fiken/resources/concerns/drafts.rb create mode 100644 lib/fiken/resources/concerns/payments.rb diff --git a/lib/fiken/resource.rb b/lib/fiken/resource.rb new file mode 100644 index 0000000..f747be7 --- /dev/null +++ b/lib/fiken/resource.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +module Fiken + # Building blocks for the resource-oriented API surface. + # + # A resource is bound to a client and (for company-scoped resources) a company + # slug. Concrete resources subclass Base and mix in the verb modules they support. + module Resource + class Base + attr_reader :client, :company_slug + + def initialize(client, company_slug = nil) + @client = client + @company_slug = company_slug + end + + # The path segment for this resource, e.g. "contacts". Override in subclasses. + def resource_path + raise NotImplementedError, "#{self.class} must define #resource_path" + end + + def base_path + if company_slug + "/companies/#{company_slug}/#{resource_path}" + else + "/#{resource_path}" + end + end + + protected + + def connection + client.connection + end + + def wrap(body) + Object.new(body) + end + + # GET a list endpoint, returning a paginated Collection. + def get_collection(path, params) + result = connection.get(path, params) + Collection.new(result, fetch_page: lambda { |page| + get_collection(path, params.merge(page: page)) + }) + end + + # GET a single resource, returning a wrapped Object. + def get_one(path) + wrap(connection.get(path).body) + end + + # POST to create. Fiken create endpoints typically return 201 with an empty + # body and a Location header; we return the parsed body when present, else an + # Object carrying the location and the id parsed from it. + def post_create(path, attributes = nil) + build_created(connection.post(path, attributes)) + end + + def put_one(path, attributes) + wrap(connection.put(path, attributes).body) + end + + def patch_one(path, attributes = nil) + wrap(connection.patch(path, attributes).body) + end + + def delete_path(path) + connection.delete(path) + true + end + + def build_created(result) + body = result.body + return wrap(body) if body.is_a?(Hash) && !body.empty? + + location = result.headers["Location"] + wrap("location" => location, "id" => location&.split("/")&.last) + end + + def build_file_part(path, io, filename, content_type) + if path + Faraday::Multipart::FilePart.new(path, content_type, filename || File.basename(path)) + elsif io + Faraday::Multipart::FilePart.new(io, content_type, filename) + else + raise ArgumentError, "provide path: or io:" + end + end + end + + module Listable + def list(**params) + get_collection(base_path, params) + end + alias all list + end + + module Findable + def find(id) + get_one("#{base_path}/#{id}") + end + alias retrieve find + end + + module Creatable + def create(attributes) + post_create(base_path, attributes) + end + end + + # Update via PUT (used for drafts and other full-replacement endpoints). + module Updatable + def update(id, attributes) + put_one("#{base_path}/#{id}", attributes) + end + end + + # Update via PATCH (used for finalized invoices, projects, time entries, ...). + module PatchUpdatable + def update(id, attributes) + patch_one("#{base_path}/#{id}", attributes) + end + end + + module Deletable + def delete(id) + delete_path("#{base_path}/#{id}") + end + end + + # For documents that expose a /drafts sub-resource. The host defines + # #draft_create_action (the path segment that finalizes a draft). + module Draftable + def drafts + Resources::Concerns::Drafts.new(client, company_slug, base_path, draft_create_action) + end + end + + # For resources that expose /{id}/attachments. Override #attachments_listable? + # for resources that only allow uploading (e.g. contacts). + module Attachable + def attachments(id) + Resources::Concerns::Attachments.new( + client, company_slug, "#{base_path}/#{id}", listable: attachments_listable? + ) + end + + def attachments_listable? + true + end + end + + # For documents that expose /{id}/payments (sales, purchases). + module Payable + def payments(id) + Resources::Concerns::Payments.new(client, company_slug, "#{base_path}/#{id}") + end + end + + # POST //send. Named #dispatch to avoid overriding Object#send. + module Sendable + def dispatch(attributes) + post_create("#{base_path}/send", attributes) + end + end + + # GET/POST //counter. + module HasCounter + def counter + get_one("#{base_path}/counter") + end + + def create_counter(attributes = nil) + post_create("#{base_path}/counter", attributes) + end + end + end +end diff --git a/lib/fiken/resources/concerns/attachments.rb b/lib/fiken/resources/concerns/attachments.rb new file mode 100644 index 0000000..cd16b99 --- /dev/null +++ b/lib/fiken/resources/concerns/attachments.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "faraday/multipart" + +module Fiken + module Resources + module Concerns + # //attachments — list (where supported) and multipart upload. + class Attachments < Resource::Base + def initialize(client, company_slug, parent_path, listable: true) + super(client, company_slug) + @parent_path = parent_path + @listable = listable + end + + def base_path + "#{@parent_path}/attachments" + end + + def list + raise Error, "this resource does not support listing attachments" unless @listable + + get_collection(base_path, {}) + end + + # Upload a file. Provide either path: (a filename on disk) or io: + filename:. + # Extra Fiken fields (e.g. comment:, attachToPayment:) are passed as form fields. + def add(path: nil, io: nil, filename: nil, content_type: "application/octet-stream", **fields) + parts = { "file" => build_file_part(path, io, filename, content_type) } + fields.each { |key, value| parts[key.to_s] = value.to_s unless value.nil? } + build_created(connection.post_multipart(base_path, parts)) + end + end + end + end +end diff --git a/lib/fiken/resources/concerns/drafts.rb b/lib/fiken/resources/concerns/drafts.rb new file mode 100644 index 0000000..d16a4ca --- /dev/null +++ b/lib/fiken/resources/concerns/drafts.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Fiken + module Resources + module Concerns + # //drafts — the draft lifecycle shared by invoices, credit notes, + # offers, order confirmations, sales and purchases. + class Drafts < Resource::Base + include Resource::Listable + include Resource::Findable + include Resource::Creatable + include Resource::Updatable # drafts update via PUT + include Resource::Deletable + + def initialize(client, company_slug, parent_path, create_action) + super(client, company_slug) + @parent_path = parent_path + @create_action = create_action + end + + def base_path + "#{@parent_path}/drafts" + end + + def attachments(draft_id) + Attachments.new(client, company_slug, "#{base_path}/#{draft_id}") + end + + # Finalize a draft into its document (e.g. createInvoice, createSale). + def create_document(draft_id, attributes = nil) + post_create("#{base_path}/#{draft_id}/#{@create_action}", attributes) + end + end + end + end +end diff --git a/lib/fiken/resources/concerns/payments.rb b/lib/fiken/resources/concerns/payments.rb new file mode 100644 index 0000000..982a088 --- /dev/null +++ b/lib/fiken/resources/concerns/payments.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Fiken + module Resources + module Concerns + # //payments — shared by sales and purchases. + class Payments < Resource::Base + include Resource::Listable + include Resource::Findable + include Resource::Creatable + + def initialize(client, company_slug, parent_path) + super(client, company_slug) + @parent_path = parent_path + end + + def base_path + "#{@parent_path}/payments" + end + end + end + end +end