diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..148dd4f86 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,24 @@ +## This issue is a (choose one): + +- [ ] Problem/bug report. +- [ ] Feature request. +- [ ] Request for support. **Note: Please try to avoid submitting issues for support requests. Use [Gitter](https://gitter.im/cerebris/jsonapi-resources) instead.** + +## Checklist before submitting: + +- [ ] I've searched for an existing issue. +- [ ] I've asked my question on [Gitter](https://gitter.im/cerebris/jsonapi-resources) and have not received a satisfactory answer. +- [ ] I've included a complete [bug report template](https://github.com/cerebris/jsonapi-resources/blob/master/lib/bug_report_templates/rails_5_master.rb). This step helps us and allows us to see the bug without trying to reproduce the problem from your description. It helps you because you will frequently detect if it's a problem specific to your project. +- [ ] The feature I'm asking for is compliant with the [JSON:API](http://jsonapi.org/) spec. + +## Description + +Choose one section below and delete the other: + +### Bug reports: + +Please review [Did you find a bug?](https://github.com/cerebris/jsonapi-resources/blob/master/README.md#did-you-find-a-bug) and replace this content with a brief summary of your issue. If you can't submit a [bug report template](https://github.com/cerebris/jsonapi-resources/blob/master/lib/bug_report_templates/rails_5_master.rb) please be as thorough as possible when describing your your description. It's helpful to indicate which version of ruby and the JR gem you are using. + +### Features: + +Please replace this line with a clear writeup of your feature request. Features that break compliance with the [JSON:API](http://jsonapi.org/) spec will probably be closed. \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..c7c3f312a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,26 @@ + + +### All Submissions: + +- [ ] I've checked to ensure there aren't other open [Pull Requests](https://github.com/cerebris/jsonapi-resources/pulls) for the same update/change. +- [ ] I've submitted a [ticket](https://github.com/cerebris/jsonapi-resources/issues) for my issue if one did not already exist. +- [ ] My submission passes all tests. (Please run the full test suite locally to cut down on noise from travis failures.) +- [ ] I've used Github [auto-closing keywords](https://help.github.com/articles/closing-issues-via-commit-messages/) in the commit message or the description. +- [ ] I've added/updated tests for this change. + +### New Feature Submissions: + +- [ ] I've submitted an issue that describes this feature, and received the go ahead from the maintainers. +- [ ] My submission includes new tests. +- [ ] My submission maintains compliance with [JSON:API](http://jsonapi.org/). + +### Bug fixes and Changes to Core Features: + +- [ ] I've included an explanation of what the changes do and why I'd like you to include them. +- [ ] I've provided test(s) that fails without the change. + +### Test Plan: + +### Reviewer Checklist: +- [ ] Maintains compliance with JSON:API +- [ ] Adequate test coverage exists to prevent regressions \ No newline at end of file diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml new file mode 100644 index 000000000..8fd0da3c2 --- /dev/null +++ b/.github/workflows/ruby.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [ 'master' ] + pull_request: + branches: ['**'] + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: + - '3.4' + - '3.3' + - '3.2' + rails: + - '7.1' + - '7.0' + - '8.0.2' + database_url: + - sqlite3:test_db + env: + RAILS_VERSION: ${{ matrix.rails }} + DATABASE_URL: ${{ matrix.database_url }} + name: Ruby ${{ matrix.ruby }} Rails ${{ matrix.rails }} DB ${{ matrix.database_url }} + steps: + - uses: actions/checkout@v3 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Run tests + run: bundle exec rake test diff --git a/.gitignore b/.gitignore index 6cc125d63..800c71c6a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ coverage test/log test_db test_db-journal +.idea +*.iml 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/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..03ce14d91 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing + +All contributions to this project are subject to the [MIT License](https://foss-haas.mit-license.org/). By submitting a contribution, you agree to license your work under these terms. + +## Contribution Process + +### 1. Issue First + +All contributions from outside the core team require an **Issue First** approach. Before submitting a pull request (PR), you must: + +- Open an issue in the repository. +- Ensure the issue includes: + - **Clear problem statement:** Describe the issue or feature request. + - **Reproduction steps:** If reporting a bug, provide steps to reproduce it. + - **Proposed approach:** Outline your suggested solution or implementation. + - **Why this change matters:** Explain the impact or necessity of the change. +- Tag `@sanger/psd-developers` in the issue to bring it to the attention of a maintainer. +- Wait for the issue to be assigned or approved by a maintainer. + +### 2. Pull Request + +Once your issue is approved: + +- Fork the repository and create a branch for your changes. +- Submit a PR referencing the approved issue. +- Ensure your code adheres to the project's coding standards and passes all tests. + +### 3. Review + +Maintainers will review your PR. Address any feedback before merging. \ No newline at end of file diff --git a/Gemfile b/Gemfile index c58d1c896..f022a438b 100644 --- a/Gemfile +++ b/Gemfile @@ -2,22 +2,25 @@ 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 + gem 'pg' + gem 'mysql2' + gem 'sqlite3' + gem 'csv' +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/LICENSE.txt b/LICENSE.txt index 997411409..fd20f1555 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright (c) 2014 Larry Gebhardt +Copyright (c) 2014-2021 Cerebris Corporation MIT License diff --git a/README.md b/README.md index 02c6b30cf..b7f2f18f2 100644 --- a/README.md +++ b/README.md @@ -2,60 +2,18 @@ [![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 5.1+, 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.10 alpha Guide](http://jsonapi-resources.com/v0.10/guide/) specific to this version. ## Demo App @@ -63,2107 +21,34 @@ 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 Add JR to your application's `Gemfile`: - gem 'jsonapi-resources' - -And then execute: - - $ bundle - -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 +``` +gem 'jsonapi-resources' ``` +And then execute: -###### 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 +```bash +bundle ``` +Or install it yourself as: -###### 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 +```bash +gem install jsonapi-resources ``` -#### 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.10 alpha Guide](http://jsonapi-resources.com/v0.10/guide/)** ## Contributing - -1. Fork it ( http://github.com/cerebris/jsonapi-resources/fork ) -2. Create your feature branch (`git checkout -b my-new-feature`) -3. Commit your changes (`git commit -am 'Add some feature'`) -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` +See CONTRIBUTING.md for details. ## License -Copyright 2014-2016 Cerebris Corporation. MIT License (see LICENSE for details). +Copyright 2014-2021 Cerebris Corporation. MIT License (see LICENSE for details). diff --git a/Rakefile b/Rakefile index 7c629c8a6..01619ed8e 100644 --- a/Rakefile +++ b/Rakefile @@ -8,7 +8,7 @@ Rake::TestTask.new do |t| t.test_files = FileList['test/**/*_test.rb'] end -task default: :test +task default: [:test] desc 'Run benchmarks' namespace :test do @@ -16,3 +16,20 @@ namespace :test do t.pattern = 'test/benchmark/*_benchmark.rb' end end + +desc 'Test bug report template' +namespace :test do + namespace :bug_report_template do + task :rails_5 do + puts 'Test bug report templates' + jsonapi_resources_root = File.expand_path('..', __FILE__) + chdir_path = File.join(jsonapi_resources_root, 'lib', 'bug_report_templates') + report_env = {'SILENT' => 'true', 'JSONAPI_RESOURCES_PATH' => jsonapi_resources_root} + Bundler.with_clean_env do + Dir.chdir(chdir_path) do + abort('bug report template rails_5_master fails') unless system(report_env, Gem.ruby, 'rails_5_master.rb') + end + end + end + end +end diff --git a/jsonapi-resources.gemspec b/jsonapi-resources.gemspec index 3f031d173..22745c81d 100644 --- a/jsonapi-resources.gemspec +++ b/jsonapi-resources.gemspec @@ -4,29 +4,30 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'jsonapi/resources/version' Gem::Specification.new do |spec| - spec.name = 'jsonapi-resources' + spec.name = 'sanger-jsonapi-resources' spec.version = JSONAPI::Resources::VERSION - spec.authors = ['Dan Gebhardt', 'Larry Gebhardt'] - spec.email = ['dan@cerebris.com', 'larry@cerebris.com'] + spec.authors = ['PSD Team - Wellcome Trust Sanger Institute'] + spec.email = ['psd@sanger.ac.uk'] spec.summary = 'Easily support JSON API in Rails.' - spec.description = 'A resource-centric approach to implementing the controllers, routes, and serializers needed to support the JSON API spec.' - spec.homepage = 'https://github.com/cerebris/jsonapi-resources' + spec.description = 'Forked from jsonapi-resources. A resource-centric approach to implementing the controllers, routes, and serializers needed to support the JSON API spec.' + spec.homepage = 'https://github.com/sanger/jsonapi-resources' spec.license = 'MIT' spec.files = Dir.glob("{bin,lib}/**/*") + %w(LICENSE.txt README.md) spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ['lib'] - spec.required_ruby_version = '>= 2.1' + spec.required_ruby_version = '>= 2.3' - spec.add_development_dependency 'bundler', '~> 1.5' + spec.add_development_dependency 'bundler', '>= 1.17' spec.add_development_dependency 'rake' - spec.add_development_dependency 'minitest' + spec.add_development_dependency 'minitest', '~> 5.10', '!= 5.10.2' 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_dependency 'activerecord', '>= 4.1' - spec.add_dependency 'railties', '>= 4.1' + spec.add_development_dependency 'database_cleaner' + spec.add_dependency 'activerecord', '>= 5.1' + spec.add_dependency 'railties', '>= 5.1' spec.add_dependency 'concurrent-ruby' end diff --git a/lib/bug_report_templates/rails_5_latest.rb b/lib/bug_report_templates/rails_5_latest.rb new file mode 100644 index 000000000..688424617 --- /dev/null +++ b/lib/bug_report_templates/rails_5_latest.rb @@ -0,0 +1,125 @@ +begin + require 'bundler/inline' +rescue LoadError => e + STDERR.puts 'Bundler version 1.10 or later is required. Please update your Bundler' + raise e +end + +gemfile(true) do + source 'https://rubygems.org' + + gem 'rails', require: false + gem 'sqlite3', platform: :mri + + gem 'activerecord-jdbcsqlite3-adapter', + git: 'https://github.com/jruby/activerecord-jdbc-adapter', + branch: 'rails-5', + platform: :jruby + + gem 'jsonapi-resources', require: false +end + +# prepare active_record database +require 'active_record' + +ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') +ActiveRecord::Base.logger = Logger.new(STDOUT) + +ActiveRecord::Schema.define do + # Add your schema here + create_table :your_models, force: true do |t| + t.string :name + end +end + +# create models +class YourModel < ActiveRecord::Base +end + +# prepare rails app +require 'action_controller/railtie' +# require 'action_view/railtie' +require 'jsonapi-resources' + +class ApplicationController < ActionController::Base +end + +# prepare jsonapi resources and controllers +class YourModelsController < ApplicationController + include JSONAPI::ActsAsResourceController +end + +class YourModelResource < JSONAPI::Resource + attribute :name + filter :name +end + +class TestApp < Rails::Application + config.root = File.dirname(__FILE__) + config.logger = Logger.new(STDOUT) + Rails.logger = config.logger + + secrets.secret_token = 'secret_token' + secrets.secret_key_base = 'secret_key_base' + + config.eager_load = false +end + +# initialize app +Rails.application.initialize! + +JSONAPI.configure do |config| + config.json_key_format = :underscored_key + config.route_format = :underscored_key +end + +# draw routes +Rails.application.routes.draw do + jsonapi_resources :your_models, only: [:index, :create] +end + +# prepare tests +require 'minitest/autorun' +require 'rack/test' + +# Replace this with the code necessary to make your test fail. +class BugTest < Minitest::Test + include Rack::Test::Methods + + def json_api_headers + {'Accept' => JSONAPI::MEDIA_TYPE, 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE} + end + + def test_index_your_models + record = YourModel.create! name: 'John Doe' + get '/your_models', nil, json_api_headers + assert last_response.ok? + json_response = JSON.parse(last_response.body) + refute_nil json_response['data'] + refute_empty json_response['data'] + refute_empty json_response['data'].first + assert record.id.to_s, json_response['data'].first['id'] + assert 'your_models', json_response['data'].first['type'] + assert({'name' => 'John Doe'}, json_response['data'].first['attributes']) + end + + def test_create_your_models + json_request = { + 'data' => { + type: 'your_models', + attributes: { + name: 'Jane Doe' + } + } + } + post '/your_models', json_request.to_json, json_api_headers + assert last_response.created? + refute_nil YourModel.find_by(name: 'Jane Doe') + end + + private + + def app + Rails.application + end +end diff --git a/lib/bug_report_templates/rails_5_master.rb b/lib/bug_report_templates/rails_5_master.rb new file mode 100644 index 000000000..2e39916ba --- /dev/null +++ b/lib/bug_report_templates/rails_5_master.rb @@ -0,0 +1,140 @@ +begin + require 'bundler/inline' + require 'bundler' +rescue LoadError => e + STDERR.puts 'Bundler version 1.10 or later is required. Please update your Bundler' + raise e +end + +gemfile(true, ui: ENV['SILENT'] ? Bundler::UI::Silent.new : Bundler::UI::Shell.new) do + source 'https://rubygems.org' + + gem 'rails', require: false + gem 'sqlite3', platform: :mri + + gem 'activerecord-jdbcsqlite3-adapter', + git: 'https://github.com/jruby/activerecord-jdbc-adapter', + branch: 'rails-5', + platform: :jruby + + if ENV['JSONAPI_RESOURCES_PATH'] + gem 'jsonapi-resources', path: ENV['JSONAPI_RESOURCES_PATH'], require: false + else + gem 'jsonapi-resources', git: 'https://github.com/cerebris/jsonapi-resources', require: false + end + +end + +# prepare active_record database +require 'active_record' + +class NullLogger < Logger + def initialize(*_args) + end + + def add(*_args, &_block) + end +end + +ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') +ActiveRecord::Base.logger = ENV['SILENT'] ? NullLogger.new : Logger.new(STDOUT) +ActiveRecord::Migration.verbose = !ENV['SILENT'] + +ActiveRecord::Schema.define do + # Add your schema here + create_table :your_models, force: true do |t| + t.string :name + end +end + +# create models +class YourModel < ActiveRecord::Base +end + +# prepare rails app +require 'action_controller/railtie' +# require 'action_view/railtie' +require 'jsonapi-resources' + +class ApplicationController < ActionController::Base +end + +# prepare jsonapi resources and controllers +class YourModelsController < ApplicationController + include JSONAPI::ActsAsResourceController +end + +class YourModelResource < JSONAPI::Resource + attribute :name + filter :name +end + +class TestApp < Rails::Application + config.root = File.dirname(__FILE__) + config.logger = ENV['SILENT'] ? NullLogger.new : Logger.new(STDOUT) + Rails.logger = config.logger + + secrets.secret_token = 'secret_token' + secrets.secret_key_base = 'secret_key_base' + + config.eager_load = false +end + +# initialize app +Rails.application.initialize! + +JSONAPI.configure do |config| + config.json_key_format = :underscored_key + config.route_format = :underscored_key +end + +# draw routes +Rails.application.routes.draw do + jsonapi_resources :your_models, only: [:index, :create] +end + +# prepare tests +require 'minitest/autorun' +require 'rack/test' + +# Replace this with the code necessary to make your test fail. +class BugTest < Minitest::Test + include Rack::Test::Methods + + def json_api_headers + {'Accept' => JSONAPI::MEDIA_TYPE, 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE} + end + + def test_index_your_models + record = YourModel.create! name: 'John Doe' + get '/your_models', nil, json_api_headers + assert last_response.ok? + json_response = JSON.parse(last_response.body) + refute_nil json_response['data'] + refute_empty json_response['data'] + refute_empty json_response['data'].first + assert record.id.to_s, json_response['data'].first['id'] + assert 'your_models', json_response['data'].first['type'] + assert({'name' => 'John Doe'}, json_response['data'].first['attributes']) + end + + def test_create_your_models + json_request = { + 'data' => { + type: 'your_models', + attributes: { + name: 'Jane Doe' + } + } + } + post '/your_models', json_request.to_json, json_api_headers + assert last_response.created? + refute_nil YourModel.find_by(name: 'Jane Doe') + end + + private + + def app + Rails.application + end +end diff --git a/lib/generators/jsonapi/controller_generator.rb b/lib/generators/jsonapi/controller_generator.rb index 41ee4eb1e..d6aba8bf9 100644 --- a/lib/generators/jsonapi/controller_generator.rb +++ b/lib/generators/jsonapi/controller_generator.rb @@ -1,3 +1,4 @@ +require 'rails/generators' module Jsonapi class ControllerGenerator < Rails::Generators::NamedBase source_root File.expand_path('../templates', __FILE__) diff --git a/lib/generators/jsonapi/resource_generator.rb b/lib/generators/jsonapi/resource_generator.rb index 80aa24b4d..25feb14a1 100644 --- a/lib/generators/jsonapi/resource_generator.rb +++ b/lib/generators/jsonapi/resource_generator.rb @@ -1,3 +1,4 @@ +require 'rails/generators' module Jsonapi class ResourceGenerator < Rails::Generators::NamedBase source_root File.expand_path('../templates', __FILE__) diff --git a/lib/jsonapi-resources.rb b/lib/jsonapi-resources.rb index 035c4515f..401d9bbc7 100644 --- a/lib/jsonapi-resources.rb +++ b/lib/jsonapi-resources.rb @@ -1,10 +1,21 @@ +# frozen_string_literal: true + +require 'jsonapi/resources/railtie' require 'jsonapi/naive_cache' require 'jsonapi/compiled_json' +require 'jsonapi/basic_resource' +require 'jsonapi/active_relation_resource' require 'jsonapi/resource' -require 'jsonapi/cached_resource_fragment' +require 'jsonapi/cached_response_fragment' require 'jsonapi/response_document' require 'jsonapi/acts_as_resource_controller' -require 'jsonapi/resource_controller' +if Rails::VERSION::MAJOR >= 6 + ActiveSupport.on_load(:action_controller_base) do + require 'jsonapi/resource_controller' + end +else + require 'jsonapi/resource_controller' +end require 'jsonapi/resource_controller_metal' require 'jsonapi/resources/version' require 'jsonapi/configuration' @@ -16,12 +27,19 @@ require 'jsonapi/exceptions' require 'jsonapi/error' require 'jsonapi/error_codes' -require 'jsonapi/request_parser' -require 'jsonapi/operation_dispatcher' +require 'jsonapi/request' require 'jsonapi/processor' require 'jsonapi/relationship' require 'jsonapi/include_directives' +require 'jsonapi/operation' require 'jsonapi/operation_result' -require 'jsonapi/operation_results' require 'jsonapi/callbacks' require 'jsonapi/link_builder' +require 'jsonapi/active_relation/adapters/join_left_active_record_adapter' +require 'jsonapi/active_relation/join_manager' +require 'jsonapi/resource_identity' +require 'jsonapi/resource_fragment' +require 'jsonapi/resource_tree' +require 'jsonapi/resource_set' +require 'jsonapi/path' +require 'jsonapi/path_segment' diff --git a/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb b/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb new file mode 100644 index 000000000..a9a0bb8a0 --- /dev/null +++ b/lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb @@ -0,0 +1,26 @@ +module JSONAPI + module ActiveRelation + module Adapters + module JoinLeftActiveRecordAdapter + # Extends left_joins functionality to rails 4, and uses the same logic for rails 5.0.x and 5.1.x + # The default left_joins logic of rails 5.2.x is used. This results in and extra join in some cases. For + # example Post.joins(:comments).joins_left(comments: :author) will join the comments table twice, + # once inner and once left in 5.2, but only as inner in earlier versions. + def joins_left(*columns) + if Rails::VERSION::MAJOR >= 6 || (Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2) + left_joins(columns) + else + join_dependency = ActiveRecord::Associations::JoinDependency.new(self, columns, []) + joins(join_dependency) + end + end + + alias_method :join_left, :joins_left + end + + if defined?(ActiveRecord) + ActiveRecord::Base.extend JoinLeftActiveRecordAdapter + end + end + end +end \ No newline at end of file diff --git a/lib/jsonapi/active_relation/join_manager.rb b/lib/jsonapi/active_relation/join_manager.rb new file mode 100644 index 000000000..3d1ec34b5 --- /dev/null +++ b/lib/jsonapi/active_relation/join_manager.rb @@ -0,0 +1,297 @@ +module JSONAPI + module ActiveRelation + + # Stores relationship paths starting from the resource_klass, consolidating duplicate paths from + # relationships, filters and sorts. When joins are made the table aliases are tracked in join_details + class JoinManager + attr_reader :resource_klass, + :source_relationship, + :resource_join_tree, + :join_details + + def initialize(resource_klass:, + source_relationship: nil, + relationships: nil, + filters: nil, + sort_criteria: nil) + + @resource_klass = resource_klass + @join_details = nil + @collected_aliases = Set.new + + @resource_join_tree = { + root: { + join_type: :root, + resource_klasses: { + resource_klass => { + relationships: {} + } + } + } + } + add_source_relationship(source_relationship) + add_sort_criteria(sort_criteria) + add_filters(filters) + add_relationships(relationships) + end + + def join(records, options) + fail "can't be joined again" if @join_details + @join_details = {} + perform_joins(records, options) + end + + # source details will only be on a relationship if the source_relationship is set + # this method gets the join details whether they are on a relationship or are just pseudo details for the base + # resource. Specify the resource type for polymorphic relationships + # + def source_join_details(type=nil) + if source_relationship + related_resource_klass = type ? resource_klass.resource_klass_for(type) : source_relationship.resource_klass + segment = PathSegment::Relationship.new(relationship: source_relationship, resource_klass: related_resource_klass) + details = @join_details[segment] + else + if type + details = @join_details["##{type}"] + else + details = @join_details[''] + end + end + details + end + + def join_details_by_polymorphic_relationship(relationship, type) + segment = PathSegment::Relationship.new(relationship: relationship, resource_klass: resource_klass.resource_klass_for(type)) + @join_details[segment] + end + + def join_details_by_relationship(relationship) + segment = PathSegment::Relationship.new(relationship: relationship, resource_klass: relationship.resource_klass) + @join_details[segment] + end + + def self.get_join_arel_node(records, options = {}) + init_join_sources = records.arel.join_sources + init_join_sources_length = init_join_sources.length + + records = yield(records, options) + + join_sources = records.arel.join_sources + if join_sources.length > init_join_sources_length + last_join = (join_sources - init_join_sources).last + else + # :nocov: + warn "get_join_arel_node: No join added" + last_join = nil + # :nocov: + end + + return records, last_join + end + + def self.alias_from_arel_node(node) + case node.left + when Arel::Table + node.left.name + when Arel::Nodes::TableAlias + node.left.right + when Arel::Nodes::StringJoin + # :nocov: + warn "alias_from_arel_node: Unsupported join type - use custom filtering and sorting" + nil + # :nocov: + end + end + + private + + def flatten_join_tree_by_depth(join_array = [], node = @resource_join_tree, level = 0) + join_array[level] = [] unless join_array[level] + + node.each do |relationship, relationship_details| + relationship_details[:resource_klasses].each do |related_resource_klass, resource_details| + join_array[level] << { relationship: relationship, + relationship_details: relationship_details, + related_resource_klass: related_resource_klass} + flatten_join_tree_by_depth(join_array, resource_details[:relationships], level+1) + end + end + join_array + end + + def add_join_details(join_key, details, check_for_duplicate_alias = true) + fail "details already set" if @join_details.has_key?(join_key) + @join_details[join_key] = details + + # Joins are being tracked as they are added to the built up relation. If the same table is added to a + # relation more than once subsequent versions will be assigned an alias. Depending on the order the joins + # are made the computed aliases may change. The order this library performs the joins was chosen + # to prevent this. However if the relation is reordered it should result in reusing on of the earlier + # aliases (in this case a plain table name). The following check will catch this an raise an exception. + # An exception is appropriate because not using the correct alias could leak data due to filters and + # applied permissions being performed on the wrong data. + if check_for_duplicate_alias && @collected_aliases.include?(details[:alias]) + fail "alias '#{details[:alias]}' has already been added. Possible relation reordering" + end + + @collected_aliases << details[:alias] + end + + def perform_joins(records, options) + join_array = flatten_join_tree_by_depth + + join_array.each do |level_joins| + level_joins.each do |join_details| + relationship = join_details[:relationship] + relationship_details = join_details[:relationship_details] + related_resource_klass = join_details[:related_resource_klass] + join_type = relationship_details[:join_type] + + if relationship == :root + unless source_relationship + add_join_details('', {alias: resource_klass._table_name, join_type: :root}) + end + next + end + + records, join_node = self.class.get_join_arel_node(records, options) {|records, options| + related_resource_klass.join_relationship( + records: records, + resource_type: related_resource_klass._type, + join_type: join_type, + relationship: relationship, + options: options) + } + + details = {alias: self.class.alias_from_arel_node(join_node), join_type: join_type} + + if relationship == source_relationship + if relationship.polymorphic? && relationship.belongs_to? + add_join_details("##{related_resource_klass._type}", details) + else + add_join_details('', details) + end + end + + # We're adding the source alias with two keys. We only want the check for duplicate aliases once. + # See the note in `add_join_details`. + check_for_duplicate_alias = !(relationship == source_relationship) + add_join_details(PathSegment::Relationship.new(relationship: relationship, resource_klass: related_resource_klass), details, check_for_duplicate_alias) + end + end + records + end + + def add_join(path, default_type = :inner, default_polymorphic_join_type = :left) + if source_relationship + if source_relationship.polymorphic? + # Polymorphic paths will come it with the resource_type as the first segment (for example `#documents.comments`) + # We just need to prepend the relationship portion the + sourced_path = "#{source_relationship.name}#{path}" + else + sourced_path = "#{source_relationship.name}.#{path}" + end + else + sourced_path = path + end + + join_manager, _field = parse_path_to_tree(sourced_path, resource_klass, default_type, default_polymorphic_join_type) + + @resource_join_tree[:root].deep_merge!(join_manager) { |key, val, other_val| + if key == :join_type + if val == other_val + val + else + :inner + end + end + } + end + + def process_path_to_tree(path_segments, resource_klass, default_join_type, default_polymorphic_join_type) + node = { + resource_klasses: { + resource_klass => { + relationships: {} + } + } + } + + segment = path_segments.shift + + if segment.is_a?(PathSegment::Relationship) + node[:resource_klasses][resource_klass][:relationships][segment.relationship] ||= {} + + # join polymorphic as left joins + node[:resource_klasses][resource_klass][:relationships][segment.relationship][:join_type] ||= + segment.relationship.polymorphic? ? default_polymorphic_join_type : default_join_type + + segment.relationship.resource_types.each do |related_resource_type| + related_resource_klass = resource_klass.resource_klass_for(related_resource_type) + + # If the resource type was specified in the path segment we want to only process the next segments for + # that resource type, otherwise process for all + process_all_types = !segment.path_specified_resource_klass? + + if process_all_types || related_resource_klass == segment.resource_klass + related_resource_tree = process_path_to_tree(path_segments.dup, related_resource_klass, default_join_type, default_polymorphic_join_type) + node[:resource_klasses][resource_klass][:relationships][segment.relationship].deep_merge!(related_resource_tree) + end + end + end + node + end + + def parse_path_to_tree(path_string, resource_klass, default_join_type = :inner, default_polymorphic_join_type = :left) + path = JSONAPI::Path.new(resource_klass: resource_klass, path_string: path_string) + + field = path.segments[-1] + return process_path_to_tree(path.segments, resource_klass, default_join_type, default_polymorphic_join_type), field + end + + def add_source_relationship(source_relationship) + @source_relationship = source_relationship + + if @source_relationship + resource_klasses = {} + source_relationship.resource_types.each do |related_resource_type| + related_resource_klass = resource_klass.resource_klass_for(related_resource_type) + resource_klasses[related_resource_klass] = {relationships: {}} + end + + join_type = source_relationship.polymorphic? ? :left : :inner + + @resource_join_tree[:root][:resource_klasses][resource_klass][:relationships][@source_relationship] = { + source: true, resource_klasses: resource_klasses, join_type: join_type + } + end + end + + def add_filters(filters) + return if filters.blank? + filters.each_key do |filter| + # Do not add joins for filters with an apply callable. This can be overridden by setting perform_joins to true + next if resource_klass._allowed_filters[filter].try(:[], :apply) && + !resource_klass._allowed_filters[filter].try(:[], :perform_joins) + + add_join(filter, :left) + end + end + + def add_sort_criteria(sort_criteria) + return if sort_criteria.blank? + + sort_criteria.each do |sort| + add_join(sort[:field], :left) + end + end + + def add_relationships(relationships) + return if relationships.blank? + relationships.each do |relationship| + add_join(relationship, :left) + end + end + end + end +end \ No newline at end of file diff --git a/lib/jsonapi/active_relation_resource.rb b/lib/jsonapi/active_relation_resource.rb new file mode 100644 index 000000000..bfe88af26 --- /dev/null +++ b/lib/jsonapi/active_relation_resource.rb @@ -0,0 +1,898 @@ +# frozen_string_literal: true + +module JSONAPI + class ActiveRelationResource < BasicResource + root_resource + + def find_related_ids(relationship, options = {}) + self.class.find_related_fragments([self], relationship.name, options).keys.collect { |rid| rid.id } + end + + class << self + # Finds Resources using the `filters`. Pagination and sort options are used when provided + # + # @param filters [Hash] the filters hash + # @option options [Hash] :context The context of the request, set in the controller + # @option options [Hash] :sort_criteria The `sort criteria` + # @option options [Hash] :include_directives The `include_directives` + # + # @return [Array] the Resource instances matching the filters, sorting and pagination rules. + def find(filters, options = {}) + sort_criteria = options.fetch(:sort_criteria) { [] } + + join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + filters: filters, + sort_criteria: sort_criteria) + + paginator = options[:paginator] + + records = apply_request_settings_to_records(records: records(options), + sort_criteria: sort_criteria,filters: filters, + join_manager: join_manager, + paginator: paginator, + options: options) + + resources_for(records, options[:context]) + end + + # Counts Resources found using the `filters` + # + # @param filters [Hash] the filters hash + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [Integer] the count + def count(filters, options = {}) + join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + filters: filters) + + records = apply_request_settings_to_records(records: records(options), + filters: filters, + join_manager: join_manager, + options: options) + + count_records(records) + end + + # Returns the single Resource identified by `key` + # + # @param key the primary key of the resource to find + # @option options [Hash] :context The context of the request, set in the controller + def find_by_key(key, options = {}) + record = find_record_by_key(key, options) + resource_for(record, options[:context]) + end + + # Returns an array of Resources identified by the `keys` array + # + # @param keys [Array] Array of primary keys to find resources for + # @option options [Hash] :context The context of the request, set in the controller + def find_by_keys(keys, options = {}) + records = find_records_by_keys(keys, options) + resources_for(records, options[:context]) + end + + # Returns an array of Resources identified by the `keys` array. The resources are not filtered as this + # will have been done in a prior step + # + # @param keys [Array] Array of primary keys to find resources for + # @option options [Hash] :context The context of the request, set in the controller + def find_to_populate_by_keys(keys, options = {}) + records = records_for_populate(options).where(_primary_key => keys) + resources_for(records, options[:context]) + end + + # Finds Resource fragments using the `filters`. Pagination and sort options are used when provided. + # Retrieving the ResourceIdentities and attributes does not instantiate a model instance. + # Note: This is incompatible with Polymorphic resources (which are going to come from two separate tables) + # + # @param filters [Hash] the filters hash + # @option options [Hash] :context The context of the request, set in the controller + # @option options [Hash] :sort_criteria The `sort criteria` + # @option options [Hash] :include_directives The `include_directives` + # @option options [Hash] :attributes Additional fields to be retrieved. + # @option options [Boolean] :cache Return the resources' cache field + # + # @return [Hash{ResourceIdentity => {identity: => ResourceIdentity, cache: cache_field, attributes: => {name => value}}}] + # the ResourceInstances matching the filters, sorting, and pagination rules along with any request + # additional_field values + def find_fragments(filters, options = {}) + include_directives = options.fetch(:include_directives, {}) + resource_klass = self + + fragments = {} + + linkage_relationships = to_one_relationships_for_linkage(include_directives[:include_related]) + + sort_criteria = options.fetch(:sort_criteria) { [] } + + join_manager = ActiveRelation::JoinManager.new(resource_klass: resource_klass, + source_relationship: nil, + relationships: linkage_relationships, + sort_criteria: sort_criteria, + filters: filters) + + paginator = options[:paginator] + + records = apply_request_settings_to_records(records: records(options), + filters: filters, + sort_criteria: sort_criteria, + paginator: paginator, + join_manager: join_manager, + options: options) + + # This alias is going to be resolve down to the model's table name and will not actually be an alias + resource_table_alias = resource_klass._table_name + + pluck_fields = [sql_field_with_alias(resource_table_alias, resource_klass._primary_key)] + + cache_field = attribute_to_model_field(:_cache_field) if options[:cache] + if cache_field + pluck_fields << sql_field_with_alias(resource_table_alias, cache_field[:name]) + end + + linkage_fields = [] + + linkage_relationships.each do |name| + linkage_relationship = resource_klass._relationship(name) + + if linkage_relationship.polymorphic? && linkage_relationship.belongs_to? + linkage_relationship.resource_types.each do |resource_type| + klass = resource_klass_for(resource_type) + linkage_table_alias = join_manager.join_details_by_polymorphic_relationship(linkage_relationship, resource_type)[:alias] + primary_key = klass._primary_key + + linkage_fields << {relationship_name: name, + resource_klass: klass, + field: sql_field_with_alias(linkage_table_alias, primary_key), + alias: alias_table_field(linkage_table_alias, primary_key)} + + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) + end + else + klass = linkage_relationship.resource_klass + linkage_table_alias = join_manager.join_details_by_relationship(linkage_relationship)[:alias] + primary_key = klass._primary_key + + linkage_fields << {relationship_name: name, + resource_klass: klass, + field: sql_field_with_alias(linkage_table_alias, primary_key), + alias: alias_table_field(linkage_table_alias, primary_key)} + + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) + end + end + + model_fields = {} + attributes = options[:attributes] + attributes.try(:each) do |attribute| + model_field = resource_klass.attribute_to_model_field(attribute) + model_fields[attribute] = model_field + pluck_fields << sql_field_with_alias(resource_table_alias, model_field[:name]) + end + + sort_fields = options.dig(:_relation_helper_options, :sort_fields) + sort_fields.try(:each) do |field| + pluck_fields << Arel.sql(field) + end + + rows = records.pluck(*pluck_fields) + rows.each do |row| + rid = JSONAPI::ResourceIdentity.new(resource_klass, pluck_fields.length == 1 ? row : row[0]) + + fragments[rid] ||= JSONAPI::ResourceFragment.new(rid) + attributes_offset = 1 + + if cache_field + fragments[rid].cache = cast_to_attribute_type(row[1], cache_field[:type]) + attributes_offset+= 1 + end + + linkage_fields.each do |linkage_field_details| + fragments[rid].initialize_related(linkage_field_details[:relationship_name]) + related_id = row[attributes_offset] + if related_id + related_rid = JSONAPI::ResourceIdentity.new(linkage_field_details[:resource_klass], related_id) + fragments[rid].add_related_identity(linkage_field_details[:relationship_name], related_rid) + end + attributes_offset+= 1 + end + + model_fields.each_with_index do |k, idx| + fragments[rid].attributes[k[0]]= cast_to_attribute_type(row[idx + attributes_offset], k[1][:type]) + end + end + + if JSONAPI.configuration.warn_on_performance_issues && (rows.length > fragments.length) + warn "Performance issue detected: `#{self.name.to_s}.records` returned non-normalized results in `#{self.name.to_s}.find_fragments`." + end + + fragments + end + + # Finds Resource Fragments related to the source resources through the specified relationship + # + # @param source_rids [Array] The resources to find related ResourcesIdentities for + # @param relationship_name [String | Symbol] The name of the relationship + # @option options [Hash] :context The context of the request, set in the controller + # @option options [Hash] :attributes Additional fields to be retrieved. + # @option options [Boolean] :cache Return the resources' cache field + # + # @return [Hash{ResourceIdentity => {identity: => ResourceIdentity, cache: cache_field, attributes: => {name => value}, related: {relationship_name: [] }}}] + # the ResourceInstances matching the filters, sorting, and pagination rules along with any request + # additional_field values + def find_related_fragments(source, relationship_name, options = {}) + relationship = _relationship(relationship_name) + + if relationship.polymorphic? # && relationship.foreign_key_on == :self + find_related_polymorphic_fragments(source, relationship, options, false) + else + find_related_monomorphic_fragments(source, relationship, options, false) + end + end + + def find_included_fragments(source, relationship_name, options) + relationship = _relationship(relationship_name) + + if relationship.polymorphic? # && relationship.foreign_key_on == :self + find_related_polymorphic_fragments(source, relationship, options, true) + else + find_related_monomorphic_fragments(source, relationship, options, true) + end + end + + # Counts Resources related to the source resource through the specified relationship + # + # @param source_rid [ResourceIdentity] Source resource identifier + # @param relationship_name [String | Symbol] The name of the relationship + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [Integer] the count + def count_related(source_resource, relationship_name, options = {}) + relationship = _relationship(relationship_name) + related_klass = relationship.resource_klass + + filters = options.fetch(:filters, {}) + + # Joins in this case are related to the related_klass + join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + source_relationship: relationship, + filters: filters) + + records = apply_request_settings_to_records(records: records(options), + resource_klass: related_klass, + primary_keys: source_resource.id, + join_manager: join_manager, + filters: filters, + options: options) + + related_alias = join_manager.join_details_by_relationship(relationship)[:alias] + + records = records.select(Arel.sql("#{concat_table_field(related_alias, related_klass._primary_key)}")) + + count_records(records) + end + + # This resource class (ActiveRelationResource) uses an `ActiveRecord::Relation` as the starting point for + # retrieving models. From this relation filters, sorts and joins are applied as needed. + # Depending on which phase of the request processing different `records` methods will be called, giving the user + # the opportunity to override them differently for performance and security reasons. + + # begin `records`methods + + # Base for the `records` methods that follow and is not directly used for accessing model data by this class. + # Overriding this method gives a single place to affect the `ActiveRecord::Relation` used for the resource. + # + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [ActiveRecord::Relation] + def records_base(_options = {}) + _model_class.all + end + + # The `ActiveRecord::Relation` used for finding user requested models. This may be overridden to enforce + # permissions checks on the request. + # + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [ActiveRecord::Relation] + def records(options = {}) + records_base(options) + end + + # The `ActiveRecord::Relation` used for populating the ResourceSet. Only resources that have been previously + # identified through the `records` method will be accessed. Thus it should not be necessary to reapply permissions + # checks. However if the model needs to include other models adding `includes` is appropriate + # + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [ActiveRecord::Relation] + def records_for_populate(options = {}) + records_base(options) + end + + # The `ActiveRecord::Relation` used for the finding related resources. Only resources that have been previously + # identified through the `records` method will be accessed and used as the basis to find related resources. Thus + # it should not be necessary to reapply permissions checks. + # + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [ActiveRecord::Relation] + def records_for_source_to_related(options = {}) + records_base(options) + end + + # end `records` methods + + def apply_join(records:, relationship:, resource_type:, join_type:, options:) + if relationship.polymorphic? && relationship.belongs_to? + case join_type + when :inner + records = records.joins(resource_type.to_s.singularize.to_sym) + when :left + records = records.joins_left(resource_type.to_s.singularize.to_sym) + end + else + relation_name = relationship.relation_name(options) + case join_type + when :inner + records = records.joins(relation_name) + when :left + records = records.joins_left(relation_name) + end + end + records + end + + def relationship_records(relationship:, join_type: :inner, resource_type: nil, options: {}) + records = relationship.parent_resource.records_for_source_to_related(options) + strategy = relationship.options[:apply_join] + + if strategy + records = call_method_or_proc(strategy, records, relationship, resource_type, join_type, options) + else + records = apply_join(records: records, + relationship: relationship, + resource_type: resource_type, + join_type: join_type, + options: options) + end + + records + end + + def join_relationship(records:, relationship:, resource_type: nil, join_type: :inner, options: {}) + relationship_records = relationship_records(relationship: relationship, + join_type: join_type, + resource_type: resource_type, + options: options) + records.merge(relationship_records) + end + + protected + + def to_one_relationships_for_linkage(include_related) + include_related ||= {} + relationships = [] + _relationships.each do |name, relationship| + if relationship.is_a?(JSONAPI::Relationship::ToOne) && !include_related.has_key?(name) && relationship.include_optional_linkage_data? + relationships << name + end + end + relationships + end + + def find_record_by_key(key, options = {}) + record = apply_request_settings_to_records(records: records(options), primary_keys: key, options: options).first + fail JSONAPI::Exceptions::RecordNotFound.new(key) if record.nil? + record + end + + def find_records_by_keys(keys, options = {}) + apply_request_settings_to_records(records: records(options), primary_keys: keys, options: options) + end + + def find_related_monomorphic_fragments(source_fragments, relationship, options, connect_source_identity) + filters = options.fetch(:filters, {}) + source_ids = source_fragments.collect {|item| item.identity.id} + + include_directives = options.fetch(:include_directives, {}) + resource_klass = relationship.resource_klass + linkage_relationships = resource_klass.to_one_relationships_for_linkage(include_directives[:include_related]) + + sort_criteria = [] + options[:sort_criteria].try(:each) do |sort| + field = sort[:field].to_s == 'id' ? resource_klass._primary_key : sort[:field] + sort_criteria << { field: field, direction: sort[:direction] } + end + + join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + source_relationship: relationship, + relationships: linkage_relationships, + sort_criteria: sort_criteria, + filters: filters) + + paginator = options[:paginator] + + records = apply_request_settings_to_records(records: records_for_source_to_related(options), + resource_klass: resource_klass, + sort_criteria: sort_criteria, + primary_keys: source_ids, + paginator: paginator, + filters: filters, + join_manager: join_manager, + options: options) + + resource_table_alias = join_manager.join_details_by_relationship(relationship)[:alias] + + pluck_fields = [ + Arel.sql("#{_table_name}.#{_primary_key} AS \"source_id\""), + sql_field_with_alias(resource_table_alias, resource_klass._primary_key) + ] + + cache_field = resource_klass.attribute_to_model_field(:_cache_field) if options[:cache] + if cache_field + pluck_fields << sql_field_with_alias(resource_table_alias, cache_field[:name]) + end + + linkage_fields = [] + + linkage_relationships.each do |name| + linkage_relationship = resource_klass._relationship(name) + + if linkage_relationship.polymorphic? && linkage_relationship.belongs_to? + linkage_relationship.resource_types.each do |resource_type| + klass = resource_klass_for(resource_type) + linkage_fields << {relationship_name: name, resource_klass: klass} + + linkage_table_alias = join_manager.join_details_by_polymorphic_relationship(linkage_relationship, resource_type)[:alias] + primary_key = klass._primary_key + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) + end + else + klass = linkage_relationship.resource_klass + linkage_fields << {relationship_name: name, resource_klass: klass} + + linkage_table_alias = join_manager.join_details_by_relationship(linkage_relationship)[:alias] + primary_key = klass._primary_key + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) + end + end + + model_fields = {} + attributes = options[:attributes] + attributes.try(:each) do |attribute| + model_field = resource_klass.attribute_to_model_field(attribute) + model_fields[attribute] = model_field + pluck_fields << sql_field_with_alias(resource_table_alias, model_field[:name]) + end + + sort_fields = options.dig(:_relation_helper_options, :sort_fields) + sort_fields.try(:each) do |field| + pluck_fields << Arel.sql(field) + end + + fragments = {} + rows = records.distinct.pluck(*pluck_fields) + rows.each do |row| + rid = JSONAPI::ResourceIdentity.new(resource_klass, row[1]) + + fragments[rid] ||= JSONAPI::ResourceFragment.new(rid) + + attributes_offset = 2 + + if cache_field + fragments[rid].cache = cast_to_attribute_type(row[attributes_offset], cache_field[:type]) + attributes_offset+= 1 + end + + model_fields.each_with_index do |k, idx| + fragments[rid].add_attribute(k[0], cast_to_attribute_type(row[idx + attributes_offset], k[1][:type])) + attributes_offset+= 1 + end + + source_rid = JSONAPI::ResourceIdentity.new(self, row[0]) + + fragments[rid].add_related_from(source_rid) + + linkage_fields.each do |linkage_field| + fragments[rid].initialize_related(linkage_field[:relationship_name]) + related_id = row[attributes_offset] + if related_id + related_rid = JSONAPI::ResourceIdentity.new(linkage_field[:resource_klass], related_id) + fragments[rid].add_related_identity(linkage_field[:relationship_name], related_rid) + end + attributes_offset+= 1 + end + + if connect_source_identity + related_relationship = resource_klass._relationships[relationship.inverse_relationship] + if related_relationship + fragments[rid].add_related_identity(related_relationship.name, source_rid) + end + end + end + + fragments + end + + # Gets resource identities where the related resource is polymorphic and the resource type and id + # are stored on the primary resources. Cache fields will always be on the related resources. + def find_related_polymorphic_fragments(source_fragments, relationship, options, connect_source_identity) + filters = options.fetch(:filters, {}) + source_ids = source_fragments.collect {|item| item.identity.id} + + resource_klass = relationship.resource_klass + include_directives = options.fetch(:include_directives, {}) + + linkage_relationships = [] + + resource_types = relationship.resource_types + + resource_types.each do |resource_type| + related_resource_klass = resource_klass_for(resource_type) + relationships = related_resource_klass.to_one_relationships_for_linkage(include_directives[:include_related]) + relationships.each do |r| + linkage_relationships << "##{resource_type}.#{r}" + end + end + + join_manager = ActiveRelation::JoinManager.new(resource_klass: self, + source_relationship: relationship, + relationships: linkage_relationships, + filters: filters) + + paginator = options[:paginator] + + # Note: We will sort by the source table. Without using unions we can't sort on a polymorphic relationship + # in any manner that makes sense + records = apply_request_settings_to_records(records: records_for_source_to_related(options), + resource_klass: resource_klass, + sort_primary: true, + primary_keys: source_ids, + paginator: paginator, + filters: filters, + join_manager: join_manager, + options: options) + + primary_key = concat_table_field(_table_name, _primary_key) + related_key = concat_table_field(_table_name, relationship.foreign_key) + related_type = concat_table_field(_table_name, relationship.polymorphic_type) + + pluck_fields = [ + Arel.sql("#{primary_key} AS #{alias_table_field(_table_name, _primary_key)}"), + Arel.sql("#{related_key} AS #{alias_table_field(_table_name, relationship.foreign_key)}"), + Arel.sql("#{related_type} AS #{alias_table_field(_table_name, relationship.polymorphic_type)}") + ] + + # Get the additional fields from each relation. There's a limitation that the fields must exist in each relation + + relation_positions = {} + relation_index = pluck_fields.length + + attributes = options.fetch(:attributes, []) + + # Add resource specific fields + if resource_types.nil? || resource_types.length == 0 + # :nocov: + warn "No resource types found for polymorphic relationship." + # :nocov: + else + resource_types.try(:each) do |type| + related_klass = resource_klass_for(type.to_s) + + cache_field = related_klass.attribute_to_model_field(:_cache_field) if options[:cache] + + table_alias = join_manager.source_join_details(type)[:alias] + + cache_offset = relation_index + if cache_field + pluck_fields << sql_field_with_alias(table_alias, cache_field[:name]) + relation_index+= 1 + end + + model_fields = {} + field_offset = relation_index + attributes.try(:each) do |attribute| + model_field = related_klass.attribute_to_model_field(attribute) + model_fields[attribute] = model_field + pluck_fields << sql_field_with_alias(table_alias, model_field[:name]) + relation_index+= 1 + end + + model_offset = relation_index + model_fields.each do |_k, v| + pluck_fields << Arel.sql("#{concat_table_field(table_alias, v[:name])}") + relation_index+= 1 + end + + relation_positions[type] = {relation_klass: related_klass, + cache_field: cache_field, + cache_offset: cache_offset, + model_fields: model_fields, + model_offset: model_offset, + field_offset: field_offset} + end + end + + # Add to_one linkage fields + linkage_fields = [] + linkage_offset = relation_index + + linkage_relationships.each do |linkage_relationship_path| + path = JSONAPI::Path.new(resource_klass: self, + path_string: "#{relationship.name}#{linkage_relationship_path}", + ensure_default_field: false) + + linkage_relationship = path.segments[-1].relationship + + if linkage_relationship.polymorphic? && linkage_relationship.belongs_to? + linkage_relationship.resource_types.each do |resource_type| + klass = resource_klass_for(resource_type) + linkage_fields << {relationship: linkage_relationship, resource_klass: klass} + + linkage_table_alias = join_manager.join_details_by_polymorphic_relationship(linkage_relationship, resource_type)[:alias] + primary_key = klass._primary_key + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) + end + else + klass = linkage_relationship.resource_klass + linkage_fields << {relationship: linkage_relationship, resource_klass: klass} + + linkage_table_alias = join_manager.join_details_by_relationship(linkage_relationship)[:alias] + primary_key = klass._primary_key + pluck_fields << sql_field_with_alias(linkage_table_alias, primary_key) + end + end + + rows = records.distinct.pluck(*pluck_fields) + + related_fragments = {} + + rows.each do |row| + unless row[1].nil? || row[2].nil? + related_klass = resource_klass_for(row[2]) + + rid = JSONAPI::ResourceIdentity.new(related_klass, row[1]) + related_fragments[rid] ||= JSONAPI::ResourceFragment.new(rid) + + source_rid = JSONAPI::ResourceIdentity.new(self, row[0]) + related_fragments[rid].add_related_from(source_rid) + + if connect_source_identity + related_relationship = related_klass._relationships[relationship.inverse_relationship] + if related_relationship + related_fragments[rid].add_related_identity(related_relationship.name, source_rid) + end + end + + relation_position = relation_positions[row[2].downcase.pluralize] + if relation_position + model_fields = relation_position[:model_fields] + cache_field = relation_position[:cache_field] + cache_offset = relation_position[:cache_offset] + field_offset = relation_position[:field_offset] + else + next # Skip processing if relation_position is nil + end + + if cache_field + related_fragments[rid].cache = cast_to_attribute_type(row[cache_offset], cache_field[:type]) + end + + if attributes.length > 0 + model_fields.each_with_index do |k, idx| + related_fragments[rid].add_attribute(k[0], cast_to_attribute_type(row[idx + field_offset], k[1][:type])) + end + end + + linkage_fields.each_with_index do |linkage_field_details, idx| + relationship = linkage_field_details[:relationship] + related_fragments[rid].initialize_related(relationship.name) + related_id = row[linkage_offset + idx] + if related_id + related_rid = JSONAPI::ResourceIdentity.new(linkage_field_details[:resource_klass], related_id) + related_fragments[rid].add_related_identity(relationship.name, related_rid) + end + end + end + end + + related_fragments + end + + def apply_request_settings_to_records(records:, + join_manager: ActiveRelation::JoinManager.new(resource_klass: self), + resource_klass: self, + filters: {}, + primary_keys: nil, + sort_criteria: nil, + sort_primary: nil, + paginator: nil, + options: {}) + + options[:_relation_helper_options] = { join_manager: join_manager, sort_fields: [] } + + records = resource_klass.apply_joins(records, join_manager, options) + + if primary_keys + records = records.where(_primary_key => primary_keys) + end + + unless filters.empty? + records = resource_klass.filter_records(records, filters, options) + end + + if sort_primary + records = records.order(_primary_key => :asc) + else + order_options = resource_klass.construct_order_options(sort_criteria) + records = resource_klass.sort_records(records, order_options, options) + end + + if paginator + records = resource_klass.apply_pagination(records, paginator, order_options) + end + + records + end + + def apply_joins(records, join_manager, options) + join_manager.join(records, options) + end + + def apply_pagination(records, paginator, order_options) + records = paginator.apply(records, order_options) if paginator + records + end + + def apply_sort(records, order_options, options) + if order_options.any? + order_options.each_pair do |field, direction| + records = apply_single_sort(records, field, direction, options) + end + end + + records + end + + def apply_single_sort(records, field, direction, options) + context = options[:context] + + strategy = _allowed_sort.fetch(field.to_sym, {})[:apply] + + options[:_relation_helper_options] ||= {} + options[:_relation_helper_options][:sort_fields] ||= [] + + if strategy + records = call_method_or_proc(strategy, records, direction, context) + else + join_manager = options.dig(:_relation_helper_options, :join_manager) + sort_field = join_manager ? get_aliased_field(field, join_manager) : field + options[:_relation_helper_options][:sort_fields].push("#{sort_field}") + records = records.order(Arel.sql("#{sort_field} #{direction}")) + end + records + end + + # Assumes ActiveRecord's counting. Override if you need a different counting method + def count_records(records) + if (Rails::VERSION::MAJOR == 5 && ActiveRecord::VERSION::MINOR >= 1) || Rails::VERSION::MAJOR >= 6 + records.count(:all) + else + records.count + end + end + + def filter_records(records, filters, options) + if _polymorphic + _polymorphic_resource_klasses.each do |klass| + records = klass.apply_filters(records, filters, options) + end + else + records = apply_filters(records, filters, options) + end + records + end + + def construct_order_options(sort_params) + if _polymorphic + warn "Sorting is not supported on polymorphic relationships" + else + super(sort_params) + end + end + + def sort_records(records, order_options, options) + apply_sort(records, order_options, options) + end + + def concat_table_field(table, field, quoted = false) + if table.blank? || field.to_s.include?('.') + # :nocov: + if quoted + quote(field) + else + field.to_s + end + # :nocov: + else + if quoted + "#{quote(table)}.#{quote(field)}" + else + # :nocov: + "#{table.to_s}.#{field.to_s}" + # :nocov: + end + end + end + + def sql_field_with_alias(table, field, quoted = true) + Arel.sql("#{concat_table_field(table, field, quoted)} AS #{alias_table_field(table, field, quoted)}") + end + + def alias_table_field(table, field, quoted = false) + if table.blank? || field.to_s.include?('.') + # :nocov: + if quoted + quote(field) + else + field.to_s + end + # :nocov: + else + if quoted + # :nocov: + quote("#{table.to_s}_#{field.to_s}") + # :nocov: + else + "#{table.to_s}_#{field.to_s}" + end + end + end + + def quote(field) + "\"#{field.to_s}\"" + end + + def apply_filters(records, filters, options = {}) + if filters + filters.each do |filter, value| + records = apply_filter(records, filter, value, options) + end + end + + records + end + + def get_aliased_field(path_with_field, join_manager) + path = JSONAPI::Path.new(resource_klass: self, path_string: path_with_field) + + relationship_segment = path.segments[-2] + field_segment = path.segments[-1] + + if relationship_segment + join_details = join_manager.join_details[path.last_relationship] + table_alias = join_details[:alias] + else + table_alias = self._table_name + end + + concat_table_field(table_alias, field_segment.delegated_field_name) + end + + def apply_filter(records, filter, value, options = {}) + strategy = _allowed_filters.fetch(filter.to_sym, Hash.new)[:apply] + + if strategy + records = call_method_or_proc(strategy, records, value, options) + else + join_manager = options.dig(:_relation_helper_options, :join_manager) + field = join_manager ? get_aliased_field(filter, join_manager) : filter + records = records.where(Arel.sql(field) => value) + end + + records + end + end + end +end diff --git a/lib/jsonapi/acts_as_resource_controller.rb b/lib/jsonapi/acts_as_resource_controller.rb index 6dd0e3f2e..8b29043d1 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -1,5 +1,7 @@ -require 'csv' +# frozen_string_literal: true +require 'csv' +require_relative 'compatibility_helper' module JSONAPI module ActsAsResourceController MEDIA_TYPE_MATCHER = /.+".+"[^,]*|[^,]+/ @@ -9,9 +11,12 @@ def self.included(base) base.extend ClassMethods base.include Callbacks base.cattr_reader :server_error_callbacks - base.define_jsonapi_resources_callbacks :process_operations + base.define_jsonapi_resources_callbacks :process_operations, + :transaction end + attr_reader :response_document, :jsonapi_request + def index process_request end @@ -25,22 +30,18 @@ def show_relationship end def create - return unless verify_content_type_header process_request end def create_relationship - return unless verify_content_type_header process_request end def update_relationship - return unless verify_content_type_header process_request end def update - return unless verify_content_type_header process_request end @@ -52,59 +53,100 @@ def destroy_relationship process_request end - def get_related_resource + def show_related_resource process_request end - def get_related_resources + def index_related_resources process_request end - def process_request - return unless verify_accept_header + def get_related_resource + # :nocov: + JSONAPI::CompatibilityHelper.deprecation_warn("In #{self.class.name} you exposed a `get_related_resource`"\ + " action. Please use `show_related_resource` instead.") + show_related_resource + # :nocov: + end - @request = JSONAPI::RequestParser.new(params, context: context, - key_formatter: key_formatter, - server_error_callbacks: (self.class.server_error_callbacks || [])) + def get_related_resources + # :nocov: + JSONAPI::CompatibilityHelper.deprecation_warn("In #{self.class.name} you exposed a `get_related_resources`"\ + " action. Please use `index_related_resources` instead.") + index_related_resources + # :nocov: + end - unless @request.errors.empty? - render_errors(@request.errors) - else - operations = @request.operations - unless JSONAPI.configuration.resource_cache.nil? - operations.each {|op| op.options[:cache_serializer] = resource_serializer } - end - results = process_operations(operations) - render_results(results) + def process_request + begin + setup_response_document + verify_content_type_header + verify_accept_header + parse_request + execute_request + rescue => e + handle_exceptions(e) end - rescue => e - handle_exceptions(e) + render_response_document end - def process_operations(operations) - run_callbacks :process_operations do - operation_dispatcher.process(operations) - end + def setup_response_document + @response_document = create_response_document end - def transaction - lambda { |&block| - ActiveRecord::Base.transaction do - block.yield + def parse_request + @jsonapi_request = JSONAPI::Request.new( + params, + context: context, + key_formatter: key_formatter, + server_error_callbacks: (self.class.server_error_callbacks || [])) + fail JSONAPI::Exceptions::Errors.new(@jsonapi_request.errors) if @jsonapi_request.errors.any? + end + + def execute_request + process_operations(jsonapi_request.transactional?) do + run_callbacks :process_operations do + jsonapi_request.operations.each do |op| + op.options[:serializer] = resource_serializer_klass.new( + op.resource_klass, + include_directives: op.options[:include_directives], + fields: op.options[:fields], + base_url: base_url, + key_formatter: key_formatter, + route_formatter: route_formatter, + serialization_options: serialization_options, + controller: self + ) + op.options[:cache_serializer_output] = !JSONAPI.configuration.resource_cache.nil? + + process_operation(op) + end end - } + if response_document.has_errors? + raise ActiveRecord::Rollback + end + end end - def rollback - lambda { - fail ActiveRecord::Rollback - } + def process_operations(transactional) + if transactional + run_callbacks :transaction do + ActiveRecord::Base.transaction do + yield + end + end + else + begin + yield + rescue ActiveRecord::Rollback + # Can't rollback without transaction, so just ignore it + end + end end - def operation_dispatcher - @operation_dispatcher ||= JSONAPI::OperationDispatcher.new(transaction: transaction, - rollback: rollback, - server_error_callbacks: @request.server_error_callbacks) + def process_operation(operation) + result = operation.process + response_document.add_result(result, operation) end private @@ -117,21 +159,8 @@ def resource_serializer_klass @resource_serializer_klass ||= JSONAPI::ResourceSerializer end - def resource_serializer - @resource_serializer ||= resource_serializer_klass.new( - resource_klass, - include_directives: @request ? @request.include_directives : nil, - fields: @request ? @request.fields : {}, - base_url: base_url, - key_formatter: key_formatter, - route_formatter: route_formatter, - serialization_options: serialization_options - ) - @resource_serializer - end - def base_url - @base_url ||= request.protocol + request.host_with_port + @base_url ||= "#{request.protocol}#{request.host_with_port}#{Rails.application.config.relative_url_root}" end def resource_klass_name @@ -139,32 +168,25 @@ 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) + if ['create', 'create_relationship', 'update_relationship', 'update'].include?(params[:action]) + unless request.media_type == JSONAPI::MEDIA_TYPE + fail JSONAPI::Exceptions::UnsupportedMediaTypeError.new(request.media_type) + end end - true - rescue => e - handle_exceptions(e) - false end def verify_accept_header unless valid_accept_media_type? fail JSONAPI::Exceptions::NotAcceptableError.new(request.accept) end - true - rescue => e - handle_exceptions(e) - false end def valid_accept_media_type? media_types = media_types_for('Accept') - media_types.blank? || - media_types.any? do |media_type| - (media_type == JSONAPI::MEDIA_TYPE || media_type.start_with?(ALL_MEDIA_TYPES)) - end + media_types.blank? || media_types.any? do |media_type| + (media_type == JSONAPI::MEDIA_TYPE || media_type.start_with?(ALL_MEDIA_TYPES)) + end end def media_types_for(header) @@ -202,57 +224,43 @@ def base_response_meta end def base_meta - if @request.nil? || @request.warnings.empty? - base_response_meta - else - base_response_meta.merge(warnings: @request.warnings) - end + base_response_meta end def base_response_links {} end - def render_errors(errors) - operation_results = JSONAPI::OperationResults.new - result = JSONAPI::ErrorsOperationResult.new(errors[0].status, errors) - operation_results.add_result(result) - - render_results(operation_results) - end - - def render_results(operation_results) - response_doc = create_response_document(operation_results) - content = response_doc.contents + def render_response_document + content = response_document.contents render_options = {} - if operation_results.has_errors? + if response_document.has_errors? render_options[:json] = content else - # Bypasing ActiveSupport allows us to use CompiledJson objects for cached response fragments + # Bypassing ActiveSupport allows us to use CompiledJson objects for cached response fragments render_options[:body] = JSON.generate(content) - end - render_options[:location] = content[:data]["links"][:self] if ( - response_doc.status == :created && content[:data].class != Array - ) + if (response_document.status == 201 && content[:data].class != Array) && + content['data'] && content['data']['links'] && content['data']['links']['self'] + render_options[:location] = content['data']['links']['self'] + end + end # For whatever reason, `render` ignores :status and :content_type when :body is set. # But, we can just set those values directly in the Response object instead. - response.status = response_doc.status + response.status = response_document.status response.headers['Content-Type'] = JSONAPI::MEDIA_TYPE render(render_options) end - def create_response_document(operation_results) + def create_response_document JSONAPI::ResponseDocument.new( - operation_results, - operation_results.has_errors? ? nil : resource_serializer, - key_formatter: key_formatter, - base_meta: base_meta, - base_links: base_response_links, - request: @request + key_formatter: key_formatter, + base_meta: base_meta, + base_links: base_response_links, + request: request ) end @@ -260,21 +268,30 @@ def create_response_document(operation_results) # Note: Be sure to either call super(e) or handle JSONAPI::Exceptions::Error and raise unhandled exceptions def handle_exceptions(e) case e - when JSONAPI::Exceptions::Error - render_errors(e.errors) - else - if JSONAPI.configuration.exception_class_whitelisted?(e) - fail e + when JSONAPI::Exceptions::Error + errors = e.errors + when ActionController::ParameterMissing + errors = JSONAPI::Exceptions::ParameterMissing.new(e.param).errors else - (self.class.server_error_callbacks || []).each { |callback| - safe_run_callback(callback, e) - } + if JSONAPI.configuration.exception_class_allowed?(e) + raise e + else + if self.class.server_error_callbacks + self.class.server_error_callbacks.each { |callback| + safe_run_callback(callback, e) + } + end - internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e) - Rails.logger.error { "Internal Server Error: #{e.message} #{e.backtrace.join("\n")}" } - render_errors(internal_server_error.errors) - end + # Store exception for other middlewares + request.env['action_dispatch.exception'] ||= e + + internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e) + Rails.logger.error { "Internal Server Error: #{e.message} #{e.backtrace.join("\n")}" } + errors = internal_server_error.errors + end end + + response_document.add_result(JSONAPI::ErrorsOperationResult.new(errors[0].status, errors), nil) end def safe_run_callback(callback, error) @@ -283,7 +300,7 @@ def safe_run_callback(callback, error) rescue => e Rails.logger.error { "Error in error handling callback: #{e.message} #{e.backtrace.join("\n")}" } internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e) - render_errors(internal_server_error.errors) + return JSONAPI::ErrorsOperationResult.new(internal_server_error.errors[0].code, internal_server_error.errors) end end @@ -291,7 +308,7 @@ def safe_run_callback(callback, error) # caught that is not a JSONAPI::Exceptions::Error # Useful for additional logging or notification configuration that # would normally depend on rails catching and rendering an exception. - # Ignores whitelist exceptions from config + # Ignores allowlist exceptions from config module ClassMethods diff --git a/lib/jsonapi/basic_resource.rb b/lib/jsonapi/basic_resource.rb new file mode 100644 index 000000000..d35ad796d --- /dev/null +++ b/lib/jsonapi/basic_resource.rb @@ -0,0 +1,1164 @@ +# frozen_string_literal: true + +require 'jsonapi/callbacks' +require 'jsonapi/configuration' +require_relative 'compatibility_helper' +module JSONAPI + class BasicResource + include Callbacks + + @abstract = true + @immutable = true + @root = true + + attr_reader :context + + define_jsonapi_resources_callbacks :create, + :update, + :remove, + :save, + :create_to_many_link, + :replace_to_many_links, + :create_to_one_link, + :replace_to_one_link, + :replace_polymorphic_to_one_link, + :remove_to_many_link, + :remove_to_one_link, + :replace_fields + + def initialize(model, context) + @model = model + @context = context + @reload_needed = false + @changing = false + @save_needed = false + end + + def _model + @model + end + + def id + _model.public_send(self.class._primary_key) + end + + def identity + JSONAPI::ResourceIdentity.new(self.class, id) + end + + def cache_field_value + _model.public_send(self.class._cache_field) + end + + def cache_id + [id, self.class.hash_cache_field(cache_field_value)] + end + + def is_new? + id.nil? + end + + def change(callback) + completed = false + + if @changing + run_callbacks callback do + completed = (yield == :completed) + end + else + run_callbacks is_new? ? :create : :update do + @changing = true + run_callbacks callback do + completed = (yield == :completed) + end + + completed = (save == :completed) if @save_needed || is_new? + end + end + + return completed ? :completed : :accepted + end + + def remove + run_callbacks :remove do + _remove + end + end + + def create_to_many_links(relationship_type, relationship_key_values, options = {}) + change :create_to_many_link do + _create_to_many_links(relationship_type, relationship_key_values, options) + end + end + + def replace_to_many_links(relationship_type, relationship_key_values, options = {}) + change :replace_to_many_links do + _replace_to_many_links(relationship_type, relationship_key_values, options) + end + end + + def replace_to_one_link(relationship_type, relationship_key_value, options = {}) + change :replace_to_one_link do + _replace_to_one_link(relationship_type, relationship_key_value, options) + end + end + + def replace_polymorphic_to_one_link(relationship_type, relationship_key_value, relationship_key_type, options = {}) + change :replace_polymorphic_to_one_link do + _replace_polymorphic_to_one_link(relationship_type, relationship_key_value, relationship_key_type, options) + end + end + + def remove_to_many_link(relationship_type, key, options = {}) + change :remove_to_many_link do + _remove_to_many_link(relationship_type, key, options) + end + end + + def remove_to_one_link(relationship_type, options = {}) + change :remove_to_one_link do + _remove_to_one_link(relationship_type, options) + end + end + + def replace_fields(field_data) + change :replace_fields do + _replace_fields(field_data) + end + end + + # Override this on a resource instance to override the fetchable keys + def fetchable_fields + self.class.fields + end + + def model_error_messages + _model.errors.messages + end + + # Add metadata to validation error objects. + # + # Suppose `model_error_messages` returned the following error messages + # hash: + # + # {password: ["too_short", "format"]} + # + # Then to add data to the validation error `validation_error_metadata` + # could return: + # + # { + # password: { + # "too_short": {"minimum_length" => 6}, + # "format": {"requirement" => "must contain letters and numbers"} + # } + # } + # + # The specified metadata is then be merged into the validation error + # object. + def validation_error_metadata + {} + end + + # Override this to return resource level meta data + # must return a hash, and if the hash is empty the meta section will not be serialized with the resource + # meta keys will be not be formatted with the key formatter for the serializer by default. They can however use the + # serializer's format_key and format_value methods if desired + # the _options hash will contain the serializer and the serialization_options + def meta(_options) + {} + end + + # Override this to return custom links + # must return a hash, which will be merged with the default { self: 'self-url' } links hash + # links keys will be not be formatted with the key formatter for the serializer by default. + # They can however use the serializer's format_key and format_value methods if desired + # the _options hash will contain the serializer and the serialization_options + def custom_links(_options) + {} + end + + private + + def save + run_callbacks :save do + _save + end + end + + # Override this on a resource to return a different result code. Any + # value other than :completed will result in operations returning + # `:accepted` + # + # For example to return `:accepted` if your model does not immediately + # save resources to the database you could override `_save` as follows: + # + # ``` + # def _save + # super + # return :accepted + # end + # ``` + def _save(validation_context = nil) + unless @model.valid?(validation_context) + fail JSONAPI::Exceptions::ValidationErrors.new(self) + end + + if defined? @model.save + saved = @model.save(validate: false) + + unless saved + if @model.errors.present? + fail JSONAPI::Exceptions::ValidationErrors.new(self) + else + fail JSONAPI::Exceptions::SaveFailed.new + end + end + else + saved = true + end + @model.reload if @reload_needed + @reload_needed = false + + @save_needed = !saved + + :completed + end + + def _remove + unless @model.destroy + fail JSONAPI::Exceptions::ValidationErrors.new(self) + end + :completed + + rescue ActiveRecord::DeleteRestrictionError => e + fail JSONAPI::Exceptions::RecordLocked.new(e.message) + end + + def reflect_relationship?(relationship, options) + return false if !relationship.reflect || + (!JSONAPI.configuration.use_relationship_reflection || options[:reflected_source]) + + inverse_relationship = relationship.resource_klass._relationships[relationship.inverse_relationship] + if inverse_relationship.nil? + warn "Inverse relationship could not be found for #{self.class.name}.#{relationship.name}. Relationship reflection disabled." + return false + end + true + end + + def _create_to_many_links(relationship_type, relationship_key_values, options) + relationship = self.class._relationships[relationship_type] + relation_name = relationship.relation_name(context: @context) + + if options[:reflected_source] + @model.public_send(relation_name) << options[:reflected_source]._model + return :completed + end + + # load requested related resources + # make sure they all exist (also based on context) and add them to relationship + + related_resources = relationship.resource_klass.find_by_keys(relationship_key_values, context: @context) + + if related_resources.count != relationship_key_values.count + # todo: obscure id so not to leak info + fail JSONAPI::Exceptions::RecordNotFound.new('unspecified') + end + + reflect = reflect_relationship?(relationship, options) + + related_resources.each do |related_resource| + if reflect + if related_resource.class._relationships[relationship.inverse_relationship].is_a?(JSONAPI::Relationship::ToMany) + related_resource.create_to_many_links(relationship.inverse_relationship, [id], reflected_source: self) + else + related_resource.replace_to_one_link(relationship.inverse_relationship, id, reflected_source: self) + end + @reload_needed = true + else + unless @model.public_send(relation_name).include?(related_resource._model) + @model.public_send(relation_name) << related_resource._model + end + end + end + + :completed + end + + def _replace_to_many_links(relationship_type, relationship_key_values, options) + relationship = self.class._relationship(relationship_type) + + reflect = reflect_relationship?(relationship, options) + + if reflect + existing = find_related_ids(relationship, options) + + to_delete = existing - (relationship_key_values & existing) + to_delete.each do |key| + _remove_to_many_link(relationship_type, key, reflected_source: self) + end + + 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_klass_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) + @save_needed = true + end + + :completed + end + + def _replace_to_one_link(relationship_type, relationship_key_value, _options) + relationship = self.class._relationships[relationship_type] + + send("#{relationship.foreign_key}=", relationship_key_value) + @save_needed = true + + :completed + end + + def _replace_polymorphic_to_one_link(relationship_type, key_value, key_type, _options) + relationship = self.class._relationships[relationship_type.to_sym] + + send("#{relationship.foreign_key}=", {type: key_type, id: key_value}) + @save_needed = true + + :completed + end + + def _remove_to_many_link(relationship_type, key, options) + relationship = self.class._relationships[relationship_type] + + reflect = reflect_relationship?(relationship, options) + + if reflect + + related_resource = relationship.resource_klass.find_by_key(key, context: @context) + + if related_resource.nil? + fail JSONAPI::Exceptions::RecordNotFound.new(key) + else + if related_resource.class._relationships[relationship.inverse_relationship].is_a?(JSONAPI::Relationship::ToMany) + related_resource.remove_to_many_link(relationship.inverse_relationship, id, reflected_source: self) + else + related_resource.remove_to_one_link(relationship.inverse_relationship, reflected_source: self) + end + end + + @reload_needed = true + else + @model.public_send(relationship.relation_name(context: @context)).delete(key) + end + + :completed + + rescue ActiveRecord::DeleteRestrictionError => e + fail JSONAPI::Exceptions::RecordLocked.new(e.message) + rescue ActiveRecord::RecordNotFound + fail JSONAPI::Exceptions::RecordNotFound.new(key) + end + + def _remove_to_one_link(relationship_type, _options) + relationship = self.class._relationships[relationship_type] + + send("#{relationship.foreign_key}=", nil) + @save_needed = true + + :completed + end + + def _replace_fields(field_data) + field_data[:attributes].each do |attribute, value| + begin + send "#{attribute}=", value + @save_needed = true + rescue ArgumentError + # :nocov: Will be thrown if an enum value isn't allowed for an enum. Currently not tested as enums are a rails 4.1 and higher feature + raise JSONAPI::Exceptions::InvalidFieldValue.new(attribute, value) + # :nocov: + end + end + + field_data[:to_one].each do |relationship_type, value| + if value.nil? + remove_to_one_link(relationship_type) + else + case value + when Hash + replace_polymorphic_to_one_link(relationship_type.to_s, value.fetch(:id), value.fetch(:type)) + else + replace_to_one_link(relationship_type, value) + end + end + end if field_data[:to_one] + + field_data[:to_many].each do |relationship_type, values| + replace_to_many_links(relationship_type, values) + end if field_data[:to_many] + + :completed + end + + def find_related_ids(relationship, options = {}) + send(relationship.foreign_key) + end + + class << self + def inherited(subclass) + super + subclass.abstract(false) + subclass.immutable(false) + subclass.caching(_caching) + subclass.cache_field(_cache_field) if @_cache_field + subclass.singleton(singleton?, (_singleton_options.dup || {})) + subclass.exclude_links(_exclude_links) + subclass.paginator(@_paginator) + subclass._attributes = (_attributes || {}).dup + subclass.polymorphic(false) + subclass.key_type(@_resource_key_type) + + subclass._model_hints = (_model_hints || {}).dup + + unless _model_name.empty? || _immutable + 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 + + subclass._allowed_sort = _allowed_sort.dup + + type = subclass.name.demodulize.sub(/Resource$/, '').underscore + subclass._type = type.pluralize.to_sym + + unless subclass._attributes[:id] + subclass.attribute :id, format: :id, readonly: true + end + + check_reserved_resource_name(subclass._type, subclass.name) + + subclass._routed = false + subclass._warned_missing_route = false + + subclass._clear_cached_attribute_options + subclass._clear_fields_cache + 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 + options[:inverse_relationship] = relationship.inverse_relationship + _add_relationship(relationship.class, relationship.name, options) + end + end + end + + def resource_klass_for(type) + type = type.underscore + 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 + if resource.nil? + fail NameError, "JSONAPI: Could not find resource '#{type}'. (Class #{resource_name} not found)" + end + resource + end + + def resource_klass_for_model(model) + resource_klass_for(resource_type_for(model)) + end + + def _resource_name_from_type(type) + "#{type.to_s.underscore.singularize}_resource".camelize + end + + def resource_type_for(model) + model_name = model.class.to_s.underscore + if _model_hints[model_name] + _model_hints[model_name] + else + model_name.rpartition('/').last + end + end + + attr_accessor :_attributes, :_relationships, :_type, :_model_hints, :_routed, :_warned_missing_route + attr_writer :_allowed_filters, :_paginator, :_allowed_sort + + def create(context) + new(create_model, context) + end + + def create_model + _model_class.new + end + + def routing_options(options) + @_routing_resource_options = options + end + + def routing_resource_options + @_routing_resource_options ||= {} + end + + # Methods used in defining a resource class + def attributes(*attrs) + options = attrs.extract_options!.dup + attrs.each do |attr| + attribute(attr, options) + end + end + + def attribute(attribute_name, options = {}) + _clear_cached_attribute_options + _clear_fields_cache + + attr = attribute_name.to_sym + + check_reserved_attribute_name(attr) + + if (attr == :id) && (options[:format].nil?) + JSONAPI::CompatibilityHelper.deprecation_warn('Id without format is deprecated. Please specify a format for the id attribute.') + end + + check_duplicate_attribute_name(attr) if options[:format].nil? + + @_attributes ||= {} + @_attributes[attr] = options + define_method attr do + @model.public_send(options[:delegate] ? options[:delegate].to_sym : attr) + end unless method_defined?(attr) + + define_method "#{attr}=" do |value| + @model.public_send("#{options[:delegate] ? options[:delegate].to_sym : attr}=", value) + end unless method_defined?("#{attr}=") + + if options.fetch(:sortable, true) && !_has_sort?(attr) + sort attr + end + end + + def attribute_to_model_field(attribute) + field_name = if attribute == :_cache_field + _cache_field + else + # Note: this will allow the returning of model attributes without a corresponding + # resource attribute, for example a belongs_to id such as `author_id` or bypassing + # the delegate. + attr = @_attributes[attribute] + attr && attr[:delegate] ? attr[:delegate].to_sym : attribute + end + + { name: field_name, type: _model_class.attribute_types[field_name.to_s]} + end + + def cast_to_attribute_type(value, type) + type.cast(value) + end + + def default_attribute_options + { format: :default } + end + + def relationship(*attrs) + options = attrs.extract_options! + klass = case options[:to] + when :one + Relationship::ToOne + when :many + Relationship::ToMany + else + #:nocov:# + fail ArgumentError.new('to: must be either :one or :many') + #:nocov:# + end + _add_relationship(klass, *attrs, options.except(:to)) + end + + def has_one(*attrs) + _add_relationship(Relationship::ToOne, *attrs) + end + + def belongs_to(*attrs) + + JSONAPI::CompatibilityHelper.deprecation_warn( "In #{name} you exposed a `has_one` relationship "\ + " using the `belongs_to` class method. We think `has_one`" \ + " is more appropriate. If you know what you're doing," \ + " and don't want to see this warning again, override the" \ + " `belongs_to` class method on your resource.") + _add_relationship(Relationship::ToOne, *attrs) + end + + 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) + resource_type = ((resource.is_a?(Class)) && (resource < JSONAPI::BasicResource)) ? resource._type : resource.to_s + + _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 + + def filter(attr, *args) + @_allowed_filters[attr.to_sym] = args.extract_options! + end + + def sort(sorting, options = {}) + self._allowed_sort[sorting.to_sym] = options + end + + def sorts(*args) + options = args.extract_options! + _allowed_sort.merge!(args.inject({}) { |h, sorting| h[sorting.to_sym] = options.dup; h }) + end + + def primary_key(key) + @_primary_key = key.to_sym + end + + def cache_field(field) + @_cache_field = field.to_sym + end + + # Override in your resource to filter the updatable keys + def updatable_fields(_context = nil) + _updatable_relationships | _updatable_attributes - [:id] + end + + # Override in your resource to filter the creatable keys + def creatable_fields(_context = nil) + _updatable_relationships | _updatable_attributes + end + + # Override in your resource to filter the sortable keys + def sortable_fields(_context = nil) + _allowed_sort.keys + end + + def sortable_field?(key, context = nil) + sortable_fields(context).include? key.to_sym + end + + def fields + @_fields_cache ||= _relationships.keys | _attributes.keys + end + + def resources_for(records, context) + records.collect do |record| + resource_for(record, context) + end + end + + def resource_for(model_record, context) + resource_klass = resource_klass_for_model(model_record) + resource_klass.new(model_record, context) + end + + def verify_filters(filters, context = nil) + verified_filters = {} + filters.each do |filter, raw_value| + verified_filter = verify_filter(filter, raw_value, context) + verified_filters[verified_filter[0]] = verified_filter[1] + end + verified_filters + end + + def is_filter_relationship?(filter) + filter == _type || _relationships.include?(filter) + end + + def verify_filter(filter, raw, context = nil) + filter_values = [] + if raw.present? + 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] + + if strategy + values = call_method_or_proc(strategy, filter_values, context) + [filter, values] + else + if is_filter_relationship?(filter) + verify_relationship_filter(filter, filter_values, context) + else + verify_custom_filter(filter, filter_values, context) + end + end + end + + def call_method_or_proc(strategy, *args) + if strategy.is_a?(Symbol) || strategy.is_a?(String) + send(strategy, *args) + else + strategy.call(*args) + end + end + + def key_type(key_type) + @_resource_key_type = key_type + end + + 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 + + case key_type + when :integer + return if key.nil? + Integer(key) + when :string + return if key.nil? + if key.to_s.include?(',') + raise JSONAPI::Exceptions::InvalidFieldValue.new(:id, key) + else + key + end + when :uuid + return 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}$/) + key + else + raise JSONAPI::Exceptions::InvalidFieldValue.new(:id, key) + end + else + key_type.call(key, context) + end + rescue + raise JSONAPI::Exceptions::InvalidFieldValue.new(:id, key) + end + + # override to allow for key processing and checking + def verify_keys(keys, context = nil) + return keys.collect do |key| + verify_key(key, context) + end + end + + # Either add a custom :verify lambda or override verify_custom_filter to allow for custom filters + def verify_custom_filter(filter, value, _context = nil) + [filter, value] + end + + # Either add a custom :verify lambda or override verify_relationship_filter to allow for custom + # relationship logic, such as uuids, multiple keys or permission checks on keys + def verify_relationship_filter(filter, raw, _context = nil) + [filter, raw] + end + + # quasi private class methods + def _attribute_options(attr) + @_cached_attribute_options[attr] ||= default_attribute_options.merge(@_attributes[attr]) + end + + def _attribute_delegated_name(attr) + @_attributes.fetch(attr.to_sym, {}).fetch(:delegate, attr) + end + + def _has_attribute?(attr) + @_attributes.keys.include?(attr.to_sym) + end + + def _updatable_attributes + _attributes.map { |key, options| key unless options[:readonly] }.compact + end + + def _updatable_relationships + @_relationships.map { |key, relationship| key unless relationship.readonly? }.compact + end + + def _relationship(type) + return nil unless type + type = type.to_sym + @_relationships[type] + end + + def _model_name + if _abstract + '' + else + return @_model_name.to_s if defined?(@_model_name) + class_name = self.name + return '' if class_name.nil? + @_model_name = class_name.demodulize.sub(/Resource$/, '') + @_model_name.to_s + end + end + + def _polymorphic_name + if !_polymorphic + '' + else + @_polymorphic_name ||= _model_name.to_s.downcase + end + end + + def _primary_key + @_primary_key ||= _default_primary_key + end + + def _default_primary_key + @_default_primary_key ||=_model_class.respond_to?(:primary_key) ? _model_class.primary_key : :id + end + + def _cache_field + @_cache_field || JSONAPI.configuration.default_resource_cache_field + end + + def _table_name + @_table_name ||= _model_class.respond_to?(:table_name) ? _model_class.table_name : _model_name.tableize + end + + def _as_parent_key + @_as_parent_key ||= "#{_type.to_s.singularize}_id" + end + + def _allowed_filters + defined?(@_allowed_filters) ? @_allowed_filters : { id: {} } + end + + def _allowed_sort + @_allowed_sort ||= {} + end + + def _paginator + @_paginator || JSONAPI.configuration.default_paginator + end + + def paginator(paginator) + @_paginator = paginator + end + + def _polymorphic + @_polymorphic + end + + def polymorphic(polymorphic = true) + @_polymorphic = polymorphic + end + + def _polymorphic_types + @poly_hash ||= {}.tap do |hash| + ObjectSpace.each_object do |klass| + next unless Module === klass + if klass < ActiveRecord::Base + klass.reflect_on_all_associations(:has_many).select{|r| r.options[:as] }.each do |reflection| + (hash[reflection.options[:as]] ||= []) << klass.name.downcase + end + end + end + end + @poly_hash[_polymorphic_name.to_sym] + end + + def _polymorphic_resource_klasses + @_polymorphic_resource_klasses ||= _polymorphic_types.collect do |type| + resource_klass_for(type) + end + end + + def root_resource + @abstract = true + @immutable = true + @root = true + end + + def root? + @root + end + + def abstract(val = true) + @abstract = val + end + + def _abstract + @abstract + end + + def immutable(val = true) + @immutable = val + end + + def _immutable + @immutable + end + + def mutable? + !@immutable + end + + def parse_exclude_links(exclude) + case exclude + when :default, "default" + [:self] + when :none, "none" + [] + when Array + exclude.collect {|link| link.to_sym} + else + fail "Invalid exclude_links" + end + end + + def exclude_links(exclude) + @_exclude_links = parse_exclude_links(exclude) + end + + def _exclude_links + @_exclude_links ||= parse_exclude_links(JSONAPI.configuration.default_exclude_links) + end + + def exclude_link?(link) + _exclude_links.include?(link.to_sym) + end + + def caching(val = true) + @caching = val + end + + def _caching + @caching + end + + def caching? + if @caching.nil? + !JSONAPI.configuration.resource_cache.nil? && JSONAPI.configuration.default_caching + else + @caching && !JSONAPI.configuration.resource_cache.nil? + end + end + + def attribute_caching_context(_context) + nil + end + + # Generate a hashcode from the value to be used as part of the cache lookup + def hash_cache_field(value) + value.hash + end + + def _model_class + return nil if _abstract + + 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) + !_allowed_filters[filter].nil? + end + + def _has_sort?(sorting) + !_allowed_sort[sorting.to_sym].nil? + end + + def module_path + if name == 'JSONAPI::Resource' + '' + else + name =~ /::[^:]+\Z/ ? ($`.freeze.gsub('::', '/') + '/').underscore : '' + end + end + + def default_sort + [{field: 'id', direction: :asc}] + end + + def construct_order_options(sort_params) + sort_params = default_sort if sort_params.blank? + + return {} unless sort_params + + sort_params.each_with_object({}) do |sort, order_hash| + field = sort[:field].to_s == 'id' ? _primary_key : sort[:field].to_s + order_hash[field] = sort[:direction] + end + end + + def _add_relationship(klass, *attrs) + _clear_fields_cache + + options = attrs.extract_options! + options[:parent_resource] = self + + attrs.each do |name| + relationship_name = name.to_sym + check_reserved_relationship_name(relationship_name) + check_duplicate_relationship_name(relationship_name) + + define_relationship_methods(relationship_name.to_sym, klass, options) + end + end + + # ResourceBuilder methods + def define_relationship_methods(relationship_name, relationship_klass, options) + relationship = register_relationship( + relationship_name, + relationship_klass.new(relationship_name, options) + ) + + define_foreign_key_setter(relationship) + end + + def define_foreign_key_setter(relationship) + if relationship.polymorphic? + define_on_resource "#{relationship.foreign_key}=" do |v| + _model.method("#{relationship.foreign_key}=").call(v[:id]) + _model.public_send("#{relationship.polymorphic_type}=", v[:type]) + end + else + define_on_resource "#{relationship.foreign_key}=" do |value| + _model.method("#{relationship.foreign_key}=").call(value) + end + end + end + + def define_on_resource(method_name, &block) + return if method_defined?(method_name) + define_method(method_name, block) + end + + def register_relationship(name, relationship_object) + @_relationships[name] = relationship_object + end + + def _clear_cached_attribute_options + @_cached_attribute_options = {} + end + + def _clear_fields_cache + @_fields_cache = nil + end + + private + + def check_reserved_resource_name(type, name) + if [:ids, :types, :hrefs, :links].include?(type) + warn "[NAME COLLISION] `#{name}` is a reserved resource name." + return + end + end + + def check_reserved_attribute_name(name) + # Allow :id since it can be used to specify the format. Since it is a method on the base Resource + # an attribute method won't be created for it. + if [:type, :_cache_field, :cache_field].include?(name.to_sym) + warn "[NAME COLLISION] `#{name}` is a reserved key in #{_resource_name_from_type(_type)}." + end + end + + def check_reserved_relationship_name(name) + if [:id, :ids, :type, :types].include?(name.to_sym) + warn "[NAME COLLISION] `#{name}` is a reserved relationship name in #{_resource_name_from_type(_type)}." + end + end + + def check_duplicate_relationship_name(name) + if _relationships.include?(name.to_sym) + warn "[DUPLICATE RELATIONSHIP] `#{name}` has already been defined in #{_resource_name_from_type(_type)}." + end + end + + def check_duplicate_attribute_name(name) + if _attributes.include?(name.to_sym) + warn "[DUPLICATE ATTRIBUTE] `#{name}` has already been defined in #{_resource_name_from_type(_type)}." + end + end + end + end +end diff --git a/lib/jsonapi/cached_resource_fragment.rb b/lib/jsonapi/cached_resource_fragment.rb deleted file mode 100644 index 8b37cc053..000000000 --- a/lib/jsonapi/cached_resource_fragment.rb +++ /dev/null @@ -1,119 +0,0 @@ -module JSONAPI - class CachedResourceFragment - def self.fetch_fragments(resource_klass, serializer, context, cache_ids) - serializer_config_key = serializer.config_key(resource_klass).gsub("/", "_") - context_json = resource_klass.attribute_caching_context(context).to_json - 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) - - 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) - results[id] = cr - end - end - - if JSONAPI.configuration.resource_cache_usage_report_function - JSONAPI.configuration.resource_cache_usage_report_function.call( - resource_klass.name, - cache_ids.size - miss_ids.size, - miss_ids.size - ) - end - - 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, - :links_json, :attributes_json, :meta_json, - :preloaded_fragments - - def initialize(resource_klass, id, type, fetchable_fields, relationships, - links_json, attributes_json, meta_json) - @resource_klass = resource_klass - @id = id - @type = type - @fetchable_fields = Set.new(fetchable_fields) - - # Relationships left uncompiled because we'll often want to insert included ids on retrieval - @relationships = relationships - - @links_json = CompiledJson.of(links_json) - @attributes_json = CompiledJson.of(attributes_json) - @meta_json = CompiledJson.of(meta_json) - - # A hash of hashes - @preloaded_fragments ||= Hash.new - end - - def to_cache_value - { - id: id, - type: type, - fetchable: fetchable_fields, - rels: relationships, - links: links_json.try(:to_s), - attrs: attributes_json.try(:to_s), - meta: meta_json.try(:to_s) - } - end - - private - - def self.lookup(resource_klass, serializer_config_key, context_key, cache_ids) - type = resource_klass._type - - keys = cache_ids.map do |(id, cache_key)| - [type, id, cache_key, serializer_config_key, context_key] - end - - hits = JSONAPI.configuration.resource_cache.read_multi(*keys).reject{|_,v| v.nil? } - 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]) - else - hash[id] = nil - end - end - end - - def self.write(resource_klass, resource, serializer, serializer_config_key, 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'], - resource.fetchable_fields, - json['relationships'], - json['links'], - json['attributes'], - json['meta'] - ) - - key = [resource_klass._type, id, cache_key, serializer_config_key, context_key] - JSONAPI.configuration.resource_cache.write(key, cr.to_cache_value) - return [id, cr] - end - - end -end diff --git a/lib/jsonapi/cached_response_fragment.rb b/lib/jsonapi/cached_response_fragment.rb new file mode 100644 index 000000000..5e7d3336a --- /dev/null +++ b/lib/jsonapi/cached_response_fragment.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +module JSONAPI + class CachedResponseFragment + + Lookup = Struct.new(:resource_klass, :serializer_config_key, :context, :context_key, :cache_ids) do + + def type + resource_klass._type + end + + def keys + cache_ids.map do |(id, cache_key)| + [type, id, cache_key, serializer_config_key, context_key] + end + end + end + + Write = Struct.new(:resource_klass, :resource, :serializer, :serializer_config_key, :context, :context_key, :relationship_data) do + def to_key_value + + (id, cache_key) = resource.cache_id + + json = serializer.object_hash(resource, relationship_data) + + cr = CachedResponseFragment.new( + resource_klass, + id, + json['type'], + context, + resource.fetchable_fields, + json['relationships'], + json['links'], + json['attributes'], + json['meta'] + ) + + key = [resource_klass._type, id, cache_key, serializer_config_key, context_key] + + [key, cr] + end + end + + attr_reader :resource_klass, :id, :type, :context, :fetchable_fields, :relationships, + :links_json, :attributes_json, :meta_json + + 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 + @relationships = relationships + + @links_json = CompiledJson.of(links_json) + @attributes_json = CompiledJson.of(attributes_json) + @meta_json = CompiledJson.of(meta_json) + end + + def to_cache_value + { + id: id, + type: type, + fetchable: fetchable_fields, + rels: relationships, + links: links_json.try(:to_s), + attrs: attributes_json.try(:to_s), + meta: meta_json.try(:to_s) + } + end + + # @param [Lookup[]] lookups + # @return [Hash, Hash>] + def self.lookup(lookups, context) + type_to_klass = lookups.map {|l| [l.type, l.resource_klass]}.to_h + + keys = lookups.map(&:keys).flatten(1) + + hits = JSONAPI.configuration.resource_cache.read_multi(*keys).reject {|_, v| v.nil?} + + return keys.inject({}) do |hash, key| + (type, id, _, _) = key + resource_klass = type_to_klass[type] + hash[resource_klass] ||= {} + + if hits.has_key?(key) + hash[resource_klass][id] = self.from_cache_value(resource_klass, context, hits[key]) + else + hash[resource_klass][id] = nil + end + + hash + end + end + + # @param [Write[]] lookups + def self.write(writes) + key_values = writes.map(&:to_key_value) + + to_write = key_values.map {|(k, v)| [k, v.to_cache_value]}.to_h + + if JSONAPI.configuration.resource_cache.respond_to? :write_multi + JSONAPI.configuration.resource_cache.write_multi(to_write) + else + to_write.each do |key, value| + JSONAPI.configuration.resource_cache.write(key, value) + end + end + + end + + 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 + end +end diff --git a/lib/jsonapi/callbacks.rb b/lib/jsonapi/callbacks.rb index 474f50f48..78de6fca9 100644 --- a/lib/jsonapi/callbacks.rb +++ b/lib/jsonapi/callbacks.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'active_support/callbacks' module JSONAPI diff --git a/lib/jsonapi/compatibility_helper.rb b/lib/jsonapi/compatibility_helper.rb new file mode 100644 index 000000000..516609349 --- /dev/null +++ b/lib/jsonapi/compatibility_helper.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# JSONAPI::CompatibilityHelper +# +# This module provides a version-safe method for issuing deprecation warnings +# that works across multiple versions of Rails (7.x, 8.x, etc). +# +# Usage: +# JSONAPI::CompatibilityHelper.deprecation_warn("Your deprecation message") +# +# The method will use the public `warn` method if available, otherwise it will +# use `send(:warn, ...)` to maintain compatibility with Rails 8+ where `warn` +# is private. +# +# Example: +# JSONAPI::CompatibilityHelper.deprecation_warn("This feature is deprecated.") + +module JSONAPI + module CompatibilityHelper + def deprecation_warn(message) + if ActiveSupport::Deprecation.respond_to?(:warn) && ActiveSupport::Deprecation.public_method_defined?(:warn) + ActiveSupport::Deprecation.warn(message) + else + ActiveSupport::Deprecation.send(:warn, message) + end + end + module_function :deprecation_warn + end +end diff --git a/lib/jsonapi/compiled_json.rb b/lib/jsonapi/compiled_json.rb index 3bdb8998d..cd0cc83bb 100644 --- a/lib/jsonapi/compiled_json.rb +++ b/lib/jsonapi/compiled_json.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class CompiledJson def self.compile(h) @@ -5,6 +7,7 @@ def self.compile(h) end def self.of(obj) + # :nocov: case obj when NilClass then nil when CompiledJson then obj @@ -12,6 +15,7 @@ def self.of(obj) when Hash then CompiledJson.compile(obj) else raise "Can't figure out how to turn #{obj.inspect} into CompiledJson" end + # :nocov: end def initialize(json, h = nil) @@ -19,7 +23,7 @@ def initialize(json, h = nil) @h = h end - def to_json(*args) + def to_json(*_args) @json end @@ -27,9 +31,17 @@ def to_s @json end + # :nocov: def to_h @h ||= JSON.parse(@json) end + # :nocov: + + def [](key) + # :nocov: + to_h[key] + # :nocov: + end undef_method :as_json end diff --git a/lib/jsonapi/configuration.rb b/lib/jsonapi/configuration.rb index 4601bb351..e1a7e1c61 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -1,20 +1,26 @@ +# frozen_string_literal: true + require 'jsonapi/formatter' require 'jsonapi/processor' require 'concurrent' - +require_relative 'compatibility_helper' module JSONAPI class Configuration attr_reader :json_key_format, :resource_key_type, :route_format, :raise_if_parameters_not_allowed, - :allow_include, + :warn_on_route_setup_issues, + :warn_on_missing_routes, + :warn_on_performance_issues, + :default_allow_include_to_one, + :default_allow_include_to_many, :allow_sort, :allow_filter, :default_paginator, :default_page_size, :maximum_page_size, - :default_processor_klass, + :default_processor_klass_name, :use_text_errors, :top_level_links_include_pagination, :top_level_meta_include_record_count, @@ -23,16 +29,19 @@ class Configuration :top_level_meta_page_count_key, :allow_transactions, :include_backtraces_in_errors, - :exception_class_whitelist, - :whitelist_all_exceptions, + :include_application_backtraces_in_errors, + :exception_class_allowlist, + :allow_all_exceptions, :always_include_to_one_linkage_data, :always_include_to_many_linkage_data, :cache_formatters, :use_relationship_reflection, :resource_cache, + :default_caching, :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 @@ -45,12 +54,17 @@ def initialize self.resource_key_type = :integer # optional request features - self.allow_include = true + self.default_allow_include_to_one = true + self.default_allow_include_to_many = true self.allow_sort = true self.allow_filter = true self.raise_if_parameters_not_allowed = true + self.warn_on_route_setup_issues = true + self.warn_on_missing_routes = true + self.warn_on_performance_issues = true + # :none, :offset, :paged, or a custom paginator name self.default_paginator = :none @@ -71,20 +85,24 @@ def initialize self.use_text_errors = false # Whether or not to include exception backtraces in JSONAPI error - # responses. Defaults to `false` in production, and `true` otherwise. - self.include_backtraces_in_errors = !Rails.env.production? + # responses. Defaults to `false` in anything other than development or test. + self.include_backtraces_in_errors = (Rails.env.development? || Rails.env.test?) + + # Whether or not to include exception application backtraces in JSONAPI error + # responses. Defaults to `false` in anything other than development or test. + self.include_application_backtraces_in_errors = (Rails.env.development? || Rails.env.test?) # 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`. - self.exception_class_whitelist = [] + # the `Pundit::NotAuthorizedError` to the `exception_class_allowlist`. + self.exception_class_allowlist = [] - # If enabled, will override configuration option `exception_class_whitelist` - # and whitelist all exceptions. - self.whitelist_all_exceptions = false + # If enabled, will override configuration option `exception_class_allowlist` + # and allow all exceptions. + self.allow_all_exceptions = false # Resource Linkage # Controls the serialization of resource linkage for non compound documents @@ -94,7 +112,7 @@ def initialize # The default Operation Processor to use if one is not defined specifically # for a Resource. - self.default_processor_klass = JSONAPI::Processor + self.default_processor_klass_name = 'JSONAPI::Processor' # Allows transactions for creating and updating records # Set this to false if your backend does not support transactions (e.g. Mongodb) @@ -117,6 +135,11 @@ def initialize # Rails cache store. self.resource_cache = nil + # Cache resources by default + # Cache resources by default. Individual resources can be excluded from caching by calling: + # `caching false` + self.default_caching = false + # 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". @@ -131,6 +154,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) @@ -192,16 +221,42 @@ def route_formatter return formatter end - def exception_class_whitelisted?(e) - @whitelist_all_exceptions || - @exception_class_whitelist.flatten.any? { |k| e.class.ancestors.map(&:to_s).include?(k.to_s) } + def exception_class_allowed?(e) + @allow_all_exceptions || + @exception_class_allowlist.flatten.any? { |k| e.class.ancestors.map(&:to_s).include?(k.to_s) } end def default_processor_klass=(default_processor_klass) + JSONAPI::CompatibilityHelper.deprecation_warn('`default_processor_klass` has been replaced by `default_processor_klass_name`.') @default_processor_klass = default_processor_klass end - attr_writer :allow_include, :allow_sort, :allow_filter + def default_processor_klass + @default_processor_klass ||= default_processor_klass_name.safe_constantize + end + + def default_processor_klass_name=(default_processor_klass_name) + @default_processor_klass = nil + @default_processor_klass_name = default_processor_klass_name + end + + def allow_include=(allow_include) + JSONAPI::CompatibilityHelper.deprecation_warn('`allow_include` has been replaced by `default_allow_include_to_one` and `default_allow_include_to_many` options.') + @default_allow_include_to_one = allow_include + @default_allow_include_to_many = allow_include + end + + def whitelist_all_exceptions=(allow_all_exceptions) + JSONAPI::CompatibilityHelper.deprecation_warn('`whitelist_all_exceptions` has been replaced by `allow_all_exceptions`') + @allow_all_exceptions = allow_all_exceptions + end + + def exception_class_whitelist=(exception_class_allowlist) + JSONAPI::CompatibilityHelper.deprecation_warn('`exception_class_whitelist` has been replaced by `exception_class_allowlist`') + @exception_class_allowlist = exception_class_allowlist + end + + attr_writer :allow_sort, :allow_filter, :default_allow_include_to_one, :default_allow_include_to_many attr_writer :default_paginator @@ -225,9 +280,11 @@ def default_processor_klass=(default_processor_klass) attr_writer :include_backtraces_in_errors - attr_writer :exception_class_whitelist + attr_writer :include_application_backtraces_in_errors + + attr_writer :exception_class_allowlist - attr_writer :whitelist_all_exceptions + attr_writer :allow_all_exceptions attr_writer :always_include_to_one_linkage_data @@ -235,15 +292,25 @@ def default_processor_klass=(default_processor_klass) attr_writer :raise_if_parameters_not_allowed + attr_writer :warn_on_route_setup_issues + + attr_writer :warn_on_missing_routes + + attr_writer :warn_on_performance_issues + attr_writer :use_relationship_reflection attr_writer :resource_cache + attr_writer :default_caching + attr_writer :default_resource_cache_field 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..8eeb87cff 100644 --- a/lib/jsonapi/error.rb +++ b/lib/jsonapi/error.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class Error attr_accessor :title, :detail, :id, :href, :code, :source, :links, :status, :meta @@ -15,7 +17,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 @@ -24,6 +26,33 @@ def to_hash instance_variables.each {|var| hash[var.to_s.delete('@')] = instance_variable_get(var) unless instance_variable_get(var).nil? } hash end + + def update_with_overrides(error_object_overrides) + @title = error_object_overrides[:title] || @title + @detail = error_object_overrides[:detail] || @detail + @id = error_object_overrides[:id] || @id + @href = error_object_overrides[:href] || href + + if error_object_overrides[:code] + # :nocov: + @code = if JSONAPI.configuration.use_text_errors + TEXT_ERRORS[error_object_overrides[:code]] + else + error_object_overrides[:code] + end + # :nocov: + end + + @source = error_object_overrides[:source] || @source + @links = error_object_overrides[:links] || @links + + if error_object_overrides[:status] + # :nocov: + @status = Rack::Utils::SYMBOL_TO_STATUS_CODE[error_object_overrides[:status]].to_s + # :nocov: + end + @meta = error_object_overrides[:meta] || @meta + end end class Warning diff --git a/lib/jsonapi/error_codes.rb b/lib/jsonapi/error_codes.rb index 290ee6189..f25608413 100644 --- a/lib/jsonapi/error_codes.rb +++ b/lib/jsonapi/error_codes.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI VALIDATION_ERROR = '100' INVALID_RESOURCE = '101' @@ -20,6 +22,8 @@ module JSONAPI INVALID_FILTERS_SYNTAX = '120' SAVE_FAILED = '121' INVALID_DATA_FORMAT = '122' + INVALID_RELATIONSHIP = '123' + BAD_REQUEST = '400' FORBIDDEN = '403' RECORD_NOT_FOUND = '404' NOT_ACCEPTABLE = '406' @@ -49,6 +53,7 @@ module JSONAPI INVALID_FILTERS_SYNTAX => 'INVALID_FILTERS_SYNTAX', SAVE_FAILED => 'SAVE_FAILED', INVALID_DATA_FORMAT => 'INVALID_DATA_FORMAT', + INVALID_RELATIONSHIP => 'INVALID_RELATIONSHIP', FORBIDDEN => 'FORBIDDEN', RECORD_NOT_FOUND => 'RECORD_NOT_FOUND', NOT_ACCEPTABLE => 'NOT_ACCEPTABLE', diff --git a/lib/jsonapi/exceptions.rb b/lib/jsonapi/exceptions.rb index 2d220e756..e917118cf 100644 --- a/lib/jsonapi/exceptions.rb +++ b/lib/jsonapi/exceptions.rb @@ -1,6 +1,18 @@ +# frozen_string_literal: true + module JSONAPI module Exceptions class Error < RuntimeError + attr_reader :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" @@ -8,11 +20,28 @@ def errors end end + class Errors < Error + def initialize(errors, error_object_overrides = {}) + @errors = errors + + @errors.each do |error| + error.update_with_overrides(error_object_overrides) + end + + super(error_object_overrides) + end + + def errors + @errors + end + end + 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 +51,437 @@ 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', + if JSONAPI.configuration.include_application_backtraces_in_errors + meta ||= Hash.new + meta[:exception] ||= exception.message + meta[:application_backtrace] = exception.backtrace.select{|line| line =~ /#{Rails.root}/} + end + + [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 BadRequest < Error + def initialize(exception, error_object_overrides = {}) + @exception = exception + super(error_object_overrides) + end - class HasManyRelationExists < Error - attr_accessor :id - def initialize(id) - @id = id + def errors + [create_error_object(code: JSONAPI::BAD_REQUEST, + status: :bad_request, + 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::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))] + [create_error_object(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 InvalidRelationship < Error + attr_accessor :relationship_name, :type + + def initialize(type, relationship_name, error_object_overrides = {}) + @relationship_name = relationship_name + @type = type + super(error_object_overrides) + end + + def errors + [create_error_object(code: JSONAPI::INVALID_RELATIONSHIP, + status: :bad_request, + title: I18n.translate('jsonapi-resources.exceptions.invalid_relationship.title', + default: 'Invalid relationship'), + detail: I18n.translate('jsonapi-resources.exceptions.invalid_relationship.detail', + default: "#{relationship_name} is not a valid field for #{type}.", + relationship_name: relationship_name, 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 includable 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, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.parameters_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 + [create_error_object(code: JSONAPI::PARAM_NOT_ALLOWED, + status: :bad_request, + 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 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 +497,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 +523,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/formatter.rb b/lib/jsonapi/formatter.rb index b6f2e4cce..7b79f931d 100644 --- a/lib/jsonapi/formatter.rb +++ b/lib/jsonapi/formatter.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class Formatter class << self @@ -108,7 +110,7 @@ def unformat(formatted_key) class DasherizedKeyFormatter < JSONAPI::KeyFormatter class << self - def format(key) + def format(_key) super.underscore.dasherize end @@ -146,7 +148,7 @@ class UnderscoredRouteFormatter < JSONAPI::RouteFormatter class CamelizedRouteFormatter < JSONAPI::RouteFormatter class << self - def format(route) + def format(_route) super.camelize(:lower) end @@ -158,7 +160,7 @@ def unformat(formatted_route) class DasherizedRouteFormatter < JSONAPI::RouteFormatter class << self - def format(route) + def format(_route) super.dasherize end diff --git a/lib/jsonapi/include_directives.rb b/lib/jsonapi/include_directives.rb index 0fd41536c..2ad300133 100644 --- a/lib/jsonapi/include_directives.rb +++ b/lib/jsonapi/include_directives.rb @@ -1,17 +1,17 @@ +# frozen_string_literal: true + module JSONAPI class IncludeDirectives # Construct an IncludeDirectives Hash from an array of dot separated include strings. # For example ['posts.comments.tags'] # will transform into => # { - # posts:{ - # include:true, - # include_related:{ + # posts: { + # include_related: { # comments:{ - # include:true, - # include_related:{ - # tags:{ - # include:true + # include_related: { + # tags: { + # include_related: {} # } # } # } @@ -19,82 +19,37 @@ class IncludeDirectives # } # } - def initialize(resource_klass, includes_array, force_eager_load: false) + def initialize(resource_klass, includes_array) @resource_klass = resource_klass - @force_eager_load = force_eager_load @include_directives_hash = { include_related: {} } includes_array.each do |include| parse_include(include) end end - def include_directives - @include_directives_hash - end - - def model_includes - get_includes(@include_directives_hash) - end - - def paths - delve_paths(get_includes(@include_directives_hash, false)) + def [](name) + @include_directives_hash[name] end private - def get_related(current_path) - current = @include_directives_hash - current_resource_klass = @resource_klass - current_path.split('.').each do |fragment| - fragment = fragment.to_sym - - if current_resource_klass - 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}." - end - - include_in_join = @force_eager_load || !current_relationship || current_relationship.eager_load_on_include - - current[:include_related][fragment] ||= { include: false, include_related: {}, include_in_join: include_in_join } - current = current[:include_related][fragment] - end - current - end - - def get_includes(directive, only_joined_includes = true) - ir = directive[:include_related] - ir = ir.select { |k,v| v[:include_in_join] } if only_joined_includes + def parse_include(include) + path = JSONAPI::Path.new(resource_klass: @resource_klass, + path_string: include, + ensure_default_field: false, + parse_fields: false) - ir.map do |name, sub_directive| - sub = get_includes(sub_directive, only_joined_includes) - sub.any? ? { name => sub } : name - end - end + current = @include_directives_hash - def parse_include(include) - parts = include.split('.') - local_path = '' + path.segments.each do |segment| + relationship_name = segment.relationship.name.to_sym - parts.each do |name| - local_path += local_path.length > 0 ? ".#{name}" : name - related = get_related(local_path) - related[:include] = true + current[:include_related][relationship_name] ||= { include_related: {} } + current = current[:include_related][relationship_name] end - end - def delve_paths(obj) - case obj - when Array - obj.map{|elem| delve_paths(elem)}.flatten(1) - when Hash - obj.map{|k,v| [[k]] + delve_paths(v).map{|path| [k] + path } }.flatten(1) - when Symbol, String - [[obj]] - else - raise "delve_paths cannot descend into #{obj.class.name}" - end + rescue JSONAPI::Exceptions::InvalidRelationship => _e + raise JSONAPI::Exceptions::InvalidInclude.new(@resource_klass, include) end end end diff --git a/lib/jsonapi/link_builder.rb b/lib/jsonapi/link_builder.rb index 1799152fc..d78f414e1 100644 --- a/lib/jsonapi/link_builder.rb +++ b/lib/jsonapi/link_builder.rb @@ -1,105 +1,104 @@ +# frozen_string_literal: true + module JSONAPI 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 }#{ 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 - - 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 + begin + unless scopes.empty? + "#{ scopes.first.to_s.camelize }::Engine".safe_constantize + end - 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,10 +107,14 @@ def format_route(route) end def formatted_module_path_from_class(klass) - scopes = module_scopes_from_class(klass) + 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) }.join('/') }/" + "/#{ scopes.map {|scope| format_route(scope.to_s.underscore)}.compact.join('/') }/" else "/" end @@ -121,24 +124,21 @@ 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) - end - - def regular_primary_resources_path - regular_resources_path(primary_resource_klass) + def resources_path(source_klass) + @_resources_path ||= {} + @_resources_path[source_klass] ||= formatted_module_path_from_class(source_klass) + format_route(source_klass._type.to_s) end - def regular_primary_resources_url - "#{ base_url }#{ regular_primary_resources_path }" - end - - def regular_resource_path(source) - "#{regular_resources_path(source.class)}/#{source.id}" + def resource_path(source) + if source.class.singleton? + resources_path(source.class) + else + "#{resources_path(source.class)}/#{source.id}" + end end - def regular_resource_url(source) - "#{ base_url }#{ regular_resource_path(source) }" + def resource_url(source) + "#{ base_url }#{ engine_mount_point }#{ resource_path(source) }" end def route_for_relationship(relationship) diff --git a/lib/jsonapi/mime_types.rb b/lib/jsonapi/mime_types.rb index f8bde565e..d888a1bd8 100644 --- a/lib/jsonapi/mime_types.rb +++ b/lib/jsonapi/mime_types.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +require 'json' + module JSONAPI MEDIA_TYPE = 'application/vnd.api+json' @@ -5,23 +9,26 @@ module MimeTypes def self.install Mime::Type.register JSONAPI::MEDIA_TYPE, :api_json - # :nocov: - if Rails::VERSION::MAJOR >= 5 - parsers = ActionDispatch::Request.parameter_parsers.merge( - Mime::Type.lookup(JSONAPI::MEDIA_TYPE).symbol => parser - ) - ActionDispatch::Request.parameter_parsers = parsers - else - ActionDispatch::ParamsParser::DEFAULT_PARSERS[Mime::Type.lookup(JSONAPI::MEDIA_TYPE)] = parser - end - # :nocov: + parsers = ActionDispatch::Request.parameter_parsers.merge( + Mime::Type.lookup(JSONAPI::MEDIA_TYPE).symbol => parser + ) + ActionDispatch::Request.parameter_parsers = parsers end 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/naive_cache.rb b/lib/jsonapi/naive_cache.rb index 53bf6ccb0..098300ba5 100644 --- a/lib/jsonapi/naive_cache.rb +++ b/lib/jsonapi/naive_cache.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI # Cache which memoizes the given block. diff --git a/lib/jsonapi/operation.rb b/lib/jsonapi/operation.rb index 239345fa6..f87f6570f 100644 --- a/lib/jsonapi/operation.rb +++ b/lib/jsonapi/operation.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class Operation attr_reader :resource_klass, :operation_type, :options @@ -8,17 +10,28 @@ def initialize(operation_type, resource_klass, options) @options = options end - def transactional? - JSONAPI::Processor._processor_from_resource_type(resource_klass).transactional_operation_type?(operation_type) - end - def process processor.process end private def processor - JSONAPI::Processor.processor_instance_for(resource_klass, operation_type, options) + self.class.processor_instance_for(resource_klass, operation_type, options) + end + + class << self + def processor_instance_for(resource_klass, operation_type, params) + _processor_from_resource_type(resource_klass).new(resource_klass, operation_type, params) + end + + def _processor_from_resource_type(resource_klass) + processor = resource_klass.name.gsub(/Resource$/,'Processor').safe_constantize + if processor.nil? + processor = JSONAPI.configuration.default_processor_klass + end + + return processor + end end end end diff --git a/lib/jsonapi/operation_dispatcher.rb b/lib/jsonapi/operation_dispatcher.rb deleted file mode 100644 index 999f37dc9..000000000 --- a/lib/jsonapi/operation_dispatcher.rb +++ /dev/null @@ -1,88 +0,0 @@ -module JSONAPI - class OperationDispatcher - - def initialize(transaction: lambda { |&block| block.yield }, - rollback: lambda { }, - server_error_callbacks: []) - - @transaction = transaction - @rollback = rollback - @server_error_callbacks = server_error_callbacks - end - - def process(operations) - results = JSONAPI::OperationResults.new - - # Use transactions if more than one operation and if one of the operations can be transactional - # Even if transactional transactions won't be used unless the derived OperationsProcessor supports them. - transactional = false - - operations.each do |operation| - transactional |= operation.transactional? - end if JSONAPI.configuration.allow_transactions - - transaction(transactional) do - # Links and meta data global to the set of operations - operations_meta = {} - operations_links = {} - operations.each do |operation| - results.add_result(process_operation(operation)) - rollback(transactional) if results.has_errors? - end - results.meta = operations_meta - results.links = operations_links - end - results - end - - private - - def transaction(transactional) - if transactional - @transaction.call do - yield - end - else - yield - end - end - - def rollback(transactional) - if transactional - @rollback.call - end - end - - def process_operation(operation) - with_default_handling do - operation.process - end - end - - def with_default_handling(&block) - block.yield - rescue => e - if JSONAPI.configuration.exception_class_whitelisted?(e) - raise e - else - @server_error_callbacks.each { |callback| - safe_run_callback(callback, e) - } - - internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e) - Rails.logger.error { "Internal Server Error: #{e.message} #{e.backtrace.join("\n")}" } - return JSONAPI::ErrorsOperationResult.new(internal_server_error.errors[0].code, internal_server_error.errors) - end - end - - def safe_run_callback(callback, error) - begin - callback.call(error) - rescue => e - Rails.logger.error { "Error in error handling callback: #{e.message} #{e.backtrace.join("\n")}" } - internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e) - return JSONAPI::ErrorsOperationResult.new(internal_server_error.errors[0].code, internal_server_error.errors) - end - end - end -end diff --git a/lib/jsonapi/operation_result.rb b/lib/jsonapi/operation_result.rb index 43e996a15..63a3d9dc1 100644 --- a/lib/jsonapi/operation_result.rb +++ b/lib/jsonapi/operation_result.rb @@ -1,15 +1,23 @@ +# frozen_string_literal: true + module JSONAPI class OperationResult attr_accessor :code attr_accessor :meta attr_accessor :links attr_accessor :options + attr_accessor :warnings def initialize(code, options = {}) - @code = code + @code = Rack::Utils.status_code(code) @options = options @meta = options.fetch(:meta, {}) @links = options.fetch(:links, {}) + @warnings = options.fetch(:warnings, {}) + end + + def to_hash(serializer = nil) + {} end end @@ -20,46 +28,98 @@ def initialize(code, errors, options = {}) @errors = errors super(code, options) end + + def to_hash(serializer = nil) + { + errors: errors.collect do |error| + # :nocov: + error.to_hash + # :nocov: + end + } + end end - class ResourceOperationResult < OperationResult - attr_accessor :resource + class ResourceSetOperationResult < OperationResult + attr_accessor :resource_set, :pagination_params - def initialize(code, resource, options = {}) - @resource = resource + def initialize(code, resource_set, options = {}) + @resource_set = resource_set + @pagination_params = options.fetch(:pagination_params, {}) super(code, options) end + + def to_hash(serializer) + if serializer + serializer.serialize_resource_set_to_hash_single(resource_set) + else + # :nocov: + {} + # :nocov: + end + end end - class ResourcesOperationResult < OperationResult - attr_accessor :resources, :pagination_params, :record_count, :page_count + class ResourcesSetOperationResult < OperationResult + attr_accessor :resource_set, :pagination_params, :record_count, :page_count - def initialize(code, resources, options = {}) - @resources = resources + def initialize(code, resource_set, options = {}) + @resource_set = resource_set @pagination_params = options.fetch(:pagination_params, {}) @record_count = options[:record_count] @page_count = options[:page_count] super(code, options) end + + def to_hash(serializer) + if serializer + serializer.serialize_resource_set_to_hash_plural(resource_set) + else + # :nocov: + {} + # :nocov: + end + end end - class RelatedResourcesOperationResult < ResourcesOperationResult - attr_accessor :source_resource, :_type + class RelatedResourcesSetOperationResult < ResourcesSetOperationResult + attr_accessor :resource_set, :source_resource, :_type - def initialize(code, source_resource, type, resources, options = {}) + def initialize(code, source_resource, type, resource_set, options = {}) @source_resource = source_resource @_type = type - super(code, resources, options) + super(code, resource_set, options) + end + + def to_hash(serializer = nil) + if serializer + serializer.serialize_related_resource_set_to_hash_plural(resource_set, source_resource) + else + # :nocov: + {} + # :nocov: + end end end - class LinksObjectOperationResult < OperationResult - attr_accessor :parent_resource, :relationship + class RelationshipOperationResult < OperationResult + attr_accessor :parent_resource, :relationship, :resource_ids - def initialize(code, parent_resource, relationship, options = {}) + def initialize(code, parent_resource, relationship, resource_ids, options = {}) @parent_resource = parent_resource @relationship = relationship + @resource_ids = resource_ids super(code, options) end + + def to_hash(serializer = nil) + if serializer + serializer.serialize_to_relationship_hash(parent_resource, relationship, resource_ids) + else + # :nocov: + {} + # :nocov: + end + end end end diff --git a/lib/jsonapi/operation_results.rb b/lib/jsonapi/operation_results.rb deleted file mode 100644 index 22363fd8a..000000000 --- a/lib/jsonapi/operation_results.rb +++ /dev/null @@ -1,35 +0,0 @@ -module JSONAPI - class OperationResults - attr_accessor :results - attr_accessor :meta - attr_accessor :links - - def initialize - @results = [] - @has_errors = false - @meta = {} - @links = {} - end - - def add_result(result) - @has_errors = true if result.is_a?(JSONAPI::ErrorsOperationResult) - @results.push(result) - end - - def has_errors? - @has_errors - end - - def all_errors - errors = [] - if @has_errors - @results.each do |result| - if result.is_a?(JSONAPI::ErrorsOperationResult) - errors.concat(result.errors) - end - end - end - errors - end - end -end diff --git a/lib/jsonapi/paginator.rb b/lib/jsonapi/paginator.rb index 3ad00abbe..1054354e4 100644 --- a/lib/jsonapi/paginator.rb +++ b/lib/jsonapi/paginator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class Paginator def initialize(_params) @@ -110,7 +112,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 +201,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/path.rb b/lib/jsonapi/path.rb new file mode 100644 index 000000000..0e5d7e844 --- /dev/null +++ b/lib/jsonapi/path.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module JSONAPI + class Path + attr_reader :segments, :resource_klass + def initialize(resource_klass:, + path_string:, + ensure_default_field: true, + parse_fields: true) + @resource_klass = resource_klass + + current_resource_klass = resource_klass + @segments = path_string.to_s.split('.').collect do |segment_string| + segment = PathSegment.parse(source_resource_klass: current_resource_klass, + segment_string: segment_string, + parse_fields: parse_fields) + + current_resource_klass = segment.resource_klass + segment + end + + if ensure_default_field && parse_fields && @segments.last.is_a?(PathSegment::Relationship) + last = @segments.last + @segments << PathSegment::Field.new(resource_klass: last.resource_klass, + field_name: last.resource_klass._primary_key) + end + end + + def relationship_segments + @segments.select {|p| p.is_a?(PathSegment::Relationship)} + end + + def relationship_path_string + relationship_segments.collect(&:to_s).join('.') + end + + def last_relationship + if @segments.last.is_a?(PathSegment::Relationship) + @segments.last + else + @segments[-2] + end + end + end +end diff --git a/lib/jsonapi/path_segment.rb b/lib/jsonapi/path_segment.rb new file mode 100644 index 000000000..4b9879819 --- /dev/null +++ b/lib/jsonapi/path_segment.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module JSONAPI + class PathSegment + def self.parse(source_resource_klass:, segment_string:, parse_fields: true) + first_part, last_part = segment_string.split('#', 2) + relationship = source_resource_klass._relationship(first_part) + + if relationship + if last_part + unless relationship.resource_types.include?(last_part) + raise JSONAPI::Exceptions::InvalidRelationship.new(source_resource_klass._type, segment_string) + end + resource_klass = source_resource_klass.resource_klass_for(last_part) + end + return PathSegment::Relationship.new(relationship: relationship, resource_klass: resource_klass) + else + if last_part.blank? && parse_fields + return PathSegment::Field.new(resource_klass: source_resource_klass, field_name: first_part) + else + raise JSONAPI::Exceptions::InvalidRelationship.new(source_resource_klass._type, segment_string) + end + end + end + + class Relationship + attr_reader :relationship, :resource_klass + + def initialize(relationship:, resource_klass: nil) + @relationship = relationship + @resource_klass = resource_klass + end + + def eql?(other) + other.is_a?(self.class) && relationship == other.relationship && resource_klass == other.resource_klass + end + + def hash + [relationship, resource_klass].hash + end + + def to_s + @resource_klass ? "#{relationship.parent_resource_klass._type}.#{relationship.name}##{resource_klass._type}" : "#{resource_klass._type}.#{relationship.name}" + end + + def resource_klass + @resource_klass || relationship.resource_klass + end + + def path_specified_resource_klass? + !@resource_klass.nil? + end + end + + class Field + attr_reader :resource_klass, :field_name + + def initialize(resource_klass:, field_name:) + @resource_klass = resource_klass + @field_name = field_name + end + + def eql?(other) + other.is_a?(self.class) && field_name == other.field_name && resource_klass == other.resource_klass + end + + def delegated_field_name + resource_klass._attribute_delegated_name(field_name) + end + + def to_s + # :nocov: + "#{resource_klass._type}.#{field_name.to_s}" + # :nocov: + end + end + end +end \ No newline at end of file diff --git a/lib/jsonapi/processor.rb b/lib/jsonapi/processor.rb index 82566568a..814642a45 100644 --- a/lib/jsonapi/processor.rb +++ b/lib/jsonapi/processor.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class Processor include Callbacks @@ -17,30 +19,6 @@ class Processor :remove_to_one_relationship, :operation - class << self - def processor_instance_for(resource_klass, operation_type, params) - _processor_from_resource_type(resource_klass).new(resource_klass, operation_type, params) - end - - def _processor_from_resource_type(resource_klass) - processor = resource_klass.name.gsub(/Resource$/,'Processor').safe_constantize - if processor.nil? - processor = JSONAPI.configuration.default_processor_klass - end - - return processor - end - - def transactional_operation_type?(operation_type) - case operation_type - when :find, :show, :show_related_resource, :show_related_resources - return false - else - return true - end - end - end - attr_reader :resource_klass, :operation_type, :params, :context, :result, :result_options def initialize(resource_klass, operation_type, params) @@ -66,93 +44,124 @@ def process def find filters = params[:filters] include_directives = params[:include_directives] - sort_criteria = params.fetch(:sort_criteria, []) + sort_criteria = params[:sort_criteria] paginator = params[:paginator] fields = params[:fields] + serializer = params[:serializer] verified_filters = resource_klass.verify_filters(filters, context) - find_options = { + + options = { context: context, - include_directives: include_directives, sort_criteria: sort_criteria, paginator: paginator, - fields: fields + fields: fields, + filters: verified_filters, + include_directives: include_directives } - resource_records = if params[:cache_serializer] - resource_klass.find_serialized_with_caching(verified_filters, - params[:cache_serializer], - find_options) - else - resource_klass.find(verified_filters, find_options) - end + resource_set = find_resource_set(include_directives, options) + + resource_set.populate!(serializer, context, options) - page_options = {} - 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) + page_options = result_options + if (JSONAPI.configuration.top_level_meta_include_record_count || (paginator && paginator.class.requires_record_count)) + page_options[:record_count] = resource_klass.count(verified_filters, + context: context, + include_directives: include_directives) 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]) + if (JSONAPI.configuration.top_level_meta_include_page_count && paginator && 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_set)) end - return JSONAPI::ResourcesOperationResult.new(:ok, resource_records, page_options) + JSONAPI::ResourcesSetOperationResult.new(:ok, resource_set, page_options) end def show include_directives = params[:include_directives] fields = params[:fields] id = params[:id] + serializer = params[:serializer] key = resource_klass.verify_key(id, context) - find_options = { + options = { context: context, - include_directives: include_directives, - fields: fields + fields: fields, + filters: { resource_klass._primary_key => key }, + include_directives: include_directives } - resource_record = if params[:cache_serializer] - resource_klass.find_by_key_serialized_with_caching(key, - params[:cache_serializer], - find_options) - else - resource_klass.find_by_key(key, find_options) - end + resource_set = find_resource_set(include_directives, options) - return JSONAPI::ResourceOperationResult.new(:ok, resource_record) + fail JSONAPI::Exceptions::RecordNotFound.new(id) if resource_set.resource_klasses.empty? + resource_set.populate!(serializer, context, options) + + JSONAPI::ResourceSetOperationResult.new(:ok, resource_set, result_options) end def show_relationship parent_key = params[:parent_key] relationship_type = params[:relationship_type].to_sym + paginator = params[:paginator] + sort_criteria = params[:sort_criteria] + include_directives = params[:include_directives] + fields = params[:fields] parent_resource = resource_klass.find_by_key(parent_key, context: context) - return JSONAPI::LinksObjectOperationResult.new(:ok, - parent_resource, - resource_klass._relationship(relationship_type)) + options = { + context: context, + sort_criteria: sort_criteria, + paginator: paginator, + fields: fields, + include_directives: include_directives + } + + resource_tree = find_related_resource_tree( + parent_resource, + relationship_type, + options, + nil + ) + + JSONAPI::RelationshipOperationResult.new(:ok, + parent_resource, + resource_klass._relationship(relationship_type), + resource_tree.fragments.keys, + result_options) end def show_related_resource + include_directives = params[:include_directives] source_klass = params[:source_klass] source_id = params[:source_id] - relationship_type = params[:relationship_type].to_sym + relationship_type = params[:relationship_type] + serializer = params[:serializer] fields = params[:fields] - # TODO Should fetch related_resource from cache if caching enabled + options = { + context: context, + fields: fields, + filters: {}, + include_directives: include_directives + } + source_resource = source_klass.find_by_key(source_id, context: context, fields: fields) - related_resource = source_resource.public_send(relationship_type) + resource_set = find_related_resource_set(source_resource, + relationship_type, + include_directives, + options) + + resource_set.populate!(serializer, context, options) - return JSONAPI::ResourceOperationResult.new(:ok, related_resource) + JSONAPI::ResourceSetOperationResult.new(:ok, resource_set, result_options) end def show_related_resources @@ -164,11 +173,12 @@ def show_related_resources paginator = params[:paginator] fields = params[:fields] include_directives = params[:include_directives] + serializer = params[:serializer] - 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, + options = { + filters: verified_filters, sort_criteria: sort_criteria, paginator: paginator, fields: fields, @@ -176,62 +186,66 @@ def show_related_resources include_directives: include_directives } - related_resources = nil - if params[:cache_serializer] - # TODO Could also avoid instantiating source_resource as actual Resource by - # allowing LinkBuilder to accept CachedResourceFragment as source in - # relationships_related_link - scope = source_resource.public_send(:"records_for_#{relationship_type}", rel_opts) - relationship = source_klass._relationship(relationship_type) - related_resources = relationship.resource_klass.find_serialized_with_caching( - scope, - params[:cache_serializer], - rel_opts - ) - else - related_resources = source_resource.public_send(relationship_type, rel_opts) - end + source_resource = source_klass.find_by_key(source_id, context: context, fields: fields) + resource_set = find_related_resource_set(source_resource, + relationship_type, + include_directives, + options) + + resource_set.populate!(serializer, context, options) + + opts = result_options if ((JSONAPI.configuration.top_level_meta_include_record_count) || - (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, {}, - related_resource_records) + (paginator && paginator.class.requires_record_count) || + (JSONAPI.configuration.top_level_meta_include_page_count)) - record_count = resource_klass.count_records(records) + opts[:record_count] = source_resource.class.count_related( + source_resource, + relationship_type, + options) end - if (JSONAPI.configuration.top_level_meta_include_page_count && record_count) - page_count = paginator.calculate_page_count(record_count) + if (JSONAPI.configuration.top_level_meta_include_page_count && opts[:record_count]) + opts[:page_count] = paginator.calculate_page_count(opts[:record_count]) end - 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) - else - {} - end - - opts = {} - opts.merge!(pagination_params: pagination_params) if JSONAPI.configuration.top_level_links_include_pagination - opts.merge!(record_count: record_count) if JSONAPI.configuration.top_level_meta_include_record_count - opts.merge!(page_count: page_count) if JSONAPI.configuration.top_level_meta_include_page_count - - return JSONAPI::RelatedResourcesOperationResult.new(:ok, - source_resource, - relationship_type, - related_resources, - opts) + opts[:pagination_params] = if paginator && JSONAPI.configuration.top_level_links_include_pagination + page_options = {} + page_options[:record_count] = opts[:record_count] if paginator.class.requires_record_count + paginator.links_page_params(page_options.merge(fetched_resources: resource_set)) + else + {} + end + + JSONAPI::RelatedResourcesSetOperationResult.new(:ok, + source_resource, + relationship_type, + resource_set, + opts) end def create_resource + include_directives = params[:include_directives] + fields = params[:fields] + serializer = params[:serializer] + data = params[:data] resource = resource_klass.create(context) result = resource.replace_fields(data) - return JSONAPI::ResourceOperationResult.new((result == :completed ? :created : :accepted), resource) + options = { + context: context, + fields: fields, + filters: { resource_klass._primary_key => resource.id }, + include_directives: include_directives + } + + resource_set = find_resource_set(include_directives, options) + + resource_set.populate!(serializer, context, options) + + JSONAPI::ResourceSetOperationResult.new((result == :completed ? :created : :accepted), resource_set, result_options) end def remove_resource @@ -240,17 +254,33 @@ def remove_resource resource = resource_klass.find_by_key(resource_id, context: context) result = resource.remove - return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) + JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def replace_fields resource_id = params[:resource_id] + include_directives = params[:include_directives] + fields = params[:fields] + serializer = params[:serializer] + data = params[:data] resource = resource_klass.find_by_key(resource_id, context: context) + result = resource.replace_fields(data) - return JSONAPI::ResourceOperationResult.new(result == :completed ? :ok : :accepted, resource) + options = { + context: context, + fields: fields, + filters: { resource_klass._primary_key => resource.id }, + include_directives: include_directives + } + + resource_set = find_resource_set(include_directives, options) + + resource_set.populate!(serializer, context, options) + + JSONAPI::ResourceSetOperationResult.new((result == :completed ? :ok : :accepted), resource_set, result_options) end def replace_to_one_relationship @@ -261,7 +291,7 @@ def replace_to_one_relationship resource = resource_klass.find_by_key(resource_id, context: context) result = resource.replace_to_one_link(relationship_type, key_value) - return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) + JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def replace_polymorphic_to_one_relationship @@ -273,7 +303,7 @@ def replace_polymorphic_to_one_relationship resource = resource_klass.find_by_key(resource_id, context: context) result = resource.replace_polymorphic_to_one_link(relationship_type, key_value, key_type) - return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) + JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def create_to_many_relationships @@ -284,7 +314,7 @@ def create_to_many_relationships resource = resource_klass.find_by_key(resource_id, context: context) result = resource.create_to_many_links(relationship_type, data) - return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) + JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def replace_to_many_relationships @@ -295,7 +325,7 @@ def replace_to_many_relationships resource = resource_klass.find_by_key(resource_id, context: context) result = resource.replace_to_many_links(relationship_type, data) - return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) + JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def remove_to_many_relationships @@ -312,7 +342,7 @@ def remove_to_many_relationships complete = false end end - return JSONAPI::OperationResult.new(complete ? :no_content : :accepted) + JSONAPI::OperationResult.new(complete ? :no_content : :accepted, result_options) end def remove_to_one_relationship @@ -322,7 +352,55 @@ def remove_to_one_relationship resource = resource_klass.find_by_key(resource_id, context: context) result = resource.remove_to_one_link(relationship_type) - return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) + JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) + end + + def result_options + options = {} + options[:warnings] = params[:warnings] if params[:warnings] + options + end + + def find_resource_set(include_directives, options) + include_related = include_directives[:include_related] if include_directives + + resource_tree = find_resource_tree(options, include_related) + + JSONAPI::ResourceSet.new(resource_tree) + end + + def find_related_resource_set(resource, relationship_name, include_directives, options) + include_related = include_directives[:include_related] if include_directives + + resource_tree = find_resource_tree_from_relationship(resource, relationship_name, options, include_related) + + JSONAPI::ResourceSet.new(resource_tree) + end + + def find_resource_tree(options, include_related) + options[:cache] = resource_klass.caching? + + fragments = resource_klass.find_fragments(options[:filters], options) + PrimaryResourceTree.new(fragments: fragments, include_related: include_related, options: options) + end + + def find_related_resource_tree(parent_resource, relationship_name, options, include_related) + options = options.except(:include_directives) + options[:cache] = resource_klass.caching? + + fragments = resource_klass.find_included_fragments([parent_resource], relationship_name, options) + PrimaryResourceTree.new(fragments: fragments, include_related: include_related, options: options) + end + + def find_resource_tree_from_relationship(resource, relationship_name, options, include_related) + relationship = resource.class._relationship(relationship_name) + + options = options.except(:include_directives) + options[:cache] = relationship.resource_klass.caching? + + fragments = resource.class.find_related_fragments([resource], relationship_name, options) + + PrimaryResourceTree.new(fragments: fragments, include_related: include_related, options: options) end end end diff --git a/lib/jsonapi/relationship.rb b/lib/jsonapi/relationship.rb index ee0dda3d7..62c17d9c8 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -1,8 +1,15 @@ +# frozen_string_literal: true +require_relative 'compatibility_helper' module JSONAPI class Relationship attr_reader :acts_as_set, :foreign_key, :options, :name, - :class_name, :polymorphic, :always_include_linkage_data, - :parent_resource, :eager_load_on_include + :class_name, :polymorphic, :always_include_optional_linkage_data, + :parent_resource, :eager_load_on_include, :custom_methods, + :inverse_relationship, :allow_include + + attr_writer :allow_include + + attr_accessor :_routed, :_warned_missing_route def initialize(name, options = {}) @name = name.to_s @@ -12,22 +19,66 @@ def initialize(name, options = {}) @parent_resource = options[:parent_resource] @relation_name = options.fetch(:relation_name, @name) @polymorphic = options.fetch(:polymorphic, false) == true - @always_include_linkage_data = options.fetch(:always_include_linkage_data, false) == true + @polymorphic_types = options[:polymorphic_types] + if options[:polymorphic_relations] + JSONAPI::CompatibilityHelper.deprecation_warn('Use polymorphic_types instead of polymorphic_relations') + @polymorphic_types ||= options[:polymorphic_relations] + end + + @always_include_optional_linkage_data = options.fetch(:always_include_optional_linkage_data, false) == true @eager_load_on_include = options.fetch(:eager_load_on_include, true) == true + @allow_include = options[:allow_include] + @class_name = nil + @inverse_relationship = nil + + @_routed = false + @_warned_missing_route = false + + exclude_links(options.fetch(:exclude_links, JSONAPI.configuration.default_exclude_links)) + + # Custom methods are reserved for future use + @custom_methods = options.fetch(:custom_methods, {}) end alias_method :polymorphic?, :polymorphic + alias_method :parent_resource_klass, :parent_resource def primary_key + # :nocov: @primary_key ||= resource_klass._primary_key + # :nocov: end def resource_klass - @resource_klass ||= @parent_resource.resource_for(@class_name) + @resource_klass ||= @parent_resource.resource_klass_for(@class_name) end def table_name + # :nocov: @table_name ||= resource_klass._table_name + # :nocov: + end + + def self.polymorphic_types(name) + @poly_hash ||= {}.tap do |hash| + ObjectSpace.each_object do |klass| + next unless Module === klass + if ActiveRecord::Base > klass + klass.reflect_on_all_associations(:has_many).select{|r| r.options[:as] }.each do |reflection| + (hash[reflection.options[:as]] ||= []) << klass.name.downcase + end + end + end + end + @poly_hash[name.to_sym] + end + + def resource_types + if polymorphic? && belongs_to? + @polymorphic_types ||= self.class.polymorphic_types(@relation_name).collect {|t| t.pluralize} + else + [resource_klass._type.to_s.pluralize] + end end def type @@ -47,17 +98,35 @@ def relation_name(options) end end - def type_for_source(source) - if polymorphic? - resource = source.public_send(name) - resource.class._type if resource - else - type + def belongs_to? + # :nocov: + false + # :nocov: + end + + def readonly? + @options[:readonly] + 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 belongs_to? - false + def _exclude_links + @_exclude_links ||= [] + end + + def exclude_link?(link) + _exclude_links.include?(link.to_sym) end class ToOne < Relationship @@ -68,27 +137,89 @@ def initialize(name, options = {}) @class_name = options.fetch(:class_name, name.to_s.camelize) @foreign_key ||= "#{name}_id".to_sym @foreign_key_on = options.fetch(:foreign_key_on, :self) + if parent_resource + @inverse_relationship = options.fetch(:inverse_relationship, parent_resource._type) + end + end + + def to_s + # :nocov: useful for debugging + "#{parent_resource}.#{name}(#{belongs_to? ? 'BelongsToOne' : 'ToOne'})" + # :nocov: end def belongs_to? + # :nocov: foreign_key_on == :self + # :nocov: end def polymorphic_type "#{name}_type" if polymorphic? end + + def include_optional_linkage_data? + @always_include_optional_linkage_data || JSONAPI::configuration.always_include_to_one_linkage_data + end + + def allow_include?(context = nil) + strategy = if @allow_include.nil? + JSONAPI.configuration.default_allow_include_to_one + else + @allow_include + end + + if !!strategy == strategy #check for boolean + return strategy + elsif strategy.is_a?(Symbol) || strategy.is_a?(String) + parent_resource.send(strategy, context) + else + strategy.call(context) + end + end end class ToMany < Relationship - attr_reader :reflect, :inverse_relationship + attr_reader :reflect def initialize(name, options = {}) super @class_name = options.fetch(:class_name, name.to_s.camelize.singularize) @foreign_key ||= "#{name.to_s.singularize}_ids".to_sym @reflect = options.fetch(:reflect, true) == true - @inverse_relationship = options.fetch(:inverse_relationship, parent_resource._type.to_s.singularize.to_sym) if parent_resource + if parent_resource + @inverse_relationship = options.fetch(:inverse_relationship, parent_resource._type.to_s.singularize.to_sym) + end + end + + def to_s + # :nocov: useful for debugging + "#{parent_resource}.#{name}(ToMany)" + # :nocov: + end + + def include_optional_linkage_data? + # :nocov: + @always_include_optional_linkage_data || JSONAPI::configuration.always_include_to_many_linkage_data + # :nocov: end + + def allow_include?(context = nil) + strategy = if @allow_include.nil? + JSONAPI.configuration.default_allow_include_to_many + else + @allow_include + end + + if !!strategy == strategy #check for boolean + return strategy + elsif strategy.is_a?(Symbol) || strategy.is_a?(String) + parent_resource.send(strategy, context) + else + strategy.call(context) + end + end + end end end diff --git a/lib/jsonapi/relationship_builder.rb b/lib/jsonapi/relationship_builder.rb deleted file mode 100644 index c5774e6f4..000000000 --- a/lib/jsonapi/relationship_builder.rb +++ /dev/null @@ -1,161 +0,0 @@ -module JSONAPI - class RelationshipBuilder - attr_reader :model_class, :options, :relationship_class - delegate :register_relationship, to: :@resource_class - - def initialize(relationship_class, model_class, options) - @relationship_class = relationship_class - @model_class = model_class - @resource_class = options[:parent_resource] - @options = options - end - - def define_relationship_methods(relationship_name) - # Initialize from an ActiveRecord model's properties - if model_class && model_class.ancestors.collect{|ancestor| ancestor.name}.include?('ActiveRecord::Base') - model_association = model_class.reflect_on_association(relationship_name) - if model_association - options[:class_name] ||= model_association.class_name - end - end - - relationship = register_relationship( - relationship_name, - relationship_class.new(relationship_name, options) - ) - - foreign_key = define_foreign_key_setter(relationship.foreign_key) - - case relationship - when JSONAPI::Relationship::ToOne - associated = define_resource_relationship_accessor(:one, relationship_name) - args = [relationship, foreign_key, associated, relationship_name] - - relationship.belongs_to? ? build_belongs_to(*args) : build_has_one(*args) - when JSONAPI::Relationship::ToMany - associated = define_resource_relationship_accessor(:many, relationship_name) - - build_to_many(relationship, foreign_key, associated, relationship_name) - end - end - - def define_foreign_key_setter(foreign_key) - define_on_resource "#{foreign_key}=" do |value| - @model.method("#{foreign_key}=").call(value) - end - foreign_key - end - - def define_resource_relationship_accessor(type, relationship_name) - associated_records_method_name = { - one: "record_for_#{relationship_name}", - many: "records_for_#{relationship_name}" - } - .fetch(type) - - define_on_resource associated_records_method_name do |options = {}| - relationship = self.class._relationships[relationship_name] - relation_name = relationship.relation_name(context: @context) - records = records_for(relation_name) - - resource_klass = relationship.resource_klass - - 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 - - paginator = options[:paginator] - if paginator - records = resource_klass.apply_pagination(records, paginator, order_options) - end - - records - end - - associated_records_method_name - end - - def build_belongs_to(relationship, foreign_key, associated_records_method_name, relationship_name) - # Calls method matching foreign key name on model instance - define_on_resource foreign_key do - @model.method(foreign_key).call - end - - # Returns instantiated related resource object or nil - define_on_resource relationship_name do |options = {}| - relationship = self.class._relationships[relationship_name] - - if relationship.polymorphic? - associated_model = public_send(associated_records_method_name) - resource_klass = self.class.resource_for_model(associated_model) if associated_model - return resource_klass.new(associated_model, @context) if resource_klass - 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 - - def build_has_one(relationship, foreign_key, associated_records_method_name, relationship_name) - # Returns primary key name of related resource class - define_on_resource foreign_key do - relationship = self.class._relationships[relationship_name] - - record = public_send(associated_records_method_name) - return nil if record.nil? - record.public_send(relationship.resource_klass._primary_key) - end - - # Returns instantiated related resource object or nil - define_on_resource relationship_name do |options = {}| - relationship = self.class._relationships[relationship_name] - - 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 - - def build_to_many(relationship, foreign_key, associated_records_method_name, relationship_name) - # Returns array of primary keys of related resource classes - define_on_resource foreign_key do - records = public_send(associated_records_method_name) - return records.collect do |record| - record.public_send(relationship.resource_klass._primary_key) - end - end - - # Returns array of instantiated related resource objects - define_on_resource relationship_name do |options = {}| - relationship = self.class._relationships[relationship_name] - - resource_klass = relationship.resource_klass - records = public_send(associated_records_method_name, options) - - return records.collect do |record| - if relationship.polymorphic? - resource_klass = self.class.resource_for_model(record) - end - resource_klass.new(record, @context) - end - end - end - - def define_on_resource(method_name, &block) - return if @resource_class.method_defined?(method_name) - @resource_class.inject_method_definition(method_name, block) - end - end -end diff --git a/lib/jsonapi/request.rb b/lib/jsonapi/request.rb new file mode 100644 index 000000000..5e80ff96a --- /dev/null +++ b/lib/jsonapi/request.rb @@ -0,0 +1,734 @@ +# frozen_string_literal: true + +module JSONAPI + class Request + attr_accessor :fields, :include, :filters, :sort_criteria, :errors, :controller_module_path, + :context, :paginator, :source_klass, :source_id, + :include_directives, :params, :warnings, :server_error_callbacks, :operations + + def initialize(params = nil, options = {}) + @params = params + if params + controller_path = params.fetch(:controller, '') + @controller_module_path = controller_path.include?('/') ? controller_path.rpartition('/').first + '/' : '' + else + @controller_module_path = '' + end + + @context = options[:context] + @key_formatter = options.fetch(:key_formatter, JSONAPI.configuration.key_formatter) + @errors = [] + @warnings = [] + @server_error_callbacks = options.fetch(:server_error_callbacks, []) + @operations = [] + + setup_operations(params) + end + + def error_object_overrides + {} + end + + def transactional? + case params[:action] + when 'index', 'show_related_resource', 'index_related_resources', 'show', 'show_relationship' + false + else + JSONAPI.configuration.allow_transactions + end + end + + def setup_operations(params) + return if params.nil? + + resource_klass = Resource.resource_klass_for(params[:controller]) if params[:controller] + + 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, resource_klass) + end + rescue ActionController::ParameterMissing => e + @errors.concat(JSONAPI::Exceptions::ParameterMissing.new(e.param, error_object_overrides).errors) + rescue JSONAPI::Exceptions::Error => e + e.error_object_overrides.merge! error_object_overrides + @errors.concat(e.errors) + end + + def setup_index_action(params, resource_klass) + fields = parse_fields(resource_klass, params[:fields]) + include_directives = parse_include_directives(resource_klass, params[:include]) + filters = parse_filters(resource_klass, params[:filter]) + sort_criteria = parse_sort_criteria(resource_klass, params[:sort]) + paginator = parse_pagination(resource_klass, params[:page]) + + @operations << JSONAPI::Operation.new( + :find, + resource_klass, + context: context, + filters: filters, + include_directives: include_directives, + sort_criteria: sort_criteria, + paginator: paginator, + fields: fields + ) + end + + def setup_show_related_resource_action(params, resource_klass) + resolve_singleton_id(params, resource_klass) + source_klass = Resource.resource_klass_for(params.require(:source)) + source_id = source_klass.verify_key(params.require(source_klass._as_parent_key), @context) + + fields = parse_fields(resource_klass, params[:fields]) + include_directives = parse_include_directives(resource_klass, params[:include]) + + relationship_type = params[:relationship].to_sym + + @operations << JSONAPI::Operation.new( + :show_related_resource, + resource_klass, + context: @context, + relationship_type: relationship_type, + source_klass: source_klass, + source_id: source_id, + fields: fields, + include_directives: include_directives + ) + end + + def setup_index_related_resources_action(params, resource_klass) + resolve_singleton_id(params, resource_klass) + source_klass = Resource.resource_klass_for(params.require(:source)) + source_id = source_klass.verify_key(params.require(source_klass._as_parent_key), @context) + + fields = parse_fields(resource_klass, params[:fields]) + include_directives = parse_include_directives(resource_klass, params[:include]) + filters = parse_filters(resource_klass, params[:filter]) + sort_criteria = parse_sort_criteria(resource_klass, params[:sort]) + paginator = parse_pagination(resource_klass, params[:page]) + relationship_type = params[:relationship] + + @operations << JSONAPI::Operation.new( + :show_related_resources, + resource_klass, + context: @context, + relationship_type: relationship_type, + source_klass: source_klass, + source_id: source_id, + filters: filters, + sort_criteria: sort_criteria, + paginator: paginator, + fields: fields, + include_directives: include_directives + ) + end + + def setup_show_action(params, resource_klass) + resolve_singleton_id(params, resource_klass) + fields = parse_fields(resource_klass, params[:fields]) + include_directives = parse_include_directives(resource_klass, params[:include]) + id = params[:id] + + @operations << JSONAPI::Operation.new( + :show, + resource_klass, + context: @context, + id: id, + include_directives: include_directives, + fields: fields, + allowed_resources: params[:allowed_resources] + ) + end + + def setup_show_relationship_action(params, resource_klass) + resolve_singleton_id(params, resource_klass) + relationship_type = params[:relationship] + parent_key = params.require(resource_klass._as_parent_key) + include_directives = parse_include_directives(resource_klass, params[:include]) + filters = parse_filters(resource_klass, params[:filter]) + sort_criteria = parse_sort_criteria(resource_klass, params[:sort]) + paginator = parse_pagination(resource_klass, params[:page]) + + @operations << JSONAPI::Operation.new( + :show_relationship, + resource_klass, + context: @context, + relationship_type: relationship_type, + parent_key: resource_klass.verify_key(parent_key), + filters: filters, + sort_criteria: sort_criteria, + paginator: paginator, + fields: fields, + include_directives: include_directives + ) + end + + def setup_create_action(params, resource_klass) + fields = parse_fields(resource_klass, params[:fields]) + include_directives = parse_include_directives(resource_klass, params[:include]) + + data = params.require(:data) + + unless data.respond_to?(:each_pair) + fail JSONAPI::Exceptions::InvalidDataFormat.new(error_object_overrides) + end + + verify_type(data[:type], resource_klass) + + data = parse_params(resource_klass, data, resource_klass.creatable_fields(@context)) + + @operations << JSONAPI::Operation.new( + :create_resource, + resource_klass, + context: @context, + data: data, + fields: fields, + include_directives: include_directives, + warnings: @warnings + ) + end + + def setup_create_relationship_action(params, resource_klass) + resolve_singleton_id(params, resource_klass) + parse_modify_relationship_action(:add, params, resource_klass) + end + + def setup_update_relationship_action(params, resource_klass) + parse_modify_relationship_action(:update, params, resource_klass) + end + + def setup_update_action(params, resource_klass) + resolve_singleton_id(params, resource_klass) + fields = parse_fields(resource_klass, params[:fields]) + include_directives = parse_include_directives(resource_klass, params[:include]) + + data = params.require(:data) + key = params[:id] + + fail JSONAPI::Exceptions::InvalidDataFormat.new(error_object_overrides) unless data.respond_to?(:each_pair) + + fail JSONAPI::Exceptions::MissingKey.new(error_object_overrides) if data[:id].nil? + + resource_id = data.require(:id) + # Singleton resources may not have the ID set in the URL + if key + fail JSONAPI::Exceptions::KeyNotIncludedInURL.new(resource_id) if key.to_s != resource_id.to_s + end + + data.delete(:id) + + verify_type(data[:type], resource_klass) + + @operations << JSONAPI::Operation.new( + :replace_fields, + resource_klass, + context: @context, + resource_id: resource_id, + data: parse_params(resource_klass, data, resource_klass.updatable_fields(@context)), + fields: fields, + include_directives: include_directives, + warnings: @warnings + ) + end + + def setup_destroy_action(params, resource_klass) + resolve_singleton_id(params, resource_klass) + @operations << JSONAPI::Operation.new( + :remove_resource, + resource_klass, + context: @context, + resource_id: resource_klass.verify_key(params.require(:id), @context)) + end + + def setup_destroy_relationship_action(params, resource_klass) + resolve_singleton_id(params, resource_klass) + parse_modify_relationship_action(:remove, params, resource_klass) + end + + def parse_modify_relationship_action(modification_type, params, resource_klass) + relationship_type = params.require(:relationship) + + parent_key = params.require(resource_klass._as_parent_key) + relationship = resource_klass._relationship(relationship_type) + + # Removals of to-one relationships are done implicitly and require no specification of data + data_required = !(modification_type == :remove && relationship.is_a?(JSONAPI::Relationship::ToOne)) + + if data_required + data = params.fetch(:data) + object_params = { relationships: { format_key(relationship.name) => { data: data } } } + + verified_params = parse_params(resource_klass, object_params, resource_klass.updatable_fields(@context)) + + parse_arguments = [resource_klass, verified_params, relationship, parent_key] + else + parse_arguments = [resource_klass, params, relationship, parent_key] + end + + send(:"parse_#{modification_type}_relationship_operation", *parse_arguments) + end + + def parse_pagination(resource_klass, page) + paginator_name = resource_klass._paginator + JSONAPI::Paginator.paginator_for(paginator_name).new(page) unless paginator_name == :none + end + + def parse_fields(resource_klass, fields) + extracted_fields = {} + + return extracted_fields if fields.nil? + + # Extract the fields for each type from the fields parameters + if fields.is_a?(ActionController::Parameters) + fields.each do |field, value| + if value.is_a?(Array) + resource_fields = value + else + resource_fields = value.split(',') unless value.nil? || value.empty? + end + extracted_fields[field] = resource_fields + end + else + fail JSONAPI::Exceptions::InvalidFieldFormat.new(error_object_overrides) + end + + # Validate the fields + validated_fields = {} + extracted_fields.each do |type, values| + underscored_type = unformat_key(type) + validated_fields[type] = [] + begin + if type != format_key(type) + fail JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides) + end + type_resource = Resource.resource_klass_for(resource_klass.module_path + underscored_type.to_s) + rescue NameError + fail JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides) + end + + if type_resource.nil? + fail JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides) + else + unless values.nil? + valid_fields = type_resource.fields.collect { |key| format_key(key) } + values.each do |field| + if valid_fields.include?(field) + validated_fields[type].push unformat_key(field) + else + fail JSONAPI::Exceptions::InvalidField.new(type, field, error_object_overrides) + end + end + else + fail JSONAPI::Exceptions::InvalidField.new(type, 'nil', error_object_overrides) + end + end + end + + validated_fields.deep_transform_keys { |key| unformat_key(key) } + end + + def check_include(resource_klass, include_parts) + relationship_name = unformat_key(include_parts.first) + + relationship = resource_klass._relationship(relationship_name) + if relationship && format_key(relationship_name) == include_parts.first + unless relationship.allow_include?(context) + fail JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), include_parts.first) + end + + unless include_parts.last.empty? + check_include(Resource.resource_klass_for(resource_klass.module_path + relationship.class_name.to_s.underscore), + include_parts.last.partition('.')) + end + else + fail JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), include_parts.first) + end + end + + def parse_include_directives(resource_klass, raw_include) + raw_include ||= '' + + included_resources = [] + begin + included_resources += raw_include.is_a?(Array) ? raw_include : CSV.parse_line(raw_include) || [] + rescue CSV::MalformedCSVError + fail JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), raw_include) + end + + begin + result = included_resources.compact.map do |included_resource| + check_include(resource_klass, included_resource.partition('.')) + unformat_key(included_resource).to_s + end + + return JSONAPI::IncludeDirectives.new(resource_klass, result) + rescue JSONAPI::Exceptions::InvalidInclude => e + @errors.concat(e.errors) + return {} + end + end + + def parse_filters(resource_klass, filters) + parsed_filters = {} + + # apply default filters + resource_klass._allowed_filters.each do |filter, opts| + next if opts[:default].nil? || !parsed_filters[filter].nil? + parsed_filters[filter] = opts[:default] + end + + return parsed_filters unless filters + + unless filters.class.method_defined?(:each) + @errors.concat(JSONAPI::Exceptions::InvalidFiltersSyntax.new(filters).errors) + return {} + end + + unless JSONAPI.configuration.allow_filter + fail JSONAPI::Exceptions::ParameterNotAllowed.new(:filter) + end + + filters.each do |key, value| + filter = unformat_key(key) + if resource_klass._allowed_filter?(filter) + parsed_filters[filter] = value + else + fail JSONAPI::Exceptions::FilterNotAllowed.new(key) + end + end + + parsed_filters + end + + def parse_sort_criteria(resource_klass, sort_criteria) + return unless sort_criteria.present? + + unless JSONAPI.configuration.allow_sort + fail JSONAPI::Exceptions::ParameterNotAllowed.new(:sort) + end + + if sort_criteria.is_a?(Array) + sorts = sort_criteria + elsif sort_criteria.is_a?(String) + begin + raw = URI.decode_www_form_component(sort_criteria) + sorts = CSV.parse_line(raw) + rescue CSV::MalformedCSVError + fail JSONAPI::Exceptions::InvalidSortCriteria.new(format_key(resource_klass._type), raw) + end + end + + @sort_criteria = sorts.collect do |sort| + if sort.start_with?('-') + criteria = { field: unformat_key(sort[1..-1]).to_s } + criteria[:direction] = :desc + else + criteria = { field: unformat_key(sort).to_s } + criteria[:direction] = :asc + end + + check_sort_criteria(resource_klass, criteria) + criteria + end + end + + def check_sort_criteria(resource_klass, sort_criteria) + sort_field = sort_criteria[:field] + + unless resource_klass.sortable_field?(sort_field.to_sym, context) + fail JSONAPI::Exceptions::InvalidSortCriteria.new(format_key(resource_klass._type), sort_field) + end + end + + def verify_type(type, resource_klass) + if type.nil? + fail JSONAPI::Exceptions::ParameterMissing.new(:type) + elsif unformat_key(type).to_sym != resource_klass._type + fail JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides) + end + end + + def parse_to_one_links_object(raw) + if raw.nil? + return { + type: nil, + id: nil + } + end + + if !(raw.is_a?(Hash) || raw.is_a?(ActionController::Parameters)) || + raw.keys.length != 2 || !(raw.key?('type') && raw.key?('id')) + fail JSONAPI::Exceptions::InvalidLinksObject.new(error_object_overrides) + end + + { + type: unformat_key(raw['type']).to_s, + id: raw['id'] + } + end + + def parse_to_many_links_object(raw) + fail JSONAPI::Exceptions::InvalidLinksObject.new(error_object_overrides) if raw.nil? + + links_object = {} + if raw.is_a?(Array) + raw.each do |link| + link_object = parse_to_one_links_object(link) + links_object[link_object[:type]] ||= [] + links_object[link_object[:type]].push(link_object[:id]) + end + else + fail JSONAPI::Exceptions::InvalidLinksObject.new(error_object_overrides) + end + links_object + end + + def parse_params(resource_klass, params, allowed_fields) + verify_permitted_params(params, allowed_fields) + + checked_attributes = {} + checked_to_one_relationships = {} + checked_to_many_relationships = {} + + params.each do |key, value| + case key.to_s + when 'relationships' + value.each do |link_key, link_value| + param = unformat_key(link_key) + relationship = resource_klass._relationship(param) + + if relationship.is_a?(JSONAPI::Relationship::ToOne) + checked_to_one_relationships[param] = parse_to_one_relationship(resource_klass, link_value, relationship) + elsif relationship.is_a?(JSONAPI::Relationship::ToMany) + parse_to_many_relationship(resource_klass, link_value, relationship) do |result_val| + checked_to_many_relationships[param] = result_val + end + end + end + when 'id' + checked_attributes['id'] = unformat_value(resource_klass, :id, value) + when 'attributes' + value.each do |key, value| + param = unformat_key(key) + checked_attributes[param] = unformat_value(resource_klass, param, value) + end + end + end + + { + 'attributes' => checked_attributes, + 'to_one' => checked_to_one_relationships, + 'to_many' => checked_to_many_relationships + }.deep_transform_keys { |key| unformat_key(key) } + end + + def parse_to_one_relationship(resource_klass, link_value, relationship) + if link_value.nil? + linkage = nil + else + linkage = link_value[:data] + end + + links_object = parse_to_one_links_object(linkage) + if !relationship.polymorphic? && links_object[:type] && (links_object[:type].to_s != relationship.type.to_s) + fail JSONAPI::Exceptions::TypeMismatch.new(links_object[:type], error_object_overrides) + end + + unless links_object[:id].nil? + resource = resource_klass || Resource + relationship_resource = resource.resource_klass_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) } + else + relationship_id + end + else + nil + end + end + + def parse_to_many_relationship(resource_klass, link_value, relationship, &add_result) + if (link_value.is_a?(Hash) || link_value.is_a?(ActionController::Parameters)) + linkage = link_value[:data] + else + fail JSONAPI::Exceptions::InvalidLinksObject.new(error_object_overrides) + end + + links_object = parse_to_many_links_object(linkage) + + if links_object.length == 0 + add_result.call([]) + else + if relationship.polymorphic? + polymorphic_results = [] + + links_object.each_pair do |type, keys| + type_name = unformat_key(type).to_s + + relationship_resource_klass = resource_klass.resource_klass_for(relationship.class_name) + relationship_klass = relationship_resource_klass._model_class + + linkage_object_resource_klass = resource_klass.resource_klass_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 + + if links_object.length > 1 || !links_object.has_key?(relationship_type) + fail JSONAPI::Exceptions::TypeMismatch.new(links_object[:type]) + end + + relationship_resource_klass = Resource.resource_klass_for(resource_klass.module_path + relationship_type) + add_result.call relationship_resource_klass.verify_keys(links_object[relationship_type], @context) + end + end + end + + def unformat_value(resource_klass, attribute, value) + value_formatter = JSONAPI::ValueFormatter.value_formatter_for(resource_klass._attribute_options(attribute)[:format]) + value_formatter.unformat(value) + end + + def verify_permitted_params(params, allowed_fields) + formatted_allowed_fields = allowed_fields.collect { |field| format_key(field).to_sym } + params_not_allowed = [] + + params.each do |key, value| + case key.to_s + when 'relationships' + value.keys.each do |links_key| + unless formatted_allowed_fields.include?(links_key.to_sym) + if JSONAPI.configuration.raise_if_parameters_not_allowed + fail JSONAPI::Exceptions::ParameterNotAllowed.new(links_key, error_object_overrides) + else + params_not_allowed.push(links_key) + value.delete links_key + end + end + end + when 'attributes' + value.each do |attr_key, _attr_value| + unless formatted_allowed_fields.include?(attr_key.to_sym) + if JSONAPI.configuration.raise_if_parameters_not_allowed + fail JSONAPI::Exceptions::ParameterNotAllowed.new(attr_key, error_object_overrides) + else + params_not_allowed.push(attr_key) + value.delete attr_key + end + end + end + when 'type' + when 'id' + unless formatted_allowed_fields.include?(:id) + if JSONAPI.configuration.raise_if_parameters_not_allowed + fail JSONAPI::Exceptions::ParameterNotAllowed.new(:id, error_object_overrides) + else + params_not_allowed.push(:id) + params.delete :id + end + end + else + if JSONAPI.configuration.raise_if_parameters_not_allowed + fail JSONAPI::Exceptions::ParameterNotAllowed.new(key, error_object_overrides) + else + params_not_allowed.push(key) + params.delete key + end + end + end + + if params_not_allowed.length > 0 + 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 + + def parse_add_relationship_operation(resource_klass, verified_params, relationship, parent_key) + if relationship.is_a?(JSONAPI::Relationship::ToMany) + @operations << JSONAPI::Operation.new( + :create_to_many_relationships, + resource_klass, + context: @context, + resource_id: parent_key, + relationship_type: relationship.name, + data: verified_params[:to_many].values[0] + ) + end + end + + def parse_update_relationship_operation(resource_klass, verified_params, relationship, parent_key) + options = { + context: @context, + resource_id: parent_key, + relationship_type: relationship.name + } + + if relationship.is_a?(JSONAPI::Relationship::ToOne) + if relationship.polymorphic? + options[:key_value] = verified_params[:to_one].values[0][:id] + options[:key_type] = verified_params[:to_one].values[0][:type] + + operation_type = :replace_polymorphic_to_one_relationship + else + options[:key_value] = verified_params[:to_one].values[0] + operation_type = :replace_to_one_relationship + end + elsif relationship.is_a?(JSONAPI::Relationship::ToMany) + unless relationship.acts_as_set + fail JSONAPI::Exceptions::ToManySetReplacementForbidden.new + end + options[:data] = verified_params[:to_many].values[0] + operation_type = :replace_to_many_relationships + end + + @operations << JSONAPI::Operation.new(operation_type, resource_klass, options) + end + + def parse_remove_relationship_operation(resource_klass, params, relationship, parent_key) + operation_base_args = [resource_klass].push( + context: @context, + resource_id: parent_key, + relationship_type: relationship.name + ) + + if relationship.is_a?(JSONAPI::Relationship::ToMany) + operation_args = operation_base_args.dup + keys = params[:to_many].values[0] + operation_args[1] = operation_args[1].merge(associated_keys: keys) + @operations << JSONAPI::Operation.new(:remove_to_many_relationships, *operation_args) + else + @operations << JSONAPI::Operation.new(:remove_to_one_relationship, *operation_base_args) + end + end + + def resolve_singleton_id(params, resource_klass) + 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 + + def unformat_key(key) + unformatted_key = @key_formatter.unformat(key) + unformatted_key.nil? ? nil : unformatted_key.to_sym + end + end +end diff --git a/lib/jsonapi/request_parser.rb b/lib/jsonapi/request_parser.rb deleted file mode 100644 index eb0dbf1ee..000000000 --- a/lib/jsonapi/request_parser.rb +++ /dev/null @@ -1,664 +0,0 @@ -require 'jsonapi/operation' -require 'jsonapi/paginator' - -module JSONAPI - class RequestParser - attr_accessor :fields, :include, :filters, :sort_criteria, :errors, :operations, - :resource_klass, :context, :paginator, :source_klass, :source_id, - :include_directives, :params, :warnings, :server_error_callbacks - - def initialize(params = nil, options = {}) - @params = params - @context = options[:context] - @key_formatter = options.fetch(:key_formatter, JSONAPI.configuration.key_formatter) - @errors = [] - @warnings = [] - @operations = [] - @fields = {} - @filters = {} - @sort_criteria = nil - @source_klass = nil - @source_id = nil - @include_directives = nil - @paginator = nil - @id = nil - @server_error_callbacks = options.fetch(:server_error_callbacks, []) - - setup_action(@params) - end - - def setup_action(params) - return if params.nil? - - @resource_klass ||= Resource.resource_for(params[:controller]) if params[:controller] - - setup_action_method_name = "setup_#{params[:action]}_action" - if respond_to?(setup_action_method_name) - send(setup_action_method_name, params) - end - rescue ActionController::ParameterMissing => e - @errors.concat(JSONAPI::Exceptions::ParameterMissing.new(e.param).errors) - end - - def setup_index_action(params) - parse_fields(params[:fields]) - parse_include_directives(params[:include]) - set_default_filters - parse_filters(params[:filter]) - parse_sort_criteria(params[:sort]) - parse_pagination(params[:page]) - add_find_operation - end - - def setup_get_related_resource_action(params) - initialize_source(params) - parse_fields(params[:fields]) - parse_include_directives(params[:include]) - set_default_filters - parse_filters(params[:filter]) - parse_sort_criteria(params[:sort]) - parse_pagination(params[:page]) - add_show_related_resource_operation(params[:relationship]) - end - - def setup_get_related_resources_action(params) - initialize_source(params) - parse_fields(params[:fields]) - parse_include_directives(params[:include]) - set_default_filters - parse_filters(params[:filter]) - parse_sort_criteria(params[:sort]) - parse_pagination(params[:page]) - add_show_related_resources_operation(params[:relationship]) - end - - def setup_show_action(params) - parse_fields(params[:fields]) - parse_include_directives(params[:include]) - @id = params[:id] - add_show_operation - end - - def setup_show_relationship_action(params) - add_show_relationship_operation(params[:relationship], params.require(@resource_klass._as_parent_key)) - end - - def setup_create_action(params) - parse_fields(params[:fields]) - parse_include_directives(params[:include]) - parse_add_operation(params.require(:data)) - end - - def setup_create_relationship_action(params) - parse_modify_relationship_action(params, :add) - end - - def setup_update_relationship_action(params) - parse_modify_relationship_action(params, :update) - end - - def setup_update_action(params) - parse_fields(params[:fields]) - parse_include_directives(params[:include]) - parse_replace_operation(params.require(:data), params[:id]) - end - - def setup_destroy_action(params) - parse_remove_operation(params) - end - - def setup_destroy_relationship_action(params) - parse_modify_relationship_action(params, :remove) - end - - def parse_modify_relationship_action(params, modification_type) - relationship_type = params.require(:relationship) - parent_key = params.require(@resource_klass._as_parent_key) - relationship = @resource_klass._relationship(relationship_type) - - # Removals of to-one relationships are done implicitly and require no specification of data - data_required = !(modification_type == :remove && relationship.is_a?(JSONAPI::Relationship::ToOne)) - - if data_required - data = params.fetch(:data) - object_params = { relationships: { format_key(relationship.name) => { data: data } } } - verified_params = parse_params(object_params, @resource_klass.updatable_fields(@context)) - - parse_arguments = [verified_params, relationship, parent_key] - else - parse_arguments = [params, relationship, parent_key] - end - - send(:"parse_#{modification_type}_relationship_operation", *parse_arguments) - end - - def initialize_source(params) - @source_klass = Resource.resource_for(params.require(:source)) - @source_id = @source_klass.verify_key(params.require(@source_klass._as_parent_key), @context) - end - - def parse_pagination(page) - paginator_name = @resource_klass._paginator - @paginator = JSONAPI::Paginator.paginator_for(paginator_name).new(page) unless paginator_name == :none - rescue JSONAPI::Exceptions::Error => e - @errors.concat(e.errors) - end - - def parse_fields(fields) - return if fields.nil? - - extracted_fields = {} - - # Extract the fields for each type from the fields parameters - if fields.is_a?(ActionController::Parameters) - fields.each do |field, value| - resource_fields = value.split(',') unless value.nil? || value.empty? - extracted_fields[field] = resource_fields - end - else - fail JSONAPI::Exceptions::InvalidFieldFormat.new - end - - # Validate the fields - extracted_fields.each do |type, values| - underscored_type = unformat_key(type) - extracted_fields[type] = [] - begin - if type != format_key(type) - fail JSONAPI::Exceptions::InvalidResource.new(type) - 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) - end - - if type_resource.nil? - @errors.concat(JSONAPI::Exceptions::InvalidResource.new(type).errors) - else - unless values.nil? - valid_fields = type_resource.fields.collect { |key| format_key(key) } - values.each do |field| - if valid_fields.include?(field) - extracted_fields[type].push unformat_key(field) - else - @errors.concat(JSONAPI::Exceptions::InvalidField.new(type, field).errors) - end - end - else - @errors.concat(JSONAPI::Exceptions::InvalidField.new(type, 'nil').errors) - end - end - end - - @fields = extracted_fields.deep_transform_keys { |key| unformat_key(key) } - end - - def check_include(resource_klass, include_parts) - relationship_name = unformat_key(include_parts.first) - - 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('.')) - end - else - @errors.concat(JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), - include_parts.first).errors) - end - end - - def parse_include_directives(include) - return if include.nil? - - unless JSONAPI.configuration.allow_include - fail JSONAPI::Exceptions::ParametersNotAllowed.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) - end - - @include_directives = JSONAPI::IncludeDirectives.new(@resource_klass, include) - end - - def parse_filters(filters) - return unless filters - - unless JSONAPI.configuration.allow_filter - fail JSONAPI::Exceptions::ParametersNotAllowed.new([:filter]) - end - - unless filters.class.method_defined?(:each) - @errors.concat(JSONAPI::Exceptions::InvalidFiltersSyntax.new(filters).errors) - return - end - - filters.each do |key, value| - filter = unformat_key(key) - if @resource_klass._allowed_filter?(filter) - @filters[filter] = value - else - @errors.concat(JSONAPI::Exceptions::FilterNotAllowed.new(filter).errors) - end - end - end - - def set_default_filters - @resource_klass._allowed_filters.each do |filter, opts| - next if opts[:default].nil? || !@filters[filter].nil? - @filters[filter] = opts[:default] - end - end - - def parse_sort_criteria(sort_criteria) - return unless sort_criteria.present? - - unless JSONAPI.configuration.allow_sort - fail JSONAPI::Exceptions::ParametersNotAllowed.new([:sort]) - end - - @sort_criteria = CSV.parse_line(URI.unescape(sort_criteria)).collect do |sort| - if sort.start_with?('-') - sort_criteria = { field: unformat_key(sort[1..-1]).to_s } - sort_criteria[:direction] = :desc - else - sort_criteria = { field: unformat_key(sort).to_s } - sort_criteria[:direction] = :asc - end - - check_sort_criteria(@resource_klass, sort_criteria) - sort_criteria - end - end - - 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) - end - end - - def add_find_operation - @operations.push JSONAPI::Operation.new(:find, - @resource_klass, - context: @context, - filters: @filters, - include_directives: @include_directives, - sort_criteria: @sort_criteria, - paginator: @paginator, - fields: @fields - ) - end - - def add_show_operation - @operations.push JSONAPI::Operation.new(:show, - @resource_klass, - context: @context, - id: @id, - include_directives: @include_directives, - fields: @fields - ) - end - - def add_show_relationship_operation(relationship_type, parent_key) - @operations.push JSONAPI::Operation.new(:show_relationship, - @resource_klass, - context: @context, - relationship_type: relationship_type, - parent_key: @resource_klass.verify_key(parent_key) - ) - end - - def add_show_related_resource_operation(relationship_type) - @operations.push JSONAPI::Operation.new(:show_related_resource, - @resource_klass, - context: @context, - relationship_type: relationship_type, - source_klass: @source_klass, - source_id: @source_id, - fields: @fields, - include_directives: @include_directives - ) - end - - def add_show_related_resources_operation(relationship_type) - @operations.push JSONAPI::Operation.new(:show_related_resources, - @resource_klass, - context: @context, - relationship_type: relationship_type, - source_klass: @source_klass, - source_id: @source_id, - filters: @source_klass.verify_filters(@filters, @context), - sort_criteria: @sort_criteria, - paginator: @paginator, - fields: @fields, - include_directives: @include_directives - ) - end - - def parse_add_operation(params) - fail JSONAPI::Exceptions::InvalidDataFormat unless params.respond_to?(:each_pair) - - verify_type(params[:type]) - - data = parse_params(params, @resource_klass.creatable_fields(@context)) - @operations.push JSONAPI::Operation.new(:create_resource, - @resource_klass, - context: @context, - data: data, - fields: @fields, - include_directives: @include_directives - ) - rescue JSONAPI::Exceptions::Error => e - @errors.concat(e.errors) - end - - def verify_type(type) - if type.nil? - fail JSONAPI::Exceptions::ParameterMissing.new(:type) - elsif unformat_key(type).to_sym != @resource_klass._type - fail JSONAPI::Exceptions::InvalidResource.new(type) - end - end - - def parse_to_one_links_object(raw) - if raw.nil? - return { - type: nil, - id: nil - } - end - - if !(raw.is_a?(Hash) || raw.is_a?(ActionController::Parameters)) || - raw.keys.length != 2 || !(raw.key?('type') && raw.key?('id')) - fail JSONAPI::Exceptions::InvalidLinksObject.new - end - - { - type: unformat_key(raw['type']).to_s, - id: raw['id'] - } - end - - def parse_to_many_links_object(raw) - fail JSONAPI::Exceptions::InvalidLinksObject.new if raw.nil? - - links_object = {} - if raw.is_a?(Array) - raw.each do |link| - link_object = parse_to_one_links_object(link) - links_object[link_object[:type]] ||= [] - links_object[link_object[:type]].push(link_object[:id]) - end - else - fail JSONAPI::Exceptions::InvalidLinksObject.new - end - links_object - end - - def parse_params(params, allowed_fields) - verify_permitted_params(params, allowed_fields) - - checked_attributes = {} - checked_to_one_relationships = {} - checked_to_many_relationships = {} - - params.each do |key, value| - case key.to_s - when 'relationships' - value.each do |link_key, link_value| - param = unformat_key(link_key) - relationship = @resource_klass._relationship(param) - - if relationship.is_a?(JSONAPI::Relationship::ToOne) - checked_to_one_relationships[param] = parse_to_one_relationship(link_value, relationship) - elsif relationship.is_a?(JSONAPI::Relationship::ToMany) - parse_to_many_relationship(link_value, relationship) do |result_val| - checked_to_many_relationships[param] = result_val - end - end - end - when 'id' - checked_attributes['id'] = unformat_value(:id, value) - when 'attributes' - value.each do |key, value| - param = unformat_key(key) - checked_attributes[param] = unformat_value(param, value) - end - end - end - - return { - 'attributes' => checked_attributes, - 'to_one' => checked_to_one_relationships, - 'to_many' => checked_to_many_relationships - }.deep_transform_keys { |key| unformat_key(key) } - end - - def parse_to_one_relationship(link_value, relationship) - if link_value.nil? - linkage = nil - else - linkage = link_value[:data] - end - - links_object = parse_to_one_links_object(linkage) - if !relationship.polymorphic? && links_object[:type] && (links_object[:type].to_s != relationship.type.to_s) - fail JSONAPI::Exceptions::TypeMismatch.new(links_object[:type]) - end - - unless links_object[:id].nil? - resource = self.resource_klass || Resource - relationship_resource = resource.resource_for(unformat_key(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) } - else - relationship_id - end - else - nil - end - end - - def parse_to_many_relationship(link_value, relationship, &add_result) - if link_value.is_a?(Array) && link_value.length == 0 - linkage = [] - elsif (link_value.is_a?(Hash) || link_value.is_a?(ActionController::Parameters)) - linkage = link_value[:data] - else - fail JSONAPI::Exceptions::InvalidLinksObject.new - end - - 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 - - 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) - end - end - end - - def unformat_value(attribute, value) - value_formatter = JSONAPI::ValueFormatter.value_formatter_for(@resource_klass._attribute_options(attribute)[:format]) - value_formatter.unformat(value) - end - - def verify_permitted_params(params, allowed_fields) - formatted_allowed_fields = allowed_fields.collect { |field| format_key(field).to_sym } - params_not_allowed = [] - - params.each do |key, value| - case key.to_s - 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 - value.delete links_key - end - end - end - 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 - value.delete attr_key - end - end - end - when 'type' - when 'id' - unless formatted_allowed_fields.include?(:id) - params_not_allowed.push(:id) - unless JSONAPI.configuration.raise_if_parameters_not_allowed - params.delete :id - end - end - else - params_not_allowed.push(key) - 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) - end - end - end - - def parse_add_relationship_operation(verified_params, relationship, parent_key) - if relationship.is_a?(JSONAPI::Relationship::ToMany) - @operations.push JSONAPI::Operation.new(:create_to_many_relationships, - resource_klass, - context: @context, - resource_id: parent_key, - relationship_type: relationship.name, - data: verified_params[:to_many].values[0] - ) - end - end - - def parse_update_relationship_operation(verified_params, relationship, parent_key) - options = { - context: @context, - resource_id: parent_key, - relationship_type: relationship.name - } - - if relationship.is_a?(JSONAPI::Relationship::ToOne) - if relationship.polymorphic? - options[:key_value] = verified_params[:to_one].values[0][:id] - options[:key_type] = verified_params[:to_one].values[0][:type] - - operation_type = :replace_polymorphic_to_one_relationship - else - options[:key_value] = verified_params[:to_one].values[0] - operation_type = :replace_to_one_relationship - end - elsif relationship.is_a?(JSONAPI::Relationship::ToMany) - unless relationship.acts_as_set - fail JSONAPI::Exceptions::ToManySetReplacementForbidden.new - end - options[:data] = verified_params[:to_many].values[0] - operation_type = :replace_to_many_relationships - end - - @operations.push JSONAPI::Operation.new(operation_type, resource_klass, options) - end - - def parse_single_replace_operation(data, keys, id_key_presence_check_required: true) - fail JSONAPI::Exceptions::InvalidDataFormat unless data.respond_to?(:each_pair) - - fail JSONAPI::Exceptions::MissingKey.new if data[:id].nil? - - key = data[:id].to_s - if id_key_presence_check_required && !keys.include?(key) - fail JSONAPI::Exceptions::KeyNotIncludedInURL.new(key) - end - - data.delete(:id) unless keys.include?(:id) - - verify_type(data[:type]) - - @operations.push JSONAPI::Operation.new(:replace_fields, - @resource_klass, - context: @context, - resource_id: key, - data: parse_params(data, @resource_klass.updatable_fields(@context)), - fields: @fields, - include_directives: @include_directives - ) - end - - def parse_replace_operation(data, keys) - parse_single_replace_operation(data, [keys], id_key_presence_check_required: keys.present?) - rescue JSONAPI::Exceptions::Error => e - @errors.concat(e.errors) - end - - def parse_remove_operation(params) - @operations.push JSONAPI::Operation.new(:remove_resource, - @resource_klass, - context: @context, - resource_id: @resource_klass.verify_key(params.require(:id), context)) - rescue JSONAPI::Exceptions::Error => e - @errors.concat(e.errors) - end - - def parse_remove_relationship_operation(params, relationship, parent_key) - operation_base_args = [resource_klass].push( - context: @context, - resource_id: parent_key, - relationship_type: relationship.name - ) - - if relationship.is_a?(JSONAPI::Relationship::ToMany) - operation_args = operation_base_args.dup - keys = params[:to_many].values[0] - operation_args[1] = operation_args[1].merge(associated_keys: keys) - @operations.push JSONAPI::Operation.new(:remove_to_many_relationships, *operation_args) - else - @operations.push JSONAPI::Operation.new(:remove_to_one_relationship, *operation_base_args) - end - end - - def format_key(key) - @key_formatter.format(key) - end - - def unformat_key(key) - unformatted_key = @key_formatter.unformat(key) - unformatted_key.nil? ? nil : unformatted_key.to_sym - end - end -end diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index 14c34d417..421b46ea2 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -1,1222 +1,7 @@ -require 'jsonapi/callbacks' -require 'jsonapi/relationship_builder' +# frozen_string_literal: true module JSONAPI - class Resource - include Callbacks - - attr_reader :context - - define_jsonapi_resources_callbacks :create, - :update, - :remove, - :save, - :create_to_many_link, - :replace_to_many_links, - :create_to_one_link, - :replace_to_one_link, - :replace_polymorphic_to_one_link, - :remove_to_many_link, - :remove_to_one_link, - :replace_fields - - def initialize(model, context) - @model = model - @context = context - @reload_needed = false - @changing = false - @save_needed = false - end - - def _model - @model - end - - def id - _model.public_send(self.class._primary_key) - end - - def cache_id - [id, _model.public_send(self.class._cache_field)] - end - - def is_new? - id.nil? - end - - def change(callback) - completed = false - - if @changing - run_callbacks callback do - completed = (yield == :completed) - end - else - run_callbacks is_new? ? :create : :update do - @changing = true - run_callbacks callback do - completed = (yield == :completed) - end - - completed = (save == :completed) if @save_needed || is_new? - end - end - - return completed ? :completed : :accepted - end - - def remove - run_callbacks :remove do - _remove - end - end - - def create_to_many_links(relationship_type, relationship_key_values, options = {}) - change :create_to_many_link do - _create_to_many_links(relationship_type, relationship_key_values, options) - end - end - - def replace_to_many_links(relationship_type, relationship_key_values, options = {}) - change :replace_to_many_links do - _replace_to_many_links(relationship_type, relationship_key_values, options) - end - end - - def replace_to_one_link(relationship_type, relationship_key_value, options = {}) - change :replace_to_one_link do - _replace_to_one_link(relationship_type, relationship_key_value, options) - end - end - - def replace_polymorphic_to_one_link(relationship_type, relationship_key_value, relationship_key_type, options = {}) - change :replace_polymorphic_to_one_link do - _replace_polymorphic_to_one_link(relationship_type, relationship_key_value, relationship_key_type, options) - end - end - - def remove_to_many_link(relationship_type, key, options = {}) - change :remove_to_many_link do - _remove_to_many_link(relationship_type, key, options) - end - end - - def remove_to_one_link(relationship_type, options = {}) - change :remove_to_one_link do - _remove_to_one_link(relationship_type, options) - end - end - - def replace_fields(field_data) - change :replace_fields do - _replace_fields(field_data) - end - end - - # Override this on a resource instance to override the fetchable keys - def fetchable_fields - self.class.fields - end - - # Override this on a resource to customize how the associated records - # are fetched for a model. Particularly helpful for authorization. - def records_for(relation_name) - _model.public_send relation_name - end - - def model_error_messages - _model.errors.messages - end - - # Add metadata to validation error objects. - # - # Suppose `model_error_messages` returned the following error messages - # hash: - # - # {password: ["too_short", "format"]} - # - # Then to add data to the validation error `validation_error_metadata` - # could return: - # - # { - # password: { - # "too_short": {"minimum_length" => 6}, - # "format": {"requirement" => "must contain letters and numbers"} - # } - # } - # - # The specified metadata is then be merged into the validation error - # object. - def validation_error_metadata - {} - end - - # Override this to return resource level meta data - # must return a hash, and if the hash is empty the meta section will not be serialized with the resource - # meta keys will be not be formatted with the key formatter for the serializer by default. They can however use the - # serializer's format_key and format_value methods if desired - # the _options hash will contain the serializer and the serialization_options - def meta(_options) - {} - end - - # Override this to return custom links - # must return a hash, which will be merged with the default { self: 'self-url' } links hash - # links keys will be not be formatted with the key formatter for the serializer by default. - # They can however use the serializer's format_key and format_value methods if desired - # the _options hash will contain the serializer and the serialization_options - def custom_links(_options) - {} - end - - def preloaded_fragments - # A hash of hashes - @preloaded_fragments ||= Hash.new - end - - private - - def save - run_callbacks :save do - _save - end - end - - # Override this on a resource to return a different result code. Any - # value other than :completed will result in operations returning - # `:accepted` - # - # For example to return `:accepted` if your model does not immediately - # save resources to the database you could override `_save` as follows: - # - # ``` - # def _save - # super - # return :accepted - # end - # ``` - def _save(validation_context = nil) - unless @model.valid?(validation_context) - fail JSONAPI::Exceptions::ValidationErrors.new(self) - end - - if defined? @model.save - saved = @model.save(validate: false) - - unless saved - if @model.errors.present? - fail JSONAPI::Exceptions::ValidationErrors.new(self) - else - fail JSONAPI::Exceptions::SaveFailed.new - end - end - else - saved = true - end - @model.reload if @reload_needed - @reload_needed = false - - @save_needed = !saved - - :completed - end - - def _remove - unless @model.destroy - fail JSONAPI::Exceptions::ValidationErrors.new(self) - end - :completed - - rescue ActiveRecord::DeleteRestrictionError => e - fail JSONAPI::Exceptions::RecordLocked.new(e.message) - end - - def reflect_relationship?(relationship, options) - return false if !relationship.reflect || - (!JSONAPI.configuration.use_relationship_reflection || options[:reflected_source]) - - inverse_relationship = relationship.resource_klass._relationships[relationship.inverse_relationship] - if inverse_relationship.nil? - warn "Inverse relationship could not be found for #{self.class.name}.#{relationship.name}. Relationship reflection disabled." - return false - end - true - end - - def _create_to_many_links(relationship_type, relationship_key_values, options) - relationship = self.class._relationships[relationship_type] - - # check if relationship_key_values are already members of this relationship - relation_name = relationship.relation_name(context: @context) - existing_relations = @model.public_send(relation_name).where(relationship.primary_key => relationship_key_values) - if existing_relations.count > 0 - # todo: obscure id so not to leak info - fail JSONAPI::Exceptions::HasManyRelationExists.new(existing_relations.first.id) - end - - if options[:reflected_source] - @model.public_send(relation_name) << options[:reflected_source]._model - return :completed - end - - # load requested related resources - # make sure they all exist (also based on context) and add them to relationship - - related_resources = relationship.resource_klass.find_by_keys(relationship_key_values, context: @context) - - if related_resources.count != relationship_key_values.count - # todo: obscure id so not to leak info - fail JSONAPI::Exceptions::RecordNotFound.new('unspecified') - end - - reflect = reflect_relationship?(relationship, options) - - related_resources.each do |related_resource| - if reflect - if related_resource.class._relationships[relationship.inverse_relationship].is_a?(JSONAPI::Relationship::ToMany) - related_resource.create_to_many_links(relationship.inverse_relationship, [id], reflected_source: self) - else - related_resource.replace_to_one_link(relationship.inverse_relationship, id, reflected_source: self) - end - @reload_needed = true - else - @model.public_send(relation_name) << related_resource._model - end - end - - :completed - end - - def _replace_to_many_links(relationship_type, relationship_key_values, options) - relationship = self.class._relationships[relationship_type] - - reflect = reflect_relationship?(relationship, options) - - if reflect - existing = send("#{relationship.foreign_key}") - to_delete = existing - (relationship_key_values & existing) - to_delete.each do |key| - _remove_to_many_link(relationship_type, key, reflected_source: self) - end - - to_add = relationship_key_values - (relationship_key_values & existing) - _create_to_many_links(relationship_type, to_add, {}) - - @reload_needed = true - else - send("#{relationship.foreign_key}=", relationship_key_values) - @save_needed = true - end - - :completed - end - - def _replace_to_one_link(relationship_type, relationship_key_value, options) - relationship = self.class._relationships[relationship_type] - - send("#{relationship.foreign_key}=", relationship_key_value) - @save_needed = true - - :completed - end - - def _replace_polymorphic_to_one_link(relationship_type, key_value, key_type, options) - relationship = self.class._relationships[relationship_type.to_sym] - - _model.public_send("#{relationship.foreign_key}=", key_value) - _model.public_send("#{relationship.polymorphic_type}=", _model_class_name(key_type)) - - @save_needed = true - - :completed - end - - def _remove_to_many_link(relationship_type, key, options) - relationship = self.class._relationships[relationship_type] - - reflect = reflect_relationship?(relationship, options) - - if reflect - - related_resource = relationship.resource_klass.find_by_key(key, context: @context) - - if related_resource.nil? - fail JSONAPI::Exceptions::RecordNotFound.new(key) - else - if related_resource.class._relationships[relationship.inverse_relationship].is_a?(JSONAPI::Relationship::ToMany) - related_resource.remove_to_many_link(relationship.inverse_relationship, id, reflected_source: self) - else - related_resource.remove_to_one_link(relationship.inverse_relationship, reflected_source: self) - end - end - - @reload_needed = true - else - @model.public_send(relationship.relation_name(context: @context)).delete(key) - end - - :completed - - rescue ActiveRecord::DeleteRestrictionError => e - fail JSONAPI::Exceptions::RecordLocked.new(e.message) - rescue ActiveRecord::RecordNotFound - fail JSONAPI::Exceptions::RecordNotFound.new(key) - end - - def _remove_to_one_link(relationship_type, options) - relationship = self.class._relationships[relationship_type] - - send("#{relationship.foreign_key}=", nil) - @save_needed = true - - :completed - end - - def _replace_fields(field_data) - field_data[:attributes].each do |attribute, value| - begin - send "#{attribute}=", value - @save_needed = true - rescue ArgumentError - # :nocov: Will be thrown if an enum value isn't allowed for an enum. Currently not tested as enums are a rails 4.1 and higher feature - raise JSONAPI::Exceptions::InvalidFieldValue.new(attribute, value) - # :nocov: - end - end - - field_data[:to_one].each do |relationship_type, value| - if value.nil? - remove_to_one_link(relationship_type) - else - case value - when Hash - replace_polymorphic_to_one_link(relationship_type.to_s, value.fetch(:id), value.fetch(:type)) - else - replace_to_one_link(relationship_type, value) - end - end - end if field_data[:to_one] - - field_data[:to_many].each do |relationship_type, values| - replace_to_many_links(relationship_type, values) - end if field_data[:to_many] - - :completed - end - - def _model_class_name(key_type) - type_class_name = key_type.to_s.classify - resource = self.class.resource_for(type_class_name) - resource ? resource._model_name.to_s : type_class_name - end - - class << self - def inherited(subclass) - subclass.abstract(false) - subclass.immutable(false) - subclass.caching(false) - 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 - end - - 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 - - check_reserved_resource_name(subclass._type, subclass.name) - end - - def resource_for(type) - type = type.underscore - type_with_module = type.include?('/') ? type : module_path + type - - resource_name = _resource_name_from_type(type_with_module) - resource = resource_name.safe_constantize if resource_name - if resource.nil? - fail NameError, "JSONAPI: Could not find resource '#{type}'. (Class #{resource_name} not found)" - end - resource - end - - def resource_for_model(model) - resource_for(resource_type_for(model)) - end - - def _resource_name_from_type(type) - "#{type.to_s.underscore.singularize}_resource".camelize - end - - def resource_type_for(model) - model_name = model.class.to_s.underscore - if _model_hints[model_name] - _model_hints[model_name] - else - model_name.rpartition('/').last - end - end - - attr_accessor :_attributes, :_relationships, :_type, :_model_hints - attr_writer :_allowed_filters, :_paginator - - def create(context) - new(create_model, context) - end - - def create_model - _model_class.new - end - - def routing_options(options) - @_routing_resource_options = options - end - - def routing_resource_options - @_routing_resource_options ||= {} - end - - # Methods used in defining a resource class - def attributes(*attrs) - options = attrs.extract_options!.dup - attrs.each do |attr| - attribute(attr, options) - end - end - - def attribute(attr, options = {}) - check_reserved_attribute_name(attr) - - if (attr.to_sym == :id) && (options[:format].nil?) - ActiveSupport::Deprecation.warn('Id without format is no longer supported. Please remove ids from attributes, or specify a format.') - end - - check_duplicate_attribute_name(attr) if options[:format].nil? - - @_attributes ||= {} - @_attributes[attr] = options - define_method attr do - @model.public_send(options[:delegate] ? options[:delegate].to_sym : attr) - end unless method_defined?(attr) - - define_method "#{attr}=" do |value| - @model.public_send("#{options[:delegate] ? options[:delegate].to_sym : attr}=", value) - end unless method_defined?("#{attr}=") - end - - def default_attribute_options - { format: :default } - end - - def relationship(*attrs) - options = attrs.extract_options! - klass = case options[:to] - when :one - Relationship::ToOne - when :many - Relationship::ToMany - else - #:nocov:# - fail ArgumentError.new('to: must be either :one or :many') - #:nocov:# - end - _add_relationship(klass, *attrs, options.except(:to)) - end - - def has_one(*attrs) - _add_relationship(Relationship::ToOne, *attrs) - end - - def belongs_to(*attrs) - ActiveSupport::Deprecation.warn "In #{name} you exposed a `has_one` relationship "\ - " using the `belongs_to` class method. We think `has_one`" \ - " is more appropriate. If you know what you're doing," \ - " and don't want to see this warning again, override the" \ - " `belongs_to` class method on your resource." - _add_relationship(Relationship::ToOne, *attrs) - end - - def has_many(*attrs) - _add_relationship(Relationship::ToMany, *attrs) - end - - def model_name(model, options = {}) - @_model_name = model.to_sym - - model_hint(model: @_model_name, resource: self) unless options[:add_model_hint] == false - end - - def model_hint(model: _model_name, resource: _type) - resource_type = ((resource.is_a?(Class)) && (resource < JSONAPI::Resource)) ? resource._type : resource.to_s - - _model_hints[model.to_s.gsub('::', '/').underscore] = resource_type.to_s - end - - def filters(*attrs) - @_allowed_filters.merge!(attrs.inject({}) { |h, attr| h[attr] = {}; h }) - end - - def filter(attr, *args) - @_allowed_filters[attr.to_sym] = args.extract_options! - end - - def primary_key(key) - @_primary_key = key.to_sym - end - - def cache_field(field) - @_cache_field = field.to_sym - end - - # Override in your resource to filter the updatable keys - def updatable_fields(_context = nil) - _updatable_relationships | _attributes.keys - [:id] - end - - # Override in your resource to filter the creatable keys - def creatable_fields(_context = nil) - _updatable_relationships | _attributes.keys - end - - # Override in your resource to filter the sortable keys - def sortable_fields(_context = nil) - _attributes.keys - end - - def fields - _relationships.keys | _attributes.keys - end - - def resolve_relationship_names_to_relations(resource_klass, model_includes, options = {}) - case model_includes - when Array - return model_includes.map do |value| - resolve_relationship_names_to_relations(resource_klass, value, options) - end - when Hash - model_includes.keys.each do |key| - relationship = resource_klass._relationships[key] - value = model_includes[key] - model_includes.delete(key) - model_includes[relationship.relation_name(options)] = resolve_relationship_names_to_relations(relationship.resource_klass, value, options) - end - return model_includes - when Symbol - relationship = resource_klass._relationships[model_includes] - return relationship.relation_name(options) - end - end - - 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) - end - - records - end - - def apply_pagination(records, paginator, order_options) - records = paginator.apply(records, order_options) if paginator - records - end - - def apply_sort(records, order_options, _context = {}) - if order_options.any? - order_options.each_pair do |field, direction| - if field.to_s.include?(".") - *model_names, column_name = field.split(".") - - 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 - order_by_query = "#{associations.last.name}_sorting.#{column_name} #{direction}" - records = records.joins(joins_query).order(order_by_query) - else - records = records.order(field => direction) - end - end - end - - records - end - - def _lookup_association_chain(model_names) - associations = [] - model_names.inject do |prev, current| - association = prev.classify.constantize.reflect_on_all_associations.detect do |assoc| - assoc.name.to_s.downcase == current.downcase - end - associations << association - association.class_name - end - - associations - end - - def _build_joins(associations) - joins = [] - - associations.inject do |prev, current| - joins << "LEFT JOIN #{current.table_name} AS #{current.name}_sorting ON #{current.name}_sorting.id = #{prev.table_name}.#{current.foreign_key}" - current - end - joins.join("\n") - end - - def apply_filter(records, filter, value, options = {}) - strategy = _allowed_filters.fetch(filter.to_sym, Hash.new)[:apply] - - if strategy - if strategy.is_a?(Symbol) || strategy.is_a?(String) - send(strategy, records, value, options) - else - strategy.call(records, value, options) - end - else - records.where(filter => value) - end - end - - def apply_filters(records, filters, options = {}) - required_includes = [] - - if filters - filters.each do |filter, value| - if _relationships.include?(filter) - if _relationships[filter].belongs_to? - records = apply_filter(records, _relationships[filter].foreign_key, value, options) - else - required_includes.push(filter.to_s) - records = apply_filter(records, "#{_relationships[filter].table_name}.#{_relationships[filter].primary_key}", value, options) - end - else - records = apply_filter(records, filter, value, options) - end - end - end - - if required_includes.any? - records = apply_includes(records, options.merge(include_directives: IncludeDirectives.new(self, required_includes, force_eager_load: true))) - end - - records - end - - def filter_records(filters, options, records = records(options)) - records = apply_filters(records, filters, options) - apply_includes(records, options) - end - - def sort_records(records, order_options, context = {}) - apply_sort(records, order_options, context) - end - - # Assumes ActiveRecord's counting. Override if you need a different counting method - def count_records(records) - records.count(:all) - end - - def find_count(filters, options = {}) - count_records(filter_records(filters, options)) - end - - def find(filters, options = {}) - resources_for(find_records(filters, options), options[:context]) - end - - def resources_for(records, context) - records.collect do |model| - resource_class = self.resource_for_model(model) - resource_class.new(model, context) - end - end - - def find_by_keys(keys, options = {}) - context = options[:context] - records = records(options) - records = apply_includes(records, options) - models = records.where({_primary_key => keys}) - models.collect do |model| - self.resource_for_model(model).new(model, context) - end - end - - def find_serialized_with_caching(filters_or_source, serializer, options = {}) - if filters_or_source.is_a?(ActiveRecord::Relation) - records = filters_or_source - elsif _model_class.respond_to?(:all) && _model_class.respond_to?(:arel_table) - records = find_records(filters_or_source, options.except(:include_directives)) - else - records = find(filters_or_source, options) - end - cached_resources_for(records, serializer, options) - end - - def find_by_key(key, options = {}) - context = options[:context] - records = find_records({_primary_key => key}, options.except(:paginator, :sort_criteria)) - model = records.first - fail JSONAPI::Exceptions::RecordNotFound.new(key) if model.nil? - self.resource_for_model(model).new(model, context) - end - - def find_by_key_serialized_with_caching(key, serializer, options = {}) - if _model_class.respond_to?(:all) && _model_class.respond_to?(:arel_table) - results = find_serialized_with_caching({_primary_key => key}, serializer, options) - result = results.first - fail JSONAPI::Exceptions::RecordNotFound.new(key) if result.nil? - return result - else - resource = find_by_key(key, options) - return cached_resources_for([resource], serializer, options).first - end - end - - # Override this method if you want to customize the relation for - # finder methods (find, find_by_key, find_serialized_with_caching) - def records(_options = {}) - _model_class.all - end - - def verify_filters(filters, context = nil) - verified_filters = {} - filters.each do |filter, raw_value| - verified_filter = verify_filter(filter, raw_value, context) - verified_filters[verified_filter[0]] = verified_filter[1] - end - verified_filters - end - - def is_filter_relationship?(filter) - filter == _type || _relationships.include?(filter) - end - - def verify_filter(filter, raw, context = nil) - filter_values = [] - if raw.present? - filter_values += raw.is_a?(String) ? CSV.parse_line(raw) : [raw] - end - - strategy = _allowed_filters.fetch(filter, Hash.new)[:verify] - - if strategy - if strategy.is_a?(Symbol) || strategy.is_a?(String) - values = send(strategy, filter_values, context) - else - values = strategy.call(filter_values, context) - end - [filter, values] - else - if is_filter_relationship?(filter) - verify_relationship_filter(filter, filter_values, context) - else - verify_custom_filter(filter, filter_values, context) - end - end - end - - def key_type(key_type) - @_resource_key_type = key_type - end - - def resource_key_type - @_resource_key_type ||= JSONAPI.configuration.resource_key_type - end - - def verify_key(key, context = nil) - key_type = resource_key_type - - case key_type - when :integer - return if key.nil? - Integer(key) - when :string - return if key.nil? - if key.to_s.include?(',') - raise JSONAPI::Exceptions::InvalidFieldValue.new(:id, key) - else - key - end - when :uuid - return 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}$/) - key - else - raise JSONAPI::Exceptions::InvalidFieldValue.new(:id, key) - end - else - key_type.call(key, context) - end - rescue - raise JSONAPI::Exceptions::InvalidFieldValue.new(:id, key) - end - - # override to allow for key processing and checking - def verify_keys(keys, context = nil) - return keys.collect do |key| - verify_key(key, context) - end - end - - # Either add a custom :verify labmda or override verify_custom_filter to allow for custom filters - def verify_custom_filter(filter, value, _context = nil) - [filter, value] - end - - # Either add a custom :verify labmda or override verify_relationship_filter to allow for custom - # relationship logic, such as uuids, multiple keys or permission checks on keys - def verify_relationship_filter(filter, raw, _context = nil) - [filter, raw] - end - - # quasi private class methods - def _attribute_options(attr) - default_attribute_options.merge(@_attributes[attr]) - end - - def _updatable_relationships - @_relationships.map { |key, _relationship| key } - end - - def _relationship(type) - type = type.to_sym - @_relationships[type] - end - - def _model_name - if _abstract - return '' - else - return @_model_name if defined?(@_model_name) - class_name = self.name - return '' if class_name.nil? - return @_model_name = class_name.demodulize.sub(/Resource$/, '') - end - end - - def _primary_key - @_primary_key ||= _model_class.respond_to?(:primary_key) ? _model_class.primary_key : :id - end - - def _cache_field - @_cache_field ||= JSONAPI.configuration.default_resource_cache_field - end - - def _table_name - @_table_name ||= _model_class.respond_to?(:table_name) ? _model_class.table_name : _model_name.tableize - end - - def _as_parent_key - @_as_parent_key ||= "#{_type.to_s.singularize}_id" - end - - def _allowed_filters - defined?(@_allowed_filters) ? @_allowed_filters : { id: {} } - end - - def _paginator - @_paginator ||= JSONAPI.configuration.default_paginator - end - - def paginator(paginator) - @_paginator = paginator - end - - def abstract(val = true) - @abstract = val - end - - def _abstract - @abstract - end - - def immutable(val = true) - @immutable = val - end - - def _immutable - @immutable - end - - def mutable? - !@immutable - end - - def caching(val = true) - @caching = val - end - - def _caching - @caching - end - - def caching? - @caching && !JSONAPI.configuration.resource_cache.nil? - end - - def attribute_caching_context(context) - nil - end - - 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 - end - - def _allowed_filter?(filter) - !_allowed_filters[filter].nil? - end - - def module_path - if name == 'JSONAPI::Resource' - '' - else - name =~ /::[^:]+\Z/ ? ($`.freeze.gsub('::', '/') + '/').underscore : '' - end - end - - def default_sort - [{field: 'id', direction: :asc}] - end - - def construct_order_options(sort_params) - sort_params ||= default_sort - - return {} unless sort_params - - sort_params.each_with_object({}) do |sort, order_hash| - field = sort[:field].to_s == 'id' ? _primary_key : sort[:field].to_s - order_hash[field] = sort[:direction] - end - end - - def _add_relationship(klass, *attrs) - options = attrs.extract_options! - options[:parent_resource] = self - - attrs.each do |relationship_name| - check_reserved_relationship_name(relationship_name) - check_duplicate_relationship_name(relationship_name) - - JSONAPI::RelationshipBuilder.new(klass, _model_class, options) - .define_relationship_methods(relationship_name.to_sym) - end - end - - # Allows JSONAPI::RelationshipBuilder to access metaprogramming hooks - def inject_method_definition(name, body) - define_method(name, body) - end - - def register_relationship(name, relationship_object) - @_relationships[name] = relationship_object - end - - private - - def cached_resources_for(records, serializer, options) - if records.is_a?(Array) && records.all?{|rec| rec.is_a?(JSONAPI::Resource)} - resources = records.map{|r| [r.id, r] }.to_h - elsif self.caching? - t = _model_class.arel_table - 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 - end - - preload_included_fragments(resources, records, serializer, options) - - resources.values - end - - def find_records(filters, options = {}) - context = options[:context] - - records = filter_records(filters, options) - - sort_criteria = options.fetch(:sort_criteria) { [] } - order_options = construct_order_options(sort_criteria) - records = sort_records(records, order_options, context) - - records = apply_pagination(records, options[:paginator], order_options) - - records - end - - def check_reserved_resource_name(type, name) - if [:ids, :types, :hrefs, :links].include?(type) - warn "[NAME COLLISION] `#{name}` is a reserved resource name." - return - end - end - - def check_reserved_attribute_name(name) - # Allow :id since it can be used to specify the format. Since it is a method on the base Resource - # an attribute method won't be created for it. - if [:type].include?(name.to_sym) - warn "[NAME COLLISION] `#{name}` is a reserved key in #{_resource_name_from_type(_type)}." - end - end - - def check_reserved_relationship_name(name) - if [:id, :ids, :type, :types].include?(name.to_sym) - warn "[NAME COLLISION] `#{name}` is a reserved relationship name in #{_resource_name_from_type(_type)}." - end - end - - def check_duplicate_relationship_name(name) - if _relationships.include?(name.to_sym) - warn "[DUPLICATE RELATIONSHIP] `#{name}` has already been defined in #{_resource_name_from_type(_type)}." - end - end - - def check_duplicate_attribute_name(name) - if _attributes.include?(name.to_sym) - warn "[DUPLICATE ATTRIBUTE] `#{name}` has already been defined in #{_resource_name_from_type(_type)}." - end - end - - def preload_included_fragments(resources, records, serializer, options) - return if resources.empty? - res_ids = resources.keys - - 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. - # Even if a target class doesn't have caching enabled, we still have to look up - # and match the target ids here, because we can't use ActiveRecord#includes. - # - # Note that `paths` returns partial paths before complete paths, so e.g. the partial - # fragments for posts.comments will exist before we start working with posts.comments.author - target_resources = {} - include_directives.paths.each do |path| - # If path is [:posts, :comments, :author], then... - pluck_attrs = [] # ...will be [posts.id, comments.id, authors.id, authors.updated_at] - pluck_attrs << self._model_class.arel_table[self._primary_key] - - relation = records - .except(:limit, :offset, :order) - .where({_primary_key => res_ids}) - - # These are updated as we iterate through the association path; afterwards they will - # refer to the final resource on the path, i.e. the actual resource to find in the cache. - # So e.g. if path is [:posts, :comments, :author], then after iteration... - parent_klass = nil # Comment - klass = self # Person - relationship = nil # JSONAPI::Relationship::ToOne for CommentResource.author - table = nil # people - assocs_path = [] # [ :posts, :approved_comments, :author ] - ar_hash = nil # { :posts => { :approved_comments => :author } } - - # 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 - path.each do |elem| - relationship = klass._relationships[elem] - 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 } } - # We can't just look up the table name from the resource class, because Arel could - # have used a table alias if the relation includes a self-reference. - join_source = relation.joins(ar_hash).arel.source.right.reverse.find do |arel_node| - arel_node.is_a?(Arel::Nodes::InnerJoin) - end - table = join_source.left - parent_klass = klass - klass = relationship.resource_klass - pluck_attrs << table[klass._primary_key] - end - - # 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 - # vs. a preload that never ran. - prefilling_resources = resources.values - path.each do |rel_name| - rel_name = serializer.key_formatter.format(rel_name) - prefilling_resources.map! do |res| - res.preloaded_fragments[rel_name] ||= {} - res.preloaded_fragments[rel_name].values - end - prefilling_resources.flatten!(1) - end - - pluck_attrs << table[klass._cache_field] if klass.caching? - relation = relation.joins(ar_hash) - if relationship.is_a?(JSONAPI::Relationship::ToMany) - # Rails doesn't include order clauses in `joins`, so we have to add that manually here. - # FIXME Should find a better way to reflect on relationship ordering. :-( - relation = relation.order(parent_klass._model_class.new.send(assocs_path.last).arel.orders) - end - - # [[post id, comment id, author id, author updated_at], ...] - id_rows = pluck_arel_attributes(relation.joins(ar_hash), *pluck_attrs) - - target_resources[klass.name] ||= {} - - if klass.caching? - sub_cache_ids = id_rows - .map{|row| row.last(2) } - .reject{|row| target_resources[klass.name].has_key?(row.first) } - .uniq - target_resources[klass.name].merge! CachedResourceFragment.fetch_fragments( - klass, serializer, context, sub_cache_ids - ) - else - sub_res_ids = id_rows - .map(&:last) - .reject{|id| target_resources[klass.name].has_key?(id) } - .uniq - found = klass.find({klass._primary_key => sub_res_ids}, relevant_options) - target_resources[klass.name].merge! found.map{|r| [r.id, r] }.to_h - end - - id_rows.each do |row| - res = resources[row.first] - path.each_with_index do |rel_name, index| - rel_name = serializer.key_formatter.format(rel_name) - 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) - else - res = assoc_rels[rel_id] - end - end - end - end - end - - def pluck_arel_attributes(relation, *attrs) - conn = relation.connection - 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}" - end - relation.pluck(*quoted_attrs) - end - end + class Resource < ActiveRelationResource + root_resource end end diff --git a/lib/jsonapi/resource_controller.rb b/lib/jsonapi/resource_controller.rb index 0c3f7f345..70450d9d7 100644 --- a/lib/jsonapi/resource_controller.rb +++ b/lib/jsonapi/resource_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class ResourceController < ActionController::Base include JSONAPI::ActsAsResourceController diff --git a/lib/jsonapi/resource_controller_metal.rb b/lib/jsonapi/resource_controller_metal.rb index bf5bc9410..e8dfb3f55 100644 --- a/lib/jsonapi/resource_controller_metal.rb +++ b/lib/jsonapi/resource_controller_metal.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class ResourceControllerMetal < ActionController::Metal MODULES = [ @@ -5,11 +7,14 @@ 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 + # Note, the url_helpers are not loaded. This will prevent links from being generated for resources, and warnings + # will be emitted. Link support can be added by including `Rails.application.routes.url_helpers`, and links + # can be disabled, and warning suppressed, for a resource with `exclude_links :default` MODULES.each do |mod| include mod end diff --git a/lib/jsonapi/resource_fragment.rb b/lib/jsonapi/resource_fragment.rb new file mode 100644 index 000000000..c42cf573d --- /dev/null +++ b/lib/jsonapi/resource_fragment.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module JSONAPI + + # A ResourceFragment holds a ResourceIdentity and associated partial resource data. + # + # The following partial resource data may be stored + # cache - the value of the cache field for the resource instance + # related - a hash of arrays of related resource identities, grouped by relationship name + # related_from - a set of related resource identities that loaded the fragment + # resource - a resource instance + # + # Todo: optionally use these for faster responses by bypassing model instantiation) + # attributes - resource attributes + + class ResourceFragment + attr_reader :identity, :attributes, :related_from, :related, :resource + + attr_accessor :primary, :cache + + alias :cache_field :cache #ToDo: Rename one or the other + + def initialize(identity, resource: nil, cache: nil, primary: false) + @identity = identity + @cache = cache + @resource = resource + @primary = primary + + @attributes = {} + @related = {} + @related_from = Set.new + end + + def initialize_related(relationship_name) + @related[relationship_name.to_sym] ||= Set.new + end + + def add_related_identity(relationship_name, identity) + initialize_related(relationship_name) + @related[relationship_name.to_sym] << identity if identity + end + + def merge_related_identities(relationship_name, identities) + initialize_related(relationship_name) + @related[relationship_name.to_sym].merge(identities) if identities + end + + def add_related_from(identity) + @related_from << identity + end + + def add_attribute(name, value) + @attributes[name] = value + end + end +end \ No newline at end of file diff --git a/lib/jsonapi/resource_identity.rb b/lib/jsonapi/resource_identity.rb new file mode 100644 index 000000000..baea3fcf8 --- /dev/null +++ b/lib/jsonapi/resource_identity.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module JSONAPI + + # ResourceIdentity describes a unique identity of a resource in the system. + # This consists of a Resource class and an identifier that is unique within + # that Resource class. ResourceIdentities are intended to be used as hash + # keys to provide ordered mixing of resource types in result sets. + # + # + # == Creating a ResourceIdentity + # + # rid = ResourceIdentity.new(PostResource, 12) + # + class ResourceIdentity + attr_reader :resource_klass, :id + + def initialize(resource_klass, id) + @resource_klass = resource_klass + @id = id + end + + def ==(other) + # :nocov: + eql?(other) + # :nocov: + end + + def eql?(other) + other.is_a?(ResourceIdentity) && other.resource_klass == @resource_klass && other.id == @id + end + + def hash + [@resource_klass, @id].hash + end + + # Creates a string representation of the identifier. + def to_s + # :nocov: + "#{resource_klass}:#{id}" + # :nocov: + end + end +end diff --git a/lib/jsonapi/resource_serializer.rb b/lib/jsonapi/resource_serializer.rb index 1bde2ab0d..731404f1e 100644 --- a/lib/jsonapi/resource_serializer.rb +++ b/lib/jsonapi/resource_serializer.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + module JSONAPI class ResourceSerializer - attr_reader :link_builder, :key_formatter, :serialization_options, :primary_class_name, + attr_reader :link_builder, :key_formatter, :serialization_options, :fields, :include_directives, :always_include_to_one_linkage_data, - :always_include_to_many_linkage_data + :always_include_to_many_linkage_data, :options # initialize # Options can include @@ -18,12 +20,12 @@ class ResourceSerializer # serialization_options: additional options that will be passed to resource meta and links lambdas def initialize(primary_resource_klass, options = {}) + @options = options @primary_resource_klass = primary_resource_klass - @primary_class_name = primary_resource_klass._type @fields = options.fetch(:fields, {}) @include = options.fetch(:include, []) - @include_directives = options[:include_directives] - @include_directives ||= JSONAPI::IncludeDirectives.new(@primary_resource_klass, @include) + @include_directives = options.fetch(:include_directives, + JSONAPI::IncludeDirectives.new(@primary_resource_klass, @include)) @key_formatter = options.fetch(:key_formatter, JSONAPI.configuration.key_formatter) @id_formatter = ValueFormatter.value_formatter_for(:id) @link_builder = generate_link_builder(primary_resource_klass, options) @@ -44,56 +46,92 @@ def initialize(primary_resource_klass, options = {}) # Converts a single resource, or an array of resources to a hash, conforming to the JSONAPI structure def serialize_to_hash(source) - @top_level_sources = Set.new([source].flatten(1).compact.map {|s| top_level_source_key(s) }) - - is_resource_collection = source.respond_to?(:to_ary) + include_related = include_directives[:include_related] + resource_set = JSONAPI::ResourceSet.new(source, include_related, options) + resource_set.populate!(self, options[:context], options) - @included_objects = {} + if source.is_a?(Array) + serialize_resource_set_to_hash_plural(resource_set) + else + serialize_resource_set_to_hash_single(resource_set) + end + end - process_primary(source, @include_directives.include_directives) + # Converts a resource_set to a hash, conforming to the JSONAPI structure + def serialize_resource_set_to_hash_single(resource_set) + primary_objects = [] included_objects = [] + + resource_set.resource_klasses.each_value do |resource_klass| + resource_klass.each_value do |resource| + serialized_resource = object_hash(resource[:resource], resource[:relationships]) + + if resource[:primary] + primary_objects.push(serialized_resource) + else + included_objects.push(serialized_resource) + end + end + end + + fail "Too many primary objects for show" if (primary_objects.count > 1) + primary_hash = { 'data' => primary_objects[0] } + + primary_hash['included'] = included_objects if included_objects.size > 0 + primary_hash + end + + def serialize_resource_set_to_hash_plural(resource_set) + primary_objects = [] - @included_objects.each_value do |objects| - objects.each_value do |object| - if object[:primary] - primary_objects.push(object[:object_hash]) + included_objects = [] + + resource_set.resource_klasses.each_value do |resource_klass| + resource_klass.each_value do |resource| + serialized_resource = object_hash(resource[:resource], resource[:relationships]) + + if resource[:primary] + primary_objects.push(serialized_resource) else - included_objects.push(object[:object_hash]) + included_objects.push(serialized_resource) end end end - primary_hash = { data: is_resource_collection ? primary_objects : primary_objects[0] } + primary_hash = { 'data' => primary_objects } - primary_hash[:included] = included_objects if included_objects.size > 0 + primary_hash['included'] = included_objects if included_objects.size > 0 primary_hash end - def serialize_to_links_hash(source, requested_relationship) + def serialize_related_resource_set_to_hash_plural(resource_set, _source_resource) + return serialize_resource_set_to_hash_plural(resource_set) + end + + def serialize_to_relationship_hash(source, requested_relationship, resource_ids) if requested_relationship.is_a?(JSONAPI::Relationship::ToOne) - data = to_one_linkage(source, requested_relationship) + data = to_one_linkage(resource_ids[0]) else - data = to_many_linkage(source, requested_relationship) + data = to_many_linkage(resource_ids) end - { - links: { - self: self_link(source, requested_relationship), - related: related_link(source, requested_relationship) - }, - data: data - } - end + rel_hash = { 'data': data } + + links = default_relationship_links(source, requested_relationship) + rel_hash['links'] = links unless links.blank? - def query_link(query_params) - link_builder.query_link(query_params) + rel_hash end def format_key(key) @key_formatter.format(key) end + def unformat_key(key) + @key_formatter.unformat(key) + end + def format_value(value, format) @value_formatter_type_cache.get(format).format(value) end @@ -109,35 +147,38 @@ def config_key(resource_klass) def config_description(resource_klass) { class_name: self.class.name, - seriserialization_options: serialization_options.sort.map(&:as_json), + serialization_options: serialization_options.sort.map(&:as_json), 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 } end - # Returns a serialized hash for the source model - def object_hash(source, include_directives = {}) + def object_hash(source, relationship_data) obj_hash = {} - if source.is_a?(JSONAPI::CachedResourceFragment) - obj_hash['id'] = source.id + return obj_hash if source.nil? + + fetchable_fields = Set.new(source.fetchable_fields) + + if source.is_a?(JSONAPI::CachedResponseFragment) + id_format = source.resource_klass._attribute_options(:id)[:format] + + id_format = 'id' if id_format == :default + obj_hash['id'] = format_value(source.id, id_format) obj_hash['type'] = source.type obj_hash['links'] = source.links_json if source.links_json obj_hash['attributes'] = source.attributes_json if source.attributes_json - relationships = cached_relationships_hash(source, include_directives) - obj_hash['relationships'] = relationships unless relationships.empty? + relationships = cached_relationships_hash(source, fetchable_fields, relationship_data) + obj_hash['relationships'] = relationships unless relationships.blank? obj_hash['meta'] = source.meta_json if source.meta_json else - fetchable_fields = Set.new(source.fetchable_fields) - # TODO Should this maybe be using @id_formatter instead, for consistency? id_format = source.class._attribute_options(:id)[:format] # protect against ids that were declared as an attribute, but did not have a format set. @@ -152,8 +193,8 @@ def object_hash(source, include_directives = {}) attributes = attributes_hash(source, fetchable_fields) obj_hash['attributes'] = attributes unless attributes.empty? - relationships = relationships_hash(source, fetchable_fields, include_directives) - obj_hash['relationships'] = relationships unless relationships.nil? || relationships.empty? + relationships = relationships_hash(source, fetchable_fields, relationship_data) + obj_hash['relationships'] = relationships unless relationships.blank? meta = meta_hash(source) obj_hash['meta'] = meta unless meta.empty? @@ -164,24 +205,11 @@ def object_hash(source, include_directives = {}) private - # Process the primary source object(s). This will then serialize associated object recursively based on the - # 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) - if source.respond_to?(:to_ary) - source.each { |resource| process_primary(resource, include_directives) } - else - return {} if source.nil? - add_resource(source, include_directives, true) - end - end - def supplying_attribute_fields(resource_klass) @_supplying_attribute_fields.fetch resource_klass do attrs = Set.new(resource_klass._attributes.keys.map(&:to_sym)) cur = resource_klass - while cur != JSONAPI::Resource + while !cur.root? # do not traverse beyond the first root resource if @fields.has_key?(cur._type) attrs &= @fields[cur._type] break @@ -196,7 +224,7 @@ def supplying_relationship_fields(resource_klass) @_supplying_relationship_fields.fetch resource_klass do relationships = Set.new(resource_klass._relationships.keys.map(&:to_sym)) cur = resource_klass - while cur != JSONAPI::Resource + while !cur.root? # do not traverse beyond the first root resource if @fields.has_key?(cur._type) relationships &= @fields[cur._type] break @@ -218,7 +246,7 @@ def attributes_hash(source, fetchable_fields) end def custom_generation_options - { + @_custom_generation_options ||= { serializer: self, serialization_options: @serialization_options } @@ -231,7 +259,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 @@ -240,103 +270,62 @@ def custom_links_hash(source) (custom_links.is_a?(Hash) && custom_links) || {} end - def top_level_source_key(source) - case source - when CachedResourceFragment then "#{source.resource_klass}_#{source.id}" - when Resource then "#{source.class}_#{@id_formatter.format(source.id)}" - else raise "Unknown source type #{source.inspect}" - end - end - - def self_referential_and_already_in_source(resource) - resource && @top_level_sources.include?(top_level_source_key(resource)) - end - - def relationships_hash(source, fetchable_fields, include_directives = {}) - if source.is_a?(CachedResourceFragment) - return cached_relationships_hash(source, include_directives) - end - - include_directives[:include_related] ||= {} - - relationships = source.class._relationships.select{|k,v| fetchable_fields.include?(k) } + def relationships_hash(source, fetchable_fields, relationship_data) + relationships = source.class._relationships.select{|k,_v| fetchable_fields.include?(k) } field_set = supplying_relationship_fields(source.class) & relationships.keys relationships.each_with_object({}) do |(name, relationship), hash| - ia = include_directives[:include_related][name] - include_linkage = ia && ia[:include] - include_linked_children = ia && !ia[:include_related].empty? - + include_data = false if field_set.include?(name) - hash[format_key(name)] = link_object(source, relationship, include_linkage) - end - - # If the object has been serialized once it will be in the related objects list, - # but it's possible all children won't have been captured. So we must still go - # through the relationships. - if include_linkage || include_linked_children - 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 - end - resources.each do |resource| - next if self_referential_and_already_in_source(resource) - id = resource.id - relationships_only = already_serialized?(relationship.type, id) - if include_linkage && !relationships_only - add_resource(resource, ia) - elsif include_linked_children || relationships_only - relationships_hash(resource, fetchable_fields, ia) + if relationship_data[name] + include_data = true + if relationship.is_a?(JSONAPI::Relationship::ToOne) + rids = relationship_data[name].first + else + rids = relationship_data[name] end end + + ro = relationship_object(source, relationship, rids, include_data) + hash[format_key(name)] = ro unless ro.blank? end end end - def cached_relationships_hash(source, include_directives) - h = source.relationships || {} - return h unless include_directives.has_key?(:include_related) + def cached_relationships_hash(source, fetchable_fields, relationship_data) + relationships = {} - relationships = source.resource_klass._relationships.select{|k,v| source.fetchable_fields.include?(k) } + source.relationships.try(:each_pair) do |k,v| + if fetchable_fields.include?(unformat_key(k).to_sym) + relationships[k.to_sym] = v + end + end - relationships.each do |rel_name, relationship| - key = @key_formatter.format(rel_name) - to_many = relationship.is_a? JSONAPI::Relationship::ToMany + field_set = supplying_relationship_fields(source.resource_klass).collect {|k| format_key(k).to_sym } & relationships.keys - ia = include_directives[:include_related][rel_name] - if ia - if h.has_key?(key) - h[key][:data] = to_many ? [] : nil - end + relationships.each_with_object({}) do |(name, relationship), hash| + if field_set.include?(name) - source.preloaded_fragments[key].each do |id, f| - add_resource(f, ia) - - if h.has_key?(key) - # The hash already has everything we need except the :data field - data = { - type: format_key(f.is_a?(Resource) ? f.class._type : f.type), - id: @id_formatter.format(id) - } - - if to_many - h[key][:data] << data - else - h[key][:data] = data - end + relationship_name = unformat_key(name).to_sym + relationship_klass = source.resource_klass._relationships[relationship_name] + + if relationship_klass.is_a?(JSONAPI::Relationship::ToOne) + # include_linkage = @always_include_to_one_linkage_data | relationship_klass.always_include_linkage_data + if relationship_data[relationship_name] + rids = relationship_data[relationship_name].first + relationship['data'] = to_one_linkage(rids) + end + else + # include_linkage = relationship_klass.always_include_linkage_data + if relationship_data[relationship_name] + rids = relationship_data[relationship_name] + relationship['data'] = to_many_linkage(rids) end end + + hash[format_key(name)] = relationship end end - - return h - end - - def already_serialized?(type, id) - type = format_key(type) - id = @id_formatter.format(id) - @included_objects.key?(type) && @included_objects[type].key?(id) end def self_link(source, relationship) @@ -347,151 +336,69 @@ def related_link(source, relationship) link_builder.relationships_related_link(source, relationship) end - def to_one_linkage(source, relationship) - linkage_id = foreign_key_value(source, relationship) - linkage_type = format_key(relationship.type_for_source(source)) - return unless linkage_id.present? && linkage_type.present? - - { - type: linkage_type, - id: linkage_id, - } + 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_many_linkage(source, relationship) + def to_many_linkage(rids) linkage = [] - 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) - # 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.pluck(:type, :id).map do |type, id| - [type.underscore.pluralize, id] - end - end - else - source.public_send(relationship.name).map do |value| - [relationship.type, value.id] - end - end - linkage_types_and_values.each do |type, value| - if type && value - linkage.append({type: format_key(type), id: @id_formatter.format(value)}) + rids && rids.each do |details| + id = details.id + type = details.resource_klass.try(:_type) + if type && id + linkage.append({'type' => format_key(type), 'id' => @id_formatter.format(id)}) end end + linkage end - def link_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 + def to_one_linkage(rid) + return unless rid + + { + 'type' => format_key(rid.resource_klass._type), + 'id' => @id_formatter.format(rid.id), + } end - def link_object_to_many(source, relationship, include_linkage) - include_linkage = include_linkage | relationship.always_include_linkage_data + def relationship_object_to_one(source, relationship, rid, include_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 - end - def link_object(source, relationship, include_linkage = false) - if relationship.is_a?(JSONAPI::Relationship::ToOne) - link_object_to_one(source, relationship, include_linkage) - elsif relationship.is_a?(JSONAPI::Relationship::ToMany) - link_object_to_many(source, relationship, include_linkage) - end - end + links = default_relationship_links(source, relationship) - # 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 - return nil unless related_resource_id - @id_formatter.format(related_resource_id) + link_object_hash['links'] = links unless links.blank? + link_object_hash['data'] = to_one_linkage(rid) if include_data + link_object_hash end - def foreign_key_types_and_values(source, relationship) - if relationship.is_a?(JSONAPI::Relationship::ToMany) - if relationship.polymorphic? - assoc = source._model.public_send(relationship.name) - # 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, @id_formatter.format(obj.id)] - end - else - assoc.pluck(:type, :id).map do |type, id| - [type.underscore.pluralize, @id_formatter.format(id)] - end - end - else - source.public_send(relationship.name).map do |value| - [relationship.type, @id_formatter.format(value.id)] - end - end - end - end + def relationship_object_to_many(source, relationship, rids, include_data) + link_object_hash = {} - # Sets that an object should be included in the primary document of the response. - def set_primary(type, id) - type = format_key(type) - @included_objects[type][id][:primary] = true + links = default_relationship_links(source, relationship) + link_object_hash['links'] = links unless links.blank? + link_object_hash['data'] = to_many_linkage(rids) if include_data + link_object_hash end - def add_resource(source, include_directives, primary = false) - type = source.is_a?(JSONAPI::CachedResourceFragment) ? source.type : source.class._type - id = source.id - - @included_objects[type] ||= {} - existing = @included_objects[type][id] - - if existing.nil? - obj_hash = object_hash(source, include_directives) - @included_objects[type][id] = { - primary: primary, - object_hash: obj_hash, - includes: Set.new(include_directives[:include_related].keys) - } - else - include_related = Set.new(include_directives[:include_related].keys) - unless existing[:includes].superset?(include_related) - obj_hash = object_hash(source, include_directives) - @included_objects[type][id][:object_hash].deep_merge!(obj_hash) - @included_objects[type][id][:includes].add(include_related) - @included_objects[type][id][:primary] = existing[:primary] | primary - end + def relationship_object(source, relationship, rid, include_data) + if relationship.is_a?(JSONAPI::Relationship::ToOne) + relationship_object_to_one(source, relationship, rid, include_data) + elsif relationship.is_a?(JSONAPI::Relationship::ToMany) + relationship_object_to_many(source, relationship, rid, include_data) end end 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/resource_set.rb b/lib/jsonapi/resource_set.rb new file mode 100644 index 000000000..01fcdb77e --- /dev/null +++ b/lib/jsonapi/resource_set.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +module JSONAPI + # Contains a hash of resource types which contain a hash of resources, relationships and primary status keyed by + # resource id. + class ResourceSet + + attr_reader :resource_klasses, :populated + + def initialize(source, include_related = nil, options = nil) + @populated = false + tree = if source.is_a?(JSONAPI::ResourceTree) + source + elsif source.class < JSONAPI::BasicResource + JSONAPI::PrimaryResourceTree.new(resource: source, include_related: include_related, options: options) + elsif source.is_a?(Array) + JSONAPI::PrimaryResourceTree.new(resources: source, include_related: include_related, options: options) + end + + if tree + @resource_klasses = flatten_resource_tree(tree) + end + end + + def populate!(serializer, context, options) + return if @populated + + # For each resource klass we want to generate the caching key + + # Hash for collecting types and ids + # @type [Hash, Id[]]] + missed_resource_ids = {} + + # Array for collecting CachedResponseFragment::Lookups + # @type [Lookup[]] + lookups = [] + + # Step One collect all of the lookups for the cache, or keys that don't require cache access + @resource_klasses.each_key do |resource_klass| + missed_resource_ids[resource_klass] ||= [] + + serializer_config_key = serializer.config_key(resource_klass).gsub("/", "_") + context_json = resource_klass.attribute_caching_context(context).to_json + context_b64 = JSONAPI.configuration.resource_cache_digest_function.call(context_json) + context_key = "ATTR-CTX-#{context_b64.gsub("/", "_")}" + + if resource_klass.caching? + cache_ids = @resource_klasses[resource_klass].map do |(k, v)| + # Store the hashcode of the cache_field to avoid storing objects and to ensure precision isn't lost + # on timestamp types (i.e. string conversions dropping milliseconds) + [k, resource_klass.hash_cache_field(v[:cache_id])] + end + + lookups.push( + CachedResponseFragment::Lookup.new( + resource_klass, + serializer_config_key, + context, + context_key, + cache_ids + ) + ) + else + @resource_klasses[resource_klass].keys.each do |k| + if @resource_klasses[resource_klass][k][:resource].nil? + missed_resource_ids[resource_klass] << k + else + register_resource(resource_klass, @resource_klasses[resource_klass][k][:resource]) + end + end + end + end + + if lookups.any? + raise "You've declared some Resources as caching without providing a caching store" if JSONAPI.configuration.resource_cache.nil? + + # Step Two execute the cache lookup + found_resources = CachedResponseFragment.lookup(lookups, context) + else + found_resources = {} + end + + # Step Three collect the results and collect hit/miss stats + stats = {} + found_resources.each do |resource_klass, resources| + resources.each do |id, cached_resource| + stats[resource_klass] ||= {} + + if cached_resource.nil? + stats[resource_klass][:misses] ||= 0 + stats[resource_klass][:misses] += 1 + + # Collect misses + missed_resource_ids[resource_klass].push(id) + else + stats[resource_klass][:hits] ||= 0 + stats[resource_klass][:hits] += 1 + + register_resource(resource_klass, cached_resource) + end + end + end + + report_stats(stats) + + writes = [] + + # Step Four find any of the missing resources and join them into the result + missed_resource_ids.each_pair do |resource_klass, ids| + next if ids.empty? + + find_opts = {context: context, fields: options[:fields]} + found_resources = resource_klass.find_to_populate_by_keys(ids, find_opts) + + found_resources.each do |resource| + relationship_data = @resource_klasses[resource_klass][resource.id][:relationships] + + if resource_klass.caching? + serializer_config_key = serializer.config_key(resource_klass).gsub("/", "_") + context_json = resource_klass.attribute_caching_context(context).to_json + context_b64 = JSONAPI.configuration.resource_cache_digest_function.call(context_json) + context_key = "ATTR-CTX-#{context_b64.gsub("/", "_")}" + + writes.push(CachedResponseFragment::Write.new( + resource_klass, + resource, + serializer, + serializer_config_key, + context, + context_key, + relationship_data + )) + end + + register_resource(resource_klass, resource) + end + end + + # Step Five conditionally write to the cache + CachedResponseFragment.write(writes) unless JSONAPI.configuration.resource_cache.nil? + + mark_populated! + self + end + + def mark_populated! + @populated = true + end + + def register_resource(resource_klass, resource, primary = false) + @resource_klasses[resource_klass] ||= {} + @resource_klasses[resource_klass][resource.id] ||= {primary: resource.try(:primary) || primary, relationships: {}} + @resource_klasses[resource_klass][resource.id][:resource] = resource + end + + private + + def report_stats(stats) + return unless JSONAPI.configuration.resource_cache_usage_report_function || JSONAPI.configuration.resource_cache.nil? + + stats.each_pair do |resource_klass, stat| + JSONAPI.configuration.resource_cache_usage_report_function.call( + resource_klass.name, + stat[:hits] || 0, + stat[:misses] || 0 + ) + end + end + + def flatten_resource_tree(resource_tree, flattened_tree = {}) + resource_tree.fragments.each_pair do |resource_rid, fragment| + + resource_klass = resource_rid.resource_klass + id = resource_rid.id + + flattened_tree[resource_klass] ||= {} + + flattened_tree[resource_klass][id] ||= {primary: fragment.primary, relationships: {}} + flattened_tree[resource_klass][id][:cache_id] ||= fragment.cache if fragment.cache + flattened_tree[resource_klass][id][:resource] ||= fragment.resource if fragment.resource + + fragment.related.try(:each_pair) do |relationship_name, related_rids| + flattened_tree[resource_klass][id][:relationships][relationship_name] ||= Set.new + flattened_tree[resource_klass][id][:relationships][relationship_name].merge(related_rids) + end + end + + related_resource_trees = resource_tree.related_resource_trees + related_resource_trees.try(:each_value) do |related_resource_tree| + flatten_resource_tree(related_resource_tree, flattened_tree) + end + + flattened_tree + end + end +end diff --git a/lib/jsonapi/resource_tree.rb b/lib/jsonapi/resource_tree.rb new file mode 100644 index 000000000..a7a9a0b63 --- /dev/null +++ b/lib/jsonapi/resource_tree.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +module JSONAPI + + # A tree structure representing the resource structure of the requested resource(s). This is an intermediate structure + # used to keep track of the resources, by identity, found at different included relationships. It will be flattened and + # the resource instances will be fetched from the cache or the record store. + class ResourceTree + + attr_reader :fragments, :related_resource_trees + + # Gets the related Resource Id Tree for a relationship, and creates it first if it does not exist + # + # @param relationship [JSONAPI::Relationship] + # + # @return [JSONAPI::RelatedResourceTree] the new or existing resource id tree for the requested relationship + def get_related_resource_tree(relationship) + relationship_name = relationship.name.to_sym + @related_resource_trees[relationship_name] ||= RelatedResourceTree.new(relationship, self) + end + + # Adds each Resource Fragment to the Resources hash + # + # @param fragments [Hash] + # @param include_related [Hash] + # + # @return [null] + def add_resource_fragments(fragments, include_related) + fragments.each_value do |fragment| + add_resource_fragment(fragment, include_related) + end + end + + # Adds a Resource Fragment to the fragments hash + # + # @param fragment [JSONAPI::ResourceFragment] + # @param include_related [Hash] + # + # @return [null] + def add_resource_fragment(fragment, include_related) + init_included_relationships(fragment, include_related) + + @fragments[fragment.identity] = fragment + end + + # Adds each Resource to the fragments hash + # + # @param resource [Hash] + # @param include_related [Hash] + # + # @return [null] + def add_resources(resources, include_related) + resources.each do |resource| + add_resource_fragment(JSONAPI::ResourceFragment.new(resource.identity, resource: resource), include_related) + end + end + + # Adds a Resource to the fragments hash + # + # @param fragment [JSONAPI::ResourceFragment] + # @param include_related [Hash] + # + # @return [null] + def add_resource(resource, include_related) + add_resource_fragment(JSONAPI::ResourceFragment.new(resource.identity, resource: resource), include_related) + end + + private + + def init_included_relationships(fragment, include_related) + include_related && include_related.each_key do |relationship_name| + fragment.initialize_related(relationship_name) + end + end + + def load_included(resource_klass, source_resource_tree, include_related, options) + include_related.try(:each_key) do |key| + relationship = resource_klass._relationship(key) + relationship_name = relationship.name.to_sym + + find_related_resource_options = options.except(:filters, :sort_criteria, :paginator) + find_related_resource_options[:sort_criteria] = relationship.resource_klass.default_sort + find_related_resource_options[:cache] = resource_klass.caching? + + related_fragments = resource_klass.find_included_fragments(source_resource_tree.fragments.values, + relationship_name, + find_related_resource_options) + + related_resource_tree = source_resource_tree.get_related_resource_tree(relationship) + related_resource_tree.add_resource_fragments(related_fragments, include_related[key][:include_related]) + + # Now recursively get the related resources for the currently found resources + load_included(relationship.resource_klass, + related_resource_tree, + include_related[relationship_name][:include_related], + options) + end + end + + def add_resources_to_tree(resource_klass, + tree, + resources, + include_related, + source_rid: nil, + source_relationship_name: nil, + connect_source_identity: true) + fragments = {} + + resources.each do |resource| + next unless resource + + # fragments[resource.identity] ||= ResourceFragment.new(resource.identity, resource: resource) + # resource_fragment = fragments[resource.identity] + # ToDo: revert when not needed for testing + resource_fragment = if fragments[resource.identity] + fragments[resource.identity] + else + fragments[resource.identity] = ResourceFragment.new(resource.identity, resource: resource) + fragments[resource.identity] + end + + if resource.class.caching? + resource_fragment.cache = resource.cache_field_value + end + + linkage_relationships = resource_klass.to_one_relationships_for_linkage(resource.class, include_related) + linkage_relationships.each do |relationship_name| + related_resource = resource.send(relationship_name) + resource_fragment.add_related_identity(relationship_name, related_resource&.identity) + end + + if source_rid && connect_source_identity + resource_fragment.add_related_from(source_rid) + source_klass = source_rid.resource_klass + related_relationship_name = source_klass._relationships[source_relationship_name].inverse_relationship + if related_relationship_name + resource_fragment.add_related_identity(related_relationship_name, source_rid) + end + end + end + + tree.add_resource_fragments(fragments, include_related) + end + end + + class PrimaryResourceTree < ResourceTree + + # Creates a PrimaryResourceTree with no resources and no related ResourceTrees + def initialize(fragments: nil, resources: nil, resource: nil, include_related: nil, options: nil) + @fragments ||= {} + @related_resource_trees ||= {} + if fragments || resources || resource + if fragments + add_resource_fragments(fragments, include_related) + end + + if resources + add_resources(resources, include_related) + end + + if resource + add_resource(resource, include_related) + end + + complete_includes!(include_related, options) + end + end + + # Adds a Resource Fragment to the fragments hash + # + # @param fragment [JSONAPI::ResourceFragment] + # @param include_related [Hash] + # + # @return [null] + def add_resource_fragment(fragment, include_related) + fragment.primary = true + super(fragment, include_related) + end + + def complete_includes!(include_related, options) + # ToDo: can we skip if more than one resource_klass found? + resource_klasses = Set.new + @fragments.each_key { |identity| resource_klasses << identity.resource_klass } + + resource_klasses.each { |resource_klass| load_included(resource_klass, self, include_related, options)} + + self + end + end + + class RelatedResourceTree < ResourceTree + + attr_reader :parent_relationship, :source_resource_tree + + # Creates a RelatedResourceTree with no resources and no related ResourceTrees. A connection to the parent + # ResourceTree is maintained. + # + # @param parent_relationship [JSONAPI::Relationship] + # @param source_resource_tree [JSONAPI::ResourceTree] + # + # @return [JSONAPI::RelatedResourceTree] the new or existing resource id tree for the requested relationship + def initialize(parent_relationship, source_resource_tree) + @fragments ||= {} + @related_resource_trees ||= {} + + @parent_relationship = parent_relationship + @parent_relationship_name = parent_relationship.name.to_sym + @source_resource_tree = source_resource_tree + end + + # Adds a Resource Fragment to the fragments hash + # + # @param fragment [JSONAPI::ResourceFragment] + # @param include_related [Hash] + # + # @return [null] + def add_resource_fragment(fragment, include_related) + init_included_relationships(fragment, include_related) + + fragment.related_from.each do |rid| + @source_resource_tree.fragments[rid].add_related_identity(parent_relationship.name, fragment.identity) + end + + if @fragments[fragment.identity] + @fragments[fragment.identity].related_from.merge(fragment.related_from) + fragment.related.each_pair do |relationship_name, rids| + if rids + @fragments[fragment.identity].merge_related_identities(relationship_name, rids) + end + end + else + @fragments[fragment.identity] = fragment + end + end + end +end \ No newline at end of file diff --git a/lib/jsonapi/resources/railtie.rb b/lib/jsonapi/resources/railtie.rb new file mode 100644 index 000000000..a2d92c1c5 --- /dev/null +++ b/lib/jsonapi/resources/railtie.rb @@ -0,0 +1,9 @@ +module JSONAPI + module Resources + class Railtie < Rails::Railtie + rake_tasks do + load 'tasks/check_upgrade.rake' + end + end + end +end \ No newline at end of file diff --git a/lib/jsonapi/resources/version.rb b/lib/jsonapi/resources/version.rb index 631ac0c72..b25e5b708 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.3.0' end end diff --git a/lib/jsonapi/response_document.rb b/lib/jsonapi/response_document.rb index 092645947..3558e5e0a 100644 --- a/lib/jsonapi/response_document.rb +++ b/lib/jsonapi/response_document.rb @@ -1,81 +1,140 @@ +# frozen_string_literal: true + module JSONAPI class ResponseDocument - def initialize(operation_results, serializer, options = {}) - @operation_results = operation_results - @serializer = serializer + attr_reader :serialized_results + + def initialize(options = {}) + @serialized_results = [] + @result_codes = [] + @error_results = [] + @global_errors = [] + @options = options + @top_level_meta = @options.fetch(:base_meta, {}) + @top_level_links = @options.fetch(:base_links, {}) + @key_formatter = @options.fetch(:key_formatter, JSONAPI.configuration.key_formatter) end - def contents - hash = results_to_hash + def has_errors? + @error_results.length.positive? || @global_errors.length.positive? + end - meta = top_level_meta - hash.merge!(meta: meta) unless meta.empty? + def add_result(result, operation) + if result.is_a?(JSONAPI::ErrorsOperationResult) + # Clear any serialized results + @serialized_results = [] - links = top_level_links - hash.merge!(links: links) unless links.empty? + # In JSONAPI v1 we only have one operation so all errors can be kept together + result.errors.each do |error| + add_global_error(error) + end + else + @serialized_results.push result.to_hash(operation.options[:serializer]) + @result_codes.push result.code.to_i + update_links(operation.options[:serializer], result) + update_meta(result) + end + end - hash + def add_global_error(error) + @global_errors.push error end - def status - if @operation_results.has_errors? - @operation_results.all_errors[0].status + def contents + if has_errors? + return { 'errors' => @global_errors } else - @operation_results.results[0].code + hash = @serialized_results[0] + meta = top_level_meta + hash.merge!('meta' => meta) unless meta.empty? + + links = top_level_links + hash.merge!('links' => links) unless links.empty? + + return hash end end - private + def status + status_codes = if has_errors? + @global_errors.collect do |error| + error.status.to_i + end + else + @result_codes + end + + # Count the unique status codes + counts = status_codes.each_with_object(Hash.new(0)) { |code, counts| counts[code] += 1 } + + # if there is only one status code we can return that + return counts.keys[0].to_i if counts.length == 1 + + # :nocov: not currently used + + # if there are many we should return the highest general code, 200, 400, 500 etc. + max_status = 0 + status_codes.each do |status| + code = status.to_i + max_status = code if max_status < code + end + return (max_status / 100).floor * 100 + # :nocov: + end - # Rolls up the top level meta data from the base_meta, the set of operations, - # and the result of each operation. The keys are then formatted. - def top_level_meta - meta = @options.fetch(:base_meta, {}) + private - meta.merge!(@operation_results.meta) + def update_meta(result) + @top_level_meta.merge!(result.meta) - @operation_results.results.each do |result| - meta.merge!(result.meta) + if JSONAPI.configuration.top_level_meta_include_record_count && result.respond_to?(:record_count) + @top_level_meta[JSONAPI.configuration.top_level_meta_record_count_key] = result.record_count + end - if JSONAPI.configuration.top_level_meta_include_record_count && result.respond_to?(:record_count) - meta[JSONAPI.configuration.top_level_meta_record_count_key] = result.record_count - end + if JSONAPI.configuration.top_level_meta_include_page_count && result.respond_to?(:page_count) + @top_level_meta[JSONAPI.configuration.top_level_meta_page_count_key] = result.page_count + end - if JSONAPI.configuration.top_level_meta_include_page_count && result.respond_to?(:page_count) - meta[JSONAPI.configuration.top_level_meta_page_count_key] = result.page_count + if result.warnings.any? + @top_level_meta[:warnings] = result.warnings.collect do |warning| + warning.to_hash end end + end - meta.as_json.deep_transform_keys { |key| @key_formatter.format(key) } + def top_level_meta + @top_level_meta.as_json.deep_transform_keys { |key| @key_formatter.format(key) } end - # Rolls up the top level links from the base_links, the set of operations, - # and the result of each operation. The keys are then formatted. - def top_level_links - links = @options.fetch(:base_links, {}) - - links.merge!(@operation_results.links) - - @operation_results.results.each do |result| - links.merge!(result.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)) - end + def update_links(serializer, result) + @top_level_links.merge!(result.links) + + # Build pagination links + if result.is_a?(JSONAPI::ResourceSetOperationResult) || + result.is_a?(JSONAPI::ResourcesSetOperationResult) || + result.is_a?(JSONAPI::RelatedResourcesSetOperationResult) + + result.pagination_params.each_pair do |link_name, params| + if result.is_a?(JSONAPI::RelatedResourcesSetOperationResult) + 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 + @top_level_links[link_name] = link unless link.blank? end end + end - links.deep_transform_keys { |key| @key_formatter.format(key) } + def top_level_links + @top_level_links.deep_transform_keys { |key| @key_formatter.format(key) } end def query_params(params) @@ -96,40 +155,5 @@ def query_params(params) query_params end - - def results_to_hash - if @operation_results.has_errors? - { errors: @operation_results.all_errors } - else - if @operation_results.results.length == 1 - result = @operation_results.results[0] - - case result - when JSONAPI::ResourceOperationResult - @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::OperationResult - {} - end - - elsif @operation_results.results.length > 1 - resources = [] - @operation_results.results.each do |result| - case result - when JSONAPI::ResourceOperationResult - resources.push(result.resource) - when JSONAPI::ResourcesOperationResult - resources.concat(result.resources) - end - end - - @serializer.serialize_to_hash(resources) - end - end - end end end diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index 77af89e7a..2bc480feb 100644 --- a/lib/jsonapi/routing_ext.rb +++ b/lib/jsonapi/routing_ext.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActionDispatch module Routing class Mapper @@ -18,7 +20,13 @@ def format_route(route) def jsonapi_resource(*resources, &_block) @resource_type = resources.first - res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(@resource_type)) + res = JSONAPI::Resource.resource_klass_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 @@ -33,8 +41,8 @@ def jsonapi_resource(*resources, &_block) end if res._immutable - options[:except] << :create unless options[:except].include?(:create) || options[:except].include?('create') - options[:except] << :update unless options[:except].include?(:update) || options[:except].include?('update') + options[:except] << :create unless options[:except].include?(:create) || options[:except].include?('create') + options[:except] << :update unless options[:except].include?(:update) || options[:except].include?('update') options[:except] << :destroy unless options[:except].include?(:destroy) || options[:except].include?('destroy') end @@ -51,7 +59,7 @@ def jsonapi_resource(*resources, &_block) end else # Rails 5 - jsonapi_resource_scope(SingletonResource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do + jsonapi_resource_scope(SingletonResource.new(@resource_type, api_only?, @scope[:shallow], **options), @resource_type) do if block_given? yield else @@ -64,7 +72,7 @@ def jsonapi_resource(*resources, &_block) end def jsonapi_relationships(options = {}) - res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(@resource_type)) + res = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix(@resource_type)) res._relationships.each do |relationship_name, relationship| if relationship.is_a?(JSONAPI::Relationship::ToMany) jsonapi_links(relationship_name, options) @@ -78,7 +86,13 @@ def jsonapi_relationships(options = {}) def jsonapi_resources(*resources, &_block) @resource_type = resources.first - res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(@resource_type)) + res = JSONAPI::Resource.resource_klass_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 @@ -102,8 +116,8 @@ def jsonapi_resources(*resources, &_block) end if res._immutable - options[:except] << :create unless options[:except].include?(:create) || options[:except].include?('create') - options[:except] << :update unless options[:except].include?(:update) || options[:except].include?('update') + options[:except] << :create unless options[:except].include?(:create) || options[:except].include?('create') + options[:except] << :update unless options[:except].include?(:update) || options[:except].include?('update') options[:except] << :destroy unless options[:except].include?(:destroy) || options[:except].include?('destroy') end @@ -119,7 +133,7 @@ def jsonapi_resources(*resources, &_block) end else # Rails 5 - jsonapi_resource_scope(Resource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do + jsonapi_resource_scope(Resource.new(@resource_type, api_only?, @scope[:shallow], **options), @resource_type) do if block_given? yield else @@ -147,25 +161,26 @@ def jsonapi_link(*links) formatted_relationship_name = format_route(link_type) options = links.extract_options!.dup - res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix) + res = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix) options[:controller] ||= res._type.to_s 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] + 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] + 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 @@ -175,69 +190,77 @@ def jsonapi_links(*links) formatted_relationship_name = format_route(link_type) options = links.extract_options!.dup - res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix) + res = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix) options[:controller] ||= res._type.to_s 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] + 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 def jsonapi_related_resource(*relationship) - source = JSONAPI::Resource.resource_for(resource_type_with_module_prefix) + source = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix) options = relationship.extract_options!.dup relationship_name = relationship.first relationship = source._relationships[relationship_name] + relationship._routed = true + formatted_relationship_name = format_route(relationship.name) if relationship.polymorphic? options[:controller] ||= relationship.class_name.underscore.pluralize else - related_resource = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(relationship.class_name.underscore.pluralize)) + related_resource = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix(relationship.class_name.underscore.pluralize)) 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: 'show_related_resource', via: [:get], + as: "related/#{relationship_name}" end def jsonapi_related_resources(*relationship) - source = JSONAPI::Resource.resource_for(resource_type_with_module_prefix) + source = JSONAPI::Resource.resource_klass_for(resource_type_with_module_prefix) options = relationship.extract_options!.dup 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)) + related_resource = JSONAPI::Resource.resource_klass_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: 'index_related_resources', via: [:get], + as: "related/#{relationship_name}" end protected @@ -250,6 +273,7 @@ def jsonapi_resource_scope(resource, resource_type) #:nodoc: @scope = @scope.parent end # :nocov: + private def resource_type_with_module_prefix(resource = nil) diff --git a/lib/sanger-jsonapi-resources.rb b/lib/sanger-jsonapi-resources.rb new file mode 100644 index 000000000..2b4763cf6 --- /dev/null +++ b/lib/sanger-jsonapi-resources.rb @@ -0,0 +1,7 @@ +# As we are packaging 'sanger-jsonapi-resources' as a separate gem, RubyGems expects +# the main file to be 'lib/sanger-jsonapi-resources.rb' to match the gem name. +# Without this file, requiring the gem or Rails autoloading would fail, even if the internal code is unchanged. +# This file exists to ensure compatibility with RubyGems and Bundler. +# The easiest solution is to use this wrapper file, which simply requires the original 'jsonapi-resources' code, +# so all internal references and modules remain unchanged and compatible. +require_relative 'jsonapi-resources' diff --git a/lib/tasks/check_upgrade.rake b/lib/tasks/check_upgrade.rake new file mode 100644 index 000000000..34ddef4a6 --- /dev/null +++ b/lib/tasks/check_upgrade.rake @@ -0,0 +1,52 @@ +require 'rake' +require 'jsonapi-resources' + +namespace :jsonapi do + namespace :resources do + desc 'Checks application for orphaned overrides' + task :check_upgrade => :environment do + Rails.application.eager_load! + + resource_klasses = ObjectSpace.each_object(Class).select { |klass| klass < JSONAPI::Resource} + + puts "Checking #{resource_klasses.count} resources" + + issues_found = 0 + + klasses_with_deprecated = resource_klasses.select { |klass| klass.methods.include?(:find_records) } + unless klasses_with_deprecated.empty? + puts " Found the following resources the still implement `find_records`:" + klasses_with_deprecated.each { |klass| puts " #{klass}"} + puts " The `find_records` method is no longer called by JR. Please review and ensure your functionality is ported over." + + issues_found = issues_found + klasses_with_deprecated.length + end + + klasses_with_deprecated = resource_klasses.select { |klass| klass.methods.include?(:records_for) } + unless klasses_with_deprecated.empty? + puts " Found the following resources the still implement `records_for`:" + klasses_with_deprecated.each { |klass| puts " #{klass}"} + puts " The `records_for` method is no longer called by JR. Please review and ensure your functionality is ported over." + + issues_found = issues_found + klasses_with_deprecated.length + end + + klasses_with_deprecated = resource_klasses.select { |klass| klass.methods.include?(:apply_includes) } + unless klasses_with_deprecated.empty? + puts " Found the following resources the still implement `apply_includes`:" + klasses_with_deprecated.each { |klass| puts " #{klass}"} + puts " The `apply_includes` method is no longer called by JR. Please review and ensure your functionality is ported over." + + issues_found = issues_found + klasses_with_deprecated.length + end + + if issues_found > 0 + puts "Finished inspection. #{issues_found} issues found that may impact upgrading. Please address these issues. " + else + puts "Finished inspection with no issues found. Note this is only a cursory check for method overrides that will no \n" \ + "longer be called by JSONAPI::Resources. This check in no way assures your code will continue to function as \n" \ + "it did before the upgrade. Please do adequate testing before using in production." + end + end + end +end diff --git a/locales/en.yml b/locales/en.yml index 7a03182a6..065b37314 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -15,10 +15,7 @@ en: detail: "All requests must use the '%{needed_media_type}' Accept without media type parameters. This request specified '%{media_type}'." unsupported_media_type: title: 'Unsupported media type' - detail: "All requests that create or update must use the '%{needed_media_type}' Content-Type. This request specified '%{media_type}.'" - has_many_relation: - title: 'Relation exists' - detail: "The relation to %{id} already exists." + detail: "All requests that create or update must use the '%{needed_media_type}' Content-Type. This request specified '%{media_type}'." to_many_set_replacement_forbidden: title: 'Complete replacement forbidden' detail: 'Complete replacement forbidden for this relationship' @@ -49,13 +46,16 @@ en: invalid_field: title: 'Invalid field' detail: "%{field} is not a valid field for %{type}." + invalid_relationship: + title: 'Invalid relationship' + detail: "%{relationship_name} is not a valid relationship for %{type}." invalid_include: - title: 'Invalid field' - detail: "%{relationship} is not a valid relationship of %{resource}" + title: 'Invalid include' + detail: "%{relationship} is not a valid includable relationship of %{resource}" 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/config/database.yml b/test/config/database.yml deleted file mode 100644 index 0cda30abf..000000000 --- a/test/config/database.yml +++ /dev/null @@ -1,5 +0,0 @@ -test: - adapter: sqlite3 - database: test_db - pool: 5 - timeout: 5000 diff --git a/test/controllers/controller_test.rb b/test/controllers/controller_test.rb index 83c0bdfac..e2568f979 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -1,12 +1,21 @@ require File.expand_path('../../test_helper', __FILE__) def set_content_type_header! - @request.headers['Content-Type'] = 'application/vnd.api+json' + @request.headers['Content-Type'] = JSONAPI::MEDIA_TYPE end class PostsControllerTest < ActionController::TestCase def setup + super JSONAPI.configuration.raise_if_parameters_not_allowed = true + JSONAPI.configuration.always_include_to_one_linkage_data = false + end + + def test_links_include_relative_root + Rails.application.config.relative_url_root = '/subdir' + assert_cacheable_get :index + assert json_response['data'][0]['links']['self'].include?('/subdir') + Rails.application.config.relative_url_root = nil end def test_index @@ -15,6 +24,12 @@ def test_index assert json_response['data'].is_a?(Array) end + def test_index_includes + assert_cacheable_get :index, params: { include: 'author,comments' } + assert_response :success + assert json_response['data'].is_a?(Array) + end + def test_accept_header_missing @request.headers['Accept'] = nil @@ -72,26 +87,40 @@ def test_accept_header_not_jsonapi assert_equal "All requests must use the '#{JSONAPI::MEDIA_TYPE}' Accept without media type parameters. This request specified '#{@request.headers['Accept']}'.", json_response['errors'][0]['detail'] end - def test_exception_class_whitelist - original_whitelist = JSONAPI.configuration.exception_class_whitelist.dup + def test_exception_class_allowlist + original_allowlist = JSONAPI.configuration.exception_class_allowlist.dup $PostProcessorRaisesErrors = true # test that the operations dispatcher rescues the error when it - # has not been added to the exception_class_whitelist + # has not been added to the exception_class_allowlist assert_cacheable_get :index assert_response 500 # test that the operations dispatcher does not rescue the error when it - # has been added to the exception_class_whitelist - JSONAPI.configuration.exception_class_whitelist << PostsController::SpecialError + # has been added to the exception_class_allowlist + JSONAPI.configuration.exception_class_allowlist << PostsController::SpecialError + assert_cacheable_get :index + assert_response 403 + ensure + $PostProcessorRaisesErrors = false + JSONAPI.configuration.exception_class_allowlist = original_allowlist + end + + def test_allow_all_exceptions + original_config = JSONAPI.configuration.allow_all_exceptions + $PostProcessorRaisesErrors = true + assert_cacheable_get :index + assert_response 500 + + JSONAPI.configuration.allow_all_exceptions = true assert_cacheable_get :index assert_response 403 ensure $PostProcessorRaisesErrors = false - JSONAPI.configuration.exception_class_whitelist = original_whitelist + JSONAPI.configuration.allow_all_exceptions = original_config end def test_whitelist_all_exceptions - original_config = JSONAPI.configuration.whitelist_all_exceptions + original_config = JSONAPI.configuration.allow_all_exceptions $PostProcessorRaisesErrors = true assert_cacheable_get :index assert_response 500 @@ -104,6 +133,21 @@ def test_whitelist_all_exceptions JSONAPI.configuration.whitelist_all_exceptions = original_config end + def test_exception_added_to_request_env + original_config = JSONAPI.configuration.allow_all_exceptions + $PostProcessorRaisesErrors = true + refute @request.env['action_dispatch.exception'] + assert_cacheable_get :index + assert @request.env['action_dispatch.exception'] + + JSONAPI.configuration.allow_all_exceptions = true + assert_cacheable_get :index + assert @request.env['action_dispatch.exception'] + ensure + $PostProcessorRaisesErrors = false + JSONAPI.configuration.allow_all_exceptions = original_config + end + def test_exception_includes_backtrace_when_enabled original_config = JSONAPI.configuration.include_backtraces_in_errors $PostProcessorRaisesErrors = true @@ -111,21 +155,40 @@ def test_exception_includes_backtrace_when_enabled JSONAPI.configuration.include_backtraces_in_errors = true assert_cacheable_get :index assert_response 500 - assert_includes @response.body, "backtrace", "expected backtrace in error body" + assert_includes @response.body, '"backtrace"', "expected backtrace in error body" JSONAPI.configuration.include_backtraces_in_errors = false assert_cacheable_get :index assert_response 500 - refute_includes @response.body, "backtrace", "expected backtrace in error body" + refute_includes @response.body, '"backtrace"', "expected backtrace in error body" ensure $PostProcessorRaisesErrors = false JSONAPI.configuration.include_backtraces_in_errors = original_config end + def test_exception_includes_application_backtrace_when_enabled + original_config = JSONAPI.configuration.include_application_backtraces_in_errors + $PostProcessorRaisesErrors = true + + JSONAPI.configuration.include_application_backtraces_in_errors = true + assert_cacheable_get :index + assert_response 500 + assert_includes @response.body, '"application_backtrace"', "expected application backtrace in error body" + + JSONAPI.configuration.include_application_backtraces_in_errors = false + assert_cacheable_get :index + assert_response 500 + refute_includes @response.body, '"application_backtrace"', "expected application backtrace in error body" + + ensure + $PostProcessorRaisesErrors = false + JSONAPI.configuration.include_application_backtraces_in_errors = original_config + end + def test_on_server_error_block_callback_with_exception original_config = JSONAPI.configuration.dup - JSONAPI.configuration.exception_class_whitelist = [] + JSONAPI.configuration.exception_class_allowlist = [] $PostProcessorRaisesErrors = true @controller.class.instance_variable_set(:@callback_message, "none") @@ -146,7 +209,7 @@ def test_on_server_error_block_callback_with_exception def test_on_server_error_method_callback_with_exception original_config = JSONAPI.configuration.dup - JSONAPI.configuration.exception_class_whitelist = [] + JSONAPI.configuration.exception_class_allowlist = [] $PostProcessorRaisesErrors = true #ignores methods that don't exist @@ -165,7 +228,7 @@ def test_on_server_error_method_callback_with_exception def test_on_server_error_method_callback_with_exception_on_serialize original_config = JSONAPI.configuration.dup - JSONAPI.configuration.exception_class_whitelist = [] + JSONAPI.configuration.exception_class_allowlist = [] $PostSerializerRaisesErrors = true #ignores methods that don't exist @@ -197,6 +260,13 @@ def test_on_server_error_callback_without_exception $PostProcessorRaisesErrors = false end + def test_posts_index_include + assert_cacheable_get :index, params: {filter: {id: '10,12'}, include: 'author'} + assert_response :success + assert_equal 2, json_response['data'].size + assert_equal 2, json_response['included'].size + end + def test_index_filter_with_empty_result assert_cacheable_get :index, params: {filter: {title: 'post that does not exist'}} assert_response :success @@ -204,7 +274,7 @@ def test_index_filter_with_empty_result assert_equal 0, json_response['data'].size end - def test_index_filter_by_id + def test_index_filter_by_single_id assert_cacheable_get :index, params: {filter: {id: '1'}} assert_response :success assert json_response['data'].is_a?(Array) @@ -225,7 +295,7 @@ def test_index_filter_with_hash_values assert_equal 1, json_response['data'].size end - def test_index_filter_by_ids + def test_index_filter_by_array_of_ids assert_cacheable_get :index, params: {filter: {ids: '1,2'}} assert_response :success assert json_response['data'].is_a?(Array) @@ -255,14 +325,14 @@ def test_index_filter_not_allowed end def test_index_include_one_level_query_count - assert_query_count(2) do + assert_query_count(4) do assert_cacheable_get :index, params: {include: 'author'} end assert_response :success end def test_index_include_two_levels_query_count - assert_query_count(3) do + assert_query_count(6) do assert_cacheable_get :index, params: {include: 'author,author.comments'} end assert_response :success @@ -313,8 +383,8 @@ def test_index_filter_by_ids_and_fields_2 end def test_filter_relationship_single - assert_query_count(1) do - assert_cacheable_get :index, params: {filter: {tags: '5,1'}} + assert_query_count(2) do + assert_cacheable_get :index, params: {filter: {tags: '505,501'}} end assert_response :success assert_equal 3, json_response['data'].size @@ -324,8 +394,8 @@ def test_filter_relationship_single end def test_filter_relationships_multiple - assert_query_count(1) do - assert_cacheable_get :index, params: {filter: {tags: '5,1', comments: '3'}} + assert_query_count(2) do + assert_cacheable_get :index, params: {filter: {tags: '505,501', comments: '3'}} end assert_response :success assert_equal 1, json_response['data'].size @@ -333,7 +403,7 @@ def test_filter_relationships_multiple end def test_filter_relationships_multiple_not_found - assert_cacheable_get :index, params: {filter: {tags: '1', comments: '3'}} + assert_cacheable_get :index, params: {filter: {tags: '501', comments: '3'}} assert_response :success assert_equal 0, json_response['data'].size end @@ -381,7 +451,7 @@ def test_resource_not_supported end def test_index_filter_on_relationship - assert_cacheable_get :index, params: {filter: {author: '1'}} + assert_cacheable_get :index, params: {filter: {author: '1001'}} assert_response :success assert_equal 3, json_response['data'].size end @@ -396,7 +466,7 @@ def test_sorting_asc assert_cacheable_get :index, params: {sort: 'title'} assert_response :success - assert_equal "A First Post", json_response['data'][0]['attributes']['title'] + assert_equal "A 1ST Post", json_response['data'][0]['attributes']['title'] end def test_sorting_desc @@ -410,7 +480,7 @@ def test_sorting_by_multiple_fields assert_cacheable_get :index, params: {sort: 'title,body'} assert_response :success - assert_equal '14', json_response['data'][0]['id'] + assert_equal '15', json_response['data'][0]['id'] end def create_alphabetically_first_user_and_post @@ -423,9 +493,16 @@ 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_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' + assert json_response['data'].length > 10, 'there are enough records to show sort' + + # Postgres sorts nulls last, whereas sqlite and mysql sort nulls first + if ENV['DATABASE_URL'].starts_with?('postgres') + assert_equal '17', json_response['data'][-1]['id'], 'nil is at the start' + assert_equal post.id.to_s, json_response['data'][0]['id'], 'alphabetically first user is not first' + else + assert_equal '17', json_response['data'][0]['id'], 'nil is at the end' + assert_equal post.id.to_s, json_response['data'][1]['id'], 'alphabetically first user is second' + end end def test_desc_sorting_by_relationship_field @@ -434,8 +511,31 @@ def test_desc_sorting_by_relationship_field assert_response :success assert json_response['data'].length > 10, 'there are enough records to show sort' - assert_equal '17', json_response['data'][-1]['id'], 'nil is at the bottom' - assert_equal post.id.to_s, json_response['data'][-2]['id'], 'alphabetically first user is second last' + + # Postgres sorts nulls last, whereas sqlite and mysql sort nulls first + if ENV['DATABASE_URL'].starts_with?('postgres') + assert_equal '17', json_response['data'][0]['id'], 'nil is at the start' + assert_equal post.id.to_s, json_response['data'][-1]['id'] + else + assert_equal '17', json_response['data'][-1]['id'], 'nil is at the end' + assert_equal post.id.to_s, json_response['data'][-2]['id'], 'alphabetically first user is second last' + end + 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' + + if ENV['DATABASE_URL'].starts_with?('postgres') + assert_equal '17', json_response['data'][-1]['id'], 'nil is at the top' + assert_equal post.id.to_s, json_response['data'][0]['id'] + else + 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 end def test_invalid_sort_param @@ -460,7 +560,7 @@ def test_excluded_sort_param assert_match /id is not a valid sort criteria for post/, response.body end - def test_show_single + def test_show_single_no_includes assert_cacheable_get :show, params: {id: '1'} assert_response :success assert json_response['data'].is_a?(Hash) @@ -473,7 +573,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,12 +582,30 @@ 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 - def test_show_single_with_includes + def test_show_single_with_has_one_include_included_exists + assert_cacheable_get :show, params: {id: '1', include: 'author'} + assert_response :success + assert_equal 1, json_response['included'].size + assert json_response['data']['relationships']['author'].has_key?('data'), 'Missing required data key' + refute_nil json_response['data']['relationships']['author']['data'], 'Data should not be nil' + refute json_response['data']['relationships']['tags'].has_key?('data'), 'Not included relationships should not have data' + end + + def test_show_single_with_has_one_include_included_does_not_exist + assert_cacheable_get :show, params: {id: '1', include: 'section'} + assert_response :success + assert_nil json_response['included'] + assert json_response['data']['relationships']['section'].has_key?('data'), 'Missing required data key' + assert_nil json_response['data']['relationships']['section']['data'], 'Data should be nil' + refute json_response['data']['relationships']['tags'].has_key?('data'), 'Not included relationships should not have data' + end + + def test_show_single_with_has_many_include assert_cacheable_get :show, params: {id: '1', include: 'comments'} assert_response :success assert json_response['data'].is_a?(Hash) @@ -499,12 +617,50 @@ def test_show_single_with_includes assert_equal 2, json_response['included'].size end + def test_includes_for_empty_relationships_shows_but_are_empty + assert_cacheable_get :show, params: {id: '17', include: 'author,tags'} + + assert_response :success + assert json_response['data']['relationships']['author'].has_key?('data'), 'data key should exist for empty has_one relationship' + assert_nil json_response['data']['relationships']['author']['data'], 'Data should be null' + assert json_response['data']['relationships']['tags'].has_key?('data'), 'data key should exist for empty has_many relationship' + assert json_response['data']['relationships']['tags']['data'].is_a?(Array), 'Data should be array' + assert json_response['data']['relationships']['tags']['data'].empty?, 'Data array should be empty' + end + def test_show_single_with_include_disallowed + original_config = JSONAPI.configuration.dup JSONAPI.configuration.allow_include = false assert_cacheable_get :show, params: {id: '1', include: 'comments'} assert_response :bad_request ensure - JSONAPI.configuration.allow_include = true + JSONAPI.configuration = original_config + end + + def test_show_single_include_linkage + JSONAPI.configuration.always_include_to_one_linkage_data = true + + assert_cacheable_get :show, params: {id: '17'} + assert_response :success + assert json_response['data']['relationships']['author'].has_key?('data'), 'data key should exist for empty has_one relationship' + assert_nil json_response['data']['relationships']['author']['data'], 'Data should be null' + refute json_response['data']['relationships']['tags'].has_key?('data'), 'data key should not exist for empty has_many relationship if not included' + + ensure + JSONAPI.configuration.always_include_to_one_linkage_data = false + end + + def test_index_single_include_linkage + JSONAPI.configuration.always_include_to_one_linkage_data = true + + assert_cacheable_get :index, params: { filter: { id: '17'} } + assert_response :success + assert json_response['data'][0]['relationships']['author'].has_key?('data'), 'data key should exist for empty has_one relationship' + assert_nil json_response['data'][0]['relationships']['author']['data'], 'Data should be null' + refute json_response['data'][0]['relationships']['tags'].has_key?('data'), 'data key should not exist for empty has_many relationship if not included' + + ensure + JSONAPI.configuration.always_include_to_one_linkage_data = false end def test_show_single_with_fields @@ -555,7 +711,7 @@ def test_create_simple body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } } } @@ -579,14 +735,14 @@ def test_create_simple_id_not_allowed body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } } } 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 +764,27 @@ 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_bad_relationship_array + set_content_type_header! + put :create, params: + { + data: { + type: 'posts', + attributes: { + title: 'A poorly formed new Post' + }, + relationships: { + author: {data: {type: 'people', id: '1003'}}, + tags: [] + } + } + } + + assert_response :bad_request + assert_match /Data is not a valid Links Object./, response.body end def test_create_extra_param @@ -623,14 +799,14 @@ def test_create_extra_param body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } } } 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 @@ -648,7 +824,7 @@ def test_create_extra_param_allow_extra_params body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } }, include: 'author' @@ -656,7 +832,7 @@ def test_create_extra_param_allow_extra_params assert_response :created assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['author']['data']['id'] + assert_equal '1003', 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'] @@ -697,7 +873,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 @@ -712,7 +888,7 @@ def test_create_multiple body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } }, { @@ -722,7 +898,7 @@ def test_create_multiple body: 'Ember is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } } ] @@ -743,14 +919,14 @@ def test_create_simple_missing_posts body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } } } 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 @@ -764,14 +940,14 @@ def test_create_simple_wrong_type body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } } } 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 @@ -784,14 +960,14 @@ def test_create_simple_missing_type body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } } } 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 @@ -805,14 +981,14 @@ def test_create_simple_unpermitted_attributes body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } } } 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 @@ -829,7 +1005,7 @@ def test_create_simple_unpermitted_attributes_allow_extra_params body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}} + author: {data: {type: 'people', id: '1003'}} } }, include: 'author' @@ -837,7 +1013,7 @@ def test_create_simple_unpermitted_attributes_allow_extra_params assert_response :created assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['author']['data']['id'] + assert_equal '1003', json_response['data']['relationships']['author']['data']['id'] assert_equal 'JR is Great', json_response['data']['attributes']['title'] assert_equal 'JR is Great', json_response['data']['attributes']['subject'] assert_equal 'JSONAPIResources is the greatest thing since unsliced bread.', json_response['data']['attributes']['body'] @@ -863,8 +1039,8 @@ def test_create_with_links_to_many_type_ids body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}}, - tags: {data: [{type: 'tags', id: 3}, {type: 'tags', id: 4}]} + author: {data: {type: 'people', id: '1003'}}, + tags: {data: [{type: 'tags', id: 503}, {type: 'tags', id: 504}]} } }, include: 'author' @@ -872,7 +1048,7 @@ def test_create_with_links_to_many_type_ids assert_response :created assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['author']['data']['id'] + assert_equal '1003', 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 @@ -889,8 +1065,8 @@ def test_create_with_links_to_many_array body: 'JSONAPIResources is the greatest thing since unsliced bread.' }, relationships: { - author: {data: {type: 'people', id: '3'}}, - tags: {data: [{type: 'tags', id: 3}, {type: 'tags', id: 4}]} + author: {data: {type: 'people', id: '1003'}}, + tags: {data: [{type: 'tags', id: 503}, {type: 'tags', id: 504}]} } }, include: 'author' @@ -898,7 +1074,7 @@ def test_create_with_links_to_many_array assert_response :created assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['author']['data']['id'] + assert_equal '1003', 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 @@ -915,8 +1091,8 @@ def test_create_with_links_include_and_fields body: 'JSONAPIResources is the greatest thing since unsliced bread!' }, relationships: { - author: {data: {type: 'people', id: '3'}}, - tags: {data: [{type: 'tags', id: 3}, {type: 'tags', id: 4}]} + author: {data: {type: 'people', id: '1003'}}, + tags: {data: [{type: 'tags', id: 503}, {type: 'tags', id: 504}]} } }, include: 'author,author.posts', @@ -925,7 +1101,7 @@ def test_create_with_links_include_and_fields assert_response :created assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['author']['data']['id'] + assert_equal '1003', 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 @@ -946,7 +1122,7 @@ def test_update_with_links }, relationships: { section: {data: {type: 'sections', id: "#{javascript.id}"}}, - tags: {data: [{type: 'tags', id: 3}, {type: 'tags', id: 4}]} + tags: {data: [{type: 'tags', id: 503}, {type: 'tags', id: 504}]} } }, include: 'tags,author,section' @@ -954,11 +1130,11 @@ def test_update_with_links assert_response :success assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['author']['data']['id'] + assert_equal '1003', json_response['data']['relationships']['author']['data']['id'] assert_equal javascript.id.to_s, json_response['data']['relationships']['section']['data']['id'] assert_equal 'A great new Post', json_response['data']['attributes']['title'] assert_equal 'AAAA', json_response['data']['attributes']['body'] - assert matches_array?([{'type' => 'tags', 'id' => '3'}, {'type' => 'tags', 'id' => '4'}], + assert matches_array?([{'type' => 'tags', 'id' => '503'}, {'type' => 'tags', 'id' => '504'}], json_response['data']['relationships']['tags']['data']) end @@ -1002,7 +1178,7 @@ def test_update_with_links_allow_extra_params }, relationships: { section: {data: {type: 'sections', id: "#{javascript.id}"}}, - tags: {data: [{type: 'tags', id: 3}, {type: 'tags', id: 4}]} + tags: {data: [{type: 'tags', id: 503}, {type: 'tags', id: 504}]} } }, include: 'tags,author,section' @@ -1010,11 +1186,11 @@ def test_update_with_links_allow_extra_params assert_response :success assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['author']['data']['id'] + assert_equal '1003', json_response['data']['relationships']['author']['data']['id'] assert_equal javascript.id.to_s, json_response['data']['relationships']['section']['data']['id'] assert_equal 'A great new Post', json_response['data']['attributes']['title'] assert_equal 'AAAA', json_response['data']['attributes']['body'] - assert matches_array?([{'type' => 'tags', 'id' => '3'}, {'type' => 'tags', 'id' => '4'}], + assert matches_array?([{'type' => 'tags', 'id' => '503'}, {'type' => 'tags', 'id' => '504'}], json_response['data']['relationships']['tags']['data']) @@ -1041,7 +1217,7 @@ def test_update_remove_links }, relationships: { section: {data: {type: 'sections', id: 1}}, - tags: {data: [{type: 'tags', id: 3}, {type: 'tags', id: 4}]} + tags: {data: [{type: 'tags', id: 503}, {type: 'tags', id: 504}]} } }, include: 'tags' @@ -1067,7 +1243,7 @@ def test_update_remove_links }, relationships: { section: nil, - tags: [] + tags: {data: []} } }, include: 'tags,author,section' @@ -1075,12 +1251,13 @@ 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_equal '1003', json_response['data']['relationships']['author']['data']['id'] + 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?([], - json_response['data']['relationships']['tags']['data']) + + # Todo: determine if we should preserve the empty array when included data is included + # assert matches_array?([], json_response['data']['relationships']['tags']['data']) end def test_update_relationship_to_one @@ -1106,7 +1283,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 @@ -1127,7 +1304,7 @@ def test_update_relationship_to_one_invalid_links_hash_count def test_update_relationship_to_many_not_array set_content_type_header! - put :update_relationship, params: {post_id: 3, relationship: 'tags', data: {type: 'tags', id: 2}} + put :update_relationship, params: {post_id: 3, relationship: 'tags', data: {type: 'tags', id: 502}} assert_response :bad_request assert_match /Invalid Links Object/, response.body @@ -1223,7 +1400,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 +1413,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 +1427,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 +1444,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: []} @@ -1275,46 +1471,46 @@ def test_update_relationship_to_many_join_table_single post_object = Post.find(3) assert_equal 0, post_object.tags.length - put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 2}]} + put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 502}]} assert_response :no_content post_object = Post.find(3) assert_equal 1, post_object.tags.length - put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 5}]} + put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 505}]} assert_response :no_content post_object = Post.find(3) tags = post_object.tags.collect { |tag| tag.id } assert_equal 1, tags.length - assert matches_array? [5], tags + assert matches_array? [505], tags end def test_update_relationship_to_many set_content_type_header! - put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 3}]} + put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 502}, {type: 'tags', id: 503}]} assert_response :no_content post_object = Post.find(3) assert_equal 2, post_object.tags.collect { |tag| tag.id }.length - assert matches_array? [2, 3], post_object.tags.collect { |tag| tag.id } + assert matches_array? [502, 503], post_object.tags.collect { |tag| tag.id } end def test_create_relationship_to_many_join_table set_content_type_header! - put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 3}]} + put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 502}, {type: 'tags', id: 503}]} assert_response :no_content post_object = Post.find(3) assert_equal 2, post_object.tags.collect { |tag| tag.id }.length - assert matches_array? [2, 3], post_object.tags.collect { |tag| tag.id } + assert matches_array? [502, 503], post_object.tags.collect { |tag| tag.id } - post :create_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 5}]} + post :create_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 505}]} assert_response :no_content post_object = Post.find(3) assert_equal 3, post_object.tags.collect { |tag| tag.id }.length - assert matches_array? [2, 3, 5], post_object.tags.collect { |tag| tag.id } + assert matches_array? [502, 503, 505], post_object.tags.collect { |tag| tag.id } end def test_create_relationship_to_many_join_table_reflect @@ -1323,12 +1519,12 @@ def test_create_relationship_to_many_join_table_reflect post_object = Post.find(15) assert_equal 5, post_object.tags.collect { |tag| tag.id }.length - put :update_relationship, params: {post_id: 15, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 3}, {type: 'tags', id: 4}]} + put :update_relationship, params: {post_id: 15, relationship: 'tags', data: [{type: 'tags', id: 502}, {type: 'tags', id: 503}, {type: 'tags', id: 504}]} assert_response :no_content post_object = Post.find(15) assert_equal 3, post_object.tags.collect { |tag| tag.id }.length - assert matches_array? [2, 3, 4], post_object.tags.collect { |tag| tag.id } + assert matches_array? [502, 503, 504], post_object.tags.collect { |tag| tag.id } ensure JSONAPI.configuration.use_relationship_reflection = false end @@ -1343,7 +1539,7 @@ def test_create_relationship_to_many_mismatched_type def test_create_relationship_to_many_missing_id set_content_type_header! - post :create_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', idd: 5}]} + post :create_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', idd: 505}]} assert_response :bad_request assert_match /Data is not a valid Links Object./, response.body @@ -1351,7 +1547,7 @@ def test_create_relationship_to_many_missing_id def test_create_relationship_to_many_not_array set_content_type_header! - post :create_relationship, params: {post_id: 3, relationship: 'tags', data: {type: 'tags', id: 5}} + post :create_relationship, params: {post_id: 3, relationship: 'tags', data: {type: 'tags', id: 505}} assert_response :bad_request assert_match /Data is not a valid Links Object./, response.body @@ -1371,11 +1567,11 @@ def test_create_relationship_to_many_join_table_no_reflection p = Post.find(4) assert_equal [], p.tag_ids - post :create_relationship, params: {post_id: 4, relationship: 'tags', data: [{type: 'tags', id: 1}, {type: 'tags', id: 2}, {type: 'tags', id: 3}]} + post :create_relationship, params: {post_id: 4, relationship: 'tags', data: [{type: 'tags', id: 501}, {type: 'tags', id: 502}, {type: 'tags', id: 503}]} assert_response :no_content p.reload - assert_equal [1,2,3], p.tag_ids + assert_equal [501,502,503], p.tag_ids ensure JSONAPI.configuration.use_relationship_reflection = false end @@ -1386,11 +1582,11 @@ def test_create_relationship_to_many_join_table_reflection p = Post.find(4) assert_equal [], p.tag_ids - post :create_relationship, params: {post_id: 4, relationship: 'tags', data: [{type: 'tags', id: 1}, {type: 'tags', id: 2}, {type: 'tags', id: 3}]} + post :create_relationship, params: {post_id: 4, relationship: 'tags', data: [{type: 'tags', id: 501}, {type: 'tags', id: 502}, {type: 'tags', id: 503}]} assert_response :no_content p.reload - assert_equal [1,2,3], p.tag_ids + assert_equal [501,502,503], p.tag_ids ensure JSONAPI.configuration.use_relationship_reflection = false end @@ -1427,17 +1623,18 @@ def test_create_relationship_to_many_reflection def test_create_relationship_to_many_join_table_record_exists set_content_type_header! - put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 3}]} + put :update_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 502}, {type: 'tags', id: 503}]} assert_response :no_content post_object = Post.find(3) assert_equal 2, post_object.tags.collect { |tag| tag.id }.length - assert matches_array? [2, 3], post_object.tags.collect { |tag| tag.id } + assert matches_array? [502, 503], post_object.tags.collect { |tag| tag.id } - post :create_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 5}]} + post :create_relationship, params: {post_id: 3, relationship: 'tags', data: [{type: 'tags', id: 502}, {type: 'tags', id: 505}]} - assert_response :bad_request - assert_match /The relation to 2 already exists./, response.body + assert_response :no_content + post_object.reload + assert_equal [502,503,505], post_object.tag_ids end def test_update_relationship_to_many_missing_tags @@ -1455,42 +1652,42 @@ def test_delete_relationship_to_many post_id: 14, relationship: 'tags', data: [ - {type: 'tags', id: 2}, - {type: 'tags', id: 3}, - {type: 'tags', id: 4} + {type: 'tags', id: 502}, + {type: 'tags', id: 503}, + {type: 'tags', id: 504} ] } assert_response :no_content p = Post.find(14) - assert_equal [2, 3, 4], p.tag_ids + assert_equal [502, 503, 504], p.tag_ids delete :destroy_relationship, params: { post_id: 14, relationship: 'tags', data: [ - {type: 'tags', id: 3}, - {type: 'tags', id: 4} + {type: 'tags', id: 503}, + {type: 'tags', id: 504} ] } p.reload assert_response :no_content - assert_equal [2], p.tag_ids + assert_equal [502], p.tag_ids end def test_delete_relationship_to_many_with_relationship_url_not_matching_type set_content_type_header! # Reflection turned off since tags doesn't have the inverse relationship PostResource.has_many :special_tags, relation_name: :special_tags, class_name: "Tag", reflect: false - post :create_relationship, params: {post_id: 14, relationship: 'special_tags', data: [{type: 'tags', id: 2}]} + post :create_relationship, params: {post_id: 14, relationship: 'special_tags', data: [{type: 'tags', id: 502}]} #check the relationship was created successfully assert_equal 1, Post.find(14).special_tags.count before_tags = Post.find(14).tags.count - delete :destroy_relationship, params: {post_id: 14, relationship: 'special_tags', data: [{type: 'tags', id: 2}]} + delete :destroy_relationship, params: {post_id: 14, relationship: 'special_tags', data: [{type: 'tags', id: 502}]} assert_equal 0, Post.find(14).special_tags.count, "Relationship that matches URL relationship not destroyed" #check that the tag association is not affected @@ -1501,24 +1698,24 @@ def test_delete_relationship_to_many_with_relationship_url_not_matching_type def test_delete_relationship_to_many_does_not_exist set_content_type_header! - put :update_relationship, params: {post_id: 14, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 3}]} + put :update_relationship, params: {post_id: 14, relationship: 'tags', data: [{type: 'tags', id: 502}, {type: 'tags', id: 503}]} assert_response :no_content p = Post.find(14) - assert_equal [2, 3], p.tag_ids + assert_equal [502, 503], p.tag_ids - delete :destroy_relationship, params: {post_id: 14, relationship: 'tags', data: [{type: 'tags', id: 4}]} + delete :destroy_relationship, params: {post_id: 14, relationship: 'tags', data: [{type: 'tags', id: 504}]} p.reload assert_response :not_found - assert_equal [2, 3], p.tag_ids + assert_equal [502, 503], p.tag_ids end def test_delete_relationship_to_many_with_empty_data set_content_type_header! - put :update_relationship, params: {post_id: 14, relationship: 'tags', data: [{type: 'tags', id: 2}, {type: 'tags', id: 3}]} + put :update_relationship, params: {post_id: 14, relationship: 'tags', data: [{type: 'tags', id: 502}, {type: 'tags', id: 503}]} assert_response :no_content p = Post.find(14) - assert_equal [2, 3], p.tag_ids + assert_equal [502, 503], p.tag_ids put :update_relationship, params: {post_id: 14, relationship: 'tags', data: [] } @@ -1542,7 +1739,7 @@ def test_update_mismatch_single_key }, relationships: { section: {type: 'sections', id: "#{javascript.id}"}, - tags: [{type: 'tags', id: 3}, {type: 'tags', id: 4}] + tags: [{type: 'tags', id: 503}, {type: 'tags', id: 504}] } } } @@ -1567,7 +1764,7 @@ def test_update_extra_param }, relationships: { section: {type: 'sections', id: "#{javascript.id}"}, - tags: [{type: 'tags', id: 3}, {type: 'tags', id: 4}] + tags: [{type: 'tags', id: 503}, {type: 'tags', id: 504}] } } } @@ -1592,7 +1789,7 @@ def test_update_extra_param_in_links relationships: { asdfg: 'aaaa', section: {type: 'sections', id: "#{javascript.id}"}, - tags: [{type: 'tags', id: 3}, {type: 'tags', id: 4}] + tags: [{type: 'tags', id: 503}, {type: 'tags', id: 504}] } } } @@ -1646,8 +1843,8 @@ def test_update_missing_param title: 'A great new Post' }, relationships: { - section: {type: 'sections', id: "#{javascript.id}"}, - tags: [{type: 'tags', id: 3}, {type: 'tags', id: 4}] + section: { data: { type: 'sections', id: "#{javascript.id}" } }, + tags: { data: [{ type: 'tags', id: 503 }, { type: 'tags', id: 504 }] } } } } @@ -1688,8 +1885,8 @@ def test_update_missing_type title: 'A great new Post' }, relationships: { - section: {type: 'sections', id: "#{javascript.id}"}, - tags: [{type: 'tags', id: 3}, {type: 'tags', id: 4}] + section: { data: { type: 'sections', id: "#{javascript.id}" } }, + tags: { data: [{ type: 'tags', id: 503 }, { type: 'tags', id: 504 }] } } } } @@ -1714,7 +1911,7 @@ def test_update_unknown_key }, relationships: { section: {type: 'sections', id: "#{javascript.id}"}, - tags: [{type: 'tags', id: 3}, {type: 'tags', id: 4}] + tags: [{type: 'tags', id: 503}, {type: 'tags', id: 504}] } } } @@ -1737,7 +1934,7 @@ def test_update_multiple_ids }, relationships: { section: { data: { type: 'sections', id: "#{javascript.id}" } }, - tags: { data: [{ type: 'tags', id: 3 }, { type: 'tags', id: 4 }] } + tags: { data: [{ type: 'tags', id: 503 }, { type: 'tags', id: 504 }] } } }, include: 'tags' @@ -1763,7 +1960,7 @@ def test_update_multiple_array }, relationships: { section: {data: {type: 'sections', id: "#{javascript.id}"}}, - tags: {data: [{type: 'tags', id: 3}, {type: 'tags', id: 4}]} + tags: {data: [{type: 'tags', id: 503}, {type: 'tags', id: 504}]} } } ], @@ -1786,14 +1983,13 @@ def test_update_unpermitted_attributes subject: 'A great new Post' }, relationships: { - author: {type: 'people', id: '1'}, - tags: [{type: 'tags', id: 3}, {type: 'tags', id: 4}] + author: {type: 'people', id: '1001'}, + tags: [{type: 'tags', id: 503}, {type: 'tags', id: 504}] } } } assert_response :bad_request - assert_match /author is not allowed./, response.body assert_match /subject is not allowed./, response.body end @@ -1808,8 +2004,8 @@ def test_update_bad_attributes subject: 'A great new Post' }, linked_objects: { - author: {type: 'people', id: '1'}, - tags: [{type: 'tags', id: 3}, {type: 'tags', id: 4}] + author: {type: 'people', id: '1001'}, + tags: [{type: 'tags', id: 503}, {type: 'tags', id: 504}] } } } @@ -1817,11 +2013,21 @@ def test_update_bad_attributes assert_response :bad_request end - def test_delete_with_validation_error + def test_delete_with_validation_error_base post = Post.create!(title: "can't destroy me", author: Person.first) delete :destroy, params: { id: post.id } assert_equal "can't destroy me", json_response['errors'][0]['title'] + assert_equal "/data", json_response['errors'][0]['source']['pointer'] + assert_response :unprocessable_entity + end + + def test_delete_with_validation_error_attr + post = Post.create!(title: "locked title", author: Person.first) + delete :destroy, params: { id: post.id } + + assert_equal "is locked", json_response['errors'][0]['title'] + assert_equal "/data/attributes/title", json_response['errors'][0]['source']['pointer'] assert_response :unprocessable_entity end @@ -1846,7 +2052,7 @@ def test_show_to_one_relationship assert_hash_equals json_response, {data: { type: 'people', - id: '1' + id: '1001' }, links: { self: 'http://test.host/posts/1/relationships/author', @@ -1861,7 +2067,7 @@ def test_show_to_many_relationship assert_hash_equals json_response, { data: [ - {type: 'tags', id: '5'} + {type: 'tags', id: '505'} ], links: { self: 'http://test.host/posts/2/relationships/tags', @@ -1888,38 +2094,151 @@ def test_show_to_one_relationship_nil } } end + + def test_index_related_resources_sorted + assert_cacheable_get :index_related_resources, params: {person_id: '1001', 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 :index_related_resources, params: {person_id: '1001', 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_index_related_resources_default_sorted + assert_cacheable_get :index_related_resources, params: {person_id: '1001', 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 + + def test_index_related_resources_has_many_filtered + assert_cacheable_get :index_related_resources, params: {person_id: '1001', relationship: 'posts', source:'people', filter: { title: 'JR How To' } } + assert_response :success + assert_equal 'JR How To', json_response['data'][0]['attributes']['title'] + assert_equal 1, json_response['data'].size + end end class TagsControllerTest < ActionController::TestCase def test_tags_index - assert_cacheable_get :index, params: {filter: {id: '6,7,8,9'}, include: 'posts.tags,posts.author.posts'} + assert_cacheable_get :index, params: {filter: {id: '506,507,508,509'}} assert_response :success assert_equal 4, json_response['data'].size - assert_equal 3, json_response['included'].size + end + + def test_tags_index_include_nested_tree + assert_cacheable_get :index, params: {filter: {id: '506,508,509'}, include: 'posts.tags,posts.author.posts'} + assert_response :success + assert_equal 3, json_response['data'].size + assert_equal 4, json_response['included'].size end def test_tags_show_multiple - assert_cacheable_get :show, params: {id: '6,7,8,9'} + assert_cacheable_get :show, params: {id: '506,507,508,509'} assert_response :bad_request - assert_match /6,7,8,9 is not a valid value for id/, response.body + assert_match /506,507,508,509 is not a valid value for id/, response.body end def test_tags_show_multiple_with_include - assert_cacheable_get :show, params: {id: '6,7,8,9', include: 'posts.tags,posts.author.posts'} + assert_cacheable_get :show, params: {id: '506,507,508,509', include: 'posts.tags,posts.author.posts'} assert_response :bad_request - assert_match /6,7,8,9 is not a valid value for id/, response.body + assert_match /506,507,508,509 is not a valid value for id/, response.body end def test_tags_show_multiple_with_nonexistent_ids - assert_cacheable_get :show, params: {id: '6,99,9,100'} + assert_cacheable_get :show, params: {id: '506,5099,509,50100'} assert_response :bad_request - assert_match /6,99,9,100 is not a valid value for id/, response.body + assert_match /506,5099,509,50100 is not a valid value for id/, response.body end def test_tags_show_multiple_with_nonexistent_ids_at_the_beginning - assert_cacheable_get :show, params: {id: '99,9,100'} + assert_cacheable_get :show, params: {id: '5099,509,50100'} assert_response :bad_request - assert_match /99,9,100 is not a valid value for id/, response.body + assert_match /5099,509,50100 is not a valid value for id/, response.body + end + + def test_nested_includes_sort + assert_cacheable_get :index, params: {filter: {id: '506,507,508,509'}, + 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 8, 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 8, json_response['data'].try(:size) + assert_equal 5, json_response['included'].try(:size) + end + + def test_pictures_index_with_polymorphic_to_one_linkage + JSONAPI.configuration.always_include_to_one_linkage_data = true + assert_cacheable_get :index + assert_response :success + assert_equal 8, json_response['data'].try(:size) + assert_equal '3', json_response['data'][2]['id'] + assert_nil json_response['data'][2]['relationships']['imageable']['data'] + assert_equal 'products', json_response['data'][0]['relationships']['imageable']['data']['type'] + assert_equal '1', json_response['data'][0]['relationships']['imageable']['data']['id'] + ensure + JSONAPI.configuration.always_include_to_one_linkage_data = false + end + + def test_pictures_index_with_polymorphic_include_one_level_to_one_linkages + JSONAPI.configuration.always_include_to_one_linkage_data = true + assert_cacheable_get :index, params: {include: 'imageable'} + assert_response :success + assert_equal 8, json_response['data'].try(:size) + assert_equal 5, json_response['included'].try(:size) + assert_nil json_response['data'][2]['relationships']['imageable']['data'] + assert_equal 'products', json_response['data'][0]['relationships']['imageable']['data']['type'] + assert_equal '1', json_response['data'][0]['relationships']['imageable']['data']['id'] + ensure + JSONAPI.configuration.always_include_to_one_linkage_data = false + end + + def test_update_relationship_to_one_polymorphic + set_content_type_header! + + put :update_relationship, params: { picture_id: 48, relationship: 'imageable', data: { type: 'product', id: '2' } } + + assert_response :no_content + picture_object = Picture.find(48) + assert_equal 2, picture_object.imageable_id + end + + def test_pictures_index_with_filter_documents + assert_cacheable_get :index, params: {include: 'imageable', filter: {'imageable#documents.name': 'Management Through the Years'}} + assert_response :success + assert_equal 3, json_response['data'].try(:size) + assert_equal 1, json_response['included'].try(:size) + end +end + +class DocumentsControllerTest < ActionController::TestCase + def test_documents_index + assert_cacheable_get :index + assert_response :success + assert_equal 5, 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 5, json_response['data'].size + assert_equal 6, json_response['included'].size end end @@ -1960,14 +2279,25 @@ def test_expense_entries_show_include 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'] + assert_match /isoCurrencies is not a valid includable relationship of expenseEntries/, json_response['errors'][0]['detail'] end def test_expense_entries_show_bad_include_missing_sub_relationship assert_cacheable_get :show, params: {id: 1, include: 'isoCurrency,employee.post'} assert_response :bad_request - assert_match /post is not a valid relationship of people/, json_response['errors'][0]['detail'] + assert_match /post is not a valid includable relationship of employees/, 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 includable 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 includable relationship of expenseEntries/, json_response['errors'][0]['detail'] end def test_expense_entries_show_fields @@ -2001,7 +2331,7 @@ def test_create_expense_entries_underscored cost: 50.58 }, relationships: { - employee: {data: {type: 'people', id: '3'}}, + employee: {data: {type: 'employees', id: '1003'}}, iso_currency: {data: {type: 'iso_currencies', id: 'USD'}} } }, @@ -2011,7 +2341,7 @@ def test_create_expense_entries_underscored assert_response :created assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['employee']['data']['id'] + assert_equal '1003', json_response['data']['relationships']['employee']['data']['id'] assert_equal 'USD', json_response['data']['relationships']['iso_currency']['data']['id'] assert_equal '50.58', json_response['data']['attributes']['cost'] @@ -2035,7 +2365,7 @@ def test_create_expense_entries_camelized_key cost: 50.58 }, relationships: { - employee: {data: {type: 'people', id: '3'}}, + employee: {data: {type: 'employees', id: '1003'}}, isoCurrency: {data: {type: 'iso_currencies', id: 'USD'}} } }, @@ -2045,7 +2375,7 @@ def test_create_expense_entries_camelized_key assert_response :created assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['employee']['data']['id'] + assert_equal '1003', json_response['data']['relationships']['employee']['data']['id'] assert_equal 'USD', json_response['data']['relationships']['isoCurrency']['data']['id'] assert_equal '50.58', json_response['data']['attributes']['cost'] @@ -2069,7 +2399,7 @@ def test_create_expense_entries_dasherized_key cost: 50.58 }, relationships: { - employee: {data: {type: 'people', id: '3'}}, + employee: {data: {type: 'employees', id: '1003'}}, 'iso-currency' => {data: {type: 'iso_currencies', id: 'USD'}} } }, @@ -2079,7 +2409,7 @@ def test_create_expense_entries_dasherized_key assert_response :created assert json_response['data'].is_a?(Hash) - assert_equal '3', json_response['data']['relationships']['employee']['data']['id'] + assert_equal '1003', json_response['data']['relationships']['employee']['data']['id'] assert_equal 'USD', json_response['data']['relationships']['iso-currency']['data']['id'] assert_equal '50.58', json_response['data']['attributes']['cost'] @@ -2270,9 +2600,9 @@ def test_update_link_with_dasherized_type set_content_type_header! put :update, params: { - id: 3, + id: 1003, data: { - id: '3', + id: '1003', type: 'people', relationships: { 'hair-cut' => { @@ -2313,9 +2643,9 @@ def test_update_validations_missing_attribute set_content_type_header! put :update, params: { - id: 3, + id: 1003, data: { - id: '3', + id: '1003', type: 'people', attributes: { name: '' @@ -2331,7 +2661,7 @@ def test_update_validations_missing_attribute def test_delete_locked initial_count = Person.count - delete :destroy, params: {id: '3'} + delete :destroy, params: {id: '1003'} assert_response :locked assert_equal initial_count, Person.count end @@ -2341,63 +2671,81 @@ def test_invalid_filter_value assert_response :bad_request end + def test_invalid_filter_value_for_index_related_resources + assert_cacheable_get :index_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 assert_equal json_response['data'].size, 1 - assert_equal json_response['data'][0]['id'], '1' - assert_equal json_response['data'][0]['attributes']['name'], 'Joe Author' + assert_equal '1001', json_response['data'][0]['id'] + assert_equal 'Joe Author', json_response['data'][0]['attributes']['name'] end - def test_get_related_resource_no_namespace + def test_show_related_resource_no_namespace original_config = JSONAPI.configuration.dup JSONAPI.configuration.json_key_format = :dasherized_key JSONAPI.configuration.route_format = :underscored_key - assert_cacheable_get :get_related_resource, params: {post_id: '2', relationship: 'author', source:'posts'} + assert_cacheable_get :show_related_resource, params: {post_id: '2', relationship: 'author', source:'posts'} assert_response :success + assert_hash_equals( { - data: { - id: '1', - type: 'people', - attributes: { - name: 'Joe Author', - email: 'joe@xyz.fake', - "date-joined" => '2013-08-07 16:25:00 -0400' + "data" => { + "id" => "1001", + "type" => "people", + "links" => { + "self" => "http://test.host/people/1001" }, - links: { - self: 'http://test.host/people/1' + "attributes" => { + "name" => "Joe Author", + "email" => "joe@xyz.fake", + "date-joined" => "2013-08-07 16:25:00 -0400" }, - relationships: { - comments: { - links: { - self: 'http://test.host/people/1/relationships/comments', - related: 'http://test.host/people/1/comments' + "relationships" => { + "comments" => { + "links" => { + "self" => "http://test.host/people/1001/relationships/comments", + "related" => "http://test.host/people/1001/comments" } }, - posts: { - links: { - self: 'http://test.host/people/1/relationships/posts', - related: 'http://test.host/people/1/posts' + "posts" => { + "links" => { + "self" => "http://test.host/people/1001/relationships/posts", + "related" => "http://test.host/people/1001/posts" } }, - preferences: { - links: { - self: 'http://test.host/people/1/relationships/preferences', - related: 'http://test.host/people/1/preferences' + "preferences" => { + "links" => { + "self" => "http://test.host/people/1001/relationships/preferences", + "related" => "http://test.host/people/1001/preferences" } }, - "hair-cut" => { + "vehicles" => { "links" => { - "self" => "http://test.host/people/1/relationships/hair_cut", - "related" => "http://test.host/people/1/hair_cut" + "self" => "http://test.host/people/1001/relationships/vehicles", + "related" => "http://test.host/people/1001/vehicles" } }, - vehicles: { - links: { - self: "http://test.host/people/1/relationships/vehicles", - related: "http://test.host/people/1/vehicles" - } + "hair-cut" => { + "links" => { + "self" => "http://test.host/people/1001/relationships/hair_cut", + "related" => "http://test.host/people/1001/hair_cut" + } + }, + "expense-entries" => { + "links" => { + "self" => "http://test.host/people/1001/relationships/expense_entries", + "related" => "http://test.host/people/1001/expense_entries" + } } } } @@ -2408,8 +2756,19 @@ def test_get_related_resource_no_namespace JSONAPI.configuration = original_config end - def test_get_related_resource_nil - assert_cacheable_get :get_related_resource, params: {post_id: '17', relationship: 'author', source:'posts'} + def test_show_related_resource_includes + original_config = JSONAPI.configuration.dup + JSONAPI.configuration.json_key_format = :dasherized_key + JSONAPI.configuration.route_format = :underscored_key + assert_cacheable_get :show_related_resource, params: {post_id: '2', relationship: 'author', source:'posts', include: 'posts'} + assert_response :success + assert_equal 'posts', json_response['included'][0]['type'] + ensure + JSONAPI.configuration = original_config + end + + def test_show_related_resource_nil + assert_cacheable_get :show_related_resource, params: {post_id: '17', relationship: 'author', source:'posts'} assert_response :success assert_hash_equals json_response, { @@ -2421,7 +2780,7 @@ def test_get_related_resource_nil class BooksControllerTest < ActionController::TestCase def test_books_include_correct_type - $test_user = Person.find(1) + $test_user = Person.find(1001) assert_cacheable_get :index, params: {filter: {id: '1'}, include: 'authors'} assert_response :success assert_equal 'authors', json_response['included'][0]['type'] @@ -2432,52 +2791,103 @@ def test_destroy_relationship_has_and_belongs_to_many assert_equal 2, Book.find(2).authors.count - delete :destroy_relationship, params: {book_id: 2, relationship: 'authors', data: [{type: 'authors', id: 1}]} + delete :destroy_relationship, params: {book_id: 2, relationship: 'authors', data: [{type: 'authors', id: '1001'}]} assert_response :no_content assert_equal 1, Book.find(2).authors.count ensure 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 - delete :destroy_relationship, params: {book_id: 2, relationship: 'authors', data: [{type: 'authors', id: 1}]} + delete :destroy_relationship, params: {book_id: 2, relationship: 'authors', data: [{type: 'authors', id: '1001'}]} assert_response :no_content assert_equal 1, Book.find(2).authors.count 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 '1001', json_response['data']['relationships']['author']['data']['id'] + assert_nil json_response['data']['relationships']['tags'] + assert_equal '1001', 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::AuthorsControllerTest < ActionController::TestCase def test_get_person_as_author - assert_cacheable_get :index, params: {filter: {id: '1'}} + assert_cacheable_get :index, params: {filter: {id: '1001'}} assert_response :success assert_equal 1, json_response['data'].size - assert_equal '1', json_response['data'][0]['id'] + assert_equal '1001', 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 - assert_cacheable_get :show, params: {id: '1'} + assert_cacheable_get :show, params: {id: '1001'} assert_response :success - assert_equal '1', json_response['data']['id'] + assert_equal '1001', 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 assert_cacheable_get :index, params: {filter: {name: 'thor'}} assert_response :success assert_equal 3, json_response['data'].size - assert_equal '1', json_response['data'][0]['id'] + assert_equal '1001', json_response['data'][0]['id'] assert_equal 'Joe Author', json_response['data'][0]['attributes']['name'] end @@ -2495,11 +2905,11 @@ def meta(options) end end - assert_cacheable_get :show, params: {id: '1'} + assert_cacheable_get :show, params: {id: '1001'} assert_response :success - assert_equal '1', json_response['data']['id'] + assert_equal '1001', json_response['data']['id'] assert_equal 'Hardcoded value', json_response['data']['meta']['fixed'] - assert_equal 'authors: http://test.host/api/v5/authors/1', json_response['data']['meta']['computed'] + assert_equal 'authors: http://test.host/api/v5/authors/1001', json_response['data']['meta']['computed'] assert_equal 'bar', json_response['data']['meta']['computed_foo'] assert_equal 'test value', json_response['data']['meta']['testKey'] @@ -2530,11 +2940,11 @@ def meta(options) end end - assert_cacheable_get :show, params: {id: '1'} + assert_cacheable_get :show, params: {id: '1001'} assert_response :success - assert_equal '1', json_response['data']['id'] + assert_equal '1001', json_response['data']['id'] assert_equal 'Hardcoded value', json_response['data']['meta']['custom_hash']['fixed'] - assert_equal 'authors: http://test.host/api/v5/authors/1', json_response['data']['meta']['custom_hash']['computed'] + assert_equal 'authors: http://test.host/api/v5/authors/1001', json_response['data']['meta']['custom_hash']['computed'] assert_equal 'bar', json_response['data']['meta']['custom_hash']['computed_foo'] assert_equal 'test value', json_response['data']['meta']['custom_hash']['testKey'] @@ -2554,14 +2964,14 @@ class BreedsControllerTest < ActionController::TestCase # Note: Breed names go through the TitleValueFormatter def test_poro_index - assert_cacheable_get :index + get :index assert_response :success assert_equal '0', json_response['data'][0]['id'] assert_equal 'Persian', json_response['data'][0]['attributes']['name'] end def test_poro_show - assert_cacheable_get :show, params: {id: '0'} + get :show, params: {id: '0'} assert_response :success assert json_response['data'].is_a?(Hash) assert_equal '0', json_response['data']['id'] @@ -2652,12 +3062,16 @@ def test_poro_delete class Api::V2::PreferencesControllerTest < ActionController::TestCase def test_show_singleton_resource_without_id + $test_user = Person.find(1001) + assert_cacheable_get :show assert_response :success end def test_update_singleton_resource_without_id set_content_type_header! + $test_user = Person.find(1001) + patch :update, params: { data: { id: "1", @@ -2680,15 +3094,15 @@ def test_show_post_namespaced def test_show_post_namespaced_include assert_cacheable_get :show, params: {id: '1', include: 'writer'} assert_response :success - assert_equal '1', json_response['data']['relationships']['writer']['data']['id'] + assert_equal '1001', json_response['data']['relationships']['writer']['data']['id'] assert_nil json_response['data']['relationships']['tags'] - assert_equal '1', json_response['included'][0]['id'] + assert_equal '1001', json_response['included'][0]['id'] assert_equal 'writers', json_response['included'][0]['type'] assert_equal 'joe@xyz.fake', json_response['included'][0]['attributes']['email'] end def test_index_filter_on_relationship_namespaced - assert_cacheable_get :index, params: {filter: {writer: '1'}} + assert_cacheable_get :index, params: {filter: {writer: '1001'}} assert_response :success assert_equal 3, json_response['data'].size end @@ -2711,7 +3125,7 @@ def test_create_simple_namespaced body: 'JSONAPIResources is the greatest thing since unsliced bread now that it has namespaced resources.' }, relationships: { - writer: { data: {type: 'writers', id: '3'}} + writer: { data: {type: 'writers', id: '1003'}} } } } @@ -2734,7 +3148,7 @@ def test_type_formatting assert json_response['data'].is_a?(Hash) assert_equal 'Jane Author', json_response['data']['attributes']['spouseName'] assert_equal 'First man to run across Antartica.', json_response['data']['attributes']['bio'] - assert_equal 23.89/45.6, json_response['data']['attributes']['qualityRating'] + assert_equal (23.89/45.6).round(5), json_response['data']['attributes']['qualityRating'].round(5) assert_equal '47000.56', json_response['data']['attributes']['salary'] assert_equal '2013-08-07T20:25:00.000Z', json_response['data']['attributes']['dateTimeJoined'] assert_equal '1965-06-30', json_response['data']['attributes']['birthday'] @@ -2786,7 +3200,7 @@ def test_create_with_invalid_data class Api::V2::BooksControllerTest < ActionController::TestCase def setup JSONAPI.configuration.json_key_format = :dasherized_key - $test_user = Person.find(1) + $test_user = Person.find(1001) end def after_teardown @@ -2826,11 +3240,23 @@ def test_books_page_count_in_meta assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] end - def test_books_record_count_in_meta_custom_name - Api::V2::BookResource.paginator :offset - JSONAPI.configuration.top_level_meta_include_record_count = true - JSONAPI.configuration.top_level_meta_record_count_key = 'total_records' - + def test_books_no_page_count_in_meta_with_none_paginator + Api::V2::BookResource.paginator :none + JSONAPI.configuration.top_level_meta_include_page_count = true + assert_cacheable_get :index, params: {include: 'book-comments'} + JSONAPI.configuration.top_level_meta_include_page_count = false + + assert_response :success + assert_nil json_response['meta']['page-count'] + assert_equal 901, json_response['data'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + end + + def test_books_record_count_in_meta_custom_name + Api::V2::BookResource.paginator :offset + JSONAPI.configuration.top_level_meta_include_record_count = true + JSONAPI.configuration.top_level_meta_record_count_key = 'total_records' + assert_cacheable_get :index, params: {include: 'book-comments'} JSONAPI.configuration.top_level_meta_include_record_count = false JSONAPI.configuration.top_level_meta_record_count_key = :record_count @@ -2859,7 +3285,7 @@ def test_books_page_count_in_meta_custom_name def test_books_offset_pagination_no_params_includes_query_count_one_level Api::V2::BookResource.paginator :offset - assert_query_count(3) do + assert_query_count(5) do assert_cacheable_get :index, params: {include: 'book-comments'} end assert_response :success @@ -2870,7 +3296,7 @@ def test_books_offset_pagination_no_params_includes_query_count_one_level def test_books_offset_pagination_no_params_includes_query_count_two_levels Api::V2::BookResource.paginator :offset - assert_query_count(4) do + assert_query_count(7) do assert_cacheable_get :index, params: {include: 'book-comments,book-comments.author'} end assert_response :success @@ -2998,69 +3424,68 @@ def test_books_paged_pagination_invalid_page_format_interpret_int def test_books_included_paged Api::V2::BookResource.paginator :offset - assert_query_count(3) do + assert_query_count(5) do assert_cacheable_get :index, params: {filter: {id: '0'}, include: 'book-comments'} + assert_response :success + assert_equal 1, json_response['data'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] end - assert_response :success - assert_equal 1, json_response['data'].size - assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] end def test_books_banned_non_book_admin - $test_user = Person.find(1) + $test_user = Person.find(1001) Api::V2::BookResource.paginator :offset JSONAPI.configuration.top_level_meta_include_record_count = true - assert_query_count(2) do + assert_query_count(3) do assert_cacheable_get :index, params: {page: {offset: 50, limit: 12}} + assert_response :success + assert_equal 12, json_response['data'].size + assert_equal 'Book 50', json_response['data'][0]['attributes']['title'] + assert_equal 901, json_response['meta']['record-count'] end - assert_response :success - assert_equal 12, json_response['data'].size - assert_equal 'Book 50', json_response['data'][0]['attributes']['title'] - assert_equal 901, json_response['meta']['record-count'] ensure JSONAPI.configuration.top_level_meta_include_record_count = false end def test_books_banned_non_book_admin_includes_switched - $test_user = Person.find(1) + $test_user = Person.find(1001) Api::V2::BookResource.paginator :offset JSONAPI.configuration.top_level_meta_include_record_count = true - assert_query_count(3) do + assert_query_count(5) do assert_cacheable_get :index, params: {page: {offset: 0, limit: 12}, include: 'book-comments'} + assert_response :success + assert_equal 12, json_response['data'].size + assert_equal 130, json_response['included'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + assert_equal 26, json_response['data'][0]['relationships']['book-comments']['data'].size + assert_equal 'book-comments', json_response['included'][0]['type'] + assert_equal 901, json_response['meta']['record-count'] end - - assert_response :success - assert_equal 12, json_response['data'].size - assert_equal 130, json_response['included'].size - assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] - assert_equal 26, json_response['data'][0]['relationships']['book-comments']['data'].size - assert_equal 'book-comments', json_response['included'][0]['type'] - assert_equal 901, json_response['meta']['record-count'] ensure JSONAPI.configuration.top_level_meta_include_record_count = false end def test_books_banned_non_book_admin_includes_nested_includes - $test_user = Person.find(1) + $test_user = Person.find(1001) JSONAPI.configuration.top_level_meta_include_record_count = true Api::V2::BookResource.paginator :offset - assert_query_count(4) do + assert_query_count(7) do assert_cacheable_get :index, params: {page: {offset: 0, limit: 12}, include: 'book-comments.author'} + assert_response :success + assert_equal 12, json_response['data'].size + assert_equal 132, json_response['included'].size + assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] + assert_equal 901, json_response['meta']['record-count'] end - assert_response :success - assert_equal 12, json_response['data'].size - assert_equal 132, json_response['included'].size - assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] - assert_equal 901, json_response['meta']['record-count'] ensure JSONAPI.configuration.top_level_meta_include_record_count = false end def test_books_banned_admin - $test_user = Person.find(5) + $test_user = Person.find(1005) Api::V2::BookResource.paginator :offset JSONAPI.configuration.top_level_meta_include_record_count = true - assert_query_count(2) do + assert_query_count(3) do assert_cacheable_get :index, params: {page: {offset: 50, limit: 12}, filter: {banned: 'true'}} end assert_response :success @@ -3072,10 +3497,10 @@ def test_books_banned_admin end def test_books_not_banned_admin - $test_user = Person.find(5) + $test_user = Person.find(1005) Api::V2::BookResource.paginator :offset JSONAPI.configuration.top_level_meta_include_record_count = true - assert_query_count(2) do + assert_query_count(3) do assert_cacheable_get :index, params: {page: {offset: 50, limit: 12}, filter: {banned: 'false'}, fields: {books: 'id,title'}} end assert_response :success @@ -3087,10 +3512,10 @@ def test_books_not_banned_admin end def test_books_banned_non_book_admin_overlapped - $test_user = Person.find(1) + $test_user = Person.find(1001) Api::V2::BookResource.paginator :offset JSONAPI.configuration.top_level_meta_include_record_count = true - assert_query_count(2) do + assert_query_count(3) do assert_cacheable_get :index, params: {page: {offset: 590, limit: 20}} end assert_response :success @@ -3102,10 +3527,10 @@ def test_books_banned_non_book_admin_overlapped end def test_books_included_exclude_unapproved - $test_user = Person.find(1) + $test_user = Person.find(1001) Api::V2::BookResource.paginator :none - assert_query_count(2) do + assert_query_count(4) do assert_cacheable_get :index, params: {filter: {id: '0,1,2,3,4'}, include: 'book-comments'} end assert_response :success @@ -3116,7 +3541,7 @@ def test_books_included_exclude_unapproved end def test_books_included_all_comments_for_admin - $test_user = Person.find(5) + $test_user = Person.find(1005) Api::V2::BookResource.paginator :none assert_cacheable_get :index, params: {filter: {id: '0,1,2,3,4'}, include: 'book-comments'} @@ -3128,14 +3553,14 @@ def test_books_included_all_comments_for_admin end def test_books_filter_by_book_comment_id_limited_user - $test_user = Person.find(1) + $test_user = Person.find(1001) assert_cacheable_get :index, params: {filter: {book_comments: '0,52' }} assert_response :success assert_equal 1, json_response['data'].size end def test_books_filter_by_book_comment_id_admin_user - $test_user = Person.find(5) + $test_user = Person.find(1005) assert_cacheable_get :index, params: {filter: {book_comments: '0,52' }} assert_response :success assert_equal 2, json_response['data'].size @@ -3143,7 +3568,7 @@ def test_books_filter_by_book_comment_id_admin_user def test_books_create_unapproved_comment_limited_user_using_relation_name set_content_type_header! - $test_user = Person.find(1) + $test_user = Person.find(1001) book_comment = BookComment.create(body: 'Not Approved dummy comment', approved: false) post :create_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} @@ -3157,7 +3582,7 @@ def test_books_create_unapproved_comment_limited_user_using_relation_name def test_books_create_approved_comment_limited_user_using_relation_name set_content_type_header! - $test_user = Person.find(1) + $test_user = Person.find(1001) book_comment = BookComment.create(body: 'Approved dummy comment', approved: true) post :create_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} @@ -3168,7 +3593,7 @@ def test_books_create_approved_comment_limited_user_using_relation_name end def test_books_delete_unapproved_comment_limited_user_using_relation_name - $test_user = Person.find(1) + $test_user = Person.find(1001) book_comment = BookComment.create(book_id: 1, body: 'Not Approved dummy comment', approved: false) delete :destroy_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} @@ -3179,7 +3604,7 @@ def test_books_delete_unapproved_comment_limited_user_using_relation_name end def test_books_delete_approved_comment_limited_user_using_relation_name - $test_user = Person.find(1) + $test_user = Person.find(1001) book_comment = BookComment.create(book_id: 1, body: 'Approved dummy comment', approved: true) delete :destroy_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} @@ -3191,7 +3616,7 @@ def test_books_delete_approved_comment_limited_user_using_relation_name def test_books_delete_approved_comment_limited_user_using_relation_name_reflected JSONAPI.configuration.use_relationship_reflection = true - $test_user = Person.find(1) + $test_user = Person.find(1001) book_comment = BookComment.create(book_id: 1, body: 'Approved dummy comment', approved: true) delete :destroy_relationship, params: {book_id: 1, relationship: 'book_comments', data: [{type: 'book_comments', id: book_comment.id}]} @@ -3201,18 +3626,28 @@ def test_books_delete_approved_comment_limited_user_using_relation_name_reflecte JSONAPI.configuration.use_relationship_reflection = false book_comment.delete end + + def test_index_related_resources_pagination + Api::V2::BookResource.paginator :offset + + assert_cacheable_get :index_related_resources, params: {author_id: '1003', relationship: 'books', source:'api/v2/authors'} + assert_response :success + assert_equal 10, json_response['data'].size + assert_equal 3, json_response['links'].size + assert_equal 'http://test.host/api/v2/authors/1003/books?page%5Blimit%5D=10&page%5Boffset%5D=0', json_response['links']['first'] + end end class Api::V2::BookCommentsControllerTest < ActionController::TestCase def setup JSONAPI.configuration.json_key_format = :dasherized_key Api::V2::BookCommentResource.paginator :none - $test_user = Person.find(1) + $test_user = Person.find(1001) end def test_book_comments_all_for_admin - $test_user = Person.find(5) - assert_query_count(1) do + $test_user = Person.find(1005) + assert_query_count(2) do assert_cacheable_get :index end assert_response :success @@ -3220,8 +3655,8 @@ def test_book_comments_all_for_admin end def test_book_comments_unapproved_context_based - $test_user = Person.find(5) - assert_query_count(1) do + $test_user = Person.find(1005) + assert_query_count(2) do assert_cacheable_get :index, params: {filter: {approved: 'false'}} end assert_response :success @@ -3229,8 +3664,8 @@ def test_book_comments_unapproved_context_based end def test_book_comments_exclude_unapproved_context_based - $test_user = Person.find(1) - assert_query_count(1) do + $test_user = Person.find(1001) + assert_query_count(2) do assert_cacheable_get :index end assert_response :success @@ -3238,6 +3673,29 @@ def test_book_comments_exclude_unapproved_context_based end end +class Api::V4::PostsControllerTest < ActionController::TestCase + def test_warn_on_joined_to_many + original_config = JSONAPI.configuration.dup + + JSONAPI.configuration.warn_on_performance_issues = true + _out, err = capture_subprocess_io do + get :index, params: {fields: {posts: 'id,title'}} + assert_response :success + end + assert_equal(err, "Performance issue detected: `Api::V4::PostResource.records` returned non-normalized results in `Api::V4::PostResource.find_fragments`.\n") + + JSONAPI.configuration.warn_on_performance_issues = false + _out, err = capture_subprocess_io do + get :index, params: {fields: {posts: 'id,title'}} + assert_response :success + end + assert_empty err + + ensure + JSONAPI.configuration = original_config + end +end + class Api::V4::BooksControllerTest < ActionController::TestCase def setup JSONAPI.configuration.json_key_format = :camelized_key @@ -3255,6 +3713,10 @@ def test_books_offset_pagination_meta JSONAPI.configuration = original_config end + def test_inherited_pagination + assert_equal :paged, Api::V4::BiggerBookResource._paginator + end + def test_books_operation_links original_config = JSONAPI.configuration.dup Api::V4::BookResource.paginator :offset @@ -3323,8 +3785,8 @@ def test_save_model_callbacks_fail end class Api::V1::MoonsControllerTest < ActionController::TestCase - def test_get_related_resource - assert_cacheable_get :get_related_resource, params: {crater_id: 'S56D', relationship: 'moon', source: "api/v1/craters"} + def test_show_related_resource + assert_cacheable_get :show_related_resource, params: {crater_id: 'S56D', relationship: 'moon', source: "api/v1/craters"} assert_response :success assert_hash_equals({ data: { @@ -3339,12 +3801,35 @@ def test_get_related_resource }, json_response) end - def test_get_related_resources_with_select_some_db_columns - PlanetResource.paginator :paged + def test_show_related_resource_to_one_linkage_data + JSONAPI.configuration.always_include_to_one_linkage_data = true + + assert_cacheable_get :show_related_resource, params: {crater_id: 'S56D', relationship: 'moon', source: "api/v1/craters"} + assert_response :success + assert_hash_equals({ + data: { + id: "1", + type: "moons", + links: {self: "http://test.host/api/v1/moons/1"}, + attributes: {name: "Titan", description: "Best known of the Saturn moons."}, + relationships: { + planet: {links: {self: "http://test.host/api/v1/moons/1/relationships/planet", + related: "http://test.host/api/v1/moons/1/planet"}, + data: {type: "planets", id: "1"} + }, + craters: {links: {self: "http://test.host/api/v1/moons/1/relationships/craters", related: "http://test.host/api/v1/moons/1/craters"}}} + } + }, json_response) + ensure + JSONAPI.configuration.always_include_to_one_linkage_data = false + end + + def test_index_related_resources_with_select_some_db_columns + Api::V1::MoonResource.paginator :paged 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: {planet_id: '1', relationship: 'moons', source: 'api/v1/planets'} + assert_cacheable_get :index_related_resources, params: {planet_id: '1', relationship: 'moons', source: 'api/v1/planets'} assert_response :success assert_equal 1, json_response['meta']['record-count'] ensure @@ -3362,8 +3847,8 @@ def test_show_single assert_nil json_response['included'] end - def test_get_related_resources - assert_cacheable_get :get_related_resources, params: {moon_id: '1', relationship: 'craters', source: "api/v1/moons"} + def test_index_related_resources + assert_cacheable_get :index_related_resources, params: {moon_id: '1', relationship: 'craters', source: "api/v1/moons"} assert_response :success assert_hash_equals({ data: [ @@ -3385,9 +3870,16 @@ def test_get_related_resources }, json_response) end - def test_get_related_resources_filtered - $test_user = Person.find(1) - get :get_related_resources, params: {moon_id: '1', relationship: 'craters', source: "api/v1/moons", filter: {description: 'Small crater'}} + def test_index_related_resources_filtered + $test_user = Person.find(1001) + assert_cacheable_get :index_related_resources, + params: { + moon_id: '1', + relationship: 'craters', + source: "api/v1/moons", + filter: { description: 'Small crater' } + } + assert_response :success assert_hash_equals({ data: [ @@ -3396,7 +3888,14 @@ def test_get_related_resources_filtered type:"craters", links:{self: "http://test.host/api/v1/craters/A4D3"}, attributes:{code: "A4D3", description: "Small crater"}, - relationships:{moon: {links: {self: "http://test.host/api/v1/craters/A4D3/relationships/moon", related: "http://test.host/api/v1/craters/A4D3/moon"}}} + relationships: { + moon: { + links: { + self: "http://test.host/api/v1/craters/A4D3/relationships/moon", + related: "http://test.host/api/v1/craters/A4D3/moon" + } + } + } } ] }, json_response) @@ -3444,6 +3943,13 @@ def setup JSONAPI.configuration.json_key_format = :camelized_key end + def test_STI_index_returns_all_types + assert_cacheable_get :index + assert_response :success + assert_equal 'cars', json_response['data'][0]['type'] + assert_equal 'boats', json_response['data'][1]['type'] + end + def test_immutable_create_not_supported set_content_type_header! @@ -3509,21 +4015,43 @@ def test_get_namespaced_model_matching_resource class Api::V7::CategoriesControllerTest < ActionController::TestCase def test_uncaught_error_in_controller_translated_to_internal_server_error - assert_cacheable_get :show, params: {id: '1'} + get :show, params: {id: '1'} assert_response 500 assert_match /Internal Server Error/, json_response['errors'][0]['detail'] end + def test_not_allowed_error_in_controller + original_config = JSONAPI.configuration.dup + JSONAPI.configuration.exception_class_allowlist = [] + get :show, params: {id: '1'} + assert_response 500 + assert_match /Internal Server Error/, json_response['errors'][0]['detail'] + ensure + JSONAPI.configuration = original_config + end + def test_not_whitelisted_error_in_controller original_config = JSONAPI.configuration.dup JSONAPI.configuration.exception_class_whitelist = [] - assert_cacheable_get :show, params: {id: '1'} + get :show, params: {id: '1'} assert_response 500 assert_match /Internal Server Error/, json_response['errors'][0]['detail'] ensure JSONAPI.configuration = original_config end + def test_allowed_error_in_controller + original_config = JSONAPI.configuration.dup + $PostProcessorRaisesErrors = true + JSONAPI.configuration.exception_class_allowlist = [PostsController::SubSpecialError] + assert_raises PostsController::SubSpecialError do + assert_cacheable_get :show, params: {id: '1'} + end + ensure + JSONAPI.configuration = original_config + $PostProcessorRaisesErrors = false + end + def test_whitelisted_error_in_controller original_config = JSONAPI.configuration.dup $PostProcessorRaisesErrors = true @@ -3542,6 +4070,15 @@ def test_caching_with_join_from_resource_with_sql_fragment assert_cacheable_get :index, params: {include: 'section'} assert_response :success end + + def test_delete_with_validation_error_base_on_resource + post = Post.create!(title: "can't destroy me either", author: Person.first) + delete :destroy, params: { id: post.id } + + assert_equal "can't destroy me", json_response['errors'][0]['title'] + assert_equal "/data/attributes/base", json_response['errors'][0]['source']['pointer'] + assert_response :unprocessable_entity + end end class Api::V6::SectionsControllerTest < ActionController::TestCase @@ -3551,68 +4088,741 @@ def test_caching_with_join_to_resource_with_sql_fragment end end +class AuthorsControllerTest < ActionController::TestCase + def test_show_author_recursive + assert_cacheable_get :show, params: {id: '1002', include: 'books.authors'} + assert_response :success + assert_equal '1002', json_response['data']['id'] + assert_equal 'authors', json_response['data']['type'] + assert_equal 'Fred Reader', json_response['data']['attributes']['name'] + + # The test is hardcoded with the include order. This should be changed at some + # point since either thing could come first and still be valid + assert_equal '1001', json_response['included'][0]['id'] + assert_equal 'authors', json_response['included'][0]['type'] + assert_equal '2', json_response['included'][1]['id'] + assert_equal 'books', json_response['included'][1]['type'] + end + + def test_show_author_do_not_include_polymorphic_linkage + assert_cacheable_get :show, params: {id: '1002', include: 'pictures'} + assert_response :success + assert_equal '1002', json_response['data']['id'] + assert_equal 'authors', json_response['data']['type'] + assert_equal 'Fred Reader', json_response['data']['attributes']['name'] + assert json_response['included'][0]['relationships']['imageable']['links'] + refute json_response['included'][0]['relationships']['imageable']['data'] + end + + def test_show_author_include_polymorphic_linkage + JSONAPI.configuration.always_include_to_one_linkage_data = true + + assert_cacheable_get :show, params: {id: '1002', include: 'pictures'} + assert_response :success + assert_equal '1002', json_response['data']['id'] + assert_equal 'authors', json_response['data']['type'] + assert_equal 'Fred Reader', json_response['data']['attributes']['name'] + assert json_response['included'][0]['relationships']['imageable']['links'] + assert json_response['included'][0]['relationships']['imageable']['data'] + assert_equal 'products', json_response['included'][0]['relationships']['imageable']['data']['type'] + assert_equal '1', json_response['included'][0]['relationships']['imageable']['data']['id'] + ensure + JSONAPI.configuration.always_include_to_one_linkage_data = false + end +end + +class Api::V2::AuthorsControllerTest < ActionController::TestCase + def test_cache_pollution_for_non_admin_indirect_access_to_banned_books + cache = ActiveSupport::Cache::MemoryStore.new + with_resource_caching(cache) do + $test_user = Person.find(1005) + get :show, params: {id: '1002', include: 'books'} + assert_response :success + assert_equal 2, json_response['included'].length + + $test_user = Person.find(1001) + get :show, params: {id: '1002', include: 'books'} + assert_response :success + assert_equal 1, json_response['included'].length + end + end +end + class Api::BoxesControllerTest < ActionController::TestCase def test_complex_includes_base assert_cacheable_get :index 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'} assert_response :success - # The test is hardcoded with the include order. This should be changed at some point since either thing could come first and still be valid - assert_equal '1', json_response['included'][0]['id'] + # The test is hardcoded with the include order. This should be changed at some + # point since either thing could come first and still be valid + assert_equal '10', json_response['included'][0]['id'] assert_equal 'things', json_response['included'][0]['type'] - assert_equal '1', json_response['included'][0]['relationships']['user']['data']['id'] + assert_equal '10001', json_response['included'][0]['relationships']['user']['data']['id'] assert_nil json_response['included'][0]['relationships']['things']['data'] - assert_equal '2', json_response['included'][1]['id'] + assert_equal '20', json_response['included'][1]['id'] assert_equal 'things', json_response['included'][1]['type'] - assert_equal '1', json_response['included'][1]['relationships']['user']['data']['id'] + assert_equal '10001', json_response['included'][1]['relationships']['user']['data']['id'] assert_nil json_response['included'][1]['relationships']['things']['data'] - - assert_equal '1', json_response['included'][2]['id'] - assert_equal 'users', json_response['included'][2]['type'] - assert_nil json_response['included'][2]['relationships']['things']['data'] end def test_complex_includes_things_nested_things - assert_cacheable_get :index, params: {include: 'things,things.things'} + assert_cacheable_get :index, params: {include: 'things,things.things,things.things.things'} assert_response :success - - # The test is hardcoded with the include order. This should be changed at some point since either thing could come first and still be valid - assert_equal '2', json_response['included'][0]['id'] - assert_equal 'things', json_response['included'][0]['type'] - assert_nil json_response['included'][0]['relationships']['user']['data'] - assert_equal '1', json_response['included'][0]['relationships']['things']['data'][0]['id'] - - assert_equal '1', json_response['included'][1]['id'] - assert_equal 'things', json_response['included'][1]['type'] - assert_nil json_response['included'][1]['relationships']['user']['data'] - assert_equal '2', json_response['included'][1]['relationships']['things']['data'][0]['id'] + assert_hash_equals( + { + "data" => [ + { + "id" => "100", + "type" => "boxes", + "links" => { + "self" => "http://test.host/api/boxes/100" + }, + "relationships" => { + "things" => { + "links" => { + "self" => "http://test.host/api/boxes/100/relationships/things", + "related" => "http://test.host/api/boxes/100/things" + }, + "data" => [ + { + "type" => "things", + "id" => "10" + }, + { + "type" => "things", + "id" => "20" + } + ] + } + } + }, + { + "id" => "102", + "type" => "boxes", + "links" => { + "self" => "http://test.host/api/boxes/102" + }, + "relationships" => { + "things" => { + "links" => { + "self" => "http://test.host/api/boxes/102/relationships/things", + "related" => "http://test.host/api/boxes/102/things" + }, + "data" => [ + { + "type" => "things", + "id" => "30" + } + ] + } + } + } + ], + "included" => [ + { + "id" => "10", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/10" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/10/relationships/box", + "related" => "http://test.host/api/things/10/box" + }, + "data" => { + "type" => "boxes", + "id" => "100" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/10/relationships/user", + "related" => "http://test.host/api/things/10/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/10/relationships/things", + "related" => "http://test.host/api/things/10/things" + }, + "data" => [ + { + "type" => "things", + "id" => "20" + } + ] + } + } + }, + { + "id" => "20", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/20" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/20/relationships/box", + "related" => "http://test.host/api/things/20/box" + }, + "data" => { + "type" => "boxes", + "id" => "100" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/20/relationships/user", + "related" => "http://test.host/api/things/20/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/20/relationships/things", + "related" => "http://test.host/api/things/20/things" + }, + "data" => [ + { + "type" => "things", + "id" => "10" + } + ] + } + } + }, + { + "id" => "30", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/30" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/30/relationships/box", + "related" => "http://test.host/api/things/30/box" + }, + "data" => { + "type" => "boxes", + "id" => "102" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/30/relationships/user", + "related" => "http://test.host/api/things/30/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/30/relationships/things", + "related" => "http://test.host/api/things/30/things" + }, + "data" => [ + { + "type" => "things", + "id" => "40" + }, + { + "type" => "things", + "id" => "50" + } + + ] + } + } + }, + { + "id" => "40", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/40" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/40/relationships/box", + "related" => "http://test.host/api/things/40/box" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/40/relationships/user", + "related" => "http://test.host/api/things/40/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/40/relationships/things", + "related" => "http://test.host/api/things/40/things" + }, + "data"=>[] + } + } + }, + { + "id" => "50", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/50" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/50/relationships/box", + "related" => "http://test.host/api/things/50/box" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/50/relationships/user", + "related" => "http://test.host/api/things/50/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/50/relationships/things", + "related" => "http://test.host/api/things/50/things" + }, + "data" => [ + { + "type" => "things", + "id" => "60" + } + ] + } + } + }, + { + "id" => "60", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/60" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/60/relationships/box", + "related" => "http://test.host/api/things/60/box" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/60/relationships/user", + "related" => "http://test.host/api/things/60/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/60/relationships/things", + "related" => "http://test.host/api/things/60/things" + } + } + } + } + ] + }, + json_response) end def test_complex_includes_nested_things_secondary_users assert_cacheable_get :index, params: {include: 'things,things.user,things.things'} assert_response :success + assert_hash_equals( + { + "data" => [ + { + "id" => "100", + "type" => "boxes", + "links" => { + "self" => "http://test.host/api/boxes/100" + }, + "relationships" => { + "things" => { + "links" => { + "self" => "http://test.host/api/boxes/100/relationships/things", + "related" => "http://test.host/api/boxes/100/things" + }, + "data" => [ + { + "type" => "things", + "id" => "10" + }, + { + "type" => "things", + "id" => "20" + } + ] + } + } + }, + { + "id" => "102", + "type" => "boxes", + "links" => { + "self" => "http://test.host/api/boxes/102" + }, + "relationships" => { + "things" => { + "links" => { + "self" => "http://test.host/api/boxes/102/relationships/things", + "related" => "http://test.host/api/boxes/102/things" + }, + "data" => [ + { + "type" => "things", + "id" => "30" + } + ] + } + } + } + ], + "included" => [ + { + "id" => "10", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/10" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/10/relationships/box", + "related" => "http://test.host/api/things/10/box" + }, + "data" => { + "type" => "boxes", + "id" => "100" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/10/relationships/user", + "related" => "http://test.host/api/things/10/user" + }, + "data" => { + "type" => "users", + "id" => "10001" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/10/relationships/things", + "related" => "http://test.host/api/things/10/things" + }, + "data" => [ + { + "type" => "things", + "id" => "20" + } + ] + } + } + }, + { + "id" => "20", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/20" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/20/relationships/box", + "related" => "http://test.host/api/things/20/box" + }, + "data" => { + "type" => "boxes", + "id" => "100" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/20/relationships/user", + "related" => "http://test.host/api/things/20/user" + }, + "data" => { + "type" => "users", + "id" => "10001" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/20/relationships/things", + "related" => "http://test.host/api/things/20/things" + }, + "data" => [ + { + "type" => "things", + "id" => "10" + } + ] + } + } + }, + { + "id" => "30", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/30" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/30/relationships/box", + "related" => "http://test.host/api/things/30/box" + }, + "data" => { + "type" => "boxes", + "id" => "102" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/30/relationships/user", + "related" => "http://test.host/api/things/30/user" + }, + "data" => { + "type" => "users", + "id" => "10002" + } + + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/30/relationships/things", + "related" => "http://test.host/api/things/30/things" + }, + "data" => [ + { + "type" => "things", + "id" => "40" + }, + { + "type" => "things", + "id" => "50" + } + + ] + } + } + }, + { + "id" => "40", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/40" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/40/relationships/box", + "related" => "http://test.host/api/things/40/box" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/40/relationships/user", + "related" => "http://test.host/api/things/40/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/40/relationships/things", + "related" => "http://test.host/api/things/40/things" + } + } + } + }, + { + "id" => "50", + "type" => "things", + "links" => { + "self" => "http://test.host/api/things/50" + }, + "relationships" => { + "box" => { + "links" => { + "self" => "http://test.host/api/things/50/relationships/box", + "related" => "http://test.host/api/things/50/box" + } + }, + "user" => { + "links" => { + "self" => "http://test.host/api/things/50/relationships/user", + "related" => "http://test.host/api/things/50/user" + } + }, + "things" => { + "links" => { + "self" => "http://test.host/api/things/50/relationships/things", + "related" => "http://test.host/api/things/50/things" + } + } + } + }, + { + "id" => "10001", + "type" => "users", + "links" => { + "self" => "http://test.host/api/users/10001" + }, + "attributes" => { + "name" => "user 1" + }, + "relationships" => { + "things" => { + "links" => { + "self" => "http://test.host/api/users/10001/relationships/things", + "related" => "http://test.host/api/users/10001/things" + }, + "data" => [ + { + "type" => "things", + "id" => "10" + }, + { + "type" => "things", + "id" => "20" + } + ] + } + } + }, + { + "id" => "10002", + "type" => "users", + "links" => { + "self" => "http://test.host/api/users/10002" + }, + "attributes" => { + "name" => "user 2" + }, + "relationships" => { + "things" => { + "links" => { + "self" => "http://test.host/api/users/10002/relationships/things", + "related" => "http://test.host/api/users/10002/things" + }, + "data" => [ + { + "type" => "things", + "id" => "30" + } + ] + } + } + } + ] + }, + json_response) + end +end + +class BlogPostsControllerTest < ActionController::TestCase + def test_filter_by_delegated_attribute + assert_cacheable_get :index, params: {filter: {name: 'some title'}} + assert_response :success + end - # The test is hardcoded with the include order. This should be changed at some point since either thing could come first and still be valid - assert_equal '1', json_response['included'][2]['id'] - assert_equal 'users', json_response['included'][2]['type'] - assert_nil json_response['included'][2]['relationships']['things']['data'] + def test_sorting_by_delegated_attribute + assert_cacheable_get :index, params: {sort: 'name'} + assert_response :success + end - assert_equal '2', json_response['included'][0]['id'] - assert_equal 'things', json_response['included'][0]['type'] - assert_equal '1', json_response['included'][0]['relationships']['user']['data']['id'] - assert_equal '1', json_response['included'][0]['relationships']['things']['data'][0]['id'] + def test_fields_with_delegated_attribute + original_config = JSONAPI.configuration.dup + JSONAPI.configuration.json_key_format = :underscored_key - assert_equal '1', json_response['included'][1]['id'] - assert_equal 'things', json_response['included'][1]['type'] - assert_equal '1', json_response['included'][1]['relationships']['user']['data']['id'] - assert_equal '2', json_response['included'][1]['relationships']['things']['data'][0]['id'] + assert_cacheable_get :index, params: {fields: {blog_posts: 'name'}} + assert_response :success + assert_equal ['name'], json_response['data'].first['attributes'].keys + ensure + JSONAPI.configuration = original_config + end +end + +class RobotsControllerTest < ActionController::TestCase + + def teardown + Robot.delete_all + end + + def test_fetch_robots_with_sort_by_name + Robot.create! name: 'John', version: 1 + Robot.create! name: 'jane', version: 1 + assert_cacheable_get :index, params: {sort: 'name'} + assert_response :success + + if ENV['DATABASE_URL'].starts_with?('postgres') + assert_equal 'jane', json_response['data'].first['attributes']['name'] + else + assert_equal 'John', json_response['data'].first['attributes']['name'] + end + end + + def test_fetch_robots_with_sort_by_lower_name + Robot.create! name: 'John', version: 1 + Robot.create! name: 'jane', version: 1 + assert_cacheable_get :index, params: {sort: 'lower_name'} + assert_response :success + assert_equal 'jane', json_response['data'].first['attributes']['name'] + end + + def test_fetch_robots_with_sort_by_version + Robot.create! name: 'John', version: 1 + Robot.create! name: 'jane', version: 2 + assert_cacheable_get :index, params: {sort: 'version'} + assert_response 400 + assert_equal 'version is not a valid sort criteria for robots', json_response['errors'].first['detail'] + end +end + +class Api::V6::AuthorDetailsControllerTest < ActionController::TestCase + def after_teardown + Api::V6::AuthorDetailResource.paginator :none # TODO: ??? + end + + def test_that_the_last_two_author_details_belong_to_an_author + Api::V6::AuthorDetailResource.paginator :offset + + total_count = AuthorDetail.count + assert_operator total_count, :>=, 2 + + assert_cacheable_get :index, params: {sort: :id, include: :author, page: {limit: 10, offset: total_count - 2}} + assert_response :success + assert_equal 2, json_response['data'].size + assert_not_nil json_response['data'][0]['relationships']['author']['data'] + assert_not_nil json_response['data'][1]['relationships']['author']['data'] + end + + def test_that_the_last_author_detail_includes_its_author_even_if_returned_as_the_single_entry_on_a_page_with_nonzero_offset + Api::V6::AuthorDetailResource.paginator :offset + + total_count = AuthorDetail.count + assert_operator total_count, :>=, 2 + + assert_cacheable_get :index, params: {sort: :id, include: :author, page: {limit: 10, offset: total_count - 1}} + assert_response :success + assert_equal 1, json_response['data'].size + assert_not_nil json_response['data'][0]['relationships']['author']['data'] end end diff --git a/test/fixtures/access_cards.yml b/test/fixtures/access_cards.yml new file mode 100644 index 000000000..a5ca5aea5 --- /dev/null +++ b/test/fixtures/access_cards.yml @@ -0,0 +1,4 @@ +john_doe_worker_card: + id: 1 + token: "some-token" + security_level: "admin" diff --git a/test/fixtures/active_record.rb b/test/fixtures/active_record.rb index 778c72538..e6866785c 100644 --- a/test/fixtures/active_record.rb +++ b/test/fixtures/active_record.rb @@ -3,11 +3,37 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.uncountable 'preferences' + inflect.uncountable 'file_properties' inflect.irregular 'numero_telefone', 'numeros_telefone' end ### DATABASE ActiveRecord::Schema.define do + create_table :sessions, id: false, force: true do |t| + t.string :id, :limit => 36, :primary_key => true, null: false + t.string :survey_id, :limit => 36, null: false + + t.timestamps + end + + create_table :responses, force: true do |t| + #t.string :id, :limit => 36, :primary_key => true, null: false + + t.string :session_id, limit: 36, null: false + + t.string :type + t.string :question_id, limit: 36 + + t.timestamps + end + + create_table :response_texts, force: true do |t| + t.text :text + t.integer :response_id + + t.timestamps + end + create_table :people, force: true do |t| t.string :name t.string :email @@ -22,10 +48,11 @@ create_table :author_details, force: true do |t| t.integer :person_id t.string :author_stuff + t.timestamps null: false end create_table :posts, force: true do |t| - t.string :title + t.string :title, limit: 255 t.text :body t.integer :author_id t.integer :parent_post_id @@ -58,17 +85,20 @@ end create_table :posts_tags, force: true do |t| - t.references :post, :tag, index: true + t.references :post, index:true + t.references :tag, index:true end add_index :posts_tags, [:post_id, :tag_id], unique: true create_table :special_post_tags, force: true do |t| - t.references :post, :tag, index: true + t.references :post, index: true + t.references :tag, index: true end add_index :special_post_tags, [:post_id, :tag_id], unique: true create_table :comments_tags, force: true do |t| - t.references :comment, :tag, index: true + t.references :comment, index: true + t.references :tag, index: true end create_table :iso_currencies, id: false, force: true do |t| @@ -120,6 +150,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 @@ -213,19 +244,30 @@ create_table :pictures, force: true do |t| t.string :name - t.integer :imageable_id - t.string :imageable_type + t.integer :author_id + t.references :imageable, polymorphic: true, index: true t.timestamps null: false end create_table :documents, force: true do |t| t.string :name + t.integer :author_id t.timestamps null: false end create_table :products, force: true do |t| t.string :name + t.integer :designer_id + t.timestamps null: false + end + + create_table :file_properties, force: true do |t| + t.string :name t.timestamps null: false + t.references :fileable, polymorphic: true, index: true + t.belongs_to :tag, index: true + + t.integer :size end create_table :vehicles, force: true do |t| @@ -285,19 +327,155 @@ create_table :related_things, force: true do |t| t.string :name - t.references :from, references: :thing - t.references :to, references: :thing + t.references :from, foreign_key: { to_table: :things } + t.references :to, foreign_key: { to_table: :things } + + t.timestamps null: false + end + + create_table :questions, force: true do |t| + t.string :text + t.timestamps null: false + end + + create_table :answers, force: true do |t| + t.references :question + t.integer :respondent_id + t.string :respondent_type + t.string :text + t.timestamps null: false + end + + create_table :patients, force: true do |t| + t.string :name + t.timestamps null: false + end + + create_table :doctors, force: true do |t| + t.string :name + t.timestamps null: false + 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 + create_table :storages, force: true do |t| + t.string :token, null: false + t.string :name + t.timestamps null: false + end + + create_table :keepers, force: true do |t| + t.string :name + t.string :keepable_type, null: false + t.integer :keepable_id, null: false + t.timestamps null: false + end + + create_table :access_cards, force: true do |t| + t.string :token, null: false + t.string :security_level + t.timestamps null: false + end + + create_table :workers, force: true do |t| + t.string :name + t.integer :access_card_id, null: false + t.timestamps null: false + end + + create_table :agencies, force: true do |t| + t.string :name + t.timestamps null: false + end + + create_table :indicators, force: true do |t| + t.string :name + t.string :import_id + t.integer :agency_id, null: false + t.timestamps null: false + end + + create_table :widgets, force: true do |t| + t.string :name + t.string :indicator_import_id, null: false + t.timestamps null: false + end + + create_table :robots, force: true do |t| + t.string :name + t.integer :version + t.timestamps null: false + end end ### MODELS +class Session < ActiveRecord::Base + self.primary_key = "id" + has_many :responses +end + +class Response < ActiveRecord::Base + belongs_to :session + has_one :paragraph, :class_name => "ResponseText::Paragraph" + + def response_type + case self.type + when "Response::SingleTextbox" + "single_textbox" + else + "question" + end + end + def response_type=type + self.type = case type + when "single_textbox" + "Response::SingleTextbox" + else + "Response" + end + end +end + +class Response::SingleTextbox < Response + has_one :paragraph, :class_name => "ResponseText::Paragraph", :foreign_key => :response_id +end + +class ResponseText < ActiveRecord::Base +end + +class ResponseText::Paragraph < ResponseText +end + class Person < ActiveRecord::Base has_many :posts, foreign_key: 'author_id' has_many :comments, foreign_key: 'author_id' + has_many :book_comments, foreign_key: 'author_id' has_many :expense_entries, foreign_key: 'employee_id', dependent: :restrict_with_exception has_many :vehicles belongs_to :preferences @@ -305,10 +483,15 @@ class Person < ActiveRecord::Base has_one :author_detail has_and_belongs_to_many :books, join_table: :book_authors + has_and_belongs_to_many :not_banned_books, -> { merge(Book.not_banned) }, + class_name: 'Book', + join_table: :book_authors has_many :even_posts, -> { where('posts.id % 2 = 0') }, class_name: 'Post', foreign_key: 'author_id' has_many :odd_posts, -> { where('posts.id % 2 = 1') }, class_name: 'Post', foreign_key: 'author_id' + has_many :pictures, foreign_key: 'author_id' + ### Validations validates :name, presence: true validates :date_joined, presence: true @@ -323,10 +506,10 @@ 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' + belongs_to :parent_post, class_name: 'Post', foreign_key: 'parent_post_id' validates :author, presence: true validates :title, length: { maximum: 35 } @@ -334,16 +517,13 @@ class Post < ActiveRecord::Base before_destroy :destroy_callback def destroy_callback - if title == "can't destroy me" - errors.add(:title, "can't destroy me") - - # :nocov: - if Rails::VERSION::MAJOR >= 5 - throw(:abort) - else - return false - end - # :nocov: + case title + when "can't destroy me", "can't destroy me either" + errors.add(:base, "can't destroy me") + throw(:abort) + when "locked title" + errors.add(:title, "is locked") + throw(:abort) end end end @@ -368,6 +548,8 @@ class Firm < Company class Tag < ActiveRecord::Base has_and_belongs_to_many :posts, join_table: :posts_tags has_and_belongs_to_many :planets, join_table: :planets_tags + + has_and_belongs_to_many :comments, join_table: :comments_tags end class Section < ActiveRecord::Base @@ -375,6 +557,7 @@ class Section < ActiveRecord::Base end class HairCut < ActiveRecord::Base + has_many :people end class Property < ActiveRecord::Base @@ -391,7 +574,7 @@ class Cat < ActiveRecord::Base class IsoCurrency < ActiveRecord::Base self.primary_key = :code - # has_many :expense_entries, foreign_key: 'currency_code' + has_many :expense_entries, foreign_key: 'currency_code' end class ExpenseEntry < ActiveRecord::Base @@ -411,13 +594,7 @@ class Planet < ActiveRecord::Base def check_not_pluto # Pluto can't be a planet, so cancel the save if name.downcase == 'pluto' - # :nocov: - if Rails::VERSION::MAJOR >= 5 - throw(:abort) - else - return false - end - # :nocov: + throw(:abort) end end end @@ -450,6 +627,7 @@ class Like < ActiveRecord::Base end class Breed + include ActiveModel::Model def initialize(id = nil, name = nil) if id.nil? @@ -468,19 +646,7 @@ def destroy $breed_data.remove(@id) end - def valid?(context = nil) - @errors.clear - if name.is_a?(String) && name.length > 0 - return true - else - @errors.add(:name, "can't be blank") - return false - end - end - - def errors - @errors - end + validates :name, presence: true end class Book < ActiveRecord::Base @@ -488,6 +654,10 @@ class Book < ActiveRecord::Base has_many :approved_book_comments, -> { where(approved: true) }, class_name: "BookComment" has_and_belongs_to_many :authors, join_table: :book_authors, class_name: "Person" + + scope :not_banned, -> { + where(banned: false) + } end class BookComment < ActiveRecord::Base @@ -495,7 +665,7 @@ class BookComment < ActiveRecord::Base belongs_to :book def self.for_user(current_user) - records = self + records = self.all # Hide the unapproved comments from people who are not book admins unless current_user && current_user.book_admin records = records.where(approved: true) @@ -555,7 +725,13 @@ class Category < ActiveRecord::Base end class Picture < ActiveRecord::Base + belongs_to :author, class_name: 'Person', foreign_key: 'author_id' + belongs_to :imageable, polymorphic: true + belongs_to :document, -> { where( pictures: { imageable_type: 'Document' } ) }, foreign_key: 'imageable_id' + belongs_to :product, -> { where( pictures: { imageable_type: 'Product' } ) }, foreign_key: 'imageable_id' + + has_one :file_properties, as: 'fileable' end class Vehicle < ActiveRecord::Base @@ -570,13 +746,19 @@ class Boat < Vehicle class Document < ActiveRecord::Base has_many :pictures, as: :imageable + belongs_to :author, class_name: 'Person', foreign_key: 'author_id' + has_one :file_properties, as: 'fileable' end -class Document::Topic < Document +class Product < ActiveRecord::Base + has_many :pictures, as: :imageable + belongs_to :designer, class_name: 'Person', foreign_key: 'designer_id' + has_one :file_properties, as: 'fileable' end -class Product < ActiveRecord::Base - has_one :picture, as: :imageable +class FileProperties < ActiveRecord::Base + belongs_to :fileable, polymorphic: true + belongs_to :tag end class Make < ActiveRecord::Base @@ -602,8 +784,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,8 +817,74 @@ class Customer < Customer end end +class Storage < ActiveRecord::Base + has_one :keeper, class_name: 'Keeper', as: :keepable +end + +class Keeper < ActiveRecord::Base + belongs_to :keepable, polymorphic: true +end + +class AccessCard < ActiveRecord::Base + has_many :workers +end + +class Worker < ActiveRecord::Base + belongs_to :access_card +end + +class Agency < ActiveRecord::Base +end + +class Indicator < ActiveRecord::Base + belongs_to :agency + has_many :widgets, primary_key: :import_id, foreign_key: :indicator_import_id +end + +class Widget < ActiveRecord::Base + belongs_to :indicator, primary_key: :import_id, foreign_key: :indicator_import_id +end + +class Robot < ActiveRecord::Base +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 SessionsController < ActionController::Base + include JSONAPI::ActsAsResourceController + before_action :create_responses_relationships, :only => [:create,:update] + + private + def create_responses_relationships + if !params[:data][:relationships].nil? && !params[:data][:relationships][:responses].nil? + responses_params = params[:data][:relationships].delete(:responses) + params[:data][:attributes][:responses] = responses_params + end + end +end + class AuthorsController < JSONAPI::ResourceControllerMetal + include Rails.application.routes.url_helpers end class PeopleController < JSONAPI::ResourceController @@ -633,21 +900,12 @@ class SpecialError < StandardError; end class SubSpecialError < PostsController::SpecialError; end class SerializeError < StandardError; end - # This is used to test that classes that are whitelisted are reraised by + # This is used to test that classes that are allowed are reraised by # the operations dispatcher. rescue_from PostsController::SpecialError do head :forbidden end - def handle_exceptions(e) - case e - when PostsController::SpecialError - raise e - else - super(e) - end - end - #called by test_on_server_error def self.set_callback_message(error) @callback_message = "Sent from method" @@ -707,6 +965,9 @@ class ProductsController < JSONAPI::ResourceController class ImageablesController < JSONAPI::ResourceController end +class FilePropertiesController < JSONAPI::ResourceController +end + class VehiclesController < JSONAPI::ResourceController end @@ -717,6 +978,9 @@ class BoatsController < JSONAPI::ResourceController end class BooksController < JSONAPI::ResourceController + def context + { title: 'Title' } + end end ### CONTROLLERS @@ -765,6 +1029,9 @@ class LikesController < JSONAPI::ResourceController module V2 class AuthorsController < JSONAPI::ResourceController + def context + {current_user: $test_user} + end end class PeopleController < JSONAPI::ResourceController @@ -774,6 +1041,9 @@ class PostsController < JSONAPI::ResourceController end class PreferencesController < JSONAPI::ResourceController + def context + {current_user: $test_user} + end end class BooksController < JSONAPI::ResourceController @@ -823,9 +1093,18 @@ class ExpenseEntriesController < JSONAPI::ResourceController class IsoCurrenciesController < JSONAPI::ResourceController end + + class PaintersController < JSONAPI::ResourceController + end end module V6 + class AuthorsController < JSONAPI::ResourceController + end + + class AuthorDetailsController < JSONAPI::ResourceController + end + class PostsController < JSONAPI::ResourceController end @@ -872,6 +1151,35 @@ module V8 class NumerosTelefoneController < JSONAPI::ResourceController end end + + module V9 + class AuthorsController < JSONAPI::ResourceController + end + + class AuthorDetailsController < JSONAPI::ResourceController + end + + class PostsController < JSONAPI::ResourceController + end + + class CommentsController < JSONAPI::ResourceController + end + + class SectionsController < JSONAPI::ResourceController + end + + 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,22 +1187,117 @@ 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 + +class StoragesController < BaseController +end + +class KeepersController < BaseController +end + +class AccessCardsController < BaseController +end + +class WorkersController < BaseController +end + +class WidgetsController < JSONAPI::ResourceController +end + +class IndicatorsController < JSONAPI::ResourceController +end + +class RobotsController < JSONAPI::ResourceController +end + ### RESOURCES class BaseResource < JSONAPI::Resource abstract end +class SessionResource < JSONAPI::Resource + key_type :uuid + + attributes :survey_id, :responses + + has_many :responses + + def responses=params + params[:data].each { |datum| + response = @model.responses.build(((datum[:attributes].respond_to?(:permit))? datum[:attributes].permit(:response_type, :question_id) : datum[:attributes])) + + (datum[:relationships] || {}).each_pair { |k,v| + case k + when "paragraph" + response.paragraph = ResponseText::Paragraph.create(((v[:data][:attributes].respond_to?(:permit))? v[:data][:attributes].permit(:text) : v[:data][:attributes])) + end + } + } + end + def responses + end + + def self.creatable_fields(context) + super + [ + :id, + ] + end + + def fetchable_fields + super - [:responses] + end +end + +class ResponseResource < JSONAPI::Resource + model_hint model: Response::SingleTextbox, resource: :response + + has_one :session + + attributes :question_id, :response_type + + has_one :paragraph +end + +class ParagraphResource < JSONAPI::Resource + model_name 'ResponseText::Paragraph' + + attributes :text + + has_one :response +end + class PersonResource < BaseResource attributes :name, :email attribute :date_joined, format: :date_with_timezone - has_many :comments - has_many :posts + has_many :comments, inverse_relationship: :author + has_many :posts, inverse_relationship: :author has_many :vehicles, polymorphic: true has_one :preferences has_one :hair_cut + has_many :expense_entries + filter :name, verify: :verify_name_filter def self.verify_name_filter(values, _context) @@ -937,10 +1340,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,18 +1363,22 @@ class CompanyResource < JSONAPI::Resource end class FirmResource < CompanyResource + model_name "Firm" end class TagResource < JSONAPI::Resource attributes :name has_many :posts + has_many :comments # Not including the planets relationship so they don't get output #has_many :planets end class SectionResource < JSONAPI::Resource attributes 'name' + + has_many :posts end module ParentApi @@ -993,6 +1402,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 @@ -1047,7 +1460,7 @@ def title=(title) return values }, apply: -> (records, value, _options) { - records.where('id IN (?)', value) + records.where('posts.id IN (?)', value) } filter :search, @@ -1058,12 +1471,17 @@ def title=(title) records.where(title: values.first['title']) } + filter 'tags.name' + + filter 'comments.author.name' + filter 'comments.tags.name' + def self.updatable_fields(context) super(context) - [:author, :subject] end def self.creatable_fields(context) - super(context) - [:subject, :id] + super(context) - [:subject] end def self.sortable_fields(context) @@ -1084,6 +1502,9 @@ class HairCutResource < JSONAPI::Resource class IsoCurrencyResource < JSONAPI::Resource attributes :name, :country_name, :minor_unit + attribute :id, format: :id, readonly: false + + has_many :expense_entries filter :country_name @@ -1095,30 +1516,156 @@ class ExpenseEntryResource < JSONAPI::Resource attribute :transaction_date, format: :date has_one :iso_currency, foreign_key: 'currency_code' - has_one :employee, class_name: 'Person' + has_one :employee end class EmployeeResource < JSONAPI::Resource attributes :name, :email model_name 'Person' + has_many :expense_entries +end + +class PoroResource < JSONAPI::BasicResource + root_resource + + class << self + def find_records(filters, options) + fail NotImplementedError, <<~EOF + Should be something like + def find_records(filters, options) + breeds = [] + id_filter = filters[:id] + id_filter = [id_filter] unless id_filter.nil? || id_filter.is_a?(Array) + $breed_data.breeds.values.each do |breed| + breeds.push(breed) unless id_filter && !id_filter.include?(breed.id) + end + breeds + end + EOF + end + + def find_record_by_key(key, options = {}) + fail NotImplementedError, <<~EOF + Should be something like + def find_record_by_key(key, options = {}) + $breed_data.breeds[key.to_i] + end + EOF + end + + def find_records_by_keys(keys, options = {}) + fail NotImplementedError, <<~EOF + Should be something like + def find_records_by_keys(keys, options = {}) + breeds = [] + keys.each do |key| + breeds.push($breed_data.breeds[key.to_i]) + end + breeds + end + EOF + end + + # Finds Resources using the `filters`. Pagination and sort options are used when provided + # + # @param filters [Hash] the filters hash + # @option options [Hash] :context The context of the request, set in the controller + # @option options [Hash] :sort_criteria The `sort criteria` + # @option options [Hash] :include_directives The `include_directives` + # + # @return [Array] the Resource instances matching the filters, sorting and pagination rules. + def find(filters, options = {}) + records = find_records(filters, options) + resources_for(records, options[:context]) + end + + # Records + def find_fragments(filters, options) + fragments = {} + find_records(filters, options).each do |record| + rid = JSONAPI::ResourceIdentity.new(resource_klass, record.id) + # We can use either the id or the full resource. + # fragments[rid] = JSONAPI::ResourceFragment.new(rid) + # OR + # fragments[rid] = JSONAPI::ResourceFragment.new(rid, resource: resource_klass.new(record, options[:context])) + # In this case we will use the resource since we already looked up the model instance + fragments[rid] = JSONAPI::ResourceFragment.new(rid, resource: resource_klass.new(record, options[:context])) + end + fragments + end + + def resource_klass + self + end + + # Counts Resources found using the `filters` + # + # @param filters [Hash] the filters hash + # @option options [Hash] :context The context of the request, set in the controller + # + # @return [Integer] the count + def count(filters, options = {}) + fail NotImplementedError, <<~EOF + Should be something like + def count(filters, options) + 0 + end + EOF + end + + # Returns the single Resource identified by `key` + # + # @param key the primary key of the resource to find + # @option options [Hash] :context The context of the request, set in the controller + def find_by_key(key, options = {}) + record = find_record_by_key(key, options) + resource_for(record, options[:context]) + end + + def find_to_populate_by_keys(keys, options = {}) + find_by_keys(keys, options) + end + + # Returns an array of Resources identified by the `keys` array + # + # @param keys [Array] Array of primary keys to find resources for + # @option options [Hash] :context The context of the request, set in the controller + def find_by_keys(keys, options = {}) + records = find_records_by_keys(keys, options) + resources_for(records, options[:context]) + end + end end -class BreedResource < JSONAPI::Resource +class BreedResource < PoroResource + attribute :name, format: :title # This is unneeded, just here for testing routing_options param: :id - def self.find(filters, options = {}) - breeds = [] - $breed_data.breeds.values.each do |breed| - breeds.push(BreedResource.new(breed, options[:context])) + class << self + def find_records(filters, options = {}) + breeds = [] + id_filter = filters[:id] + id_filter = [id_filter] unless id_filter.nil? || id_filter.is_a?(Array) + $breed_data.breeds.values.each do |breed| + breeds.push(breed) unless id_filter && !id_filter.include?(breed.id) + end + breeds end - breeds - end - def self.find_by_key(id, options = {}) - BreedResource.new($breed_data.breeds[id.to_i], options[:context]) + def find_record_by_key(key, options = {}) + $breed_data.breeds[key.to_i] + end + + def find_records_by_keys(keys, options = {}) + breeds = [] + keys.each do |key| + breeds.push($breed_data.breeds[key.to_i]) + end + breeds + end end def _save @@ -1132,13 +1679,9 @@ class PlanetResource < JSONAPI::Resource attribute :description has_many :moons - has_one :planet_type + belongs_to :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 @@ -1168,7 +1711,7 @@ class CraterResource < JSONAPI::Resource filter :description, apply: -> (records, value, options) { fail "context not set" unless options[:context][:current_user] != nil && options[:context][:current_user] == $test_user - records.where(:description => value) + records.where(concat_table_field(options.dig(:_relation_helper_options, :join_manager).source_join_details[:alias], :description) => value) } def self.verify_key(key, context = nil) @@ -1179,11 +1722,13 @@ def self.verify_key(key, context = nil) class PreferencesResource < JSONAPI::Resource attribute :advanced_mode - has_one :author, :foreign_key_on => :related + singleton singleton_key: -> (context) { + key = context[:current_user].try(:preferences).try(:id) + raise JSONAPI::Exceptions::RecordNotFound.new(nil) if key.nil? + key + } - def self.find_records(filters, options = {}) - Preferences.limit(1) - end + has_one :author, :foreign_key_on => :related, class_name: "Person" end class FactResource < JSONAPI::Resource @@ -1204,29 +1749,58 @@ class CategoryResource < JSONAPI::Resource class PictureResource < JSONAPI::Resource attribute :name + has_one :author + has_one :imageable, polymorphic: true + has_one :file_properties, inverse_relationship: :fileable, :foreign_key_on => :related, polymorphic: true + + filter 'imageable.name', perform_joins: true, apply: -> (records, value, options) { + join_manager = options.dig(:_relation_helper_options, :join_manager) + relationship = _relationship(:imageable) + or_parts = relationship.resource_types.collect do |type| + table_alias = join_manager.join_details_by_polymorphic_relationship(relationship, type)[:alias] + "#{concat_table_field(table_alias, "name")} = '#{value.first}'" + end + records.where(Arel.sql(or_parts.join(' OR '))) + } + + filter 'imageable#documents.name' +end + +class ImageableResource < JSONAPI::Resource + polymorphic +end + +class FileableResource < JSONAPI::Resource + polymorphic end class DocumentResource < JSONAPI::Resource attribute :name - has_many :pictures -end + has_many :pictures, inverse_relationship: :imageable + has_one :author, class_name: 'Person' -class TopicResource < JSONAPI::Resource - model_name 'Document::Topic' - has_many :pictures + has_one :file_properties, inverse_relationship: :fileable, :foreign_key_on => :related end class ProductResource < JSONAPI::Resource attribute :name - has_one :picture, always_include_linkage_data: true + has_many :pictures, inverse_relationship: :imageable + has_one :designer, class_name: 'Person' + + has_one :file_properties, inverse_relationship: :fileable, :foreign_key_on => :related def picture_id _model.picture.id end end -class ImageableResource < JSONAPI::Resource +class FilePropertiesResource < JSONAPI::Resource + attribute :name + attribute :size + + has_one :fileable, polymorphic: true + has_one :tag end class MakeResource < JSONAPI::Resource @@ -1243,94 +1817,22 @@ class AuthorResource < JSONAPI::Resource attributes :name has_many :books, inverse_relationship: :authors + has_many :pictures + # has_one :preferences end class BookResource < JSONAPI::Resource - has_many :authors, class_name: 'Author', inverse_relationship: :books -end - -class AuthorDetailResource < JSONAPI::Resource - attributes :author_stuff -end - -class SimpleCustomLinkResource < JSONAPI::Resource - model_name 'Post' - attributes :title, :body, :subject - - def subject - @model.title - end - - has_one :writer, foreign_key: 'author_id', class_name: 'Writer' - has_one :section - has_many :comments, acts_as_set: false - - filters :writer - - def custom_links(options) - { raw: options[:serializer].link_builder.self_link(self) + "/raw" } - end -end - -class CustomLinkWithRelativePathOptionResource < JSONAPI::Resource - model_name 'Post' - attributes :title, :body, :subject - - def subject - @model.title - end - - has_one :writer, foreign_key: 'author_id', class_name: 'Writer' - has_one :section - has_many :comments, acts_as_set: false - - filters :writer - - def custom_links(options) - { raw: options[:serializer].link_builder.self_link(self) + "/super/duper/path.xml" } - end -end - -class CustomLinkWithIfCondition < JSONAPI::Resource - model_name 'Post' - attributes :title, :body, :subject - - def subject - @model.title - end - - has_one :writer, foreign_key: 'author_id', class_name: 'Writer' - has_one :section - has_many :comments, acts_as_set: false + attribute :title - filters :writer + has_many :authors, class_name: 'Author', inverse_relationship: :books - def custom_links(options) - if title == "JR Solves your serialization woes!" - {conditional_custom_link: options[:serializer].link_builder.self_link(self) + "/conditional/link.json"} - end + def title + context[:title] end end -class CustomLinkWithLambda < JSONAPI::Resource - model_name 'Post' - attributes :title, :body, :subject, :created_at - - def subject - @model.title - end - - has_one :writer, foreign_key: 'author_id', class_name: 'Writer' - has_one :section - has_many :comments, acts_as_set: false - - filters :writer - - def custom_links(options) - { - link_to_external_api: "http://external-api.com/posts/#{ created_at.year }/#{ created_at.month }/#{ created_at.day }-#{ subject.gsub(' ', '-') }" - } - end +class AuthorDetailResource < JSONAPI::Resource + attributes :author_stuff end module Api @@ -1365,6 +1867,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 @@ -1390,16 +1901,45 @@ class BoatResource < BoatResource; end module Api module V2 class PreferencesResource < PreferencesResource; end - class PersonResource < PersonResource; end + class SectionResource < SectionResource; end + class TagResource < TagResource; end + class CommentResource < CommentResource; end + class VehicleResource < VehicleResource; end + class CarResource < CarResource; end + class BoatResource < BoatResource; end + class HairCutResource < HairCutResource; end + class ExpenseEntryResource < ExpenseEntryResource; end + + class PersonResource < PersonResource + has_many :book_comments + end + class PostResource < PostResource; end + class AuthorResource < JSONAPI::Resource + model_name 'Person' + attributes :name + + has_many :books, inverse_relationship: :authors, relation_name: -> (options) { + book_admin = options[:context][:book_admin] || options[:context][:current_user].try(:book_admin) + + if book_admin + :books + else + :not_banned_books + end + } + + has_many :book_comments + end + class BookResource < JSONAPI::Resource - attribute :title + attribute "title" attributes :isbn, :banned - has_many :authors + has_many "authors", class_name: 'Authors' - has_many :book_comments, relation_name: -> (options = {}) { + has_many "book_comments", relation_name: -> (options = {}) { context = options[:context] current_user = context ? context[:current_user] : nil @@ -1410,9 +1950,24 @@ 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 + + filter :book_comments, + apply: ->(records, value, options) { + context = options[:context] + current_user = context ? context[:current_user] : nil + + relation = + unless current_user && current_user.book_admin + :approved_book_comments + else + :book_comments + end + + # Using an inner join here, which is different than the new default left_join + return records.joins(relation).references(relation).where('book_comments.id' => value) + } - filters :book_comments filter :banned, apply: :apply_filter_banned class << self @@ -1428,7 +1983,7 @@ def records(options = {}) context = options[:context] current_user = context ? context[:current_user] : nil - records = _model_class + records = _model_class.all # Hide the banned books from people who are not book admins unless current_user && current_user.book_admin records = records.where(not_banned_books) @@ -1453,7 +2008,7 @@ class BookCommentResource < JSONAPI::Resource attributes :body, :approved has_one :book - has_one :author, class_name: 'Person' + has_one :author filters :book filter :approved, apply: ->(records, value, options) { @@ -1464,6 +2019,9 @@ class BookCommentResource < JSONAPI::Resource records.where(approved_comments(value[0] == 'true')) end } + filter :body, apply: ->(records, value, options) { + records.where(BookComment.arel_table[:body].matches("%#{value[0]}%")) + } class << self def book_comments @@ -1492,15 +2050,30 @@ class PreferencesResource < PreferencesResource; end module Api module V4 - class PostResource < PostResource; end + class PostResource < PostResource + class << self + def records(options = {}) + # Sets up a performance issue for testing + super(options).joins(:comments) + end + end + 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 AuthorResource < Api::V2::AuthorResource; end class BookResource < Api::V2::BookResource paginator :paged end + class BiggerBookResource < Api::V4::BookResource + end + class BookCommentResource < Api::V2::BookCommentResource paginator :paged end @@ -1509,48 +2082,108 @@ 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' relationship :posts, to: :many relationship :author_detail, to: :one, foreign_key_on: :related - filter :name - - def self.find_records(filters, options = {}) - rel = _model_class - filters.each do |attr, filter| - if attr.to_s == "id" - rel = rel.where(id: filter) - else - rel = rel.where("\"#{attr}\" LIKE \"%#{filter[0]}%\"") - end - end - rel - end + filter :name, apply: lambda { |records, value, options| + table_alias = options.dig(:_relation_helper_options, :join_manager).source_join_details[:alias] + t = Arel::Table.new(:people, as: table_alias) + records.where(t[:name].matches("%#{value[0]}%")) + } def fetchable_fields super - [:email] end + + def self.sortable_fields(context) + super(context) + [:"author_detail.author_stuff"] + end end class AuthorDetailResource < JSONAPI::Resource attributes :author_stuff end + class PaintingResource < JSONAPI::Resource + model_name 'Painting' + attributes :title, :category #, :collector_roster + has_one :painter + has_many :collectors + + filter :title + filter :category + filter :collectors + + def collector_roster + collectors.map(&:name) + end + end + + class CollectorResource < JSONAPI::Resource + attributes :name + has_one :painting + 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) + } + end + class PersonResource < PersonResource; end - class PostResource < PostResource; end + class PreferencesResource < PreferencesResource; end class TagResource < TagResource; end class SectionResource < SectionResource; end class CommentResource < CommentResource; end class ExpenseEntryResource < ExpenseEntryResource; end class IsoCurrencyResource < IsoCurrencyResource; end class EmployeeResource < EmployeeResource; end + class VehicleResource < PersonResource; end + class HairCutResource < HairCutResource; end end end module Api module V6 + class HairCutResource < HairCutResource; end + + class AuthorDetailResource < JSONAPI::Resource + attributes :author_stuff + has_one :author, foreign_key: :person_id, inverse_relationship: :author_detail + end + + class AuthorResource < JSONAPI::Resource + attributes :name, :email + model_name 'Person' + relationship :posts, to: :many + relationship :author_detail, to: :one, foreign_key_on: :related, foreign_key: :person_id + has_one :hair_cut + + filter :name + + def self.sortable_fields(context) + super(context) + [:"author_detail.author_stuff"] + end + end + + class PreferencesResource < PreferencesResource; end class PersonResource < PersonResource; end class TagResource < TagResource; end @@ -1561,9 +2194,12 @@ class SectionResource < SectionResource class CommentResource < CommentResource; end class PostResource < PostResource - # Test caching with SQL fragments - def self.records(options = {}) - super.joins('INNER JOIN people on people.id = author_id') + attribute :base + + has_one :author + + def base + _model.title end end @@ -1616,6 +2252,8 @@ class PurchaseOrderResource < JSONAPI::Resource class OrderFlagResource < JSONAPI::Resource attributes :name + caching false + has_many :purchase_orders, reflect: false end @@ -1660,6 +2298,98 @@ class NumeroTelefoneResource < JSONAPI::Resource attribute :numero_telefone end end + + module V9 + class PersonResource < JSONAPI::Resource + has_one :preferences + singleton false + end + + class PostResource < PostResource + has_many :comments, apply_join: -> (records, relationship, resource_type, join_type, options) { + case join_type + when :inner + records = records.joins(relationship.relation_name(options)) + when :left + records = records.joins_left(relationship.relation_name(options)) + end + records.where(comments: {approved: true}) + } + end + + class TagResource < TagResource; end + class SectionResource < SectionResource; end + class CommentResource < CommentResource + has_one :author, class_name: 'Person', apply_join: -> (records, relationship, resource_type, join_type, options) { + records = apply_join(records: records, + relationship: relationship, + resource_type: resource_type, + join_type: join_type, + options: options) + + records.where(author: {special: true}) + } + end + + class AuthorResource < Api::V2::AuthorResource + end + + class BookResource < Api::V2::BookResource + end + + class BookCommentResource < Api::V2::BookCommentResource + 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 + + module V10 + class PersonResource < PersonResource; end + class PostResource < PostResource + has_many :comments, apply_join: -> (records, relationship, resource_type, join_type, options) { + case join_type + when :inner + records = records.joins(relationship.relation_name(options)) + when :left + records = records.joins_left(relationship.relation_name(options)) + end + records.where(comments: {approved: true}) + } + end + + class TagResource < TagResource; end + class SectionResource < SectionResource; end + class CommentResource < CommentResource + has_one :author, class_name: 'Person', apply_join: -> (records, relationship, resource_type, join_type, options) { + records = apply_join(records: records, + relationship: relationship, + resource_type: resource_type, + join_type: join_type, + options: options) + + records.where(author: {special: true}) + } + end + + class AuthorResource < Api::V2::AuthorResource + end + + class BookResource < Api::V2::BookResource + end + + class BookCommentResource < Api::V2::BookCommentResource + end + end end module AdminApi @@ -1676,31 +2406,66 @@ class PersonResource < JSONAPI::Resource end end +module OptionalNamespace + module V1 + class PersonResource < JSONAPI::Resource + end + end +end + 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 @@ -1721,6 +2486,47 @@ class FlatPostResource < JSONAPI::Resource class FlatPostsController < JSONAPI::ResourceController end +class BlogPost < ActiveRecord::Base + self.table_name = 'posts' +end + +class BlogPostsController < JSONAPI::ResourceController + +end + +class BlogPostResource < JSONAPI::Resource + model_name 'BlogPost', add_model_hint: false + model_hint model: 'BlogPost', resource: BlogPostResource + + attribute :name, :delegate => :title + attribute :body + + filter :name +end + +class AgencyResource < JSONAPI::Resource + attributes :name +end + +class IndicatorResource < JSONAPI::Resource + attributes :name + has_one :agency + has_many :widgets, foreign_key: :indicator_import_id, primary_key: :import_id + + def self.sortable_fields(_context = nil) + super + [:'widgets.name'] + end +end + +class WidgetResource < JSONAPI::Resource + attributes :name + has_one :indicator, foreign_key: :indicator_import_id, primary_key: :import_id + + def self.sortable_fields(_context = nil) + super + [:'indicator.agency.name'] + end +end + # CustomProcessors class Api::V4::BookProcessor < JSONAPI::Processor after_find do @@ -1776,6 +2582,9 @@ def show module Api class BoxResource < JSONAPI::Resource has_many :things + + filter 'things.things.name' + filter 'things.name' end class ThingResource < JSONAPI::Resource @@ -1783,10 +2592,87 @@ class ThingResource < JSONAPI::Resource has_one :user has_many :things + + filter 'things.things.name' end class UserResource < JSONAPI::Resource has_many :things + attribute :name + 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 + +class StorageResource < JSONAPI::Resource + key_type :string + primary_key :token + + attribute :name + has_many :keepers +end + +class KeeperResource < JSONAPI::Resource + has_one :keepable, polymorphic: true + + attribute :name +end + +class KeepableResource < JSONAPI::Resource + has_many :keepers +end + +class AccessCardResource < JSONAPI::Resource + key_type :string + primary_key :token + + has_many :workers + + attribute :security_level +end + +class WorkerResource < JSONAPI::Resource + has_one :access_card + + attribute :name +end + +class RobotResource < ::JSONAPI::Resource + attribute :name + attribute :version, sortable: false + + sort :lower_name, apply: ->(records, direction, _context) do + records.order("LOWER(robots.name) #{direction}") end end 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/author_details.yml b/test/fixtures/author_details.yml index 5711265c8..0d9d56077 100644 --- a/test/fixtures/author_details.yml +++ b/test/fixtures/author_details.yml @@ -1,9 +1,14 @@ a: id: 1 - person_id: 1 + person_id: 1001 author_stuff: blah blah b: id: 2 - person_id: 2 - author_stuff: blah blah blah \ No newline at end of file + person_id: 1002 + author_stuff: blah blah blah + +c: + id: 3 + person_id: 1003 + author_stuff: Prolific writer of schlock \ No newline at end of file diff --git a/test/fixtures/book_authors.yml b/test/fixtures/book_authors.yml index 12af5bd16..5b3819989 100644 --- a/test/fixtures/book_authors.yml +++ b/test/fixtures/book_authors.yml @@ -1,11 +1,23 @@ book_author_1_1: book_id: 1 - person_id: 1 + person_id: 1001 book_author_2_1: book_id: 2 - person_id: 1 + person_id: 1001 book_author_2_2: book_id: 2 - person_id: 2 + person_id: 1002 + +book_author_654_2: + book_id: 654 # Banned book + person_id: 1002 + + +<% for book_num in 300..343 %> +book_author_1003_<%= book_num %>: + id: <%= book_num + 30321 %> + book_id: <%= book_num %> + person_id: 1003 +<% end %> diff --git a/test/fixtures/book_comments.yml b/test/fixtures/book_comments.yml index 0fbf3487b..2dd40bd7e 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: <%= book_num.even? ? (comment_id % 2) + 1000: (comment_id % 2) + 1002 %> book_id: <%= book_num %> approved: <%= comment_num.even? %> <% comment_id = comment_id + 1 %> diff --git a/test/fixtures/boxes.yml b/test/fixtures/boxes.yml index c2c299d81..7ed5966a0 100644 --- a/test/fixtures/boxes.yml +++ b/test/fixtures/boxes.yml @@ -1,2 +1,5 @@ -box_1: - id: 1 +box_100: + id: 100 + +box_102: + id: 102 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/comments.yml b/test/fixtures/comments.yml index c68f16c05..4d23a67b1 100644 --- a/test/fixtures/comments.yml +++ b/test/fixtures/comments.yml @@ -2,30 +2,31 @@ post_1_dumb_post: id: 1 post_id: 1 body: what a dumb post - author_id: 1 + author_id: 1001 post_1_i_liked_it: id: 2 post_id: 1 body: i liked it - author_id: 2 + author_id: 1002 post_2_thanks_man: id: 3 post_id: 2 body: Thanks man. Great post. But what is JR? - author_id: 2 + author_id: 1002 rogue_comment: + id: 6 body: Rogue Comment Here - author_id: 3 + author_id: 1003 rogue_comment_2: id: 7 body: Rogue Comment 2 Here - author_id: 1 + author_id: 1001 rogue_comment_3: id: 8 body: Rogue Comment 3 Here - author_id: 1 \ No newline at end of file + author_id: 1001 \ No newline at end of file diff --git a/test/fixtures/comments_tags.yml b/test/fixtures/comments_tags.yml index d85aaa257..4491d6d62 100644 --- a/test/fixtures/comments_tags.yml +++ b/test/fixtures/comments_tags.yml @@ -1,20 +1,20 @@ post_1_dumb_post_whiny: comment_id: 1 - tag_id: 2 + tag_id: 502 post_1_dumb_post_short: comment_id: 1 - tag_id: 1 + tag_id: 501 post_1_i_liked_it_happy: comment_id: 2 - tag_id: 4 + tag_id: 504 post_1_i_liked_it_short: comment_id: 2 - tag_id: 1 + tag_id: 501 post_2_thanks_man_jr: comment_id: 3 - tag_id: 5 + tag_id: 505 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/documents.yml b/test/fixtures/documents.yml index 12312278d..635ee8881 100644 --- a/test/fixtures/documents.yml +++ b/test/fixtures/documents.yml @@ -1,3 +1,24 @@ document_1: id: 1 name: Company Brochure + author_id: 1002 + +document_2: + id: 2 + name: Enagement Letter + author_id: 1001 + +document_200: + id: 200 + name: Management Through the Years + author_id: + +document_201: + id: 201 + name: Foo + author_id: 1001 + +#ToDo: rename this once we have different filter types by default. See test_polymorpic_relation_filter +document_300: + id: 300 + name: Enterprise Gizmo \ No newline at end of file diff --git a/test/fixtures/expense_entries.yml b/test/fixtures/expense_entries.yml index 2f640b707..eabeea196 100644 --- a/test/fixtures/expense_entries.yml +++ b/test/fixtures/expense_entries.yml @@ -1,13 +1,13 @@ entry_1: id: 1 currency_code: USD - employee_id: 3 + employee_id: 1003 cost: 12.05 transaction_date: <%= Date.parse('2014-04-15') %> entry_2: id: 2 currency_code: USD - employee_id: 3 + employee_id: 1003 cost: 12.06 transaction_date: <%= Date.parse('2014-04-15') %> \ No newline at end of file diff --git a/test/fixtures/file_properties.yml b/test/fixtures/file_properties.yml new file mode 100644 index 000000000..0a1f2e207 --- /dev/null +++ b/test/fixtures/file_properties.yml @@ -0,0 +1,41 @@ +picture_2: + id: 20002 + name: company_brochure.jpg + fileable_id: 2 + fileable_type: Picture + +picture_3: + id: 20003 + name: group_photo.jpg + fileable_id: 3 + fileable_type: Picture + +picture_40: + id: 20040 + name: company_management_team_2015.jpg + fileable_id: 40 + fileable_type: Picture + +document_2: + id: 30002 + name: Enagement Letter.doc + fileable_id: 2 + fileable_type: Document + +document_200: + id: 30200 + name: Management Through the Years.doc + fileable_id: 200 + fileable_type: Document + +product_1: + id: 40001 + name: Enterprise Gizmo.spec + fileable_id: 1 + fileable_type: Product + +product_2: + id: 40002 + name: Fighting Hot Sauce.spec + fileable_id: 2 + fileable_type: Product diff --git a/test/fixtures/keepers.yml b/test/fixtures/keepers.yml new file mode 100644 index 000000000..6612cbd27 --- /dev/null +++ b/test/fixtures/keepers.yml @@ -0,0 +1,5 @@ +john_doe: + id: 1 + name: "John Doe" + keepable_id: 1 # storages.yml warehouse_1 + keepable_type: "Storage" 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..8bb10b780 100644 --- a/test/fixtures/people.yml +++ b/test/fixtures/people.yml @@ -1,37 +1,39 @@ a: - id: 1 + id: 1001 name: Joe Author email: joe@xyz.fake date_joined: <%= DateTime.parse('2013-08-07 20:25:00 UTC +00:00') %> preferences_id: 1 b: - id: 2 + id: 1002 name: Fred Reader email: fred@xyz.fake date_joined: <%= DateTime.parse('2013-10-31 20:25:00 UTC +00:00') %> + hair_cut_id: 1 c: - id: 3 + id: 1003 name: Lazy Author email: lazy@xyz.fake date_joined: <%= DateTime.parse('2013-10-31 21:25:00 UTC +00:00') %> d: - id: 4 + id: 1004 name: Tag Crazy Author email: taggy@xyz.fake date_joined: <%= DateTime.parse('2013-11-30 4:20:00 UTC +00:00') %> e: - id: 5 + id: 1005 name: Wilma Librarian 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 + id: 1000 name: The Shadow email: nobody@nowhere.comment_num date_joined: <%= DateTime.parse('1970-01-01 20:25:00 UTC +00:00') %> diff --git a/test/fixtures/pictures.yml b/test/fixtures/pictures.yml index 62584e945..a43f1df62 100644 --- a/test/fixtures/pictures.yml +++ b/test/fixtures/pictures.yml @@ -3,13 +3,47 @@ picture_1: name: enterprise_gizmo.jpg imageable_id: 1 imageable_type: Product + author_id: 1002 picture_2: id: 2 name: company_brochure.jpg imageable_id: 1 imageable_type: Document + author_id: 1002 picture_3: id: 3 name: group_photo.jpg + author_id: 1001 + +picture_40: + id: 40 + name: company_management_team_2015.jpg + imageable_id: 200 + imageable_type: Document + author_id: 1001 + +picture_41: + id: 41 + name: company_management_team_2016.jpg + imageable_id: 200 + imageable_type: Document + +picture_47: + id: 47 + name: company_management_team_2017.jpg + imageable_id: 200 + imageable_type: Document + +picture_48: + id: 48 + name: JunkYardDogs.jpg + imageable_id: 201 + imageable_type: Document + +picture_50: + id: 50 + name: Gizmo_logo.png + imageable_id: 300 + imageable_type: Document \ No newline at end of file diff --git a/test/fixtures/posts.yml b/test/fixtures/posts.yml index fa8947fc1..02d94ef3f 100644 --- a/test/fixtures/posts.yml +++ b/test/fixtures/posts.yml @@ -2,101 +2,119 @@ post_1: id: 1 title: New post body: A body!!! - author_id: 1 + author_id: 1001 post_2: id: 2 title: JR Solves your serialization woes! body: Use JR - author_id: 1 + author_id: 1001 section_id: 2 post_3: id: 3 title: Update This Later body: AAAA - author_id: 3 + author_id: 1003 post_4: id: 4 title: Delete This Later - Single body: AAAA - author_id: 3 + author_id: 1003 post_5: id: 5 title: Delete This Later - Multiple1 body: AAAA - author_id: 3 + author_id: 1003 post_6: id: 6 title: Delete This Later - Multiple2 body: AAAA - author_id: 3 + author_id: 1003 post_7: id: 7 title: Delete This Later - Single2 body: AAAA - author_id: 3 + author_id: 1003 post_8: id: 8 title: Delete This Later - Multiple2-1 body: AAAA - author_id: 3 + author_id: 1003 post_9: id: 9 title: Delete This Later - Multiple2-2 body: AAAA - author_id: 3 + author_id: 1003 post_10: id: 10 title: Update This Later - Multiple body: AAAA - author_id: 3 + author_id: 1003 post_11: id: 11 title: JR How To body: Use JR to write API apps - author_id: 1 + author_id: 1001 post_12: id: 12 title: Tagged up post 1 body: AAAA - author_id: 4 + author_id: 1004 post_13: id: 13 title: Tagged up post 2 body: BBBB - author_id: 4 + author_id: 1004 post_14: id: 14 title: A First Post body: A First Post!!!!!!!!! - author_id: 3 + author_id: 1003 post_15: id: 15 - title: AAAA First Post + title: A 1ST Post body: First!!!!!!!!! - author_id: 3 + author_id: 1003 post_16: id: 16 title: SDFGH body: Not First!!!! - author_id: 3 + author_id: 1003 post_17: id: 17 title: No Author!!!!!! body: This post has no Author - author_id: \ No newline at end of file + author_id: + +post_18: + id: 18 + title: Delete This later 18 + body: AAAA + author_id: 1003 + +post_19: + id: 19 + title: Update Later - Operations + body: AAAA This should be updated + author_id: 1003 + +post_20: + id: 20 + title: Update Later - Ops Multiple + body: AAAA This should also be updated + author_id: 1003 diff --git a/test/fixtures/posts_tags.yml b/test/fixtures/posts_tags.yml index f42495cd8..dbf5b58c1 100644 --- a/test/fixtures/posts_tags.yml +++ b/test/fixtures/posts_tags.yml @@ -1,79 +1,79 @@ post_1_short: post_id: 1 - tag_id: 1 + tag_id: 501 post_1_whiny: post_id: 1 - tag_id: 2 + tag_id: 502 post_1_grumpy: post_id: 1 - tag_id: 3 + tag_id: 503 post_2_jr: post_id: 2 - tag_id: 5 + tag_id: 505 post_11_jr: post_id: 11 - tag_id: 5 + tag_id: 505 post_12_silly: post_id: 12 - tag_id: 6 + tag_id: 506 post_12_sleepy: post_id: 12 - tag_id: 7 + tag_id: 507 post_12_goofy: post_id: 12 - tag_id: 8 + tag_id: 508 post_12_wacky: post_id: 12 - tag_id: 9 + tag_id: 509 post_13_silly: post_id: 13 - tag_id: 6 + tag_id: 506 post_13_sleepy: post_id: 13 - tag_id: 7 + tag_id: 507 post_13_goofy: post_id: 13 - tag_id: 8 + tag_id: 508 post_13_wacky: post_id: 13 - tag_id: 9 + tag_id: 509 post_14_whiny: post_id: 14 - tag_id: 2 + tag_id: 502 post_14_grumpy: post_id: 14 - tag_id: 3 + tag_id: 503 post_15_11: post_id: 15 - tag_id: 11 + tag_id: 511 post_15_2: post_id: 15 - tag_id: 2 + tag_id: 502 post_15_4: post_id: 15 - tag_id: 4 + tag_id: 504 post_15_10: post_id: 15 - tag_id: 10 + tag_id: 510 post_15_16: post_id: 15 - tag_id: 16 + tag_id: 516 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/products.yml b/test/fixtures/products.yml index 77eab3ece..325d48938 100644 --- a/test/fixtures/products.yml +++ b/test/fixtures/products.yml @@ -1,3 +1,8 @@ product_1: id: 1 name: Enterprise Gizmo + designer_id: 1001 + +product_2: + id: 2 + name: Fighting Hot Sauce 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/related_things.yml b/test/fixtures/related_things.yml index bfa9b2a44..d40d37161 100644 --- a/test/fixtures/related_things.yml +++ b/test/fixtures/related_things.yml @@ -1,9 +1,24 @@ -related_thing_1: - id: 1 - from_id: 1 - to_id: 2 - -related_thing_2: - id: 2 - from_id: 2 - to_id: 1 \ No newline at end of file +related_thing_10: + id: 101 + from_id: 10 + to_id: 20 + +related_thing_20: + id: 201 + from_id: 20 + to_id: 10 + +related_thing_3040: + id: 301 + from_id: 30 + to_id: 40 + +related_thing_3050: + id: 302 + from_id: 30 + to_id: 50 + +related_thing_5060: + id: 303 + from_id: 50 + to_id: 60 diff --git a/test/fixtures/storages.yml b/test/fixtures/storages.yml new file mode 100644 index 000000000..50f889c80 --- /dev/null +++ b/test/fixtures/storages.yml @@ -0,0 +1,4 @@ +warehouse_1: + id: 1 + name: "Warehouse 1" + token: "some-token" diff --git a/test/fixtures/tags.yml b/test/fixtures/tags.yml index 5a9b248c6..7179675ad 100644 --- a/test/fixtures/tags.yml +++ b/test/fixtures/tags.yml @@ -1,64 +1,63 @@ short_tag: - id: 1 + id: 501 name: short whiny_tag: - id: 2 + id: 502 name: whiny grumpy_tag: - id: 3 + id: 503 name: grumpy happy_tag: - id: 4 + id: 504 name: happy jr_tag: - id: 5 + id: 505 name: JR silly_tag: - id: 6 + id: 506 name: silly sleepy_tag: - id: 7 + id: 507 name: sleepy goofy_tag: - id: 8 + id: 508 name: goofy wacky_tag: - id: 9 + id: 509 name: wacky bad_tag: - id: 10 + id: 510 name: bad tag_11: - id: 11 + id: 511 name: Tag11 tag_12: - id: 12 + id: 512 name: Tag12 tag_13: - id: 13 + id: 513 name: Tag13 tag_14: - id: 14 + id: 514 name: Tag14 tag_15: - id: 15 + id: 515 name: Tag15 tag_16: - id: 16 + id: 516 name: Tag16 - diff --git a/test/fixtures/things.yml b/test/fixtures/things.yml index 10667a7ee..29970936f 100644 --- a/test/fixtures/things.yml +++ b/test/fixtures/things.yml @@ -1,9 +1,41 @@ -thing_1: - id: 1 - user_id: 1 - box_id: 1 - -thing_2: - id: 2 - user_id: 1 - box_id: 1 \ No newline at end of file +thing_10: + id: 10 + user_id: 10001 + box_id: 100 + name: Thing10 + +thing_20: + id: 20 + user_id: 10001 + box_id: 100 + name: Thing20 + +thing_30: + id: 30 + user_id: 10002 + box_id: 102 + name: Thing30 + +thing_40: + id: 40 + user_id: 10002 + box_id: + name: Thing40 + +thing_50: + id: 50 + user_id: 10002 + box_id: + name: Thing50 + +thing_60: + id: 60 + user_id: 10002 + box_id: + name: Thing60 + +#thing_70: +# id: 70 +# user_id: 10001 +# box_id: 100 +# name: Thing70 diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 69aa43201..6d9ccdd32 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -1,2 +1,7 @@ user_1: - id: 1 + id: 10001 + name: user 1 + +user_2: + id: 10002 + name: user 2 diff --git a/test/fixtures/vehicles.yml b/test/fixtures/vehicles.yml index 720257cee..c046bca03 100644 --- a/test/fixtures/vehicles.yml +++ b/test/fixtures/vehicles.yml @@ -5,7 +5,7 @@ Miata: model: Miata MX5 drive_layout: Front Engine RWD serial_number: 32432adfsfdysua - person_id: 1 + person_id: 1001 Launch20: id: 2 @@ -14,4 +14,22 @@ Launch20: model: Launch 20 length_at_water_line: 15.5ft serial_number: 434253JJJSD - person_id: 1 + person_id: 1001 + +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 diff --git a/test/fixtures/workers.yml b/test/fixtures/workers.yml new file mode 100644 index 000000000..ce0f82c81 --- /dev/null +++ b/test/fixtures/workers.yml @@ -0,0 +1,4 @@ +john_doe_worker: + id: 1 + name: "John Doe" + access_card_id: 1 # access_cards.yml john_doe_worker_card diff --git a/test/helpers/configuration_helpers.rb b/test/helpers/configuration_helpers.rb index ed7169700..b3f14f443 100644 --- a/test/helpers/configuration_helpers.rb +++ b/test/helpers/configuration_helpers.rb @@ -16,6 +16,7 @@ def with_resource_caching(cache, classes = :all) results = {total: {hits: 0, misses: 0}} new_config_options = { resource_cache: cache, + default_caching: true, resource_cache_usage_report_function: Proc.new do |name, hits, misses| [name.to_sym, :total].each do |key| results[key] ||= {hits: 0, misses: 0} @@ -28,7 +29,7 @@ def with_resource_caching(cache, classes = :all) with_jsonapi_config(new_config_options) do if classes == :all or (classes.is_a?(Hash) && classes.keys == [:except]) resource_classes = ObjectSpace.each_object(Class).select do |klass| - if klass < JSONAPI::Resource + if klass < JSONAPI::BasicResource # Not using Resource#_model_class to avoid tripping the warning early, which could # cause ResourceTest#test_nil_model_class to fail. model_class = klass._model_name.to_s.safe_constantize @@ -50,17 +51,7 @@ def with_resource_caching(cache, classes = :all) end begin - classes.each do |klass| - raise "#{klass.name} already caching!" if klass.caching? - klass.caching - raise "Couldn't enable caching for #{klass.name}" unless klass.caching? - end - yield - ensure - classes.each do |klass| - klass.caching(false) - end end end 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/helpers/value_matchers.rb b/test/helpers/value_matchers.rb index c5bb2ab01..2908f8d4f 100644 --- a/test/helpers/value_matchers.rb +++ b/test/helpers/value_matchers.rb @@ -5,13 +5,21 @@ def matches_value?(v1, v2, options = {}) if v1 == :any # any value is acceptable elsif v1 == :not_nil - return false if v2 == nil + if v2 == nil + return false + end elsif v1.kind_of?(Hash) - return false unless matches_hash?(v1, v2, options) + unless matches_hash?(v1, v2, options) + return false + end elsif v1.kind_of?(Array) - return false unless matches_array?(v1, v2, options) + unless matches_array?(v1, v2, options) + return false + end else - return false unless v2 == v1 + unless v2 == v1 + return false + end end true end @@ -19,7 +27,9 @@ def matches_value?(v1, v2, options = {}) def matches_array?(array1, array2, options = {}) return false unless array1.kind_of?(Array) && array2.kind_of?(Array) if options[:exact] - return false unless array1.size == array2.size + unless array1.size == array2.size + return false + end end # order of items shouldn't matter: @@ -36,7 +46,9 @@ def matches_array?(array1, array2, options = {}) break end end - return false unless matched.has_key?(i.to_s) + unless matched.has_key?(i.to_s) + return false + end end true end @@ -45,14 +57,18 @@ def matches_array?(array1, array2, options = {}) def matches_hash?(hash1, hash2, options = {}) return false unless hash1.kind_of?(Hash) && hash2.kind_of?(Hash) if options[:exact] - return false unless hash1.size == hash2.size + unless hash1.size == hash2.size + return false + end end hash1 = hash1.deep_symbolize_keys hash2 = hash2.deep_symbolize_keys hash1.each do |k1, v1| - return false unless hash2.has_key?(k1) && matches_value?(v1, hash2[k1], options) + unless hash2.has_key?(k1) && matches_value?(v1, hash2[k1], options) + return false + end end true end diff --git a/test/integration/requests/request_test.rb b/test/integration/requests/request_test.rb index 99885de21..1863b5c7d 100644 --- a/test/integration/requests/request_test.rb +++ b/test/integration/requests/request_test.rb @@ -2,10 +2,15 @@ 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) + $test_user = Person.find(1001) + end + + def teardown + DatabaseCleaner.clean end def after_teardown @@ -20,6 +25,74 @@ def test_large_get assert_cacheable_jsonapi_get '/api/v2/books?include=book_comments,book_comments.author' end + def test_get_not_found + get "/people/2000" + assert_jsonapi_response 404 + end + + def test_post_sessions + session_id = SecureRandom.uuid + + post '/sessions', params: { + data: { + id: session_id, + type: "sessions", + attributes: { + survey_id: SecureRandom.uuid, + }, + relationships: { + responses: { + data: [ + { + type: "responses", + attributes: { + response_type: "single_textbox", + question_id: SecureRandom.uuid, + }, + relationships: { + paragraph: { + data: { + type: "responses", + response_type: "paragraph", + attributes: { + text: "This is my single textbox response" + } + } + } + } + }, + ], + }, + }, + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + assert_jsonapi_response 201 + json_body = JSON.parse(response.body) + session_id = json_body["data"]["id"] + + # Get what we just created + get "/sessions/#{session_id}?include=responses" + assert_jsonapi_response 200 + json_body = JSON.parse(response.body) + + assert(json_body.is_a?(Object)); + assert(json_body["included"].is_a?(Array)); + assert_equal("single_textbox", json_body["included"][0]["attributes"]["response_type"]["single_textbox"]); + + get "/sessions/#{session_id}?include=responses,responses.paragraph" + assert_jsonapi_response 200 + json_body = JSON.parse(response.body) + + assert_equal("single_textbox", json_body["included"][0]["attributes"]["response_type"]["single_textbox"]); + + # Rails 4.2.x branch will not retrieve the responses.paragraph, 5.x branch will - this looks to be a deeper, but unrelated bug + #assert_equal("paragraphs", json_body["included"][1]["type"]); + end + def test_get_inflected_resource assert_cacheable_jsonapi_get '/api/v8/numeros_telefone' end @@ -36,6 +109,39 @@ def test_get_nested_to_many_bad_param assert_cacheable_jsonapi_get '/posts/1/comments?relationship=books' end + def test_nested_filters + assert_cacheable_jsonapi_get '/posts?filter[search][title]=New post' + assert_jsonapi_response 200 + assert_equal 1, json_response['data'].size + end + + def test_relationship_filters + assert_cacheable_jsonapi_get '/posts?filter[tags.name]=whiny&sort=-author.name' + assert_jsonapi_response 200 + assert_equal 3, json_response['data'].size + end + + # ToDo: change filter to return results + def test_relationship_filters_nested + assert_cacheable_jsonapi_get '/posts?filter[comments.author.name]=Lazy Author&filter[comments.tags.name]=whiny' + assert_jsonapi_response 200 + assert_equal 0, json_response['data'].size + end + + def test_filters_one_level + assert_cacheable_jsonapi_get '/api/boxes?filter[things.name]=Thing10' + assert_jsonapi_response 200 + assert_equal 1, json_response['data'].size + assert_equal '100', json_response['data'][0]['id'] + end + + def test_filters_two_level + assert_cacheable_jsonapi_get '/api/boxes?filter[things.things.name]=Thing40' + assert_jsonapi_response 200 + assert_equal 1, json_response['data'].size + assert_equal '102', json_response['data'][0]['id'] + end + def test_get_underscored_key original_config = JSONAPI.configuration.dup JSONAPI.configuration.json_key_format = :underscored_key @@ -45,6 +151,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 @@ -113,8 +228,8 @@ def test_put_single_without_content_type 'relationships' => { 'tags' => { 'data' => [ - {'type' => 'tags', 'id' => '3'}, - {'type' => 'tags', 'id' => '4'} + {'type' => 'tags', 'id' => '503'}, + {'type' => 'tags', 'id' => '504'} ] } } @@ -140,8 +255,8 @@ def test_put_single 'relationships' => { 'tags' => { 'data' => [ - {'type' => 'tags', 'id' => '3'}, - {'type' => 'tags', 'id' => '4'} + {'type' => 'tags', 'id' => '503'}, + {'type' => 'tags', 'id' => '504'} ] } } @@ -165,8 +280,8 @@ def test_post_single_with_wrong_content_type 'relationships' => { 'tags' => { 'data' => [ - {'type' => 'tags', 'id' => '3'}, - {'type' => 'tags', 'id' => '4'} + {'type' => 'tags', 'id' => '503'}, + {'type' => 'tags', 'id' => '504'} ] } } @@ -190,7 +305,37 @@ def test_post_single 'body' => 'JSONAPIResources is the greatest thing since unsliced bread.' }, 'relationships' => { - 'author' => {'data' => {'type' => 'people', 'id' => '3'}} + 'author' => {'data' => {'type' => 'people', 'id' => '1003'}} + } + } + }.to_json, + headers: { + 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, + 'Accept' => JSONAPI::MEDIA_TYPE + } + + 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, @@ -200,6 +345,67 @@ def test_post_single } 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 @@ -338,8 +544,8 @@ def test_put_content_type 'relationships' => { 'tags' => { 'data' => [ - {'type' => 'tags', 'id' => '3'}, - {'type' => 'tags', 'id' => '4'} + {'type' => 'tags', 'id' => '503'}, + {'type' => 'tags', 'id' => '504'} ] } } @@ -353,6 +559,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: { @@ -365,8 +604,8 @@ def test_patch_content_type 'relationships' => { 'tags' => { 'data' => [ - {'type' => 'tags', 'id' => '3'}, - {'type' => 'tags', 'id' => '4'} + {'type' => 'tags', 'id' => '503'}, + {'type' => 'tags', 'id' => '504'} ] } } @@ -380,6 +619,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/1000', params: + { + 'data' => { + 'id' => 1000, + '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/1000', params: + { + 'data' => { + 'id' => 1000, + '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/1000', params: + { + 'data' => { + 'id' => 1000, + '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: { @@ -484,17 +813,28 @@ def test_pagination_related_resources_links_meta JSONAPI.configuration.top_level_meta_include_record_count = false end - def test_filter_related_resources + def test_filter_related_resources_relationship_filter Api::V2::BookCommentResource.paginator :offset JSONAPI.configuration.top_level_meta_include_record_count = true assert_cacheable_jsonapi_get '/api/v2/books/1/book_comments?filter[book]=2' assert_equal 0, json_response['meta']['record_count'] assert_cacheable_jsonapi_get '/api/v2/books/1/book_comments?filter[book]=1&page[limit]=20' + assert_equal 20, json_response['data'].length assert_equal 26, json_response['meta']['record_count'] ensure JSONAPI.configuration.top_level_meta_include_record_count = false end + def test_filter_related_resources + Api::V2::BookCommentResource.paginator :offset + JSONAPI.configuration.top_level_meta_include_record_count = true + assert_cacheable_jsonapi_get '/api/v2/books/1/book_comments?filter[body]=2' + assert_equal 9, json_response['data'].length + assert_equal 9, json_response['meta']['record_count'] + ensure + JSONAPI.configuration.top_level_meta_include_record_count = false + end + def test_page_count_meta Api::V2::BookCommentResource.paginator :paged JSONAPI.configuration.top_level_meta_include_record_count = true @@ -562,18 +902,31 @@ def test_pagination_empty_results # assert_equal 'This is comment 18 on book 1.', json_response['data'][9]['attributes']['body'] # end + def test_polymorphic_related_resources + assert_cacheable_jsonapi_get '/pictures/1/imageable' + assert_equal 'Enterprise Gizmo', json_response['data']['attributes']['name'] + + assert_cacheable_jsonapi_get '/pictures/2/imageable' + assert_equal 'Company Brochure', json_response['data']['attributes']['name'] + end + + def test_polymorphic_relation_filter + assert_cacheable_jsonapi_get '/pictures?include=imageable&filter[imageable.name]=Enterprise Gizmo' + assert_equal '1', json_response['data'][0]['id'] + assert_equal '50', json_response['data'][1]['id'] + 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, { @@ -581,13 +934,13 @@ def test_flow_link_to_one_self_link 'self' => 'http://www.example.com/posts/1/relationships/author', 'related' => 'http://www.example.com/posts/1/author' }, - 'data' => {'type' => 'people', 'id' => '1'} + 'data' => {'type' => 'people', 'id' => '1001'} }) 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, @@ -597,19 +950,19 @@ def test_flow_link_to_many_self_link 'related' => 'http://www.example.com/posts/1/tags' }, 'data' => [ - {'type' => 'tags', 'id' => '1'}, - {'type' => 'tags', 'id' => '2'}, - {'type' => 'tags', 'id' => '3'} + {'type' => 'tags', 'id' => '501'}, + {'type' => 'tags', 'id' => '502'}, + {'type' => 'tags', 'id' => '503'} ] }) 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: - {'data' => [{'type' => 'tags', 'id' => '10'}]}.to_json, + post post_5['relationships']['tags']['links']['self'], params: + {'data' => [{'type' => 'tags', 'id' => '510'}]}.to_json, headers: { 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE, 'Accept' => JSONAPI::MEDIA_TYPE @@ -617,7 +970,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' => { @@ -625,7 +978,7 @@ def test_flow_link_to_many_self_link_put 'related' => 'http://www.example.com/posts/5/tags' }, 'data' => [ - {'type' => 'tags', 'id' => '10'} + {'type' => 'tags', 'id' => '510'} ] }) end @@ -854,7 +1207,7 @@ def test_patch_formatted_dasherized_replace_to_many def test_patch_formatted_dasherized_replace_to_many_computed_relation $original_test_user = $test_user - $test_user = Person.find(5) + $test_user = Person.find(1005) original_config = JSONAPI.configuration.dup JSONAPI.configuration.route_format = :dasherized_route JSONAPI.configuration.json_key_format = :dasherized_key @@ -913,7 +1266,7 @@ def test_post_to_many_link def test_post_computed_relation_to_many $original_test_user = $test_user - $test_user = Person.find(5) + $test_user = Person.find(1005) original_config = JSONAPI.configuration.dup JSONAPI.configuration.route_format = :dasherized_route JSONAPI.configuration.json_key_format = :dasherized_key @@ -958,7 +1311,7 @@ def test_patch_to_many_link def test_patch_to_many_link_computed_relation $original_test_user = $test_user - $test_user = Person.find(5) + $test_user = Person.find(1005) original_config = JSONAPI.configuration.dup JSONAPI.configuration.route_format = :dasherized_route JSONAPI.configuration.json_key_format = :dasherized_key @@ -1002,14 +1355,53 @@ def test_include_parameter_allowed assert_cacheable_jsonapi_get '/api/v2/books/1/book_comments?include=author' end - def test_include_parameter_not_allowed + def test_deprecated_include_parameter_not_allowed + original_config = JSONAPI.configuration.dup JSONAPI.configuration.allow_include = false get '/api/v2/books/1/book_comments?include=author', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } assert_jsonapi_response 400 ensure - JSONAPI.configuration.allow_include = true + JSONAPI.configuration = original_config + end + + def test_deprecated_include_message + ActiveSupport::Deprecation.silenced = false + original_config = JSONAPI.configuration.dup + _out, err = capture_io do + eval <<-CODE + JSONAPI.configuration.allow_include = false + CODE + end + assert_match /DEPRECATION WARNING: `allow_include` has been replaced by `default_allow_include_to_one` and `default_allow_include_to_many` options./, err + ensure + JSONAPI.configuration = original_config + ActiveSupport::Deprecation.silenced = true + end + + + def test_to_one_include_parameter_not_allowed + original_config = JSONAPI.configuration.dup + JSONAPI.configuration.default_allow_include_to_one = false + get '/api/v2/books/1/book_comments?include=author', headers: { + 'Accept' => JSONAPI::MEDIA_TYPE + } + assert_jsonapi_response 400 + ensure + JSONAPI.configuration = original_config + end + + def test_to_one_include_parameter_allowed + original_config = JSONAPI.configuration.dup + JSONAPI.configuration.default_allow_include_to_one = true + get '/api/v2/books/1/book_comments?include=author', headers: { + 'Accept' => JSONAPI::MEDIA_TYPE + } + assert_jsonapi_response 200 + assert_equal 1, json_response['included'].size + ensure + JSONAPI.configuration = original_config end def test_filter_parameter_not_allowed @@ -1030,14 +1422,437 @@ 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_sort_primary_attribute + get '/api/v6/authors?sort=name', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_jsonapi_response 200 + assert_equal '1002', json_response['data'][0]['id'] + + get '/api/v6/authors?sort=-name', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_jsonapi_response 200 + assert_equal '1005', json_response['data'][0]['id'] + end + + def test_sort_included_attribute + # Postgres sorts nulls last, whereas sqlite and mysql sort nulls first + pg = ENV['DATABASE_URL'].starts_with?('postgres') + + get '/api/v6/authors?sort=author_detail.author_stuff', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_jsonapi_response 200 + assert_equal pg ? '1001' : '1000', json_response['data'][0]['id'] + + get '/api/v6/authors?sort=-author_detail.author_stuff', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_jsonapi_response 200 + assert_equal pg ? '1000' : '1002', json_response['data'][0]['id'] + 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_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_with_belongs_to_relationship_and_changed_primary_key + worker = Worker.find(1) + access_card = worker.access_card + assert_cacheable_jsonapi_get '/workers/1?include=access_card' + assert_jsonapi_response 200 + + data = json_response['data'] + refute_nil data + assert_equal worker.id.to_s, data['id'] + + refute_nil data['relationships'] + refute_nil data['relationships']['access_card'] + refute_nil data['relationships']['access_card']['data'] + assert_equal 'access_cards', data['relationships']['access_card']['data']['type'] + assert_equal access_card.token, data['relationships']['access_card']['data']['id'] + + included = json_response['included'] + refute_nil included + assert_equal 'access_cards', included.first['type'] + assert_equal access_card.token, included.first['id'] + end + + + def test_get_resource_include_singleton_relationship + $original_test_user = $test_user + $test_user = Person.find(1005) + + assert_cacheable_jsonapi_get '/api/v9/people/1005?include=preferences' + assert_jsonapi_response 200 + assert_hash_equals json_response, + { + "data" => { + "id" => "1005", + "type" => "people", + "links" => { + "self" => "http://www.example.com/api/v9/people/1005" + }, + "relationships" => { + "preferences" => { + "links" => { + "self" => "http://www.example.com/api/v9/people/1005/relationships/preferences", + "related" => "http://www.example.com/api/v9/people/1005/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(1005) + + get "/api/v9/people/#{$test_user.id}?include=preferences" + assert_jsonapi_response 200 + assert_hash_equals json_response, + { + "data" => { + "id" => "1005", + "type" => "people", + "links" => { + "self" => "http://www.example.com/api/v9/people/1005" + }, + "relationships" => { + "preferences" => { + "links" => { + "self" => "http://www.example.com/api/v9/people/1005/relationships/preferences", + "related" => "http://www.example.com/api/v9/people/1005/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(1001) + 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" => "1001", + "type" => "people", + "links" => { + "self" => "http://www.example.com/api/v9/people/1001" + }, + "relationships" => { + "preferences" => { + "links" => { + "self" => "http://www.example.com/api/v9/people/1001/relationships/preferences", + "related" => "http://www.example.com/api/v9/people/1001/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(1005) + + 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(1001) + + 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(1001) + + 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(1004) + + assert_nil $test_user.preferences + + post '/api/v9/preferences', params: + { + 'data' => { + 'type' => 'preferences', + 'attributes' => { + 'nickname' => 'Frank' + }, + 'relationships' => { + 'person' => {'data' => {'type' => 'people', 'id' => '1004'}} + } + } + }.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(1005) + + 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(1003) + + 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..fd10e0b46 100644 --- a/test/integration/routes/routes_test.rb +++ b/test/integration/routes/routes_test.rb @@ -2,6 +2,18 @@ class RoutesTest < ActionDispatch::IntegrationTest + # def test_dump_routes + # r = {} + # + # Rails.application.routes.routes.each do |route| + # r[route.path.spec.right.left.to_s] ||= {routes: {}} + # r[route.path.spec.right.left.to_s][:routes][route.path.spec.to_s] ||= {} + # r[route.path.spec.right.left.to_s][:routes][route.path.spec.to_s][route.defaults[:action]] = route + # end + # + # r + # end + def test_routing_post assert_routing({path: 'posts', method: :post}, {controller: 'posts', action: 'create'}) @@ -64,21 +76,23 @@ def test_routing_uuid # end # Polymorphic - def test_routing_polymorphic_get_related_resource - assert_routing( - { - path: '/pictures/1/imageable', - method: :get - }, - { - relationship: 'imageable', - source: 'pictures', - controller: 'imageables', - action: 'get_related_resource', - picture_id: '1' - } - ) - end + # ToDo: refute this routing. Polymorphic relationships can't support a shared set of filters or includes so + # this this route is no longer supported + # def test_routing_polymorphic_show_related_resource + # assert_routing( + # { + # path: '/pictures/1/imageable', + # method: :get + # }, + # { + # relationship: 'imageable', + # source: 'pictures', + # controller: 'imageables', + # action: 'show_related_resource', + # picture_id: '1' + # } + # ) + # end def test_routing_polymorphic_patch_related_resource assert_routing( @@ -191,6 +205,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: 'index_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}, @@ -213,6 +241,6 @@ def test_routing_primary_key_jsonapi_resources # { controller: 'api/v3/posts', action: 'destroy_relationship', post_id: '1', keys: '1,2', relationship: 'tags' }) # end - # Test that non acts as set to_many relationship update route is not created + # Test that non-acts-as-set to_many relationship update route is not created end diff --git a/test/test_helper.rb b/test/test_helper.rb index 9ce9a60af..f6f9c1b70 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,25 +1,28 @@ require 'simplecov' +require 'database_cleaner' # To run tests with coverage: # COVERAGE=true bundle exec rake test # To test on a specific rails version use this: -# export RAILS_VERSION=4.2.6; bundle update rails; bundle exec rake test -# export RAILS_VERSION=5.0.0; bundle update rails; bundle exec rake test +# export RAILS_VERSION=5.2.4.4; bundle update; bundle exec rake test +# export RAILS_VERSION=6.0.3.4; bundle update; bundle exec rake test +# export RAILS_VERSION=6.1.1; bundle update; bundle exec rake test -# We are no longer having Travis test Rails 4.1.x., but you can try it with: -# export RAILS_VERSION=4.1.0; bundle update rails; bundle exec rake test +# We are no longer having Travis test Rails 4.2.11., but you can try it with: +# export RAILS_VERSION=4.2.11; bundle update rails; bundle exec rake test # To Switch rails versions and run a particular test order: -# export RAILS_VERSION=4.2.6; bundle update rails; bundle exec rake TESTOPTS="--seed=39333" test +# export RAILS_VERSION=6.1.1; bundle update; bundle exec rake TESTOPTS="--seed=39333" test if ENV['COVERAGE'] SimpleCov.start do end end +ENV['DATABASE_URL'] ||= "sqlite3:test_db" + require 'active_record/railtie' -require 'rails/test_help' require 'minitest/mock' require 'jsonapi-resources' require 'pry' @@ -38,6 +41,12 @@ config.json_key_format = :camelized_key end +if ActiveSupport::Deprecation.respond_to?(:behavior=) + ActiveSupport::Deprecation.behavior = :silence +elsif ActiveSupport::Deprecation.respond_to?(:silenced=) + ActiveSupport::Deprecation.silenced = true +end + puts "Testing With RAILS VERSION #{Rails.version}" class TestApp < Rails::Application @@ -53,13 +62,17 @@ class TestApp < Rails::Application config.active_record.schema_format = :none config.active_support.test_order = :random - if Rails::VERSION::MAJOR >= 5 - 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 + 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::MAJOR == 5 && Rails::VERSION::MINOR == 2 + config.active_record.sqlite3.represent_boolean_as_integer = true end end +DatabaseCleaner.allow_remote_database_url = true +DatabaseCleaner.strategy = :transaction + module MyEngine class Engine < ::Rails::Engine isolate_namespace MyEngine @@ -73,123 +86,89 @@ class Engine < ::Rails::Engine end # Monkeypatch ActionController::TestCase to delete the RAW_POST_DATA on subsequent calls in the same test. -if Rails::VERSION::MAJOR >= 5 - module ClearRawPostHeader - def process(action, *args) - @request.delete_header 'RAW_POST_DATA' - super - end - end - - class ActionController::TestCase - prepend ClearRawPostHeader +module ClearRawPostHeader + def process(action, **args) + @request.delete_header 'RAW_POST_DATA' + super action, **args end end -# Tests are now using the rails 5 format for the http methods. So for rails 4 we will simply convert them back -# in a standard way. -if Rails::VERSION::MAJOR < 5 - module Rails4ActionControllerProcess - def process(*args) - if args[2] && args[2][:params] - args[2] = args[2][:params] - end - super - end - end - class ActionController::TestCase - prepend Rails4ActionControllerProcess - end - - module ActionDispatch - module Integration #:nodoc: - module Rails4IntegrationProcess - def process(method, path, parameters = nil, headers_or_env = nil) - params = parameters.nil? ? nil : parameters[:params] - headers = parameters.nil? ? nil : parameters[:headers] - super method, path, params, headers - end - end - - class Session - prepend Rails4IntegrationProcess - end - end - end +class ActionController::TestCase + prepend ClearRawPostHeader end # Patch to allow :api_json mime type to be treated as JSON # Otherwise it is run through `to_query` and empty arrays are dropped. -if Rails::VERSION::MAJOR >= 5 - module ActionController - class TestRequest < ActionDispatch::TestRequest - def assign_parameters(routes, controller_path, action, parameters, generated_path, query_string_keys) - non_path_parameters = {} - path_parameters = {} - - parameters.each do |key, value| - if query_string_keys.include?(key) - non_path_parameters[key] = value +module ActionController + class TestRequest < ActionDispatch::TestRequest + def assign_parameters(routes, controller_path, action, parameters, generated_path, query_string_keys) + non_path_parameters = {} + path_parameters = {} + + parameters.each do |key, value| + if query_string_keys.include?(key) + non_path_parameters[key] = value + else + if value.is_a?(Array) + value = value.map(&:to_param) else - if value.is_a?(Array) - value = value.map(&:to_param) - else - value = value.to_param - end - - path_parameters[key] = value + value = value.to_param end + + path_parameters[key] = value end + end - if get? - if self.query_string.blank? - self.query_string = non_path_parameters.to_query - end + if get? + if self.query_string.blank? + self.query_string = non_path_parameters.to_query + end + else + if ENCODER.should_multipart?(non_path_parameters) + self.content_type = ENCODER.content_type + data = ENCODER.build_multipart non_path_parameters else - if ENCODER.should_multipart?(non_path_parameters) - self.content_type = ENCODER.content_type - data = ENCODER.build_multipart non_path_parameters - else - fetch_header('CONTENT_TYPE') do |k| - set_header k, 'application/x-www-form-urlencoded' - end - - # parser = ActionDispatch::Http::Parameters::DEFAULT_PARSERS[Mime::Type.lookup(fetch_header('CONTENT_TYPE'))] - - case content_mime_type.to_sym - when nil - raise "Unknown Content-Type: #{content_type}" - when :json, :api_json - data = ActiveSupport::JSON.encode(non_path_parameters) - when :xml - data = non_path_parameters.to_xml - when :url_encoded_form - data = non_path_parameters.to_query - else - @custom_param_parsers[content_mime_type] = ->(_) { non_path_parameters } - data = non_path_parameters.to_query - end + fetch_header('CONTENT_TYPE') do |k| + set_header k, 'application/x-www-form-urlencoded' end - set_header 'CONTENT_LENGTH', data.length.to_s - set_header 'rack.input', StringIO.new(data) + # parser = ActionDispatch::Http::Parameters::DEFAULT_PARSERS[Mime::Type.lookup(fetch_header('CONTENT_TYPE'))] + + case content_mime_type.to_sym + when nil + raise "Unknown Content-Type: #{content_type}" + when :json, :api_json + data = ActiveSupport::JSON.encode(non_path_parameters) + when :xml + data = non_path_parameters.to_xml + when :url_encoded_form + data = non_path_parameters.to_query + else + @custom_param_parsers[content_mime_type] = ->(_) { non_path_parameters } + data = non_path_parameters.to_query + end end - fetch_header("PATH_INFO") do |k| - set_header k, generated_path - end - path_parameters[:controller] = controller_path - path_parameters[:action] = action + set_header 'CONTENT_LENGTH', data.length.to_s + set_header 'rack.input', StringIO.new(data) + end - self.path_parameters = path_parameters + fetch_header("PATH_INFO") do |k| + set_header k, generated_path end + path_parameters[:controller] = controller_path + path_parameters[:action] = action + + self.path_parameters = path_parameters end end end def assert_query_count(expected, msg = nil, &block) @queries = [] - callback = lambda {|_, _, _, _, payload| @queries.push payload[:sql] } + callback = lambda {|_, _, _, _, payload| + @queries.push payload[:sql] + } ActiveSupport::Notifications.subscribed(callback, 'sql.active_record', &block) show_queries unless expected == @queries.size @@ -197,6 +176,17 @@ def assert_query_count(expected, msg = nil, &block) @queries = nil end +def track_queries(&block) + @queries = [] + callback = lambda {|_, _, _, _, payload| + @queries.push payload[:sql] + } + ActiveSupport::Notifications.subscribed(callback, 'sql.active_record', &block) + + show_queries + @queries = nil +end + def show_queries @queries.each_with_index do |query, index| puts "sql[#{index}]: #{query}" @@ -224,11 +214,13 @@ class CatResource < JSONAPI::Resource JSONAPI.configuration.route_format = :underscored_route TestApp.routes.draw do + jsonapi_resources :sessions jsonapi_resources :people jsonapi_resources :special_people jsonapi_resources :comments jsonapi_resources :firms jsonapi_resources :tags + jsonapi_resources :hair_cuts jsonapi_resources :posts do jsonapi_relationships jsonapi_links :special_tags @@ -241,22 +233,41 @@ 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 :file_properties jsonapi_resources :vehicles jsonapi_resources :cars jsonapi_resources :boats jsonapi_resources :flat_posts + jsonapi_resources :blog_posts jsonapi_resources :books jsonapi_resources :authors + jsonapi_resources :questions + jsonapi_resources :answers + jsonapi_resources :doctors + jsonapi_resources :patients + + jsonapi_resources :access_cards + jsonapi_resources :response + jsonapi_resources :paragraph + + jsonapi_resources :employees + jsonapi_resources :robots + + jsonapi_resources :lists + jsonapi_resources :list_items + namespace :api do jsonapi_resources :boxes + jsonapi_resources :things + jsonapi_resources :users namespace :v1 do jsonapi_resources :people @@ -271,20 +282,28 @@ 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 :books jsonapi_resources :book_comments + # + jsonapi_resources :sections + jsonapi_resources :comments + jsonapi_resources :vehicles + jsonapi_resources :cars + jsonapi_resources :boats + jsonapi_resources :hair_cuts + jsonapi_resources :people end namespace :v3 do @@ -316,12 +335,20 @@ class CatResource < JSONAPI::Resource JSONAPI.configuration.route_format = :dasherized_route namespace :v5 do + jsonapi_resources :people + jsonapi_resources :posts do end - + jsonapi_resources :painters + jsonapi_resources :paintings + jsonapi_resources :collectors jsonapi_resources :authors + jsonapi_resources :author_details jsonapi_resources :expense_entries jsonapi_resources :iso_currencies + jsonapi_resources :tags + jsonapi_resources :comments + jsonapi_resources :employees @@ -330,11 +357,14 @@ class CatResource < JSONAPI::Resource JSONAPI.configuration.route_format = :dasherized_route namespace :v6 do + jsonapi_resources :authors + jsonapi_resources :author_details jsonapi_resources :posts jsonapi_resources :sections jsonapi_resources :customers jsonapi_resources :purchase_orders jsonapi_resources :line_items + jsonapi_resources :order_flags end JSONAPI.configuration.route_format = :underscored_route @@ -350,6 +380,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 +405,13 @@ class CatResource < JSONAPI::Resource end end + jsonapi_resources :keepers, only: [:show] + jsonapi_resources :storages + jsonapi_resources :workers, only: [:show] + jsonapi_resources :widgets, only: [:index] + jsonapi_resources :indicators, only: [:index] + jsonapi_resources :robots, only: [:index] + mount MyEngine::Engine => "/boomshaka", as: :my_engine mount ApiV2Engine::Engine => "/api_v2", as: :api_v2_engine end @@ -392,6 +434,12 @@ class CatResource < JSONAPI::Resource jsonapi_resources :people end end + + namespace :optional_namespace, path: 'optional_namespace' do + namespace :v1, path: '' do + jsonapi_resources :people + end + end end ApiV2Engine::Engine.routes.draw do @@ -412,12 +460,12 @@ def run_in_transaction? true end - self.fixture_path = "#{Rails.root}/fixtures" + self.fixture_paths = ["#{Rails.root}/fixtures"] fixtures :all end class ActiveSupport::TestCase - self.fixture_path = "#{Rails.root}/fixtures" + self.fixture_paths = ["#{Rails.root}/fixtures"] fixtures :all setup do @routes = TestApp.routes @@ -425,11 +473,11 @@ class ActiveSupport::TestCase end class ActionDispatch::IntegrationTest - self.fixture_path = "#{Rails.root}/fixtures" + self.fixture_paths = ["#{Rails.root}/fixtures"] fixtures :all def assert_jsonapi_response(expected_status, msg = nil) - assert_equal JSONAPI::MEDIA_TYPE, response.content_type + assert_equal JSONAPI::MEDIA_TYPE, response.media_type if status != expected_status && status >= 400 pp json_response rescue nil end @@ -476,15 +524,15 @@ def assert_cacheable_jsonapi_get(url, cached_classes = :all) end class ActionController::TestCase - def assert_cacheable_get(action, *args) + def assert_cacheable_get(action, **args) assert_nil JSONAPI.configuration.resource_cache normal_queries = [] normal_query_callback = lambda {|_, _, _, _, payload| normal_queries.push payload[:sql] } ActiveSupport::Notifications.subscribed(normal_query_callback, 'sql.active_record') do - get action, *args + get action, **args end - non_caching_response = json_response_sans_backtraces + non_caching_response = json_response_sans_all_backtraces non_caching_status = response.status # Don't let all the cache-testing requests mess with assert_query_count @@ -510,13 +558,15 @@ def assert_cacheable_get(action, *args) [:warmup, :lookup].each do |phase| begin cache_queries = [] - cache_query_callback = lambda {|_, _, _, _, payload| cache_queries.push payload[:sql] } + cache_query_callback = lambda { |_, _, _, _, payload| + cache_queries.push payload[:sql] + } cache_activity[phase] = with_resource_caching(cache, cached_resources) do ActiveSupport::Notifications.subscribed(cache_query_callback, 'sql.active_record') do @controller = nil setup_controller_request_and_response @request.headers.merge!(orig_request_headers.dup) - get action, *args + get action, **args end end rescue Exception @@ -534,21 +584,20 @@ def assert_cacheable_get(action, *args) ) assert_equal( non_caching_response.pretty_inspect, - json_response_sans_backtraces.pretty_inspect, + json_response_sans_all_backtraces.pretty_inspect, "Cache (mode: #{mode}) #{phase} response body must match normal response" ) assert_operator( cache_queries.size, :<=, - normal_queries.size*2, # Allow up to double the number of queries as the uncached action + normal_queries.size, "Cache (mode: #{mode}) #{phase} action made too many queries:\n#{cache_queries.pretty_inspect}" ) end if mode == :all - # TODO Should also be caching :show_related_resource (non-plural) action - if [:index, :show, :show_related_resources].include?(action) - if ar_resource_klass && response.status == 200 && json_response["data"].try(:size) > 0 + if [:index, :show, :show_related_resource, :show_related_resources].include?(action) + if ar_resource_klass && response.status == 200 && json_response["data"].try(:size).try(:>, 0) assert_operator( cache_activity[:warmup][:total][:misses], :>, @@ -574,12 +623,13 @@ def assert_cacheable_get(action, *args) private - def json_response_sans_backtraces + def json_response_sans_all_backtraces return nil if response.body.to_s.strip.empty? r = json_response.dup (r["errors"] || []).each do |err| err["meta"].delete("backtrace") if err.has_key?("meta") + err["meta"].delete("application_backtrace") if err.has_key?("meta") end return r end @@ -639,3 +689,16 @@ def unformat(value) end end end + +class OptionalRouteFormatter < JSONAPI::RouteFormatter + class << self + def format(route) + return if route == 'v1' + super + end + + def unformat(formatted_route) + super + end + end +end diff --git a/test/unit/active_relation_resource_finder/join_manager_test.rb b/test/unit/active_relation_resource_finder/join_manager_test.rb new file mode 100644 index 000000000..840c90ee2 --- /dev/null +++ b/test/unit/active_relation_resource_finder/join_manager_test.rb @@ -0,0 +1,236 @@ +require File.expand_path('../../../test_helper', __FILE__) +require 'jsonapi-resources' + +class JoinTreeTest < ActiveSupport::TestCase + + def db_true + case ActiveRecord::Base.connection.adapter_name + when 'SQLite' + if Rails::VERSION::MAJOR >= 6 || (Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2) + "1" + else + "'t'" + end + when 'PostgreSQL' + 'TRUE' + end + end + + def test_no_added_joins + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource) + + records = PostResource.records({}) + records = join_manager.join(records, {}) + assert_equal 'SELECT "posts".* FROM "posts"', records.to_sql + + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + end + + def test_add_single_join + filters = {'tags' => ['1']} + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, filters: filters) + records = PostResource.records({}) + records = join_manager.join(records, {}) + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', records.to_sql + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) + end + + def test_add_single_sort_join + sort_criteria = [{field: 'tags.name', direction: :desc}] + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, sort_criteria: sort_criteria) + records = PostResource.records({}) + records = join_manager.join(records, {}) + + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', records.to_sql + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) + end + + def test_add_single_sort_and_filter_join + filters = {'tags' => ['1']} + sort_criteria = [{field: 'tags.name', direction: :desc}] + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, sort_criteria: sort_criteria, filters: filters) + records = PostResource.records({}) + records = join_manager.join(records, {}) + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id"', records.to_sql + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) + end + + def test_add_sibling_joins + filters = { + 'tags' => ['1'], + 'author' => ['1'] + } + + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, filters: filters) + records = PostResource.records({}) + records = join_manager.join(records, {}) + + assert_equal 'SELECT "posts".* FROM "posts" LEFT OUTER JOIN "posts_tags" ON "posts_tags"."post_id" = "posts"."id" LEFT OUTER JOIN "tags" ON "tags"."id" = "posts_tags"."tag_id" LEFT OUTER JOIN "people" ON "people"."id" = "posts"."author_id"', records.to_sql + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:tags))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(PostResource._relationship(:author))) + end + + + def test_add_joins_source_relationship + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, + source_relationship: PostResource._relationship(:comments)) + records = PostResource.records({}) + records = join_manager.join(records, {}) + + assert_equal 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id"', records.to_sql + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) + end + + + def test_add_joins_source_relationship_with_custom_apply + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, + source_relationship: Api::V10::PostResource._relationship(:comments)) + records = Api::V10::PostResource.records({}) + records = join_manager.join(records, {}) + + sql = 'SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE "comments"."approved" = ' + db_true + + assert_equal sql, records.to_sql + + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) + end + + def test_add_nested_scoped_joins + filters = { + 'comments.author' => ['1'], + 'comments.tags' => ['1'], + 'author' => ['1'] + } + + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, filters: filters) + records = Api::V10::PostResource.records({}) + records = join_manager.join(records, {}) + + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) + assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:author))) + + # Now test with different order for the filters + filters = { + 'author' => ['1'], + 'comments.author' => ['1'], + 'comments.tags' => ['1'] + } + + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, filters: filters) + records = Api::V10::PostResource.records({}) + records = join_manager.join(records, {}) + + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) + assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:author))) + end + + def test_add_nested_joins_with_fields + filters = { + 'comments.author.name' => ['1'], + 'comments.tags.id' => ['1'], + 'author.foo' => ['1'] + } + + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, filters: filters) + records = Api::V10::PostResource.records({}) + records = join_manager.join(records, {}) + + assert_hash_equals({alias: 'posts', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) + assert_hash_equals({alias: 'authors_comments', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:author))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:author))) + end + + def test_add_joins_with_sub_relationship + relationships = %w(author author.comments tags) + + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: Api::V10::PostResource, relationships: relationships, + source_relationship: Api::V10::PostResource._relationship(:comments)) + records = Api::V10::PostResource.records({}) + records = join_manager.join(records, {}) + + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.join_details_by_relationship(Api::V10::PostResource._relationship(:comments))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'comments_people', join_type: :left}, join_manager.join_details_by_relationship(Api::V10::PersonResource._relationship(:comments))) + end + + def test_add_joins_with_sub_relationship_and_filters + filters = { + 'author.name' => ['1'], + 'author.comments.name' => ['Foo'] + } + + relationships = %w(author author.comments tags) + + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PostResource, + filters: filters, + relationships: relationships, + source_relationship: PostResource._relationship(:comments)) + records = PostResource.records({}) + records = join_manager.join(records, {}) + + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.source_join_details) + assert_hash_equals({alias: 'comments', join_type: :inner}, join_manager.join_details_by_relationship(PostResource._relationship(:comments))) + assert_hash_equals({alias: 'people', join_type: :left}, join_manager.join_details_by_relationship(CommentResource._relationship(:author))) + assert_hash_equals({alias: 'tags', join_type: :left}, join_manager.join_details_by_relationship(CommentResource._relationship(:tags))) + assert_hash_equals({alias: 'comments_people', join_type: :left}, join_manager.join_details_by_relationship(PersonResource._relationship(:comments))) + end + + def test_polymorphic_join_belongs_to_just_source + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PictureResource, + source_relationship: PictureResource._relationship(:imageable)) + + records = PictureResource.records({}) + records = join_manager.join(records, {}) + + # assert_equal 'SELECT "pictures".* FROM "pictures" LEFT OUTER JOIN "products" ON "products"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Product\' LEFT OUTER JOIN "documents" ON "documents"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Document\'', records.to_sql + assert_hash_equals({alias: 'products', join_type: :left}, join_manager.source_join_details('products')) + assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.source_join_details('documents')) + assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products')) + assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'documents')) + end + + def test_polymorphic_join_belongs_to_filter + filters = {'imageable' => ['Foo']} + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PictureResource, filters: filters) + + records = PictureResource.records({}) + records = join_manager.join(records, {}) + + # assert_equal 'SELECT "pictures".* FROM "pictures" LEFT OUTER JOIN "products" ON "products"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Product\' LEFT OUTER JOIN "documents" ON "documents"."id" = "pictures"."imageable_id" AND "pictures"."imageable_type" = \'Document\'', records.to_sql + assert_hash_equals({alias: 'pictures', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products')) + assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'documents')) + end + + def test_polymorphic_join_belongs_to_filter_on_resource + filters = { + 'imageable#documents.name' => ['foo'] + } + + relationships = %w(imageable file_properties) + join_manager = JSONAPI::ActiveRelation::JoinManager.new(resource_klass: PictureResource, + filters: filters, + relationships: relationships) + + records = PictureResource.records({}) + records = join_manager.join(records, {}) + + assert_hash_equals({alias: 'pictures', join_type: :root}, join_manager.source_join_details) + assert_hash_equals({alias: 'products', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'products')) + assert_hash_equals({alias: 'documents', join_type: :left}, join_manager.join_details_by_polymorphic_relationship(PictureResource._relationship(:imageable), 'documents')) + assert_hash_equals({alias: 'file_properties', join_type: :left}, join_manager.join_details_by_relationship(PictureResource._relationship(:file_properties))) + end +end diff --git a/test/unit/jsonapi_request/jsonapi_request_test.rb b/test/unit/jsonapi_request/jsonapi_request_test.rb index b96ffb1d1..dc467a728 100644 --- a/test/unit/jsonapi_request/jsonapi_request_test.rb +++ b/test/unit/jsonapi_request/jsonapi_request_test.rb @@ -4,7 +4,7 @@ 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' filters :name @@ -14,6 +14,12 @@ def self.sortable_fields(context) end end +class TreeResource < JSONAPI::Resource + def self.sortable_field?(key, context) + key =~ /^sort\d+/ + end +end + class JSONAPIRequestTest < ActiveSupport::TestCase def test_parse_includes_underscored params = ActionController::Parameters.new( @@ -24,7 +30,7 @@ def test_parse_includes_underscored } ) - request = JSONAPI::RequestParser.new( + request = JSONAPI::Request.new( params, { context: nil, @@ -32,9 +38,96 @@ def test_parse_includes_underscored } ) + request.parse_include_directives(ExpenseEntryResource, params[:include]) assert request.errors.empty? end + def test_check_include_allowed + reset_includes + JSONAPI::Request.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) + ensure + reset_includes + end + + def test_check_nested_include_allowed + reset_includes + JSONAPI::Request.new.check_include(ExpenseEntryResource, "employee.expenseEntries".partition('.')) + ensure + reset_includes + end + + def test_check_include_relationship_does_not_exist + reset_includes + + assert_raises JSONAPI::Exceptions::InvalidInclude do + assert JSONAPI::Request.new.check_include(ExpenseEntryResource, "foo".partition('.')) + end + ensure + reset_includes + end + + def test_check_nested_include_relationship_does_not_exist_wrong_format + reset_includes + + assert_raises JSONAPI::Exceptions::InvalidInclude do + JSONAPI::Request.new.check_include(ExpenseEntryResource, "employee.expense-entries".partition('.')) + end + ensure + reset_includes + end + + def test_check_include_has_one_not_allowed_default + reset_includes + + JSONAPI::Request.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) + JSONAPI.configuration.default_allow_include_to_one = false + + assert_raises JSONAPI::Exceptions::InvalidInclude do + JSONAPI::Request.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) + end + ensure + reset_includes + end + + def test_check_include_has_one_not_allowed_resource + reset_includes + + JSONAPI::Request.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) + ExpenseEntryResource._relationship(:iso_currency).allow_include = false + + assert_raises JSONAPI::Exceptions::InvalidInclude do + JSONAPI::Request.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) + end + ensure + reset_includes + end + + def test_check_include_has_many_not_allowed_default + reset_includes + + JSONAPI::Request.new.check_include(EmployeeResource, "expenseEntries".partition('.')) + JSONAPI.configuration.default_allow_include_to_many = false + + assert_raises JSONAPI::Exceptions::InvalidInclude do + JSONAPI::Request.new.check_include(EmployeeResource, "expenseEntries".partition('.')) + end + ensure + reset_includes + end + + def test_check_include_has_many_not_allowed_resource + reset_includes + + JSONAPI::Request.new.check_include(EmployeeResource, "expenseEntries".partition('.')) + EmployeeResource._relationship(:expense_entries).allow_include = false + + assert_raises JSONAPI::Exceptions::InvalidInclude do + JSONAPI::Request.new.check_include(EmployeeResource, "expenseEntries".partition('.')) + end + ensure + reset_includes + end + def test_parse_dasherized_with_dasherized_include params = ActionController::Parameters.new( { @@ -44,7 +137,7 @@ def test_parse_dasherized_with_dasherized_include } ) - request = JSONAPI::RequestParser.new( + request = JSONAPI::Request.new( params, { context: nil, @@ -52,6 +145,7 @@ def test_parse_dasherized_with_dasherized_include } ) + request.parse_include_directives(ExpenseEntryResource, params[:include]) assert request.errors.empty? end @@ -64,7 +158,7 @@ def test_parse_dasherized_with_underscored_include } ) - request = JSONAPI::RequestParser.new( + request = JSONAPI::Request.new( params, { context: nil, @@ -72,8 +166,9 @@ def test_parse_dasherized_with_underscored_include } ) + request.parse_include_directives(ExpenseEntryResource, params[:include]) refute request.errors.empty? - assert_equal 'iso_currency is not a valid relationship of expense-entries', request.errors[0].detail + assert_equal 'iso_currency is not a valid includable relationship of expense-entries', request.errors[0].detail end def test_parse_fields_underscored @@ -85,7 +180,7 @@ def test_parse_fields_underscored } ) - request = JSONAPI::RequestParser.new( + request = JSONAPI::Request.new( params, { context: nil, @@ -93,6 +188,7 @@ def test_parse_fields_underscored } ) + request.parse_fields(ExpenseEntryResource, params[:fields]) assert request.errors.empty? end @@ -107,7 +203,7 @@ def test_parse_dasherized_with_dasherized_fields } ) - request = JSONAPI::RequestParser.new( + request = JSONAPI::Request.new( params, { context: nil, @@ -115,6 +211,7 @@ def test_parse_dasherized_with_dasherized_fields } ) + request.parse_fields(ExpenseEntryResource, params[:fields]) assert request.errors.empty? end @@ -129,7 +226,7 @@ def test_parse_dasherized_with_underscored_fields } ) - request = JSONAPI::RequestParser.new( + request = JSONAPI::Request.new( params, { context: nil, @@ -137,8 +234,11 @@ def test_parse_dasherized_with_underscored_fields } ) - 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 + request.parse_fields(ExpenseEntryResource, params[:fields]) + 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,68 +252,83 @@ def test_parse_dasherized_with_underscored_resource } ) - request = JSONAPI::RequestParser.new( + request = JSONAPI::Request.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 + request.parse_fields(ExpenseEntryResource, 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 setup_request - @request.parse_filters({name: 'Whiskers'}) - assert_equal(@request.filters[:name], 'Whiskers') + filters = @request.parse_filters(CatResource, {name: 'Whiskers'}) + assert_equal(filters[:name], 'Whiskers') assert_equal(@request.errors, []) end 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") + e = assert_raises JSONAPI::Exceptions::FilterNotAllowed do + @request.parse_filters(CatResource, {breed: 'Whiskers'}) # breed is not a set filter + end + assert_equal 'breed is not allowed.', e.errors[0].detail end def test_parse_filters_with_no_filters setup_request - @request.parse_filters(nil) - assert_equal(@request.filters, {}) + filters = @request.parse_filters(CatResource, nil) + assert_equal(filters, {}) assert_equal(@request.errors, []) end def test_parse_filters_with_invalid_filters_param setup_request - @request.parse_filters('noeach') # String does not implement #each - assert_equal(@request.filters, {}) + filters = @request.parse_filters(CatResource, 'noeach') # String does not implement #each + assert_equal(filters, {}) assert_equal(@request.errors.count, 1) assert_equal(@request.errors.first.title, "Invalid filters syntax") end def test_parse_sort_with_valid_sorts setup_request - @request.parse_sort_criteria("-name") - assert_equal(@request.filters, {}) + sort_criteria = @request.parse_sort_criteria(CatResource, "-name") assert_equal(@request.errors, []) - assert_equal(@request.sort_criteria, [{:field=>"name", :direction=>:desc}]) + assert_equal(sort_criteria, [{:field=>"name", :direction=>:desc}]) + end + + def test_parse_sort_with_resource_validated_sorts + setup_request + e = assert_raises JSONAPI::Exceptions::InvalidSortCriteria do + @request.parse_sort_criteria(TreeResource, "sort66,name") + end + assert_equal 'name is not a valid sort criteria for trees', e.errors[0].detail end def test_parse_sort_with_relationships setup_request - @request.parse_sort_criteria("-mother.name") - assert_equal(@request.filters, {}) + sort_criteria = @request.parse_sort_criteria(CatResource, "-mother.name") assert_equal(@request.errors, []) - assert_equal(@request.sort_criteria, [{:field=>"mother.name", :direction=>:desc}]) + assert_equal(sort_criteria, [{:field=>"mother.name", :direction=>:desc}]) end private def setup_request - @request = JSONAPI::RequestParser.new - @request.resource_klass = CatResource + @request = JSONAPI::Request.new + end + + def reset_includes + JSONAPI.configuration.json_key_format = :camelized_key + JSONAPI.configuration.default_allow_include_to_one = true + JSONAPI.configuration.default_allow_include_to_many = true + ExpenseEntryResource._relationship(:iso_currency).allow_include = nil + EmployeeResource._relationship(:expense_entries).allow_include = nil end end diff --git a/test/unit/operation/operation_dispatcher_test.rb b/test/unit/operation/operation_dispatcher_test.rb deleted file mode 100644 index 278ebd6dd..000000000 --- a/test/unit/operation/operation_dispatcher_test.rb +++ /dev/null @@ -1,434 +0,0 @@ -require File.expand_path('../../../test_helper', __FILE__) - -class OperationDispatcherTest < Minitest::Test - def setup - betax = Planet.find(5) - betay = Planet.find(6) - betaz = Planet.find(7) - unknown = PlanetType.find(5) - end - - def test_create_single_resource - op = JSONAPI::OperationDispatcher.new - - count = Planet.count - - operations = [ - JSONAPI::Operation.new(:create_resource, PlanetResource, data: {attributes: {'name' => 'earth', 'description' => 'The best planet ever.'}}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(:created, operation_results.results[0].code) - assert_equal(operation_results.results.size, 1) - assert_equal(Planet.count, count + 1) - end - - def test_create_multiple_resources - op = JSONAPI::OperationDispatcher.new - - count = Planet.count - - operations = [ - JSONAPI::Operation.new(:create_resource, PlanetResource, data: {attributes: {'name' => 'earth', 'description' => 'The best planet for life.'}}), - JSONAPI::Operation.new(:create_resource, PlanetResource, data: {attributes: {'name' => 'mars', 'description' => 'The red planet.'}}), - JSONAPI::Operation.new(:create_resource, PlanetResource, data: {attributes: {'name' => 'venus', 'description' => 'A very hot planet.'}}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 3) - assert_equal(Planet.count, count + 3) - end - - def test_replace_to_one_relationship - op = JSONAPI::OperationDispatcher.new - - saturn = Planet.find(1) - gas_giant = PlanetType.find(1) - planetoid = PlanetType.find(2) - assert_equal(saturn.planet_type_id, planetoid.id) - - operations = [ - JSONAPI::Operation.new(:replace_to_one_relationship, - PlanetResource, - { - resource_id: saturn.id, - relationship_type: :planet_type, - key_value: gas_giant.id - } - ) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_kind_of(JSONAPI::OperationResult, operation_results.results[0]) - assert_equal(:no_content, operation_results.results[0].code) - - saturn.reload - assert_equal(saturn.planet_type_id, gas_giant.id) - - # Remove link - operations = [ - JSONAPI::Operation.new(:replace_to_one_relationship, - PlanetResource, - { - resource_id: saturn.id, - relationship_type: :planet_type, - key_value: nil - } - ) - ] - - op.process(operations) - saturn.reload - assert_equal(saturn.planet_type_id, nil) - - # Reset - operations = [ - JSONAPI::Operation.new(:replace_to_one_relationship, - PlanetResource, - { - resource_id: saturn.id, - relationship_type: :planet_type, - key_value: 5 - } - ) - ] - - op.process(operations) - saturn.reload - assert_equal(saturn.planet_type_id, 5) - end - - def test_create_to_many_relationships - op = JSONAPI::OperationDispatcher.new - - betax = Planet.find(5) - betay = Planet.find(6) - betaz = Planet.find(7) - gas_giant = PlanetType.find(1) - unknown = PlanetType.find(5) - betax.planet_type_id = unknown.id - betay.planet_type_id = unknown.id - betaz.planet_type_id = unknown.id - betax.save! - betay.save! - betaz.save! - - operations = [ - JSONAPI::Operation.new(:create_to_many_relationships, - PlanetTypeResource, - { - resource_id: gas_giant.id, - relationship_type: :planets, - data: [betax.id, betay.id, betaz.id] - } - ) - ] - - op.process(operations) - - betax.reload - betay.reload - betaz.reload - - assert_equal(betax.planet_type_id, gas_giant.id) - assert_equal(betay.planet_type_id, gas_giant.id) - assert_equal(betaz.planet_type_id, gas_giant.id) - - # Reset - betax.planet_type_id = unknown.id - betay.planet_type_id = unknown.id - betaz.planet_type_id = unknown.id - betax.save! - betay.save! - betaz.save! - end - - def test_replace_to_many_relationships - op = JSONAPI::OperationDispatcher.new - - betax = Planet.find(5) - betay = Planet.find(6) - betaz = Planet.find(7) - gas_giant = PlanetType.find(1) - unknown = PlanetType.find(5) - betax.planet_type_id = unknown.id - betay.planet_type_id = unknown.id - betaz.planet_type_id = unknown.id - betax.save! - betay.save! - betaz.save! - - operations = [ - JSONAPI::Operation.new(:replace_to_many_relationships, - PlanetTypeResource, - { - resource_id: gas_giant.id, - relationship_type: :planets, - data: [betax.id, betay.id, betaz.id] - } - ) - ] - - op.process(operations) - - betax.reload - betay.reload - betaz.reload - - assert_equal(betax.planet_type_id, gas_giant.id) - assert_equal(betay.planet_type_id, gas_giant.id) - assert_equal(betaz.planet_type_id, gas_giant.id) - - # Reset - betax.planet_type_id = unknown.id - betay.planet_type_id = unknown.id - betaz.planet_type_id = unknown.id - betax.save! - betay.save! - betaz.save! - end - - def test_replace_attributes - op = JSONAPI::OperationDispatcher.new - - count = Planet.count - saturn = Planet.find(1) - assert_equal(saturn.name, 'Satern') - - operations = [ - JSONAPI::Operation.new(:replace_fields, - PlanetResource, - { - resource_id: 1, - data: {attributes: {'name' => 'saturn'}} - } - ) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - - assert_kind_of(JSONAPI::ResourceOperationResult, operation_results.results[0]) - assert_equal(:ok, operation_results.results[0].code) - - saturn = Planet.find(1) - - assert_equal(saturn.name, 'saturn') - - assert_equal(Planet.count, count) - end - - def test_remove_resource - op = JSONAPI::OperationDispatcher.new - - count = Planet.count - makemake = Planet.find(2) - assert_equal(makemake.name, 'Makemake') - - operations = [ - JSONAPI::Operation.new(:remove_resource, PlanetResource, resource_id: 2), - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - - assert_kind_of(JSONAPI::OperationResult, operation_results.results[0]) - assert_equal(:no_content, operation_results.results[0].code) - assert_equal(Planet.count, count - 1) - end - - def test_rollback_from_error - op = JSONAPI::OperationDispatcher.new(transaction: - lambda { |&block| - ActiveRecord::Base.transaction do - block.yield - end - }, - rollback: - lambda { - fail ActiveRecord::Rollback - } - ) - - count = Planet.count - - operations = [ - JSONAPI::Operation.new(:remove_resource, PlanetResource, resource_id: 3), - JSONAPI::Operation.new(:remove_resource, PlanetResource, resource_id: 4), - JSONAPI::Operation.new(:remove_resource, PlanetResource, resource_id: 4) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - - assert_equal(Planet.count, count) - - assert_equal(operation_results.results.size, 3) - - assert_kind_of(JSONAPI::OperationResult, operation_results.results[0]) - assert_equal(:no_content, operation_results.results[0].code) - assert_equal(:no_content, operation_results.results[1].code) - assert_equal('404', operation_results.results[2].code) - end - - def test_show_operation - op = JSONAPI::OperationDispatcher.new - - operations = [ - JSONAPI::Operation.new(:show, PlanetResource, {id: '1'}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - refute operation_results.has_errors? - end - - def test_show_operation_error - op = JSONAPI::OperationDispatcher.new - - operations = [ - JSONAPI::Operation.new(:show, PlanetResource, {id: '145'}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - assert operation_results.has_errors? - end - - def test_show_relationship_operation - op = JSONAPI::OperationDispatcher.new - - operations = [ - JSONAPI::Operation.new(:show_relationship, PlanetResource, {parent_key: '1', relationship_type: :planet_type}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - refute operation_results.has_errors? - end - - def test_show_relationship_operation_error - op = JSONAPI::OperationDispatcher.new - - operations = [ - JSONAPI::Operation.new(:show_relationship, PlanetResource, {parent_key: '145', relationship_type: :planet_type}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - assert operation_results.has_errors? - end - - def test_show_related_resource_operation - op = JSONAPI::OperationDispatcher.new - - operations = [ - JSONAPI::Operation.new(:show_related_resource, PlanetResource, - { - source_klass: PlanetResource, - source_id: '1', - relationship_type: :planet_type}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - refute operation_results.has_errors? - end - - def test_show_related_resource_operation_error - op = JSONAPI::OperationDispatcher.new - - operations = [ - JSONAPI::Operation.new(:show_related_resource, PlanetResource, - { - source_klass: PlanetResource, - source_id: '145', - relationship_type: :planet_type}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - assert operation_results.has_errors? - end - - def test_show_related_resources_operation - op = JSONAPI::OperationDispatcher.new - - operations = [ - JSONAPI::Operation.new(:show_related_resources, PlanetResource, - { - source_klass: PlanetResource, - source_id: '1', - relationship_type: :moons}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - refute operation_results.has_errors? - end - - def test_show_related_resources_operation_error - op = JSONAPI::OperationDispatcher.new - - operations = [ - JSONAPI::Operation.new(:show_related_resources, PlanetResource, - { - source_klass: PlanetResource, - source_id: '145', - relationship_type: :moons}) - ] - - operation_results = op.process(operations) - - assert_kind_of(JSONAPI::OperationResults, operation_results) - assert_equal(operation_results.results.size, 1) - assert operation_results.has_errors? - end - - def test_safe_run_callback_pass - op = JSONAPI::OperationDispatcher.new - error = StandardError.new - - check = false - callback = ->(error) { check = true } - - op.send(:safe_run_callback, callback, error) - assert check - end - - def test_safe_run_callback_catch_fail - op = JSONAPI::OperationDispatcher.new - error = StandardError.new - - callback = ->(error) { nil.explosions } - result = op.send(:safe_run_callback, callback, error) - - assert_instance_of(JSONAPI::ErrorsOperationResult, result) - assert_equal(result.code, '500') - end -end diff --git a/test/unit/paths/path_test.rb b/test/unit/paths/path_test.rb new file mode 100644 index 000000000..9ef438d40 --- /dev/null +++ b/test/unit/paths/path_test.rb @@ -0,0 +1,112 @@ +require File.expand_path('../../../test_helper', __FILE__) +require 'jsonapi-resources' + +class PathTest < ActiveSupport::TestCase + + def test_one_relationship + path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments') + + assert path.segments.is_a?(Array) + assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + assert_equal Api::V1::PostResource._relationship(:comments), path.segments[0].relationship + end + + def test_one_field + path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'title') + + assert path.segments.is_a?(Array) + assert path.segments[0].is_a?(JSONAPI::PathSegment::Field), "should be a PathSegment::Field" + assert_equal 'title', path.segments[0].field_name + end + + def test_two_relationships + path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments.author') + + assert path.segments.is_a?(Array) + assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + assert path.segments[1].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + assert_equal Api::V1::PostResource._relationship(:comments), path.segments[0].relationship + assert_equal Api::V1::CommentResource._relationship(:author), path.segments[1].relationship + end + + def test_two_relationships_and_field + path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments.author.name') + + assert path.segments.is_a?(Array) + assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + assert path.segments[1].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + assert path.segments[2].is_a?(JSONAPI::PathSegment::Field), "should be a PathSegment::Field" + + assert_equal Api::V1::PostResource._relationship(:comments), path.segments[0].relationship + assert_equal Api::V1::CommentResource._relationship(:author), path.segments[1].relationship + assert_equal 'name', path.segments[2].field_name + + assert_equal 2, path.relationship_segments.length + end + + def test_two_relationships_and_parse_fields_false_raises_with_field + + assert_raises JSONAPI::Exceptions::InvalidRelationship do + path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, + path_string: 'comments.author.name', + parse_fields: false) + end + end + + def test_ensure_default_field_false + path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments.author', ensure_default_field: false) + + assert path.segments.is_a?(Array) + assert_equal 2, path.segments.length + assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + assert path.segments[1].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + + assert_equal Api::V1::PostResource._relationship(:comments), path.segments[0].relationship + assert_equal Api::V1::CommentResource._relationship(:author), path.segments[1].relationship + end + + def test_ensure_default_field_true + path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments.author', ensure_default_field: true) + + assert path.segments.is_a?(Array) + assert_equal 3, path.segments.length + assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + assert path.segments[1].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + + assert_equal Api::V1::PostResource._relationship(:comments), path.segments[0].relationship + assert_equal Api::V1::CommentResource._relationship(:author), path.segments[1].relationship + end + + def test_polymorphic_path + path = JSONAPI::Path.new(resource_klass: PictureResource, path_string: :imageable) + + assert path.segments.is_a?(Array) + assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + assert_equal PictureResource._relationship(:imageable), path.segments[0].relationship + refute path.segments[0].path_specified_resource_klass?, "should note that the resource klass was not specified" + end + + def test_polymorphic_path_with_resource_type + path = JSONAPI::Path.new(resource_klass: PictureResource, path_string: 'imageable#documents') + + assert path.segments.is_a?(Array) + assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" + assert_equal PictureResource._relationship(:imageable), path.segments[0].relationship + assert_equal DocumentResource, path.segments[0].resource_klass, "should return the specified resource klass" + assert path.segments[0].path_specified_resource_klass?, "should note that the resource klass was specified" + end + + def test_polymorphic_path_with_wrong_resource_type + assert_raises JSONAPI::Exceptions::InvalidRelationship do + JSONAPI::Path.new(resource_klass: PictureResource, path_string: 'imageable#docs') + end + end + + def test_raises_when_field_is_specified_if_not_expected + assert JSONAPI::Path.new(resource_klass: PictureResource, path_string: 'comments.author.name', parse_fields: true) + + assert_raises JSONAPI::Exceptions::InvalidRelationship do + JSONAPI::Path.new(resource_klass: PictureResource, path_string: 'comments.author.name', parse_fields: false) + end + end +end diff --git a/test/unit/processor/default_processor_test.rb b/test/unit/processor/default_processor_test.rb new file mode 100644 index 000000000..9ee55ba78 --- /dev/null +++ b/test/unit/processor/default_processor_test.rb @@ -0,0 +1,113 @@ +require File.expand_path('../../../test_helper', __FILE__) +require 'jsonapi-resources' +require 'json' + +class DefaultProcessorTest < ActionDispatch::IntegrationTest + def setup + JSONAPI.configuration.json_key_format = :camelized_key + JSONAPI.configuration.route_format = :camelized_route + JSONAPI.configuration.always_include_to_one_linkage_data = false + + JSONAPI.configuration.resource_cache = ActiveSupport::Cache::MemoryStore.new + PostResource.caching true + PersonResource.caching true + + $serializer = JSONAPI::ResourceSerializer.new(PostResource, + base_url: 'http://example.com', + url_helpers: TestApp.routes.url_helpers) + + # no includes + filters = { id: [10, 12] } + + options = { filters: filters } + params = { + filters: filters, + include_directives: {}, + sort_criteria: {}, + paginator: {}, + fields: {}, + serializer: {} + } + p = JSONAPI::Processor.new(PostResource, :find, params) + $id_tree_no_includes = p.send(:find_resource_tree, options, nil) + $resource_set_no_includes = JSONAPI::ResourceSet.new($id_tree_no_includes) + $populated_resource_set_no_includes = JSONAPI::ResourceSet.new($id_tree_no_includes).populate!($serializer, nil,{}) + + # has_one included + directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']) + params = { + filters: filters, + include_directives: directives, + sort_criteria: {}, + paginator: {}, + fields: {}, + serializer: {} + } + p = JSONAPI::Processor.new(PostResource, :find, params) + + $id_tree_has_one_includes = p.send(:find_resource_tree, options, directives[:include_related]) + $resource_set_has_one_includes = JSONAPI::ResourceSet.new($id_tree_has_one_includes) + $populated_resource_set_has_one_includes = JSONAPI::ResourceSet.new($id_tree_has_one_includes).populate!($serializer, nil,{}) + end + + def after_teardown + JSONAPI.configuration.always_include_to_one_linkage_data = false + JSONAPI.configuration.json_key_format = :camelized_key + JSONAPI.configuration.route_format = :underscored_route + + JSONAPI.configuration.resource_cache = nil + PostResource.caching nil + PersonResource.caching nil + end + + def test_id_tree_without_includes_should_be_a_resource_tree + assert $id_tree_no_includes.is_a?(JSONAPI::PrimaryResourceTree) + end + + def test_id_tree_without_includes_should_have_resources + assert_equal 2, $id_tree_no_includes.fragments.size + end + + def test_id_tree_without_includes_should_not_have_related_resources + assert_empty $id_tree_no_includes.related_resource_trees + end + + def test_id_tree_without_includes_resource_relationships_should_be_empty + assert_equal 0, $id_tree_no_includes.fragments[JSONAPI::ResourceIdentity.new(PostResource, 10)].related.length + assert_equal 0, $id_tree_no_includes.fragments[JSONAPI::ResourceIdentity.new(PostResource, 12)].related.length + end + + def test_id_tree_has_one_includes_should_be_a_resource_tree + assert $id_tree_has_one_includes.is_a?(JSONAPI::PrimaryResourceTree) + end + + def test_id_tree_has_one_includes_should_have_included_resources + assert $id_tree_has_one_includes.related_resource_trees.is_a?(Hash) + assert $id_tree_has_one_includes.related_resource_trees[:author].is_a?(JSONAPI::RelatedResourceTree) + assert_equal 2, $id_tree_has_one_includes.related_resource_trees[:author].fragments.size + end + + def test_id_tree_has_one_includes_should_have_resources + assert_equal 2, $id_tree_has_one_includes.fragments.size + end + + def test_id_tree_has_one_includes_resource_relationships_should_have_rids + assert_equal 1, $id_tree_has_one_includes.fragments[JSONAPI::ResourceIdentity.new(PostResource, 10)].related[:author].length + assert_equal 1, $id_tree_has_one_includes.fragments[JSONAPI::ResourceIdentity.new(PostResource, 12)].related[:author].length + end + + def test_populated_resource_set_has_one_includes_have_resources + assert $populated_resource_set_has_one_includes.resource_klasses[PostResource][10].is_a?(Hash) + assert $populated_resource_set_has_one_includes.resource_klasses[PostResource][12].is_a?(Hash) + assert $populated_resource_set_has_one_includes.resource_klasses[PersonResource][1003].is_a?(Hash) + assert $populated_resource_set_has_one_includes.resource_klasses[PersonResource][1004].is_a?(Hash) + end + + def test_populated_resource_set_has_one_includes_relationships_are_resolved + assert_equal 1003, $populated_resource_set_has_one_includes.resource_klasses[PostResource][10][:relationships][:author].first.id + assert_equal 1004, $populated_resource_set_has_one_includes.resource_klasses[PostResource][12][:relationships][:author].first.id + + assert_equal 10, $populated_resource_set_has_one_includes.resource_klasses[PersonResource][1003][:relationships][:posts].first.id + assert_equal 12, $populated_resource_set_has_one_includes.resource_klasses[PersonResource][1004][:relationships][:posts].first.id + end +end \ No newline at end of file diff --git a/test/unit/resource/active_relation_resource_test.rb b/test/unit/resource/active_relation_resource_test.rb new file mode 100644 index 000000000..858009c9b --- /dev/null +++ b/test/unit/resource/active_relation_resource_test.rb @@ -0,0 +1,237 @@ +require File.expand_path('../../../test_helper', __FILE__) + +class ArPostResource < JSONAPI::Resource + model_name 'Post' + attribute :headline, delegate: :title + has_one :author + has_many :tags, primary_key: :tags_import_id +end + +class ActiveRelationResourceTest < ActiveSupport::TestCase + def setup + end + + def test_find_fragments_no_attributes + filters = {} + posts_identities = ArPostResource.find_fragments(filters) + + assert_equal 20, posts_identities.length + assert_equal JSONAPI::ResourceIdentity.new(ArPostResource, 1), posts_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(ArPostResource, 1), posts_identities.values[0].identity + assert posts_identities.values[0].is_a?(JSONAPI::ResourceFragment) + end + + def test_find_fragments_cache_field + filters = {} + options = { cache: true } + posts_identities = ArPostResource.find_fragments(filters, options) + + assert_equal 20, posts_identities.length + assert_equal JSONAPI::ResourceIdentity.new(ArPostResource, 1), posts_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(ArPostResource, 1), posts_identities.values[0].identity + assert posts_identities.values[0].is_a?(JSONAPI::ResourceFragment) + assert posts_identities.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + end + + def test_find_fragments_cache_field_attributes + filters = {} + options = { attributes: [:headline, :author_id], cache: true } + posts_identities = ArPostResource.find_fragments(filters, options) + + assert_equal 20, posts_identities.length + assert_equal JSONAPI::ResourceIdentity.new(ArPostResource, 1), posts_identities.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(ArPostResource, 1), posts_identities.values[0].identity + assert posts_identities.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 2, posts_identities.values[0].attributes.length + assert posts_identities.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + assert_equal 'New post', posts_identities.values[0].attributes[:headline] + assert_equal 1001, posts_identities.values[0].attributes[:author_id] + end + + def test_find_related_has_one_fragments_no_attributes + options = {} + source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 1), + JSONAPI::ResourceIdentity.new(ArPostResource, 2), + JSONAPI::ResourceIdentity.new(ArPostResource, 20)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + related_fragments = ArPostResource.find_included_fragments(source_fragments, 'author', options) + + assert_equal 2, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 2, related_fragments.values[0].related_from.length + end + + def test_find_related_has_one_fragments_cache_field + options = { cache: true } + source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 1), + JSONAPI::ResourceIdentity.new(ArPostResource, 2), + JSONAPI::ResourceIdentity.new(ArPostResource, 20)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + related_fragments = ArPostResource.find_included_fragments(source_fragments, 'author', options) + + assert_equal 2, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 2, related_fragments.values[0].related_from.length + assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + end + + def test_find_related_has_one_fragments_cache_field_attributes + options = { cache: true, attributes: [:name] } + source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 1), + JSONAPI::ResourceIdentity.new(ArPostResource, 2), + JSONAPI::ResourceIdentity.new(ArPostResource, 20)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + related_fragments = ArPostResource.find_included_fragments(source_fragments, 'author', options) + + assert_equal 2, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(AuthorResource, 1001), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 2, related_fragments.values[0].related_from.length + assert_equal 1, related_fragments.values[0].attributes.length + assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + assert_equal 'Joe Author', related_fragments.values[0].attributes[:name] + end + + def test_find_related_has_many_fragments_no_attributes + options = {} + source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 1), + JSONAPI::ResourceIdentity.new(ArPostResource, 2), + JSONAPI::ResourceIdentity.new(ArPostResource, 12), + JSONAPI::ResourceIdentity.new(ArPostResource, 14)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + related_fragments = ArPostResource.find_included_fragments(source_fragments, 'tags', options) + + assert_equal 8, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + assert_equal 2, related_fragments[JSONAPI::ResourceIdentity.new(TagResource, 502)].related_from.length + end + + def test_find_related_has_many_fragments_pagination + params = ActionController::Parameters.new(number: 2, size: 4) + options = { paginator: PagedPaginator.new(params) } + source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 15)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + related_fragments = ArPostResource.find_included_fragments(source_fragments, 'tags', options) + + assert_equal 1, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 516), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 516), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + end + + def test_find_related_has_many_fragments_cache_field + options = { cache: true } + source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 1), + JSONAPI::ResourceIdentity.new(ArPostResource, 2), + JSONAPI::ResourceIdentity.new(ArPostResource, 12), + JSONAPI::ResourceIdentity.new(ArPostResource, 14)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + related_fragments = ArPostResource.find_included_fragments(source_fragments, 'tags', options) + + assert_equal 8, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + assert_equal 2, related_fragments[JSONAPI::ResourceIdentity.new(TagResource, 502)].related_from.length + assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + end + + def test_find_related_has_many_fragments_cache_field_attributes + options = { cache: true, attributes: [:name] } + source_rids = [JSONAPI::ResourceIdentity.new(ArPostResource, 1), + JSONAPI::ResourceIdentity.new(ArPostResource, 2), + JSONAPI::ResourceIdentity.new(ArPostResource, 12), + JSONAPI::ResourceIdentity.new(ArPostResource, 14)] + + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + related_fragments = ArPostResource.find_included_fragments(source_fragments, 'tags', options) + + assert_equal 8, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(TagResource, 501), related_fragments.values[0].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + assert_equal 2, related_fragments[JSONAPI::ResourceIdentity.new(TagResource, 502)].related_from.length + assert_equal 1, related_fragments.values[0].attributes.length + assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + assert_equal 'short', related_fragments.values[0].attributes[:name] + end + + def test_find_related_polymorphic_fragments_no_attributes + options = {} + source_rids = [JSONAPI::ResourceIdentity.new(PictureResource, 1), + JSONAPI::ResourceIdentity.new(PictureResource, 2), + JSONAPI::ResourceIdentity.new(PictureResource, 3)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + related_fragments = PictureResource.find_included_fragments(source_fragments, 'imageable', options) + + assert_equal 2, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.values[0].identity + assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_fragments.keys[1] + assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_fragments.values[1].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.values[0].identity + end + + def test_find_related_polymorphic_fragments_cache_field + options = { cache: true } + source_rids = [JSONAPI::ResourceIdentity.new(PictureResource, 1), + JSONAPI::ResourceIdentity.new(PictureResource, 2), + JSONAPI::ResourceIdentity.new(PictureResource, 3)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + related_fragments = PictureResource.find_included_fragments(source_fragments, 'imageable', options) + + assert_equal 2, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.values[0].identity + assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_fragments.keys[1] + assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_fragments.values[1].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + assert related_fragments.values[1].cache.is_a?(ActiveSupport::TimeWithZone) + end + + def test_find_related_polymorphic_fragments_cache_field_attributes + options = { cache: true, attributes: [:name] } + source_rids = [JSONAPI::ResourceIdentity.new(PictureResource, 1), + JSONAPI::ResourceIdentity.new(PictureResource, 2), + JSONAPI::ResourceIdentity.new(PictureResource, 3)] + source_fragments = source_rids.collect {|rid| JSONAPI::ResourceFragment.new(rid) } + + related_fragments = PictureResource.find_included_fragments(source_fragments, 'imageable', options) + + assert_equal 2, related_fragments.length + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.keys[0] + assert_equal JSONAPI::ResourceIdentity.new(ProductResource, 1), related_fragments.values[0].identity + assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_fragments.keys[1] + assert_equal JSONAPI::ResourceIdentity.new(DocumentResource, 1), related_fragments.values[1].identity + assert related_fragments.values[0].is_a?(JSONAPI::ResourceFragment) + assert_equal 1, related_fragments.values[0].related_from.length + assert_equal 1, related_fragments.values[0].attributes.length + assert related_fragments.values[0].cache.is_a?(ActiveSupport::TimeWithZone) + assert related_fragments.values[1].cache.is_a?(ActiveSupport::TimeWithZone) + assert_equal 'Enterprise Gizmo', related_fragments.values[0].attributes[:name] + assert_equal 'Company Brochure', related_fragments.values[1].attributes[:name] + end +end diff --git a/test/unit/resource/relationship_test.rb b/test/unit/resource/relationship_test.rb index 8208933e4..a98d26601 100644 --- a/test/unit/resource/relationship_test.rb +++ b/test/unit/resource/relationship_test.rb @@ -1,5 +1,23 @@ require File.expand_path('../../../test_helper', __FILE__) +class LambdaBlogPostsResource < JSONAPI::Resource + model_name 'Post' + + has_one :author, allow_include: -> (context) { context[:admin] } + has_many :comments, allow_include: -> (context) { context[:admin] } +end + +class CallableBlogPostsResource < JSONAPI::Resource + model_name 'Post' + + has_one :author, allow_include: :is_admin + has_many :comments, allow_include: :is_admin + + def self.is_admin(context) + context[:admin] + end +end + class HasOneRelationshipTest < ActiveSupport::TestCase def test_polymorphic_type @@ -9,4 +27,220 @@ def test_polymorphic_type assert_equal(relationship.polymorphic_type, "imageable_type") end + def test_allow_include_not_set_defaults_to_config_to_one + original_config = JSONAPI.configuration.dup + + JSONAPI.configuration.default_allow_include_to_one = true + relationship = JSONAPI::Relationship::ToOne.new("foo") + assert(relationship.allow_include?) + + JSONAPI.configuration.default_allow_include_to_one = false + relationship = JSONAPI::Relationship::ToOne.new("foo") + refute(relationship.allow_include?) + + ensure + JSONAPI.configuration = original_config + end + + def test_allow_include_not_set_defaults_to_config_to_many + original_config = JSONAPI.configuration.dup + + JSONAPI.configuration.default_allow_include_to_many = true + relationship = JSONAPI::Relationship::ToMany.new("foobar") + assert(relationship.allow_include?) + + JSONAPI.configuration.default_allow_include_to_one = false + relationship = JSONAPI::Relationship::ToOne.new("foobar") + refute(relationship.allow_include?) + + ensure + JSONAPI.configuration = original_config + end + + def test_allow_include_set_overrides_to_config_to_one + original_config = JSONAPI.configuration.dup + + JSONAPI.configuration.default_allow_include_to_one = true + relationship1 = JSONAPI::Relationship::ToOne.new("foo1", allow_include: false) + relationship2 = JSONAPI::Relationship::ToOne.new("foo2", allow_include: true) + refute(relationship1.allow_include?) + assert(relationship2.allow_include?) + + JSONAPI.configuration.default_allow_include_to_one = false + refute(relationship1.allow_include?) + assert(relationship2.allow_include?) + + ensure + JSONAPI.configuration = original_config + end + + def test_allow_include_set_overrides_to_config_to_many + original_config = JSONAPI.configuration.dup + + JSONAPI.configuration.default_allow_include_to_one = true + relationship1 = JSONAPI::Relationship::ToMany.new("foobar1", allow_include: false) + relationship2 = JSONAPI::Relationship::ToMany.new("foobar2", allow_include: true) + refute(relationship1.allow_include?) + assert(relationship2.allow_include?) + + JSONAPI.configuration.default_allow_include_to_one = false + refute(relationship1.allow_include?) + assert(relationship2.allow_include?) + + ensure + JSONAPI.configuration = original_config + end + + def test_allow_include_set_by_lambda + assert LambdaBlogPostsResource._relationship(:author).allow_include?(admin: true) + refute LambdaBlogPostsResource._relationship(:author).allow_include?(admin: false) + + assert LambdaBlogPostsResource._relationship(:comments).allow_include?(admin: true) + refute LambdaBlogPostsResource._relationship(:comments).allow_include?(admin: false) + end + + def test_allow_include_set_by_callable + assert CallableBlogPostsResource._relationship(:author).allow_include?(admin: true) + refute CallableBlogPostsResource._relationship(:author).allow_include?(admin: false) + + assert CallableBlogPostsResource._relationship(:comments).allow_include?(admin: true) + refute CallableBlogPostsResource._relationship(:comments).allow_include?(admin: false) + 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 + + 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 + + end diff --git a/test/unit/resource/resource_test.rb b/test/unit/resource/resource_test.rb index 4c8e94daa..df2df1730 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,38 +58,25 @@ class CatResource < JSONAPI::Resource has_one :father, class_name: 'Cat' end -class PersonWithCustomRecordsForResource < PersonResource - def records_for(relationship_name) - :records_for - end -end - -class PersonWithCustomRecordsForRelationshipsResource < PersonResource - def records_for_posts - :records_for_posts - end - - def record_for_preferences - :record_for_preferences - end -end - -class PersonWithCustomRecordsForErrorResource < PersonResource - class AuthorizationError < StandardError; end - def records_for(relationship_name) - raise AuthorizationError - end +class TestSingletonResource < JSONAPI::Resource end 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 +87,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 @@ -121,30 +116,36 @@ def test_module_path def test_resource_for_root_resource assert_raises NameError do - JSONAPI::Resource.resource_for('related') + JSONAPI::Resource.resource_klass_for('related') end end def test_resource_for_resource_does_not_exist_at_root assert_raises NameError do - ArticleResource.resource_for('related') + ArticleResource.resource_klass_for('related') end end def test_resource_for_with_underscored_namespaced_paths - assert_equal(JSONAPI::Resource.resource_for('my_module/related'), MyModule::RelatedResource) - assert_equal(PostResource.resource_for('my_module/related'), MyModule::RelatedResource) - assert_equal(MyModule::MyNamespacedResource.resource_for('my_module/related'), MyModule::RelatedResource) + assert_equal(JSONAPI::Resource.resource_klass_for('my_module/related'), MyModule::RelatedResource) + assert_equal(PostResource.resource_klass_for('my_module/related'), MyModule::RelatedResource) + assert_equal(MyModule::MyNamespacedResource.resource_klass_for('my_module/related'), MyModule::RelatedResource) end def test_resource_for_with_camelized_namespaced_paths - assert_equal(JSONAPI::Resource.resource_for('MyModule::Related'), MyModule::RelatedResource) - assert_equal(PostResource.resource_for('MyModule::Related'), MyModule::RelatedResource) - assert_equal(MyModule::MyNamespacedResource.resource_for('MyModule::Related'), MyModule::RelatedResource) + assert_equal(JSONAPI::Resource.resource_klass_for('MyModule::Related'), MyModule::RelatedResource) + assert_equal(PostResource.resource_klass_for('MyModule::Related'), MyModule::RelatedResource) + assert_equal(MyModule::MyNamespacedResource.resource_klass_for('MyModule::Related'), MyModule::RelatedResource) end def test_resource_for_namespaced_resource - assert_equal(MyModule::MyNamespacedResource.resource_for('related'), MyModule::RelatedResource) + assert_equal(MyModule::MyNamespacedResource.resource_klass_for('related'), MyModule::RelatedResource) + end + + def test_resource_for_nested_namespaced_resource + assert_equal(JSONAPI::Resource.resource_klass_for('my_module/nested/profile'), MyModule::Nested::ProfileResource) + assert_equal(MyModule::MyNamespacedResource.resource_klass_for('my_module/nested/profile'), MyModule::Nested::ProfileResource) + assert_equal(MyModule::MyNamespacedResource.resource_klass_for('nested/profile'), MyModule::Nested::ProfileResource) end def test_relationship_parent_point_to_correct_resource @@ -172,15 +173,17 @@ def test_derived_not_abstract refute PersonResource._abstract end + def test_inherited_calls_superclass + subclasses = BaseResource.subclasses + assert_includes(subclasses, PersonResource) + assert_includes(subclasses, SpecialBaseResource) + assert_equal(2, subclasses.size) + end + 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 - assert_nil NoMatchResource._model_class - end + 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 - # :nocov: end def test_nil_abstract_model_class @@ -194,96 +197,43 @@ 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 - def test_replace_polymorphic_to_one_link - picture_resource = PictureResource.find_by_key(Picture.first) - picture_resource.replace_polymorphic_to_one_link('imageable', '9', 'Topic') - - assert Picture.first.imageable_id == 9 - assert Picture.first.imageable_type == Document::Topic.to_s - 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 end def test_find_with_customized_base_records - author = Person.find(1) - posts = ArticleResource.find([], context: author).map(&:_model) + author = Person.find(1001) + posts = ArticleResource.find({}, context: author).map(&:_model) assert(posts.include?(Post.find(1))) refute(posts.include?(Post.find(3))) end - def test_records_for - author = Person.find(1) - preferences = Preferences.first - refute(preferences == nil) - author.update! preferences: preferences - author_resource = PersonResource.new(author, nil) - assert_equal(author_resource.preferences._model, preferences) - - author_resource = PersonWithCustomRecordsForResource.new(author, nil) - assert_equal(author_resource.preferences._model, :records_for) - - author_resource = PersonWithCustomRecordsForErrorResource.new(author, nil) - assert_raises PersonWithCustomRecordsForErrorResource::AuthorizationError do - author_resource.posts - end - end - - def test_records_for_meta_method_for_to_one - author = Person.find(1) - author.update! preferences: Preferences.first - author_resource = PersonWithCustomRecordsForRelationshipsResource.new(author, nil) - assert_equal(author_resource.record_for_preferences, :record_for_preferences) - end - - def test_records_for_meta_method_for_to_one_calling_records_for - author = Person.find(1) - author.update! preferences: Preferences.first - author_resource = PersonWithCustomRecordsForResource.new(author, nil) - assert_equal(author_resource.record_for_preferences, :records_for) - end - - def test_associated_records_meta_method_for_to_many - author = Person.find(1) - author.posts << Post.find(1) - author_resource = PersonWithCustomRecordsForRelationshipsResource.new(author, nil) - assert_equal(author_resource.records_for_posts, :records_for_posts) - end - - def test_associated_records_meta_method_for_to_many_calling_records_for - author = Person.find(1) - author.posts << Post.find(1) - author_resource = PersonWithCustomRecordsForResource.new(author, nil) - assert_equal(author_resource.records_for_posts, :records_for) - end - def test_find_by_key_with_customized_base_records - author = Person.find(1) + author = Person.find(1001) post = ArticleResource.find_by_key(1, context: author)._model assert_equal(post, Post.find(1)) @@ -294,7 +244,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 @@ -313,185 +263,153 @@ def test_filter_on_aliased_to_many_relationship_id end def test_filter_on_has_one_relationship_id - people = PreferencesResource.find(:author => 1) - assert_equal([1], people.map(&:id)) + prefs = PreferencesResource.find(:author => 1001) + assert_equal([1], prefs.map(&:id)) end def test_to_many_relationship_filters post_resource = PostResource.new(Post.find(1), nil) - comments = post_resource.comments - assert_equal(2, comments.size) - # define apply_filters method on post resource to not respect filters - PostResource.instance_eval do - def apply_filters(records, filters, options) - # :nocov: - records - # :nocov: - end - end + comments = PostResource.find_included_fragments([post_resource], :comments, {}) + assert_equal(2, comments.size) - filtered_comments = post_resource.comments({ filters: { body: 'i liked it' } }) + filtered_comments = PostResource.find_included_fragments([post_resource], :comments, { filters: { body: 'i liked it' } }) assert_equal(1, filtered_comments.size) - - ensure - # reset method to original implementation - PostResource.instance_eval do - def apply_filters(records, filters, options) - # :nocov: - super - # :nocov: - end - end end def test_to_many_relationship_sorts post_resource = PostResource.new(Post.find(1), nil) - comment_ids = post_resource.comments.map{|c| c._model.id } + comment_ids = post_resource.class.find_included_fragments([post_resource], :comments, {}).keys.collect {|c| c.id } assert_equal [1,2], comment_ids - # define apply_filters method on post resource to not respect filters + # define apply_filters method on post resource to sort descending PostResource.instance_eval do - def apply_sort(records, criteria, context = {}) + def apply_sort(records, _order_options, options) # :nocov: - records + order_by_query = "#{options[:related_alias]}.id desc" + records.order(order_by_query) # :nocov: end end - sorted_comment_ids = post_resource.comments(sort_criteria: [{ field: 'id', direction: :desc}]).map{|c| c._model.id } + sorted_comment_ids = post_resource.class.find_included_fragments( + [post_resource], + :comments, + { sort_criteria: [{ field: 'id', direction: :desc }] }).keys.collect {|c| c.id} + assert_equal [2,1], sorted_comment_ids ensure - # reset method to original implementation PostResource.instance_eval do - def apply_sort(records, criteria, context = {}) - # :nocov: - super - # :nocov: - end - end - end - - def test_lookup_association_chain - model_names = %w(person posts parent_post) - result = PostResource._lookup_association_chain(model_names) - assert_equal 2, result.length - - posts_reflection, parent_post_reflection = result - assert_equal :posts, posts_reflection.name - assert_equal :parent_post, parent_post_reflection.name - - assert_equal "posts", posts_reflection.table_name - assert_equal "posts", parent_post_reflection.table_name - - assert_equal "author_id", posts_reflection.foreign_key - assert_equal "parent_post_id", parent_post_reflection.foreign_key - end - - def test_build_joins - model_names = %w(person posts parent_post author) - associations = PostResource._lookup_association_chain(model_names) - result = PostResource._build_joins(associations) - - assert_equal "LEFT JOIN posts AS parent_post_sorting ON parent_post_sorting.id = posts.parent_post_id -LEFT JOIN people AS author_sorting ON author_sorting.id = posts.author_id", result - end - - def test_to_many_relationship_pagination - post_resource = PostResource.new(Post.find(1), nil) - comments = post_resource.comments - assert_equal 2, comments.size + def apply_sort(records, order_options, context = {}) + if order_options.any? + order_options.each_pair do |field, direction| + records = apply_single_sort(records, field, direction, context) + end + end - # define apply_filters method on post resource to not respect filters - PostResource.instance_eval do - def apply_pagination(records, criteria, order_options) - # :nocov: records - # :nocov: - end - end - - paginator_class = Class.new(JSONAPI::Paginator) do - 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 - - paged_comments = post_resource.comments(paginator: paginator_class.new(1)) - assert_equal 1, paged_comments.size - - ensure - # reset method to original implementation - PostResource.instance_eval do - def apply_pagination(records, criteria, order_options) - # :nocov: - super - # :nocov: end end end + # ToDo: Implement relationship pagination + # + # def test_to_many_relationship_pagination + # post_resource = PostResource.new(Post.find(1), nil) + # comments = post_resource.comments + # assert_equal 2, comments.size + # + # # define apply_filters method on post resource to not respect filters + # PostResource.instance_eval do + # def apply_pagination(records, criteria, order_options) + # # :nocov: + # records + # # :nocov: + # end + # end + # + # paginator_class = Class.new(JSONAPI::Paginator) do + # 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 + # + # paged_comments = post_resource.comments(paginator: paginator_class.new(1)) + # assert_equal 1, paged_comments.size + # + # ensure + # # reset method to original implementation + # PostResource.instance_eval do + # def apply_pagination(records, criteria, order_options) + # # :nocov: + # records = paginator.apply(records, order_options) if paginator + # records + # # :nocov: + # end + # end + # 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,19 +420,21 @@ 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 def test_id_attr_deprecation + + ActiveSupport::Deprecation.silenced = false _out, err = capture_io do eval <<-CODE class ProblemResource < JSONAPI::Resource @@ -523,6 +443,8 @@ class ProblemResource < JSONAPI::Resource CODE end assert_match /DEPRECATION WARNING: Id without format is no longer supported. Please remove ids from attributes, or specify a format./, err + ensure + ActiveSupport::Deprecation.silenced = true end def test_id_attr_with_format @@ -585,7 +507,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 @@ -613,7 +535,7 @@ def test_resource_for_model_use_hint special_person = Person.create!(name: 'Special', date_joined: Date.today, special: true) special_resource = SpecialPersonResource.new(special_person, nil) resource_model = SpecialPersonResource.records({}).first # simulate a find - assert_equal(SpecialPersonResource, SpecialPersonResource.resource_for_model(resource_model)) + assert_equal(SpecialPersonResource, SpecialPersonResource.resource_klass_for_model(resource_model)) end def test_resource_performs_validations_in_custom_context @@ -629,4 +551,97 @@ def test_resources_for_transforms_records_into_resources resources = PostResource.resources_for([Post.first], {}) assert_equal(PostResource, resources.first.class) end + + def test_readonly_attribute + refute_includes(PostWithReadonlyAttributesResource.creatable_fields, :title) + refute_includes(PostWithReadonlyAttributesResource.updatable_fields, :title) + + refute_includes(PostWithReadonlyAttributesResource.creatable_fields, :author) + refute_includes(PostWithReadonlyAttributesResource.updatable_fields, :author) + end + + def test_sortable_field? + assert(PostResource.sortable_field?(:title)) + assert(PostResource.sortable_field?(:body)) + refute(PostResource.sortable_field?(:color)) + 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 + + 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 end diff --git a/test/unit/serializer/include_directives_test.rb b/test/unit/serializer/include_directives_test.rb index 8738c5044..552d13b1b 100644 --- a/test/unit/serializer/include_directives_test.rb +++ b/test/unit/serializer/include_directives_test.rb @@ -4,15 +4,13 @@ class IncludeDirectivesTest < ActiveSupport::TestCase def test_one_level_one_include - directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts']).include_directives + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts']).instance_variable_get(:@include_directives_hash) assert_hash_equals( { include_related: { posts: { - include: true, - include_related:{}, - include_in_join: true + include_related: {} } } }, @@ -20,69 +18,62 @@ def test_one_level_one_include end def test_one_level_multiple_includes - directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts', 'comments', 'tags']).include_directives + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts', 'comments', 'expense_entries']).instance_variable_get(:@include_directives_hash) assert_hash_equals( { include_related: { posts: { - include: true, - include_related:{}, - include_in_join: true + include_related: {} }, comments: { - include: true, - include_related:{}, - include_in_join: true + include_related: {} }, - tags: { - include: true, - include_related:{}, - include_in_join: true + expense_entries: { + include_related: {} } } }, directives) end - def test_two_levels_include_full_path - directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts.comments']).include_directives + def test_multiple_level_multiple_includes + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts', 'posts.comments', 'comments', 'expense_entries']).instance_variable_get(:@include_directives_hash) assert_hash_equals( { include_related: { posts: { - include: true, - include_related:{ + include_related: { comments: { - include: true, - include_related:{}, - include_in_join: true + include_related: {} } - }, - include_in_join: true + } + }, + comments: { + include_related: {} + }, + expense_entries: { + include_related: {} } } }, directives) end - def test_no_eager_join - directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts.tags']).include_directives + + def test_two_levels_include_full_path + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts.comments']).instance_variable_get(:@include_directives_hash) assert_hash_equals( { include_related: { posts: { - include: true, - include_related:{ - tags: { - include: true, - include_related:{}, - include_in_join: false + include_related: { + comments: { + include_related: {} } - }, - include_in_join: true + } } } }, @@ -90,21 +81,17 @@ def test_no_eager_join end def test_two_levels_include_full_path_redundant - directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts','posts.comments']).include_directives + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts', 'posts.comments']).instance_variable_get(:@include_directives_hash) assert_hash_equals( { include_related: { posts: { - include: true, - include_related:{ + include_related: { comments: { - include: true, - include_related:{}, - include_in_join: true + include_related: {} } - }, - include_in_join: true + } } } }, @@ -112,35 +99,47 @@ def test_two_levels_include_full_path_redundant end def test_three_levels_include_full - directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts.comments.tags']).include_directives + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts.comments.tags']).instance_variable_get(:@include_directives_hash) assert_hash_equals( { include_related: { posts: { - include: true, - include_related:{ + include_related: { comments: { - include: true, - include_related:{ + include_related: { tags: { - include: true, - include_related:{}, - include_in_join: true + include_related: {} } - }, - include_in_join: true + } } - }, - include_in_join: true + } } } }, directives) end - 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) + # 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, ['../../../../']).instance_variable_get(:@include_directives_hash) + end + end + + def test_invalid_includes_2 + assert_raises JSONAPI::Exceptions::InvalidInclude do + JSONAPI::IncludeDirectives.new(PersonResource, ['posts./sdaa./........']).instance_variable_get(:@include_directives_hash) + end + end + + def test_invalid_includes_3 + assert_raises JSONAPI::Exceptions::InvalidInclude do + JSONAPI::IncludeDirectives.new(PersonResource, ['invalid../../../../']).instance_variable_get(:@include_directives_hash) + 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 deleted file mode 100644 index 347fc8d33..000000000 --- a/test/unit/serializer/polymorphic_serializer_test.rb +++ /dev/null @@ -1,387 +0,0 @@ -require File.expand_path('../../../test_helper', __FILE__) -require 'jsonapi-resources' -require 'json' - -class PolymorphismTest < ActionDispatch::IntegrationTest - def setup - @pictures = Picture.all - @person = Person.find(1) - - JSONAPI.configuration.json_key_format = :camelized_key - JSONAPI.configuration.route_format = :camelized_route - end - - def after_teardown - JSONAPI.configuration.json_key_format = :underscored_key - end - - def test_polymorphic_relationship - relationships = PictureResource._relationships - imageable = relationships[:imageable] - - assert_equal relationships.size, 1 - assert imageable.polymorphic? - end - - def test_sti_polymorphic_to_many_serialization - serialized_data = JSONAPI::ResourceSerializer.new( - PersonResource, - include: %w(vehicles) - ).serialize_to_hash(PersonResource.new(@person, nil)) - - 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 => [ - { type: 'cars', id: '1' }, - { type: 'boats', id: '2' } - ] - }, - preferences: { - links: { - self: '/people/1/relationships/preferences', - related: '/people/1/preferences' - } - }, - hairCut: { - links: { - self: '/people/1/relationships/hairCut', - related: '/people/1/hairCut' - } - } - } - }, - included: [ - { - id: '1', - type: 'cars', - links: { - self: '/cars/1' - }, - attributes: { - make: 'Mazda', - model: 'Miata MX5', - driveLayout: 'Front Engine RWD', - serialNumber: '32432adfsfdysua' - }, - relationships: { - person: { - links: { - self: '/cars/1/relationships/person', - related: '/cars/1/person' - } - } - } - }, - { - id: '2', - type: 'boats', - links: { - self: '/boats/2' - }, - attributes: { - make: 'Chris-Craft', - model: 'Launch 20', - lengthAtWaterLine: '15.5ft', - serialNumber: '434253JJJSD' - }, - relationships: { - person: { - links: { - self: '/boats/2/relationships/person', - related: '/boats/2/person' - } - } - } - } - ] - }, - serialized_data - ) - 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 }) - - assert_hash_equals( - { - data: [ - { - id: '1', - type: 'pictures', - links: { - self: '/pictures/1' - }, - attributes: { - name: 'enterprise_gizmo.jpg' - }, - relationships: { - imageable: { - links: { - self: '/pictures/1/relationships/imageable', - related: '/pictures/1/imageable' - }, - data: { - type: 'products', - id: '1' - } - } - } - }, - { - id: '2', - type: 'pictures', - links: { - self: '/pictures/2' - }, - attributes: { - name: 'company_brochure.jpg' - }, - relationships: { - imageable: { - links: { - self: '/pictures/2/relationships/imageable', - related: '/pictures/2/imageable' - }, - data: { - type: 'documents', - id: '1' - } - } - } - }, - { - id: '3', - type: 'pictures', - links: { - self: '/pictures/3' - }, - attributes: { - name: 'group_photo.jpg' - }, - relationships: { - imageable: { - links: { - self: '/pictures/3/relationships/imageable', - related: '/pictures/3/imageable' - }, - data: nil - } - } - } - - ], - :included => [ - { - id: '1', - type: 'products', - links: { - self: '/products/1' - }, - attributes: { - name: 'Enterprise Gizmo' - }, - relationships: { - picture: { - links: { - self: '/products/1/relationships/picture', - related: '/products/1/picture', - }, - data: { - type: 'pictures', - id: '1' - } - } - } - }, - { - id: '1', - type: 'documents', - links: { - self: '/documents/1' - }, - attributes: { - name: 'Company Brochure' - }, - relationships: { - pictures: { - links: { - self: '/documents/1/relationships/pictures', - related: '/documents/1/pictures' - } - } - } - } - ] - }, - serialized_data - ) - end - - def test_polymorphic_get_related_resource - get '/pictures/1/imageable', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } - serialized_data = JSON.parse(response.body) - assert_hash_equals( - { - data: { - id: '1', - type: 'products', - links: { - self: 'http://www.example.com/products/1' - }, - attributes: { - name: 'Enterprise Gizmo' - }, - relationships: { - picture: { - links: { - self: 'http://www.example.com/products/1/relationships/picture', - related: 'http://www.example.com/products/1/picture' - }, - data: { - type: 'pictures', - id: '1' - } - } - } - } - }, - serialized_data - ) - end - - def test_create_resource_with_polymorphic_relationship - document = Document.find(1) - post "/pictures/", params: - { - data: { - type: "pictures", - attributes: { - name: "hello.jpg" - }, - relationships: { - imageable: { - data: { - type: "documents", - id: document.id.to_s - } - } - } - } - }.to_json, - headers: { - 'Content-Type' => JSONAPI::MEDIA_TYPE, - 'Accept' => JSONAPI::MEDIA_TYPE - } - assert_equal 201, response.status - picture = Picture.find(json_response["data"]["id"]) - assert_not_nil picture.imageable, "imageable should be present" - ensure - picture.destroy if picture - end - - def test_polymorphic_create_relationship - picture = Picture.find(3) - original_imageable = picture.imageable - assert_nil original_imageable - - patch "/pictures/#{picture.id}/relationships/imageable", params: - { - relationship: 'imageable', - data: { - type: 'documents', - id: '1' - } - }.to_json, - headers: { - 'Content-Type' => JSONAPI::MEDIA_TYPE, - 'Accept' => JSONAPI::MEDIA_TYPE - } - assert_response :no_content - picture = Picture.find(3) - assert_equal 'Document', picture.imageable.class.to_s - - # restore data - picture.imageable = original_imageable - picture.save - end - - def test_polymorphic_update_relationship - picture = Picture.find(1) - original_imageable = picture.imageable - assert_not_equal 'Document', picture.imageable.class.to_s - - patch "/pictures/#{picture.id}/relationships/imageable", params: - { - relationship: 'imageable', - data: { - type: 'documents', - id: '1' - } - }.to_json, - headers: { - 'Content-Type' => JSONAPI::MEDIA_TYPE, - 'Accept' => JSONAPI::MEDIA_TYPE - } - assert_response :no_content - picture = Picture.find(1) - assert_equal 'Document', picture.imageable.class.to_s - - # restore data - picture.imageable = original_imageable - picture.save - end - - def test_polymorphic_delete_relationship - picture = Picture.find(1) - original_imageable = picture.imageable - assert original_imageable - - delete "/pictures/#{picture.id}/relationships/imageable", params: - { - relationship: 'imageable' - }.to_json, - headers: { - 'Content-Type' => JSONAPI::MEDIA_TYPE, - 'Accept' => JSONAPI::MEDIA_TYPE - } - assert_response :no_content - picture = Picture.find(1) - assert_nil picture.imageable - - # restore data - picture.imageable = original_imageable - picture.save - end -end diff --git a/test/unit/serializer/response_document_test.rb b/test/unit/serializer/response_document_test.rb deleted file mode 100644 index a32727d3d..000000000 --- a/test/unit/serializer/response_document_test.rb +++ /dev/null @@ -1,56 +0,0 @@ -require File.expand_path('../../../test_helper', __FILE__) -require 'jsonapi-resources' -require 'json' - -class ResponseDocumentTest < ActionDispatch::IntegrationTest - def setup - JSONAPI.configuration.json_key_format = :dasherized_key - JSONAPI.configuration.route_format = :dasherized_route - end - - def create_response_document(operation_results, resource_klass) - JSONAPI::ResponseDocument.new( - operation_results, - JSONAPI::ResourceSerializer.new(resource_klass), - { - primary_resource_klass: resource_klass - } - ) - end - - def test_response_document - operations = [ - JSONAPI::Operation.new(:create_resource, PlanetResource, data: {attributes: {'name' => 'Earth 2.0'}}), - JSONAPI::Operation.new(:create_resource, PlanetResource, data: {attributes: {'name' => 'Vulcan'}}) - ] - - op = JSONAPI::OperationDispatcher.new() - operation_results = op.process(operations) - - response_doc = create_response_document(operation_results, PlanetResource) - - assert_equal :created, response_doc.status - contents = response_doc.contents - assert contents.is_a?(Hash) - assert contents[:data].is_a?(Array) - assert_equal 2, contents[:data].size - end - - def test_response_document_multiple_find - operations = [ - JSONAPI::Operation.new(:find, PostResource, filters: {id: '1'}), - JSONAPI::Operation.new(:find, PostResource, filters: {id: '2'}) - ] - - op = JSONAPI::OperationDispatcher.new() - operation_results = op.process(operations) - - response_doc = create_response_document(operation_results, PostResource) - - assert_equal :ok, response_doc.status - contents = response_doc.contents - assert contents.is_a?(Hash) - assert contents[:data].is_a?(Array) - assert_equal 2, contents[:data].size - end -end diff --git a/test/unit/serializer/serializer_test.rb b/test/unit/serializer/serializer_test.rb index 2686424e0..33455b0f1 100644 --- a/test/unit/serializer/serializer_test.rb +++ b/test/unit/serializer/serializer_test.rb @@ -20,11 +20,77 @@ def after_teardown end def test_serializer + post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) + id_tree = JSONAPI::PrimaryResourceTree.new - serialized = JSONAPI::ResourceSerializer.new( - PostResource, - base_url: 'http://example.com').serialize_to_hash(PostResource.new(@post, nil) + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['']) + + id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(post_1_identity), directives[:include_related]) + resource_set = JSONAPI::ResourceSet.new(id_tree) + + serializer = JSONAPI::ResourceSerializer.new( + PostResource, + base_url: 'http://example.com', + url_helpers: TestApp.routes.url_helpers) + + resource_set.populate!(serializer, {}, {}) + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) + + assert_hash_equals( + { + 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' + } + }, + author: { + links: { + self: 'http://example.com/posts/1/relationships/author', + related: 'http://example.com/posts/1/author' + } + }, + 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' + } + } + } + } + }, + serialized ) + end + + def test_serialize_source_to_hash + post = posts(:post_1) + post_resource = PostResource.new(post, {}) + + serializer = JSONAPI::ResourceSerializer.new( + PostResource, + base_url: 'http://example.com', + url_helpers: TestApp.routes.url_helpers) + + serialized = serializer.serialize_to_hash(post_resource) assert_hash_equals( { @@ -72,22 +138,51 @@ def test_serializer end def test_serializer_nil_handling + id_tree = JSONAPI::PrimaryResourceTree.new + + resource_set = JSONAPI::ResourceSet.new(id_tree) + + serializer = JSONAPI::ResourceSerializer.new( + Api::V1::PostResource, + base_url: 'http://example.com', + url_helpers: TestApp.routes.url_helpers) + + resource_set.populate!(serializer, {}, {}) + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) + assert_hash_equals( { data: nil }, - JSONAPI::ResourceSerializer.new(PostResource).serialize_to_hash(nil) + serialized ) end - def test_serializer_namespaced_resource + def test_serializer_namespaced_resource_with_custom_resource_links + post_1_identity = JSONAPI::ResourceIdentity.new(Api::V1::PostResource, 1) + id_tree = JSONAPI::PrimaryResourceTree.new + + directives = JSONAPI::IncludeDirectives.new(PersonResource, ['']) + + id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(post_1_identity), directives[:include_related]) + resource_set = JSONAPI::ResourceSet.new(id_tree) + + serializer = JSONAPI::ResourceSerializer.new( + Api::V1::PostResource, + base_url: 'http://example.com', + url_helpers: TestApp.routes.url_helpers) + + resource_set.populate!(serializer, {}, {}) + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) + 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,45 +211,69 @@ 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)) + serialized ) end def test_serializer_limited_fieldset + post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) + id_tree = JSONAPI::PrimaryResourceTree.new + + directives = JSONAPI::IncludeDirectives.new(PersonResource, []) + + id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(post_1_identity), directives[:include_related]) + resource_set = JSONAPI::ResourceSet.new(id_tree) + + serializer = JSONAPI::ResourceSerializer.new( + PostResource, + fields: {posts: [:id, :title, :author]}, + url_helpers: TestApp.routes.url_helpers) + + resource_set.populate!(serializer, {}, {}) + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) assert_hash_equals( - { - data: { - type: 'posts', - id: '1', - links: { - self: '/posts/1' - }, - attributes: { - title: 'New post' - }, - relationships: { - author: { - links: { - self: '/posts/1/relationships/author', - related: '/posts/1/author' - } + { + data: { + type: 'posts', + id: '1', + links: { + self: '/posts/1' + }, + attributes: { + title: 'New post' + }, + relationships: { + author: { + links: { + self: '/posts/1/relationships/author', + related: '/posts/1/author' + } + } + } } - } - } - }, - JSONAPI::ResourceSerializer.new(PostResource, - fields: {posts: [:id, :title, :author]}).serialize_to_hash(PostResource.new(@post, nil)) + }, + serialized ) end def test_serializer_include - serialized = JSONAPI::ResourceSerializer.new( - PostResource, - include: ['author'] - ).serialize_to_hash(PostResource.new(@post, nil)) + post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) + person_1001_identity = JSONAPI::ResourceIdentity.new(PersonResource, 1001) + id_tree = JSONAPI::PrimaryResourceTree.new + + directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']) + + id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(post_1_identity), directives[:include_related]) + id_tree.complete_includes!(directives[:include_related], {}) + + resource_set = JSONAPI::ResourceSet.new(id_tree) + + serializer = JSONAPI::ResourceSerializer.new(PostResource, + url_helpers: TestApp.routes.url_helpers) + + resource_set.populate!(serializer, {}, {}) + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) assert_hash_equals( { @@ -183,7 +302,7 @@ def test_serializer_include }, data: { type: 'people', - id: '1' + id: '1001' } }, tags: { @@ -203,44 +322,56 @@ def test_serializer_include included: [ { type: 'people', - id: '1', + id: '1001', attributes: { name: 'Joe Author', email: 'joe@xyz.fake', dateJoined: '2013-08-07 16:25:00 -0400' }, links: { - self: '/people/1' + self: '/people/1001' }, relationships: { comments: { links: { - self: '/people/1/relationships/comments', - related: '/people/1/comments' + self: '/people/1001/relationships/comments', + related: '/people/1001/comments' } }, posts: { links: { - self: '/people/1/relationships/posts', - related: '/people/1/posts' - } + self: '/people/1001/relationships/posts', + related: '/people/1001/posts' + }, + data: [ + { + type: 'posts', + id: '1' + } + ] }, preferences: { links: { - self: '/people/1/relationships/preferences', - related: '/people/1/preferences' + self: '/people/1001/relationships/preferences', + related: '/people/1001/preferences' } }, hairCut: { links: { - self: "/people/1/relationships/hairCut", - related: "/people/1/hairCut" + self: '/people/1001/relationships/hairCut', + related: '/people/1001/hairCut' } }, vehicles: { links: { - self: "/people/1/relationships/vehicles", - related: "/people/1/vehicles" + self: '/people/1001/relationships/vehicles', + related: '/people/1001/vehicles' + } + }, + expenseEntries: { + links: { + self: '/people/1001/relationships/expenseEntries', + related: '/people/1001/expenseEntries' } } } @@ -251,26 +382,30 @@ def test_serializer_include ) end - def test_serializer_key_format - serialized = JSONAPI::ResourceSerializer.new( + def test_serializer_source_to_hash_include + post = posts(:post_1) + post_resource = PostResource.new(post, {}) + + serializer = JSONAPI::ResourceSerializer.new( PostResource, - include: ['author'], - key_formatter: UnderscoredKeyFormatter - ).serialize_to_hash(PostResource.new(@post, nil)) + url_helpers: TestApp.routes.url_helpers, + include_directives: JSONAPI::IncludeDirectives.new(PostResource, ['author'])) + + serialized = serializer.serialize_to_hash(post_resource) assert_hash_equals( { data: { type: 'posts', id: '1', + links: { + self: '/posts/1' + }, attributes: { title: 'New post', body: 'A body!!!', subject: 'New post' }, - links: { - self: '/posts/1' - }, relationships: { section: { links: { @@ -285,7 +420,7 @@ def test_serializer_key_format }, data: { type: 'people', - id: '1' + id: '1001' } }, tags: { @@ -305,44 +440,56 @@ def test_serializer_key_format included: [ { type: 'people', - id: '1', + id: '1001', attributes: { name: 'Joe Author', email: 'joe@xyz.fake', - date_joined: '2013-08-07 16:25:00 -0400' + dateJoined: '2013-08-07 16:25:00 -0400' }, links: { - self: '/people/1' + self: '/people/1001' }, relationships: { comments: { links: { - self: '/people/1/relationships/comments', - related: '/people/1/comments' + self: '/people/1001/relationships/comments', + related: '/people/1001/comments' } }, posts: { links: { - self: '/people/1/relationships/posts', - related: '/people/1/posts' - } + self: '/people/1001/relationships/posts', + related: '/people/1001/posts' + }, + data: [ + { + type: 'posts', + id: '1' + } + ] }, preferences: { links: { - self: '/people/1/relationships/preferences', - related: '/people/1/preferences' + self: '/people/1001/relationships/preferences', + related: '/people/1001/preferences' } }, - hair_cut: { + hairCut: { links: { - self: '/people/1/relationships/hairCut', - related: '/people/1/hairCut' + self: '/people/1001/relationships/hairCut', + related: '/people/1001/hairCut' } }, vehicles: { links: { - self: "/people/1/relationships/vehicles", - related: "/people/1/vehicles" + self: '/people/1001/relationships/vehicles', + related: '/people/1001/vehicles' + } + }, + expenseEntries: { + links: { + self: '/people/1001/relationships/expenseEntries', + related: '/people/1001/expenseEntries' } } } @@ -353,312 +500,161 @@ def test_serializer_key_format ) end - def test_serializer_include_sub_objects + def test_serializer_source_array_to_hash_include + post_resources = [PostResource.new(posts(:post_1), {}), PostResource.new(posts(:post_2), {})] + + serializer = JSONAPI::ResourceSerializer.new( + PostResource, + url_helpers: TestApp.routes.url_helpers, + include_directives: JSONAPI::IncludeDirectives.new(PostResource, ['author'])) + + serialized = serializer.serialize_to_hash(post_resources) assert_hash_equals( { - data: { - type: 'posts', - id: '1', - attributes: { - title: 'New post', - body: 'A body!!!', - subject: 'New post' - }, - links: { - self: '/posts/1' - }, - relationships: { - section: { - links: { - self: '/posts/1/relationships/section', - related: '/posts/1/section' - } - }, - author: { - links: { - self: '/posts/1/relationships/author', - related: '/posts/1/author' - } + data: [ + { + type: 'posts', + id: '1', + links: { + self: '/posts/1' }, - tags: { - links: { - self: '/posts/1/relationships/tags', - related: '/posts/1/tags' - } + attributes: { + title: 'New post', + body: 'A body!!!', + subject: 'New post' }, - comments: { - links: { - self: '/posts/1/relationships/comments', - related: '/posts/1/comments' - }, - data: [ - {type: 'comments', id: '1'}, - {type: 'comments', id: '2'} - ] - } - } - }, - included: [ - { - type: 'tags', - id: '1', - attributes: { - name: 'short' - }, - links: { - self: '/tags/1' + relationships: { + section: { + links: { + self: '/posts/1/relationships/section', + related: '/posts/1/section' + } }, - relationships: { - posts: { - links: { - self: '/tags/1/relationships/posts', - related: '/tags/1/posts' - } + author: { + links: { + self: '/posts/1/relationships/author', + related: '/posts/1/author' + }, + data: { + type: 'people', + id: '1001' } - } - }, - { - type: 'tags', - id: '2', - attributes: { - name: 'whiny' }, - links: { - self: '/tags/2' + tags: { + links: { + self: '/posts/1/relationships/tags', + related: '/posts/1/tags' + } }, - relationships: { - posts: { - links: { - self: '/tags/2/relationships/posts', - related: '/tags/2/posts' - } + comments: { + links: { + self: '/posts/1/relationships/comments', + related: '/posts/1/comments' } } - }, + } + }, { - type: 'tags', - id: '4', - attributes: { - name: 'happy' - }, + type: 'posts', + id: '2', links: { - self: '/tags/4' + self: '/posts/2' }, - relationships: { - posts: { - links: { - self: '/tags/4/relationships/posts', - related: '/tags/4/posts' - }, - } - } - }, - { - type: 'comments', - id: '1', attributes: { - body: 'what a dumb post' - }, - links: { - self: '/comments/1' + title: 'JR Solves your serialization woes!', + body: 'Use JR', + subject: 'JR Solves your serialization woes!' }, relationships: { - author: { + section: { links: { - self: '/comments/1/relationships/author', - related: '/comments/1/author' + self: '/posts/2/relationships/section', + related: '/posts/2/section' } }, - post: { + author: { links: { - self: '/comments/1/relationships/post', - related: '/comments/1/post' + self: '/posts/2/relationships/author', + related: '/posts/2/author' + }, + data: { + type: 'people', + id: '1001' } }, tags: { links: { - self: '/comments/1/relationships/tags', - related: '/comments/1/tags' - }, - data: [ - {type: 'tags', id: '1'}, - {type: 'tags', id: '2'} - ] - } - } - }, - { - type: 'comments', - id: '2', - attributes: { - body: 'i liked it' - }, - links: { - self: '/comments/2' - }, - relationships: { - author: { - links: { - self: '/comments/2/relationships/author', - related: '/comments/2/author' + self: '/posts/2/relationships/tags', + related: '/posts/2/tags' } }, - post: { + comments: { links: { - self: '/comments/2/relationships/post', - related: '/comments/2/post' + self: '/posts/2/relationships/comments', + related: '/posts/2/comments' } - }, - tags: { - links: { - self: '/comments/2/relationships/tags', - related: '/comments/2/tags' - }, - data: [ - {type: 'tags', id: '1'}, - {type: 'tags', id: '4'} - ] } } } - ] - }, - JSONAPI::ResourceSerializer.new(PostResource, - include: ['comments', 'comments.tags']).serialize_to_hash(PostResource.new(@post, nil)) - ) - end - - def test_serializer_keeps_sorted_order_of_objects_with_self_referential_relationships - 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] - - assert_equal(3, serialized_data.length) - assert_equal("1", serialized_data[0]["id"]) - assert_equal("2", serialized_data[1]["id"]) - assert_equal("3", serialized_data[2]["id"]) - end - - - def test_serializer_different_foreign_key - serialized = JSONAPI::ResourceSerializer.new( - PersonResource, - include: ['comments'] - ).serialize_to_hash(PersonResource.new(@fred, nil)) - - assert_hash_equals( - { - data: { - type: 'people', - id: '2', - attributes: { - name: 'Fred Reader', - email: 'fred@xyz.fake', - dateJoined: '2013-10-31 16:25:00 -0400' - }, - links: { - self: '/people/2' - }, - relationships: { - posts: { - links: { - self: '/people/2/relationships/posts', - related: '/people/2/posts' - } - }, - comments: { - links: { - self: '/people/2/relationships/comments', - related: '/people/2/comments' - }, - data: [ - {type: 'comments', id: '2'}, - {type: 'comments', id: '3'} - ] - }, - preferences: { - links: { - self: "/people/2/relationships/preferences", - related: "/people/2/preferences" - } - }, - hairCut: { - links: { - self: "/people/2/relationships/hairCut", - related: "/people/2/hairCut" - } - }, - vehicles: { - links: { - self: "/people/2/relationships/vehicles", - related: "/people/2/vehicles" - } - }, - } - }, + ], included: [ { - type: 'comments', - id: '2', + type: 'people', + id: '1001', attributes: { - body: 'i liked it' + name: 'Joe Author', + email: 'joe@xyz.fake', + dateJoined: '2013-08-07 16:25:00 -0400' }, links: { - self: '/comments/2' + self: '/people/1001' }, relationships: { - author: { + comments: { links: { - self: '/comments/2/relationships/author', - related: '/comments/2/author' + self: '/people/1001/relationships/comments', + related: '/people/1001/comments' } }, - post: { + posts: { links: { - self: '/comments/2/relationships/post', - related: '/comments/2/post' - } + self: '/people/1001/relationships/posts', + related: '/people/1001/posts' + }, + data: [ + { + type: 'posts', + id: '1' + }, + { + type: 'posts', + id: '2' + } + ] }, - tags: { + preferences: { links: { - self: '/comments/2/relationships/tags', - related: '/comments/2/tags' + self: '/people/1001/relationships/preferences', + related: '/people/1001/preferences' } - } - } - }, - { - type: 'comments', - id: '3', - attributes: { - body: 'Thanks man. Great post. But what is JR?' - }, - links: { - self: '/comments/3' - }, - relationships: { - author: { + }, + hairCut: { links: { - self: '/comments/3/relationships/author', - related: '/comments/3/author' + self: '/people/1001/relationships/hairCut', + related: '/people/1001/hairCut' } }, - post: { + vehicles: { links: { - self: '/comments/3/relationships/post', - related: '/comments/3/post' + self: '/people/1001/relationships/vehicles', + related: '/people/1001/vehicles' } }, - tags: { + expenseEntries: { links: { - self: '/comments/3/relationships/tags', - related: '/comments/3/tags' + self: '/people/1001/relationships/expenseEntries', + related: '/people/1001/expenseEntries' } } } @@ -669,30 +665,180 @@ def test_serializer_different_foreign_key ) end - def test_serializer_array_of_resources_always_include_to_one_linkage_data + def test_serializer_key_format + post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) + id_tree = JSONAPI::PrimaryResourceTree.new + + directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']) + + id_tree.add_resource_fragment(JSONAPI::ResourceFragment.new(post_1_identity), directives[:include_related]) + id_tree.complete_includes!(directives[:include_related], {}) + + resource_set = JSONAPI::ResourceSet.new(id_tree) + + serializer = JSONAPI::ResourceSerializer.new(PostResource, + key_formatter: UnderscoredKeyFormatter, + url_helpers: TestApp.routes.url_helpers) + + resource_set.populate!(serializer, {}, {}) + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) + + assert_hash_equals( + { + data: { + type: 'posts', + id: '1', + links: { + self: '/posts/1' + }, + attributes: { + title: 'New post', + body: 'A body!!!', + subject: 'New post' + }, + relationships: { + section: { + links: { + self: '/posts/1/relationships/section', + related: '/posts/1/section' + } + }, + author: { + links: { + self: '/posts/1/relationships/author', + related: '/posts/1/author' + }, + data: { + type: 'people', + id: '1001' + } + }, + tags: { + links: { + self: '/posts/1/relationships/tags', + related: '/posts/1/tags' + } + }, + comments: { + links: { + self: '/posts/1/relationships/comments', + related: '/posts/1/comments' + } + } + } + }, + included: [ + { + type: 'people', + id: '1001', + attributes: { + name: 'Joe Author', + email: 'joe@xyz.fake', + date_joined: '2013-08-07 16:25:00 -0400' + }, + links: { + self: '/people/1001' + }, + relationships: { + comments: { + links: { + self: '/people/1001/relationships/comments', + related: '/people/1001/comments' + } + }, + posts: { + links: { + self: '/people/1001/relationships/posts', + related: '/people/1001/posts' + }, + data: [ + { + type: 'posts', + id: '1' + } + ] + }, + preferences: { + links: { + self: '/people/1001/relationships/preferences', + related: '/people/1001/preferences' + } + }, + hair_cut: { + links: { + self: '/people/1001/relationships/hairCut', + related: '/people/1001/hairCut' + } + }, + vehicles: { + links: { + self: '/people/1001/relationships/vehicles', + related: '/people/1001/vehicles' + } + }, + expense_entries: { + links: { + self: '/people/1001/relationships/expenseEntries', + related: '/people/1001/expenseEntries' + } + } + } + } + ] + }, + serialized + ) + end + + def test_serializers_linkage_even_without_included_resource + + post_1_identity = JSONAPI::ResourceIdentity.new(PostResource, 1) + person_1001_identity = JSONAPI::ResourceIdentity.new(PersonResource, 1001) + + id_tree = JSONAPI::PrimaryResourceTree.new + + directives = JSONAPI::IncludeDirectives.new(PersonResource, []) - posts = [] - Post.find(1, 2).each do |post| - posts.push PostResource.new(post, nil) - end + fragment = JSONAPI::ResourceFragment.new(post_1_identity) - JSONAPI.configuration.always_include_to_one_linkage_data = true + fragment.add_related_identity(:author, person_1001_identity) + fragment.initialize_related(:section) + fragment.initialize_related(:tags) + + id_tree.add_resource_fragment(fragment, directives[:include_related]) + resource_set = JSONAPI::ResourceSet.new(id_tree) + + serializer = JSONAPI::ResourceSerializer.new(PostResource, + url_helpers: TestApp.routes.url_helpers) + + resource_set.populate!(serializer, {}, {}) + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) assert_hash_equals( { - data: [ + data: { - type: 'posts', id: '1', + type: 'posts', + links: { + self: '/posts/1' + }, attributes: { title: 'New post', body: 'A body!!!', subject: 'New post' }, - links: { - self: '/posts/1' - }, relationships: { + author: { + links: { + self: '/posts/1/relationships/author', + related: '/posts/1/author' + }, + data: { + type: 'people', + id: '1001' + } + }, section: { links: { self: '/posts/1/relationships/section', @@ -700,1700 +846,141 @@ def test_serializer_array_of_resources_always_include_to_one_linkage_data }, data: nil }, - author: { - links: { - self: '/posts/1/relationships/author', - related: '/posts/1/author' - }, - data: { - type: 'people', - id: '1' - } - }, tags: { links: { self: '/posts/1/relationships/tags', related: '/posts/1/tags' - } + }, + data: [] }, comments: { links: { self: '/posts/1/relationships/comments', related: '/posts/1/comments' - }, - data: [ - {type: 'comments', id: '1'}, - {type: 'comments', id: '2'} - ] - } - } - }, - { - type: 'posts', - id: '2', - attributes: { - title: 'JR Solves your serialization woes!', - body: 'Use JR', - subject: 'JR Solves your serialization woes!' - }, - links: { - self: '/posts/2' - }, - relationships: { - section: { - links: { - self: '/posts/2/relationships/section', - related: '/posts/2/section' - }, - data: { - type: 'sections', - id: '2' - } - }, - author: { - links: { - self: '/posts/2/relationships/author', - related: '/posts/2/author' - }, - data: { - type: 'people', - id: '1' - } - }, - tags: { - links: { - self: '/posts/2/relationships/tags', - related: '/posts/2/tags' } - }, - comments: { - links: { - self: '/posts/2/relationships/comments', - related: '/posts/2/comments' - }, - data: [ - {type: 'comments', id: '3'} - ] } } } - ], - included: [ - { - type: 'tags', - id: '1', - attributes: { - name: 'short' - }, - links: { - self: '/tags/1' - }, - relationships: { - posts: { - links: { - self: '/tags/1/relationships/posts', - related: '/tags/1/posts' - } - } - } + }, + serialized + ) + end + + def test_serializer_include_from_resource + serializer = JSONAPI::ResourceSerializer.new(PostResource, url_helpers: TestApp.routes.url_helpers) + + directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']) + + resource_set = JSONAPI::ResourceSet.new(PostResource.find_by_key(1), directives[:include_related], {}) + resource_set.populate!(serializer, {}, {}) + + serialized = serializer.serialize_resource_set_to_hash_single(resource_set) + + assert_hash_equals( + { + data: { + type: 'posts', + id: '1', + links: { + self: '/posts/1' }, - { - type: 'tags', - id: '2', - attributes: { - name: 'whiny' - }, - links: { - self: '/tags/2' - }, - relationships: { - posts: { - links: { - self: '/tags/2/relationships/posts', - related: '/tags/2/posts' - } - } - } + attributes: { + title: 'New post', + body: 'A body!!!', + subject: 'New post' }, - { - type: 'tags', - id: '4', - attributes: { - name: 'happy' - }, - links: { - self: '/tags/4' + relationships: { + section: { + links: { + self: '/posts/1/relationships/section', + related: '/posts/1/section' + } }, - relationships: { - posts: { - links: { - self: '/tags/4/relationships/posts', - related: '/tags/4/posts' - } + author: { + links: { + self: '/posts/1/relationships/author', + related: '/posts/1/author' + }, + data: { + type: 'people', + id: '1001' } - } - }, - { - type: 'tags', - id: '5', - attributes: { - name: 'JR' }, - links: { - self: '/tags/5' + tags: { + links: { + self: '/posts/1/relationships/tags', + related: '/posts/1/tags' + } }, - relationships: { - posts: { - links: { - self: '/tags/5/relationships/posts', - related: '/tags/5/posts' - } + comments: { + links: { + self: '/posts/1/relationships/comments', + related: '/posts/1/comments' } } - }, + } + }, + included: [ { - type: 'comments', - id: '1', + type: 'people', + id: '1001', attributes: { - body: 'what a dumb post' + name: 'Joe Author', + email: 'joe@xyz.fake', + dateJoined: '2013-08-07 16:25:00 -0400' }, links: { - self: '/comments/1' + self: '/people/1001' }, relationships: { - author: { - links: { - self: '/comments/1/relationships/author', - related: '/comments/1/author' - }, - data: { - type: 'people', - id: '1' - } - }, - post: { + comments: { links: { - self: '/comments/1/relationships/post', - related: '/comments/1/post' - }, - data: { - type: 'posts', - id: '1' + self: '/people/1001/relationships/comments', + related: '/people/1001/comments' } }, - tags: { + posts: { links: { - self: '/comments/1/relationships/tags', - related: '/comments/1/tags' + self: '/people/1001/relationships/posts', + related: '/people/1001/posts' }, data: [ - {type: 'tags', id: '1'}, - {type: 'tags', id: '2'} + { + type: 'posts', + id: '1' + } ] - } - } - }, - { - type: 'comments', - id: '2', - attributes: { - body: 'i liked it' - }, - links: { - self: '/comments/2' - }, - relationships: { - author: { + }, + preferences: { links: { - self: '/comments/2/relationships/author', - related: '/comments/2/author' - }, - data: { - type: 'people', - id: '2' + self: '/people/1001/relationships/preferences', + related: '/people/1001/preferences' } }, - post: { + hairCut: { links: { - self: '/comments/2/relationships/post', - related: '/comments/2/post' - }, - data: { - type: 'posts', - id: '1' + self: '/people/1001/relationships/hairCut', + related: '/people/1001/hairCut' } }, - tags: { - links: { - self: '/comments/2/relationships/tags', - related: '/comments/2/tags' - }, - data: [ - {type: 'tags', id: '4'}, - {type: 'tags', id: '1'} - ] - } - } - }, - { - type: 'comments', - id: '3', - attributes: { - body: 'Thanks man. Great post. But what is JR?' - }, - links: { - self: '/comments/3' - }, - relationships: { - author: { - links: { - self: '/comments/3/relationships/author', - related: '/comments/3/author' - }, - data: { - type: 'people', - id: '2' - } - }, - post: { - links: { - self: '/comments/3/relationships/post', - related: '/comments/3/post' - }, - data: { - type: 'posts', - id: '2' - } - }, - tags: { - links: { - self: '/comments/3/relationships/tags', - related: '/comments/3/tags' - }, - data: [ - {type: 'tags', id: '5'} - ] - } - } - } - ] - }, - JSONAPI::ResourceSerializer.new(PostResource, - include: ['comments', 'comments.tags']).serialize_to_hash(posts) - ) - ensure - JSONAPI.configuration.always_include_to_one_linkage_data = false - end - - def test_serializer_array_of_resources - - posts = [] - Post.find(1, 2).each do |post| - posts.push PostResource.new(post, nil) - end - - assert_hash_equals( - { - data: [ - { - type: 'posts', - id: '1', - attributes: { - title: 'New post', - body: 'A body!!!', - subject: 'New post' - }, - links: { - self: '/posts/1' - }, - relationships: { - section: { - links: { - self: '/posts/1/relationships/section', - related: '/posts/1/section' - } - }, - author: { - links: { - self: '/posts/1/relationships/author', - related: '/posts/1/author' - } - }, - tags: { - links: { - self: '/posts/1/relationships/tags', - related: '/posts/1/tags' - } - }, - comments: { - links: { - self: '/posts/1/relationships/comments', - related: '/posts/1/comments' - }, - data: [ - {type: 'comments', id: '1'}, - {type: 'comments', id: '2'} - ] - } - } - }, - { - type: 'posts', - id: '2', - attributes: { - title: 'JR Solves your serialization woes!', - body: 'Use JR', - subject: 'JR Solves your serialization woes!' - }, - links: { - self: '/posts/2' - }, - relationships: { - section: { - links: { - self: '/posts/2/relationships/section', - related: '/posts/2/section' - } - }, - author: { - links: { - self: '/posts/2/relationships/author', - related: '/posts/2/author' - } - }, - tags: { - links: { - self: '/posts/2/relationships/tags', - related: '/posts/2/tags' - } - }, - comments: { - links: { - self: '/posts/2/relationships/comments', - related: '/posts/2/comments' - }, - data: [ - {type: 'comments', id: '3'} - ] - } - } - } - ], - included: [ - { - type: 'tags', - id: '1', - attributes: { - name: 'short' - }, - links: { - self: '/tags/1' - }, - relationships: { - posts: { - links: { - self: '/tags/1/relationships/posts', - related: '/tags/1/posts' - } - } - } - }, - { - type: 'tags', - id: '2', - attributes: { - name: 'whiny' - }, - links: { - self: '/tags/2' - }, - relationships: { - posts: { - links: { - self: '/tags/2/relationships/posts', - related: '/tags/2/posts' - } - } - } - }, - { - type: 'tags', - id: '4', - attributes: { - name: 'happy' - }, - links: { - self: '/tags/4' - }, - relationships: { - posts: { - links: { - self: '/tags/4/relationships/posts', - related: '/tags/4/posts' - } - } - } - }, - { - type: 'tags', - id: '5', - attributes: { - name: 'JR' - }, - links: { - self: '/tags/5' - }, - relationships: { - posts: { - links: { - self: '/tags/5/relationships/posts', - related: '/tags/5/posts' - } - } - } - }, - { - type: 'comments', - id: '1', - attributes: { - body: 'what a dumb post' - }, - links: { - self: '/comments/1' - }, - relationships: { - author: { - links: { - self: '/comments/1/relationships/author', - related: '/comments/1/author' - } - }, - post: { - links: { - self: '/comments/1/relationships/post', - related: '/comments/1/post' - } - }, - tags: { - links: { - self: '/comments/1/relationships/tags', - related: '/comments/1/tags' - }, - data: [ - {type: 'tags', id: '1'}, - {type: 'tags', id: '2'} - ] - } - } - }, - { - type: 'comments', - id: '2', - attributes: { - body: 'i liked it' - }, - links: { - self: '/comments/2' - }, - relationships: { - author: { - links: { - self: '/comments/2/relationships/author', - related: '/comments/2/author' - } - }, - post: { - links: { - self: '/comments/2/relationships/post', - related: '/comments/2/post' - } - }, - tags: { - links: { - self: '/comments/2/relationships/tags', - related: '/comments/2/tags' - }, - data: [ - {type: 'tags', id: '4'}, - {type: 'tags', id: '1'} - ] - } - } - }, - { - type: 'comments', - id: '3', - attributes: { - body: 'Thanks man. Great post. But what is JR?' - }, - links: { - self: '/comments/3' - }, - relationships: { - author: { - links: { - self: '/comments/3/relationships/author', - related: '/comments/3/author' - } - }, - post: { - links: { - self: '/comments/3/relationships/post', - related: '/comments/3/post' - } - }, - tags: { - links: { - self: '/comments/3/relationships/tags', - related: '/comments/3/tags' - }, - data: [ - {type: 'tags', id: '5'} - ] - } - } - } - ] - }, - JSONAPI::ResourceSerializer.new(PostResource, - include: ['comments', 'comments.tags']).serialize_to_hash(posts) - ) - end - - def test_serializer_array_of_resources_limited_fields - - posts = [] - Post.find(1, 2).each do |post| - posts.push PostResource.new(post, nil) - end - - assert_hash_equals( - { - data: [ - { - type: 'posts', - id: '1', - attributes: { - title: 'New post' - }, - links: { - self: '/posts/1' - } - }, - { - type: 'posts', - id: '2', - attributes: { - title: 'JR Solves your serialization woes!' - }, - links: { - self: '/posts/2' - } - } - ], - included: [ - { - type: 'posts', - id: '11', - attributes: { - title: 'JR How To' - }, - links: { - self: '/posts/11' - } - }, - { - type: 'people', - id: '1', - attributes: { - email: 'joe@xyz.fake' - }, - links: { - self: '/people/1' - }, - relationships: { - comments: { - links: { - self: '/people/1/relationships/comments', - related: '/people/1/comments' - } - } - } - }, - { - id: '1', - type: 'tags', - attributes: { - name: 'short' - }, - links: { - self: '/tags/1' - } - }, - { - id: '2', - type: 'tags', - attributes: { - name: 'whiny' - }, - links: { - self: '/tags/2' - } - }, - { - id: '4', - type: 'tags', - attributes: { - name: 'happy' - }, - links: { - self: '/tags/4' - } - }, - { - id: '5', - type: 'tags', - attributes: { - name: 'JR' - }, - links: { - self: '/tags/5' - } - }, - { - type: 'comments', - id: '1', - attributes: { - body: 'what a dumb post' - }, - links: { - self: '/comments/1' - }, - relationships: { - post: { - links: { - self: '/comments/1/relationships/post', - related: '/comments/1/post' - } - } - } - }, - { - type: 'comments', - id: '2', - attributes: { - body: 'i liked it' - }, - links: { - self: '/comments/2' - }, - relationships: { - post: { - links: { - self: '/comments/2/relationships/post', - related: '/comments/2/post' - } - } - } - }, - { - type: 'comments', - id: '3', - attributes: { - body: 'Thanks man. Great post. But what is JR?' - }, - links: { - self: '/comments/3' - }, - relationships: { - post: { - links: { - self: '/comments/3/relationships/post', - related: '/comments/3/post' - } - } - } - } - ] - }, - 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) - ) - end - - def test_serializer_camelized_with_value_formatters - assert_hash_equals( - { - data: { - type: 'expenseEntries', - id: '1', - attributes: { - transactionDate: '04/15/2014', - cost: '12.05' - }, - links: { - self: '/expenseEntries/1' - }, - relationships: { - isoCurrency: { - links: { - self: '/expenseEntries/1/relationships/isoCurrency', - related: '/expenseEntries/1/isoCurrency' - }, - data: { - type: 'isoCurrencies', - id: 'USD' - } - }, - employee: { - links: { - self: '/expenseEntries/1/relationships/employee', - related: '/expenseEntries/1/employee' - }, - data: { - type: 'people', - id: '3' - } - } - } - }, - included: [ - { - type: 'isoCurrencies', - id: 'USD', - attributes: { - countryName: 'United States', - name: 'United States Dollar', - minorUnit: 'cent' - }, - links: { - self: '/isoCurrencies/USD' - } - }, - { - type: 'people', - id: '3', - attributes: { - email: 'lazy@xyz.fake', - name: 'Lazy Author', - dateJoined: '2013-10-31 17:25:00 -0400' - }, - links: { - self: '/people/3', - } - } - ] - }, - JSONAPI::ResourceSerializer.new(ExpenseEntryResource, - include: ['iso_currency', 'employee'], - fields: {people: [:id, :name, :email, :date_joined]}).serialize_to_hash( - ExpenseEntryResource.new(@expense_entry, nil)) - ) - end - - def test_serializer_empty_links_null_and_array - planet_hash = JSONAPI::ResourceSerializer.new(PlanetResource).serialize_to_hash( - PlanetResource.new(Planet.find(8), nil)) - - assert_hash_equals( - { - data: { - 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' - } - }, - tags: { - links: { - self: '/planets/8/relationships/tags', - related: '/planets/8/tags' - } - }, - moons: { - links: { - self: '/planets/8/relationships/moons', - related: '/planets/8/moons' - } - } - } - } - }, planet_hash) - end - - def test_serializer_include_with_empty_links_null_and_array - planets = [] - Planet.find(7, 8).each do |planet| - 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) - - 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' - } - }, - moons: { - links: { - self: '/planets/8/relationships/moons', - related: '/planets/8/moons' - } - } - } - } - ], - included: [ - { - type: 'planetTypes', - id: '5', - attributes: { - name: 'unknown' - }, - links: { - self: '/planetTypes/5' - } - } - ] - }, planet_hash) - end - - def test_serializer_booleans - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :underscored_key - - preferences = PreferencesResource.new(Preferences.find(1), nil) - - assert_hash_equals( - { - data: { - type: 'preferences', - id: '1', - attributes: { - advanced_mode: false - }, - links: { - self: '/preferences/1' - }, - relationships: { - author: { - links: { - self: '/preferences/1/relationships/author', - related: '/preferences/1/author' - } - } - } - } - }, - JSONAPI::ResourceSerializer.new(PreferencesResource).serialize_to_hash(preferences) - ) - ensure - JSONAPI.configuration = original_config - end - - def test_serializer_data_types - original_config = JSONAPI.configuration.dup - JSONAPI.configuration.json_key_format = :underscored_key - - facts = FactResource.new(Fact.find(1), nil) - - assert_hash_equals( - { - data: { - type: 'facts', - id: '1', - attributes: { - spouse_name: 'Jane Author', - bio: 'First man to run across Antartica.', - quality_rating: 23.89/45.6, - salary: BigDecimal('47000.56', 30).as_json, - date_time_joined: DateTime.parse('2013-08-07 20:25:00 UTC +00:00').in_time_zone('UTC').as_json, - birthday: Date.parse('1965-06-30').as_json, - bedtime: Time.parse('2000-01-01 20:00:00 UTC +00:00').as_json, #DB seems to set the date to 2000-01-01 for time types - photo: "abc", - cool: false - }, - links: { - self: '/facts/1' - } - } - }, - JSONAPI::ResourceSerializer.new(FactResource).serialize_to_hash(facts) - ) - 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)) - - assert_hash_equals( - { - data: { - type: 'authors', - id: '1', - attributes: { - name: 'Joe Author', - }, - links: { - self: '/api/v5/authors/1' - }, - relationships: { - posts: { - links: { - self: '/api/v5/authors/1/relationships/posts', - related: '/api/v5/authors/1/posts' - } - }, - authorDetail: { - links: { - self: '/api/v5/authors/1/relationships/authorDetail', - related: '/api/v5/authors/1/authorDetail' - }, - data: {type: 'authorDetails', id: '1'} - } - } - }, - included: [ - { - type: 'authorDetails', - id: '1', - attributes: { - authorStuff: 'blah blah' - }, - links: { - self: '/api/v5/authorDetails/1' - } - } - ] - }, - serialized - ) - end - - def test_serializer_resource_meta_fixed_value - Api::V5::AuthorResource.class_eval do - def meta(options) - { - fixed: 'Hardcoded value', - computed: "#{self.class._type.to_s}: #{options[:serializer].link_builder.self_link(self)}" - } - end - end - - serialized = JSONAPI::ResourceSerializer.new( - Api::V5::AuthorResource, - include: ['author_detail'] - ).serialize_to_hash(Api::V5::AuthorResource.new(Person.find(1), nil)) - - assert_hash_equals( - { - data: { - type: 'authors', - id: '1', - attributes: { - name: 'Joe Author', - }, - links: { - self: '/api/v5/authors/1' - }, - relationships: { - posts: { - links: { - self: '/api/v5/authors/1/relationships/posts', - related: '/api/v5/authors/1/posts' - } - }, - authorDetail: { - links: { - self: '/api/v5/authors/1/relationships/authorDetail', - related: '/api/v5/authors/1/authorDetail' - }, - data: {type: 'authorDetails', id: '1'} - } - }, - meta: { - fixed: 'Hardcoded value', - computed: 'authors: /api/v5/authors/1' - } - }, - included: [ - { - type: 'authorDetails', - id: '1', - attributes: { - authorStuff: 'blah blah' - }, - links: { - self: '/api/v5/authorDetails/1' - } - } - ] - }, - serialized - ) - ensure - Api::V5::AuthorResource.class_eval do - def meta(options) - # :nocov: - { } - # :nocov: - end - end - end - - def test_serialize_model_attr - @make = Make.first - serialized = JSONAPI::ResourceSerializer.new( - MakeResource, - ).serialize_to_hash(MakeResource.new(@make, nil)) - - assert_hash_equals( - { - "model" => "A model attribute" - }, - serialized[:data]["attributes"] - ) - end - - def test_confusingly_named_attrs - @wp = WebPage.first - serialized = JSONAPI::ResourceSerializer.new( - WebPageResource, - ).serialize_to_hash(WebPageResource.new(@wp, nil)) - - assert_hash_equals( - { - :data=>{ - "id"=>"#{@wp.id}", - "type"=>"webPages", - "links"=>{ - :self=>"/webPages/#{@wp.id}" - }, - "attributes"=>{ - "href"=>"http://example.com", - "link"=>"http://link.example.com" - } - } - }, - serialized - ) - 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" - } - } - } - } - } - - assert_hash_equals(custom_link_spec, serialized_custom_link_resource) - end - - 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)) - - assert_hash_equals( - { - data: { - id: "1", - type: "personWithEvenAndOddPosts", - links: { - self: "/personWithEvenAndOddPosts/1" - }, - relationships: { - evenPosts: { - links: { - self: "/personWithEvenAndOddPosts/1/relationships/evenPosts", - related: "/personWithEvenAndOddPosts/1/evenPosts" - }, - data: [ - { - type: "posts", - id: "2" - } - ] - }, - oddPosts: { - links: { - self: "/personWithEvenAndOddPosts/1/relationships/oddPosts", - related: "/personWithEvenAndOddPosts/1/oddPosts" - }, - data:[ - { - type: "posts", - id: "1" - }, - { - type: "posts", - id: "11" - } - ] - } - } - }, - included:[ - { - id: "2", - type: "posts", - links: { - self: "/posts/2" - }, - attributes: { - title: "JR Solves your serialization woes!", - body: "Use JR", - subject: "JR Solves your serialization woes!" - }, - relationships: { - author: { - links: { - self: "/posts/2/relationships/author", - related: "/posts/2/author" - } - }, - section: { - links: { - self: "/posts/2/relationships/section", - related: "/posts/2/section" - } - }, - tags: { - links: { - self: "/posts/2/relationships/tags", - related: "/posts/2/tags" - } - }, - comments: { - links: { - self: "/posts/2/relationships/comments", - related: "/posts/2/comments" - } - } - } - }, - { - id: "1", - type: "posts", - links: { - self: "/posts/1" - }, - attributes: { - title: "New post", - body: "A body!!!", - subject: "New post" - }, - relationships: { - author: { - links: { - self: "/posts/1/relationships/author", - related: "/posts/1/author" - } - }, - section: { - links: { - self: "/posts/1/relationships/section", - related: "/posts/1/section" - } - }, - tags: { - links: { - self: "/posts/1/relationships/tags", - related: "/posts/1/tags" - } - }, - comments: { - links: { - self: "/posts/1/relationships/comments", - related: "/posts/1/comments" - } - } - } - }, - { - id: "11", - type: "posts", - links: { - self: "/posts/11" - }, - attributes: { - title: "JR How To", - body: "Use JR to write API apps", - subject: "JR How To" - }, - relationships: { - author: { - links: { - self: "/posts/11/relationships/author", - related: "/posts/11/author" - } - }, - section: { - links: { - self: "/posts/11/relationships/section", - related: "/posts/11/section" - } - }, - tags: { + vehicles: { links: { - self: "/posts/11/relationships/tags", - related: "/posts/11/tags" + self: '/people/1001/relationships/vehicles', + related: '/people/1001/vehicles' } }, - comments: { + expenseEntries: { links: { - self: "/posts/11/relationships/comments", - related: "/posts/11/comments" + self: '/people/1001/relationships/expenseEntries', + related: '/people/1001/expenseEntries' } } } } ] }, - serialized_resource - ) - 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] - } - ) - 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] } - ) - - 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] } - ) - - 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] } + serialized ) - - assert_equal serializer_a.config_key(PostResource), serializer_b.config_key(PostResource) end end