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..aeb9b1ae9 --- /dev/null +++ b/.github/workflows/ruby.yml @@ -0,0 +1,79 @@ +name: CI + +on: + push: + branches: [ 'master', 'release-0-8', 'release-0-9', 'release-0-10' ] + pull_request: + branches: ['**'] + +jobs: + tests: + runs-on: ubuntu-latest + services: + postgres: + image: postgres + env: + POSTGRES_PASSWORD: password + POSTGRES_DB: test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + strategy: + fail-fast: false + matrix: + ruby: + - 2.6 + - 2.7 + - '3.0' + - 3.1 + - 3.2 + rails: + - 7.0.4 + - 6.1.7 + - 6.0.6 + - 5.2.8.1 + - 5.1.7 + database_url: + - postgresql://postgres:password@localhost:5432/test + - sqlite3:test_db + exclude: + - ruby: 3.2 + rails: 6.0.6 + - ruby: 3.2 + rails: 5.2.8.1 + - ruby: 3.2 + rails: 5.1.7 + - ruby: 3.1 + rails: 6.0.6 + - ruby: 3.1 + rails: 5.2.8.1 + - ruby: 3.1 + rails: 5.1.7 + - ruby: '3.0' + rails: 6.0.6 + - ruby: '3.0' + rails: 5.2.8.1 + - ruby: '3.0' + rails: 5.1.7 + - ruby: 2.6 + rails: 7.0.4 + - database_url: postgresql://postgres:password@localhost:5432/test + rails: 5.1.7 + 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 }} + - name: Install dependencies + run: bundle install --jobs 4 --retry 3 + - 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 3a2d03dd8..000000000 --- a/.travis.yml +++ /dev/null @@ -1,19 +0,0 @@ -language: ruby -sudo: false -env: - - "RAILS_VERSION=4.2.7" - - "RAILS_VERSION=5.0.0" - - "RAILS_VERSION=master" -rvm: - - 2.1.10 - - 2.2.6 - - 2.3.3 - - 2.4.0 -matrix: - exclude: - - rvm: 2.1.10 - env: "RAILS_VERSION=5.0.0" - - rvm: 2.4.0 - env: "RAILS_VERSION=4.2.7" - allow_failures: - - env: "RAILS_VERSION=master" diff --git a/Gemfile b/Gemfile index c58d1c896..2535d0200 100644 --- a/Gemfile +++ b/Gemfile @@ -2,22 +2,28 @@ 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' + + if version.start_with?('4.2', '5.0') + gem 'sqlite3', '~> 1.3.13' + else + gem 'sqlite3', '~> 1.4' + end +end + case version when 'master' gem 'railties', { git: 'https://github.com/rails/rails.git' } gem 'arel', { git: 'https://github.com/rails/arel.git' } when 'default' - gem 'railties', '>= 5.0' + gem 'railties', '>= 6.0' else gem 'railties', "~> #{version}" -end +end \ No newline at end of file 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 8213112a3..377e49304 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ 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.2+, 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. ## Documentation @@ -28,26 +28,49 @@ which *should* be compatible with JSON:API compliant server implementations such Add JR to your application's `Gemfile`: - gem 'jsonapi-resources' +``` +gem 'jsonapi-resources' +``` And then execute: - $ bundle +```bash +bundle +``` Or install it yourself as: - $ gem install jsonapi-resources +```bash +gem install jsonapi-resources +``` **For further usage see the [v0.10 alpha Guide](http://jsonapi-resources.com/v0.10/guide/)** ## Contributing +1. Submit an issue describing any new features you wish it add or the bug you intend to fix 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 +1. Create your feature branch (`git checkout -b my-new-feature`) +1. Run the full test suite (`rake test`) +1. Fix any failing tests +1. Commit your changes (`git commit -am 'Add some feature'`) +1. Push to the branch (`git push origin my-new-feature`) +1. Create a new Pull Request + +## Did you find a bug? + +* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/cerebris/jsonapi-resources/issues). + +* If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/cerebris/jsonapi-resources/issues/new). +Be sure to include a **title and clear description**, as much relevant information as possible, +and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring. + +* If possible, use the relevant bug report templates to create the issue. +Simply copy the content of the appropriate template into a .rb file, make the necessary changes to demonstrate the issue, +and **paste the content into the issue description or attach as a file**: + * [**Rails 5** issues](https://github.com/cerebris/jsonapi-resources/blob/master/lib/bug_report_templates/rails_5_master.rb) + ## 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..eb3c67fa5 100644 --- a/jsonapi-resources.gemspec +++ b/jsonapi-resources.gemspec @@ -17,16 +17,17 @@ Gem::Specification.new do |spec| 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/jsonapi-resources.rb b/lib/jsonapi-resources.rb index 194de869a..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,7 +27,7 @@ require 'jsonapi/exceptions' require 'jsonapi/error' require 'jsonapi/error_codes' -require 'jsonapi/request_parser' +require 'jsonapi/request' require 'jsonapi/processor' require 'jsonapi/relationship' require 'jsonapi/include_directives' @@ -24,3 +35,11 @@ require 'jsonapi/operation_result' 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..c41d4a7f6 --- /dev/null +++ b/lib/jsonapi/active_relation/join_manager.rb @@ -0,0 +1,315 @@ +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, relationship, join_type, 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 + # Try to find a pre-existing join for this table. + # We can get here if include_optional_linkage_data is true + # (or always_include_to_xxx_linkage_data), + # and the user's custom `records` method has already added that join. + # + # If we want a left join and there is already an inner/left join, + # then we can use that. + # If we want an inner join and there is alrady an inner join, + # then we can use that (but not a left join, since that doesn't filter things out). + valid_join_types = [Arel::Nodes::InnerJoin] + valid_join_types << Arel::Nodes::OuterJoin if join_type == :left + table_name = relationship.resource_klass._table_name + + last_join = join_sources.find { |j| + valid_join_types.any? { |t| j.is_a?(t) } && j.left.name == table_name + } + end + + if last_join.nil? + # :nocov: + warn "get_join_arel_node: No join added" + # :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, relationship, join_type, 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 diff --git a/lib/jsonapi/active_relation_resource.rb b/lib/jsonapi/active_relation_resource.rb new file mode 100644 index 000000000..581ed1e02 --- /dev/null +++ b/lib/jsonapi/active_relation_resource.rb @@ -0,0 +1,894 @@ +# 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] + model_fields = relation_position[:model_fields] + cache_field = relation_position[:cache_field] + cache_offset = relation_position[:cache_offset] + field_offset = relation_position[:field_offset] + + 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 bc0229ddc..e448fa0ea 100644 --- a/lib/jsonapi/acts_as_resource_controller.rb +++ b/lib/jsonapi/acts_as_resource_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'csv' module JSONAPI @@ -10,11 +12,10 @@ def self.included(base) base.include Callbacks base.cattr_reader :server_error_callbacks base.define_jsonapi_resources_callbacks :process_operations, - :transaction, - :rollback + :transaction end - attr_reader :response_document + attr_reader :response_document, :jsonapi_request def index process_request @@ -52,81 +53,93 @@ 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 - @response_document = create_response_document + def get_related_resource + # :nocov: + ActiveSupport::Deprecation.warn "In #{self.class.name} you exposed a `get_related_resource`"\ + " action. Please use `show_related_resource` instead." + show_related_resource + # :nocov: + end - unless verify_content_type_header && verify_accept_header - render_response_document - return - end + def get_related_resources + # :nocov: + ActiveSupport::Deprecation.warn "In #{self.class.name} you exposed a `get_related_resources`"\ + " action. Please use `index_related_resources` instead." + index_related_resources + # :nocov: + end - request_parser = JSONAPI::RequestParser.new( - params, - context: context, - key_formatter: key_formatter, - server_error_callbacks: (self.class.server_error_callbacks || [])) + def process_request + begin + setup_response_document + verify_content_type_header + verify_accept_header + parse_request + execute_request + rescue => e + handle_exceptions(e) + end + render_response_document + end - transactional = request_parser.transactional? + def setup_response_document + @response_document = create_response_document + end - force_rollback = false - run_in_transaction(transactional) do - begin - run_callbacks :process_operations do - begin - request_parser.each(response_document) 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 - ) - op.options[:cache_serializer_output] = !JSONAPI.configuration.resource_cache.nil? - - process_operation(op) - end - rescue => e - handle_exceptions(e) - end - end - rescue => e - force_rollback = true - raise e - ensure - if response_document.has_errors? || force_rollback - rollback_transaction(transactional) + 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 - render_response_document end - def run_in_transaction(transactional) + def process_operations(transactional) if transactional run_callbacks :transaction do - transaction do + ActiveRecord::Base.transaction do yield end end else - yield - end - end - - def rollback_transaction(transactional) - if transactional - run_callbacks :rollback do - rollback + begin + yield + rescue ActiveRecord::Rollback + # Can't rollback without transaction, so just ignore it end end end @@ -136,16 +149,6 @@ def process_operation(operation) response_document.add_result(result, operation) end - def transaction - ActiveRecord::Base.transaction do - yield - end - end - - def rollback - fail ActiveRecord::Rollback - end - private def resource_klass @@ -157,7 +160,7 @@ def resource_serializer_klass 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 @@ -166,24 +169,16 @@ def resource_klass_name def verify_content_type_header if ['create', 'create_relationship', 'update_relationship', 'update'].include?(params[:action]) - unless request.content_type == JSONAPI::MEDIA_TYPE - fail JSONAPI::Exceptions::UnsupportedMediaTypeError.new(request.content_type) + unless request.media_type == JSONAPI::MEDIA_TYPE + fail JSONAPI::Exceptions::UnsupportedMediaTypeError.new(request.media_type) end 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? @@ -194,7 +189,7 @@ def valid_accept_media_type? end end - def media_types_for(header) + def media_types_for(header) (request.headers[header] || '') .scan(MEDIA_TYPE_MATCHER) .to_a @@ -243,10 +238,13 @@ def render_response_document 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) - render_options[:location] = content['data']['links']['self'] if (response_document.status == 201 && 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. @@ -275,8 +273,8 @@ def handle_exceptions(e) when ActionController::ParameterMissing errors = JSONAPI::Exceptions::ParameterMissing.new(e.param).errors else - if JSONAPI.configuration.exception_class_whitelisted?(e) - fail e + if JSONAPI.configuration.exception_class_allowed?(e) + raise e else if self.class.server_error_callbacks self.class.server_error_callbacks.each { |callback| @@ -284,6 +282,9 @@ def handle_exceptions(e) } 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 @@ -307,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..2eeba5c5d --- /dev/null +++ b/lib/jsonapi/basic_resource.rb @@ -0,0 +1,1163 @@ +# frozen_string_literal: true + +require 'jsonapi/callbacks' +require 'jsonapi/configuration' + +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?) + 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}=") + + 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) + 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 + + # @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 67df1d9a5..000000000 --- a/lib/jsonapi/cached_resource_fragment.rb +++ /dev/null @@ -1,127 +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, 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, 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 - - attr_reader :resource_klass, :id, :type, :context, :fetchable_fields, :relationships, - :links_json, :attributes_json, :meta_json, - :preloaded_fragments - - 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) - - # 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 - - def to_real_resource - rs = Resource.resource_for(self.type).find_by_keys([self.id], {context: self.context}) - return rs.try(:first) - end - - private - - def self.lookup(resource_klass, serializer_config_key, context, 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, context, hits[key]) - else - hash[id] = nil - 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 - - def self.write(resource_klass, resource, serializer, serializer_config_key, context, context_key) - (id, cache_key) = resource.cache_id - json = serializer.object_hash(resource) # No inclusions passed to object_hash - cr = self.new( - resource_klass, - json['id'], - json['type'], - context, - resource.fetchable_fields, - json['relationships'], - json['links'], - 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/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..6cd5d8e1b 100644 --- a/lib/jsonapi/configuration.rb +++ b/lib/jsonapi/configuration.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'jsonapi/formatter' require 'jsonapi/processor' require 'concurrent' @@ -8,13 +10,17 @@ class Configuration :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) + ActiveSupport::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) + ActiveSupport::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) + ActiveSupport::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) + ActiveSupport::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 354af7adc..12d65f585 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 @@ -32,18 +34,22 @@ def update_with_overrides(error_object_overrides) @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 diff --git a/lib/jsonapi/error_codes.rb b/lib/jsonapi/error_codes.rb index 35f309bc7..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,7 @@ 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' @@ -50,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 2bc206177..e917118cf 100644 --- a/lib/jsonapi/exceptions.rb +++ b/lib/jsonapi/exceptions.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + module JSONAPI module Exceptions class Error < RuntimeError - attr :error_object_overrides + attr_reader :error_object_overrides def initialize(error_object_overrides = {}) @error_object_overrides = error_object_overrides @@ -49,6 +51,12 @@ def errors meta[:backtrace] = exception.backtrace end + 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', @@ -135,26 +143,6 @@ def errors end end - - class HasManyRelationExists < Error - attr_accessor :id - - def initialize(id, error_object_overrides = {}) - @id = id - super(error_object_overrides) - end - - def errors - [create_error_object(code: JSONAPI::RELATION_EXISTS, - status: :bad_request, - title: I18n.translate('jsonapi-resources.exceptions.has_many_relation.title', - default: 'Relation exists'), - detail: I18n.translate('jsonapi-resources.exceptions.has_many_relation.detail', - default: "The relation to #{id} already exists.", - id: id))] - end - end - class BadRequest < Error def initialize(exception, error_object_overrides = {}) @exception = exception @@ -341,6 +329,26 @@ def errors 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 @@ -356,7 +364,7 @@ def errors 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}", + default: "#{relationship} is not a valid includable relationship of #{resource}", relationship: relationship, resource: resource))] end end @@ -465,11 +473,12 @@ def errors 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, 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) @@ -491,7 +500,7 @@ def json_api_error(attr_key, message) create_error_object(code: JSONAPI::VALIDATION_ERROR, status: :unprocessable_entity, title: message, - detail: "#{format_key(attr_key)} - #{message}", + detail: detail(attr_key, message), source: { pointer: pointer(attr_key) }, meta: metadata_for(attr_key, message)) end @@ -501,7 +510,12 @@ def metadata_for(attr_key, message) 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}" @@ -509,6 +523,10 @@ 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 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 61604496c..d78f414e1 100644 --- a/lib/jsonapi/link_builder.rb +++ b/lib/jsonapi/link_builder.rb @@ -1,109 +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) begin unless scopes.empty? "#{ scopes.first.to_s.camelize }::Engine".safe_constantize end - rescue LoadError => e - nil - 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 - - 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 @@ -112,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 @@ -125,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 78e8f1d4f..d888a1bd8 100644 --- a/lib/jsonapi/mime_types.rb +++ b/lib/jsonapi/mime_types.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'json' module JSONAPI @@ -7,16 +9,10 @@ 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 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 80897fd92..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 @@ -14,7 +16,22 @@ def process 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_result.rb b/lib/jsonapi/operation_result.rb index eed2916d4..63a3d9dc1 100644 --- a/lib/jsonapi/operation_result.rb +++ b/lib/jsonapi/operation_result.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class OperationResult attr_accessor :code @@ -30,34 +32,39 @@ def initialize(code, errors, options = {}) 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 = nil) + def to_hash(serializer) if serializer - serializer.serialize_to_hash(resource) + 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] @@ -66,45 +73,52 @@ def initialize(code, resources, options = {}) def to_hash(serializer) if serializer - serializer.serialize_to_hash(resources) + 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_to_hash(resources) + 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_links_hash(parent_resource, relationship) + serializer.serialize_to_relationship_hash(parent_resource, relationship, resource_ids) else + # :nocov: {} + # :nocov: end end end diff --git a/lib/jsonapi/paginator.rb b/lib/jsonapi/paginator.rb index 53f8fbbe4..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) 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 f7ab30b19..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,21 +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 - end - attr_reader :resource_klass, :operation_type, :params, :context, :result, :result_options def initialize(resource_klass, operation_type, params) @@ -54,103 +41,127 @@ def process @result = JSONAPI::ErrorsOperationResult.new(e.errors[0].code, e.errors) end - def result_options - options = {} - options[:warnings] = params[:warnings] if params[:warnings] - options - end - 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_output] - resource_klass.find_serialized_with_caching(verified_filters, - params[: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 = result_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) + 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_output] - resource_klass.find_by_key_serialized_with_caching(key, - params[:serializer], - find_options) - else - resource_klass.find_by_key(key, find_options) - end + resource_set = find_resource_set(include_directives, options) + + fail JSONAPI::Exceptions::RecordNotFound.new(id) if resource_set.resource_klasses.empty? + resource_set.populate!(serializer, context, options) - return JSONAPI::ResourceOperationResult.new(:ok, resource_record, result_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), - result_options) + 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) - return JSONAPI::ResourceOperationResult.new(:ok, related_resource, result_options) + resource_set.populate!(serializer, context, options) + + JSONAPI::ResourceSetOperationResult.new(:ok, resource_set, result_options) end def show_related_resources @@ -162,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, @@ -174,61 +186,66 @@ def show_related_resources include_directives: include_directives } - if params[:cache_serializer_output] - # 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[: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, { context: context }, - 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 = result_options - 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, result_options) + 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 @@ -237,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, result_options) + 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, result_options) + 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 @@ -258,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, result_options) + JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def replace_polymorphic_to_one_relationship @@ -270,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, result_options) + JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def create_to_many_relationships @@ -281,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, result_options) + JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def replace_to_many_relationships @@ -292,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, result_options) + JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted, result_options) end def remove_to_many_relationships @@ -309,7 +342,7 @@ def remove_to_many_relationships complete = false end end - return JSONAPI::OperationResult.new(complete ? :no_content : :accepted, result_options) + JSONAPI::OperationResult.new(complete ? :no_content : :accepted, result_options) end def remove_to_one_relationship @@ -319,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, result_options) + 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 978d1606e..8824fc65d 100644 --- a/lib/jsonapi/relationship.rb +++ b/lib/jsonapi/relationship.rb @@ -1,8 +1,15 @@ +# frozen_string_literal: true + 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] + ActiveSupport::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,23 +98,37 @@ 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 - end - end - 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 _exclude_links + @_exclude_links ||= [] + end + + def exclude_link?(link) + _exclude_links.include?(link.to_sym) + end + class ToOne < Relationship attr_reader :foreign_key_on @@ -72,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 9c7364d2f..000000000 --- a/lib/jsonapi/relationship_builder.rb +++ /dev/null @@ -1,167 +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] - - 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 && associated_model - else - resource_klass = relationship.resource_klass - if resource_klass - associated_model = public_send(associated_records_method_name) - return associated_model ? resource_klass.new(associated_model, @context) : nil - end - end - end - end - - 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_parser.rb b/lib/jsonapi/request.rb similarity index 60% rename from lib/jsonapi/request_parser.rb rename to lib/jsonapi/request.rb index 3e7388819..5e80ff96a 100644 --- a/lib/jsonapi/request_parser.rb +++ b/lib/jsonapi/request.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + module JSONAPI - class RequestParser + 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 + :include_directives, :params, :warnings, :server_error_callbacks, :operations def initialize(params = nil, options = {}) @params = params @@ -18,36 +20,28 @@ def initialize(params = nil, options = {}) @errors = [] @warnings = [] @server_error_callbacks = options.fetch(:server_error_callbacks, []) + @operations = [] + + setup_operations(params) end def error_object_overrides {} end - def each(response_document) - operation = setup_base_op(params) - if @errors.any? - fail JSONAPI::Exceptions::Errors.new(@errors) - else - yield operation - end - rescue ActionController::ParameterMissing => e - fail JSONAPI::Exceptions::ParameterMissing.new(e.param, error_object_overrides) - end - def transactional? case params[:action] - when 'index', 'get_related_resource', 'get_related_resources', 'show', 'show_relationship' - return false - else - return true + when 'index', 'show_related_resource', 'index_related_resources', 'show', 'show_relationship' + false + else + JSONAPI.configuration.allow_transactions end end - def setup_base_op(params) + def setup_operations(params) return if params.nil? - resource_klass = Resource.resource_for(params[:controller]) if params[:controller] + 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) @@ -68,20 +62,21 @@ def setup_index_action(params, resource_klass) sort_criteria = parse_sort_criteria(resource_klass, params[:sort]) paginator = parse_pagination(resource_klass, params[:page]) - JSONAPI::Operation.new( - :find, - resource_klass, - context: context, - filters: filters, - include_directives: include_directives, - sort_criteria: sort_criteria, - paginator: paginator, - fields: fields + @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_get_related_resource_action(params, resource_klass) - source_klass = Resource.resource_for(params.require(:source)) + 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]) @@ -89,20 +84,21 @@ def setup_get_related_resource_action(params, resource_klass) relationship_type = params[:relationship].to_sym - 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 + @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_get_related_resources_action(params, resource_klass) - source_klass = Resource.resource_for(params.require(:source)) + 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]) @@ -112,47 +108,58 @@ def setup_get_related_resources_action(params, resource_klass) paginator = parse_pagination(resource_klass, params[:page]) relationship_type = params[:relationship] - 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 + @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] - JSONAPI::Operation.new( - :show, - resource_klass, - context: @context, - id: id, - include_directives: include_directives, - fields: fields, - allowed_resources: params[:allowed_resources] + @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]) - JSONAPI::Operation.new( - :show_relationship, - resource_klass, - context: @context, - relationship_type: relationship_type, - parent_key: resource_klass.verify_key(parent_key) + @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 @@ -170,18 +177,19 @@ def setup_create_action(params, resource_klass) data = parse_params(resource_klass, data, resource_klass.creatable_fields(@context)) - JSONAPI::Operation.new( - :create_resource, - resource_klass, - context: @context, - data: data, - fields: fields, - include_directives: include_directives, - warnings: @warnings + @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 @@ -190,6 +198,7 @@ def setup_update_relationship_action(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]) @@ -201,7 +210,7 @@ def setup_update_action(params, resource_klass) fail JSONAPI::Exceptions::MissingKey.new(error_object_overrides) if data[:id].nil? resource_id = data.require(:id) - # Singlton resources may not have the ID set in the URL + # 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 @@ -210,27 +219,29 @@ def setup_update_action(params, resource_klass) verify_type(data[:type], resource_klass) - 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 + @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) - JSONAPI::Operation.new( - :remove_resource, - resource_klass, - context: @context, - resource_id: resource_klass.verify_key(params.require(:id), @context)) + 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 @@ -281,7 +292,6 @@ def parse_fields(resource_klass, fields) fail JSONAPI::Exceptions::InvalidFieldFormat.new(error_object_overrides) end - errors = [] # Validate the fields validated_fields = {} extracted_fields.each do |type, values| @@ -291,15 +301,13 @@ def parse_fields(resource_klass, fields) if type != format_key(type) fail JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides) end - type_resource = Resource.resource_for(resource_klass.module_path + underscored_type.to_s) + type_resource = Resource.resource_klass_for(resource_klass.module_path + underscored_type.to_s) rescue NameError - errors.concat(JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides).errors) - rescue JSONAPI::Exceptions::InvalidResource => e - errors.concat(e.errors) + fail JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides) end if type_resource.nil? - errors.concat(JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides).errors) + fail JSONAPI::Exceptions::InvalidResource.new(type, error_object_overrides) else unless values.nil? valid_fields = type_resource.fields.collect { |key| format_key(key) } @@ -307,17 +315,15 @@ def parse_fields(resource_klass, fields) if valid_fields.include?(field) validated_fields[type].push unformat_key(field) else - errors.concat(JSONAPI::Exceptions::InvalidField.new(type, field, error_object_overrides).errors) + fail JSONAPI::Exceptions::InvalidField.new(type, field, error_object_overrides) end end else - errors.concat(JSONAPI::Exceptions::InvalidField.new(type, 'nil', error_object_overrides).errors) + fail JSONAPI::Exceptions::InvalidField.new(type, 'nil', error_object_overrides) end end end - fail JSONAPI::Exceptions::Errors.new(errors) unless errors.empty? - validated_fields.deep_transform_keys { |key| unformat_key(key) } end @@ -326,38 +332,40 @@ def check_include(resource_klass, include_parts) 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_for(resource_klass.module_path + relationship.class_name.to_s.underscore), + check_include(Resource.resource_klass_for(resource_klass.module_path + relationship.class_name.to_s.underscore), include_parts.last.partition('.')) end else - @errors.concat(JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), - include_parts.first).errors) + fail JSONAPI::Exceptions::InvalidInclude.new(format_key(resource_klass._type), include_parts.first) end end def parse_include_directives(resource_klass, raw_include) - return unless raw_include - - unless JSONAPI.configuration.allow_include - fail JSONAPI::Exceptions::ParameterNotAllowed.new(:include) - end + raw_include ||= '' included_resources = [] begin - included_resources += raw_include.is_a?(Array) ? raw_include : CSV.parse_line(raw_include) + 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 - return if included_resources.nil? + begin + result = included_resources.compact.map do |included_resource| + check_include(resource_klass, included_resource.partition('.')) + unformat_key(included_resource).to_s + end - result = included_resources.map do |included_resource| - check_include(resource_klass, included_resource.partition('.')) - unformat_key(included_resource).to_s + return JSONAPI::IncludeDirectives.new(resource_klass, result) + rescue JSONAPI::Exceptions::InvalidInclude => e + @errors.concat(e.errors) + return {} end - - JSONAPI::IncludeDirectives.new(resource_klass, result) end def parse_filters(resource_klass, filters) @@ -385,7 +393,7 @@ def parse_filters(resource_klass, filters) if resource_klass._allowed_filter?(filter) parsed_filters[filter] = value else - @errors.concat(JSONAPI::Exceptions::FilterNotAllowed.new(filter).errors) + fail JSONAPI::Exceptions::FilterNotAllowed.new(key) end end @@ -403,7 +411,7 @@ def parse_sort_criteria(resource_klass, sort_criteria) sorts = sort_criteria elsif sort_criteria.is_a?(String) begin - raw = URI.unescape(sort_criteria) + 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) @@ -426,11 +434,9 @@ def parse_sort_criteria(resource_klass, sort_criteria) def check_sort_criteria(resource_klass, sort_criteria) sort_field = sort_criteria[:field] - sortable_fields = resource_klass.sortable_fields(context) - unless sortable_fields.include? sort_field.to_sym - @errors.concat(JSONAPI::Exceptions::InvalidSortCriteria - .new(format_key(resource_klass._type), sort_field).errors) + unless resource_klass.sortable_field?(sort_field.to_sym, context) + fail JSONAPI::Exceptions::InvalidSortCriteria.new(format_key(resource_klass._type), sort_field) end end @@ -445,19 +451,19 @@ def verify_type(type, resource_klass) def parse_to_one_links_object(raw) if raw.nil? return { - type: nil, - id: nil + 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')) + 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'] + type: unformat_key(raw['type']).to_s, + id: raw['id'] } end @@ -486,33 +492,33 @@ def parse_params(resource_klass, params, allowed_fields) 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 + 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 - 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 + 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 - return { - 'attributes' => checked_attributes, - 'to_one' => checked_to_one_relationships, - 'to_many' => checked_to_many_relationships + { + 'attributes' => checked_attributes, + 'to_one' => checked_to_one_relationships, + 'to_many' => checked_to_many_relationships }.deep_transform_keys { |key| unformat_key(key) } end @@ -530,7 +536,7 @@ def parse_to_one_relationship(resource_klass, link_value, relationship) unless links_object[:id].nil? resource = resource_klass || Resource - relationship_resource = resource.resource_for(unformat_key(links_object[:type]).to_s) + 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) } @@ -543,9 +549,7 @@ def parse_to_one_relationship(resource_klass, link_value, relationship) end def parse_to_many_relationship(resource_klass, 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)) + 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) @@ -553,20 +557,39 @@ def parse_to_many_relationship(resource_klass, link_value, relationship, &add_re 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], error_object_overrides) - end + 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 - 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) + 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 @@ -579,59 +602,52 @@ def unformat_value(resource_klass, attribute, value) def verify_permitted_params(params, allowed_fields) formatted_allowed_fields = allowed_fields.collect { |field| format_key(field).to_sym } params_not_allowed = [] - param_errors = [] 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 - param_errors.concat JSONAPI::Exceptions::ParameterNotAllowed.new( - links_key, error_object_overrides).errors - 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 - param_errors.concat JSONAPI::Exceptions::ParameterNotAllowed.new( - attr_key, error_object_overrides).errors - else - params_not_allowed.push(attr_key) - value.delete attr_key - end + 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 - when 'type' - when 'id' - unless formatted_allowed_fields.include?(:id) + 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 - param_errors.concat JSONAPI::Exceptions::ParameterNotAllowed.new( - :id, error_object_overrides).errors + fail JSONAPI::Exceptions::ParameterNotAllowed.new(attr_key, error_object_overrides) else - params_not_allowed.push(:id) - params.delete :id + params_not_allowed.push(attr_key) + value.delete attr_key end end - else + end + when 'type' + when 'id' + unless formatted_allowed_fields.include?(:id) if JSONAPI.configuration.raise_if_parameters_not_allowed - param_errors += JSONAPI::Exceptions::ParameterNotAllowed.new( - key, error_object_overrides).errors + fail JSONAPI::Exceptions::ParameterNotAllowed.new(:id, error_object_overrides) else - params_not_allowed.push(key) - params.delete key + 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 param_errors.length > 0 - fail JSONAPI::Exceptions::Errors.new(param_errors) - elsif params_not_allowed.length > 0 + 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', @@ -643,22 +659,22 @@ def verify_permitted_params(params, allowed_fields) def parse_add_relationship_operation(resource_klass, verified_params, relationship, parent_key) if relationship.is_a?(JSONAPI::Relationship::ToMany) - return 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] + @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 + context: @context, + resource_id: parent_key, + relationship_type: relationship.name } if relationship.is_a?(JSONAPI::Relationship::ToOne) @@ -679,23 +695,30 @@ def parse_update_relationship_operation(resource_klass, verified_params, relatio operation_type = :replace_to_many_relationships end - JSONAPI::Operation.new(operation_type, resource_klass, options) + @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 + 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) - JSONAPI::Operation.new(:remove_to_many_relationships, *operation_args) + @operations << JSONAPI::Operation.new(:remove_to_many_relationships, *operation_args) else - JSONAPI::Operation.new(:remove_to_one_relationship, *operation_base_args) + @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 diff --git a/lib/jsonapi/resource.rb b/lib/jsonapi/resource.rb index f2a81a6d4..4d34dd290 100644 --- a/lib/jsonapi/resource.rb +++ b/lib/jsonapi/resource.rb @@ -1,1259 +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}=", self.class.model_name_for_type(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 - - class << self - def inherited(subclass) - subclass.abstract(false) - subclass.immutable(false) - subclass.caching(false) - subclass._attributes = (_attributes || {}).dup - - subclass._model_hints = (_model_hints || {}).dup - - unless _model_name.empty? - subclass.model_name(_model_name, add_model_hint: (_model_hints && !_model_hints[_model_name].nil?) == true) - end - - subclass.rebuild_relationships(_relationships || {}) - - subclass._allowed_filters = (_allowed_filters || Set.new).dup - - type = subclass.name.demodulize.sub(/Resource$/, '').underscore - subclass._type = type.pluralize.to_sym - - unless subclass._attributes[:id] - subclass.attribute :id, format: :id, readonly: true - end - - check_reserved_resource_name(subclass._type, subclass.name) - end - - def rebuild_relationships(relationships) - original_relationships = relationships.deep_dup - - @_relationships = {} - - if original_relationships.is_a?(Hash) - original_relationships.each_value do |relationship| - options = relationship.options.dup - options[:parent_resource] = self - _add_relationship(relationship.class, relationship.name, options) - end - end - end - - def resource_for(type) - type = type.underscore - type_with_module = type.include?('/') ? type : module_path + type - - 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 - - def model_name_for_type(key_type) - type_class_name = key_type.to_s.classify - resource = resource_for(type_class_name) - resource ? resource._model_name.to_s : type_class_name - 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 - - rebuild_relationships(_relationships) - 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 | _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) - _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? - 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 - 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_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) - type = type.to_sym - @_relationships[type] - end - - def _model_name - if _abstract - return '' - 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$/, '') - return @_model_name.to_s - 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_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 a base Resource declare it as abstract." - end - - @model_class - 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[:context]).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 - - 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 - non_polymorphic = true - path.each do |elem| - relationship = klass._relationships[elem] - if relationship.polymorphic - # Can't preload through a polymorphic belongs_to association, ResourceSerializer - # will just have to bypass the cache and load the real Resource. - non_polymorphic = false - break - end - assocs_path << relationship.relation_name(options).to_sym - # Converts [:a, :b, :c] to Rails-style { :a => { :b => :c }} - ar_hash = assocs_path.reverse.reduce{|memo, step| { step => memo } } - # 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 - next unless non_polymorphic - - # Pre-fill empty hashes for each resource up to the end of the path. - # This allows us to later distinguish between a preload that returned nothing - # 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}, context: options[:context]) - 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 +end \ No newline at end of file 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 e52e8c4c2..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, :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,11 +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 @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) @@ -43,76 +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_source_objects(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 = [] - # pull the processed objects corresponding to the source objects. Ensures we preserve order. - if is_resource_collection - source.each do |primary| - if primary.id - case primary - when CachedResourceFragment then primary_objects.push(@included_objects[primary.type][primary.id][:object_hash]) - when Resource then primary_objects.push(@included_objects[primary.class._type][primary.id][:object_hash]) - else raise "Unknown source type #{primary.inspect}" - end - end - end - else - if source.try(:id) - case source - when CachedResourceFragment then primary_objects.push(@included_objects[source.type][source.id][:object_hash]) - when Resource then primary_objects.push(@included_objects[source.class._type][source.id][:object_hash]) - else raise "Unknown source type #{source.inspect}" + 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 = [] - @included_objects.each_value do |objects| - objects.each_value do |object| - unless object[:primary] - included_objects.push(object[:object_hash]) + + 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 - 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 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 } - def query_link(query_params) - link_builder.query_link(query_params) + links = default_relationship_links(source, requested_relationship) + rel_hash['links'] = links unless links.blank? + + 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 @@ -128,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. @@ -171,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? @@ -183,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_source_objects(source, include_directives) - if source.respond_to?(:to_ary) - source.each { |resource| process_source_objects(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 @@ -215,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 @@ -237,7 +246,7 @@ def attributes_hash(source, fetchable_fields) end def custom_generation_options - { + @_custom_generation_options ||= { serializer: self, serialization_options: @serialization_options } @@ -250,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 @@ -259,116 +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 do |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 - real_res = nil - 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) - fragments = source.preloaded_fragments[key] - if fragments.nil? - # The resources we want were not preloaded, we'll have to bypass the cache. - # This happens when including through belongs_to polymorphic relationships - if real_res.nil? - real_res = source.to_real_resource + 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 - relation_resources = [real_res.public_send(rel_name)].flatten(1).compact - fragments = relation_resources.map{|r| [r.id, r]}.to_h - end - fragments.each do |id, f| - add_resource(f, ia) - - if h.has_key?(key) - # 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 + 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) @@ -379,123 +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 + def to_one_linkage(rid) + return unless rid + + { + 'type' => format_key(rid.resource_klass._type), + 'id' => @id_formatter.format(rid.id), + } + end + + 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_one_linkage(source, relationship) if include_linkage + + links = default_relationship_links(source, relationship) + + link_object_hash['links'] = links unless links.blank? + link_object_hash['data'] = to_one_linkage(rid) if include_data link_object_hash end - def link_object_to_many(source, relationship, include_linkage) - include_linkage = include_linkage | relationship.always_include_linkage_data + def relationship_object_to_many(source, relationship, rids, 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 + + 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 link_object(source, relationship, include_linkage = false) + def relationship_object(source, relationship, rid, include_data) if relationship.is_a?(JSONAPI::Relationship::ToOne) - link_object_to_one(source, relationship, include_linkage) + relationship_object_to_one(source, relationship, rid, include_data) elsif relationship.is_a?(JSONAPI::Relationship::ToMany) - link_object_to_many(source, relationship, include_linkage) - end - end - - # Extracts the foreign key value for a to_one relationship. - def foreign_key_value(source, relationship) - related_resource_id = if source.preloaded_fragments.has_key?(format_key(relationship.name)) - source.preloaded_fragments[format_key(relationship.name)].values.first.try(:id) - elsif source.respond_to?("#{relationship.name}_id") - # If you have direct access to the underlying id, you don't have to load the relationship - # which can save quite a lot of time when loading a lot of data. - # This does not apply to e.g. has_one :through relationships. - source.public_send("#{relationship.name}_id") - else - source.public_send(relationship.name).try(:id) - end - return nil unless related_resource_id - @id_formatter.format(related_resource_id) - 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 + 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..fb4178797 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.11.0.beta1' end end diff --git a/lib/jsonapi/response_document.rb b/lib/jsonapi/response_document.rb index 3f4833bce..3558e5e0a 100644 --- a/lib/jsonapi/response_document.rb +++ b/lib/jsonapi/response_document.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module JSONAPI class ResponseDocument attr_reader :serialized_results @@ -17,7 +19,7 @@ def initialize(options = {}) end def has_errors? - @error_results.length > 0 || @global_errors.length > 0 + @error_results.length.positive? || @global_errors.length.positive? end def add_result(result, operation) @@ -71,6 +73,8 @@ def status # 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| @@ -78,13 +82,9 @@ def status max_status = code if max_status < code end return (max_status / 100).floor * 100 + # :nocov: end - # - # def status_sym - # Rack::Utils::HTTP_STATUS_CODES[status].downcase.gsub(/\s|-|'/, '_').to_sym - # end - private def update_meta(result) @@ -113,14 +113,22 @@ def update_links(serializer, result) @top_level_links.merge!(result.links) # Build pagination links - if result.is_a?(JSONAPI::ResourcesOperationResult) || result.is_a?(JSONAPI::RelatedResourcesOperationResult) + 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::RelatedResourcesOperationResult) + if result.is_a?(JSONAPI::RelatedResourcesSetOperationResult) relationship = result.source_resource.class._relationships[result._type.to_sym] - @top_level_links[link_name] = serializer.link_builder.relationships_related_link(result.source_resource, relationship, query_params(params)) + unless relationship.exclude_link?(link_name) + link = serializer.link_builder.relationships_related_link(result.source_resource, relationship, query_params(params)) + end else - @top_level_links[link_name] = serializer.query_link(query_params(params)) + 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 diff --git a/lib/jsonapi/routing_ext.rb b/lib/jsonapi/routing_ext.rb index facbc1c6f..b0b940138 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 @@ -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 @@ -147,14 +161,15 @@ 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? @@ -175,14 +190,15 @@ 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? @@ -204,41 +220,47 @@ def jsonapi_links(*links) 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] + 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] + action: 'index_related_resources', via: [:get], + as: "related/#{relationship_name}" end protected @@ -250,8 +272,8 @@ def jsonapi_resource_scope(resource, resource_type) #:nodoc: ensure @scope = @scope.parent end - # :nocov: + private def resource_type_with_module_prefix(resource = nil) 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 d1dfccc01..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,9 +46,12 @@ 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 include' - detail: "%{relationship} is not a valid relationship of %{resource}" + 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}" 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 82abc5863..e2568f979 100644 --- a/test/controllers/controller_test.rb +++ b/test/controllers/controller_test.rb @@ -6,7 +6,16 @@ def set_content_type_header! 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_whitelist = original_whitelist + 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.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 @@ -424,8 +494,15 @@ def test_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'][0]['id'], 'nil is at the top' - assert_equal post.id.to_s, json_response['data'][1]['id'], 'alphabetically first user is second' + + # 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,15 @@ 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 @@ -444,8 +528,14 @@ def test_sorting_by_relationship_field_include assert_response :success assert json_response['data'].length > 10, 'there are enough records to show sort' - assert_equal '17', json_response['data'][0]['id'], 'nil is at the top' - assert_equal post.id.to_s, json_response['data'][1]['id'], 'alphabetically first user is second' + + 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 @@ -470,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) @@ -497,7 +587,25 @@ def test_show_does_not_include_pages_count_in_meta 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) @@ -509,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 @@ -565,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'}} } } } @@ -589,7 +735,7 @@ 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'}} } } } @@ -621,6 +767,26 @@ def test_create_link_to_missing_object 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 set_content_type_header! post :create, params: @@ -633,7 +799,7 @@ 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'}} } } } @@ -658,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' @@ -666,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'] @@ -722,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'}} } }, { @@ -732,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'}} } } ] @@ -753,7 +919,7 @@ 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'}} } } } @@ -774,7 +940,7 @@ 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'}} } } } @@ -794,7 +960,7 @@ 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'}} } } } @@ -815,7 +981,7 @@ 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'}} } } } @@ -839,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' @@ -847,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'] @@ -873,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' @@ -882,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 @@ -899,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' @@ -908,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 @@ -925,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', @@ -935,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 @@ -956,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' @@ -964,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 @@ -1012,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' @@ -1020,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']) @@ -1051,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' @@ -1077,7 +1243,7 @@ def test_update_remove_links }, relationships: { section: nil, - tags: [] + tags: {data: []} } }, include: 'tags,author,section' @@ -1085,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 '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 @@ -1137,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 @@ -1277,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: []} @@ -1285,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 @@ -1333,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 @@ -1353,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 @@ -1361,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 @@ -1381,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 @@ -1396,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 @@ -1437,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 @@ -1465,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 @@ -1511,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: [] } @@ -1552,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}] } } } @@ -1577,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}] } } } @@ -1602,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}] } } } @@ -1657,7 +1844,7 @@ def test_update_missing_param }, 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 }] } } } } @@ -1699,7 +1886,7 @@ def test_update_missing_type }, 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 }] } } } } @@ -1724,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}] } } } @@ -1747,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' @@ -1773,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}]} } } ], @@ -1796,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 @@ -1818,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}] } } } @@ -1827,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 @@ -1856,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', @@ -1871,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', @@ -1898,42 +2094,73 @@ 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: '6,7,8,9'}, + assert_cacheable_get :index, params: {filter: {id: '506,507,508,509'}, include: 'posts.tags,posts.author.posts', sort: 'name'} assert_response :success @@ -1946,14 +2173,57 @@ class PicturesControllerTest < ActionController::TestCase def test_pictures_index assert_cacheable_get :index assert_response :success - assert_equal 3, json_response['data'].size + 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 3, json_response['data'].size - assert_equal 2, json_response['included'].size + 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 @@ -1961,14 +2231,14 @@ class DocumentsControllerTest < ActionController::TestCase def test_documents_index assert_cacheable_get :index assert_response :success - assert_equal 1, json_response['data'].size + 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 1, json_response['data'].size - assert_equal 1, json_response['included'].size + assert_equal 5, json_response['data'].size + assert_equal 6, json_response['included'].size end end @@ -2009,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 @@ -2050,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'}} } }, @@ -2060,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'] @@ -2084,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'}} } }, @@ -2094,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'] @@ -2118,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'}} } }, @@ -2128,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'] @@ -2319,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' => { @@ -2362,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: '' @@ -2380,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 @@ -2390,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" + } } } } @@ -2457,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, { @@ -2470,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'] @@ -2481,19 +2791,19 @@ 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 @@ -2508,21 +2818,66 @@ def test_index_with_caching_enabled_uses_context 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_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_nil json_response['data']['attributes']['email'] @@ -2532,7 +2887,7 @@ 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 @@ -2550,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'] @@ -2585,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'] @@ -2609,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'] @@ -2707,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", @@ -2735,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 @@ -2766,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'}} } } } @@ -2789,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'] @@ -2841,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 @@ -2881,6 +3240,18 @@ def test_books_page_count_in_meta assert_equal 'Book 0', json_response['data'][0]['attributes']['title'] end + 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 @@ -2914,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 @@ -2925,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 @@ -3053,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 @@ -3127,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 @@ -3142,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 @@ -3157,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 @@ -3171,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'} @@ -3183,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 @@ -3198,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}]} @@ -3212,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}]} @@ -3223,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}]} @@ -3234,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}]} @@ -3246,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}]} @@ -3256,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 @@ -3275,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 @@ -3284,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 @@ -3293,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 @@ -3310,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 @@ -3378,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: { @@ -3394,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 @@ -3417,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: [ @@ -3440,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: [ @@ -3451,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) @@ -3499,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! @@ -3564,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 @@ -3597,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 @@ -3608,19 +4090,62 @@ def test_caching_with_join_to_resource_with_sql_fragment class AuthorsControllerTest < ActionController::TestCase def test_show_author_recursive - get :show, params: {id: '2', include: 'books.authors'} + assert_cacheable_get :show, params: {id: '1002', include: 'books.authors'} assert_response :success - assert_equal '2', json_response['data']['id'] + 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 '1', json_response['included'][0]['id'] + 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 @@ -3629,6 +4154,11 @@ def test_complex_includes_base assert_response :success end + def test_complex_includes_filters_nil_includes + assert_cacheable_get :index, params: {include: ',,'} + assert_response :success + end + def test_complex_includes_two_level assert_cacheable_get :index, params: {include: 'things,things.user'} @@ -3636,58 +4166,663 @@ def test_complex_includes_two_level # 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'] + 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 - # 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'] +class BlogPostsControllerTest < ActionController::TestCase + def test_filter_by_delegated_attribute + assert_cacheable_get :index, params: {filter: {name: 'some title'}} + 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_sorting_by_delegated_attribute + assert_cacheable_get :index, params: {sort: 'name'} + assert_response :success + end - 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'] + def test_fields_with_delegated_attribute + original_config = JSONAPI.configuration.dup + JSONAPI.configuration.json_key_format = :underscored_key + + 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 8d0fe7f46..f89593178 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,6 +48,7 @@ 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| @@ -120,6 +147,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 +241,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| @@ -293,6 +332,7 @@ create_table :questions, force: true do |t| t.string :text + t.timestamps null: false end create_table :answers, force: true do |t| @@ -300,23 +340,139 @@ 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 @@ -324,10 +480,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 @@ -342,10 +503,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 } @@ -353,16 +514,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 @@ -387,6 +545,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 @@ -394,6 +554,7 @@ class Section < ActiveRecord::Base end class HairCut < ActiveRecord::Base + has_many :people end class Property < ActiveRecord::Base @@ -410,7 +571,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 @@ -430,13 +591,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 @@ -469,6 +624,7 @@ class Like < ActiveRecord::Base end class Breed + include ActiveModel::Model def initialize(id = nil, name = nil) if id.nil? @@ -487,19 +643,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 @@ -507,6 +651,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 @@ -514,7 +662,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) @@ -574,7 +722,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 @@ -589,13 +743,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 @@ -621,8 +781,8 @@ 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 @@ -654,8 +814,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 @@ -671,7 +897,7 @@ 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 @@ -736,6 +962,9 @@ class ProductsController < JSONAPI::ResourceController class ImageablesController < JSONAPI::ResourceController end +class FilePropertiesController < JSONAPI::ResourceController +end + class VehiclesController < JSONAPI::ResourceController end @@ -797,6 +1026,9 @@ class LikesController < JSONAPI::ResourceController module V2 class AuthorsController < JSONAPI::ResourceController + def context + {current_user: $test_user} + end end class PeopleController < JSONAPI::ResourceController @@ -806,6 +1038,9 @@ class PostsController < JSONAPI::ResourceController end class PreferencesController < JSONAPI::ResourceController + def context + {current_user: $test_user} + end end class BooksController < JSONAPI::ResourceController @@ -824,6 +1059,8 @@ def context module V3 class PostsController < JSONAPI::ResourceController end + class MoonsController < JSONAPI::ResourceController + end end module V4 @@ -855,9 +1092,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 @@ -904,6 +1150,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 @@ -926,22 +1201,102 @@ class DoctorsController < JSONAPI::ResourceController 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) @@ -984,10 +1339,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 @@ -1012,12 +1369,15 @@ 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 @@ -1041,6 +1401,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 @@ -1095,7 +1459,7 @@ def title=(title) return values }, apply: -> (records, value, _options) { - records.where('id IN (?)', value) + records.where('posts.id IN (?)', value) } filter :search, @@ -1106,6 +1470,11 @@ 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 @@ -1134,6 +1503,8 @@ class IsoCurrencyResource < JSONAPI::Resource attributes :name, :country_name, :minor_unit attribute :id, format: :id, readonly: false + has_many :expense_entries + filter :country_name key_type :string @@ -1144,44 +1515,170 @@ 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' -end + 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 -class BreedResource < JSONAPI::Resource - attribute :name, format: :title + 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 - # This is unneeded, just here for testing - routing_options param: :id + 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 self.find(filters, options = {}) - breeds = [] - $breed_data.breeds.values.each do |breed| - breeds.push(BreedResource.new(breed, options[:context])) + def resource_klass + self end - breeds - end - def self.find_by_key(id, options = {}) - BreedResource.new($breed_data.breeds[id.to_i], 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 = {}) + fail NotImplementedError, <<~EOF + Should be something like + def count(filters, options) + 0 + end + EOF + end - def _save - super - return :accepted - end -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 -class PlanetResource < JSONAPI::Resource + 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 < PoroResource + + attribute :name, format: :title + + # This is unneeded, just here for testing + routing_options param: :id + + 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 + + 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 + super + return :accepted + end +end + +class PlanetResource < JSONAPI::Resource attribute :name attribute :description has_many :moons - has_one :planet_type + belongs_to :planet_type has_many :tags, acts_as_set: true end @@ -1213,7 +1710,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) @@ -1224,11 +1721,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 @@ -1249,29 +1748,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 @@ -1288,6 +1816,8 @@ class AuthorResource < JSONAPI::Resource attributes :name has_many :books, inverse_relationship: :authors + has_many :pictures + # has_one :preferences end class BookResource < JSONAPI::Resource @@ -1304,86 +1834,6 @@ 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 - - filters :writer - - 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 - 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 -end - module Api module V1 class WriterResource < JSONAPI::Resource @@ -1416,6 +1866,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 @@ -1441,16 +1900,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 @@ -1461,9 +1949,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 @@ -1479,7 +1982,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) @@ -1504,7 +2007,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) { @@ -1515,6 +2018,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 @@ -1538,20 +2044,44 @@ module Api module V3 class PostResource < PostResource; end class PreferencesResource < PreferencesResource; end + class PlanetResource < JSONAPI::Resource + end + class MoonResource < JSONAPI::Resource + has_one :planet, always_include_optional_linkage_data: true + + def self.records(options = {}) + Moon.joins(:planet).merge(Planet.where(name: 'Satern')) # sic + end + end end 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 @@ -1560,48 +2090,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 @@ -1612,9 +2202,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 @@ -1667,6 +2260,8 @@ class PurchaseOrderResource < JSONAPI::Resource class OrderFlagResource < JSONAPI::Resource attributes :name + caching false + has_many :purchase_orders, reflect: false end @@ -1711,6 +2306,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 @@ -1727,31 +2414,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 @@ -1772,6 +2494,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 @@ -1827,6 +2590,9 @@ def show module Api class BoxResource < JSONAPI::Resource has_many :things + + filter 'things.things.name' + filter 'things.name' end class ThingResource < JSONAPI::Resource @@ -1834,10 +2600,13 @@ 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 @@ -1865,6 +2634,56 @@ 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 + ### PORO Data - don't do this in a production app $breed_data = BreedData.new $breed_data.add(Breed.new(0, 'persian')) diff --git a/test/fixtures/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/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/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 4cdf94503..02d94ef3f 100644 --- a/test/fixtures/posts.yml +++ b/test/fixtures/posts.yml @@ -2,98 +2,98 @@ 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 @@ -105,16 +105,16 @@ post_18: id: 18 title: Delete This later 18 body: AAAA - author_id: 3 + author_id: 1003 post_19: id: 19 title: Update Later - Operations body: AAAA This should be updated - author_id: 3 + author_id: 1003 post_20: id: 20 title: Update Later - Ops Multiple body: AAAA This should also be updated - author_id: 3 + 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/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 31821f8fd..b7895608c 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 @@ -122,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'} ] } } @@ -149,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'} ] } } @@ -174,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'} ] } } @@ -199,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, @@ -209,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 @@ -347,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'} ] } } @@ -407,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'} ] } } @@ -422,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: { @@ -526,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 @@ -604,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, { @@ -623,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, @@ -639,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 @@ -659,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' => { @@ -667,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 @@ -896,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 @@ -955,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 @@ -1000,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 @@ -1044,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 @@ -1082,6 +1432,29 @@ def test_sort_parameter_openquoted 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 @@ -1094,12 +1467,398 @@ def test_include_parameter_openquoted 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 + + def test_include_optional_linkage_data_with_join + get "/api/v3/moons", headers: { 'Accept' => JSONAPI::MEDIA_TYPE } + assert_jsonapi_response 200 + refute_nil json_response['data'][0]['relationships']['planet'] + 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 51ba0459d..a02fff9e7 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,23 +1,28 @@ +require 'logger' 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' @@ -38,6 +43,8 @@ config.json_key_format = :camelized_key end +ActiveSupport::Deprecation.silenced = true + puts "Testing With RAILS VERSION #{Rails.version}" class TestApp < Rails::Application @@ -53,13 +60,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 +84,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 +174,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 +212,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,16 +231,18 @@ 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 @@ -260,8 +252,20 @@ class CatResource < JSONAPI::Resource 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 @@ -276,20 +280,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 @@ -301,6 +313,9 @@ class CatResource < JSONAPI::Resource jsonapi_link :author, except: [:destroy] jsonapi_links :tags, only: [:show, :create] end + + jsonapi_resources :planets + jsonapi_resources :moons end JSONAPI.configuration.route_format = :camelized_route @@ -321,12 +336,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 @@ -335,11 +358,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 @@ -355,6 +381,11 @@ class CatResource < JSONAPI::Resource namespace :v8 do jsonapi_resources :numeros_telefone end + + namespace :v9 do + jsonapi_resources :people + jsonapi_resource :preferences + end end namespace :admin_api do @@ -375,6 +406,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 @@ -397,6 +435,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 @@ -434,7 +478,7 @@ class ActionDispatch::IntegrationTest 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 @@ -481,15 +525,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 @@ -515,13 +559,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 @@ -539,21 +585,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], :>, @@ -579,12 +624,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 @@ -644,3 +690,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 002be1927..dc467a728 100644 --- a/test/unit/jsonapi_request/jsonapi_request_test.rb +++ b/test/unit/jsonapi_request/jsonapi_request_test.rb @@ -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, @@ -36,6 +42,92 @@ def test_parse_includes_underscored 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( { @@ -45,7 +137,7 @@ def test_parse_dasherized_with_dasherized_include } ) - request = JSONAPI::RequestParser.new( + request = JSONAPI::Request.new( params, { context: nil, @@ -66,7 +158,7 @@ def test_parse_dasherized_with_underscored_include } ) - request = JSONAPI::RequestParser.new( + request = JSONAPI::Request.new( params, { context: nil, @@ -76,7 +168,7 @@ 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 @@ -88,7 +180,7 @@ def test_parse_fields_underscored } ) - request = JSONAPI::RequestParser.new( + request = JSONAPI::Request.new( params, { context: nil, @@ -111,7 +203,7 @@ def test_parse_dasherized_with_dasherized_fields } ) - request = JSONAPI::RequestParser.new( + request = JSONAPI::Request.new( params, { context: nil, @@ -134,7 +226,7 @@ def test_parse_dasherized_with_underscored_fields } ) - request = JSONAPI::RequestParser.new( + request = JSONAPI::Request.new( params, { context: nil, @@ -142,7 +234,7 @@ def test_parse_dasherized_with_underscored_fields } ) - e = assert_raises JSONAPI::Exceptions::Errors do + e = assert_raises JSONAPI::Exceptions::InvalidField do request.parse_fields(ExpenseEntryResource, params[:fields]) end refute e.errors.empty? @@ -160,14 +252,14 @@ 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) } ) - e = assert_raises JSONAPI::Exceptions::Errors do + e = assert_raises JSONAPI::Exceptions::InvalidResource do request.parse_fields(ExpenseEntryResource, params[:fields]) end refute e.errors.empty? @@ -183,10 +275,10 @@ def test_parse_filters_with_valid_filters def test_parse_filters_with_non_valid_filter setup_request - filters = @request.parse_filters(CatResource, {breed: 'Whiskers'}) # breed is not a set filter - assert_equal(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 @@ -211,6 +303,14 @@ def test_parse_sort_with_valid_sorts 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 sort_criteria = @request.parse_sort_criteria(CatResource, "-mother.name") @@ -221,6 +321,14 @@ def test_parse_sort_with_relationships private def setup_request - @request = JSONAPI::RequestParser.new + @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/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 01e921127..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 @@ -58,38 +58,25 @@ class FelineResource < 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 @@ -129,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 @@ -180,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) || (Rails::VERSION::MAJOR >= 5) - 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 @@ -213,14 +208,6 @@ def test_class_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 FelineResource.\n" do FelineResource.instance_eval do @@ -238,60 +225,15 @@ def test_duplicate_attribute_name 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)) @@ -321,130 +263,98 @@ 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 FelineResource.instance_eval do key_type :integer @@ -523,6 +433,8 @@ def test_key_type_proc end def test_id_attr_deprecation + + ActiveSupport::Deprecation.silenced = false _out, err = capture_io do eval <<-CODE class ProblemResource < JSONAPI::Resource @@ -531,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 @@ -593,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 @@ -621,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 @@ -645,4 +559,89 @@ def test_readonly_attribute 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 a91238a7b..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,18 +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 + JSONAPI::LinkBuilder.new( + primary_resource_klass: ApiV2Engine::PersonResource + ).engine assert_nil JSONAPI::LinkBuilder.new( primary_resource_klass: Api::V1::PersonResource - ).engine_name + ).engine end def test_self_link_regular_app @@ -51,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) @@ -60,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) @@ -83,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) @@ -99,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) @@ -113,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) @@ -125,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) @@ -138,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) @@ -151,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 } } @@ -264,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 } } @@ -278,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 } } @@ -290,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 } } @@ -306,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 } } @@ -320,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 } } @@ -332,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 } } @@ -348,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 bb905fde8..000000000 --- a/test/unit/serializer/polymorphic_serializer_test.rb +++ /dev/null @@ -1,482 +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) - - @questions = Question.all - - 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_belongs_to_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_has_one_serialization - serialized_data = JSONAPI::ResourceSerializer.new( - QuestionResource, - include: %w(respondent) - ).serialize_to_hash(@questions.map { |p| QuestionResource.new p, nil }) - - assert_hash_equals( - { - data: [ - { - id: '1', - type: 'questions', - links: { - self: '/questions/1' - }, - attributes: { - text: 'How are you feeling today?' - }, - relationships: { - answer: { - links: { - self: '/questions/1/relationships/answer', - related: '/questions/1/answer' - } - }, - respondent: { - links: { - self: '/questions/1/relationships/respondent', - related: '/questions/1/respondent' - }, - data: { - type: 'patients', - id: '1' - } - } - } - }, - { - id: '2', - type: 'questions', - links: { - self: '/questions/2' - }, - attributes: { - text: 'How does the patient look today?' - }, - relationships: { - answer: { - links: { - self: '/questions/2/relationships/answer', - related: '/questions/2/answer' - } - }, - respondent: { - links: { - self: '/questions/2/relationships/respondent', - related: '/questions/2/respondent' - }, - data: { - type: 'doctors', - id: '1' - } - } - } - } - ], - :included => [ - { - id: '1', - type: 'patients', - links: { - self: '/patients/1' - }, - attributes: { - name: 'Bob Smith' - }, - }, - { - id: '1', - type: 'doctors', - links: { - self: '/doctors/1' - }, - attributes: { - name: 'Henry Jones Jr' - }, - } - ] - }, - serialized_data - ) - end - - def test_polymorphic_get_related_resource - get '/pictures/1/imageable', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } - serialized_data = JSON.parse(response.body) - 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/serializer_test.rb b/test/unit/serializer/serializer_test.rb index f546c7901..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