0.0
No release in over a year
Include JsonApiable module in your API::BaseController to receive a collection of useful methods, such as arguments and relationships parser, filters, etc.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
 Dependencies
 Project Readme

JsonApiable

Maintainability Gem Version

JsonApiable is a Ruby module that makes it easier for Rails API controllers to handle JSON:API parameter and relationship parsing, strong parameter validation, returning well-structured errors and more - all in a Rails-friendly way.

JsonApiable doesn't assume anything about other JSON:API gems you may be using. Feel free to use it in conjunction with fast_jsonapi, active_model_serializer, jsonapi-resources or any other library.

Installation

Add this line to your application's Gemfile:

gem 'json_apiable'

And then execute:

$ bundle

Or install it yourself as:

$ gem install json_apiable

Usage

Basics

# include JsonApiable in your Base API controller
class API::BaseController < ActionController::Base
  # By including JsonApiable, you get the following before/after actions in your controllers:
  # 
  # before_action :ensure_jsonapi_content_type       - Ensure correct Content-Type (application/vnd.api+json) is set in 
  #                                                    the request, and return error othewrise 
  # before_action :ensure_jsonapi_valid_query_params - Ensure only valid query parameters are used
  # before_action :parse_jsonapi_pagination          - Parse "?page:{number:1, size:25}" query hash, set defaults and 
  #                                                    return errors when invalid values received
  #                                                    Read more: https://jsonapi.org/format/#fetching-pagination 
  # before_action :parse_jsonapi_include             - Parse "?include=posts.author" include directives . 
  #                                                    Read more: https://jsonapi.org/format/#fetching-includes
  # after_action :set_jsonapi_content_type           - Ensure correct Content-Type (application/vnd.api+json) is set 
  #                                                    in the response
  
  # By including JsonApiable, you get the following exceptions handled automatically:
  # rescue_from ArgumentError
  # rescue_from ActionController::UnpermittedParameters
  # rescue_from MalformedRequestError
  # rescue_from UnprocessableEntityError
  # rescue_from ActiveRecord::RecordNotFound
  include JsonApiable
end

class API::PostsController < API::BaseController
  
  # GET /v1/posts
  def index
    # pass page and include info to your logic
    posts = GetPostsService.call(jsonapi_page_hash, jsonapi_include_array)
    # some other gem, such as fast_jsonapi is assumed to produce the json:api output
    render json: posts
  end

  # PATCH /v1/posts/123/update
  # { "data":
  #    { "type": "post",
  #      "attributes": {
  #         "title": "My New Title"
  #      },
  #      "relationships": {
  #         "author": {
  #            "data": {
  #                 "type": "user",
  #                 "id": "4528"
  #            },
  #           "comments": {
  #             "data": [
  #               { "type": "comment", "id": "1489" },
  #               { "type": "comment", "id": "1490" } 
  #             ] 
  #           } 
  #         }
  #        } 
  #     } 
  # }
  def update
    @post = Post.find(params[:id])

    # turn relationships into Rails associations and assign them together with attributes 
    # as you would normally do in Rails
    # 
    # jsonapi_assign_params =>
    # { "title"=>"My New Title",
    #   "author_id" => 4528, 
    #   "comments_attributes"=>{
    #         "0"=>{"id"=>"1489", "_destroy"=>"false"}, 
    #         "1"=>{"id"=>"1490", "_destroy"=>"false"}},
    #   "comment_ids"=>["1489", "1490"],
    # 
    # } 
    @post.update_attributes!(jsonapi_assign_params)
    render json: @post
  end

  def create
    # use jsonapi_attribute_present? to quickly test presence of specific attributes
    raise UnprocessableEntityError, 'No title!' unless jsonapi_attribute_present?(:title)
    # use jsonapi_attribute to get attribute values. If non-existent, nil would be returned
    @title = jsonapi_attribute(:title)
    # exclude 'author' attribute from assign params, for example because it's a separate table on the DB level)
    @author_name = jsonapi_exclude_attribute(:author_name)
    # exclude 'comments' relationship from assign params, for example because we want to filter which ones are added to post
    @comments_hash = jsonapi_exclude_relationship(:comments)
    do_some_logc_with_excluded_params
    # jsonapi_assign_params wouldn't include 'author' attribute and 'comments' relationship
    Post.create!(jsonapi_assign_params)
  end

  protected

  # declare which attributes should be allowed to be assigned. Complex attributes are allowed
  def jsonapi_allowed_attributes
    [:title,
     :body,
     dates: %i[first_drafted published last_edited]]
  end

  # declare which relationships should be allowed to be assigned
  def jsonapi_allowed_relationships
    %i[comments contributors]
  end



end

Filters

JsonApiable supports parsing filter requests in the form example.com/v1/posts?filter[status]=draft and returning errors in case provided filter keys or values do not adhere to what you define:

# Create filter class that inherits from JsonApiable::BaseFilter
class API::PostFilter < JsonApiable::BaseFilter
# Declare which filter keys are supported
  def self.jsonapi_allowed_filters
    {
      # For each key, declare what values are allowed. The supported value matchers include: 
      # 1) Array of values
      # example.com/v1/posts?filter[status]=draft,published 
      status: Post.statuses.keys,
      
      # 2) DateTime matcher - proc that checks that the provided value is a valid DateTime
      # example.com/v1/posts?filter[published_at]='2001-02-03T04:05:06+03:00' 
      published_at: datetime_matcher,
      
      # 3) Boolean matcher - proc that checks that the provided value is a boolean (true/t/1 for True, false/f/0 for False)
      # example.com/v1/posts?filter[subscribers_only]=true       
      subscribers_only: boolean_matcher,
      
      # 4) ID matcher - proc that checks that the provided ids exist for given model
      # example.com/v1/posts?filter[ids]=10893,14596
      ids: ids_matcher(Post),
      
      # Of course, you can also implement your own matchers. For example:
      reviewed_at: recent_datetime_matcher 
    }
  end

  # Example of custom filter value matcher
  def self.recent_datetime_matcher
    proc do |value|
      datetime = Time.zone.parse(value)
      datetime.present? && datetime > 10.years.ago && datetime < 2.years.from_now
    end
  end
end

Now set the filter for actions which should support filtering:

class API::PostsController < API::BaseController
  before_action -> { set_jsonapi_filter(API::PostFilter) }, only: %i[index search]
end

And you are good to go!

Incidentally, PostFilter class is also a good place to implement your filter logic:

class API::PostFilter < JsonApiable::BaseFilter
  # The following methods are available to a filter class instance:
  # jsonapi_collection - collection on which to execute filtering
  # jsonapi_filter_hash - a filter query hash, e.g. { 'status' => ['draft', 'published'], 'published_at' => '2001-02-03T04:05:06+03:00'  }
  def call
    jsonapi_collection.where(status: jsonapi_filter_hash[:status])
  end
end

Now you can call filter posts collection in your controller:

posts = GetPosts.call
# jsonapi_filter_class - API::PostFilter in our example
# jsonapi_filter_hash - a filter query hash, e.g. { 'status' => ['draft', 'published'] }
filtered_posts = jsonapi_filter_class.new(posts, jsonapi_filter_hash).call

Configuration

Add an initializer to your app with the following config block:

JsonApiable.configure do |config|
  # white-list query params that should be allowed
  config.valid_query_params = %w[ id access_token user_id organization_id ]

  # by default, error is returned if the request Content-Type isn't valid JSON-API. Override the behaviour by using this block:
  config.supported_media_type_proc = proc do |request|
    request.content_type == JsonApiable::JSONAPI_CONTENT_TYPE || request.headers['My-Special-Header'].present?
  end

  # by default, ActiveRecord::RecordNotFound is caught by JsonApiable and turned into an error response. If your backend raises a different class of exception, set it here
  config.not_found_exception_class = MyExceptionClass

end

Gotchas

  • To make sure requests with invalid attributes/relationships result in a well-structured json-api error, configure your Rails app to raise exceptions on invalid parameters (JsonApiable will catch them and return an appropriate response). In config/application.rb set config.action_controller.action_on_unpermitted_parameters = :raise

Limitations

  • has_one associations are expected to be represented as complex-attributes on the API level. So if User has_one Address, than on the API level, JsonApiable expects address to be specified as a hash inside User's attributes rather than a separate relationship. This makes sense in most cases. If your API represantion differs, @post.update_attributes!(jsonapi_assign_params) assignment won't work correctly.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/json_apiable.

License

The gem is available as open source under the terms of the MIT License.