├── .gitignore ├── .travis.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── jsonapi-resources.gemspec ├── lib ├── generators │ └── jsonapi │ │ ├── USAGE │ │ ├── controller_generator.rb │ │ ├── resource_generator.rb │ │ └── templates │ │ ├── jsonapi_controller.rb │ │ └── jsonapi_resource.rb ├── jsonapi-resources.rb └── jsonapi │ ├── acts_as_resource_controller.rb │ ├── callbacks.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_dispatcher.rb │ ├── operation_result.rb │ ├── operation_results.rb │ ├── paginator.rb │ ├── processor.rb │ ├── relationship.rb │ ├── request_parser.rb │ ├── resource.rb │ ├── resource_controller.rb │ ├── resource_controller_metal.rb │ ├── resource_serializer.rb │ ├── resources │ └── version.rb │ ├── response_document.rb │ └── routing_ext.rb ├── locales └── en.yml └── test ├── benchmark └── request_benchmark.rb ├── config └── database.yml ├── controllers └── controller_test.rb ├── fixtures ├── active_record.rb ├── author_details.yml ├── book_authors.yml ├── book_comments.yml ├── books.yml ├── categories.yml ├── comments.yml ├── comments_tags.yml ├── companies.yml ├── craters.yml ├── customers.yml ├── documents.yml ├── expense_entries.yml ├── facts.yml ├── hair_cuts.yml ├── iso_currencies.yml ├── line_items.yml ├── makes.yml ├── moons.yml ├── numeros_telefone.yml ├── order_flags.yml ├── people.yml ├── pictures.yml ├── planet_types.yml ├── planets.yml ├── posts.yml ├── posts_tags.yml ├── preferences.yml ├── products.yml ├── purchase_orders.yml ├── sections.yml ├── tags.yml ├── vehicles.yml └── web_pages.yml ├── helpers ├── assertions.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 ├── formatters └── dasherized_key_formatter_test.rb ├── jsonapi_request └── jsonapi_request_test.rb ├── operation └── operation_dispatcher_test.rb ├── pagination ├── offset_paginator_test.rb └── paged_paginator_test.rb ├── resource ├── relationship_test.rb └── resource_test.rb └── serializer ├── include_directives_test.rb ├── link_builder_test.rb ├── polymorphic_serializer_test.rb ├── response_document_test.rb └── serializer_test.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | .ruby-version 7 | Gemfile.lock 8 | InstalledFiles 9 | _yardoc 10 | coverage 11 | doc/ 12 | lib/bundler/man 13 | pkg 14 | rdoc 15 | spec/reports 16 | test/tmp 17 | test/version_tmp 18 | tmp 19 | coverage 20 | test/log 21 | test_db 22 | test_db-journal -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: false 3 | env: 4 | - "RAILS_VERSION=4.1.0" 5 | - "RAILS_VERSION=4.2.6" 6 | - "RAILS_VERSION=5.0.0.rc1" 7 | rvm: 8 | - 2.1 9 | - 2.2.4 10 | - 2.3.0 11 | matrix: 12 | exclude: 13 | - rvm: 2.0 14 | env: "RAILS_VERSION=5.0.0.rc1" 15 | - rvm: 2.1 16 | env: "RAILS_VERSION=5.0.0.rc1" -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | platforms :ruby do 6 | gem 'sqlite3', '1.3.10' 7 | end 8 | 9 | platforms :jruby do 10 | gem 'activerecord-jdbcsqlite3-adapter' 11 | end 12 | 13 | version = ENV['RAILS_VERSION'] || 'default' 14 | 15 | case version 16 | when 'master' 17 | gem 'rails', { git: 'https://github.com/rails/rails.git' } 18 | gem 'arel', { git: 'https://github.com/rails/arel.git' } 19 | when 'default' 20 | gem 'rails', '>= 4.2' 21 | else 22 | gem 'rails', "~> #{version}" 23 | end 24 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Larry Gebhardt 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.0' 21 | 22 | spec.add_development_dependency 'bundler', '~> 1.5' 23 | spec.add_development_dependency 'rake' 24 | spec.add_development_dependency 'minitest' 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_dependency 'rails', '>= 4.0' 30 | spec.add_dependency 'concurrent-ruby' 31 | end 32 | -------------------------------------------------------------------------------- /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 | require 'jsonapi/naive_cache' 2 | require 'jsonapi/resource' 3 | require 'jsonapi/response_document' 4 | require 'jsonapi/acts_as_resource_controller' 5 | require 'jsonapi/resource_controller' 6 | require 'jsonapi/resource_controller_metal' 7 | require 'jsonapi/resources/version' 8 | require 'jsonapi/configuration' 9 | require 'jsonapi/paginator' 10 | require 'jsonapi/formatter' 11 | require 'jsonapi/routing_ext' 12 | require 'jsonapi/mime_types' 13 | require 'jsonapi/resource_serializer' 14 | require 'jsonapi/exceptions' 15 | require 'jsonapi/error' 16 | require 'jsonapi/error_codes' 17 | require 'jsonapi/request_parser' 18 | require 'jsonapi/operation_dispatcher' 19 | require 'jsonapi/processor' 20 | require 'jsonapi/relationship' 21 | require 'jsonapi/include_directives' 22 | require 'jsonapi/operation_result' 23 | require 'jsonapi/operation_results' 24 | require 'jsonapi/callbacks' 25 | require 'jsonapi/link_builder' 26 | -------------------------------------------------------------------------------- /lib/jsonapi/acts_as_resource_controller.rb: -------------------------------------------------------------------------------- 1 | require 'csv' 2 | 3 | module JSONAPI 4 | module ActsAsResourceController 5 | MEDIA_TYPE_MATCHER = /(.+".+"[^,]*|[^,]+)/ 6 | ALL_MEDIA_TYPES = '*/*' 7 | 8 | def self.included(base) 9 | base.extend ClassMethods 10 | base.include Callbacks 11 | base.before_action :ensure_correct_media_type, only: [:create, :update, :create_relationship, :update_relationship] 12 | base.before_action :ensure_valid_accept_media_type 13 | base.cattr_reader :server_error_callbacks 14 | base.define_jsonapi_resources_callbacks :process_operations 15 | end 16 | 17 | def index 18 | process_request 19 | end 20 | 21 | def show 22 | process_request 23 | end 24 | 25 | def show_relationship 26 | process_request 27 | end 28 | 29 | def create 30 | process_request 31 | end 32 | 33 | def create_relationship 34 | process_request 35 | end 36 | 37 | def update_relationship 38 | process_request 39 | end 40 | 41 | def update 42 | process_request 43 | end 44 | 45 | def destroy 46 | process_request 47 | end 48 | 49 | def destroy_relationship 50 | process_request 51 | end 52 | 53 | def get_related_resource 54 | process_request 55 | end 56 | 57 | def get_related_resources 58 | process_request 59 | end 60 | 61 | def process_request 62 | @request = JSONAPI::RequestParser.new(params, context: context, 63 | key_formatter: key_formatter, 64 | server_error_callbacks: (self.class.server_error_callbacks || [])) 65 | unless @request.errors.empty? 66 | render_errors(@request.errors) 67 | else 68 | process_operations 69 | render_results(@operation_results) 70 | end 71 | 72 | rescue => e 73 | handle_exceptions(e) 74 | end 75 | 76 | def process_operations 77 | run_callbacks :process_operations do 78 | @operation_results = operation_dispatcher.process(@request.operations) 79 | end 80 | end 81 | 82 | def transaction 83 | lambda { |&block| 84 | ActiveRecord::Base.transaction do 85 | block.yield 86 | end 87 | } 88 | end 89 | 90 | def rollback 91 | lambda { 92 | fail ActiveRecord::Rollback 93 | } 94 | end 95 | 96 | def operation_dispatcher 97 | @operation_dispatcher ||= JSONAPI::OperationDispatcher.new(transaction: transaction, 98 | rollback: rollback, 99 | server_error_callbacks: @request.server_error_callbacks) 100 | end 101 | 102 | private 103 | 104 | def resource_klass 105 | @resource_klass ||= resource_klass_name.safe_constantize 106 | end 107 | 108 | def resource_serializer_klass 109 | @resource_serializer_klass ||= JSONAPI::ResourceSerializer 110 | end 111 | 112 | def base_url 113 | @base_url ||= request.protocol + request.host_with_port 114 | end 115 | 116 | def resource_klass_name 117 | @resource_klass_name ||= "#{self.class.name.underscore.sub(/_controller$/, '').singularize}_resource".camelize 118 | end 119 | 120 | def ensure_correct_media_type 121 | unless request.content_type == JSONAPI::MEDIA_TYPE 122 | fail JSONAPI::Exceptions::UnsupportedMediaTypeError.new(request.content_type) 123 | end 124 | rescue => e 125 | handle_exceptions(e) 126 | end 127 | 128 | def ensure_valid_accept_media_type 129 | if invalid_accept_media_type? 130 | fail JSONAPI::Exceptions::NotAcceptableError.new(request.accept) 131 | end 132 | rescue => e 133 | handle_exceptions(e) 134 | end 135 | 136 | def invalid_accept_media_type? 137 | media_types = media_types_for('Accept') 138 | 139 | return false if media_types.blank? || media_types.include?(ALL_MEDIA_TYPES) 140 | 141 | jsonapi_media_types = media_types.select do |media_type| 142 | media_type.include?(JSONAPI::MEDIA_TYPE) 143 | end 144 | 145 | jsonapi_media_types.size.zero? || 146 | jsonapi_media_types.none? do |media_type| 147 | media_type == JSONAPI::MEDIA_TYPE 148 | end 149 | end 150 | 151 | def media_types_for(header) 152 | (request.headers[header] || '') 153 | .match(MEDIA_TYPE_MATCHER) 154 | .to_a 155 | .map(&:strip) 156 | end 157 | 158 | # override to set context 159 | def context 160 | {} 161 | end 162 | 163 | def serialization_options 164 | {} 165 | end 166 | 167 | # Control by setting in an initializer: 168 | # JSONAPI.configuration.json_key_format = :camelized_key 169 | # JSONAPI.configuration.route = :camelized_route 170 | # 171 | # Override if you want to set a per controller key format. 172 | # Must return an instance of a class derived from KeyFormatter. 173 | def key_formatter 174 | JSONAPI.configuration.key_formatter 175 | end 176 | 177 | def route_formatter 178 | JSONAPI.configuration.route_formatter 179 | end 180 | 181 | def base_response_meta 182 | {} 183 | end 184 | 185 | def base_meta 186 | if @request.nil? || @request.warnings.empty? 187 | base_response_meta 188 | else 189 | base_response_meta.merge(warnings: @request.warnings) 190 | end 191 | end 192 | 193 | def base_response_links 194 | {} 195 | end 196 | 197 | def render_errors(errors) 198 | operation_results = JSONAPI::OperationResults.new 199 | result = JSONAPI::ErrorsOperationResult.new(errors[0].status, errors) 200 | operation_results.add_result(result) 201 | 202 | render_results(operation_results) 203 | end 204 | 205 | def render_results(operation_results) 206 | response_doc = create_response_document(operation_results) 207 | 208 | render_options = { 209 | status: response_doc.status, 210 | json: response_doc.contents, 211 | content_type: JSONAPI::MEDIA_TYPE 212 | } 213 | 214 | render_options[:location] = response_doc.contents[:data]["links"][:self] if ( 215 | response_doc.status == :created && response_doc.contents[:data].class != Array 216 | ) 217 | 218 | render(render_options) 219 | end 220 | 221 | def create_response_document(operation_results) 222 | JSONAPI::ResponseDocument.new( 223 | operation_results, 224 | primary_resource_klass: resource_klass, 225 | include_directives: @request ? @request.include_directives : nil, 226 | fields: @request ? @request.fields : nil, 227 | base_url: base_url, 228 | key_formatter: key_formatter, 229 | route_formatter: route_formatter, 230 | base_meta: base_meta, 231 | base_links: base_response_links, 232 | resource_serializer_klass: resource_serializer_klass, 233 | request: @request, 234 | serialization_options: serialization_options 235 | ) 236 | end 237 | 238 | # override this to process other exceptions 239 | # Note: Be sure to either call super(e) or handle JSONAPI::Exceptions::Error and raise unhandled exceptions 240 | def handle_exceptions(e) 241 | case e 242 | when JSONAPI::Exceptions::Error 243 | render_errors(e.errors) 244 | else 245 | if JSONAPI.configuration.exception_class_whitelisted?(e) 246 | fail e 247 | else 248 | internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e) 249 | Rails.logger.error { "Internal Server Error: #{e.message} #{e.backtrace.join("\n")}" } 250 | render_errors(internal_server_error.errors) 251 | end 252 | end 253 | end 254 | 255 | # Pass in a methods or a block to be run when an exception is 256 | # caught that is not a JSONAPI::Exceptions::Error 257 | # Useful for additional logging or notification configuration that 258 | # would normally depend on rails catching and rendering an exception. 259 | # Ignores whitelist exceptions from config 260 | 261 | module ClassMethods 262 | 263 | def on_server_error(*args, &callback_block) 264 | callbacks ||= [] 265 | 266 | if callback_block 267 | callbacks << callback_block 268 | end 269 | 270 | method_callbacks = args.map do |method| 271 | ->(error) do 272 | if self.respond_to? method 273 | send(method, error) 274 | else 275 | Rails.logger.warn("#{method} not defined on #{self}, skipping error callback") 276 | end 277 | end 278 | end.compact 279 | callbacks += method_callbacks 280 | self.class_variable_set :@@server_error_callbacks, callbacks 281 | end 282 | 283 | end 284 | end 285 | end 286 | -------------------------------------------------------------------------------- /lib/jsonapi/callbacks.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/callbacks' 2 | 3 | module JSONAPI 4 | module Callbacks 5 | def self.included(base) 6 | base.class_eval do 7 | include ActiveSupport::Callbacks 8 | base.extend ClassMethods 9 | end 10 | end 11 | 12 | module ClassMethods 13 | def define_jsonapi_resources_callbacks(*callbacks) 14 | options = callbacks.extract_options! 15 | options = { 16 | only: [:before, :around, :after] 17 | }.merge!(options) 18 | 19 | types = Array(options.delete(:only)) 20 | 21 | callbacks.each do |callback| 22 | define_callbacks(callback, options) 23 | 24 | types.each do |type| 25 | send("_define_#{type}_callback", self, callback) 26 | end 27 | end 28 | end 29 | 30 | private 31 | 32 | def _define_before_callback(klass, callback) #:nodoc: 33 | klass.define_singleton_method("before_#{callback}") do |*args, &block| 34 | set_callback(:"#{callback}", :before, *args, &block) 35 | end 36 | end 37 | 38 | def _define_around_callback(klass, callback) #:nodoc: 39 | klass.define_singleton_method("around_#{callback}") do |*args, &block| 40 | set_callback(:"#{callback}", :around, *args, &block) 41 | end 42 | end 43 | 44 | def _define_after_callback(klass, callback) #:nodoc: 45 | klass.define_singleton_method("after_#{callback}") do |*args, &block| 46 | set_callback(:"#{callback}", :after, *args, &block) 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/jsonapi/configuration.rb: -------------------------------------------------------------------------------- 1 | require 'jsonapi/formatter' 2 | require 'jsonapi/processor' 3 | require 'concurrent' 4 | 5 | module JSONAPI 6 | class Configuration 7 | attr_reader :json_key_format, 8 | :resource_key_type, 9 | :route_format, 10 | :raise_if_parameters_not_allowed, 11 | :allow_include, 12 | :allow_sort, 13 | :allow_filter, 14 | :default_paginator, 15 | :default_page_size, 16 | :maximum_page_size, 17 | :default_processor_klass, 18 | :use_text_errors, 19 | :top_level_links_include_pagination, 20 | :top_level_meta_include_record_count, 21 | :top_level_meta_record_count_key, 22 | :top_level_meta_include_page_count, 23 | :top_level_meta_page_count_key, 24 | :exception_class_whitelist, 25 | :always_include_to_one_linkage_data, 26 | :always_include_to_many_linkage_data, 27 | :cache_formatters 28 | 29 | def initialize 30 | #:underscored_key, :camelized_key, :dasherized_key, or custom 31 | self.json_key_format = :dasherized_key 32 | 33 | #:underscored_route, :camelized_route, :dasherized_route, or custom 34 | self.route_format = :dasherized_route 35 | 36 | #:integer, :uuid, :string, or custom (provide a proc) 37 | self.resource_key_type = :integer 38 | 39 | # optional request features 40 | self.allow_include = true 41 | self.allow_sort = true 42 | self.allow_filter = true 43 | 44 | self.raise_if_parameters_not_allowed = true 45 | 46 | # :none, :offset, :paged, or a custom paginator name 47 | self.default_paginator = :none 48 | 49 | # Output pagination links at top level 50 | self.top_level_links_include_pagination = true 51 | 52 | self.default_page_size = 10 53 | self.maximum_page_size = 20 54 | 55 | # Metadata 56 | # Output record count in top level meta for find operation 57 | self.top_level_meta_include_record_count = false 58 | self.top_level_meta_record_count_key = :record_count 59 | 60 | self.top_level_meta_include_page_count = false 61 | self.top_level_meta_page_count_key = :page_count 62 | 63 | self.use_text_errors = false 64 | 65 | # List of classes that should not be rescued by the operations processor. 66 | # For example, if you use Pundit for authorization, you might 67 | # raise a Pundit::NotAuthorizedError at some point during operations 68 | # processing. If you want to use Rails' `rescue_from` macro to 69 | # catch this error and render a 403 status code, you should add 70 | # the `Pundit::NotAuthorizedError` to the `exception_class_whitelist`. 71 | self.exception_class_whitelist = [] 72 | 73 | # Resource Linkage 74 | # Controls the serialization of resource linkage for non compound documents 75 | # NOTE: always_include_to_many_linkage_data is not currently implemented 76 | self.always_include_to_one_linkage_data = false 77 | self.always_include_to_many_linkage_data = false 78 | 79 | # The default Operation Processor to use if one is not defined specifically 80 | # for a Resource. 81 | self.default_processor_klass = JSONAPI::Processor 82 | 83 | # Formatter Caching 84 | # Set to false to disable caching of string operations on keys and links. 85 | self.cache_formatters = true 86 | end 87 | 88 | def cache_formatters=(bool) 89 | @cache_formatters = bool 90 | if bool 91 | @key_formatter_tlv = Concurrent::ThreadLocalVar.new 92 | @route_formatter_tlv = Concurrent::ThreadLocalVar.new 93 | else 94 | @key_formatter_tlv = nil 95 | @route_formatter_tlv = nil 96 | end 97 | end 98 | 99 | def json_key_format=(format) 100 | @json_key_format = format 101 | if @cache_formatters 102 | @key_formatter_tlv = Concurrent::ThreadLocalVar.new 103 | end 104 | end 105 | 106 | def route_format=(format) 107 | @route_format = format 108 | if @cache_formatters 109 | @route_formatter_tlv = Concurrent::ThreadLocalVar.new 110 | end 111 | end 112 | 113 | def key_formatter 114 | if self.cache_formatters 115 | formatter = @key_formatter_tlv.value 116 | return formatter if formatter 117 | end 118 | 119 | formatter = JSONAPI::Formatter.formatter_for(self.json_key_format) 120 | 121 | if self.cache_formatters 122 | formatter = @key_formatter_tlv.value = formatter.cached 123 | end 124 | 125 | return formatter 126 | end 127 | 128 | def resource_key_type=(key_type) 129 | @resource_key_type = key_type 130 | end 131 | 132 | def route_formatter 133 | if self.cache_formatters 134 | formatter = @route_formatter_tlv.value 135 | return formatter if formatter 136 | end 137 | 138 | formatter = JSONAPI::Formatter.formatter_for(self.route_format) 139 | 140 | if self.cache_formatters 141 | formatter = @route_formatter_tlv.value = formatter.cached 142 | end 143 | 144 | return formatter 145 | end 146 | 147 | def exception_class_whitelisted?(e) 148 | @exception_class_whitelist.flatten.any? { |k| e.class.ancestors.include?(k) } 149 | end 150 | 151 | def default_processor_klass=(default_processor_klass) 152 | @default_processor_klass = default_processor_klass 153 | end 154 | 155 | attr_writer :allow_include, :allow_sort, :allow_filter 156 | 157 | attr_writer :default_paginator 158 | 159 | attr_writer :default_page_size 160 | 161 | attr_writer :maximum_page_size 162 | 163 | attr_writer :use_text_errors 164 | 165 | attr_writer :top_level_links_include_pagination 166 | 167 | attr_writer :top_level_meta_include_record_count 168 | 169 | attr_writer :top_level_meta_record_count_key 170 | 171 | attr_writer :top_level_meta_include_page_count 172 | 173 | attr_writer :top_level_meta_page_count_key 174 | 175 | attr_writer :exception_class_whitelist 176 | 177 | attr_writer :always_include_to_one_linkage_data 178 | 179 | attr_writer :always_include_to_many_linkage_data 180 | 181 | attr_writer :raise_if_parameters_not_allowed 182 | end 183 | 184 | class << self 185 | attr_accessor :configuration 186 | end 187 | 188 | @configuration ||= Configuration.new 189 | 190 | def self.configure 191 | yield(@configuration) 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /lib/jsonapi/error.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | class Error 3 | attr_accessor :title, :detail, :id, :href, :code, :source, :links, :status, :meta 4 | 5 | def initialize(options = {}) 6 | @title = options[:title] 7 | @detail = options[:detail] 8 | @id = options[:id] 9 | @href = options[:href] 10 | @code = if JSONAPI.configuration.use_text_errors 11 | TEXT_ERRORS[options[:code]] 12 | else 13 | options[:code] 14 | end 15 | @source = options[:source] 16 | @links = options[:links] 17 | 18 | @status = Rack::Utils::SYMBOL_TO_STATUS_CODE[options[:status]].to_s 19 | @meta = options[:meta] 20 | end 21 | 22 | def to_hash 23 | hash = {} 24 | instance_variables.each {|var| hash[var.to_s.delete('@')] = instance_variable_get(var) unless instance_variable_get(var).nil? } 25 | hash 26 | end 27 | end 28 | 29 | class Warning 30 | attr_accessor :title, :detail, :code 31 | def initialize(options = {}) 32 | @title = options[:title] 33 | @detail = options[:detail] 34 | @code = if JSONAPI.configuration.use_text_errors 35 | TEXT_ERRORS[options[:code]] 36 | else 37 | options[:code] 38 | end 39 | end 40 | 41 | def to_hash 42 | hash = {} 43 | instance_variables.each {|var| hash[var.to_s.delete('@')] = instance_variable_get(var) unless instance_variable_get(var).nil? } 44 | hash 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/jsonapi/error_codes.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | VALIDATION_ERROR = '100' 3 | INVALID_RESOURCE = '101' 4 | FILTER_NOT_ALLOWED = '102' 5 | INVALID_FIELD_VALUE = '103' 6 | INVALID_FIELD = '104' 7 | PARAM_NOT_ALLOWED = '105' 8 | PARAM_MISSING = '106' 9 | INVALID_FILTER_VALUE = '107' 10 | COUNT_MISMATCH = '108' 11 | KEY_ORDER_MISMATCH = '109' 12 | KEY_NOT_INCLUDED_IN_URL = '110' 13 | INVALID_INCLUDE = '112' 14 | RELATION_EXISTS = '113' 15 | INVALID_SORT_CRITERIA = '114' 16 | INVALID_LINKS_OBJECT = '115' 17 | TYPE_MISMATCH = '116' 18 | INVALID_PAGE_OBJECT = '117' 19 | INVALID_PAGE_VALUE = '118' 20 | INVALID_FIELD_FORMAT = '119' 21 | INVALID_FILTERS_SYNTAX = '120' 22 | SAVE_FAILED = '121' 23 | FORBIDDEN = '403' 24 | RECORD_NOT_FOUND = '404' 25 | NOT_ACCEPTABLE = '406' 26 | UNSUPPORTED_MEDIA_TYPE = '415' 27 | LOCKED = '423' 28 | INTERNAL_SERVER_ERROR = '500' 29 | 30 | TEXT_ERRORS = 31 | { VALIDATION_ERROR => 'VALIDATION_ERROR', 32 | INVALID_RESOURCE => 'INVALID_RESOURCE', 33 | FILTER_NOT_ALLOWED => 'FILTER_NOT_ALLOWED', 34 | INVALID_FIELD_VALUE => 'INVALID_FIELD_VALUE', 35 | INVALID_FIELD => 'INVALID_FIELD', 36 | PARAM_NOT_ALLOWED => 'PARAM_NOT_ALLOWED', 37 | PARAM_MISSING => 'PARAM_MISSING', 38 | INVALID_FILTER_VALUE => 'INVALID_FILTER_VALUE', 39 | COUNT_MISMATCH => 'COUNT_MISMATCH', 40 | KEY_ORDER_MISMATCH => 'KEY_ORDER_MISMATCH', 41 | KEY_NOT_INCLUDED_IN_URL => 'KEY_NOT_INCLUDED_IN_URL', 42 | INVALID_INCLUDE => 'INVALID_INCLUDE', 43 | RELATION_EXISTS => 'RELATION_EXISTS', 44 | INVALID_SORT_CRITERIA => 'INVALID_SORT_CRITERIA', 45 | INVALID_LINKS_OBJECT => 'INVALID_LINKS_OBJECT', 46 | TYPE_MISMATCH => 'TYPE_MISMATCH', 47 | INVALID_PAGE_OBJECT => 'INVALID_PAGE_OBJECT', 48 | INVALID_PAGE_VALUE => 'INVALID_PAGE_VALUE', 49 | INVALID_FIELD_FORMAT => 'INVALID_FIELD_FORMAT', 50 | INVALID_FILTERS_SYNTAX => 'INVALID_FILTERS_SYNTAX', 51 | SAVE_FAILED => 'SAVE_FAILED', 52 | FORBIDDEN => 'FORBIDDEN', 53 | RECORD_NOT_FOUND => 'RECORD_NOT_FOUND', 54 | NOT_ACCEPTABLE => 'NOT_ACCEPTABLE', 55 | UNSUPPORTED_MEDIA_TYPE => 'UNSUPPORTED_MEDIA_TYPE', 56 | LOCKED => 'LOCKED', 57 | INTERNAL_SERVER_ERROR => 'INTERNAL_SERVER_ERROR' 58 | } 59 | end 60 | -------------------------------------------------------------------------------- /lib/jsonapi/formatter.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | class Formatter 3 | class << self 4 | def format(arg) 5 | arg.to_s 6 | end 7 | 8 | def unformat(arg) 9 | arg 10 | end 11 | 12 | def cached 13 | return FormatterWrapperCache.new(self) 14 | end 15 | 16 | def formatter_for(format) 17 | "#{format.to_s.camelize}Formatter".safe_constantize 18 | end 19 | end 20 | end 21 | 22 | class KeyFormatter < Formatter 23 | class << self 24 | def format(key) 25 | super 26 | end 27 | 28 | def unformat(formatted_key) 29 | super 30 | end 31 | end 32 | end 33 | 34 | class RouteFormatter < Formatter 35 | class << self 36 | def format(route) 37 | super 38 | end 39 | 40 | def unformat(formatted_route) 41 | super 42 | end 43 | end 44 | end 45 | 46 | class ValueFormatter < Formatter 47 | class << self 48 | def format(raw_value) 49 | super(raw_value) 50 | end 51 | 52 | def unformat(value) 53 | super(value) 54 | end 55 | 56 | def value_formatter_for(type) 57 | "#{type.to_s.camelize}ValueFormatter".safe_constantize 58 | end 59 | end 60 | end 61 | 62 | # Warning: Not thread-safe. Wrap in ThreadLocalVar as needed. 63 | class FormatterWrapperCache 64 | attr_reader :formatter_klass 65 | 66 | def initialize(formatter_klass) 67 | @formatter_klass = formatter_klass 68 | @format_cache = NaiveCache.new{|arg| formatter_klass.format(arg) } 69 | @unformat_cache = NaiveCache.new{|arg| formatter_klass.unformat(arg) } 70 | end 71 | 72 | def format(arg) 73 | @format_cache.get(arg) 74 | end 75 | 76 | def unformat(arg) 77 | @unformat_cache.get(arg) 78 | end 79 | 80 | def cached 81 | self 82 | end 83 | end 84 | end 85 | 86 | class UnderscoredKeyFormatter < JSONAPI::KeyFormatter 87 | end 88 | 89 | class CamelizedKeyFormatter < JSONAPI::KeyFormatter 90 | class << self 91 | def format(key) 92 | super.camelize(:lower) 93 | end 94 | 95 | def unformat(formatted_key) 96 | formatted_key.to_s.underscore 97 | end 98 | end 99 | end 100 | 101 | class DasherizedKeyFormatter < JSONAPI::KeyFormatter 102 | class << self 103 | def format(key) 104 | super.underscore.dasherize 105 | end 106 | 107 | def unformat(formatted_key) 108 | formatted_key.to_s.underscore 109 | end 110 | end 111 | end 112 | 113 | class DefaultValueFormatter < JSONAPI::ValueFormatter 114 | class << self 115 | def format(raw_value) 116 | raw_value 117 | end 118 | end 119 | end 120 | 121 | class IdValueFormatter < JSONAPI::ValueFormatter 122 | class << self 123 | def format(raw_value) 124 | return if raw_value.nil? 125 | raw_value.to_s 126 | end 127 | end 128 | end 129 | 130 | class UnderscoredRouteFormatter < JSONAPI::RouteFormatter 131 | end 132 | 133 | class CamelizedRouteFormatter < JSONAPI::RouteFormatter 134 | class << self 135 | def format(route) 136 | super.camelize(:lower) 137 | end 138 | 139 | def unformat(formatted_route) 140 | formatted_route.to_s.underscore 141 | end 142 | end 143 | end 144 | 145 | class DasherizedRouteFormatter < JSONAPI::RouteFormatter 146 | class << self 147 | def format(route) 148 | super.dasherize 149 | end 150 | 151 | def unformat(formatted_route) 152 | formatted_route.to_s.underscore 153 | end 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /lib/jsonapi/include_directives.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | class IncludeDirectives 3 | # Construct an IncludeDirectives Hash from an array of dot separated include strings. 4 | # For example ['posts.comments.tags'] 5 | # will transform into => 6 | # { 7 | # posts:{ 8 | # include:true, 9 | # include_related:{ 10 | # comments:{ 11 | # include:true, 12 | # include_related:{ 13 | # tags:{ 14 | # include:true 15 | # } 16 | # } 17 | # } 18 | # } 19 | # } 20 | # } 21 | 22 | def initialize(includes_array) 23 | @include_directives_hash = { include_related: {} } 24 | includes_array.each do |include| 25 | parse_include(include) 26 | end 27 | end 28 | 29 | def include_directives 30 | @include_directives_hash 31 | end 32 | 33 | def model_includes 34 | get_includes(@include_directives_hash) 35 | end 36 | 37 | private 38 | 39 | def get_related(current_path) 40 | current = @include_directives_hash 41 | current_path.split('.').each do |fragment| 42 | fragment = fragment.to_sym 43 | current[:include_related][fragment] ||= { include: false, include_related: {} } 44 | current = current[:include_related][fragment] 45 | end 46 | current 47 | end 48 | 49 | def get_includes(directive) 50 | directive[:include_related].map do |name, directive| 51 | sub = get_includes(directive) 52 | sub.any? ? { name => sub } : name 53 | end 54 | end 55 | 56 | def parse_include(include) 57 | parts = include.split('.') 58 | local_path = '' 59 | 60 | parts.each do |name| 61 | local_path += local_path.length > 0 ? ".#{name}" : name 62 | related = get_related(local_path) 63 | related[:include] = true 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/jsonapi/link_builder.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | class LinkBuilder 3 | attr_reader :base_url, 4 | :primary_resource_klass, 5 | :route_formatter, 6 | :engine_name 7 | 8 | def initialize(config = {}) 9 | @base_url = config[:base_url] 10 | @primary_resource_klass = config[:primary_resource_klass] 11 | @route_formatter = config[:route_formatter] 12 | @engine_name = build_engine_name 13 | 14 | # Warning: These make LinkBuilder non-thread-safe. That's not a problem with the 15 | # request-specific way it's currently used, though. 16 | @resources_path_cache = JSONAPI::NaiveCache.new do |source_klass| 17 | formatted_module_path_from_class(source_klass) + format_route(source_klass._type.to_s) 18 | end 19 | end 20 | 21 | def engine? 22 | !!@engine_name 23 | end 24 | 25 | def primary_resources_url 26 | if engine? 27 | engine_primary_resources_url 28 | else 29 | regular_primary_resources_url 30 | end 31 | end 32 | 33 | def query_link(query_params) 34 | "#{ primary_resources_url }?#{ query_params.to_query }" 35 | end 36 | 37 | def relationships_related_link(source, relationship, query_params = {}) 38 | url = "#{ self_link(source) }/#{ route_for_relationship(relationship) }" 39 | url = "#{ url }?#{ query_params.to_query }" if query_params.present? 40 | url 41 | end 42 | 43 | def relationships_self_link(source, relationship) 44 | "#{ self_link(source) }/relationships/#{ route_for_relationship(relationship) }" 45 | end 46 | 47 | def self_link(source) 48 | if engine? 49 | engine_resource_url(source) 50 | else 51 | regular_resource_url(source) 52 | end 53 | end 54 | 55 | private 56 | 57 | def build_engine_name 58 | scopes = module_scopes_from_class(primary_resource_klass) 59 | 60 | unless scopes.empty? 61 | "#{ scopes.first.to_s.camelize }::Engine".safe_constantize 62 | end 63 | end 64 | 65 | def engine_path_from_resource_class(klass) 66 | path_name = engine_resources_path_name_from_class(klass) 67 | engine_name.routes.url_helpers.public_send(path_name) 68 | end 69 | 70 | def engine_primary_resources_path 71 | engine_path_from_resource_class(primary_resource_klass) 72 | end 73 | 74 | def engine_primary_resources_url 75 | "#{ base_url }#{ engine_primary_resources_path }" 76 | end 77 | 78 | def engine_resource_path(source) 79 | resource_path_name = engine_resource_path_name_from_source(source) 80 | engine_name.routes.url_helpers.public_send(resource_path_name, source.id) 81 | end 82 | 83 | def engine_resource_path_name_from_source(source) 84 | scopes = module_scopes_from_class(source.class)[1..-1] 85 | base_path_name = scopes.map { |scope| scope.underscore }.join("_") 86 | end_path_name = source.class._type.to_s.singularize 87 | [base_path_name, end_path_name, "path"].reject(&:blank?).join("_") 88 | end 89 | 90 | def engine_resource_url(source) 91 | "#{ base_url }#{ engine_resource_path(source) }" 92 | end 93 | 94 | def engine_resources_path_name_from_class(klass) 95 | scopes = module_scopes_from_class(klass)[1..-1] 96 | base_path_name = scopes.map { |scope| scope.underscore }.join("_") 97 | end_path_name = klass._type.to_s 98 | "#{ base_path_name }_#{ end_path_name }_path" 99 | end 100 | 101 | def format_route(route) 102 | route_formatter.format(route) 103 | end 104 | 105 | def formatted_module_path_from_class(klass) 106 | scopes = module_scopes_from_class(klass) 107 | 108 | unless scopes.empty? 109 | "/#{ scopes.map{ |scope| format_route(scope.to_s.underscore) }.join('/') }/" 110 | else 111 | "/" 112 | end 113 | end 114 | 115 | def module_scopes_from_class(klass) 116 | klass.name.to_s.split("::")[0...-1] 117 | end 118 | 119 | def regular_resources_path(source_klass) 120 | @resources_path_cache.get(source_klass) 121 | end 122 | 123 | def regular_primary_resources_path 124 | regular_resources_path(primary_resource_klass) 125 | end 126 | 127 | def regular_primary_resources_url 128 | "#{ base_url }#{ regular_primary_resources_path }" 129 | end 130 | 131 | def regular_resource_path(source) 132 | "#{regular_resources_path(source.class)}/#{source.id}" 133 | end 134 | 135 | def regular_resource_url(source) 136 | "#{ base_url }#{ regular_resource_path(source) }" 137 | end 138 | 139 | def route_for_relationship(relationship) 140 | format_route(relationship.name) 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /lib/jsonapi/mime_types.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | MEDIA_TYPE = 'application/vnd.api+json' 3 | 4 | module MimeTypes 5 | def self.install 6 | Mime::Type.register JSONAPI::MEDIA_TYPE, :api_json 7 | 8 | # :nocov: 9 | if Rails::VERSION::MAJOR >= 5 10 | parsers = ActionDispatch::Request.parameter_parsers.merge( 11 | Mime::Type.lookup(JSONAPI::MEDIA_TYPE).symbol => parser 12 | ) 13 | ActionDispatch::Request.parameter_parsers = parsers 14 | else 15 | ActionDispatch::ParamsParser::DEFAULT_PARSERS[Mime::Type.lookup(JSONAPI::MEDIA_TYPE)] = parser 16 | end 17 | # :nocov: 18 | end 19 | 20 | def self.parser 21 | lambda do |body| 22 | data = JSON.parse(body) 23 | data = {:_json => data} unless data.is_a?(Hash) 24 | data.with_indifferent_access 25 | end 26 | end 27 | end 28 | 29 | MimeTypes.install 30 | end 31 | -------------------------------------------------------------------------------- /lib/jsonapi/naive_cache.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | 3 | # Cache which memoizes the given block. 4 | # 5 | # It's "naive" because it clears the least-recently-inserted cache entry 6 | # rather than the least-recently-used. This makes lookups faster but cache 7 | # misses more frequent after cleanups. Therefore you the best time to use 8 | # this cache is when you expect only a small number of unique lookup keys, so 9 | # that the cache never has to clear. 10 | # 11 | # Also, it's not thread safe (although jsonapi-resources is careful to only 12 | # use it in a thread safe way). 13 | class NaiveCache 14 | def initialize(cap = 10000, &calculator) 15 | @cap = cap 16 | @data = {} 17 | @calculator = calculator 18 | end 19 | 20 | def get(key) 21 | found = true 22 | value = @data.fetch(key) { found = false } 23 | return value if found 24 | value = @calculator.call(key) 25 | @data[key] = value 26 | @data.shift if @data.length > @cap 27 | return value 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/jsonapi/operation.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | class Operation 3 | attr_reader :resource_klass, :operation_type, :options 4 | 5 | def initialize(operation_type, resource_klass, options) 6 | @operation_type = operation_type 7 | @resource_klass = resource_klass 8 | @options = options 9 | end 10 | 11 | def transactional? 12 | JSONAPI::Processor._processor_from_resource_type(resource_klass).transactional_operation_type?(operation_type) 13 | end 14 | 15 | def process 16 | processor.process 17 | end 18 | 19 | private 20 | def processor 21 | JSONAPI::Processor.processor_instance_for(resource_klass, operation_type, options) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/jsonapi/operation_dispatcher.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | class OperationDispatcher 3 | 4 | def initialize(transaction: lambda { |&block| block.yield }, 5 | rollback: lambda { }, 6 | server_error_callbacks: []) 7 | 8 | @transaction = transaction 9 | @rollback = rollback 10 | @server_error_callbacks = server_error_callbacks 11 | end 12 | 13 | def process(operations) 14 | results = JSONAPI::OperationResults.new 15 | 16 | # Use transactions if more than one operation and if one of the operations can be transactional 17 | # Even if transactional transactions won't be used unless the derived OperationsProcessor supports them. 18 | transactional = false 19 | operations.each do |operation| 20 | transactional |= operation.transactional? 21 | end 22 | 23 | transaction(transactional) do 24 | # Links and meta data global to the set of operations 25 | operations_meta = {} 26 | operations_links = {} 27 | operations.each do |operation| 28 | results.add_result(process_operation(operation)) 29 | rollback(transactional) if results.has_errors? 30 | end 31 | results.meta = operations_meta 32 | results.links = operations_links 33 | end 34 | results 35 | end 36 | 37 | private 38 | 39 | def transaction(transactional) 40 | if transactional 41 | @transaction.call do 42 | yield 43 | end 44 | else 45 | yield 46 | end 47 | end 48 | 49 | def rollback(transactional) 50 | if transactional 51 | @rollback.call 52 | end 53 | end 54 | 55 | def process_operation(operation) 56 | with_default_handling do 57 | operation.process 58 | end 59 | end 60 | 61 | def with_default_handling(&block) 62 | block.yield 63 | rescue => e 64 | if JSONAPI.configuration.exception_class_whitelisted?(e) 65 | raise e 66 | else 67 | @server_error_callbacks.each { |callback| 68 | safe_run_callback(callback, e) 69 | } 70 | 71 | internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e) 72 | Rails.logger.error { "Internal Server Error: #{e.message} #{e.backtrace.join("\n")}" } 73 | return JSONAPI::ErrorsOperationResult.new(internal_server_error.errors[0].code, internal_server_error.errors) 74 | end 75 | end 76 | 77 | def safe_run_callback(callback, error) 78 | begin 79 | callback.call(error) 80 | rescue => e 81 | Rails.logger.error { "Error in error handling callback: #{e.message} #{e.backtrace.join("\n")}" } 82 | internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e) 83 | return JSONAPI::ErrorsOperationResult.new(internal_server_error.errors[0].code, internal_server_error.errors) 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/jsonapi/operation_result.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | class OperationResult 3 | attr_accessor :code 4 | attr_accessor :meta 5 | attr_accessor :links 6 | attr_accessor :options 7 | 8 | def initialize(code, options = {}) 9 | @code = code 10 | @options = options 11 | @meta = options.fetch(:meta, {}) 12 | @links = options.fetch(:links, {}) 13 | end 14 | end 15 | 16 | class ErrorsOperationResult < OperationResult 17 | attr_accessor :errors 18 | 19 | def initialize(code, errors, options = {}) 20 | @errors = errors 21 | super(code, options) 22 | end 23 | end 24 | 25 | class ResourceOperationResult < OperationResult 26 | attr_accessor :resource 27 | 28 | def initialize(code, resource, options = {}) 29 | @resource = resource 30 | super(code, options) 31 | end 32 | end 33 | 34 | class ResourcesOperationResult < OperationResult 35 | attr_accessor :resources, :pagination_params, :record_count, :page_count 36 | 37 | def initialize(code, resources, options = {}) 38 | @resources = resources 39 | @pagination_params = options.fetch(:pagination_params, {}) 40 | @record_count = options[:record_count] 41 | @page_count = options[:page_count] 42 | super(code, options) 43 | end 44 | end 45 | 46 | class RelatedResourcesOperationResult < ResourcesOperationResult 47 | attr_accessor :source_resource, :_type 48 | 49 | def initialize(code, source_resource, type, resources, options = {}) 50 | @source_resource = source_resource 51 | @_type = type 52 | super(code, resources, options) 53 | end 54 | end 55 | 56 | class LinksObjectOperationResult < OperationResult 57 | attr_accessor :parent_resource, :relationship 58 | 59 | def initialize(code, parent_resource, relationship, options = {}) 60 | @parent_resource = parent_resource 61 | @relationship = relationship 62 | super(code, options) 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/jsonapi/operation_results.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | class OperationResults 3 | attr_accessor :results 4 | attr_accessor :meta 5 | attr_accessor :links 6 | 7 | def initialize 8 | @results = [] 9 | @has_errors = false 10 | @meta = {} 11 | @links = {} 12 | end 13 | 14 | def add_result(result) 15 | @has_errors = true if result.is_a?(JSONAPI::ErrorsOperationResult) 16 | @results.push(result) 17 | end 18 | 19 | def has_errors? 20 | @has_errors 21 | end 22 | 23 | def all_errors 24 | errors = [] 25 | if @has_errors 26 | @results.each do |result| 27 | if result.is_a?(JSONAPI::ErrorsOperationResult) 28 | errors.concat(result.errors) 29 | end 30 | end 31 | end 32 | errors 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/jsonapi/paginator.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | class Paginator 3 | def initialize(_params) 4 | end 5 | 6 | def apply(_relation, _order_options) 7 | # relation 8 | end 9 | 10 | def links_page_params(_options = {}) 11 | # :nocov: 12 | {} 13 | # :nocov: 14 | end 15 | 16 | class << self 17 | def requires_record_count 18 | # :nocov: 19 | false 20 | # :nocov: 21 | end 22 | 23 | def paginator_for(paginator) 24 | paginator_class_name = "#{paginator.to_s.camelize}Paginator" 25 | paginator_class_name.safe_constantize if paginator_class_name 26 | end 27 | end 28 | end 29 | end 30 | 31 | class OffsetPaginator < JSONAPI::Paginator 32 | attr_reader :limit, :offset 33 | 34 | def initialize(params) 35 | parse_pagination_params(params) 36 | verify_pagination_params 37 | end 38 | 39 | def self.requires_record_count 40 | true 41 | end 42 | 43 | def apply(relation, _order_options) 44 | relation.offset(@offset).limit(@limit) 45 | end 46 | 47 | def links_page_params(options = {}) 48 | record_count = options[:record_count] 49 | links_page_params = {} 50 | 51 | links_page_params['first'] = { 52 | 'offset' => 0, 53 | 'limit' => @limit 54 | } 55 | 56 | if @offset > 0 57 | previous_offset = @offset - @limit 58 | 59 | previous_offset = 0 if previous_offset < 0 60 | 61 | links_page_params['prev'] = { 62 | 'offset' => previous_offset, 63 | 'limit' => @limit 64 | } 65 | end 66 | 67 | next_offset = @offset + @limit 68 | 69 | unless next_offset >= record_count 70 | links_page_params['next'] = { 71 | 'offset' => next_offset, 72 | 'limit' => @limit 73 | } 74 | end 75 | 76 | if record_count 77 | last_offset = record_count - @limit 78 | 79 | last_offset = 0 if last_offset < 0 80 | 81 | links_page_params['last'] = { 82 | 'offset' => last_offset, 83 | 'limit' => @limit 84 | } 85 | end 86 | 87 | links_page_params 88 | end 89 | 90 | private 91 | 92 | def parse_pagination_params(params) 93 | if params.nil? 94 | @offset = 0 95 | @limit = JSONAPI.configuration.default_page_size 96 | elsif params.is_a?(ActionController::Parameters) 97 | validparams = params.permit(:offset, :limit) 98 | 99 | @offset = validparams[:offset] ? validparams[:offset].to_i : 0 100 | @limit = validparams[:limit] ? validparams[:limit].to_i : JSONAPI.configuration.default_page_size 101 | else 102 | fail JSONAPI::Exceptions::InvalidPageObject.new 103 | end 104 | rescue ActionController::UnpermittedParameters => e 105 | raise JSONAPI::Exceptions::PageParametersNotAllowed.new(e.params) 106 | end 107 | 108 | def verify_pagination_params 109 | if @limit < 1 110 | fail JSONAPI::Exceptions::InvalidPageValue.new(:limit, @limit) 111 | elsif @limit > JSONAPI.configuration.maximum_page_size 112 | fail JSONAPI::Exceptions::InvalidPageValue.new(:limit, @limit, 113 | "Limit exceeds maximum page size of #{JSONAPI.configuration.maximum_page_size}.") 114 | end 115 | 116 | if @offset < 0 117 | fail JSONAPI::Exceptions::InvalidPageValue.new(:offset, @offset) 118 | end 119 | end 120 | end 121 | 122 | class PagedPaginator < JSONAPI::Paginator 123 | attr_reader :size, :number 124 | 125 | def initialize(params) 126 | parse_pagination_params(params) 127 | verify_pagination_params 128 | end 129 | 130 | def self.requires_record_count 131 | true 132 | end 133 | 134 | def calculate_page_count(record_count) 135 | (record_count / @size.to_f).ceil 136 | end 137 | 138 | def apply(relation, _order_options) 139 | offset = (@number - 1) * @size 140 | relation.offset(offset).limit(@size) 141 | end 142 | 143 | def links_page_params(options = {}) 144 | record_count = options[:record_count] 145 | page_count = calculate_page_count(record_count) 146 | 147 | links_page_params = {} 148 | 149 | links_page_params['first'] = { 150 | 'number' => 1, 151 | 'size' => @size 152 | } 153 | 154 | if @number > 1 155 | links_page_params['prev'] = { 156 | 'number' => @number - 1, 157 | 'size' => @size 158 | } 159 | end 160 | 161 | unless @number >= page_count 162 | links_page_params['next'] = { 163 | 'number' => @number + 1, 164 | 'size' => @size 165 | } 166 | end 167 | 168 | if record_count 169 | links_page_params['last'] = { 170 | 'number' => page_count == 0 ? 1 : page_count, 171 | 'size' => @size 172 | } 173 | end 174 | 175 | links_page_params 176 | end 177 | 178 | private 179 | 180 | def parse_pagination_params(params) 181 | if params.nil? 182 | @number = 1 183 | @size = JSONAPI.configuration.default_page_size 184 | elsif params.is_a?(ActionController::Parameters) 185 | validparams = params.permit(:number, :size) 186 | 187 | @size = validparams[:size] ? validparams[:size].to_i : JSONAPI.configuration.default_page_size 188 | @number = validparams[:number] ? validparams[:number].to_i : 1 189 | else 190 | @size = JSONAPI.configuration.default_page_size 191 | @number = params.to_i 192 | end 193 | rescue ActionController::UnpermittedParameters => e 194 | raise JSONAPI::Exceptions::PageParametersNotAllowed.new(e.params) 195 | end 196 | 197 | def verify_pagination_params 198 | if @size < 1 199 | fail JSONAPI::Exceptions::InvalidPageValue.new(:size, @size) 200 | elsif @size > JSONAPI.configuration.maximum_page_size 201 | fail JSONAPI::Exceptions::InvalidPageValue.new(:size, @size, 202 | "size exceeds maximum page size of #{JSONAPI.configuration.maximum_page_size}.") 203 | end 204 | 205 | if @number < 1 206 | fail JSONAPI::Exceptions::InvalidPageValue.new(:number, @number) 207 | end 208 | end 209 | end 210 | -------------------------------------------------------------------------------- /lib/jsonapi/processor.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | class Processor 3 | include Callbacks 4 | define_jsonapi_resources_callbacks :find, 5 | :show, 6 | :show_relationship, 7 | :show_related_resource, 8 | :show_related_resources, 9 | :create_resource, 10 | :remove_resource, 11 | :replace_fields, 12 | :replace_to_one_relationship, 13 | :replace_polymorphic_to_one_relationship, 14 | :create_to_many_relationship, 15 | :replace_to_many_relationship, 16 | :remove_to_many_relationship, 17 | :remove_to_one_relationship, 18 | :operation 19 | 20 | class << self 21 | def processor_instance_for(resource_klass, operation_type, params) 22 | _processor_from_resource_type(resource_klass).new(resource_klass, operation_type, params) 23 | end 24 | 25 | def _processor_from_resource_type(resource_klass) 26 | processor = resource_klass.name.gsub(/Resource$/,'Processor').safe_constantize 27 | if processor.nil? 28 | processor = JSONAPI.configuration.default_processor_klass 29 | end 30 | 31 | return processor 32 | end 33 | 34 | def transactional_operation_type?(operation_type) 35 | case operation_type 36 | when :find, :show, :show_related_resource, :show_related_resources 37 | return false 38 | else 39 | return true 40 | end 41 | end 42 | end 43 | 44 | attr_reader :resource_klass, :operation_type, :params, :context, :result, :result_options 45 | 46 | def initialize(resource_klass, operation_type, params) 47 | @resource_klass = resource_klass 48 | @operation_type = operation_type 49 | @params = params 50 | @context = params[:context] 51 | @result = nil 52 | @result_options = {} 53 | end 54 | 55 | def process 56 | run_callbacks :operation do 57 | run_callbacks operation_type do 58 | @result = send(operation_type) 59 | end 60 | end 61 | 62 | rescue JSONAPI::Exceptions::Error => e 63 | @result = JSONAPI::ErrorsOperationResult.new(e.errors[0].code, e.errors) 64 | end 65 | 66 | def find 67 | filters = params[:filters] 68 | include_directives = params[:include_directives] 69 | sort_criteria = params.fetch(:sort_criteria, []) 70 | paginator = params[:paginator] 71 | fields = params[:fields] 72 | 73 | verified_filters = resource_klass.verify_filters(filters, context) 74 | resource_records = resource_klass.find(verified_filters, 75 | context: context, 76 | include_directives: include_directives, 77 | sort_criteria: sort_criteria, 78 | paginator: paginator, 79 | fields: fields) 80 | 81 | page_options = {} 82 | if (JSONAPI.configuration.top_level_meta_include_record_count || 83 | (paginator && paginator.class.requires_record_count)) 84 | page_options[:record_count] = resource_klass.find_count(verified_filters, 85 | context: context, 86 | include_directives: include_directives) 87 | end 88 | 89 | if (JSONAPI.configuration.top_level_meta_include_page_count && page_options[:record_count]) 90 | page_options[:page_count] = paginator.calculate_page_count(page_options[:record_count]) 91 | end 92 | 93 | if JSONAPI.configuration.top_level_links_include_pagination && paginator 94 | page_options[:pagination_params] = paginator.links_page_params(page_options) 95 | end 96 | 97 | return JSONAPI::ResourcesOperationResult.new(:ok, resource_records, page_options) 98 | end 99 | 100 | def show 101 | include_directives = params[:include_directives] 102 | fields = params[:fields] 103 | id = params[:id] 104 | 105 | key = resource_klass.verify_key(id, context) 106 | 107 | resource_record = resource_klass.find_by_key(key, 108 | context: context, 109 | include_directives: include_directives, 110 | fields: fields) 111 | 112 | return JSONAPI::ResourceOperationResult.new(:ok, resource_record) 113 | end 114 | 115 | def show_relationship 116 | parent_key = params[:parent_key] 117 | relationship_type = params[:relationship_type].to_sym 118 | 119 | parent_resource = resource_klass.find_by_key(parent_key, context: context) 120 | 121 | return JSONAPI::LinksObjectOperationResult.new(:ok, 122 | parent_resource, 123 | resource_klass._relationship(relationship_type)) 124 | end 125 | 126 | def show_related_resource 127 | source_klass = params[:source_klass] 128 | source_id = params[:source_id] 129 | relationship_type = params[:relationship_type].to_sym 130 | fields = params[:fields] 131 | 132 | source_resource = source_klass.find_by_key(source_id, context: context, fields: fields) 133 | 134 | related_resource = source_resource.public_send(relationship_type) 135 | 136 | return JSONAPI::ResourceOperationResult.new(:ok, related_resource) 137 | end 138 | 139 | def show_related_resources 140 | source_klass = params[:source_klass] 141 | source_id = params[:source_id] 142 | relationship_type = params[:relationship_type] 143 | filters = params[:filters] 144 | sort_criteria = params[:sort_criteria] 145 | paginator = params[:paginator] 146 | fields = params[:fields] 147 | 148 | source_resource ||= source_klass.find_by_key(source_id, context: context, fields: fields) 149 | 150 | related_resources = source_resource.public_send(relationship_type, 151 | filters: filters, 152 | sort_criteria: sort_criteria, 153 | paginator: paginator, 154 | fields: fields) 155 | 156 | if ((JSONAPI.configuration.top_level_meta_include_record_count) || 157 | (paginator && paginator.class.requires_record_count) || 158 | (JSONAPI.configuration.top_level_meta_include_page_count)) 159 | related_resource_records = source_resource.public_send("records_for_" + relationship_type) 160 | records = resource_klass.filter_records(filters, {}, 161 | related_resource_records) 162 | 163 | record_count = resource_klass.count_records(records) 164 | end 165 | 166 | if (JSONAPI.configuration.top_level_meta_include_page_count && record_count) 167 | page_count = paginator.calculate_page_count(record_count) 168 | end 169 | 170 | pagination_params = if paginator && JSONAPI.configuration.top_level_links_include_pagination 171 | page_options = {} 172 | page_options[:record_count] = record_count if paginator.class.requires_record_count 173 | paginator.links_page_params(page_options) 174 | else 175 | {} 176 | end 177 | 178 | opts = {} 179 | opts.merge!(pagination_params: pagination_params) if JSONAPI.configuration.top_level_links_include_pagination 180 | opts.merge!(record_count: record_count) if JSONAPI.configuration.top_level_meta_include_record_count 181 | opts.merge!(page_count: page_count) if JSONAPI.configuration.top_level_meta_include_page_count 182 | 183 | return JSONAPI::RelatedResourcesOperationResult.new(:ok, 184 | source_resource, 185 | relationship_type, 186 | related_resources, 187 | opts) 188 | end 189 | 190 | def create_resource 191 | data = params[:data] 192 | resource = resource_klass.create(context) 193 | result = resource.replace_fields(data) 194 | 195 | return JSONAPI::ResourceOperationResult.new((result == :completed ? :created : :accepted), resource) 196 | end 197 | 198 | def remove_resource 199 | resource_id = params[:resource_id] 200 | 201 | resource = resource_klass.find_by_key(resource_id, context: context) 202 | result = resource.remove 203 | 204 | return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) 205 | end 206 | 207 | def replace_fields 208 | resource_id = params[:resource_id] 209 | data = params[:data] 210 | 211 | resource = resource_klass.find_by_key(resource_id, context: context) 212 | result = resource.replace_fields(data) 213 | 214 | return JSONAPI::ResourceOperationResult.new(result == :completed ? :ok : :accepted, resource) 215 | end 216 | 217 | def replace_to_one_relationship 218 | resource_id = params[:resource_id] 219 | relationship_type = params[:relationship_type].to_sym 220 | key_value = params[:key_value] 221 | 222 | resource = resource_klass.find_by_key(resource_id, context: context) 223 | result = resource.replace_to_one_link(relationship_type, key_value) 224 | 225 | return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) 226 | end 227 | 228 | def replace_polymorphic_to_one_relationship 229 | resource_id = params[:resource_id] 230 | relationship_type = params[:relationship_type].to_sym 231 | key_value = params[:key_value] 232 | key_type = params[:key_type] 233 | 234 | resource = resource_klass.find_by_key(resource_id, context: context) 235 | result = resource.replace_polymorphic_to_one_link(relationship_type, key_value, key_type) 236 | 237 | return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) 238 | end 239 | 240 | def create_to_many_relationship 241 | resource_id = params[:resource_id] 242 | relationship_type = params[:relationship_type].to_sym 243 | data = params[:data] 244 | 245 | resource = resource_klass.find_by_key(resource_id, context: context) 246 | result = resource.create_to_many_links(relationship_type, data) 247 | 248 | return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) 249 | end 250 | 251 | def replace_to_many_relationship 252 | resource_id = params[:resource_id] 253 | relationship_type = params[:relationship_type].to_sym 254 | data = params.fetch(:data) 255 | 256 | resource = resource_klass.find_by_key(resource_id, context: context) 257 | result = resource.replace_to_many_links(relationship_type, data) 258 | 259 | return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) 260 | end 261 | 262 | def remove_to_many_relationship 263 | resource_id = params[:resource_id] 264 | relationship_type = params[:relationship_type].to_sym 265 | associated_key = params[:associated_key] 266 | 267 | resource = resource_klass.find_by_key(resource_id, context: context) 268 | result = resource.remove_to_many_link(relationship_type, associated_key) 269 | 270 | return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) 271 | end 272 | 273 | def remove_to_one_relationship 274 | resource_id = params[:resource_id] 275 | relationship_type = params[:relationship_type].to_sym 276 | 277 | resource = resource_klass.find_by_key(resource_id, context: context) 278 | result = resource.remove_to_one_link(relationship_type) 279 | 280 | return JSONAPI::OperationResult.new(result == :completed ? :no_content : :accepted) 281 | end 282 | end 283 | end 284 | -------------------------------------------------------------------------------- /lib/jsonapi/relationship.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | class Relationship 3 | attr_reader :acts_as_set, :foreign_key, :options, :name, 4 | :class_name, :polymorphic, :always_include_linkage_data, 5 | :parent_resource 6 | 7 | def initialize(name, options = {}) 8 | @name = name.to_s 9 | @options = options 10 | @acts_as_set = options.fetch(:acts_as_set, false) == true 11 | @foreign_key = options[:foreign_key] ? options[:foreign_key].to_sym : nil 12 | @parent_resource = options[:parent_resource] 13 | @relation_name = options.fetch(:relation_name, @name) 14 | @polymorphic = options.fetch(:polymorphic, false) == true 15 | @always_include_linkage_data = options.fetch(:always_include_linkage_data, false) == true 16 | end 17 | 18 | alias_method :polymorphic?, :polymorphic 19 | 20 | def primary_key 21 | @primary_key ||= resource_klass._primary_key 22 | end 23 | 24 | def resource_klass 25 | @resource_klass ||= @parent_resource.resource_for(@class_name) 26 | end 27 | 28 | def table_name 29 | @table_name ||= resource_klass._table_name 30 | end 31 | 32 | def type 33 | @type ||= resource_klass._type.to_sym 34 | end 35 | 36 | def relation_name(options) 37 | case @relation_name 38 | when Symbol 39 | # :nocov: 40 | @relation_name 41 | # :nocov: 42 | when String 43 | @relation_name.to_sym 44 | when Proc 45 | @relation_name.call(options) 46 | end 47 | end 48 | 49 | def type_for_source(source) 50 | if polymorphic? 51 | resource = source.public_send(name) 52 | resource.class._type if resource 53 | else 54 | type 55 | end 56 | end 57 | 58 | def belongs_to? 59 | false 60 | end 61 | 62 | class ToOne < Relationship 63 | attr_reader :foreign_key_on 64 | 65 | def initialize(name, options = {}) 66 | super 67 | @class_name = options.fetch(:class_name, name.to_s.camelize) 68 | @foreign_key ||= "#{name}_id".to_sym 69 | @foreign_key_on = options.fetch(:foreign_key_on, :self) 70 | end 71 | 72 | def belongs_to? 73 | foreign_key_on == :self 74 | end 75 | 76 | def polymorphic_type 77 | "#{name}_type" if polymorphic? 78 | end 79 | end 80 | 81 | class ToMany < Relationship 82 | def initialize(name, options = {}) 83 | super 84 | @class_name = options.fetch(:class_name, name.to_s.camelize.singularize) 85 | @foreign_key ||= "#{name.to_s.singularize}_ids".to_sym 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/jsonapi/resource_controller.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | class ResourceController < ActionController::Base 3 | include JSONAPI::ActsAsResourceController 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/jsonapi/resource_controller_metal.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | class ResourceControllerMetal < ActionController::Metal 3 | MODULES = [ 4 | AbstractController::Rendering, 5 | ActionController::Rendering, 6 | ActionController::Renderers::All, 7 | ActionController::StrongParameters, 8 | ActionController::ForceSSL, 9 | ActionController::Instrumentation, 10 | JSONAPI::ActsAsResourceController 11 | ].freeze 12 | 13 | MODULES.each do |mod| 14 | include mod 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/jsonapi/resource_serializer.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | class ResourceSerializer 3 | 4 | attr_reader :link_builder, :key_formatter, :serialization_options, :primary_class_name 5 | 6 | # initialize 7 | # Options can include 8 | # include: 9 | # Purpose: determines which objects will be side loaded with the source objects in a linked section 10 | # Example: ['comments','author','comments.tags','author.posts'] 11 | # fields: 12 | # Purpose: determines which fields are serialized for a resource type. This encompasses both attributes and 13 | # relationship ids in the links section for a resource. Fields are global for a resource type. 14 | # Example: { people: [:id, :email, :comments], posts: [:id, :title, :author], comments: [:id, :body, :post]} 15 | # key_formatter: KeyFormatter instance to override the default configuration 16 | # serializer_options: additional options that will be passed to resource meta and links lambdas 17 | 18 | def initialize(primary_resource_klass, options = {}) 19 | @primary_class_name = primary_resource_klass._type 20 | @fields = options.fetch(:fields, {}) 21 | @include = options.fetch(:include, []) 22 | @include_directives = options[:include_directives] 23 | @key_formatter = options.fetch(:key_formatter, JSONAPI.configuration.key_formatter) 24 | @id_formatter = ValueFormatter.value_formatter_for(:id) 25 | @link_builder = generate_link_builder(primary_resource_klass, options) 26 | @always_include_to_one_linkage_data = options.fetch(:always_include_to_one_linkage_data, 27 | JSONAPI.configuration.always_include_to_one_linkage_data) 28 | @always_include_to_many_linkage_data = options.fetch(:always_include_to_many_linkage_data, 29 | JSONAPI.configuration.always_include_to_many_linkage_data) 30 | @serialization_options = options.fetch(:serialization_options, {}) 31 | 32 | # Warning: This makes ResourceSerializer non-thread-safe. That's not a problem with the 33 | # request-specific way it's currently used, though. 34 | @value_formatter_type_cache = NaiveCache.new{|arg| ValueFormatter.value_formatter_for(arg) } 35 | end 36 | 37 | # Converts a single resource, or an array of resources to a hash, conforming to the JSONAPI structure 38 | def serialize_to_hash(source) 39 | @top_level_sources = Set.new([source].flatten.compact.map {|s| top_level_source_key(s) }) 40 | 41 | is_resource_collection = source.respond_to?(:to_ary) 42 | 43 | @included_objects = {} 44 | @include_directives ||= JSONAPI::IncludeDirectives.new(@include) 45 | 46 | process_primary(source, @include_directives.include_directives) 47 | 48 | included_objects = [] 49 | primary_objects = [] 50 | @included_objects.each_value do |objects| 51 | objects.each_value do |object| 52 | if object[:primary] 53 | primary_objects.push(object[:object_hash]) 54 | else 55 | included_objects.push(object[:object_hash]) 56 | end 57 | end 58 | end 59 | 60 | primary_hash = { data: is_resource_collection ? primary_objects : primary_objects[0] } 61 | 62 | primary_hash[:included] = included_objects if included_objects.size > 0 63 | primary_hash 64 | end 65 | 66 | def serialize_to_links_hash(source, requested_relationship) 67 | if requested_relationship.is_a?(JSONAPI::Relationship::ToOne) 68 | data = to_one_linkage(source, requested_relationship) 69 | else 70 | data = to_many_linkage(source, requested_relationship) 71 | end 72 | 73 | { 74 | links: { 75 | self: self_link(source, requested_relationship), 76 | related: related_link(source, requested_relationship) 77 | }, 78 | data: data 79 | } 80 | end 81 | 82 | def query_link(query_params) 83 | link_builder.query_link(query_params) 84 | end 85 | 86 | def format_key(key) 87 | @key_formatter.format(key) 88 | end 89 | 90 | def format_value(value, format) 91 | @value_formatter_type_cache.get(format).format(value) 92 | end 93 | 94 | private 95 | 96 | # Process the primary source object(s). This will then serialize associated object recursively based on the 97 | # requested includes. Fields are controlled fields option for each resource type, such 98 | # as fields: { people: [:id, :email, :comments], posts: [:id, :title, :author], comments: [:id, :body, :post]} 99 | # The fields options controls both fields and included links references. 100 | def process_primary(source, include_directives) 101 | if source.respond_to?(:to_ary) 102 | source.each { |resource| process_primary(resource, include_directives) } 103 | else 104 | return {} if source.nil? 105 | 106 | resource = source 107 | id = resource.id 108 | add_included_object(id, object_hash(source, include_directives), true) 109 | end 110 | end 111 | 112 | # Returns a serialized hash for the source model 113 | def object_hash(source, include_directives) 114 | obj_hash = {} 115 | 116 | id_format = source.class._attribute_options(:id)[:format] 117 | # protect against ids that were declared as an attribute, but did not have a format set. 118 | id_format = 'id' if id_format == :default 119 | obj_hash['id'] = format_value(source.id, id_format) 120 | 121 | obj_hash['type'] = format_key(source.class._type.to_s) 122 | 123 | links = links_hash(source) 124 | obj_hash['links'] = links unless links.empty? 125 | 126 | attributes = attributes_hash(source) 127 | obj_hash['attributes'] = attributes unless attributes.empty? 128 | 129 | relationships = relationships_hash(source, include_directives) 130 | obj_hash['relationships'] = relationships unless relationships.nil? || relationships.empty? 131 | 132 | meta = meta_hash(source) 133 | obj_hash['meta'] = meta unless meta.empty? 134 | 135 | obj_hash 136 | end 137 | 138 | def requested_fields(klass) 139 | return if @fields.nil? || @fields.empty? 140 | if @fields[klass._type] 141 | @fields[klass._type] 142 | elsif klass.superclass != JSONAPI::Resource 143 | requested_fields(klass.superclass) 144 | end 145 | end 146 | 147 | def attributes_hash(source) 148 | requested = requested_fields(source.class) 149 | fields = source.fetchable_fields & source.class._attributes.keys.to_a 150 | fields = requested & fields unless requested.nil? 151 | 152 | fields.each_with_object({}) do |name, hash| 153 | format = source.class._attribute_options(name)[:format] 154 | unless name == :id 155 | hash[format_key(name)] = format_value(source.public_send(name), format) 156 | end 157 | end 158 | end 159 | 160 | def custom_generation_options 161 | { 162 | serializer: self, 163 | serialization_options: @serialization_options 164 | } 165 | end 166 | 167 | def meta_hash(source) 168 | meta = source.meta(custom_generation_options) 169 | (meta.is_a?(Hash) && meta) || {} 170 | end 171 | 172 | def links_hash(source) 173 | { 174 | self: link_builder.self_link(source) 175 | }.merge(custom_links_hash(source)).compact 176 | end 177 | 178 | def custom_links_hash(source) 179 | custom_links = source.custom_links(custom_generation_options) 180 | (custom_links.is_a?(Hash) && custom_links) || {} 181 | end 182 | 183 | def top_level_source_key(source) 184 | "#{source.class}_#{source.id}" 185 | end 186 | 187 | def self_referential_and_already_in_source(resource) 188 | resource && @top_level_sources.include?(top_level_source_key(resource)) 189 | end 190 | 191 | def relationships_hash(source, include_directives) 192 | relationships = source.class._relationships 193 | requested = requested_fields(source.class) 194 | fields = relationships.keys 195 | fields = requested & fields unless requested.nil? 196 | 197 | field_set = Set.new(fields) 198 | 199 | included_relationships = source.fetchable_fields & relationships.keys 200 | 201 | data = {} 202 | 203 | relationships.each_with_object(data) do |(name, relationship), hash| 204 | if included_relationships.include? name 205 | ia = include_directives[:include_related][name] 206 | 207 | include_linkage = ia && ia[:include] 208 | include_linked_children = ia && !ia[:include_related].empty? 209 | resources = (include_linkage || include_linked_children) && [source.public_send(name)].flatten.compact 210 | 211 | if field_set.include?(name) 212 | hash[format_key(name)] = link_object(source, relationship, include_linkage) 213 | end 214 | 215 | # If the object has been serialized once it will be in the related objects list, 216 | # but it's possible all children won't have been captured. So we must still go 217 | # through the relationships. 218 | if include_linkage || include_linked_children 219 | resources.each do |resource| 220 | next if self_referential_and_already_in_source(resource) 221 | id = resource.id 222 | type = resource.class.resource_for_model(resource._model) 223 | relationships_only = already_serialized?(type, id) 224 | if include_linkage && !relationships_only 225 | add_included_object(id, object_hash(resource, ia)) 226 | elsif include_linked_children || relationships_only 227 | relationships_hash(resource, ia) 228 | end 229 | end 230 | end 231 | end 232 | end 233 | end 234 | 235 | def already_serialized?(type, id) 236 | type = format_key(type) 237 | @included_objects.key?(type) && @included_objects[type].key?(id) 238 | end 239 | 240 | def self_link(source, relationship) 241 | link_builder.relationships_self_link(source, relationship) 242 | end 243 | 244 | def related_link(source, relationship) 245 | link_builder.relationships_related_link(source, relationship) 246 | end 247 | 248 | def to_one_linkage(source, relationship) 249 | linkage = {} 250 | linkage_id = foreign_key_value(source, relationship) 251 | 252 | if linkage_id 253 | linkage[:type] = format_key(relationship.type_for_source(source)) 254 | linkage[:id] = linkage_id 255 | else 256 | linkage = nil 257 | end 258 | linkage 259 | end 260 | 261 | def to_many_linkage(source, relationship) 262 | linkage = [] 263 | linkage_types_and_values = foreign_key_types_and_values(source, relationship) 264 | 265 | linkage_types_and_values.each do |type, value| 266 | linkage.append({type: format_key(type), id: value}) 267 | end 268 | linkage 269 | end 270 | 271 | def link_object_to_one(source, relationship, include_linkage) 272 | include_linkage = include_linkage | @always_include_to_one_linkage_data | relationship.always_include_linkage_data 273 | link_object_hash = {} 274 | link_object_hash[:links] = {} 275 | link_object_hash[:links][:self] = self_link(source, relationship) 276 | link_object_hash[:links][:related] = related_link(source, relationship) 277 | link_object_hash[:data] = to_one_linkage(source, relationship) if include_linkage 278 | link_object_hash 279 | end 280 | 281 | def link_object_to_many(source, relationship, include_linkage) 282 | include_linkage = include_linkage | relationship.always_include_linkage_data 283 | link_object_hash = {} 284 | link_object_hash[:links] = {} 285 | link_object_hash[:links][:self] = self_link(source, relationship) 286 | link_object_hash[:links][:related] = related_link(source, relationship) 287 | link_object_hash[:data] = to_many_linkage(source, relationship) if include_linkage 288 | link_object_hash 289 | end 290 | 291 | def link_object(source, relationship, include_linkage = false) 292 | if relationship.is_a?(JSONAPI::Relationship::ToOne) 293 | link_object_to_one(source, relationship, include_linkage) 294 | elsif relationship.is_a?(JSONAPI::Relationship::ToMany) 295 | link_object_to_many(source, relationship, include_linkage) 296 | end 297 | end 298 | 299 | # Extracts the foreign key value for a to_one relationship. 300 | def foreign_key_value(source, relationship) 301 | related_resource = source.public_send(relationship.name) 302 | return nil unless related_resource 303 | @id_formatter.format(related_resource.id) 304 | end 305 | 306 | def foreign_key_types_and_values(source, relationship) 307 | if relationship.is_a?(JSONAPI::Relationship::ToMany) 308 | if relationship.polymorphic? 309 | assoc = source._model.public_send(relationship.name) 310 | # Avoid hitting the database again for values already pre-loaded 311 | if assoc.respond_to?(:loaded?) and assoc.loaded? 312 | assoc.map do |obj| 313 | [obj.type.underscore.pluralize, @id_formatter.format(obj.id)] 314 | end 315 | else 316 | assoc.pluck(:type, :id).map do |type, id| 317 | [type.underscore.pluralize, @id_formatter.format(id)] 318 | end 319 | end 320 | else 321 | source.public_send(relationship.foreign_key).map do |value| 322 | [relationship.type, @id_formatter.format(value)] 323 | end 324 | end 325 | end 326 | end 327 | 328 | # Sets that an object should be included in the primary document of the response. 329 | def set_primary(type, id) 330 | type = format_key(type) 331 | @included_objects[type][id][:primary] = true 332 | end 333 | 334 | # Collects the hashes for all objects processed by the serializer 335 | def add_included_object(id, object_hash, primary = false) 336 | type = object_hash['type'] 337 | 338 | @included_objects[type] = {} unless @included_objects.key?(type) 339 | 340 | if already_serialized?(type, id) 341 | @included_objects[type][id][:object_hash].merge!(object_hash) 342 | set_primary(type, id) if primary 343 | else 344 | @included_objects[type].store(id, primary: primary, object_hash: object_hash) 345 | end 346 | end 347 | 348 | def generate_link_builder(primary_resource_klass, options) 349 | LinkBuilder.new( 350 | base_url: options.fetch(:base_url, ''), 351 | route_formatter: options.fetch(:route_formatter, JSONAPI.configuration.route_formatter), 352 | primary_resource_klass: primary_resource_klass, 353 | ) 354 | end 355 | end 356 | end 357 | -------------------------------------------------------------------------------- /lib/jsonapi/resources/version.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | module Resources 3 | VERSION = '0.7.0' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/jsonapi/response_document.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | class ResponseDocument 3 | def initialize(operation_results, options = {}) 4 | @operation_results = operation_results 5 | @options = options 6 | 7 | @key_formatter = @options.fetch(:key_formatter, JSONAPI.configuration.key_formatter) 8 | end 9 | 10 | def contents 11 | hash = results_to_hash 12 | 13 | meta = top_level_meta 14 | hash.merge!(meta: meta) unless meta.empty? 15 | 16 | links = top_level_links 17 | hash.merge!(links: links) unless links.empty? 18 | 19 | hash 20 | end 21 | 22 | def status 23 | if @operation_results.has_errors? 24 | @operation_results.all_errors[0].status 25 | else 26 | @operation_results.results[0].code 27 | end 28 | end 29 | 30 | private 31 | 32 | def serializer 33 | @serializer ||= @options.fetch(:resource_serializer_klass, JSONAPI::ResourceSerializer).new( 34 | @options.fetch(:primary_resource_klass), 35 | include_directives: @options[:include_directives], 36 | fields: @options[:fields], 37 | base_url: @options.fetch(:base_url, ''), 38 | key_formatter: @key_formatter, 39 | route_formatter: @options.fetch(:route_formatter, JSONAPI.configuration.route_formatter), 40 | serialization_options: @options.fetch(:serialization_options, {}) 41 | ) 42 | end 43 | 44 | # Rolls up the top level meta data from the base_meta, the set of operations, 45 | # and the result of each operation. The keys are then formatted. 46 | def top_level_meta 47 | meta = @options.fetch(:base_meta, {}) 48 | 49 | meta.merge!(@operation_results.meta) 50 | 51 | @operation_results.results.each do |result| 52 | meta.merge!(result.meta) 53 | 54 | if JSONAPI.configuration.top_level_meta_include_record_count && result.respond_to?(:record_count) 55 | meta[JSONAPI.configuration.top_level_meta_record_count_key] = result.record_count 56 | end 57 | 58 | if JSONAPI.configuration.top_level_meta_include_page_count && result.respond_to?(:page_count) 59 | meta[JSONAPI.configuration.top_level_meta_page_count_key] = result.page_count 60 | end 61 | end 62 | 63 | meta.deep_transform_keys { |key| @key_formatter.format(key) } 64 | end 65 | 66 | # Rolls up the top level links from the base_links, the set of operations, 67 | # and the result of each operation. The keys are then formatted. 68 | def top_level_links 69 | links = @options.fetch(:base_links, {}) 70 | 71 | links.merge!(@operation_results.links) 72 | 73 | @operation_results.results.each do |result| 74 | links.merge!(result.links) 75 | 76 | # Build pagination links 77 | if result.is_a?(JSONAPI::ResourcesOperationResult) || result.is_a?(JSONAPI::RelatedResourcesOperationResult) 78 | result.pagination_params.each_pair do |link_name, params| 79 | if result.is_a?(JSONAPI::RelatedResourcesOperationResult) 80 | relationship = result.source_resource.class._relationships[result._type.to_sym] 81 | links[link_name] = serializer.link_builder.relationships_related_link(result.source_resource, relationship, query_params(params)) 82 | else 83 | links[link_name] = serializer.query_link(query_params(params)) 84 | end 85 | end 86 | end 87 | end 88 | 89 | links.deep_transform_keys { |key| @key_formatter.format(key) } 90 | end 91 | 92 | def query_params(params) 93 | query_params = {} 94 | query_params[:page] = params 95 | 96 | request = @options[:request] 97 | if request.params[:fields] 98 | query_params[:fields] = request.params[:fields].respond_to?(:to_unsafe_hash) ? request.params[:fields].to_unsafe_hash : request.params[:fields] 99 | end 100 | 101 | query_params[:include] = request.params[:include] if request.params[:include] 102 | query_params[:sort] = request.params[:sort] if request.params[:sort] 103 | 104 | if request.params[:filter] 105 | query_params[:filter] = request.params[:filter].respond_to?(:to_unsafe_hash) ? request.params[:filter].to_unsafe_hash : request.params[:filter] 106 | end 107 | 108 | query_params 109 | end 110 | 111 | def results_to_hash 112 | if @operation_results.has_errors? 113 | { errors: @operation_results.all_errors } 114 | else 115 | if @operation_results.results.length == 1 116 | result = @operation_results.results[0] 117 | 118 | case result 119 | when JSONAPI::ResourceOperationResult 120 | serializer.serialize_to_hash(result.resource) 121 | when JSONAPI::ResourcesOperationResult 122 | serializer.serialize_to_hash(result.resources) 123 | when JSONAPI::LinksObjectOperationResult 124 | serializer.serialize_to_links_hash(result.parent_resource, 125 | result.relationship) 126 | when JSONAPI::OperationResult 127 | {} 128 | end 129 | 130 | elsif @operation_results.results.length > 1 131 | resources = [] 132 | @operation_results.results.each do |result| 133 | case result 134 | when JSONAPI::ResourceOperationResult 135 | resources.push(result.resource) 136 | when JSONAPI::ResourcesOperationResult 137 | resources.concat(result.resources) 138 | end 139 | end 140 | 141 | serializer.serialize_to_hash(resources) 142 | end 143 | end 144 | end 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /lib/jsonapi/routing_ext.rb: -------------------------------------------------------------------------------- 1 | module ActionDispatch 2 | module Routing 3 | class Mapper 4 | Resource.class_eval do 5 | def unformat_route(route) 6 | JSONAPI.configuration.route_formatter.unformat(route.to_s) 7 | end 8 | 9 | def nested_param 10 | :"#{unformat_route(singular)}_#{param}" 11 | end 12 | end 13 | 14 | Resources.class_eval do 15 | def format_route(route) 16 | JSONAPI.configuration.route_formatter.format(route.to_s) 17 | end 18 | 19 | def jsonapi_resource(*resources, &_block) 20 | @resource_type = resources.first 21 | res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(@resource_type)) 22 | 23 | options = resources.extract_options!.dup 24 | options[:controller] ||= @resource_type 25 | options.merge!(res.routing_resource_options) 26 | options[:path] = format_route(@resource_type) 27 | 28 | if options[:except] 29 | options[:except] << :new unless options[:except].include?(:new) || options[:except].include?('new') 30 | options[:except] << :edit unless options[:except].include?(:edit) || options[:except].include?('edit') 31 | else 32 | options[:except] = [:new, :edit] 33 | end 34 | 35 | resource @resource_type, options do 36 | # :nocov: 37 | if @scope.respond_to? :[]= 38 | # Rails 4 39 | @scope[:jsonapi_resource] = @resource_type 40 | 41 | if block_given? 42 | yield 43 | else 44 | jsonapi_relationships 45 | end 46 | else 47 | # Rails 5 48 | jsonapi_resource_scope(SingletonResource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do 49 | if block_given? 50 | yield 51 | else 52 | jsonapi_relationships 53 | end 54 | end 55 | end 56 | # :nocov: 57 | end 58 | end 59 | 60 | def jsonapi_relationships(options = {}) 61 | res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(@resource_type)) 62 | res._relationships.each do |relationship_name, relationship| 63 | if relationship.is_a?(JSONAPI::Relationship::ToMany) 64 | jsonapi_links(relationship_name, options) 65 | jsonapi_related_resources(relationship_name, options) 66 | else 67 | jsonapi_link(relationship_name, options) 68 | jsonapi_related_resource(relationship_name, options) 69 | end 70 | end 71 | end 72 | 73 | def jsonapi_resources(*resources, &_block) 74 | @resource_type = resources.first 75 | res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(@resource_type)) 76 | 77 | options = resources.extract_options!.dup 78 | options[:controller] ||= @resource_type 79 | options.merge!(res.routing_resource_options) 80 | 81 | options[:param] = :id 82 | 83 | options[:path] = format_route(@resource_type) 84 | 85 | if res.resource_key_type == :uuid 86 | options[:constraints] ||= {} 87 | options[:constraints][:id] ||= /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/ 88 | end 89 | 90 | if options[:except] 91 | options[:except] = Array(options[:except]) 92 | options[:except] << :new unless options[:except].include?(:new) || options[:except].include?('new') 93 | options[:except] << :edit unless options[:except].include?(:edit) || options[:except].include?('edit') 94 | else 95 | options[:except] = [:new, :edit] 96 | end 97 | 98 | if res._immutable 99 | options[:except] << :create 100 | options[:except] << :update 101 | options[:except] << :destroy 102 | end 103 | 104 | resources @resource_type, options do 105 | # :nocov: 106 | if @scope.respond_to? :[]= 107 | # Rails 4 108 | @scope[:jsonapi_resource] = @resource_type 109 | if block_given? 110 | yield 111 | else 112 | jsonapi_relationships 113 | end 114 | else 115 | # Rails 5 116 | jsonapi_resource_scope(Resource.new(@resource_type, api_only?, @scope[:shallow], options), @resource_type) do 117 | if block_given? 118 | yield 119 | else 120 | jsonapi_relationships 121 | end 122 | end 123 | end 124 | # :nocov: 125 | end 126 | end 127 | 128 | def links_methods(options) 129 | default_methods = [:show, :create, :destroy, :update] 130 | if only = options[:only] 131 | Array(only).map(&:to_sym) 132 | elsif except = options[:except] 133 | default_methods - Array(except) 134 | else 135 | default_methods 136 | end 137 | end 138 | 139 | def jsonapi_link(*links) 140 | link_type = links.first 141 | formatted_relationship_name = format_route(link_type) 142 | options = links.extract_options!.dup 143 | 144 | res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix) 145 | options[:controller] ||= res._type.to_s 146 | 147 | methods = links_methods(options) 148 | 149 | if methods.include?(:show) 150 | match "relationships/#{formatted_relationship_name}", controller: options[:controller], 151 | action: 'show_relationship', relationship: link_type.to_s, via: [:get] 152 | end 153 | 154 | if res.mutable? 155 | if methods.include?(:update) 156 | match "relationships/#{formatted_relationship_name}", controller: options[:controller], 157 | action: 'update_relationship', relationship: link_type.to_s, via: [:put, :patch] 158 | end 159 | 160 | if methods.include?(:destroy) 161 | match "relationships/#{formatted_relationship_name}", controller: options[:controller], 162 | action: 'destroy_relationship', relationship: link_type.to_s, via: [:delete] 163 | end 164 | end 165 | end 166 | 167 | def jsonapi_links(*links) 168 | link_type = links.first 169 | formatted_relationship_name = format_route(link_type) 170 | options = links.extract_options!.dup 171 | 172 | res = JSONAPI::Resource.resource_for(resource_type_with_module_prefix) 173 | options[:controller] ||= res._type.to_s 174 | 175 | methods = links_methods(options) 176 | 177 | if methods.include?(:show) 178 | match "relationships/#{formatted_relationship_name}", controller: options[:controller], 179 | action: 'show_relationship', relationship: link_type.to_s, via: [:get] 180 | end 181 | 182 | if res.mutable? 183 | if methods.include?(:create) 184 | match "relationships/#{formatted_relationship_name}", controller: options[:controller], 185 | action: 'create_relationship', relationship: link_type.to_s, via: [:post] 186 | end 187 | 188 | if methods.include?(:update) 189 | match "relationships/#{formatted_relationship_name}", controller: options[:controller], 190 | action: 'update_relationship', relationship: link_type.to_s, via: [:put, :patch] 191 | end 192 | 193 | if methods.include?(:destroy) 194 | match "relationships/#{formatted_relationship_name}", controller: options[:controller], 195 | action: 'destroy_relationship', relationship: link_type.to_s, via: [:delete] 196 | end 197 | end 198 | end 199 | 200 | def jsonapi_related_resource(*relationship) 201 | source = JSONAPI::Resource.resource_for(resource_type_with_module_prefix) 202 | options = relationship.extract_options!.dup 203 | 204 | relationship_name = relationship.first 205 | relationship = source._relationships[relationship_name] 206 | 207 | formatted_relationship_name = format_route(relationship.name) 208 | 209 | if relationship.polymorphic? 210 | options[:controller] ||= relationship.class_name.underscore.pluralize 211 | else 212 | related_resource = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(relationship.class_name.underscore.pluralize)) 213 | options[:controller] ||= related_resource._type.to_s 214 | end 215 | 216 | match "#{formatted_relationship_name}", controller: options[:controller], 217 | relationship: relationship.name, source: resource_type_with_module_prefix(source._type), 218 | action: 'get_related_resource', via: [:get] 219 | end 220 | 221 | def jsonapi_related_resources(*relationship) 222 | source = JSONAPI::Resource.resource_for(resource_type_with_module_prefix) 223 | options = relationship.extract_options!.dup 224 | 225 | relationship_name = relationship.first 226 | relationship = source._relationships[relationship_name] 227 | 228 | formatted_relationship_name = format_route(relationship.name) 229 | related_resource = JSONAPI::Resource.resource_for(resource_type_with_module_prefix(relationship.class_name.underscore)) 230 | options[:controller] ||= related_resource._type.to_s 231 | 232 | match "#{formatted_relationship_name}", controller: options[:controller], 233 | relationship: relationship.name, source: resource_type_with_module_prefix(source._type), 234 | action: 'get_related_resources', via: [:get] 235 | end 236 | 237 | protected 238 | # :nocov: 239 | def jsonapi_resource_scope(resource, resource_type) #:nodoc: 240 | @scope = @scope.new(scope_level_resource: resource, jsonapi_resource: resource_type) 241 | 242 | controller(resource.resource_scope) { yield } 243 | ensure 244 | @scope = @scope.parent 245 | end 246 | # :nocov: 247 | private 248 | 249 | def resource_type_with_module_prefix(resource = nil) 250 | resource_name = resource || @scope[:jsonapi_resource] 251 | [@scope[:module], resource_name].compact.collect(&:to_s).join('/') 252 | end 253 | end 254 | end 255 | end 256 | end 257 | -------------------------------------------------------------------------------- /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 | has_many_relation: 20 | title: 'Relation exists' 21 | detail: "The relation to %{id} already exists." 22 | to_many_set_replacement_forbidden: 23 | title: 'Complete replacement forbidden' 24 | detail: 'Complete replacement forbidden for this relationship' 25 | invalid_filter_syntax: 26 | title: 'Invalid filters syntax' 27 | detail: "%{filters} is not a valid syntax for filtering." 28 | filter_not_allowed: 29 | title: "Filter not allowed" 30 | detail: "%{filter} is not allowed." 31 | invalid_filter_value: 32 | title: 'Invalid filter value' 33 | detail: "%{value} is not a valid value for %{filter}." 34 | invalid_field_value: 35 | title: 'Invalid field value' 36 | detail: "%{value} is not a valid value for %{field}." 37 | invalid_field_format: 38 | title: 'Invalid field format' 39 | detail: 'Fields must specify a type.' 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_include: 50 | title: 'Invalid field' 51 | detail: "%{relationship} is not a valid relationship of %{resource}" 52 | invalid_sort_criteria: 53 | title: 'Invalid sort criteria' 54 | detail: "%{sort_criteria} is not a valid sort criteria for %{resource}" 55 | parameters_not_allowed: 56 | title: 'Param not allowed' 57 | detail: "%{param} is not allowed." 58 | parameter_missing: 59 | title: 'Missing Parameter' 60 | detail: "The required parameter, %{param}, is missing." 61 | count_mismatch: 62 | title: 'Count to key mismatch' 63 | detail: 'The resource collection does not contain the same number of objects as the number of keys.' 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/request_benchmark.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../test_helper', __FILE__) 2 | 3 | class RequestBenchmark < IntegrationBenchmark 4 | def setup 5 | $test_user = Person.find(1) 6 | end 7 | 8 | def bench_large_index_request 9 | 10.times do 10 | get '/api/v2/books?include=bookComments,bookComments.author' 11 | assert_jsonapi_response 200 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/config/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: sqlite3 3 | database: test_db 4 | pool: 5 5 | timeout: 5000 6 | -------------------------------------------------------------------------------- /test/fixtures/author_details.yml: -------------------------------------------------------------------------------- 1 | a: 2 | id: 1 3 | person_id: 1 4 | author_stuff: blah blah 5 | 6 | b: 7 | id: 2 8 | person_id: 2 9 | author_stuff: blah blah blah -------------------------------------------------------------------------------- /test/fixtures/book_authors.yml: -------------------------------------------------------------------------------- 1 | book_author_1_1: 2 | book_id: 1 3 | person_id: 1 -------------------------------------------------------------------------------- /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 : (comment_id % 2) + 2 %> 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/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/comments.yml: -------------------------------------------------------------------------------- 1 | post_1_dumb_post: 2 | id: 1 3 | post_id: 1 4 | body: what a dumb post 5 | author_id: 1 6 | 7 | post_1_i_liked_it: 8 | id: 2 9 | post_id: 1 10 | body: i liked it 11 | author_id: 2 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: 2 18 | 19 | rogue_comment: 20 | body: Rogue Comment Here 21 | author_id: 3 22 | -------------------------------------------------------------------------------- /test/fixtures/comments_tags.yml: -------------------------------------------------------------------------------- 1 | post_1_dumb_post_whiny: 2 | comment_id: 1 3 | tag_id: 2 4 | 5 | post_1_dumb_post_short: 6 | comment_id: 1 7 | tag_id: 1 8 | 9 | post_1_i_liked_it_happy: 10 | comment_id: 2 11 | tag_id: 4 12 | 13 | post_1_i_liked_it_short: 14 | comment_id: 2 15 | tag_id: 1 16 | 17 | post_2_thanks_man_jr: 18 | comment_id: 3 19 | tag_id: 5 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/documents.yml: -------------------------------------------------------------------------------- 1 | document_1: 2 | id: 1 3 | name: Company Brochure 4 | -------------------------------------------------------------------------------- /test/fixtures/expense_entries.yml: -------------------------------------------------------------------------------- 1 | entry_1: 2 | id: 1 3 | currency_code: USD 4 | employee_id: 3 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: 3 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/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/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/people.yml: -------------------------------------------------------------------------------- 1 | a: 2 | id: 1 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: 2 10 | name: Fred Reader 11 | email: fred@xyz.fake 12 | date_joined: <%= DateTime.parse('2013-10-31 20:25:00 UTC +00:00') %> 13 | 14 | c: 15 | id: 3 16 | name: Lazy Author 17 | email: lazy@xyz.fake 18 | date_joined: <%= DateTime.parse('2013-10-31 21:25:00 UTC +00:00') %> 19 | 20 | d: 21 | id: 4 22 | name: Tag Crazy Author 23 | email: taggy@xyz.fake 24 | date_joined: <%= DateTime.parse('2013-11-30 4:20:00 UTC +00:00') %> 25 | 26 | e: 27 | id: 5 28 | name: Wilma Librarian 29 | email: lib@xyz.fake 30 | date_joined: <%= DateTime.parse('2013-11-30 4:20:00 UTC +00:00') %> 31 | book_admin: true -------------------------------------------------------------------------------- /test/fixtures/pictures.yml: -------------------------------------------------------------------------------- 1 | picture_1: 2 | id: 1 3 | name: enterprise_gizmo.jpg 4 | imageable_id: 1 5 | imageable_type: Product 6 | 7 | picture_2: 8 | id: 2 9 | name: company_brochure.jpg 10 | imageable_id: 1 11 | imageable_type: Document 12 | 13 | picture_3: 14 | id: 3 15 | name: group_photo.jpg 16 | -------------------------------------------------------------------------------- /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: 1 6 | 7 | post_2: 8 | id: 2 9 | title: JR Solves your serialization woes! 10 | body: Use JR 11 | author_id: 1 12 | section_id: 2 13 | 14 | post_3: 15 | id: 3 16 | title: Update This Later 17 | body: AAAA 18 | author_id: 3 19 | 20 | post_4: 21 | id: 4 22 | title: Delete This Later - Single 23 | body: AAAA 24 | author_id: 3 25 | 26 | post_5: 27 | id: 5 28 | title: Delete This Later - Multiple1 29 | body: AAAA 30 | author_id: 3 31 | 32 | post_6: 33 | id: 6 34 | title: Delete This Later - Multiple2 35 | body: AAAA 36 | author_id: 3 37 | 38 | post_7: 39 | id: 7 40 | title: Delete This Later - Single2 41 | body: AAAA 42 | author_id: 3 43 | 44 | post_8: 45 | id: 8 46 | title: Delete This Later - Multiple2-1 47 | body: AAAA 48 | author_id: 3 49 | 50 | post_9: 51 | id: 9 52 | title: Delete This Later - Multiple2-2 53 | body: AAAA 54 | author_id: 3 55 | 56 | post_10: 57 | id: 10 58 | title: Update This Later - Multiple 59 | body: AAAA 60 | author_id: 3 61 | 62 | post_11: 63 | id: 11 64 | title: JR How To 65 | body: Use JR to write API apps 66 | author_id: 1 67 | 68 | post_12: 69 | id: 12 70 | title: Tagged up post 1 71 | body: AAAA 72 | author_id: 4 73 | 74 | post_13: 75 | id: 13 76 | title: Tagged up post 2 77 | body: BBBB 78 | author_id: 4 79 | 80 | post_14: 81 | id: 14 82 | title: A First Post 83 | body: A First Post!!!!!!!!! 84 | author_id: 3 85 | 86 | post_15: 87 | id: 15 88 | title: AAAA First Post 89 | body: First!!!!!!!!! 90 | author_id: 3 91 | 92 | post_16: 93 | id: 16 94 | title: SDFGH 95 | body: Not First!!!! 96 | author_id: 3 97 | 98 | post_17: 99 | id: 17 100 | title: No Author!!!!!! 101 | body: This post has no Author 102 | author_id: -------------------------------------------------------------------------------- /test/fixtures/posts_tags.yml: -------------------------------------------------------------------------------- 1 | post_1_short: 2 | post_id: 1 3 | tag_id: 1 4 | 5 | post_1_whiny: 6 | post_id: 1 7 | tag_id: 2 8 | 9 | post_1_grumpy: 10 | post_id: 1 11 | tag_id: 3 12 | 13 | post_2_jr: 14 | post_id: 2 15 | tag_id: 5 16 | 17 | post_11_jr: 18 | post_id: 11 19 | tag_id: 5 20 | 21 | post_12_silly: 22 | post_id: 12 23 | tag_id: 6 24 | 25 | post_12_sleepy: 26 | post_id: 12 27 | tag_id: 7 28 | 29 | post_12_goofy: 30 | post_id: 12 31 | tag_id: 8 32 | 33 | post_12_wacky: 34 | post_id: 12 35 | tag_id: 9 36 | 37 | post_13_silly: 38 | post_id: 13 39 | tag_id: 6 40 | 41 | post_13_sleepy: 42 | post_id: 13 43 | tag_id: 7 44 | 45 | post_13_goofy: 46 | post_id: 13 47 | tag_id: 8 48 | 49 | post_13_wacky: 50 | post_id: 13 51 | tag_id: 9 52 | 53 | post_14_whiny: 54 | post_id: 14 55 | tag_id: 2 56 | 57 | post_14_grumpy: 58 | post_id: 14 59 | tag_id: 3 60 | -------------------------------------------------------------------------------- /test/fixtures/preferences.yml: -------------------------------------------------------------------------------- 1 | a: 2 | id: 1 3 | advanced_mode: false 4 | 5 | b: 6 | id: 2 7 | advanced_mode: false 8 | c: 9 | id: 3 10 | advanced_mode: false 11 | 12 | d: 13 | id: 4 14 | advanced_mode: false 15 | -------------------------------------------------------------------------------- /test/fixtures/products.yml: -------------------------------------------------------------------------------- 1 | product_1: 2 | id: 1 3 | name: Enterprise Gizmo 4 | -------------------------------------------------------------------------------- /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/sections.yml: -------------------------------------------------------------------------------- 1 | javascript: 2 | id: 1 3 | name: javascript 4 | 5 | ruby: 6 | id: 2 7 | name: ruby 8 | 9 | -------------------------------------------------------------------------------- /test/fixtures/tags.yml: -------------------------------------------------------------------------------- 1 | short_tag: 2 | id: 1 3 | name: short 4 | 5 | whiny_tag: 6 | id: 2 7 | name: whiny 8 | 9 | grumpy_tag: 10 | id: 3 11 | name: grumpy 12 | 13 | happy_tag: 14 | id: 4 15 | name: happy 16 | 17 | jr_tag: 18 | id: 5 19 | name: JR 20 | 21 | silly_tag: 22 | id: 6 23 | name: silly 24 | 25 | sleepy_tag: 26 | id: 7 27 | name: sleepy 28 | 29 | goofy_tag: 30 | id: 8 31 | name: goofy 32 | 33 | wacky_tag: 34 | id: 9 35 | name: wacky 36 | 37 | bad_tag: 38 | id: 10 39 | name: bad -------------------------------------------------------------------------------- /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: 1 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: 1 18 | -------------------------------------------------------------------------------- /test/fixtures/web_pages.yml: -------------------------------------------------------------------------------- 1 | web_page1: 2 | href: http://example.com 3 | link: http://link.example.com 4 | -------------------------------------------------------------------------------- /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/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.content_type 36 | # ct = @response.content_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 | return false if v2 == nil 9 | elsif v1.kind_of?(Hash) 10 | return false unless matches_hash?(v1, v2, options) 11 | elsif v1.kind_of?(Array) 12 | return false unless matches_array?(v1, v2, options) 13 | else 14 | return false unless v2 == v1 15 | end 16 | true 17 | end 18 | 19 | def matches_array?(array1, array2, options = {}) 20 | return false unless array1.kind_of?(Array) && array2.kind_of?(Array) 21 | if options[:exact] 22 | return false unless array1.size == array2.size 23 | end 24 | 25 | # order of items shouldn't matter: 26 | # ['a', 'b', 'c'], ['b', 'c', 'a'] -> true 27 | # 28 | # matched items should only be used once: 29 | # ['a', 'b', 'c'], ['a', 'a', 'a'] -> false 30 | # ['a', 'a', 'a'], ['a', 'b', 'c'] -> false 31 | matched = {} 32 | (0..(array1.size - 1)).each do |i| 33 | (0..(array2.size - 1)).each do |j| 34 | if !matched.has_value?(j.to_s) && matches_value?(array1[i], array2[j], options) 35 | matched[i.to_s] = j.to_s 36 | break 37 | end 38 | end 39 | return false unless matched.has_key?(i.to_s) 40 | end 41 | true 42 | end 43 | 44 | # options => {exact: true} # hashes must match exactly (i.e. have same number of key-value pairs that are all equal) 45 | def matches_hash?(hash1, hash2, options = {}) 46 | return false unless hash1.kind_of?(Hash) && hash2.kind_of?(Hash) 47 | if options[:exact] 48 | return false unless hash1.size == hash2.size 49 | end 50 | 51 | hash1 = hash1.deep_symbolize_keys 52 | hash2 = hash2.deep_symbolize_keys 53 | 54 | hash1.each do |k1, v1| 55 | return false unless hash2.has_key?(k1) && matches_value?(v1, hash2[k1], options) 56 | end 57 | true 58 | end 59 | end 60 | 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 | get '/flat_posts', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } 10 | assert_equal 200, status 11 | assert_equal "flat_posts", json_response["data"].first["type"] 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /test/integration/routes/routes_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | 3 | class RoutesTest < ActionDispatch::IntegrationTest 4 | 5 | def test_routing_post 6 | assert_routing({path: 'posts', method: :post}, 7 | {controller: 'posts', action: 'create'}) 8 | end 9 | 10 | def test_routing_patch 11 | assert_routing({path: '/posts/1', method: :patch}, 12 | {controller: 'posts', action: 'update', id: '1'}) 13 | end 14 | 15 | def test_routing_posts_show 16 | assert_routing({path: '/posts/1', method: :get}, 17 | {action: 'show', controller: 'posts', id: '1'}) 18 | end 19 | 20 | def test_routing_posts_links_author_show 21 | assert_routing({path: '/posts/1/relationships/author', method: :get}, 22 | {controller: 'posts', action: 'show_relationship', post_id: '1', relationship: 'author'}) 23 | end 24 | 25 | def test_routing_posts_links_author_destroy 26 | assert_routing({path: '/posts/1/relationships/author', method: :delete}, 27 | {controller: 'posts', action: 'destroy_relationship', post_id: '1', relationship: 'author'}) 28 | end 29 | 30 | def test_routing_posts_links_author_update 31 | assert_routing({path: '/posts/1/relationships/author', method: :patch}, 32 | {controller: 'posts', action: 'update_relationship', post_id: '1', relationship: 'author'}) 33 | end 34 | 35 | def test_routing_posts_links_tags_show 36 | assert_routing({path: '/posts/1/relationships/tags', method: :get}, 37 | {controller: 'posts', action: 'show_relationship', post_id: '1', relationship: 'tags'}) 38 | end 39 | 40 | def test_routing_posts_links_tags_destroy 41 | assert_routing({path: '/posts/1/relationships/tags', method: :delete}, 42 | {controller: 'posts', action: 'destroy_relationship', post_id: '1', relationship: 'tags'}) 43 | end 44 | 45 | def test_routing_posts_links_tags_create 46 | assert_routing({path: '/posts/1/relationships/tags', method: :post}, 47 | {controller: 'posts', action: 'create_relationship', post_id: '1', relationship: 'tags'}) 48 | end 49 | 50 | def test_routing_posts_links_tags_update_acts_as_set 51 | assert_routing({path: '/posts/1/relationships/tags', method: :patch}, 52 | {controller: 'posts', action: 'update_relationship', post_id: '1', relationship: 'tags'}) 53 | end 54 | 55 | def test_routing_uuid 56 | assert_routing({path: '/pets/v1/cats/f1a4d5f2-e77a-4d0a-acbb-ee0b98b3f6b5', method: :get}, 57 | {action: 'show', controller: 'pets/v1/cats', id: 'f1a4d5f2-e77a-4d0a-acbb-ee0b98b3f6b5'}) 58 | end 59 | 60 | # ToDo: refute this routing 61 | # def test_routing_uuid_bad_format 62 | # assert_routing({path: '/pets/v1/cats/f1a4d5f2-e77a-4d0a-acbb-ee0b9', method: :get}, 63 | # {action: 'show', controller: 'pets/v1/cats', id: 'f1a4d5f2-e77a-4d0a-acbb-ee0b98'}) 64 | # end 65 | 66 | # Polymorphic 67 | def test_routing_polymorphic_get_related_resource 68 | assert_routing( 69 | { 70 | path: '/pictures/1/imageable', 71 | method: :get 72 | }, 73 | { 74 | relationship: 'imageable', 75 | source: 'pictures', 76 | controller: 'imageables', 77 | action: 'get_related_resource', 78 | picture_id: '1' 79 | } 80 | ) 81 | end 82 | 83 | def test_routing_polymorphic_patch_related_resource 84 | assert_routing( 85 | { 86 | path: '/pictures/1/relationships/imageable', 87 | method: :patch 88 | }, 89 | { 90 | relationship: 'imageable', 91 | controller: 'pictures', 92 | action: 'update_relationship', 93 | picture_id: '1' 94 | } 95 | ) 96 | end 97 | 98 | def test_routing_polymorphic_delete_related_resource 99 | assert_routing( 100 | { 101 | path: '/pictures/1/relationships/imageable', 102 | method: :delete 103 | }, 104 | { 105 | relationship: 'imageable', 106 | controller: 'pictures', 107 | action: 'destroy_relationship', 108 | picture_id: '1' 109 | } 110 | ) 111 | end 112 | 113 | # V1 114 | def test_routing_v1_posts_show 115 | assert_routing({path: '/api/v1/posts/1', method: :get}, 116 | {action: 'show', controller: 'api/v1/posts', id: '1'}) 117 | end 118 | 119 | def test_routing_v1_posts_delete 120 | assert_routing({path: '/api/v1/posts/1', method: :delete}, 121 | {action: 'destroy', controller: 'api/v1/posts', id: '1'}) 122 | end 123 | 124 | def test_routing_v1_posts_links_writer_show 125 | assert_routing({path: '/api/v1/posts/1/relationships/writer', method: :get}, 126 | {controller: 'api/v1/posts', action: 'show_relationship', post_id: '1', relationship: 'writer'}) 127 | end 128 | 129 | # V2 130 | def test_routing_v2_posts_links_author_show 131 | assert_routing({path: '/api/v2/posts/1/relationships/author', method: :get}, 132 | {controller: 'api/v2/posts', action: 'show_relationship', post_id: '1', relationship: 'author'}) 133 | end 134 | 135 | def test_routing_v2_preferences_show 136 | assert_routing({path: '/api/v2/preferences', method: :get}, 137 | {action: 'show', controller: 'api/v2/preferences'}) 138 | end 139 | 140 | # V3 141 | def test_routing_v3_posts_show 142 | assert_routing({path: '/api/v3/posts/1', method: :get}, 143 | {action: 'show', controller: 'api/v3/posts', id: '1'}) 144 | end 145 | 146 | # V4 camelCase 147 | def test_routing_v4_posts_show 148 | assert_routing({path: '/api/v4/posts/1', method: :get}, 149 | {action: 'show', controller: 'api/v4/posts', id: '1'}) 150 | end 151 | 152 | def test_routing_v4_isoCurrencies_resources 153 | assert_routing({path: '/api/v4/isoCurrencies/USD', method: :get}, 154 | {action: 'show', controller: 'api/v4/iso_currencies', id: 'USD'}) 155 | end 156 | 157 | def test_routing_v4_expenseEntries_resources 158 | assert_routing({path: '/api/v4/expenseEntries/1', method: :get}, 159 | {action: 'show', controller: 'api/v4/expense_entries', id: '1'}) 160 | 161 | assert_routing({path: '/api/v4/expenseEntries/1/relationships/isoCurrency', method: :get}, 162 | {controller: 'api/v4/expense_entries', action: 'show_relationship', expense_entry_id: '1', relationship: 'iso_currency'}) 163 | end 164 | 165 | # V5 dasherized 166 | def test_routing_v5_posts_show 167 | assert_routing({path: '/api/v5/posts/1', method: :get}, 168 | {action: 'show', controller: 'api/v5/posts', id: '1'}) 169 | end 170 | 171 | def test_routing_v5_isoCurrencies_resources 172 | assert_routing({path: '/api/v5/iso-currencies/USD', method: :get}, 173 | {action: 'show', controller: 'api/v5/iso_currencies', id: 'USD'}) 174 | end 175 | 176 | def test_routing_v5_expenseEntries_resources 177 | assert_routing({path: '/api/v5/expense-entries/1', method: :get}, 178 | {action: 'show', controller: 'api/v5/expense_entries', id: '1'}) 179 | 180 | assert_routing({path: '/api/v5/expense-entries/1/relationships/iso-currency', method: :get}, 181 | {controller: 'api/v5/expense_entries', action: 'show_relationship', expense_entry_id: '1', relationship: 'iso_currency'}) 182 | end 183 | 184 | def test_routing_authors_show 185 | assert_routing({path: '/api/v5/authors/1', method: :get}, 186 | {action: 'show', controller: 'api/v5/authors', id: '1'}) 187 | end 188 | 189 | def test_routing_author_links_posts_create_not_acts_as_set 190 | assert_routing({path: '/api/v5/authors/1/relationships/posts', method: :post}, 191 | {controller: 'api/v5/authors', action: 'create_relationship', author_id: '1', relationship: 'posts'}) 192 | end 193 | 194 | #primary_key 195 | def test_routing_primary_key_jsonapi_resources 196 | assert_routing({path: '/iso_currencies/USD', method: :get}, 197 | {action: 'show', controller: 'iso_currencies', id: 'USD'}) 198 | end 199 | 200 | # ToDo: Refute routing 201 | # def test_routing_v3_posts_delete 202 | # assert_routing({ path: '/api/v3/posts/1', method: :delete }, 203 | # {action: 'destroy', controller: 'api/v3/posts', id: '1'}) 204 | # end 205 | 206 | # def test_routing_posts_links_author_except_destroy 207 | # assert_routing({ path: '/api/v3/posts/1/relationships/author', method: :delete }, 208 | # { controller: 'api/v3/posts', action: 'destroy_relationship', post_id: '1', relationship: 'author' }) 209 | # end 210 | # 211 | # def test_routing_posts_links_tags_only_create_show 212 | # assert_routing({ path: '/api/v3/posts/1/relationships/tags/1,2', method: :delete }, 213 | # { controller: 'api/v3/posts', action: 'destroy_relationship', post_id: '1', keys: '1,2', relationship: 'tags' }) 214 | # end 215 | 216 | # Test that non acts as set to_many relationship update route is not created 217 | 218 | end 219 | -------------------------------------------------------------------------------- /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: { filter: { id: "1,2" }, 6 | include: "author", 7 | fields: { posts: "author", people: "email" } }, 8 | headers: { 'Accept' => JSONAPI::MEDIA_TYPE } 9 | assert_response :success 10 | assert_equal 2, json_response["data"].size 11 | assert json_response["data"][0]["relationships"].key?("author") 12 | assert json_response["included"][0]["attributes"].keys == ["email"] 13 | end 14 | 15 | def test_fields_for_parent_class 16 | get "/firms", params: { fields: { companies: "name" } }, headers: { 17 | 'Accept' => JSONAPI::MEDIA_TYPE 18 | } 19 | assert_equal json_response["data"][0]["attributes"].keys, ["name"] 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /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/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | 3 | # To run tests with coverage: 4 | # COVERAGE=true bundle exec rake test 5 | 6 | # To test on a specific rails version use this: 7 | # export RAILS_VERSION=4.2.6; bundle update rails; bundle exec rake test 8 | # export RAILS_VERSION=5.0.0.rc1; bundle update rails; bundle exec rake test 9 | 10 | # We are no longer having Travis test Rails 4.0.x., but you can try it with: 11 | # export RAILS_VERSION=4.0.0; bundle update rails; bundle exec rake test 12 | 13 | # To Switch rails versions and run a particular test order: 14 | # export RAILS_VERSION=4.2.6; bundle update rails; bundle exec rake TESTOPTS="--seed=39333" test 15 | 16 | if ENV['COVERAGE'] 17 | SimpleCov.start do 18 | end 19 | end 20 | 21 | require 'rails/all' 22 | require 'rails/test_help' 23 | require 'minitest/mock' 24 | require 'jsonapi-resources' 25 | require 'pry' 26 | 27 | require File.expand_path('../helpers/value_matchers', __FILE__) 28 | require File.expand_path('../helpers/assertions', __FILE__) 29 | require File.expand_path('../helpers/functional_helpers', __FILE__) 30 | 31 | Rails.env = 'test' 32 | 33 | I18n.load_path += Dir[File.expand_path("../../locales/*.yml", __FILE__)] 34 | I18n.enforce_available_locales = false 35 | 36 | JSONAPI.configure do |config| 37 | config.json_key_format = :camelized_key 38 | end 39 | 40 | puts "Testing With RAILS VERSION #{Rails.version}" 41 | 42 | class TestApp < Rails::Application 43 | config.eager_load = false 44 | config.root = File.dirname(__FILE__) 45 | config.session_store :cookie_store, key: 'session' 46 | config.secret_key_base = 'secret' 47 | 48 | #Raise errors on unsupported parameters 49 | config.action_controller.action_on_unpermitted_parameters = :raise 50 | 51 | ActiveRecord::Schema.verbose = false 52 | config.active_record.schema_format = :none 53 | config.active_support.test_order = :random 54 | 55 | # Turn off millisecond precision to maintain Rails 4.0 and 4.1 compatibility in test results 56 | ActiveSupport::JSON::Encoding.time_precision = 0 if Rails::VERSION::MAJOR >= 4 && Rails::VERSION::MINOR >= 1 57 | 58 | if Rails::VERSION::MAJOR >= 5 59 | config.active_support.halt_callback_chains_on_return_false = false 60 | config.active_record.time_zone_aware_types = [:time, :datetime] 61 | end 62 | end 63 | 64 | module MyEngine 65 | class Engine < ::Rails::Engine 66 | isolate_namespace MyEngine 67 | end 68 | end 69 | 70 | # Patch RAILS 4.0 to not use millisecond precision 71 | if Rails::VERSION::MAJOR >= 4 && Rails::VERSION::MINOR < 1 72 | module ActiveSupport 73 | class TimeWithZone 74 | def as_json(options = nil) 75 | if ActiveSupport::JSON::Encoding.use_standard_json_time_format 76 | xmlschema 77 | else 78 | %(#{time.strftime("%Y/%m/%d %H:%M:%S")} #{formatted_offset(false)}) 79 | end 80 | end 81 | end 82 | end 83 | end 84 | 85 | # Monkeypatch ActionController::TestCase to delete the RAW_POST_DATA on subsequent calls in the same test. 86 | if Rails::VERSION::MAJOR >= 5 87 | module ClearRawPostHeader 88 | def process(action, *args) 89 | @request.delete_header 'RAW_POST_DATA' 90 | super 91 | end 92 | end 93 | 94 | class ActionController::TestCase 95 | prepend ClearRawPostHeader 96 | end 97 | end 98 | 99 | # Tests are now using the rails 5 format for the http methods. So for rails 4 we will simply convert them back 100 | # in a standard way. 101 | if Rails::VERSION::MAJOR < 5 102 | module Rails4ActionControllerProcess 103 | def process(*args) 104 | if args[2] && args[2][:params] 105 | args[2] = args[2][:params] 106 | end 107 | super *args 108 | end 109 | end 110 | class ActionController::TestCase 111 | prepend Rails4ActionControllerProcess 112 | end 113 | 114 | module ActionDispatch 115 | module Integration #:nodoc: 116 | module Rails4IntegrationProcess 117 | def process(method, path, parameters = nil, headers_or_env = nil) 118 | params = parameters.nil? ? nil : parameters[:params] 119 | headers = parameters.nil? ? nil : parameters[:headers] 120 | super method, path, params, headers 121 | end 122 | end 123 | 124 | class Session 125 | prepend Rails4IntegrationProcess 126 | end 127 | end 128 | end 129 | end 130 | 131 | # Patch to allow :api_json mime type to be treated as JSON 132 | # Otherwise it is run through `to_query` and empty arrays are dropped. 133 | if Rails::VERSION::MAJOR >= 5 134 | module ActionController 135 | class TestRequest < ActionDispatch::TestRequest 136 | def assign_parameters(routes, controller_path, action, parameters, generated_path, query_string_keys) 137 | non_path_parameters = {} 138 | path_parameters = {} 139 | 140 | parameters.each do |key, value| 141 | if query_string_keys.include?(key) 142 | non_path_parameters[key] = value 143 | else 144 | if value.is_a?(Array) 145 | value = value.map(&:to_param) 146 | else 147 | value = value.to_param 148 | end 149 | 150 | path_parameters[key] = value 151 | end 152 | end 153 | 154 | if get? 155 | if self.query_string.blank? 156 | self.query_string = non_path_parameters.to_query 157 | end 158 | else 159 | if ENCODER.should_multipart?(non_path_parameters) 160 | self.content_type = ENCODER.content_type 161 | data = ENCODER.build_multipart non_path_parameters 162 | else 163 | fetch_header('CONTENT_TYPE') do |k| 164 | set_header k, 'application/x-www-form-urlencoded' 165 | end 166 | 167 | # parser = ActionDispatch::Http::Parameters::DEFAULT_PARSERS[Mime::Type.lookup(fetch_header('CONTENT_TYPE'))] 168 | 169 | case content_mime_type.to_sym 170 | when nil 171 | raise "Unknown Content-Type: #{content_type}" 172 | when :json, :api_json 173 | data = ActiveSupport::JSON.encode(non_path_parameters) 174 | when :xml 175 | data = non_path_parameters.to_xml 176 | when :url_encoded_form 177 | data = non_path_parameters.to_query 178 | else 179 | @custom_param_parsers[content_mime_type] = ->(_) { non_path_parameters } 180 | data = non_path_parameters.to_query 181 | end 182 | end 183 | 184 | set_header 'CONTENT_LENGTH', data.length.to_s 185 | set_header 'rack.input', StringIO.new(data) 186 | end 187 | 188 | fetch_header("PATH_INFO") do |k| 189 | set_header k, generated_path 190 | end 191 | path_parameters[:controller] = controller_path 192 | path_parameters[:action] = action 193 | 194 | self.path_parameters = path_parameters 195 | end 196 | end 197 | end 198 | end 199 | 200 | def count_queries(&block) 201 | @query_count = 0 202 | @queries = [] 203 | ActiveSupport::Notifications.subscribe('sql.active_record') do |name, started, finished, unique_id, payload| 204 | @query_count = @query_count + 1 205 | @queries.push payload[:sql] 206 | end 207 | yield block 208 | ActiveSupport::Notifications.unsubscribe('sql.active_record') 209 | @query_count 210 | end 211 | 212 | def assert_query_count(expected, msg = nil) 213 | msg = message(msg) { 214 | "Expected #{expected} queries, ran #{@query_count} queries" 215 | } 216 | show_queries unless expected == @query_count 217 | assert expected == @query_count, msg 218 | end 219 | 220 | def show_queries 221 | @queries.each_with_index do |query, index| 222 | puts "sql[#{index}]: #{query}" 223 | end 224 | end 225 | 226 | TestApp.initialize! 227 | 228 | require File.expand_path('../fixtures/active_record', __FILE__) 229 | 230 | module Pets 231 | module V1 232 | class CatsController < JSONAPI::ResourceController 233 | 234 | end 235 | 236 | class CatResource < JSONAPI::Resource 237 | attribute :name 238 | attribute :breed 239 | 240 | key_type :uuid 241 | end 242 | end 243 | end 244 | 245 | JSONAPI.configuration.route_format = :underscored_route 246 | TestApp.routes.draw do 247 | jsonapi_resources :people 248 | jsonapi_resources :special_people 249 | jsonapi_resources :comments 250 | jsonapi_resources :firms 251 | jsonapi_resources :tags 252 | jsonapi_resources :posts do 253 | jsonapi_relationships 254 | jsonapi_links :special_tags 255 | end 256 | jsonapi_resources :sections 257 | jsonapi_resources :iso_currencies 258 | jsonapi_resources :expense_entries 259 | jsonapi_resources :breeds 260 | jsonapi_resources :planets 261 | jsonapi_resources :planet_types 262 | jsonapi_resources :moons 263 | jsonapi_resources :craters 264 | jsonapi_resources :preferences 265 | jsonapi_resources :facts 266 | jsonapi_resources :categories 267 | jsonapi_resources :pictures 268 | jsonapi_resources :documents 269 | jsonapi_resources :products 270 | jsonapi_resources :vehicles 271 | jsonapi_resources :cars 272 | jsonapi_resources :boats 273 | jsonapi_resources :flat_posts 274 | 275 | jsonapi_resources :books 276 | jsonapi_resources :authors 277 | 278 | namespace :api do 279 | namespace :v1 do 280 | jsonapi_resources :people 281 | jsonapi_resources :comments 282 | jsonapi_resources :tags 283 | jsonapi_resources :posts 284 | jsonapi_resources :sections 285 | jsonapi_resources :iso_currencies 286 | jsonapi_resources :expense_entries 287 | jsonapi_resources :breeds 288 | jsonapi_resources :planets 289 | jsonapi_resources :planet_types 290 | jsonapi_resources :moons 291 | jsonapi_resources :craters 292 | jsonapi_resources :preferences 293 | jsonapi_resources :likes 294 | end 295 | 296 | JSONAPI.configuration.route_format = :underscored_route 297 | namespace :v2 do 298 | jsonapi_resources :posts do 299 | jsonapi_link :author, except: :destroy 300 | end 301 | 302 | jsonapi_resource :preferences, except: [:create, :destroy] 303 | 304 | jsonapi_resources :books 305 | jsonapi_resources :book_comments 306 | end 307 | 308 | namespace :v3 do 309 | jsonapi_resource :preferences do 310 | # Intentionally empty block to skip relationship urls 311 | end 312 | 313 | jsonapi_resources :posts, except: [:destroy] do 314 | jsonapi_link :author, except: [:destroy] 315 | jsonapi_links :tags, only: [:show, :create] 316 | end 317 | end 318 | 319 | JSONAPI.configuration.route_format = :camelized_route 320 | namespace :v4 do 321 | jsonapi_resources :posts do 322 | end 323 | 324 | jsonapi_resources :expense_entries do 325 | jsonapi_link :iso_currency 326 | jsonapi_related_resource :iso_currency 327 | end 328 | 329 | jsonapi_resources :iso_currencies do 330 | end 331 | 332 | jsonapi_resources :books 333 | end 334 | 335 | JSONAPI.configuration.route_format = :dasherized_route 336 | namespace :v5 do 337 | jsonapi_resources :posts do 338 | end 339 | 340 | jsonapi_resources :authors 341 | jsonapi_resources :expense_entries 342 | jsonapi_resources :iso_currencies 343 | 344 | jsonapi_resources :employees 345 | 346 | end 347 | JSONAPI.configuration.route_format = :underscored_route 348 | 349 | JSONAPI.configuration.route_format = :dasherized_route 350 | namespace :v6 do 351 | jsonapi_resources :customers 352 | jsonapi_resources :purchase_orders 353 | jsonapi_resources :line_items 354 | end 355 | JSONAPI.configuration.route_format = :underscored_route 356 | 357 | namespace :v7 do 358 | jsonapi_resources :customers 359 | jsonapi_resources :purchase_orders 360 | jsonapi_resources :line_items 361 | jsonapi_resources :categories 362 | 363 | jsonapi_resources :clients 364 | end 365 | 366 | namespace :v8 do 367 | jsonapi_resources :numeros_telefone 368 | end 369 | end 370 | 371 | namespace :admin_api do 372 | namespace :v1 do 373 | jsonapi_resources :people 374 | end 375 | end 376 | 377 | namespace :dasherized_namespace, path: 'dasherized-namespace' do 378 | namespace :v1 do 379 | jsonapi_resources :people 380 | end 381 | end 382 | 383 | namespace :pets do 384 | namespace :v1 do 385 | jsonapi_resources :cats 386 | end 387 | end 388 | 389 | mount MyEngine::Engine => "/boomshaka", as: :my_engine 390 | end 391 | 392 | MyEngine::Engine.routes.draw do 393 | namespace :api do 394 | namespace :v1 do 395 | jsonapi_resources :people 396 | end 397 | end 398 | 399 | namespace :admin_api do 400 | namespace :v1 do 401 | jsonapi_resources :people 402 | end 403 | end 404 | 405 | namespace :dasherized_namespace, path: 'dasherized-namespace' do 406 | namespace :v1 do 407 | jsonapi_resources :people 408 | end 409 | end 410 | end 411 | 412 | # Ensure backward compatibility with Minitest 4 413 | Minitest::Test = MiniTest::Unit::TestCase unless defined?(Minitest::Test) 414 | 415 | class Minitest::Test 416 | include Helpers::Assertions 417 | include Helpers::ValueMatchers 418 | include Helpers::FunctionalHelpers 419 | include ActiveRecord::TestFixtures 420 | 421 | def run_in_transaction? 422 | true 423 | end 424 | 425 | self.fixture_path = "#{Rails.root}/fixtures" 426 | fixtures :all 427 | end 428 | 429 | class ActiveSupport::TestCase 430 | self.fixture_path = "#{Rails.root}/fixtures" 431 | fixtures :all 432 | setup do 433 | @routes = TestApp.routes 434 | end 435 | end 436 | 437 | class ActionDispatch::IntegrationTest 438 | self.fixture_path = "#{Rails.root}/fixtures" 439 | fixtures :all 440 | 441 | def assert_jsonapi_response(expected_status) 442 | assert_equal JSONAPI::MEDIA_TYPE, response.content_type 443 | assert_equal expected_status, status 444 | end 445 | end 446 | 447 | class IntegrationBenchmark < ActionDispatch::IntegrationTest 448 | def self.runnable_methods 449 | methods_matching(/^bench_/) 450 | end 451 | 452 | def self.run_one_method(klass, method_name, reporter) 453 | Benchmark.bm(method_name.length) do |job| 454 | job.report(method_name) do 455 | super(klass, method_name, reporter) 456 | end 457 | end 458 | end 459 | end 460 | 461 | class UpperCamelizedKeyFormatter < JSONAPI::KeyFormatter 462 | class << self 463 | def format(key) 464 | super.camelize(:upper) 465 | end 466 | 467 | def unformat(formatted_key) 468 | formatted_key.to_s.underscore 469 | end 470 | end 471 | end 472 | 473 | class DateWithTimezoneValueFormatter < JSONAPI::ValueFormatter 474 | class << self 475 | def format(raw_value) 476 | raw_value.in_time_zone('Eastern Time (US & Canada)').to_s 477 | end 478 | end 479 | end 480 | 481 | class DateValueFormatter < JSONAPI::ValueFormatter 482 | class << self 483 | def format(raw_value) 484 | raw_value.strftime('%m/%d/%Y') 485 | end 486 | end 487 | end 488 | 489 | class TitleValueFormatter < JSONAPI::ValueFormatter 490 | class << self 491 | def format(raw_value) 492 | super(raw_value).titlecase 493 | end 494 | 495 | def unformat(value) 496 | value.to_s.downcase 497 | end 498 | end 499 | end 500 | -------------------------------------------------------------------------------- /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 | belongs_to :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 JSONAPIRequestTest < ActiveSupport::TestCase 18 | def test_parse_includes_underscored 19 | params = ActionController::Parameters.new( 20 | { 21 | controller: 'expense_entries', 22 | action: 'index', 23 | include: 'iso_currency' 24 | } 25 | ) 26 | 27 | request = JSONAPI::RequestParser.new( 28 | params, 29 | { 30 | context: nil, 31 | key_formatter: JSONAPI::Formatter.formatter_for(:underscored_key) 32 | } 33 | ) 34 | 35 | assert request.errors.empty? 36 | end 37 | 38 | def test_parse_dasherized_with_dasherized_include 39 | params = ActionController::Parameters.new( 40 | { 41 | controller: 'expense_entries', 42 | action: 'index', 43 | include: 'iso-currency' 44 | } 45 | ) 46 | 47 | request = JSONAPI::RequestParser.new( 48 | params, 49 | { 50 | context: nil, 51 | key_formatter: JSONAPI::Formatter.formatter_for(:dasherized_key) 52 | } 53 | ) 54 | 55 | assert request.errors.empty? 56 | end 57 | 58 | def test_parse_dasherized_with_underscored_include 59 | params = ActionController::Parameters.new( 60 | { 61 | controller: 'expense_entries', 62 | action: 'index', 63 | include: 'iso_currency' 64 | } 65 | ) 66 | 67 | request = JSONAPI::RequestParser.new( 68 | params, 69 | { 70 | context: nil, 71 | key_formatter: JSONAPI::Formatter.formatter_for(:dasherized_key) 72 | } 73 | ) 74 | 75 | refute request.errors.empty? 76 | assert_equal 'iso_currency is not a valid relationship of expense-entries', request.errors[0].detail 77 | end 78 | 79 | def test_parse_fields_underscored 80 | params = ActionController::Parameters.new( 81 | { 82 | controller: 'expense_entries', 83 | action: 'index', 84 | fields: {expense_entries: 'iso_currency'} 85 | } 86 | ) 87 | 88 | request = JSONAPI::RequestParser.new( 89 | params, 90 | { 91 | context: nil, 92 | key_formatter: JSONAPI::Formatter.formatter_for(:underscored_key) 93 | } 94 | ) 95 | 96 | assert request.errors.empty? 97 | end 98 | 99 | def test_parse_dasherized_with_dasherized_fields 100 | params = ActionController::Parameters.new( 101 | { 102 | controller: 'expense_entries', 103 | action: 'index', 104 | fields: { 105 | 'expense-entries' => 'iso-currency' 106 | } 107 | } 108 | ) 109 | 110 | request = JSONAPI::RequestParser.new( 111 | params, 112 | { 113 | context: nil, 114 | key_formatter: JSONAPI::Formatter.formatter_for(:dasherized_key) 115 | } 116 | ) 117 | 118 | assert request.errors.empty? 119 | end 120 | 121 | def test_parse_dasherized_with_underscored_fields 122 | params = ActionController::Parameters.new( 123 | { 124 | controller: 'expense_entries', 125 | action: 'index', 126 | fields: { 127 | 'expense-entries' => 'iso_currency' 128 | } 129 | } 130 | ) 131 | 132 | request = JSONAPI::RequestParser.new( 133 | params, 134 | { 135 | context: nil, 136 | key_formatter: JSONAPI::Formatter.formatter_for(:dasherized_key) 137 | } 138 | ) 139 | 140 | refute request.errors.empty? 141 | assert_equal 'iso_currency is not a valid field for expense-entries.', request.errors[0].detail 142 | end 143 | 144 | def test_parse_dasherized_with_underscored_resource 145 | params = ActionController::Parameters.new( 146 | { 147 | controller: 'expense_entries', 148 | action: 'index', 149 | fields: { 150 | 'expense_entries' => 'iso-currency' 151 | } 152 | } 153 | ) 154 | 155 | request = JSONAPI::RequestParser.new( 156 | params, 157 | { 158 | context: nil, 159 | key_formatter: JSONAPI::Formatter.formatter_for(:dasherized_key) 160 | } 161 | ) 162 | 163 | refute request.errors.empty? 164 | assert_equal 'expense_entries is not a valid resource.', request.errors[0].detail 165 | end 166 | 167 | def test_parse_filters_with_valid_filters 168 | setup_request 169 | @request.parse_filters({name: 'Whiskers'}) 170 | assert_equal(@request.filters[:name], 'Whiskers') 171 | assert_equal(@request.errors, []) 172 | end 173 | 174 | def test_parse_filters_with_non_valid_filter 175 | setup_request 176 | @request.parse_filters({breed: 'Whiskers'}) # breed is not a set filter 177 | assert_equal(@request.filters, {}) 178 | assert_equal(@request.errors.count, 1) 179 | assert_equal(@request.errors.first.title, "Filter not allowed") 180 | end 181 | 182 | def test_parse_filters_with_no_filters 183 | setup_request 184 | @request.parse_filters(nil) 185 | assert_equal(@request.filters, {}) 186 | assert_equal(@request.errors, []) 187 | end 188 | 189 | def test_parse_filters_with_invalid_filters_param 190 | setup_request 191 | @request.parse_filters('noeach') # String does not implement #each 192 | assert_equal(@request.filters, {}) 193 | assert_equal(@request.errors.count, 1) 194 | assert_equal(@request.errors.first.title, "Invalid filters syntax") 195 | end 196 | 197 | def test_parse_sort_with_valid_sorts 198 | setup_request 199 | @request.parse_sort_criteria("-name") 200 | assert_equal(@request.filters, {}) 201 | assert_equal(@request.errors, []) 202 | assert_equal(@request.sort_criteria, [{:field=>"name", :direction=>:desc}]) 203 | end 204 | 205 | def test_parse_sort_with_relationships 206 | setup_request 207 | @request.parse_sort_criteria("-mother.name") 208 | assert_equal(@request.filters, {}) 209 | assert_equal(@request.errors, []) 210 | assert_equal(@request.sort_criteria, [{:field=>"mother.name", :direction=>:desc}]) 211 | end 212 | 213 | private 214 | 215 | def setup_request 216 | @request = JSONAPI::RequestParser.new 217 | @request.resource_klass = CatResource 218 | end 219 | end 220 | -------------------------------------------------------------------------------- /test/unit/operation/operation_dispatcher_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | 3 | class OperationDispatcherTest < Minitest::Test 4 | def setup 5 | betax = Planet.find(5) 6 | betay = Planet.find(6) 7 | betaz = Planet.find(7) 8 | unknown = PlanetType.find(5) 9 | end 10 | 11 | def test_create_single_resource 12 | op = JSONAPI::OperationDispatcher.new 13 | 14 | count = Planet.count 15 | 16 | operations = [ 17 | JSONAPI::Operation.new(:create_resource, PlanetResource, data: {attributes: {'name' => 'earth', 'description' => 'The best planet ever.'}}) 18 | ] 19 | 20 | operation_results = op.process(operations) 21 | 22 | assert_kind_of(JSONAPI::OperationResults, operation_results) 23 | assert_equal(:created, operation_results.results[0].code) 24 | assert_equal(operation_results.results.size, 1) 25 | assert_equal(Planet.count, count + 1) 26 | end 27 | 28 | def test_create_multiple_resources 29 | op = JSONAPI::OperationDispatcher.new 30 | 31 | count = Planet.count 32 | 33 | operations = [ 34 | JSONAPI::Operation.new(:create_resource, PlanetResource, data: {attributes: {'name' => 'earth', 'description' => 'The best planet for life.'}}), 35 | JSONAPI::Operation.new(:create_resource, PlanetResource, data: {attributes: {'name' => 'mars', 'description' => 'The red planet.'}}), 36 | JSONAPI::Operation.new(:create_resource, PlanetResource, data: {attributes: {'name' => 'venus', 'description' => 'A very hot planet.'}}) 37 | ] 38 | 39 | operation_results = op.process(operations) 40 | 41 | assert_kind_of(JSONAPI::OperationResults, operation_results) 42 | assert_equal(operation_results.results.size, 3) 43 | assert_equal(Planet.count, count + 3) 44 | end 45 | 46 | def test_replace_to_one_relationship 47 | op = JSONAPI::OperationDispatcher.new 48 | 49 | saturn = Planet.find(1) 50 | gas_giant = PlanetType.find(1) 51 | planetoid = PlanetType.find(2) 52 | assert_equal(saturn.planet_type_id, planetoid.id) 53 | 54 | operations = [ 55 | JSONAPI::Operation.new(:replace_to_one_relationship, 56 | PlanetResource, 57 | { 58 | resource_id: saturn.id, 59 | relationship_type: :planet_type, 60 | key_value: gas_giant.id 61 | } 62 | ) 63 | ] 64 | 65 | operation_results = op.process(operations) 66 | 67 | assert_kind_of(JSONAPI::OperationResults, operation_results) 68 | assert_kind_of(JSONAPI::OperationResult, operation_results.results[0]) 69 | assert_equal(:no_content, operation_results.results[0].code) 70 | 71 | saturn.reload 72 | assert_equal(saturn.planet_type_id, gas_giant.id) 73 | 74 | # Remove link 75 | operations = [ 76 | JSONAPI::Operation.new(:replace_to_one_relationship, 77 | PlanetResource, 78 | { 79 | resource_id: saturn.id, 80 | relationship_type: :planet_type, 81 | key_value: nil 82 | } 83 | ) 84 | ] 85 | 86 | op.process(operations) 87 | saturn.reload 88 | assert_equal(saturn.planet_type_id, nil) 89 | 90 | # Reset 91 | operations = [ 92 | JSONAPI::Operation.new(:replace_to_one_relationship, 93 | PlanetResource, 94 | { 95 | resource_id: saturn.id, 96 | relationship_type: :planet_type, 97 | key_value: 5 98 | } 99 | ) 100 | ] 101 | 102 | op.process(operations) 103 | saturn.reload 104 | assert_equal(saturn.planet_type_id, 5) 105 | end 106 | 107 | def test_create_to_many_relationship 108 | op = JSONAPI::OperationDispatcher.new 109 | 110 | betax = Planet.find(5) 111 | betay = Planet.find(6) 112 | betaz = Planet.find(7) 113 | gas_giant = PlanetType.find(1) 114 | unknown = PlanetType.find(5) 115 | betax.planet_type_id = unknown.id 116 | betay.planet_type_id = unknown.id 117 | betaz.planet_type_id = unknown.id 118 | betax.save! 119 | betay.save! 120 | betaz.save! 121 | 122 | operations = [ 123 | JSONAPI::Operation.new(:create_to_many_relationship, 124 | PlanetTypeResource, 125 | { 126 | resource_id: gas_giant.id, 127 | relationship_type: :planets, 128 | data: [betax.id, betay.id, betaz.id] 129 | } 130 | ) 131 | ] 132 | 133 | op.process(operations) 134 | 135 | betax.reload 136 | betay.reload 137 | betaz.reload 138 | 139 | assert_equal(betax.planet_type_id, gas_giant.id) 140 | assert_equal(betay.planet_type_id, gas_giant.id) 141 | assert_equal(betaz.planet_type_id, gas_giant.id) 142 | 143 | # Reset 144 | betax.planet_type_id = unknown.id 145 | betay.planet_type_id = unknown.id 146 | betaz.planet_type_id = unknown.id 147 | betax.save! 148 | betay.save! 149 | betaz.save! 150 | end 151 | 152 | def test_replace_to_many_relationship 153 | op = JSONAPI::OperationDispatcher.new 154 | 155 | betax = Planet.find(5) 156 | betay = Planet.find(6) 157 | betaz = Planet.find(7) 158 | gas_giant = PlanetType.find(1) 159 | unknown = PlanetType.find(5) 160 | betax.planet_type_id = unknown.id 161 | betay.planet_type_id = unknown.id 162 | betaz.planet_type_id = unknown.id 163 | betax.save! 164 | betay.save! 165 | betaz.save! 166 | 167 | operations = [ 168 | JSONAPI::Operation.new(:replace_to_many_relationship, 169 | PlanetTypeResource, 170 | { 171 | resource_id: gas_giant.id, 172 | relationship_type: :planets, 173 | data: [betax.id, betay.id, betaz.id] 174 | } 175 | ) 176 | ] 177 | 178 | op.process(operations) 179 | 180 | betax.reload 181 | betay.reload 182 | betaz.reload 183 | 184 | assert_equal(betax.planet_type_id, gas_giant.id) 185 | assert_equal(betay.planet_type_id, gas_giant.id) 186 | assert_equal(betaz.planet_type_id, gas_giant.id) 187 | 188 | # Reset 189 | betax.planet_type_id = unknown.id 190 | betay.planet_type_id = unknown.id 191 | betaz.planet_type_id = unknown.id 192 | betax.save! 193 | betay.save! 194 | betaz.save! 195 | end 196 | 197 | def test_replace_attributes 198 | op = JSONAPI::OperationDispatcher.new 199 | 200 | count = Planet.count 201 | saturn = Planet.find(1) 202 | assert_equal(saturn.name, 'Satern') 203 | 204 | operations = [ 205 | JSONAPI::Operation.new(:replace_fields, 206 | PlanetResource, 207 | { 208 | resource_id: 1, 209 | data: {attributes: {'name' => 'saturn'}} 210 | } 211 | ) 212 | ] 213 | 214 | operation_results = op.process(operations) 215 | 216 | assert_kind_of(JSONAPI::OperationResults, operation_results) 217 | assert_equal(operation_results.results.size, 1) 218 | 219 | assert_kind_of(JSONAPI::ResourceOperationResult, operation_results.results[0]) 220 | assert_equal(:ok, operation_results.results[0].code) 221 | 222 | saturn = Planet.find(1) 223 | 224 | assert_equal(saturn.name, 'saturn') 225 | 226 | assert_equal(Planet.count, count) 227 | end 228 | 229 | def test_remove_resource 230 | op = JSONAPI::OperationDispatcher.new 231 | 232 | count = Planet.count 233 | makemake = Planet.find(2) 234 | assert_equal(makemake.name, 'Makemake') 235 | 236 | operations = [ 237 | JSONAPI::Operation.new(:remove_resource, PlanetResource, resource_id: 2), 238 | ] 239 | 240 | operation_results = op.process(operations) 241 | 242 | assert_kind_of(JSONAPI::OperationResults, operation_results) 243 | assert_equal(operation_results.results.size, 1) 244 | 245 | assert_kind_of(JSONAPI::OperationResult, operation_results.results[0]) 246 | assert_equal(:no_content, operation_results.results[0].code) 247 | assert_equal(Planet.count, count - 1) 248 | end 249 | 250 | def test_rollback_from_error 251 | op = JSONAPI::OperationDispatcher.new(transaction: 252 | lambda { |&block| 253 | ActiveRecord::Base.transaction do 254 | block.yield 255 | end 256 | }, 257 | rollback: 258 | lambda { 259 | fail ActiveRecord::Rollback 260 | } 261 | ) 262 | 263 | count = Planet.count 264 | 265 | operations = [ 266 | JSONAPI::Operation.new(:remove_resource, PlanetResource, resource_id: 3), 267 | JSONAPI::Operation.new(:remove_resource, PlanetResource, resource_id: 4), 268 | JSONAPI::Operation.new(:remove_resource, PlanetResource, resource_id: 4) 269 | ] 270 | 271 | operation_results = op.process(operations) 272 | 273 | assert_kind_of(JSONAPI::OperationResults, operation_results) 274 | 275 | assert_equal(Planet.count, count) 276 | 277 | assert_equal(operation_results.results.size, 3) 278 | 279 | assert_kind_of(JSONAPI::OperationResult, operation_results.results[0]) 280 | assert_equal(:no_content, operation_results.results[0].code) 281 | assert_equal(:no_content, operation_results.results[1].code) 282 | assert_equal('404', operation_results.results[2].code) 283 | end 284 | 285 | def test_show_operation 286 | op = JSONAPI::OperationDispatcher.new 287 | 288 | operations = [ 289 | JSONAPI::Operation.new(:show, PlanetResource, {id: '1'}) 290 | ] 291 | 292 | operation_results = op.process(operations) 293 | 294 | assert_kind_of(JSONAPI::OperationResults, operation_results) 295 | assert_equal(operation_results.results.size, 1) 296 | refute operation_results.has_errors? 297 | end 298 | 299 | def test_show_operation_error 300 | op = JSONAPI::OperationDispatcher.new 301 | 302 | operations = [ 303 | JSONAPI::Operation.new(:show, PlanetResource, {id: '145'}) 304 | ] 305 | 306 | operation_results = op.process(operations) 307 | 308 | assert_kind_of(JSONAPI::OperationResults, operation_results) 309 | assert_equal(operation_results.results.size, 1) 310 | assert operation_results.has_errors? 311 | end 312 | 313 | def test_show_relationship_operation 314 | op = JSONAPI::OperationDispatcher.new 315 | 316 | operations = [ 317 | JSONAPI::Operation.new(:show_relationship, PlanetResource, {parent_key: '1', relationship_type: :planet_type}) 318 | ] 319 | 320 | operation_results = op.process(operations) 321 | 322 | assert_kind_of(JSONAPI::OperationResults, operation_results) 323 | assert_equal(operation_results.results.size, 1) 324 | refute operation_results.has_errors? 325 | end 326 | 327 | def test_show_relationship_operation_error 328 | op = JSONAPI::OperationDispatcher.new 329 | 330 | operations = [ 331 | JSONAPI::Operation.new(:show_relationship, PlanetResource, {parent_key: '145', relationship_type: :planet_type}) 332 | ] 333 | 334 | operation_results = op.process(operations) 335 | 336 | assert_kind_of(JSONAPI::OperationResults, operation_results) 337 | assert_equal(operation_results.results.size, 1) 338 | assert operation_results.has_errors? 339 | end 340 | 341 | def test_show_related_resource_operation 342 | op = JSONAPI::OperationDispatcher.new 343 | 344 | operations = [ 345 | JSONAPI::Operation.new(:show_related_resource, PlanetResource, 346 | { 347 | source_klass: PlanetResource, 348 | source_id: '1', 349 | relationship_type: :planet_type}) 350 | ] 351 | 352 | operation_results = op.process(operations) 353 | 354 | assert_kind_of(JSONAPI::OperationResults, operation_results) 355 | assert_equal(operation_results.results.size, 1) 356 | refute operation_results.has_errors? 357 | end 358 | 359 | def test_show_related_resource_operation_error 360 | op = JSONAPI::OperationDispatcher.new 361 | 362 | operations = [ 363 | JSONAPI::Operation.new(:show_related_resource, PlanetResource, 364 | { 365 | source_klass: PlanetResource, 366 | source_id: '145', 367 | relationship_type: :planet_type}) 368 | ] 369 | 370 | operation_results = op.process(operations) 371 | 372 | assert_kind_of(JSONAPI::OperationResults, operation_results) 373 | assert_equal(operation_results.results.size, 1) 374 | assert operation_results.has_errors? 375 | end 376 | 377 | def test_show_related_resources_operation 378 | op = JSONAPI::OperationDispatcher.new 379 | 380 | operations = [ 381 | JSONAPI::Operation.new(:show_related_resources, PlanetResource, 382 | { 383 | source_klass: PlanetResource, 384 | source_id: '1', 385 | relationship_type: :moons}) 386 | ] 387 | 388 | operation_results = op.process(operations) 389 | 390 | assert_kind_of(JSONAPI::OperationResults, operation_results) 391 | assert_equal(operation_results.results.size, 1) 392 | refute operation_results.has_errors? 393 | end 394 | 395 | def test_show_related_resources_operation_error 396 | op = JSONAPI::OperationDispatcher.new 397 | 398 | operations = [ 399 | JSONAPI::Operation.new(:show_related_resources, PlanetResource, 400 | { 401 | source_klass: PlanetResource, 402 | source_id: '145', 403 | relationship_type: :moons}) 404 | ] 405 | 406 | operation_results = op.process(operations) 407 | 408 | assert_kind_of(JSONAPI::OperationResults, operation_results) 409 | assert_equal(operation_results.results.size, 1) 410 | assert operation_results.has_errors? 411 | end 412 | 413 | def test_safe_run_callback_pass 414 | op = JSONAPI::OperationDispatcher.new 415 | error = StandardError.new 416 | 417 | check = false 418 | callback = ->(error) { check = true } 419 | 420 | op.send(:safe_run_callback, callback, error) 421 | assert check 422 | end 423 | 424 | def test_safe_run_callback_catch_fail 425 | op = JSONAPI::OperationDispatcher.new 426 | error = StandardError.new 427 | 428 | callback = ->(error) { nil.explosions } 429 | result = op.send(:safe_run_callback, callback, error) 430 | 431 | assert_instance_of(JSONAPI::ErrorsOperationResult, result) 432 | assert_equal(result.code, '500') 433 | end 434 | end 435 | -------------------------------------------------------------------------------- /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/resource/relationship_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | 3 | class HasOneRelationshipTest < ActiveSupport::TestCase 4 | 5 | def test_polymorphic_type 6 | relationship = JSONAPI::Relationship::ToOne.new("imageable", 7 | polymorphic: true 8 | ) 9 | assert_equal(relationship.polymorphic_type, "imageable_type") 10 | end 11 | 12 | end 13 | -------------------------------------------------------------------------------- /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(['posts']).include_directives 8 | 9 | assert_hash_equals( 10 | { 11 | include_related: { 12 | posts: { 13 | include: true, 14 | include_related:{} 15 | } 16 | } 17 | }, 18 | directives) 19 | end 20 | 21 | def test_one_level_multiple_includes 22 | directives = JSONAPI::IncludeDirectives.new(['posts', 'comments', 'tags']).include_directives 23 | 24 | assert_hash_equals( 25 | { 26 | include_related: { 27 | posts: { 28 | include: true, 29 | include_related:{} 30 | }, 31 | comments: { 32 | include: true, 33 | include_related:{} 34 | }, 35 | tags: { 36 | include: true, 37 | include_related:{} 38 | } 39 | } 40 | }, 41 | directives) 42 | end 43 | 44 | def test_two_levels_include_full_path 45 | directives = JSONAPI::IncludeDirectives.new(['posts.comments']).include_directives 46 | 47 | assert_hash_equals( 48 | { 49 | include_related: { 50 | posts: { 51 | include: true, 52 | include_related:{ 53 | comments: { 54 | include: true, 55 | include_related:{} 56 | } 57 | } 58 | } 59 | } 60 | }, 61 | directives) 62 | end 63 | 64 | def test_two_levels_include_full_path_redundant 65 | directives = JSONAPI::IncludeDirectives.new(['posts','posts.comments']).include_directives 66 | 67 | assert_hash_equals( 68 | { 69 | include_related: { 70 | posts: { 71 | include: true, 72 | include_related:{ 73 | comments: { 74 | include: true, 75 | include_related:{} 76 | } 77 | } 78 | } 79 | } 80 | }, 81 | directives) 82 | end 83 | 84 | def test_three_levels_include_full 85 | directives = JSONAPI::IncludeDirectives.new(['posts.comments.tags']).include_directives 86 | 87 | assert_hash_equals( 88 | { 89 | include_related: { 90 | posts: { 91 | include: true, 92 | include_related:{ 93 | comments: { 94 | include: true, 95 | include_related:{ 96 | tags: { 97 | include: true, 98 | include_related:{} 99 | } 100 | } 101 | } 102 | } 103 | } 104 | } 105 | }, 106 | directives) 107 | end 108 | 109 | def test_three_levels_include_full_model_includes 110 | directives = JSONAPI::IncludeDirectives.new(['posts.comments.tags']) 111 | assert_array_equals([{:posts=>[{:comments=>[:tags]}]}], directives.model_includes) 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /test/unit/serializer/link_builder_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | require 'jsonapi-resources' 3 | require 'json' 4 | 5 | class LinkBuilderTest < ActionDispatch::IntegrationTest 6 | def setup 7 | # the route format is being set directly in test_helper and is being set differently depending on 8 | # the order in which the namespaces get loaded. in order to prevent random test seeds to fail we need to set the 9 | # default configuration in the test 'setup'. 10 | JSONAPI.configuration.route_format = :underscored_route 11 | 12 | @base_url = "http://example.com" 13 | @route_formatter = JSONAPI.configuration.route_formatter 14 | @steve = Person.create(name: "Steve Rogers", date_joined: "1941-03-01") 15 | end 16 | 17 | def test_engine_boolean 18 | assert JSONAPI::LinkBuilder.new( 19 | primary_resource_klass: MyEngine::Api::V1::PersonResource 20 | ).engine?, "MyEngine should be considered an Engine" 21 | 22 | refute JSONAPI::LinkBuilder.new( 23 | primary_resource_klass: Api::V1::PersonResource 24 | ).engine?, "Api shouldn't be considered an Engine" 25 | end 26 | 27 | def test_engine_name 28 | assert_equal MyEngine::Engine, 29 | JSONAPI::LinkBuilder.new( 30 | primary_resource_klass: MyEngine::Api::V1::PersonResource 31 | ).engine_name 32 | 33 | assert_equal nil, 34 | JSONAPI::LinkBuilder.new( 35 | primary_resource_klass: Api::V1::PersonResource 36 | ).engine_name 37 | end 38 | 39 | def test_self_link_regular_app 40 | primary_resource_klass = Api::V1::PersonResource 41 | 42 | config = { 43 | base_url: @base_url, 44 | route_formatter: @route_formatter, 45 | primary_resource_klass: primary_resource_klass, 46 | } 47 | 48 | builder = JSONAPI::LinkBuilder.new(config) 49 | source = primary_resource_klass.new(@steve, nil) 50 | expected_link = "#{ @base_url }/api/v1/people/#{ source.id }" 51 | 52 | assert_equal expected_link, builder.self_link(source) 53 | end 54 | 55 | def test_self_link_with_engine_app 56 | primary_resource_klass = MyEngine::Api::V1::PersonResource 57 | 58 | config = { 59 | base_url: @base_url, 60 | route_formatter: @route_formatter, 61 | primary_resource_klass: primary_resource_klass, 62 | } 63 | 64 | builder = JSONAPI::LinkBuilder.new(config) 65 | source = primary_resource_klass.new(@steve, nil) 66 | expected_link = "#{ @base_url }/boomshaka/api/v1/people/#{ source.id }" 67 | 68 | assert_equal expected_link, builder.self_link(source) 69 | end 70 | 71 | def test_self_link_with_engine_app_and_camel_case_scope 72 | primary_resource_klass = MyEngine::AdminApi::V1::PersonResource 73 | 74 | config = { 75 | base_url: @base_url, 76 | route_formatter: @route_formatter, 77 | primary_resource_klass: primary_resource_klass, 78 | } 79 | 80 | builder = JSONAPI::LinkBuilder.new(config) 81 | source = primary_resource_klass.new(@steve, nil) 82 | expected_link = "#{ @base_url }/boomshaka/admin_api/v1/people/#{ source.id }" 83 | 84 | assert_equal expected_link, builder.self_link(source) 85 | end 86 | 87 | def test_primary_resources_url_for_regular_app 88 | config = { 89 | base_url: @base_url, 90 | route_formatter: @route_formatter, 91 | primary_resource_klass: Api::V1::PersonResource, 92 | } 93 | 94 | builder = JSONAPI::LinkBuilder.new(config) 95 | expected_link = "#{ @base_url }/api/v1/people" 96 | 97 | assert_equal expected_link, builder.primary_resources_url 98 | end 99 | 100 | def test_primary_resources_url_for_engine 101 | config = { 102 | base_url: @base_url, 103 | route_formatter: @route_formatter, 104 | primary_resource_klass: MyEngine::Api::V1::PersonResource 105 | } 106 | 107 | builder = JSONAPI::LinkBuilder.new(config) 108 | expected_link = "#{ @base_url }/boomshaka/api/v1/people" 109 | 110 | assert_equal expected_link, builder.primary_resources_url 111 | end 112 | 113 | def test_relationships_self_link_for_regular_app 114 | config = { 115 | base_url: @base_url, 116 | route_formatter: @route_formatter, 117 | primary_resource_klass: Api::V1::PersonResource 118 | } 119 | 120 | builder = JSONAPI::LinkBuilder.new(config) 121 | source = Api::V1::PersonResource.new(@steve, nil) 122 | relationship = JSONAPI::Relationship::ToMany.new("posts", {}) 123 | expected_link = "#{ @base_url }/api/v1/people/#{ @steve.id }/relationships/posts" 124 | 125 | assert_equal expected_link, 126 | builder.relationships_self_link(source, relationship) 127 | end 128 | 129 | def test_relationships_self_link_for_engine 130 | config = { 131 | base_url: @base_url, 132 | route_formatter: @route_formatter, 133 | primary_resource_klass: MyEngine::Api::V1::PersonResource 134 | } 135 | 136 | builder = JSONAPI::LinkBuilder.new(config) 137 | source = MyEngine::Api::V1::PersonResource.new(@steve, nil) 138 | relationship = JSONAPI::Relationship::ToMany.new("posts", {}) 139 | expected_link = "#{ @base_url }/boomshaka/api/v1/people/#{ @steve.id }/relationships/posts" 140 | 141 | assert_equal expected_link, 142 | builder.relationships_self_link(source, relationship) 143 | end 144 | 145 | def test_relationships_related_link_for_regular_app 146 | config = { 147 | base_url: @base_url, 148 | route_formatter: @route_formatter, 149 | primary_resource_klass: Api::V1::PersonResource 150 | } 151 | 152 | builder = JSONAPI::LinkBuilder.new(config) 153 | source = Api::V1::PersonResource.new(@steve, nil) 154 | relationship = JSONAPI::Relationship::ToMany.new("posts", {}) 155 | expected_link = "#{ @base_url }/api/v1/people/#{ @steve.id }/posts" 156 | 157 | assert_equal expected_link, 158 | builder.relationships_related_link(source, relationship) 159 | end 160 | 161 | def test_relationships_related_link_for_engine 162 | config = { 163 | base_url: @base_url, 164 | route_formatter: @route_formatter, 165 | primary_resource_klass: MyEngine::Api::V1::PersonResource 166 | } 167 | 168 | builder = JSONAPI::LinkBuilder.new(config) 169 | source = MyEngine::Api::V1::PersonResource.new(@steve, nil) 170 | relationship = JSONAPI::Relationship::ToMany.new("posts", {}) 171 | expected_link = "#{ @base_url }/boomshaka/api/v1/people/#{ @steve.id }/posts" 172 | 173 | assert_equal expected_link, 174 | builder.relationships_related_link(source, relationship) 175 | end 176 | 177 | def test_relationships_related_link_with_query_params 178 | config = { 179 | base_url: @base_url, 180 | route_formatter: @route_formatter, 181 | primary_resource_klass: Api::V1::PersonResource 182 | } 183 | 184 | builder = JSONAPI::LinkBuilder.new(config) 185 | source = Api::V1::PersonResource.new(@steve, nil) 186 | relationship = JSONAPI::Relationship::ToMany.new("posts", {}) 187 | expected_link = "#{ @base_url }/api/v1/people/#{ @steve.id }/posts?page%5Blimit%5D=12&page%5Boffset%5D=0" 188 | query = { page: { offset: 0, limit: 12 } } 189 | 190 | assert_equal expected_link, 191 | builder.relationships_related_link(source, relationship, query) 192 | end 193 | 194 | def test_query_link_for_regular_app 195 | config = { 196 | base_url: @base_url, 197 | route_formatter: @route_formatter, 198 | primary_resource_klass: Api::V1::PersonResource 199 | } 200 | 201 | query = { page: { offset: 0, limit: 12 } } 202 | builder = JSONAPI::LinkBuilder.new(config) 203 | expected_link = "#{ @base_url }/api/v1/people?page%5Blimit%5D=12&page%5Boffset%5D=0" 204 | 205 | assert_equal expected_link, builder.query_link(query) 206 | end 207 | 208 | def test_query_link_for_regular_app_with_camel_case_scope 209 | config = { 210 | base_url: @base_url, 211 | route_formatter: @route_formatter, 212 | primary_resource_klass: AdminApi::V1::PersonResource 213 | } 214 | 215 | query = { page: { offset: 0, limit: 12 } } 216 | builder = JSONAPI::LinkBuilder.new(config) 217 | expected_link = "#{ @base_url }/admin_api/v1/people?page%5Blimit%5D=12&page%5Boffset%5D=0" 218 | 219 | assert_equal expected_link, builder.query_link(query) 220 | end 221 | 222 | def test_query_link_for_regular_app_with_dasherized_scope 223 | config = { 224 | base_url: @base_url, 225 | route_formatter: DasherizedRouteFormatter, 226 | primary_resource_klass: DasherizedNamespace::V1::PersonResource 227 | } 228 | 229 | query = { page: { offset: 0, limit: 12 } } 230 | builder = JSONAPI::LinkBuilder.new(config) 231 | expected_link = "#{ @base_url }/dasherized-namespace/v1/people?page%5Blimit%5D=12&page%5Boffset%5D=0" 232 | 233 | assert_equal expected_link, builder.query_link(query) 234 | end 235 | 236 | def test_query_link_for_engine 237 | config = { 238 | base_url: @base_url, 239 | route_formatter: @route_formatter, 240 | primary_resource_klass: MyEngine::Api::V1::PersonResource 241 | } 242 | 243 | query = { page: { offset: 0, limit: 12 } } 244 | builder = JSONAPI::LinkBuilder.new(config) 245 | expected_link = "#{ @base_url }/boomshaka/api/v1/people?page%5Blimit%5D=12&page%5Boffset%5D=0" 246 | 247 | assert_equal expected_link, builder.query_link(query) 248 | end 249 | 250 | def test_query_link_for_engine_with_dasherized_scope 251 | config = { 252 | base_url: @base_url, 253 | route_formatter: DasherizedRouteFormatter, 254 | primary_resource_klass: MyEngine::DasherizedNamespace::V1::PersonResource 255 | } 256 | 257 | query = { page: { offset: 0, limit: 12 } } 258 | builder = JSONAPI::LinkBuilder.new(config) 259 | expected_link = "#{ @base_url }/boomshaka/dasherized-namespace/v1/people?page%5Blimit%5D=12&page%5Boffset%5D=0" 260 | 261 | assert_equal expected_link, builder.query_link(query) 262 | end 263 | 264 | def test_query_link_for_engine_with_camel_case_scope 265 | config = { 266 | base_url: @base_url, 267 | route_formatter: @route_formatter, 268 | primary_resource_klass: MyEngine::AdminApi::V1::PersonResource 269 | } 270 | 271 | query = { page: { offset: 0, limit: 12 } } 272 | builder = JSONAPI::LinkBuilder.new(config) 273 | expected_link = "#{ @base_url }/boomshaka/admin_api/v1/people?page%5Blimit%5D=12&page%5Boffset%5D=0" 274 | 275 | assert_equal expected_link, builder.query_link(query) 276 | end 277 | end 278 | -------------------------------------------------------------------------------- /test/unit/serializer/polymorphic_serializer_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | require 'jsonapi-resources' 3 | require 'json' 4 | 5 | class PolymorphismTest < ActionDispatch::IntegrationTest 6 | def setup 7 | @pictures = Picture.all 8 | @person = Person.find(1) 9 | 10 | JSONAPI.configuration.json_key_format = :camelized_key 11 | JSONAPI.configuration.route_format = :camelized_route 12 | end 13 | 14 | def after_teardown 15 | JSONAPI.configuration.json_key_format = :underscored_key 16 | end 17 | 18 | def test_polymorphic_relationship 19 | relationships = PictureResource._relationships 20 | imageable = relationships[:imageable] 21 | 22 | assert_equal relationships.size, 1 23 | assert imageable.polymorphic? 24 | end 25 | 26 | def test_sti_polymorphic_to_many_serialization 27 | serialized_data = JSONAPI::ResourceSerializer.new( 28 | PersonResource, 29 | include: %w(vehicles) 30 | ).serialize_to_hash(PersonResource.new(@person, nil)) 31 | 32 | assert_hash_equals( 33 | { 34 | data: { 35 | id: '1', 36 | type: 'people', 37 | links: { 38 | self: '/people/1' 39 | }, 40 | attributes: { 41 | name: 'Joe Author', 42 | email: 'joe@xyz.fake', 43 | dateJoined: '2013-08-07 16:25:00 -0400' 44 | }, 45 | relationships: { 46 | comments: { 47 | links: { 48 | self: '/people/1/relationships/comments', 49 | related: '/people/1/comments' 50 | } 51 | }, 52 | posts: { 53 | links: { 54 | self: '/people/1/relationships/posts', 55 | related: '/people/1/posts' 56 | } 57 | }, 58 | vehicles: { 59 | links: { 60 | self: '/people/1/relationships/vehicles', 61 | related: '/people/1/vehicles' 62 | }, 63 | :data => [ 64 | { type: 'cars', id: '1' }, 65 | { type: 'boats', id: '2' } 66 | ] 67 | }, 68 | preferences: { 69 | links: { 70 | self: '/people/1/relationships/preferences', 71 | related: '/people/1/preferences' 72 | } 73 | }, 74 | hairCut: { 75 | links: { 76 | self: '/people/1/relationships/hairCut', 77 | related: '/people/1/hairCut' 78 | } 79 | } 80 | } 81 | }, 82 | included: [ 83 | { 84 | id: '1', 85 | type: 'cars', 86 | links: { 87 | self: '/cars/1' 88 | }, 89 | attributes: { 90 | make: 'Mazda', 91 | model: 'Miata MX5', 92 | driveLayout: 'Front Engine RWD', 93 | serialNumber: '32432adfsfdysua' 94 | }, 95 | relationships: { 96 | person: { 97 | links: { 98 | self: '/cars/1/relationships/person', 99 | related: '/cars/1/person' 100 | } 101 | } 102 | } 103 | }, 104 | { 105 | id: '2', 106 | type: 'boats', 107 | links: { 108 | self: '/boats/2' 109 | }, 110 | attributes: { 111 | make: 'Chris-Craft', 112 | model: 'Launch 20', 113 | lengthAtWaterLine: '15.5ft', 114 | serialNumber: '434253JJJSD' 115 | }, 116 | relationships: { 117 | person: { 118 | links: { 119 | self: '/boats/2/relationships/person', 120 | related: '/boats/2/person' 121 | } 122 | } 123 | } 124 | } 125 | ] 126 | }, 127 | serialized_data 128 | ) 129 | end 130 | 131 | def test_polymorphic_to_one_serialization 132 | serialized_data = JSONAPI::ResourceSerializer.new( 133 | PictureResource, 134 | include: %w(imageable) 135 | ).serialize_to_hash(@pictures.map { |p| PictureResource.new p, nil }) 136 | 137 | assert_hash_equals( 138 | { 139 | data: [ 140 | { 141 | id: '1', 142 | type: 'pictures', 143 | links: { 144 | self: '/pictures/1' 145 | }, 146 | attributes: { 147 | name: 'enterprise_gizmo.jpg' 148 | }, 149 | relationships: { 150 | imageable: { 151 | links: { 152 | self: '/pictures/1/relationships/imageable', 153 | related: '/pictures/1/imageable' 154 | }, 155 | data: { 156 | type: 'products', 157 | id: '1' 158 | } 159 | } 160 | } 161 | }, 162 | { 163 | id: '2', 164 | type: 'pictures', 165 | links: { 166 | self: '/pictures/2' 167 | }, 168 | attributes: { 169 | name: 'company_brochure.jpg' 170 | }, 171 | relationships: { 172 | imageable: { 173 | links: { 174 | self: '/pictures/2/relationships/imageable', 175 | related: '/pictures/2/imageable' 176 | }, 177 | data: { 178 | type: 'documents', 179 | id: '1' 180 | } 181 | } 182 | } 183 | }, 184 | { 185 | id: '3', 186 | type: 'pictures', 187 | links: { 188 | self: '/pictures/3' 189 | }, 190 | attributes: { 191 | name: 'group_photo.jpg' 192 | }, 193 | relationships: { 194 | imageable: { 195 | links: { 196 | self: '/pictures/3/relationships/imageable', 197 | related: '/pictures/3/imageable' 198 | }, 199 | data: nil 200 | } 201 | } 202 | } 203 | 204 | ], 205 | :included => [ 206 | { 207 | id: '1', 208 | type: 'products', 209 | links: { 210 | self: '/products/1' 211 | }, 212 | attributes: { 213 | name: 'Enterprise Gizmo' 214 | }, 215 | relationships: { 216 | picture: { 217 | links: { 218 | self: '/products/1/relationships/picture', 219 | related: '/products/1/picture', 220 | }, 221 | data: { 222 | type: 'pictures', 223 | id: '1' 224 | } 225 | } 226 | } 227 | }, 228 | { 229 | id: '1', 230 | type: 'documents', 231 | links: { 232 | self: '/documents/1' 233 | }, 234 | attributes: { 235 | name: 'Company Brochure' 236 | }, 237 | relationships: { 238 | pictures: { 239 | links: { 240 | self: '/documents/1/relationships/pictures', 241 | related: '/documents/1/pictures' 242 | } 243 | } 244 | } 245 | } 246 | ] 247 | }, 248 | serialized_data 249 | ) 250 | end 251 | 252 | def test_polymorphic_get_related_resource 253 | get '/pictures/1/imageable', headers: { 'Accept' => JSONAPI::MEDIA_TYPE } 254 | serialized_data = JSON.parse(response.body) 255 | assert_hash_equals( 256 | { 257 | data: { 258 | id: '1', 259 | type: 'products', 260 | links: { 261 | self: 'http://www.example.com/products/1' 262 | }, 263 | attributes: { 264 | name: 'Enterprise Gizmo' 265 | }, 266 | relationships: { 267 | picture: { 268 | links: { 269 | self: 'http://www.example.com/products/1/relationships/picture', 270 | related: 'http://www.example.com/products/1/picture' 271 | }, 272 | data: { 273 | type: 'pictures', 274 | id: '1' 275 | } 276 | } 277 | } 278 | } 279 | }, 280 | serialized_data 281 | ) 282 | end 283 | 284 | def test_create_resource_with_polymorphic_relationship 285 | document = Document.find(1) 286 | post "/pictures/", params: 287 | { 288 | data: { 289 | type: "pictures", 290 | attributes: { 291 | name: "hello.jpg" 292 | }, 293 | relationships: { 294 | imageable: { 295 | data: { 296 | type: "documents", 297 | id: document.id.to_s 298 | } 299 | } 300 | } 301 | } 302 | }.to_json, 303 | headers: { 304 | 'Content-Type' => JSONAPI::MEDIA_TYPE, 305 | 'Accept' => JSONAPI::MEDIA_TYPE 306 | } 307 | assert_equal 201, response.status 308 | picture = Picture.find(json_response["data"]["id"]) 309 | assert_not_nil picture.imageable, "imageable should be present" 310 | ensure 311 | picture.destroy if picture 312 | end 313 | 314 | def test_polymorphic_create_relationship 315 | picture = Picture.find(3) 316 | original_imageable = picture.imageable 317 | assert_nil original_imageable 318 | 319 | patch "/pictures/#{picture.id}/relationships/imageable", params: 320 | { 321 | relationship: 'imageable', 322 | data: { 323 | type: 'documents', 324 | id: '1' 325 | } 326 | }.to_json, 327 | headers: { 328 | 'Content-Type' => JSONAPI::MEDIA_TYPE, 329 | 'Accept' => JSONAPI::MEDIA_TYPE 330 | } 331 | assert_response :no_content 332 | picture = Picture.find(3) 333 | assert_equal 'Document', picture.imageable.class.to_s 334 | 335 | # restore data 336 | picture.imageable = original_imageable 337 | picture.save 338 | end 339 | 340 | def test_polymorphic_update_relationship 341 | picture = Picture.find(1) 342 | original_imageable = picture.imageable 343 | assert_not_equal 'Document', picture.imageable.class.to_s 344 | 345 | patch "/pictures/#{picture.id}/relationships/imageable", params: 346 | { 347 | relationship: 'imageable', 348 | data: { 349 | type: 'documents', 350 | id: '1' 351 | } 352 | }.to_json, 353 | headers: { 354 | 'Content-Type' => JSONAPI::MEDIA_TYPE, 355 | 'Accept' => JSONAPI::MEDIA_TYPE 356 | } 357 | assert_response :no_content 358 | picture = Picture.find(1) 359 | assert_equal 'Document', picture.imageable.class.to_s 360 | 361 | # restore data 362 | picture.imageable = original_imageable 363 | picture.save 364 | end 365 | 366 | def test_polymorphic_delete_relationship 367 | picture = Picture.find(1) 368 | original_imageable = picture.imageable 369 | assert original_imageable 370 | 371 | delete "/pictures/#{picture.id}/relationships/imageable", params: 372 | { 373 | relationship: 'imageable' 374 | }.to_json, 375 | headers: { 376 | 'Content-Type' => JSONAPI::MEDIA_TYPE, 377 | 'Accept' => JSONAPI::MEDIA_TYPE 378 | } 379 | assert_response :no_content 380 | picture = Picture.find(1) 381 | assert_nil picture.imageable 382 | 383 | # restore data 384 | picture.imageable = original_imageable 385 | picture.save 386 | end 387 | end 388 | -------------------------------------------------------------------------------- /test/unit/serializer/response_document_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../../../test_helper', __FILE__) 2 | require 'jsonapi-resources' 3 | require 'json' 4 | 5 | class ResponseDocumentTest < ActionDispatch::IntegrationTest 6 | def setup 7 | JSONAPI.configuration.json_key_format = :dasherized_key 8 | JSONAPI.configuration.route_format = :dasherized_route 9 | end 10 | 11 | def create_response_document(operation_results, resource_klass) 12 | JSONAPI::ResponseDocument.new( 13 | operation_results, 14 | { 15 | primary_resource_klass: resource_klass 16 | } 17 | ) 18 | end 19 | 20 | def test_response_document 21 | operations = [ 22 | JSONAPI::Operation.new(:create_resource, PlanetResource, data: {attributes: {'name' => 'Earth 2.0'}}), 23 | JSONAPI::Operation.new(:create_resource, PlanetResource, data: {attributes: {'name' => 'Vulcan'}}) 24 | ] 25 | 26 | op = JSONAPI::OperationDispatcher.new() 27 | operation_results = op.process(operations) 28 | 29 | response_doc = create_response_document(operation_results, PlanetResource) 30 | 31 | assert_equal :created, response_doc.status 32 | contents = response_doc.contents 33 | assert contents.is_a?(Hash) 34 | assert contents[:data].is_a?(Array) 35 | assert_equal 2, contents[:data].size 36 | end 37 | 38 | def test_response_document_multiple_find 39 | operations = [ 40 | JSONAPI::Operation.new(:find, PostResource, filters: {id: '1'}), 41 | JSONAPI::Operation.new(:find, PostResource, filters: {id: '2'}) 42 | ] 43 | 44 | op = JSONAPI::OperationDispatcher.new() 45 | operation_results = op.process(operations) 46 | 47 | response_doc = create_response_document(operation_results, PostResource) 48 | 49 | assert_equal :ok, response_doc.status 50 | contents = response_doc.contents 51 | assert contents.is_a?(Hash) 52 | assert contents[:data].is_a?(Array) 53 | assert_equal 2, contents[:data].size 54 | end 55 | end 56 | --------------------------------------------------------------------------------