Add resource framework: Base, CRUD mixins, and shared drafts/attachments/payments concerns

This commit is contained in:
2026-05-29 15:01:07 +02:00
parent 7ca2d76e51
commit 8185659f9c
4 changed files with 274 additions and 0 deletions
+179
View File
@@ -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 /<resource>/send. Named #dispatch to avoid overriding Object#send.
module Sendable
def dispatch(attributes)
post_create("#{base_path}/send", attributes)
end
end
# GET/POST /<resource>/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