# 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