├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── ruby.yml ├── .gitignore ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── jsonapi-resources.gemspec ├── lib ├── bug_report_templates │ ├── rails_5_latest.rb │ └── rails_5_master.rb ├── generators │ └── jsonapi │ │ ├── USAGE │ │ ├── controller_generator.rb │ │ ├── resource_generator.rb │ │ └── templates │ │ ├── jsonapi_controller.rb │ │ └── jsonapi_resource.rb ├── jsonapi-resources.rb ├── jsonapi │ ├── active_relation │ │ ├── adapters │ │ │ └── join_left_active_record_adapter.rb │ │ └── join_manager.rb │ ├── active_relation_resource.rb │ ├── acts_as_resource_controller.rb │ ├── basic_resource.rb │ ├── cached_response_fragment.rb │ ├── callbacks.rb │ ├── compiled_json.rb │ ├── configuration.rb │ ├── error.rb │ ├── error_codes.rb │ ├── exceptions.rb │ ├── formatter.rb │ ├── include_directives.rb │ ├── link_builder.rb │ ├── mime_types.rb │ ├── naive_cache.rb │ ├── operation.rb │ ├── operation_result.rb │ ├── paginator.rb │ ├── path.rb │ ├── path_segment.rb │ ├── processor.rb │ ├── relationship.rb │ ├── request.rb │ ├── resource.rb │ ├── resource_controller.rb │ ├── resource_controller_metal.rb │ ├── resource_fragment.rb │ ├── resource_identity.rb │ ├── resource_serializer.rb │ ├── resource_set.rb │ ├── resource_tree.rb │ ├── resources │ │ ├── railtie.rb │ │ └── version.rb │ ├── response_document.rb │ └── routing_ext.rb └── tasks │ └── check_upgrade.rake ├── locales └── en.yml └── test ├── benchmark ├── reflect_create_and_delete_benchmark.rb ├── reflect_update_relationships_benchmark.rb └── request_benchmark.rb ├── controllers └── controller_test.rb ├── fixtures ├── access_cards.yml ├── active_record.rb ├── answers.yml ├── author_details.yml ├── book_authors.yml ├── book_comments.yml ├── books.yml ├── boxes.yml ├── categories.yml ├── collectors.yml ├── comments.yml ├── comments_tags.yml ├── companies.yml ├── craters.yml ├── customers.yml ├── doctors.yml ├── documents.yml ├── expense_entries.yml ├── facts.yml ├── file_properties.yml ├── hair_cuts.yml ├── iso_currencies.yml ├── keepers.yml ├── line_items.yml ├── makes.yml ├── moons.yml ├── numeros_telefone.yml ├── order_flags.yml ├── painters.yml ├── paintings.yml ├── patients.yml ├── people.yml ├── pictures.yml ├── planet_types.yml ├── planets.yml ├── posts.yml ├── posts_tags.yml ├── preferences.yml ├── products.yml ├── purchase_orders.yml ├── questions.yml ├── related_things.yml ├── sections.yml ├── storages.yml ├── tags.yml ├── things.yml ├── users.yml ├── vehicles.yml ├── web_pages.yml └── workers.yml ├── helpers ├── assertions.rb ├── configuration_helpers.rb ├── functional_helpers.rb ├── value_matchers.rb └── value_matchers_test.rb ├── integration ├── requests │ ├── namespaced_model_test.rb │ └── request_test.rb ├── routes │ └── routes_test.rb └── sti_fields_test.rb ├── lib └── generators │ └── jsonapi │ ├── controller_generator_test.rb │ └── resource_generator_test.rb ├── test_helper.rb └── unit ├── active_relation_resource_finder └── join_manager_test.rb ├── formatters └── dasherized_key_formatter_test.rb ├── jsonapi_request └── jsonapi_request_test.rb ├── pagination ├── offset_paginator_test.rb └── paged_paginator_test.rb ├── paths └── path_test.rb ├── processor └── default_processor_test.rb ├── resource ├── active_relation_resource_test.rb ├── relationship_test.rb └── resource_test.rb └── serializer ├── include_directives_test.rb ├── link_builder_test.rb └── serializer_test.rb /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## This issue is a (choose one): 2 | 3 | - [ ] Problem/bug report. 4 | - [ ] Feature request. 5 | - [ ] Request for support. **Note: Please try to avoid submitting issues for support requests. Use [Gitter](https://gitter.im/cerebris/jsonapi-resources) instead.** 6 | 7 | ## Checklist before submitting: 8 | 9 | - [ ] I've searched for an existing issue. 10 | - [ ] I've asked my question on [Gitter](https://gitter.im/cerebris/jsonapi-resources) and have not received a satisfactory answer. 11 | - [ ] 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. 12 | - [ ] The feature I'm asking for is compliant with the [JSON:API](http://jsonapi.org/) spec. 13 | 14 | ## Description 15 | 16 | Choose one section below and delete the other: 17 | 18 | ### Bug reports: 19 | 20 | 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. 21 | 22 | ### Features: 23 | 24 | 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. -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### All Submissions: 4 | 5 | - [ ] I've checked to ensure there aren't other open [Pull Requests](https://github.com/cerebris/jsonapi-resources/pulls) for the same update/change. 6 | - [ ] I've submitted a [ticket](https://github.com/cerebris/jsonapi-resources/issues) for my issue if one did not already exist. 7 | - [ ] My submission passes all tests. (Please run the full test suite locally to cut down on noise from travis failures.) 8 | - [ ] I've used Github [auto-closing keywords](https://help.github.com/articles/closing-issues-via-commit-messages/) in the commit message or the description. 9 | - [ ] I've added/updated tests for this change. 10 | 11 | ### New Feature Submissions: 12 | 13 | - [ ] I've submitted an issue that describes this feature, and received the go ahead from the maintainers. 14 | - [ ] My submission includes new tests. 15 | - [ ] My submission maintains compliance with [JSON:API](http://jsonapi.org/). 16 | 17 | ### Bug fixes and Changes to Core Features: 18 | 19 | - [ ] I've included an explanation of what the changes do and why I'd like you to include them. 20 | - [ ] I've provided test(s) that fails without the change. 21 | 22 | ### Test Plan: 23 | 24 | ### Reviewer Checklist: 25 | - [ ] Maintains compliance with JSON:API 26 | - [ ] Adequate test coverage exists to prevent regressions -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ 'master', 'release-0-8', 'release-0-9', 'release-0-10' ] 6 | pull_request: 7 | branches: ['**'] 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | services: 13 | postgres: 14 | image: postgres 15 | env: 16 | POSTGRES_PASSWORD: password 17 | POSTGRES_DB: test 18 | options: >- 19 | --health-cmd pg_isready 20 | --health-interval 10s 21 | --health-timeout 5s 22 | --health-retries 5 23 | ports: 24 | - 5432:5432 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | ruby: 29 | - 2.6 30 | - 2.7 31 | - '3.0' 32 | - 3.1 33 | - 3.2 34 | rails: 35 | - 7.0.4 36 | - 6.1.7 37 | - 6.0.6 38 | - 5.2.8.1 39 | - 5.1.7 40 | database_url: 41 | - postgresql://postgres:password@localhost:5432/test 42 | - sqlite3:test_db 43 | exclude: 44 | - ruby: 3.2 45 | rails: 6.0.6 46 | - ruby: 3.2 47 | rails: 5.2.8.1 48 | - ruby: 3.2 49 | rails: 5.1.7 50 | - ruby: 3.1 51 | rails: 6.0.6 52 | - ruby: 3.1 53 | rails: 5.2.8.1 54 | - ruby: 3.1 55 | rails: 5.1.7 56 | - ruby: '3.0' 57 | rails: 6.0.6 58 | - ruby: '3.0' 59 | rails: 5.2.8.1 60 | - ruby: '3.0' 61 | rails: 5.1.7 62 | - ruby: 2.6 63 | rails: 7.0.4 64 | - database_url: postgresql://postgres:password@localhost:5432/test 65 | rails: 5.1.7 66 | env: 67 | RAILS_VERSION: ${{ matrix.rails }} 68 | DATABASE_URL: ${{ matrix.database_url }} 69 | name: Ruby ${{ matrix.ruby }} Rails ${{ matrix.rails }} DB ${{ matrix.database_url }} 70 | steps: 71 | - uses: actions/checkout@v3 72 | - name: Set up Ruby 73 | uses: ruby/setup-ruby@v1 74 | with: 75 | ruby-version: ${{ matrix.ruby }} 76 | - name: Install dependencies 77 | run: bundle install --jobs 4 --retry 3 78 | - name: Run tests 79 | run: bundle exec rake test 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | *.sw* 4 | .bundle 5 | .config 6 | .yardoc 7 | .ruby-version 8 | Gemfile.lock 9 | InstalledFiles 10 | _yardoc 11 | coverage 12 | doc/ 13 | lib/bundler/man 14 | pkg 15 | rdoc 16 | spec/reports 17 | test/tmp 18 | test/version_tmp 19 | tmp 20 | coverage 21 | test/log 22 | test_db 23 | test_db-journal 24 | .idea 25 | *.iml 26 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | platforms :jruby do 6 | gem 'activerecord-jdbcsqlite3-adapter' 7 | end 8 | 9 | version = ENV['RAILS_VERSION'] || 'default' 10 | 11 | platforms :ruby do 12 | gem 'pg' 13 | 14 | if version.start_with?('4.2', '5.0') 15 | gem 'sqlite3', '~> 1.3.13' 16 | else 17 | gem 'sqlite3', '~> 1.4' 18 | end 19 | end 20 | 21 | case version 22 | when 'master' 23 | gem 'railties', { git: 'https://github.com/rails/rails.git' } 24 | gem 'arel', { git: 'https://github.com/rails/arel.git' } 25 | when 'default' 26 | gem 'railties', '>= 6.0' 27 | else 28 | gem 'railties', "~> #{version}" 29 | end -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2021 Cerebris Corporation 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSONAPI::Resources [![Gem Version](https://badge.fury.io/rb/jsonapi-resources.svg)](https://badge.fury.io/rb/jsonapi-resources) [![Build Status](https://secure.travis-ci.org/cerebris/jsonapi-resources.svg?branch=master)](http://travis-ci.org/cerebris/jsonapi-resources) [![Code Climate](https://codeclimate.com/github/cerebris/jsonapi-resources/badges/gpa.svg)](https://codeclimate.com/github/cerebris/jsonapi-resources) 2 | 3 | [![Join the chat at https://gitter.im/cerebris/jsonapi-resources](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/cerebris/jsonapi-resources?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 4 | 5 | `JSONAPI::Resources`, or "JR", provides a framework for developing an API server that complies with the 6 | [JSON:API](http://jsonapi.org/) specification. 7 | 8 | Like JSON:API itself, JR's design is focused on the resources served by an API. JR needs little more than a definition 9 | of your resources, including their attributes and relationships, to make your server compliant with JSON API. 10 | 11 | JR is designed to work with Rails 5.1+, and provides custom routes, controllers, and serializers. JR's resources may be 12 | backed by ActiveRecord models or by custom objects. 13 | 14 | ## Documentation 15 | 16 | Full documentation can be found at [http://jsonapi-resources.com](http://jsonapi-resources.com), including the [v0.10 alpha Guide](http://jsonapi-resources.com/v0.10/guide/) specific to this version. 17 | 18 | ## Demo App 19 | 20 | We have a simple demo app, called [Peeps](https://github.com/cerebris/peeps), available to show how JR is used. 21 | 22 | ## Client Libraries 23 | 24 | JSON:API maintains a (non-verified) listing of [client libraries](http://jsonapi.org/implementations/#client-libraries) 25 | which *should* be compatible with JSON:API compliant server implementations such as JR. 26 | 27 | ## Installation 28 | 29 | Add JR to your application's `Gemfile`: 30 | 31 | ``` 32 | gem 'jsonapi-resources' 33 | ``` 34 | 35 | And then execute: 36 | 37 | ```bash 38 | bundle 39 | ``` 40 | 41 | Or install it yourself as: 42 | 43 | ```bash 44 | gem install jsonapi-resources 45 | ``` 46 | 47 | **For further usage see the [v0.10 alpha Guide](http://jsonapi-resources.com/v0.10/guide/)** 48 | 49 | ## Contributing 50 | 51 | 1. Submit an issue describing any new features you wish it add or the bug you intend to fix 52 | 1. Fork it ( http://github.com/cerebris/jsonapi-resources/fork ) 53 | 1. Create your feature branch (`git checkout -b my-new-feature`) 54 | 1. Run the full test suite (`rake test`) 55 | 1. Fix any failing tests 56 | 1. Commit your changes (`git commit -am 'Add some feature'`) 57 | 1. Push to the branch (`git push origin my-new-feature`) 58 | 1. Create a new Pull Request 59 | 60 | ## Did you find a bug? 61 | 62 | * **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/cerebris/jsonapi-resources/issues). 63 | 64 | * If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/cerebris/jsonapi-resources/issues/new). 65 | Be sure to include a **title and clear description**, as much relevant information as possible, 66 | and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring. 67 | 68 | * If possible, use the relevant bug report templates to create the issue. 69 | Simply copy the content of the appropriate template into a .rb file, make the necessary changes to demonstrate the issue, 70 | and **paste the content into the issue description or attach as a file**: 71 | * [**Rails 5** issues](https://github.com/cerebris/jsonapi-resources/blob/master/lib/bug_report_templates/rails_5_master.rb) 72 | 73 | 74 | ## License 75 | 76 | Copyright 2014-2021 Cerebris Corporation. MIT License (see LICENSE for details). 77 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | require 'bundler/gem_tasks' 3 | require 'rake/testtask' 4 | 5 | Rake::TestTask.new do |t| 6 | t.verbose = true 7 | t.warning = false 8 | t.test_files = FileList['test/**/*_test.rb'] 9 | end 10 | 11 | task default: [:test] 12 | 13 | desc 'Run benchmarks' 14 | namespace :test do 15 | Rake::TestTask.new(:benchmark) do |t| 16 | t.pattern = 'test/benchmark/*_benchmark.rb' 17 | end 18 | end 19 | 20 | desc 'Test bug report template' 21 | namespace :test do 22 | namespace :bug_report_template do 23 | task :rails_5 do 24 | puts 'Test bug report templates' 25 | jsonapi_resources_root = File.expand_path('..', __FILE__) 26 | chdir_path = File.join(jsonapi_resources_root, 'lib', 'bug_report_templates') 27 | report_env = {'SILENT' => 'true', 'JSONAPI_RESOURCES_PATH' => jsonapi_resources_root} 28 | Bundler.with_clean_env do 29 | Dir.chdir(chdir_path) do 30 | abort('bug report template rails_5_master fails') unless system(report_env, Gem.ruby, 'rails_5_master.rb') 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /jsonapi-resources.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'jsonapi/resources/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'jsonapi-resources' 8 | spec.version = JSONAPI::Resources::VERSION 9 | spec.authors = ['Dan Gebhardt', 'Larry Gebhardt'] 10 | spec.email = ['dan@cerebris.com', 'larry@cerebris.com'] 11 | spec.summary = 'Easily support JSON API in Rails.' 12 | spec.description = 'A resource-centric approach to implementing the controllers, routes, and serializers needed to support the JSON API spec.' 13 | spec.homepage = 'https://github.com/cerebris/jsonapi-resources' 14 | spec.license = 'MIT' 15 | 16 | spec.files = Dir.glob("{bin,lib}/**/*") + %w(LICENSE.txt README.md) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ['lib'] 20 | spec.required_ruby_version = '>= 2.3' 21 | 22 | spec.add_development_dependency 'bundler', '>= 1.17' 23 | spec.add_development_dependency 'rake' 24 | spec.add_development_dependency 'minitest', '~> 5.10', '!= 5.10.2' 25 | spec.add_development_dependency 'minitest-spec-rails' 26 | spec.add_development_dependency 'simplecov' 27 | spec.add_development_dependency 'pry' 28 | spec.add_development_dependency 'concurrent-ruby-ext' 29 | spec.add_development_dependency 'database_cleaner' 30 | spec.add_dependency 'activerecord', '>= 5.1' 31 | spec.add_dependency 'railties', '>= 5.1' 32 | spec.add_dependency 'concurrent-ruby' 33 | end 34 | -------------------------------------------------------------------------------- /lib/bug_report_templates/rails_5_latest.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require 'bundler/inline' 3 | rescue LoadError => e 4 | STDERR.puts 'Bundler version 1.10 or later is required. Please update your Bundler' 5 | raise e 6 | end 7 | 8 | gemfile(true) do 9 | source 'https://rubygems.org' 10 | 11 | gem 'rails', require: false 12 | gem 'sqlite3', platform: :mri 13 | 14 | gem 'activerecord-jdbcsqlite3-adapter', 15 | git: 'https://github.com/jruby/activerecord-jdbc-adapter', 16 | branch: 'rails-5', 17 | platform: :jruby 18 | 19 | gem 'jsonapi-resources', require: false 20 | end 21 | 22 | # prepare active_record database 23 | require 'active_record' 24 | 25 | ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') 26 | ActiveRecord::Base.logger = Logger.new(STDOUT) 27 | 28 | ActiveRecord::Schema.define do 29 | # Add your schema here 30 | create_table :your_models, force: true do |t| 31 | t.string :name 32 | end 33 | end 34 | 35 | # create models 36 | class YourModel < ActiveRecord::Base 37 | end 38 | 39 | # prepare rails app 40 | require 'action_controller/railtie' 41 | # require 'action_view/railtie' 42 | require 'jsonapi-resources' 43 | 44 | class ApplicationController < ActionController::Base 45 | end 46 | 47 | # prepare jsonapi resources and controllers 48 | class YourModelsController < ApplicationController 49 | include JSONAPI::ActsAsResourceController 50 | end 51 | 52 | class YourModelResource < JSONAPI::Resource 53 | attribute :name 54 | filter :name 55 | end 56 | 57 | class TestApp < Rails::Application 58 | config.root = File.dirname(__FILE__) 59 | config.logger = Logger.new(STDOUT) 60 | Rails.logger = config.logger 61 | 62 | secrets.secret_token = 'secret_token' 63 | secrets.secret_key_base = 'secret_key_base' 64 | 65 | config.eager_load = false 66 | end 67 | 68 | # initialize app 69 | Rails.application.initialize! 70 | 71 | JSONAPI.configure do |config| 72 | config.json_key_format = :underscored_key 73 | config.route_format = :underscored_key 74 | end 75 | 76 | # draw routes 77 | Rails.application.routes.draw do 78 | jsonapi_resources :your_models, only: [:index, :create] 79 | end 80 | 81 | # prepare tests 82 | require 'minitest/autorun' 83 | require 'rack/test' 84 | 85 | # Replace this with the code necessary to make your test fail. 86 | class BugTest < Minitest::Test 87 | include Rack::Test::Methods 88 | 89 | def json_api_headers 90 | {'Accept' => JSONAPI::MEDIA_TYPE, 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE} 91 | end 92 | 93 | def test_index_your_models 94 | record = YourModel.create! name: 'John Doe' 95 | get '/your_models', nil, json_api_headers 96 | assert last_response.ok? 97 | json_response = JSON.parse(last_response.body) 98 | refute_nil json_response['data'] 99 | refute_empty json_response['data'] 100 | refute_empty json_response['data'].first 101 | assert record.id.to_s, json_response['data'].first['id'] 102 | assert 'your_models', json_response['data'].first['type'] 103 | assert({'name' => 'John Doe'}, json_response['data'].first['attributes']) 104 | end 105 | 106 | def test_create_your_models 107 | json_request = { 108 | 'data' => { 109 | type: 'your_models', 110 | attributes: { 111 | name: 'Jane Doe' 112 | } 113 | } 114 | } 115 | post '/your_models', json_request.to_json, json_api_headers 116 | assert last_response.created? 117 | refute_nil YourModel.find_by(name: 'Jane Doe') 118 | end 119 | 120 | private 121 | 122 | def app 123 | Rails.application 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /lib/bug_report_templates/rails_5_master.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require 'bundler/inline' 3 | require 'bundler' 4 | rescue LoadError => e 5 | STDERR.puts 'Bundler version 1.10 or later is required. Please update your Bundler' 6 | raise e 7 | end 8 | 9 | gemfile(true, ui: ENV['SILENT'] ? Bundler::UI::Silent.new : Bundler::UI::Shell.new) do 10 | source 'https://rubygems.org' 11 | 12 | gem 'rails', require: false 13 | gem 'sqlite3', platform: :mri 14 | 15 | gem 'activerecord-jdbcsqlite3-adapter', 16 | git: 'https://github.com/jruby/activerecord-jdbc-adapter', 17 | branch: 'rails-5', 18 | platform: :jruby 19 | 20 | if ENV['JSONAPI_RESOURCES_PATH'] 21 | gem 'jsonapi-resources', path: ENV['JSONAPI_RESOURCES_PATH'], require: false 22 | else 23 | gem 'jsonapi-resources', git: 'https://github.com/cerebris/jsonapi-resources', require: false 24 | end 25 | 26 | end 27 | 28 | # prepare active_record database 29 | require 'active_record' 30 | 31 | class NullLogger < Logger 32 | def initialize(*_args) 33 | end 34 | 35 | def add(*_args, &_block) 36 | end 37 | end 38 | 39 | ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') 40 | ActiveRecord::Base.logger = ENV['SILENT'] ? NullLogger.new : Logger.new(STDOUT) 41 | ActiveRecord::Migration.verbose = !ENV['SILENT'] 42 | 43 | ActiveRecord::Schema.define do 44 | # Add your schema here 45 | create_table :your_models, force: true do |t| 46 | t.string :name 47 | end 48 | end 49 | 50 | # create models 51 | class YourModel < ActiveRecord::Base 52 | end 53 | 54 | # prepare rails app 55 | require 'action_controller/railtie' 56 | # require 'action_view/railtie' 57 | require 'jsonapi-resources' 58 | 59 | class ApplicationController < ActionController::Base 60 | end 61 | 62 | # prepare jsonapi resources and controllers 63 | class YourModelsController < ApplicationController 64 | include JSONAPI::ActsAsResourceController 65 | end 66 | 67 | class YourModelResource < JSONAPI::Resource 68 | attribute :name 69 | filter :name 70 | end 71 | 72 | class TestApp < Rails::Application 73 | config.root = File.dirname(__FILE__) 74 | config.logger = ENV['SILENT'] ? NullLogger.new : Logger.new(STDOUT) 75 | Rails.logger = config.logger 76 | 77 | secrets.secret_token = 'secret_token' 78 | secrets.secret_key_base = 'secret_key_base' 79 | 80 | config.eager_load = false 81 | end 82 | 83 | # initialize app 84 | Rails.application.initialize! 85 | 86 | JSONAPI.configure do |config| 87 | config.json_key_format = :underscored_key 88 | config.route_format = :underscored_key 89 | end 90 | 91 | # draw routes 92 | Rails.application.routes.draw do 93 | jsonapi_resources :your_models, only: [:index, :create] 94 | end 95 | 96 | # prepare tests 97 | require 'minitest/autorun' 98 | require 'rack/test' 99 | 100 | # Replace this with the code necessary to make your test fail. 101 | class BugTest < Minitest::Test 102 | include Rack::Test::Methods 103 | 104 | def json_api_headers 105 | {'Accept' => JSONAPI::MEDIA_TYPE, 'CONTENT_TYPE' => JSONAPI::MEDIA_TYPE} 106 | end 107 | 108 | def test_index_your_models 109 | record = YourModel.create! name: 'John Doe' 110 | get '/your_models', nil, json_api_headers 111 | assert last_response.ok? 112 | json_response = JSON.parse(last_response.body) 113 | refute_nil json_response['data'] 114 | refute_empty json_response['data'] 115 | refute_empty json_response['data'].first 116 | assert record.id.to_s, json_response['data'].first['id'] 117 | assert 'your_models', json_response['data'].first['type'] 118 | assert({'name' => 'John Doe'}, json_response['data'].first['attributes']) 119 | end 120 | 121 | def test_create_your_models 122 | json_request = { 123 | 'data' => { 124 | type: 'your_models', 125 | attributes: { 126 | name: 'Jane Doe' 127 | } 128 | } 129 | } 130 | post '/your_models', json_request.to_json, json_api_headers 131 | assert last_response.created? 132 | refute_nil YourModel.find_by(name: 'Jane Doe') 133 | end 134 | 135 | private 136 | 137 | def app 138 | Rails.application 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/generators/jsonapi/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | Generator for JSONAPI Resources 3 | 4 | Examples: 5 | rails generate jsonapi:resource Post 6 | 7 | This will create: 8 | app/resources/post_resource.rb 9 | 10 | rails generate jsonapi:controller Post 11 | 12 | This will create: 13 | app/controllers/posts_controller.rb 14 | -------------------------------------------------------------------------------- /lib/generators/jsonapi/controller_generator.rb: -------------------------------------------------------------------------------- 1 | module Jsonapi 2 | class ControllerGenerator < Rails::Generators::NamedBase 3 | source_root File.expand_path('../templates', __FILE__) 4 | 5 | def create_resource 6 | template_file = File.join( 7 | 'app/controllers', 8 | class_path, 9 | "#{file_name.pluralize}_controller.rb" 10 | ) 11 | template 'jsonapi_controller.rb', template_file 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/generators/jsonapi/resource_generator.rb: -------------------------------------------------------------------------------- 1 | module Jsonapi 2 | class ResourceGenerator < Rails::Generators::NamedBase 3 | source_root File.expand_path('../templates', __FILE__) 4 | 5 | def create_resource 6 | template_file = File.join( 7 | 'app/resources', 8 | class_path, 9 | "#{file_name.singularize}_resource.rb" 10 | ) 11 | template 'jsonapi_resource.rb', template_file 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/generators/jsonapi/templates/jsonapi_controller.rb: -------------------------------------------------------------------------------- 1 | <% module_namespacing do -%> 2 | class <%= class_name.pluralize %>Controller < JSONAPI::ResourceController 3 | end 4 | <% end -%> 5 | -------------------------------------------------------------------------------- /lib/generators/jsonapi/templates/jsonapi_resource.rb: -------------------------------------------------------------------------------- 1 | <% module_namespacing do -%> 2 | class <%= class_name.singularize %>Resource < JSONAPI::Resource 3 | end 4 | <% end -%> 5 | -------------------------------------------------------------------------------- /lib/jsonapi-resources.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'jsonapi/resources/railtie' 4 | require 'jsonapi/naive_cache' 5 | require 'jsonapi/compiled_json' 6 | require 'jsonapi/basic_resource' 7 | require 'jsonapi/active_relation_resource' 8 | require 'jsonapi/resource' 9 | require 'jsonapi/cached_response_fragment' 10 | require 'jsonapi/response_document' 11 | require 'jsonapi/acts_as_resource_controller' 12 | if Rails::VERSION::MAJOR >= 6 13 | ActiveSupport.on_load(:action_controller_base) do 14 | require 'jsonapi/resource_controller' 15 | end 16 | else 17 | require 'jsonapi/resource_controller' 18 | end 19 | require 'jsonapi/resource_controller_metal' 20 | require 'jsonapi/resources/version' 21 | require 'jsonapi/configuration' 22 | require 'jsonapi/paginator' 23 | require 'jsonapi/formatter' 24 | require 'jsonapi/routing_ext' 25 | require 'jsonapi/mime_types' 26 | require 'jsonapi/resource_serializer' 27 | require 'jsonapi/exceptions' 28 | require 'jsonapi/error' 29 | require 'jsonapi/error_codes' 30 | require 'jsonapi/request' 31 | require 'jsonapi/processor' 32 | require 'jsonapi/relationship' 33 | require 'jsonapi/include_directives' 34 | require 'jsonapi/operation' 35 | require 'jsonapi/operation_result' 36 | require 'jsonapi/callbacks' 37 | require 'jsonapi/link_builder' 38 | require 'jsonapi/active_relation/adapters/join_left_active_record_adapter' 39 | require 'jsonapi/active_relation/join_manager' 40 | require 'jsonapi/resource_identity' 41 | require 'jsonapi/resource_fragment' 42 | require 'jsonapi/resource_tree' 43 | require 'jsonapi/resource_set' 44 | require 'jsonapi/path' 45 | require 'jsonapi/path_segment' 46 | -------------------------------------------------------------------------------- /lib/jsonapi/active_relation/adapters/join_left_active_record_adapter.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | module ActiveRelation 3 | module Adapters 4 | module JoinLeftActiveRecordAdapter 5 | # Extends left_joins functionality to rails 4, and uses the same logic for rails 5.0.x and 5.1.x 6 | # The default left_joins logic of rails 5.2.x is used. This results in and extra join in some cases. For 7 | # example Post.joins(:comments).joins_left(comments: :author) will join the comments table twice, 8 | # once inner and once left in 5.2, but only as inner in earlier versions. 9 | def joins_left(*columns) 10 | if Rails::VERSION::MAJOR >= 6 || (Rails::VERSION::MAJOR >= 5 && ActiveRecord::VERSION::MINOR >= 2) 11 | left_joins(columns) 12 | else 13 | join_dependency = ActiveRecord::Associations::JoinDependency.new(self, columns, []) 14 | joins(join_dependency) 15 | end 16 | end 17 | 18 | alias_method :join_left, :joins_left 19 | end 20 | 21 | if defined?(ActiveRecord) 22 | ActiveRecord::Base.extend JoinLeftActiveRecordAdapter 23 | end 24 | end 25 | end 26 | end -------------------------------------------------------------------------------- /lib/jsonapi/cached_response_fragment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | class CachedResponseFragment 5 | 6 | Lookup = Struct.new(:resource_klass, :serializer_config_key, :context, :context_key, :cache_ids) do 7 | 8 | def type 9 | resource_klass._type 10 | end 11 | 12 | def keys 13 | cache_ids.map do |(id, cache_key)| 14 | [type, id, cache_key, serializer_config_key, context_key] 15 | end 16 | end 17 | end 18 | 19 | Write = Struct.new(:resource_klass, :resource, :serializer, :serializer_config_key, :context, :context_key, :relationship_data) do 20 | def to_key_value 21 | 22 | (id, cache_key) = resource.cache_id 23 | 24 | json = serializer.object_hash(resource, relationship_data) 25 | 26 | cr = CachedResponseFragment.new( 27 | resource_klass, 28 | id, 29 | json['type'], 30 | context, 31 | resource.fetchable_fields, 32 | json['relationships'], 33 | json['links'], 34 | json['attributes'], 35 | json['meta'] 36 | ) 37 | 38 | key = [resource_klass._type, id, cache_key, serializer_config_key, context_key] 39 | 40 | [key, cr] 41 | end 42 | end 43 | 44 | attr_reader :resource_klass, :id, :type, :context, :fetchable_fields, :relationships, 45 | :links_json, :attributes_json, :meta_json 46 | 47 | def initialize(resource_klass, id, type, context, fetchable_fields, relationships, 48 | links_json, attributes_json, meta_json) 49 | @resource_klass = resource_klass 50 | @id = id 51 | @type = type 52 | @context = context 53 | @fetchable_fields = Set.new(fetchable_fields) 54 | 55 | # Relationships left uncompiled because we'll often want to insert included ids on retrieval 56 | @relationships = relationships 57 | 58 | @links_json = CompiledJson.of(links_json) 59 | @attributes_json = CompiledJson.of(attributes_json) 60 | @meta_json = CompiledJson.of(meta_json) 61 | end 62 | 63 | def to_cache_value 64 | { 65 | id: id, 66 | type: type, 67 | fetchable: fetchable_fields, 68 | rels: relationships, 69 | links: links_json.try(:to_s), 70 | attrs: attributes_json.try(:to_s), 71 | meta: meta_json.try(:to_s) 72 | } 73 | end 74 | 75 | # @param [Lookup[]] lookups 76 | # @return [Hash, Hash>] 77 | def self.lookup(lookups, context) 78 | type_to_klass = lookups.map {|l| [l.type, l.resource_klass]}.to_h 79 | 80 | keys = lookups.map(&:keys).flatten(1) 81 | 82 | hits = JSONAPI.configuration.resource_cache.read_multi(*keys).reject {|_, v| v.nil?} 83 | 84 | return keys.inject({}) do |hash, key| 85 | (type, id, _, _) = key 86 | resource_klass = type_to_klass[type] 87 | hash[resource_klass] ||= {} 88 | 89 | if hits.has_key?(key) 90 | hash[resource_klass][id] = self.from_cache_value(resource_klass, context, hits[key]) 91 | else 92 | hash[resource_klass][id] = nil 93 | end 94 | 95 | hash 96 | end 97 | end 98 | 99 | # @param [Write[]] lookups 100 | def self.write(writes) 101 | key_values = writes.map(&:to_key_value) 102 | 103 | to_write = key_values.map {|(k, v)| [k, v.to_cache_value]}.to_h 104 | 105 | if JSONAPI.configuration.resource_cache.respond_to? :write_multi 106 | JSONAPI.configuration.resource_cache.write_multi(to_write) 107 | else 108 | to_write.each do |key, value| 109 | JSONAPI.configuration.resource_cache.write(key, value) 110 | end 111 | end 112 | 113 | end 114 | 115 | def self.from_cache_value(resource_klass, context, h) 116 | new( 117 | resource_klass, 118 | h.fetch(:id), 119 | h.fetch(:type), 120 | context, 121 | h.fetch(:fetchable), 122 | h.fetch(:rels, nil), 123 | h.fetch(:links, nil), 124 | h.fetch(:attrs, nil), 125 | h.fetch(:meta, nil) 126 | ) 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/jsonapi/callbacks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/callbacks' 4 | 5 | module JSONAPI 6 | module Callbacks 7 | def self.included(base) 8 | base.class_eval do 9 | include ActiveSupport::Callbacks 10 | base.extend ClassMethods 11 | end 12 | end 13 | 14 | module ClassMethods 15 | def define_jsonapi_resources_callbacks(*callbacks) 16 | options = callbacks.extract_options! 17 | options = { 18 | only: [:before, :around, :after] 19 | }.merge!(options) 20 | 21 | types = Array(options.delete(:only)) 22 | 23 | callbacks.each do |callback| 24 | define_callbacks(callback, options) 25 | 26 | types.each do |type| 27 | send("_define_#{type}_callback", self, callback) 28 | end 29 | end 30 | end 31 | 32 | private 33 | 34 | def _define_before_callback(klass, callback) #:nodoc: 35 | klass.define_singleton_method("before_#{callback}") do |*args, &block| 36 | set_callback(:"#{callback}", :before, *args, &block) 37 | end 38 | end 39 | 40 | def _define_around_callback(klass, callback) #:nodoc: 41 | klass.define_singleton_method("around_#{callback}") do |*args, &block| 42 | set_callback(:"#{callback}", :around, *args, &block) 43 | end 44 | end 45 | 46 | def _define_after_callback(klass, callback) #:nodoc: 47 | klass.define_singleton_method("after_#{callback}") do |*args, &block| 48 | set_callback(:"#{callback}", :after, *args, &block) 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/jsonapi/compiled_json.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | class CompiledJson 5 | def self.compile(h) 6 | new(JSON.generate(h), h) 7 | end 8 | 9 | def self.of(obj) 10 | # :nocov: 11 | case obj 12 | when NilClass then nil 13 | when CompiledJson then obj 14 | when String then CompiledJson.new(obj) 15 | when Hash then CompiledJson.compile(obj) 16 | else raise "Can't figure out how to turn #{obj.inspect} into CompiledJson" 17 | end 18 | # :nocov: 19 | end 20 | 21 | def initialize(json, h = nil) 22 | @json = json 23 | @h = h 24 | end 25 | 26 | def to_json(*_args) 27 | @json 28 | end 29 | 30 | def to_s 31 | @json 32 | end 33 | 34 | # :nocov: 35 | def to_h 36 | @h ||= JSON.parse(@json) 37 | end 38 | # :nocov: 39 | 40 | def [](key) 41 | # :nocov: 42 | to_h[key] 43 | # :nocov: 44 | end 45 | 46 | undef_method :as_json 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/jsonapi/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | class Error 5 | attr_accessor :title, :detail, :id, :href, :code, :source, :links, :status, :meta 6 | 7 | def initialize(options = {}) 8 | @title = options[:title] 9 | @detail = options[:detail] 10 | @id = options[:id] 11 | @href = options[:href] 12 | @code = if JSONAPI.configuration.use_text_errors 13 | TEXT_ERRORS[options[:code]] 14 | else 15 | options[:code] 16 | end 17 | @source = options[:source] 18 | @links = options[:links] 19 | 20 | @status = Rack::Utils::SYMBOL_TO_STATUS_CODE[options[:status]].to_s 21 | @meta = options[:meta] 22 | end 23 | 24 | def to_hash 25 | hash = {} 26 | instance_variables.each {|var| hash[var.to_s.delete('@')] = instance_variable_get(var) unless instance_variable_get(var).nil? } 27 | hash 28 | end 29 | 30 | def update_with_overrides(error_object_overrides) 31 | @title = error_object_overrides[:title] || @title 32 | @detail = error_object_overrides[:detail] || @detail 33 | @id = error_object_overrides[:id] || @id 34 | @href = error_object_overrides[:href] || href 35 | 36 | if error_object_overrides[:code] 37 | # :nocov: 38 | @code = if JSONAPI.configuration.use_text_errors 39 | TEXT_ERRORS[error_object_overrides[:code]] 40 | else 41 | error_object_overrides[:code] 42 | end 43 | # :nocov: 44 | end 45 | 46 | @source = error_object_overrides[:source] || @source 47 | @links = error_object_overrides[:links] || @links 48 | 49 | if error_object_overrides[:status] 50 | # :nocov: 51 | @status = Rack::Utils::SYMBOL_TO_STATUS_CODE[error_object_overrides[:status]].to_s 52 | # :nocov: 53 | end 54 | @meta = error_object_overrides[:meta] || @meta 55 | end 56 | end 57 | 58 | class Warning 59 | attr_accessor :title, :detail, :code 60 | def initialize(options = {}) 61 | @title = options[:title] 62 | @detail = options[:detail] 63 | @code = if JSONAPI.configuration.use_text_errors 64 | TEXT_ERRORS[options[:code]] 65 | else 66 | options[:code] 67 | end 68 | end 69 | 70 | def to_hash 71 | hash = {} 72 | instance_variables.each {|var| hash[var.to_s.delete('@')] = instance_variable_get(var) unless instance_variable_get(var).nil? } 73 | hash 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/jsonapi/error_codes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | VALIDATION_ERROR = '100' 5 | INVALID_RESOURCE = '101' 6 | FILTER_NOT_ALLOWED = '102' 7 | INVALID_FIELD_VALUE = '103' 8 | INVALID_FIELD = '104' 9 | PARAM_NOT_ALLOWED = '105' 10 | PARAM_MISSING = '106' 11 | INVALID_FILTER_VALUE = '107' 12 | KEY_ORDER_MISMATCH = '109' 13 | KEY_NOT_INCLUDED_IN_URL = '110' 14 | INVALID_INCLUDE = '112' 15 | RELATION_EXISTS = '113' 16 | INVALID_SORT_CRITERIA = '114' 17 | INVALID_LINKS_OBJECT = '115' 18 | TYPE_MISMATCH = '116' 19 | INVALID_PAGE_OBJECT = '117' 20 | INVALID_PAGE_VALUE = '118' 21 | INVALID_FIELD_FORMAT = '119' 22 | INVALID_FILTERS_SYNTAX = '120' 23 | SAVE_FAILED = '121' 24 | INVALID_DATA_FORMAT = '122' 25 | INVALID_RELATIONSHIP = '123' 26 | BAD_REQUEST = '400' 27 | FORBIDDEN = '403' 28 | RECORD_NOT_FOUND = '404' 29 | NOT_ACCEPTABLE = '406' 30 | UNSUPPORTED_MEDIA_TYPE = '415' 31 | LOCKED = '423' 32 | INTERNAL_SERVER_ERROR = '500' 33 | 34 | TEXT_ERRORS = 35 | { VALIDATION_ERROR => 'VALIDATION_ERROR', 36 | INVALID_RESOURCE => 'INVALID_RESOURCE', 37 | FILTER_NOT_ALLOWED => 'FILTER_NOT_ALLOWED', 38 | INVALID_FIELD_VALUE => 'INVALID_FIELD_VALUE', 39 | INVALID_FIELD => 'INVALID_FIELD', 40 | PARAM_NOT_ALLOWED => 'PARAM_NOT_ALLOWED', 41 | PARAM_MISSING => 'PARAM_MISSING', 42 | INVALID_FILTER_VALUE => 'INVALID_FILTER_VALUE', 43 | KEY_ORDER_MISMATCH => 'KEY_ORDER_MISMATCH', 44 | KEY_NOT_INCLUDED_IN_URL => 'KEY_NOT_INCLUDED_IN_URL', 45 | INVALID_INCLUDE => 'INVALID_INCLUDE', 46 | RELATION_EXISTS => 'RELATION_EXISTS', 47 | INVALID_SORT_CRITERIA => 'INVALID_SORT_CRITERIA', 48 | INVALID_LINKS_OBJECT => 'INVALID_LINKS_OBJECT', 49 | TYPE_MISMATCH => 'TYPE_MISMATCH', 50 | INVALID_PAGE_OBJECT => 'INVALID_PAGE_OBJECT', 51 | INVALID_PAGE_VALUE => 'INVALID_PAGE_VALUE', 52 | INVALID_FIELD_FORMAT => 'INVALID_FIELD_FORMAT', 53 | INVALID_FILTERS_SYNTAX => 'INVALID_FILTERS_SYNTAX', 54 | SAVE_FAILED => 'SAVE_FAILED', 55 | INVALID_DATA_FORMAT => 'INVALID_DATA_FORMAT', 56 | INVALID_RELATIONSHIP => 'INVALID_RELATIONSHIP', 57 | FORBIDDEN => 'FORBIDDEN', 58 | RECORD_NOT_FOUND => 'RECORD_NOT_FOUND', 59 | NOT_ACCEPTABLE => 'NOT_ACCEPTABLE', 60 | UNSUPPORTED_MEDIA_TYPE => 'UNSUPPORTED_MEDIA_TYPE', 61 | LOCKED => 'LOCKED', 62 | INTERNAL_SERVER_ERROR => 'INTERNAL_SERVER_ERROR' 63 | } 64 | end 65 | -------------------------------------------------------------------------------- /lib/jsonapi/formatter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | class Formatter 5 | class << self 6 | def format(arg) 7 | arg.to_s 8 | end 9 | 10 | def unformat(arg) 11 | arg 12 | end 13 | 14 | def cached 15 | return FormatterWrapperCache.new(self) 16 | end 17 | 18 | def uncached 19 | return self 20 | end 21 | 22 | def formatter_for(format) 23 | "#{format.to_s.camelize}Formatter".safe_constantize 24 | end 25 | end 26 | end 27 | 28 | class KeyFormatter < Formatter 29 | class << self 30 | def format(key) 31 | super 32 | end 33 | 34 | def unformat(formatted_key) 35 | super 36 | end 37 | end 38 | end 39 | 40 | class RouteFormatter < Formatter 41 | class << self 42 | def format(route) 43 | super 44 | end 45 | 46 | def unformat(formatted_route) 47 | super 48 | end 49 | end 50 | end 51 | 52 | class ValueFormatter < Formatter 53 | class << self 54 | def format(raw_value) 55 | super(raw_value) 56 | end 57 | 58 | def unformat(value) 59 | super(value) 60 | end 61 | 62 | def value_formatter_for(type) 63 | "#{type.to_s.camelize}ValueFormatter".safe_constantize 64 | end 65 | end 66 | end 67 | 68 | # Warning: Not thread-safe. Wrap in ThreadLocalVar as needed. 69 | class FormatterWrapperCache 70 | attr_reader :formatter_klass 71 | 72 | def initialize(formatter_klass) 73 | @formatter_klass = formatter_klass 74 | @format_cache = NaiveCache.new{|arg| formatter_klass.format(arg) } 75 | @unformat_cache = NaiveCache.new{|arg| formatter_klass.unformat(arg) } 76 | end 77 | 78 | def format(arg) 79 | @format_cache.get(arg) 80 | end 81 | 82 | def unformat(arg) 83 | @unformat_cache.get(arg) 84 | end 85 | 86 | def cached 87 | self 88 | end 89 | 90 | def uncached 91 | return @formatter_klass 92 | end 93 | end 94 | end 95 | 96 | class UnderscoredKeyFormatter < JSONAPI::KeyFormatter 97 | end 98 | 99 | class CamelizedKeyFormatter < JSONAPI::KeyFormatter 100 | class << self 101 | def format(key) 102 | super.camelize(:lower) 103 | end 104 | 105 | def unformat(formatted_key) 106 | formatted_key.to_s.underscore 107 | end 108 | end 109 | end 110 | 111 | class DasherizedKeyFormatter < JSONAPI::KeyFormatter 112 | class << self 113 | def format(_key) 114 | super.underscore.dasherize 115 | end 116 | 117 | def unformat(formatted_key) 118 | formatted_key.to_s.underscore 119 | end 120 | end 121 | end 122 | 123 | class DefaultValueFormatter < JSONAPI::ValueFormatter 124 | class << self 125 | def format(raw_value) 126 | case raw_value 127 | when Date, Time, DateTime, ActiveSupport::TimeWithZone, BigDecimal 128 | # Use the as_json methods added to various base classes by ActiveSupport 129 | return raw_value.as_json 130 | else 131 | return raw_value 132 | end 133 | end 134 | end 135 | end 136 | 137 | class IdValueFormatter < JSONAPI::ValueFormatter 138 | class << self 139 | def format(raw_value) 140 | return if raw_value.nil? 141 | raw_value.to_s 142 | end 143 | end 144 | end 145 | 146 | class UnderscoredRouteFormatter < JSONAPI::RouteFormatter 147 | end 148 | 149 | class CamelizedRouteFormatter < JSONAPI::RouteFormatter 150 | class << self 151 | def format(_route) 152 | super.camelize(:lower) 153 | end 154 | 155 | def unformat(formatted_route) 156 | formatted_route.to_s.underscore 157 | end 158 | end 159 | end 160 | 161 | class DasherizedRouteFormatter < JSONAPI::RouteFormatter 162 | class << self 163 | def format(_route) 164 | super.dasherize 165 | end 166 | 167 | def unformat(formatted_route) 168 | formatted_route.to_s.underscore 169 | end 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /lib/jsonapi/include_directives.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | class IncludeDirectives 5 | # Construct an IncludeDirectives Hash from an array of dot separated include strings. 6 | # For example ['posts.comments.tags'] 7 | # will transform into => 8 | # { 9 | # posts: { 10 | # include_related: { 11 | # comments:{ 12 | # include_related: { 13 | # tags: { 14 | # include_related: {} 15 | # } 16 | # } 17 | # } 18 | # } 19 | # } 20 | # } 21 | 22 | def initialize(resource_klass, includes_array) 23 | @resource_klass = resource_klass 24 | @include_directives_hash = { include_related: {} } 25 | includes_array.each do |include| 26 | parse_include(include) 27 | end 28 | end 29 | 30 | def [](name) 31 | @include_directives_hash[name] 32 | end 33 | 34 | private 35 | 36 | def parse_include(include) 37 | path = JSONAPI::Path.new(resource_klass: @resource_klass, 38 | path_string: include, 39 | ensure_default_field: false, 40 | parse_fields: false) 41 | 42 | current = @include_directives_hash 43 | 44 | path.segments.each do |segment| 45 | relationship_name = segment.relationship.name.to_sym 46 | 47 | current[:include_related][relationship_name] ||= { include_related: {} } 48 | current = current[:include_related][relationship_name] 49 | end 50 | 51 | rescue JSONAPI::Exceptions::InvalidRelationship => _e 52 | raise JSONAPI::Exceptions::InvalidInclude.new(@resource_klass, include) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/jsonapi/link_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | class LinkBuilder 5 | attr_reader :base_url, 6 | :primary_resource_klass, 7 | :route_formatter, 8 | :engine, 9 | :engine_mount_point, 10 | :url_helpers 11 | 12 | @@url_helper_methods = {} 13 | 14 | def initialize(config = {}) 15 | @base_url = config[:base_url] 16 | @primary_resource_klass = config[:primary_resource_klass] 17 | @route_formatter = config[:route_formatter] 18 | @engine = build_engine 19 | @engine_mount_point = @engine ? @engine.routes.find_script_name({}) : "" 20 | 21 | # url_helpers may be either a controller which has the route helper methods, or the application router's 22 | # url helpers module, `Rails.application.routes.url_helpers`. Because the method no longer behaves as a 23 | # singleton, and it's expensive to generate the module, the controller is preferred. 24 | @url_helpers = config[:url_helpers] 25 | end 26 | 27 | def engine? 28 | !!@engine 29 | end 30 | 31 | def primary_resources_url 32 | if @primary_resource_klass._routed 33 | primary_resources_path = resources_path(primary_resource_klass) 34 | @primary_resources_url_cached ||= "#{ base_url }#{ engine_mount_point }#{ primary_resources_path }" 35 | else 36 | if JSONAPI.configuration.warn_on_missing_routes && !@primary_resource_klass._warned_missing_route 37 | warn "primary_resources_url for #{@primary_resource_klass} could not be generated" 38 | @primary_resource_klass._warned_missing_route = true 39 | end 40 | nil 41 | end 42 | end 43 | 44 | def query_link(query_params) 45 | url = primary_resources_url 46 | return url if url.nil? 47 | "#{ url }?#{ query_params.to_query }" 48 | end 49 | 50 | def relationships_related_link(source, relationship, query_params = {}) 51 | if relationship._routed 52 | url = "#{ self_link(source) }/#{ route_for_relationship(relationship) }" 53 | url = "#{ url }?#{ query_params.to_query }" if query_params.present? 54 | url 55 | else 56 | if JSONAPI.configuration.warn_on_missing_routes && !relationship._warned_missing_route 57 | warn "related_link for #{relationship} could not be generated" 58 | relationship._warned_missing_route = true 59 | end 60 | nil 61 | end 62 | end 63 | 64 | def relationships_self_link(source, relationship) 65 | if relationship._routed 66 | "#{ self_link(source) }/relationships/#{ route_for_relationship(relationship) }" 67 | else 68 | if JSONAPI.configuration.warn_on_missing_routes && !relationship._warned_missing_route 69 | warn "self_link for #{relationship} could not be generated" 70 | relationship._warned_missing_route = true 71 | end 72 | nil 73 | end 74 | end 75 | 76 | def self_link(source) 77 | if source.class._routed 78 | resource_url(source) 79 | else 80 | if JSONAPI.configuration.warn_on_missing_routes && !source.class._warned_missing_route 81 | warn "self_link for #{source.class} could not be generated" 82 | source.class._warned_missing_route = true 83 | end 84 | nil 85 | end 86 | end 87 | 88 | private 89 | 90 | def build_engine 91 | scopes = module_scopes_from_class(primary_resource_klass) 92 | 93 | begin 94 | unless scopes.empty? 95 | "#{ scopes.first.to_s.camelize }::Engine".safe_constantize 96 | end 97 | 98 | # :nocov: 99 | rescue LoadError => _e 100 | nil 101 | # :nocov: 102 | end 103 | end 104 | 105 | def format_route(route) 106 | route_formatter.format(route) 107 | end 108 | 109 | def formatted_module_path_from_class(klass) 110 | scopes = if @engine 111 | module_scopes_from_class(klass)[1..-1] 112 | else 113 | module_scopes_from_class(klass) 114 | end 115 | 116 | unless scopes.empty? 117 | "/#{ scopes.map {|scope| format_route(scope.to_s.underscore)}.compact.join('/') }/" 118 | else 119 | "/" 120 | end 121 | end 122 | 123 | def module_scopes_from_class(klass) 124 | klass.name.to_s.split("::")[0...-1] 125 | end 126 | 127 | def resources_path(source_klass) 128 | @_resources_path ||= {} 129 | @_resources_path[source_klass] ||= formatted_module_path_from_class(source_klass) + format_route(source_klass._type.to_s) 130 | end 131 | 132 | def resource_path(source) 133 | if source.class.singleton? 134 | resources_path(source.class) 135 | else 136 | "#{resources_path(source.class)}/#{source.id}" 137 | end 138 | end 139 | 140 | def resource_url(source) 141 | "#{ base_url }#{ engine_mount_point }#{ resource_path(source) }" 142 | end 143 | 144 | def route_for_relationship(relationship) 145 | format_route(relationship.name) 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /lib/jsonapi/mime_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | 5 | module JSONAPI 6 | MEDIA_TYPE = 'application/vnd.api+json' 7 | 8 | module MimeTypes 9 | def self.install 10 | Mime::Type.register JSONAPI::MEDIA_TYPE, :api_json 11 | 12 | parsers = ActionDispatch::Request.parameter_parsers.merge( 13 | Mime::Type.lookup(JSONAPI::MEDIA_TYPE).symbol => parser 14 | ) 15 | ActionDispatch::Request.parameter_parsers = parsers 16 | end 17 | 18 | def self.parser 19 | lambda do |body| 20 | begin 21 | data = JSON.parse(body) 22 | if data.is_a?(Hash) 23 | data.with_indifferent_access 24 | else 25 | fail JSONAPI::Exceptions::InvalidRequestFormat.new 26 | end 27 | rescue JSON::ParserError => e 28 | { _parser_exception: JSONAPI::Exceptions::BadRequest.new(e.to_s) } 29 | rescue => e 30 | { _parser_exception: e } 31 | end 32 | end 33 | end 34 | end 35 | 36 | MimeTypes.install 37 | end 38 | -------------------------------------------------------------------------------- /lib/jsonapi/naive_cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | 5 | # Cache which memoizes the given block. 6 | # 7 | # It's "naive" because it clears the least-recently-inserted cache entry 8 | # rather than the least-recently-used. This makes lookups faster but cache 9 | # misses more frequent after cleanups. Therefore you the best time to use 10 | # this cache is when you expect only a small number of unique lookup keys, so 11 | # that the cache never has to clear. 12 | # 13 | # Also, it's not thread safe (although jsonapi-resources is careful to only 14 | # use it in a thread safe way). 15 | class NaiveCache 16 | def initialize(cap = 10000, &calculator) 17 | @cap = cap 18 | @data = {} 19 | @calculator = calculator 20 | end 21 | 22 | def get(key) 23 | found = true 24 | value = @data.fetch(key) { found = false } 25 | return value if found 26 | value = @calculator.call(key) 27 | @data[key] = value 28 | @data.shift if @data.length > @cap 29 | return value 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/jsonapi/operation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | class Operation 5 | attr_reader :resource_klass, :operation_type, :options 6 | 7 | def initialize(operation_type, resource_klass, options) 8 | @operation_type = operation_type 9 | @resource_klass = resource_klass 10 | @options = options 11 | end 12 | 13 | def process 14 | processor.process 15 | end 16 | 17 | private 18 | def processor 19 | self.class.processor_instance_for(resource_klass, operation_type, options) 20 | end 21 | 22 | class << self 23 | def processor_instance_for(resource_klass, operation_type, params) 24 | _processor_from_resource_type(resource_klass).new(resource_klass, operation_type, params) 25 | end 26 | 27 | def _processor_from_resource_type(resource_klass) 28 | processor = resource_klass.name.gsub(/Resource$/,'Processor').safe_constantize 29 | if processor.nil? 30 | processor = JSONAPI.configuration.default_processor_klass 31 | end 32 | 33 | return processor 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/jsonapi/operation_result.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | class OperationResult 5 | attr_accessor :code 6 | attr_accessor :meta 7 | attr_accessor :links 8 | attr_accessor :options 9 | attr_accessor :warnings 10 | 11 | def initialize(code, options = {}) 12 | @code = Rack::Utils.status_code(code) 13 | @options = options 14 | @meta = options.fetch(:meta, {}) 15 | @links = options.fetch(:links, {}) 16 | @warnings = options.fetch(:warnings, {}) 17 | end 18 | 19 | def to_hash(serializer = nil) 20 | {} 21 | end 22 | end 23 | 24 | class ErrorsOperationResult < OperationResult 25 | attr_accessor :errors 26 | 27 | def initialize(code, errors, options = {}) 28 | @errors = errors 29 | super(code, options) 30 | end 31 | 32 | def to_hash(serializer = nil) 33 | { 34 | errors: errors.collect do |error| 35 | # :nocov: 36 | error.to_hash 37 | # :nocov: 38 | end 39 | } 40 | end 41 | end 42 | 43 | class ResourceSetOperationResult < OperationResult 44 | attr_accessor :resource_set, :pagination_params 45 | 46 | def initialize(code, resource_set, options = {}) 47 | @resource_set = resource_set 48 | @pagination_params = options.fetch(:pagination_params, {}) 49 | super(code, options) 50 | end 51 | 52 | def to_hash(serializer) 53 | if serializer 54 | serializer.serialize_resource_set_to_hash_single(resource_set) 55 | else 56 | # :nocov: 57 | {} 58 | # :nocov: 59 | end 60 | end 61 | end 62 | 63 | class ResourcesSetOperationResult < OperationResult 64 | attr_accessor :resource_set, :pagination_params, :record_count, :page_count 65 | 66 | def initialize(code, resource_set, options = {}) 67 | @resource_set = resource_set 68 | @pagination_params = options.fetch(:pagination_params, {}) 69 | @record_count = options[:record_count] 70 | @page_count = options[:page_count] 71 | super(code, options) 72 | end 73 | 74 | def to_hash(serializer) 75 | if serializer 76 | serializer.serialize_resource_set_to_hash_plural(resource_set) 77 | else 78 | # :nocov: 79 | {} 80 | # :nocov: 81 | end 82 | end 83 | end 84 | 85 | class RelatedResourcesSetOperationResult < ResourcesSetOperationResult 86 | attr_accessor :resource_set, :source_resource, :_type 87 | 88 | def initialize(code, source_resource, type, resource_set, options = {}) 89 | @source_resource = source_resource 90 | @_type = type 91 | super(code, resource_set, options) 92 | end 93 | 94 | def to_hash(serializer = nil) 95 | if serializer 96 | serializer.serialize_related_resource_set_to_hash_plural(resource_set, source_resource) 97 | else 98 | # :nocov: 99 | {} 100 | # :nocov: 101 | end 102 | end 103 | end 104 | 105 | class RelationshipOperationResult < OperationResult 106 | attr_accessor :parent_resource, :relationship, :resource_ids 107 | 108 | def initialize(code, parent_resource, relationship, resource_ids, options = {}) 109 | @parent_resource = parent_resource 110 | @relationship = relationship 111 | @resource_ids = resource_ids 112 | super(code, options) 113 | end 114 | 115 | def to_hash(serializer = nil) 116 | if serializer 117 | serializer.serialize_to_relationship_hash(parent_resource, relationship, resource_ids) 118 | else 119 | # :nocov: 120 | {} 121 | # :nocov: 122 | end 123 | end 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /lib/jsonapi/paginator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | class Paginator 5 | def initialize(_params) 6 | end 7 | 8 | def apply(_relation, _order_options) 9 | # relation 10 | end 11 | 12 | def links_page_params(_options = {}) 13 | # :nocov: 14 | {} 15 | # :nocov: 16 | end 17 | 18 | class << self 19 | def requires_record_count 20 | # :nocov: 21 | false 22 | # :nocov: 23 | end 24 | 25 | def paginator_for(paginator) 26 | paginator_class_name = "#{paginator.to_s.camelize}Paginator" 27 | paginator_class_name.safe_constantize if paginator_class_name 28 | end 29 | end 30 | end 31 | end 32 | 33 | class OffsetPaginator < JSONAPI::Paginator 34 | attr_reader :limit, :offset 35 | 36 | def initialize(params) 37 | parse_pagination_params(params) 38 | verify_pagination_params 39 | end 40 | 41 | def self.requires_record_count 42 | true 43 | end 44 | 45 | def apply(relation, _order_options) 46 | relation.offset(@offset).limit(@limit) 47 | end 48 | 49 | def links_page_params(options = {}) 50 | record_count = options[:record_count] 51 | links_page_params = {} 52 | 53 | links_page_params['first'] = { 54 | 'offset' => 0, 55 | 'limit' => @limit 56 | } 57 | 58 | if @offset > 0 59 | previous_offset = @offset - @limit 60 | 61 | previous_offset = 0 if previous_offset < 0 62 | 63 | links_page_params['prev'] = { 64 | 'offset' => previous_offset, 65 | 'limit' => @limit 66 | } 67 | end 68 | 69 | next_offset = @offset + @limit 70 | 71 | unless next_offset >= record_count 72 | links_page_params['next'] = { 73 | 'offset' => next_offset, 74 | 'limit' => @limit 75 | } 76 | end 77 | 78 | if record_count 79 | last_offset = record_count - @limit 80 | 81 | last_offset = 0 if last_offset < 0 82 | 83 | links_page_params['last'] = { 84 | 'offset' => last_offset, 85 | 'limit' => @limit 86 | } 87 | end 88 | 89 | links_page_params 90 | end 91 | 92 | private 93 | 94 | def parse_pagination_params(params) 95 | if params.nil? 96 | @offset = 0 97 | @limit = JSONAPI.configuration.default_page_size 98 | elsif params.is_a?(ActionController::Parameters) 99 | validparams = params.permit(:offset, :limit) 100 | 101 | @offset = validparams[:offset] ? validparams[:offset].to_i : 0 102 | @limit = validparams[:limit] ? validparams[:limit].to_i : JSONAPI.configuration.default_page_size 103 | else 104 | fail JSONAPI::Exceptions::InvalidPageObject.new 105 | end 106 | rescue ActionController::UnpermittedParameters => e 107 | raise JSONAPI::Exceptions::PageParametersNotAllowed.new(e.params) 108 | end 109 | 110 | def verify_pagination_params 111 | if @limit < 1 112 | fail JSONAPI::Exceptions::InvalidPageValue.new(:limit, @limit) 113 | elsif @limit > JSONAPI.configuration.maximum_page_size 114 | fail JSONAPI::Exceptions::InvalidPageValue.new(:limit, @limit, 115 | detail: "Limit exceeds maximum page size of #{JSONAPI.configuration.maximum_page_size}.") 116 | end 117 | 118 | if @offset < 0 119 | fail JSONAPI::Exceptions::InvalidPageValue.new(:offset, @offset) 120 | end 121 | end 122 | end 123 | 124 | class PagedPaginator < JSONAPI::Paginator 125 | attr_reader :size, :number 126 | 127 | def initialize(params) 128 | parse_pagination_params(params) 129 | verify_pagination_params 130 | end 131 | 132 | def self.requires_record_count 133 | true 134 | end 135 | 136 | def calculate_page_count(record_count) 137 | (record_count / @size.to_f).ceil 138 | end 139 | 140 | def apply(relation, _order_options) 141 | offset = (@number - 1) * @size 142 | relation.offset(offset).limit(@size) 143 | end 144 | 145 | def links_page_params(options = {}) 146 | record_count = options[:record_count] 147 | page_count = calculate_page_count(record_count) 148 | 149 | links_page_params = {} 150 | 151 | links_page_params['first'] = { 152 | 'number' => 1, 153 | 'size' => @size 154 | } 155 | 156 | if @number > 1 157 | links_page_params['prev'] = { 158 | 'number' => @number - 1, 159 | 'size' => @size 160 | } 161 | end 162 | 163 | unless @number >= page_count 164 | links_page_params['next'] = { 165 | 'number' => @number + 1, 166 | 'size' => @size 167 | } 168 | end 169 | 170 | if record_count 171 | links_page_params['last'] = { 172 | 'number' => page_count == 0 ? 1 : page_count, 173 | 'size' => @size 174 | } 175 | end 176 | 177 | links_page_params 178 | end 179 | 180 | private 181 | 182 | def parse_pagination_params(params) 183 | if params.nil? 184 | @number = 1 185 | @size = JSONAPI.configuration.default_page_size 186 | elsif params.is_a?(ActionController::Parameters) 187 | validparams = params.permit(:number, :size) 188 | 189 | @size = validparams[:size] ? validparams[:size].to_i : JSONAPI.configuration.default_page_size 190 | @number = validparams[:number] ? validparams[:number].to_i : 1 191 | else 192 | @size = JSONAPI.configuration.default_page_size 193 | @number = params.to_i 194 | end 195 | rescue ActionController::UnpermittedParameters => e 196 | raise JSONAPI::Exceptions::PageParametersNotAllowed.new(e.params) 197 | end 198 | 199 | def verify_pagination_params 200 | if @size < 1 201 | fail JSONAPI::Exceptions::InvalidPageValue.new(:size, @size) 202 | elsif @size > JSONAPI.configuration.maximum_page_size 203 | fail JSONAPI::Exceptions::InvalidPageValue.new(:size, @size, 204 | detail: "size exceeds maximum page size of #{JSONAPI.configuration.maximum_page_size}.") 205 | end 206 | 207 | if @number < 1 208 | fail JSONAPI::Exceptions::InvalidPageValue.new(:number, @number) 209 | end 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /lib/jsonapi/path.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | class Path 5 | attr_reader :segments, :resource_klass 6 | def initialize(resource_klass:, 7 | path_string:, 8 | ensure_default_field: true, 9 | parse_fields: true) 10 | @resource_klass = resource_klass 11 | 12 | current_resource_klass = resource_klass 13 | @segments = path_string.to_s.split('.').collect do |segment_string| 14 | segment = PathSegment.parse(source_resource_klass: current_resource_klass, 15 | segment_string: segment_string, 16 | parse_fields: parse_fields) 17 | 18 | current_resource_klass = segment.resource_klass 19 | segment 20 | end 21 | 22 | if ensure_default_field && parse_fields && @segments.last.is_a?(PathSegment::Relationship) 23 | last = @segments.last 24 | @segments << PathSegment::Field.new(resource_klass: last.resource_klass, 25 | field_name: last.resource_klass._primary_key) 26 | end 27 | end 28 | 29 | def relationship_segments 30 | @segments.select {|p| p.is_a?(PathSegment::Relationship)} 31 | end 32 | 33 | def relationship_path_string 34 | relationship_segments.collect(&:to_s).join('.') 35 | end 36 | 37 | def last_relationship 38 | if @segments.last.is_a?(PathSegment::Relationship) 39 | @segments.last 40 | else 41 | @segments[-2] 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/jsonapi/path_segment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | class PathSegment 5 | def self.parse(source_resource_klass:, segment_string:, parse_fields: true) 6 | first_part, last_part = segment_string.split('#', 2) 7 | relationship = source_resource_klass._relationship(first_part) 8 | 9 | if relationship 10 | if last_part 11 | unless relationship.resource_types.include?(last_part) 12 | raise JSONAPI::Exceptions::InvalidRelationship.new(source_resource_klass._type, segment_string) 13 | end 14 | resource_klass = source_resource_klass.resource_klass_for(last_part) 15 | end 16 | return PathSegment::Relationship.new(relationship: relationship, resource_klass: resource_klass) 17 | else 18 | if last_part.blank? && parse_fields 19 | return PathSegment::Field.new(resource_klass: source_resource_klass, field_name: first_part) 20 | else 21 | raise JSONAPI::Exceptions::InvalidRelationship.new(source_resource_klass._type, segment_string) 22 | end 23 | end 24 | end 25 | 26 | class Relationship 27 | attr_reader :relationship, :resource_klass 28 | 29 | def initialize(relationship:, resource_klass: nil) 30 | @relationship = relationship 31 | @resource_klass = resource_klass 32 | end 33 | 34 | def eql?(other) 35 | other.is_a?(self.class) && relationship == other.relationship && resource_klass == other.resource_klass 36 | end 37 | 38 | def hash 39 | [relationship, resource_klass].hash 40 | end 41 | 42 | def to_s 43 | @resource_klass ? "#{relationship.parent_resource_klass._type}.#{relationship.name}##{resource_klass._type}" : "#{resource_klass._type}.#{relationship.name}" 44 | end 45 | 46 | def resource_klass 47 | @resource_klass || relationship.resource_klass 48 | end 49 | 50 | def path_specified_resource_klass? 51 | !@resource_klass.nil? 52 | end 53 | end 54 | 55 | class Field 56 | attr_reader :resource_klass, :field_name 57 | 58 | def initialize(resource_klass:, field_name:) 59 | @resource_klass = resource_klass 60 | @field_name = field_name 61 | end 62 | 63 | def eql?(other) 64 | other.is_a?(self.class) && field_name == other.field_name && resource_klass == other.resource_klass 65 | end 66 | 67 | def delegated_field_name 68 | resource_klass._attribute_delegated_name(field_name) 69 | end 70 | 71 | def to_s 72 | # :nocov: 73 | "#{resource_klass._type}.#{field_name.to_s}" 74 | # :nocov: 75 | end 76 | end 77 | end 78 | end -------------------------------------------------------------------------------- /lib/jsonapi/relationship.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | class Relationship 5 | attr_reader :acts_as_set, :foreign_key, :options, :name, 6 | :class_name, :polymorphic, :always_include_optional_linkage_data, 7 | :parent_resource, :eager_load_on_include, :custom_methods, 8 | :inverse_relationship, :allow_include 9 | 10 | attr_writer :allow_include 11 | 12 | attr_accessor :_routed, :_warned_missing_route 13 | 14 | def initialize(name, options = {}) 15 | @name = name.to_s 16 | @options = options 17 | @acts_as_set = options.fetch(:acts_as_set, false) == true 18 | @foreign_key = options[:foreign_key] ? options[:foreign_key].to_sym : nil 19 | @parent_resource = options[:parent_resource] 20 | @relation_name = options.fetch(:relation_name, @name) 21 | @polymorphic = options.fetch(:polymorphic, false) == true 22 | @polymorphic_types = options[:polymorphic_types] 23 | if options[:polymorphic_relations] 24 | ActiveSupport::Deprecation.warn('Use polymorphic_types instead of polymorphic_relations') 25 | @polymorphic_types ||= options[:polymorphic_relations] 26 | end 27 | 28 | @always_include_optional_linkage_data = options.fetch(:always_include_optional_linkage_data, false) == true 29 | @eager_load_on_include = options.fetch(:eager_load_on_include, true) == true 30 | @allow_include = options[:allow_include] 31 | @class_name = nil 32 | @inverse_relationship = nil 33 | 34 | @_routed = false 35 | @_warned_missing_route = false 36 | 37 | exclude_links(options.fetch(:exclude_links, JSONAPI.configuration.default_exclude_links)) 38 | 39 | # Custom methods are reserved for future use 40 | @custom_methods = options.fetch(:custom_methods, {}) 41 | end 42 | 43 | alias_method :polymorphic?, :polymorphic 44 | alias_method :parent_resource_klass, :parent_resource 45 | 46 | def primary_key 47 | # :nocov: 48 | @primary_key ||= resource_klass._primary_key 49 | # :nocov: 50 | end 51 | 52 | def resource_klass 53 | @resource_klass ||= @parent_resource.resource_klass_for(@class_name) 54 | end 55 | 56 | def table_name 57 | # :nocov: 58 | @table_name ||= resource_klass._table_name 59 | # :nocov: 60 | end 61 | 62 | def self.polymorphic_types(name) 63 | @poly_hash ||= {}.tap do |hash| 64 | ObjectSpace.each_object do |klass| 65 | next unless Module === klass 66 | if ActiveRecord::Base > klass 67 | klass.reflect_on_all_associations(:has_many).select{|r| r.options[:as] }.each do |reflection| 68 | (hash[reflection.options[:as]] ||= []) << klass.name.downcase 69 | end 70 | end 71 | end 72 | end 73 | @poly_hash[name.to_sym] 74 | end 75 | 76 | def resource_types 77 | if polymorphic? && belongs_to? 78 | @polymorphic_types ||= self.class.polymorphic_types(@relation_name).collect {|t| t.pluralize} 79 | else 80 | [resource_klass._type.to_s.pluralize] 81 | end 82 | end 83 | 84 | def type 85 | @type ||= resource_klass._type.to_sym 86 | end 87 | 88 | def relation_name(options) 89 | case @relation_name 90 | when Symbol 91 | # :nocov: 92 | @relation_name 93 | # :nocov: 94 | when String 95 | @relation_name.to_sym 96 | when Proc 97 | @relation_name.call(options) 98 | end 99 | end 100 | 101 | def belongs_to? 102 | # :nocov: 103 | false 104 | # :nocov: 105 | end 106 | 107 | def readonly? 108 | @options[:readonly] 109 | end 110 | 111 | def exclude_links(exclude) 112 | case exclude 113 | when :default, "default" 114 | @_exclude_links = [:self, :related] 115 | when :none, "none" 116 | @_exclude_links = [] 117 | when Array 118 | @_exclude_links = exclude.collect {|link| link.to_sym} 119 | else 120 | fail "Invalid exclude_links" 121 | end 122 | end 123 | 124 | def _exclude_links 125 | @_exclude_links ||= [] 126 | end 127 | 128 | def exclude_link?(link) 129 | _exclude_links.include?(link.to_sym) 130 | end 131 | 132 | class ToOne < Relationship 133 | attr_reader :foreign_key_on 134 | 135 | def initialize(name, options = {}) 136 | super 137 | @class_name = options.fetch(:class_name, name.to_s.camelize) 138 | @foreign_key ||= "#{name}_id".to_sym 139 | @foreign_key_on = options.fetch(:foreign_key_on, :self) 140 | if parent_resource 141 | @inverse_relationship = options.fetch(:inverse_relationship, parent_resource._type) 142 | end 143 | end 144 | 145 | def to_s 146 | # :nocov: useful for debugging 147 | "#{parent_resource}.#{name}(#{belongs_to? ? 'BelongsToOne' : 'ToOne'})" 148 | # :nocov: 149 | end 150 | 151 | def belongs_to? 152 | # :nocov: 153 | foreign_key_on == :self 154 | # :nocov: 155 | end 156 | 157 | def polymorphic_type 158 | "#{name}_type" if polymorphic? 159 | end 160 | 161 | def include_optional_linkage_data? 162 | @always_include_optional_linkage_data || JSONAPI::configuration.always_include_to_one_linkage_data 163 | end 164 | 165 | def allow_include?(context = nil) 166 | strategy = if @allow_include.nil? 167 | JSONAPI.configuration.default_allow_include_to_one 168 | else 169 | @allow_include 170 | end 171 | 172 | if !!strategy == strategy #check for boolean 173 | return strategy 174 | elsif strategy.is_a?(Symbol) || strategy.is_a?(String) 175 | parent_resource.send(strategy, context) 176 | else 177 | strategy.call(context) 178 | end 179 | end 180 | end 181 | 182 | class ToMany < Relationship 183 | attr_reader :reflect 184 | 185 | def initialize(name, options = {}) 186 | super 187 | @class_name = options.fetch(:class_name, name.to_s.camelize.singularize) 188 | @foreign_key ||= "#{name.to_s.singularize}_ids".to_sym 189 | @reflect = options.fetch(:reflect, true) == true 190 | if parent_resource 191 | @inverse_relationship = options.fetch(:inverse_relationship, parent_resource._type.to_s.singularize.to_sym) 192 | end 193 | end 194 | 195 | def to_s 196 | # :nocov: useful for debugging 197 | "#{parent_resource}.#{name}(ToMany)" 198 | # :nocov: 199 | end 200 | 201 | def include_optional_linkage_data? 202 | # :nocov: 203 | @always_include_optional_linkage_data || JSONAPI::configuration.always_include_to_many_linkage_data 204 | # :nocov: 205 | end 206 | 207 | def allow_include?(context = nil) 208 | strategy = if @allow_include.nil? 209 | JSONAPI.configuration.default_allow_include_to_many 210 | else 211 | @allow_include 212 | end 213 | 214 | if !!strategy == strategy #check for boolean 215 | return strategy 216 | elsif strategy.is_a?(Symbol) || strategy.is_a?(String) 217 | parent_resource.send(strategy, context) 218 | else 219 | strategy.call(context) 220 | end 221 | end 222 | 223 | end 224 | end 225 | end 226 | -------------------------------------------------------------------------------- /lib/jsonapi/resource.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | class Resource < ActiveRelationResource 5 | root_resource 6 | end 7 | end -------------------------------------------------------------------------------- /lib/jsonapi/resource_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | class ResourceController < ActionController::Base 5 | include JSONAPI::ActsAsResourceController 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/jsonapi/resource_controller_metal.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | class ResourceControllerMetal < ActionController::Metal 5 | MODULES = [ 6 | AbstractController::Rendering, 7 | ActionController::Rendering, 8 | ActionController::Renderers::All, 9 | ActionController::StrongParameters, 10 | Gem::Requirement.new('< 6.1').satisfied_by?(ActionPack.gem_version) ? ActionController::ForceSSL : nil, 11 | ActionController::Instrumentation, 12 | JSONAPI::ActsAsResourceController 13 | ].compact.freeze 14 | 15 | # Note, the url_helpers are not loaded. This will prevent links from being generated for resources, and warnings 16 | # will be emitted. Link support can be added by including `Rails.application.routes.url_helpers`, and links 17 | # can be disabled, and warning suppressed, for a resource with `exclude_links :default` 18 | MODULES.each do |mod| 19 | include mod 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/jsonapi/resource_fragment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | 5 | # A ResourceFragment holds a ResourceIdentity and associated partial resource data. 6 | # 7 | # The following partial resource data may be stored 8 | # cache - the value of the cache field for the resource instance 9 | # related - a hash of arrays of related resource identities, grouped by relationship name 10 | # related_from - a set of related resource identities that loaded the fragment 11 | # resource - a resource instance 12 | # 13 | # Todo: optionally use these for faster responses by bypassing model instantiation) 14 | # attributes - resource attributes 15 | 16 | class ResourceFragment 17 | attr_reader :identity, :attributes, :related_from, :related, :resource 18 | 19 | attr_accessor :primary, :cache 20 | 21 | alias :cache_field :cache #ToDo: Rename one or the other 22 | 23 | def initialize(identity, resource: nil, cache: nil, primary: false) 24 | @identity = identity 25 | @cache = cache 26 | @resource = resource 27 | @primary = primary 28 | 29 | @attributes = {} 30 | @related = {} 31 | @related_from = Set.new 32 | end 33 | 34 | def initialize_related(relationship_name) 35 | @related[relationship_name.to_sym] ||= Set.new 36 | end 37 | 38 | def add_related_identity(relationship_name, identity) 39 | initialize_related(relationship_name) 40 | @related[relationship_name.to_sym] << identity if identity 41 | end 42 | 43 | def merge_related_identities(relationship_name, identities) 44 | initialize_related(relationship_name) 45 | @related[relationship_name.to_sym].merge(identities) if identities 46 | end 47 | 48 | def add_related_from(identity) 49 | @related_from << identity 50 | end 51 | 52 | def add_attribute(name, value) 53 | @attributes[name] = value 54 | end 55 | end 56 | end -------------------------------------------------------------------------------- /lib/jsonapi/resource_identity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | 5 | # ResourceIdentity describes a unique identity of a resource in the system. 6 | # This consists of a Resource class and an identifier that is unique within 7 | # that Resource class. ResourceIdentities are intended to be used as hash 8 | # keys to provide ordered mixing of resource types in result sets. 9 | # 10 | # 11 | # == Creating a ResourceIdentity 12 | # 13 | # rid = ResourceIdentity.new(PostResource, 12) 14 | # 15 | class ResourceIdentity 16 | attr_reader :resource_klass, :id 17 | 18 | def initialize(resource_klass, id) 19 | @resource_klass = resource_klass 20 | @id = id 21 | end 22 | 23 | def ==(other) 24 | # :nocov: 25 | eql?(other) 26 | # :nocov: 27 | end 28 | 29 | def eql?(other) 30 | other.is_a?(ResourceIdentity) && other.resource_klass == @resource_klass && other.id == @id 31 | end 32 | 33 | def hash 34 | [@resource_klass, @id].hash 35 | end 36 | 37 | # Creates a string representation of the identifier. 38 | def to_s 39 | # :nocov: 40 | "#{resource_klass}:#{id}" 41 | # :nocov: 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/jsonapi/resource_set.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | # Contains a hash of resource types which contain a hash of resources, relationships and primary status keyed by 5 | # resource id. 6 | class ResourceSet 7 | 8 | attr_reader :resource_klasses, :populated 9 | 10 | def initialize(source, include_related = nil, options = nil) 11 | @populated = false 12 | tree = if source.is_a?(JSONAPI::ResourceTree) 13 | source 14 | elsif source.class < JSONAPI::BasicResource 15 | JSONAPI::PrimaryResourceTree.new(resource: source, include_related: include_related, options: options) 16 | elsif source.is_a?(Array) 17 | JSONAPI::PrimaryResourceTree.new(resources: source, include_related: include_related, options: options) 18 | end 19 | 20 | if tree 21 | @resource_klasses = flatten_resource_tree(tree) 22 | end 23 | end 24 | 25 | def populate!(serializer, context, options) 26 | return if @populated 27 | 28 | # For each resource klass we want to generate the caching key 29 | 30 | # Hash for collecting types and ids 31 | # @type [Hash, Id[]]] 32 | missed_resource_ids = {} 33 | 34 | # Array for collecting CachedResponseFragment::Lookups 35 | # @type [Lookup[]] 36 | lookups = [] 37 | 38 | # Step One collect all of the lookups for the cache, or keys that don't require cache access 39 | @resource_klasses.each_key do |resource_klass| 40 | missed_resource_ids[resource_klass] ||= [] 41 | 42 | serializer_config_key = serializer.config_key(resource_klass).gsub("/", "_") 43 | context_json = resource_klass.attribute_caching_context(context).to_json 44 | context_b64 = JSONAPI.configuration.resource_cache_digest_function.call(context_json) 45 | context_key = "ATTR-CTX-#{context_b64.gsub("/", "_")}" 46 | 47 | if resource_klass.caching? 48 | cache_ids = @resource_klasses[resource_klass].map do |(k, v)| 49 | # Store the hashcode of the cache_field to avoid storing objects and to ensure precision isn't lost 50 | # on timestamp types (i.e. string conversions dropping milliseconds) 51 | [k, resource_klass.hash_cache_field(v[:cache_id])] 52 | end 53 | 54 | lookups.push( 55 | CachedResponseFragment::Lookup.new( 56 | resource_klass, 57 | serializer_config_key, 58 | context, 59 | context_key, 60 | cache_ids 61 | ) 62 | ) 63 | else 64 | @resource_klasses[resource_klass].keys.each do |k| 65 | if @resource_klasses[resource_klass][k][:resource].nil? 66 | missed_resource_ids[resource_klass] << k 67 | else 68 | register_resource(resource_klass, @resource_klasses[resource_klass][k][:resource]) 69 | end 70 | end 71 | end 72 | end 73 | 74 | if lookups.any? 75 | raise "You've declared some Resources as caching without providing a caching store" if JSONAPI.configuration.resource_cache.nil? 76 | 77 | # Step Two execute the cache lookup 78 | found_resources = CachedResponseFragment.lookup(lookups, context) 79 | else 80 | found_resources = {} 81 | end 82 | 83 | # Step Three collect the results and collect hit/miss stats 84 | stats = {} 85 | found_resources.each do |resource_klass, resources| 86 | resources.each do |id, cached_resource| 87 | stats[resource_klass] ||= {} 88 | 89 | if cached_resource.nil? 90 | stats[resource_klass][:misses] ||= 0 91 | stats[resource_klass][:misses] += 1 92 | 93 | # Collect misses 94 | missed_resource_ids[resource_klass].push(id) 95 | else 96 | stats[resource_klass][:hits] ||= 0 97 | stats[resource_klass][:hits] += 1 98 | 99 | register_resource(resource_klass, cached_resource) 100 | end 101 | end 102 | end 103 | 104 | report_stats(stats) 105 | 106 | writes = [] 107 | 108 | # Step Four find any of the missing resources and join them into the result 109 | missed_resource_ids.each_pair do |resource_klass, ids| 110 | next if ids.empty? 111 | 112 | find_opts = {context: context, fields: options[:fields]} 113 | found_resources = resource_klass.find_to_populate_by_keys(ids, find_opts) 114 | 115 | found_resources.each do |resource| 116 | relationship_data = @resource_klasses[resource_klass][resource.id][:relationships] 117 | 118 | if resource_klass.caching? 119 | serializer_config_key = serializer.config_key(resource_klass).gsub("/", "_") 120 | context_json = resource_klass.attribute_caching_context(context).to_json 121 | context_b64 = JSONAPI.configuration.resource_cache_digest_function.call(context_json) 122 | context_key = "ATTR-CTX-#{context_b64.gsub("/", "_")}" 123 | 124 | writes.push(CachedResponseFragment::Write.new( 125 | resource_klass, 126 | resource, 127 | serializer, 128 | serializer_config_key, 129 | context, 130 | context_key, 131 | relationship_data 132 | )) 133 | end 134 | 135 | register_resource(resource_klass, resource) 136 | end 137 | end 138 | 139 | # Step Five conditionally write to the cache 140 | CachedResponseFragment.write(writes) unless JSONAPI.configuration.resource_cache.nil? 141 | 142 | mark_populated! 143 | self 144 | end 145 | 146 | def mark_populated! 147 | @populated = true 148 | end 149 | 150 | def register_resource(resource_klass, resource, primary = false) 151 | @resource_klasses[resource_klass] ||= {} 152 | @resource_klasses[resource_klass][resource.id] ||= {primary: resource.try(:primary) || primary, relationships: {}} 153 | @resource_klasses[resource_klass][resource.id][:resource] = resource 154 | end 155 | 156 | private 157 | 158 | def report_stats(stats) 159 | return unless JSONAPI.configuration.resource_cache_usage_report_function || JSONAPI.configuration.resource_cache.nil? 160 | 161 | stats.each_pair do |resource_klass, stat| 162 | JSONAPI.configuration.resource_cache_usage_report_function.call( 163 | resource_klass.name, 164 | stat[:hits] || 0, 165 | stat[:misses] || 0 166 | ) 167 | end 168 | end 169 | 170 | def flatten_resource_tree(resource_tree, flattened_tree = {}) 171 | resource_tree.fragments.each_pair do |resource_rid, fragment| 172 | 173 | resource_klass = resource_rid.resource_klass 174 | id = resource_rid.id 175 | 176 | flattened_tree[resource_klass] ||= {} 177 | 178 | flattened_tree[resource_klass][id] ||= {primary: fragment.primary, relationships: {}} 179 | flattened_tree[resource_klass][id][:cache_id] ||= fragment.cache if fragment.cache 180 | flattened_tree[resource_klass][id][:resource] ||= fragment.resource if fragment.resource 181 | 182 | fragment.related.try(:each_pair) do |relationship_name, related_rids| 183 | flattened_tree[resource_klass][id][:relationships][relationship_name] ||= Set.new 184 | flattened_tree[resource_klass][id][:relationships][relationship_name].merge(related_rids) 185 | end 186 | end 187 | 188 | related_resource_trees = resource_tree.related_resource_trees 189 | related_resource_trees.try(:each_value) do |related_resource_tree| 190 | flatten_resource_tree(related_resource_tree, flattened_tree) 191 | end 192 | 193 | flattened_tree 194 | end 195 | end 196 | end 197 | -------------------------------------------------------------------------------- /lib/jsonapi/resource_tree.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | 5 | # A tree structure representing the resource structure of the requested resource(s). This is an intermediate structure 6 | # used to keep track of the resources, by identity, found at different included relationships. It will be flattened and 7 | # the resource instances will be fetched from the cache or the record store. 8 | class ResourceTree 9 | 10 | attr_reader :fragments, :related_resource_trees 11 | 12 | # Gets the related Resource Id Tree for a relationship, and creates it first if it does not exist 13 | # 14 | # @param relationship [JSONAPI::Relationship] 15 | # 16 | # @return [JSONAPI::RelatedResourceTree] the new or existing resource id tree for the requested relationship 17 | def get_related_resource_tree(relationship) 18 | relationship_name = relationship.name.to_sym 19 | @related_resource_trees[relationship_name] ||= RelatedResourceTree.new(relationship, self) 20 | end 21 | 22 | # Adds each Resource Fragment to the Resources hash 23 | # 24 | # @param fragments [Hash] 25 | # @param include_related [Hash] 26 | # 27 | # @return [null] 28 | def add_resource_fragments(fragments, include_related) 29 | fragments.each_value do |fragment| 30 | add_resource_fragment(fragment, include_related) 31 | end 32 | end 33 | 34 | # Adds a Resource Fragment to the fragments hash 35 | # 36 | # @param fragment [JSONAPI::ResourceFragment] 37 | # @param include_related [Hash] 38 | # 39 | # @return [null] 40 | def add_resource_fragment(fragment, include_related) 41 | init_included_relationships(fragment, include_related) 42 | 43 | @fragments[fragment.identity] = fragment 44 | end 45 | 46 | # Adds each Resource to the fragments hash 47 | # 48 | # @param resource [Hash] 49 | # @param include_related [Hash] 50 | # 51 | # @return [null] 52 | def add_resources(resources, include_related) 53 | resources.each do |resource| 54 | add_resource_fragment(JSONAPI::ResourceFragment.new(resource.identity, resource: resource), include_related) 55 | end 56 | end 57 | 58 | # Adds a Resource to the fragments hash 59 | # 60 | # @param fragment [JSONAPI::ResourceFragment] 61 | # @param include_related [Hash] 62 | # 63 | # @return [null] 64 | def add_resource(resource, include_related) 65 | add_resource_fragment(JSONAPI::ResourceFragment.new(resource.identity, resource: resource), include_related) 66 | end 67 | 68 | private 69 | 70 | def init_included_relationships(fragment, include_related) 71 | include_related && include_related.each_key do |relationship_name| 72 | fragment.initialize_related(relationship_name) 73 | end 74 | end 75 | 76 | def load_included(resource_klass, source_resource_tree, include_related, options) 77 | include_related.try(:each_key) do |key| 78 | relationship = resource_klass._relationship(key) 79 | relationship_name = relationship.name.to_sym 80 | 81 | find_related_resource_options = options.except(:filters, :sort_criteria, :paginator) 82 | find_related_resource_options[:sort_criteria] = relationship.resource_klass.default_sort 83 | find_related_resource_options[:cache] = resource_klass.caching? 84 | 85 | related_fragments = resource_klass.find_included_fragments(source_resource_tree.fragments.values, 86 | relationship_name, 87 | find_related_resource_options) 88 | 89 | related_resource_tree = source_resource_tree.get_related_resource_tree(relationship) 90 | related_resource_tree.add_resource_fragments(related_fragments, include_related[key][:include_related]) 91 | 92 | # Now recursively get the related resources for the currently found resources 93 | load_included(relationship.resource_klass, 94 | related_resource_tree, 95 | include_related[relationship_name][:include_related], 96 | options) 97 | end 98 | end 99 | 100 | def add_resources_to_tree(resource_klass, 101 | tree, 102 | resources, 103 | include_related, 104 | source_rid: nil, 105 | source_relationship_name: nil, 106 | connect_source_identity: true) 107 | fragments = {} 108 | 109 | resources.each do |resource| 110 | next unless resource 111 | 112 | # fragments[resource.identity] ||= ResourceFragment.new(resource.identity, resource: resource) 113 | # resource_fragment = fragments[resource.identity] 114 | # ToDo: revert when not needed for testing 115 | resource_fragment = if fragments[resource.identity] 116 | fragments[resource.identity] 117 | else 118 | fragments[resource.identity] = ResourceFragment.new(resource.identity, resource: resource) 119 | fragments[resource.identity] 120 | end 121 | 122 | if resource.class.caching? 123 | resource_fragment.cache = resource.cache_field_value 124 | end 125 | 126 | linkage_relationships = resource_klass.to_one_relationships_for_linkage(resource.class, include_related) 127 | linkage_relationships.each do |relationship_name| 128 | related_resource = resource.send(relationship_name) 129 | resource_fragment.add_related_identity(relationship_name, related_resource&.identity) 130 | end 131 | 132 | if source_rid && connect_source_identity 133 | resource_fragment.add_related_from(source_rid) 134 | source_klass = source_rid.resource_klass 135 | related_relationship_name = source_klass._relationships[source_relationship_name].inverse_relationship 136 | if related_relationship_name 137 | resource_fragment.add_related_identity(related_relationship_name, source_rid) 138 | end 139 | end 140 | end 141 | 142 | tree.add_resource_fragments(fragments, include_related) 143 | end 144 | end 145 | 146 | class PrimaryResourceTree < ResourceTree 147 | 148 | # Creates a PrimaryResourceTree with no resources and no related ResourceTrees 149 | def initialize(fragments: nil, resources: nil, resource: nil, include_related: nil, options: nil) 150 | @fragments ||= {} 151 | @related_resource_trees ||= {} 152 | if fragments || resources || resource 153 | if fragments 154 | add_resource_fragments(fragments, include_related) 155 | end 156 | 157 | if resources 158 | add_resources(resources, include_related) 159 | end 160 | 161 | if resource 162 | add_resource(resource, include_related) 163 | end 164 | 165 | complete_includes!(include_related, options) 166 | end 167 | end 168 | 169 | # Adds a Resource Fragment to the fragments hash 170 | # 171 | # @param fragment [JSONAPI::ResourceFragment] 172 | # @param include_related [Hash] 173 | # 174 | # @return [null] 175 | def add_resource_fragment(fragment, include_related) 176 | fragment.primary = true 177 | super(fragment, include_related) 178 | end 179 | 180 | def complete_includes!(include_related, options) 181 | # ToDo: can we skip if more than one resource_klass found? 182 | resource_klasses = Set.new 183 | @fragments.each_key { |identity| resource_klasses << identity.resource_klass } 184 | 185 | resource_klasses.each { |resource_klass| load_included(resource_klass, self, include_related, options)} 186 | 187 | self 188 | end 189 | end 190 | 191 | class RelatedResourceTree < ResourceTree 192 | 193 | attr_reader :parent_relationship, :source_resource_tree 194 | 195 | # Creates a RelatedResourceTree with no resources and no related ResourceTrees. A connection to the parent 196 | # ResourceTree is maintained. 197 | # 198 | # @param parent_relationship [JSONAPI::Relationship] 199 | # @param source_resource_tree [JSONAPI::ResourceTree] 200 | # 201 | # @return [JSONAPI::RelatedResourceTree] the new or existing resource id tree for the requested relationship 202 | def initialize(parent_relationship, source_resource_tree) 203 | @fragments ||= {} 204 | @related_resource_trees ||= {} 205 | 206 | @parent_relationship = parent_relationship 207 | @parent_relationship_name = parent_relationship.name.to_sym 208 | @source_resource_tree = source_resource_tree 209 | end 210 | 211 | # Adds a Resource Fragment to the fragments hash 212 | # 213 | # @param fragment [JSONAPI::ResourceFragment] 214 | # @param include_related [Hash] 215 | # 216 | # @return [null] 217 | def add_resource_fragment(fragment, include_related) 218 | init_included_relationships(fragment, include_related) 219 | 220 | fragment.related_from.each do |rid| 221 | @source_resource_tree.fragments[rid].add_related_identity(parent_relationship.name, fragment.identity) 222 | end 223 | 224 | if @fragments[fragment.identity] 225 | @fragments[fragment.identity].related_from.merge(fragment.related_from) 226 | fragment.related.each_pair do |relationship_name, rids| 227 | if rids 228 | @fragments[fragment.identity].merge_related_identities(relationship_name, rids) 229 | end 230 | end 231 | else 232 | @fragments[fragment.identity] = fragment 233 | end 234 | end 235 | end 236 | end -------------------------------------------------------------------------------- /lib/jsonapi/resources/railtie.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | module Resources 3 | class Railtie < Rails::Railtie 4 | rake_tasks do 5 | load 'tasks/check_upgrade.rake' 6 | end 7 | end 8 | end 9 | end -------------------------------------------------------------------------------- /lib/jsonapi/resources/version.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | module Resources 3 | VERSION = '0.11.0.beta1' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/jsonapi/response_document.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module JSONAPI 4 | class ResponseDocument 5 | attr_reader :serialized_results 6 | 7 | def initialize(options = {}) 8 | @serialized_results = [] 9 | @result_codes = [] 10 | @error_results = [] 11 | @global_errors = [] 12 | 13 | @options = options 14 | 15 | @top_level_meta = @options.fetch(:base_meta, {}) 16 | @top_level_links = @options.fetch(:base_links, {}) 17 | 18 | @key_formatter = @options.fetch(:key_formatter, JSONAPI.configuration.key_formatter) 19 | end 20 | 21 | def has_errors? 22 | @error_results.length.positive? || @global_errors.length.positive? 23 | end 24 | 25 | def add_result(result, operation) 26 | if result.is_a?(JSONAPI::ErrorsOperationResult) 27 | # Clear any serialized results 28 | @serialized_results = [] 29 | 30 | # In JSONAPI v1 we only have one operation so all errors can be kept together 31 | result.errors.each do |error| 32 | add_global_error(error) 33 | end 34 | else 35 | @serialized_results.push result.to_hash(operation.options[:serializer]) 36 | @result_codes.push result.code.to_i 37 | update_links(operation.options[:serializer], result) 38 | update_meta(result) 39 | end 40 | end 41 | 42 | def add_global_error(error) 43 | @global_errors.push error 44 | end 45 | 46 | def contents 47 | if has_errors? 48 | return { 'errors' => @global_errors } 49 | else 50 | hash = @serialized_results[0] 51 | meta = top_level_meta 52 | hash.merge!('meta' => meta) unless meta.empty? 53 | 54 | links = top_level_links 55 | hash.merge!('links' => links) unless links.empty? 56 | 57 | return hash 58 | end 59 | end 60 | 61 | def status 62 | status_codes = if has_errors? 63 | @global_errors.collect do |error| 64 | error.status.to_i 65 | end 66 | else 67 | @result_codes 68 | end 69 | 70 | # Count the unique status codes 71 | counts = status_codes.each_with_object(Hash.new(0)) { |code, counts| counts[code] += 1 } 72 | 73 | # if there is only one status code we can return that 74 | return counts.keys[0].to_i if counts.length == 1 75 | 76 | # :nocov: not currently used 77 | 78 | # if there are many we should return the highest general code, 200, 400, 500 etc. 79 | max_status = 0 80 | status_codes.each do |status| 81 | code = status.to_i 82 | max_status = code if max_status < code 83 | end 84 | return (max_status / 100).floor * 100 85 | # :nocov: 86 | end 87 | 88 | private 89 | 90 | def update_meta(result) 91 | @top_level_meta.merge!(result.meta) 92 | 93 | if JSONAPI.configuration.top_level_meta_include_record_count && result.respond_to?(:record_count) 94 | @top_level_meta[JSONAPI.configuration.top_level_meta_record_count_key] = result.record_count 95 | end 96 | 97 | if JSONAPI.configuration.top_level_meta_include_page_count && result.respond_to?(:page_count) 98 | @top_level_meta[JSONAPI.configuration.top_level_meta_page_count_key] = result.page_count 99 | end 100 | 101 | if result.warnings.any? 102 | @top_level_meta[:warnings] = result.warnings.collect do |warning| 103 | warning.to_hash 104 | end 105 | end 106 | end 107 | 108 | def top_level_meta 109 | @top_level_meta.as_json.deep_transform_keys { |key| @key_formatter.format(key) } 110 | end 111 | 112 | def update_links(serializer, result) 113 | @top_level_links.merge!(result.links) 114 | 115 | # Build pagination links 116 | if result.is_a?(JSONAPI::ResourceSetOperationResult) || 117 | result.is_a?(JSONAPI::ResourcesSetOperationResult) || 118 | result.is_a?(JSONAPI::RelatedResourcesSetOperationResult) 119 | 120 | result.pagination_params.each_pair do |link_name, params| 121 | if result.is_a?(JSONAPI::RelatedResourcesSetOperationResult) 122 | relationship = result.source_resource.class._relationships[result._type.to_sym] 123 | unless relationship.exclude_link?(link_name) 124 | link = serializer.link_builder.relationships_related_link(result.source_resource, relationship, query_params(params)) 125 | end 126 | else 127 | unless serializer.link_builder.primary_resource_klass.exclude_link?(link_name) 128 | link = serializer.link_builder.query_link(query_params(params)) 129 | end 130 | end 131 | @top_level_links[link_name] = link unless link.blank? 132 | end 133 | end 134 | end 135 | 136 | def top_level_links 137 | @top_level_links.deep_transform_keys { |key| @key_formatter.format(key) } 138 | end 139 | 140 | def query_params(params) 141 | query_params = {} 142 | query_params[:page] = params 143 | 144 | request = @options[:request] 145 | if request.params[:fields] 146 | query_params[:fields] = request.params[:fields].respond_to?(:to_unsafe_hash) ? request.params[:fields].to_unsafe_hash : request.params[:fields] 147 | end 148 | 149 | query_params[:include] = request.params[:include] if request.params[:include] 150 | query_params[:sort] = request.params[:sort] if request.params[:sort] 151 | 152 | if request.params[:filter] 153 | query_params[:filter] = request.params[:filter].respond_to?(:to_unsafe_hash) ? request.params[:filter].to_unsafe_hash : request.params[:filter] 154 | end 155 | 156 | query_params 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /lib/tasks/check_upgrade.rake: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'jsonapi-resources' 3 | 4 | namespace :jsonapi do 5 | namespace :resources do 6 | desc 'Checks application for orphaned overrides' 7 | task :check_upgrade => :environment do 8 | Rails.application.eager_load! 9 | 10 | resource_klasses = ObjectSpace.each_object(Class).select { |klass| klass < JSONAPI::Resource} 11 | 12 | puts "Checking #{resource_klasses.count} resources" 13 | 14 | issues_found = 0 15 | 16 | klasses_with_deprecated = resource_klasses.select { |klass| klass.methods.include?(:find_records) } 17 | unless klasses_with_deprecated.empty? 18 | puts " Found the following resources the still implement `find_records`:" 19 | klasses_with_deprecated.each { |klass| puts " #{klass}"} 20 | puts " The `find_records` method is no longer called by JR. Please review and ensure your functionality is ported over." 21 | 22 | issues_found = issues_found + klasses_with_deprecated.length 23 | end 24 | 25 | klasses_with_deprecated = resource_klasses.select { |klass| klass.methods.include?(:records_for) } 26 | unless klasses_with_deprecated.empty? 27 | puts " Found the following resources the still implement `records_for`:" 28 | klasses_with_deprecated.each { |klass| puts " #{klass}"} 29 | puts " The `records_for` method is no longer called by JR. Please review and ensure your functionality is ported over." 30 | 31 | issues_found = issues_found + klasses_with_deprecated.length 32 | end 33 | 34 | klasses_with_deprecated = resource_klasses.select { |klass| klass.methods.include?(:apply_includes) } 35 | unless klasses_with_deprecated.empty? 36 | puts " Found the following resources the still implement `apply_includes`:" 37 | klasses_with_deprecated.each { |klass| puts " #{klass}"} 38 | puts " The `apply_includes` method is no longer called by JR. Please review and ensure your functionality is ported over." 39 | 40 | issues_found = issues_found + klasses_with_deprecated.length 41 | end 42 | 43 | if issues_found > 0 44 | puts "Finished inspection. #{issues_found} issues found that may impact upgrading. Please address these issues. " 45 | else 46 | puts "Finished inspection with no issues found. Note this is only a cursory check for method overrides that will no \n" \ 47 | "longer be called by JSONAPI::Resources. This check in no way assures your code will continue to function as \n" \ 48 | "it did before the upgrade. Please do adequate testing before using in production." 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | jsonapi-resources: 3 | exceptions: 4 | internal_server_error: 5 | title: 'Internal Server Error' 6 | detail: 'Internal Server Error' 7 | invalid_resource: 8 | title: 'Invalid resource' 9 | detail: "%{resource} is not a valid resource." 10 | record_not_found: 11 | title: 'Record not found' 12 | detail: "The record identified by %{id} could not be found." 13 | not_acceptable: 14 | title: 'Not acceptable' 15 | detail: "All requests must use the '%{needed_media_type}' Accept without media type parameters. This request specified '%{media_type}'." 16 | unsupported_media_type: 17 | title: 'Unsupported media type' 18 | detail: "All requests that create or update must use the '%{needed_media_type}' Content-Type. This request specified '%{media_type}'." 19 | to_many_set_replacement_forbidden: 20 | title: 'Complete replacement forbidden' 21 | detail: 'Complete replacement forbidden for this relationship' 22 | invalid_filter_syntax: 23 | title: 'Invalid filters syntax' 24 | detail: "%{filters} is not a valid syntax for filtering." 25 | filter_not_allowed: 26 | title: "Filter not allowed" 27 | detail: "%{filter} is not allowed." 28 | invalid_filter_value: 29 | title: 'Invalid filter value' 30 | detail: "%{value} is not a valid value for %{filter}." 31 | invalid_field_value: 32 | title: 'Invalid field value' 33 | detail: "%{value} is not a valid value for %{field}." 34 | invalid_field_format: 35 | title: 'Invalid field format' 36 | detail: 'Fields must specify a type.' 37 | invalid_data_format: 38 | title: 'Invalid data format' 39 | detail: 'Data must be a hash.' 40 | invalid_links_object: 41 | title: 'Invalid Links Object' 42 | detail: 'Data is not a valid Links Object.' 43 | type_mismatch: 44 | title: 'Type Mismatch' 45 | detail: "%{type} is not a valid type for this operation." 46 | invalid_field: 47 | title: 'Invalid field' 48 | detail: "%{field} is not a valid field for %{type}." 49 | invalid_relationship: 50 | title: 'Invalid relationship' 51 | detail: "%{relationship_name} is not a valid relationship for %{type}." 52 | invalid_include: 53 | title: 'Invalid include' 54 | detail: "%{relationship} is not a valid includable relationship of %{resource}" 55 | invalid_sort_criteria: 56 | title: 'Invalid sort criteria' 57 | detail: "%{sort_criteria} is not a valid sort criteria for %{resource}" 58 | parameter_not_allowed: 59 | title: 'Param not allowed' 60 | detail: "%{param} is not allowed." 61 | parameter_missing: 62 | title: 'Missing Parameter' 63 | detail: "The required parameter, %{param}, is missing." 64 | key_not_included_in_url: 65 | title: 'Key is not included in URL' 66 | detail: "The URL does not support the key %{key}" 67 | missing_key: 68 | title: 'A key is required' 69 | detail: 'The resource object does not contain a key.' 70 | record_locked: 71 | title: 'Locked resource' 72 | save_failed: 73 | title: 'Save failed or was cancelled' 74 | detail: 'Save failed or was cancelled' 75 | invalid_page_object: 76 | title: 'Invalid Page Object' 77 | detail: 'Invalid Page Object.' 78 | page_parameters_not_allowed: 79 | title: 'Page parameter not allowed' 80 | detail: "%{param} is not an allowed page parameter." 81 | invalid_page_value: 82 | title: 'Invalid page value' 83 | detail: "%{value} is not a valid value for %{page} page parameter." 84 | -------------------------------------------------------------------------------- /test/benchmark/reflect_create_and_delete_benchmark.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../test_helper', __FILE__) 2 | 3 | class ReflectCreateAndDeleteBenchmark < IntegrationBenchmark 4 | def setup 5 | $test_user = Person.find(1) 6 | end 7 | 8 | def create_and_delete_comments 9 | post '/posts/15/relationships/comments', params: 10 | { 11 | 'data' => [ 12 | {type: 'comments', id: 1}, 13 | {type: 'comments', id: 2}, 14 | {type: 'comments', id: 3} 15 | ] 16 | }.to_json, 17 | headers: { 18 | "CONTENT_TYPE" => JSONAPI::MEDIA_TYPE, 19 | 'Accept' => JSONAPI::MEDIA_TYPE 20 | } 21 | assert_response :no_content 22 | post_object = Post.find(15) 23 | assert_equal 3, post_object.comments.collect { |comment| comment.id }.length 24 | 25 | delete '/posts/15/relationships/comments', params: 26 | { 27 | 'data' => [ 28 | {type: 'comments', id: 1}, 29 | {type: 'comments', id: 2}, 30 | {type: 'comments', id: 3} 31 | ] 32 | }.to_json, 33 | headers: { 34 | "CONTENT_TYPE" => JSONAPI::MEDIA_TYPE, 35 | 'Accept' => JSONAPI::MEDIA_TYPE 36 | } 37 | assert_response :no_content 38 | post_object = Post.find(15) 39 | assert_equal 0, post_object.comments.collect { |comment| comment.id }.length 40 | end 41 | 42 | # ToDo: Cleanup fixtures and session so benchmarks are consistent without an order dependence. 43 | 44 | # def bench_create_and_delete 45 | # reflect = ENV['REFLECT'] 46 | # if reflect 47 | # puts "relationship reflection on" 48 | # JSONAPI.configuration.use_relationship_reflection = true 49 | # else 50 | # puts "relationship reflection off" 51 | # JSONAPI.configuration.use_relationship_reflection = false 52 | # end 53 | # 54 | # 100.times do 55 | # create_and_delete_comments 56 | # end 57 | # 58 | # ensure 59 | # JSONAPI.configuration.use_relationship_reflection = false 60 | # end 61 | 62 | def bench_create_and_delete_comments_reflection_on 63 | JSONAPI.configuration.use_relationship_reflection = true 64 | 65 | 100.times do 66 | create_and_delete_comments 67 | end 68 | ensure 69 | JSONAPI.configuration.use_relationship_reflection = false 70 | end 71 | 72 | def bench_create_and_delete_comments_reflection_off 73 | JSONAPI.configuration.use_relationship_reflection = false 74 | 75 | 100.times do 76 | create_and_delete_comments 77 | end 78 | ensure 79 | JSONAPI.configuration.use_relationship_reflection = false 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /test/benchmark/reflect_update_relationships_benchmark.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../test_helper', __FILE__) 2 | 3 | class ReflectUpdateRelationshipsBenchmark < IntegrationBenchmark 4 | def setup 5 | $test_user = Person.find(1) 6 | end 7 | 8 | def replace_tags 9 | put '/posts/15/relationships/tags', params: 10 | { 11 | 'data' => [{type: 'tags', id: 11}, {type: 'tags', id: 3}, {type: 'tags', id: 12}, {type: 'tags', id: 13}, {type: 'tags', id: 14} 12 | ] 13 | }.to_json, 14 | headers: { 15 | "CONTENT_TYPE" => JSONAPI::MEDIA_TYPE, 16 | 'Accept' => JSONAPI::MEDIA_TYPE 17 | } 18 | assert_response :no_content 19 | post_object = Post.find(15) 20 | assert_equal 5, post_object.tags.collect { |tag| tag.id }.length 21 | 22 | put '/posts/15/relationships/tags', params: 23 | { 24 | 'data' => [{type: 'tags', id: 2}, {type: 'tags', id: 3}, {type: 'tags', id: 4} 25 | ] 26 | }.to_json, 27 | headers: { 28 | "CONTENT_TYPE" => JSONAPI::MEDIA_TYPE, 29 | 'Accept' => JSONAPI::MEDIA_TYPE 30 | } 31 | assert_response :no_content 32 | post_object = Post.find(15) 33 | assert_equal 3, post_object.tags.collect { |tag| tag.id }.length 34 | end 35 | 36 | # ToDo: Cleanup fixtures and session so benchmarks are consistent without an order dependence. 37 | 38 | # def bench_update_relationship 39 | # reflect = ENV['REFLECT'] 40 | # if reflect 41 | # puts "relationship reflection on" 42 | # else 43 | # puts "relationship reflection off" 44 | # end 45 | # JSONAPI.configuration.use_relationship_reflection = reflect 46 | # 47 | # 100.times do 48 | # replace_tags 49 | # end 50 | # ensure 51 | # JSONAPI.configuration.use_relationship_reflection = false 52 | # end 53 | 54 | def bench_update_relationship_reflection_on 55 | JSONAPI.configuration.use_relationship_reflection = true 56 | 57 | 100.times do 58 | replace_tags 59 | end 60 | ensure 61 | JSONAPI.configuration.use_relationship_reflection = false 62 | end 63 | 64 | def bench_update_relationship_reflection_off 65 | JSONAPI.configuration.use_relationship_reflection = false 66 | 67 | 100.times do 68 | replace_tags 69 | end 70 | ensure 71 | JSONAPI.configuration.use_relationship_reflection = false 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /test/benchmark/request_benchmark.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../test_helper', __FILE__) 2 | 3 | class RequestBenchmark < IntegrationBenchmark 4 | def setup 5 | super 6 | $test_user = Person.find(1) 7 | end 8 | 9 | def bench_large_index_request_uncached 10 | 10.times do 11 | assert_jsonapi_get '/api/v2/books?include=bookComments,bookComments.author' 12 | end 13 | end 14 | 15 | def bench_large_index_request_caching 16 | cache = ActiveSupport::Cache::MemoryStore.new 17 | with_resource_caching(cache) do 18 | 10.times do 19 | assert_jsonapi_get '/api/v2/books?include=bookComments,bookComments.author' 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/fixtures/access_cards.yml: -------------------------------------------------------------------------------- 1 | john_doe_worker_card: 2 | id: 1 3 | token: "some-token" 4 | security_level: "admin" 5 | -------------------------------------------------------------------------------- /test/fixtures/answers.yml: -------------------------------------------------------------------------------- 1 | answer1: 2 | id: 1 3 | question_id: 1 4 | text: Great thanks 5 | respondent_id: 1 6 | respondent_type: Patient 7 | answer2: 8 | id: 2 9 | question_id: 2 10 | text: Better than last week 11 | respondent_id: 1 12 | respondent_type: Doctor -------------------------------------------------------------------------------- /test/fixtures/author_details.yml: -------------------------------------------------------------------------------- 1 | a: 2 | id: 1 3 | person_id: 1001 4 | author_stuff: blah blah 5 | 6 | b: 7 | id: 2 8 | person_id: 1002 9 | author_stuff: blah blah blah 10 | 11 | c: 12 | id: 3 13 | person_id: 1003 14 | author_stuff: Prolific writer of schlock -------------------------------------------------------------------------------- /test/fixtures/book_authors.yml: -------------------------------------------------------------------------------- 1 | book_author_1_1: 2 | book_id: 1 3 | person_id: 1001 4 | 5 | book_author_2_1: 6 | book_id: 2 7 | person_id: 1001 8 | 9 | book_author_2_2: 10 | book_id: 2 11 | person_id: 1002 12 | 13 | book_author_654_2: 14 | book_id: 654 # Banned book 15 | person_id: 1002 16 | 17 | 18 | <% for book_num in 300..343 %> 19 | book_author_1003_<%= book_num %>: 20 | id: <%= book_num + 30321 %> 21 | book_id: <%= book_num %> 22 | person_id: 1003 23 | <% end %> 24 | -------------------------------------------------------------------------------- /test/fixtures/book_comments.yml: -------------------------------------------------------------------------------- 1 | <% comment_id = 0 %> 2 | <% for book_num in 0..4 %> 3 | <% for comment_num in 0..50 %> 4 | book_<%= book_num %>_comment_<%= comment_num %>: 5 | id: <%= comment_id %> 6 | body: This is comment <%= comment_num %> on book <%= book_num %>. 7 | author_id: <%= book_num.even? ? (comment_id % 2) + 1000: (comment_id % 2) + 1002 %> 8 | book_id: <%= book_num %> 9 | approved: <%= comment_num.even? %> 10 | <% comment_id = comment_id + 1 %> 11 | <% end %> 12 | <% end %> -------------------------------------------------------------------------------- /test/fixtures/books.yml: -------------------------------------------------------------------------------- 1 | <% for book_num in 0..999 %> 2 | book_<%= book_num %>: 3 | id: <%= book_num %> 4 | title: Book <%= book_num %> 5 | isbn: 12345-<%= book_num %>-6789 6 | banned: <%= book_num > 600 && book_num < 700 %> 7 | <% end %> 8 | -------------------------------------------------------------------------------- /test/fixtures/boxes.yml: -------------------------------------------------------------------------------- 1 | box_100: 2 | id: 100 3 | 4 | box_102: 5 | id: 102 6 | -------------------------------------------------------------------------------- /test/fixtures/categories.yml: -------------------------------------------------------------------------------- 1 | category_a: 2 | id: 1 3 | name: Category A 4 | status: active 5 | 6 | category_b: 7 | id: 2 8 | name: Category B 9 | status: active 10 | 11 | category_c: 12 | id: 3 13 | name: Category C 14 | status: active 15 | 16 | category_d: 17 | id: 4 18 | name: Category D 19 | status: inactive 20 | 21 | category_e: 22 | id: 5 23 | name: Category E 24 | status: inactive 25 | 26 | category_f: 27 | id: 6 28 | name: Category F 29 | status: inactive 30 | 31 | category_g: 32 | id: 7 33 | name: Category G 34 | status: inactive 35 | 36 | -------------------------------------------------------------------------------- /test/fixtures/collectors.yml: -------------------------------------------------------------------------------- 1 | collector_1: 2 | id: 1 3 | name: "Alice" 4 | painting_id: 4 5 | 6 | collector_2: 7 | id: 2 8 | name: "Bob" 9 | painting_id: 4 -------------------------------------------------------------------------------- /test/fixtures/comments.yml: -------------------------------------------------------------------------------- 1 | post_1_dumb_post: 2 | id: 1 3 | post_id: 1 4 | body: what a dumb post 5 | author_id: 1001 6 | 7 | post_1_i_liked_it: 8 | id: 2 9 | post_id: 1 10 | body: i liked it 11 | author_id: 1002 12 | 13 | post_2_thanks_man: 14 | id: 3 15 | post_id: 2 16 | body: Thanks man. Great post. But what is JR? 17 | author_id: 1002 18 | 19 | rogue_comment: 20 | id: 6 21 | body: Rogue Comment Here 22 | author_id: 1003 23 | 24 | rogue_comment_2: 25 | id: 7 26 | body: Rogue Comment 2 Here 27 | author_id: 1001 28 | 29 | rogue_comment_3: 30 | id: 8 31 | body: Rogue Comment 3 Here 32 | author_id: 1001 -------------------------------------------------------------------------------- /test/fixtures/comments_tags.yml: -------------------------------------------------------------------------------- 1 | post_1_dumb_post_whiny: 2 | comment_id: 1 3 | tag_id: 502 4 | 5 | post_1_dumb_post_short: 6 | comment_id: 1 7 | tag_id: 501 8 | 9 | post_1_i_liked_it_happy: 10 | comment_id: 2 11 | tag_id: 504 12 | 13 | post_1_i_liked_it_short: 14 | comment_id: 2 15 | tag_id: 501 16 | 17 | post_2_thanks_man_jr: 18 | comment_id: 3 19 | tag_id: 505 20 | 21 | -------------------------------------------------------------------------------- /test/fixtures/companies.yml: -------------------------------------------------------------------------------- 1 | firm1: 2 | type: Firm 3 | name: JSON Consulting Services 4 | address: 456 1st Ave. 5 | -------------------------------------------------------------------------------- /test/fixtures/craters.yml: -------------------------------------------------------------------------------- 1 | crater1: 2 | code: S56D 3 | description: Very large crater 4 | moon_id: 1 5 | 6 | crater2: 7 | code: A4D3 8 | description: Small crater 9 | moon_id: 1 -------------------------------------------------------------------------------- /test/fixtures/customers.yml: -------------------------------------------------------------------------------- 1 | xyz_corp: 2 | id: 1 3 | name: XYZ Corporation 4 | 5 | abc_corp: 6 | id: 2 7 | name: ABC Corporation 8 | 9 | asdfg_corp: 10 | id: 3 11 | name: ASDFG Corporation 12 | -------------------------------------------------------------------------------- /test/fixtures/doctors.yml: -------------------------------------------------------------------------------- 1 | doctor1: 2 | id: 1 3 | name: Henry Jones Jr -------------------------------------------------------------------------------- /test/fixtures/documents.yml: -------------------------------------------------------------------------------- 1 | document_1: 2 | id: 1 3 | name: Company Brochure 4 | author_id: 1002 5 | 6 | document_2: 7 | id: 2 8 | name: Enagement Letter 9 | author_id: 1001 10 | 11 | document_200: 12 | id: 200 13 | name: Management Through the Years 14 | author_id: 15 | 16 | document_201: 17 | id: 201 18 | name: Foo 19 | author_id: 1001 20 | 21 | #ToDo: rename this once we have different filter types by default. See test_polymorpic_relation_filter 22 | document_300: 23 | id: 300 24 | name: Enterprise Gizmo -------------------------------------------------------------------------------- /test/fixtures/expense_entries.yml: -------------------------------------------------------------------------------- 1 | entry_1: 2 | id: 1 3 | currency_code: USD 4 | employee_id: 1003 5 | cost: 12.05 6 | transaction_date: <%= Date.parse('2014-04-15') %> 7 | 8 | entry_2: 9 | id: 2 10 | currency_code: USD 11 | employee_id: 1003 12 | cost: 12.06 13 | transaction_date: <%= Date.parse('2014-04-15') %> -------------------------------------------------------------------------------- /test/fixtures/facts.yml: -------------------------------------------------------------------------------- 1 | fact_1: 2 | id: 1 3 | spouse_name: Jane Author 4 | bio: First man to run across Antartica. 5 | quality_rating: <%= 23.89/45.6 %> 6 | salary: 47000.56 7 | date_time_joined: 2013-08-07 20:25:00 UTC +00:00 8 | birthday: 1965-06-30 9 | bedtime: 2000-01-01 20:00:00 UTC +00:00 10 | photo: abc 11 | cool: false 12 | -------------------------------------------------------------------------------- /test/fixtures/file_properties.yml: -------------------------------------------------------------------------------- 1 | picture_2: 2 | id: 20002 3 | name: company_brochure.jpg 4 | fileable_id: 2 5 | fileable_type: Picture 6 | 7 | picture_3: 8 | id: 20003 9 | name: group_photo.jpg 10 | fileable_id: 3 11 | fileable_type: Picture 12 | 13 | picture_40: 14 | id: 20040 15 | name: company_management_team_2015.jpg 16 | fileable_id: 40 17 | fileable_type: Picture 18 | 19 | document_2: 20 | id: 30002 21 | name: Enagement Letter.doc 22 | fileable_id: 2 23 | fileable_type: Document 24 | 25 | document_200: 26 | id: 30200 27 | name: Management Through the Years.doc 28 | fileable_id: 200 29 | fileable_type: Document 30 | 31 | product_1: 32 | id: 40001 33 | name: Enterprise Gizmo.spec 34 | fileable_id: 1 35 | fileable_type: Product 36 | 37 | product_2: 38 | id: 40002 39 | name: Fighting Hot Sauce.spec 40 | fileable_id: 2 41 | fileable_type: Product 42 | -------------------------------------------------------------------------------- /test/fixtures/hair_cuts.yml: -------------------------------------------------------------------------------- 1 | mohawk: 2 | id: 1 3 | style: mohawk 4 | -------------------------------------------------------------------------------- /test/fixtures/iso_currencies.yml: -------------------------------------------------------------------------------- 1 | usd: 2 | code: USD 3 | name: United States Dollar 4 | country_name: United States 5 | minor_unit: cent 6 | 7 | eur: 8 | code: EUR 9 | name: Euro Member Countries 10 | country_name: Euro Member Countries 11 | minor_unit: cent 12 | 13 | cad: 14 | code: CAD 15 | name: Canadian dollar 16 | country_name: Canada 17 | minor_unit: cent -------------------------------------------------------------------------------- /test/fixtures/keepers.yml: -------------------------------------------------------------------------------- 1 | john_doe: 2 | id: 1 3 | name: "John Doe" 4 | keepable_id: 1 # storages.yml warehouse_1 5 | keepable_type: "Storage" 6 | -------------------------------------------------------------------------------- /test/fixtures/line_items.yml: -------------------------------------------------------------------------------- 1 | po_1_li_1: 2 | id: 1 3 | purchase_order_id: 1 4 | part_number: 556324 5 | quantity: 1 6 | item_cost: 45.67 7 | 8 | po_1_li_2: 9 | id: 2 10 | purchase_order_id: 1 11 | part_number: 79324231A 12 | quantity: 3 13 | item_cost: 19.99 14 | 15 | li_3: 16 | id: 3 17 | part_number: 79324231A 18 | quantity: 67 19 | item_cost: 19.99 20 | 21 | li_4: 22 | id: 4 23 | part_number: 5678 24 | quantity: 2 25 | item_cost: 199.99 26 | 27 | li_5: 28 | id: 5 29 | part_number: 5WERT 30 | quantity: 1 31 | item_cost: 299.98 32 | 33 | li_6: 34 | id: 6 35 | part_number: 25washer 36 | quantity: 10 37 | item_cost: 0.98 -------------------------------------------------------------------------------- /test/fixtures/makes.yml: -------------------------------------------------------------------------------- 1 | make1: 2 | model: A model attribute 3 | -------------------------------------------------------------------------------- /test/fixtures/moons.yml: -------------------------------------------------------------------------------- 1 | titan: 2 | id: 1 3 | name: Titan 4 | description: Best known of the Saturn moons. 5 | planet_id: 1 6 | 7 | -------------------------------------------------------------------------------- /test/fixtures/numeros_telefone.yml: -------------------------------------------------------------------------------- 1 | info: 2 | id: 1 3 | numero_telefone: 1-800-555-1212 -------------------------------------------------------------------------------- /test/fixtures/order_flags.yml: -------------------------------------------------------------------------------- 1 | rush_order_flag: 2 | id: 1 3 | name: Rush 4 | 5 | ship_together_order_flag: 6 | id: 2 7 | name: Ship Together 8 | -------------------------------------------------------------------------------- /test/fixtures/painters.yml: -------------------------------------------------------------------------------- 1 | painter_1: 2 | id: 1 3 | name: "Wyspianski" 4 | 5 | painter_2: 6 | id: 2 7 | name: "Matejko" -------------------------------------------------------------------------------- /test/fixtures/paintings.yml: -------------------------------------------------------------------------------- 1 | painting_1: 2 | id: 1 3 | title: "Rejtan" 4 | category: "historic" 5 | painter_id: 2 6 | 7 | painting_2: 8 | id: 2 9 | title: "Stanczyk" 10 | category: "fantasy" 11 | painter_id: 2 12 | 13 | painting_3: 14 | id: 3 15 | title: "Macierzynstwo" 16 | category: "pastel" 17 | painter_id: 1 18 | 19 | painting_4: 20 | id: 4 21 | title: "Helenka" 22 | category: "oil" 23 | painter_id: 1 24 | 25 | painting_5: 26 | id: 5 27 | title: "Motherhood" 28 | category: "oil" 29 | painter_id: 1 30 | 31 | painting_6: 32 | id: 6 33 | title: "Motherhood" 34 | category: "fake" 35 | painter_id: 1 -------------------------------------------------------------------------------- /test/fixtures/patients.yml: -------------------------------------------------------------------------------- 1 | patient1: 2 | id: 1 3 | name: Bob Smith -------------------------------------------------------------------------------- /test/fixtures/people.yml: -------------------------------------------------------------------------------- 1 | a: 2 | id: 1001 3 | name: Joe Author 4 | email: joe@xyz.fake 5 | date_joined: <%= DateTime.parse('2013-08-07 20:25:00 UTC +00:00') %> 6 | preferences_id: 1 7 | 8 | b: 9 | id: 1002 10 | name: Fred Reader 11 | email: fred@xyz.fake 12 | date_joined: <%= DateTime.parse('2013-10-31 20:25:00 UTC +00:00') %> 13 | hair_cut_id: 1 14 | 15 | c: 16 | id: 1003 17 | name: Lazy Author 18 | email: lazy@xyz.fake 19 | date_joined: <%= DateTime.parse('2013-10-31 21:25:00 UTC +00:00') %> 20 | 21 | d: 22 | id: 1004 23 | name: Tag Crazy Author 24 | email: taggy@xyz.fake 25 | date_joined: <%= DateTime.parse('2013-11-30 4:20:00 UTC +00:00') %> 26 | 27 | e: 28 | id: 1005 29 | name: Wilma Librarian 30 | email: lib@xyz.fake 31 | date_joined: <%= DateTime.parse('2013-11-30 4:20:00 UTC +00:00') %> 32 | book_admin: true 33 | preferences_id: 55 34 | 35 | x: 36 | id: 1000 37 | name: The Shadow 38 | email: nobody@nowhere.comment_num 39 | date_joined: <%= DateTime.parse('1970-01-01 20:25:00 UTC +00:00') %> 40 | -------------------------------------------------------------------------------- /test/fixtures/pictures.yml: -------------------------------------------------------------------------------- 1 | picture_1: 2 | id: 1 3 | name: enterprise_gizmo.jpg 4 | imageable_id: 1 5 | imageable_type: Product 6 | author_id: 1002 7 | 8 | picture_2: 9 | id: 2 10 | name: company_brochure.jpg 11 | imageable_id: 1 12 | imageable_type: Document 13 | author_id: 1002 14 | 15 | picture_3: 16 | id: 3 17 | name: group_photo.jpg 18 | author_id: 1001 19 | 20 | picture_40: 21 | id: 40 22 | name: company_management_team_2015.jpg 23 | imageable_id: 200 24 | imageable_type: Document 25 | author_id: 1001 26 | 27 | picture_41: 28 | id: 41 29 | name: company_management_team_2016.jpg 30 | imageable_id: 200 31 | imageable_type: Document 32 | 33 | picture_47: 34 | id: 47 35 | name: company_management_team_2017.jpg 36 | imageable_id: 200 37 | imageable_type: Document 38 | 39 | picture_48: 40 | id: 48 41 | name: JunkYardDogs.jpg 42 | imageable_id: 201 43 | imageable_type: Document 44 | 45 | picture_50: 46 | id: 50 47 | name: Gizmo_logo.png 48 | imageable_id: 300 49 | imageable_type: Document -------------------------------------------------------------------------------- /test/fixtures/planet_types.yml: -------------------------------------------------------------------------------- 1 | gas_giant: 2 | id: 1 3 | name: Gas Giant 4 | 5 | planetoid: 6 | id: 2 7 | name: Planetoid 8 | 9 | terrestrial: 10 | id: 3 11 | name: Terrestrial 12 | 13 | sulfuric: 14 | id: 4 15 | name: Sulfuric 16 | 17 | unknown: 18 | id: 5 19 | name: unknown -------------------------------------------------------------------------------- /test/fixtures/planets.yml: -------------------------------------------------------------------------------- 1 | saturn: 2 | id: 1 3 | name: Satern 4 | description: Saturn is the sixth planet from the Sun and the second largest planet in the Solar System, after Jupiter. 5 | planet_type_id: 2 6 | 7 | makemake: 8 | id: 2 9 | name: Makemake 10 | description: A small planetoid in the Kuiperbelt. 11 | planet_type_id: 2 12 | 13 | uranus: 14 | id: 3 15 | name: Uranus 16 | description: Insert adolescent jokes here. 17 | planet_type_id: 1 18 | 19 | jupiter: 20 | id: 4 21 | name: Jupiter 22 | description: A gas giant. 23 | planet_type_id: 1 24 | 25 | betax: 26 | id: 5 27 | name: Beta X 28 | description: Newly discovered Planet X 29 | planet_type_id: 5 30 | 31 | betay: 32 | id: 6 33 | name: Beta X 34 | description: Newly discovered Planet Y 35 | planet_type_id: 5 36 | 37 | betaz: 38 | id: 7 39 | name: Beta X 40 | description: Newly discovered Planet Z 41 | planet_type_id: 5 42 | 43 | betaw: 44 | id: 8 45 | name: Beta W 46 | description: Newly discovered Planet W 47 | planet_type_id: 48 | -------------------------------------------------------------------------------- /test/fixtures/posts.yml: -------------------------------------------------------------------------------- 1 | post_1: 2 | id: 1 3 | title: New post 4 | body: A body!!! 5 | author_id: 1001 6 | 7 | post_2: 8 | id: 2 9 | title: JR Solves your serialization woes! 10 | body: Use JR 11 | author_id: 1001 12 | section_id: 2 13 | 14 | post_3: 15 | id: 3 16 | title: Update This Later 17 | body: AAAA 18 | author_id: 1003 19 | 20 | post_4: 21 | id: 4 22 | title: Delete This Later - Single 23 | body: AAAA 24 | author_id: 1003 25 | 26 | post_5: 27 | id: 5 28 | title: Delete This Later - Multiple1 29 | body: AAAA 30 | author_id: 1003 31 | 32 | post_6: 33 | id: 6 34 | title: Delete This Later - Multiple2 35 | body: AAAA 36 | author_id: 1003 37 | 38 | post_7: 39 | id: 7 40 | title: Delete This Later - Single2 41 | body: AAAA 42 | author_id: 1003 43 | 44 | post_8: 45 | id: 8 46 | title: Delete This Later - Multiple2-1 47 | body: AAAA 48 | author_id: 1003 49 | 50 | post_9: 51 | id: 9 52 | title: Delete This Later - Multiple2-2 53 | body: AAAA 54 | author_id: 1003 55 | 56 | post_10: 57 | id: 10 58 | title: Update This Later - Multiple 59 | body: AAAA 60 | author_id: 1003 61 | 62 | post_11: 63 | id: 11 64 | title: JR How To 65 | body: Use JR to write API apps 66 | author_id: 1001 67 | 68 | post_12: 69 | id: 12 70 | title: Tagged up post 1 71 | body: AAAA 72 | author_id: 1004 73 | 74 | post_13: 75 | id: 13 76 | title: Tagged up post 2 77 | body: BBBB 78 | author_id: 1004 79 | 80 | post_14: 81 | id: 14 82 | title: A First Post 83 | body: A First Post!!!!!!!!! 84 | author_id: 1003 85 | 86 | post_15: 87 | id: 15 88 | title: A 1ST Post 89 | body: First!!!!!!!!! 90 | author_id: 1003 91 | 92 | post_16: 93 | id: 16 94 | title: SDFGH 95 | body: Not First!!!! 96 | author_id: 1003 97 | 98 | post_17: 99 | id: 17 100 | title: No Author!!!!!! 101 | body: This post has no Author 102 | author_id: 103 | 104 | post_18: 105 | id: 18 106 | title: Delete This later 18 107 | body: AAAA 108 | author_id: 1003 109 | 110 | post_19: 111 | id: 19 112 | title: Update Later - Operations 113 | body: AAAA This should be updated 114 | author_id: 1003 115 | 116 | post_20: 117 | id: 20 118 | title: Update Later - Ops Multiple 119 | body: AAAA This should also be updated 120 | author_id: 1003 121 | -------------------------------------------------------------------------------- /test/fixtures/posts_tags.yml: -------------------------------------------------------------------------------- 1 | post_1_short: 2 | post_id: 1 3 | tag_id: 501 4 | 5 | post_1_whiny: 6 | post_id: 1 7 | tag_id: 502 8 | 9 | post_1_grumpy: 10 | post_id: 1 11 | tag_id: 503 12 | 13 | post_2_jr: 14 | post_id: 2 15 | tag_id: 505 16 | 17 | post_11_jr: 18 | post_id: 11 19 | tag_id: 505 20 | 21 | post_12_silly: 22 | post_id: 12 23 | tag_id: 506 24 | 25 | post_12_sleepy: 26 | post_id: 12 27 | tag_id: 507 28 | 29 | post_12_goofy: 30 | post_id: 12 31 | tag_id: 508 32 | 33 | post_12_wacky: 34 | post_id: 12 35 | tag_id: 509 36 | 37 | post_13_silly: 38 | post_id: 13 39 | tag_id: 506 40 | 41 | post_13_sleepy: 42 | post_id: 13 43 | tag_id: 507 44 | 45 | post_13_goofy: 46 | post_id: 13 47 | tag_id: 508 48 | 49 | post_13_wacky: 50 | post_id: 13 51 | tag_id: 509 52 | 53 | post_14_whiny: 54 | post_id: 14 55 | tag_id: 502 56 | 57 | post_14_grumpy: 58 | post_id: 14 59 | tag_id: 503 60 | 61 | post_15_11: 62 | post_id: 15 63 | tag_id: 511 64 | 65 | post_15_2: 66 | post_id: 15 67 | tag_id: 502 68 | 69 | post_15_4: 70 | post_id: 15 71 | tag_id: 504 72 | 73 | post_15_10: 74 | post_id: 15 75 | tag_id: 510 76 | 77 | post_15_16: 78 | post_id: 15 79 | tag_id: 516 80 | -------------------------------------------------------------------------------- /test/fixtures/preferences.yml: -------------------------------------------------------------------------------- 1 | a: 2 | id: 1 3 | advanced_mode: false 4 | nickname: Joe Schmoe 5 | 6 | b: 7 | id: 2 8 | advanced_mode: false 9 | c: 10 | id: 3 11 | advanced_mode: false 12 | 13 | d: 14 | id: 4 15 | advanced_mode: false 16 | 17 | wilma: 18 | id: 55 19 | advanced_mode: true 20 | nickname: Wilma -------------------------------------------------------------------------------- /test/fixtures/products.yml: -------------------------------------------------------------------------------- 1 | product_1: 2 | id: 1 3 | name: Enterprise Gizmo 4 | designer_id: 1001 5 | 6 | product_2: 7 | id: 2 8 | name: Fighting Hot Sauce 9 | -------------------------------------------------------------------------------- /test/fixtures/purchase_orders.yml: -------------------------------------------------------------------------------- 1 | po_1: 2 | id: 1 3 | requested_delivery_date: 4 | delivery_date: nil 5 | customer_id: 1 6 | 7 | po_2: 8 | id: 2 9 | requested_delivery_date: 10 | delivery_date: nil 11 | customer_id: 1 12 | 13 | po_3: 14 | id: 3 15 | requested_delivery_date: 16 | delivery_date: nil 17 | customer_id: 1 18 | 19 | po_4: 20 | id: 4 21 | requested_delivery_date: 22 | delivery_date: nil 23 | customer_id: 3 24 | -------------------------------------------------------------------------------- /test/fixtures/questions.yml: -------------------------------------------------------------------------------- 1 | question1: 2 | id: 1 3 | text: How are you feeling today? 4 | question2: 5 | id: 2 6 | text: How does the patient look today? -------------------------------------------------------------------------------- /test/fixtures/related_things.yml: -------------------------------------------------------------------------------- 1 | related_thing_10: 2 | id: 101 3 | from_id: 10 4 | to_id: 20 5 | 6 | related_thing_20: 7 | id: 201 8 | from_id: 20 9 | to_id: 10 10 | 11 | related_thing_3040: 12 | id: 301 13 | from_id: 30 14 | to_id: 40 15 | 16 | related_thing_3050: 17 | id: 302 18 | from_id: 30 19 | to_id: 50 20 | 21 | related_thing_5060: 22 | id: 303 23 | from_id: 50 24 | to_id: 60 25 | -------------------------------------------------------------------------------- /test/fixtures/sections.yml: -------------------------------------------------------------------------------- 1 | javascript: 2 | id: 1 3 | name: javascript 4 | 5 | ruby: 6 | id: 2 7 | name: ruby 8 | 9 | -------------------------------------------------------------------------------- /test/fixtures/storages.yml: -------------------------------------------------------------------------------- 1 | warehouse_1: 2 | id: 1 3 | name: "Warehouse 1" 4 | token: "some-token" 5 | -------------------------------------------------------------------------------- /test/fixtures/tags.yml: -------------------------------------------------------------------------------- 1 | short_tag: 2 | id: 501 3 | name: short 4 | 5 | whiny_tag: 6 | id: 502 7 | name: whiny 8 | 9 | grumpy_tag: 10 | id: 503 11 | name: grumpy 12 | 13 | happy_tag: 14 | id: 504 15 | name: happy 16 | 17 | jr_tag: 18 | id: 505 19 | name: JR 20 | 21 | silly_tag: 22 | id: 506 23 | name: silly 24 | 25 | sleepy_tag: 26 | id: 507 27 | name: sleepy 28 | 29 | goofy_tag: 30 | id: 508 31 | name: goofy 32 | 33 | wacky_tag: 34 | id: 509 35 | name: wacky 36 | 37 | bad_tag: 38 | id: 510 39 | name: bad 40 | 41 | tag_11: 42 | id: 511 43 | name: Tag11 44 | 45 | tag_12: 46 | id: 512 47 | name: Tag12 48 | 49 | tag_13: 50 | id: 513 51 | name: Tag13 52 | 53 | tag_14: 54 | id: 514 55 | name: Tag14 56 | 57 | tag_15: 58 | id: 515 59 | name: Tag15 60 | 61 | tag_16: 62 | id: 516 63 | name: Tag16 64 | -------------------------------------------------------------------------------- /test/fixtures/things.yml: -------------------------------------------------------------------------------- 1 | thing_10: 2 | id: 10 3 | user_id: 10001 4 | box_id: 100 5 | name: Thing10 6 | 7 | thing_20: 8 | id: 20 9 | user_id: 10001 10 | box_id: 100 11 | name: Thing20 12 | 13 | thing_30: 14 | id: 30 15 | user_id: 10002 16 | box_id: 102 17 | name: Thing30 18 | 19 | thing_40: 20 | id: 40 21 | user_id: 10002 22 | box_id: 23 | name: Thing40 24 | 25 | thing_50: 26 | id: 50 27 | user_id: 10002 28 | box_id: 29 | name: Thing50 30 | 31 | thing_60: 32 | id: 60 33 | user_id: 10002 34 | box_id: 35 | name: Thing60 36 | 37 | #thing_70: 38 | # id: 70 39 | # user_id: 10001 40 | # box_id: 100 41 | # name: Thing70 42 | -------------------------------------------------------------------------------- /test/fixtures/users.yml: -------------------------------------------------------------------------------- 1 | user_1: 2 | id: 10001 3 | name: user 1 4 | 5 | user_2: 6 | id: 10002 7 | name: user 2 8 | -------------------------------------------------------------------------------- /test/fixtures/vehicles.yml: -------------------------------------------------------------------------------- 1 | Miata: 2 | id: 1 3 | type: Car 4 | make: Mazda 5 | model: Miata MX5 6 | drive_layout: Front Engine RWD 7 | serial_number: 32432adfsfdysua 8 | person_id: 1001 9 | 10 | Launch20: 11 | id: 2 12 | type: Boat 13 | make: Chris-Craft 14 | model: Launch 20 15 | length_at_water_line: 15.5ft 16 | serial_number: 434253JJJSD 17 | person_id: 1001 18 | 19 | M5: 20 | id: 3 21 | type: Car 22 | make: BMW 23 | model: M5 24 | drive_layout: Front Engine RWD 25 | serial_number: 56256 26 | person_id: 2 27 | 28 | M3: 29 | id: 4 30 | type: Car 31 | make: BMW 32 | model: M3 33 | drive_layout: Front Engine RWD 34 | serial_number: 894345 35 | person_id: 2 36 | -------------------------------------------------------------------------------- /test/fixtures/web_pages.yml: -------------------------------------------------------------------------------- 1 | web_page1: 2 | href: http://example.com 3 | link: http://link.example.com 4 | -------------------------------------------------------------------------------- /test/fixtures/workers.yml: -------------------------------------------------------------------------------- 1 | john_doe_worker: 2 | id: 1 3 | name: "John Doe" 4 | access_card_id: 1 # access_cards.yml john_doe_worker_card 5 | -------------------------------------------------------------------------------- /test/helpers/assertions.rb: -------------------------------------------------------------------------------- 1 | module Helpers 2 | module Assertions 3 | def assert_hash_equals(exp, act, msg = nil) 4 | msg = message(msg, '') { diff exp, act } 5 | assert(matches_hash?(exp, act, {exact: true}), msg) 6 | end 7 | 8 | def assert_array_equals(exp, act, msg = nil) 9 | msg = message(msg, '') { diff exp, act } 10 | assert(matches_array?(exp, act, {exact: true}), msg) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/helpers/configuration_helpers.rb: -------------------------------------------------------------------------------- 1 | module Helpers 2 | module ConfigurationHelpers 3 | def with_jsonapi_config(new_config_options) 4 | original_config = JSONAPI.configuration.dup # TODO should be a deep dup 5 | begin 6 | new_config_options.each do |k, v| 7 | JSONAPI.configuration.send(:"#{k}=", v) 8 | end 9 | return yield 10 | ensure 11 | JSONAPI.configuration = original_config 12 | end 13 | end 14 | 15 | def with_resource_caching(cache, classes = :all) 16 | results = {total: {hits: 0, misses: 0}} 17 | new_config_options = { 18 | resource_cache: cache, 19 | default_caching: true, 20 | resource_cache_usage_report_function: Proc.new do |name, hits, misses| 21 | [name.to_sym, :total].each do |key| 22 | results[key] ||= {hits: 0, misses: 0} 23 | results[key][:hits] += hits 24 | results[key][:misses] += misses 25 | end 26 | end 27 | } 28 | 29 | with_jsonapi_config(new_config_options) do 30 | if classes == :all or (classes.is_a?(Hash) && classes.keys == [:except]) 31 | resource_classes = ObjectSpace.each_object(Class).select do |klass| 32 | if klass < JSONAPI::BasicResource 33 | # Not using Resource#_model_class to avoid tripping the warning early, which could 34 | # cause ResourceTest#test_nil_model_class to fail. 35 | model_class = klass._model_name.to_s.safe_constantize 36 | if model_class && model_class.respond_to?(:arel_table) 37 | next true 38 | end 39 | end 40 | next false 41 | end 42 | 43 | if classes.is_a?(Hash) 44 | classes.values.first.each do |excluded| 45 | deleted = resource_classes.delete(excluded) 46 | raise "Can't find #{excluded} among AR-based Resource classes" if deleted.nil? 47 | end 48 | end 49 | 50 | classes = resource_classes 51 | end 52 | 53 | begin 54 | yield 55 | end 56 | end 57 | 58 | return results 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/helpers/functional_helpers.rb: -------------------------------------------------------------------------------- 1 | module Helpers 2 | module FunctionalHelpers 3 | # from http://jamieonsoftware.com/blog/entry/testing-restful-response-types 4 | # def assert_response_is(type, message = '') 5 | # case type 6 | # when :js 7 | # check = [ 8 | # 'text/javascript' 9 | # ] 10 | # when :json 11 | # check = [ 12 | # 'application/json', 13 | # 'text/json', 14 | # 'application/x-javascript', 15 | # 'text/x-javascript', 16 | # 'text/x-json' 17 | # ] 18 | # when :xml 19 | # check = [ 'application/xml', 'text/xml' ] 20 | # when :yaml 21 | # check = [ 22 | # 'text/yaml', 23 | # 'text/x-yaml', 24 | # 'application/yaml', 25 | # 'application/x-yaml' 26 | # ] 27 | # else 28 | # if methods.include?('assert_response_types') 29 | # check = assert_response_types 30 | # else 31 | # check = [] 32 | # end 33 | # end 34 | # 35 | # if @response.media_type 36 | # ct = @response.media_type 37 | # elsif methods.include?('assert_response_response') 38 | # ct = assert_response_response 39 | # else 40 | # ct = '' 41 | # end 42 | # 43 | # begin 44 | # assert check.include?(ct) 45 | # rescue Test::Unit::AssertionFailedError 46 | # raise Test::Unit::AssertionFailedError.new(build_message(message, "The response type is not ?", type.to_s)) 47 | # end 48 | # end 49 | 50 | # def assert_js_redirect_to(path) 51 | # assert_response_is :js 52 | # assert_match /#{"window.location.href = \"" + path + "\""}/, @response.body 53 | # end 54 | # 55 | def json_response 56 | JSON.parse(@response.body) 57 | end 58 | end 59 | end -------------------------------------------------------------------------------- /test/helpers/value_matchers.rb: -------------------------------------------------------------------------------- 1 | module Helpers 2 | module ValueMatchers 3 | ### Matchers 4 | def matches_value?(v1, v2, options = {}) 5 | if v1 == :any 6 | # any value is acceptable 7 | elsif v1 == :not_nil 8 | if v2 == nil 9 | return false 10 | end 11 | elsif v1.kind_of?(Hash) 12 | unless matches_hash?(v1, v2, options) 13 | return false 14 | end 15 | elsif v1.kind_of?(Array) 16 | unless matches_array?(v1, v2, options) 17 | return false 18 | end 19 | else 20 | unless v2 == v1 21 | return false 22 | end 23 | end 24 | true 25 | end 26 | 27 | def matches_array?(array1, array2, options = {}) 28 | return false unless array1.kind_of?(Array) && array2.kind_of?(Array) 29 | if options[:exact] 30 | unless array1.size == array2.size 31 | return false 32 | end 33 | end 34 | 35 | # order of items shouldn't matter: 36 | # ['a', 'b', 'c'], ['b', 'c', 'a'] -> true 37 | # 38 | # matched items should only be used once: 39 | # ['a', 'b', 'c'], ['a', 'a', 'a'] -> false 40 | # ['a', 'a', 'a'], ['a', 'b', 'c'] -> false 41 | matched = {} 42 | (0..(array1.size - 1)).each do |i| 43 | (0..(array2.size - 1)).each do |j| 44 | if !matched.has_value?(j.to_s) && matches_value?(array1[i], array2[j], options) 45 | matched[i.to_s] = j.to_s 46 | break 47 | end 48 | end 49 | unless matched.has_key?(i.to_s) 50 | return false 51 | end 52 | end 53 | true 54 | end 55 | 56 | # options => {exact: true} # hashes must match exactly (i.e. have same number of key-value pairs that are all equal) 57 | def matches_hash?(hash1, hash2, options = {}) 58 | return false unless hash1.kind_of?(Hash) && hash2.kind_of?(Hash) 59 | if options[:exact] 60 | unless hash1.size == hash2.size 61 | return false 62 | end 63 | end 64 | 65 | hash1 = hash1.deep_symbolize_keys 66 | hash2 = hash2.deep_symbolize_keys 67 | 68 | hash1.each do |k1, v1| 69 | unless hash2.has_key?(k1) && matches_value?(v1, hash2[k1], options) 70 | return false 71 | end 72 | end 73 | true 74 | end 75 | end 76 | end -------------------------------------------------------------------------------- /test/helpers/value_matchers_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../test_helper', __FILE__) 2 | 3 | class ValueMatchersTest < ActionController::TestCase 4 | 5 | def test_matches_value_any 6 | assert(matches_value?(:any, 'a')) 7 | assert(matches_value?(:any, nil)) 8 | end 9 | 10 | def test_matches_value_not_nil 11 | assert(matches_value?(:not_nil, 'a')) 12 | refute(matches_value?(:not_nil, nil)) 13 | end 14 | 15 | def test_matches_value_array 16 | assert(matches_value?(['a', 'b', 'c'], ['b', 'c', 'a'])) 17 | assert(matches_value?(['a', 'b', 'c'], ['a', 'b', 'c'])) 18 | refute(matches_value?(['a', 'b', 'c'], ['a', 'a'])) 19 | refute(matches_value?(['a', 'b', 'c'], ['a', 'b', 'd'])) 20 | 21 | assert(matches_value?(['a', 'b', :any], ['a', 'b', 'c'])) 22 | assert(matches_value?(['a', 'b', :not_nil], ['a', 'b', 'c'])) 23 | refute(matches_value?(['a', 'b', :not_nil], ['a', 'b', nil])) 24 | end 25 | 26 | def test_matches_value_hash 27 | assert(matches_value?({a: 'a', b: 'b', c: 'c'}, {a: 'a', b: 'b', c: 'c'})) 28 | assert(matches_value?({a: 'a', b: 'b', c: 'c'}, {b: 'b', c: 'c', a: 'a'})) 29 | refute(matches_value?({a: 'a', b: 'b', c: 'c'}, {b: 'a', c: 'c', a: 'b'})) 30 | 31 | assert(matches_value?({a: 'a', b: 'b', c: {a: 'a', d: 'e'}}, {b: 'b', c: {a: 'a', d: 'e'}, a: 'a'})) 32 | refute(matches_value?({a: 'a', b: 'b', c: {a: 'a', d: 'd'}}, {b: 'b', c: {a: 'a', d: 'e'}, a: 'a'})) 33 | 34 | assert(matches_value?({a: 'a', b: 'b', c: {a: 'a', d: {a: :not_nil}}}, {b: 'b', c: {a: 'a', d: {a: 'b'}}, a: 'a'})) 35 | refute(matches_value?({a: 'a', b: 'b', c: {a: 'a', d: {a: :not_nil}}}, {b: 'b', c: {a: 'a', d: {a: nil}}, a: 'a'})) 36 | 37 | assert(matches_value?({a: 'a', b: 'b', c: {a: 'a', d: {a: :any}}}, {b: 'b', c: {a: 'a', d: {a: 'b'}}, a: 'a'})) 38 | assert(matches_value?({a: 'a', b: 'b', c: {a: 'a', d: {a: :any}}}, {b: 'b', c: {a: 'a', d: {a: nil}}, a: 'a'})) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/integration/requests/namespaced_model_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | 3 | class NamedspacedModelTest < ActionDispatch::IntegrationTest 4 | def setup 5 | JSONAPI.configuration.json_key_format = :underscored_key 6 | end 7 | 8 | def test_get_flat_posts 9 | assert_cacheable_jsonapi_get '/flat_posts' 10 | assert_equal "flat_posts", json_response["data"].first["type"] 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/integration/routes/routes_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | 3 | class RoutesTest < ActionDispatch::IntegrationTest 4 | 5 | # def test_dump_routes 6 | # r = {} 7 | # 8 | # Rails.application.routes.routes.each do |route| 9 | # r[route.path.spec.right.left.to_s] ||= {routes: {}} 10 | # r[route.path.spec.right.left.to_s][:routes][route.path.spec.to_s] ||= {} 11 | # r[route.path.spec.right.left.to_s][:routes][route.path.spec.to_s][route.defaults[:action]] = route 12 | # end 13 | # 14 | # r 15 | # end 16 | 17 | def test_routing_post 18 | assert_routing({path: 'posts', method: :post}, 19 | {controller: 'posts', action: 'create'}) 20 | end 21 | 22 | def test_routing_patch 23 | assert_routing({path: '/posts/1', method: :patch}, 24 | {controller: 'posts', action: 'update', id: '1'}) 25 | end 26 | 27 | def test_routing_posts_show 28 | assert_routing({path: '/posts/1', method: :get}, 29 | {action: 'show', controller: 'posts', id: '1'}) 30 | end 31 | 32 | def test_routing_posts_links_author_show 33 | assert_routing({path: '/posts/1/relationships/author', method: :get}, 34 | {controller: 'posts', action: 'show_relationship', post_id: '1', relationship: 'author'}) 35 | end 36 | 37 | def test_routing_posts_links_author_destroy 38 | assert_routing({path: '/posts/1/relationships/author', method: :delete}, 39 | {controller: 'posts', action: 'destroy_relationship', post_id: '1', relationship: 'author'}) 40 | end 41 | 42 | def test_routing_posts_links_author_update 43 | assert_routing({path: '/posts/1/relationships/author', method: :patch}, 44 | {controller: 'posts', action: 'update_relationship', post_id: '1', relationship: 'author'}) 45 | end 46 | 47 | def test_routing_posts_links_tags_show 48 | assert_routing({path: '/posts/1/relationships/tags', method: :get}, 49 | {controller: 'posts', action: 'show_relationship', post_id: '1', relationship: 'tags'}) 50 | end 51 | 52 | def test_routing_posts_links_tags_destroy 53 | assert_routing({path: '/posts/1/relationships/tags', method: :delete}, 54 | {controller: 'posts', action: 'destroy_relationship', post_id: '1', relationship: 'tags'}) 55 | end 56 | 57 | def test_routing_posts_links_tags_create 58 | assert_routing({path: '/posts/1/relationships/tags', method: :post}, 59 | {controller: 'posts', action: 'create_relationship', post_id: '1', relationship: 'tags'}) 60 | end 61 | 62 | def test_routing_posts_links_tags_update_acts_as_set 63 | assert_routing({path: '/posts/1/relationships/tags', method: :patch}, 64 | {controller: 'posts', action: 'update_relationship', post_id: '1', relationship: 'tags'}) 65 | end 66 | 67 | def test_routing_uuid 68 | assert_routing({path: '/pets/v1/cats/f1a4d5f2-e77a-4d0a-acbb-ee0b98b3f6b5', method: :get}, 69 | {action: 'show', controller: 'pets/v1/cats', id: 'f1a4d5f2-e77a-4d0a-acbb-ee0b98b3f6b5'}) 70 | end 71 | 72 | # ToDo: refute this routing 73 | # def test_routing_uuid_bad_format 74 | # assert_routing({path: '/pets/v1/cats/f1a4d5f2-e77a-4d0a-acbb-ee0b9', method: :get}, 75 | # {action: 'show', controller: 'pets/v1/cats', id: 'f1a4d5f2-e77a-4d0a-acbb-ee0b98'}) 76 | # end 77 | 78 | # Polymorphic 79 | # ToDo: refute this routing. Polymorphic relationships can't support a shared set of filters or includes so 80 | # this this route is no longer supported 81 | # def test_routing_polymorphic_show_related_resource 82 | # assert_routing( 83 | # { 84 | # path: '/pictures/1/imageable', 85 | # method: :get 86 | # }, 87 | # { 88 | # relationship: 'imageable', 89 | # source: 'pictures', 90 | # controller: 'imageables', 91 | # action: 'show_related_resource', 92 | # picture_id: '1' 93 | # } 94 | # ) 95 | # end 96 | 97 | def test_routing_polymorphic_patch_related_resource 98 | assert_routing( 99 | { 100 | path: '/pictures/1/relationships/imageable', 101 | method: :patch 102 | }, 103 | { 104 | relationship: 'imageable', 105 | controller: 'pictures', 106 | action: 'update_relationship', 107 | picture_id: '1' 108 | } 109 | ) 110 | end 111 | 112 | def test_routing_polymorphic_delete_related_resource 113 | assert_routing( 114 | { 115 | path: '/pictures/1/relationships/imageable', 116 | method: :delete 117 | }, 118 | { 119 | relationship: 'imageable', 120 | controller: 'pictures', 121 | action: 'destroy_relationship', 122 | picture_id: '1' 123 | } 124 | ) 125 | end 126 | 127 | # V1 128 | def test_routing_v1_posts_show 129 | assert_routing({path: '/api/v1/posts/1', method: :get}, 130 | {action: 'show', controller: 'api/v1/posts', id: '1'}) 131 | end 132 | 133 | def test_routing_v1_posts_delete 134 | assert_routing({path: '/api/v1/posts/1', method: :delete}, 135 | {action: 'destroy', controller: 'api/v1/posts', id: '1'}) 136 | end 137 | 138 | def test_routing_v1_posts_links_writer_show 139 | assert_routing({path: '/api/v1/posts/1/relationships/writer', method: :get}, 140 | {controller: 'api/v1/posts', action: 'show_relationship', post_id: '1', relationship: 'writer'}) 141 | end 142 | 143 | # V2 144 | def test_routing_v2_posts_links_author_show 145 | assert_routing({path: '/api/v2/posts/1/relationships/author', method: :get}, 146 | {controller: 'api/v2/posts', action: 'show_relationship', post_id: '1', relationship: 'author'}) 147 | end 148 | 149 | def test_routing_v2_preferences_show 150 | assert_routing({path: '/api/v2/preferences', method: :get}, 151 | {action: 'show', controller: 'api/v2/preferences'}) 152 | end 153 | 154 | # V3 155 | def test_routing_v3_posts_show 156 | assert_routing({path: '/api/v3/posts/1', method: :get}, 157 | {action: 'show', controller: 'api/v3/posts', id: '1'}) 158 | end 159 | 160 | # V4 camelCase 161 | def test_routing_v4_posts_show 162 | assert_routing({path: '/api/v4/posts/1', method: :get}, 163 | {action: 'show', controller: 'api/v4/posts', id: '1'}) 164 | end 165 | 166 | def test_routing_v4_isoCurrencies_resources 167 | assert_routing({path: '/api/v4/isoCurrencies/USD', method: :get}, 168 | {action: 'show', controller: 'api/v4/iso_currencies', id: 'USD'}) 169 | end 170 | 171 | def test_routing_v4_expenseEntries_resources 172 | assert_routing({path: '/api/v4/expenseEntries/1', method: :get}, 173 | {action: 'show', controller: 'api/v4/expense_entries', id: '1'}) 174 | 175 | assert_routing({path: '/api/v4/expenseEntries/1/relationships/isoCurrency', method: :get}, 176 | {controller: 'api/v4/expense_entries', action: 'show_relationship', expense_entry_id: '1', relationship: 'iso_currency'}) 177 | end 178 | 179 | # V5 dasherized 180 | def test_routing_v5_posts_show 181 | assert_routing({path: '/api/v5/posts/1', method: :get}, 182 | {action: 'show', controller: 'api/v5/posts', id: '1'}) 183 | end 184 | 185 | def test_routing_v5_isoCurrencies_resources 186 | assert_routing({path: '/api/v5/iso-currencies/USD', method: :get}, 187 | {action: 'show', controller: 'api/v5/iso_currencies', id: 'USD'}) 188 | end 189 | 190 | def test_routing_v5_expenseEntries_resources 191 | assert_routing({path: '/api/v5/expense-entries/1', method: :get}, 192 | {action: 'show', controller: 'api/v5/expense_entries', id: '1'}) 193 | 194 | assert_routing({path: '/api/v5/expense-entries/1/relationships/iso-currency', method: :get}, 195 | {controller: 'api/v5/expense_entries', action: 'show_relationship', expense_entry_id: '1', relationship: 'iso_currency'}) 196 | end 197 | 198 | def test_routing_authors_show 199 | assert_routing({path: '/api/v5/authors/1', method: :get}, 200 | {action: 'show', controller: 'api/v5/authors', id: '1'}) 201 | end 202 | 203 | def test_routing_author_links_posts_create_not_acts_as_set 204 | assert_routing({path: '/api/v5/authors/1/relationships/posts', method: :post}, 205 | {controller: 'api/v5/authors', action: 'create_relationship', author_id: '1', relationship: 'posts'}) 206 | end 207 | 208 | def test_routing_list_items_index 209 | assert_routing({path: '/list_items', method: :get}, 210 | {controller: 'list_items', action: 'index'}) 211 | end 212 | 213 | def test_routing_list_related_items 214 | assert_routing({path: '/lists/1/items', method: :get}, 215 | {controller: 'list_items', action: 'index_related_resources', relationship: 'items', list_id: '1', source: 'lists'}) 216 | end 217 | 218 | def test_list_items_route_helper_name 219 | assert_equal(list_items_path, '/list_items') 220 | end 221 | 222 | #primary_key 223 | def test_routing_primary_key_jsonapi_resources 224 | assert_routing({path: '/iso_currencies/USD', method: :get}, 225 | {action: 'show', controller: 'iso_currencies', id: 'USD'}) 226 | end 227 | 228 | # ToDo: Refute routing 229 | # def test_routing_v3_posts_delete 230 | # assert_routing({ path: '/api/v3/posts/1', method: :delete }, 231 | # {action: 'destroy', controller: 'api/v3/posts', id: '1'}) 232 | # end 233 | 234 | # def test_routing_posts_links_author_except_destroy 235 | # assert_routing({ path: '/api/v3/posts/1/relationships/author', method: :delete }, 236 | # { controller: 'api/v3/posts', action: 'destroy_relationship', post_id: '1', relationship: 'author' }) 237 | # end 238 | # 239 | # def test_routing_posts_links_tags_only_create_show 240 | # assert_routing({ path: '/api/v3/posts/1/relationships/tags/1,2', method: :delete }, 241 | # { controller: 'api/v3/posts', action: 'destroy_relationship', post_id: '1', keys: '1,2', relationship: 'tags' }) 242 | # end 243 | 244 | # Test that non-acts-as-set to_many relationship update route is not created 245 | 246 | end 247 | -------------------------------------------------------------------------------- /test/integration/sti_fields_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../../test_helper", __FILE__) 2 | 3 | class StiFieldsTest < ActionDispatch::IntegrationTest 4 | def test_index_fields_when_resource_does_not_match_relationship 5 | get "/posts", params: { 6 | filter: { id: "1,2" }, 7 | include: "author", 8 | fields: { posts: "author", people: "email" } }, 9 | headers: { 'Accept' => JSONAPI::MEDIA_TYPE } 10 | assert_response :success 11 | assert_equal 2, json_response["data"].size 12 | assert json_response["data"][0]["relationships"].key?("author") 13 | assert json_response["included"][0]["attributes"].keys == ["email"] 14 | end 15 | 16 | def test_fields_for_parent_class 17 | get "/firms", params: { fields: { companies: "name" } }, headers: { 18 | 'Accept' => JSONAPI::MEDIA_TYPE 19 | } 20 | assert_equal json_response["data"][0]["attributes"].keys, ["name"] 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/lib/generators/jsonapi/controller_generator_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../../test_helper', __FILE__) 2 | require 'generators/jsonapi/controller_generator' 3 | 4 | module Jsonapi 5 | class ControllerGeneratorTest < Rails::Generators::TestCase 6 | tests ControllerGenerator 7 | destination Rails.root.join('../controllers') 8 | setup :prepare_destination 9 | teardown :cleanup_destination_root 10 | 11 | def cleanup_destination_root 12 | FileUtils.rm_rf destination_root 13 | end 14 | 15 | test "controller is created" do 16 | run_generator ["post"] 17 | assert_file 'app/controllers/posts_controller.rb', /class PostsController < JSONAPI::ResourceController/ 18 | end 19 | 20 | test "controller is created with namespace" do 21 | run_generator ["api/v1/post"] 22 | assert_file 'app/controllers/api/v1/posts_controller.rb', /class Api::V1::PostsController < JSONAPI::ResourceController/ 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/lib/generators/jsonapi/resource_generator_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../../test_helper', __FILE__) 2 | require 'generators/jsonapi/resource_generator' 3 | 4 | module Jsonapi 5 | class ResourceGeneratorTest < Rails::Generators::TestCase 6 | tests ResourceGenerator 7 | destination Rails.root.join('../resources') 8 | setup :prepare_destination 9 | teardown :cleanup_destination_root 10 | 11 | def cleanup_destination_root 12 | FileUtils.rm_rf destination_root 13 | end 14 | 15 | test "resource is created" do 16 | run_generator ["post"] 17 | assert_file 'app/resources/post_resource.rb', /class PostResource < JSONAPI::Resource/ 18 | end 19 | 20 | test "resource is singular" do 21 | run_generator ["posts"] 22 | assert_file 'app/resources/post_resource.rb', /class PostResource < JSONAPI::Resource/ 23 | end 24 | 25 | test "resource is created with namespace" do 26 | run_generator ["api/v1/post"] 27 | assert_file 'app/resources/api/v1/post_resource.rb', /class Api::V1::PostResource < JSONAPI::Resource/ 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/unit/formatters/dasherized_key_formatter_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | 3 | class DasherizedKeyFormatterTest < ActiveSupport::TestCase 4 | def test_dasherize_camelize 5 | formatted = DasherizedKeyFormatter.format("CarWash") 6 | assert_equal formatted, "car-wash" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/unit/jsonapi_request/jsonapi_request_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | 3 | class CatResource < JSONAPI::Resource 4 | attribute :name 5 | attribute :breed 6 | 7 | has_one :mother, class_name: 'Cat' 8 | has_one :father, class_name: 'Cat' 9 | 10 | filters :name 11 | 12 | def self.sortable_fields(context) 13 | super(context) << :"mother.name" 14 | end 15 | end 16 | 17 | class TreeResource < JSONAPI::Resource 18 | def self.sortable_field?(key, context) 19 | key =~ /^sort\d+/ 20 | end 21 | end 22 | 23 | class JSONAPIRequestTest < ActiveSupport::TestCase 24 | def test_parse_includes_underscored 25 | params = ActionController::Parameters.new( 26 | { 27 | controller: 'expense_entries', 28 | action: 'index', 29 | include: 'iso_currency' 30 | } 31 | ) 32 | 33 | request = JSONAPI::Request.new( 34 | params, 35 | { 36 | context: nil, 37 | key_formatter: JSONAPI::Formatter.formatter_for(:underscored_key) 38 | } 39 | ) 40 | 41 | request.parse_include_directives(ExpenseEntryResource, params[:include]) 42 | assert request.errors.empty? 43 | end 44 | 45 | def test_check_include_allowed 46 | reset_includes 47 | JSONAPI::Request.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) 48 | ensure 49 | reset_includes 50 | end 51 | 52 | def test_check_nested_include_allowed 53 | reset_includes 54 | JSONAPI::Request.new.check_include(ExpenseEntryResource, "employee.expenseEntries".partition('.')) 55 | ensure 56 | reset_includes 57 | end 58 | 59 | def test_check_include_relationship_does_not_exist 60 | reset_includes 61 | 62 | assert_raises JSONAPI::Exceptions::InvalidInclude do 63 | assert JSONAPI::Request.new.check_include(ExpenseEntryResource, "foo".partition('.')) 64 | end 65 | ensure 66 | reset_includes 67 | end 68 | 69 | def test_check_nested_include_relationship_does_not_exist_wrong_format 70 | reset_includes 71 | 72 | assert_raises JSONAPI::Exceptions::InvalidInclude do 73 | JSONAPI::Request.new.check_include(ExpenseEntryResource, "employee.expense-entries".partition('.')) 74 | end 75 | ensure 76 | reset_includes 77 | end 78 | 79 | def test_check_include_has_one_not_allowed_default 80 | reset_includes 81 | 82 | JSONAPI::Request.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) 83 | JSONAPI.configuration.default_allow_include_to_one = false 84 | 85 | assert_raises JSONAPI::Exceptions::InvalidInclude do 86 | JSONAPI::Request.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) 87 | end 88 | ensure 89 | reset_includes 90 | end 91 | 92 | def test_check_include_has_one_not_allowed_resource 93 | reset_includes 94 | 95 | JSONAPI::Request.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) 96 | ExpenseEntryResource._relationship(:iso_currency).allow_include = false 97 | 98 | assert_raises JSONAPI::Exceptions::InvalidInclude do 99 | JSONAPI::Request.new.check_include(ExpenseEntryResource, "isoCurrency".partition('.')) 100 | end 101 | ensure 102 | reset_includes 103 | end 104 | 105 | def test_check_include_has_many_not_allowed_default 106 | reset_includes 107 | 108 | JSONAPI::Request.new.check_include(EmployeeResource, "expenseEntries".partition('.')) 109 | JSONAPI.configuration.default_allow_include_to_many = false 110 | 111 | assert_raises JSONAPI::Exceptions::InvalidInclude do 112 | JSONAPI::Request.new.check_include(EmployeeResource, "expenseEntries".partition('.')) 113 | end 114 | ensure 115 | reset_includes 116 | end 117 | 118 | def test_check_include_has_many_not_allowed_resource 119 | reset_includes 120 | 121 | JSONAPI::Request.new.check_include(EmployeeResource, "expenseEntries".partition('.')) 122 | EmployeeResource._relationship(:expense_entries).allow_include = false 123 | 124 | assert_raises JSONAPI::Exceptions::InvalidInclude do 125 | JSONAPI::Request.new.check_include(EmployeeResource, "expenseEntries".partition('.')) 126 | end 127 | ensure 128 | reset_includes 129 | end 130 | 131 | def test_parse_dasherized_with_dasherized_include 132 | params = ActionController::Parameters.new( 133 | { 134 | controller: 'expense_entries', 135 | action: 'index', 136 | include: 'iso-currency' 137 | } 138 | ) 139 | 140 | request = JSONAPI::Request.new( 141 | params, 142 | { 143 | context: nil, 144 | key_formatter: JSONAPI::Formatter.formatter_for(:dasherized_key) 145 | } 146 | ) 147 | 148 | request.parse_include_directives(ExpenseEntryResource, params[:include]) 149 | assert request.errors.empty? 150 | end 151 | 152 | def test_parse_dasherized_with_underscored_include 153 | params = ActionController::Parameters.new( 154 | { 155 | controller: 'expense_entries', 156 | action: 'index', 157 | include: 'iso_currency' 158 | } 159 | ) 160 | 161 | request = JSONAPI::Request.new( 162 | params, 163 | { 164 | context: nil, 165 | key_formatter: JSONAPI::Formatter.formatter_for(:dasherized_key) 166 | } 167 | ) 168 | 169 | request.parse_include_directives(ExpenseEntryResource, params[:include]) 170 | refute request.errors.empty? 171 | assert_equal 'iso_currency is not a valid includable relationship of expense-entries', request.errors[0].detail 172 | end 173 | 174 | def test_parse_fields_underscored 175 | params = ActionController::Parameters.new( 176 | { 177 | controller: 'expense_entries', 178 | action: 'index', 179 | fields: {expense_entries: 'iso_currency'} 180 | } 181 | ) 182 | 183 | request = JSONAPI::Request.new( 184 | params, 185 | { 186 | context: nil, 187 | key_formatter: JSONAPI::Formatter.formatter_for(:underscored_key) 188 | } 189 | ) 190 | 191 | request.parse_fields(ExpenseEntryResource, params[:fields]) 192 | assert request.errors.empty? 193 | end 194 | 195 | def test_parse_dasherized_with_dasherized_fields 196 | params = ActionController::Parameters.new( 197 | { 198 | controller: 'expense_entries', 199 | action: 'index', 200 | fields: { 201 | 'expense-entries' => 'iso-currency' 202 | } 203 | } 204 | ) 205 | 206 | request = JSONAPI::Request.new( 207 | params, 208 | { 209 | context: nil, 210 | key_formatter: JSONAPI::Formatter.formatter_for(:dasherized_key) 211 | } 212 | ) 213 | 214 | request.parse_fields(ExpenseEntryResource, params[:fields]) 215 | assert request.errors.empty? 216 | end 217 | 218 | def test_parse_dasherized_with_underscored_fields 219 | params = ActionController::Parameters.new( 220 | { 221 | controller: 'expense_entries', 222 | action: 'index', 223 | fields: { 224 | 'expense-entries' => 'iso_currency' 225 | } 226 | } 227 | ) 228 | 229 | request = JSONAPI::Request.new( 230 | params, 231 | { 232 | context: nil, 233 | key_formatter: JSONAPI::Formatter.formatter_for(:dasherized_key) 234 | } 235 | ) 236 | 237 | e = assert_raises JSONAPI::Exceptions::InvalidField do 238 | request.parse_fields(ExpenseEntryResource, params[:fields]) 239 | end 240 | refute e.errors.empty? 241 | assert_equal 'iso_currency is not a valid field for expense-entries.', e.errors[0].detail 242 | end 243 | 244 | def test_parse_dasherized_with_underscored_resource 245 | params = ActionController::Parameters.new( 246 | { 247 | controller: 'expense_entries', 248 | action: 'index', 249 | fields: { 250 | 'expense_entries' => 'iso-currency' 251 | } 252 | } 253 | ) 254 | 255 | request = JSONAPI::Request.new( 256 | params, 257 | { 258 | context: nil, 259 | key_formatter: JSONAPI::Formatter.formatter_for(:dasherized_key) 260 | } 261 | ) 262 | e = assert_raises JSONAPI::Exceptions::InvalidResource do 263 | request.parse_fields(ExpenseEntryResource, params[:fields]) 264 | end 265 | refute e.errors.empty? 266 | assert_equal 'expense_entries is not a valid resource.', e.errors[0].detail 267 | end 268 | 269 | def test_parse_filters_with_valid_filters 270 | setup_request 271 | filters = @request.parse_filters(CatResource, {name: 'Whiskers'}) 272 | assert_equal(filters[:name], 'Whiskers') 273 | assert_equal(@request.errors, []) 274 | end 275 | 276 | def test_parse_filters_with_non_valid_filter 277 | setup_request 278 | e = assert_raises JSONAPI::Exceptions::FilterNotAllowed do 279 | @request.parse_filters(CatResource, {breed: 'Whiskers'}) # breed is not a set filter 280 | end 281 | assert_equal 'breed is not allowed.', e.errors[0].detail 282 | end 283 | 284 | def test_parse_filters_with_no_filters 285 | setup_request 286 | filters = @request.parse_filters(CatResource, nil) 287 | assert_equal(filters, {}) 288 | assert_equal(@request.errors, []) 289 | end 290 | 291 | def test_parse_filters_with_invalid_filters_param 292 | setup_request 293 | filters = @request.parse_filters(CatResource, 'noeach') # String does not implement #each 294 | assert_equal(filters, {}) 295 | assert_equal(@request.errors.count, 1) 296 | assert_equal(@request.errors.first.title, "Invalid filters syntax") 297 | end 298 | 299 | def test_parse_sort_with_valid_sorts 300 | setup_request 301 | sort_criteria = @request.parse_sort_criteria(CatResource, "-name") 302 | assert_equal(@request.errors, []) 303 | assert_equal(sort_criteria, [{:field=>"name", :direction=>:desc}]) 304 | end 305 | 306 | def test_parse_sort_with_resource_validated_sorts 307 | setup_request 308 | e = assert_raises JSONAPI::Exceptions::InvalidSortCriteria do 309 | @request.parse_sort_criteria(TreeResource, "sort66,name") 310 | end 311 | assert_equal 'name is not a valid sort criteria for trees', e.errors[0].detail 312 | end 313 | 314 | def test_parse_sort_with_relationships 315 | setup_request 316 | sort_criteria = @request.parse_sort_criteria(CatResource, "-mother.name") 317 | assert_equal(@request.errors, []) 318 | assert_equal(sort_criteria, [{:field=>"mother.name", :direction=>:desc}]) 319 | end 320 | 321 | private 322 | 323 | def setup_request 324 | @request = JSONAPI::Request.new 325 | end 326 | 327 | def reset_includes 328 | JSONAPI.configuration.json_key_format = :camelized_key 329 | JSONAPI.configuration.default_allow_include_to_one = true 330 | JSONAPI.configuration.default_allow_include_to_many = true 331 | ExpenseEntryResource._relationship(:iso_currency).allow_include = nil 332 | EmployeeResource._relationship(:expense_entries).allow_include = nil 333 | end 334 | end 335 | -------------------------------------------------------------------------------- /test/unit/pagination/offset_paginator_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | require 'jsonapi-resources' 3 | 4 | class OffsetPaginatorTest < ActiveSupport::TestCase 5 | 6 | def test_offset_default_page_params 7 | params = ActionController::Parameters.new( 8 | { 9 | } 10 | ) 11 | 12 | paginator = OffsetPaginator.new(params) 13 | 14 | assert_equal JSONAPI.configuration.default_page_size, paginator.limit 15 | assert_equal 0, paginator.offset 16 | end 17 | 18 | def test_offset_parse_page_params_default_offset 19 | params = ActionController::Parameters.new( 20 | { 21 | limit: 20 22 | } 23 | ) 24 | 25 | paginator = OffsetPaginator.new(params) 26 | 27 | assert_equal 20, paginator.limit 28 | assert_equal 0, paginator.offset 29 | end 30 | 31 | def test_offset_parse_page_params 32 | params = ActionController::Parameters.new( 33 | { 34 | limit: 5, 35 | offset: 7 36 | } 37 | ) 38 | 39 | paginator = OffsetPaginator.new(params) 40 | 41 | assert_equal 5, paginator.limit 42 | assert_equal 7, paginator.offset 43 | end 44 | 45 | def test_offset_parse_page_params_limit_too_large 46 | params = ActionController::Parameters.new( 47 | { 48 | limit: 50, 49 | offset: 0 50 | } 51 | ) 52 | 53 | assert_raises JSONAPI::Exceptions::InvalidPageValue do 54 | OffsetPaginator.new(params) 55 | end 56 | end 57 | 58 | def test_offset_parse_page_params_not_allowed 59 | params = ActionController::Parameters.new( 60 | { 61 | limit: 50, 62 | start: 0 63 | } 64 | ) 65 | 66 | assert_raises JSONAPI::Exceptions::PageParametersNotAllowed do 67 | OffsetPaginator.new(params) 68 | end 69 | end 70 | 71 | def test_offset_parse_page_params_start 72 | params = ActionController::Parameters.new( 73 | { 74 | limit: 5, 75 | offset: 0 76 | } 77 | ) 78 | 79 | paginator = OffsetPaginator.new(params) 80 | 81 | assert_equal 5, paginator.limit 82 | assert_equal 0, paginator.offset 83 | end 84 | 85 | def test_offset_links_page_params_empty_results 86 | params = ActionController::Parameters.new( 87 | { 88 | limit: 5, 89 | offset: 0 90 | } 91 | ) 92 | 93 | paginator = OffsetPaginator.new(params) 94 | links_params = paginator.links_page_params(record_count: 0) 95 | 96 | assert_equal 2, links_params.size 97 | 98 | assert_equal 5, links_params['first']['limit'] 99 | assert_equal 0, links_params['first']['offset'] 100 | 101 | assert_equal 5, links_params['last']['limit'] 102 | assert_equal 0, links_params['last']['offset'] 103 | end 104 | 105 | def test_offset_links_page_params_small_resultsets 106 | params = ActionController::Parameters.new( 107 | { 108 | limit: 5, 109 | offset: 0 110 | } 111 | ) 112 | 113 | paginator = OffsetPaginator.new(params) 114 | links_params = paginator.links_page_params(record_count: 3) 115 | 116 | assert_equal 2, links_params.size 117 | 118 | assert_equal 5, links_params['first']['limit'] 119 | assert_equal 0, links_params['first']['offset'] 120 | 121 | assert_equal 5, links_params['last']['limit'] 122 | assert_equal 0, links_params['last']['offset'] 123 | end 124 | 125 | def test_offset_links_page_params_large_data_set_start 126 | params = ActionController::Parameters.new( 127 | { 128 | limit: 5, 129 | offset: 0 130 | } 131 | ) 132 | 133 | paginator = OffsetPaginator.new(params) 134 | links_params = paginator.links_page_params(record_count: 50) 135 | 136 | assert_equal 3, links_params.size 137 | 138 | assert_equal 5, links_params['first']['limit'] 139 | assert_equal 0, links_params['first']['offset'] 140 | 141 | assert_equal 5, links_params['next']['limit'] 142 | assert_equal 5, links_params['next']['offset'] 143 | 144 | assert_equal 5, links_params['last']['limit'] 145 | assert_equal 45, links_params['last']['offset'] 146 | end 147 | 148 | def test_offset_links_page_params_large_data_set_before_start 149 | params = ActionController::Parameters.new( 150 | { 151 | limit: 5, 152 | offset: 2 153 | } 154 | ) 155 | 156 | paginator = OffsetPaginator.new(params) 157 | links_params = paginator.links_page_params(record_count: 50) 158 | 159 | assert_equal 4, links_params.size 160 | 161 | assert_equal 5, links_params['first']['limit'] 162 | assert_equal 0, links_params['first']['offset'] 163 | 164 | assert_equal 5, links_params['prev']['limit'] 165 | assert_equal 0, links_params['prev']['offset'] 166 | 167 | assert_equal 5, links_params['next']['limit'] 168 | assert_equal 7, links_params['next']['offset'] 169 | 170 | assert_equal 5, links_params['last']['limit'] 171 | assert_equal 45, links_params['last']['offset'] 172 | end 173 | 174 | def test_offset_links_page_params_large_data_set_middle 175 | params = ActionController::Parameters.new( 176 | { 177 | limit: 5, 178 | offset: 27 179 | } 180 | ) 181 | 182 | paginator = OffsetPaginator.new(params) 183 | links_params = paginator.links_page_params(record_count: 50) 184 | 185 | assert_equal 4, links_params.size 186 | 187 | assert_equal 5, links_params['first']['limit'] 188 | assert_equal 0, links_params['first']['offset'] 189 | 190 | assert_equal 5, links_params['prev']['limit'] 191 | assert_equal 22, links_params['prev']['offset'] 192 | 193 | assert_equal 5, links_params['next']['limit'] 194 | assert_equal 32, links_params['next']['offset'] 195 | 196 | assert_equal 5, links_params['last']['limit'] 197 | assert_equal 45, links_params['last']['offset'] 198 | end 199 | 200 | def test_offset_links_page_params_large_data_set_end 201 | params = ActionController::Parameters.new( 202 | { 203 | limit: 5, 204 | offset: 45 205 | } 206 | ) 207 | 208 | paginator = OffsetPaginator.new(params) 209 | links_params = paginator.links_page_params(record_count: 50) 210 | 211 | assert_equal 3, links_params.size 212 | 213 | assert_equal 5, links_params['first']['limit'] 214 | assert_equal 0, links_params['first']['offset'] 215 | 216 | assert_equal 5, links_params['prev']['limit'] 217 | assert_equal 40, links_params['prev']['offset'] 218 | 219 | assert_equal 5, links_params['last']['limit'] 220 | assert_equal 45, links_params['last']['offset'] 221 | end 222 | 223 | def test_offset_links_page_params_large_data_set_past_end 224 | params = ActionController::Parameters.new( 225 | { 226 | limit: 5, 227 | offset: 48 228 | } 229 | ) 230 | 231 | paginator = OffsetPaginator.new(params) 232 | links_params = paginator.links_page_params(record_count: 50) 233 | 234 | assert_equal 3, links_params.size 235 | 236 | assert_equal 5, links_params['first']['limit'] 237 | assert_equal 0, links_params['first']['offset'] 238 | 239 | assert_equal 5, links_params['prev']['limit'] 240 | assert_equal 43, links_params['prev']['offset'] 241 | 242 | assert_equal 5, links_params['last']['limit'] 243 | assert_equal 45, links_params['last']['offset'] 244 | end 245 | end 246 | -------------------------------------------------------------------------------- /test/unit/pagination/paged_paginator_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | require 'jsonapi-resources' 3 | 4 | class PagedPaginatorTest < ActiveSupport::TestCase 5 | 6 | def test_paged_default_page_params 7 | params = ActionController::Parameters.new( 8 | { 9 | } 10 | ) 11 | 12 | paginator = PagedPaginator.new(params) 13 | 14 | assert_equal JSONAPI.configuration.default_page_size, paginator.size 15 | assert_equal 1, paginator.number 16 | end 17 | 18 | def test_paged_parse_page_params_default_page 19 | params = ActionController::Parameters.new( 20 | { 21 | size: 20 22 | } 23 | ) 24 | 25 | paginator = PagedPaginator.new(params) 26 | 27 | assert_equal 20, paginator.size 28 | assert_equal 1, paginator.number 29 | end 30 | 31 | def test_paged_parse_page_params 32 | params = ActionController::Parameters.new( 33 | { 34 | size: 5, 35 | number: 7 36 | } 37 | ) 38 | 39 | paginator = PagedPaginator.new(params) 40 | 41 | assert_equal 5, paginator.size 42 | assert_equal 7, paginator.number 43 | end 44 | 45 | def test_paged_parse_page_params_size_too_large 46 | params = ActionController::Parameters.new( 47 | { 48 | size: 50, 49 | number: 1 50 | } 51 | ) 52 | 53 | assert_raises JSONAPI::Exceptions::InvalidPageValue do 54 | PagedPaginator.new(params) 55 | end 56 | end 57 | 58 | def test_paged_parse_page_params_not_allowed 59 | params = ActionController::Parameters.new( 60 | { 61 | size: 50, 62 | start: 1 63 | } 64 | ) 65 | 66 | assert_raises JSONAPI::Exceptions::PageParametersNotAllowed do 67 | PagedPaginator.new(params) 68 | end 69 | end 70 | 71 | def test_paged_parse_page_params_start 72 | params = ActionController::Parameters.new( 73 | { 74 | size: 5, 75 | number: 1 76 | } 77 | ) 78 | 79 | paginator = PagedPaginator.new(params) 80 | 81 | assert_equal 5, paginator.size 82 | assert_equal 1, paginator.number 83 | end 84 | 85 | def test_paged_links_page_params_empty_results 86 | params = ActionController::Parameters.new( 87 | { 88 | size: 5, 89 | number: 1 90 | } 91 | ) 92 | 93 | paginator = PagedPaginator.new(params) 94 | links_params = paginator.links_page_params(record_count: 0) 95 | 96 | assert_equal 2, links_params.size 97 | 98 | assert_equal 5, links_params['first']['size'] 99 | assert_equal 1, links_params['first']['number'] 100 | 101 | assert_equal 5, links_params['last']['size'] 102 | assert_equal 1, links_params['last']['number'] 103 | end 104 | 105 | def test_paged_links_page_params_small_resultsets 106 | params = ActionController::Parameters.new( 107 | { 108 | size: 5, 109 | number: 1 110 | } 111 | ) 112 | 113 | paginator = PagedPaginator.new(params) 114 | links_params = paginator.links_page_params(record_count: 3) 115 | 116 | assert_equal 2, links_params.size 117 | 118 | assert_equal 5, links_params['first']['size'] 119 | assert_equal 1, links_params['first']['number'] 120 | 121 | assert_equal 5, links_params['last']['size'] 122 | assert_equal 1, links_params['last']['number'] 123 | end 124 | 125 | def test_paged_links_page_params_large_data_set_start_full_pages 126 | params = ActionController::Parameters.new( 127 | { 128 | size: 5, 129 | number: 1 130 | } 131 | ) 132 | 133 | paginator = PagedPaginator.new(params) 134 | links_params = paginator.links_page_params(record_count: 50) 135 | 136 | assert_equal 3, links_params.size 137 | 138 | assert_equal 5, links_params['first']['size'] 139 | assert_equal 1, links_params['first']['number'] 140 | 141 | assert_equal 5, links_params['next']['size'] 142 | assert_equal 2, links_params['next']['number'] 143 | 144 | assert_equal 5, links_params['last']['size'] 145 | assert_equal 10, links_params['last']['number'] 146 | end 147 | 148 | def test_paged_links_page_params_large_data_set_start_partial_last 149 | params = ActionController::Parameters.new( 150 | { 151 | size: 5, 152 | number: 1 153 | } 154 | ) 155 | 156 | paginator = PagedPaginator.new(params) 157 | links_params = paginator.links_page_params(record_count: 51) 158 | 159 | assert_equal 3, links_params.size 160 | 161 | assert_equal 5, links_params['first']['size'] 162 | assert_equal 1, links_params['first']['number'] 163 | 164 | assert_equal 5, links_params['next']['size'] 165 | assert_equal 2, links_params['next']['number'] 166 | 167 | assert_equal 5, links_params['last']['size'] 168 | assert_equal 11, links_params['last']['number'] 169 | end 170 | 171 | def test_paged_links_page_params_large_data_set_middle 172 | params = ActionController::Parameters.new( 173 | { 174 | size: 5, 175 | number: 4 176 | } 177 | ) 178 | 179 | paginator = PagedPaginator.new(params) 180 | links_params = paginator.links_page_params(record_count: 50) 181 | 182 | assert_equal 4, links_params.size 183 | 184 | assert_equal 5, links_params['first']['size'] 185 | assert_equal 1, links_params['first']['number'] 186 | 187 | assert_equal 5, links_params['prev']['size'] 188 | assert_equal 3, links_params['prev']['number'] 189 | 190 | assert_equal 5, links_params['next']['size'] 191 | assert_equal 5, links_params['next']['number'] 192 | 193 | assert_equal 5, links_params['last']['size'] 194 | assert_equal 10, links_params['last']['number'] 195 | end 196 | 197 | def test_paged_links_page_params_large_data_set_end 198 | params = ActionController::Parameters.new( 199 | { 200 | size: 5, 201 | number: 10 202 | } 203 | ) 204 | 205 | paginator = PagedPaginator.new(params) 206 | links_params = paginator.links_page_params(record_count: 50) 207 | 208 | assert_equal 3, links_params.size 209 | 210 | assert_equal 5, links_params['first']['size'] 211 | assert_equal 1, links_params['first']['number'] 212 | 213 | assert_equal 5, links_params['prev']['size'] 214 | assert_equal 9, links_params['prev']['number'] 215 | 216 | assert_equal 5, links_params['last']['size'] 217 | assert_equal 10, links_params['last']['number'] 218 | end 219 | 220 | def test_paged_links_page_params_large_data_set_past_end 221 | params = ActionController::Parameters.new( 222 | { 223 | size: 5, 224 | number: 11 225 | } 226 | ) 227 | 228 | paginator = PagedPaginator.new(params) 229 | links_params = paginator.links_page_params(record_count: 50) 230 | 231 | assert_equal 3, links_params.size 232 | 233 | assert_equal 5, links_params['first']['size'] 234 | assert_equal 1, links_params['first']['number'] 235 | 236 | assert_equal 5, links_params['prev']['size'] 237 | assert_equal 10, links_params['prev']['number'] 238 | 239 | assert_equal 5, links_params['last']['size'] 240 | assert_equal 10, links_params['last']['number'] 241 | end 242 | end 243 | -------------------------------------------------------------------------------- /test/unit/paths/path_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | require 'jsonapi-resources' 3 | 4 | class PathTest < ActiveSupport::TestCase 5 | 6 | def test_one_relationship 7 | path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments') 8 | 9 | assert path.segments.is_a?(Array) 10 | assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" 11 | assert_equal Api::V1::PostResource._relationship(:comments), path.segments[0].relationship 12 | end 13 | 14 | def test_one_field 15 | path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'title') 16 | 17 | assert path.segments.is_a?(Array) 18 | assert path.segments[0].is_a?(JSONAPI::PathSegment::Field), "should be a PathSegment::Field" 19 | assert_equal 'title', path.segments[0].field_name 20 | end 21 | 22 | def test_two_relationships 23 | path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments.author') 24 | 25 | assert path.segments.is_a?(Array) 26 | assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" 27 | assert path.segments[1].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" 28 | assert_equal Api::V1::PostResource._relationship(:comments), path.segments[0].relationship 29 | assert_equal Api::V1::CommentResource._relationship(:author), path.segments[1].relationship 30 | end 31 | 32 | def test_two_relationships_and_field 33 | path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments.author.name') 34 | 35 | assert path.segments.is_a?(Array) 36 | assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" 37 | assert path.segments[1].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" 38 | assert path.segments[2].is_a?(JSONAPI::PathSegment::Field), "should be a PathSegment::Field" 39 | 40 | assert_equal Api::V1::PostResource._relationship(:comments), path.segments[0].relationship 41 | assert_equal Api::V1::CommentResource._relationship(:author), path.segments[1].relationship 42 | assert_equal 'name', path.segments[2].field_name 43 | 44 | assert_equal 2, path.relationship_segments.length 45 | end 46 | 47 | def test_two_relationships_and_parse_fields_false_raises_with_field 48 | 49 | assert_raises JSONAPI::Exceptions::InvalidRelationship do 50 | path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, 51 | path_string: 'comments.author.name', 52 | parse_fields: false) 53 | end 54 | end 55 | 56 | def test_ensure_default_field_false 57 | path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments.author', ensure_default_field: false) 58 | 59 | assert path.segments.is_a?(Array) 60 | assert_equal 2, path.segments.length 61 | assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" 62 | assert path.segments[1].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" 63 | 64 | assert_equal Api::V1::PostResource._relationship(:comments), path.segments[0].relationship 65 | assert_equal Api::V1::CommentResource._relationship(:author), path.segments[1].relationship 66 | end 67 | 68 | def test_ensure_default_field_true 69 | path = JSONAPI::Path.new(resource_klass: Api::V1::PostResource, path_string: 'comments.author', ensure_default_field: true) 70 | 71 | assert path.segments.is_a?(Array) 72 | assert_equal 3, path.segments.length 73 | assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" 74 | assert path.segments[1].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" 75 | 76 | assert_equal Api::V1::PostResource._relationship(:comments), path.segments[0].relationship 77 | assert_equal Api::V1::CommentResource._relationship(:author), path.segments[1].relationship 78 | end 79 | 80 | def test_polymorphic_path 81 | path = JSONAPI::Path.new(resource_klass: PictureResource, path_string: :imageable) 82 | 83 | assert path.segments.is_a?(Array) 84 | assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" 85 | assert_equal PictureResource._relationship(:imageable), path.segments[0].relationship 86 | refute path.segments[0].path_specified_resource_klass?, "should note that the resource klass was not specified" 87 | end 88 | 89 | def test_polymorphic_path_with_resource_type 90 | path = JSONAPI::Path.new(resource_klass: PictureResource, path_string: 'imageable#documents') 91 | 92 | assert path.segments.is_a?(Array) 93 | assert path.segments[0].is_a?(JSONAPI::PathSegment::Relationship), "should be a PathSegment::Relationship" 94 | assert_equal PictureResource._relationship(:imageable), path.segments[0].relationship 95 | assert_equal DocumentResource, path.segments[0].resource_klass, "should return the specified resource klass" 96 | assert path.segments[0].path_specified_resource_klass?, "should note that the resource klass was specified" 97 | end 98 | 99 | def test_polymorphic_path_with_wrong_resource_type 100 | assert_raises JSONAPI::Exceptions::InvalidRelationship do 101 | JSONAPI::Path.new(resource_klass: PictureResource, path_string: 'imageable#docs') 102 | end 103 | end 104 | 105 | def test_raises_when_field_is_specified_if_not_expected 106 | assert JSONAPI::Path.new(resource_klass: PictureResource, path_string: 'comments.author.name', parse_fields: true) 107 | 108 | assert_raises JSONAPI::Exceptions::InvalidRelationship do 109 | JSONAPI::Path.new(resource_klass: PictureResource, path_string: 'comments.author.name', parse_fields: false) 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /test/unit/processor/default_processor_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | require 'jsonapi-resources' 3 | require 'json' 4 | 5 | class DefaultProcessorTest < ActionDispatch::IntegrationTest 6 | def setup 7 | JSONAPI.configuration.json_key_format = :camelized_key 8 | JSONAPI.configuration.route_format = :camelized_route 9 | JSONAPI.configuration.always_include_to_one_linkage_data = false 10 | 11 | JSONAPI.configuration.resource_cache = ActiveSupport::Cache::MemoryStore.new 12 | PostResource.caching true 13 | PersonResource.caching true 14 | 15 | $serializer = JSONAPI::ResourceSerializer.new(PostResource, 16 | base_url: 'http://example.com', 17 | url_helpers: TestApp.routes.url_helpers) 18 | 19 | # no includes 20 | filters = { id: [10, 12] } 21 | 22 | options = { filters: filters } 23 | params = { 24 | filters: filters, 25 | include_directives: {}, 26 | sort_criteria: {}, 27 | paginator: {}, 28 | fields: {}, 29 | serializer: {} 30 | } 31 | p = JSONAPI::Processor.new(PostResource, :find, params) 32 | $id_tree_no_includes = p.send(:find_resource_tree, options, nil) 33 | $resource_set_no_includes = JSONAPI::ResourceSet.new($id_tree_no_includes) 34 | $populated_resource_set_no_includes = JSONAPI::ResourceSet.new($id_tree_no_includes).populate!($serializer, nil,{}) 35 | 36 | # has_one included 37 | directives = JSONAPI::IncludeDirectives.new(PostResource, ['author']) 38 | params = { 39 | filters: filters, 40 | include_directives: directives, 41 | sort_criteria: {}, 42 | paginator: {}, 43 | fields: {}, 44 | serializer: {} 45 | } 46 | p = JSONAPI::Processor.new(PostResource, :find, params) 47 | 48 | $id_tree_has_one_includes = p.send(:find_resource_tree, options, directives[:include_related]) 49 | $resource_set_has_one_includes = JSONAPI::ResourceSet.new($id_tree_has_one_includes) 50 | $populated_resource_set_has_one_includes = JSONAPI::ResourceSet.new($id_tree_has_one_includes).populate!($serializer, nil,{}) 51 | end 52 | 53 | def after_teardown 54 | JSONAPI.configuration.always_include_to_one_linkage_data = false 55 | JSONAPI.configuration.json_key_format = :camelized_key 56 | JSONAPI.configuration.route_format = :underscored_route 57 | 58 | JSONAPI.configuration.resource_cache = nil 59 | PostResource.caching nil 60 | PersonResource.caching nil 61 | end 62 | 63 | def test_id_tree_without_includes_should_be_a_resource_tree 64 | assert $id_tree_no_includes.is_a?(JSONAPI::PrimaryResourceTree) 65 | end 66 | 67 | def test_id_tree_without_includes_should_have_resources 68 | assert_equal 2, $id_tree_no_includes.fragments.size 69 | end 70 | 71 | def test_id_tree_without_includes_should_not_have_related_resources 72 | assert_empty $id_tree_no_includes.related_resource_trees 73 | end 74 | 75 | def test_id_tree_without_includes_resource_relationships_should_be_empty 76 | assert_equal 0, $id_tree_no_includes.fragments[JSONAPI::ResourceIdentity.new(PostResource, 10)].related.length 77 | assert_equal 0, $id_tree_no_includes.fragments[JSONAPI::ResourceIdentity.new(PostResource, 12)].related.length 78 | end 79 | 80 | def test_id_tree_has_one_includes_should_be_a_resource_tree 81 | assert $id_tree_has_one_includes.is_a?(JSONAPI::PrimaryResourceTree) 82 | end 83 | 84 | def test_id_tree_has_one_includes_should_have_included_resources 85 | assert $id_tree_has_one_includes.related_resource_trees.is_a?(Hash) 86 | assert $id_tree_has_one_includes.related_resource_trees[:author].is_a?(JSONAPI::RelatedResourceTree) 87 | assert_equal 2, $id_tree_has_one_includes.related_resource_trees[:author].fragments.size 88 | end 89 | 90 | def test_id_tree_has_one_includes_should_have_resources 91 | assert_equal 2, $id_tree_has_one_includes.fragments.size 92 | end 93 | 94 | def test_id_tree_has_one_includes_resource_relationships_should_have_rids 95 | assert_equal 1, $id_tree_has_one_includes.fragments[JSONAPI::ResourceIdentity.new(PostResource, 10)].related[:author].length 96 | assert_equal 1, $id_tree_has_one_includes.fragments[JSONAPI::ResourceIdentity.new(PostResource, 12)].related[:author].length 97 | end 98 | 99 | def test_populated_resource_set_has_one_includes_have_resources 100 | assert $populated_resource_set_has_one_includes.resource_klasses[PostResource][10].is_a?(Hash) 101 | assert $populated_resource_set_has_one_includes.resource_klasses[PostResource][12].is_a?(Hash) 102 | assert $populated_resource_set_has_one_includes.resource_klasses[PersonResource][1003].is_a?(Hash) 103 | assert $populated_resource_set_has_one_includes.resource_klasses[PersonResource][1004].is_a?(Hash) 104 | end 105 | 106 | def test_populated_resource_set_has_one_includes_relationships_are_resolved 107 | assert_equal 1003, $populated_resource_set_has_one_includes.resource_klasses[PostResource][10][:relationships][:author].first.id 108 | assert_equal 1004, $populated_resource_set_has_one_includes.resource_klasses[PostResource][12][:relationships][:author].first.id 109 | 110 | assert_equal 10, $populated_resource_set_has_one_includes.resource_klasses[PersonResource][1003][:relationships][:posts].first.id 111 | assert_equal 12, $populated_resource_set_has_one_includes.resource_klasses[PersonResource][1004][:relationships][:posts].first.id 112 | end 113 | end -------------------------------------------------------------------------------- /test/unit/resource/relationship_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | 3 | class LambdaBlogPostsResource < JSONAPI::Resource 4 | model_name 'Post' 5 | 6 | has_one :author, allow_include: -> (context) { context[:admin] } 7 | has_many :comments, allow_include: -> (context) { context[:admin] } 8 | end 9 | 10 | class CallableBlogPostsResource < JSONAPI::Resource 11 | model_name 'Post' 12 | 13 | has_one :author, allow_include: :is_admin 14 | has_many :comments, allow_include: :is_admin 15 | 16 | def self.is_admin(context) 17 | context[:admin] 18 | end 19 | end 20 | 21 | class HasOneRelationshipTest < ActiveSupport::TestCase 22 | 23 | def test_polymorphic_type 24 | relationship = JSONAPI::Relationship::ToOne.new("imageable", 25 | polymorphic: true 26 | ) 27 | assert_equal(relationship.polymorphic_type, "imageable_type") 28 | end 29 | 30 | def test_allow_include_not_set_defaults_to_config_to_one 31 | original_config = JSONAPI.configuration.dup 32 | 33 | JSONAPI.configuration.default_allow_include_to_one = true 34 | relationship = JSONAPI::Relationship::ToOne.new("foo") 35 | assert(relationship.allow_include?) 36 | 37 | JSONAPI.configuration.default_allow_include_to_one = false 38 | relationship = JSONAPI::Relationship::ToOne.new("foo") 39 | refute(relationship.allow_include?) 40 | 41 | ensure 42 | JSONAPI.configuration = original_config 43 | end 44 | 45 | def test_allow_include_not_set_defaults_to_config_to_many 46 | original_config = JSONAPI.configuration.dup 47 | 48 | JSONAPI.configuration.default_allow_include_to_many = true 49 | relationship = JSONAPI::Relationship::ToMany.new("foobar") 50 | assert(relationship.allow_include?) 51 | 52 | JSONAPI.configuration.default_allow_include_to_one = false 53 | relationship = JSONAPI::Relationship::ToOne.new("foobar") 54 | refute(relationship.allow_include?) 55 | 56 | ensure 57 | JSONAPI.configuration = original_config 58 | end 59 | 60 | def test_allow_include_set_overrides_to_config_to_one 61 | original_config = JSONAPI.configuration.dup 62 | 63 | JSONAPI.configuration.default_allow_include_to_one = true 64 | relationship1 = JSONAPI::Relationship::ToOne.new("foo1", allow_include: false) 65 | relationship2 = JSONAPI::Relationship::ToOne.new("foo2", allow_include: true) 66 | refute(relationship1.allow_include?) 67 | assert(relationship2.allow_include?) 68 | 69 | JSONAPI.configuration.default_allow_include_to_one = false 70 | refute(relationship1.allow_include?) 71 | assert(relationship2.allow_include?) 72 | 73 | ensure 74 | JSONAPI.configuration = original_config 75 | end 76 | 77 | def test_allow_include_set_overrides_to_config_to_many 78 | original_config = JSONAPI.configuration.dup 79 | 80 | JSONAPI.configuration.default_allow_include_to_one = true 81 | relationship1 = JSONAPI::Relationship::ToMany.new("foobar1", allow_include: false) 82 | relationship2 = JSONAPI::Relationship::ToMany.new("foobar2", allow_include: true) 83 | refute(relationship1.allow_include?) 84 | assert(relationship2.allow_include?) 85 | 86 | JSONAPI.configuration.default_allow_include_to_one = false 87 | refute(relationship1.allow_include?) 88 | assert(relationship2.allow_include?) 89 | 90 | ensure 91 | JSONAPI.configuration = original_config 92 | end 93 | 94 | def test_allow_include_set_by_lambda 95 | assert LambdaBlogPostsResource._relationship(:author).allow_include?(admin: true) 96 | refute LambdaBlogPostsResource._relationship(:author).allow_include?(admin: false) 97 | 98 | assert LambdaBlogPostsResource._relationship(:comments).allow_include?(admin: true) 99 | refute LambdaBlogPostsResource._relationship(:comments).allow_include?(admin: false) 100 | end 101 | 102 | def test_allow_include_set_by_callable 103 | assert CallableBlogPostsResource._relationship(:author).allow_include?(admin: true) 104 | refute CallableBlogPostsResource._relationship(:author).allow_include?(admin: false) 105 | 106 | assert CallableBlogPostsResource._relationship(:comments).allow_include?(admin: true) 107 | refute CallableBlogPostsResource._relationship(:comments).allow_include?(admin: false) 108 | end 109 | 110 | def test_exclude_links_on_relationship 111 | relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: :none 112 | assert_equal [], relationship._exclude_links 113 | refute relationship.exclude_link?(:self) 114 | refute relationship.exclude_link?("self") 115 | 116 | relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: :default 117 | assert_equal [:self, :related], relationship._exclude_links 118 | assert relationship.exclude_link?(:self) 119 | assert relationship.exclude_link?("self") 120 | assert relationship.exclude_link?(:related) 121 | assert relationship.exclude_link?("related") 122 | 123 | relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: "none" 124 | assert_equal [], relationship._exclude_links 125 | refute relationship.exclude_link?(:self) 126 | refute relationship.exclude_link?("self") 127 | 128 | relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: "default" 129 | assert_equal [:self, :related], relationship._exclude_links 130 | assert relationship.exclude_link?(:self) 131 | assert relationship.exclude_link?("self") 132 | 133 | relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: :none 134 | assert_equal [], relationship._exclude_links 135 | refute relationship.exclude_link?(:self) 136 | refute relationship.exclude_link?("self") 137 | 138 | relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: [:self] 139 | assert_equal [:self], relationship._exclude_links 140 | assert relationship.exclude_link?(:self) 141 | assert relationship.exclude_link?("self") 142 | 143 | relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: :none 144 | assert_equal [], relationship._exclude_links 145 | refute relationship.exclude_link?(:self) 146 | refute relationship.exclude_link?("self") 147 | 148 | relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: ["self", :related] 149 | assert_equal [:self, :related], relationship._exclude_links 150 | assert relationship.exclude_link?(:self) 151 | assert relationship.exclude_link?("self") 152 | 153 | relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: [] 154 | assert_equal [], relationship._exclude_links 155 | refute relationship.exclude_link?(:self) 156 | refute relationship.exclude_link?("self") 157 | 158 | assert_raises do 159 | JSONAPI::Relationship::ToOne.new "foo", :self 160 | end 161 | end 162 | 163 | def test_global_exclude_links_configuration_on_relationship 164 | JSONAPI.configuration.default_exclude_links = :none 165 | relationship = JSONAPI::Relationship::ToOne.new "foo" 166 | assert_equal [], relationship._exclude_links 167 | refute relationship.exclude_link?(:self) 168 | refute relationship.exclude_link?("self") 169 | 170 | JSONAPI.configuration.default_exclude_links = :default 171 | relationship = JSONAPI::Relationship::ToOne.new "foo" 172 | assert_equal [:self, :related], relationship._exclude_links 173 | assert relationship.exclude_link?(:self) 174 | assert relationship.exclude_link?("self") 175 | assert relationship.exclude_link?(:related) 176 | assert relationship.exclude_link?("related") 177 | 178 | JSONAPI.configuration.default_exclude_links = "none" 179 | relationship = JSONAPI::Relationship::ToOne.new "foo" 180 | assert_equal [], relationship._exclude_links 181 | refute relationship.exclude_link?(:self) 182 | refute relationship.exclude_link?("self") 183 | 184 | JSONAPI.configuration.default_exclude_links = "default" 185 | relationship = JSONAPI::Relationship::ToOne.new "foo" 186 | assert_equal [:self, :related], relationship._exclude_links 187 | assert relationship.exclude_link?(:self) 188 | assert relationship.exclude_link?("self") 189 | 190 | JSONAPI.configuration.default_exclude_links = :none 191 | relationship = JSONAPI::Relationship::ToOne.new "foo" 192 | assert_equal [], relationship._exclude_links 193 | refute relationship.exclude_link?(:self) 194 | refute relationship.exclude_link?("self") 195 | 196 | JSONAPI.configuration.default_exclude_links = [:self] 197 | relationship = JSONAPI::Relationship::ToOne.new "foo" 198 | assert_equal [:self], relationship._exclude_links 199 | assert relationship.exclude_link?(:self) 200 | assert relationship.exclude_link?("self") 201 | 202 | JSONAPI.configuration.default_exclude_links = :none 203 | relationship = JSONAPI::Relationship::ToOne.new "foo" 204 | assert_equal [], relationship._exclude_links 205 | refute relationship.exclude_link?(:self) 206 | refute relationship.exclude_link?("self") 207 | 208 | JSONAPI.configuration.default_exclude_links = ["self", :related] 209 | relationship = JSONAPI::Relationship::ToOne.new "foo" 210 | assert_equal [:self, :related], relationship._exclude_links 211 | assert relationship.exclude_link?(:self) 212 | assert relationship.exclude_link?("self") 213 | 214 | JSONAPI.configuration.default_exclude_links = [] 215 | relationship = JSONAPI::Relationship::ToOne.new "foo" 216 | assert_equal [], relationship._exclude_links 217 | refute relationship.exclude_link?(:self) 218 | refute relationship.exclude_link?("self") 219 | 220 | assert_raises do 221 | JSONAPI.configuration.default_exclude_links = :self 222 | JSONAPI::Relationship::ToOne.new "foo" 223 | end 224 | 225 | # Test if the relationships will override the the global configuration 226 | JSONAPI.configuration.default_exclude_links = :default 227 | relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: :none 228 | assert_equal [], relationship._exclude_links 229 | refute relationship.exclude_link?(:self) 230 | refute relationship.exclude_link?("self") 231 | refute relationship.exclude_link?(:related) 232 | refute relationship.exclude_link?("related") 233 | 234 | JSONAPI.configuration.default_exclude_links = :default 235 | relationship = JSONAPI::Relationship::ToOne.new "foo", exclude_links: [:self] 236 | assert_equal [:self], relationship._exclude_links 237 | refute relationship.exclude_link?(:related) 238 | refute relationship.exclude_link?("related") 239 | assert relationship.exclude_link?(:self) 240 | assert relationship.exclude_link?("self") 241 | ensure 242 | JSONAPI.configuration.default_exclude_links = :none 243 | end 244 | 245 | 246 | end 247 | -------------------------------------------------------------------------------- /test/unit/serializer/include_directives_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | require 'jsonapi-resources' 3 | 4 | class IncludeDirectivesTest < ActiveSupport::TestCase 5 | 6 | def test_one_level_one_include 7 | directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts']).instance_variable_get(:@include_directives_hash) 8 | 9 | assert_hash_equals( 10 | { 11 | include_related: { 12 | posts: { 13 | include_related: {} 14 | } 15 | } 16 | }, 17 | directives) 18 | end 19 | 20 | def test_one_level_multiple_includes 21 | directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts', 'comments', 'expense_entries']).instance_variable_get(:@include_directives_hash) 22 | 23 | assert_hash_equals( 24 | { 25 | include_related: { 26 | posts: { 27 | include_related: {} 28 | }, 29 | comments: { 30 | include_related: {} 31 | }, 32 | expense_entries: { 33 | include_related: {} 34 | } 35 | } 36 | }, 37 | directives) 38 | end 39 | 40 | def test_multiple_level_multiple_includes 41 | directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts', 'posts.comments', 'comments', 'expense_entries']).instance_variable_get(:@include_directives_hash) 42 | 43 | assert_hash_equals( 44 | { 45 | include_related: { 46 | posts: { 47 | include_related: { 48 | comments: { 49 | include_related: {} 50 | } 51 | } 52 | }, 53 | comments: { 54 | include_related: {} 55 | }, 56 | expense_entries: { 57 | include_related: {} 58 | } 59 | } 60 | }, 61 | directives) 62 | end 63 | 64 | 65 | def test_two_levels_include_full_path 66 | directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts.comments']).instance_variable_get(:@include_directives_hash) 67 | 68 | assert_hash_equals( 69 | { 70 | include_related: { 71 | posts: { 72 | include_related: { 73 | comments: { 74 | include_related: {} 75 | } 76 | } 77 | } 78 | } 79 | }, 80 | directives) 81 | end 82 | 83 | def test_two_levels_include_full_path_redundant 84 | directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts', 'posts.comments']).instance_variable_get(:@include_directives_hash) 85 | 86 | assert_hash_equals( 87 | { 88 | include_related: { 89 | posts: { 90 | include_related: { 91 | comments: { 92 | include_related: {} 93 | } 94 | } 95 | } 96 | } 97 | }, 98 | directives) 99 | end 100 | 101 | def test_three_levels_include_full 102 | directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts.comments.tags']).instance_variable_get(:@include_directives_hash) 103 | 104 | assert_hash_equals( 105 | { 106 | include_related: { 107 | posts: { 108 | include_related: { 109 | comments: { 110 | include_related: { 111 | tags: { 112 | include_related: {} 113 | } 114 | } 115 | } 116 | } 117 | } 118 | } 119 | }, 120 | directives) 121 | end 122 | 123 | # def test_three_levels_include_full_model_includes 124 | # directives = JSONAPI::IncludeDirectives.new(PersonResource, ['posts.comments.tags']) 125 | # assert_array_equals([{:posts=>[{:comments=>[:tags]}]}], directives.model_includes) 126 | # end 127 | # 128 | def test_invalid_includes_1 129 | assert_raises JSONAPI::Exceptions::InvalidInclude do 130 | JSONAPI::IncludeDirectives.new(PersonResource, ['../../../../']).instance_variable_get(:@include_directives_hash) 131 | end 132 | end 133 | 134 | def test_invalid_includes_2 135 | assert_raises JSONAPI::Exceptions::InvalidInclude do 136 | JSONAPI::IncludeDirectives.new(PersonResource, ['posts./sdaa./........']).instance_variable_get(:@include_directives_hash) 137 | end 138 | end 139 | 140 | def test_invalid_includes_3 141 | assert_raises JSONAPI::Exceptions::InvalidInclude do 142 | JSONAPI::IncludeDirectives.new(PersonResource, ['invalid../../../../']).instance_variable_get(:@include_directives_hash) 143 | end 144 | end 145 | end 146 | --------------------------------------------------------------------------------