diff --git a/README.md b/README.md index 3dc6ada..a4bbd85 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Usually your strong parameters in controller are invoked this way: ```ruby def create model = Model.new(create_params) - + if model.save ... else @@ -72,6 +72,126 @@ If you provide any related resources in the `relationships` table, this gem will For more examples take a look at [Relationships](https://github.com/visualitypl/jsonapi_parameters/wiki/Relationships) in the wiki documentation. +##### Client generated IDs + +You can specify ignore_ids_with_prefix: +``` +JsonApi::Parameters.ignore_ids_with_prefix = 'client_' +``` + +ignore_ids_with_prefix is by default set to `nil` + +If defined, all IDs starting with `JsonApi::Parameters.ignore_ids_with_prefix` will be removed from params. + +In case of creating new nested resources, client will need to generate IDs sent in `relationships` and `included` parts of request. + +``` +{ + "type": "multitracks", + "attributes": { + "title": "Multitrack" + }, + "relationships": { + "tracks": { + "data": [ + { + "type": "tracks", + "id": "cid_new_track" // Client ID for new resources -> needs to match ID in included below + } + ] + } + }, + "included": [ + { + "id": "cid_new_track", // Client ID for new resources -> needs to match ID in relationships below + "type": "tracks", + "attributes": { + "name": "Drums" + } + } + ] +} +``` + +``` +params.from_jsonapi + +{ + "multitrack" => { + "title" => "Multitrack", + "tracks_attributes" => { + "0" => { // No ID is present, so ActiveRecord#create correctly creates the new instance + "name" => "Drums" + } + } + } +} +``` + +In case of updating existing nested resources and creating new ones in the same request, client needs to generate IDs for new resources and use existing ones for existing resources. Client IDs will be removed from params. + + +``` +{ + "type": "multitracks", + "attributes": { + "title": "Multitrack" + }, + "relationships": { + "tracks": { + "data": [ + { + "type": "tracks", + "id": "123" // Existing ID for existing resources + }, + { + "type": "tracks", + "id": "cid_new_track" // Client ID for new resources -> needs to match ID in included below + } + ] + } + }, + "included": [ + { + "id": "123", // Existing ID for existing resources + "type": "tracks", + "attributes": { + "name": "Piano" + } + }, + { + "id": "cid_new_track", // Client ID for new resources -> needs to match ID in relationships below + "type": "tracks", + "attributes": { + "name": "Drums" + } + } + ] +} +``` + +``` +params.from_jsonapi + +{ + "multitrack" => { + "title" => "Multitrack", + "tracks_attributes" => { + "0" => { + "id" => "123", + "name" => "Piano" + }, + "1" => { // No ID is present, so ActiveRecord#update correctly creates the new instance + "name" => "Drums" + } + } + } +} +``` + + +Translate + ### Plain Ruby / outside Rails @@ -88,19 +208,19 @@ translator = Translator.new translator.jsonapify(params) ``` - + ## Mime Type -As [stated in the JSON:API specification](https://jsonapi.org/#mime-types) correct mime type for JSON:API input should be [`application/vnd.api+json`](http://www.iana.org/assignments/media-types/application/vnd.api+json). +As [stated in the JSON:API specification](https://jsonapi.org/#mime-types) correct mime type for JSON:API input should be [`application/vnd.api+json`](http://www.iana.org/assignments/media-types/application/vnd.api+json). This gem's intention is to make input consumption as easy as possible. Hence, it [registers this mime type for you](lib/jsonapi_parameters/core_ext/action_dispatch/http/mime_type.rb). ## Stack limit In theory, any payload may consist of infinite amount of relationships (and so each relationship may have its own, included, infinite amount of nested relationships). -Because of that, it is a potential vector of attack. +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. +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. @@ -115,10 +235,10 @@ translator = Translator.new translator.jsonapify(custom_stack_limit: 4) # OR - + translator.stack_limit = 4 translator.jsonapify.(...) -``` +``` #### Rails ```ruby @@ -129,7 +249,7 @@ def create_params end # OR - + def create_params params.stack_level = 4 diff --git a/lib/jsonapi_parameters/default_handlers/base_handler.rb b/lib/jsonapi_parameters/default_handlers/base_handler.rb index e57f3ec..df7eb1d 100644 --- a/lib/jsonapi_parameters/default_handlers/base_handler.rb +++ b/lib/jsonapi_parameters/default_handlers/base_handler.rb @@ -23,6 +23,23 @@ def find_included_object(related_id:, related_type:) included_object_enum[:type] == related_type end end + + def build_included_object(included_object, related_id) + included_object_base(included_object).tap do |body| + body[:id] = related_id unless client_generated_id?(related_id) + body[:relationships] = included_object[:relationships] if included_object.key?(:relationships) # Pass nested relationships + end + end + + def included_object_base(included_object) + { **(included_object[:attributes] || {}) } + end + + def client_generated_id?(related_id) + return false unless JsonApi::Parameters.ignore_ids_with_prefix + + related_id.to_s.starts_with?(JsonApi::Parameters.ignore_ids_with_prefix) + end end end end 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..871f8e1 100644 --- a/lib/jsonapi_parameters/default_handlers/to_many_relation_handler.rb +++ b/lib/jsonapi_parameters/default_handlers/to_many_relation_handler.rb @@ -36,9 +36,7 @@ def prepare_relationship_vals @with_inclusion &= !included_object.empty? if with_inclusion - { **(included_object[:attributes] || {}), id: related_id }.tap do |body| - body[:relationships] = included_object[:relationships] if included_object.key?(:relationships) # Pass nested relationships - end + build_included_object(included_object, related_id) else relationship.dig(:id) 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..9ebf578 100644 --- a/lib/jsonapi_parameters/default_handlers/to_one_relation_handler.rb +++ b/lib/jsonapi_parameters/default_handlers/to_one_relation_handler.rb @@ -17,9 +17,7 @@ def handle return ["#{singularize(relationship_key)}_id".to_sym, related_id] if included_object.empty? - included_object = { **(included_object[:attributes] || {}), id: related_id }.tap do |body| - body[:relationships] = included_object[:relationships] if included_object.key?(:relationships) # Pass nested relationships - end + included_object = build_included_object(included_object, related_id) ["#{singularize(relationship_key)}_attributes".to_sym, included_object] end diff --git a/lib/jsonapi_parameters/parameters.rb b/lib/jsonapi_parameters/parameters.rb index f8e79f5..fda2c4d 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 + @ignore_ids_with_prefix = nil class << self attr_accessor :ensure_underscore_translation + attr_accessor :ignore_ids_with_prefix end end end diff --git a/spec/app/app/controllers/authors_controller.rb b/spec/app/app/controllers/authors_controller.rb index f369154..e9b0a80 100644 --- a/spec/app/app/controllers/authors_controller.rb +++ b/spec/app/app/controllers/authors_controller.rb @@ -1,6 +1,6 @@ class AuthorsController < ApplicationController def create - author = Author.new(author_params) + author = Author.new(create_author_params) if author.save render json: AuthorSerializer.new(author).serializable_hash @@ -12,7 +12,7 @@ def create def update author = Author.find(params[:id]) - if author.update(author_params) + if author.update(update_author_params) render json: AuthorSerializer.new(author).serializable_hash, status: :ok else head 500 @@ -21,11 +21,19 @@ def update private - def author_params + def create_author_params params.from_jsonapi.require(:author).permit( :name, :scissors_id, posts_attributes: [:title, :body, :category_name], post_ids: [], scissors_attributes: [:sharp], ) end + + def update_author_params + params.from_jsonapi.require(:author).permit( + :name, :scissors_id, + posts_attributes: [:id, :title, :body, :category_name], post_ids: [], + scissors_attributes: [:sharp], + ) + end end diff --git a/spec/integration/authors_controller_spec.rb b/spec/integration/authors_controller_spec.rb index 77706f8..e21eb79 100644 --- a/spec/integration/authors_controller_spec.rb +++ b/spec/integration/authors_controller_spec.rb @@ -137,6 +137,183 @@ expect(jsonapi_response[:data][:relationships][:posts][:data]).to eq([]) end + context 'when ignore_ids_with_prefix is defined' do + it 'creates an author with a post, and then adds a new post and updates existing one' do # rubocop:disable RSpec/ExampleLength + JsonApi::Parameters.ignore_ids_with_prefix = 'cid_' + + params = { + data: { + type: 'authors', + attributes: { + name: 'John Doe' + }, + relationships: { + posts: { + data: [ + { + id: 'cid_new_post', + type: 'post' + } + ] + } + } + }, + included: [ + { + id: 'cid_new_post', + type: 'post', + attributes: { + title: 'Some title', + body: 'Some body that I used to love', + category_name: 'Some category' + } + } + ] + } + + post_with_rails_fix :create, params: params + + author_id = jsonapi_response[:data][:id] + post_id = jsonapi_response[:data][:relationships][:posts][:data].first[:id] + params = { + id: author_id, + data: { + type: 'authors', + id: author_id, + relationships: { + posts: { + data: [ + { + id: post_id, + type: 'post' + }, + { + id: 'cid_new_post', + type: 'post' + } + ] + } + } + }, + included: [ + { + type: 'post', + id: post_id, + attributes: { + title: 'Updated title', + body: 'Updated body', + category_name: 'Updated category' + } + }, + { + type: 'post', + id: 'cid_new_post', + attributes: { + title: 'New title', + body: 'New body', + category_name: 'New category' + } + } + ] + } + + patch_with_rails_fix :update, params: params, as: :json + + expect(Post.first.title).to eq('Updated title') + expect(Post.first.body).to eq('Updated body') + expect(Post.first.category_name).to eq('Updated category') + expect(Post.last.title).to eq('New title') + expect(Post.last.body).to eq('New body') + expect(Post.last.category_name).to eq('New category') + + JsonApi::Parameters.ignore_ids_with_prefix = nil + end + end + + context 'when ignore_ids_with_prefix is not defined' do + it 'raises an error for not being able to find a record with client defined ID' do + params = { + data: { + type: 'authors', + attributes: { + name: 'John Doe' + }, + relationships: { + posts: { + data: [ + { + id: '123', + type: 'post' + } + ] + } + } + }, + included: [ + { + type: 'post', + id: '123', + attributes: { + title: 'Some title', + body: 'Some body that I used to love', + category_name: 'Some category' + } + } + ] + } + + post_with_rails_fix :create, params: params + + author_id = jsonapi_response[:data][:id] + post_id = jsonapi_response[:data][:relationships][:posts][:data].first[:id] + params = { + id: author_id, + data: { + type: 'authors', + id: author_id, + relationships: { + posts: { + data: [ + { + id: post_id, + type: 'post' + }, + { + id: 'cid_new_post', + type: 'post' + } + ] + } + } + }, + included: [ + { + type: 'post', + id: post_id, + attributes: { + title: 'Updated title', + body: 'Updated body', + category_name: 'Updated category' + } + }, + { + type: 'post', + id: 'cid_new_post', + attributes: { + title: 'New title', + body: 'New body', + category_name: 'New category' + } + } + ] + } + + expect do + patch_with_rails_fix :update, params: params, as: :json + end.to raise_error(ActiveRecord::RecordNotFound) + end + end + it 'creates an author with a pair of sharp scissors' do params = { data: { diff --git a/spec/lib/jsonapi_parameters/client_id_prefix_spec.rb b/spec/lib/jsonapi_parameters/client_id_prefix_spec.rb new file mode 100644 index 0000000..5986e56 --- /dev/null +++ b/spec/lib/jsonapi_parameters/client_id_prefix_spec.rb @@ -0,0 +1,171 @@ +require 'spec_helper' + +#### +# Sample klass +#### +class Translator + include JsonApi::Parameters +end + +describe Translator do # rubocop:disable RSpec/FilePath + context 'when ignore_ids_with_prefix is not set' do + it 'does not ignore any ID sent by the client' do + input = { + data: { + type: 'photos', + attributes: { + title: 'Ember Hamster', + src: 'http://example.com/images/productivity.png' + }, + relationships: { + photographers: { + data: [ + { + type: 'people', + id: 9 + }, + { + type: 'people', + id: 10 + }, + { + type: 'people', + id: 'client_id_new_person' + } + ] + } + } + }, + included: [ + { + type: 'people', + id: 10, + attributes: { + name: 'Some guy' + } + }, + { + type: 'people', + id: 9, + attributes: { + name: 'Some other guy' + } + }, + { + type: 'people', + id: 'client_id_new_person', + attributes: { + name: 'New guy' + } + } + ] + } + + predicted_output = { + photo: { + title: 'Ember Hamster', + src: 'http://example.com/images/productivity.png', + photographers_attributes: [ + { + id: 9, + name: 'Some other guy' + }, + { + id: 10, + name: 'Some guy' + }, + { + id: 'client_id_new_person', + name: 'New guy' + } + ] + } + } + + translated_input = described_class.new.jsonapify(input) + expect(HashDiff.diff(translated_input, predicted_output)).to eq([]) + end + end + + context 'when ignore_ids_with_prefix is set' do + it 'ignores IDs with starting with ignore_ids_with_prefix' do + JsonApi::Parameters.ignore_ids_with_prefix = 'client_id_' + + input = { + data: { + type: 'photos', + attributes: { + title: 'Ember Hamster', + src: 'http://example.com/images/productivity.png' + }, + relationships: { + photographers: { + data: [ + { + type: 'people', + id: 9 + }, + { + type: 'people', + id: 10 + }, + { + type: 'people', + id: 'client_id_new_person' + } + ] + } + } + }, + included: [ + { + type: 'people', + id: 10, + attributes: { + name: 'Some guy' + } + }, + { + type: 'people', + id: 9, + attributes: { + name: 'Some other guy' + } + }, + { + type: 'people', + id: 'client_id_new_person', + attributes: { + name: 'New guy' + } + } + ] + } + + predicted_output = { + photo: { + title: 'Ember Hamster', + src: 'http://example.com/images/productivity.png', + photographers_attributes: [ + { + id: 9, + name: 'Some other guy' + }, + { + id: 10, + name: 'Some guy' + }, + { + name: 'New guy' + } + ] + } + } + + translated_input = described_class.new.jsonapify(input) + expect(HashDiff.diff(translated_input, predicted_output)).to eq([]) + + JsonApi::Parameters.ignore_ids_with_prefix = nil + end + end +end