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



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

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+

Returns:

  • the context object

See Also:



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

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
# 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
  }
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



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

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_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:



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

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



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

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.



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

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



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

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:



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

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



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

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



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

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)



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

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



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

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



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

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:



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

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

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

Delegates #associate to adapter. Built for overriding.



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

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

#association_namesObject



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

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:



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

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+

Returns:

  • (Symbol)

    the context namespace

See Also:



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

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:



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

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:



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

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.



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

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:



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

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:



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

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:



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

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

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

Delegates #disassociate to adapter. Built for overriding.



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

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:



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

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:



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

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:



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

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:



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

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.



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

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:



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

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



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

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:



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

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:



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

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:



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

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:



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

def transaction
  response = nil
  begin
    adapter.transaction(model) do
      response = yield
    end
  rescue Errors::ValidationError
  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:



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

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:



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

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:



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

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