From 2f44901830e560aeba5b2d75b6bc52bda0c79132 Mon Sep 17 00:00:00 2001 From: marahin Date: Tue, 25 Aug 2020 15:28:08 +0200 Subject: [PATCH 01/13] Add working JsonApi Schema validator --- jsonapi_parameters.gemspec | 2 + lib/jsonapi_parameters.rb | 1 + .../to_many_relation_handler.rb | 6 +- .../to_one_relation_handler.rb | 4 +- lib/jsonapi_parameters/translator.rb | 22 +- lib/jsonapi_parameters/validator.rb | 46 ++ .../jsonapi_parameters/stack_limit_spec.rb | 10 +- spec/support/inputs_outputs_pairs.rb | 102 ++--- spec/support/jsonapi_schema.json | 397 ++++++++++++++++++ 9 files changed, 523 insertions(+), 67 deletions(-) create mode 100644 lib/jsonapi_parameters/validator.rb create mode 100644 spec/support/jsonapi_schema.json diff --git a/jsonapi_parameters.gemspec b/jsonapi_parameters.gemspec index e565efc..780bcce 100644 --- a/jsonapi_parameters.gemspec +++ b/jsonapi_parameters.gemspec @@ -17,6 +17,8 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency 'activesupport', '>= 4.1.8' spec.add_runtime_dependency 'actionpack', '>= 4.1.8' + spec.add_runtime_dependency 'activemodel', '>= 4.1.8' + spec.add_runtime_dependency 'json_schemer', '~> 0.2.13' spec.add_development_dependency 'nokogiri', '~> 1.10.5' spec.add_development_dependency 'json', '~> 2.0' diff --git a/lib/jsonapi_parameters.rb b/lib/jsonapi_parameters.rb index bd188dd..15610f8 100644 --- a/lib/jsonapi_parameters.rb +++ b/lib/jsonapi_parameters.rb @@ -1,5 +1,6 @@ require 'jsonapi_parameters/parameters' require 'jsonapi_parameters/handlers' +require 'jsonapi_parameters/validator' require 'jsonapi_parameters/translator' require 'jsonapi_parameters/core_ext' require 'jsonapi_parameters/stack_limit' diff --git a/lib/jsonapi_parameters/default_handlers/to_many_relation_handler.rb b/lib/jsonapi_parameters/default_handlers/to_many_relation_handler.rb index 567a12a..f808285 100644 --- a/lib/jsonapi_parameters/default_handlers/to_many_relation_handler.rb +++ b/lib/jsonapi_parameters/default_handlers/to_many_relation_handler.rb @@ -27,7 +27,7 @@ def prepare_relationship_vals related_type = relationship.dig(:type) included_object = find_included_object( - related_id: related_id, related_type: related_type + related_id: related_id.to_s, related_type: related_type ) || {} # If at least one related object has not been found in `included` tree, @@ -36,11 +36,11 @@ def prepare_relationship_vals @with_inclusion &= !included_object.empty? if with_inclusion - { **(included_object[:attributes] || {}), id: related_id }.tap do |body| + { **(included_object[:attributes] || {}), id: related_id&.to_s }.tap do |body| body[:relationships] = included_object[:relationships] if included_object.key?(:relationships) # Pass nested relationships end else - relationship.dig(:id) + relationship.dig(:id)&.to_s end end end diff --git a/lib/jsonapi_parameters/default_handlers/to_one_relation_handler.rb b/lib/jsonapi_parameters/default_handlers/to_one_relation_handler.rb index b6a302b..053aff2 100644 --- a/lib/jsonapi_parameters/default_handlers/to_one_relation_handler.rb +++ b/lib/jsonapi_parameters/default_handlers/to_one_relation_handler.rb @@ -15,9 +15,9 @@ def handle related_id: related_id, related_type: related_type ) || {} - return ["#{singularize(relationship_key)}_id".to_sym, related_id] if included_object.empty? + return ["#{singularize(relationship_key)}_id".to_sym, related_id&.to_s] if included_object.empty? - included_object = { **(included_object[:attributes] || {}), id: related_id }.tap do |body| + included_object = { **(included_object[:attributes] || {}), id: related_id&.to_s }.tap do |body| body[:relationships] = included_object[:relationships] if included_object.key?(:relationships) # Pass nested relationships end diff --git a/lib/jsonapi_parameters/translator.rb b/lib/jsonapi_parameters/translator.rb index b7574b6..8bcf572 100644 --- a/lib/jsonapi_parameters/translator.rb +++ b/lib/jsonapi_parameters/translator.rb @@ -16,15 +16,23 @@ def jsonapi_translate(params, naming_convention:) return params if params.nil? || params.empty? - @jsonapi_unsafe_hash = if naming_convention != :snake || JsonApi::Parameters.ensure_underscore_translation - params = params.deep_transform_keys { |key| key.to_s.underscore.to_sym } - params[:data][:type] = params[:data][:type].underscore if params.dig(:data, :type) - params - else - params.deep_symbolize_keys - end + @jsonapi_unsafe_hash = ensure_naming(params, naming_convention) formed_parameters + rescue => err + Validator.new(@jsonapi_unsafe_hash.deep_dup).validate! # Validate the payload and raise errors... + + raise err # ... or if there were none, re-raise initial error + end + + def ensure_naming(params, naming_convention) + if naming_convention != :snake || JsonApi::Parameters.ensure_underscore_translation + params = params.deep_transform_keys { |key| key.to_s.underscore.to_sym } + params[:data][:type] = params[:data][:type].underscore if params.dig(:data, :type) + params + else + params.deep_symbolize_keys + end end def formed_parameters diff --git a/lib/jsonapi_parameters/validator.rb b/lib/jsonapi_parameters/validator.rb new file mode 100644 index 0000000..3d52d9d --- /dev/null +++ b/lib/jsonapi_parameters/validator.rb @@ -0,0 +1,46 @@ +require 'active_model' +require 'json_schemer' + +module JsonApi::Parameters + SCHEMA_PATH = 'spec/support/jsonapi_schema.json'.freeze + + class Validator + include ActiveModel::Validations + + attr_reader :payload + + class PayloadValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + @schema = JSONSchemer.schema(File.read(SCHEMA_PATH)) + + unless @schema.valid?(value) # rubocop:disable Style/GuardClause + @schema.validate(value).each do |validation_error| + record.errors[attribute] << nice_error(validation_error) + end + end + end + + private + + # Thanks to https://polythematik.de/2020/02/17/ruby-json-schema/ + def nice_error(err) + case err['type'] + when 'required' + "path '#{err['data_pointer']}' is missing keys: #{err['details']['missing_keys'].join ', '}" + when 'format' + "path '#{err['data_pointer']}' is not in required format (#{err['schema']['format']})" + when 'minLength' + "path '#{err['data_pointer']}' is not long enough (min #{err['schema']['minLength']})" + else + "there is a problem with path '#{err['data_pointer']}'. Please check your input." + end + end + end + + validates :payload, presence: true, payload: true + + def initialize(payload) + @payload = payload.deep_stringify_keys + end + end +end diff --git a/spec/lib/jsonapi_parameters/stack_limit_spec.rb b/spec/lib/jsonapi_parameters/stack_limit_spec.rb index 2f71420..d1d9676 100644 --- a/spec/lib/jsonapi_parameters/stack_limit_spec.rb +++ b/spec/lib/jsonapi_parameters/stack_limit_spec.rb @@ -21,7 +21,7 @@ class Translator it 'raises an error if the stack level is above the limit' do input = select_input_by_name('POST create payloads', 'triple-nested payload') - input[:included] << { id: 3, type: 'entity', relationships: { subentity: { data: { type: 'entity', id: 4 } } } } + input[:included] << { id: '3', type: 'entity', relationships: { subentity: { data: { type: 'entity', id: '4' } } } } translator = described_class.new @@ -32,7 +32,7 @@ class Translator context 'stack limit' do it 'can be overwritten' do input = select_input_by_name('POST create payloads', 'triple-nested payload') - input[:included] << { id: 3, type: 'entity', relationships: { subentity: { data: { type: 'entity', id: 4 } } } } + input[:included] << { id: '3', type: 'entity', relationships: { subentity: { data: { type: 'entity', id: '4' } } } } translator = described_class.new translator.stack_limit = 4 @@ -42,16 +42,16 @@ class Translator it 'can be overwritten using short notation' do input = select_input_by_name('POST create payloads', 'triple-nested payload') - input[:included] << { id: 3, type: 'entity', relationships: { subentity: { data: { type: 'entity', id: 4 } } } } + input[:included] << { id: '3', type: 'entity', relationships: { subentity: { data: { type: 'entity', id: '4' } } } } translator = described_class.new expect { translator.jsonapify(input, custom_stack_limit: 4) }.not_to raise_error(JsonApi::Parameters::StackLevelTooDeepError) expect { translator.jsonapify(input, custom_stack_limit: 4) }.not_to raise_error # To ensure this is passing end - it 'can be reset' do + it 'can be reset' do input = select_input_by_name('POST create payloads', 'triple-nested payload') - input[:included] << { id: 3, type: 'entity', relationships: { subentity: { data: { type: 'entity', id: 4 } } } } + input[:included] << { id: '3', type: 'entity', relationships: { subentity: { data: { type: 'entity', id: '4' } } } } translator = described_class.new translator.stack_limit = 4 diff --git a/spec/support/inputs_outputs_pairs.rb b/spec/support/inputs_outputs_pairs.rb index d9b3807..1960519 100644 --- a/spec/support/inputs_outputs_pairs.rb +++ b/spec/support/inputs_outputs_pairs.rb @@ -6,8 +6,8 @@ module JsonApi::Parameters::Testing { test: { name: 'test name' } } ] }, { 'single root, client generated id' => [ - { data: { id: 22, type: 'tests', attributes: { name: 'test name' } } }, - { test: { id: 22, name: 'test name' } } + { data: { id: '22', type: 'tests', attributes: { name: 'test name' } } }, + { test: { id: '22', name: 'test name' } } ] }, { 'single root, multiple attributes' => [ { data: { type: 'tests', attributes: { name: 'test name', age: 21 } } }, @@ -112,7 +112,7 @@ module JsonApi::Parameters::Testing photographer: { data: { type: 'people', - id: 9 + id: '9' } } } @@ -122,7 +122,7 @@ module JsonApi::Parameters::Testing photo: { title: 'Ember Hamster', src: 'http://example.com/images/productivity.png', - photographer_id: 9 + photographer_id: '9' } } ] }, @@ -138,11 +138,11 @@ module JsonApi::Parameters::Testing photographers: { data: [ { - id: 9, + id: '9', type: 'people' }, { - id: 10, + id: '10', type: 'people' } ] @@ -152,14 +152,14 @@ module JsonApi::Parameters::Testing included: [ { type: 'people', - id: 10, + id: '10', attributes: { name: 'Some guy' } }, { type: 'people', - id: 9, + id: '9', attributes: { name: 'Some other guy' } @@ -172,11 +172,11 @@ module JsonApi::Parameters::Testing src: 'http://example.com/images/productivity.png', photographers_attributes: [ { - id: 9, + id: '9', name: 'Some other guy' }, { - id: 10, + id: '10', name: 'Some guy' } ] @@ -199,13 +199,13 @@ module JsonApi::Parameters::Testing genres: { data: [ { - id: 74, type: 'genres' + id: '74', type: 'genres' } ] }, director: { data: { - id: 682, type: 'directors' + id: '682', type: 'directors' } } } @@ -219,8 +219,8 @@ module JsonApi::Parameters::Testing content_rating: 'restricted', storyline: 'A seemingly indestructible android is sent from 2029 to 1984 to assassinate a waitress, whose unborn son will lead humanity in a war against the machines, while a soldier from that war is sent to protect her at all costs.', budget: 6400000, - director_id: 682, - genre_ids: [74] + director_id: '682', + genre_ids: ['74'] } } ] }, @@ -240,13 +240,13 @@ module JsonApi::Parameters::Testing genres: { data: [ { - id: 74, type: 'genres' + id: '74', type: 'genres' } ] }, director: { data: { - id: 682, type: 'directors' + id: '682', type: 'directors' } } } @@ -254,7 +254,7 @@ module JsonApi::Parameters::Testing included: [ { type: 'directors', - id: 682, + id: '682', attributes: { name: 'Some guy' } @@ -269,8 +269,8 @@ module JsonApi::Parameters::Testing content_rating: 'restricted', storyline: 'A seemingly indestructible android is sent from 2029 to 1984 to assassinate a waitress, whose unborn son will lead humanity in a war against the machines, while a soldier from that war is sent to protect her at all costs.', budget: 6400000, - director_attributes: { id: 682, name: 'Some guy' }, - genre_ids: [74] + director_attributes: { id: '682', name: 'Some guy' }, + genre_ids: ['74'] } } ] }, @@ -288,33 +288,34 @@ module JsonApi::Parameters::Testing { 'triple-nested payload' => [ { data: { + id: '0', type: 'entity', relationships: { subentity: { data: { type: 'entity', - id: 1 + id: '1' } } } }, included: [ { - id: 1, type: 'entity', relationships: { + id: '1', type: 'entity', relationships: { subentity: { data: { type: 'entity', - id: 2 + id: '2' } } } }, { - id: 2, type: 'entity', relationships: { + id: '2', type: 'entity', relationships: { subentity: { data: { type: 'entity', - id: 3 + id: '3' } } } @@ -323,11 +324,12 @@ module JsonApi::Parameters::Testing }, { entity: { + id: '0', subentity_attributes: { - id: 1, + id: '1', subentity_attributes: { - id: 2, - subentity_id: 3 + id: '2', + subentity_id: '3' } } } @@ -348,11 +350,11 @@ module JsonApi::Parameters::Testing data: [ { type: 'people', - id: 9 + id: '9' }, { type: 'people', - id: 10 + id: '10' } ] } @@ -361,14 +363,14 @@ module JsonApi::Parameters::Testing included: [ { type: 'people', - id: 10, + id: '10', attributes: { name: 'Some guy' } }, { type: 'people', - id: 9, + id: '9', attributes: { name: 'Some other guy' } @@ -381,11 +383,11 @@ module JsonApi::Parameters::Testing src: 'http://example.com/images/productivity.png', photographers_attributes: [ { - id: 9, + id: '9', name: 'Some other guy' }, { - id: 10, + id: '10', name: 'Some guy' } ] @@ -486,19 +488,19 @@ module JsonApi::Parameters::Testing relationships: { contacts_employment_statuses: { data: [ - { id: 444, type: "contact_employment_statuses" } + { id: '444', type: "contact_employment_statuses" } ] } } }, included: [ { - id: 444, type: "contact_employment_statuses", + id: '444', type: "contact_employment_statuses", attributes: { involved_in: true, receives_submissions: false }, relationships: { - employment_status: { data: { id: 110, type: "employment_statuses" } } + employment_status: { data: { id: '110', type: "employment_statuses" } } } } ] @@ -507,7 +509,7 @@ module JsonApi::Parameters::Testing contact: { id: "1", contacts_employment_statuses_attributes: [ - { id: 444, involved_in: true, receives_submissions: false, employment_status_id: 110 } + { id: '444', involved_in: true, receives_submissions: false, employment_status_id: '110' } ] } } @@ -519,23 +521,23 @@ module JsonApi::Parameters::Testing relationships: { contacts_employment_statuses: { data: [ - { id: 444, type: "contact_employment_statuses" } + { id: '444', type: "contact_employment_statuses" } ] } } }, included: [ { - id: 444, type: "contact_employment_statuses", + id: '444', type: "contact_employment_statuses", attributes: { involved_in: true, receives_submissions: false }, relationships: { - employment_status: { data: { id: 110, type: "employment_statuses" } } + employment_status: { data: { id: '110', type: "employment_statuses" } } } }, { - id: 110, type: "employment_statuses", + id: '110', type: "employment_statuses", attributes: { status: "yes", } @@ -546,7 +548,7 @@ module JsonApi::Parameters::Testing contact: { id: "1", contacts_employment_statuses_attributes: [ - { id: 444, involved_in: true, receives_submissions: false, employment_status_attributes: { id: 110, status: "yes" } } + { id: '444', involved_in: true, receives_submissions: false, employment_status_attributes: { id: '110', status: "yes" } } ] } } @@ -558,19 +560,19 @@ module JsonApi::Parameters::Testing relationships: { contacts_employment_statuses: { data: [ - { id: 444, type: "contact_employment_statuses" } + { id: '444', type: "contact_employment_statuses" } ] } } }, included: [ { - id: 444, type: "contact_employment_statuses", + id: '444', type: "contact_employment_statuses", attributes: { involved_in: true, receives_submissions: false }, relationships: { - employment_status: { data: [{ id: 110, type: "employment_statuses" }] } + employment_status: { data: [{ id: '110', type: "employment_statuses" }] } } }, ] @@ -579,7 +581,7 @@ module JsonApi::Parameters::Testing contact: { id: "1", contacts_employment_statuses_attributes: [ - { id: 444, involved_in: true, receives_submissions: false, employment_status_ids: [110] } + { id: '444', involved_in: true, receives_submissions: false, employment_status_ids: ['110'] } ] } } @@ -590,18 +592,18 @@ module JsonApi::Parameters::Testing id: "1", type: "contacts", relationships: { contacts_employment_status: { - data: { id: 444, type: "contact_employment_status" } + data: { id: '444', type: "contact_employment_status" } } } }, included: [ { - id: 444, type: "contact_employment_status", + id: '444', type: "contact_employment_status", attributes: { involved_in: true, receives_submissions: false }, relationships: { - employment_status: { data: { id: 110, type: "employment_statuses" } } + employment_status: { data: { id: '110', type: "employment_statuses" } } } } ] @@ -610,7 +612,7 @@ module JsonApi::Parameters::Testing contact: { id: "1", contacts_employment_status_attributes: { - id: 444, involved_in: true, receives_submissions: false, employment_status_id: 110 + id: '444', involved_in: true, receives_submissions: false, employment_status_id: '110' } } } diff --git a/spec/support/jsonapi_schema.json b/spec/support/jsonapi_schema.json new file mode 100644 index 0000000..8e507da --- /dev/null +++ b/spec/support/jsonapi_schema.json @@ -0,0 +1,397 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "title": "JSON:API Schema", + "description": "This is a schema for responses in the JSON:API format. For more, see http://jsonapi.org", + "oneOf": [ + { + "$ref": "#/definitions/success" + }, + { + "$ref": "#/definitions/failure" + }, + { + "$ref": "#/definitions/info" + } + ], + + "definitions": { + "success": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/definitions/data" + }, + "included": { + "description": "To reduce the number of HTTP requests, servers **MAY** allow responses that include related resources along with the requested primary resources. Such responses are called \"compound documents\".", + "type": "array", + "items": { + "$ref": "#/definitions/resource" + }, + "uniqueItems": true + }, + "meta": { + "$ref": "#/definitions/meta" + }, + "links": { + "description": "Link members related to the primary data.", + "allOf": [ + { + "$ref": "#/definitions/links" + }, + { + "$ref": "#/definitions/pagination" + } + ] + }, + "jsonapi": { + "$ref": "#/definitions/jsonapi" + } + }, + "additionalProperties": false + }, + "failure": { + "type": "object", + "required": [ + "errors" + ], + "properties": { + "errors": { + "type": "array", + "items": { + "$ref": "#/definitions/error" + }, + "uniqueItems": true + }, + "meta": { + "$ref": "#/definitions/meta" + }, + "jsonapi": { + "$ref": "#/definitions/jsonapi" + }, + "links": { + "$ref": "#/definitions/links" + } + }, + "additionalProperties": false + }, + "info": { + "type": "object", + "required": [ + "meta" + ], + "properties": { + "meta": { + "$ref": "#/definitions/meta" + }, + "links": { + "$ref": "#/definitions/links" + }, + "jsonapi": { + "$ref": "#/definitions/jsonapi" + } + }, + "additionalProperties": false + }, + + "meta": { + "description": "Non-standard meta-information that can not be represented as an attribute or relationship.", + "type": "object", + "additionalProperties": true + }, + "data": { + "description": "The document's \"primary data\" is a representation of the resource or collection of resources targeted by a request.", + "oneOf": [ + { + "$ref": "#/definitions/resource" + }, + { + "description": "An array of resource objects, an array of resource identifier objects, or an empty array ([]), for requests that target resource collections.", + "type": "array", + "items": { + "$ref": "#/definitions/resource" + }, + "uniqueItems": true + }, + { + "description": "null if the request is one that might correspond to a single resource, but doesn't currently.", + "type": "null" + } + ] + }, + "resource": { + "description": "\"Resource objects\" appear in a JSON:API document to represent resources.", + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string" + }, + "id": { + "type": "string" + }, + "attributes": { + "$ref": "#/definitions/attributes" + }, + "relationships": { + "$ref": "#/definitions/relationships" + }, + "links": { + "$ref": "#/definitions/links" + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "additionalProperties": false + }, + "relationshipLinks": { + "description": "A resource object **MAY** contain references to other resource objects (\"relationships\"). Relationships may be to-one or to-many. Relationships can be specified by including a member in a resource's links object.", + "type": "object", + "properties": { + "self": { + "description": "A `self` member, whose value is a URL for the relationship itself (a \"relationship URL\"). This URL allows the client to directly manipulate the relationship. For example, it would allow a client to remove an `author` from an `article` without deleting the people resource itself.", + "$ref": "#/definitions/link" + }, + "related": { + "$ref": "#/definitions/link" + } + }, + "additionalProperties": true + }, + "links": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/link" + } + }, + "link": { + "description": "A link **MUST** be represented as either: a string containing the link's URL or a link object.", + "oneOf": [ + { + "description": "A string containing the link's URL.", + "type": "string", + "format": "uri-reference" + }, + { + "type": "object", + "required": [ + "href" + ], + "properties": { + "href": { + "description": "A string containing the link's URL.", + "type": "string", + "format": "uri-reference" + }, + "meta": { + "$ref": "#/definitions/meta" + } + } + } + ] + }, + + "attributes": { + "description": "Members of the attributes object (\"attributes\") represent information about the resource object in which it's defined.", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9](?:[-\\w]*[a-zA-Z0-9])?$": { + "description": "Attributes may contain any valid JSON value." + } + }, + "not": { + "anyOf": [ + {"required": ["relationships"]}, + {"required": ["links"]}, + {"required": ["id"]}, + {"required": ["type"]} + ] + }, + "additionalProperties": false + }, + + "relationships": { + "description": "Members of the relationships object (\"relationships\") represent references from the resource object in which it's defined to other resource objects.", + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9](?:[-\\w]*[a-zA-Z0-9])?$": { + "properties": { + "links": { + "$ref": "#/definitions/relationshipLinks" + }, + "data": { + "description": "Member, whose value represents \"resource linkage\".", + "oneOf": [ + { + "$ref": "#/definitions/relationshipToOne" + }, + { + "$ref": "#/definitions/relationshipToMany" + } + ] + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "anyOf": [ + {"required": ["data"]}, + {"required": ["meta"]}, + {"required": ["links"]} + ], + "not": { + "anyOf": [ + {"required": ["id"]}, + {"required": ["type"]} + ] + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "relationshipToOne": { + "description": "References to other resource objects in a to-one (\"relationship\"). Relationships can be specified by including a member in a resource's links object.", + "anyOf": [ + { + "$ref": "#/definitions/empty" + }, + { + "$ref": "#/definitions/linkage" + } + ] + }, + "relationshipToMany": { + "description": "An array of objects each containing \"type\" and \"id\" members for to-many relationships.", + "type": "array", + "items": { + "$ref": "#/definitions/linkage" + }, + "uniqueItems": true + }, + "empty": { + "description": "Describes an empty to-one relationship.", + "type": "null" + }, + "linkage": { + "description": "The \"type\" and \"id\" to non-empty members.", + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string" + }, + "id": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "additionalProperties": false + }, + "pagination": { + "type": "object", + "properties": { + "first": { + "description": "The first page of data", + "oneOf": [ + { "type": "string", "format": "uri-reference" }, + { "type": "null" } + ] + }, + "last": { + "description": "The last page of data", + "oneOf": [ + { "type": "string", "format": "uri-reference" }, + { "type": "null" } + ] + }, + "prev": { + "description": "The previous page of data", + "oneOf": [ + { "type": "string", "format": "uri-reference" }, + { "type": "null" } + ] + }, + "next": { + "description": "The next page of data", + "oneOf": [ + { "type": "string", "format": "uri-reference" }, + { "type": "null" } + ] + } + } + }, + + "jsonapi": { + "description": "An object describing the server's implementation", + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "additionalProperties": false + }, + + "error": { + "type": "object", + "properties": { + "id": { + "description": "A unique identifier for this particular occurrence of the problem.", + "type": "string" + }, + "links": { + "$ref": "#/definitions/links" + }, + "status": { + "description": "The HTTP status code applicable to this problem, expressed as a string value.", + "type": "string" + }, + "code": { + "description": "An application-specific error code, expressed as a string value.", + "type": "string" + }, + "title": { + "description": "A short, human-readable summary of the problem. It **SHOULD NOT** change from occurrence to occurrence of the problem, except for purposes of localization.", + "type": "string" + }, + "detail": { + "description": "A human-readable explanation specific to this occurrence of the problem.", + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "pointer": { + "description": "A JSON Pointer [RFC6901] to the associated entity in the request document [e.g. \"/data\" for a primary data object, or \"/data/attributes/title\" for a specific attribute].", + "type": "string" + }, + "parameter": { + "description": "A string indicating which query parameter caused the error.", + "type": "string" + } + } + }, + "meta": { + "$ref": "#/definitions/meta" + } + }, + "additionalProperties": false + } + } +} + From 57f6ffd4bca1d5dc88f132598905d47e75d1be92 Mon Sep 17 00:00:00 2001 From: marahin Date: Tue, 25 Aug 2020 16:18:21 +0200 Subject: [PATCH 02/13] Add feature flag for suppressing validation errors --- lib/jsonapi_parameters/parameters.rb | 2 + lib/jsonapi_parameters/translator.rb | 9 ++- lib/jsonapi_parameters/validator.rb | 4 +- .../jsonapi_parameters/stack_limit_spec.rb | 2 +- .../lib/jsonapi_parameters/translator_spec.rb | 10 +++ spec/lib/jsonapi_parameters/validator_spec.rb | 67 +++++++++++++++++++ spec/spec_helper.rb | 2 + 7 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 spec/lib/jsonapi_parameters/validator_spec.rb diff --git a/lib/jsonapi_parameters/parameters.rb b/lib/jsonapi_parameters/parameters.rb index f8e79f5..05431ad 100644 --- a/lib/jsonapi_parameters/parameters.rb +++ b/lib/jsonapi_parameters/parameters.rb @@ -1,9 +1,11 @@ module JsonApi module Parameters @ensure_underscore_translation = false + @supress_validation_errors = false class << self attr_accessor :ensure_underscore_translation + attr_accessor :suppress_validation_errors end end end diff --git a/lib/jsonapi_parameters/translator.rb b/lib/jsonapi_parameters/translator.rb index 8bcf572..c92b191 100644 --- a/lib/jsonapi_parameters/translator.rb +++ b/lib/jsonapi_parameters/translator.rb @@ -19,8 +19,9 @@ def jsonapi_translate(params, naming_convention:) @jsonapi_unsafe_hash = ensure_naming(params, naming_convention) formed_parameters - rescue => err - Validator.new(@jsonapi_unsafe_hash.deep_dup).validate! # Validate the payload and raise errors... + rescue StandardError => err + # Validate the payload and raise errors... + Validator.new(@jsonapi_unsafe_hash.deep_dup).validate! unless JsonApi::Parameters.suppress_validation_errors raise err # ... or if there were none, re-raise initial error end @@ -42,7 +43,7 @@ def formed_parameters end def jsonapi_main_key - @jsonapi_unsafe_hash.dig(:data, :type)&.singularize || '' + @jsonapi_unsafe_hash.dig(:data, :type)&.singularize || raise(TranslatorError) end def jsonapi_main_body @@ -122,4 +123,6 @@ def handle_nested_relationships(val) val end + + class TranslatorError < StandardError; end end diff --git a/lib/jsonapi_parameters/validator.rb b/lib/jsonapi_parameters/validator.rb index 3d52d9d..5aa865b 100644 --- a/lib/jsonapi_parameters/validator.rb +++ b/lib/jsonapi_parameters/validator.rb @@ -22,7 +22,7 @@ def validate_each(record, attribute, value) private - # Thanks to https://polythematik.de/2020/02/17/ruby-json-schema/ + # Based on & thanks to https://polythematik.de/2020/02/17/ruby-json-schema/ def nice_error(err) case err['type'] when 'required' @@ -32,7 +32,7 @@ def nice_error(err) when 'minLength' "path '#{err['data_pointer']}' is not long enough (min #{err['schema']['minLength']})" else - "there is a problem with path '#{err['data_pointer']}'. Please check your input." + "path '#{err['data_pointer']}' is invalid according to the JsonApi schema" end end end diff --git a/spec/lib/jsonapi_parameters/stack_limit_spec.rb b/spec/lib/jsonapi_parameters/stack_limit_spec.rb index d1d9676..a788632 100644 --- a/spec/lib/jsonapi_parameters/stack_limit_spec.rb +++ b/spec/lib/jsonapi_parameters/stack_limit_spec.rb @@ -49,7 +49,7 @@ class Translator expect { translator.jsonapify(input, custom_stack_limit: 4) }.not_to raise_error # To ensure this is passing end - it 'can be reset' do + it 'can be reset' do input = select_input_by_name('POST create payloads', 'triple-nested payload') input[:included] << { id: '3', type: 'entity', relationships: { subentity: { data: { type: 'entity', id: '4' } } } } translator = described_class.new diff --git a/spec/lib/jsonapi_parameters/translator_spec.rb b/spec/lib/jsonapi_parameters/translator_spec.rb index 18ae0fa..8dead4e 100644 --- a/spec/lib/jsonapi_parameters/translator_spec.rb +++ b/spec/lib/jsonapi_parameters/translator_spec.rb @@ -8,6 +8,16 @@ class Translator end describe Translator do + context 'TranslatorError' do + it 'is risen when main key could not be created' do + translator = described_class.new + + translator.instance_variable_set(:@jsonapi_unsafe_hash, {}) + + expect { translator.send(:jsonapi_main_key) }.to raise_error { JsonApi::Parameters::TranslatorError } + end + end + context 'without enforced underscore translation' do describe 'plain hash parameters' do JsonApi::Parameters::Testing::PAIRS.each do |case_type_name, kases| diff --git a/spec/lib/jsonapi_parameters/validator_spec.rb b/spec/lib/jsonapi_parameters/validator_spec.rb new file mode 100644 index 0000000..2f770e0 --- /dev/null +++ b/spec/lib/jsonapi_parameters/validator_spec.rb @@ -0,0 +1,67 @@ +require 'spec_helper' + +describe JsonApi::Parameters::Validator do # rubocop:disable RSpec/FilePath + describe 'initializer' do + it 'ensures @payload has keys deeply stringified' do + validator = described_class.new(payload: { sample: 'value' }) + + expect(validator.payload.keys).to include('payload') + expect(validator.payload['payload'].keys).to include('sample') + end + + context 'validations' do + describe 'suppression enabled' do + before { JsonApi::Parameters.suppress_validation_errors = true } + + after { JsonApi::Parameters.suppress_validation_errors = false } + + let(:translator) do + class Translator + include JsonApi::Parameters + end + + Translator.new + end + + it 'does not raise validation errors' do + payload = { payload: { sample: 'value' } } + + expect { translator.jsonapify(payload) }.not_to raise_error(ActiveModel::ValidationError) + end + + it 'still raises any other errors' do + payload = { payload: { sample: 'value' } } + + expect { translator.jsonapify(payload) }.to raise_error(JsonApi::Parameters::TranslatorError) + end + end + + describe 'suppression disabled by default' do + let(:translator) do + class Translator + include JsonApi::Parameters + end + + Translator.new + end + + it 'does not raise validation errors' do + payload = { payload: { sample: 'value' } } + + expect { translator.jsonapify(payload) }.to raise_error(ActiveModel::ValidationError) + expect { translator.jsonapify(payload) }.not_to raise_error(JsonApi::Parameters::TranslatorError) + end + end + + it 'loads JsonApi schema' do + payload = { payload: { sample: 'value' } } + validator = described_class.new(payload) + + expect(File).to receive(:read).with(JsonApi::Parameters::SCHEMA_PATH).and_call_original + expect(JSONSchemer).to receive(:schema).and_call_original + + expect { validator.validate! }.to raise_error(ActiveModel::ValidationError) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f24bc39..7b9b48b 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -42,3 +42,5 @@ def select_io_pair_by_name(category, name) def select_input_by_name(category, name) select_io_pair_by_name(category, name)[0] end + +RSpec::Expectations.configuration.on_potential_false_positives = :nothing From e43d0c92937783c29e44a84295302045e22b20f9 Mon Sep 17 00:00:00 2001 From: marahin Date: Tue, 25 Aug 2020 17:11:45 +0200 Subject: [PATCH 03/13] Add a flag to enable prevalidation --- README.md | 60 +++++++++++++------ lib/jsonapi_parameters/parameters.rb | 2 + lib/jsonapi_parameters/translator.rb | 2 + lib/jsonapi_parameters/validator.rb | 6 ++ spec/lib/jsonapi_parameters/validator_spec.rb | 45 +++++++++++--- 5 files changed, 89 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 741737a..4ba1ba7 100644 --- a/README.md +++ b/README.md @@ -59,20 +59,6 @@ def create_params end ``` -#### Relationships - -JsonApi::Parameters supports ActiveRecord relationship parameters, including [nested attributes](https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html). - -Relationship parameters are being read from two optional trees: - -* `relationships`, -* `included` - -If you provide any related resources in the `relationships` table, this gem will also look for corresponding, `included` resources and their attributes. Thanks to that this gem supports nested attributes, and will try to translate these included resources and pass them along. - -For more examples take a look at [Relationships](https://github.com/visualitypl/jsonapi_parameters/wiki/Relationships) in the wiki documentation. - - ### Plain Ruby / outside Rails ```ruby @@ -88,6 +74,24 @@ translator = Translator.new translator.jsonapify(params) ``` + + +## Relationships + +JsonApi::Parameters supports ActiveRecord relationship parameters, including [nested attributes](https://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html). + +Relationship parameters are being read from two optional trees: + +* `relationships`, +* `included` + +If you provide any related resources in the `relationships` table, this gem will also look for corresponding, `included` resources and their attributes. Thanks to that this gem supports nested attributes, and will try to translate these included resources and pass them along. + +For more examples take a look at [Relationships](https://github.com/visualitypl/jsonapi_parameters/wiki/Relationships) in the wiki documentation. + +If you need custom relationship handling (for instance, if you have a relationship named `scissors` that is plural, but it actually is a single entity), you can use Handlers to define appropriate behaviour. + +Read more at [Relationship Handlers](https://github.com/visualitypl/jsonapi_parameters/wiki/Relationship-handlers). ## Mime Type @@ -102,7 +106,7 @@ Because of that, it is a potential vector of attack. For this reason we have introduced a default limit of stack levels that JsonApi::Parameters will go down through while parsing the payloads. -This default limit is 3, and can be overwritten by specifying the custom limit. +This default limit is 3, and can be overwritten by specifying the custom limit. When the limit is exceeded, a `StackLevelTooDeepError` is risen. #### Ruby ```ruby @@ -139,11 +143,31 @@ ensure end ``` -## Customization +## Validations -If you need custom relationship handling (for instance, if you have a relationship named `scissors` that is plural, but it actually is a single entity), you can use Handlers to define appropriate behaviour. +JsonApi::Parameters is validating your payloads **ONLY** when an error occurs. **This means that unless there was an exception, your payload will not be validated.** -Read more at [Relationship Handlers](https://github.com/visualitypl/jsonapi_parameters/wiki/Relationship-handlers). +Reason for that is we prefer to avoid any performance overheads, and in most cases the validation errors will only be useful in the development environments, and mostly in the early parts of the implementation process. Our decision was to leave the validation to happen only in case JsonApi::Parameters failed to accomplish its task. + +The validation happens with the use of jsonapi.org's JSON schema draft 6, available [here](https://jsonapi.org/faq/#is-there-a-json-schema-describing-json-api), and a gem called [JSONSchemer](https://github.com/davishmcclurg/json_schemer). + +If you would prefer to suppress validation errors, you can do so by declaring it globally in your application: + +```ruby +# config/initializers/jsonapi_parameters.rb + +JsonApi::Parameters.suppress_validation_errors = true +``` + +If you would prefer to prevalidate every payload _before_ attempting to fully parse it, you can do so by enforcing prevalidation: + +```ruby +# config/initializers/jsonapi_parameters.rb + +JsonApi::Parameters.enforce_prevalidation = true +``` + +It is important to note that setting suppression and prevalidation is exclusive. If both settings are set to `true` no prevalidation will happen. ## License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/lib/jsonapi_parameters/parameters.rb b/lib/jsonapi_parameters/parameters.rb index 05431ad..282f8f6 100644 --- a/lib/jsonapi_parameters/parameters.rb +++ b/lib/jsonapi_parameters/parameters.rb @@ -2,10 +2,12 @@ module JsonApi module Parameters @ensure_underscore_translation = false @supress_validation_errors = false + @enforce_prevalidation = false class << self attr_accessor :ensure_underscore_translation attr_accessor :suppress_validation_errors + attr_accessor :enforce_prevalidation end end end diff --git a/lib/jsonapi_parameters/translator.rb b/lib/jsonapi_parameters/translator.rb index c92b191..010ac38 100644 --- a/lib/jsonapi_parameters/translator.rb +++ b/lib/jsonapi_parameters/translator.rb @@ -18,6 +18,8 @@ def jsonapi_translate(params, naming_convention:) @jsonapi_unsafe_hash = ensure_naming(params, naming_convention) + Validator.new(@jsonapi_unsafe_hash.deep_dup).validate! if should_prevalidate? + formed_parameters rescue StandardError => err # Validate the payload and raise errors... diff --git a/lib/jsonapi_parameters/validator.rb b/lib/jsonapi_parameters/validator.rb index 5aa865b..87d7eb4 100644 --- a/lib/jsonapi_parameters/validator.rb +++ b/lib/jsonapi_parameters/validator.rb @@ -4,6 +4,12 @@ module JsonApi::Parameters SCHEMA_PATH = 'spec/support/jsonapi_schema.json'.freeze + private + + def should_prevalidate? + JsonApi::Parameters.enforce_prevalidation && !JsonApi::Parameters.suppress_validation_errors + end + class Validator include ActiveModel::Validations diff --git a/spec/lib/jsonapi_parameters/validator_spec.rb b/spec/lib/jsonapi_parameters/validator_spec.rb index 2f770e0..abcbac9 100644 --- a/spec/lib/jsonapi_parameters/validator_spec.rb +++ b/spec/lib/jsonapi_parameters/validator_spec.rb @@ -10,19 +10,48 @@ end context 'validations' do - describe 'suppression enabled' do - before { JsonApi::Parameters.suppress_validation_errors = true } + let(:translator) do + class Translator + include JsonApi::Parameters + end - after { JsonApi::Parameters.suppress_validation_errors = false } + Translator.new + end - let(:translator) do - class Translator - include JsonApi::Parameters - end + describe 'with prevalidation enforced' do + before { JsonApi::Parameters.enforce_prevalidation = true } - Translator.new + after { JsonApi::Parameters.enforce_prevalidation = false } + + it 'raises validation errors' do + payload = { payload: { sample: 'value' } } + + expect { translator.jsonapify(payload) }.to raise_error(ActiveModel::ValidationError) end + it 'does not raise TranslatorError' do + payload = { payload: { sample: 'value' } } + + expect { translator.jsonapify(payload) }.not_to raise_error(JsonApi::Parameters::TranslatorError) + end + + it 'does not call formed_parameters' do + payload = { payload: { sample: 'value' } } + + expect(translator).not_to receive(:formed_parameters) + + begin + translator.jsonapify(payload) + rescue ActiveModel::ValidationError => _ # rubocop:disable Lint/HandleExceptions + end + end + end + + describe 'suppression enabled' do + before { JsonApi::Parameters.suppress_validation_errors = true } + + after { JsonApi::Parameters.suppress_validation_errors = false } + it 'does not raise validation errors' do payload = { payload: { sample: 'value' } } From 42729237bbf7a9b6220647763983a0b61201db7b Mon Sep 17 00:00:00 2001 From: marahin Date: Tue, 25 Aug 2020 17:15:33 +0200 Subject: [PATCH 04/13] Remove unnecessary #to_s at #find_included_body in 2many relation handler --- .../default_handlers/to_many_relation_handler.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi_parameters/default_handlers/to_many_relation_handler.rb b/lib/jsonapi_parameters/default_handlers/to_many_relation_handler.rb index f808285..9b7e335 100644 --- a/lib/jsonapi_parameters/default_handlers/to_many_relation_handler.rb +++ b/lib/jsonapi_parameters/default_handlers/to_many_relation_handler.rb @@ -27,7 +27,7 @@ def prepare_relationship_vals related_type = relationship.dig(:type) included_object = find_included_object( - related_id: related_id.to_s, related_type: related_type + related_id: related_id, related_type: related_type ) || {} # If at least one related object has not been found in `included` tree, From 3f94c2cf2a6d2cca80ed0faa371ac047996d4933 Mon Sep 17 00:00:00 2001 From: Jasiek Matusz Date: Wed, 26 Aug 2020 12:44:11 +0200 Subject: [PATCH 05/13] Correct a typo "supress" -> "suppress" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Paweł Strzałkowski --- lib/jsonapi_parameters/parameters.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi_parameters/parameters.rb b/lib/jsonapi_parameters/parameters.rb index 282f8f6..8867f00 100644 --- a/lib/jsonapi_parameters/parameters.rb +++ b/lib/jsonapi_parameters/parameters.rb @@ -1,7 +1,7 @@ module JsonApi module Parameters @ensure_underscore_translation = false - @supress_validation_errors = false + @suppress_validation_errors = false @enforce_prevalidation = false class << self From 0d8015fac37736e4eb6dba33591b605b829497cf Mon Sep 17 00:00:00 2001 From: marahin Date: Wed, 26 Aug 2020 12:52:22 +0200 Subject: [PATCH 06/13] Call Validator by full namespace --- lib/jsonapi_parameters/translator.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/jsonapi_parameters/translator.rb b/lib/jsonapi_parameters/translator.rb index 010ac38..7696ed0 100644 --- a/lib/jsonapi_parameters/translator.rb +++ b/lib/jsonapi_parameters/translator.rb @@ -18,12 +18,12 @@ def jsonapi_translate(params, naming_convention:) @jsonapi_unsafe_hash = ensure_naming(params, naming_convention) - Validator.new(@jsonapi_unsafe_hash.deep_dup).validate! if should_prevalidate? + JsonApi::Parameters::Validator.new(@jsonapi_unsafe_hash.deep_dup).validate! if should_prevalidate? formed_parameters rescue StandardError => err # Validate the payload and raise errors... - Validator.new(@jsonapi_unsafe_hash.deep_dup).validate! unless JsonApi::Parameters.suppress_validation_errors + JsonApi::Parameters::Validator.new(@jsonapi_unsafe_hash.deep_dup).validate! unless JsonApi::Parameters.suppress_validation_errors raise err # ... or if there were none, re-raise initial error end From 92535e33ee87fa3dc4be47b4a5da209c715141bb Mon Sep 17 00:00:00 2001 From: marahin Date: Wed, 26 Aug 2020 16:07:55 +0200 Subject: [PATCH 07/13] Throw TranslatorErrors when related_id and related_type are not present when they should be --- .../default_handlers/to_many_relation_handler.rb | 8 ++++++-- .../default_handlers/to_one_relation_handler.rb | 4 +++- spec/support/inputs_outputs_pairs.rb | 2 ++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/jsonapi_parameters/default_handlers/to_many_relation_handler.rb b/lib/jsonapi_parameters/default_handlers/to_many_relation_handler.rb index 9b7e335..5f4e556 100644 --- a/lib/jsonapi_parameters/default_handlers/to_many_relation_handler.rb +++ b/lib/jsonapi_parameters/default_handlers/to_many_relation_handler.rb @@ -26,6 +26,10 @@ def prepare_relationship_vals related_id = relationship.dig(:id) related_type = relationship.dig(:type) + unless related_id && related_type + raise JsonApi::Parameters::TranslatorError.new("relationship has to contain both id and type: #{relationship.inspect}") + end + included_object = find_included_object( related_id: related_id, related_type: related_type ) || {} @@ -36,11 +40,11 @@ def prepare_relationship_vals @with_inclusion &= !included_object.empty? if with_inclusion - { **(included_object[:attributes] || {}), id: related_id&.to_s }.tap do |body| + { **(included_object[:attributes] || {}), id: related_id.to_s }.tap do |body| body[:relationships] = included_object[:relationships] if included_object.key?(:relationships) # Pass nested relationships end else - relationship.dig(:id)&.to_s + related_id.to_s end end end diff --git a/lib/jsonapi_parameters/default_handlers/to_one_relation_handler.rb b/lib/jsonapi_parameters/default_handlers/to_one_relation_handler.rb index 053aff2..4fcba64 100644 --- a/lib/jsonapi_parameters/default_handlers/to_one_relation_handler.rb +++ b/lib/jsonapi_parameters/default_handlers/to_one_relation_handler.rb @@ -15,9 +15,11 @@ def handle related_id: related_id, related_type: related_type ) || {} + # We call `related_id&.to_s` because we want to make sure NOT to end up with `nil.to_s` + # if `related_id` is nil, it should remain nil, to nullify the relationship return ["#{singularize(relationship_key)}_id".to_sym, related_id&.to_s] if included_object.empty? - included_object = { **(included_object[:attributes] || {}), id: related_id&.to_s }.tap do |body| + included_object = { **(included_object[:attributes] || {}), id: related_id.to_s }.tap do |body| body[:relationships] = included_object[:relationships] if included_object.key?(:relationships) # Pass nested relationships end diff --git a/spec/support/inputs_outputs_pairs.rb b/spec/support/inputs_outputs_pairs.rb index 1960519..bd6c3f5 100644 --- a/spec/support/inputs_outputs_pairs.rb +++ b/spec/support/inputs_outputs_pairs.rb @@ -420,6 +420,7 @@ module JsonApi::Parameters::Testing { 'https://jsonapi.org/format/#crud-updating-to-one-relationships example (removal, single owner)' => [ { data: { + id: '1', type: 'account', attributes: { name: 'Bob Loblaw', @@ -434,6 +435,7 @@ module JsonApi::Parameters::Testing }, { account: { + id: '1', name: 'Bob Loblaw', profile_url: 'http://example.com/images/no-nonsense.png', owner_id: nil From 28aad22204192eaeb8b6c9e0a2eedc589907150d Mon Sep 17 00:00:00 2001 From: marahin Date: Wed, 26 Aug 2020 16:12:43 +0200 Subject: [PATCH 08/13] Move jsonapi_schema.json to lib/jsonapi_parameters --- {spec/support => lib/jsonapi_parameters}/jsonapi_schema.json | 0 lib/jsonapi_parameters/validator.rb | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename {spec/support => lib/jsonapi_parameters}/jsonapi_schema.json (100%) diff --git a/spec/support/jsonapi_schema.json b/lib/jsonapi_parameters/jsonapi_schema.json similarity index 100% rename from spec/support/jsonapi_schema.json rename to lib/jsonapi_parameters/jsonapi_schema.json diff --git a/lib/jsonapi_parameters/validator.rb b/lib/jsonapi_parameters/validator.rb index 87d7eb4..cdbfc87 100644 --- a/lib/jsonapi_parameters/validator.rb +++ b/lib/jsonapi_parameters/validator.rb @@ -2,7 +2,7 @@ require 'json_schemer' module JsonApi::Parameters - SCHEMA_PATH = 'spec/support/jsonapi_schema.json'.freeze + SCHEMA_PATH = 'lib/jsonapi_parameters/jsonapi_schema.json'.freeze private From 64d635ca4f0bf08864e77aab0297b44dae5218e3 Mon Sep 17 00:00:00 2001 From: marahin Date: Thu, 3 Sep 2020 15:16:07 +0200 Subject: [PATCH 09/13] Correct the description for checking validation errors --- spec/lib/jsonapi_parameters/validator_spec.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/spec/lib/jsonapi_parameters/validator_spec.rb b/spec/lib/jsonapi_parameters/validator_spec.rb index abcbac9..7103141 100644 --- a/spec/lib/jsonapi_parameters/validator_spec.rb +++ b/spec/lib/jsonapi_parameters/validator_spec.rb @@ -74,11 +74,10 @@ class Translator Translator.new end - it 'does not raise validation errors' do + it 'raises validation errors' do payload = { payload: { sample: 'value' } } expect { translator.jsonapify(payload) }.to raise_error(ActiveModel::ValidationError) - expect { translator.jsonapify(payload) }.not_to raise_error(JsonApi::Parameters::TranslatorError) end end From ad511416a023a6d5102af0e82215a1ecf13f809c Mon Sep 17 00:00:00 2001 From: marahin Date: Tue, 29 Sep 2020 11:06:30 +0200 Subject: [PATCH 10/13] Change jsonapi_schemer dependency to match one without Ruby2.5 requirement --- jsonapi_parameters.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jsonapi_parameters.gemspec b/jsonapi_parameters.gemspec index 780bcce..4962a10 100644 --- a/jsonapi_parameters.gemspec +++ b/jsonapi_parameters.gemspec @@ -18,7 +18,7 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency 'activesupport', '>= 4.1.8' spec.add_runtime_dependency 'actionpack', '>= 4.1.8' spec.add_runtime_dependency 'activemodel', '>= 4.1.8' - spec.add_runtime_dependency 'json_schemer', '~> 0.2.13' + spec.add_runtime_dependency 'json_schemer', '~> 0.2.14' spec.add_development_dependency 'nokogiri', '~> 1.10.5' spec.add_development_dependency 'json', '~> 2.0' From 6776da51eb7633c1c0134b99ccb5eaf78f6a60ed Mon Sep 17 00:00:00 2001 From: marahin Date: Wed, 30 Sep 2020 12:10:03 +0200 Subject: [PATCH 11/13] Prefix global schema validation settings --- README.md | 4 ++-- lib/jsonapi_parameters/parameters.rb | 8 ++++---- lib/jsonapi_parameters/translator.rb | 2 +- lib/jsonapi_parameters/validator.rb | 2 +- spec/lib/jsonapi_parameters/validator_spec.rb | 8 ++++---- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4ba1ba7..55d7383 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ If you would prefer to suppress validation errors, you can do so by declaring it ```ruby # config/initializers/jsonapi_parameters.rb -JsonApi::Parameters.suppress_validation_errors = true +JsonApi::Parameters.suppress_schema_validation_errors = true ``` If you would prefer to prevalidate every payload _before_ attempting to fully parse it, you can do so by enforcing prevalidation: @@ -164,7 +164,7 @@ If you would prefer to prevalidate every payload _before_ attempting to fully pa ```ruby # config/initializers/jsonapi_parameters.rb -JsonApi::Parameters.enforce_prevalidation = true +JsonApi::Parameters.enforce_schema_prevalidation = true ``` It is important to note that setting suppression and prevalidation is exclusive. If both settings are set to `true` no prevalidation will happen. diff --git a/lib/jsonapi_parameters/parameters.rb b/lib/jsonapi_parameters/parameters.rb index 8867f00..e3550ba 100644 --- a/lib/jsonapi_parameters/parameters.rb +++ b/lib/jsonapi_parameters/parameters.rb @@ -1,13 +1,13 @@ module JsonApi module Parameters @ensure_underscore_translation = false - @suppress_validation_errors = false - @enforce_prevalidation = false + @suppress_schema_validation_errors = false + @enforce_schema_prevalidation = false class << self attr_accessor :ensure_underscore_translation - attr_accessor :suppress_validation_errors - attr_accessor :enforce_prevalidation + attr_accessor :suppress_schema_validation_errors + attr_accessor :enforce_schema_prevalidation end end end diff --git a/lib/jsonapi_parameters/translator.rb b/lib/jsonapi_parameters/translator.rb index 7696ed0..eba0833 100644 --- a/lib/jsonapi_parameters/translator.rb +++ b/lib/jsonapi_parameters/translator.rb @@ -23,7 +23,7 @@ def jsonapi_translate(params, naming_convention:) formed_parameters rescue StandardError => err # Validate the payload and raise errors... - JsonApi::Parameters::Validator.new(@jsonapi_unsafe_hash.deep_dup).validate! unless JsonApi::Parameters.suppress_validation_errors + JsonApi::Parameters::Validator.new(@jsonapi_unsafe_hash.deep_dup).validate! unless JsonApi::Parameters.suppress_schema_validation_errors raise err # ... or if there were none, re-raise initial error end diff --git a/lib/jsonapi_parameters/validator.rb b/lib/jsonapi_parameters/validator.rb index cdbfc87..4aa5aaa 100644 --- a/lib/jsonapi_parameters/validator.rb +++ b/lib/jsonapi_parameters/validator.rb @@ -7,7 +7,7 @@ module JsonApi::Parameters private def should_prevalidate? - JsonApi::Parameters.enforce_prevalidation && !JsonApi::Parameters.suppress_validation_errors + JsonApi::Parameters.enforce_schema_prevalidation && !JsonApi::Parameters.suppress_schema_validation_errors end class Validator diff --git a/spec/lib/jsonapi_parameters/validator_spec.rb b/spec/lib/jsonapi_parameters/validator_spec.rb index 7103141..4b8fb14 100644 --- a/spec/lib/jsonapi_parameters/validator_spec.rb +++ b/spec/lib/jsonapi_parameters/validator_spec.rb @@ -19,9 +19,9 @@ class Translator end describe 'with prevalidation enforced' do - before { JsonApi::Parameters.enforce_prevalidation = true } + before { JsonApi::Parameters.enforce_schema_prevalidation = true } - after { JsonApi::Parameters.enforce_prevalidation = false } + after { JsonApi::Parameters.enforce_schema_prevalidation = false } it 'raises validation errors' do payload = { payload: { sample: 'value' } } @@ -48,9 +48,9 @@ class Translator end describe 'suppression enabled' do - before { JsonApi::Parameters.suppress_validation_errors = true } + before { JsonApi::Parameters.suppress_schema_validation_errors = true } - after { JsonApi::Parameters.suppress_validation_errors = false } + after { JsonApi::Parameters.suppress_schema_validation_errors = false } it 'does not raise validation errors' do payload = { payload: { sample: 'value' } } From 9c36245faad338ade29af99206e0789494a783c0 Mon Sep 17 00:00:00 2001 From: Jasiek Matusz Date: Thu, 1 Oct 2020 12:41:22 +0200 Subject: [PATCH 12/13] Load jsonapi_schema.json properly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Patryk Ptasiński --- lib/jsonapi_parameters/validator.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/jsonapi_parameters/validator.rb b/lib/jsonapi_parameters/validator.rb index 4aa5aaa..5627588 100644 --- a/lib/jsonapi_parameters/validator.rb +++ b/lib/jsonapi_parameters/validator.rb @@ -2,7 +2,7 @@ require 'json_schemer' module JsonApi::Parameters - SCHEMA_PATH = 'lib/jsonapi_parameters/jsonapi_schema.json'.freeze + SCHEMA_PATH = Pathname.new(__dir__).join('jsonapi_schema.json').to_s.freeze private From e9f8b7bf8e939b3470289fa092c294dc077f5e5f Mon Sep 17 00:00:00 2001 From: marahin Date: Fri, 2 Oct 2020 15:31:55 +0200 Subject: [PATCH 13/13] Allow additionalProperties in jsonapi schema --- lib/jsonapi_parameters/jsonapi_schema.json | 20 +++++++++---------- spec/lib/jsonapi_parameters/validator_spec.rb | 18 +++++++++++++++++ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/lib/jsonapi_parameters/jsonapi_schema.json b/lib/jsonapi_parameters/jsonapi_schema.json index 8e507da..e656c17 100644 --- a/lib/jsonapi_parameters/jsonapi_schema.json +++ b/lib/jsonapi_parameters/jsonapi_schema.json @@ -50,7 +50,7 @@ "$ref": "#/definitions/jsonapi" } }, - "additionalProperties": false + "additionalProperties": true }, "failure": { "type": "object", @@ -75,7 +75,7 @@ "$ref": "#/definitions/links" } }, - "additionalProperties": false + "additionalProperties": true }, "info": { "type": "object", @@ -93,7 +93,7 @@ "$ref": "#/definitions/jsonapi" } }, - "additionalProperties": false + "additionalProperties": true }, "meta": { @@ -148,7 +148,7 @@ "$ref": "#/definitions/meta" } }, - "additionalProperties": false + "additionalProperties": true }, "relationshipLinks": { "description": "A resource object **MAY** contain references to other resource objects (\"relationships\"). Relationships may be to-one or to-many. Relationships can be specified by including a member in a resource's links object.", @@ -213,7 +213,7 @@ {"required": ["type"]} ] }, - "additionalProperties": false + "additionalProperties": true }, "relationships": { @@ -251,10 +251,10 @@ {"required": ["type"]} ] }, - "additionalProperties": false + "additionalProperties": true } }, - "additionalProperties": false + "additionalProperties": true }, "relationshipToOne": { "description": "References to other resource objects in a to-one (\"relationship\"). Relationships can be specified by including a member in a resource's links object.", @@ -297,7 +297,7 @@ "$ref": "#/definitions/meta" } }, - "additionalProperties": false + "additionalProperties": true }, "pagination": { "type": "object", @@ -344,7 +344,7 @@ "$ref": "#/definitions/meta" } }, - "additionalProperties": false + "additionalProperties": true }, "error": { @@ -390,7 +390,7 @@ "$ref": "#/definitions/meta" } }, - "additionalProperties": false + "additionalProperties": true } } } diff --git a/spec/lib/jsonapi_parameters/validator_spec.rb b/spec/lib/jsonapi_parameters/validator_spec.rb index 4b8fb14..65c6536 100644 --- a/spec/lib/jsonapi_parameters/validator_spec.rb +++ b/spec/lib/jsonapi_parameters/validator_spec.rb @@ -91,5 +91,23 @@ class Translator expect { validator.validate! }.to raise_error(ActiveModel::ValidationError) end end + + describe 'Rails specific parameters' do + it 'does not yield validation error on :controller, :action, :commit' do + rails_specific_params = [:controller, :action, :commit] + payload = { controller: 'examples_controller', action: 'create', commit: 'Sign up' } + validator = described_class.new(payload) + + expect { validator.validate! }.to raise_error(ActiveModel::ValidationError) + + begin + validator.validate! + rescue ActiveModel::ValidationError => err + rails_specific_params.each do |param| + expect(err.message).not_to include("Payload path '/#{param}'") + end + end + end + end end end