Backends and Models
Overview
A Resource queries and persists to a Backend. It returns Models from the Backend response, which get serialized. In this way, it is an implementation of the Repository Pattern.
This is best illustrated in code. Let’s say we have a Backend class that accepts a hash of options to perform a query:
results = Backend.query \
conditions: { name: 'Jane' },
sort: { created_at: :asc }
results # [{ id: 1, name: 'Jane', rank: 83 }, ...]
And a PORO Model that encapsulates those results, holding business logic:
class Employee
attr_accessor :id, :name, :rank
def initialize(attrs = {})
attrs.each_pair { |k, v| send(:"#{k}=", v) }
end
def exemplary?
rank > 80
end
end
Meaning that normally, our code would look something like:
results = Backend.query(params)
employees = results.map { |r| Employee.new(r) }
employees.map(&:exemplary?) # => [true, false, ...]
Let’s wire-up that same code to a Resource:
class EmployeeResource < ApplicationResource
# We'll be coding the logic manually
self.adapter = Graphiti::Adapters::Null
attribute :name, :string
# The blank scope we start with
def base_scope
{ conditions: {}, sort: {} }
end
# Merge filters into the hash based on request params
filter :name do
eq do |scope, value|
scope[:conditions].merge!(value)
scope
end
end
# Set sort based on request params
sort :name do |scope, direction|
scope[:sort] = { name: direction }
scope
end
# 'scope' here is our hash
# We pass it to Backend.query, and return Models
def resolve(scope)
results = Backend.query(scope)
results.map { |r| Employee.new(r) }
end
end
As you see above, a scope can be anything from an
ActiveRecord::Relation
to a plain Ruby Hash. We want to adjust
something based on the request parameters and pass it to our backend.
From the raw backend results, we can instantiate Models. Note that we
always return the full scope at the end of each block.
Of course, most Backends have predictable and consistent interfaces. It would be a pain to manually write this code for every Resource. So instead we could build an Adapter to DRY this logic:
class EmployeeResource < ApplicationResource
self.adapter = BackendAdapter
attribute :name, :string
end
In summary: a Resource builds a query that is sent to a Backend. The backend executes the query, and we instantiate Models from the raw results.
ActiveRecord
From the ActiveRecord Guides:
Active Record was described by Martin Fowler in his book Patterns of Enterprise Application Architecture. In Active Record, objects carry both persistent data and behavior which operates on that data. Active Record takes the opinion that ensuring data access logic as part of the object will educate users of that object on how to write to and read from the database.
In other words, ActiveRecord combines a Backend and Model. Opinions on this vary, but Graphiti supports either approach: we can separate data and business layers, or combine them. See the ActiveRecord doppelgänger of the above at our Resource cheatsheet.
Model Requirements
The only hard requirement of a Model is that it responds to id
. We use
model.id
to determine uniqueness when rendering a JSONAPI response.
You will get incorrect results if model.id
is not unique.
Models should also respond to any readable attributes. Remember that:
attribute :name, :string
Is the same as
# @object is your Model instance
attribute :name, :string do
@object.name
end
If your Model does not respond to #name
, either pass a block to attribute
or
look into aliasing.
Validations
Graphiti will perform validations on your models during write requests, returning a JSONAPI-compliant errors payload. To get this functionality, your model must adhere to the ActiveModel::Validations API:
model.valid?
object.errors.messages.each_pair { ... }
It is highly recommended to mix in:
class Employee
include ActiveModel::Validations
end
Model Implementations
Because our default is ActiveRecord, it may be unclear what other Models look like. Graphiti itself has no opinion about your Model layer, but below are a few examples.
2.1 PORO
class Employee
attr_accessor :id,
:first_name,
:last_name,
:age
def initialize(attrs = {})
attrs.each_pair { |k,v| send(:"#{k}=", v) }
end
end
This is a common Ruby example. attr_accessor
defines getters and
setters for our properties, and we assign those properties in the
constructor:
e = Employee.new(id: 1, first_name: 'Jane')
e.first_name # => 'Jane'
2.2 ActiveModel::Model
A simple abstraction of the above is ActiveModel::Model:
class Employee
include ActiveModel::Model
attr_accessor :id,
:first_name,
:last_name,
:age
end
e = Employee.new(id: 1, first_name: 'Jane')
e.first_name # => 'Jane'
2.3 Dry::Struct
dry-types is a dependency of Graphiti and successor to the popular Virtus.
module Types
include Dry::Types.module
end
class Employee < Dry::Struct
attribute :id, Types::Integer
attribute :first_name, Types::String
attribute :last_name, Types::Integer
attribute :age, Types::Integer
end
e = Employee.new(id: 1, first_name: 'Jane')
e.first_name # => 'Jane'
3 Model Tips
ID-less Models
If your Model does not have an id
property, using a random UUID is
perfectly acceptable:
def id
@id ||= SecureRandom.uuid
end