Skip to main content

Resources

A Resource is an abstraction around an API endpoint, the way a Model is an abstraction around a database table. It holds the logic for querying, persisting, and serializing one kind of thing.

class EmployeeResource < ApplicationResource
attribute :first_name, :string
attribute :age, :integer

has_many :positions
end

This page is the full reference. For the whole API on one screen, see the cheatsheet on the home page. For how a request flows through a Resource, see Lifecycle of a Request.

Resources connect to each other. That's covered separately in Relationships, and writes in Persisting.

Attributes

attribute :first_name, :string

A name (first_name) maps to a JSON key. A Type (string) maps to a JSON value and its coercion rules.

Limiting Behavior

attribute :name, :string,
readable: true, # renders in responses
writable: true, # accepted on create/update
sortable: true, # ?sort=name works
filterable: true, # ?filter[name]=... works
schema: true # exported to schema.json, not affected by only/except

Turn any flag off directly, or with only/except shorthand:

attribute :name, :string, sortable: false
attribute :name, :string, only: [:sortable]
attribute :name, :string, except: [:writable]

Guards. readable and writable also accept a symbol, string, or proc. The behavior applies only when the guard returns true, and the guard's arity decides what it receives:

attribute :name, :string, writable: :admin?
attribute :salary, :integer, readable: :visible?, writable: :salary_writable?

def admin? # no arguments
context.current_user.admin?
end

def visible?(model) # the model
model.internal == false
end

def salary_writable?(model, attribute_name) # the model and the attribute name
PolicyChecker.new(model).attribute_writable?(attribute_name)
end

The model is only looked up when a guard declares a parameter for it, so zero-argument guards cost nothing. On an update it's the persisted record. On a create, it's a new unsaved instance.

Guard returns false onResult
readableThe attribute is omitted from the response.
writableThe request is rejected with an unwritable_attribute validation error, before anything is persisted.

Default Behavior

# On ApplicationResource, affects every subclass
self.attributes_readable_by_default = false # default true
self.attributes_writable_by_default = false # default true
self.attributes_filterable_by_default = false # default true
self.attributes_sortable_by_default = false # default true
self.attributes_schema_by_default = false # default true

Each *_by_default setting can also be a guard symbol, delegating the check to a method. Useful for wiring every attribute through one authorization system:

self.attributes_readable_by_default = :attribute_readable?

def attribute_readable?(model_instance, attribute_name)
PolicyChecker.new(model_instance).attribute_readable?(attribute_name)
end

Customizing Display

attribute :name, :string do
@object.name.upcase # @object is the model instance
end

Types

TypeNotes
string
integer
integer_idRenders as a string, queries/persists as an integer. Default type for id.
uuidLike string, but only eq/not_eq, case-sensitive by default.
string_enumLike string, but only eq/not_eq/eql/not_eql, and requires allow:.
integer_enumLike integer, but only eq/not_eq, and requires allow:.
big_decimal
float
boolean
date
datetime
hash
array

Every type except boolean, hash, and array also has an array_of_* variant: array_of_integers, array_of_dates, array_of_uuids, and so on.

Each Type governs reading, writing, and filtering by wrapping a Dry Type. Inspect one to see its parts:

Graphiti::Types[:integer_id]

# {
# params: Dry::Types['coercible.integer'],
# read: Dry::Types['coercible.string'],
# write: Dry::Types['coercible.integer'],
# ...
# }

Edit an implementation in place. Here, :string is made to render as an integer:

Graphiti::Types[:string][:read] = Dry::Types['coercible.integer']

Enum Types

string_enum and integer_enum behave like string and integer, except declaring one (as an attribute or a filter) requires the allow: option, the list of acceptable values:

attribute :status, :string_enum, allow: ['draft', 'published']

If your attribute is backed by an ActiveRecord enum, reference the values directly:

# app/models/post.rb
class Post < ApplicationRecord
enum status: {
draft: 0,
published: 1
}
end

# app/resources/post_resource.rb
class PostResource < ApplicationResource
attribute :status, :string_enum, allow: Post.statuses.keys
end

See Filter Options for more on allow.

Graphiti does not validate enum values on write. Your model layer is still expected to validate incoming data.

Custom Types

Dry Types supports custom types:

# Define the Type
definition = Dry::Types::Nominal.new(String)
type = definition.constructor do |input|
input.upcase
end

# Register it with Graphiti
Graphiti::Types[:caps_lock] = {
params: type,
read: type,
write: type,
kind: 'scalar',
canonical_name: :caps_lock,
description: 'All capital letters'
}

# Use in a Resource
attribute :name, :caps_lock

Querying

class PostResource < ApplicationResource
# Applies to every query: start with a base scope, alter it based on
# the incoming request. Called just like ActiveRecord's Post.all.
def base_scope
Post.all
end

# Must execute the query and return an array of Model instances.
def resolve(scope)
scope.to_a
end
end

Query Interface

Resources can query and persist without an API request or response. Pass a JSONAPI-compliant query hash directly:

EmployeeResource.all({
filter: { first_name: 'Jane' },
sort: '-created_at',
page: { size: 10, number: 2 }
})

The return value from .all is a proxy object, similar to ActiveRecord::Relation. No query fires until you call .map, .data, or a render method:

employees = EmployeeResource.all
employees.class # Graphiti::ResourceProxy
employees.map(&:first_name) # => ["Jane", "Joe", ...]
employees.data # => [#<Employee>, #<Employee>, ...]

employees.to_jsonapi
employees.to_json
employees.to_xml

.find returns a single record's proxy by id, raising Graphiti::Errors::RecordNotFound if none are returned:

employee = EmployeeResource.find(id: 123)
employee.data.first_name # => "Jane"

Composing with Scopes

#base_scope

def base_scope
Position.where(active: true)
end

Override #base_scope for logic that should apply to every query. Here, it only ever returns active Positions.

Pass a second argument to .all to override the base scope for a single call:

class InactivePostsController < PostsController
def index
posts = PostResource.all(params, Post.where(active: false))
respond_with(posts)
end
end

Sort

sort :name, :string do |scope, direction|
scope.order(first_name: direction, last_name: direction)
end

Omit the type if a matching attribute is already defined. This overrides its default sort behavior:

attribute :name, :string

sort :name do |scope, direction|
# ... code ...
end

sort on its own defines a sort-only attribute. Define the attribute first if you also need filtering or other behavior.

Sort Options

OptionDescription
onlyRestrict to a single direction, e.g. sort :name, only: [:desc]

Filter

filter :name, :string do
eq do |scope, value|
scope.where(first_name: value)
end

# prefix do ... end
# suffix do ... end
# etc
end

Omit the type if a matching attribute is already defined. This overrides its default filter behavior. filter on its own defines a filter-only attribute. Define the attribute first if you also need sorting or other behavior.

Every operator below also has a not_ counterpart (not_eq, not_prefix, ...). Values arrive as an array unless the filter is single: true. Comma-delimit multiple values in a query string (/employees?filter[name]=Jane,John).

TypeDefault operators
stringeq, eql, prefix, suffix, match
uuideq
string_enum, integer_enumeq, eql
integer_id, integer, big_decimal, float, date, datetimeeq, gt, gte, lt, lte
booleaneq (always single: true)
hasheq
arrayeq

Define custom operators on the fly:

filter :name do
fuzzy_match do |scope, value|
# ... code ...
end
end

This supports filter[name][fuzzy_match]=foo.

Filter Options

OptionDescription
only, exceptLimit the operators generated from the type's defaults, e.g. filter :name, :string, only: [:eq, :suffix]
allowOnly permit these values, e.g. filter :size, :string, allow: ['Big', 'Medium', 'Small']
denyReject these values, e.g. filter :size, :string, deny: ['X-Large']
singleAccept one value instead of an array. boolean filters are single: true by default.
requiredReject the request if the filter is absent, e.g. filter :customer_id, :string, required: true (equivalently, attribute :customer_id, :integer, filterable: :required)
dependentRequire other filters alongside this one, e.g. filter :customer_id, :integer, dependent: [:customer_type] paired with filter :customer_type, :string, dependent: [:customer_id], so querying by id requires type, and vice versa
allow_nilCoerce an incoming null to Ruby nil instead of the string "null". Default false. Set self.filters_accept_nil_by_default = true on a Resource to flip it for all of that Resource's filters.
# Default behavior
filter :name, :string do
eq do |scope, value|
value # => ["Jane"]
end
end

# With single: true
filter :name, :string, single: true do
eq do |scope, value|
value # => "Jane"
end
end

Boolean Filter

Filters with type boolean are single: true by default. A boolean filter accepting multiple values doesn't make sense.

Hash Filter

Filters with type hash parse JSON automatically when passed in a URL query string:

# GET /employees?filter[metadata]={ "foo": 100 }

filter :metadata, :hash do
eq do |scope, value|
value # => [{ "foo" => 100 }]
end
end

Escaping Values

By default, Graphiti parses a comma-delimited string as an array. Wrap a value in {{curlies}} to keep it intact, for a "keyword search" field that could itself contain a comma:

# GET /employees?filter[keywords]={{some,value}}

filter :keywords, :string do
eq do |scope, value|
value # => "some,value"
end
end

Or define an array explicitly instead of relying on comma-splitting:

# GET /employees?filter[keywords]=[some,value]

filter :keywords, :string do
eq do |scope, value|
value # => ["some", "value"]
end
end

A single: true filter skips array parsing entirely and escapes the value for you, filtering on the string as given.

Statistics

stat total: [:count]
stat rating: [:average]
stat likes: [:sum]
stat score: [:maximum]

stat rating: [:average] do
standard_deviation do |scope, attr|
# your standard deviation code here
end
end

Every Resource has a total: :count statistic by default. Statistics respect filtering but not pagination, so you can show a "Total Posts" count above a paginated grid without a second request:

PostResource.all({
stats: { total: 'count' }
})
# GET /posts?stats[total]=count
{
meta: {
stats: {
total: {
count: 100
}
}
}
}

Extra Fields

extra_attribute :net_worth

Works like attribute, except the field is read-only and only returned when explicitly requested: ?extra_fields[employees]=net_worth.

Adjust the scope (e.g. to eager-load) only when the extra field is requested:

resource.on_extra_attribute :net_worth do |scope|
scope.includes(:assets)
end

#resolve

#resolve must execute the query and return an array of Model instances. Override it to add behavior around the default:

def resolve(scope)
Rails.logger.info "begin resolving scope..."
result = super
Rails.logger.info "resolved!"
result
end

Configuration

class PostResource < ApplicationResource
self.model = Post
self.type = 'posts'

# Only used if you care about Links
primary_endpoint '/posts', [:index, :show, :create, :update, :destroy]

self.default_sort = [{ title: :asc }] # default nil
self.default_page_size = 10 # default 20
end

Typically inherited from ApplicationResource, where cross-cutting settings live:

class ApplicationResource < Graphiti::Resource
# Required when there's no corresponding model
self.abstract_class = true

# Subclasses override as needed
self.adapter = Graphiti::Adapters::ActiveRecord

# Default attribute flags. See #limiting-behavior
self.attributes_readable_by_default = true
self.attributes_writable_by_default = true
self.attributes_sortable_by_default = true
self.attributes_filterable_by_default = true

# Used for link generation
self.base_url = Rails.application.routes.default_url_options[:host]
# Suggest referencing this in config/routes.rb:
# scope path: ApplicationResource.endpoint_namespace do
# resources :posts
# end
self.endpoint_namespace = '/api/v1'

# Raise if a Resource is accessed from a URL it isn't allowlisted for
self.validate_endpoints = false

# Automatically generate JSONAPI links?
self.autolink = true
end

Polymorphic Resources

Polymorphic Resources are similar to ActiveRecord STI: a single query returns multiple Resource types. Querying /tasks can return bugs, features, and epics.

class Employee < ApplicationRecord
has_many :tasks
end

# tasks table has a 'type' column
class Task < ApplicationRecord
belongs_to :employee
end

class Bug < Task
end

# ONLY Feature has #points
class Feature < Task
def points
5
end
end

# ONLY Epic has the milestones relationship
class Epic < Task
has_many :milestones
end

class Milestone < ApplicationRecord
belongs_to :epic
end
class TaskResource < ApplicationResource
# Reference child classes
self.polymorphic = [
'BugResource',
'FeatureResource',
'EpicResource'
]

attribute :title, :string
end

class BugResource < TaskResource
end

class FeatureResource < TaskResource
attribute :points, :integer
end

class EpicResource < TaskResource
has_many :milestones
end

class MilestoneResource < TaskResource
belongs_to :epic
end

/tasks returns JSONAPI types of bugs, features, and epics. Only features render points. Only epics render the milestones relationship. /tasks?include=milestones correctly only queries and renders Milestones for Epics.

Resources connect to each other through relationships. See Relationships.

Generators

$ rails generate graphiti:resource NAME [attribute:type] [options]
$ rails generate graphiti:resource Employee first_name:string age:integer

Adds a route, controller, resource, and tests.

Limit the actions the resource supports with -a:

$ rails generate graphiti:resource Employee -a index show

Writing data (creating, updating, and destroying resources, including a graph of them in a single request) is covered in Persisting.

Context

# app/resources/post_resource.rb
attribute :active, :boolean, writable: :admin?

def admin?
context.current_user.admin?
end

Every Resource has access to #context. Under Rails, context is the controller instance processing the request.

Put common helpers like current_user on ApplicationResource, so every Resource can call them:

# app/resources/application_resource.rb
class ApplicationResource < Graphiti::Resource
# ... code ...
def current_user
context.current_user
end
end

# app/resources/post_resource.rb
class PostResource < ApplicationResource
# ... code ...
def admin?
current_user.admin?
end
end

Set context manually with with_context:

ctx = OpenStruct.new(current_user: User.first)
Graphiti.with_context(ctx) do
# current_user == ctx.current_user
PostResource.all
end

Concurrency

# config/initializers/graphiti.rb
Graphiti.configure do |c|
c.concurrency = false
end

Under Rails, concurrency turns on by default when ::Rails.application.config.cache_classes is true (the default for staging and production). Sibling sideloads then load concurrently, so a Post sideloading Comments and Author loads both at the same time.

Concurrency runs sideloads in new Threads, so thread locals are dropped. Use Graphiti.context instead of Thread.current for anything that needs to survive a sideload:

# BAD:
Thread.current[:foo] = "bar"
Thread.current[:foo] # => will be nil when sideloading!

# GOOD:
Graphiti.context[:foo] = "bar"
Graphiti.context[:foo] # => "bar", even when sideloading

Adapters

Common resource overrides can be packaged into an Adapter for code re-use, most commonly to use a different client/datastore than ActiveRecord/RelationalDB.

Adapters are best explained in the 'Without ActiveRecord' recipe.