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

.config ⇒ Hash

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

Returns:

  • (Hash) —

    the current configuration



384
385
386
# File 'lib/jsonapi_compliable/resource.rb', line 384

def config
  @config
end

Instance Attribute Details

#context ⇒ Object (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:



429
430
431
# File 'lib/jsonapi_compliable/resource.rb', line 429

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_sideload ⇒ Object



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

.belongs_to ⇒ Object



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



364
365
366
# File 'lib/jsonapi_compliable/resource.rb', line 364

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.



375
376
377
# File 'lib/jsonapi_compliable/resource.rb', line 375

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



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

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:



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

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

.has_and_belongs_to_many ⇒ Object



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

def_delegator :sideloading, :has_and_belongs_to_many

.has_many ⇒ Object



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

def_delegator :sideloading, :has_many

.has_one ⇒ Object



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



290
291
292
# File 'lib/jsonapi_compliable/resource.rb', line 290

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

.polymorphic_belongs_to ⇒ Object



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

def_delegator :sideloading, :polymorphic_belongs_to

.polymorphic_has_many ⇒ Object

See Also:

  • Adapters::ActiveRecordSideloading#polymorphic_has_many


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

def_delegator :sideloading, :polymorphic_has_many

.sideloading ⇒ 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.



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)



275
276
277
# File 'lib/jsonapi_compliable/resource.rb', line 275

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



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

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



323
324
325
# File 'lib/jsonapi_compliable/resource.rb', line 323

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

Instance Method Details

#adapter ⇒ 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.

See Also:



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

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

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

Delegates #associate to adapter. Built for overriding.



521
522
523
# File 'lib/jsonapi_compliable/resource.rb', line 521

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

#association_names ⇒ Object



542
543
544
# File 'lib/jsonapi_compliable/resource.rb', line 542

def association_names
  sideloading.association_names
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:



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

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

#context_namespace ⇒ Symbol

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:



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

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:



473
474
475
# File 'lib/jsonapi_compliable/resource.rb', line 473

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

#default_filters ⇒ 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.

See Also:



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

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

#default_page_number ⇒ 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.



585
586
587
# File 'lib/jsonapi_compliable/resource.rb', line 585

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

#default_page_size ⇒ 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.

See Also:



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

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

#default_sort ⇒ 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.

See Also:



579
580
581
# File 'lib/jsonapi_compliable/resource.rb', line 579

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:



512
513
514
# File 'lib/jsonapi_compliable/resource.rb', line 512

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

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

Delegates #disassociate to adapter. Built for overriding.



530
531
532
# File 'lib/jsonapi_compliable/resource.rb', line 530

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

#extra_fields ⇒ 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.

See Also:



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

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

#filters ⇒ 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.

See Also:



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

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

#model ⇒ 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.

See Also:



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

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

#pagination ⇒ 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.

See Also:



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

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.



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

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:



682
683
684
# File 'lib/jsonapi_compliable/resource.rb', line 682

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

#sideload ⇒ Object

See Also:



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

def_delegator :sideloading, :sideload

#sideloading ⇒ 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.

Interface to the sideloads for this Resource



573
574
575
# File 'lib/jsonapi_compliable/resource.rb', line 573

def sideloading
  self.class.sideloading
end

#sorting ⇒ 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.

See Also:



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

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:



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

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

#stats ⇒ 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.

See Also:



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

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

#transaction ⇒ Object

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:



702
703
704
705
706
707
708
709
710
711
# File 'lib/jsonapi_compliable/resource.rb', line 702

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

#type ⇒ 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.

Returns :undefined_jsonapi_type when not configured.

See Also:



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

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:



492
493
494
# File 'lib/jsonapi_compliable/resource.rb', line 492

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:



416
417
418
419
420
# File 'lib/jsonapi_compliable/resource.rb', line 416

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