require 'jsonapi/operation' require 'jsonapi/paginator' module JSONAPI class Request attr_accessor :fields, :include, :filters, :sort_criteria, :errors, :operations, :resource_klass, :context, :paginator, :source_klass, :source_id def initialize(params = nil, options = {}) @context = options.fetch(:context, nil) @key_formatter = options.fetch(:key_formatter, JSONAPI.configuration.key_formatter) @errors = [] @operations = [] @fields = {} @include = [] @filters = {} @sort_criteria = [] @source_klass = nil @source_id = nil setup(params) if params end def setup(params) @resource_klass ||= Resource.resource_for(params[:controller]) if params[:controller] unless params.nil? case params[:action] when 'index' parse_fields(params[:fields]) parse_include(params[:include]) parse_filters(params[:filter]) parse_sort_criteria(params[:sort]) parse_pagination(params[:page]) when 'get_related_resource', 'get_related_resources' @source_klass = Resource.resource_for(params.require(:source)) @source_id = @source_klass.verify_key(params.require(@source_klass._as_parent_key), @context) parse_fields(params[:fields]) parse_include(params[:include]) parse_filters(params[:filter]) parse_sort_criteria(params[:sort]) parse_pagination(params[:page]) when 'show' parse_fields(params[:fields]) parse_include(params[:include]) when 'create' parse_fields(params[:fields]) parse_include(params[:include]) parse_add_operation(params.require(:data)) when 'create_association' parse_add_association_operation(params.require(:data), params.require(:association), params.require(@resource_klass._as_parent_key)) when 'update_association' parse_update_association_operation(params.fetch(:data), params.require(:association), params.require(@resource_klass._as_parent_key)) when 'update' parse_fields(params[:fields]) parse_include(params[:include]) parse_replace_operation(params.require(:data), params.require(@resource_klass._primary_key)) when 'destroy' parse_remove_operation(params) when 'destroy_association' parse_remove_association_operation(params) end end rescue ActionController::ParameterMissing => e @errors.concat(JSONAPI::Exceptions::ParameterMissing.new(e.param).errors) end def parse_pagination(page) paginator_name = @resource_klass._paginator @paginator = JSONAPI::Paginator.paginator_for(paginator_name).new(page) unless paginator_name == :none rescue JSONAPI::Exceptions::Error => e @errors.concat(e.errors) end def parse_fields(fields) return if fields.nil? extracted_fields = {} # Extract the fields for each type from the fields parameters if fields.is_a?(ActionController::Parameters) fields.each do |field, value| resource_fields = value.split(',') unless value.nil? || value.empty? extracted_fields[field] = resource_fields end else raise JSONAPI::Exceptions::InvalidFieldFormat.new end # Validate the fields extracted_fields.each do |type, values| underscored_type = unformat_key(type) extracted_fields[type] = [] begin if type != format_key(type) raise JSONAPI::Exceptions::InvalidResource.new(type) end type_resource = Resource.resource_for(@resource_klass.module_path + underscored_type.to_s) rescue NameError @errors.concat(JSONAPI::Exceptions::InvalidResource.new(type).errors) rescue JSONAPI::Exceptions::InvalidResource => e @errors.concat(e.errors) end if type_resource.nil? || !(@resource_klass._type == underscored_type || @resource_klass._has_association?(underscored_type)) @errors.concat(JSONAPI::Exceptions::InvalidResource.new(type).errors) else unless values.nil? valid_fields = type_resource.fields.collect { |key| format_key(key) } values.each do |field| if valid_fields.include?(field) extracted_fields[type].push unformat_key(field) else @errors.concat(JSONAPI::Exceptions::InvalidField.new(type, field).errors) end end else @errors.concat(JSONAPI::Exceptions::InvalidField.new(type, 'nil').errors) end end end @fields = extracted_fields.deep_transform_keys { |key| unformat_key(key) } end def check_include(resource_klass, include_parts) association_name = unformat_key(include_parts.first) association = resource_klass._association(association_name) if association && format_key(association_name) == include_parts.first unless include_parts.last.empty? check_include(Resource.resource_for(@resource_klass.module_path + association.class_name.to_s), include_parts.last.partition('.')) end else @errors.concat(JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), include_parts.first,).errors) end end def parse_include(include) return if include.nil? included_resources = CSV.parse_line(include) return if included_resources.nil? @include = [] included_resources.each do |included_resource| check_include(@resource_klass, included_resource.partition('.')) @include.push(unformat_key(included_resource).to_s) end end def parse_filters(filters) return unless filters @filters = {} filters.each do |key, value| filter = unformat_key(key) if @resource_klass._allowed_filter?(filter) @filters[filter] = value else @errors.concat(JSONAPI::Exceptions::FilterNotAllowed.new(filter).errors) end end end def parse_sort_criteria(sort_criteria) return unless sort_criteria @sort_criteria = CSV.parse_line(sort_criteria).collect do |sort| sort_criteria = {field: unformat_key(sort[1..-1]).to_s} if sort.start_with?('+') sort_criteria[:direction] = :asc elsif sort.start_with?('-') sort_criteria[:direction] = :desc else @errors.concat(JSONAPI::Exceptions::InvalidSortFormat .new(format_key(resource_klass._type), sort).errors) end check_sort_criteria(@resource_klass, sort_criteria) sort_criteria end end def check_sort_criteria(resource_klass, sort_criteria) sort_field = sort_criteria[:field] sortable_fields = resource_klass.sortable_fields(context) unless sortable_fields.include? sort_field.to_sym @errors.concat(JSONAPI::Exceptions::InvalidSortCriteria .new(format_key(resource_klass._type), sort_field).errors) end end def parse_add_operation(data) if data.is_a?(Array) data.each do |p| @operations.push JSONAPI::CreateResourceOperation.new(@resource_klass, parse_params(verify_and_remove_type(p), @resource_klass.createable_fields(@context))) end else @operations.push JSONAPI::CreateResourceOperation.new(@resource_klass, parse_params(verify_and_remove_type(data), @resource_klass.createable_fields(@context))) end rescue JSONAPI::Exceptions::Error => e @errors.concat(e.errors) end def verify_and_remove_type(params) #remove type and verify it matches the resource if unformat_key(params[:type]) == @resource_klass._type params.delete(:type) else if params[:type].nil? raise JSONAPI::Exceptions::ParameterMissing.new(:type) else raise JSONAPI::Exceptions::InvalidResource.new(params[:type]) end end params end def parse_has_one_links_object(raw) if raw.nil? return { type: nil, id: nil } end if !raw.is_a?(Hash) || raw.length != 2 || !(raw.has_key?('type') && raw.has_key?('id')) raise JSONAPI::Exceptions::InvalidLinksObject.new end { type: raw['type'], id: raw['id'] } end def parse_has_many_links_object(raw) if raw.nil? raise JSONAPI::Exceptions::InvalidLinksObject.new end links_object = {} if raw.is_a?(Array) raw.each do |link| link_object = parse_has_one_links_object(link) links_object[link_object[:type]] ||= [] links_object[link_object[:type]].push(link_object[:id]) end else raise JSONAPI::Exceptions::InvalidLinksObject.new end links_object end def parse_params(params, allowed_fields) verify_permitted_params(params, allowed_fields) checked_attributes = {} checked_has_one_associations = {} checked_has_many_associations = {} params.each do |key, value| if key == 'links' || key == :links value.each do |link_key, link_value| param = unformat_key(link_key) association = @resource_klass._association(param) if association.is_a?(JSONAPI::Association::HasOne) links_object = parse_has_one_links_object(link_value) # Since we do not yet support polymorphic associations we will raise an error if the type does not match the # association's type. # ToDo: Support Polymorphic associations if links_object[:type] && (links_object[:type] != association.type.to_s) raise JSONAPI::Exceptions::TypeMismatch.new(links_object[:type]) end unless links_object[:id].nil? association_resource = Resource.resource_for(@resource_klass.module_path + links_object[:type]) checked_has_one_associations[param] = association_resource.verify_key(links_object[:id], @context) else checked_has_one_associations[param] = nil end elsif association.is_a?(JSONAPI::Association::HasMany) links_object = parse_has_many_links_object(link_value) # Since we do not yet support polymorphic associations we will raise an error if the type does not match the # association's type. # ToDo: Support Polymorphic associations if links_object.length == 0 checked_has_many_associations[param] = [] else if links_object.length > 1 || !links_object.has_key?(association.type.to_s) raise JSONAPI::Exceptions::TypeMismatch.new(links_object[:type]) end links_object.each_pair do |type, keys| association_resource = Resource.resource_for(@resource_klass.module_path + type) checked_has_many_associations[param] = association_resource.verify_keys(keys, @context) end end end end else param = unformat_key(key) checked_attributes[param] = unformat_value(param, value) end end return { 'attributes' => checked_attributes, 'has_one' => checked_has_one_associations, 'has_many' => checked_has_many_associations }.deep_transform_keys { |key| unformat_key(key) } end def unformat_value(attribute, value) value_formatter = JSONAPI::ValueFormatter.value_formatter_for(@resource_klass._attribute_options(attribute)[:format]) value_formatter.unformat(value, @context) end def verify_permitted_params(params, allowed_fields) formatted_allowed_fields = allowed_fields.collect { |field| format_key(field).to_sym } params_not_allowed = [] params.each do |key, value| if key == 'links' || key == :links value.each_key do |links_key| params_not_allowed.push(links_key) unless formatted_allowed_fields.include?(links_key.to_sym) end else params_not_allowed.push(key) unless formatted_allowed_fields.include?(key.to_sym) end end raise JSONAPI::Exceptions::ParametersNotAllowed.new(params_not_allowed) if params_not_allowed.length > 0 end def parse_add_association_operation(data, association_type, parent_key) association = resource_klass._association(association_type) if association.is_a?(JSONAPI::Association::HasMany) object_params = {links: {association.name => data}} verified_param_set = parse_params(object_params, @resource_klass.updateable_fields(@context)) @operations.push JSONAPI::CreateHasManyAssociationOperation.new(resource_klass, parent_key, association_type, verified_param_set[:has_many].values[0]) end end def parse_update_association_operation(data, association_type, parent_key) association = resource_klass._association(association_type) if association.is_a?(JSONAPI::Association::HasOne) object_params = {links: {association.name => data}} verified_param_set = parse_params(object_params, @resource_klass.updateable_fields(@context)) @operations.push JSONAPI::ReplaceHasOneAssociationOperation.new(resource_klass, parent_key, association_type, verified_param_set[:has_one].values[0]) else object_params = {links: {association.name => data}} verified_param_set = parse_params(object_params, @resource_klass.updateable_fields(@context)) @operations.push JSONAPI::ReplaceHasManyAssociationOperation.new(resource_klass, parent_key, association_type, verified_param_set[:has_many].values[0]) end end def parse_single_replace_operation(data, keys) if data[@resource_klass._primary_key].nil? raise JSONAPI::Exceptions::MissingKey.new end type = data[:type] if type.nil? || type != @resource_klass._type.to_s raise JSONAPI::Exceptions::ParameterMissing.new(:type) end key = data[@resource_klass._primary_key] if !keys.include?(key) raise JSONAPI::Exceptions::KeyNotIncludedInURL.new(key) end if !keys.include?(@resource_klass._primary_key) data.delete(:id) end @operations.push(JSONAPI::ReplaceFieldsOperation.new(@resource_klass, key, parse_params(verify_and_remove_type(data), @resource_klass.updateable_fields(@context)))) end def parse_replace_operation(data, keys) if data.is_a?(Array) if keys.count != data.count raise JSONAPI::Exceptions::CountMismatch end data.each do |object_params| parse_single_replace_operation(object_params, keys) end else parse_single_replace_operation(data, [keys]) end rescue JSONAPI::Exceptions::Error => e @errors.concat(e.errors) end def parse_remove_operation(params) keys = parse_key_array(params.permit(@resource_klass._primary_key)[@resource_klass._primary_key]) keys.each do |key| @operations.push JSONAPI::RemoveResourceOperation.new(@resource_klass, key) end rescue ActionController::UnpermittedParameters => e @errors.concat(JSONAPI::Exceptions::ParametersNotAllowed.new(e.params).errors) rescue JSONAPI::Exceptions::Error => e @errors.concat(e.errors) end def parse_remove_association_operation(params) association_type = params[:association] parent_key = params[resource_klass._as_parent_key] association = resource_klass._association(association_type) if association.is_a?(JSONAPI::Association::HasMany) keys = parse_key_array(params[:keys]) keys.each do |key| @operations.push JSONAPI::RemoveHasManyAssociationOperation.new(resource_klass, parent_key, association_type, key) end else @operations.push JSONAPI::RemoveHasOneAssociationOperation.new(resource_klass, parent_key, association_type) end end def parse_key_array(raw) return @resource_klass.verify_keys(raw.split(/,/), context) end def format_key(key) @key_formatter.format(key) end def unformat_key(key) unformatted_key = @key_formatter.unformat(key) unformatted_key.nil? ? nil : unformatted_key.to_sym end end end