diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..b322ef42a --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: [ 'master', 'release-0-8', 'release-0-9', 'release-0-10' ] + pull_request: + branches: ['**'] + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: + - 2.6.6 + - 2.7.2 + rails: + - 6.1.1 + - 6.0.3.4 + - 5.2.4.4 + - 5.1.7 + - 5.0.7.2 + - 4.2.11 + exclude: + - ruby: 2.7.2 + rails: 5.0.7.2 + - ruby: 2.6.6 + rails: 5.0.7.2 + - ruby: 2.7.2 + rails: 4.2.11 + - ruby: 2.6.6 + rails: 4.2.11 + env: + RAILS_VERSION: ${{ matrix.rails }} + name: Ruby ${{ matrix.ruby }} Rails ${{ matrix.rails }} + steps: + - uses: actions/checkout@v2 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + - name: Install dependencies + run: bundle install --jobs 4 --retry 3 + - name: Run tests + run: bundle exec rake test diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ffd2d0481..000000000 --- a/.travis.yml +++ /dev/null @@ -1,18 +0,0 @@ -language: ruby -sudo: false -env: - - "RAILS_VERSION=4.2.6" - - "RAILS_VERSION=5.0.0" - - "RAILS_VERSION=master" -rvm: - - 2.1 - - 2.2.4 - - 2.3.0 -matrix: - exclude: - - rvm: 2.0 - env: "RAILS_VERSION=5.0.0" - - rvm: 2.1 - env: "RAILS_VERSION=5.0.0" - allow_failures: - - env: "RAILS_VERSION=master" diff --git a/Gemfile b/Gemfile index c58d1c896..f08ca7961 100644 --- a/Gemfile +++ b/Gemfile @@ -2,22 +2,26 @@ source 'https://rubygems.org' gemspec -platforms :ruby do - gem 'sqlite3', '1.3.10' -end - platforms :jruby do gem 'activerecord-jdbcsqlite3-adapter' end version = ENV['RAILS_VERSION'] || 'default' +platforms :ruby do + if version.start_with?('4.2', '5.0') + gem 'sqlite3', '~> 1.3.13' + else + gem 'sqlite3', '~> 1.4' + end +end + case version when 'master' gem 'railties', { git: 'https://github.com/rails/rails.git' } gem 'arel', { git: 'https://github.com/rails/arel.git' } when 'default' - gem 'railties', '>= 5.0' + gem 'railties', '>= 6.0' else gem 'railties', "~> #{version}" end diff --git a/README.md b/README.md index 02c6b30cf..c494c9c74 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,19 @@ -# JSONAPI::Resources [![Gem Version](https://badge.fury.io/rb/jsonapi-resources.svg)](https://badge.fury.io/rb/jsonapi-resources) [![Build Status](https://secure.travis-ci.org/cerebris/jsonapi-resources.svg?branch=master)](http://travis-ci.org/cerebris/jsonapi-resources) [![Code Climate](https://codeclimate.com/github/cerebris/jsonapi-resources/badges/gpa.svg)](https://codeclimate.com/github/cerebris/jsonapi-resources) +# JSONAPI::Resources [![Gem Version](https://badge.fury.io/rb/jsonapi-resources.svg)](https://badge.fury.io/rb/jsonapi-resources) [![Build Status](https://secure.travis-ci.org/cerebris/jsonapi-resources.svg?branch=beta)](http://travis-ci.org/cerebris/jsonapi-resources) [![Code Climate](https://codeclimate.com/github/cerebris/jsonapi-resources/badges/gpa.svg)](https://codeclimate.com/github/cerebris/jsonapi-resources) [![Join the chat at https://gitter.im/cerebris/jsonapi-resources](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/cerebris/jsonapi-resources?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) -**NOTE:** This README is the documentation for `JSONAPI::Resources`. If you are viewing this at the -[project page on Github](https://github.com/cerebris/jsonapi-resources) you are viewing the documentation for the `master` -branch. This may contain information that is not relevant to the release you are using. Please see the README for the -[version](https://github.com/cerebris/jsonapi-resources/releases) you are using. +`JSONAPI::Resources`, or "JR", provides a framework for developing an API server that complies with the +[JSON:API](http://jsonapi.org/) specification. - --- - -`JSONAPI::Resources`, or "JR", provides a framework for developing a server that complies with the -[JSON API](http://jsonapi.org/) specification. - -Like JSON API itself, JR's design is focused on the resources served by an API. JR needs little more than a definition +Like JSON:API itself, JR's design is focused on the resources served by an API. JR needs little more than a definition of your resources, including their attributes and relationships, to make your server compliant with JSON API. -JR is designed to work with Rails 4.0+, and provides custom routes, controllers, and serializers. JR's resources may be +JR is designed to work with Rails 4.2+, and provides custom routes, controllers, and serializers. JR's resources may be backed by ActiveRecord models or by custom objects. -## Table of Contents +## Documentation -* [Demo App] (#demo-app) -* [Client Libraries] (#client-libraries) -* [Installation] (#installation) -* [Usage] (#usage) - * [Resources] (#resources) - * [JSONAPI::Resource] (#jsonapiresource) - * [Context] (#context) - * [Attributes] (#attributes) - * [Primary Key] (#primary-key) - * [Model Name] (#model-name) - * [Model Hints] (#model-hints) - * [Relationships] (#relationships) - * [Filters] (#filters) - * [Pagination] (#pagination) - * [Included relationships (side-loading resources)] (#included-relationships-side-loading-resources) - * [Resource meta] (#resource-meta) - * [Custom Links] (#custom-links) - * [Callbacks] (#callbacks) - * [Controllers] (#controllers) - * [Namespaces] (#namespaces) - * [Error Codes] (#error-codes) - * [Handling Exceptions] (#handling-exceptions) - * [Action Callbacks] (#action-callbacks) - * [Operation Processors] (#operation-processors) - * [Serializer] (#serializer) - * [Serializer options] (#serializer-options) - * [Formatting] (#formatting) - * [Key Format] (#key-format) - * [Routing] (#routing) - * [Nested Routes] (#nested-routes) - * [Authorization](#authorization) - * [Resource Caching] (#resource-caching) - * [Caching Caveats] (#caching-caveats) -* [Configuration] (#configuration) -* [Contributing] (#contributing) -* [License] (#license) +Full documentation can be found at [http://jsonapi-resources.com](http://jsonapi-resources.com), including the [v0.9 beta Guide](http://jsonapi-resources.com/v0.9/guide/) specific to this version. ## Demo App @@ -63,8 +21,8 @@ We have a simple demo app, called [Peeps](https://github.com/cerebris/peeps), av ## Client Libraries -JSON API maintains a (non-verified) listing of [client libraries](http://jsonapi.org/implementations/#client-libraries) -which *should* be compatible with JSON API compliant server implementations such as JR. +JSON:API maintains a (non-verified) listing of [client libraries](http://jsonapi.org/implementations/#client-libraries) +which *should* be compatible with JSON:API compliant server implementations such as JR. ## Installation @@ -80,2071 +38,7 @@ Or install it yourself as: $ gem install jsonapi-resources -## Usage - -### Resources - -Resources define the public interface to your API. A resource defines which attributes are exposed, as well as -relationships to other resources. - -Resource definitions should by convention be placed in a directory under app named resources, `app/resources`. The file name should be the single underscored name of the model that backs the resource with `_resource.rb` appended. For example, -a `Contact` model's resource should have a class named `ContactResource` defined in a file named `contact_resource.rb`. - -#### JSONAPI::Resource - -Resources must be derived from `JSONAPI::Resource`, or a class that is itself derived from `JSONAPI::Resource`. - -For example: - -```ruby -class ContactResource < JSONAPI::Resource -end -``` - -A jsonapi-resource generator is available -``` -rails generate jsonapi:resource contact -``` - -##### Abstract Resources - -Resources that are not backed by a model (purely used as base classes for other resources) should be declared as -abstract. - -Because abstract resources do not expect to be backed by a model, they won't attempt to discover the model class -or any of its relationships. - -```ruby -class BaseResource < JSONAPI::Resource - abstract - - has_one :creator -end - -class ContactResource < BaseResource -end -``` - -##### Immutable Resources - -Resources that are immutable should be declared as such with the `immutable` method. Immutable resources will only -generate routes for `index`, `show` and `show_relationship`. - -###### Immutable for Readonly - -Some resources are read-only and are not to be modified through the API. Declaring a resource as immutable prevents -creation of routes that allow modification of the resource. - -###### Immutable Heterogeneous Collections - -Immutable resources can be used as the basis for a heterogeneous collection. Resources in heterogeneous collections can -still be mutated through their own type-specific endpoints. - -```ruby -class VehicleResource < JSONAPI::Resource - immutable - - has_one :owner - attributes :make, :model, :serial_number -end - -class CarResource < VehicleResource - attributes :drive_layout - has_one :driver -end - -class BoatResource < VehicleResource - attributes :length_at_water_line - has_one :captain -end - -# routes - jsonapi_resources :vehicles - jsonapi_resources :cars - jsonapi_resources :boats - -``` - -In the above example vehicles are immutable. A call to `/vehicles` or `/vehicles/1` will return vehicles with types -of either `car` or `boat`. But calls to PUT or POST a `car` must be made to `/cars`. The rails models backing the above -code use Single Table Inheritance. - -#### Context - -Sometimes you will want to access things such as the current logged in user (and other state only available within your controllers) from within your resource classes. To make this state available to a resource class you need to put it into the context hash - this can be done via a `context` method on one of your controllers or across all controllers using ApplicationController. - -For example: - -```ruby -class ApplicationController < JSONAPI::ResourceController - def context - {current_user: current_user} - end -end - -# Specific resource controllers derive from ApplicationController -# and share its context -class PeopleController < ApplicationController - -end - -# Assuming you don't permit user_id (so the client won't assign a wrong user to own the object) -# you can ensure the current user is assigned the record by using the controller's context hash. -class PeopleResource < JSONAPI::Resource - before_save do - @model.user_id = context[:current_user].id if @model.new_record? - end -end -``` - -You can put things that affect serialization and resource configuration into the context. - -#### Attributes - -Any of a resource's attributes that are accessible must be explicitly declared. Single attributes can be declared using -the `attribute` method, and multiple attributes can be declared with the `attributes` method on the resource class. - -For example: - -```ruby -class ContactResource < JSONAPI::Resource - attribute :name_first - attributes :name_last, :email, :twitter -end -``` - -This resource has 4 defined attributes: `name_first`, `name_last`, `email`, `twitter`, as well as the automatically -defined attributes `id` and `type`. By default these attributes must exist on the model that is handled by the resource. - -A resource object wraps a Ruby object, usually an `ActiveModel` record, which is available as the `@model` variable. -This allows a resource's methods to access the underlying model. - -For example, a computed attribute for `full_name` could be defined as such: - -```ruby -class ContactResource < JSONAPI::Resource - attributes :name_first, :name_last, :email, :twitter - attribute :full_name - - def full_name - "#{@model.name_first}, #{@model.name_last}" - end -end -``` - -##### Attribute Delegation - -Normally resource attributes map to an attribute on the model of the same name. Using the `delegate` option allows a resource -attribute to map to a differently named model attribute. For example: - -```ruby -class ContactResource < JSONAPI::Resource - attribute :name_first, delegate: :first_name - attribute :name_last, delegate: :last_name -end -``` - -##### Fetchable Attributes - -By default all attributes are assumed to be fetchable. The list of fetchable attributes can be filtered by overriding -the `fetchable_fields` method. - -Here's an example that prevents guest users from seeing the `email` field: - -```ruby -class AuthorResource < JSONAPI::Resource - attributes :name, :email - model_name 'Person' - has_many :posts - - def fetchable_fields - if (context[:current_user].guest) - super - [:email] - else - super - end - end -end -``` - -Context flows through from the controller to the resource and can be used to control the attributes based on the -current user (or other value). - -##### Creatable and Updatable Attributes - -By default all attributes are assumed to be updatable and creatable. To prevent some attributes from being accepted by -the `update` or `create` methods, override the `self.updatable_fields` and `self.creatable_fields` methods on a resource. - -This example prevents `full_name` from being set: - -```ruby -class ContactResource < JSONAPI::Resource - attributes :name_first, :name_last, :full_name - - def full_name - "#{@model.name_first}, #{@model.name_last}" - end - - def self.updatable_fields(context) - super - [:full_name] - end - - def self.creatable_fields(context) - super - [:full_name] - end -end -``` - -The `context` is not by default used by the `ResourceController`, but may be used if you override the controller methods. -By using the context you have the option to determine the creatable and updatable fields based on the user. - -##### Sortable Attributes - -JR supports [sorting primary resources by multiple sort criteria](http://jsonapi.org/format/#fetching-sorting). - -By default all attributes are assumed to be sortable. To prevent some attributes from being sortable, override the -`self.sortable_fields` method on a resource. - -Here's an example that prevents sorting by post's `body`: - -```ruby -class PostResource < JSONAPI::Resource - attributes :title, :body - - def self.sortable_fields(context) - super(context) - [:body] - end -end -``` - -JR also supports sorting primary resources by fields on relationships. - -Here's an example of sorting books by the author name: - -```ruby -class Book < ActiveRecord::Base - belongs_to :author -end - -class Author < ActiveRecord::Base - has_many :books -end - -class BookResource < JSONAPI::Resource - attributes :title, :body - - def self.sortable_fields(context) - super(context) << :"author.name" - end -end -``` -The request will look something like: -``` -GET /books?include=author&sort=author.name -``` - -###### Default sorting - -By default JR sorts ascending on the `id` of the primary resource, unless the request specifies an alternate sort order. -To override this you may override the `self.default_sort` on a `resource`. `default_sort` should return an array of -`sort_param` hashes. A `sort_param` hash contains a `field` and a `direction`, with `direction` being either `:asc` or -`:desc`. - -For example: - -```ruby - def self.default_sort - [{field: 'name_last', direction: :desc}, {field: 'name_first', direction: :desc}] - end -``` - -##### Attribute Formatting - -Attributes can have a `Format`. By default all attributes use the default formatter. If an attribute has the `format` -option set the system will attempt to find a formatter based on this name. In the following example the `last_login_time` -will be returned formatted to a certain time zone: - -```ruby -class PersonResource < JSONAPI::Resource - attributes :name, :email - attribute :last_login_time, format: :date_with_timezone -end -``` - -The system will lookup a value formatter named `DateWithTimezoneValueFormatter` and will use this when serializing and -updating the attribute. See the [Value Formatters](#value-formatters) section for more details. - -##### Flattening a Rails relationship - -It is possible to flatten Rails relationships into attributes by using getters and setters. This can become handy if a relation needs to be created alongside the creation of the main object which can be the case if there is a bi-directional presence validation. For example: - -```ruby -# Given Models -class Person < ActiveRecord::Base - has_many :spoken_languages - validates :name, :email, :spoken_languages, presence: true -end - -class SpokenLanguage < ActiveRecord::Base - belongs_to :person, inverse_of: :spoken_languages - validates :person, :language_code, presence: true -end - -# Resource with getters and setter -class PersonResource < JSONAPI::Resource - attributes :name, :email, :spoken_languages - - # Getter - def spoken_languages - @model.spoken_languages.pluck(:language_code) - end - - # Setter (because spoken_languages needed for creation) - def spoken_languages=(new_spoken_language_codes) - @model.spoken_languages.destroy_all - new_spoken_language_codes.each do |new_lang_code| - @model.spoken_languages.build(language_code: new_lang_code) - end - end -end -``` - -#### Primary Key - -Resources are always represented using a key of `id`. The resource will interrogate the model to find the primary key. -If the underlying model does not use `id` as the primary key _and_ does not support the `primary_key` method you -must use the `primary_key` method to tell the resource which field on the model to use as the primary key. **Note:** -this _must_ be the actual primary key of the model. - -By default only integer values are allowed for primary key. To change this behavior you can set the `resource_key_type` -configuration option: - -```ruby -JSONAPI.configure do |config| - # Allowed values are :integer(default), :uuid, :string, or a proc - config.resource_key_type = :uuid -end -``` - -##### Override key type on a resource - -You can override the default resource key type on a per-resource basis by calling `key_type` in the resource class, -with the same allowed values as the `resource_key_type` configuration option. - -```ruby -class ContactResource < JSONAPI::Resource - attribute :id - attributes :name_first, :name_last, :email, :twitter - key_type :uuid -end -``` - -##### Custom resource key validators - -If you need more control over the key, you can override the #verify_key method on your resource, or set a lambda that -accepts key and context arguments in `config/initializers/jsonapi_resources.rb`: - -```ruby -JSONAPI.configure do |config| - config.resource_key_type = -> (key, context) { key && String(key) } -end -``` - -#### Model Name - -The name of the underlying model is inferred from the Resource name. It can be overridden by use of the `model_name` -method. For example: - -```ruby -class AuthorResource < JSONAPI::Resource - attribute :name - model_name 'Person' - has_many :posts -end -``` - -#### Model Hints - -Resource instances are created from model records. The determination of the correct resource type is performed using a -simple rule based on the model's name. The name is used to find a resource in the same module (as the originating -resource) that matches the name. This usually works quite well, however it can fail when model names do not match -resource names. It can also fail when using namespaced models. In this case a `model_hint` can be created to map model -names to resources. For example: - -```ruby -class AuthorResource < JSONAPI::Resource - attribute :name - model_name 'Person' - model_hint model: Commenter, resource: :special_person - - has_many :posts - has_many :commenters -end -``` - -Note that when `model_name` is set a corresponding `model_hint` is also added. This can be skipped by using the -`add_model_hint` option set to false. For example: - -```ruby -class AuthorResource < JSONAPI::Resource - model_name 'Legacy::Person', add_model_hint: false -end -``` - -Model hints inherit from parent resources, but are not global in scope. The `model_hint` method accepts `model` and -`resource` named parameters. `model` takes an ActiveRecord class or class name (defaults to the model name), and -`resource` takes a resource type or a resource class (defaults to the current resource's type). - -#### Relationships - -Related resources need to be specified in the resource. These may be declared with the `relationship` or the `has_one` -and the `has_many` methods. - -Here's a simple example using the `relationship` method where a post has a single author and an author can have many -posts: - -```ruby -class PostResource < JSONAPI::Resource - attributes :title, :body - - relationship :author, to: :one -end -``` - -And the corresponding author: - -```ruby -class AuthorResource < JSONAPI::Resource - attribute :name - - relationship :posts, to: :many -end -``` - -And here's the equivalent resources using the `has_one` and `has_many` methods: - -```ruby -class PostResource < JSONAPI::Resource - attributes :title, :body - - has_one :author -end -``` - -And the corresponding author: - -```ruby -class AuthorResource < JSONAPI::Resource - attribute :name - - has_many :posts -end -``` - -##### Options - -The relationship methods (`relationship`, `has_one`, and `has_many`) support the following options: - - * `class_name` - a string specifying the underlying class for the related resource. Defaults to the `class_name` property on the underlying model. - * `foreign_key` - the method on the resource used to fetch the related resource. Defaults to `_id` for has_one and `_ids` for has_many relationships. - * `acts_as_set` - allows the entire set of related records to be replaced in one operation. Defaults to false if not set. - * `polymorphic` - set to true to identify relationships that are polymorphic. - * `relation_name` - the name of the relation to use on the model. A lambda may be provided which allows conditional selection of the relation based on the context. - * `always_include_linkage_data` - if set to true, the relationship includes linkage data. Defaults to false if not set. - * `eager_load_on_include` - if set to false, will not include this relationship in join SQL when requested via an include. You usually want to leave this on, but it will break 'relationships' which are not active record, for example if you want to expose a tree using the `ancestry` gem or similar, or the SQL query becomes too large to handle. Defaults to true if not set. - -`to_one` relationships support the additional option: - * `foreign_key_on` - defaults to `:self`. To indicate that the foreign key is on the related resource specify `:related`. - -`to_many` relationships support the additional option: - * `reflect` - defaults to `true`. To indicate that updates to the relationship are performed on the related resource, if relationship reflection is turned on. See [Configuration] (#configuration) - -Examples: - -```ruby -class CommentResource < JSONAPI::Resource - attributes :body - has_one :post - has_one :author, class_name: 'Person' - has_many :tags, acts_as_set: true -end - -class ExpenseEntryResource < JSONAPI::Resource - attributes :cost, :transaction_date - - has_one :currency, class_name: 'Currency', foreign_key: 'currency_code' - has_one :employee -end - -class TagResource < JSONAPI::Resource - attributes :name - has_one :taggable, polymorphic: true -end -``` - -```ruby -class BookResource < JSONAPI::Resource - - # Only book_admins may see unapproved comments for a book. Using - # a lambda to select the correct relation on the model - has_many :book_comments, relation_name: -> (options = {}) { - context = options[:context] - current_user = context ? context[:current_user] : nil - - unless current_user && current_user.book_admin - :approved_book_comments - else - :book_comments - end - } - ... -end -``` - -The polymorphic relationship will require the resource and controller to exist, although routing to them will cause an -error. - -```ruby -class TaggableResource < JSONAPI::Resource; end -class TaggablesController < JSONAPI::ResourceController; end -``` - -#### Filters - -Filters for locating objects of the resource type are specified in the resource definition. Single filters can be -declared using the `filter` method, and multiple filters can be declared with the `filters` method on the resource -class. - -For example: - -```ruby -class ContactResource < JSONAPI::Resource - attributes :name_first, :name_last, :email, :twitter - - filter :id - filters :name_first, :name_last -end -``` - -Then a request could pass in a filter for example `http://example.com/contacts?filter[name_last]=Smith` and the system -will find all people where the last name exactly matches Smith. - -##### Default Filters - -A default filter may be defined for a resource using the `default` option on the `filter` method. This default is used -unless the request overrides this value. - -For example: - -```ruby - class CommentResource < JSONAPI::Resource - attributes :body, :status - has_one :post - has_one :author - - filter :status, default: 'published,pending' -end -``` - -The default value is used as if it came from the request. - -##### Applying Filters - -You may customize how a filter behaves by supplying a callable to the `:apply` option. This callable will be used to -apply that filter. The callable is passed the `records`, which is an `ActiveRecord::Relation`, the `value`, and an -`_options` hash. It is expected to return an `ActiveRecord::Relation`. - -Note: When a filter is not supplied a `verify` callable to modify the `value` that the `apply` callable receives, -`value` defaults to an array of the string values provided to the filter parameter. - -This example shows how you can implement different approaches for different filters. - -```ruby -# When given the following parameter:'filter[visibility]': 'public' - -filter :visibility, apply: ->(records, value, _options) { - records.where('users.publicly_visible = ?', value[0] == 'public') -} -``` - -If you omit the `apply` callable the filter will be applied as `records.where(filter => value)`. - -Note: It is also possible to override the `self.apply_filter` method, though this approach is now deprecated: - -```ruby -def self.apply_filter(records, filter, value, options) - case filter - when :last_name, :first_name, :name - if value.is_a?(Array) - value.each do |val| - records = records.where(_model_class.arel_table[filter].matches(val)) - end - records - else - records.where(_model_class.arel_table[filter].matches(value)) - end - else - super(records, filter, value) - end -end -``` - -##### Verifying Filters - -Because filters typically come straight from the request, it's prudent to verify their values. To do so, provide a -callable to the `verify` option. This callable will be passed the `value` and the `context`. Verify should return the -verified value, which may be modified. - -```ruby - filter :ids, - verify: ->(values, context) { - verify_keys(values, context) - values - }, - apply: ->(records, value, _options) { - records.where('id IN (?)', value) - } -``` - -```ruby -# A more complex example, showing how to filter for any overlap between the -# value array and the possible_ids, using both verify and apply callables. - - filter :possible_ids, - verify: ->(values, context) { - values.map {|value| value.to_i} - }, - apply: ->(records, value, _options) { - records.where('possible_ids && ARRAY[?]', value) - } -``` - -##### Finders - -Basic finding by filters is supported by resources. This is implemented in the `find` and `find_by_key` finder methods. -Currently this is implemented for `ActiveRecord` based resources. The finder methods rely on the `records` method to get -an `ActiveRecord::Relation` relation. It is therefore possible to override `records` to affect the three find related -methods. - -###### Customizing base records for finder methods - -If you need to change the base records on which `find` and `find_by_key` operate, you can override the `records` method -on the resource class. - -For example to allow a user to only retrieve his own posts you can do the following: - -```ruby -class PostResource < JSONAPI::Resource - attributes :title, :body - - def self.records(options = {}) - context = options[:context] - context[:current_user].posts - end -end -``` - -When you create a relationship, a method is created to fetch record(s) for that relationship, using the relation name -for the relationship. - -```ruby -class PostResource < JSONAPI::Resource - has_one :author - has_many :comments - - # def record_for_author - # relationship = self.class._relationship(:author) - # relation_name = relationship.relation_name(context: @context) - # records_for(relation_name) - # end - - # def records_for_comments - # relationship = self.class._relationship(:comments) - # relation_name = relationship.relation_name(context: @context) - # records_for(relation_name) - # end -end - -``` - -For example, you may want to raise an error if the user is not authorized to view the related records. See the next -section for additional details on raising errors. - -```ruby -class BaseResource < JSONAPI::Resource - def records_for(relation_name) - context = options[:context] - records = _model.public_send(relation_name) - - unless context[:current_user].can_view?(records) - raise NotAuthorizedError - end - - records - end -end -``` - -###### Raising Errors - -Inside the finder methods (like `records_for`) or inside of resource callbacks -(like `before_save`) you can `raise` an error to halt processing. JSONAPI::Resources -has some built in errors that will return appropriate error codes. By -default any other error that you raise will return a `500` status code -for a general internal server error. - -To return useful error codes that represent application errors you -should set the `exception_class_whitelist` config variable, and then you -should use the Rails `rescue_from` macro to render a status code. - -For example, this config setting allows the `NotAuthorizedError` to bubble up out of -JSONAPI::Resources and into your application. - -```ruby -# config/initializer/jsonapi-resources.rb -JSONAPI.configure do |config| - config.exception_class_whitelist = [NotAuthorizedError] -end -``` - -Handling the error and rendering the appropriate code is now the responsibility of the -application and could be handled like this: - -```ruby -class ApiController < ApplicationController - rescue_from NotAuthorizedError, with: :reject_forbidden_request - def reject_forbidden_request - render json: {error: 'Forbidden'}, :status => 403 - end -end -``` - - -###### Applying Filters - -The `apply_filter` method is called to apply each filter to the `Arel` relation. You may override this method to gain -control over how the filters are applied to the `Arel` relation. - -This example shows how you can implement different approaches for different filters. - -```ruby -def self.apply_filter(records, filter, value, options) - case filter - when :visibility - records.where('users.publicly_visible = ?', value == :public) - when :last_name, :first_name, :name - if value.is_a?(Array) - value.each do |val| - records = records.where(_model_class.arel_table[filter].matches(val)) - end - records - else - records.where(_model_class.arel_table[filter].matches(value)) - end - else - super(records, filter, value) - end -end -``` - - -###### Applying Sorting - -You can override the `apply_sort` method to gain control over how the sorting is done. This may be useful in case you'd -like to base the sorting on variables in your context. - -Example: - -```ruby -def self.apply_sort(records, order_options, context = {}) - if order_options.has?(:trending) - records = records.order_by_trending_scope - order_options - [:trending] - end - - super(records, order_options, context) -end -``` - - -###### Override finder methods - -Finally if you have more complex requirements for finding you can override the `find` and `find_by_key` methods on the -resource class. - -Here's an example that defers the `find` operation to a `current_user` set on the `context` option: - -```ruby -class AuthorResource < JSONAPI::Resource - attribute :name - model_name 'Person' - has_many :posts - - filter :name - - def self.find(filters, options = {}) - context = options[:context] - authors = context[:current_user].find_authors(filters) - - return authors.map do |author| - self.new(author, context) - end - end -end -``` - -#### Pagination - -Pagination is performed using a `paginator`, which is a class responsible for parsing the `page` request parameters and -applying the pagination logic to the results. - -##### Paginators - -`JSONAPI::Resource` supports several pagination methods by default, and allows you to implement a custom system if the -defaults do not meet your needs. - -###### Paged Paginator - -The `paged` `paginator` returns results based on pages of a fixed size. Valid `page` parameters are `number` and `size`. -If `number` is omitted the first page is returned. If `size` is omitted the `default_page_size` from the configuration -settings is used. - -``` -GET /articles?page%5Bnumber%5D=10&page%5Bsize%5D=10 HTTP/1.1 -Accept: application/vnd.api+json -``` - -###### Offset Paginator - -The `offset` `paginator` returns results based on an offset from the beginning of the resultset. Valid `page` parameters -are `offset` and `limit`. If `offset` is omitted a value of 0 will be used. If `limit` is omitted the `default_page_size` -from the configuration settings is used. - -``` -GET /articles?page%5Blimit%5D=10&page%5Boffset%5D=10 HTTP/1.1 -Accept: application/vnd.api+json -``` - -###### Custom Paginators - -Custom `paginators` can be used. These should derive from `Paginator`. The `apply` method takes a `relation` and -`order_options` and is expected to return a `relation`. The `initialize` method receives the parameters from the `page` -request parameters. It is up to the paginator author to parse and validate these parameters. - -For example, here is a very simple single record at a time paginator: - -```ruby -class SingleRecordPaginator < JSONAPI::Paginator - def initialize(params) - # param parsing and validation here - @page = params.to_i - end - - def apply(relation, order_options) - relation.offset(@page).limit(1) - end -end -``` - -##### Paginator Configuration - -The default paginator, which will be used for all resources, is set using `JSONAPI.configure`. For example, in your -`config/initializers/jsonapi_resources.rb`: - -```ruby -JSONAPI.configure do |config| - # built in paginators are :none, :offset, :paged - config.default_paginator = :offset - - config.default_page_size = 10 - config.maximum_page_size = 20 -end -``` - -If no `default_paginator` is configured, pagination will be disabled by default. - -Paginators can also be set at the resource-level, which will override the default setting. This is done using the -`paginator` method: - -```ruby -class BookResource < JSONAPI::Resource - attribute :title - attribute :isbn - - paginator :offset -end -``` - -To disable pagination in a resource, specify `:none` for `paginator`. - -#### Included relationships (side-loading resources) - -JR supports [request include params](http://jsonapi.org/format/#fetching-includes) out of the box, for side loading related resources. - -Here's an example from the spec: - -``` -GET /articles/1?include=comments HTTP/1.1 -Accept: application/vnd.api+json -``` - -Will get you the following payload by default: - -``` -{ - "data": { - "type": "articles", - "id": "1", - "attributes": { - "title": "JSON API paints my bikeshed!" - }, - "links": { - "self": "http://example.com/articles/1" - }, - "relationships": { - "comments": { - "links": { - "self": "http://example.com/articles/1/relationships/comments", - "related": "http://example.com/articles/1/comments" - }, - "data": [ - { "type": "comments", "id": "5" }, - { "type": "comments", "id": "12" } - ] - } - } - }, - "included": [{ - "type": "comments", - "id": "5", - "attributes": { - "body": "First!" - }, - "links": { - "self": "http://example.com/comments/5" - } - }, { - "type": "comments", - "id": "12", - "attributes": { - "body": "I like XML better" - }, - "links": { - "self": "http://example.com/comments/12" - } - }] -} -``` - -Note: When passing `include` and `fields` params together, relationships not included in the `fields` parameter will not be serialized. This will have the side effect of not serializing the included resources. To ensure the related resources are properly side loaded specify them in the `fields`, like `fields[posts]=comments,title&include=comments`. - -#### Resource Meta - -Meta information can be included for each resource using the meta method in the resource declaration. For example: - -```ruby -class BookResource < JSONAPI::Resource - attribute :title - attribute :isbn - - def meta(options) - { - copyright: 'API Copyright 2015 - XYZ Corp.', - computed_copyright: options[:serialization_options][:copyright], - last_updated_at: _model.updated_at - } - end -end - -``` - -The `meta` method will be called for each resource instance. Override the `meta` method on a resource class to control -the meta information for the resource. If a non empty hash is returned from `meta` this will be serialized. The `meta` -method is called with an `options` hash. The `options` hash will contain the following: - - * `:serializer` -> the serializer instance - * `:serialization_options` -> the contents of the `serialization_options` method on the controller. - -#### Custom Links - -Custom links can be included for each resource by overriding the `custom_links` method. If a non empty hash is returned from `custom_links`, it will be merged with the default links hash containing the resource's `self` link. The `custom_links` method is called with the same `options` hash used by for [resource meta information](#resource-meta). The `options` hash contains the following: - - * `:serializer` -> the serializer instance - * `:serialization_options` -> the contents of the `serialization_options` method on the controller. - -For example: - -```ruby -class CityCouncilMeeting < JSONAPI::Resource - attribute :title, :location, :approved - - def custom_links(options) - { minutes: options[:serializer].link_builder.self_link(self) + "/minutes" } - end -end -``` - -This will create a custom link with the key `minutes`, which will be merged with the default `self` link, like so: - -```json -{ - "data": [ - { - "id": "1", - "type": "cityCouncilMeetings", - "links": { - "self": "http://city.gov/api/city-council-meetings/1", - "minutes": "http://city.gov/api/city-council-meetings/1/minutes" - }, - "attributes": {...} - }, - //... - ] -} -``` - -Of course, the `custom_links` method can include logic to include links only when relevant: - -````ruby -class CityCouncilMeeting < JSONAPI::Resource - attribute :title, :location, :approved - - delegate :approved?, to: :model - - def custom_links(options) - extra_links = {} - if approved? - extra_links[:minutes] = options[:serializer].link_builder.self_link(self) + "/minutes" - end - extra_links - end -end -``` - -It's also possibly to suppress the default `self` link by returning a hash with `{self: nil}`: - -````ruby -class Selfless < JSONAPI::Resource - def custom_links(options) - {self: nil} - end -end -``` - -#### Callbacks - -`ActiveSupport::Callbacks` is used to provide callback functionality, so the behavior is very similar to what you may be -used to from `ActiveRecord`. - -For example, you might use a callback to perform authorization on your resource before an action. - -```ruby -class BaseResource < JSONAPI::Resource - before_create :authorize_create - - def authorize_create - # ... - end -end -``` - -The types of supported callbacks are: -- `before` -- `after` -- `around` - -##### `JSONAPI::Resource` Callbacks - -Callbacks can be defined for the following `JSONAPI::Resource` events: - -- `:create` -- `:update` -- `:remove` -- `:save` -- `:create_to_many_link` -- `:replace_to_many_links` -- `:create_to_one_link` -- `:replace_to_one_link` -- `:remove_to_many_link` -- `:remove_to_one_link` -- `:replace_fields` - -###### Relationship Reflection - -By default updates to relationships only invoke callbacks on the primary -Resource. By setting the `use_relationship_reflection` [Configuration] (#configuration) option -updates to `has_many` relationships will occur on the related resource, triggering -callbacks on both resources. - -##### `JSONAPI::Processor` Callbacks - -Callbacks can also be defined for `JSONAPI::Processor` events: -- `:operation`: Any individual operation. -- `:find`: A `find` operation is being processed. -- `:show`: A `show` operation is being processed. -- `:show_relationship`: A `show_relationship` operation is being processed. -- `:show_related_resource`: A `show_related_resource` operation is being processed. -- `:show_related_resources`: A `show_related_resources` operation is being processed. -- `:create_resource`: A `create_resource` operation is being processed. -- `:remove_resource`: A `remove_resource` operation is being processed. -- `:replace_fields`: A `replace_fields` operation is being processed. -- `:replace_to_one_relationship`: A `replace_to_one_relationship` operation is being processed. -- `:create_to_many_relationship`: A `create_to_many_relationship` operation is being processed. -- `:replace_to_many_relationship`: A `replace_to_many_relationship` operation is being processed. -- `:remove_to_many_relationship`: A `remove_to_many_relationship` operation is being processed. -- `:remove_to_one_relationship`: A `remove_to_one_relationship` operation is being processed. - -See [Operation Processors] (#operation-processors) for details on using OperationProcessors - -##### `JSONAPI::OperationsProcessor` Callbacks (a removed feature) - -Note: The `JSONAPI::OperationsProcessor` has been removed and replaced with the `JSONAPI::OperationDispatcher` -and `Processor` classes per resource. The callbacks have been renamed and moved to the -`Processor`s, with the exception of the `operations` callback which is now on the controller. - -### Controllers - -There are two ways to implement a controller for your resources. Either derive from `ResourceController` or import -the `ActsAsResourceController` module. - -##### ResourceController - -`JSONAPI::Resources` provides a class, `ResourceController`, that can be used as the base class for your controllers. -`ResourceController` supports `index`, `show`, `create`, `update`, and `destroy` methods. Just deriving your controller -from `ResourceController` will give you a fully functional controller. - -For example: - -```ruby -class PeopleController < JSONAPI::ResourceController - -end -``` - -Of course you are free to extend this as needed and override action handlers or other methods. - -A jsonapi-controller generator is avaliable - -``` -rails generate jsonapi:controller contact -``` - -###### ResourceControllerMetal - -`JSONAPI::Resources` also provides an alternative class to `ResourceController` called `ResourceControllerMetal`. -In order to provide a lighter weight controller option this strips the controller down to just the classes needed -to work with `JSONAPI::Resources`. - -For example: - -```ruby -class PeopleController < JSONAPI::ResourceControllerMetal - -end -``` - -Note: This may not provide all of the expected controller capabilities if you are using additional gems such as DoorKeeper. - -###### Serialization Options - -Additional options can be passed to the serializer using the `serialization_options` method. - -For example: - -```ruby -class ApplicationController < JSONAPI::ResourceController - def serialization_options - {copyright: 'Copyright 2015'} - end -end -``` - -These `serialization_options` are passed to the `meta` method used to generate resource `meta` values. - -##### ActsAsResourceController - -`JSONAPI::Resources` also provides a module, `JSONAPI::ActsAsResourceController`. You can include this module to -mix in all the features of `ResourceController` into your existing controller class. - -For example: - -```ruby -class PostsController < ActionController::Base - include JSONAPI::ActsAsResourceController -end -``` - -#### Namespaces - -JSONAPI::Resources supports namespacing of controllers and resources. With namespacing you can version your API. - -If you namespace your controller it will require a namespaced resource. - -In the following example we have a `resource` that isn't namespaced, and one that has now been namespaced. There are -slight differences between the two resources, as might be seen in a new version of an API: - -```ruby -class PostResource < JSONAPI::Resource - attribute :title - attribute :body - attribute :subject - - has_one :author, class_name: 'Person' - has_one :section - has_many :tags, acts_as_set: true - has_many :comments, acts_as_set: false - def subject - @model.title - end - - filters :title, :author, :tags, :comments - filter :id -end - -... - -module Api - module V1 - class PostResource < JSONAPI::Resource - # V1 replaces the non-namespaced resource - # V1 no longer supports tags and now calls author 'writer' - attribute :title - attribute :body - attribute :subject - - has_one :writer, foreign_key: 'author_id' - has_one :section - has_many :comments, acts_as_set: false - - def subject - @model.title - end - - filters :writer - end - - class WriterResource < JSONAPI::Resource - attributes :name, :email - model_name 'Person' - has_many :posts - - filter :name - end - end -end -``` - -The following controllers are used: - -```ruby -class PostsController < JSONAPI::ResourceController -end - -module Api - module V1 - class PostsController < JSONAPI::ResourceController - end - end -end -``` - -You will also need to namespace your routes: - -```ruby -Rails.application.routes.draw do - - jsonapi_resources :posts - - namespace :api do - namespace :v1 do - jsonapi_resources :posts - end - end -end -``` - -When a namespaced `resource` is used, any related `resources` must also be in the same namespace. - -#### Error codes - -Error codes are provided for each error object returned, based on the error. These errors are: - -```ruby -module JSONAPI - VALIDATION_ERROR = '100' - INVALID_RESOURCE = '101' - FILTER_NOT_ALLOWED = '102' - INVALID_FIELD_VALUE = '103' - INVALID_FIELD = '104' - PARAM_NOT_ALLOWED = '105' - PARAM_MISSING = '106' - INVALID_FILTER_VALUE = '107' - COUNT_MISMATCH = '108' - KEY_ORDER_MISMATCH = '109' - KEY_NOT_INCLUDED_IN_URL = '110' - INVALID_INCLUDE = '112' - RELATION_EXISTS = '113' - INVALID_SORT_CRITERIA = '114' - INVALID_LINKS_OBJECT = '115' - TYPE_MISMATCH = '116' - INVALID_PAGE_OBJECT = '117' - INVALID_PAGE_VALUE = '118' - INVALID_FIELD_FORMAT = '119' - INVALID_FILTERS_SYNTAX = '120' - SAVE_FAILED = '121' - FORBIDDEN = '403' - RECORD_NOT_FOUND = '404' - NOT_ACCEPTABLE = '406' - UNSUPPORTED_MEDIA_TYPE = '415' - LOCKED = '423' -end -``` - -These codes can be customized in your app by creating an initializer to override any or all of the codes. - -In addition textual error codes can be returned by setting the configuration option `use_text_errors = true`. For -example: - -```ruby -JSONAPI.configure do |config| - config.use_text_errors = true -end -``` - - -#### Handling Exceptions - -By default, all exceptions raised downstream from a resource controller will be caught, logged, and a ```500 Internal Server Error``` will be rendered. Exceptions can be whitelisted in the config to pass through the handler and be caught manually, or you can pass a callback from a resource controller to insert logic into the rescue block without interrupting the control flow. This can be particularly useful for additional logging or monitoring without the added work of rendering responses. - -Pass a block, refer to controller class methods, or both. Note that methods must be defined as class methods on a controller and accept one parameter, which is passed the exception object that was rescued. - -```ruby - class ApplicationController < JSONAPI::ResourceController - - on_server_error :first_callback - - #or - - # on_server_error do |error| - #do things - #end - - def self.first_callback(error) - #env["airbrake.error_id"] = notify_airbrake(error) - end - end - -``` - -#### Action Callbacks - -##### verify_content_type_header - -By default, when controllers extend functionalities from `jsonapi-resources`, the `ActsAsResourceController#verify_content_type_header` -method will be triggered before `create`, `update`, `create_relationship` and `update_relationship` actions. This method is responsible -for checking if client's request corresponds to the correct media type required by [JSON API](http://jsonapi.org/format/#content-negotiation-clients): `application/vnd.api+json`. - -In case you need to check the media type for custom actions, just make sure to call the method in your controller's `before_action`: - -```ruby -class UsersController < JSONAPI::ResourceController - before_action :verify_content_type_header, only: [:auth] - - def auth - # some crazy auth code goes here - end -end -``` - -### Operation Processors - -Operation Processors are called to perform the operation(s) that make up a request. The controller (through the `OperationDispatcher`), creates an `OperatorProcessor` to handle each operation. The processor is created based on the resource name, including the namespace. If a processor does not exist for a resource (namespace matters) the default operation processor is used instead. The default processor can be changed by a configuration setting. - -Defining a custom `Processor` allows for custom callback handling of each operation type for each resource type. For example: - -```ruby -class Api::V4::BookProcessor < JSONAPI::Processor - after_find do - unless @result.is_a?(JSONAPI::ErrorsOperationResult) - @result.meta[:total_records_found] = @result.record_count - end - end -end -``` - -This simple example uses a callback to update the result's meta property with the total count of records (a redundant -feature only for example purposes), if there wasn't an error in the operation. It is also possible to override the -`find` method as well if a different behavior is needed, for example: - -```ruby -class Api::V4::BookProcessor < JSONAPI::Processor - def find - filters = params[:filters] - include_directives = params[:include_directives] - sort_criteria = params.fetch(:sort_criteria, []) - paginator = params[:paginator] - - verified_filters = resource_klass.verify_filters(filters, context) - resource_records = resource_klass.find(verified_filters, - context: context, - include_directives: include_directives, - sort_criteria: sort_criteria, - paginator: paginator) - - page_options = {} - # Overriding the default record count logic to always include it in the meta - #if (JSONAPI.configuration.top_level_meta_include_record_count || - # (paginator && paginator.class.requires_record_count)) - page_options[:record_count] = resource_klass.find_count(verified_filters, - context: context, - include_directives: include_directives) - #end -end -``` - -Note: The authors of this gem expect the most common uses cases to be handled using the callbacks. It is likely that the -internal functionality of the operation processing methods will change, at least for several revisions. Effort will be -made to call this out in release notes. You have been warned. - -### Serializer - -The `ResourceSerializer` can be used to serialize a resource into JSON API compliant JSON. `ResourceSerializer` must be - initialized with the primary resource type it will be serializing. `ResourceSerializer` has a `serialize_to_hash` - method that takes a resource instance or array of resource instances to serialize. For example: - -```ruby -post = Post.find(1) -JSONAPI::ResourceSerializer.new(PostResource).serialize_to_hash(PostResource.new(post, nil)) -``` - -Note: If your resource needs to access to state from a context hash, make sure to pass the context hash as the second argument of -the resource class new method. For example: - -```ruby -post = Post.find(1) -context = { current_user: current_user } -JSONAPI::ResourceSerializer.new(PostResource).serialize_to_hash(PostResource.new(post, context)) -``` - -This returns results like this: - -```json -{ - "data": { - "type": "posts", - "id": "1", - "links": { - "self": "http://example.com/posts/1" - }, - "attributes": { - "title": "New post", - "body": "A body!!!", - "subject": "New post" - }, - "relationships": { - "section": { - "links": { - "self": "http://example.com/posts/1/relationships/section", - "related": "http://example.com/posts/1/section" - }, - "data": null - }, - "author": { - "links": { - "self": "http://example.com/posts/1/relationships/author", - "related": "http://example.com/posts/1/author" - }, - "data": { - "type": "people", - "id": "1" - } - }, - "tags": { - "links": { - "self": "http://example.com/posts/1/relationships/tags", - "related": "http://example.com/posts/1/tags" - } - }, - "comments": { - "links": { - "self": "http://example.com/posts/1/relationships/comments", - "related": "http://example.com/posts/1/comments" - } - } - } - } -} -``` - -#### Serializer options - -The `ResourceSerializer` can be initialized with some optional parameters: - -##### `include` - -An array of resources. Nested resources can be specified with dot notation. - - *Purpose*: determines which objects will be side loaded with the source objects in an `included` section - - *Example*: ```include: ['comments','author','comments.tags','author.posts']``` - -##### `fields` - -A hash of resource types and arrays of fields for each resource type. - - *Purpose*: determines which fields are serialized for a resource type. This encompasses both attributes and - relationship ids in the links section for a resource. Fields are global for a resource type. - - *Example*: ```fields: { people: [:email, :comments], posts: [:title, :author], comments: [:body, :post]}``` - -```ruby -post = Post.find(1) -include_resources = ['comments','author','comments.tags','author.posts'] - -JSONAPI::ResourceSerializer.new(PostResource, include: include_resources, - fields: { - people: [:email, :comments], - posts: [:title, :author], - tags: [:name], - comments: [:body, :post] - } -).serialize_to_hash(PostResource.new(post, nil)) -``` - -#### Formatting - -JR by default uses some simple rules to format (and unformat) an attribute for (de-)serialization. Strings and Integers are output to JSON -as is, and all other values have `.to_s` applied to them. This outputs something in all cases, but it is certainly not -correct for every situation. - -If you want to change the way an attribute is (de-)serialized you have a couple of ways. The simplest method is to create a -getter (and setter) method on the resource which overrides the attribute and apply the (un-)formatting there. For example: - -```ruby -class PersonResource < JSONAPI::Resource - attributes :name, :email, :last_login_time - - # Setter example - def email=(new_email) - @model.email = new_email.downcase - end - - # Getter example - def last_login_time - @model.last_login_time.in_time_zone(@context[:current_user].time_zone).to_s - end -end -``` - -This is simple to implement for a one off situation, but not for example if you want to apply the same formatting rules -to all DateTime fields in your system. Another issue is the attribute on the resource will always return a formatted -response, whether you want it or not. - -##### Value Formatters - -To overcome the above limitations JR uses Value Formatters. Value Formatters allow you to control the way values are -handled for an attribute. The `format` can be set per attribute as it is declared in the resource. For example: - -```ruby -class PersonResource < JSONAPI::Resource - attributes :name, :email, :spoken_languages - attribute :last_login_time, format: :date_with_utc_timezone - - # Getter/Setter for spoken_languages ... -end -``` - -A Value formatter has a `format` and an `unformat` method. Here's the base ValueFormatter and DefaultValueFormatter for -reference: - -```ruby -module JSONAPI - class ValueFormatter < Formatter - class << self - def format(raw_value) - super(raw_value) - end - - def unformat(value) - super(value) - end - ... - end - end -end - -class DefaultValueFormatter < JSONAPI::ValueFormatter - class << self - def format(raw_value) - case raw_value - when Date, Time, DateTime, ActiveSupport::TimeWithZone, BigDecimal - # Use the as_json methods added to various base classes by ActiveSupport - return raw_value.as_json - else - return raw_value - end - end - end -end -``` - -You can also create your own Value Formatter. Value Formatters must be named with the `format` name followed by -`ValueFormatter`, i.e. `DateWithUTCTimezoneValueFormatter` and derive from `JSONAPI::ValueFormatter`. It is -recommended that you create a directory for your formatters, called `formatters`. - -The `format` method is called by the `ResourceSerializer` as is serializing a resource. The format method takes the -`raw_value` parameter. `raw_value` is the value as read from the model. - -The `unformat` method is called when processing the request. Each incoming attribute (except `links`) are run through -the `unformat` method. The `unformat` method takes a `value`, which is the value as it comes in on the -request. This allows you process the incoming value to alter its state before it is stored in the model. - -###### Use a Different Default Value Formatter - -Another way to handle formatting is to set a different default value formatter. This will affect all attributes that do -not have a `format` set. You can do this by overriding the `default_attribute_options` method for a resource (or a base -resource for a system wide change). - -```ruby - def self.default_attribute_options - {format: :my_default} - end -``` - -and - -```ruby -class MyDefaultValueFormatter < DefaultValueFormatter - class << self - def format(raw_value) - case raw_value - when DateTime - return super(raw_value.in_time_zone('UTC')) - else - return super - end - end - end -end -``` - -This way all DateTime values will be formatted to display in the UTC timezone. - -#### Key Format - -By default JR uses dasherized keys as per the -[JSON API naming recommendations](http://jsonapi.org/recommendations/#naming). This can be changed by specifying a -different key formatter. - -For example, to use camel cased keys with an initial lowercase character (JSON's default) create an initializer and add -the following: - -```ruby -JSONAPI.configure do |config| - # built in key format options are :underscored_key, :camelized_key and :dasherized_key - config.json_key_format = :camelized_key -end -``` - -This will cause the serializer to use the `CamelizedKeyFormatter`. You can also create your own `KeyFormatter`, for -example: - -```ruby -class UpperCamelizedKeyFormatter < JSONAPI::KeyFormatter - class << self - def format(key) - super.camelize(:upper) - end - end -end -``` - -You would specify this in `JSONAPI.configure` as `:upper_camelized`. - -### Routing - -JR has a couple of helper methods available to assist you with setting up routes. - -##### `jsonapi_resources` - -Like `resources` in `ActionDispatch`, `jsonapi_resources` provides resourceful routes mapping between HTTP verbs and URLs -and controller actions. This will also setup mappings for relationship URLs for a resource's relationships. For example: - -```ruby -Rails.application.routes.draw do - jsonapi_resources :contacts - jsonapi_resources :phone_numbers -end -``` - -gives the following routes - -``` - Prefix Verb URI Pattern Controller#Action -contact_relationships_phone_numbers GET /contacts/:contact_id/relationships/phone-numbers(.:format) contacts#show_relationship {:relationship=>"phone_numbers"} - POST /contacts/:contact_id/relationships/phone-numbers(.:format) contacts#create_relationship {:relationship=>"phone_numbers"} - DELETE /contacts/:contact_id/relationships/phone-numbers/:keys(.:format) contacts#destroy_relationship {:relationship=>"phone_numbers"} - contact_phone_numbers GET /contacts/:contact_id/phone-numbers(.:format) phone_numbers#get_related_resources {:relationship=>"phone_numbers", :source=>"contacts"} - contacts GET /contacts(.:format) contacts#index - POST /contacts(.:format) contacts#create - contact GET /contacts/:id(.:format) contacts#show - PATCH /contacts/:id(.:format) contacts#update - PUT /contacts/:id(.:format) contacts#update - DELETE /contacts/:id(.:format) contacts#destroy - phone_number_relationships_contact GET /phone-numbers/:phone_number_id/relationships/contact(.:format) phone_numbers#show_relationship {:relationship=>"contact"} - PUT|PATCH /phone-numbers/:phone_number_id/relationships/contact(.:format) phone_numbers#update_relationship {:relationship=>"contact"} - DELETE /phone-numbers/:phone_number_id/relationships/contact(.:format) phone_numbers#destroy_relationship {:relationship=>"contact"} - phone_number_contact GET /phone-numbers/:phone_number_id/contact(.:format) contacts#get_related_resource {:relationship=>"contact", :source=>"phone_numbers"} - phone_numbers GET /phone-numbers(.:format) phone_numbers#index - POST /phone-numbers(.:format) phone_numbers#create - phone_number GET /phone-numbers/:id(.:format) phone_numbers#show - PATCH /phone-numbers/:id(.:format) phone_numbers#update - PUT /phone-numbers/:id(.:format) phone_numbers#update - DELETE /phone-numbers/:id(.:format) phone_numbers#destroy -``` - -##### `jsonapi_resource` - -Like `jsonapi_resources`, but for resources you lookup without an id. - -#### Nested Routes - -By default nested routes are created for getting related resources and manipulating relationships. You can control the -nested routes by passing a block into `jsonapi_resources` or `jsonapi_resource`. An empty block will not create -any nested routes. For example: - -```ruby -Rails.application.routes.draw do - jsonapi_resources :contacts do - end -end -``` - -gives routes that are only related to the primary resource, and none for its relationships: - -``` - Prefix Verb URI Pattern Controller#Action - contacts GET /contacts(.:format) contacts#index - POST /contacts(.:format) contacts#create - contact GET /contacts/:id(.:format) contacts#show - PATCH /contacts/:id(.:format) contacts#update - PUT /contacts/:id(.:format) contacts#update - DELETE /contacts/:id(.:format) contacts#destroy -``` - -To manually add in the nested routes you can use the `jsonapi_links`, `jsonapi_related_resources` and -`jsonapi_related_resource` inside the block. Or, you can add the default set of nested routes using the -`jsonapi_relationships` method. For example: - -```ruby -Rails.application.routes.draw do - jsonapi_resources :contacts do - jsonapi_relationships - end -end -``` - -###### `jsonapi_links` - -You can add relationship routes in with `jsonapi_links`, for example: - -```ruby -Rails.application.routes.draw do - jsonapi_resources :contacts do - jsonapi_links :phone_numbers - end -end -``` - -Gives the following routes: - -``` -contact_relationships_phone_numbers GET /contacts/:contact_id/relationships/phone-numbers(.:format) contacts#show_relationship {:relationship=>"phone_numbers"} - POST /contacts/:contact_id/relationships/phone-numbers(.:format) contacts#create_relationship {:relationship=>"phone_numbers"} - DELETE /contacts/:contact_id/relationships/phone-numbers/:keys(.:format) contacts#destroy_relationship {:relationship=>"phone_numbers"} - contacts GET /contacts(.:format) contacts#index - POST /contacts(.:format) contacts#create - contact GET /contacts/:id(.:format) contacts#show - PATCH /contacts/:id(.:format) contacts#update - PUT /contacts/:id(.:format) contacts#update - DELETE /contacts/:id(.:format) contacts#destroy - -``` - -The new routes allow you to show, create and destroy the relationships between resources. - -###### `jsonapi_related_resources` - -Creates a nested route to GET the related has_many resources. For example: - -```ruby -Rails.application.routes.draw do - jsonapi_resources :contacts do - jsonapi_related_resources :phone_numbers - end -end - -``` - -gives the following routes: - -``` - Prefix Verb URI Pattern Controller#Action -contact_phone_numbers GET /contacts/:contact_id/phone-numbers(.:format) phone_numbers#get_related_resources {:relationship=>"phone_numbers", :source=>"contacts"} - contacts GET /contacts(.:format) contacts#index - POST /contacts(.:format) contacts#create - contact GET /contacts/:id(.:format) contacts#show - PATCH /contacts/:id(.:format) contacts#update - PUT /contacts/:id(.:format) contacts#update - DELETE /contacts/:id(.:format) contacts#destroy - -``` - -A single additional route was created to allow you GET the phone numbers through the contact. - -###### `jsonapi_related_resource` - -Like `jsonapi_related_resources`, but for has_one related resources. - -```ruby -Rails.application.routes.draw do - jsonapi_resources :phone_numbers do - jsonapi_related_resource :contact - end -end -``` - -gives the following routes: - -``` - Prefix Verb URI Pattern Controller#Action -phone_number_contact GET /phone-numbers/:phone_number_id/contact(.:format) contacts#get_related_resource {:relationship=>"contact", :source=>"phone_numbers"} - phone_numbers GET /phone-numbers(.:format) phone_numbers#index - POST /phone-numbers(.:format) phone_numbers#create - phone_number GET /phone-numbers/:id(.:format) phone_numbers#show - PATCH /phone-numbers/:id(.:format) phone_numbers#update - PUT /phone-numbers/:id(.:format) phone_numbers#update - DELETE /phone-numbers/:id(.:format) phone_numbers#destroy - -``` - -### Authorization - -Currently `json-api-resources` doesn't come with built-in primitives for authorization. However multiple users of the framework have come up with different approaches, check out: - -- [jsonapi-authorization](https://github.com/venuu/jsonapi-authorization) -- [pundit-resources](https://github.com/togglepro/pundit-resources) - -Refer to the comments/discussion [here](https://github.com/cerebris/jsonapi-resources/issues/16#issuecomment-222438975) for the differences between approaches - -### Resource Caching - -To improve the response time of GET requests, JR can cache the generated JSON fragments for -Resources which are suitable. First, set `config.resource_cache` to an ActiveSupport cache store: - -```ruby -JSONAPI.configure do |config| - config.resource_cache = Rails.cache -end -``` - -Then, on each Resource you want to cache, call the `caching` method: - -```ruby -class PostResource < JSONAPI::Resource - caching -end -``` - -See the caveats section below for situations where you might not want to enable caching on particular -Resources. - -The Resource model must also have a field that is updated whenever any of the model's data changes. -The default Rails timestamps handle this pretty well, and the default cache key field is `updated_at` for this reason. -You can use an alternate field (which you are then responsible for updating) by calling the `cache_field` method: - -```ruby -class PostResource < JSONAPI::Resource - caching - cache_field :change_counter - - before_save do - if self.change_counter.nil? - self.change_counter = 1 - elsif self.changed? - self.change_counter += 1 - end - end - - after_touch do - update_attribute(:change_counter, self.change_counter + 1) - end -end -``` - -If context affects the content of the serialized result, you must define a class method `attribute_caching_context` on that Resource, which should return a different value for contexts that produce different results. In particular, if the `meta` or `fetchable_fields` methods, or any method providing the actual content of an attribute, changes depending on context, then you must provide `attribute_caching_context`. The actual value it -returns isn't important, what matters is that the value must be different if any relevant part of the context is different. - -```ruby -class PostResource < JSONAPI::Resource - caching - - attributes :title, :body, :secret_field - - def fetchable_fields - return super if context.user.superuser? - return super - [:secret_field] - end - - def meta - if context.user.can_see_creation_dates? - return { created: _model.created_at } - else - return {} - end - end - - def self.attribute_caching_context(context) - return { - admin: context.user.superuser?, - creation_date_viewer: context.user.can_see_creation_dates? - } - end -end -``` - -#### Caching Caveats - -* Models for cached Resources must update a cache key field whenever their data changes. However, if you bypass Rails and e.g. alter the database row directly without changing the `updated_at` field, the cached entry for that resource will be inaccurate. Also, `updated_at` provides a narrow race condition window; if a resource is updated twice in the same second, it's possible that only the first update will be cached. If you're concerned about this, you will need to find a way to make sure your models' cache fields change on every update, e.g. by using a unique random value or a monotonic clock. -* If an attribute's value is affected by related resources, e.g. the `spoken_languages` example above, then changes to the related resource must also touch the cache field on the resource that uses it. The `belongs_to` relation in ActiveRecord provides a `:touch` option for this purpose. -* JR does not actively clean the cache, so you must use an ActiveSupport cache that automatically expires old entries, or you will leak resources. The MemoryCache built in to Rails does this by default, but other caches will have to be configured with an `:expires_in` option and/or a cache-specific clearing mechanism. -* Similarly, if you make a substantial code change that affects a lot of serialized representations (i.e. changing the way an attribute is shown), you'll have to clear out all relevant cache entries yourself. The simplest way to do this is to run `JSONAPI.configuration.resource_cache.clear` from the console. You do not have to do this after merely adding or removing attributes; only changes that affect the actual content of attributes require manual cache clearing. -* If resource caching is enabled at all, then custom relationship methods on any resource might not always be used, even resources that are not cached. For example, if you manually define a `comments` method or `records_for_comments` method on a Resource that `has_many :comments`, you cannot expect it to be used when caching is enabled, even if you never call `caching` on that particular Resource. Instead, you should use relationship name lambdas. -* The above also applies to custom `find` or `find_by_key` methods. Instead, if you are using resource caching anywhere in your app, try overriding the `find_records` method to return an appropriate `ActiveRecord::Relation`. -* Caching relies on ActiveRecord features; you cannot enable caching on resources based on non-AR models, e.g. PORO objects or singleton resources. -* If you write a custom `ResourceSerializer` which takes new options, then you must define `config_description` to include those options if they might impact the serialized value: - -```ruby -class MySerializer < JSONAPI::ResourceSerializer - def initialize(primary_resource_klass, options = {}) - @my_special_option = options.delete(:my_special_option) - super - end - - def config_description(resource_klass) - super.merge({my_special_option: @my_special_option}) - end -end -``` - -## Configuration - -JR has a few configuration options. Some have already been mentioned above. To set configuration options create an -initializer and add the options you wish to set. All options have defaults, so you only need to set the options that -are different. The default options are shown below. - -If using custom classes (such as a CustomPaginator), be sure to require them at the top of the initializer before usage. - -```ruby -JSONAPI.configure do |config| - #:underscored_key, :camelized_key, :dasherized_key, or custom - config.json_key_format = :dasherized_key - - #:underscored_route, :camelized_route, :dasherized_route, or custom - config.route_format = :dasherized_route - - # Default Processor, used if a resource specific one is not defined. - # Must be a class - config.default_processor_klass = JSONAPI::Processor - - #:integer, :uuid, :string, or custom (provide a proc) - config.resource_key_type = :integer - - # optional request features - config.allow_include = true - config.allow_sort = true - config.allow_filter = true - - # How to handle unsupported attributes and relationships which are provided in the request - # true => raises an error - # false => allows the request to continue. A warning is included in the response meta data indicating - # the fields which were ignored. This is useful for client libraries which send extra parameters. - config.raise_if_parameters_not_allowed = true - - # :none, :offset, :paged, or a custom paginator name - config.default_paginator = :none - - # Output pagination links at top level - config.top_level_links_include_pagination = true - - config.default_page_size = 10 - config.maximum_page_size = 20 - - # Output the record count in top level meta data for find operations - config.top_level_meta_include_record_count = false - config.top_level_meta_record_count_key = :record_count - - # For :paged paginators, the following are also available - config.top_level_meta_include_page_count = false - config.top_level_meta_page_count_key = :page_count - - config.use_text_errors = false - - # List of classes that should not be rescued by the operations processor. - # For example, if you use Pundit for authorization, you might - # raise a Pundit::NotAuthorizedError at some point during operations - # processing. If you want to use Rails' `rescue_from` macro to - # catch this error and render a 403 status code, you should add - # the `Pundit::NotAuthorizedError` to the `exception_class_whitelist`. - # Subclasses of the whitelisted classes will also be whitelisted. - config.exception_class_whitelist = [] - - # If enabled, will override configuration option `exception_class_whitelist` - # and whitelist all exceptions. - config.whitelist_all_exceptions = false - - # Resource Linkage - # Controls the serialization of resource linkage for non compound documents - # NOTE: always_include_to_many_linkage_data is not currently implemented - config.always_include_to_one_linkage_data = false - - # Relationship reflection invokes the related resource when updates - # are made to a has_many relationship. By default relationship_reflection - # is turned off because it imposes a small performance penalty. - config.use_relationship_reflection = false - - # Allows transactions for creating and updating records - # Set this to false if your backend does not support transactions (e.g. Mongodb) - config.allow_transactions = true - - # Formatter Caching - # Set to false to disable caching of string operations on keys and links. - # Note that unlike the resource cache, formatter caching is always done - # internally in-memory and per-thread; no ActiveSupport::Cache is used. - config.cache_formatters = true - - # Resource cache - # An ActiveSupport::Cache::Store or similar, used by Resources with caching enabled. - # Set to `nil` (the default) to disable caching, or to `Rails.cache` to use the - # Rails cache store. - config.resource_cache = nil - - # Default resource cache field - # On Resources with caching enabled, this field will be used to check for out-of-date - # cache entries, unless overridden on a specific Resource. Defaults to "updated_at". - config.default_resource_cache_field = :updated_at - - # Resource cache digest function - # Provide a callable that returns a unique value for string inputs with - # low chance of collision. The default is SHA256 base64. - config.resource_cache_digest_function = Digest::SHA2.new.method(:base64digest) - - # Resource cache usage reporting - # Optionally provide a callable which JSONAPI will call with information about cache - # performance. Should accept three arguments: resource name, hits count, misses count. - config.resource_cache_usage_report_function = nil -end -``` +**For further usage see the [v0.9 beta Guide](http://jsonapi-resources.com/v0.9/guide/)** ## Contributing @@ -2154,16 +48,6 @@ end 4. Push to the branch (`git push origin my-new-feature`) 5. Create a new Pull Request -### Running Tests - -To run the tests for this project: - -- `rake test` or `bundle exec rake test` - -To run a single test: - -- `bundle exec ruby -I test test/controllers/controller_test.rb -n test_type_formatting` - ## License Copyright 2014-2016 Cerebris Corporation. MIT License (see LICENSE for details). diff --git a/jsonapi-resources.gemspec b/jsonapi-resources.gemspec index 3f031d173..fe55e9f10 100644 --- a/jsonapi-resources.gemspec +++ b/jsonapi-resources.gemspec @@ -19,13 +19,15 @@ Gem::Specification.new do |spec| spec.require_paths = ['lib'] spec.required_ruby_version = '>= 2.1' - spec.add_development_dependency 'bundler', '~> 1.5' + spec.add_development_dependency 'bundler' spec.add_development_dependency 'rake' spec.add_development_dependency 'minitest' spec.add_development_dependency 'minitest-spec-rails' spec.add_development_dependency 'simplecov' spec.add_development_dependency 'pry' spec.add_development_dependency 'concurrent-ruby-ext' + spec.add_development_dependency 'database_cleaner' + spec.add_development_dependency 'memory_profiler' spec.add_dependency 'activerecord', '>= 4.1' spec.add_dependency 'railties', '>= 4.1' spec.add_dependency 'concurrent-ruby' diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index 6dd0e3f2e..0d4ebb677 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -125,7 +125,8 @@ def resource_serializer base_url: base_url, key_formatter: key_formatter, route_formatter: route_formatter, - serialization_options: serialization_options + serialization_options: serialization_options, + controller: self ) @resource_serializer end @@ -139,8 +140,8 @@ def resource_klass_name end def verify_content_type_header - unless request.content_type == JSONAPI::MEDIA_TYPE - fail JSONAPI::Exceptions::UnsupportedMediaTypeError.new(request.content_type) + unless request.media_type == JSONAPI::MEDIA_TYPE + fail JSONAPI::Exceptions::UnsupportedMediaTypeError.new(request.media_type) end true rescue => e @@ -233,8 +234,8 @@ def render_results(operation_results) render_options[:body] = JSON.generate(content) end - render_options[:location] = content[:data]["links"][:self] if ( - response_doc.status == :created && content[:data].class != Array + render_options[:location] = content[:data]["links"]["self"] if ( + response_doc.status == :created && content[:data].class != Array && content[:data]["links"] ) # For whatever reason, `render` ignores :status and :content_type when :body is set. diff --git a/lib/jsonapi/cached_resource_fragment.rb b/lib/jsonapi/cached_resource_fragment.rb index 8b37cc053..67df1d9a5 100644 --- a/lib/jsonapi/cached_resource_fragment.rb +++ b/lib/jsonapi/cached_resource_fragment.rb @@ -6,14 +6,14 @@ def self.fetch_fragments(resource_klass, serializer, context, cache_ids) context_b64 = JSONAPI.configuration.resource_cache_digest_function.call(context_json) context_key = "ATTR-CTX-#{context_b64.gsub("/", "_")}" - results = self.lookup(resource_klass, serializer_config_key, context_key, cache_ids) + results = self.lookup(resource_klass, serializer_config_key, context, context_key, cache_ids) miss_ids = results.select{|k,v| v.nil? }.keys unless miss_ids.empty? find_filters = {resource_klass._primary_key => miss_ids.uniq} find_options = {context: context} resource_klass.find(find_filters, find_options).each do |resource| - (id, cr) = write(resource_klass, resource, serializer, serializer_config_key, context_key) + (id, cr) = write(resource_klass, resource, serializer, serializer_config_key, context, context_key) results[id] = cr end end @@ -29,28 +29,16 @@ def self.fetch_fragments(resource_klass, serializer, context, cache_ids) return results end - def self.from_cache_value(resource_klass, h) - new( - resource_klass, - h.fetch(:id), - h.fetch(:type), - h.fetch(:fetchable), - h.fetch(:rels, nil), - h.fetch(:links, nil), - h.fetch(:attrs, nil), - h.fetch(:meta, nil) - ) - end - - attr_reader :resource_klass, :id, :type, :fetchable_fields, :relationships, + attr_reader :resource_klass, :id, :type, :context, :fetchable_fields, :relationships, :links_json, :attributes_json, :meta_json, :preloaded_fragments - def initialize(resource_klass, id, type, fetchable_fields, relationships, + def initialize(resource_klass, id, type, context, fetchable_fields, relationships, links_json, attributes_json, meta_json) @resource_klass = resource_klass @id = id @type = type + @context = context @fetchable_fields = Set.new(fetchable_fields) # Relationships left uncompiled because we'll often want to insert included ids on retrieval @@ -76,9 +64,14 @@ def to_cache_value } end + def to_real_resource + rs = Resource.resource_for(self.type).find_by_keys([self.id], {context: self.context}) + return rs.try(:first) + end + private - def self.lookup(resource_klass, serializer_config_key, context_key, cache_ids) + def self.lookup(resource_klass, serializer_config_key, context, context_key, cache_ids) type = resource_klass._type keys = cache_ids.map do |(id, cache_key)| @@ -89,20 +82,35 @@ def self.lookup(resource_klass, serializer_config_key, context_key, cache_ids) return keys.each_with_object({}) do |key, hash| (_, id, _, _) = key if hits.has_key?(key) - hash[id] = self.from_cache_value(resource_klass, hits[key]) + hash[id] = self.from_cache_value(resource_klass, context, hits[key]) else hash[id] = nil end end end - def self.write(resource_klass, resource, serializer, serializer_config_key, context_key) + def self.from_cache_value(resource_klass, context, h) + new( + resource_klass, + h.fetch(:id), + h.fetch(:type), + context, + h.fetch(:fetchable), + h.fetch(:rels, nil), + h.fetch(:links, nil), + h.fetch(:attrs, nil), + h.fetch(:meta, nil) + ) + end + + def self.write(resource_klass, resource, serializer, serializer_config_key, context, context_key) (id, cache_key) = resource.cache_id json = serializer.object_hash(resource) # No inclusions passed to object_hash cr = self.new( resource_klass, json['id'], json['type'], + context, resource.fetchable_fields, json['relationships'], json['links'], diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index 4601bb351..2cf5e2501 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -8,6 +8,7 @@ class Configuration :resource_key_type, :route_format, :raise_if_parameters_not_allowed, + :warn_on_missing_routes, :allow_include, :allow_sort, :allow_filter, @@ -32,7 +33,8 @@ class Configuration :resource_cache, :default_resource_cache_field, :resource_cache_digest_function, - :resource_cache_usage_report_function + :resource_cache_usage_report_function, + :default_exclude_links def initialize #:underscored_key, :camelized_key, :dasherized_key, or custom @@ -51,6 +53,8 @@ def initialize self.raise_if_parameters_not_allowed = true + self.warn_on_missing_routes = true + # :none, :offset, :paged, or a custom paginator name self.default_paginator = :none @@ -131,6 +135,12 @@ def initialize # Optionally provide a callable which JSONAPI will call with information about cache # performance. Should accept three arguments: resource name, hits count, misses count. self.resource_cache_usage_report_function = nil + + # Global configuration for links exclusion + # Controls whether to generate links like `self`, `related` with all the resources + # and relationships. Accepts either `:default`, `:none`, or array containing the + # specific default links to exclude, which may be `:self` and `:related`. + self.default_exclude_links = :none end def cache_formatters=(bool) @@ -235,6 +245,8 @@ def default_processor_klass=(default_processor_klass) attr_writer :raise_if_parameters_not_allowed + attr_writer :warn_on_missing_routes + attr_writer :use_relationship_reflection attr_writer :resource_cache @@ -244,6 +256,8 @@ def default_processor_klass=(default_processor_klass) attr_writer :resource_cache_digest_function attr_writer :resource_cache_usage_report_function + + attr_writer :default_exclude_links end class << self diff --git a/lib/jsonapi/error.rb b/lib/jsonapi/error.rb index 41545c13e..fc39d93aa 100644 --- a/lib/jsonapi/error.rb +++ b/lib/jsonapi/error.rb @@ -15,7 +15,7 @@ def initialize(options = {}) @source = options[:source] @links = options[:links] - @status = Rack::Utils::SYMBOL_TO_STATUS_CODE[options[:status]].to_s + @status = Rack::Utils.status_code(options[:status]).to_s @meta = options[:meta] end diff --git a/lib/jsonapi/error_codes.rb b/lib/jsonapi/error_codes.rb index 290ee6189..35f309bc7 100644 --- a/lib/jsonapi/error_codes.rb +++ b/lib/jsonapi/error_codes.rb @@ -20,6 +20,7 @@ module JSONAPI INVALID_FILTERS_SYNTAX = '120' SAVE_FAILED = '121' INVALID_DATA_FORMAT = '122' + BAD_REQUEST = '400' FORBIDDEN = '403' RECORD_NOT_FOUND = '404' NOT_ACCEPTABLE = '406' diff --git a/lib/jsonapi/exceptions.rb b/lib/jsonapi/exceptions.rb index 2d220e756..6c44de76b 100644 --- a/lib/jsonapi/exceptions.rb +++ b/lib/jsonapi/exceptions.rb @@ -1,6 +1,16 @@ module JSONAPI module Exceptions class Error < RuntimeError + attr :error_object_overrides + + def initialize(error_object_overrides = {}) + @error_object_overrides = error_object_overrides + end + + def create_error_object(error_defaults) + JSONAPI::Error.new(error_defaults.merge(error_object_overrides)) + end + def errors # :nocov: raise NotImplementedError, "Subclass of Error must implement errors method" @@ -11,8 +21,9 @@ def errors class InternalServerError < Error attr_accessor :exception - def initialize(exception) + def initialize(exception, error_object_overrides = {}) @exception = exception + super(error_object_overrides) end def errors @@ -22,372 +33,430 @@ def errors meta[:backtrace] = exception.backtrace end - [JSONAPI::Error.new(code: JSONAPI::INTERNAL_SERVER_ERROR, - status: :internal_server_error, - title: I18n.t('jsonapi-resources.exceptions.internal_server_error.title', - default: 'Internal Server Error'), - detail: I18n.t('jsonapi-resources.exceptions.internal_server_error.detail', + [create_error_object(code: JSONAPI::INTERNAL_SERVER_ERROR, + status: :internal_server_error, + title: I18n.t('jsonapi-resources.exceptions.internal_server_error.title', default: 'Internal Server Error'), - meta: meta)] + detail: I18n.t('jsonapi-resources.exceptions.internal_server_error.detail', + default: 'Internal Server Error'), + meta: meta)] end end class InvalidResource < Error attr_accessor :resource - def initialize(resource) + + def initialize(resource, error_object_overrides = {}) @resource = resource + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_RESOURCE, - status: :bad_request, - title: I18n.t('jsonapi-resources.exceptions.invalid_resource.title', - default: 'Invalid resource'), - detail: I18n.t('jsonapi-resources.exceptions.invalid_resource.detail', - default: "#{resource} is not a valid resource.", resource: resource))] + [create_error_object(code: JSONAPI::INVALID_RESOURCE, + status: :bad_request, + title: I18n.t('jsonapi-resources.exceptions.invalid_resource.title', + default: 'Invalid resource'), + detail: I18n.t('jsonapi-resources.exceptions.invalid_resource.detail', + default: "#{resource} is not a valid resource.", resource: resource))] end end class RecordNotFound < Error attr_accessor :id - def initialize(id) + + def initialize(id, error_object_overrides = {}) @id = id + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::RECORD_NOT_FOUND, - status: :not_found, - title: I18n.translate('jsonapi-resources.exceptions.record_not_found.title', - default: 'Record not found'), - detail: I18n.translate('jsonapi-resources.exceptions.record_not_found.detail', - default: "The record identified by #{id} could not be found.", id: id))] + [create_error_object(code: JSONAPI::RECORD_NOT_FOUND, + status: :not_found, + title: I18n.translate('jsonapi-resources.exceptions.record_not_found.title', + default: 'Record not found'), + detail: I18n.translate('jsonapi-resources.exceptions.record_not_found.detail', + default: "The record identified by #{id} could not be found.", id: id))] end end class UnsupportedMediaTypeError < Error attr_accessor :media_type - def initialize(media_type) + + def initialize(media_type, error_object_overrides = {}) @media_type = media_type + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::UNSUPPORTED_MEDIA_TYPE, - status: :unsupported_media_type, - title: I18n.translate('jsonapi-resources.exceptions.unsupported_media_type.title', - default: 'Unsupported media type'), - detail: I18n.translate('jsonapi-resources.exceptions.unsupported_media_type.detail', - default: "All requests that create or update must use the '#{JSONAPI::MEDIA_TYPE}' Content-Type. This request specified '#{media_type}'.", - needed_media_type: JSONAPI::MEDIA_TYPE, - media_type: media_type))] + [create_error_object(code: JSONAPI::UNSUPPORTED_MEDIA_TYPE, + status: :unsupported_media_type, + title: I18n.translate('jsonapi-resources.exceptions.unsupported_media_type.title', + default: 'Unsupported media type'), + detail: I18n.translate('jsonapi-resources.exceptions.unsupported_media_type.detail', + default: "All requests that create or update must use the '#{JSONAPI::MEDIA_TYPE}' Content-Type. This request specified '#{media_type}'.", + needed_media_type: JSONAPI::MEDIA_TYPE, + media_type: media_type))] end end class NotAcceptableError < Error attr_accessor :media_type - def initialize(media_type) + def initialize(media_type, error_object_overrides = {}) @media_type = media_type + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::NOT_ACCEPTABLE, - status: :not_acceptable, - title: I18n.translate('jsonapi-resources.exceptions.not_acceptable.title', - default: 'Not acceptable'), - detail: I18n.translate('jsonapi-resources.exceptions.not_acceptable.detail', - default: "All requests must use the '#{JSONAPI::MEDIA_TYPE}' Accept without media type parameters. This request specified '#{media_type}'.", - needed_media_type: JSONAPI::MEDIA_TYPE, - media_type: media_type))] + [create_error_object(code: JSONAPI::NOT_ACCEPTABLE, + status: :not_acceptable, + title: I18n.translate('jsonapi-resources.exceptions.not_acceptable.title', + default: 'Not acceptable'), + detail: I18n.translate('jsonapi-resources.exceptions.not_acceptable.detail', + default: "All requests must use the '#{JSONAPI::MEDIA_TYPE}' Accept without media type parameters. This request specified '#{media_type}'.", + needed_media_type: JSONAPI::MEDIA_TYPE, + media_type: media_type))] end end class HasManyRelationExists < Error attr_accessor :id - def initialize(id) + + def initialize(id, error_object_overrides = {}) @id = id + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::RELATION_EXISTS, + [create_error_object(code: JSONAPI::RELATION_EXISTS, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.has_many_relation.title', + default: 'Relation exists'), + detail: I18n.translate('jsonapi-resources.exceptions.has_many_relation.detail', + default: "The relation to #{id} already exists.", + id: id))] + end + end + + class BadRequest < Error + def initialize(exception) + @exception = exception + end + + def errors + [JSONAPI::Error.new(code: JSONAPI::BAD_REQUEST, status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.has_many_relation.title', - default: 'Relation exists'), - detail: I18n.translate('jsonapi-resources.exceptions.has_many_relation.detail', - default: "The relation to #{id} already exists.", - id: id))] + title: I18n.translate('jsonapi-resources.exceptions.bad_request.title', + default: 'Bad Request'), + detail: I18n.translate('jsonapi-resources.exceptions.bad_request.detail', + default: @exception))] + end + end + + class InvalidRequestFormat < Error + def errors + [JSONAPI::Error.new(code: JSONAPI::BAD_REQUEST, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_request_format.title', + default: 'Bad Request'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_request_format.detail', + default: 'Request must be a hash'))] end end class ToManySetReplacementForbidden < Error def errors - [JSONAPI::Error.new(code: JSONAPI::FORBIDDEN, - status: :forbidden, - title: I18n.translate('jsonapi-resources.exceptions.to_many_set_replacement_forbidden.title', - default: 'Complete replacement forbidden'), - detail: I18n.translate('jsonapi-resources.exceptions.to_many_set_replacement_forbidden.detail', - default: 'Complete replacement forbidden for this relationship'))] + [create_error_object(code: JSONAPI::FORBIDDEN, + status: :forbidden, + title: I18n.translate('jsonapi-resources.exceptions.to_many_set_replacement_forbidden.title', + default: 'Complete replacement forbidden'), + detail: I18n.translate('jsonapi-resources.exceptions.to_many_set_replacement_forbidden.detail', + default: 'Complete replacement forbidden for this relationship'))] end end class InvalidFiltersSyntax < Error attr_accessor :filters - def initialize(filters) + + def initialize(filters, error_object_overrides = {}) @filters = filters + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_FILTERS_SYNTAX, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_filter_syntax.title', - default: 'Invalid filters syntax'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_filter_syntax.detail', - default: "#{filters} is not a valid syntax for filtering.", - filters: filters))] + [create_error_object(code: JSONAPI::INVALID_FILTERS_SYNTAX, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_filter_syntax.title', + default: 'Invalid filters syntax'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_filter_syntax.detail', + default: "#{filters} is not a valid syntax for filtering.", + filters: filters))] end end class FilterNotAllowed < Error attr_accessor :filter - def initialize(filter) + + def initialize(filter, error_object_overrides = {}) @filter = filter + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::FILTER_NOT_ALLOWED, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.filter_not_allowed.title', - default: 'Filter not allowed'), - detail: I18n.translate('jsonapi-resources.exceptions.filter_not_allowed.detail', - default: "#{filter} is not allowed.", filter: filter))] + [create_error_object(code: JSONAPI::FILTER_NOT_ALLOWED, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.filter_not_allowed.title', + default: 'Filter not allowed'), + detail: I18n.translate('jsonapi-resources.exceptions.filter_not_allowed.detail', + default: "#{filter} is not allowed.", filter: filter))] end end class InvalidFilterValue < Error attr_accessor :filter, :value - def initialize(filter, value) + + def initialize(filter, value, error_object_overrides = {}) @filter = filter @value = value + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_FILTER_VALUE, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_filter_value.title', - default: 'Invalid filter value'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_filter_value.detail', - default: "#{value} is not a valid value for #{filter}.", - value: value, filter: filter))] + [create_error_object(code: JSONAPI::INVALID_FILTER_VALUE, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_filter_value.title', + default: 'Invalid filter value'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_filter_value.detail', + default: "#{value} is not a valid value for #{filter}.", + value: value, filter: filter))] end end class InvalidFieldValue < Error attr_accessor :field, :value - def initialize(field, value) + + def initialize(field, value, error_object_overrides = {}) @field = field @value = value + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_FIELD_VALUE, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_field_value.title', - default: 'Invalid field value'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_field_value.detail', - default: "#{value} is not a valid value for #{field}.", - value: value, field: field))] + [create_error_object(code: JSONAPI::INVALID_FIELD_VALUE, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_field_value.title', + default: 'Invalid field value'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_field_value.detail', + default: "#{value} is not a valid value for #{field}.", + value: value, field: field))] end end class InvalidFieldFormat < Error def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_FIELD_FORMAT, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_field_format.title', - default: 'Invalid field format'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_field_format.detail', - default: 'Fields must specify a type.'))] + [create_error_object(code: JSONAPI::INVALID_FIELD_FORMAT, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_field_format.title', + default: 'Invalid field format'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_field_format.detail', + default: 'Fields must specify a type.'))] end end class InvalidDataFormat < Error def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_DATA_FORMAT, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_data_format.title', - default: 'Invalid data format'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_data_format.detail', - default: 'Data must be a hash.'))] + [create_error_object(code: JSONAPI::INVALID_DATA_FORMAT, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_data_format.title', + default: 'Invalid data format'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_data_format.detail', + default: 'Data must be a hash.'))] end end class InvalidLinksObject < Error def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_LINKS_OBJECT, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_links_object.title', - default: 'Invalid Links Object'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_links_object.detail', - default: 'Data is not a valid Links Object.'))] + [create_error_object(code: JSONAPI::INVALID_LINKS_OBJECT, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_links_object.title', + default: 'Invalid Links Object'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_links_object.detail', + default: 'Data is not a valid Links Object.'))] end end class TypeMismatch < Error attr_accessor :type - def initialize(type) + + def initialize(type, error_object_overrides = {}) @type = type + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::TYPE_MISMATCH, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.type_mismatch.title', - default: 'Type Mismatch'), - detail: I18n.translate('jsonapi-resources.exceptions.type_mismatch.detail', - default: "#{type} is not a valid type for this operation.", type: type))] + [create_error_object(code: JSONAPI::TYPE_MISMATCH, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.type_mismatch.title', + default: 'Type Mismatch'), + detail: I18n.translate('jsonapi-resources.exceptions.type_mismatch.detail', + default: "#{type} is not a valid type for this operation.", type: type))] end end class InvalidField < Error attr_accessor :field, :type - def initialize(type, field) + + def initialize(type, field, error_object_overrides = {}) @field = field @type = type + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_FIELD, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_field.title', - default: 'Invalid field'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_field.detail', - default: "#{field} is not a valid field for #{type}.", - field: field, type: type))] + [create_error_object(code: JSONAPI::INVALID_FIELD, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_field.title', + default: 'Invalid field'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_field.detail', + default: "#{field} is not a valid field for #{type}.", + field: field, type: type))] end end class InvalidInclude < Error attr_accessor :relationship, :resource - def initialize(resource, relationship) + + def initialize(resource, relationship, error_object_overrides = {}) @resource = resource @relationship = relationship + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_INCLUDE, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_include.title', - default: 'Invalid field'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_include.detail', - default: "#{relationship} is not a valid relationship of #{resource}", - relationship: relationship, resource: resource))] + [create_error_object(code: JSONAPI::INVALID_INCLUDE, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_include.title', + default: 'Invalid field'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_include.detail', + default: "#{relationship} is not a valid relationship of #{resource}", + relationship: relationship, resource: resource))] end end class InvalidSortCriteria < Error attr_accessor :sort_criteria, :resource - def initialize(resource, sort_criteria) + + def initialize(resource, sort_criteria, error_object_overrides = {}) @resource = resource @sort_criteria = sort_criteria + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_SORT_CRITERIA, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_sort_criteria.title', - default: 'Invalid sort criteria'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_sort_criteria.detail', - default: "#{sort_criteria} is not a valid sort criteria for #{resource}", - sort_criteria: sort_criteria, resource: resource))] + [create_error_object(code: JSONAPI::INVALID_SORT_CRITERIA, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_sort_criteria.title', + default: 'Invalid sort criteria'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_sort_criteria.detail', + default: "#{sort_criteria} is not a valid sort criteria for #{resource}", + sort_criteria: sort_criteria, resource: resource))] end end - class ParametersNotAllowed < Error - attr_accessor :params - def initialize(params) - @params = params + class ParameterNotAllowed < Error + attr_accessor :param + + def initialize(param, error_object_overrides = {}) + @param = param + super(error_object_overrides) end def errors - params.collect do |param| - JSONAPI::Error.new(code: JSONAPI::PARAM_NOT_ALLOWED, + [create_error_object(code: JSONAPI::PARAM_NOT_ALLOWED, status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.parameters_not_allowed.title', + title: I18n.translate('jsonapi-resources.exceptions.parameter_not_allowed.title', default: 'Param not allowed'), - detail: I18n.translate('jsonapi-resources.exceptions.parameters_not_allowed.detail', - default: "#{param} is not allowed.", param: param)) - - end + detail: I18n.translate('jsonapi-resources.exceptions.parameter_not_allowed.detail', + default: "#{param} is not allowed.", param: param))] end end class ParameterMissing < Error attr_accessor :param - def initialize(param) + + def initialize(param, error_object_overrides = {}) @param = param + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::PARAM_MISSING, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.parameter_missing.title', - default: 'Missing Parameter'), - detail: I18n.translate('jsonapi-resources.exceptions.parameter_missing.detail', - default: "The required parameter, #{param}, is missing.", param: param))] + [create_error_object(code: JSONAPI::PARAM_MISSING, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.parameter_missing.title', + default: 'Missing Parameter'), + detail: I18n.translate('jsonapi-resources.exceptions.parameter_missing.detail', + default: "The required parameter, #{param}, is missing.", param: param))] end end class KeyNotIncludedInURL < Error attr_accessor :key - def initialize(key) + + def initialize(key, error_object_overrides = {}) @key = key + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::KEY_NOT_INCLUDED_IN_URL, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.key_not_included_in_url.title', - default: 'Key is not included in URL'), - detail: I18n.translate('jsonapi-resources.exceptions.key_not_included_in_url.detail', - default: "The URL does not support the key #{key}", - key: key))] + [create_error_object(code: JSONAPI::KEY_NOT_INCLUDED_IN_URL, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.key_not_included_in_url.title', + default: 'Key is not included in URL'), + detail: I18n.translate('jsonapi-resources.exceptions.key_not_included_in_url.detail', + default: "The URL does not support the key #{key}", + key: key))] end end class MissingKey < Error def errors - [JSONAPI::Error.new(code: JSONAPI::KEY_ORDER_MISMATCH, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.missing_key.title', - default: 'A key is required'), - detail: I18n.translate('jsonapi-resources.exceptions.missing_key.detail', - default: 'The resource object does not contain a key.'))] + [create_error_object(code: JSONAPI::KEY_ORDER_MISMATCH, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.missing_key.title', + default: 'A key is required'), + detail: I18n.translate('jsonapi-resources.exceptions.missing_key.detail', + default: 'The resource object does not contain a key.'))] end end class RecordLocked < Error attr_accessor :message - def initialize(message) + + def initialize(message, error_object_overrides = {}) @message = message + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::LOCKED, - status: :locked, - title: I18n.translate('jsonapi-resources.exceptions.record_locked.title', - default: 'Locked resource'), - detail: "#{message}")] + [create_error_object(code: JSONAPI::LOCKED, + status: :locked, + title: I18n.translate('jsonapi-resources.exceptions.record_locked.title', + default: 'Locked resource'), + detail: "#{message}")] end end class ValidationErrors < Error - attr_reader :error_messages, :error_metadata, :resource_relationships + attr_reader :error_messages, :error_metadata, :resource_relationships, :resource_class - def initialize(resource) + def initialize(resource, error_object_overrides = {}) @error_messages = resource.model_error_messages @error_metadata = resource.validation_error_metadata + @resource_class = resource.class @resource_relationships = resource.class._relationships.keys @key_formatter = JSONAPI.configuration.key_formatter + super(error_object_overrides) end def format_key(key) @@ -403,20 +472,25 @@ def errors private def json_api_error(attr_key, message) - JSONAPI::Error.new(code: JSONAPI::VALIDATION_ERROR, - status: :unprocessable_entity, - title: message, - detail: "#{format_key(attr_key)} - #{message}", - source: { pointer: pointer(attr_key) }, - meta: metadata_for(attr_key, message)) + create_error_object(code: JSONAPI::VALIDATION_ERROR, + status: :unprocessable_entity, + title: message, + detail: detail(attr_key, message), + source: { pointer: pointer(attr_key) }, + meta: metadata_for(attr_key, message)) end def metadata_for(attr_key, message) return if error_metadata.nil? - error_metadata[attr_key] ? error_metadata[attr_key][message] : nil + error_metadata[attr_key] ? error_metadata[attr_key][message] : nil + end + + def detail(attr_key, message) + general_error?(attr_key) ? message : "#{format_key(attr_key)} - #{message}" end def pointer(attr_or_relationship_name) + return '/data' if general_error?(attr_or_relationship_name) formatted_attr_or_relationship_name = format_key(attr_or_relationship_name) if resource_relationships.include?(attr_or_relationship_name) "/data/relationships/#{formatted_attr_or_relationship_name}" @@ -424,65 +498,72 @@ def pointer(attr_or_relationship_name) "/data/attributes/#{formatted_attr_or_relationship_name}" end end + + def general_error?(attr_key) + attr_key.to_sym == :base && !resource_class._has_attribute?(attr_key) + end end class SaveFailed < Error def errors - [JSONAPI::Error.new(code: JSONAPI::SAVE_FAILED, - status: :unprocessable_entity, - title: I18n.translate('jsonapi-resources.exceptions.save_failed.title', - default: 'Save failed or was cancelled'), - detail: I18n.translate('jsonapi-resources.exceptions.save_failed.detail', - default: 'Save failed or was cancelled'))] + [create_error_object(code: JSONAPI::SAVE_FAILED, + status: :unprocessable_entity, + title: I18n.translate('jsonapi-resources.exceptions.save_failed.title', + default: 'Save failed or was cancelled'), + detail: I18n.translate('jsonapi-resources.exceptions.save_failed.detail', + default: 'Save failed or was cancelled'))] end end class InvalidPageObject < Error def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_PAGE_OBJECT, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_page_object.title', - default: 'Invalid Page Object'), - detail: I18n.translate('jsonapi-resources.exceptions.invalid_page_object.detail', - default: 'Invalid Page Object.'))] + [create_error_object(code: JSONAPI::INVALID_PAGE_OBJECT, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_page_object.title', + default: 'Invalid Page Object'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_page_object.detail', + default: 'Invalid Page Object.'))] end end class PageParametersNotAllowed < Error attr_accessor :params - def initialize(params) + + def initialize(params, error_object_overrides = {}) @params = params + super(error_object_overrides) end def errors params.collect do |param| - JSONAPI::Error.new(code: JSONAPI::PARAM_NOT_ALLOWED, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.page_parameters_not_allowed.title', - default: 'Page parameter not allowed'), - detail: I18n.translate('jsonapi-resources.exceptions.page_parameters_not_allowed.detail', - default: "#{param} is not an allowed page parameter.", - param: param)) + create_error_object(code: JSONAPI::PARAM_NOT_ALLOWED, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.page_parameters_not_allowed.title', + default: 'Page parameter not allowed'), + detail: I18n.translate('jsonapi-resources.exceptions.page_parameters_not_allowed.detail', + default: "#{param} is not an allowed page parameter.", + param: param)) end end end class InvalidPageValue < Error attr_accessor :page, :value - def initialize(page, value, msg = nil) + + def initialize(page, value, error_object_overrides = {}) @page = page @value = value - @msg = msg || I18n.translate('jsonapi-resources.exceptions.invalid_page_value.detail', - default: "#{value} is not a valid value for #{page} page parameter.", - value: value, page: page) + super(error_object_overrides) end def errors - [JSONAPI::Error.new(code: JSONAPI::INVALID_PAGE_VALUE, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.invalid_page_value.title', - default: 'Invalid page value'), - detail: @msg)] + [create_error_object(code: JSONAPI::INVALID_PAGE_VALUE, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_page_value.title', + default: 'Invalid page value'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_page_value.detail', + default: "#{value} is not a valid value for #{page} page parameter.", + value: value, page: page))] end end end diff --git a/lib/jsonapi/include_directives.rb b/lib/jsonapi/include_directives.rb index 0fd41536c..5e45ec195 100644 --- a/lib/jsonapi/include_directives.rb +++ b/lib/jsonapi/include_directives.rb @@ -40,6 +40,16 @@ def paths delve_paths(get_includes(@include_directives_hash, false)) end + def merge_filter(relation, filter) + config = include_config(relation.to_sym) + config[:include_filters] ||= {} + config[:include_filters].merge!(filter) + end + + def include_config(relation) + @include_directives_hash[:include_related][relation] + end + private def get_related(current_path) @@ -52,7 +62,7 @@ def get_related(current_path) current_relationship = current_resource_klass._relationships[fragment] current_resource_klass = current_relationship.try(:resource_klass) else - warn "[RELATIONSHIP NOT FOUND] Relationship could not be found for #{current_path}." + raise JSONAPI::Exceptions::InvalidInclude.new(current_resource_klass, current_path) end include_in_join = @force_eager_load || !current_relationship || current_relationship.eager_load_on_include diff --git a/lib/jsonapi/link_builder.rb b/lib/jsonapi/link_builder.rb index 1799152fc..8f4c0cca1 100644 --- a/lib/jsonapi/link_builder.rb +++ b/lib/jsonapi/link_builder.rb @@ -3,103 +3,100 @@ class LinkBuilder attr_reader :base_url, :primary_resource_klass, :route_formatter, - :engine_name + :engine, + :engine_mount_point, + :url_helpers + + @@url_helper_methods = {} def initialize(config = {}) - @base_url = config[:base_url] + @base_url = config[:base_url] @primary_resource_klass = config[:primary_resource_klass] - @route_formatter = config[:route_formatter] - @engine_name = build_engine_name + @route_formatter = config[:route_formatter] + @engine = build_engine + @engine_mount_point = @engine ? @engine.routes.find_script_name({}) : "" - # Warning: These make LinkBuilder non-thread-safe. That's not a problem with the - # request-specific way it's currently used, though. - @resources_path_cache = JSONAPI::NaiveCache.new do |source_klass| - formatted_module_path_from_class(source_klass) + format_route(source_klass._type.to_s) - end + # url_helpers may be either a controller which has the route helper methods, or the application router's + # url helpers module, `Rails.application.routes.url_helpers`. Because the method no longer behaves as a + # singleton, and it's expensive to generate the module, the controller is preferred. + @url_helpers = config[:url_helpers] end def engine? - !!@engine_name + !!@engine end def primary_resources_url - if engine? - engine_primary_resources_url + if @primary_resource_klass._routed + primary_resources_path = resources_path(primary_resource_klass) + @primary_resources_url_cached ||= "#{ base_url }#{ serialized_engine_mount_point }#{ primary_resources_path }" else - regular_primary_resources_url + if JSONAPI.configuration.warn_on_missing_routes && !@primary_resource_klass._warned_missing_route + warn "primary_resources_url for #{@primary_resource_klass} could not be generated" + @primary_resource_klass._warned_missing_route = true + end + nil end end def query_link(query_params) - "#{ primary_resources_url }?#{ query_params.to_query }" + url = primary_resources_url + return url if url.nil? + "#{ url }?#{ query_params.to_query }" end def relationships_related_link(source, relationship, query_params = {}) - url = "#{ self_link(source) }/#{ route_for_relationship(relationship) }" - url = "#{ url }?#{ query_params.to_query }" if query_params.present? - url + if relationship._routed + url = "#{ self_link(source) }/#{ route_for_relationship(relationship) }" + url = "#{ url }?#{ query_params.to_query }" if query_params.present? + url + else + if JSONAPI.configuration.warn_on_missing_routes && !relationship._warned_missing_route + warn "related_link for #{relationship} could not be generated" + relationship._warned_missing_route = true + end + nil + end end def relationships_self_link(source, relationship) - "#{ self_link(source) }/relationships/#{ route_for_relationship(relationship) }" + if relationship._routed + "#{ self_link(source) }/relationships/#{ route_for_relationship(relationship) }" + else + if JSONAPI.configuration.warn_on_missing_routes && !relationship._warned_missing_route + warn "self_link for #{relationship} could not be generated" + relationship._warned_missing_route = true + end + nil + end end def self_link(source) - if engine? - engine_resource_url(source) + if source.class._routed + resource_url(source) else - regular_resource_url(source) + if JSONAPI.configuration.warn_on_missing_routes && !source.class._warned_missing_route + warn "self_link for #{source.class} could not be generated" + source.class._warned_missing_route = true + end + nil end end private - def build_engine_name + def build_engine scopes = module_scopes_from_class(primary_resource_klass) - unless scopes.empty? - "#{ scopes.first.to_s.camelize }::Engine".safe_constantize - end - end - - def engine_path_from_resource_class(klass) - path_name = engine_resources_path_name_from_class(klass) - engine_name.routes.url_helpers.public_send(path_name) - end - - def engine_primary_resources_path - engine_path_from_resource_class(primary_resource_klass) - end - - def engine_primary_resources_url - "#{ base_url }#{ engine_primary_resources_path }" - end - - def engine_resource_path(source) - resource_path_name = engine_resource_path_name_from_source(source) - engine_name.routes.url_helpers.public_send(resource_path_name, source.id) - end - - def engine_resource_path_name_from_source(source) - scopes = module_scopes_from_class(source.class)[1..-1] - base_path_name = scopes.map { |scope| scope.underscore }.join("_") - end_path_name = source.class._type.to_s.singularize - [base_path_name, end_path_name, "path"].reject(&:blank?).join("_") - end - - def engine_resource_url(source) - "#{ base_url }#{ engine_resource_path(source) }" - end + begin + unless scopes.empty? + "#{ scopes.first.to_s.camelize }::Engine".safe_constantize + end - def engine_resources_path_name_from_class(klass) - scopes = module_scopes_from_class(klass)[1..-1] - base_path_name = scopes.map { |scope| scope.underscore }.join("_") - end_path_name = klass._type.to_s - - if base_path_name.blank? - "#{ end_path_name }_path" - else - "#{ base_path_name }_#{ end_path_name }_path" + # :nocov: + rescue LoadError => _e + nil + # :nocov: end end @@ -108,12 +105,19 @@ def format_route(route) end def formatted_module_path_from_class(klass) - scopes = module_scopes_from_class(klass) - - unless scopes.empty? - "/#{ scopes.map{ |scope| format_route(scope.to_s.underscore) }.join('/') }/" - else - "/" + @_module_path_cache ||= {} + @_module_path_cache[klass] ||= begin + scopes = if @engine + module_scopes_from_class(klass)[1..-1] + else + module_scopes_from_class(klass) + end + + unless scopes.empty? + "/#{ scopes.map {|scope| format_route(scope.to_s.underscore)}.compact.join('/') }/" + else + "/" + end end end @@ -121,24 +125,25 @@ def module_scopes_from_class(klass) klass.name.to_s.split("::")[0...-1] end - def regular_resources_path(source_klass) - @resources_path_cache.get(source_klass) + def resources_path(source_klass) + formatted_module_path_from_class(source_klass) + format_route(source_klass._type.to_s) end - def regular_primary_resources_path - regular_resources_path(primary_resource_klass) - end + def resource_path(source) + url = "#{resources_path(source.class)}" - def regular_primary_resources_url - "#{ base_url }#{ regular_primary_resources_path }" + unless source.class.singleton? + url = "#{url}/#{source.id}" + end + url end - def regular_resource_path(source) - "#{regular_resources_path(source.class)}/#{source.id}" + def resource_url(source) + "#{ base_url }#{ serialized_engine_mount_point }#{ resource_path(source) }" end - def regular_resource_url(source) - "#{ base_url }#{ regular_resource_path(source) }" + def serialized_engine_mount_point + engine_mount_point == "/" ? "" : engine_mount_point end def route_for_relationship(relationship) diff --git a/lib/jsonapi/mime_types.rb b/lib/jsonapi/mime_types.rb index f8bde565e..78e8f1d4f 100644 --- a/lib/jsonapi/mime_types.rb +++ b/lib/jsonapi/mime_types.rb @@ -1,3 +1,5 @@ +require 'json' + module JSONAPI MEDIA_TYPE = 'application/vnd.api+json' @@ -19,9 +21,18 @@ def self.install def self.parser lambda do |body| - data = JSON.parse(body) - data = {:_json => data} unless data.is_a?(Hash) - data.with_indifferent_access + begin + data = JSON.parse(body) + if data.is_a?(Hash) + data.with_indifferent_access + else + fail JSONAPI::Exceptions::InvalidRequestFormat.new + end + rescue JSON::ParserError => e + { _parser_exception: JSONAPI::Exceptions::BadRequest.new(e.to_s) } + rescue => e + { _parser_exception: e } + end end end end diff --git a/lib/jsonapi/operation_result.rb b/lib/jsonapi/operation_result.rb index 43e996a15..ce3806255 100644 --- a/lib/jsonapi/operation_result.rb +++ b/lib/jsonapi/operation_result.rb @@ -53,7 +53,7 @@ def initialize(code, source_resource, type, resources, options = {}) end end - class LinksObjectOperationResult < OperationResult + class RelationshipOperationResult < OperationResult attr_accessor :parent_resource, :relationship def initialize(code, parent_resource, relationship, options = {}) diff --git a/lib/jsonapi/paginator.rb b/lib/jsonapi/paginator.rb index 3ad00abbe..53f8fbbe4 100644 --- a/lib/jsonapi/paginator.rb +++ b/lib/jsonapi/paginator.rb @@ -110,7 +110,7 @@ def verify_pagination_params fail JSONAPI::Exceptions::InvalidPageValue.new(:limit, @limit) elsif @limit > JSONAPI.configuration.maximum_page_size fail JSONAPI::Exceptions::InvalidPageValue.new(:limit, @limit, - "Limit exceeds maximum page size of #{JSONAPI.configuration.maximum_page_size}.") + detail: "Limit exceeds maximum page size of #{JSONAPI.configuration.maximum_page_size}.") end if @offset < 0 @@ -199,7 +199,7 @@ def verify_pagination_params fail JSONAPI::Exceptions::InvalidPageValue.new(:size, @size) elsif @size > JSONAPI.configuration.maximum_page_size fail JSONAPI::Exceptions::InvalidPageValue.new(:size, @size, - "size exceeds maximum page size of #{JSONAPI.configuration.maximum_page_size}.") + detail: "size exceeds maximum page size of #{JSONAPI.configuration.maximum_page_size}.") end if @number < 1 diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index 82566568a..6f99c8054 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -96,11 +96,11 @@ def find end if (JSONAPI.configuration.top_level_meta_include_page_count && page_options[:record_count]) - page_options[:page_count] = paginator.calculate_page_count(page_options[:record_count]) + page_options[:page_count] = paginator ? paginator.calculate_page_count(page_options[:record_count]) : 1 end if JSONAPI.configuration.top_level_links_include_pagination && paginator - page_options[:pagination_params] = paginator.links_page_params(page_options) + page_options[:pagination_params] = paginator.links_page_params(page_options.merge(fetched_resources: resource_records)) end return JSONAPI::ResourcesOperationResult.new(:ok, resource_records, page_options) @@ -136,9 +136,9 @@ def show_relationship parent_resource = resource_klass.find_by_key(parent_key, context: context) - return JSONAPI::LinksObjectOperationResult.new(:ok, - parent_resource, - resource_klass._relationship(relationship_type)) + return JSONAPI::RelationshipOperationResult.new(:ok, + parent_resource, + resource_klass._relationship(relationship_type)) end def show_related_resource @@ -166,9 +166,10 @@ def show_related_resources include_directives = params[:include_directives] source_resource ||= source_klass.find_by_key(source_id, context: context, fields: fields) + verified_filters = resource_klass.verify_filters(filters, context) rel_opts = { - filters: filters, + filters: verified_filters, sort_criteria: sort_criteria, paginator: paginator, fields: fields, @@ -196,7 +197,7 @@ def show_related_resources (paginator && paginator.class.requires_record_count) || (JSONAPI.configuration.top_level_meta_include_page_count)) related_resource_records = source_resource.public_send("records_for_" + relationship_type) - records = resource_klass.filter_records(filters, {}, + records = resource_klass.filter_records(verified_filters, rel_opts, related_resource_records) record_count = resource_klass.count_records(records) @@ -209,7 +210,7 @@ def show_related_resources pagination_params = if paginator && JSONAPI.configuration.top_level_links_include_pagination page_options = {} page_options[:record_count] = record_count if paginator.class.requires_record_count - paginator.links_page_params(page_options) + paginator.links_page_params(page_options.merge(fetched_resources: related_resources)) else {} end diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index ee0dda3d7..6aa06e2d9 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -4,6 +4,8 @@ class Relationship :class_name, :polymorphic, :always_include_linkage_data, :parent_resource, :eager_load_on_include + attr_accessor :_routed, :_warned_missing_route + def initialize(name, options = {}) @name = name.to_s @options = options @@ -14,6 +16,10 @@ def initialize(name, options = {}) @polymorphic = options.fetch(:polymorphic, false) == true @always_include_linkage_data = options.fetch(:always_include_linkage_data, false) == true @eager_load_on_include = options.fetch(:eager_load_on_include, true) == true + @_routed = false + @_warned_missing_route = false + + exclude_links(options.fetch(:exclude_links, JSONAPI.configuration.default_exclude_links)) end alias_method :polymorphic?, :polymorphic @@ -49,8 +55,14 @@ def relation_name(options) def type_for_source(source) if polymorphic? - resource = source.public_send(name) - resource.class._type if resource + # try polymorphic type column before asking it from the resource record + if source._model.respond_to?(polymorphic_type) + model_type = source._model.send(polymorphic_type) + source.class.resource_for(model_type)._type if model_type + else + resource = source.public_send(name) + resource.class._type if resource + end else type end @@ -60,6 +72,27 @@ def belongs_to? false end + def exclude_links(exclude) + case exclude + when :default, "default" + @_exclude_links = [:self, :related] + when :none, "none" + @_exclude_links = [] + when Array + @_exclude_links = exclude.collect {|link| link.to_sym} + else + fail "Invalid exclude_links" + end + end + + def _exclude_links + @_exclude_links ||= [] + end + + def exclude_link?(link) + _exclude_links.include?(link.to_sym) + end + class ToOne < Relationship attr_reader :foreign_key_on @@ -70,6 +103,12 @@ def initialize(name, options = {}) @foreign_key_on = options.fetch(:foreign_key_on, :self) end + def to_s + # :nocov: + "#{parent_resource}.#{name}(#{belongs_to? ? 'BelongsToOne' : 'ToOne'})" + # :nocov: + end + def belongs_to? foreign_key_on == :self end @@ -89,6 +128,12 @@ def initialize(name, options = {}) @reflect = options.fetch(:reflect, true) == true @inverse_relationship = options.fetch(:inverse_relationship, parent_resource._type.to_s.singularize.to_sym) if parent_resource end + + def to_s + # :nocov: + "#{parent_resource}.#{name}(ToMany)" + # :nocov: + end end end end diff --git a/lib/jsonapi/relationship_builder.rb b/lib/jsonapi/relationship_builder.rb index c5774e6f4..b00f9aa84 100644 --- a/lib/jsonapi/relationship_builder.rb +++ b/lib/jsonapi/relationship_builder.rb @@ -60,16 +60,16 @@ def define_resource_relationship_accessor(type, relationship_name) resource_klass = relationship.resource_klass + records = resource_klass.apply_includes(records, options) + filters = options.fetch(:filters, {}) unless filters.nil? || filters.empty? records = resource_klass.apply_filters(records, filters, options) end sort_criteria = options.fetch(:sort_criteria, {}) - unless sort_criteria.nil? || sort_criteria.empty? - order_options = relationship.resource_klass.construct_order_options(sort_criteria) - records = resource_klass.apply_sort(records, order_options, @context) - end + order_options = relationship.resource_klass.construct_order_options(sort_criteria) + records = resource_klass.apply_sort(records, order_options, @context) paginator = options[:paginator] if paginator @@ -120,10 +120,16 @@ def build_has_one(relationship, foreign_key, associated_records_method_name, rel define_on_resource relationship_name do |options = {}| relationship = self.class._relationships[relationship_name] - resource_klass = relationship.resource_klass - if resource_klass + if relationship.polymorphic? associated_model = public_send(associated_records_method_name) - return associated_model ? resource_klass.new(associated_model, @context) : nil + resource_klass = self.class.resource_for_model(associated_model) if associated_model + return resource_klass.new(associated_model, @context) if resource_klass && associated_model + else + resource_klass = relationship.resource_klass + if resource_klass + associated_model = public_send(associated_records_method_name) + return associated_model ? resource_klass.new(associated_model, @context) : nil + end end end end diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb index eb0dbf1ee..63f5672af 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request_parser.rb @@ -34,6 +34,7 @@ def setup_action(params) setup_action_method_name = "setup_#{params[:action]}_action" if respond_to?(setup_action_method_name) + raise params[:_parser_exception] if params[:_parser_exception] send(setup_action_method_name, params) end rescue ActionController::ParameterMissing => e @@ -51,6 +52,7 @@ def setup_index_action(params) end def setup_get_related_resource_action(params) + resolve_singleton_id(params) initialize_source(params) parse_fields(params[:fields]) parse_include_directives(params[:include]) @@ -62,6 +64,7 @@ def setup_get_related_resource_action(params) end def setup_get_related_resources_action(params) + resolve_singleton_id(params) initialize_source(params) parse_fields(params[:fields]) parse_include_directives(params[:include]) @@ -73,13 +76,17 @@ def setup_get_related_resources_action(params) end def setup_show_action(params) + resolve_singleton_id(params) parse_fields(params[:fields]) parse_include_directives(params[:include]) + parse_filters(params[:filter]) + @id = params[:id] add_show_operation end def setup_show_relationship_action(params) + resolve_singleton_id(params) add_show_relationship_operation(params[:relationship], params.require(@resource_klass._as_parent_key)) end @@ -90,24 +97,29 @@ def setup_create_action(params) end def setup_create_relationship_action(params) + resolve_singleton_id(params) parse_modify_relationship_action(params, :add) end def setup_update_relationship_action(params) + resolve_singleton_id(params) parse_modify_relationship_action(params, :update) end def setup_update_action(params) + resolve_singleton_id(params) parse_fields(params[:fields]) parse_include_directives(params[:include]) parse_replace_operation(params.require(:data), params[:id]) end def setup_destroy_action(params) + resolve_singleton_id(params) parse_remove_operation(params) end def setup_destroy_relationship_action(params) + resolve_singleton_id(params) parse_modify_relationship_action(params, :remove) end @@ -169,13 +181,11 @@ def parse_fields(fields) end type_resource = Resource.resource_for(@resource_klass.module_path + underscored_type.to_s) rescue NameError - @errors.concat(JSONAPI::Exceptions::InvalidResource.new(type).errors) - rescue JSONAPI::Exceptions::InvalidResource => e - @errors.concat(e.errors) + fail JSONAPI::Exceptions::InvalidResource.new(type) end if type_resource.nil? - @errors.concat(JSONAPI::Exceptions::InvalidResource.new(type).errors) + fail JSONAPI::Exceptions::InvalidResource.new(type) else unless values.nil? valid_fields = type_resource.fields.collect { |key| format_key(key) } @@ -183,11 +193,11 @@ def parse_fields(fields) if valid_fields.include?(field) extracted_fields[type].push unformat_key(field) else - @errors.concat(JSONAPI::Exceptions::InvalidField.new(type, field).errors) + fail JSONAPI::Exceptions::InvalidField.new(type, field) end end else - @errors.concat(JSONAPI::Exceptions::InvalidField.new(type, 'nil').errors) + fail JSONAPI::Exceptions::InvalidField.new(type, 'nil') end end end @@ -201,38 +211,47 @@ def check_include(resource_klass, include_parts) relationship = resource_klass._relationship(relationship_name) if relationship && format_key(relationship_name) == include_parts.first unless include_parts.last.empty? - check_include(Resource.resource_for(@resource_klass.module_path + relationship.class_name.to_s.underscore), include_parts.last.partition('.')) + check_include(Resource.resource_for(resource_klass.module_path + relationship.class_name.to_s.underscore), include_parts.last.partition('.')) end else - @errors.concat(JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), - include_parts.first).errors) + fail JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), include_parts.first) end end - def parse_include_directives(include) - return if include.nil? + def parse_include_directives(raw_include) + return unless raw_include unless JSONAPI.configuration.allow_include - fail JSONAPI::Exceptions::ParametersNotAllowed.new([:include]) + fail JSONAPI::Exceptions::ParameterNotAllowed.new(:include) end - included_resources = CSV.parse_line(include) - return if included_resources.nil? - - include = [] - included_resources.each do |included_resource| - check_include(@resource_klass, included_resource.partition('.')) - include.push(unformat_key(included_resource).to_s) + included_resources = [] + begin + included_resources += Array(CSV.parse_line(raw_include)) + rescue CSV::MalformedCSVError + fail JSONAPI::Exceptions::InvalidInclude.new(format_key(@resource_klass._type), raw_include) end - @include_directives = JSONAPI::IncludeDirectives.new(@resource_klass, include) + return if included_resources.empty? + + begin + result = included_resources.compact.map do |included_resource| + check_include(@resource_klass, included_resource.partition('.')) + unformat_key(included_resource).to_s + end + + @include_directives = JSONAPI::IncludeDirectives.new(@resource_klass, result) + rescue JSONAPI::Exceptions::InvalidInclude => e + @errors.concat(e.errors) + @include_directives = JSONAPI::IncludeDirectives.new(@resource_klass, []) + end end def parse_filters(filters) return unless filters unless JSONAPI.configuration.allow_filter - fail JSONAPI::Exceptions::ParametersNotAllowed.new([:filter]) + fail JSONAPI::Exceptions::ParameterNotAllowed.new(:filter) end unless filters.class.method_defined?(:each) @@ -241,15 +260,44 @@ def parse_filters(filters) end filters.each do |key, value| - filter = unformat_key(key) - if @resource_klass._allowed_filter?(filter) - @filters[filter] = value + + unformatted_key = unformat_key(key) + if resource_klass._allowed_filter?(unformatted_key) + @filters[unformatted_key] = value + elsif unformatted_key.to_s.include?('.') + parse_relationship_filter(unformatted_key, value) else - @errors.concat(JSONAPI::Exceptions::FilterNotAllowed.new(filter).errors) + return @errors.concat(Exceptions::FilterNotAllowed.new(unformatted_key).errors) end end end + def parse_relationship_filter(key, value) + included_resource_name, filter_method = key.to_s.split('.') + filter_method = filter_method.to_sym if filter_method.present? + + if included_resource_name + relationship = resource_klass._relationship(included_resource_name || '') + + unless relationship + return @errors.concat(Exceptions::FilterNotAllowed.new(filter_method).errors) + end + + unless relationship.resource_klass._allowed_filter?(filter_method) + return @errors.concat(Exceptions::FilterNotAllowed.new(filter_method).errors) + end + + unless @include_directives.try(:include_config, relationship.name.to_sym).present? + return @errors.concat(Exceptions::FilterNotAllowed.new(filter_method).errors) + end + + verified_filter = relationship.resource_klass.verify_filters({ filter_method => value }, @context) + @include_directives.merge_filter(relationship.name, verified_filter) + else + return @errors.concat(Exceptions::FilterNotAllowed.new(filter_method).errors) + end + end + def set_default_filters @resource_klass._allowed_filters.each do |filter, opts| next if opts[:default].nil? || !@filters[filter].nil? @@ -261,10 +309,18 @@ def parse_sort_criteria(sort_criteria) return unless sort_criteria.present? unless JSONAPI.configuration.allow_sort - fail JSONAPI::Exceptions::ParametersNotAllowed.new([:sort]) + fail JSONAPI::Exceptions::ParameterNotAllowed.new(:sort) end - @sort_criteria = CSV.parse_line(URI.unescape(sort_criteria)).collect do |sort| + sorts = [] + begin + raw = URI::DEFAULT_PARSER.unescape(sort_criteria) + sorts += CSV.parse_line(raw) + rescue CSV::MalformedCSVError + fail JSONAPI::Exceptions::InvalidSortCriteria.new(format_key(@resource_klass._type), raw) + end + + @sort_criteria = sorts.collect do |sort| if sort.start_with?('-') sort_criteria = { field: unformat_key(sort[1..-1]).to_s } sort_criteria[:direction] = :desc @@ -282,9 +338,8 @@ def check_sort_criteria(resource_klass, sort_criteria) sort_field = sort_criteria[:field] sortable_fields = resource_klass.sortable_fields(context) - unless sortable_fields.include? sort_field.to_sym - @errors.concat(JSONAPI::Exceptions::InvalidSortCriteria - .new(format_key(resource_klass._type), sort_field).errors) + unless sortable_fields.include?sort_field.to_sym + fail JSONAPI::Exceptions::InvalidSortCriteria.new(format_key(resource_klass._type), sort_field) end end @@ -338,7 +393,7 @@ def add_show_related_resources_operation(relationship_type) relationship_type: relationship_type, source_klass: @source_klass, source_id: @source_id, - filters: @source_klass.verify_filters(@filters, @context), + filters: @filters, sort_criteria: @sort_criteria, paginator: @paginator, fields: @fields, @@ -459,7 +514,7 @@ def parse_to_one_relationship(link_value, relationship) unless links_object[:id].nil? resource = self.resource_klass || Resource - relationship_resource = resource.resource_for(unformat_key(links_object[:type]).to_s) + relationship_resource = resource.resource_for(unformat_key(relationship.options[:class_name] || links_object[:type]).to_s) relationship_id = relationship_resource.verify_key(links_object[:id], @context) if relationship.polymorphic? { id: relationship_id, type: unformat_key(links_object[:type].to_s) } @@ -482,20 +537,40 @@ def parse_to_many_relationship(link_value, relationship, &add_result) links_object = parse_to_many_links_object(linkage) - # Since we do not yet support polymorphic to_many relationships we will raise an error if the type does not match the - # relationship's type. - # ToDo: Support Polymorphic relationships - if links_object.length == 0 add_result.call([]) else - if links_object.length > 1 || !links_object.has_key?(unformat_key(relationship.type).to_s) - fail JSONAPI::Exceptions::TypeMismatch.new(links_object[:type]) - end + if relationship.polymorphic? + polymorphic_results = [] + + links_object.each_pair do |type, keys| + resource = self.resource_klass || Resource + type_name = unformat_key(type).to_s + + relationship_resource_klass = resource.resource_for(relationship.class_name) + relationship_klass = relationship_resource_klass._model_class + + linkage_object_resource_klass = resource.resource_for(type_name) + linkage_object_klass = linkage_object_resource_klass._model_class + + unless linkage_object_klass == relationship_klass || linkage_object_klass.in?(relationship_klass.subclasses) + fail JSONAPI::Exceptions::TypeMismatch.new(type_name) + end + + relationship_ids = relationship_resource_klass.verify_keys(keys, @context) + polymorphic_results << { type: type, ids: relationship_ids } + end + + add_result.call polymorphic_results + else + relationship_type = unformat_key(relationship.type).to_s - links_object.each_pair do |type, keys| - relationship_resource = Resource.resource_for(@resource_klass.module_path + unformat_key(type).to_s) - add_result.call relationship_resource.verify_keys(keys, @context) + if links_object.length > 1 || !links_object.has_key?(relationship_type) + fail JSONAPI::Exceptions::TypeMismatch.new(links_object[:type]) + end + + relationship_resource = Resource.resource_for(@resource_klass.module_path + relationship_type) + add_result.call relationship_resource.verify_keys(links_object[relationship_type], @context) end end end @@ -514,8 +589,10 @@ def verify_permitted_params(params, allowed_fields) when 'relationships' value.keys.each do |links_key| unless formatted_allowed_fields.include?(links_key.to_sym) - params_not_allowed.push(links_key) - unless JSONAPI.configuration.raise_if_parameters_not_allowed + if JSONAPI.configuration.raise_if_parameters_not_allowed + fail JSONAPI::Exceptions::ParameterNotAllowed.new(links_key) + else + params_not_allowed.push(links_key) value.delete links_key end end @@ -523,8 +600,10 @@ def verify_permitted_params(params, allowed_fields) when 'attributes' value.each do |attr_key, attr_value| unless formatted_allowed_fields.include?(attr_key.to_sym) - params_not_allowed.push(attr_key) - unless JSONAPI.configuration.raise_if_parameters_not_allowed + if JSONAPI.configuration.raise_if_parameters_not_allowed + fail JSONAPI::Exceptions::ParameterNotAllowed.new(attr_key) + else + params_not_allowed.push(attr_key) value.delete attr_key end end @@ -532,27 +611,30 @@ def verify_permitted_params(params, allowed_fields) when 'type' when 'id' unless formatted_allowed_fields.include?(:id) - params_not_allowed.push(:id) - unless JSONAPI.configuration.raise_if_parameters_not_allowed + if JSONAPI.configuration.raise_if_parameters_not_allowed + fail JSONAPI::Exceptions::ParameterNotAllowed.new(:id) + else + params_not_allowed.push(:id) params.delete :id end end else - params_not_allowed.push(key) + if JSONAPI.configuration.raise_if_parameters_not_allowed + fail JSONAPI::Exceptions::ParameterNotAllowed.new(key) + else + params_not_allowed.push(key) + params.delete key + end end end if params_not_allowed.length > 0 - if JSONAPI.configuration.raise_if_parameters_not_allowed - fail JSONAPI::Exceptions::ParametersNotAllowed.new(params_not_allowed) - else - params_not_allowed_warnings = params_not_allowed.map do |key| - JSONAPI::Warning.new(code: JSONAPI::PARAM_NOT_ALLOWED, - title: 'Param not allowed', - detail: "#{key} is not allowed.") - end - self.warnings.concat(params_not_allowed_warnings) + params_not_allowed_warnings = params_not_allowed.map do |param| + JSONAPI::Warning.new(code: JSONAPI::PARAM_NOT_ALLOWED, + title: 'Param not allowed', + detail: "#{param} is not allowed.") end + self.warnings.concat(params_not_allowed_warnings) end end @@ -621,7 +703,8 @@ def parse_single_replace_operation(data, keys, id_key_presence_check_required: t end def parse_replace_operation(data, keys) - parse_single_replace_operation(data, [keys], id_key_presence_check_required: keys.present?) + parse_single_replace_operation(data, [keys], + id_key_presence_check_required: keys.present? && !@resource_klass.singleton?) rescue JSONAPI::Exceptions::Error => e @errors.concat(e.errors) end @@ -652,6 +735,13 @@ def parse_remove_relationship_operation(params, relationship, parent_key) end end + def resolve_singleton_id(params) + if @resource_klass.singleton? && params[:id].nil? + key = @resource_klass.singleton_key(context) + params[:id] = key + end + end + def format_key(key) @key_formatter.format(key) end diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 14c34d417..6fa78dffb 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -5,6 +5,9 @@ module JSONAPI class Resource include Callbacks + DEFAULT_ATTRIBUTE_OPTIONS = { format: :default }.freeze + MODULE_PATH_REGEXP = /::[^:]+\Z/.freeze + attr_reader :context define_jsonapi_resources_callbacks :create, @@ -302,6 +305,26 @@ def _replace_to_many_links(relationship_type, relationship_key_values, options) to_add = relationship_key_values - (relationship_key_values & existing) _create_to_many_links(relationship_type, to_add, {}) + @reload_needed = true + elsif relationship.polymorphic? + relationship_key_values.each do |relationship_key_value| + relationship_resource_klass = self.class.resource_for(relationship_key_value[:type]) + ids = relationship_key_value[:ids] + + related_records = relationship_resource_klass + .records(options) + .where({relationship_resource_klass._primary_key => ids}) + + missed_ids = ids - related_records.pluck(relationship_resource_klass._primary_key) + + if missed_ids.present? + fail JSONAPI::Exceptions::RecordNotFound.new(missed_ids) + end + + relation_name = relationship.relation_name(context: @context) + @model.send("#{relation_name}") << related_records + end + @reload_needed = true else send("#{relationship.foreign_key}=", relationship_key_values) @@ -415,32 +438,50 @@ def inherited(subclass) subclass.abstract(false) subclass.immutable(false) subclass.caching(false) + subclass.singleton(singleton?, (_singleton_options.dup || {})) + subclass.exclude_links(_exclude_links) subclass._attributes = (_attributes || {}).dup + subclass._model_hints = (_model_hints || {}).dup - subclass._relationships = {} - # Add the relationships from the base class to the subclass using the original options - if _relationships.is_a?(Hash) - _relationships.each_value do |relationship| - options = relationship.options.dup - options[:parent_resource] = subclass - subclass._add_relationship(relationship.class, relationship.name, options) - end + unless _model_name.empty? + subclass.model_name(_model_name, add_model_hint: (_model_hints && !_model_hints[_model_name].nil?) == true) end + subclass.rebuild_relationships(_relationships || {}) + subclass._allowed_filters = (_allowed_filters || Set.new).dup type = subclass.name.demodulize.sub(/Resource$/, '').underscore subclass._type = type.pluralize.to_sym - subclass.attribute :id, format: :id + unless subclass._attributes[:id] + subclass.attribute :id, format: :id + end check_reserved_resource_name(subclass._type, subclass.name) + + subclass._routed = false + subclass._warned_missing_route = false + end + + def rebuild_relationships(relationships) + original_relationships = relationships.deep_dup + + @_relationships = {} + + if original_relationships.is_a?(Hash) + original_relationships.each_value do |relationship| + options = relationship.options.dup + options[:parent_resource] = self + _add_relationship(relationship.class, relationship.name, options) + end + end end def resource_for(type) type = type.underscore - type_with_module = type.include?('/') ? type : module_path + type + type_with_module = type.start_with?(module_path) ? type : module_path + type resource_name = _resource_name_from_type(type_with_module) resource = resource_name.safe_constantize if resource_name @@ -467,7 +508,7 @@ def resource_type_for(model) end end - attr_accessor :_attributes, :_relationships, :_type, :_model_hints + attr_accessor :_attributes, :_relationships, :_type, :_model_hints, :_routed, :_warned_missing_route attr_writer :_allowed_filters, :_paginator def create(context) @@ -494,10 +535,12 @@ def attributes(*attrs) end end - def attribute(attr, options = {}) + def attribute(attribute_name, options = {}) + attr = attribute_name.to_sym + check_reserved_attribute_name(attr) - if (attr.to_sym == :id) && (options[:format].nil?) + if (attr == :id) && (options[:format].nil?) ActiveSupport::Deprecation.warn('Id without format is no longer supported. Please remove ids from attributes, or specify a format.') end @@ -515,7 +558,7 @@ def attribute(attr, options = {}) end def default_attribute_options - { format: :default } + DEFAULT_ATTRIBUTE_OPTIONS end def relationship(*attrs) @@ -550,10 +593,19 @@ def has_many(*attrs) _add_relationship(Relationship::ToMany, *attrs) end + # @model_class is inherited from superclass, and this causes some issues: + # ``` + # CarResource._model_class #=> Vehicle # it should be Car + # ``` + # so in order to invoke the right class from subclasses, + # we should call this method to override it. def model_name(model, options = {}) + @model_class = nil @_model_name = model.to_sym model_hint(model: @_model_name, resource: self) unless options[:add_model_hint] == false + + rebuild_relationships(_relationships) end def model_hint(model: _model_name, resource: _type) @@ -562,6 +614,19 @@ def model_hint(model: _model_name, resource: _type) _model_hints[model.to_s.gsub('::', '/').underscore] = resource_type.to_s end + def singleton(*attrs) + @_singleton = (!!attrs[0] == attrs[0]) ? attrs[0] : true + @_singleton_options = attrs.extract_options! + end + + def _singleton_options + @_singleton_options ||= {} + end + + def singleton? + @_singleton ||= false + end + def filters(*attrs) @_allowed_filters.merge!(attrs.inject({}) { |h, attr| h[attr] = {}; h }) end @@ -585,7 +650,7 @@ def updatable_fields(_context = nil) # Override in your resource to filter the creatable keys def creatable_fields(_context = nil) - _updatable_relationships | _attributes.keys + _updatable_relationships | _attributes.keys - [:id] end # Override in your resource to filter the sortable keys @@ -621,7 +686,7 @@ def apply_includes(records, options = {}) include_directives = options[:include_directives] if include_directives model_includes = resolve_relationship_names_to_relations(self, include_directives.model_includes, options) - records = records.includes(model_includes) + records = records.includes(model_includes) if model_includes.present? end records @@ -641,7 +706,7 @@ def apply_sort(records, order_options, _context = {}) associations = _lookup_association_chain([records.model.to_s, *model_names]) joins_query = _build_joins([records.model, *associations]) - # _sorting is appended to avoid name clashes with manual joins eg. overriden filters + # _sorting is appended to avoid name clashes with manual joins eg. overridden filters order_by_query = "#{associations.last.name}_sorting.#{column_name} #{direction}" records = records.joins(joins_query).order(order_by_query) else @@ -715,9 +780,30 @@ def apply_filters(records, filters, options = {}) records end + def apply_included_resources_filters(records, options = {}) + include_directives = options[:include_directives] + return records unless include_directives + related_directives = include_directives.include_directives.fetch(:include_related) + related_directives.reduce(records) do |memo, (relationship_name, config)| + relationship = _relationship(relationship_name) + next memo unless relationship && relationship.is_a?(JSONAPI::Relationship::ToMany) + filtering_resource = relationship.resource_klass + + # Don't try to merge where clauses when relation isn't already being joined to query. + next memo unless config[:include_in_join] + + filters = config[:include_filters] + next memo unless filters + + rel_records = filtering_resource.apply_filters(filtering_resource.records(options), filters, options).references(relationship_name) + memo.merge(rel_records) + end + end + def filter_records(filters, options, records = records(options)) records = apply_filters(records, filters, options) - apply_includes(records, options) + records = apply_includes(records, options) + apply_included_resources_filters(records, options) end def sort_records(records, order_options, context = {}) @@ -793,6 +879,9 @@ def records(_options = {}) def verify_filters(filters, context = nil) verified_filters = {} + + return verified_filters if filters.nil? + filters.each do |filter, raw_value| verified_filter = verify_filter(filter, raw_value, context) verified_filters[verified_filter[0]] = verified_filter[1] @@ -807,7 +896,11 @@ def is_filter_relationship?(filter) def verify_filter(filter, raw, context = nil) filter_values = [] if raw.present? - filter_values += raw.is_a?(String) ? CSV.parse_line(raw) : [raw] + begin + filter_values += raw.is_a?(String) ? CSV.parse_line(raw) : [raw] + rescue CSV::MalformedCSVError + filter_values << raw + end end strategy = _allowed_filters.fetch(filter, Hash.new)[:verify] @@ -836,6 +929,24 @@ def resource_key_type @_resource_key_type ||= JSONAPI.configuration.resource_key_type end + # override to all resolution of masked ids to actual ids. Because singleton routes do not specify the id this + # will be needed to allow lookup of singleton resources. Alternately singleton resources can override + # `verify_key` + def singleton_key(context) + if @_singleton_options && @_singleton_options[:singleton_key] + strategy = @_singleton_options[:singleton_key] + case strategy + when Proc + key = strategy.call(context) + when Symbol, String + key = send(strategy, context) + else + raise "singleton_key must be a proc or function name" + end + end + key + end + def verify_key(key, context = nil) key_type = resource_key_type @@ -887,6 +998,10 @@ def _attribute_options(attr) default_attribute_options.merge(@_attributes[attr]) end + def _has_attribute?(attr) + @_attributes.keys.include?(attr.to_sym) + end + def _updatable_relationships @_relationships.map { |key, _relationship| key } end @@ -900,10 +1015,11 @@ def _model_name if _abstract return '' else - return @_model_name if defined?(@_model_name) + return @_model_name.to_s if defined?(@_model_name) class_name = self.name return '' if class_name.nil? - return @_model_name = class_name.demodulize.sub(/Resource$/, '') + @_model_name = class_name.demodulize.sub(/Resource$/, '') + return @_model_name.to_s end end @@ -955,6 +1071,31 @@ def mutable? !@immutable end + def exclude_links(exclude) + _resolve_exclude_links(exclude) + end + + def _exclude_links + @_exclude_links ||= _resolve_exclude_links(JSONAPI.configuration.default_exclude_links) + end + + def exclude_link?(link) + _exclude_links.include?(link.to_sym) + end + + def _resolve_exclude_links(exclude) + case exclude + when :default, "default" + @_exclude_links = [:self] + when :none, "none" + @_exclude_links = [] + when Array + @_exclude_links = exclude.collect {|link| link.to_sym} + else + fail "Invalid exclude_links" + end + end + def caching(val = true) @caching = val end @@ -974,11 +1115,17 @@ def attribute_caching_context(context) def _model_class return nil if _abstract - return @model if defined?(@model) - return nil if self.name.to_s.blank? && _model_name.to_s.blank? - @model = _model_name.to_s.safe_constantize - warn "[MODEL NOT FOUND] Model could not be found for #{self.name}. If this a base Resource declare it as abstract." if @model.nil? - @model + return @model_class if @model_class + + model_name = _model_name + return nil if model_name.to_s.blank? + + @model_class = model_name.to_s.safe_constantize + if @model_class.nil? + warn "[MODEL NOT FOUND] Model could not be found for #{self.name}. If this is a base Resource declare it as abstract." + end + + @model_class end def _allowed_filter?(filter) @@ -989,7 +1136,7 @@ def module_path if name == 'JSONAPI::Resource' '' else - name =~ /::[^:]+\Z/ ? ($`.freeze.gsub('::', '/') + '/').underscore : '' + name =~ MODULE_PATH_REGEXP ? ($`.freeze.gsub('::', '/') + '/').underscore : '' end end @@ -1012,7 +1159,8 @@ def _add_relationship(klass, *attrs) options = attrs.extract_options! options[:parent_resource] = self - attrs.each do |relationship_name| + attrs.each do |name| + relationship_name = name.to_sym check_reserved_relationship_name(relationship_name) check_duplicate_relationship_name(relationship_name) @@ -1040,7 +1188,7 @@ def cached_resources_for(records, serializer, options) cache_ids = pluck_arel_attributes(records, t[_primary_key], t[_cache_field]) resources = CachedResourceFragment.fetch_fragments(self, serializer, options[:context], cache_ids) else - resources = resources_for(records, options).map{|r| [r.id, r] }.to_h + resources = resources_for(records, options[:context]).map{|r| [r.id, r] }.to_h end preload_included_fragments(resources, records, serializer, options) @@ -1102,7 +1250,6 @@ def preload_included_fragments(resources, records, serializer, options) include_directives = options[:include_directives] return unless include_directives - relevant_options = options.except(:include_directives, :order, :paginator) context = options[:context] # For each association, including indirect associations, find the target record ids. @@ -1133,8 +1280,15 @@ def preload_included_fragments(resources, records, serializer, options) # For each step on the path, figure out what the actual table name/alias in the join # will be, and include the primary key of that table in our list of fields to select + non_polymorphic = true path.each do |elem| relationship = klass._relationships[elem] + if relationship.polymorphic + # Can't preload through a polymorphic belongs_to association, ResourceSerializer + # will just have to bypass the cache and load the real Resource. + non_polymorphic = false + break + end assocs_path << relationship.relation_name(options).to_sym # Converts [:a, :b, :c] to Rails-style { :a => { :b => :c }} ar_hash = assocs_path.reverse.reduce{|memo, step| { step => memo } } @@ -1148,6 +1302,7 @@ def preload_included_fragments(resources, records, serializer, options) klass = relationship.resource_klass pluck_attrs << table[klass._primary_key] end + next unless non_polymorphic # Pre-fill empty hashes for each resource up to the end of the path. # This allows us to later distinguish between a preload that returned nothing @@ -1188,7 +1343,7 @@ def preload_included_fragments(resources, records, serializer, options) .map(&:last) .reject{|id| target_resources[klass.name].has_key?(id) } .uniq - found = klass.find({klass._primary_key => sub_res_ids}, relevant_options) + found = klass.find({klass._primary_key => sub_res_ids}, context: options[:context]) target_resources[klass.name].merge! found.map{|r| [r.id, r] }.to_h end @@ -1199,7 +1354,8 @@ def preload_included_fragments(resources, records, serializer, options) rel_id = row[index+1] assoc_rels = res.preloaded_fragments[rel_name] if index == path.length - 1 - assoc_rels[rel_id] = target_resources[klass.name].fetch(rel_id) + association_res = target_resources[klass.name].fetch(rel_id, nil) + assoc_rels[rel_id] = association_res if association_res else res = assoc_rels[rel_id] end @@ -1213,7 +1369,7 @@ def pluck_arel_attributes(relation, *attrs) quoted_attrs = attrs.map do |attr| quoted_table = conn.quote_table_name(attr.relation.table_alias || attr.relation.name) quoted_column = conn.quote_column_name(attr.name) - "#{quoted_table}.#{quoted_column}" + Arel.sql("#{quoted_table}.#{quoted_column}") end relation.pluck(*quoted_attrs) end diff --git a/lib/jsonapi/resource_controller_metal.rb b/lib/jsonapi/resource_controller_metal.rb index bf5bc9410..11e1a9bfd 100644 --- a/lib/jsonapi/resource_controller_metal.rb +++ b/lib/jsonapi/resource_controller_metal.rb @@ -5,10 +5,10 @@ class ResourceControllerMetal < ActionController::Metal ActionController::Rendering, ActionController::Renderers::All, ActionController::StrongParameters, - ActionController::ForceSSL, + Gem::Requirement.new('< 6.1').satisfied_by?(ActionPack.gem_version) ? ActionController::ForceSSL : nil, ActionController::Instrumentation, JSONAPI::ActsAsResourceController - ].freeze + ].compact.freeze MODULES.each do |mod| include mod diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index 1bde2ab0d..0ef56d31f 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -50,15 +50,35 @@ def serialize_to_hash(source) @included_objects = {} - process_primary(source, @include_directives.include_directives) + process_source_objects(source, @include_directives.include_directives) - included_objects = [] primary_objects = [] + + # pull the processed objects corresponding to the source objects. Ensures we preserve order. + if is_resource_collection + source.each do |primary| + if primary.id + case primary + when CachedResourceFragment then primary_objects.push(@included_objects[primary.type][primary.id][:object_hash]) + when Resource then primary_objects.push(@included_objects[primary.class._type][primary.id][:object_hash]) + else raise "Unknown source type #{primary.inspect}" + end + end + end + else + if source.try(:id) + case source + when CachedResourceFragment then primary_objects.push(@included_objects[source.type][source.id][:object_hash]) + when Resource then primary_objects.push(@included_objects[source.class._type][source.id][:object_hash]) + else raise "Unknown source type #{source.inspect}" + end + end + end + + included_objects = [] @included_objects.each_value do |objects| objects.each_value do |object| - if object[:primary] - primary_objects.push(object[:object_hash]) - else + unless object[:primary] included_objects.push(object[:object_hash]) end end @@ -70,20 +90,19 @@ def serialize_to_hash(source) primary_hash end - def serialize_to_links_hash(source, requested_relationship) + def serialize_to_relationship_hash(source, requested_relationship) if requested_relationship.is_a?(JSONAPI::Relationship::ToOne) data = to_one_linkage(source, requested_relationship) else data = to_many_linkage(source, requested_relationship) end - { - links: { - self: self_link(source, requested_relationship), - related: related_link(source, requested_relationship) - }, - data: data - } + rel_hash = { 'data': data } + + links = default_relationship_links(source, requested_relationship) + rel_hash['links'] = links unless links.blank? + + rel_hash end def query_link(query_params) @@ -113,7 +132,6 @@ def config_description(resource_klass) supplying_attribute_fields: supplying_attribute_fields(resource_klass).sort, supplying_relationship_fields: supplying_relationship_fields(resource_klass).sort, link_builder_base_url: link_builder.base_url, - route_formatter_class: link_builder.route_formatter.uncached.class.name, key_formatter_class: key_formatter.uncached.class.name, always_include_to_one_linkage_data: always_include_to_one_linkage_data, always_include_to_many_linkage_data: always_include_to_many_linkage_data @@ -132,7 +150,7 @@ def object_hash(source, include_directives = {}) obj_hash['attributes'] = source.attributes_json if source.attributes_json relationships = cached_relationships_hash(source, include_directives) - obj_hash['relationships'] = relationships unless relationships.empty? + obj_hash['relationships'] = relationships unless relationships.blank? obj_hash['meta'] = source.meta_json if source.meta_json else @@ -153,7 +171,7 @@ def object_hash(source, include_directives = {}) obj_hash['attributes'] = attributes unless attributes.empty? relationships = relationships_hash(source, fetchable_fields, include_directives) - obj_hash['relationships'] = relationships unless relationships.nil? || relationships.empty? + obj_hash['relationships'] = relationships unless relationships.blank? meta = meta_hash(source) obj_hash['meta'] = meta unless meta.empty? @@ -168,9 +186,9 @@ def object_hash(source, include_directives = {}) # requested includes. Fields are controlled fields option for each resource type, such # as fields: { people: [:id, :email, :comments], posts: [:id, :title, :author], comments: [:id, :body, :post]} # The fields options controls both fields and included links references. - def process_primary(source, include_directives) + def process_source_objects(source, include_directives) if source.respond_to?(:to_ary) - source.each { |resource| process_primary(resource, include_directives) } + source.each { |resource| process_source_objects(resource, include_directives) } else return {} if source.nil? add_resource(source, include_directives, true) @@ -231,7 +249,9 @@ def meta_hash(source) def links_hash(source) links = custom_links_hash(source) - links[:self] = link_builder.self_link(source) unless links.key?(:self) + if !links.key?('self') && !source.class.exclude_link?(:self) + links['self'] = link_builder.self_link(source) + end links.compact end @@ -268,7 +288,8 @@ def relationships_hash(source, fetchable_fields, include_directives = {}) include_linked_children = ia && !ia[:include_related].empty? if field_set.include?(name) - hash[format_key(name)] = link_object(source, relationship, include_linkage) + ro = relationship_object(source, relationship, include_linkage) + hash[format_key(name)] = ro unless ro.blank? end # If the object has been serialized once it will be in the related objects list, @@ -278,7 +299,8 @@ def relationships_hash(source, fetchable_fields, include_directives = {}) resources = if source.preloaded_fragments.has_key?(format_key(name)) source.preloaded_fragments[format_key(name)].values else - [source.public_send(name)].flatten(1).compact + options = { filters: ia && ia[:include_filters] || {} } + [source.public_send(name, options)].flatten(1).compact end resources.each do |resource| next if self_referential_and_already_in_source(resource) @@ -298,8 +320,11 @@ def cached_relationships_hash(source, include_directives) h = source.relationships || {} return h unless include_directives.has_key?(:include_related) - relationships = source.resource_klass._relationships.select{|k,v| source.fetchable_fields.include?(k) } + relationships = source.resource_klass._relationships.select do |k,v| + source.fetchable_fields.include?(k) + end + real_res = nil relationships.each do |rel_name, relationship| key = @key_formatter.format(rel_name) to_many = relationship.is_a? JSONAPI::Relationship::ToMany @@ -310,7 +335,17 @@ def cached_relationships_hash(source, include_directives) h[key][:data] = to_many ? [] : nil end - source.preloaded_fragments[key].each do |id, f| + fragments = source.preloaded_fragments[key] + if fragments.nil? + # The resources we want were not preloaded, we'll have to bypass the cache. + # This happens when including through belongs_to polymorphic relationships + if real_res.nil? + real_res = source.to_real_resource + end + relation_resources = [real_res.public_send(rel_name)].flatten(1).compact + fragments = relation_resources.map{|r| [r.id, r]}.to_h + end + fragments.each do |id, f| add_resource(f, ia) if h.has_key?(key) @@ -347,6 +382,13 @@ def related_link(source, relationship) link_builder.relationships_related_link(source, relationship) end + def default_relationship_links(source, relationship) + links = {} + links['self'] = self_link(source, relationship) unless relationship.exclude_link?(:self) + links['related'] = related_link(source, relationship) unless relationship.exclude_link?(:related) + links.compact + end + def to_one_linkage(source, relationship) linkage_id = foreign_key_value(source, relationship) linkage_type = format_key(relationship.type_for_source(source)) @@ -360,24 +402,29 @@ def to_one_linkage(source, relationship) def to_many_linkage(source, relationship) linkage = [] + include_config = include_directives.include_config(relationship.name.to_sym) if include_directives + include_filters = include_config[:include_filters] if include_config + options = { filters: include_filters || {} } + linkage_types_and_values = if source.preloaded_fragments.has_key?(format_key(relationship.name)) source.preloaded_fragments[format_key(relationship.name)].map do |_, resource| [relationship.type, resource.id] end elsif relationship.polymorphic? - assoc = source._model.public_send(relationship.name) + assoc = source.public_send("records_for_#{relationship.name}", options) # Avoid hitting the database again for values already pre-loaded if assoc.respond_to?(:loaded?) and assoc.loaded? assoc.map do |obj| [obj.type.underscore.pluralize, obj.id] end else + assoc = assoc.unscope(:includes) if assoc.is_a?(ActiveRecord::Relation) assoc.pluck(:type, :id).map do |type, id| [type.underscore.pluralize, id] end end else - source.public_send(relationship.name).map do |value| + source.public_send(relationship.name, options).map do |value| [relationship.type, value.id] end end @@ -390,46 +437,51 @@ def to_many_linkage(source, relationship) linkage end - def link_object_to_one(source, relationship, include_linkage) + def relationship_object_to_one(source, relationship, include_linkage) include_linkage = include_linkage | @always_include_to_one_linkage_data | relationship.always_include_linkage_data - link_object_hash = {} - link_object_hash[:links] = {} - link_object_hash[:links][:self] = self_link(source, relationship) - link_object_hash[:links][:related] = related_link(source, relationship) - link_object_hash[:data] = to_one_linkage(source, relationship) if include_linkage - link_object_hash + relationship_object_hash = {} + + links = default_relationship_links(source, relationship) + + relationship_object_hash['links'] = links unless links.blank? + relationship_object_hash[:data] = to_one_linkage(source, relationship) if include_linkage + relationship_object_hash end - def link_object_to_many(source, relationship, include_linkage) + def relationship_object_to_many(source, relationship, include_linkage) include_linkage = include_linkage | relationship.always_include_linkage_data - link_object_hash = {} - link_object_hash[:links] = {} - link_object_hash[:links][:self] = self_link(source, relationship) - link_object_hash[:links][:related] = related_link(source, relationship) - link_object_hash[:data] = to_many_linkage(source, relationship) if include_linkage - link_object_hash + relationship_object_hash = {} + + links = default_relationship_links(source, relationship) + relationship_object_hash['links'] = links unless links.blank? + relationship_object_hash[:data] = to_many_linkage(source, relationship) if include_linkage + relationship_object_hash end - def link_object(source, relationship, include_linkage = false) + def relationship_object(source, relationship, include_linkage = false) if relationship.is_a?(JSONAPI::Relationship::ToOne) - link_object_to_one(source, relationship, include_linkage) + relationship_object_to_one(source, relationship, include_linkage) elsif relationship.is_a?(JSONAPI::Relationship::ToMany) - link_object_to_many(source, relationship, include_linkage) + relationship_object_to_many(source, relationship, include_linkage) end end # Extracts the foreign key value for a to_one relationship. def foreign_key_value(source, relationship) - related_resource_id = if source.preloaded_fragments.has_key?(format_key(relationship.name)) - source.preloaded_fragments[format_key(relationship.name)].values.first.try(:id) - elsif source.respond_to?("#{relationship.name}_id") - # If you have direct access to the underlying id, you don't have to load the relationship - # which can save quite a lot of time when loading a lot of data. - # This does not apply to e.g. has_one :through relationships. - source.public_send("#{relationship.name}_id") - else - source.public_send(relationship.name).try(:id) - end + # If you have changed the key_name, don't even try to look at `"#{relationship.name}_id"` + # just load the association and call the custom key_name + foreign_key_type_changed = relationship.options[:foreign_key_type_changed] || false + related_resource_id = + if source.preloaded_fragments.has_key?(format_key(relationship.name)) + source.preloaded_fragments[format_key(relationship.name)].values.first.try(:id) + elsif !foreign_key_type_changed && source.respond_to?("#{relationship.name}_id") + # If you have direct access to the underlying id, you don't have to load the relationship + # which can save quite a lot of time when loading a lot of data. + # This does not apply to e.g. has_one :through relationships. + source.public_send("#{relationship.name}_id") + else + source.public_send(relationship.name).try(:id) + end return nil unless related_resource_id @id_formatter.format(related_resource_id) end @@ -490,8 +542,9 @@ def add_resource(source, include_directives, primary = false) def generate_link_builder(primary_resource_klass, options) LinkBuilder.new( base_url: options.fetch(:base_url, ''), - route_formatter: options.fetch(:route_formatter, JSONAPI.configuration.route_formatter), primary_resource_klass: primary_resource_klass, + route_formatter: options.fetch(:route_formatter, JSONAPI.configuration.route_formatter), + url_helpers: options.fetch(:url_helpers, options[:controller]), ) end end diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index 631ac0c72..28232b327 100644 --- a/lib/jsonapi/resources/version.rb +++ b/lib/jsonapi/resources/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Resources - VERSION = '0.9.0.pre' + VERSION = '0.9.12' end end diff --git a/lib/jsonapi/response_document.rb b/lib/jsonapi/response_document.rb index 092645947..8be03cbcd 100644 --- a/lib/jsonapi/response_document.rb +++ b/lib/jsonapi/response_document.rb @@ -64,14 +64,19 @@ def top_level_links # Build pagination links if result.is_a?(JSONAPI::ResourcesOperationResult) || result.is_a?(JSONAPI::RelatedResourcesOperationResult) - result.pagination_params.each_pair do |link_name, params| - if result.is_a?(JSONAPI::RelatedResourcesOperationResult) - relationship = result.source_resource.class._relationships[result._type.to_sym] - links[link_name] = @serializer.link_builder.relationships_related_link(result.source_resource, relationship, query_params(params)) - else - links[link_name] = @serializer.query_link(query_params(params)) + result.pagination_params.each_pair do |link_name, params| + if result.is_a?(JSONAPI::RelatedResourcesOperationResult) + relationship = result.source_resource.class._relationships[result._type.to_sym] + unless relationship.exclude_link?(link_name) + link = @serializer.link_builder.relationships_related_link(result.source_resource, relationship, query_params(params)) + end + else + unless @serializer.link_builder.primary_resource_klass.exclude_link?(link_name) + link = @serializer.link_builder.query_link(query_params(params)) end end + links[link_name] = link unless link.blank? + end end end @@ -109,9 +114,9 @@ def results_to_hash @serializer.serialize_to_hash(result.resource) when JSONAPI::ResourcesOperationResult @serializer.serialize_to_hash(result.resources) - when JSONAPI::LinksObjectOperationResult - @serializer.serialize_to_links_hash(result.parent_resource, - result.relationship) + when JSONAPI::RelationshipOperationResult + @serializer.serialize_to_relationship_hash(result.parent_resource, + result.relationship) when JSONAPI::OperationResult {} end diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index 77af89e7a..5aae3537e 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -20,6 +20,12 @@ def jsonapi_resource(*resources, &_block) @resource_type = resources.first res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(@resource_type)) + res._routed = true + + unless res.singleton? + warn "Singleton routes created for non singleton resource #{res}. Links may not be generated correctly." + end + options = resources.extract_options!.dup options[:controller] ||= @resource_type options.merge!(res.routing_resource_options) @@ -80,6 +86,12 @@ def jsonapi_resources(*resources, &_block) @resource_type = resources.first res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(@resource_type)) + res._routed = true + + if res.singleton? + warn "Singleton resource #{res} should use `jsonapi_resource` instead." + end + options = resources.extract_options!.dup options[:controller] ||= @resource_type options.merge!(res.routing_resource_options) @@ -153,19 +165,23 @@ def jsonapi_link(*links) methods = links_methods(options) if methods.include?(:show) - match "relationships/#{formatted_relationship_name}", controller: options[:controller], - action: 'show_relationship', relationship: link_type.to_s, via: [:get] + match "relationships/#{formatted_relationship_name}", + controller: options[:controller], + action: 'show_relationship', relationship: link_type.to_s, via: [:get], + as: "relationships/#{link_type}" end if res.mutable? if methods.include?(:update) - match "relationships/#{formatted_relationship_name}", controller: options[:controller], - action: 'update_relationship', relationship: link_type.to_s, via: [:put, :patch] + match "relationships/#{formatted_relationship_name}", + controller: options[:controller], + action: 'update_relationship', relationship: link_type.to_s, via: [:put, :patch] end if methods.include?(:destroy) - match "relationships/#{formatted_relationship_name}", controller: options[:controller], - action: 'destroy_relationship', relationship: link_type.to_s, via: [:delete] + match "relationships/#{formatted_relationship_name}", + controller: options[:controller], + action: 'destroy_relationship', relationship: link_type.to_s, via: [:delete] end end end @@ -182,23 +198,24 @@ def jsonapi_links(*links) if methods.include?(:show) match "relationships/#{formatted_relationship_name}", controller: options[:controller], - action: 'show_relationship', relationship: link_type.to_s, via: [:get] + action: 'show_relationship', relationship: link_type.to_s, via: [:get], + as: "relationships/#{link_type}" end if res.mutable? if methods.include?(:create) match "relationships/#{formatted_relationship_name}", controller: options[:controller], - action: 'create_relationship', relationship: link_type.to_s, via: [:post] + action: 'create_relationship', relationship: link_type.to_s, via: [:post] end if methods.include?(:update) match "relationships/#{formatted_relationship_name}", controller: options[:controller], - action: 'update_relationship', relationship: link_type.to_s, via: [:put, :patch] + action: 'update_relationship', relationship: link_type.to_s, via: [:put, :patch] end if methods.include?(:destroy) match "relationships/#{formatted_relationship_name}", controller: options[:controller], - action: 'destroy_relationship', relationship: link_type.to_s, via: [:delete] + action: 'destroy_relationship', relationship: link_type.to_s, via: [:delete] end end end @@ -210,6 +227,8 @@ def jsonapi_related_resource(*relationship) relationship_name = relationship.first relationship = source._relationships[relationship_name] + relationship._routed = true + formatted_relationship_name = format_route(relationship.name) if relationship.polymorphic? @@ -219,9 +238,10 @@ def jsonapi_related_resource(*relationship) options[:controller] ||= related_resource._type.to_s end - match "#{formatted_relationship_name}", controller: options[:controller], - relationship: relationship.name, source: resource_type_with_module_prefix(source._type), - action: 'get_related_resource', via: [:get] + match formatted_relationship_name, controller: options[:controller], + relationship: relationship.name, source: resource_type_with_module_prefix(source._type), + action: 'get_related_resource', via: [:get], + as: "related/#{relationship_name}" end def jsonapi_related_resources(*relationship) @@ -231,13 +251,17 @@ def jsonapi_related_resources(*relationship) relationship_name = relationship.first relationship = source._relationships[relationship_name] + relationship._routed = true + formatted_relationship_name = format_route(relationship.name) related_resource = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(relationship.class_name.underscore)) options[:controller] ||= related_resource._type.to_s - match "#{formatted_relationship_name}", controller: options[:controller], - relationship: relationship.name, source: resource_type_with_module_prefix(source._type), - action: 'get_related_resources', via: [:get] + match formatted_relationship_name, + controller: options[:controller], + relationship: relationship.name, source: resource_type_with_module_prefix(source._type), + action: 'get_related_resources', via: [:get], + as: "related/#{relationship_name}" end protected diff --git a/locales/en.yml b/locales/en.yml index 7a03182a6..18ed6a31a 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -55,7 +55,7 @@ en: invalid_sort_criteria: title: 'Invalid sort criteria' detail: "%{sort_criteria} is not a valid sort criteria for %{resource}" - parameters_not_allowed: + parameter_not_allowed: title: 'Param not allowed' detail: "%{param} is not allowed." parameter_missing: diff --git a/test/benchmark/request_benchmark.rb b/test/benchmark/request_benchmark.rb index d42d3a695..b71cdf0b5 100644 --- a/test/benchmark/request_benchmark.rb +++ b/test/benchmark/request_benchmark.rb @@ -7,7 +7,7 @@ def setup end def bench_large_index_request_uncached - 10.times do + 100.times do assert_jsonapi_get '/api/v2/books?include=bookComments,bookComments.author' end end @@ -15,7 +15,7 @@ def bench_large_index_request_uncached def bench_large_index_request_caching cache = ActiveSupport::Cache::MemoryStore.new with_resource_caching(cache) do - 10.times do + 100.times do assert_jsonapi_get '/api/v2/books?include=bookComments,bookComments.author' end end diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 83c0bdfac..c69b5eb8b 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -423,7 +423,7 @@ def test_sorting_by_relationship_field assert_cacheable_get :index, params: {sort: 'author.name'} assert_response :success - assert json_response['data'].length > 10, 'there are enough recordsto show sort' + assert json_response['data'].length > 10, 'there are enough records to show sort' assert_equal '17', json_response['data'][0]['id'], 'nil is at the top' assert_equal post.id.to_s, json_response['data'][1]['id'], 'alphabetically first user is second' end @@ -438,6 +438,16 @@ def test_desc_sorting_by_relationship_field assert_equal post.id.to_s, json_response['data'][-2]['id'], 'alphabetically first user is second last' end + def test_sorting_by_relationship_field_include + post = create_alphabetically_first_user_and_post + assert_cacheable_get :index, params: {include: 'author', sort: 'author.name'} + + assert_response :success + assert json_response['data'].length > 10, 'there are enough records to show sort' + assert_equal '17', json_response['data'][0]['id'], 'nil is at the top' + assert_equal post.id.to_s, json_response['data'][1]['id'], 'alphabetically first user is second' + end + def test_invalid_sort_param assert_cacheable_get :index, params: {sort: 'asdfg'} @@ -473,7 +483,7 @@ def test_show_does_not_include_records_count_in_meta JSONAPI.configuration.top_level_meta_include_record_count = true assert_cacheable_get :show, params: { id: Post.first.id } assert_response :success - assert_equal json_response['meta'], nil + assert_nil json_response['meta'] ensure JSONAPI.configuration.top_level_meta_include_record_count = false end @@ -482,7 +492,7 @@ def test_show_does_not_include_pages_count_in_meta JSONAPI.configuration.top_level_meta_include_page_count = true assert_cacheable_get :show, params: { id: Post.first.id } assert_response :success - assert_equal json_response['meta'], nil + assert_nil json_response['meta'] ensure JSONAPI.configuration.top_level_meta_include_page_count = false end @@ -499,6 +509,22 @@ def test_show_single_with_includes assert_equal 2, json_response['included'].size end + def test_show_with_filtered_includes_when_not_eager_loaded + # tags are not eagerly loaded on a post but may still be filtered + get :show, params: { id: '1', include: 'tags', filter: { 'tags.name' => ['whiny'] } } + assert_response :success + assert_equal 1, json_response['included'].size + end + + def test_show_with_filtered_includes_when_not_eager_loaded_and_all_filtered_out + get :show, params: { id: '1', include: 'tags', filter: { 'tags.name' => ['no-tag-with-this-name'] } } + assert_response :success + assert json_response['data'].is_a?(Hash) + assert_equal 'New post', json_response['data']['attributes']['title'] + assert_equal 'A body!!!', json_response['data']['attributes']['body'] + assert_nil json_response['included'] + end + def test_show_single_with_include_disallowed JSONAPI.configuration.allow_include = false assert_cacheable_get :show, params: {id: '1', include: 'comments'} @@ -564,6 +590,7 @@ def test_create_simple assert json_response['data'].is_a?(Hash) assert_equal 'JR is Great', json_response['data']['attributes']['title'] assert_equal 'JSONAPIResources is the greatest thing since unsliced bread.', json_response['data']['attributes']['body'] + assert_equal "http://test.host/posts/#{json_response['data']['id']}", json_response['data']['links']['self'] assert_equal json_response['data']['links']['self'], response.location end @@ -586,7 +613,7 @@ def test_create_simple_id_not_allowed assert_response :bad_request assert_match /id is not allowed/, response.body - assert_equal nil,response.location + assert_nil response.location end def test_create_link_to_missing_object @@ -608,7 +635,7 @@ def test_create_link_to_missing_object assert_response :unprocessable_entity # TODO: check if this validation is working assert_match /author - can't be blank/, response.body - assert_equal nil, response.location + assert_nil response.location end def test_create_extra_param @@ -630,7 +657,7 @@ def test_create_extra_param assert_response :bad_request assert_match /asdfg is not allowed/, response.body - assert_equal nil,response.location + assert_nil response.location end def test_create_extra_param_allow_extra_params @@ -667,7 +694,7 @@ def test_create_extra_param_allow_extra_params assert_equal "Param not allowed", json_response['meta']["warnings"][1]["title"] assert_equal "asdfg is not allowed.", json_response['meta']["warnings"][1]["detail"] assert_equal '105', json_response['meta']["warnings"][1]["code"] - assert_equal json_response['data']['links']['self'], response.location + assert_equal "http://test.host/posts/#{json_response['data']['id']}", json_response['data']['links']['self'] ensure JSONAPI.configuration.raise_if_parameters_not_allowed = true end @@ -697,7 +724,7 @@ def test_create_with_invalid_data assert_equal "/data/attributes/title", json_response['errors'][1]['source']['pointer'] assert_equal "is too long (maximum is 35 characters)", json_response['errors'][1]['title'] assert_equal "title - is too long (maximum is 35 characters)", json_response['errors'][1]['detail'] - assert_equal nil, response.location + assert_nil response.location end def test_create_multiple @@ -750,7 +777,7 @@ def test_create_simple_missing_posts assert_response :bad_request assert_match /The required parameter, data, is missing./, json_response['errors'][0]['detail'] - assert_equal nil, response.location + assert_nil response.location end def test_create_simple_wrong_type @@ -771,7 +798,7 @@ def test_create_simple_wrong_type assert_response :bad_request assert_match /posts_spelled_wrong is not a valid resource./, json_response['errors'][0]['detail'] - assert_equal nil, response.location + assert_nil response.location end def test_create_simple_missing_type @@ -791,7 +818,7 @@ def test_create_simple_missing_type assert_response :bad_request assert_match /The required parameter, type, is missing./, json_response['errors'][0]['detail'] - assert_equal nil, response.location + assert_nil response.location end def test_create_simple_unpermitted_attributes @@ -812,7 +839,7 @@ def test_create_simple_unpermitted_attributes assert_response :bad_request assert_match /subject/, json_response['errors'][0]['detail'] - assert_equal nil, response.location + assert_nil response.location end def test_create_simple_unpermitted_attributes_allow_extra_params @@ -847,7 +874,7 @@ def test_create_simple_unpermitted_attributes_allow_extra_params assert_equal "Param not allowed", json_response['meta']["warnings"][0]["title"] assert_equal "subject is not allowed.", json_response['meta']["warnings"][0]["detail"] assert_equal '105', json_response['meta']["warnings"][0]["code"] - assert_equal json_response['data']['links']['self'], response.location + assert_equal "http://test.host/posts/#{json_response['data']['id']}", json_response['data']['links']['self'] ensure JSONAPI.configuration.raise_if_parameters_not_allowed = true end @@ -875,7 +902,7 @@ def test_create_with_links_to_many_type_ids assert_equal '3', json_response['data']['relationships']['author']['data']['id'] assert_equal 'JR is Great', json_response['data']['attributes']['title'] assert_equal 'JSONAPIResources is the greatest thing since unsliced bread.', json_response['data']['attributes']['body'] - assert_equal json_response['data']['links']['self'], response.location + assert_equal "http://test.host/posts/#{json_response['data']['id']}", json_response['data']['links']['self'] end def test_create_with_links_to_many_array @@ -901,7 +928,7 @@ def test_create_with_links_to_many_array assert_equal '3', json_response['data']['relationships']['author']['data']['id'] assert_equal 'JR is Great', json_response['data']['attributes']['title'] assert_equal 'JSONAPIResources is the greatest thing since unsliced bread.', json_response['data']['attributes']['body'] - assert_equal json_response['data']['links']['self'], response.location + assert_equal "http://test.host/posts/#{json_response['data']['id']}", json_response['data']['links']['self'] end def test_create_with_links_include_and_fields @@ -928,7 +955,7 @@ def test_create_with_links_include_and_fields assert_equal '3', json_response['data']['relationships']['author']['data']['id'] assert_equal 'JR is Great!', json_response['data']['attributes']['title'] assert_not_nil json_response['included'].size - assert_equal json_response['data']['links']['self'], response.location + assert_equal "http://test.host/posts/#{json_response['data']['id']}", json_response['data']['links']['self'] end def test_update_with_links @@ -1076,7 +1103,7 @@ def test_update_remove_links assert_response :success assert json_response['data'].is_a?(Hash) assert_equal '3', json_response['data']['relationships']['author']['data']['id'] - assert_equal nil, json_response['data']['relationships']['section']['data'] + assert_nil json_response['data']['relationships']['section']['data'] assert_equal 'A great new Post', json_response['data']['attributes']['title'] assert_equal 'AAAA', json_response['data']['attributes']['body'] assert matches_array?([], @@ -1106,7 +1133,7 @@ def test_update_relationship_to_one_nil assert_response :no_content post_object = Post.find(4) - assert_equal nil, post_object.section_id + assert_nil post_object.section_id end def test_update_relationship_to_one_invalid_links_hash_keys_ids @@ -1223,7 +1250,7 @@ def test_update_relationship_to_one_singular_param_id_nil put :update_relationship, params: {post_id: 3, relationship: 'section', data: {type: 'sections', id: nil}} assert_response :no_content - assert_equal nil, post_object.reload.section_id + assert_nil post_object.reload.section_id end def test_update_relationship_to_one_data_nil @@ -1236,7 +1263,7 @@ def test_update_relationship_to_one_data_nil put :update_relationship, params: {post_id: 3, relationship: 'section', data: nil} assert_response :no_content - assert_equal nil, post_object.reload.section_id + assert_nil post_object.reload.section_id end def test_remove_relationship_to_one @@ -1250,7 +1277,7 @@ def test_remove_relationship_to_one assert_response :no_content post_object = Post.find(3) - assert_equal nil, post_object.section_id + assert_nil post_object.section_id end def test_update_relationship_to_one_singular_param @@ -1267,6 +1294,25 @@ def test_update_relationship_to_one_singular_param assert_equal ruby.id, post_object.section_id end + def test_remove_relationship_to_many_belongs_to + set_content_type_header! + c = Comment.find(3) + p = Post.find(2) + total_comment_count = Comment.count + post_comment_count = p.comments.count + + put :destroy_relationship, params: {post_id: "#{p.id}", relationship: 'comments', data: [{type: 'comments', id: "#{c.id}"}]} + + assert_response :no_content + p = Post.find(2) + c = Comment.find(3) + + assert_equal post_comment_count - 1, p.comments.length + assert_equal total_comment_count, Comment.count + + assert_nil c.post_id + end + def test_update_relationship_to_many_join_table_single set_content_type_header! put :update_relationship, params: {post_id: 3, relationship: 'tags', data: []} @@ -1793,7 +1839,6 @@ def test_update_unpermitted_attributes } assert_response :bad_request - assert_match /author is not allowed./, response.body assert_match /subject is not allowed./, response.body end @@ -1888,6 +1933,24 @@ def test_show_to_one_relationship_nil } } end + + def test_get_related_resources_sorted + assert_cacheable_get :get_related_resources, params: {person_id: '1', relationship: 'posts', source:'people', sort: 'title' } + assert_response :success + assert_equal 'JR How To', json_response['data'][0]['attributes']['title'] + assert_equal 'New post', json_response['data'][2]['attributes']['title'] + assert_cacheable_get :get_related_resources, params: {person_id: '1', relationship: 'posts', source:'people', sort: '-title' } + assert_response :success + assert_equal 'New post', json_response['data'][0]['attributes']['title'] + assert_equal 'JR How To', json_response['data'][2]['attributes']['title'] + end + + def test_get_related_resources_default_sorted + assert_cacheable_get :get_related_resources, params: {person_id: '1', relationship: 'posts', source:'people'} + assert_response :success + assert_equal 'New post', json_response['data'][0]['attributes']['title'] + assert_equal 'JR How To', json_response['data'][2]['attributes']['title'] + end end class TagsControllerTest < ActionController::TestCase @@ -1921,6 +1984,75 @@ def test_tags_show_multiple_with_nonexistent_ids_at_the_beginning assert_response :bad_request assert_match /99,9,100 is not a valid value for id/, response.body end + + def test_nested_includes_sort + assert_cacheable_get :index, params: {filter: {id: '6,7,8,9'}, + include: 'posts.tags,posts.author.posts', + sort: 'name'} + assert_response :success + assert_equal 4, json_response['data'].size + assert_equal 3, json_response['included'].size + end +end + +class PicturesControllerTest < ActionController::TestCase + def test_pictures_index + assert_cacheable_get :index + assert_response :success + assert_equal 3, json_response['data'].size + end + + def test_pictures_index_with_polymorphic_include_one_level + assert_cacheable_get :index, params: {include: 'imageable'} + assert_response :success + assert_equal 3, json_response['data'].size + assert_equal 2, json_response['included'].size + end +end + +class DocumentsControllerTest < ActionController::TestCase + def test_documents_index + assert_cacheable_get :index + assert_response :success + assert_equal 1, json_response['data'].size + end + + def test_documents_index_with_polymorphic_include_one_level + assert_cacheable_get :index, params: {include: 'pictures'} + assert_response :success + assert_equal 1, json_response['data'].size + assert_equal 1, json_response['included'].size + end +end + +class PicturesControllerTest < ActionController::TestCase + def test_pictures_index + assert_cacheable_get :index + assert_response :success + assert_equal 3, json_response['data'].size + end + + def test_pictures_index_with_polymorphic_include_one_level + assert_cacheable_get :index, params: {include: 'imageable'} + assert_response :success + assert_equal 3, json_response['data'].size + assert_equal 2, json_response['included'].size + end +end + +class DocumentsControllerTest < ActionController::TestCase + def test_documents_index + assert_cacheable_get :index + assert_response :success + assert_equal 1, json_response['data'].size + end + + def test_documents_index_with_polymorphic_include_one_level + assert_cacheable_get :index, params: {include: 'pictures'} + assert_response :success + assert_equal 1, json_response['data'].size + assert_equal 1, json_response['included'].size + end end class ExpenseEntriesControllerTest < ActionController::TestCase @@ -1961,7 +2093,6 @@ def test_expense_entries_show_bad_include_missing_relationship assert_cacheable_get :show, params: {id: 1, include: 'isoCurrencies,employees'} assert_response :bad_request assert_match /isoCurrencies is not a valid relationship of expenseEntries/, json_response['errors'][0]['detail'] - assert_match /employees is not a valid relationship of expenseEntries/, json_response['errors'][1]['detail'] end def test_expense_entries_show_bad_include_missing_sub_relationship @@ -1970,6 +2101,18 @@ def test_expense_entries_show_bad_include_missing_sub_relationship assert_match /post is not a valid relationship of people/, json_response['errors'][0]['detail'] end + def test_invalid_include + assert_cacheable_get :index, params: {include: 'invalid../../../../'} + assert_response :bad_request + assert_match /invalid is not a valid relationship of expenseEntries/, json_response['errors'][0]['detail'] + end + + def test_invalid_include_long_garbage_string + assert_cacheable_get :index, params: {include: 'invalid.foo.bar.dfsdfs,dfsdfs.sdfwe.ewrerw.erwrewrew'} + assert_response :bad_request + assert_match /invalid is not a valid relationship of expenseEntries/, json_response['errors'][0]['detail'] + end + def test_expense_entries_show_fields assert_cacheable_get :show, params: {id: 1, include: 'isoCurrency,employee', 'fields' => {'expenseEntries' => 'transactionDate'}} assert_response :success @@ -2341,6 +2484,17 @@ def test_invalid_filter_value assert_response :bad_request end + def test_invalid_filter_value_for_get_related_resources + assert_cacheable_get :get_related_resources, params: { + hair_cut_id: 1, + relationship: 'people', + source: 'hair_cuts', + filter: {name: 'L'} + } + + assert_response :bad_request + end + def test_valid_filter_value assert_cacheable_get :index, params: {filter: {name: 'Joe Author'}} assert_response :success @@ -2439,7 +2593,7 @@ def test_destroy_relationship_has_and_belongs_to_many JSONAPI.configuration.use_relationship_reflection = false end - def test_destroy_relationship_has_and_belongs_to_many_refect + def test_destroy_relationship_has_and_belongs_to_many_reflect JSONAPI.configuration.use_relationship_reflection = true assert_equal 2, Book.find(2).authors.count @@ -2451,6 +2605,124 @@ def test_destroy_relationship_has_and_belongs_to_many_refect ensure JSONAPI.configuration.use_relationship_reflection = false end + + def test_index_with_caching_enabled_uses_context + assert_cacheable_get :index + assert_response :success + assert json_response['data'][0]['attributes']['title'] = 'Title' + end +end + +class Api::V5::PostsControllerTest < ActionController::TestCase + def test_show_post_no_relationship_routes_exludes_relationships + assert_cacheable_get :show, params: {id: '1'} + assert_response :success + assert_nil json_response['data']['relationships'] + end + + def test_exclude_resource_links + assert_cacheable_get :show, params: {id: '1'} + assert_response :success + assert_nil json_response['data']['relationships'] + assert_equal 1, json_response['data']['links'].length + + Api::V5::PostResource.exclude_links :default + assert_cacheable_get :show, params: {id: '1'} + assert_response :success + assert_nil json_response['data']['relationships'] + assert_nil json_response['data']['links'] + + Api::V5::PostResource.exclude_links [:self] + assert_cacheable_get :show, params: {id: '1'} + assert_response :success + assert_nil json_response['data']['relationships'] + assert_nil json_response['data']['links'] + + Api::V5::PostResource.exclude_links :none + assert_cacheable_get :show, params: {id: '1'} + assert_response :success + assert_nil json_response['data']['relationships'] + assert_equal 1, json_response['data']['links'].length + ensure + Api::V5::PostResource.exclude_links :none + end + + def test_show_post_no_relationship_route_include + get :show, params: {id: '1', include: 'author'} + assert_response :success + assert_equal '1', json_response['data']['relationships']['author']['data']['id'] + assert_nil json_response['data']['relationships']['tags'] + assert_equal '1', json_response['included'][0]['id'] + assert_equal 'people', json_response['included'][0]['type'] + assert_equal 'joe@xyz.fake', json_response['included'][0]['attributes']['email'] + end +end + +class Api::V5::PaintersControllerTest < ActionController::TestCase + def test_index_with_included_resources_with_filters + # There are two painters, but by filtering the included relationship, the + # painters are limited due to the join, thus only the painter with oil + # paintings is returned. + get :index, params: { include: 'paintings', filter: { 'paintings.category' => 'oil' } } + assert_response :success + assert_equal 1, json_response['data'].size, 'Size of data is wrong' + assert_equal '1', json_response['data'][0]['id'] + assert_equal 2, json_response['included'].size, 'Size of included data is wrong' + assert_equal '4', json_response['included'][0]['id'] + assert_equal '5', json_response['included'][1]['id'] + end + + def test_index_with_filters_and_included_resources_with_filters + get :index, params: { include: 'paintings', filter: { 'name' => 'Wyspianski', 'paintings.category' => 'oil' } } + + assert_response :success + assert_equal 1, json_response['data'].size + assert_equal '1', json_response['data'][0]['id'] + assert_equal 2, json_response['included'].size + assert_equal '4', json_response['included'][0]['id'] + end + + def test_index_with_filters_and_included_resources_with_multiple_filters + # Painting 5 is the genuine, but painting 6 is a fake. Verify that multiple nested filters are merged and only the oil painting is returned. + get :index, params: { include: 'paintings', filter: { 'name' => 'Wyspianski', 'paintings.category' => 'oil', 'paintings.title' => 'Motherhood' } } + + assert_response :success + assert_equal 1, json_response['data'].size + assert_equal '1', json_response['data'][0]['id'] + assert_equal 1, json_response['included'].size + assert_equal '5', json_response['included'][0]['id'] + end + + def test_show_with_filters_and_included_resources_with_filters + get :show, params: { id: 1, include: 'paintings', filter: { 'paintings.category' => 'oil' } } + assert_response :success + assert_equal '1', json_response['data']['id'] + assert_equal 2, json_response['included'].size + assert_equal '4', json_response['included'][0]['id'] + end + + def test_show_with_filters_and_no_included_resources + get :show, params: { id: 1, filter: { 'paintings.category' => 'oil' } } + assert_response :bad_request + assert_equal('Filter not allowed', json_response['errors'][0]['title']) + assert_equal('category is not allowed.', json_response['errors'][0]['detail']) + end +end + +class Api::V5::CollectorsControllerTest < ActionController::TestCase + def test_index_with_custom_filter_that_has_dot + get :index, params: { + filter: { + 'name' => 'Alice', + 'painting.title_equals': 'Helenka' + } + } + + assert_response :success + assert_equal 1, json_response['data'].size + assert_equal '1', json_response['data'][0]['id'] + refute json_response.key?('included') + end end class Api::V5::AuthorsControllerTest < ActionController::TestCase @@ -2461,7 +2733,7 @@ def test_get_person_as_author assert_equal '1', json_response['data'][0]['id'] assert_equal 'authors', json_response['data'][0]['type'] assert_equal 'Joe Author', json_response['data'][0]['attributes']['name'] - assert_equal nil, json_response['data'][0]['attributes']['email'] + assert_nil json_response['data'][0]['attributes']['email'] end def test_show_person_as_author @@ -2470,7 +2742,7 @@ def test_show_person_as_author assert_equal '1', json_response['data']['id'] assert_equal 'authors', json_response['data']['type'] assert_equal 'Joe Author', json_response['data']['attributes']['name'] - assert_equal nil, json_response['data']['attributes']['email'] + assert_nil json_response['data']['attributes']['email'] end def test_get_person_as_author_by_name_filter @@ -2550,6 +2822,65 @@ def meta(options) end end +class Api::V2::BooksControllerTest < ActionController::TestCase + def test_get_related_resources_with_filters + $test_user = Person.find(5) + original_config = JSONAPI.configuration.dup + JSONAPI.configuration.top_level_meta_include_record_count = true + JSONAPI.configuration.json_key_format = :dasherized_key + assert_cacheable_get :get_related_resources, + params: { + author_id: '1', + relationship: 'books', + source: 'api/v2/authors', + filter: { fiction: 'true' } + } + assert_response :success + assert_equal 1, json_response['meta']['record-count'] + ensure + JSONAPI.configuration = original_config + end +end + +class Api::V2::BooksControllerTest < ActionController::TestCase + def test_get_related_resources_with_filters + $test_user = Person.find(5) + original_config = JSONAPI.configuration.dup + JSONAPI.configuration.top_level_meta_include_record_count = true + JSONAPI.configuration.json_key_format = :dasherized_key + assert_cacheable_get :get_related_resources, + params: { + author_id: '1', + relationship: 'books', + source: 'api/v2/authors', + filter: { fiction: 'true' } + } + assert_response :success + assert_equal 1, json_response['meta']['record-count'] + ensure + JSONAPI.configuration = original_config + end + + def test_get_related_resources_with_filters_2 + # Admin user can find an author's banned books + $test_user = Person.find(5) + original_config = JSONAPI.configuration.dup + JSONAPI.configuration.top_level_meta_include_record_count = true + JSONAPI.configuration.json_key_format = :dasherized_key + assert_cacheable_get :get_related_resources, + params: { + author_id: '1', + relationship: 'books', + source: 'api/v2/authors', + filter: { banned: 'true' } + } + assert_response :success + assert_equal 1, json_response['meta']['record-count'] + ensure + JSONAPI.configuration = original_config + end +end + class BreedsControllerTest < ActionController::TestCase # Note: Breed names go through the TitleValueFormatter @@ -2652,12 +2983,15 @@ def test_poro_delete class Api::V2::PreferencesControllerTest < ActionController::TestCase def test_show_singleton_resource_without_id + $test_user = Person.find(1) + assert_cacheable_get :show assert_response :success end def test_update_singleton_resource_without_id set_content_type_header! + $test_user = Person.find(1) patch :update, params: { data: { id: "1", @@ -3049,7 +3383,7 @@ def test_books_banned_non_book_admin_includes_nested_includes end assert_response :success assert_equal 12, json_response['data'].size - assert_equal 132, json_response['included'].size + assert_equal 135, json_response['included'].size assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] assert_equal 901, json_response['meta']['record-count'] ensure @@ -3557,6 +3891,11 @@ def test_complex_includes_base assert_response :success end + def test_complex_includes_filters_nil_includes + assert_cacheable_get :index, params: {include: ',,'} + assert_response :success + end + def test_complex_includes_two_level assert_cacheable_get :index, params: {include: 'things,things.user'} diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 778c72538..746307c9d 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -120,6 +120,7 @@ create_table :preferences, force: true do |t| t.integer :person_id t.boolean :advanced_mode, default: false + t.string :nickname t.timestamps null: false end @@ -141,6 +142,7 @@ t.string :title t.string :isbn t.boolean :banned, default: false + t.boolean :fiction, default: false t.timestamps null: false end @@ -291,6 +293,51 @@ t.timestamps null: false end + create_table :questions, force: true do |t| + t.string :text + end + + create_table :answers, force: true do |t| + t.references :question + t.integer :respondent_id + t.string :respondent_type + t.string :text + end + + create_table :patients, force: true do |t| + t.string :name + end + + create_table :doctors, force: true do |t| + t.string :name + end + + create_table :painters, force: true do |t| + t.string :name + + t.timestamps null: false + end + + create_table :paintings, force: true do |t| + t.string :title + t.string :category + t.belongs_to :painter + + t.timestamps null: false + end + + create_table :collectors, force: true do |t| + t.string :name + t.belongs_to :painting + end + + create_table :lists, force: true do |t| + t.string :name + end + + create_table :list_items, force: true do |t| + t.belongs_to :list + end # special cases end @@ -323,7 +370,7 @@ class Post < ActiveRecord::Base belongs_to :writer, class_name: 'Person', foreign_key: 'author_id' has_many :comments has_and_belongs_to_many :tags, join_table: :posts_tags - has_many :special_post_tags, source: :tag + has_many :special_post_tags has_many :special_tags, through: :special_post_tags, source: :tag belongs_to :section has_one :parent_post, class_name: 'Post', foreign_key: 'parent_post_id' @@ -375,6 +422,7 @@ class Section < ActiveRecord::Base end class HairCut < ActiveRecord::Base + has_many :people end class Property < ActiveRecord::Base @@ -602,8 +650,27 @@ class Thing < ActiveRecord::Base end class RelatedThing < ActiveRecord::Base - belongs_to :from, class_name: Thing, foreign_key: :from_id - belongs_to :to, class_name: Thing, foreign_key: :to_id + belongs_to :from, class_name: "Thing", foreign_key: :from_id + belongs_to :to, class_name: "Thing", foreign_key: :to_id +end + +class Question < ActiveRecord::Base + has_one :answer + + def respondent + answer.try(:respondent) + end +end + +class Answer < ActiveRecord::Base + belongs_to :question + belongs_to :respondent, polymorphic: true +end + +class Patient < ActiveRecord::Base +end + +class Doctor < ActiveRecord::Base end module Api @@ -616,6 +683,27 @@ class Customer < Customer end end +class Painter < ActiveRecord::Base + has_many :paintings +end + +class Painting < ActiveRecord::Base + belongs_to :painter + has_many :collectors +end + +class Collector < ActiveRecord::Base + belongs_to :painting +end + +class List < ActiveRecord::Base + has_many :items, class_name: 'ListItem', inverse_of: :list +end + +class ListItem < ActiveRecord::Base + belongs_to :list, inverse_of: :items +end + ### CONTROLLERS class AuthorsController < JSONAPI::ResourceControllerMetal end @@ -717,6 +805,9 @@ class BoatsController < JSONAPI::ResourceController end class BooksController < JSONAPI::ResourceController + def context + { title: 'Title' } + end end ### CONTROLLERS @@ -770,10 +861,16 @@ class AuthorsController < JSONAPI::ResourceController class PeopleController < JSONAPI::ResourceController end + class CommentsController < JSONAPI::ResourceController + end + class PostsController < JSONAPI::ResourceController end class PreferencesController < JSONAPI::ResourceController + def context + {current_user: $test_user} + end end class BooksController < JSONAPI::ResourceController @@ -823,6 +920,12 @@ class ExpenseEntriesController < JSONAPI::ResourceController class IsoCurrenciesController < JSONAPI::ResourceController end + + class PaintersController < JSONAPI::ResourceController + end + + class CollectorsController < JSONAPI::ResourceController + end end module V6 @@ -872,6 +975,20 @@ module V8 class NumerosTelefoneController < JSONAPI::ResourceController end end + + module V9 + class PeopleController < JSONAPI::ResourceController + def context + {current_user: $test_user} + end + end + + class PreferencesController < JSONAPI::ResourceController + def context + {current_user: $test_user} + end + end + end end module Api @@ -879,6 +996,27 @@ class BoxesController < JSONAPI::ResourceController end end +class QuestionsController < JSONAPI::ResourceController +end + +class AnswersController < JSONAPI::ResourceController +end + +class PatientsController < JSONAPI::ResourceController +end + +class DoctorsController < JSONAPI::ResourceController +end + +class RespondentController < JSONAPI::ResourceController +end + +class ListsController < JSONAPI::ResourceController +end + +class ListItemsController < JSONAPI::ResourceController +end + ### RESOURCES class BaseResource < JSONAPI::Resource abstract @@ -908,7 +1046,7 @@ def self.verify_name_filter(values, _context) end -class PersonWithEvenAndOddPostsResource < JSONAPI::Resource +class PersonWithEvenAndOddPostResource < JSONAPI::Resource model_name 'Person' has_many :even_posts, foreign_key: 'author_id', class_name: 'Post', relation_name: :even_posts @@ -937,10 +1075,12 @@ class VehicleResource < JSONAPI::Resource end class CarResource < VehicleResource + model_name "Car" attributes :drive_layout end class BoatResource < VehicleResource + model_name "Boat" attributes :length_at_water_line end @@ -958,12 +1098,14 @@ class CompanyResource < JSONAPI::Resource end class FirmResource < CompanyResource + model_name "Firm" end class TagResource < JSONAPI::Resource attributes :name has_many :posts + filter :name # Not including the planets relationship so they don't get output #has_many :planets end @@ -993,6 +1135,10 @@ class PostResource < JSONAPI::Resource # Not needed - just for testing primary_key :id + def self.default_sort + [{field: 'title', direction: :desc}, {field: 'id', direction: :desc}] + end + before_save do msg = "Before save" end @@ -1063,7 +1209,7 @@ def self.updatable_fields(context) end def self.creatable_fields(context) - super(context) - [:subject, :id] + super(context) - [:subject] end def self.sortable_fields(context) @@ -1085,6 +1231,10 @@ class HairCutResource < JSONAPI::Resource class IsoCurrencyResource < JSONAPI::Resource attributes :name, :country_name, :minor_unit + def self.creatable_fields(_context = nil) + super + [:id] + end + filter :country_name key_type :string @@ -1135,10 +1285,6 @@ class PlanetResource < JSONAPI::Resource has_one :planet_type has_many :tags, acts_as_set: true - - def records_for_moons(opts = {}) - Moon.joins(:craters).select('moons.*, craters.code').distinct - end end class PropertyResource < JSONAPI::Resource @@ -1179,6 +1325,12 @@ def self.verify_key(key, context = nil) class PreferencesResource < JSONAPI::Resource attribute :advanced_mode + singleton singleton_key: -> (context) { + key = context[:current_user].try(:preferences).try(:id) + raise JSONAPI::Exceptions::RecordNotFound.new(nil) if key.nil? + key + } + has_one :author, :foreign_key_on => :related def self.find_records(filters, options = {}) @@ -1243,10 +1395,17 @@ class AuthorResource < JSONAPI::Resource attributes :name has_many :books, inverse_relationship: :authors + has_one :preference end class BookResource < JSONAPI::Resource + attribute :title + has_many :authors, class_name: 'Author', inverse_relationship: :books + + def title + context[:title] + end end class AuthorDetailResource < JSONAPI::Resource @@ -1365,6 +1524,15 @@ def subject end filters :writer + + def custom_links(options) + self_link = options[:serializer].link_builder.self_link(self) + self_link ||= '' + { + 'self' => self_link + '?secret=true', + 'raw' => self_link + "/raw" + } + end end class PersonResource < PersonResource; end @@ -1391,15 +1559,25 @@ module Api module V2 class PreferencesResource < PreferencesResource; end class PersonResource < PersonResource; end + class CommentResource < CommentResource; end class PostResource < PostResource; end + class AuthorResource < AuthorResource; end + class VehicleResource < VehicleResource; end + class CarResource < CarResource; end + class BoatResource < BoatResource; end + class HairCutResource < HairCutResource; end + class TagResource < TagResource; end + class SectionResource < SectionResource; end class BookResource < JSONAPI::Resource - attribute :title - attributes :isbn, :banned + attribute "title" + attributes :isbn, :banned, :fiction - has_many :authors + paginator :offset - has_many :book_comments, relation_name: -> (options = {}) { + has_many "authors" + + has_many "book_comments", relation_name: -> (options = {}) { context = options[:context] current_user = context ? context[:current_user] : nil @@ -1410,10 +1588,11 @@ class BookResource < JSONAPI::Resource end }, reflect: true - has_many :aliased_comments, class_name: 'BookComments', relation_name: :approved_book_comments + has_many "aliased_comments", class_name: 'BookComments', relation_name: :approved_book_comments filters :book_comments filter :banned, apply: :apply_filter_banned + filter :fiction, apply: :apply_filter_fiction class << self def books @@ -1443,9 +1622,14 @@ def apply_filter_banned(records, value, options) # Only book admins might filter for banned books if current_user && current_user.book_admin records.where('books.banned = ?', value[0] == 'true') + else + records end end + def apply_filter_fiction(records, value, _options) + records.where('books.fiction = ?', value[0] == 'true') + end end end @@ -1455,6 +1639,8 @@ class BookCommentResource < JSONAPI::Resource has_one :book has_one :author, class_name: 'Person' + paginator :offset + filters :book filter :approved, apply: ->(records, value, options) { context = options[:context] @@ -1495,7 +1681,9 @@ module V4 class PostResource < PostResource; end class PersonResource < PersonResource; end class ExpenseEntryResource < ExpenseEntryResource; end - class IsoCurrencyResource < IsoCurrencyResource; end + class IsoCurrencyResource < IsoCurrencyResource + has_many :expense_entries, exclude_links: :default + end class BookResource < Api::V2::BookResource paginator :paged @@ -1509,6 +1697,16 @@ class BookCommentResource < Api::V2::BookCommentResource module Api module V5 + class PostResource < JSONAPI::Resource + attribute :title + attribute :body + + has_one :author, class_name: 'Person', exclude_links: [:self, "related"] + has_one :section, exclude_links: [:self, :related] + has_many :tags, acts_as_set: true, inverse_relationship: :posts, eager_load_on_include: false, exclude_links: :default + has_many :comments, acts_as_set: false, inverse_relationship: :post, exclude_links: ["self", :related] + end + class AuthorResource < JSONAPI::Resource attributes :name, :email model_name 'Person' @@ -1538,8 +1736,55 @@ class AuthorDetailResource < JSONAPI::Resource attributes :author_stuff end - class PersonResource < PersonResource; end - class PostResource < PostResource; end + class PaintingResource < JSONAPI::Resource + model_name 'Painting' + attributes :title, :category, :collector_roster + has_one :painter + has_many :collectors + + filter :title + filter :category + + def collector_roster + collectors.map(&:name) + end + end + + class CollectorResource < JSONAPI::Resource + attributes :name + has_one :painting + + filter :name + filter :'painting.title_equals', apply: -> (records, values, _opts) do + records.joins(:painting).where(paintings: { title: values }) + end + end + + class PainterResource < JSONAPI::Resource + model_name 'Painter' + attributes :name + has_many :paintings + + filter :name, apply: lambda { |records, value, options| + records.where('name LIKE ?', value) + } + + def records_for(relation_name) + records = super(relation_name) + + return records unless relation_name == :paintings + records.includes(:collectors) + end + end + + class PersonResource < JSONAPI::Resource + attributes :name, :email + attribute :date_joined, format: :date_with_timezone + + has_many :comments + has_many :posts + end + class TagResource < TagResource; end class SectionResource < SectionResource; end class CommentResource < CommentResource; end @@ -1660,6 +1905,26 @@ class NumeroTelefoneResource < JSONAPI::Resource attribute :numero_telefone end end + + module V9 + class PersonResource < JSONAPI::Resource + has_one :preferences + singleton false + end + + class PreferencesResource < JSONAPI::Resource + singleton singleton_key: -> (context) { + key = context[:current_user].try(:preferences).try(:id) + raise JSONAPI::Exceptions::RecordNotFound.new(nil) if key.nil? + key + } + + has_one :person, :foreign_key_on => :related + + attribute :nickname + end + end + end module AdminApi @@ -1679,28 +1944,56 @@ class PersonResource < JSONAPI::Resource module MyEngine module Api module V1 + class PostResource < PostResource + end + class PersonResource < JSONAPI::Resource + has_many :posts end end end module AdminApi module V1 + class PostResource < PostResource + end + class PersonResource < JSONAPI::Resource + has_many :posts end end end module DasherizedNamespace module V1 + class PostResource < PostResource + end + class PersonResource < JSONAPI::Resource + has_many :posts + end + end + end + + module OptionalNamespace + module V1 + class PostResource < PostResource + end + + class PersonResource < JSONAPI::Resource + has_many :posts end end end end module ApiV2Engine + class PostResource < PostResource + has_one :person + end + class PersonResource < JSONAPI::Resource + has_many :posts end end @@ -1790,6 +2083,38 @@ class UserResource < JSONAPI::Resource end end +class QuestionResource < JSONAPI::Resource + has_one :answer + has_one :respondent, polymorphic: true, class_name: "Respondent", foreign_key_on: :related + + attributes :text +end + +class AnswerResource < JSONAPI::Resource + has_one :question + has_one :respondent, polymorphic: true +end + +class PatientResource < JSONAPI::Resource + attributes :name +end + +class DoctorResource < JSONAPI::Resource + attributes :name +end + +class RespondentResource < JSONAPI::Resource + abstract +end + +class ListResource < JSONAPI::Resource + has_many :items, class_name: 'ListItem' +end + +class ListItemResource < JSONAPI::Resource + has_one :list +end + ### PORO Data - don't do this in a production app $breed_data = BreedData.new $breed_data.add(Breed.new(0, 'persian')) diff --git a/test/fixtures/answers.yml b/test/fixtures/answers.yml new file mode 100644 index 000000000..197d8d6a8 --- /dev/null +++ b/test/fixtures/answers.yml @@ -0,0 +1,12 @@ +answer1: + id: 1 + question_id: 1 + text: Great thanks + respondent_id: 1 + respondent_type: Patient +answer2: + id: 2 + question_id: 2 + text: Better than last week + respondent_id: 1 + respondent_type: Doctor \ No newline at end of file diff --git a/test/fixtures/book_authors.yml b/test/fixtures/book_authors.yml index 12af5bd16..24f06d13f 100644 --- a/test/fixtures/book_authors.yml +++ b/test/fixtures/book_authors.yml @@ -9,3 +9,7 @@ book_author_2_1: book_author_2_2: book_id: 2 person_id: 2 + +book_author_601_1: + book_id: 601 + person_id: 1 \ No newline at end of file diff --git a/test/fixtures/book_comments.yml b/test/fixtures/book_comments.yml index 0fbf3487b..51136ee4b 100644 --- a/test/fixtures/book_comments.yml +++ b/test/fixtures/book_comments.yml @@ -4,7 +4,7 @@ book_<%= book_num %>_comment_<%= comment_num %>: id: <%= comment_id %> body: This is comment <%= comment_num %> on book <%= book_num %>. - author_id: <%= book_num.even? ? comment_id % 2 : (comment_id % 2) + 2 %> + author_id: <%= (comment_id % 5) + 1 %> book_id: <%= book_num %> approved: <%= comment_num.even? %> <% comment_id = comment_id + 1 %> diff --git a/test/fixtures/books.yml b/test/fixtures/books.yml index 87265107f..8a55f7ec0 100644 --- a/test/fixtures/books.yml +++ b/test/fixtures/books.yml @@ -4,4 +4,5 @@ book_<%= book_num %>: title: Book <%= book_num %> isbn: 12345-<%= book_num %>-6789 banned: <%= book_num > 600 && book_num < 700 %> + fiction: <%= book_num > 600 && book_num < 700 %> <% end %> diff --git a/test/fixtures/collectors.yml b/test/fixtures/collectors.yml new file mode 100644 index 000000000..3b7755626 --- /dev/null +++ b/test/fixtures/collectors.yml @@ -0,0 +1,9 @@ +collector_1: + id: 1 + name: "Alice" + painting_id: 4 + +collector_2: + id: 2 + name: "Bob" + painting_id: 4 \ No newline at end of file diff --git a/test/fixtures/doctors.yml b/test/fixtures/doctors.yml new file mode 100644 index 000000000..c9a53c919 --- /dev/null +++ b/test/fixtures/doctors.yml @@ -0,0 +1,3 @@ +doctor1: + id: 1 + name: Henry Jones Jr \ No newline at end of file diff --git a/test/fixtures/painters.yml b/test/fixtures/painters.yml new file mode 100644 index 000000000..6c5e0caaa --- /dev/null +++ b/test/fixtures/painters.yml @@ -0,0 +1,7 @@ +painter_1: + id: 1 + name: "Wyspianski" + +painter_2: + id: 2 + name: "Matejko" \ No newline at end of file diff --git a/test/fixtures/paintings.yml b/test/fixtures/paintings.yml new file mode 100644 index 000000000..85c1c688d --- /dev/null +++ b/test/fixtures/paintings.yml @@ -0,0 +1,35 @@ +painting_1: + id: 1 + title: "Rejtan" + category: "historic" + painter_id: 2 + +painting_2: + id: 2 + title: "Stanczyk" + category: "fantasy" + painter_id: 2 + +painting_3: + id: 3 + title: "Macierzynstwo" + category: "pastel" + painter_id: 1 + +painting_4: + id: 4 + title: "Helenka" + category: "oil" + painter_id: 1 + +painting_5: + id: 5 + title: "Motherhood" + category: "oil" + painter_id: 1 + +painting_6: + id: 6 + title: "Motherhood" + category: "fake" + painter_id: 1 \ No newline at end of file diff --git a/test/fixtures/patients.yml b/test/fixtures/patients.yml new file mode 100644 index 000000000..a75987574 --- /dev/null +++ b/test/fixtures/patients.yml @@ -0,0 +1,3 @@ +patient1: + id: 1 + name: Bob Smith \ No newline at end of file diff --git a/test/fixtures/people.yml b/test/fixtures/people.yml index 8e151f64c..4e873be5b 100644 --- a/test/fixtures/people.yml +++ b/test/fixtures/people.yml @@ -29,6 +29,7 @@ e: email: lib@xyz.fake date_joined: <%= DateTime.parse('2013-11-30 4:20:00 UTC +00:00') %> book_admin: true + preferences_id: 55 x: id: 0 diff --git a/test/fixtures/preferences.yml b/test/fixtures/preferences.yml index 2084de513..48c472d76 100644 --- a/test/fixtures/preferences.yml +++ b/test/fixtures/preferences.yml @@ -1,6 +1,7 @@ a: id: 1 advanced_mode: false + nickname: Joe Schmoe b: id: 2 @@ -12,3 +13,8 @@ c: d: id: 4 advanced_mode: false + +wilma: + id: 55 + advanced_mode: true + nickname: Wilma \ No newline at end of file diff --git a/test/fixtures/questions.yml b/test/fixtures/questions.yml new file mode 100644 index 000000000..caabed013 --- /dev/null +++ b/test/fixtures/questions.yml @@ -0,0 +1,6 @@ +question1: + id: 1 + text: How are you feeling today? +question2: + id: 2 + text: How does the patient look today? \ No newline at end of file diff --git a/test/fixtures/vehicles.yml b/test/fixtures/vehicles.yml index 720257cee..61c80d7c8 100644 --- a/test/fixtures/vehicles.yml +++ b/test/fixtures/vehicles.yml @@ -15,3 +15,21 @@ Launch20: length_at_water_line: 15.5ft serial_number: 434253JJJSD person_id: 1 + +M5: + id: 3 + type: Car + make: BMW + model: M5 + drive_layout: Front Engine RWD + serial_number: 56256 + person_id: 2 + +M3: + id: 4 + type: Car + make: BMW + model: M3 + drive_layout: Front Engine RWD + serial_number: 894345 + person_id: 2 \ No newline at end of file diff --git a/test/helpers/functional_helpers.rb b/test/helpers/functional_helpers.rb index e0f504df2..3d6dc9d34 100644 --- a/test/helpers/functional_helpers.rb +++ b/test/helpers/functional_helpers.rb @@ -32,8 +32,8 @@ module FunctionalHelpers # end # end # - # if @response.content_type - # ct = @response.content_type + # if @response.media_type + # ct = @response.media_type # elsif methods.include?('assert_response_response') # ct = assert_response_response # else diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 99885de21..797f588b0 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -2,12 +2,17 @@ class RequestTest < ActionDispatch::IntegrationTest def setup + DatabaseCleaner.start JSONAPI.configuration.json_key_format = :underscored_key JSONAPI.configuration.route_format = :underscored_route Api::V2::BookResource.paginator :offset $test_user = Person.find(1) end + def teardown + DatabaseCleaner.clean + end + def after_teardown JSONAPI.configuration.route_format = :underscored_route end @@ -45,6 +50,15 @@ def test_get_underscored_key JSONAPI.configuration = original_config end + def test_filter_with_value_containing_double_quote + original_config = JSONAPI.configuration.dup + JSONAPI.configuration.json_key_format = :underscored_key + get '/iso_currencies?filter[country_name]=%22' + assert_jsonapi_response 200 + ensure + JSONAPI.configuration = original_config + end + def test_get_underscored_key_filtered original_config = JSONAPI.configuration.dup JSONAPI.configuration.json_key_format = :underscored_key @@ -202,6 +216,97 @@ def test_post_single assert_jsonapi_response 201 end + def test_post_polymorphic_with_has_many_relationship + post '/people', params: + { + 'data' => { + 'type' => 'people', + 'attributes' => { + 'name' => 'Reo', + 'email' => 'reo@xyz.fake', + 'date_joined' => 'Thu, 01 Jan 2019 00:00:00 UTC +00:00', + }, + 'relationships' => { + 'vehicles' => { + 'data' => [ + {'type' => 'car', 'id' => '1'}, + {'type' => 'boat', 'id' => '2'}, + {'type' => 'car', 'id' => '3'}, + {'type' => 'car', 'id' => '4'} + ] + } + } + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_jsonapi_response 201 + + body = JSON.parse(response.body) + person = Person.find(body.dig("data", "id")) + + assert_equal "Reo", person.name + assert_equal 4, person.vehicles.count + assert_equal Car, person.vehicles.first.class + assert_equal Boat, person.vehicles.second.class + assert_equal Car, person.vehicles.third.class + assert_equal Car, person.vehicles.fourth.class + end + + def test_post_polymorphic_invalid_with_wrong_type + post '/people', params: + { + 'data' => { + 'type' => 'people', + 'attributes' => { + 'name' => 'Reo', + 'email' => 'reo@xyz.fake', + 'date_joined' => 'Thu, 01 Jan 2019 00:00:00 UTC +00:00', + }, + 'relationships' => { + 'vehicles' => {'data' => [{'type' => 'author', 'id' => '1'}]}, + } + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_jsonapi_response 400, msg: "Submitting a thing as a vehicle should raise a type mismatch error" + end + + def test_post_polymorphic_invalid_with_not_matched_type_and_id + post '/people', params: + { + 'data' => { + 'type' => 'people', + 'attributes' => { + 'name' => 'Reo', + 'email' => 'reo@xyz.fake', + 'date_joined' => 'Thu, 01 Jan 2019 00:00:00 UTC +00:00', + }, + 'relationships' => { + 'vehicles' => { + 'data' => [ + {'type' => 'car', 'id' => '1'}, + {'type' => 'car', 'id' => '2'} #vehicle 2 is actually a boat + ] + } + } + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_jsonapi_response 404, msg: "Submitting a thing as a vehicle should raise a record not found" + end + def test_post_single_missing_data_contents post '/posts', params: { @@ -353,6 +458,39 @@ def test_put_content_type assert_match JSONAPI::MEDIA_TYPE, headers['Content-Type'] end + def test_put_valid_json + put '/posts/3', params: '{"data": { "type": "posts", "id": "3", "attributes": { "title": "A great new Post" } } }', + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_equal 200, status + end + + def test_put_invalid_json + put '/posts/3', params: '{"data": { "type": "posts", "id": "3" "attributes": { "title": "A great new Post" } } }', + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_equal 400, status + assert_equal 'Bad Request', json_response['errors'][0]['title'] + assert_match 'unexpected token at', json_response['errors'][0]['detail'] + end + + def test_put_valid_json_but_array + put '/posts/3', params: '[{"data": { "type": "posts", "id": "3", "attributes": { "title": "A great new Post" } } }]', + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_equal 400, status + assert_equal 'Request must be a hash', json_response['errors'][0]['detail'] + end + def test_patch_content_type patch '/posts/3', params: { @@ -380,6 +518,96 @@ def test_patch_content_type assert_match JSONAPI::MEDIA_TYPE, headers['Content-Type'] end + def test_patch_polymorphic_with_has_many_relationship + patch '/people/1', params: + { + 'data' => { + 'id' => 1, + 'type' => 'people', + 'attributes' => { + 'name' => 'Reo', + 'email' => 'reo@xyz.fake', + 'date_joined' => 'Thu, 01 Jan 2019 00:00:00 UTC +00:00', + }, + 'relationships' => { + 'vehicles' => { + 'data' => [ + {'type' => 'car', 'id' => '1'}, + {'type' => 'boat', 'id' => '2'} + ] + } + } + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_jsonapi_response 200 + + body = JSON.parse(response.body) + person = Person.find(body.dig("data", "id")) + + assert_equal "Reo", person.name + assert_equal 2, person.vehicles.count + assert_equal Car, person.vehicles.first.class + assert_equal Boat, person.vehicles.second.class + end + + def test_patch_polymorphic_invalid_with_wrong_type + patch '/people/1', params: + { + 'data' => { + 'id' => 1, + 'type' => 'people', + 'attributes' => { + 'name' => 'Reo', + 'email' => 'reo@xyz.fake', + 'date_joined' => 'Thu, 01 Jan 2019 00:00:00 UTC +00:00', + }, + 'relationships' => { + 'vehicles' => {'data' => [{'type' => 'author', 'id' => '1'}]}, + } + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_jsonapi_response 400, msg: "Submitting a thing as a vehicle should raise a type mismatch error" + end + + def test_patch_polymorphic_invalid_with_not_matched_type_and_id + patch '/people/1', params: + { + 'data' => { + 'id' => 1, + 'type' => 'people', + 'attributes' => { + 'name' => 'Reo', + 'email' => 'reo@xyz.fake', + 'date_joined' => 'Thu, 01 Jan 2019 00:00:00 UTC +00:00', + }, + 'relationships' => { + 'vehicles' => { + 'data' => [ + {'type' => 'car', 'id' => '1'}, + {'type' => 'car', 'id' => '2'} #vehicle 2 is actually a boat + ] + } + } + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_jsonapi_response 404, msg: "Submitting a thing as a vehicle should raise a record not found" + end + def test_post_correct_content_type post '/posts', params: { @@ -562,18 +790,45 @@ def test_pagination_empty_results # assert_equal 'This is comment 18 on book 1.', json_response['data'][9]['attributes']['body'] # end + def test_query_count_related_resources + # Expected Queries: + # * Fetch specified book record + # * Fetch book comment records associated with specified book + # * Select count of book comment records for pagination + Api::V2::BookCommentResource.paginator :offset + assert_query_count 3 do + get '/api/v2/books/1/book_comments?page[limit]=20' + end + assert_equal 20, json_response['data'].size + end + + def test_query_count_related_resources_with_includes + # Expected Queries: + # * Fetch specified book record + # * Fetch book comment records associated with specified book + # * Fetch all author records the book comments to be returned + # * Select count of book comment records for pagination + Api::V2::BookCommentResource.paginator :offset + + assert_query_count 4 do + get '/api/v2/books/1/book_comments?page[limit]=20&include=author' + end + assert_equal 20, json_response['data'].size + assert_equal 5, json_response['included'].size + end + def test_flow_self - assert_cacheable_jsonapi_get '/posts' - post_1 = json_response['data'][0] + assert_cacheable_jsonapi_get '/posts/1' + post_1 = json_response['data'] assert_cacheable_jsonapi_get post_1['links']['self'] assert_hash_equals post_1, json_response['data'] end def test_flow_link_to_one_self_link - assert_cacheable_jsonapi_get '/posts' - post_1 = json_response['data'][0] + assert_cacheable_jsonapi_get '/posts/1' + post_1 = json_response['data'] assert_cacheable_jsonapi_get post_1['relationships']['author']['links']['self'] assert_hash_equals(json_response, { @@ -586,8 +841,8 @@ def test_flow_link_to_one_self_link end def test_flow_link_to_many_self_link - assert_cacheable_jsonapi_get '/posts' - post_1 = json_response['data'][0] + assert_cacheable_jsonapi_get '/posts/1' + post_1 = json_response['data'] assert_cacheable_jsonapi_get post_1['relationships']['tags']['links']['self'] assert_hash_equals(json_response, @@ -605,10 +860,10 @@ def test_flow_link_to_many_self_link end def test_flow_link_to_many_self_link_put - assert_cacheable_jsonapi_get '/posts' - post_1 = json_response['data'][4] + assert_cacheable_jsonapi_get '/posts/5' + post_5 = json_response['data'] - post post_1['relationships']['tags']['links']['self'], params: + post post_5['relationships']['tags']['links']['self'], params: {'data' => [{'type' => 'tags', 'id' => '10'}]}.to_json, headers: { 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, @@ -617,7 +872,7 @@ def test_flow_link_to_many_self_link_put assert_equal 204, status - assert_cacheable_jsonapi_get post_1['relationships']['tags']['links']['self'] + assert_cacheable_jsonapi_get post_5['relationships']['tags']['links']['self'] assert_hash_equals(json_response, { 'links' => { @@ -1030,14 +1285,396 @@ def test_sort_parameter_not_allowed JSONAPI.configuration.allow_sort = true end + def test_sort_parameter_quoted + get '/api/v2/books?sort=%22title%22', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_jsonapi_response 200 + end + + def test_sort_parameter_openquoted + get '/api/v2/books?sort=%22title', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_jsonapi_response 400 + end + + def test_include_parameter_quoted + get '/api/v2/posts?include=%22author%22', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_jsonapi_response 200 + end + + def test_include_parameter_openquoted + get '/api/v2/posts?include=%22author', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_jsonapi_response 400 + end + + def test_include_value_missing + get '/api/v2/posts?include=', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_jsonapi_response 200 + end + def test_getting_different_resources_when_sti assert_cacheable_jsonapi_get '/vehicles' - types = json_response['data'].map{|r| r['type']}.sort - assert_array_equals ['boats', 'cars'], types + types = json_response['data'].map{|r| r['type']}.to_set + assert types == Set['cars', 'boats'] end def test_getting_resource_with_correct_type_when_sti assert_cacheable_jsonapi_get '/vehicles/1' assert_equal 'cars', json_response['data']['type'] end + + def test_get_resource_include_singleton_relationship + $original_test_user = $test_user + $test_user = Person.find(5) + + assert_cacheable_jsonapi_get '/api/v9/people/5?include=preferences' + assert_jsonapi_response 200 + assert_hash_equals json_response, + { + "data" => { + "id" => "5", + "type" => "people", + "links" => { + "self" => "http://www.example.com/api/v9/people/5" + }, + "relationships" => { + "preferences" => { + "links" => { + "self" => "http://www.example.com/api/v9/people/5/relationships/preferences", + "related" => "http://www.example.com/api/v9/people/5/preferences" + }, + "data" => { + "type" => "preferences", + "id" => "55" + } + } + } + }, + "included" => [ + { + "id" => "55", + "type" => "preferences", + "attributes" => { + "nickname" => "Wilma" + }, + 'relationships' => { + 'person' => { + "links" => { + "self" => "http://www.example.com/api/v9/preferences/relationships/person", + "related" => "http://www.example.com/api/v9/preferences/person" + } + } + }, + "links" => { + "self" => "http://www.example.com/api/v9/preferences" + } + } + ] + } + ensure + $test_user = $original_test_user + end + + def test_caching_included_singleton + original_config = JSONAPI.configuration.dup + + Api::V9::PreferencesResource.caching(true) + Api::V9::PersonResource.caching(true) + + JSONAPI.configuration.resource_cache = ActiveSupport::Cache::MemoryStore.new + + $original_test_user = $test_user + $test_user = Person.find(5) + + get "/api/v9/people/#{$test_user.id}?include=preferences" + assert_jsonapi_response 200 + assert_hash_equals json_response, + { + "data" => { + "id" => "5", + "type" => "people", + "links" => { + "self" => "http://www.example.com/api/v9/people/5" + }, + "relationships" => { + "preferences" => { + "links" => { + "self" => "http://www.example.com/api/v9/people/5/relationships/preferences", + "related" => "http://www.example.com/api/v9/people/5/preferences" + }, + "data" => { + "type" => "preferences", + "id" => "55" + } + } + } + }, + "included" => [ + { + "id" => "55", + "type" => "preferences", + "attributes" => { + "nickname" => "Wilma" + }, + 'relationships' => { + 'person' => { + "links" => { + "self" => "http://www.example.com/api/v9/preferences/relationships/person", + "related" => "http://www.example.com/api/v9/preferences/person" + } + } + }, + "links" => { + "self" => "http://www.example.com/api/v9/preferences" + } + } + ] + } + + $test_user = Person.find(1) + assert_equal 2, JSONAPI.configuration.resource_cache.instance_variable_get(:@data).length + + get "/api/v9/people/#{$test_user.id}?include=preferences" + assert_jsonapi_response 200 + assert_hash_equals json_response, + { + "data" => { + "id" => "1", + "type" => "people", + "links" => { + "self" => "http://www.example.com/api/v9/people/1" + }, + "relationships" => { + "preferences" => { + "links" => { + "self" => "http://www.example.com/api/v9/people/1/relationships/preferences", + "related" => "http://www.example.com/api/v9/people/1/preferences" + }, + "data" => { + "type" => "preferences", + "id" => "1" + } + } + } + }, + "included" => [ + { + "id" => "1", + "type" => "preferences", + "attributes" => { + "nickname" => "Joe Schmoe" + }, + 'relationships' => { + 'person' => { + "links" => { + "self" => "http://www.example.com/api/v9/preferences/relationships/person", + "related" => "http://www.example.com/api/v9/preferences/person" + } + } + }, + "links" => { + "self" => "http://www.example.com/api/v9/preferences" + } + } + ] + } + + assert_equal 4, JSONAPI.configuration.resource_cache.instance_variable_get(:@data).length + + ensure + JSONAPI.configuration = original_config + $test_user = $original_test_user + + Api::V9::PreferencesResource.caching(false) + Api::V9::PersonResource.caching(false) + end + + def test_caching_singleton_primary + original_config = JSONAPI.configuration.dup + + Api::V9::PreferencesResource.caching(true) + Api::V9::PersonResource.caching(true) + + JSONAPI.configuration.resource_cache = ActiveSupport::Cache::MemoryStore.new + + $original_test_user = $test_user + $test_user = Person.find(5) + + get "/api/v9/preferences" + assert_jsonapi_response 200 + assert_hash_equals json_response, + { + "data" => { + "id" => "55", + "type" => "preferences", + "attributes" => { + "nickname" => "Wilma" + }, + 'relationships' => { + 'person' => { + "links" => { + "self" => "http://www.example.com/api/v9/preferences/relationships/person", + "related" => "http://www.example.com/api/v9/preferences/person" + } + } + }, + "links" => { + "self" => "http://www.example.com/api/v9/preferences" + } + } + } + + assert_equal 1, JSONAPI.configuration.resource_cache.instance_variable_get(:@data).length + + $test_user = Person.find(1) + + get "/api/v9/preferences" + assert_jsonapi_response 200 + assert_hash_equals json_response, + { + "data" => { + "id" => "1", + "type" => "preferences", + "attributes" => { + "nickname" => "Joe Schmoe" + }, + 'relationships' => { + 'person' => { + "links" => { + "self" => "http://www.example.com/api/v9/preferences/relationships/person", + "related" => "http://www.example.com/api/v9/preferences/person" + } + } + }, + "links" => { + "self" => "http://www.example.com/api/v9/preferences" + } + } + } + + assert_equal 2, JSONAPI.configuration.resource_cache.instance_variable_get(:@data).length + + ensure + JSONAPI.configuration = original_config + $test_user = $original_test_user + + Api::V9::PreferencesResource.caching(false) + Api::V9::PersonResource.caching(false) + end + + def test_patch_singleton + original_config = JSONAPI.configuration.dup + + Api::V9::PreferencesResource.caching(true) + Api::V9::PersonResource.caching(true) + + JSONAPI.configuration.resource_cache = ActiveSupport::Cache::MemoryStore.new + + $original_test_user = $test_user + $test_user = Person.find(1) + + patch '/api/v9/preferences', params: + { + 'data' => { + 'type' => 'preferences', + 'id' => '1', + 'attributes' => { + 'nickname' => 'Joey' + }, + 'relationships' => { + 'person' => { + "links" => { + "self" => "http://www.example.com/api/v9/preferences/relationships/person", + "related" => "http://www.example.com/api/v9/preferences/person" + } + } + } + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_equal 200, status + prefs = Preferences.find(1) + assert_equal 'Joey', prefs.nickname + + ensure + JSONAPI.configuration = original_config + $test_user = $original_test_user + + Api::V9::PreferencesResource.caching(false) + Api::V9::PersonResource.caching(false) + end + + def test_create_singleton + original_config = JSONAPI.configuration.dup + + Api::V9::PreferencesResource.caching(true) + Api::V9::PersonResource.caching(true) + + JSONAPI.configuration.resource_cache = ActiveSupport::Cache::MemoryStore.new + + $original_test_user = $test_user + $test_user = Person.find(4) + + assert_nil $test_user.preferences + + post '/api/v9/preferences', params: + { + 'data' => { + 'type' => 'preferences', + 'attributes' => { + 'nickname' => 'Frank' + }, + 'relationships' => { + 'person' => {'data' => {'type' => 'people', 'id' => '4'}} + } + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + assert_equal 201, status + assert_equal 'Frank', json_response['data']['attributes']['nickname'] + + ensure + JSONAPI.configuration = original_config + $test_user = $original_test_user + + Api::V9::PreferencesResource.caching(false) + Api::V9::PersonResource.caching(false) + end + + def test_destroy_singleton + original_config = JSONAPI.configuration.dup + + $original_test_user = $test_user + $test_user = Person.find(5) + + init_pref_count = Preferences.count + delete '/api/v9/preferences', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_equal 204, status + assert_equal init_pref_count - 1, Preferences.count + assert_nil headers['Content-Type'] + ensure + JSONAPI.configuration = original_config + $test_user = $original_test_user + end + + def test_destroy_singleton_not_found + original_config = JSONAPI.configuration.dup + + $original_test_user = $test_user + $test_user = Person.find(3) + + init_pref_count = Preferences.count + delete '/api/v9/preferences', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_equal 404, status + assert_equal init_pref_count, Preferences.count + ensure + JSONAPI.configuration = original_config + $test_user = $original_test_user + end end diff --git a/test/integration/routes/routes_test.rb b/test/integration/routes/routes_test.rb index 7f31ffb00..0508ee2cc 100644 --- a/test/integration/routes/routes_test.rb +++ b/test/integration/routes/routes_test.rb @@ -191,6 +191,20 @@ def test_routing_author_links_posts_create_not_acts_as_set {controller: 'api/v5/authors', action: 'create_relationship', author_id: '1', relationship: 'posts'}) end + def test_routing_list_items_index + assert_routing({path: '/list_items', method: :get}, + {controller: 'list_items', action: 'index'}) + end + + def test_routing_list_related_items + assert_routing({path: '/lists/1/items', method: :get}, + {controller: 'list_items', action: 'get_related_resources', relationship: 'items', list_id: '1', source: 'lists'}) + end + + def test_list_items_route_helper_name + assert_equal(list_items_path, '/list_items') + end + #primary_key def test_routing_primary_key_jsonapi_resources assert_routing({path: '/iso_currencies/USD', method: :get}, diff --git a/test/test_helper.rb b/test/test_helper.rb index 9ce9a60af..30d439ddb 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,4 +1,5 @@ require 'simplecov' +require 'database_cleaner' # To run tests with coverage: # COVERAGE=true bundle exec rake test @@ -23,6 +24,7 @@ require 'minitest/mock' require 'jsonapi-resources' require 'pry' +require 'memory_profiler' require File.expand_path('../helpers/value_matchers', __FILE__) require File.expand_path('../helpers/assertions', __FILE__) @@ -57,6 +59,9 @@ class TestApp < Rails::Application config.active_support.halt_callback_chains_on_return_false = false config.active_record.time_zone_aware_types = [:time, :datetime] config.active_record.belongs_to_required_by_default = false + if Rails::VERSION::MINOR >= 2 + config.active_record.sqlite3.represent_boolean_as_integer = true + end end end @@ -229,6 +234,7 @@ class CatResource < JSONAPI::Resource jsonapi_resources :comments jsonapi_resources :firms jsonapi_resources :tags + jsonapi_resources :hair_cuts jsonapi_resources :posts do jsonapi_relationships jsonapi_links :special_tags @@ -241,13 +247,14 @@ class CatResource < JSONAPI::Resource jsonapi_resources :planet_types jsonapi_resources :moons jsonapi_resources :craters - jsonapi_resources :preferences + jsonapi_resource :preferences jsonapi_resources :facts jsonapi_resources :categories jsonapi_resources :pictures jsonapi_resources :documents jsonapi_resources :products jsonapi_resources :vehicles + jsonapi_resources :makes jsonapi_resources :cars jsonapi_resources :boats jsonapi_resources :flat_posts @@ -255,8 +262,22 @@ class CatResource < JSONAPI::Resource jsonapi_resources :books jsonapi_resources :authors + jsonapi_resources :questions + jsonapi_resources :answers + jsonapi_resources :doctors + jsonapi_resources :patients + jsonapi_resources :employees + jsonapi_resources :web_pages + + jsonapi_resources :lists + jsonapi_resources :list_items + + jsonapi_resources :person_with_even_and_odd_posts + namespace :api do jsonapi_resources :boxes + jsonapi_resources :things + jsonapi_resources :users namespace :v1 do jsonapi_resources :people @@ -271,18 +292,20 @@ class CatResource < JSONAPI::Resource jsonapi_resources :planet_types jsonapi_resources :moons jsonapi_resources :craters - jsonapi_resources :preferences + jsonapi_resource :preferences jsonapi_resources :likes + jsonapi_resources :writers end JSONAPI.configuration.route_format = :underscored_route namespace :v2 do - jsonapi_resources :posts do - jsonapi_link :author, except: :destroy - end + jsonapi_resources :posts jsonapi_resource :preferences, except: [:create, :destroy] + jsonapi_resources :authors + jsonapi_resources :people + jsonapi_resources :comments jsonapi_resources :books jsonapi_resources :book_comments end @@ -318,8 +341,15 @@ class CatResource < JSONAPI::Resource namespace :v5 do jsonapi_resources :posts do end + jsonapi_resources :people + jsonapi_resources :tags + jsonapi_resources :comments + jsonapi_resources :painters + jsonapi_resources :paintings + jsonapi_resources :collectors jsonapi_resources :authors + jsonapi_resources :author_details jsonapi_resources :expense_entries jsonapi_resources :iso_currencies @@ -335,6 +365,7 @@ class CatResource < JSONAPI::Resource jsonapi_resources :customers jsonapi_resources :purchase_orders jsonapi_resources :line_items + jsonapi_resources :order_flags end JSONAPI.configuration.route_format = :underscored_route @@ -350,6 +381,11 @@ class CatResource < JSONAPI::Resource namespace :v8 do jsonapi_resources :numeros_telefone end + + namespace :v9 do + jsonapi_resources :people + jsonapi_resource :preferences + end end namespace :admin_api do @@ -370,6 +406,10 @@ class CatResource < JSONAPI::Resource end end + namespace :parent_api do + jsonapi_resources :posts + end + mount MyEngine::Engine => "/boomshaka", as: :my_engine mount ApiV2Engine::Engine => "/api_v2", as: :api_v2_engine end @@ -398,6 +438,8 @@ class CatResource < JSONAPI::Resource jsonapi_resources :people end +DatabaseCleaner.strategy = :transaction + # Ensure backward compatibility with Minitest 4 Minitest::Test = MiniTest::Unit::TestCase unless defined?(Minitest::Test) @@ -429,7 +471,8 @@ class ActionDispatch::IntegrationTest fixtures :all def assert_jsonapi_response(expected_status, msg = nil) - assert_equal JSONAPI::MEDIA_TYPE, response.content_type + media_type = response.media_type + assert_equal JSONAPI::MEDIA_TYPE, media_type if status != expected_status && status >= 400 pp json_response rescue nil end @@ -597,6 +640,9 @@ def self.run_one_method(klass, method_name, reporter) end end puts + if ENV["MEMORY_PROFILER"] + MemoryProfiler.report(allow_files: 'lib/jsonapi') { super(klass, method_name, reporter) }.pretty_print + end end end diff --git a/test/unit/jsonapi_request/jsonapi_request_test.rb b/test/unit/jsonapi_request/jsonapi_request_test.rb index b96ffb1d1..4a426c0f9 100644 --- a/test/unit/jsonapi_request/jsonapi_request_test.rb +++ b/test/unit/jsonapi_request/jsonapi_request_test.rb @@ -4,10 +4,11 @@ class CatResource < JSONAPI::Resource attribute :name attribute :breed - belongs_to :mother, class_name: 'Cat' + has_one :mother, class_name: 'Cat' has_one :father, class_name: 'Cat' + has_many :children, class_name: 'Cat' - filters :name + filters :name, :first_marriage_children def self.sortable_fields(context) super(context) << :"mother.name" @@ -129,16 +130,17 @@ def test_parse_dasherized_with_underscored_fields } ) - request = JSONAPI::RequestParser.new( - params, - { - context: nil, - key_formatter: JSONAPI::Formatter.formatter_for(:dasherized_key) - } - ) - - refute request.errors.empty? - assert_equal 'iso_currency is not a valid field for expense-entries.', request.errors[0].detail + e = assert_raises JSONAPI::Exceptions::InvalidField do + JSONAPI::RequestParser.new( + params, + { + context: nil, + key_formatter: JSONAPI::Formatter.formatter_for(:dasherized_key) + } + ) + end + refute e.errors.empty? + assert_equal 'iso_currency is not a valid field for expense-entries.', e.errors[0].detail end def test_parse_dasherized_with_underscored_resource @@ -152,16 +154,18 @@ def test_parse_dasherized_with_underscored_resource } ) - request = JSONAPI::RequestParser.new( - params, - { - context: nil, - key_formatter: JSONAPI::Formatter.formatter_for(:dasherized_key) - } - ) - - refute request.errors.empty? - assert_equal 'expense_entries is not a valid resource.', request.errors[0].detail + e = assert_raises JSONAPI::Exceptions::InvalidResource do + JSONAPI::RequestParser.new( + params, + { + context: nil, + key_formatter: JSONAPI::Formatter.formatter_for(:dasherized_key) + } + ) + parse_fields(params[:fields]) + end + refute e.errors.empty? + assert_equal 'expense_entries is not a valid resource.', e.errors[0].detail end def test_parse_filters_with_valid_filters @@ -174,9 +178,9 @@ def test_parse_filters_with_valid_filters def test_parse_filters_with_non_valid_filter setup_request @request.parse_filters({breed: 'Whiskers'}) # breed is not a set filter - assert_equal(@request.filters, {}) assert_equal(@request.errors.count, 1) assert_equal(@request.errors.first.title, "Filter not allowed") + assert_equal(@request.errors.first.detail, "breed is not allowed.") end def test_parse_filters_with_no_filters @@ -210,6 +214,50 @@ def test_parse_sort_with_relationships assert_equal(@request.sort_criteria, [{:field=>"mother.name", :direction=>:desc}]) end + def test_parse_filters_with_valid_included_filter + setup_request + @request.parse_include_directives('children') + @request.parse_filters({ "children.first_marriage_children" => "Tom" }) + assert_equal(@request.filters, {}) + include_config = @request.include_directives.include_config(:children) + assert_equal(include_config[:include_filters], + first_marriage_children: ['Tom']) + + assert_equal(@request.errors, []) + end + + def test_parse_filters_with_non_valid_relationship_for_included_filter + setup_request + @request.parse_include_directives('babies') + @request.parse_filters('babies.first_marriage_children' => 'Tom') + assert_equal({}, @request.filters, 'Filters should be empty') + include_config = @request.include_directives.include_config(:babies) + assert_nil(include_config) + assert_equal(2, @request.errors.count) + filter_error = @request.errors.find { |e| e.title == 'Filter not allowed' } + assert_equal('first_marriage_children is not allowed.', filter_error.detail) + end + + def test_parse_filters_with_non_valid_included_filter + setup_request + @request.parse_filters({ "children.second_marriage_children" => "Tom" }) + assert_equal(@request.filters, {}) + assert_nil(@request.include_directives) + assert_equal(@request.errors.count, 1) + assert_equal(@request.errors.first.title, "Filter not allowed") + end + + def test_parse_filters_with_valid_filter_and_included_filter + setup_request + @request.parse_include_directives('children') + @request.parse_filters({ name: "Whiskers", "children.first_marriage_children" => "Tom" }) + assert_equal(@request.filters, {name: "Whiskers"}) + include_config = @request.include_directives.include_config(:children) + assert_equal(include_config[:include_filters], + first_marriage_children: ['Tom']) + assert_equal(@request.errors, []) + end + private def setup_request diff --git a/test/unit/operation/operation_dispatcher_test.rb b/test/unit/operation/operation_dispatcher_test.rb index 278ebd6dd..9b19528d0 100644 --- a/test/unit/operation/operation_dispatcher_test.rb +++ b/test/unit/operation/operation_dispatcher_test.rb @@ -85,7 +85,7 @@ def test_replace_to_one_relationship op.process(operations) saturn.reload - assert_equal(saturn.planet_type_id, nil) + assert_nil saturn.planet_type_id # Reset operations = [ diff --git a/test/unit/resource/relationship_test.rb b/test/unit/resource/relationship_test.rb index 8208933e4..74791d8f3 100644 --- a/test/unit/resource/relationship_test.rb +++ b/test/unit/resource/relationship_test.rb @@ -9,4 +9,138 @@ def test_polymorphic_type assert_equal(relationship.polymorphic_type, "imageable_type") end + def test_global_exclude_links_configuration_on_relationship + JSONAPI.configuration.default_exclude_links = :none + relationship = JSONAPI::Relationship::ToOne.new "foo" + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + JSONAPI.configuration.default_exclude_links = :default + relationship = JSONAPI::Relationship::ToOne.new "foo" + assert_equal [:self, :related], relationship._exclude_links + assert relationship.exclude_link?(:self) + assert relationship.exclude_link?("self") + assert relationship.exclude_link?(:related) + assert relationship.exclude_link?("related") + + JSONAPI.configuration.default_exclude_links = "none" + relationship = JSONAPI::Relationship::ToOne.new "foo" + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + JSONAPI.configuration.default_exclude_links = "default" + relationship = JSONAPI::Relationship::ToOne.new "foo" + assert_equal [:self, :related], relationship._exclude_links + assert relationship.exclude_link?(:self) + assert relationship.exclude_link?("self") + + JSONAPI.configuration.default_exclude_links = :none + relationship = JSONAPI::Relationship::ToOne.new "foo" + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + JSONAPI.configuration.default_exclude_links = [:self] + relationship = JSONAPI::Relationship::ToOne.new "foo" + assert_equal [:self], relationship._exclude_links + assert relationship.exclude_link?(:self) + assert relationship.exclude_link?("self") + + JSONAPI.configuration.default_exclude_links = :none + relationship = JSONAPI::Relationship::ToOne.new "foo" + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + JSONAPI.configuration.default_exclude_links = ["self", :related] + relationship = JSONAPI::Relationship::ToOne.new "foo" + assert_equal [:self, :related], relationship._exclude_links + assert relationship.exclude_link?(:self) + assert relationship.exclude_link?("self") + + JSONAPI.configuration.default_exclude_links = [] + relationship = JSONAPI::Relationship::ToOne.new "foo" + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + assert_raises do + JSONAPI.configuration.default_exclude_links = :self + JSONAPI::Relationship::ToOne.new "foo" + end + + # Test if the relationships will override the the global configuration + JSONAPI.configuration.default_exclude_links = :default + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: :none + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + refute relationship.exclude_link?(:related) + refute relationship.exclude_link?("related") + + JSONAPI.configuration.default_exclude_links = :default + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: [:self] + assert_equal [:self], relationship._exclude_links + refute relationship.exclude_link?(:related) + refute relationship.exclude_link?("related") + assert relationship.exclude_link?(:self) + assert relationship.exclude_link?("self") + ensure + JSONAPI.configuration.default_exclude_links = :none + end + + def test_exclude_links_on_relationship + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: :none + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: :default + assert_equal [:self, :related], relationship._exclude_links + assert relationship.exclude_link?(:self) + assert relationship.exclude_link?("self") + assert relationship.exclude_link?(:related) + assert relationship.exclude_link?("related") + + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: "none" + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: "default" + assert_equal [:self, :related], relationship._exclude_links + assert relationship.exclude_link?(:self) + assert relationship.exclude_link?("self") + + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: :none + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: [:self] + assert_equal [:self], relationship._exclude_links + assert relationship.exclude_link?(:self) + assert relationship.exclude_link?("self") + + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: :none + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: ["self", :related] + assert_equal [:self, :related], relationship._exclude_links + assert relationship.exclude_link?(:self) + assert relationship.exclude_link?("self") + + relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: [] + assert_equal [], relationship._exclude_links + refute relationship.exclude_link?(:self) + refute relationship.exclude_link?("self") + + assert_raises do + JSONAPI::Relationship::ToOne.new "foo", :self + end + end end diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 4c8e94daa..7fe44bbd8 100644 --- a/test/unit/resource/resource_test.rb +++ b/test/unit/resource/resource_test.rb @@ -13,7 +13,7 @@ class PostWithBadAfterSave < ActiveRecord::Base after_save :do_some_after_save_stuff def do_some_after_save_stuff - errors[:base] << 'Boom! Error added in after_save callback.' + errors.add(:base, 'Boom! Error added in after_save callback.') raise ActiveRecord::RecordInvalid.new(self) end end @@ -23,7 +23,7 @@ class PostWithCustomValidationContext < ActiveRecord::Base validate :api_specific_check, on: :json_api_create def api_specific_check - errors[:base] << 'Record is invalid' + errors.add(:base, 'Record is invalid') end end @@ -47,7 +47,9 @@ class NoMatchAbstractResource < JSONAPI::Resource abstract end -class CatResource < JSONAPI::Resource +class FelineResource < JSONAPI::Resource + model_name 'Cat' + attribute :name attribute :breed attribute :kind, :delegate => :breed @@ -56,6 +58,9 @@ class CatResource < JSONAPI::Resource has_one :father, class_name: 'Cat' end +class TestSingletonResource < JSONAPI::Resource +end + class PersonWithCustomRecordsForResource < PersonResource def records_for(relationship_name) :records_for @@ -83,11 +88,18 @@ module MyModule class MyNamespacedResource < JSONAPI::Resource model_name "Person" has_many :related + has_one :default_profile, class_name: "Nested::Profile" end class RelatedResource < JSONAPI::Resource model_name "Comment" end + + module Nested + class ProfileResource < JSONAPI::Resource + model_name "Nested::Profile" + end + end end module MyAPI @@ -98,6 +110,12 @@ class RelatedResource < MyModule::RelatedResource end end +class PostWithReadonlyAttributesResource < JSONAPI::Resource + model_name 'Post' + attribute :title, readonly: true + has_one :author, readonly: true +end + class ResourceTest < ActiveSupport::TestCase def setup @post = Post.first @@ -147,6 +165,12 @@ def test_resource_for_namespaced_resource assert_equal(MyModule::MyNamespacedResource.resource_for('related'), MyModule::RelatedResource) end + def test_resource_for_nested_namespaced_resource + assert_equal(JSONAPI::Resource.resource_for('my_module/nested/profile'), MyModule::Nested::ProfileResource) + assert_equal(MyModule::MyNamespacedResource.resource_for('my_module/nested/profile'), MyModule::Nested::ProfileResource) + assert_equal(MyModule::MyNamespacedResource.resource_for('nested/profile'), MyModule::Nested::ProfileResource) + end + def test_relationship_parent_point_to_correct_resource assert_equal MyModule::MyNamespacedResource, MyModule::MyNamespacedResource._relationships[:related].parent_resource end @@ -175,8 +199,8 @@ def test_derived_not_abstract def test_nil_model_class # ToDo:Figure out why this test does not work on Rails 4.0 # :nocov: - if Rails::VERSION::MAJOR >= 4 && Rails::VERSION::MINOR >= 1 - assert_output nil, "[MODEL NOT FOUND] Model could not be found for NoMatchResource. If this a base Resource declare it as abstract.\n" do + if (Rails::VERSION::MAJOR >= 4 && Rails::VERSION::MINOR >= 1) || (Rails::VERSION::MAJOR >= 5) + assert_output nil, "[MODEL NOT FOUND] Model could not be found for NoMatchResource. If this is a base Resource declare it as abstract.\n" do assert_nil NoMatchResource._model_class end end @@ -194,13 +218,13 @@ def test_model_alternate end def test_class_attributes - attrs = CatResource._attributes + attrs = FelineResource._attributes assert_kind_of(Hash, attrs) assert_equal(attrs.keys.size, 4) end def test_class_relationships - relationships = CatResource._relationships + relationships = FelineResource._relationships assert_kind_of(Hash, relationships) assert_equal(relationships.size, 2) end @@ -214,16 +238,16 @@ def test_replace_polymorphic_to_one_link end def test_duplicate_relationship_name - assert_output nil, "[DUPLICATE RELATIONSHIP] `mother` has already been defined in CatResource.\n" do - CatResource.instance_eval do + assert_output nil, "[DUPLICATE RELATIONSHIP] `mother` has already been defined in FelineResource.\n" do + FelineResource.instance_eval do has_one :mother, class_name: 'Cat' end end end def test_duplicate_attribute_name - assert_output nil, "[DUPLICATE ATTRIBUTE] `name` has already been defined in CatResource.\n" do - CatResource.instance_eval do + assert_output nil, "[DUPLICATE ATTRIBUTE] `name` has already been defined in FelineResource.\n" do + FelineResource.instance_eval do attribute :name end end @@ -294,7 +318,7 @@ def test_find_by_key_with_customized_base_records end def test_updatable_fields_does_not_include_id - assert(!CatResource.updatable_fields.include?(:id)) + assert(!FelineResource.updatable_fields.include?(:id)) end def test_filter_on_to_many_relationship_id @@ -438,60 +462,60 @@ def apply_pagination(records, criteria, order_options) end def test_key_type_integer - CatResource.instance_eval do + FelineResource.instance_eval do key_type :integer end - assert CatResource.verify_key('45') - assert CatResource.verify_key(45) + assert FelineResource.verify_key('45') + assert FelineResource.verify_key(45) assert_raises JSONAPI::Exceptions::InvalidFieldValue do - CatResource.verify_key('45,345') + FelineResource.verify_key('45,345') end ensure - CatResource.instance_eval do + FelineResource.instance_eval do key_type nil end end def test_key_type_string - CatResource.instance_eval do + FelineResource.instance_eval do key_type :string end - assert CatResource.verify_key('45') - assert CatResource.verify_key(45) + assert FelineResource.verify_key('45') + assert FelineResource.verify_key(45) assert_raises JSONAPI::Exceptions::InvalidFieldValue do - CatResource.verify_key('45,345') + FelineResource.verify_key('45,345') end ensure - CatResource.instance_eval do + FelineResource.instance_eval do key_type nil end end def test_key_type_uuid - CatResource.instance_eval do + FelineResource.instance_eval do key_type :uuid end - assert CatResource.verify_key('f1a4d5f2-e77a-4d0a-acbb-ee0b98b3f6b5') + assert FelineResource.verify_key('f1a4d5f2-e77a-4d0a-acbb-ee0b98b3f6b5') assert_raises JSONAPI::Exceptions::InvalidFieldValue do - CatResource.verify_key('f1a-e77a-4d0a-acbb-ee0b98b3f6b5') + FelineResource.verify_key('f1a-e77a-4d0a-acbb-ee0b98b3f6b5') end ensure - CatResource.instance_eval do + FelineResource.instance_eval do key_type nil end end def test_key_type_proc - CatResource.instance_eval do + FelineResource.instance_eval do key_type -> (key, context) { return key if key.nil? if key.to_s.match(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/) @@ -502,14 +526,14 @@ def test_key_type_proc } end - assert CatResource.verify_key('f1a4d5f2-e77a-4d0a-acbb-ee0b98b3f6b5') + assert FelineResource.verify_key('f1a4d5f2-e77a-4d0a-acbb-ee0b98b3f6b5') assert_raises JSONAPI::Exceptions::InvalidFieldValue do - CatResource.verify_key('f1a-e77a-4d0a-acbb-ee0b98b3f6b5') + FelineResource.verify_key('f1a-e77a-4d0a-acbb-ee0b98b3f6b5') end ensure - CatResource.instance_eval do + FelineResource.instance_eval do key_type nil end end @@ -585,7 +609,7 @@ class NoModelResource < JSONAPI::Resource NoModelResource._model_class CODE end - assert_match "[MODEL NOT FOUND] Model could not be found for ResourceTest::NoModelResource. If this a base Resource declare it as abstract.\n", err + assert_match "[MODEL NOT FOUND] Model could not be found for ResourceTest::NoModelResource. If this is a base Resource declare it as abstract.\n", err end def test_no_warning_when_abstract @@ -629,4 +653,83 @@ def test_resources_for_transforms_records_into_resources resources = PostResource.resources_for([Post.first], {}) assert_equal(PostResource, resources.first.class) end + + def test_singleton_options + TestSingletonResource.singleton true + assert TestSingletonResource.singleton? + assert TestSingletonResource._singleton_options.blank? + + TestSingletonResource.singleton false + refute TestSingletonResource.singleton? + assert TestSingletonResource._singleton_options.blank? + + TestSingletonResource.singleton true, a: :b + assert TestSingletonResource.singleton? + refute TestSingletonResource._singleton_options.blank? + assert_equal :b, TestSingletonResource._singleton_options[:a] + + TestSingletonResource.singleton false, c: :d + refute TestSingletonResource.singleton? + refute TestSingletonResource._singleton_options.blank? + assert_equal :d, TestSingletonResource._singleton_options[:c] + + TestSingletonResource.singleton e: :f + assert TestSingletonResource.singleton? + refute TestSingletonResource._singleton_options.blank? + assert_equal :f, TestSingletonResource._singleton_options[:e] + end + + def test_exclude_links_on_resource + Api::V5::PostResource.exclude_links :none + assert_equal [], Api::V5::PostResource._exclude_links + refute Api::V5::PostResource.exclude_link?(:self) + refute Api::V5::PostResource.exclude_link?("self") + + Api::V5::PostResource.exclude_links :default + assert_equal [:self], Api::V5::PostResource._exclude_links + assert Api::V5::PostResource.exclude_link?(:self) + assert Api::V5::PostResource.exclude_link?("self") + + Api::V5::PostResource.exclude_links "none" + assert_equal [], Api::V5::PostResource._exclude_links + refute Api::V5::PostResource.exclude_link?(:self) + refute Api::V5::PostResource.exclude_link?("self") + + Api::V5::PostResource.exclude_links "default" + assert_equal [:self], Api::V5::PostResource._exclude_links + assert Api::V5::PostResource.exclude_link?(:self) + assert Api::V5::PostResource.exclude_link?("self") + + Api::V5::PostResource.exclude_links :none + assert_equal [], Api::V5::PostResource._exclude_links + refute Api::V5::PostResource.exclude_link?(:self) + refute Api::V5::PostResource.exclude_link?("self") + + Api::V5::PostResource.exclude_links [:self] + assert_equal [:self], Api::V5::PostResource._exclude_links + assert Api::V5::PostResource.exclude_link?(:self) + assert Api::V5::PostResource.exclude_link?("self") + + Api::V5::PostResource.exclude_links :none + assert_equal [], Api::V5::PostResource._exclude_links + refute Api::V5::PostResource.exclude_link?(:self) + refute Api::V5::PostResource.exclude_link?("self") + + Api::V5::PostResource.exclude_links ["self"] + assert_equal [:self], Api::V5::PostResource._exclude_links + assert Api::V5::PostResource.exclude_link?(:self) + assert Api::V5::PostResource.exclude_link?("self") + + Api::V5::PostResource.exclude_links [] + assert_equal [], Api::V5::PostResource._exclude_links + refute Api::V5::PostResource.exclude_link?(:self) + refute Api::V5::PostResource.exclude_link?("self") + + assert_raises do + Api::V5::PostResource.exclude_links :self + end + + ensure + Api::V5::PostResource.exclude_links :none + end end diff --git a/test/unit/serializer/include_directives_test.rb b/test/unit/serializer/include_directives_test.rb index 8738c5044..56306f114 100644 --- a/test/unit/serializer/include_directives_test.rb +++ b/test/unit/serializer/include_directives_test.rb @@ -143,4 +143,22 @@ def test_three_levels_include_full_model_includes directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts.comments.tags']) assert_array_equals([{:posts=>[{:comments=>[:tags]}]}], directives.model_includes) end + + def test_invalid_includes_1 + assert_raises JSONAPI::Exceptions::InvalidInclude do + JSONAPI::IncludeDirectives.new(PersonResource, ['../../../../']).include_directives + end + end + + def test_invalid_includes_2 + assert_raises JSONAPI::Exceptions::InvalidInclude do + JSONAPI::IncludeDirectives.new(PersonResource, ['posts./sdaa./........']).include_directives + end + end + + def test_invalid_includes_3 + assert_raises JSONAPI::Exceptions::InvalidInclude do + JSONAPI::IncludeDirectives.new(PersonResource, ['invalid../../../../']).include_directives + end + end end diff --git a/test/unit/serializer/link_builder_test.rb b/test/unit/serializer/link_builder_test.rb index e1062280b..d7c277ad2 100644 --- a/test/unit/serializer/link_builder_test.rb +++ b/test/unit/serializer/link_builder_test.rb @@ -2,6 +2,20 @@ require 'jsonapi-resources' require 'json' +module Api + module Secret + class PostResource < JSONAPI::Resource + attribute :title + attribute :body + + has_one :author, class_name: 'Person' + end + + class PersonResource < JSONAPI::Resource + end + end +end + class LinkBuilderTest < ActionDispatch::IntegrationTest def setup # the route format is being set directly in test_helper and is being set differently depending on @@ -11,7 +25,9 @@ def setup @base_url = "http://example.com" @route_formatter = JSONAPI.configuration.route_formatter - @steve = Person.create(name: "Steve Rogers", date_joined: "1941-03-01") + @steve = Person.create(name: "Steve Rogers", date_joined: "1941-03-01", id: 777) + @steves_prefs = Preferences.create(advanced_mode: true, id: 444, person_id: 777) + @great_post = Post.create(title: "Greatest Post", id: 555) end def test_engine_boolean @@ -30,19 +46,18 @@ def test_engine_boolean def test_engine_name assert_equal MyEngine::Engine, - JSONAPI::LinkBuilder.new( - primary_resource_klass: MyEngine::Api::V1::PersonResource - ).engine_name + JSONAPI::LinkBuilder.new( + primary_resource_klass: MyEngine::Api::V1::PersonResource + ).engine assert_equal ApiV2Engine::Engine, - JSONAPI::LinkBuilder.new( - primary_resource_klass: ApiV2Engine::PersonResource - ).engine_name - - assert_equal nil, - JSONAPI::LinkBuilder.new( - primary_resource_klass: Api::V1::PersonResource - ).engine_name + JSONAPI::LinkBuilder.new( + primary_resource_klass: ApiV2Engine::PersonResource + ).engine + + assert_nil JSONAPI::LinkBuilder.new( + primary_resource_klass: Api::V1::PersonResource + ).engine end def test_self_link_regular_app @@ -52,6 +67,7 @@ def test_self_link_regular_app base_url: @base_url, route_formatter: @route_formatter, primary_resource_klass: primary_resource_klass, + url_helpers: TestApp.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) @@ -61,13 +77,200 @@ def test_self_link_regular_app assert_equal expected_link, builder.self_link(source) end + def test_self_link_regular_app_not_routed + primary_resource_klass = Api::Secret::PostResource + + config = { + base_url: @base_url, + route_formatter: @route_formatter, + primary_resource_klass: primary_resource_klass, + url_helpers: TestApp.routes.url_helpers, + } + + builder = JSONAPI::LinkBuilder.new(config) + source = primary_resource_klass.new(@great_post, nil) + + + # Should not warn if warn_on_missing_routes is false + JSONAPI.configuration.warn_on_missing_routes = false + primary_resource_klass._warned_missing_route = false + + _out, err = capture_subprocess_io do + link = builder.self_link(source) + assert_nil link + end + assert_empty(err) + + # Test warn_on_missing_routes + JSONAPI.configuration.warn_on_missing_routes = true + primary_resource_klass._warned_missing_route = false + + _out, err = capture_subprocess_io do + link = builder.self_link(source) + assert_nil link + end + assert_equal(err, "self_link for Api::Secret::PostResource could not be generated\n") + + # should only warn once + builder = JSONAPI::LinkBuilder.new(config) + _out, err = capture_subprocess_io do + link = builder.self_link(source) + assert_nil link + end + assert_empty(err) + + ensure + JSONAPI.configuration.warn_on_missing_routes = true + end + + def test_primary_resources_url_not_routed + primary_resource_klass = Api::Secret::PostResource + + config = { + base_url: @base_url, + route_formatter: @route_formatter, + primary_resource_klass: primary_resource_klass, + url_helpers: TestApp.routes.url_helpers, + } + + builder = JSONAPI::LinkBuilder.new(config) + + # Should not warn if warn_on_missing_routes is false + JSONAPI.configuration.warn_on_missing_routes = false + primary_resource_klass._warned_missing_route = false + + _out, err = capture_subprocess_io do + link = builder.primary_resources_url + assert_nil link + end + assert_empty(err) + + # Test warn_on_missing_routes + JSONAPI.configuration.warn_on_missing_routes = true + primary_resource_klass._warned_missing_route = false + _out, err = capture_subprocess_io do + link = builder.primary_resources_url + assert_nil link + end + assert_equal(err, "primary_resources_url for Api::Secret::PostResource could not be generated\n") + + # should only warn once + builder = JSONAPI::LinkBuilder.new(config) + _out, err = capture_subprocess_io do + link = builder.primary_resources_url + assert_nil link + end + assert_empty(err) + + ensure + JSONAPI.configuration.warn_on_missing_routes = true + end + + def test_relationships_self_link_not_routed + primary_resource_klass = Api::Secret::PostResource + + config = { + base_url: @base_url, + route_formatter: @route_formatter, + primary_resource_klass: primary_resource_klass, + url_helpers: TestApp.routes.url_helpers, + } + + builder = JSONAPI::LinkBuilder.new(config) + + source = primary_resource_klass.new(@great_post, nil) + + relationship = Api::Secret::PostResource._relationships[:author] + + # Should not warn if warn_on_missing_routes is false + JSONAPI.configuration.warn_on_missing_routes = false + relationship._warned_missing_route = false + + _out, err = capture_subprocess_io do + link = builder.relationships_self_link(source, relationship) + assert_nil link + end + assert_empty(err) + + # Test warn_on_missing_routes + JSONAPI.configuration.warn_on_missing_routes = true + relationship._warned_missing_route = false + + _out, err = capture_subprocess_io do + link = builder.relationships_self_link(source, relationship) + assert_nil link + end + assert_equal(err, "self_link for Api::Secret::PostResource.author(BelongsToOne) could not be generated\n") + + # should only warn once + builder = JSONAPI::LinkBuilder.new(config) + _out, err = capture_subprocess_io do + link = builder.relationships_self_link(source, relationship) + assert_nil link + end + assert_empty(err) + + ensure + JSONAPI.configuration.warn_on_missing_routes = true + end + + def test_relationships_related_link_not_routed + primary_resource_klass = Api::Secret::PostResource + + config = { + base_url: @base_url, + route_formatter: @route_formatter, + primary_resource_klass: primary_resource_klass, + url_helpers: TestApp.routes.url_helpers, + } + + builder = JSONAPI::LinkBuilder.new(config) + + source = primary_resource_klass.new(@great_post, nil) + + relationship = Api::Secret::PostResource._relationships[:author] + + # Should not warn if warn_on_missing_routes is false + JSONAPI.configuration.warn_on_missing_routes = false + relationship._warned_missing_route = false + + _out, err = capture_subprocess_io do + link = builder.relationships_related_link(source, relationship) + assert_nil link + end + assert_empty(err) + + # Test warn_on_missing_routes + JSONAPI.configuration.warn_on_missing_routes = true + relationship._warned_missing_route = false + + _out, err = capture_subprocess_io do + link = builder.relationships_related_link(source, relationship) + assert_nil link + end + assert_equal(err, "related_link for Api::Secret::PostResource.author(BelongsToOne) could not be generated\n") + + # should only warn once + builder = JSONAPI::LinkBuilder.new(config) + _out, err = capture_subprocess_io do + link = builder.relationships_related_link(source, relationship) + assert_nil link + end + assert_empty(err) + + ensure + JSONAPI.configuration.warn_on_missing_routes = true + end + def test_self_link_with_engine_app primary_resource_klass = ApiV2Engine::PersonResource + primary_resource_klass._warned_missing_route = false config = { - base_url: @base_url, + base_url: "#{ @base_url }", route_formatter: @route_formatter, primary_resource_klass: primary_resource_klass, + url_helpers: ApiV2Engine::Engine.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) @@ -84,6 +287,7 @@ def test_self_link_with_engine_namespaced_app base_url: @base_url, route_formatter: @route_formatter, primary_resource_klass: primary_resource_klass, + url_helpers: MyEngine::Engine.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) @@ -100,6 +304,7 @@ def test_self_link_with_engine_app_and_camel_case_scope base_url: @base_url, route_formatter: @route_formatter, primary_resource_klass: primary_resource_klass, + url_helpers: MyEngine::Engine.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) @@ -114,6 +319,7 @@ def test_primary_resources_url_for_regular_app base_url: @base_url, route_formatter: @route_formatter, primary_resource_klass: Api::V1::PersonResource, + url_helpers: TestApp.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) @@ -126,7 +332,8 @@ def test_primary_resources_url_for_engine config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: ApiV2Engine::PersonResource + primary_resource_klass: ApiV2Engine::PersonResource, + url_helpers: ApiV2Engine::Engine.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) @@ -139,7 +346,8 @@ def test_primary_resources_url_for_namespaced_engine config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: MyEngine::Api::V1::PersonResource + primary_resource_klass: MyEngine::Api::V1::PersonResource, + url_helpers: MyEngine::Engine.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) @@ -152,108 +360,149 @@ def test_relationships_self_link_for_regular_app config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: Api::V1::PersonResource + primary_resource_klass: Api::V1::PersonResource, + url_helpers: TestApp.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) source = Api::V1::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {}) + relationship = Api::V1::PersonResource._relationships[:posts] expected_link = "#{ @base_url }/api/v1/people/#{ @steve.id }/relationships/posts" assert_equal expected_link, - builder.relationships_self_link(source, relationship) + builder.relationships_self_link(source, relationship) + end + + def test_relationships_self_link_for_regular_app_singleton + config = { + base_url: @base_url, + route_formatter: @route_formatter, + primary_resource_klass: Api::V1::PersonResource, + url_helpers: TestApp.routes.url_helpers, + } + + builder = JSONAPI::LinkBuilder.new(config) + source = Api::V1::PreferencesResource.new(@steves_prefs, nil) + relationship = Api::V1::PreferencesResource._relationships[:author] + expected_link = "#{ @base_url }/api/v1/preferences/relationships/author" + + assert_equal expected_link, + builder.relationships_self_link(source, relationship) + end + + def test_relationships_related_link_for_regular_app_singleton + config = { + base_url: @base_url, + route_formatter: @route_formatter, + primary_resource_klass: Api::V1::PersonResource, + url_helpers: TestApp.routes.url_helpers, + } + + builder = JSONAPI::LinkBuilder.new(config) + source = Api::V1::PreferencesResource.new(@steves_prefs, nil) + relationship = Api::V1::PreferencesResource._relationships[:author] + expected_link = "#{ @base_url }/api/v1/preferences/author" + + assert_equal expected_link, + builder.relationships_related_link(source, relationship) end def test_relationships_self_link_for_engine config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: ApiV2Engine::PersonResource + primary_resource_klass: ApiV2Engine::PersonResource, + url_helpers: ApiV2Engine::Engine.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) source = ApiV2Engine::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {}) + relationship = ApiV2Engine::PersonResource._relationships[:posts] expected_link = "#{ @base_url }/api_v2/people/#{ @steve.id }/relationships/posts" assert_equal expected_link, - builder.relationships_self_link(source, relationship) + builder.relationships_self_link(source, relationship) end def test_relationships_self_link_for_namespaced_engine config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: MyEngine::Api::V1::PersonResource + primary_resource_klass: MyEngine::Api::V1::PersonResource, + url_helpers: MyEngine::Engine.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) source = MyEngine::Api::V1::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {}) + relationship = MyEngine::Api::V1::PersonResource._relationships[:posts] expected_link = "#{ @base_url }/boomshaka/api/v1/people/#{ @steve.id }/relationships/posts" assert_equal expected_link, - builder.relationships_self_link(source, relationship) + builder.relationships_self_link(source, relationship) end def test_relationships_related_link_for_regular_app config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: Api::V1::PersonResource + primary_resource_klass: Api::V1::PersonResource, + url_helpers: TestApp.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) source = Api::V1::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {}) + relationship = Api::V1::PersonResource._relationships[:posts] expected_link = "#{ @base_url }/api/v1/people/#{ @steve.id }/posts" assert_equal expected_link, - builder.relationships_related_link(source, relationship) + builder.relationships_related_link(source, relationship) end def test_relationships_related_link_for_engine config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: ApiV2Engine::PersonResource + primary_resource_klass: ApiV2Engine::PersonResource, + url_helpers: ApiV2Engine::Engine.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) source = ApiV2Engine::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {}) + relationship = ApiV2Engine::PersonResource._relationships[:posts] expected_link = "#{ @base_url }/api_v2/people/#{ @steve.id }/posts" assert_equal expected_link, - builder.relationships_related_link(source, relationship) + builder.relationships_related_link(source, relationship) end def test_relationships_related_link_for_namespaced_engine config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: MyEngine::Api::V1::PersonResource + primary_resource_klass: MyEngine::Api::V1::PersonResource, + url_helpers: MyEngine::Engine.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) source = MyEngine::Api::V1::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {}) + relationship = MyEngine::Api::V1::PersonResource._relationships[:posts] expected_link = "#{ @base_url }/boomshaka/api/v1/people/#{ @steve.id }/posts" assert_equal expected_link, - builder.relationships_related_link(source, relationship) + builder.relationships_related_link(source, relationship) end def test_relationships_related_link_with_query_params config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: Api::V1::PersonResource + primary_resource_klass: Api::V1::PersonResource, + url_helpers: TestApp.routes.url_helpers, } builder = JSONAPI::LinkBuilder.new(config) source = Api::V1::PersonResource.new(@steve, nil) - relationship = JSONAPI::Relationship::ToMany.new("posts", {}) + relationship = Api::V1::PersonResource._relationships[:posts] expected_link = "#{ @base_url }/api/v1/people/#{ @steve.id }/posts?page%5Blimit%5D=12&page%5Boffset%5D=0" query = { page: { offset: 0, limit: 12 } } @@ -265,7 +514,8 @@ def test_query_link_for_regular_app config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: Api::V1::PersonResource + primary_resource_klass: Api::V1::PersonResource, + url_helpers: TestApp.routes.url_helpers, } query = { page: { offset: 0, limit: 12 } } @@ -279,7 +529,8 @@ def test_query_link_for_regular_app_with_camel_case_scope config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: AdminApi::V1::PersonResource + primary_resource_klass: AdminApi::V1::PersonResource, + url_helpers: TestApp.routes.url_helpers, } query = { page: { offset: 0, limit: 12 } } @@ -291,9 +542,10 @@ def test_query_link_for_regular_app_with_camel_case_scope def test_query_link_for_regular_app_with_dasherized_scope config = { - base_url: @base_url, - route_formatter: DasherizedRouteFormatter, - primary_resource_klass: DasherizedNamespace::V1::PersonResource + base_url: @base_url, + route_formatter: DasherizedRouteFormatter, + primary_resource_klass: DasherizedNamespace::V1::PersonResource, + url_helpers: TestApp.routes.url_helpers, } query = { page: { offset: 0, limit: 12 } } @@ -307,7 +559,8 @@ def test_query_link_for_engine config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: ApiV2Engine::PersonResource + primary_resource_klass: ApiV2Engine::PersonResource, + url_helpers: ApiV2Engine::Engine.routes.url_helpers, } query = { page: { offset: 0, limit: 12 } } @@ -321,7 +574,8 @@ def test_query_link_for_namespaced_engine config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: MyEngine::Api::V1::PersonResource + primary_resource_klass: MyEngine::Api::V1::PersonResource, + url_helpers: MyEngine::Engine.routes.url_helpers, } query = { page: { offset: 0, limit: 12 } } @@ -333,9 +587,10 @@ def test_query_link_for_namespaced_engine def test_query_link_for_engine_with_dasherized_scope config = { - base_url: @base_url, - route_formatter: DasherizedRouteFormatter, - primary_resource_klass: MyEngine::DasherizedNamespace::V1::PersonResource + base_url: @base_url, + route_formatter: DasherizedRouteFormatter, + primary_resource_klass: MyEngine::DasherizedNamespace::V1::PersonResource, + url_helpers: MyEngine::Engine.routes.url_helpers, } query = { page: { offset: 0, limit: 12 } } @@ -349,7 +604,8 @@ def test_query_link_for_engine_with_camel_case_scope config = { base_url: @base_url, route_formatter: @route_formatter, - primary_resource_klass: MyEngine::AdminApi::V1::PersonResource + primary_resource_klass: MyEngine::AdminApi::V1::PersonResource, + url_helpers: MyEngine::Engine.routes.url_helpers, } query = { page: { offset: 0, limit: 12 } } diff --git a/test/unit/serializer/polymorphic_serializer_test.rb b/test/unit/serializer/polymorphic_serializer_test.rb index 347fc8d33..163d2e29f 100644 --- a/test/unit/serializer/polymorphic_serializer_test.rb +++ b/test/unit/serializer/polymorphic_serializer_test.rb @@ -7,6 +7,8 @@ def setup @pictures = Picture.all @person = Person.find(1) + @questions = Question.all + JSONAPI.configuration.json_key_format = :camelized_key JSONAPI.configuration.route_format = :camelized_route end @@ -24,10 +26,11 @@ def test_polymorphic_relationship end def test_sti_polymorphic_to_many_serialization - serialized_data = JSONAPI::ResourceSerializer.new( - PersonResource, - include: %w(vehicles) - ).serialize_to_hash(PersonResource.new(@person, nil)) + serializer = JSONAPI::ResourceSerializer.new(PersonResource, + include: %w(vehicles), + url_helpers: TestApp.routes.url_helpers) + + serialized_data = serializer.serialize_to_hash(PersonResource.new(@person, nil)) assert_hash_equals( { @@ -128,11 +131,78 @@ def test_sti_polymorphic_to_many_serialization ) end - def test_polymorphic_to_one_serialization - serialized_data = JSONAPI::ResourceSerializer.new( - PictureResource, - include: %w(imageable) - ).serialize_to_hash(@pictures.map { |p| PictureResource.new p, nil }) + def test_sti_polymorphic_to_many_serialization_with_custom_polymorphic_records + person_resource = PersonResource.new(@person, nil) + serializer = JSONAPI::ResourceSerializer.new(PersonResource, + include: %w(vehicles), + url_helpers: TestApp.routes.url_helpers) + + def person_resource.records_for_vehicles(opts = {}) + @model.vehicles.none + end + + serialized_data = serializer.serialize_to_hash(person_resource) + + assert_hash_equals( + { + data: { + id: '1', + type: 'people', + links: { + self: '/people/1' + }, + attributes: { + name: 'Joe Author', + email: 'joe@xyz.fake', + dateJoined: '2013-08-07 16:25:00 -0400' + }, + relationships: { + comments: { + links: { + self: '/people/1/relationships/comments', + related: '/people/1/comments' + } + }, + posts: { + links: { + self: '/people/1/relationships/posts', + related: '/people/1/posts' + } + }, + vehicles: { + links: { + self: '/people/1/relationships/vehicles', + related: '/people/1/vehicles' + }, + :data => [] + }, + preferences: { + links: { + self: '/people/1/relationships/preferences', + related: '/people/1/preferences' + } + }, + hairCut: { + links: { + self: '/people/1/relationships/hairCut', + related: '/people/1/hairCut' + } + } + } + } + }, + serialized_data + ) + end + + + + def test_polymorphic_belongs_to_serialization + serializer = JSONAPI::ResourceSerializer.new(PictureResource, + include: %w(imageable), + url_helpers: TestApp.routes.url_helpers) + + serialized_data = serializer.serialize_to_hash(@pictures.map {|p| PictureResource.new p, nil}) assert_hash_equals( { @@ -249,6 +319,101 @@ def test_polymorphic_to_one_serialization ) end + def test_polymorphic_has_one_serialization + serializer = JSONAPI::ResourceSerializer.new( + QuestionResource, + include: %w(respondent), + url_helpers: TestApp.routes.url_helpers) + + serialized_data = serializer.serialize_to_hash(@questions.map { |p| QuestionResource.new p, nil }) + + assert_hash_equals( + { + data: [ + { + id: '1', + type: 'questions', + links: { + self: '/questions/1' + }, + attributes: { + text: 'How are you feeling today?' + }, + relationships: { + answer: { + links: { + self: '/questions/1/relationships/answer', + related: '/questions/1/answer' + } + }, + respondent: { + links: { + self: '/questions/1/relationships/respondent', + related: '/questions/1/respondent' + }, + data: { + type: 'patients', + id: '1' + } + } + } + }, + { + id: '2', + type: 'questions', + links: { + self: '/questions/2' + }, + attributes: { + text: 'How does the patient look today?' + }, + relationships: { + answer: { + links: { + self: '/questions/2/relationships/answer', + related: '/questions/2/answer' + } + }, + respondent: { + links: { + self: '/questions/2/relationships/respondent', + related: '/questions/2/respondent' + }, + data: { + type: 'doctors', + id: '1' + } + } + } + } + ], + :included => [ + { + id: '1', + type: 'patients', + links: { + self: '/patients/1' + }, + attributes: { + name: 'Bob Smith' + }, + }, + { + id: '1', + type: 'doctors', + links: { + self: '/doctors/1' + }, + attributes: { + name: 'Henry Jones Jr' + }, + } + ] + }, + serialized_data + ) + end + def test_polymorphic_get_related_resource get '/pictures/1/imageable', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } serialized_data = JSON.parse(response.body) diff --git a/test/unit/serializer/response_document_test.rb b/test/unit/serializer/response_document_test.rb index a32727d3d..17d8c4b82 100644 --- a/test/unit/serializer/response_document_test.rb +++ b/test/unit/serializer/response_document_test.rb @@ -9,12 +9,13 @@ def setup end def create_response_document(operation_results, resource_klass) - JSONAPI::ResponseDocument.new( - operation_results, - JSONAPI::ResourceSerializer.new(resource_klass), - { - primary_resource_klass: resource_klass - } + serializer = JSONAPI::ResourceSerializer.new(resource_klass, url_helpers: TestApp.routes.url_helpers) + + JSONAPI::ResponseDocument.new(operation_results, + serializer, + { + primary_resource_klass: resource_klass + } ) end diff --git a/test/unit/serializer/serializer_test.rb b/test/unit/serializer/serializer_test.rb index 2686424e0..457ce2ebf 100644 --- a/test/unit/serializer/serializer_test.rb +++ b/test/unit/serializer/serializer_test.rb @@ -21,10 +21,11 @@ def after_teardown def test_serializer - serialized = JSONAPI::ResourceSerializer.new( - PostResource, - base_url: 'http://example.com').serialize_to_hash(PostResource.new(@post, nil) - ) + serializer = JSONAPI::ResourceSerializer.new(PostResource, + base_url: 'http://example.com', + url_helpers: TestApp.routes.url_helpers) + + serialized = serializer.serialize_to_hash(PostResource.new(@post, nil)) assert_hash_equals( { @@ -72,22 +73,30 @@ def test_serializer end def test_serializer_nil_handling + serializer = JSONAPI::ResourceSerializer.new(PostResource, + url_helpers: TestApp.routes.url_helpers) + assert_hash_equals( { data: nil }, - JSONAPI::ResourceSerializer.new(PostResource).serialize_to_hash(nil) + serializer.serialize_to_hash(nil) ) end def test_serializer_namespaced_resource + serializer = JSONAPI::ResourceSerializer.new(Api::V1::PostResource, + base_url: 'http://example.com', + url_helpers: TestApp.routes.url_helpers) + assert_hash_equals( { data: { type: 'posts', id: '1', links: { - self: 'http://example.com/api/v1/posts/1' + self: 'http://example.com/api/v1/posts/1?secret=true', + raw: 'http://example.com/api/v1/posts/1/raw' }, attributes: { title: 'New post', @@ -116,13 +125,14 @@ def test_serializer_namespaced_resource } } }, - JSONAPI::ResourceSerializer.new(Api::V1::PostResource, - base_url: 'http://example.com').serialize_to_hash( - Api::V1::PostResource.new(@post, nil)) + serializer.serialize_to_hash(Api::V1::PostResource.new(@post, nil)) ) end def test_serializer_limited_fieldset + serializer = JSONAPI::ResourceSerializer.new(PostResource, + fields: {posts: [:id, :title, :author]}, + url_helpers: TestApp.routes.url_helpers) assert_hash_equals( { @@ -145,16 +155,16 @@ def test_serializer_limited_fieldset } } }, - JSONAPI::ResourceSerializer.new(PostResource, - fields: {posts: [:id, :title, :author]}).serialize_to_hash(PostResource.new(@post, nil)) + serializer.serialize_to_hash(PostResource.new(@post, nil)) ) end def test_serializer_include - serialized = JSONAPI::ResourceSerializer.new( - PostResource, - include: ['author'] - ).serialize_to_hash(PostResource.new(@post, nil)) + serializer = JSONAPI::ResourceSerializer.new(PostResource, + include: ['author'], + url_helpers: TestApp.routes.url_helpers) + + serialized = serializer.serialize_to_hash(PostResource.new(@post, nil)) assert_hash_equals( { @@ -251,12 +261,55 @@ def test_serializer_include ) end + def test_serializer_filtered_include + painter = Painter.find(1) + include_directives = JSONAPI::IncludeDirectives.new(Api::V5::PainterResource, ['paintings']) + include_directives.merge_filter('paintings', category: ['oil']) + + serializer = JSONAPI::ResourceSerializer.new(Api::V5::PainterResource, + include_directives: include_directives, + fields: {painters: [:id], paintings: [:id]}, + url_helpers: TestApp.routes.url_helpers) + + serialized = serializer.serialize_to_hash(Api::V5::PainterResource.new(painter, nil)) + + assert_hash_equals( + { + data: { + type: 'painters', + id: '1', + links: { + self: '/api/v5/painters/1' + }, + }, + included: [ + { + type: 'paintings', + id: '4', + links: { + self: '/api/v5/paintings/4' + } + }, + { + type: 'paintings', + id: '5', + links: { + self: '/api/v5/paintings/5' + } + } + ] + }, + serialized + ) + end + def test_serializer_key_format - serialized = JSONAPI::ResourceSerializer.new( - PostResource, - include: ['author'], - key_formatter: UnderscoredKeyFormatter - ).serialize_to_hash(PostResource.new(@post, nil)) + serializer = JSONAPI::ResourceSerializer.new(PostResource, + include: ['author'], + key_formatter: UnderscoredKeyFormatter, + url_helpers: TestApp.routes.url_helpers) + + serialized = serializer.serialize_to_hash(PostResource.new(@post, nil)) assert_hash_equals( { @@ -355,6 +408,12 @@ def test_serializer_key_format def test_serializer_include_sub_objects + serializer = JSONAPI::ResourceSerializer.new(PostResource, + include: ['comments', 'comments.tags'], + url_helpers: TestApp.routes.url_helpers) + + serialized = serializer.serialize_to_hash(PostResource.new(@post, nil)) + assert_hash_equals( { data: { @@ -524,8 +583,7 @@ def test_serializer_include_sub_objects } ] }, - JSONAPI::ResourceSerializer.new(PostResource, - include: ['comments', 'comments.tags']).serialize_to_hash(PostResource.new(@post, nil)) + serialized ) end @@ -533,11 +591,13 @@ def test_serializer_keeps_sorted_order_of_objects_with_self_referential_relation post1, post2, post3 = Post.find(1), Post.find(2), Post.find(3) post1.parent_post = post3 ordered_posts = [post1, post2, post3] - serialized_data = JSONAPI::ResourceSerializer.new( - ParentApi::PostResource, - include: ['parent_post'], - base_url: 'http://example.com').serialize_to_hash(ordered_posts.map {|p| ParentApi::PostResource.new(p, nil)} - )[:data] + + serializer = JSONAPI::ResourceSerializer.new(ParentApi::PostResource, + include: ['parent_post'], + base_url: 'http://example.com', + url_helpers: TestApp.routes.url_helpers) + + serialized_data = serializer.serialize_to_hash(ordered_posts.map {|p| ParentApi::PostResource.new(p, nil)})[:data] assert_equal(3, serialized_data.length) assert_equal("1", serialized_data[0]["id"]) @@ -547,10 +607,11 @@ def test_serializer_keeps_sorted_order_of_objects_with_self_referential_relation def test_serializer_different_foreign_key - serialized = JSONAPI::ResourceSerializer.new( - PersonResource, - include: ['comments'] - ).serialize_to_hash(PersonResource.new(@fred, nil)) + serializer = JSONAPI::ResourceSerializer.new(PersonResource, + include: ['comments'], + url_helpers: TestApp.routes.url_helpers) + + serialized = serializer.serialize_to_hash(PersonResource.new(@fred, nil)) assert_hash_equals( { @@ -678,6 +739,12 @@ def test_serializer_array_of_resources_always_include_to_one_linkage_data JSONAPI.configuration.always_include_to_one_linkage_data = true + serializer = JSONAPI::ResourceSerializer.new(PostResource, + include: ['comments', 'comments.tags'], + url_helpers: TestApp.routes.url_helpers) + + serialized = serializer.serialize_to_hash(posts) + assert_hash_equals( { data: [ @@ -978,8 +1045,7 @@ def test_serializer_array_of_resources_always_include_to_one_linkage_data } ] }, - JSONAPI::ResourceSerializer.new(PostResource, - include: ['comments', 'comments.tags']).serialize_to_hash(posts) + serialized ) ensure JSONAPI.configuration.always_include_to_one_linkage_data = false @@ -992,6 +1058,12 @@ def test_serializer_array_of_resources posts.push PostResource.new(post, nil) end + serializer = JSONAPI::ResourceSerializer.new(PostResource, + include: ['comments', 'comments.tags'], + url_helpers: TestApp.routes.url_helpers) + + serialized = serializer.serialize_to_hash(posts) + assert_hash_equals( { data: [ @@ -1255,8 +1327,7 @@ def test_serializer_array_of_resources } ] }, - JSONAPI::ResourceSerializer.new(PostResource, - include: ['comments', 'comments.tags']).serialize_to_hash(posts) + serialized ) end @@ -1267,6 +1338,18 @@ def test_serializer_array_of_resources_limited_fields posts.push PostResource.new(post, nil) end + serializer = JSONAPI::ResourceSerializer.new(PostResource, + include: ['comments', 'author', 'comments.tags', 'author.posts'], + fields: { + people: [:id, :email, :comments], + posts: [:id, :title], + tags: [:name], + comments: [:id, :body, :post] + }, + url_helpers: TestApp.routes.url_helpers) + + serialized = serializer.serialize_to_hash(posts) + assert_hash_equals( { data: [ @@ -1416,18 +1499,18 @@ def test_serializer_array_of_resources_limited_fields } ] }, - JSONAPI::ResourceSerializer.new(PostResource, - include: ['comments', 'author', 'comments.tags', 'author.posts'], - fields: { - people: [:id, :email, :comments], - posts: [:id, :title], - tags: [:name], - comments: [:id, :body, :post] - }).serialize_to_hash(posts) + serialized ) end def test_serializer_camelized_with_value_formatters + serializer = JSONAPI::ResourceSerializer.new(ExpenseEntryResource, + include: ['iso_currency', 'employee'], + fields: {people: [:id, :name, :email, :date_joined]}, + url_helpers: TestApp.routes.url_helpers) + + serialized = serializer.serialize_to_hash(ExpenseEntryResource.new(@expense_entry, nil)) + assert_hash_equals( { data: { @@ -1490,16 +1573,15 @@ def test_serializer_camelized_with_value_formatters } ] }, - JSONAPI::ResourceSerializer.new(ExpenseEntryResource, - include: ['iso_currency', 'employee'], - fields: {people: [:id, :name, :email, :date_joined]}).serialize_to_hash( - ExpenseEntryResource.new(@expense_entry, nil)) + serialized ) end def test_serializer_empty_links_null_and_array - planet_hash = JSONAPI::ResourceSerializer.new(PlanetResource).serialize_to_hash( - PlanetResource.new(Planet.find(8), nil)) + serializer = JSONAPI::ResourceSerializer.new(PlanetResource, + url_helpers: TestApp.routes.url_helpers) + + serialized = serializer.serialize_to_hash(PlanetResource.new(Planet.find(8), nil)) assert_hash_equals( { @@ -1534,7 +1616,9 @@ def test_serializer_empty_links_null_and_array } } } - }, planet_hash) + }, + serialized + ) end def test_serializer_include_with_empty_links_null_and_array @@ -1543,93 +1627,98 @@ def test_serializer_include_with_empty_links_null_and_array planets.push PlanetResource.new(planet, nil) end - planet_hash = JSONAPI::ResourceSerializer.new(PlanetResource, - include: ['planet_type'], - fields: { planet_types: [:id, :name] }).serialize_to_hash(planets) + serializer = JSONAPI::ResourceSerializer.new(PlanetResource, + include: ['planet_type'], + fields: {planet_types: [:id, :name]}, + url_helpers: TestApp.routes.url_helpers) + + serialized = serializer.serialize_to_hash(planets) assert_hash_equals( { data: [{ - type: 'planets', - id: '7', - attributes: { - name: 'Beta X', - description: 'Newly discovered Planet Z' - }, - links: { - self: '/planets/7' - }, - relationships: { - planetType: { - links: { - self: '/planets/7/relationships/planetType', - related: '/planets/7/planetType' - }, - data: { - type: 'planetTypes', - id: '5' - } - }, - tags: { - links: { - self: '/planets/7/relationships/tags', - related: '/planets/7/tags' - } - }, - moons: { - links: { - self: '/planets/7/relationships/moons', - related: '/planets/7/moons' - } - } - } - }, - { - type: 'planets', - id: '8', - attributes: { - name: 'Beta W', - description: 'Newly discovered Planet W' - }, - links: { - self: '/planets/8' - }, - relationships: { - planetType: { - links: { - self: '/planets/8/relationships/planetType', - related: '/planets/8/planetType' - }, - data: nil - }, - tags: { - links: { - self: '/planets/8/relationships/tags', - related: '/planets/8/tags' - } + type: 'planets', + id: '7', + attributes: { + name: 'Beta X', + description: 'Newly discovered Planet Z' + }, + links: { + self: '/planets/7' + }, + relationships: { + planetType: { + links: { + self: '/planets/7/relationships/planetType', + related: '/planets/7/planetType' + }, + data: { + type: 'planetTypes', + id: '5' + } + }, + tags: { + links: { + self: '/planets/7/relationships/tags', + related: '/planets/7/tags' + } + }, + moons: { + links: { + self: '/planets/7/relationships/moons', + related: '/planets/7/moons' + } + } + } + }, + { + type: 'planets', + id: '8', + attributes: { + name: 'Beta W', + description: 'Newly discovered Planet W' + }, + links: { + self: '/planets/8' + }, + relationships: { + planetType: { + links: { + self: '/planets/8/relationships/planetType', + related: '/planets/8/planetType' + }, + data: nil + }, + tags: { + links: { + self: '/planets/8/relationships/tags', + related: '/planets/8/tags' + } + }, + moons: { + links: { + self: '/planets/8/relationships/moons', + related: '/planets/8/moons' + } + } + } + } + ], + included: [ + { + type: 'planetTypes', + id: '5', + attributes: { + name: 'unknown' }, - moons: { - links: { - self: '/planets/8/relationships/moons', - related: '/planets/8/moons' - } + links: { + self: '/planetTypes/5' } } - } - ], - included: [ - { - type: 'planetTypes', - id: '5', - attributes: { - name: 'unknown' - }, - links: { - self: '/planetTypes/5' - } - } - ] - }, planet_hash) + ] + }, + serialized + ) end def test_serializer_booleans @@ -1638,6 +1727,11 @@ def test_serializer_booleans preferences = PreferencesResource.new(Preferences.find(1), nil) + serializer = JSONAPI::ResourceSerializer.new(PreferencesResource, + url_helpers: TestApp.routes.url_helpers) + + serialized = serializer.serialize_to_hash(preferences) + assert_hash_equals( { data: { @@ -1647,19 +1741,19 @@ def test_serializer_booleans advanced_mode: false }, links: { - self: '/preferences/1' + self: '/preferences' }, relationships: { author: { links: { - self: '/preferences/1/relationships/author', - related: '/preferences/1/author' + self: '/preferences/relationships/author', + related: '/preferences/author' } } } } }, - JSONAPI::ResourceSerializer.new(PreferencesResource).serialize_to_hash(preferences) + serialized ) ensure JSONAPI.configuration = original_config @@ -1671,6 +1765,11 @@ def test_serializer_data_types facts = FactResource.new(Fact.find(1), nil) + serializer = JSONAPI::ResourceSerializer.new(FactResource, + url_helpers: TestApp.routes.url_helpers) + + serialized = serializer.serialize_to_hash(facts) + assert_hash_equals( { data: { @@ -1692,17 +1791,21 @@ def test_serializer_data_types } } }, - JSONAPI::ResourceSerializer.new(FactResource).serialize_to_hash(facts) + serialized ) ensure JSONAPI.configuration = original_config end def test_serializer_to_one - serialized = JSONAPI::ResourceSerializer.new( - Api::V5::AuthorResource, - include: ['author_detail'] - ).serialize_to_hash(Api::V5::AuthorResource.new(Person.find(1), nil)) + original_config = JSONAPI.configuration.dup + JSONAPI.configuration.route_format = :dasherized_route + + serializer = JSONAPI::ResourceSerializer.new(Api::V5::AuthorResource, + include: ['author_detail'], + url_helpers: TestApp.routes.url_helpers) + + serialized = serializer.serialize_to_hash(Api::V5::AuthorResource.new(Person.find(1), nil)) assert_hash_equals( { @@ -1724,8 +1827,8 @@ def test_serializer_to_one }, authorDetail: { links: { - self: '/api/v5/authors/1/relationships/authorDetail', - related: '/api/v5/authors/1/authorDetail' + self: '/api/v5/authors/1/relationships/author-detail', + related: '/api/v5/authors/1/author-detail' }, data: {type: 'authorDetails', id: '1'} } @@ -1739,13 +1842,15 @@ def test_serializer_to_one authorStuff: 'blah blah' }, links: { - self: '/api/v5/authorDetails/1' + self: '/api/v5/author-details/1' } } ] }, serialized ) + ensure + JSONAPI.configuration = original_config end def test_serializer_resource_meta_fixed_value @@ -1758,10 +1863,12 @@ def meta(options) end end - serialized = JSONAPI::ResourceSerializer.new( - Api::V5::AuthorResource, - include: ['author_detail'] - ).serialize_to_hash(Api::V5::AuthorResource.new(Person.find(1), nil)) + serializer = JSONAPI::ResourceSerializer.new(Api::V5::AuthorResource, + include: ['author_detail'], + url_helpers: TestApp.routes.url_helpers + ) + + serialized = serializer.serialize_to_hash(Api::V5::AuthorResource.new(Person.find(1), nil)) assert_hash_equals( { @@ -1821,9 +1928,10 @@ def meta(options) def test_serialize_model_attr @make = Make.first - serialized = JSONAPI::ResourceSerializer.new( - MakeResource, - ).serialize_to_hash(MakeResource.new(@make, nil)) + serializer = JSONAPI::ResourceSerializer.new(MakeResource, + url_helpers: TestApp.routes.url_helpers) + + serialized = serializer.serialize_to_hash(MakeResource.new(@make, nil)) assert_hash_equals( { @@ -1835,9 +1943,10 @@ def test_serialize_model_attr def test_confusingly_named_attrs @wp = WebPage.first - serialized = JSONAPI::ResourceSerializer.new( - WebPageResource, - ).serialize_to_hash(WebPageResource.new(@wp, nil)) + serializer = JSONAPI::ResourceSerializer.new(WebPageResource, + url_helpers: TestApp.routes.url_helpers) + + serialized = serializer.serialize_to_hash(WebPageResource.new(@wp, nil)) assert_hash_equals( { @@ -1857,328 +1966,15 @@ def test_confusingly_named_attrs ) end - def test_questionable_has_one - # has_one - out, err = capture_io do - eval <<-CODE - class ::Questionable < ActiveRecord::Base - has_one :link - has_one :href - end - class ::QuestionableResource < JSONAPI::Resource - model_name '::Questionable' - has_one :link - has_one :href - end - cn = ::Questionable.new id: 1 - puts JSONAPI::ResourceSerializer.new( - ::QuestionableResource, - ).serialize_to_hash(::QuestionableResource.new(cn, nil)) - CODE - end - assert err.blank? - assert_equal( - { - :data=>{ - "id"=>"1", - "type"=>"questionables", - "links"=>{ - :self=>"/questionables/1" - }, - "relationships"=>{ - "link"=>{ - :links=>{ - :self=>"/questionables/1/relationships/link", - :related=>"/questionables/1/link" - } - }, - "href"=>{ - :links=>{ - :self=>"/questionables/1/relationships/href", - :related=>"/questionables/1/href" - } - } - } - } - }.to_s, - out.strip - ) - end - - def test_questionable_has_many - # has_one - out, err = capture_io do - eval <<-CODE - class ::Questionable2 < ActiveRecord::Base - self.table_name = 'questionables' - has_many :links - has_many :hrefs - end - class ::Questionable2Resource < JSONAPI::Resource - model_name '::Questionable2' - has_many :links - has_many :hrefs - end - cn = ::Questionable2.new id: 1 - puts JSONAPI::ResourceSerializer.new( - ::Questionable2Resource, - ).serialize_to_hash(::Questionable2Resource.new(cn, nil)) - CODE - end - assert err.blank? - assert_equal( - { - :data=>{ - "id"=>"1", - "type"=>"questionable2s", - "links"=>{ - :self=>"/questionable2s/1" - }, - "relationships"=>{ - "links"=>{ - :links=>{ - :self=>"/questionable2s/1/relationships/links", - :related=>"/questionable2s/1/links" - } - }, - "hrefs"=>{ - :links=>{ - :self=>"/questionable2s/1/relationships/hrefs", - :related=>"/questionable2s/1/hrefs" - } - } - } - } - }.to_s, - out.strip - ) - end - - def test_simple_custom_links - serialized_custom_link_resource = JSONAPI::ResourceSerializer.new(SimpleCustomLinkResource, base_url: 'http://example.com').serialize_to_hash(SimpleCustomLinkResource.new(Post.first, {})) - - custom_link_spec = { - data: { - type: 'simpleCustomLinks', - id: '1', - attributes: { - title: "New post", - body: "A body!!!", - subject: "New post" - }, - links: { - self: "http://example.com/simpleCustomLinks/1", - raw: "http://example.com/simpleCustomLinks/1/raw" - }, - relationships: { - writer: { - links: { - self: "http://example.com/simpleCustomLinks/1/relationships/writer", - related: "http://example.com/simpleCustomLinks/1/writer" - } - }, - section: { - links: { - self: "http://example.com/simpleCustomLinks/1/relationships/section", - related: "http://example.com/simpleCustomLinks/1/section" - } - }, - comments: { - links: { - self: "http://example.com/simpleCustomLinks/1/relationships/comments", - related: "http://example.com/simpleCustomLinks/1/comments" - } - } - } - } - } - - assert_hash_equals(custom_link_spec, serialized_custom_link_resource) - end - - def test_custom_links_with_custom_relative_paths - serialized_custom_link_resource = JSONAPI::ResourceSerializer - .new(CustomLinkWithRelativePathOptionResource, base_url: 'http://example.com') - .serialize_to_hash(CustomLinkWithRelativePathOptionResource.new(Post.first, {})) - - custom_link_spec = { - data: { - type: 'customLinkWithRelativePathOptions', - id: '1', - attributes: { - title: "New post", - body: "A body!!!", - subject: "New post" - }, - links: { - self: "http://example.com/customLinkWithRelativePathOptions/1", - raw: "http://example.com/customLinkWithRelativePathOptions/1/super/duper/path.xml" - }, - relationships: { - writer: { - links: { - self: "http://example.com/customLinkWithRelativePathOptions/1/relationships/writer", - related: "http://example.com/customLinkWithRelativePathOptions/1/writer" - } - }, - section: { - links: { - self: "http://example.com/customLinkWithRelativePathOptions/1/relationships/section", - related: "http://example.com/customLinkWithRelativePathOptions/1/section" - } - }, - comments: { - links: { - self: "http://example.com/customLinkWithRelativePathOptions/1/relationships/comments", - related: "http://example.com/customLinkWithRelativePathOptions/1/comments" - } - } - } - } - } - - assert_hash_equals(custom_link_spec, serialized_custom_link_resource) - end - - def test_custom_links_with_if_condition_equals_false - serialized_custom_link_resource = JSONAPI::ResourceSerializer - .new(CustomLinkWithIfCondition, base_url: 'http://example.com') - .serialize_to_hash(CustomLinkWithIfCondition.new(Post.first, {})) - - custom_link_spec = { - data: { - type: 'customLinkWithIfConditions', - id: '1', - attributes: { - title: "New post", - body: "A body!!!", - subject: "New post" - }, - links: { - self: "http://example.com/customLinkWithIfConditions/1", - }, - relationships: { - writer: { - links: { - self: "http://example.com/customLinkWithIfConditions/1/relationships/writer", - related: "http://example.com/customLinkWithIfConditions/1/writer" - } - }, - section: { - links: { - self: "http://example.com/customLinkWithIfConditions/1/relationships/section", - related: "http://example.com/customLinkWithIfConditions/1/section" - } - }, - comments: { - links: { - self: "http://example.com/customLinkWithIfConditions/1/relationships/comments", - related: "http://example.com/customLinkWithIfConditions/1/comments" - } - } - } - } - } - - assert_hash_equals(custom_link_spec, serialized_custom_link_resource) - end - - def test_custom_links_with_if_condition_equals_true - serialized_custom_link_resource = JSONAPI::ResourceSerializer - .new(CustomLinkWithIfCondition, base_url: 'http://example.com') - .serialize_to_hash(CustomLinkWithIfCondition.new(Post.find_by(title: "JR Solves your serialization woes!"), {})) - - custom_link_spec = { - data: { - type: 'customLinkWithIfConditions', - id: '2', - attributes: { - title: "JR Solves your serialization woes!", - body: "Use JR", - subject: "JR Solves your serialization woes!" - }, - links: { - self: "http://example.com/customLinkWithIfConditions/2", - conditional_custom_link: "http://example.com/customLinkWithIfConditions/2/conditional/link.json" - }, - relationships: { - writer: { - links: { - self: "http://example.com/customLinkWithIfConditions/2/relationships/writer", - related: "http://example.com/customLinkWithIfConditions/2/writer" - } - }, - section: { - links: { - self: "http://example.com/customLinkWithIfConditions/2/relationships/section", - related: "http://example.com/customLinkWithIfConditions/2/section" - } - }, - comments: { - links: { - self: "http://example.com/customLinkWithIfConditions/2/relationships/comments", - related: "http://example.com/customLinkWithIfConditions/2/comments" - } - } - } - } - } - - assert_hash_equals(custom_link_spec, serialized_custom_link_resource) - end - - - def test_custom_links_with_lambda - # custom link is based on created_at timestamp of Post - post_created_at = Post.first.created_at - serialized_custom_link_resource = JSONAPI::ResourceSerializer - .new(CustomLinkWithLambda, base_url: 'http://example.com') - .serialize_to_hash(CustomLinkWithLambda.new(Post.first, {})) - - custom_link_spec = { - data: { - type: 'customLinkWithLambdas', - id: '1', - attributes: { - title: "New post", - body: "A body!!!", - subject: "New post", - createdAt: post_created_at.as_json - }, - links: { - self: "http://example.com/customLinkWithLambdas/1", - link_to_external_api: "http://external-api.com/posts/#{post_created_at.year}/#{post_created_at.month}/#{post_created_at.day}-New-post" - }, - relationships: { - writer: { - links: { - self: "http://example.com/customLinkWithLambdas/1/relationships/writer", - related: "http://example.com/customLinkWithLambdas/1/writer" - } - }, - section: { - links: { - self: "http://example.com/customLinkWithLambdas/1/relationships/section", - related: "http://example.com/customLinkWithLambdas/1/section" - } - }, - comments: { - links: { - self: "http://example.com/customLinkWithLambdas/1/relationships/comments", - related: "http://example.com/customLinkWithLambdas/1/comments" - } - } - } - } - } + def test_includes_two_relationships_with_same_foreign_key + original_config = JSONAPI.configuration.dup + JSONAPI.configuration.route_format = :underscored_route - assert_hash_equals(custom_link_spec, serialized_custom_link_resource) - end + serializer = JSONAPI::ResourceSerializer.new(PersonWithEvenAndOddPostResource, + include: ['even_posts', 'odd_posts'], + url_helpers: TestApp.routes.url_helpers) - def test_includes_two_relationships_with_same_foreign_key - serialized_resource = JSONAPI::ResourceSerializer - .new(PersonWithEvenAndOddPostsResource, include: ['even_posts','odd_posts']) - .serialize_to_hash(PersonWithEvenAndOddPostsResource.new(Person.find(1), nil)) + serialized = serializer.serialize_to_hash(PersonWithEvenAndOddPostResource.new(Person.find(1), nil)) assert_hash_equals( { @@ -2186,13 +1982,13 @@ def test_includes_two_relationships_with_same_foreign_key id: "1", type: "personWithEvenAndOddPosts", links: { - self: "/personWithEvenAndOddPosts/1" + self: "/person_with_even_and_odd_posts/1" }, relationships: { evenPosts: { links: { - self: "/personWithEvenAndOddPosts/1/relationships/evenPosts", - related: "/personWithEvenAndOddPosts/1/evenPosts" + self: "/person_with_even_and_odd_posts/1/relationships/even_posts", + related: "/person_with_even_and_odd_posts/1/even_posts" }, data: [ { @@ -2203,8 +1999,8 @@ def test_includes_two_relationships_with_same_foreign_key }, oddPosts: { links: { - self: "/personWithEvenAndOddPosts/1/relationships/oddPosts", - related: "/personWithEvenAndOddPosts/1/oddPosts" + self: "/person_with_even_and_odd_posts/1/relationships/odd_posts", + related: "/person_with_even_and_odd_posts/1/odd_posts" }, data:[ { @@ -2336,62 +2132,60 @@ def test_includes_two_relationships_with_same_foreign_key } ] }, - serialized_resource + serialized ) + ensure + JSONAPI.configuration = original_config end def test_config_keys_stable (serializer_a, serializer_b) = 2.times.map do - JSONAPI::ResourceSerializer.new( - PostResource, - include: ['comments', 'author', 'comments.tags', 'author.posts'], - fields: { - people: [:email, :comments], - posts: [:title], - tags: [:name], - comments: [:body, :post] - } - ) + JSONAPI::ResourceSerializer.new(PostResource, + include: ['comments', 'author', 'comments.tags', 'author.posts'], + fields: { + people: [:email, :comments], + posts: [:title], + tags: [:name], + comments: [:body, :post] + }, + url_helpers: TestApp.routes.url_helpers) end assert_equal serializer_a.config_key(PostResource), serializer_b.config_key(PostResource) end def test_config_keys_vary_with_relevant_config_changes - serializer_a = JSONAPI::ResourceSerializer.new( - PostResource, - fields: { posts: [:title] } - ) - serializer_b = JSONAPI::ResourceSerializer.new( - PostResource, - fields: { posts: [:title, :body] } - ) + serializer_a = JSONAPI::ResourceSerializer.new(PostResource, + fields: {posts: [:title]}, + url_helpers: TestApp.routes.url_helpers) + + serializer_b = JSONAPI::ResourceSerializer.new(PostResource, + fields: {posts: [:title, :body]}, + url_helpers: TestApp.routes.url_helpers) assert_not_equal serializer_a.config_key(PostResource), serializer_b.config_key(PostResource) end def test_config_keys_stable_with_irrelevant_config_changes - serializer_a = JSONAPI::ResourceSerializer.new( - PostResource, - fields: { posts: [:title, :body], people: [:name, :email] } - ) - serializer_b = JSONAPI::ResourceSerializer.new( - PostResource, - fields: { posts: [:title, :body], people: [:name] } - ) + serializer_a = JSONAPI::ResourceSerializer.new(PostResource, + fields: {posts: [:title, :body], people: [:name, :email]}, + url_helpers: TestApp.routes.url_helpers) + + serializer_b = JSONAPI::ResourceSerializer.new(PostResource, + fields: {posts: [:title, :body], people: [:name]}, + url_helpers: TestApp.routes.url_helpers) assert_equal serializer_a.config_key(PostResource), serializer_b.config_key(PostResource) end def test_config_keys_stable_with_different_primary_resource - serializer_a = JSONAPI::ResourceSerializer.new( - PostResource, - fields: { posts: [:title, :body], people: [:name, :email] } - ) - serializer_b = JSONAPI::ResourceSerializer.new( - PersonResource, - fields: { posts: [:title, :body], people: [:name, :email] } - ) + serializer_a = JSONAPI::ResourceSerializer.new(PostResource, + fields: {posts: [:title, :body], people: [:name, :email]}, + url_helpers: TestApp.routes.url_helpers) + + serializer_b = JSONAPI::ResourceSerializer.new(PersonResource, + fields: {posts: [:title, :body], people: [:name, :email]}, + url_helpers: TestApp.routes.url_helpers) assert_equal serializer_a.config_key(PostResource), serializer_b.config_key(PostResource) end