Add resource framework: Base, CRUD mixins, and shared drafts/attachments/payments concerns
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "faraday/multipart"
|
||||||
|
|
||||||
|
module Fiken
|
||||||
|
module Resources
|
||||||
|
module Concerns
|
||||||
|
# /<parent>/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
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Fiken
|
||||||
|
module Resources
|
||||||
|
module Concerns
|
||||||
|
# /<parent>/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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Fiken
|
||||||
|
module Resources
|
||||||
|
module Concerns
|
||||||
|
# /<parent>/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
|
||||||
Reference in New Issue
Block a user