app/controllers/apicasso/crud_controller.rb in apicasso-0.4.5 vs app/controllers/apicasso/crud_controller.rb in apicasso-0.4.6

- old
+ new

@@ -1,245 +1,246 @@ -# frozen_string_literal: true - -module Apicasso - # Controller to consume read-only data to be used on client's frontend - class CrudController < Apicasso::ApplicationController - before_action :set_root_resource - before_action :set_object, except: %i[index create schema] - before_action :set_nested_resource, only: %i[nested_index] - before_action :set_records, only: %i[index nested_index] - - include Orderable - - # GET /:resource - # Returns a paginated, ordered and filtered query based response. - # Consider this - # To get all `Channel` sorted by ascending `name` , filtered by - # the ones that have a `domain` that matches exactly `"domain.com"`, - # paginating records 42 per page and retrieving the page 42. - # Example: - # GET /sites?sort=+name,-updated_at&q[domain_eq]=domain.com&page=42&per_page=42 - def index - set_access_control_headers - render json: index_json - end - - # GET /:resource/1 - # Common behavior for showing a record, with an addition of - # relation/methods including on response - def show - set_access_control_headers - render json: show_json - end - - # PATCH/PUT /:resource/1 - # Common behavior for an update API endpoint - def update - authorize_for(action: :update, - resource: resource.name.underscore.to_sym, - object: @object) - if @object.update(object_params) - render json: @object - else - render json: @object.errors, status: :unprocessable_entity - end - end - - # DELETE /:resource/1 - # Common behavior for an destroy API endpoint - def destroy - authorize_for(action: :destroy, - resource: resource.name.underscore.to_sym, - object: @object) - if @object.destroy - head :no_content, status: :ok - else - render json: @object.errors, status: :unprocessable_entity - end - end - - # GET /:resource/1/:nested_resource - alias nested_index index - - # POST /:resource - def create - @object = resource.new(object_params) - authorize_for(action: :create, - resource: resource.name.underscore.to_sym, - object: @object) - if @object.save - render json: @object, status: :created - else - render json: @object.errors, status: :unprocessable_entity - end - end - - # OPTIONS /:resource - # OPTIONS /:resource/1/:nested_resource - # Will return a JSON with the schema of the current resource, using - # attribute names as keys and attirbute types as values. - def schema - render json: resource_schema.to_json unless preflight? - end - - private - - # Common setup to stablish which model is the resource of this request - def set_root_resource - @root_resource = params[:resource].classify.constantize - end - - # Common setup to stablish which object this request is querying - def set_object - id = params[:id] - @object = resource.friendly.find(id) - rescue NoMethodError - @object = resource.find(id) - ensure - authorize! :read, @object - end - - # Setup to stablish the nested model to be queried - def set_nested_resource - @nested_resource = @object.send(params[:nested].underscore.pluralize) - end - - # Reutrns root_resource if nested_resource is not set scoped by permissions - def resource - (@nested_resource || @root_resource) - end - - # Used to setup the resource's schema, mapping attributes and it's types - def resource_schema - schemated = {} - resource.columns_hash.each { |key, value| schemated[key] = value.type } - schemated - end - - # Used to setup the records from the selected resource that are - # going to be rendered, if authorized - def set_records - authorize! :read, resource.name.underscore.to_sym - @records = resource.ransack(parsed_query).result - key_scope_records - reorder_records if params[:sort].present? - select_fields if params[:select].present? - include_relations if params[:include].present? - end - - # Selects a fieldset that should be returned, instead of all fields - # from records. - def select_fields - @records = @records.select(*params[:select].split(',')) - end - - # Reordering of records which happens when receiving `params[:sort]` - def reorder_records - @records = @records.unscope(:order).order(ordering_params(params)) - end - - # Raw paginated records object - def paginated_records - @records - .paginate(page: params[:page], per_page: params[:per_page]) - end - - # Records that can be accessed from current Apicasso::Key scope - # permissions - def key_scope_records - @records = @records.accessible_by(current_ability).unscope(:order) - end - - # The response for index action, which can be a pagination of a record collection - # or a grouped count of attributes - def index_json - if params[:group].present? - @records.group(params[:group][:by].split(',')).send(:calculate, params[:group][:calculate], params[:group][:field]) - else - collection_response - end - end - - # The response for show action, which can be a fieldset - # or a full response of attributes - def show_json - if params[:select].present? - @object.to_json(include: parsed_include, only: parsed_select) - else - @object.to_json(include: parsed_include) - end - end - - # Parsing of `paginated_records` with pagination variables metadata - def built_paginated - { entries: paginated_records }.merge(pagination_metadata_for(paginated_records)) - end - - # All records matching current query and it's total - def built_unpaginated - { entries: @records, total: @records.size } - end - - # Parsed JSON to be used as response payload, with included relations - def include_relations - @records = JSON.parse(included_collection.to_json(include: parsed_include)) - rescue ActiveRecord::AssociationNotFoundError, ActiveRecord::ConfigurationError - @records = JSON.parse(@records.to_json(include: parsed_include)) - end - - # A way to SQL-include for current param[:include], only if available - def included_collection - @records.includes(parsed_include) - rescue ActiveRecord::AssociationNotFoundError - @records - end - - # Returns the collection checking if it needs pagination - def collection_response - if params[:per_page].to_i < 0 - built_unpaginated - else - built_paginated - end - end - - # Only allow a trusted parameter "white list" through, - # based on resource's schema. - def object_params - params.require(resource.name.underscore.to_sym) - .permit(resource_params) - end - - # Resource params mapping, with a twist: - # Including relations as they are needed - def resource_params - built = resource_schema.keys - built += has_one_params if has_one_params.present? - built += has_many_params if has_many_params.present? - built - end - - # A wrapper to has_one relations parameter building - def has_one_params - resource.reflect_on_all_associations(:has_one).map do |one| - if one.class_name.starts_with?('ActiveStorage') - next if one.class_name.ends_with?('Blob') - one.name.to_s.gsub(/(_attachment)$/, '').to_sym - else - one.name - end - end.compact - end - - # A wrapper to has_many parameter building - def has_many_params - resource.reflect_on_all_associations(:has_many).map do |many| - if many.class_name.starts_with?('ActiveStorage') - next if many.class_name.ends_with?('Blob') - { many.name.to_s.gsub(/(_attachments)$/, '').to_sym => [] } - else - { many.name.to_sym => [] } - end - end.compact - end - end -end +# frozen_string_literal: true + +module Apicasso + # Controller to consume read-only data to be used on client's frontend + class CrudController < Apicasso::ApplicationController + before_action :set_root_resource + before_action :set_object, except: %i[index create schema] + before_action :set_nested_resource, only: %i[nested_index] + before_action :set_records, only: %i[index nested_index] + include Orderable + # GET /:resource + # Returns a paginated, ordered and filtered query based response. + # Consider this + # To get all `Channel` sorted by ascending `name` , filtered by + # the ones that have a `domain` that matches exactly `"domain.com"`, + # paginating records 42 per page and retrieving the page 42. + # Example: + # GET /sites?sort=+name,-updated_at&q[domain_eq]=domain.com&page=42&per_page=42 + def index + set_access_control_headers + render json: index_json + end + + # GET /:resource/1 + # Common behavior for showing a record, with an addition of + # relation/methods including on response + def show + set_access_control_headers + render json: show_json + end + + # PATCH/PUT /:resource/1 + # Common behavior for an update API endpoint + def update + authorize_for(action: :update, + resource: resource.name.underscore.to_sym, + object: @object) + if @object.update(object_params) + render json: @object.to_json + else + render json: @object.errors, status: :unprocessable_entity + end + end + + # DELETE /:resource/1 + # Common behavior for an destroy API endpoint + def destroy + authorize_for(action: :destroy, + resource: resource.name.underscore.to_sym, + object: @object) + if @object.destroy + head :no_content, status: :ok + else + render json: @object.errors, status: :unprocessable_entity + end + end + + # GET /:resource/1/:nested_resource + alias nested_index index + + # POST /:resource + def create + @object = resource.new(object_params) + authorize_for(action: :create, + resource: resource.name.underscore.to_sym, + object: @object) + if @object.save + render json: @object.to_json, status: :created + else + render json: @object.errors, status: :unprocessable_entity + end + end + + # OPTIONS /:resource + # OPTIONS /:resource/1/:nested_resource + # Will return a JSON with the schema of the current resource, using + # attribute names as keys and attirbute types as values. + def schema + render json: resource_schema.to_json unless preflight? + end + + private + + # Common setup to stablish which model is the resource of this request + def set_root_resource + @root_resource = params[:resource].classify.constantize + end + + # Common setup to stablish which object this request is querying + def set_object + id = params[:id] + @object = resource.friendly.find(id) + rescue NoMethodError + @object = resource.find(id) + ensure + authorize! :read, @object + end + + # Setup to stablish the nested model to be queried + def set_nested_resource + @nested_resource = @object.send(params[:nested].underscore.pluralize) + end + + # Reutrns root_resource if nested_resource is not set scoped by permissions + def resource + (@nested_resource || @root_resource) + end + + # Used to setup the resource's schema, mapping attributes and it's types + def resource_schema + schemated = {} + resource.columns_hash.each { |key, value| schemated[key] = value.type } + schemated + end + + # Used to setup the records from the selected resource that are + # going to be rendered, if authorized + def set_records + authorize! :read, resource.name.underscore.to_sym + @records = resource.ransack(parsed_query).result + @object = resource.new + key_scope_records + reorder_records if params[:sort].present? + select_fields if params[:select].present? + include_relations if params[:include].present? + end + + # Selects a fieldset that should be returned, instead of all fields + # from records. + def select_fields + @records = @records.select(*parsed_select) + end + + # Reordering of records which happens when receiving `params[:sort]` + def reorder_records + @records = @records.unscope(:order).order(ordering_params(params)) + end + + # Raw paginated records object + def paginated_records + @records + .paginate(page: params[:page], per_page: params[:per_page]) + end + + # Records that can be accessed from current Apicasso::Key scope + # permissions + def key_scope_records + @records = @records.accessible_by(current_ability).unscope(:order) + end + + # The response for index action, which can be a pagination of a record collection + # or a grouped count of attributes + def index_json + if params[:group].present? + @records.group(params[:group][:by].split(',')) + .send(:calculate, + params[:group][:calculate], + params[:group][:field]) + else + collection_response + end + end + + # The response for show action, which can be a fieldset + # or a full response of attributes + def show_json + json_hash = include_options + json_hash[:only] = parsed_select if params[:select].present? + @object.as_json(json_hash) + end + + # Parsing of `paginated_records` with pagination variables metadata + def built_paginated + { entries: paginated_records.as_json(include_options) } + .merge(pagination_metadata_for(paginated_records)) + end + + # All records matching current query and it's total + def built_unpaginated + { entries: @records.as_json(include_options), + total: @records.size } + end + + # Parse to include options + def include_options + { include: parsed_associations || [], + methods: parsed_methods || [] } + end + + # Parsed JSON to be used as response payload, with included relations + def include_relations + @records = @records.includes(parsed_associations) + end + + # Returns the collection checking if it needs pagination + def collection_response + if params[:per_page].to_i < 0 + built_unpaginated + else + built_paginated + end + end + + # Only allow a trusted parameter "white list" through, + # based on resource's schema. + def object_params + params.require(resource.name.underscore.to_sym) + .permit(resource_params) + end + + # Resource params mapping, with a twist: + # Including relations as they are needed + def resource_params + built = resource_schema.keys + built += has_one_params if has_one_params.present? + built += has_many_params if has_many_params.present? + built + end + + # A wrapper to has_one relations parameter building + def has_one_params + resource.reflect_on_all_associations(:has_one).map do |one| + if one.class_name.starts_with?('ActiveStorage') + next if one.class_name.ends_with?('Blob') + + one.name.to_s.gsub(/(_attachment)$/, '').to_sym + else + one.name + end + end.compact + end + + # A wrapper to has_many parameter building + def has_many_params + resource.reflect_on_all_associations(:has_many).map do |many| + if many.class_name.starts_with?('ActiveStorage') + next if many.class_name.ends_with?('Blob') + + { many.name.to_s.gsub(/(_attachments)$/, '').to_sym => [] } + else + { many.name.to_sym => [] } + end + end.compact + end + end +end