module AlphaApi::Concerns::Actionable

Public Instance Methods

create() click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 8
def create
  authorize! :create, resource_class
  new_resource = build_resource(permitted_create_params)
  if new_resource.valid?
    authorize! :create, new_resource
    new_resource.save
    render status: :created, json: resource_serializer.new(new_resource).serializable_hash
  else
    errors = reformat_validation_error(new_resource)
    raise Exceptions::ValidationErrors.new(errors), 'Validation Errors'
  end
end
destroy() click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 57
def destroy
  if destroyable
    resource = resource_class.find(params[:id])
    authorize! :destroy, resource
    if resource.destroy
      head :no_content
    else
      raise Exceptions::ValidationErrors.new(resource.errors), 'Validation Errors'
    end
  else
    raise Exceptions::MethodNotAllowed, 'Method Not Allowed'
  end
end
index() click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 21
def index
  authorize! :read, resource_class
  query = apply_filter_and_sort(collection)
  apply_pagination
  if params[:page].present?
    records = paginate(query)
    records = records.padding(params[:page][:offset]) if params[:page][:offset]
  else
    records = query
  end

  options = options(nested_resources, params[:page], query.count)
  render json: resource_serializer.new(records, options).serializable_hash
end
show() click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 36
def show
  resource = resource_class.find(params[:id])
  authorize! :read, resource
  options = options(nested_resources)
  render json: resource_serializer.new(resource, options).serializable_hash
end
update() click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 43
def update
  cached_resource_class = resource_class
  resource = cached_resource_class.find(params[:id])
  authorize! :update, resource
  options = options(nested_resources)
  if resource.update(permitted_update_params(resource))
    updated_resource = cached_resource_class.find(params[:id])
    render json: resource_serializer.new(updated_resource, options).serializable_hash
  else
    errors = reformat_validation_error(resource)
    raise Exceptions::ValidationErrors.new(errors), 'Validation Errors'
  end
end

Protected Instance Methods

allow_all() click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 169
def allow_all
  false
end
allowed_associations() click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 77
def allowed_associations
  []
end
allowed_sortings() click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 81
def allowed_sortings
  []
end
apply_combined_filters(query) click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 118
def apply_combined_filters(query)
  conditions = []
  filterable_fields.each do |field|
    value = params.dig(:filter, field)
    type = field_type(field)
    next unless value.present? && type.present?
    if type == :uuid || valid_enum?(field, value)
      query = query.where(field => value)
    elsif type == :string
      query = query.where(%("#{resource_class.table_name}"."#{field}" ILIKE #{sanitise(value)}))
    elsif valid_boolean?(field, value)
      query = query.where(field => value == 'true')
    else
      raise Exceptions::InvalidFilter, 'Only type of string and uuid fields are supported'
    end
  end
  query
end
apply_filter(query) click to toggle source

@override customised filters

# File lib/alpha_api/concerns/actionable.rb, line 93
def apply_filter(query)
  if filterable_fields.empty?
    raise Exceptions::InvalidFilter, 'Filters are not supported for this resource type'
  else
    query
  end
end
apply_filter_and_sort(query) click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 85
def apply_filter_and_sort(query)
  query = apply_standard_filter(query) if fields_filter_required?
  # custom filters
  query = apply_filter(query) if params[:filter]
  query = apply_sorting(query)
end
apply_pagination() click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 148
def apply_pagination
  page_number = (params.dig(:page, :number) || 1).to_i
  page_offset = (params.dig(:page, :offset) || 0).to_i
  page_size = (params.dig(:page, :size) || 20).to_i

  if allow_all && page_size == -1
    params[:page] = nil
  else
    raise Exceptions::InvalidRequest, 'Page number must be positive' unless page_number.positive?
    raise Exceptions::InvalidRequest, 'Page offset must be non-negative' if page_offset.negative?
    raise Exceptions::InvalidRequest, 'Page size must be positive' unless page_size.positive?
    raise Exceptions::InvalidRequest, 'Page size cannot be greater than 100' if page_size > 100

    params[:page] = {
      number: page_number,
      offset: page_offset,
      size: page_size
    }
  end
end
apply_search_term(query, search_term) click to toggle source

only override this method when filterable_fields is not empty

# File lib/alpha_api/concerns/actionable.rb, line 110
def apply_search_term(query, search_term)
  # exclude all _id fields for OR query
  conditions = filterable_fields.select { |field| field_type(field) == :string }.map do |field|
    %("#{resource_class.table_name}"."#{field}" ILIKE #{sanitise(search_term)})
  end
  query = query.where(conditions.join(' OR '))
end
apply_sorting(query) click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 173
def apply_sorting(query)
  sort_params = params['sort']
  return query.order(default_sorting) unless sort_params.present?
  raise Exceptions::InvalidRequest, 'Sort parameter must be a string' unless sort_params.is_a? String
  sorting = []

  sorts = sort_params.split(',').map(&:strip).map do |sort|
    is_desc = sort.start_with?('-')
    sort = is_desc ? sort[1..-1] : sort
    raise Exceptions::InvalidRequest, "Sorting by #{sort} is not allowed" unless allowed_sortings.include?(sort.to_sym)
    sort = association_mapper(sort)

    # have to includes the association to be able to sort on
    association = sort.split('.')[-2]
    query = query.includes(association.to_sym) if association

    sorting << sort_clause(sort, is_desc ? 'DESC NULLS LAST' : 'ASC NULLS FIRST')
  end

  query.order(sorting.join(','))
end
apply_standard_filter(query) click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 137
def apply_standard_filter(query)
  return query if filterable_fields.empty?
  # generate where clauses of _contains
  search_term = params[:search_term]
  query = if search_term.present?
            apply_search_term(query, search_term)
          else
            apply_combined_filters(query)
          end
end
association_mapper(sort) click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 195
def association_mapper(sort)
  components = sort.split('.')
  return sort if components.length == 1
  mapper = { 'reseller' => 'organisation' }
  table_name = components[-2]
  "#{mapper[table_name] || table_name}.#{components[-1]}"
end
build_resource(resource_params) click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 205
def build_resource(resource_params)
  resource_class.new(resource_params)
end
collection() click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 209
def collection
  resource_class.accessible_by(current_ability)
end
default_sorting() click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 240
def default_sorting
  { created_at: :desc }
end
destroyable() click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 73
def destroyable
  false
end
fields_filter_required?() click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 105
def fields_filter_required?
  (params[:search_term] || params[:filter]) && filterable_fields.present?
end
filterable_fields() click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 101
def filterable_fields
  []
end
nested_resources() click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 244
def nested_resources
  nested_resources = params[:include].to_s.split(',')
  invalid_resources = []
  nested_resources.each { |res| invalid_resources.push(res) unless allowed_associations.include?(res.to_sym) }
  unless invalid_resources.empty?
    raise Exceptions::InvalidArgument, "Invalid value for include: #{invalid_resources.join(', ')}"
  end

  nested_resources
end
options(included, page = nil, count = nil) click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 255
def options(included, page = nil, count = nil)
  options = {
    include: included,
    params: {
      included: included
    }
  }
  options[:meta] = {}
  options[:meta][:total_count] = count if count
  options[:meta][:page_number] = page[:number] if page
  options[:meta][:page_size] = page[:size] if page
  options
end
permitted_create_params() click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 269
def permitted_create_params
  data = params.require(:data)
  data.require(:attributes) unless data.include?(:attributes)
  data[:attributes]
end
permitted_update_params(_resource) click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 275
def permitted_update_params(_resource)
  data = params.require(:data)
  data.require(:attributes) unless data.include?(:attributes)
  data[:attributes]
end
reconcile_item(existing_items, item) click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 281
def reconcile_item(existing_items, item)
  item_id = item['id']
  if item_id && !existing_items.find { |i| i.id == item_id }
    # Any unreconciled items in the update need to be re-created
    item.except(:id)
  else
    item
  end
end
reconcile_nested_attributes(existing_items, items_in_update) click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 213
def reconcile_nested_attributes(existing_items, items_in_update)
  item_ids_in_update = items_in_update.map { |item| item['id'] }.compact
  if item_ids_in_update.uniq.length != item_ids_in_update.length
    raise(Exceptions::InvalidRequest, 'Nested attribute IDs must be unique')
  end

  nested_attributes = []

  items_in_update.each do |item|
    nested_attributes << reconcile_item(existing_items, item)
  end

  # Existing item was not found in updated items, so should be deleted
  existing_items.reject { |existing| item_ids_in_update.include?(existing.id) }.each do |deleting_item|
    nested_attributes << {
      'id': deleting_item.id,
      '_destroy': true
    }
  end

  nested_attributes
end
reformat_validation_error(resource) click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 314
def reformat_validation_error(resource)
  resource.errors
end
resource() click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 236
def resource
   resource_class.find(params[:id])
end
resource_class() click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 318
def resource_class
  controller_name.classify.constantize
end
resource_serializer() click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 322
def resource_serializer
  "Api::V1::#{controller_name.classify}Serializer".constantize
end
sort_clause(sort, order) click to toggle source

e.g. sort: 'user.organisation', order: 'desc'

# File lib/alpha_api/concerns/actionable.rb, line 327
def sort_clause(sort, order)
  components = sort.split('.')
  attr_name = components[-1]
  if components.length == 1
    # direct attributes
    "#{resource_class.table_name}.#{attr_name} #{order}"
  elsif components.length == 2
    # direct association attributes
    association = resource_class.reflect_on_association(components[-2])
    "#{association.table_name}.#{attr_name} #{order}"
  else
    # could potencially support that as well by includes deeply nested associations
    raise Exceptions::InvalidRequest, 'Sorting on deeply nested association is not supported'
  end
end

Private Instance Methods

field_type(field) click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 345
def field_type(field)
  resource_class.attribute_types[field.to_s].type
end
sanitise(str) click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 349
def sanitise(str)
  ActiveRecord::Base.connection.quote("%#{str}%")
end
valid_boolean?(field, value) click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 353
def valid_boolean?(field, value)
  field_type(field) == :boolean && ['true', 'false'].include?(value)
end
valid_enum?(field, value) click to toggle source
# File lib/alpha_api/concerns/actionable.rb, line 357
def valid_enum?(field, value)
  enum = resource_class.defined_enums[field.to_s]
  enum ? enum.keys.include?(value) : false
end