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

Basic deserialization. #1248

Merged
merged 1 commit into from
Jan 13, 2016
Merged

Basic deserialization. #1248

merged 1 commit into from
Jan 13, 2016

Conversation

beauby
Copy link
Contributor

@beauby beauby commented Oct 6, 2015

Latest update:

Recap on the current situation:

  • Two methods are exposed (ActiveModelSerializers::Deserialization.jsonapi_parse and ActiveModelSerializers::Deserialization.jsonapi_parse!)
  • They both take the same parameters:
    • a document (either a Hash or an instance of ActionController::Parameters representing a JSON API payload)
    • an options hash:
      • only or except: array of white/black-listed fields (recall that in the context of JSON API, "fields" denotes both attributes and relationships)
      • polymorphic: array of polymorphic associations (those for which the #{relationship}_type must be set for ActiveRecord to handle it properly)
      • keys: hash of translated keys (for instance when the payload contains an author relationship which refers to an user association on the model)
  • The "unsafe" method (the one ending with a !) throws an InvalidDocument exception when the payload does not meet basic criteria for JSON API deserialization.
  • The "safe" method simply returns an empty hash.

Example:

def create
  @post = Post.new(jsonapi_create_params)
end

def jsonapi_create_params
  ActiveModelSerializers::Deserialization.jsonapi_parse(params, only: [:title, :content])
end

There should be a bit more tests, but other than that, I believe this PR is ready. Any thoughts?

Update:

This PR offers basic JSON API deserialization through ActiveModel::Serializer::Adapter::JsonApi::Deserialization.parse(hash, options = {}) which takes a hash or an instance of ActionController::Parameters representing a JSON API document (and possibly some options, more on that further down) and returns an ActiveRecord-friendly hash, so that you can use it as follows:

post_params = ActiveModel::Serializer::Adapter::JsonApi::Deserialization.parse(params.to_h) # or params.to_unsafe_h depending on your rails version
@post = Post.new(post_params)

The only available option currently is fields, which allows for

  • whitelisting of fields (attributes and relationships) by providing an array of symbols, and
  • specifying the corresponding attribute name on the model when using the key option on the association in the serializer, by providing a Hash at the end of the array.
    Example:
hash = {
  'data' => {
    'type' => 'photos',
    'id' => 'zorglub',
    'attributes' => {
      'title' => 'Ember Hamster',
      'src' => 'http://example.com/images/productivity.png'
    },
    'relationships' => {
      'author' => {
        'data' => nil
      },
      'photographer' => {
        'data' => { 'type' => 'people', 'id' => '9' }
      },
      'comments' => {
        'data' => [
          { 'type' => 'comments', 'id' => '1' },
          { 'type' => 'comments', 'id' => '2' }
        ]
      }
    }
  }
}

parse(hash, fields: [:id, :title, :src, :comments, author: :user])

returns

{
  id: 'zorglub',
  src: 'http://example.com/images/productivity.png',
  user_id: nil,
  comment_ids: ['1', '2']
}

Note: It currently does not handle polymorphic associations, but could easily be extended to do so.

Initial post:

This PR offers basic JSON API deserialization through ActiveModel::Serializer::Adapter::JsonApi.parse(params_or_hash) which takes a hash or an instance of ActionController::Parameters representing a JSON API document and turns it into an ActiveRecord-friendly hash, so that you cas use it as follows:

post_params = ActiveModel::Serializer::Adapter::JsonApi.parse(params)
@post = Post.new(post_params)

Note that this PR does not support aliased relationships (that is the name of the relationship in the client-issued JSON API document won't be changed).
Moreover, it currently does not support any kind of whitelisting directly, although you could simply do post_params.slice!(:id, :name, :description) to whitelist or post_params.except!(:is_admin) to blacklist.
Finally, it currently fails if the JSON API document is ill-formed.

We could also make it so that the parse method transforms whatever comes its way into a new ActionController::Parameters instance, so that people can require / permit stuff instead of slice / except.

def self.parse(document)
hash = {}

hash[:id] = document['data']['id'] if document['data']['id']
Copy link
Contributor

Choose a reason for hiding this comment

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

should there be a check for the existence of 'data'? could be handy to have detailed parse errors

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure. C.f. the description of the PR, there is currently no error handling, but it is planned.

Copy link
Contributor

Choose a reason for hiding this comment

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

👍

Copy link
Member

@bf4 bf4 Oct 7, 2015 via email

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Rails' from_json and this method do different stuff. Here we are not building an instance of a model, we are just translating a json payload into a hash that can be passed to an AR model's constructor.

@beauby
Copy link
Contributor Author

beauby commented Oct 25, 2015

Updated with handling of ill-formed documents, whitelisting, and aliased relationships.

# specifying the attribute name on the model.
# @return [Hash] ActiveRecord-ready hash
#
def parse(document, options = {})
Copy link
Member

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The issue with from_json is that it should be called on a model. Here, we make no assumption about the model. I guess we could override ActiveModel::Serializers::JSON so that it calls the parse method and updates the model. But it would be JSON API specific. Thoughts?

Copy link
Member

@bf4 bf4 Oct 26, 2015 via email

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My question still holds though. As a consistent interface of what? to what?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think calling from_json on json-api data should generate a model / associations / whatever.

so, imo, from_json could call this method, parse and return an instance of a model.

@denkristoffer
Copy link

Posting something like {"data":{"attributes":{"email":"john@doe.dev","password":"qwerty"}}} I get an error:
undefined method each_with_object' for {"email"=>"john@doe.dev", "password"=>"qwerty"}:ActionController::Parameters`

Is this on my end?

@beauby
Copy link
Contributor Author

beauby commented Oct 27, 2015

@sachse Your payload looks ok. Could you give me your ruby version, rails version, platform and a full stacktrace?
Also, if you have time, could you clone this branch and run the tests ($ rake test)?

@denkristoffer
Copy link

@beauby I'm running Ruby 2.2.3 and Rails 5 from master on OS X. The test suite passes without any problems.

Full trace:

/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/active_model_serializers-a8564dcfe8f0/lib/active_model/serializer/adapter/json_api/deserialization.rb:46:in `parse_attributes'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/active_model_serializers-a8564dcfe8f0/lib/active_model/serializer/adapter/json_api/deserialization.rb:25:in `parse'
app/controllers/v1/sessions_controller.rb:18:in `session_params'
app/controllers/v1/sessions_controller.rb:13:in `create'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_controller/metal/basic_implicit_render.rb:4:in `send_action'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/abstract_controller/base.rb:183:in `process_action'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_controller/metal/rendering.rb:30:in `process_action'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/abstract_controller/callbacks.rb:20:in `block in process_action'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/activesupport/lib/active_support/callbacks.rb:97:in `__run_callbacks__'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/activesupport/lib/active_support/callbacks.rb:743:in `_run_process_action_callbacks'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/activesupport/lib/active_support/callbacks.rb:90:in `run_callbacks'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/abstract_controller/callbacks.rb:19:in `process_action'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_controller/metal/rescue.rb:29:in `process_action'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_controller/metal/instrumentation.rb:31:in `block in process_action'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/activesupport/lib/active_support/notifications.rb:164:in `block in instrument'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/activesupport/lib/active_support/notifications/instrumenter.rb:20:in `instrument'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/activesupport/lib/active_support/notifications.rb:164:in `instrument'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_controller/metal/instrumentation.rb:29:in `process_action'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_controller/metal/params_wrapper.rb:248:in `process_action'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/activerecord/lib/active_record/railties/controller_runtime.rb:18:in `process_action'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/abstract_controller/base.rb:128:in `process'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_controller/metal.rb:192:in `dispatch'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_controller/metal.rb:264:in `dispatch'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_dispatch/routing/route_set.rb:48:in `dispatch'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_dispatch/routing/route_set.rb:32:in `serve'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_dispatch/journey/router.rb:42:in `block in serve'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_dispatch/journey/router.rb:29:in `each'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_dispatch/journey/router.rb:29:in `serve'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_dispatch/routing/route_set.rb:717:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionview/lib/action_view/digestor.rb:14:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rack-35599cfc2751/lib/rack/etag.rb:25:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rack-35599cfc2751/lib/rack/conditional_get.rb:38:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rack-35599cfc2751/lib/rack/head.rb:12:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/activerecord/lib/active_record/query_cache.rb:36:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb:963:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/activerecord/lib/active_record/migration.rb:489:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_dispatch/middleware/callbacks.rb:29:in `block in call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/activesupport/lib/active_support/callbacks.rb:97:in `__run_callbacks__'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/activesupport/lib/active_support/callbacks.rb:743:in `_run_call_callbacks'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/activesupport/lib/active_support/callbacks.rb:90:in `run_callbacks'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_dispatch/middleware/callbacks.rb:27:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_dispatch/middleware/reloader.rb:71:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_dispatch/middleware/remote_ip.rb:79:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb:48:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_dispatch/middleware/show_exceptions.rb:31:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/railties/lib/rails/rack/logger.rb:42:in `call_app'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/railties/lib/rails/rack/logger.rb:24:in `block in call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/activesupport/lib/active_support/tagged_logging.rb:70:in `block in tagged'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/activesupport/lib/active_support/tagged_logging.rb:26:in `tagged'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/activesupport/lib/active_support/tagged_logging.rb:70:in `tagged'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/railties/lib/rails/rack/logger.rb:24:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_dispatch/middleware/request_id.rb:24:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rack-35599cfc2751/lib/rack/runtime.rb:22:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/activesupport/lib/active_support/cache/strategy/local_cache_middleware.rb:28:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_dispatch/middleware/load_interlock.rb:13:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/actionpack/lib/action_dispatch/middleware/static.rb:133:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rack-35599cfc2751/lib/rack/sendfile.rb:111:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rails-09463183867f/railties/lib/rails/engine.rb:522:in `call'
/Users/kristoffer/.gem/ruby/2.2.3/bundler/gems/rack-35599cfc2751/lib/rack/handler/webrick.rb:86:in `service'
/Users/kristoffer/.rubies/ruby-2.2.3/lib/ruby/2.2.0/webrick/httpserver.rb:138:in `service'
/Users/kristoffer/.rubies/ruby-2.2.3/lib/ruby/2.2.0/webrick/httpserver.rb:94:in `run'
/Users/kristoffer/.rubies/ruby-2.2.3/lib/ruby/2.2.0/webrick/server.rb:294:in `block in start_thread'

@ehibun
Copy link

ehibun commented Oct 27, 2015

I'm getting the same issue. I'm running Ruby 2.2.3, Rails 5 (from master), and AMS from your fork as of 10 minutes ago.

With the following code:

@post_params = ActiveModel::Serializer::Adapter::JsonApi::Deserialization.parse(params)
@genre = Genre.new(genre_post_params)

I get this stack trace:

ActionController::RoutingError at /genres
=========================================

> undefined method `each_with_object' for {"name"=>"test"}:ActionController::Parameters

/Users/peter/Sites/rails/actionpack/lib/action_dispatch/routing/route_set.rb, line 35
-------------------------------------------------------------------------------------

``` ruby
   30             controller = controller req
   31             res        = controller.make_response! req
   32             dispatch(controller, params[:action], req, res)
   33           rescue NameError => e
   34             if @raise_on_name_error
>  35               raise ActionController::RoutingError, e.message, e.backtrace
   36             else
   37               return [404, {'X-Cascade' => 'pass'}, []]
   38             end
   39           end
   40   

App backtrace
-------------



Full backtrace
--------------

 - /Users/peter/Sites/rails/actionpack/lib/action_dispatch/routing/route_set.rb:35:in `rescue in serve'
 - /Users/peter/Sites/rails/actionpack/lib/action_dispatch/routing/route_set.rb:29:in `serve'
 - /Users/peter/Sites/rails/actionpack/lib/action_dispatch/journey/router.rb:42:in `block in serve'
 - /Users/peter/Sites/rails/actionpack/lib/action_dispatch/journey/router.rb:29:in `serve'
 - /Users/peter/Sites/rails/actionpack/lib/action_dispatch/routing/route_set.rb:717:in `call'
 - bullet (4.14.10) lib/bullet/rack.rb:10:in `call'
 - /Users/peter/Sites/rails/actionview/lib/action_view/digestor.rb:14:in `call'
 -  () Users/peter/.rbenv/versions/2.2.3/lib/ruby/gems/2.2.0/bundler/gems/rack-35599cfc2751/lib/rack/etag.rb:25:in `call'
 -  () Users/peter/.rbenv/versions/2.2.3/lib/ruby/gems/2.2.0/bundler/gems/rack-35599cfc2751/lib/rack/conditional_get.rb:38:in `call'
 -  () Users/peter/.rbenv/versions/2.2.3/lib/ruby/gems/2.2.0/bundler/gems/rack-35599cfc2751/lib/rack/head.rb:12:in `call'
 - /Users/peter/Sites/rails/activerecord/lib/active_record/query_cache.rb:36:in `call'
 - /Users/peter/Sites/rails/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb:963:in `call'
 - /Users/peter/Sites/rails/activerecord/lib/active_record/migration.rb:489:in `call'
 - /Users/peter/Sites/rails/actionpack/lib/action_dispatch/middleware/callbacks.rb:29:in `block in call'
 - /Users/peter/Sites/rails/activesupport/lib/active_support/callbacks.rb:97:in `__run_callbacks__'
 - /Users/peter/Sites/rails/activesupport/lib/active_support/callbacks.rb:743:in `_run_call_callbacks'
 - /Users/peter/Sites/rails/activesupport/lib/active_support/callbacks.rb:90:in `run_callbacks'
 - /Users/peter/Sites/rails/actionpack/lib/action_dispatch/middleware/callbacks.rb:27:in `call'
 - /Users/peter/Sites/rails/actionpack/lib/action_dispatch/middleware/reloader.rb:71:in `call'
 - /Users/peter/Sites/rails/actionpack/lib/action_dispatch/middleware/remote_ip.rb:79:in `call'
 - better_errors (2.1.1) lib/better_errors/middleware.rb:84:in `protected_app_call'
 - better_errors (2.1.1) lib/better_errors/middleware.rb:79:in `better_errors_call'
 - better_errors (2.1.1) lib/better_errors/middleware.rb:57:in `call'
 - /Users/peter/Sites/rails/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb:48:in `call'
 - /Users/peter/Sites/rails/actionpack/lib/action_dispatch/middleware/show_exceptions.rb:31:in `call'
 - /Users/peter/Sites/rails/railties/lib/rails/rack/logger.rb:42:in `call_app'
 - /Users/peter/Sites/rails/railties/lib/rails/rack/logger.rb:24:in `block in call'
 - /Users/peter/Sites/rails/activesupport/lib/active_support/tagged_logging.rb:70:in `block in tagged'
 - /Users/peter/Sites/rails/activesupport/lib/active_support/tagged_logging.rb:26:in `tagged'
 - /Users/peter/Sites/rails/activesupport/lib/active_support/tagged_logging.rb:70:in `tagged'
 - /Users/peter/Sites/rails/railties/lib/rails/rack/logger.rb:24:in `call'
 - /Users/peter/Sites/rails/actionpack/lib/action_dispatch/middleware/request_id.rb:24:in `call'
 -  () Users/peter/.rbenv/versions/2.2.3/lib/ruby/gems/2.2.0/bundler/gems/rack-35599cfc2751/lib/rack/runtime.rb:22:in `call'
 - /Users/peter/Sites/rails/activesupport/lib/active_support/cache/strategy/local_cache_middleware.rb:28:in `call'
 - /Users/peter/Sites/rails/actionpack/lib/action_dispatch/middleware/load_interlock.rb:13:in `call'
 - /Users/peter/Sites/rails/actionpack/lib/action_dispatch/middleware/static.rb:133:in `call'
 -  () Users/peter/.rbenv/versions/2.2.3/lib/ruby/gems/2.2.0/bundler/gems/rack-35599cfc2751/lib/rack/sendfile.rb:111:in `call'
 - /Users/peter/Sites/rails/railties/lib/rails/engine.rb:522:in `call'
 -  () Users/peter/.rbenv/versions/2.2.3/lib/ruby/gems/2.2.0/bundler/gems/rack-35599cfc2751/lib/rack/handler/webrick.rb:86:in `service'
 - /Users/peter/.rbenv/versions/2.2.3/lib/ruby/2.2.0/webrick/httpserver.rb:138:in `service'
 - /Users/peter/.rbenv/versions/2.2.3/lib/ruby/2.2.0/webrick/httpserver.rb:94:in `run'
 - /Users/peter/.rbenv/versions/2.2.3/lib/ruby/2.2.0/webrick/server.rb:294:in `block in start_thread'

@beauby
Copy link
Contributor Author

beauby commented Oct 27, 2015

Thanks @sachse. I believe ActionController::Parameters changed a bit in Rails5. I'll find a workaround tonight.

@beauby
Copy link
Contributor Author

beauby commented Oct 27, 2015

Couldn't resist to see what's going on. Since this commit ActionController::Parameters does not inherit from HashWithIndifferentAccess anymore, hence the issue at hand. Pushing a fix in 2mins.


def test_parameters
parameters = ActionController::Parameters.new(@hash)
parsed_hash = ActiveModel::Serializer::Adapter::JsonApi::Deserialization.parse(parameters)

Choose a reason for hiding this comment

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

One solution to fixing this would be to send parameters.to_unsafe_h instead of only parameters. Or to try to call that method before calling to_h inside the #parse method.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@jmonteiro The purpose of this test is to ensure the parse method can handle ActionController::Parameters in order to reduce friction for the user.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The idea is that deserialization should handle parameters transparently. The preferred way for whitelisting would be through the parse method, but in case a user wants to do whitelisting on the ActionController::Parameters instance, we should not override its will, that's why we're using to_h instead of to_unsafe_h inside parse.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh, I may have made a false assumption here. I thought a pristine instance of ActionController::Parameters would have all its parameters whitelisted by default.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok the catch here is that when initializing an instance of ActionController::Parameters like here, it will automatically whitelist all attributes, hence the test wouldn't catch the problem you're mentioning. However, the to_unsafe_h method was only added in rails 4.2, so I'm not sure what the best solution is here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For now, I'll remove direct support for ActionController::Parameters.

@GCorbel
Copy link

GCorbel commented Nov 17, 2015

It works great. It should be merged! Thanks for your work.

@beauby
Copy link
Contributor Author

beauby commented Nov 24, 2015

Rebased. Any feedback on this? I believe it is pretty useful.

@bf4
Copy link
Member

bf4 commented Nov 26, 2015

Any updates since last discussion? Also, ref: #1098

@NullVoxPopuli
Copy link
Contributor

my only feedback was for inline documentation, which I know @beauby despises... so...

:-)

@NullVoxPopuli
Copy link
Contributor

but other than that, I think it should be merged -- just to get things moving.

👍


def test_illformed_payload
parsed_hash = ActiveModel::Serializer::Adapter::JsonApi::Deserialization.parse({})
assert_equal({}, parsed_hash)
Copy link
Member

Choose a reason for hiding this comment

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

I don't understand what this is testing. What would a failure look like? (also, malformed or invalid?)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This test just makes sure AMS does not go crazy when the hash does not comply to the JSON API spec. I guess ideally there should be a bunch more tests for invalid/missing keys.


document = document.dup.permit!.to_h if document.is_a?(ActionController::Parameters)

fail ArgumentError, 'Invalid payload' unless payload_valid?(document)
Copy link
Member

Choose a reason for hiding this comment

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

I'd rather not use the exception for flow control. I'd rather have all the code in parse and have it do, maybe

if  payload_invalid?(document)
  if block_given?
    yield document
  else
    return {} # I guess this is supposed to be a benign value?
  end
end

and then

InvalidDocument = Class.new(ArgumentError)
def parse!(document, options = {})
  parse(document, options) do |invalid_document)
    fail InvalidDocument, "Invalid Document: #{reason}" # reason would be good, right?
  end
end

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I am personally less comfortable with yielding stuff around for flow-control, but that's probably just me, so it's not really relevant. The way I thought of it: I defined an "unsafe" method (which would throw an exception when encountering an ill-formed document – I intended to make a custom exception with the reason and such, but haven't got there yet), and a "safe" version which would just catch the exception. I don't know whether this is common practice or specifically avoided. I'd have to have a look at how rails does stuff. In the meantime, would you mind elaborating on the reasons why using exceptions for (highly-local) flow-control does not suit you?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ping @bf4

Copy link
Member

Choose a reason for hiding this comment

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

oh, sorry... there's a number of reasons not to use exceptions for flow control, in no particular order

  • they're slower than the alternative
  • flow control isn't "exceptional"
  • flow control isn't a good reason to possibly crash a program
  • it's better to use throw/catch or blocks
    • throw/catch for unwinding the stack
    • blocks for allowing user-defined error-handling strategies

which to me I would summarize as "don't do it unless there's a really good reason"

If I had to choose over 'fail' vs. 'throw/catch', without any refactor, I'd pick throw catch, but really, I think the convention that parse! raises an exception is fine (if it is a good API for us), as long as the exceptional method doesn't raise an exception!

Copy link
Member

Choose a reason for hiding this comment

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

I can dig up sources if you're curious. they're used in Nokogiri::SAX, Sinatra, and in Rails 5 callbacks rails/rails#17227 .

Copy link
Member

Choose a reason for hiding this comment

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

Pong

B mobile phone

On Jan 3, 2016, at 9:08 PM, Lucas Hosseini notifications@github.com wrote:

In lib/active_model/serializer/adapter/json_api/deserialization.rb:

  •      #     # }
    
  •      #
    
  •      #   parse(document, fields: [:title, :author, date: :published_at]) #=>
    
  •      #     # {
    
  •      #     #   title: 'Title 1',
    
  •      #     #   published_at: '2015-12-20',
    
  •      #     #   author_id: 2,
    
  •      #     # }
    
  •      #
    
  •      # rubocop:disable Metrics/CyclomaticComplexity
    
  •      def parse!(document, options = {})
    
  •        fields = parse_fields_option(options[:fields])
    
  •        document = document.dup.permit!.to_h if document.is_a?(ActionController::Parameters)
    
  •        fail ArgumentError, 'Invalid payload' unless payload_valid?(document)
    
    Ping @bf4


Reply to this email directly or view it on GitHub.

Copy link

Choose a reason for hiding this comment

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

could I intervene? Why not to use pros of both approaches?

DEFAULT_FALLBACK = ->(error) { fail ArgumentError, 'Invalid payload' }
SAFE_FALLBACK = ->(error) { {} }

def parse!(document, options = {}, &fallback)
  fallback ||= DEFAULT_FALLBACK

  fields = parse_fields_option(options[:fields])

  document = document.dup.permit!.to_h if document.is_a?(ActionController::Parameters)

  return fallback.call(document) unless payload_valid?(document)

  # ...


def parse(document, options = {})
  parse!(document, options, &SAFE_FALLBACK)
end

Choose a reason for hiding this comment

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

Skipping back to the first post in this thread, and now that I'm starting to experiment with this PR a bit more; the current Argument Error: Invalid Payload format is a little too inflexible to return detailed error messages to the client. In terms of technical approach I couldn't comment but I agree with @bf4 on the following line:

fail InvalidDocument, "Invalid Document: #{reason}" # reason would be good, right?

A custom error class with a reason would allow us to rescue_from InvalidDocument, with: ... so the default param is missing or the value is empty: #{reason_field} error message can be replicated, at least functionally.

I'd feel less comfortable rescuing an ArgumentError in a base controller since this is a bit of a catch all and without a reason, a specific error can't be passed on to the client.

# @example
# parse_attributes({ 'a' => 'b', 'c' => 'd'}, nil)
# # => { :a => 'b', :c => 'd' }
# parse_attributes({ 'a' => 'b', 'c' => 'd', 'e' => 'f' }, { :a, :c => :g })
Copy link

Choose a reason for hiding this comment

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

invalid example

{ :a, :c => :g }

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're right, it was meant to be [ :a, :c => :g ]. Thanks for spotting this.

@beauby
Copy link
Contributor Author

beauby commented Jan 12, 2016

Last commit:

  • Error reporting with custom exception and reason
  • Rewrite to allow the following options:
    • only/except
    • keys (hash of translated keys, like :author => :user)
    • polymorphic (list of polymorphic fields (mainly relationships), for which the #{relationship}_type attribute should be set)

@beauby beauby force-pushed the jsonapi-parse branch 2 times, most recently from 4abc3a3 to fa70440 Compare January 12, 2016 11:32
@beauby
Copy link
Contributor Author

beauby commented Jan 12, 2016

Recap on the current situation:

  • Two methods are exposed (ActiveModelSerializers::Deserialization.jsonapi_parse and ActiveModelSerializers::Deserialization.jsonapi_parse!)
  • They both take the same parameters:
    • a document (either a Hash or an instance of ActionController::Parameters representing a JSON API payload)
    • an options hash:
      • only or except: array of white/black-listed fields (recall that in the context of JSON API, "fields" denotes both attributes and relationships)
      • polymorphic: array of polymorphic associations (those for which the #{relationship}_type must be set for ActiveRecord to handle it properly)
      • keys: hash of translated keys (for instance when the payload contains an author relationship which refers to an user association on the model)
  • The "unsafe" method (the one ending with a !) throws an InvalidDocument exception when the payload does not meet basic criteria for JSON API deserialization.
  • The "safe" method simply returns an empty hash.

There should be a bit more tests, but other than that, I believe this PR is ready. Any thoughts?

@beauby
Copy link
Contributor Author

beauby commented Jan 13, 2016

Will merge once green.

@beauby
Copy link
Contributor Author

beauby commented Jan 13, 2016

Ladies and gentlemen: merging!

beauby added a commit that referenced this pull request Jan 13, 2016
@beauby beauby merged commit adaf5b8 into rails-api:master Jan 13, 2016
@GCorbel
Copy link

GCorbel commented Jan 13, 2016

Woohoo! Well done @beauby !

# # }
#
# parse(document, only: [:title, :date, :author],
# keys: { date: :published_at },
Copy link
Member

Choose a reason for hiding this comment

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

Nice!

@maletor
Copy link

maletor commented Mar 22, 2016

Should this support transforming the key back? Typically an application will store keys in underscore format. AMS should then convert them to dashed for JSON API. But, upon deserialization, they should be converted back to underscores. Seems like that should happen here.

@bf4 bf4 deleted the jsonapi-parse branch March 22, 2016 01:02
@bf4
Copy link
Member

bf4 commented Mar 22, 2016

@maletor Can you make a new issue? thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet