Class: JsonapiCompliable::Resource

Inherits:
Object
  • Object
show all
Extended by:
Forwardable
Defined in:
lib/jsonapi_compliable/resource.rb

Overview

Resources hold configuration: How do you want to process incoming JSONAPI requests?

Let’s say we start with an empty hash as our scope object:

render_jsonapi({})

Let’s define the behavior of various parameters. Here we’ll merge options into our hash when the user filters, sorts, and paginates. Then, we’ll pass that hash off to an HTTP Client:

class PostResource < ApplicationResource
  type :posts
  use_adapter JsonapiCompliable::Adapters::Null

  # What do do when filter[active] parameter comes in
  allow_filter :active do |scope, value|
    scope.merge(active: value)
  end

  # What do do when sorting parameters come in
  sort do |scope, attribute, direction|
    scope.merge(order: { attribute => direction })
  end

  # What do do when pagination parameters come in
  page do |scope, current_page, per_page|
    scope.merge(page: current_page, per_page: per_page)
  end

  # Resolve the scope by passing the hash to an HTTP Client
  def resolve(scope)
    MyHttpClient.get(scope)
  end
end

This code can quickly become duplicative - we probably want to reuse this logic for other objects that use the same HTTP client.

That’s why we also have Adapters. Adapters encapsulate common, reusable resource configuration. That’s why we don’t need to specify the above code when using ActiveRecord - the default logic is already in the adapter.

class PostResource < ApplicationResource
  type :posts
  use_adapter JsonapiCompliable::Adapters::ActiveRecord

  allow_filter :title
end

Of course, we can always override the Resource directly for one-off customizations:

class PostResource < ApplicationResource
  type :posts
  use_adapter JsonapiCompliable::Adapters::ActiveRecord

  allow_filter :title_prefix do |scope, value|
    scope.where(["title LIKE ?", "#{value}%"])
  end
end

Resources can also define Sideloads. Sideloads define the relationships between resources:

allow_sideload :comments, resource: CommentResource do
  # How to fetch the associated objects
  # This will be further chained down the line
  scope do |posts|
    Comment.where(post_id: posts.map(&:id))
  end

  # Now that we've resolved everything, how to assign the objects
  assign do |posts, comments|
    posts.each do |post|
      relevant_comments = comments.select { |c| c.post_id === post.id }
      post.comments = relevant_comments
    end
  end
end

Once again, we can DRY this up using an Adapter:

use_adapter JsonapiCompliable::Adapters::ActiveRecord

has_many :comments,
  scope: -> { Comment.all },
  resource: CommentResource,
  foreign_key: :post_id

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Class Attribute Details

.configHash

This is where we store all information set via DSL. Useful for introspection. Gets dup’d when inherited.

Returns:

  • (Hash)

    the current configuration



407
408
409
# File 'lib/jsonapi_compliable/resource.rb', line 407

def config
  @config
end

Instance Attribute Details

#contextObject (readonly)

The current context object set by #with_context. If you are using Rails, this is a controller instance.

This method is equivalent to JsonapiCompliable.context[:object]

Returns:

  • the context object

See Also:



453
454
455
# File 'lib/jsonapi_compliable/resource.rb', line 453

def context
  @context
end

Class Method Details

.allow_filter(name, options = {}) ⇒ Object

Whitelist a filter

If a filter is not allowed, a Jsonapi::Errors::BadFilter error will be raised.

Examples:

Basic Filtering

allow_filter :title

# When using ActiveRecord, this code is equivalent
allow_filter :title do |scope, value|
  scope.where(title: value)
end

Custom Filtering

# All filters can be customized with a block
allow_filter :title_prefix do |scope, value|
  scope.where('title LIKE ?', "#{value}%")
end

Guarding Filters

# Only allow the current user to filter on a property
allow_filter :title, if: :admin?

def admin?
  current_user.role == 'admin'
end

Parameters:

  • name (Symbol)

    The name of the filter

  • options (Hash) (defaults to: {})

Options Hash (options):

  • :if (Symbol)

    A method name on the current context - If the method returns false, BadFilter will be raised.

  • :aliases (Array<Symbol>)

    Allow the user to specify these aliases in the URL, then match to this filter. Mainly used for backwards-compatibility.

Yield Parameters:

  • scope

    The object being scoped

  • value

    The sanitized value from the URL



169
170
171
172
173
174
175
176
177
178
# File 'lib/jsonapi_compliable/resource.rb', line 169

def self.allow_filter(name, *args, &blk)
  opts = args.extract_options!
  aliases = [name, opts[:aliases]].flatten.compact
  config[:filters][name.to_sym] = {
    aliases: aliases,
    if: opts[:if],
    filter: blk,
    required: opts[:required].respond_to?(:call) ? opts[:required] : !!opts[:required]
  }
end

.allow_sideloadObject



100
# File 'lib/jsonapi_compliable/resource.rb', line 100

def_delegator :sideloading, :allow_sideload

.allow_stat(symbol_or_hash) {|scope, attr| ... } ⇒ Object

Whitelist a statistic.

Statistics are requested like

GET /posts?stats[total]=count

And returned in meta:

{
  data: [...],
  meta: { stats: { total: { count: 100 } } }
}

Statistics take into account the current scope, *without pagination*.

Examples:

Total Count

allow_stat total: [:count]

Average Rating

allow_stat rating: [:average]

Custom Stat

allow_stat rating: [:average] do
  standard_deviation { |scope, attr| ... }
end

Parameters:

  • symbol_or_hash (Symbol, Hash)

    The attribute and metric

Yield Parameters:

  • scope

    The object being scoped

  • attr (Symbol)

    The name of the metric



209
210
211
212
213
# File 'lib/jsonapi_compliable/resource.rb', line 209

def self.allow_stat(symbol_or_hash, &blk)
  dsl = Stats::DSL.new(config[:adapter], symbol_or_hash)
  dsl.instance_eval(&blk) if blk
  config[:stats][dsl.name] = dsl
end

.before_commit(only: [:create, :update, :destroy], &blk) ⇒ Object

Register a hook that fires AFTER all validation logic has run - including validation of nested objects - but BEFORE the transaction has closed.

Helpful for things like “contact this external service after persisting data, but roll everything back if there’s an error making the service call”

Parameters:

  • +only: (Hash)

    [:create, :update, :destroy]+



268
269
270
271
272
# File 'lib/jsonapi_compliable/resource.rb', line 268

def self.before_commit(only: [:create, :update, :destroy], &blk)
  Array(only).each do |verb|
    config[:before_commit][verb] = blk
  end
end

.belongs_toObject



109
# File 'lib/jsonapi_compliable/resource.rb', line 109

def_delegator :sideloading, :belongs_to

.default_filter(name) {|scope| ... } ⇒ Object

When you want a filter to always apply, on every request.

Default filters can be overridden if there is a corresponding allow_filter:

Examples:

Only Active Posts

default_filter :active do |scope|
  scope.where(active: true)
end

Overriding Default Filters

allow_filter :active

default_filter :active do |scope|
  scope.where(active: true)
end

# GET /posts?filter[active]=false
# Returns only active posts

Parameters:

  • name (Symbol)

    The default filter name

Yield Parameters:

  • scope

    The object being scoped

See Also:



237
238
239
240
241
# File 'lib/jsonapi_compliable/resource.rb', line 237

def self.default_filter(name, &blk)
  config[:default_filters][name.to_sym] = {
    filter: blk
  }
end

.default_page_number(val) ⇒ Object

Set an alternative default page number. Defaults to 1.

Parameters:

  • val (Integer)

    The new default



387
388
389
# File 'lib/jsonapi_compliable/resource.rb', line 387

def self.default_page_number(val)
  config[:default_page_number] = val
end

.default_page_size(val) ⇒ Object

Set an alternate default page size, when not specified in query parameters.

Examples:

# GET /employees will only render 10 employees
default_page_size 10

Parameters:

  • val (Integer)

    The new default page size.



398
399
400
# File 'lib/jsonapi_compliable/resource.rb', line 398

def self.default_page_size(val)
  config[:default_page_size] = val
end

.default_sort(val) ⇒ Object

Override default sort applied when not present in the query parameters.

Default: [{ id: :asc }]

Examples:

Order by created_at descending by default

# GET /employees will order by created_at descending
default_sort([{ created_at: :desc }])

Parameters:

  • val (Array<Hash>)

    Array of sorting criteria



359
360
361
# File 'lib/jsonapi_compliable/resource.rb', line 359

def self.default_sort(val)
  config[:default_sort] = val
end

.extra_field(name) {|scope, current_page, per_page| ... } ⇒ Object

Perform special logic when an extra field is requested. Often used to eager load data that will be used to compute the extra field.

This is not required if you have no custom logic.

Examples:

Eager load if extra field is required

# GET /employees?extra_fields[employees]=net_worth
extra_field(employees: [:net_worth]) do |scope|
  scope.includes(:assets)
end

Parameters:

  • name (Symbol)

    Name of the extra field

Yield Parameters:

  • scope

    The current object being scoped

  • current_page (Integer)

    The page parameter value

  • per_page (Integer)

    The page parameter value

See Also:



335
336
337
# File 'lib/jsonapi_compliable/resource.rb', line 335

def self.extra_field(name, &blk)
  config[:extra_fields][name] = blk
end

.has_and_belongs_to_manyObject



112
# File 'lib/jsonapi_compliable/resource.rb', line 112

def_delegator :sideloading, :has_and_belongs_to_many

.has_manyObject



103
# File 'lib/jsonapi_compliable/resource.rb', line 103

def_delegator :sideloading, :has_many

.has_oneObject



106
# File 'lib/jsonapi_compliable/resource.rb', line 106

def_delegator :sideloading, :has_one

.inherited(klass) ⇒ Object



126
127
128
# File 'lib/jsonapi_compliable/resource.rb', line 126

def self.inherited(klass)
  klass.config = Util::Hash.deep_dup(self.config)
end

.model(klass) ⇒ Object

The Model object associated with this class.

This model will be utilized on write requests.

Models need not be ActiveRecord ;)

Examples:

class PostResource < ApplicationResource
  # ... code ...
  model Post
end

Parameters:

  • klass (Class)

    The associated Model class



256
257
258
# File 'lib/jsonapi_compliable/resource.rb', line 256

def self.model(klass)
  config[:model] = klass
end

.paginate {|scope, current_page, per_page| ... } ⇒ Object

Define custom pagination logic

Examples:

Use will_paginate instead of Kaminari

# GET /employees?page[size]=10&page[number]=2
paginate do |scope, current_page, per_page|
  scope.paginate(page: current_page, per_page: per_page)
end

Yield Parameters:

  • scope

    The current object being scoped

  • current_page (Integer)

    The page parameter value

  • per_page (Integer)

    The page parameter value



313
314
315
# File 'lib/jsonapi_compliable/resource.rb', line 313

def self.paginate(&blk)
  config[:pagination] = blk
end

.polymorphic_belongs_toObject



115
# File 'lib/jsonapi_compliable/resource.rb', line 115

def_delegator :sideloading, :polymorphic_belongs_to

.polymorphic_has_manyObject

See Also:

  • Adapters::ActiveRecordSideloading#polymorphic_has_many


118
# File 'lib/jsonapi_compliable/resource.rb', line 118

def_delegator :sideloading, :polymorphic_has_many

.sideloadingObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



131
132
133
# File 'lib/jsonapi_compliable/resource.rb', line 131

def self.sideloading
  @sideloading ||= Sideload.new(:base, resource: self)
end

.sort {|scope, att, dir| ... } ⇒ Object

Define custom sorting logic

Examples:

Sort on alternate table

# GET /employees?sort=title
sort do |scope, att, dir|
  if att == :title
    scope.joins(:current_position).order("title #{dir}")
  else
    scope.order(att => dir)
  end
end

Yield Parameters:

  • scope

    The current object being scoped

  • att (Symbol)

    The requested sort attribute

  • dir (Symbol)

    The requested sort direction (:asc/:desc)



298
299
300
# File 'lib/jsonapi_compliable/resource.rb', line 298

def self.sort(&blk)
  config[:sorting] = blk
end

.type(value = nil) ⇒ Object

The JSONAPI Type. For instance if you queried:

GET /employees?fields=title

And/Or got back in the response

{ id: ‘1’, type: ‘positions’ }

The type would be :positions

This should match the type set in your serializer.

Examples:

class PostResource < ApplicationResource
  type :posts
end

Parameters:

  • value (Array<Hash>) (defaults to: nil)

    Array of sorting criteria



381
382
383
# File 'lib/jsonapi_compliable/resource.rb', line 381

def self.type(value = nil)
  config[:type] = value
end

.use_adapter(klass) ⇒ Object

Configure the adapter you want to use.

Examples:

ActiveRecord Adapter

require 'jsonapi_compliable/adapters/active_record'
use_adapter JsonapiCompliable::Adapters::ActiveRecord

Parameters:

  • klass (Class)

    The adapter class



346
347
348
# File 'lib/jsonapi_compliable/resource.rb', line 346

def self.use_adapter(klass)
  config[:adapter] = klass.new
end

Instance Method Details

#adapterObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

See Also:



670
671
672
# File 'lib/jsonapi_compliable/resource.rb', line 670

def adapter
  self.class.config[:adapter]
end

#associate(parent, child, association_name, type) ⇒ Object

Delegates #associate to adapter. Built for overriding.



545
546
547
# File 'lib/jsonapi_compliable/resource.rb', line 545

def associate(parent, child, association_name, type)
  adapter.associate(parent, child, association_name, type)
end

#association_namesObject



566
567
568
# File 'lib/jsonapi_compliable/resource.rb', line 566

def association_names
  sideloading.association_names
end

#before_commit(model, method) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Actually fire the before commit hooks

See Also:



278
279
280
281
# File 'lib/jsonapi_compliable/resource.rb', line 278

def before_commit(model, method)
  hook = self.class.config[:before_commit][method]
  hook.call(model) if hook
end

#build_scope(base, query, opts = {}) ⇒ Scope

Build a scope using this Resource configuration

Essentially “api private”, but can be useful for testing.

Parameters:

  • base

    The base scope we are going to chain

  • query

    The relevant Query object

  • opts (defaults to: {})

    Opts passed to Scope.new

Returns:

  • (Scope)

    a configured Scope instance

See Also:



478
479
480
# File 'lib/jsonapi_compliable/resource.rb', line 478

def build_scope(base, query, opts = {})
  Scope.new(base, self, query, opts)
end

#context_namespaceSymbol

The current context namespace set by #with_context. If you are using Rails, this is the controller method name (e.g. :index)

This method is equivalent to JsonapiCompliable.context[:namespace]

Returns:

  • (Symbol)

    the context namespace

See Also:



464
465
466
# File 'lib/jsonapi_compliable/resource.rb', line 464

def context_namespace
  JsonapiCompliable.context[:namespace]
end

#create(create_params) ⇒ Object

Create the relevant model. You must configure a model (see .model) to create. If you override, you must return the created instance.

Examples:

Send e-mail on creation

def create(attributes)
  instance = model.create(attributes)
  UserMailer.welcome_email(instance).deliver_later
  instance
end

Parameters:

  • create_params (Hash)

    The relevant attributes, including id and foreign keys

Returns:

  • (Object)

    an instance of the just-created model

See Also:



497
498
499
# File 'lib/jsonapi_compliable/resource.rb', line 497

def create(create_params)
  adapter.create(model, create_params)
end

#default_filtersObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

See Also:



658
659
660
# File 'lib/jsonapi_compliable/resource.rb', line 658

def default_filters
  self.class.config[:default_filters]
end

#default_page_numberObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



609
610
611
# File 'lib/jsonapi_compliable/resource.rb', line 609

def default_page_number
  self.class.config[:default_page_number] || 1
end

#default_page_sizeObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

See Also:



615
616
617
# File 'lib/jsonapi_compliable/resource.rb', line 615

def default_page_size
  self.class.config[:default_page_size] || 20
end

#default_sortObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

See Also:



603
604
605
# File 'lib/jsonapi_compliable/resource.rb', line 603

def default_sort
  self.class.config[:default_sort] || [{ id: :asc }]
end

#destroy(id) ⇒ Object

Destroy the relevant model. You must configure a model (see .model) to destroy. If you override, you must return the destroyed instance.

Examples:

Send e-mail on destroy

def destroy(attributes)
  instance = model_class.find(id)
  instance.destroy
  UserMailer.goodbye_email(instance).deliver_later
  instance
end

Parameters:

  • id (String)

    The id of the relevant Model

Returns:

  • (Object)

    an instance of the just-destroyed model

See Also:



536
537
538
# File 'lib/jsonapi_compliable/resource.rb', line 536

def destroy(id)
  adapter.destroy(model, id)
end

#disassociate(parent, child, association_name, type) ⇒ Object

Delegates #disassociate to adapter. Built for overriding.



554
555
556
# File 'lib/jsonapi_compliable/resource.rb', line 554

def disassociate(parent, child, association_name, type)
  adapter.disassociate(parent, child, association_name, type)
end

#extra_fieldsObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

See Also:



652
653
654
# File 'lib/jsonapi_compliable/resource.rb', line 652

def extra_fields
  self.class.config[:extra_fields]
end

#filtersObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

See Also:



628
629
630
# File 'lib/jsonapi_compliable/resource.rb', line 628

def filters
  self.class.config[:filters]
end

#modelObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

See Also:



664
665
666
# File 'lib/jsonapi_compliable/resource.rb', line 664

def model
  self.class.config[:model]
end

#paginationObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

See Also:



646
647
648
# File 'lib/jsonapi_compliable/resource.rb', line 646

def pagination
  self.class.config[:pagination]
end

#persist_with_relationships(meta, attributes, relationships, caller_model = nil) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



559
560
561
562
563
# File 'lib/jsonapi_compliable/resource.rb', line 559

def persist_with_relationships(meta, attributes, relationships, caller_model = nil)
  persistence = JsonapiCompliable::Util::Persistence \
    .new(self, meta, attributes, relationships, caller_model)
  persistence.run
end

#resolve(scope) ⇒ Array

How do you want to resolve the scope?

For ActiveRecord, when we want to actually fire SQL, it’s #to_a.

This method must return an array of resolved model objects.

By default, delegates to the adapter. You likely want to alter your adapter rather than override this directly.

Examples:

Custom API Call

# Let's build a hash and pass it off to an HTTP client
class PostResource < ApplicationResource
  type :posts
  use_adapter JsonapiCompliable::Adapters::Null

  sort do |scope, attribute, direction|
    scope.merge!(order: { attribute => direction }
  end

  page do |scope, current_page, per_page|
    scope.merge!(page: current_page, per_page: per_page)
  end

  def resolve(scope)
    MyHttpClient.get(scope)
  end
end

Parameters:

  • scope

    The scope object we’ve built up

Returns:

  • (Array)

    array of resolved model objects

See Also:



706
707
708
# File 'lib/jsonapi_compliable/resource.rb', line 706

def resolve(scope)
  adapter.resolve(scope)
end

#sideloadObject

See Also:



123
# File 'lib/jsonapi_compliable/resource.rb', line 123

def_delegator :sideloading, :sideload

#sideloadingObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Interface to the sideloads for this Resource



597
598
599
# File 'lib/jsonapi_compliable/resource.rb', line 597

def sideloading
  self.class.sideloading
end

#sortingObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

See Also:



634
635
636
# File 'lib/jsonapi_compliable/resource.rb', line 634

def sorting
  self.class.config[:sorting]
end

#stat(attribute, calculation) ⇒ Proc

The relevant proc for the given attribute and calculation.

Raises JsonapiCompliable::Errors::StatNotFound if not corresponding stat has been configured.

Examples:

Custom Stats

# Given this configuration
allow_stat :rating do
  average { |scope, attr| ... }
end

# We'd call the method like
resource.stat(:rating, :average)
# Which would return the custom proc

Parameters:

  • attribute (String, Symbol)

    The attribute we’re calculating.

  • calculation (String, Symbol)

    The calculation to run

Returns:

  • (Proc)

    the corresponding callable

Raises:

See Also:



589
590
591
592
593
# File 'lib/jsonapi_compliable/resource.rb', line 589

def stat(attribute, calculation)
  stats_dsl = stats[attribute] || stats[attribute.to_sym]
  raise Errors::StatNotFound.new(attribute, calculation) unless stats_dsl
  stats_dsl.calculation(calculation)
end

#statsObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

See Also:



640
641
642
# File 'lib/jsonapi_compliable/resource.rb', line 640

def stats
  self.class.config[:stats]
end

#transactionObject

How to run write requests within a transaction.

Should roll back the transaction, but avoid bubbling up the error, if JsonapiCompliable::Errors::ValidationError is raised within the block.

By default, delegates to the adapter. You likely want to alter your adapter rather than override this directly.

Examples:

resource.transaction do
  # ... save calls ...
end

Returns:

  • the result of yield

See Also:



726
727
728
729
730
731
732
733
734
735
736
# File 'lib/jsonapi_compliable/resource.rb', line 726

def transaction
  response = nil
  begin
    adapter.transaction(model) do
      response = yield
    end
  rescue Errors::ValidationError => e
    response = e.validation_response
  end
  response
end

#typeObject

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns :undefined_jsonapi_type when not configured.

See Also:



622
623
624
# File 'lib/jsonapi_compliable/resource.rb', line 622

def type
  self.class.config[:type] || :undefined_jsonapi_type
end

#update(update_params) ⇒ Object

Update the relevant model. You must configure a model (see .model) to update. If you override, you must return the updated instance.

Examples:

Send e-mail on update

def update(attributes)
  instance = model.update_attributes(attributes)
  UserMailer.profile_updated_email(instance).deliver_later
  instance
end

Parameters:

  • update_params (Hash)

    The relevant attributes, including id and foreign keys

Returns:

  • (Object)

    an instance of the just-updated model

See Also:



516
517
518
# File 'lib/jsonapi_compliable/resource.rb', line 516

def update(update_params)
  adapter.update(model, update_params)
end

#with_context(object, namespace = nil) ⇒ Object

Run code within a given context. Useful for running code within, say, a Rails controller context

When using Rails, controller actions are wrapped this way.

Examples:

Sinatra

get '/api/posts' do
  resource.with_context self, :index do
    scope = jsonapi_scope(Tweet.all)
    render_jsonapi(scope.resolve, scope: false)
  end
end

Parameters:

  • object

    The context (Rails controller or equivalent)

  • namespace (defaults to: nil)

    One of index/show/etc

See Also:



440
441
442
443
444
# File 'lib/jsonapi_compliable/resource.rb', line 440

def with_context(object, namespace = nil)
  JsonapiCompliable.with_context(object, namespace) do
    yield
  end
end