Skip to content

Using JSONAPI::Resources with non ActiveRecord models and service objects

Terry Wilson edited this page Jun 29, 2019 · 7 revisions

This is an example that has worked well for me in creating resources that are not explicitly backed by ActiveRecord models. Our Rails project do use ActiveRecord as our ORM, so AR is present and almost always used in conjunction with these.

Update: This recipe could (maybe should) be combined with the Singleton Resource Example

Example 1 - A very simple dashboard resource.

We have a Vue.js client app that connects to our Rails backend. JSON:API and JSONAPI::Resources answers many of the questions about what the API should look like. In the situation of providing aggregated data such as stats found on an admin dashboard I wanted a resource that could be called with a single GET request and provide the necessary attributes. This is a slightly contrived example as we also use PostgreSQL materialized views for many of the queries that get pulled together in objects like this.

Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      jsonapi_resources :dashboard, only: [:index]
    end
  end
end
class API::V1::DashboardController < JSONAPI::ResourceController end
class API::V1::DashboardResource < API::V1::ServicesBaseResource
  model_name 'Services::Dashboard' # since these models are in a Services module this is required

  key_type :uuid
  paginator :none # prevents offset and limit from being called

  attributes :contacts_count, :events_count
end
module Services
  class Dashboard
    include ActiveModel::Model

    attr_reader :id, :contacts_count, :events_count

    def initialize(*args)
      @id = SecureRandom.uuid
    end

    def contacts_count
      @contacts_count ||= Contact.count
    end

    def events_count
      @events_count ||= Event.count
    end

    def readonly?
      true
    end

    def persisted?
      false
    end

    def self.all
      new
    end

    def order(*args)
      [self] # following order collect is called on the result so return an array with self
    end

    def count(*args)
      1 # count is called for meta
    end
  end
end

Example 2 - A Service Object resource for complex actions

In our use case it we needed a way to handle more complex service oriented resources. This example uses a service object MultiSessionRegistration to create ActiveRecord registration objects for each session in an event. The body of the POST request requires a contact_id and event_id. This takes advantage of including ActiveModel::Model for validations. The validator pushes any duplicate errors into the errors object so that JR returns those errors appropriately.

Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      jsonapi_resources :multi_session_registration, only: [:create]
    end
  end
end
class API::V1::MultiSessionRegistrationController < JSONAPI::ResourceController end
class API::V1::MultiSessionRegistrationResource < JSONAPI::Resource
  model_name 'Services::MultiSessionRegistration'

  include Rails.application.routes.url_helpers
  attributes :contact_id, :event_id, :package_id

  def custom_links(options)
    contact = Contact.find(contact_id)
    base_url = options[:serializer].link_builder.base_url
    { registrations: "#{base_url}#{api_v1_contact_url(contact, only_path: true)}/registrations?filter[new]" }
  end
end
require 'securerandom'

module Services
  class Base
    include ActiveModel::Model
    attr_reader :id

    class << self
      def inherited(_subclass)
      end

      def call(args = {})
        new(args).call
      end
    end

    def call
      fail NotImplementedError.new('call needs to be implemented in subclasses and return self')
    end

    def self.all
      new
    end

    def order(*_args)
      [self]
    end

    def count(*_args)
      1
    end
  end
end
module Services
  class MultiSessionRegistration < Base
    attr_accessor :contact_id, :event_id, :package_id

    validates_presence_of :contact
    validates_presence_of :event, if: -> { package_id.blank? }
    validates_presence_of :package, if: -> { event_id.blank? }

    validates_with MultiSessionRegistrationValidator

    def initialize(**params)
      super

      @errors = ActiveModel::Errors.new(self)
      @package_id = params[:package_id]
      @event_id = params[:event_id]
      @contact_id = params[:contact_id]
    end

    def call
      process_registrations
      self
    end

    def save(*args)
      begin
        process_registrations
      rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
        Rails.logger.error(e.record.errors)
        return false
      end
      true
    end

    def contact
      Contact.find(@contact_id) if Contact.where(id: @contact_id).exists?
    end

    def event
      Event.find(@event_id) if Event.where(id: @event_id).exists?
    end

    def package
      Package.find(@package_id) if Package.where(id: @package_id).exists?
    end

    def self.all
      raise JSONAPI::Exceptions::RecordNotFound.new(nil)
    end

    def self.find(id)
      raise JSONAPI::Exceptions::RecordNotFound.new(nil)
    end

    private

    def process_registrations
      Registration.transaction do
        registrations.each do |registration|
          registration.save!
        end

        @id = SecureRandom.uuid
      end
    end

    def registrations
      sessions.map do |session|
        create_registration_for(session)
      end
    end

    def create_registration_for(session)
      Registration.new(contact: contact, session: session)
    end

    def sessions
      if event
        return event.sessions
      elsif package
        return package.sessions
      end
    end
  end
end
class MultiSessionRegistrationValidator < ActiveModel::Validator
  def validate(record)
    return unless record.contact_id
    return unless record.event_id || record.package_id

    session_ids = if record.event_id
      Event.find(record.event_id).sessions.pluck(:id) if Event.where(id: record.event_id).exists?
    elsif record.package_id
      Package.find(record.package_id).sessions.pluck(:id) if Package.where(id: record.package_id).exists?
    end

    return unless session_ids

    session_ids.each do |id|
      if Registration.where(contact: record.contact_id, session: id).exists?
        record.errors[:duplicate] << "already exists for contact: #{record.contact_id} and session #{id}."
      end
    end
  end
end