diff --git a/README.md b/README.md index 741737a..55d7383 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_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: + +```ruby +# config/initializers/jsonapi_parameters.rb + +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. ## License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/jsonapi_parameters.gemspec b/jsonapi_parameters.gemspec index e565efc..4962a10 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.14' 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..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 }.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) + 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 b6a302b..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 ) || {} - return ["#{singularize(relationship_key)}_id".to_sym, related_id] if included_object.empty? + # 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 }.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/jsonapi_schema.json b/lib/jsonapi_parameters/jsonapi_schema.json new file mode 100644 index 0000000..e656c17 --- /dev/null +++ b/lib/jsonapi_parameters/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": true + }, + "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": true + }, + "info": { + "type": "object", + "required": [ + "meta" + ], + "properties": { + "meta": { + "$ref": "#/definitions/meta" + }, + "links": { + "$ref": "#/definitions/links" + }, + "jsonapi": { + "$ref": "#/definitions/jsonapi" + } + }, + "additionalProperties": true + }, + + "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": 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.", + "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": true + }, + + "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": true + } + }, + "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.", + "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": true + }, + "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": true + }, + + "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": true + } + } +} + diff --git a/lib/jsonapi_parameters/parameters.rb b/lib/jsonapi_parameters/parameters.rb index f8e79f5..e3550ba 100644 --- a/lib/jsonapi_parameters/parameters.rb +++ b/lib/jsonapi_parameters/parameters.rb @@ -1,9 +1,13 @@ module JsonApi module Parameters @ensure_underscore_translation = false + @suppress_schema_validation_errors = false + @enforce_schema_prevalidation = false class << self attr_accessor :ensure_underscore_translation + 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 b7574b6..eba0833 100644 --- a/lib/jsonapi_parameters/translator.rb +++ b/lib/jsonapi_parameters/translator.rb @@ -16,15 +16,26 @@ 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) + + JsonApi::Parameters::Validator.new(@jsonapi_unsafe_hash.deep_dup).validate! if should_prevalidate? 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_schema_validation_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 @@ -34,7 +45,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 @@ -114,4 +125,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 new file mode 100644 index 0000000..5627588 --- /dev/null +++ b/lib/jsonapi_parameters/validator.rb @@ -0,0 +1,52 @@ +require 'active_model' +require 'json_schemer' + +module JsonApi::Parameters + SCHEMA_PATH = Pathname.new(__dir__).join('jsonapi_schema.json').to_s.freeze + + private + + def should_prevalidate? + JsonApi::Parameters.enforce_schema_prevalidation && !JsonApi::Parameters.suppress_schema_validation_errors + end + + 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 + + # Based on & 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 + "path '#{err['data_pointer']}' is invalid according to the JsonApi schema" + 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..a788632 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,7 +42,7 @@ 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) @@ -51,7 +51,7 @@ class Translator 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/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..65c6536 --- /dev/null +++ b/spec/lib/jsonapi_parameters/validator_spec.rb @@ -0,0 +1,113 @@ +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 + let(:translator) do + class Translator + include JsonApi::Parameters + end + + Translator.new + end + + describe 'with prevalidation enforced' do + before { JsonApi::Parameters.enforce_schema_prevalidation = true } + + after { JsonApi::Parameters.enforce_schema_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_schema_validation_errors = true } + + after { JsonApi::Parameters.suppress_schema_validation_errors = false } + + 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 'raises validation errors' do + payload = { payload: { sample: 'value' } } + + expect { translator.jsonapify(payload) }.to raise_error(ActiveModel::ValidationError) + 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 + + 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 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 diff --git a/spec/support/inputs_outputs_pairs.rb b/spec/support/inputs_outputs_pairs.rb index d9b3807..bd6c3f5 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' } ] @@ -418,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', @@ -432,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 @@ -486,19 +490,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 +511,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 +523,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 +550,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 +562,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 +583,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 +594,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 +614,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' } } }