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

Add support for ignoring client generated IDs #55

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
136 changes: 128 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment on lines +77 to +163
Copy link
Contributor

@choosen choosen Dec 17, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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
Library will add relations to record. Nested resources will receive attributes from _included_ section by `id` match.
By default id field is treated as Client-Generated ID, so it will identify record in database.
If you want to add related object attributes without providing Client-Generated ID then
you can specify ignore_ids_with_prefix:
```ruby
JsonApi::Parameters.ignore_ids_with_prefix = 'local_id_' # library default is nil
```
If defined, all IDs starting with `local_id_` will be removed from parsed params.
In case of creating new nested resources, client will need to generate local IDs sent in `relationships` and `included` parts of request.
```js
{
"type": "multitracks",
"attributes": {
"title": "Multitrack"
},
"relationships": {
"tracks": {
"data": [
{
"type": "tracks",
"id": "local_id_new_track" // Local ID for new resources -> needs to match ID in included below
}
]
}
},
"included": [
{
"id": "local_id_new_track", // Local ID for new resources -> needs to match ID in relationships above
"type": "tracks",
"attributes": {
"name": "Drums"
}
}
]
}
```
```js
params.from_jsonapi
{
"multitrack" => {
"title" => "Multitrack",
"tracks_attributes" => {
"0" => { // No ID is present, so ActiveRecord#create correctly creates
"name" => "Drums" // the new instance without Client-Generated ID
}
}
}
}
```
In case of updating existing nested resources and creating new ones in the same request, client needs to generate local IDs for new resources and use existing ones for existing resources. Local IDs will be removed from params.
```js
{
"type": "multitracks",
"attributes": {
"title": "Multitrack"
},
"relationships": {
"tracks": {
"data": [
{
"type": "tracks",
"id": "123" // Existing ID for existing resources
},
{
"type": "tracks",
"id": "local_id_new_track" // Local 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": "local_id_new_track", // Local ID for new resources -> needs to match ID in relationships above
```

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nikajukic Can I fix those and merge it ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@choosen Sorry, I thought you already did it above, now I see it was only a suggestion 🙈

Sure, go for it!

"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

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

Expand All @@ -115,10 +235,10 @@ translator = Translator.new
translator.jsonapify(custom_stack_limit: 4)

# OR

translator.stack_limit = 4
translator.jsonapify.(...)
```
```

#### Rails
```ruby
Expand All @@ -129,7 +249,7 @@ def create_params
end

# OR

def create_params
params.stack_level = 4

Expand Down
17 changes: 17 additions & 0 deletions lib/jsonapi_parameters/default_handlers/base_handler.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/jsonapi_parameters/parameters.rb
Original file line number Diff line number Diff line change
@@ -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
14 changes: 11 additions & 3 deletions spec/app/app/controllers/authors_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Loading