Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[OS-17] JSON Schema validation #45

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 42 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Marahin marked this conversation as resolved.
Show resolved Hide resolved
```

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).
2 changes: 2 additions & 0 deletions jsonapi_parameters.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions lib/jsonapi_parameters.rb
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Marahin marked this conversation as resolved.
Show resolved Hide resolved
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions lib/jsonapi_parameters/parameters.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
module JsonApi
module Parameters
@ensure_underscore_translation = false
@supress_validation_errors = false
Marahin marked this conversation as resolved.
Show resolved Hide resolved
@enforce_prevalidation = false

class << self
attr_accessor :ensure_underscore_translation
attr_accessor :suppress_validation_errors
attr_accessor :enforce_prevalidation
end
end
end
29 changes: 21 additions & 8 deletions lib/jsonapi_parameters/translator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

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

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
Expand All @@ -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
Expand Down Expand Up @@ -114,4 +125,6 @@ def handle_nested_relationships(val)

val
end

class TranslatorError < StandardError; end
end
52 changes: 52 additions & 0 deletions lib/jsonapi_parameters/validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
require 'active_model'
require 'json_schemer'

module JsonApi::Parameters
SCHEMA_PATH = 'spec/support/jsonapi_schema.json'.freeze
Marahin marked this conversation as resolved.
Show resolved Hide resolved

private

def should_prevalidate?
JsonApi::Parameters.enforce_prevalidation && !JsonApi::Parameters.suppress_validation_errors
end

class Validator
Marahin marked this conversation as resolved.
Show resolved Hide resolved
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
8 changes: 4 additions & 4 deletions spec/lib/jsonapi_parameters/stack_limit_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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)
Expand All @@ -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

Expand Down
10 changes: 10 additions & 0 deletions spec/lib/jsonapi_parameters/translator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
Loading