On my implementation of a client i used dry-types and dry-struct to define the types on the jsonapi spec.
For example: (copied all the types for the sake of sharing one snippet ๐
)
# frozen_string_literal: true
module JSONAPI
module Types
include Dry::Types.module
Data = Hash | Array
Link = String | Constructor(JSONAPI::Types::LinkObject)
Links = Map(key_type: Symbol, value_type: JSONAPI::Types::Link)
ErrorsArray = Array.of(Constructor(JSONAPI::Types::ErrorObject)).default([])
Relationships = Map(key_type: Symbol, value_type: Constructor(JSONAPI::Types::Relationship))
ResourcesArray = Array.of(Constructor(JSONAPI::Types::Resource)).default([])
PrimaryData = Constructor(JSONAPI::Types::Data) do |data|
case data
when Hash
JSONAPI::Types::Resource.call(data)
when Array
JSONAPI::Types::ResourcesArray.call(data)
end
end
class Base < Dry::Struct
transform_types { |type| type.meta(omittable: true) }
end
class Document < Base
attribute :data, JSONAPI::Types::PrimaryData
attribute :errors, JSONAPI::Types::ErrorsArray
attribute :links, JSONAPI::Types::Links
attribute :included, JSONAPI::Types::ResourcesArray
attribute :meta, Types::Hash
def errors?
errors.any?
end
class << self
def parse(payload)
new transform_keys(payload.is_a?(String) ? parse_json(payload) : payload)
end
def parse_json(payload)
JSON.parse(payload, symbolize_names: true)
rescue JSON::ParserError
{}
end
private
def transform_keys(payload)
(payload || {}).deep_transform_keys do |key|
{ attributes: :resource_attributes }.fetch(key.to_sym, key.to_sym)
end
end
end
end
class Resource < Base
attribute :id, Types::String
attribute :type, Types::String
# Renamed to avoid collision with Dry::Struct
attribute :resource_attributes, Types::Hash
attribute :relationships, JSONAPI::Types::Relationships
attribute :links, JSONAPI::Types::Links
def identifier_object
attributes.slice(:id, :type)
end
end
class Relationship < Base
attribute :data, JSONAPI::Types::Data
attribute :links, JSONAPI::Types::Links
attribute :meta, Types::Hash
def resource_identifier_objects
case data
when Hash
[data]
when Array
data
else
[]
end
end
end
class LinkObject < Base
attribute :href, Types::String
attribute :meta, Types::Hash
end
class ErrorSource < Base
attribute :pointer, Types::String
attribute :parameter, Types::String
end
class ErrorObject < Base
attribute :id, Types::String
attribute :status, Types::String
attribute :code, Types::String
attribute :title, Types::String
attribute :detail, Types::String
attribute :meta, Types::Hash
attribute :links, JSONAPI::Types::Links
attribute :source, Types::Constructor(JSONAPI::Types::ErrorSource)
end
end
end
Then you could just parse the payload like:
JSONAPI::Types::Document.parse(payload)
What i like of this approach is that the types definition of the spec could be extracted to a gem in other to be used on different projects.
I also went for using rest-client instead of Faraday.
Because i wanted to have a client class that was usable on a more higher lever without known about resources:
# frozen_string_literal: true
module JSONAPI
module Client
RestClient.log = Logger.new(STDERR) if Rails.env.development?
def self.default_headers
{
accept: "application/vnd.api+json",
content_type: "application/vnd.api+json"
}
end
def self.headers(additional_headers)
default_headers.merge(additional_headers)
end
def self.create(url, payload = {}, additional_headers = {})
execute :post, url, payload: payload.to_json, headers: headers(additional_headers)
end
def self.update(url, payload = {}, additional_headers = {})
execute :patch, url, payload: payload.to_json, headers: headers(additional_headers)
end
def self.delete(url, additional_headers = {})
execute :delete, url, headers: headers(additional_headers)
end
def self.fetch(url, query = {}, additional_headers = {})
execute :get, url, headers: headers(additional_headers).merge(params: query)
end
def self.execute(method, url, options = {})
response =
begin
RestClient::Request.execute(options.merge(method: method, url: url))
rescue RestClient::UnprocessableEntity => error
error.response
end
JSONAPI::Types::Document.parse(response)
end
end
end
On top of this i started implementing the Resource functionality ActiveResource
style:
# frozen_string_literal: true
module JSONAPI
module Resource
class Client
attr_reader :base_uri, :resource_path, :headers
def initialize(base_uri, resource_path, headers = {})
@base_uri = base_uri
@resource_path = resource_path
@headers = headers
end
def collection_url
URI.join(base_uri, resource_path).to_s
end
def individual_url(id)
[collection_url, id].join("/")
end
def related_url(id, relationship)
[collection_url, id, relationship].join("/")
end
def find(id, query = {})
JSONAPI::Client.fetch(individual_url(id), query, headers)
end
def all(query = {})
JSONAPI::Client.fetch(collection_url, query, headers)
end
def related(id, relationship, query = {})
JSONAPI::Client.fetch(related_url(id, relationship), query, headers)
end
def create(payload)
JSONAPI::Client.create(collection_url, payload, headers)
end
def update(id, payload)
JSONAPI::Client.update(individual_url(id), payload, headers)
end
def destroy(id)
JSONAPI::Client.delete(individual_url(id), headers)
end
end
end
end
# frozen_string_literal: true
module JSONAPI
module Resource
class Base # rubocop:disable Metrics/ClassLength
class_attribute :base_uri
attr_accessor :id, :persisted, :resource_attributes, :relationships, :errors, :links, :included
delegate :client, :type, to: :class
alias persisted? persisted
class << self
def table_name
resource_name.pluralize
end
def type
table_name
end
def resource_name
name.demodulize.underscore
end
def resource_path
table_name
end
def client
JSONAPI::Resource::Client.new(base_uri, resource_path, default_headers)
end
def default_headers
{}
end
def persist(params = {})
new(params).tap(&:persist)
end
def attribute(name, type:)
define_method(name) do
type.call(resource_attributes[name])
end
define_method("#{name}=") do |value|
resource_attributes[name] = type.call(value)
end
end
def create(params = {})
new(params).tap(&:save)
end
def create!(params = {})
new(params).tap(&:save!)
end
def find(id, query = {})
document = client.find(id, query)
if document.errors?
new(errors: document.errors)
else
persist(**document.data.to_hash, included: document.included)
end
end
def all(query = {})
document = client.all(query)
if document.errors?
JSONAPI::Resource::Collection.new(errors: document.errors)
else
JSONAPI::Resource::Collection.new(links: document.links, resources: map_to_resources(document))
end
end
def map_to_resources(document)
document.data.map do |resource|
persist(**resource.to_hash, included: document.included)
end
end
end
def initialize(params = {})
@id = params[:id]
@resource_attributes = params.fetch(:resource_attributes, {})
@relationships = params.fetch(:relationships, {})
@included = params.fetch(:included, [])
@errors = params.fetch(:errors, [])
@links = params.fetch(:links, {})
@persisted = false
end
def method_missing(method_name, *args, &block)
return resource_attributes.fetch(method_name) if respond_to_missing?(method_name)
super
end
def respond_to_missing?(method_name, *)
resource_attributes.key?(method_name)
end
def save
document = persisted? ? client.update(id, request_payload) : client.create(request_payload)
if document.errors?
@errors = document.errors
else
@errors = []
@id = document.data.id
@resource_attributes = document.data.resource_attributes || {}
@relationships = document.data.relationships || {}
@included = document.included
@links = document.data.links || {}
@persisted = true
end
@errors.empty?
end
def save!
raise JSONAPI::UnprocessableEntity unless save
end
def destroy
document = client.destroy(id)
if document.errors?
@errors = document.errors
@persisted = true
else
@persisted = false
end
!persisted
end
def destroy!
raise JSONAPI::UnprocessableEntity unless destroy
end
def persist
@persisted = true
end
def request_payload
JSONAPI::Payload.new(type: type)
.with_id(id)
.with_attributes(resource_attributes)
.with_relationships(relationships)
.to_h
end
def to_relationship
JSONAPI::Types::Relationship.new(data: identifier_object)
end
def identifier_object
{ type: type, id: id }
end
def fetch_related_collection(relationship, klass, query = {})
document = client.related(id, relationship, query)
if document.errors?
JSONAPI::Resource::Collection.new(errors: document.errors)
else
JSONAPI::Resource::Collection.new(links: document.links, resources: klass.map_to_resources(document))
end
end
end
end
end
Of course my client is far from being as feature rich as this gem, but i needed a bit more of control, so i went for it. ๐
I wanted to share it to see if there is some ideas you think are worth including on this gem.
I totally understand if you don't agree on the decisions i took.
Just trying to see if we could collaborate on just one library :)
Resources