# # This file is part of the apes gem. Copyright (C) 2016 and above Shogun <shogun@cowtech.it>. # Licensed under the MIT license, which can be found at http://www.opensource.org/licenses/mit-license.php. # module Apes module Concerns # JSON API request handling module. module Request # Valid JSON API content type CONTENT_TYPE = "application/vnd.api+json".freeze # Sets headers for CORS handling. def request_handle_cors cors_source = Apes::RuntimeConfiguration.development? ? "http://#{request_source_host}:4200" : Apes::RuntimeConfiguration.cors_source headers["Access-Control-Allow-Origin"] = cors_source headers["Access-Control-Allow-Methods"] = "POST, GET, PUT, DELETE, OPTIONS" headers["Access-Control-Allow-Headers"] = "Content-Type, X-User-Email, X-User-Token" headers["Access-Control-Max-Age"] = 1.year.to_i.to_s end # Validates a request according to JSON API. def request_validate content_type = request_valid_content_type request.format = :json response.content_type = content_type unless Apes::RuntimeConfiguration.development? && params["json"] @cursor = PaginationCursor.new(params, :page) params[:data] ||= HashWithIndifferentAccess.new validate_data(content_type) end # Returns the hostname of the client. # # @return The hostname of the client. def request_source_host @api_source ||= URI.parse(request.url).host end # Returns the valid content type for a non GET JSON API request. # # @return [String] valid content type for a JSON API request. def request_valid_content_type Apes::Concerns::Request::CONTENT_TYPE end # Extract all attributes from input data making they are all valid and present. # # @param target [Object] The target model. This is use to obtain validations. # @param type_field [Symbol] The attribute which contains input type. # @param attributes_field [Symbol] The attribute which contains input attributes. # @param relationships_field [Symbol] The attribute which contains relationships specifications. # @return [HashWithIndifferentAccess] The attributes to create or update a target model. def request_extract_model(target, type_field: :type, attributes_field: :attributes, relationships_field: :relationships) data = params[:data] request_validate_model_type(target, data, type_field) data = data[attributes_field] fail_request!(:bad_request, "Missing attributes in the \"attributes\" field.") if data.blank? # Extract attributes using strong parameters data = unembed_relationships(validate_attributes(data, target), target, relationships_field) # Extract relationships data.merge!(validate_relationships(params[:data], target, relationships_field)) data end # Converts attributes for a target model in the desired types. # # @param target [Object] The target model. This is use to obtain types. # @param attributes [HashWithIndifferentAccess] The attributes to convert. # @return [HashWithIndifferentAccess] The converted attributes. def request_cast_attributes(target, attributes) types = target.class.column_types attributes.each do |k, v| request_cast_attribute(target, attributes, types, k, v) end attributes end private # :nodoc: def validate_data(content_type) if request.post? || request.patch? raise(Apes::Errors::BadRequestError) unless request.content_type == content_type request_load_data raise(Apes::Errors::MissingDataError) unless params[:data].present? end end # :nodoc: def request_load_data data_source = begin request.body.read rescue nil end return if data_source.blank? data = ActiveSupport::JSON.decode(data_source) params[:data] = data.fetch("data", {}).with_indifferent_access rescue JSON::ParserError raise(Apes::Errors::InvalidDataError) end # :nodoc: def request_validate_model_type(target, data, type_field) provided_type = data[type_field] expected_type = sanitize_model_name(target.class.name) return if sanitize_model_name(data[type_field]) == expected_type fail_request!( :bad_request, "#{provided_type.present? ? "Invalid type \"#{provided_type}\"" : "No type"} provided when type \"#{expected_type}\" was expected." ) end # :nodoc: def request_cast_attribute(target, attributes, types, key, value) case types[key].type when :boolean then Validators::BooleanValidator.parse(value, raise_errors: true) attributes[key] = value.to_boolean when :datetime value = Validators::TimestampValidator.parse(value, raise_errors: true) attributes[key] = value end rescue => e target.additional_errors.add(key, e.message) end # :nodoc: def sanitize_model_name(name) name.ensure_string.underscore.singularize end # :nodoc: def validate_attributes(data, target) # Before performing the validation, copy all embedded data to a temporary hash and replace with boolean in order to pass validation copied = {} data.each do |k, v| if v.is_a?(Hash) copied[k] = v data[k] = true end end ActionController::Parameters.new(data).permit(target.class::ATTRIBUTES).merge(copied) # Now return by restoring copied attributes rescue ActionController::UnpermittedParameters => e e.params.map! { |s| sprintf("attributes.%s", s) } raise e end # :nodoc: def unembed_relationships(data, target, field) return data unless defined?(target.class::RELATIONSHIPS) relationships = target.class::RELATIONSHIPS data.each do |k, v| k = k.to_sym next unless relationships.include?(k) params[:data][field] ||= {} params[:data][field][k] = {data: {type: sanitize_model_name(relationships[k] || k), id: v}} data.delete(k) end data end # :nodoc: def validate_relationships(data, target, field) return {} unless defined?(target.class::RELATIONSHIPS) relationships = target.class::RELATIONSHIPS allowed = relationships.keys.reduce({}) do |accu, k| accu[k] = {data: [:type, :id]} accu end resolve_references(target, relationships, ActionController::Parameters.new(data[field]).permit(allowed)) rescue ActionController::UnpermittedParameters => e e.params.map! { |s| sprintf("%s.%s", field, s) } raise e end # :nodoc: def resolve_references(target, relationships, references) references.reduce({}) do |accu, (field, data)| begin expected, id, sanitized, type = prepare_resolution(data, field, relationships) accu[field] = validate_reference(expected, id, sanitized, type) rescue => e raise e if e.is_a?(Lazier::Exceptions::Debug) target.additional_errors.add(field, e.message) end accu end end # :nodoc: def validate_reference(expected, id, sanitized, type) raise("Relationship does not contain the \"data.type\" attribute") if type.blank? raise("Relationship does not contain the \"data.id\" attribute") if id.blank? raise("Invalid relationship type \"#{type}\" provided for when type \"#{expected}\" was expected.") unless sanitized == sanitize_model_name(expected) reference = expected.classify.constantize.find_with_any(id) raise("Refers to a non existing \"#{sanitized}\" resource.") unless reference reference end # :nodoc: def prepare_resolution(data, field, relationships) type = data.dig(:data, :type) id = data.dig(:data, :id) expected = sanitize_model_name(relationships[field.to_sym] || field.classify) sanitized = sanitize_model_name(type) [expected, id, sanitized, type] end end end end