├── .rspec ├── Gemfile ├── lib └── jsonapi │ ├── utils │ ├── version.rb │ ├── exceptions.rb │ ├── response.rb │ ├── support │ │ ├── filter │ │ │ ├── custom.rb │ │ │ └── default.rb │ │ ├── error.rb │ │ ├── sort.rb │ │ └── pagination.rb │ ├── response │ │ ├── support.rb │ │ ├── renders.rb │ │ └── formatters.rb │ ├── exceptions │ │ ├── internal_server_error.rb │ │ └── active_record.rb │ └── request.rb │ └── utils.rb ├── spec ├── config │ ├── database.yml │ └── locales │ │ └── ru.yml ├── jsonapi │ ├── utils_spec.rb │ └── utils │ │ └── support │ │ └── pagination_spec.rb ├── support │ ├── shared │ │ ├── jsonapi_request.rb │ │ └── jsonapi_errors.rb │ ├── paginators.rb │ ├── exceptions.rb │ ├── helpers.rb │ ├── resources.rb │ ├── factories.rb │ ├── models.rb │ └── controllers.rb ├── spec_helper.rb ├── rails_helper.rb ├── controllers │ ├── profile_controller_spec.rb │ ├── posts_controller_spec.rb │ └── users_controller_spec.rb ├── test_app.rb └── features │ ├── page_count_spec.rb │ └── record_count_spec.rb ├── bin ├── setup ├── console └── rspec ├── Rakefile ├── .travis.yml ├── .gitignore ├── LICENSE.txt ├── CODE_OF_CONDUCT.md ├── jsonapi-utils.gemspec └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /lib/jsonapi/utils/version.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | module Utils 3 | VERSION = '0.7.4'.freeze 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/config/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: sqlite3 3 | database: test_db 4 | pool: 5 5 | timeout: 5000 6 | -------------------------------------------------------------------------------- /lib/jsonapi/utils/exceptions.rb: -------------------------------------------------------------------------------- 1 | require 'jsonapi/utils/exceptions/active_record' 2 | require 'jsonapi/utils/exceptions/internal_server_error' 3 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /spec/jsonapi/utils_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe JSONAPI::Utils do 4 | it 'has a version number' do 5 | expect(JSONAPI::Utils::VERSION).not_to be nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) do |test| 5 | test.pattern = 'spec/**/*_spec.rb' 6 | end 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | env: 3 | matrix: 4 | - "RAILS_VERSION=6.1" 5 | rvm: 6 | - 2.6 7 | - 2.7 8 | - 3.0 9 | script: bundle exec rspec spec 10 | 11 | before_install: 12 | - gem install bundler 13 | -------------------------------------------------------------------------------- /spec/config/locales/ru.yml: -------------------------------------------------------------------------------- 1 | ru: 2 | activerecord: 3 | attributes: 4 | post: 5 | title: "Заголовок" 6 | errors: 7 | models: 8 | post: 9 | attributes: 10 | title: 11 | blank: "не может быть пустым" 12 | -------------------------------------------------------------------------------- /spec/support/shared/jsonapi_request.rb: -------------------------------------------------------------------------------- 1 | shared_context 'JSON API headers' do 2 | let(:headers) do 3 | { 'Accept' => 'application/vnd.api+json', 4 | 'Content-Type' => 'application/vnd.api+json' } 5 | end 6 | 7 | before(:each) { request.headers.merge!(headers) } 8 | end 9 | -------------------------------------------------------------------------------- /spec/support/paginators.rb: -------------------------------------------------------------------------------- 1 | class CustomOffsetPaginator < OffsetPaginator 2 | def pagination_range(page_params) 3 | offset = page_params['offset'].to_i.nonzero? || 0 4 | limit = page_params['limit'].to_i.nonzero? || JSONAPI.configuration.default_page_size 5 | offset..offset + limit - 1 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/log/ 9 | /spec/test_db 10 | /spec/reports/ 11 | /tmp/ 12 | /log/ 13 | *.log 14 | *.bundle 15 | *.so 16 | *.sw* 17 | *.o 18 | *.a 19 | *.gem 20 | .DS_Store 21 | .byebug_history 22 | test_db 23 | Gemfile.lock 24 | -------------------------------------------------------------------------------- /lib/jsonapi/utils/response.rb: -------------------------------------------------------------------------------- 1 | require 'jsonapi/utils/response/formatters' 2 | require 'jsonapi/utils/response/renders' 3 | require 'jsonapi/utils/response/support' 4 | 5 | module JSONAPI 6 | module Utils 7 | module Response 8 | include Renders 9 | include Formatters 10 | include Support 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'jsonapi/utils' 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require 'irb' 14 | IRB.start 15 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'smart_rspec' 2 | require 'factory_bot' 3 | require 'support/helpers' 4 | 5 | RSpec.configure do |config| 6 | config.include Helpers::ResponseParser 7 | config.include FactoryBot::Syntax::Methods 8 | 9 | config.define_derived_metadata do |meta| 10 | meta[:aggregate_failures] = true 11 | end 12 | 13 | config.mock_with :rspec do |mocks| 14 | mocks.verify_partial_doubles = true 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/exceptions.rb: -------------------------------------------------------------------------------- 1 | module Exceptions 2 | class MyCustomError < ::JSONAPI::Exceptions::Error 3 | attr_accessor :object 4 | 5 | def initialize(object) 6 | @object = object 7 | end 8 | 9 | def errors 10 | [JSONAPI::Error.new( 11 | code: '125', 12 | status: :unprocessable_entity, 13 | id: 'my_custom_validation_error', 14 | title: 'My custom error message' 15 | )] 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | # 4 | # This file was generated by Bundler. 5 | # 6 | # The application 'rspec' is installed as part of a gem, and 7 | # this file is here to facilitate running it. 8 | # 9 | 10 | require "pathname" 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 12 | Pathname.new(__FILE__).realpath) 13 | 14 | require "rubygems" 15 | require "bundler/setup" 16 | 17 | load Gem.bin_path("rspec-core", "rspec") 18 | -------------------------------------------------------------------------------- /lib/jsonapi/utils/support/filter/custom.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI::Utils::Support::Filter 2 | module Custom 3 | def _custom_filters 4 | @_allowed_custom_filters || [] 5 | end 6 | 7 | def custom_filters(*attrs) 8 | attrs.each { |attr| custom_filter(attr) } 9 | end 10 | 11 | def custom_filter(attr) 12 | attr = attr.to_sym 13 | @_allowed_filters[attr] = {} 14 | @_allowed_custom_filters ||= [] 15 | @_allowed_custom_filters |= [attr] 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/support/helpers.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | module Helpers 4 | module ResponseParser 5 | def json 6 | @json ||= JSON.parse(response.body) 7 | end 8 | 9 | def errors 10 | @errors ||= json['errors'] 11 | end 12 | 13 | def error 14 | @error ||= errors.first 15 | end 16 | 17 | def data 18 | @data ||= json['data'] 19 | end 20 | 21 | def collection 22 | @collection ||= [data].flatten 23 | end 24 | 25 | def links 26 | @links ||= json['links'] 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/jsonapi/utils.rb: -------------------------------------------------------------------------------- 1 | require 'jsonapi-resources' 2 | require 'jsonapi/utils/version' 3 | require 'jsonapi/utils/exceptions' 4 | require 'jsonapi/utils/request' 5 | require 'jsonapi/utils/response' 6 | require 'jsonapi/utils/support/filter/custom' 7 | 8 | JSONAPI::Resource.extend JSONAPI::Utils::Support::Filter::Custom 9 | 10 | module JSONAPI 11 | module Utils 12 | def self.included(base) 13 | base.include ActsAsResourceController 14 | base.include Request 15 | base.include Response 16 | 17 | if base.respond_to?(:before_action) 18 | base.before_action :jsonapi_request_handling 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/jsonapi/utils/support/error.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | module Utils 3 | module Support 4 | module Error 5 | MEMBERS = %i(title detail id code source links status meta).freeze 6 | 7 | module_function 8 | 9 | def sanitize(errors) 10 | Array(errors).map do |error| 11 | MEMBERS.reduce({}) do |sum, key| 12 | value = error.try(key) || error.try(:[], key) 13 | if value.nil? 14 | sum 15 | else 16 | value = value.to_s if key == :code 17 | sum.merge(key => value) 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | require 'rails/all' 4 | require 'rails/test_help' 5 | require 'rspec/rails' 6 | 7 | require 'jsonapi-resources' 8 | require 'jsonapi/utils' 9 | 10 | require 'support/models' 11 | require 'support/factories' 12 | require 'support/resources' 13 | require 'support/controllers' 14 | require 'support/paginators' 15 | 16 | require 'support/shared/jsonapi_errors' 17 | require 'support/shared/jsonapi_request' 18 | 19 | require 'test_app' 20 | 21 | RSpec.configure do |config| 22 | config.before(:all) do 23 | TestApp.draw_app_routes 24 | 25 | %w[posts categories profiles users].each do |table_name| 26 | ActiveRecord::Base.connection.execute("DELETE FROM #{table_name}; VACUUM;") 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/jsonapi/utils/response/support.rb: -------------------------------------------------------------------------------- 1 | require 'jsonapi/utils/support/error' 2 | require 'jsonapi/utils/support/filter/default' 3 | require 'jsonapi/utils/support/pagination' 4 | require 'jsonapi/utils/support/sort' 5 | 6 | module JSONAPI 7 | module Utils 8 | module Response 9 | module Support 10 | include ::JSONAPI::Utils::Support::Error 11 | include ::JSONAPI::Utils::Support::Filter::Default 12 | include ::JSONAPI::Utils::Support::Pagination 13 | include ::JSONAPI::Utils::Support::Sort 14 | 15 | private 16 | 17 | def correct_media_type 18 | unless response.body.empty? 19 | response.headers['Content-Type'] = JSONAPI::MEDIA_TYPE 20 | end 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/jsonapi/utils/exceptions/internal_server_error.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | module Utils 3 | module Exceptions 4 | class InternalServerError < ::JSONAPI::Exceptions::Error 5 | # HTTP status code 6 | # 7 | # @return [String] 8 | # 9 | # @api public 10 | def code 11 | '500' 12 | end 13 | 14 | # Decorate errors for 500 responses. 15 | # 16 | # @return [Array] 17 | # 18 | # @api public 19 | def errors 20 | [JSONAPI::Error.new( 21 | code: code, 22 | status: :internal_server_error, 23 | title: 'Internal Server Error', 24 | detail: 'An internal error ocurred while processing the request.' 25 | )] 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/support/resources.rb: -------------------------------------------------------------------------------- 1 | class CategoryResource < JSONAPI::Resource 2 | attribute :title 3 | has_many :posts 4 | end 5 | 6 | class PostResource < JSONAPI::Resource 7 | attributes :title, :content_type, :body 8 | has_one :author 9 | has_one :category 10 | end 11 | 12 | class AuthorResource < JSONAPI::Resource 13 | model_name 'Person' 14 | has_many :posts 15 | end 16 | 17 | module V2 18 | class PostResource < ::PostResource; end 19 | end 20 | 21 | class UserResource < JSONAPI::Resource 22 | attributes :first_name, :last_name, :full_name 23 | 24 | has_one :profile, foreign_key_on: :related 25 | has_many :posts 26 | 27 | filters :first_name 28 | custom_filters :full_name 29 | 30 | def full_name 31 | "#{@model.first_name} #{@model.last_name}" 32 | end 33 | end 34 | 35 | class ProfileResource < JSONAPI::Resource 36 | attributes :nickname, :location 37 | has_one :user, class_name: 'User', foreign_key: 'user_id' 38 | end 39 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Tiago Guedes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/support/factories.rb: -------------------------------------------------------------------------------- 1 | require 'factory_bot' 2 | require_relative './models' 3 | 4 | # require 'byebug'; byebug 5 | 6 | FactoryBot.define do 7 | factory :user, class: User do 8 | sequence(:id) { |n| n } 9 | sequence(:first_name) { |n| "User##{n}" } 10 | sequence(:last_name) { |n| "Lastname##{n}" } 11 | 12 | after(:create) { |user| create(:profile, user: user) } 13 | 14 | trait :with_posts do 15 | transient { post_count { 3 } } 16 | after(:create) do |user, e| 17 | create_list(:post, e.post_count, author: user) 18 | end 19 | end 20 | end 21 | 22 | factory :profile, class: Profile do 23 | user 24 | sequence(:id) { |n| n } 25 | sequence(:nickname) { |n| "Nickname##{n}" } 26 | sequence(:location) { |n| "Location##{n}" } 27 | end 28 | 29 | factory :post, class: Post do 30 | association :author, factory: :user 31 | category 32 | 33 | sequence(:id) { |n| n } 34 | sequence(:title) { |n| "Title for Post #{n}" } 35 | sequence(:body) { |n| "Body for Post #{n}" } 36 | content_type { :article } 37 | hidden_field { 'It\'s a hidden field!' } 38 | end 39 | 40 | factory :category, class: Category do 41 | sequence(:title) { |n| "Title for Category #{n}" } 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/controllers/profile_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe ProfileController, type: :controller do 4 | include_context 'JSON API headers' 5 | 6 | let(:relationships) { ProfileResource._relationships.keys.map(&:to_s) } 7 | let(:fields) { ProfileResource.fields.reject { |e| e == :id }.map(&:to_s) - relationships } 8 | let(:attributes) { { nickname: 'Foobar', location: 'Springfield, USA' } } 9 | 10 | let(:body) do 11 | { 12 | data: { 13 | type: 'profiles', 14 | id: '1234', 15 | attributes: attributes 16 | } 17 | } 18 | end 19 | 20 | describe '#show' do 21 | it 'renders from ActiveModel::Model logic' do 22 | get :show 23 | expect(response).to have_http_status :ok 24 | expect(response).to have_primary_data('profiles') 25 | expect(response).to have_data_attributes(fields) 26 | end 27 | end 28 | 29 | describe '#update' do 30 | it 'renders a 422 response' do 31 | patch :update, params: body 32 | expect(response).to have_http_status :unprocessable_entity 33 | expect(errors.dig(0, 'id')).to eq('nickname#blank') 34 | expect(errors.dig(0, 'title')).to eq("can't be blank") 35 | expect(errors.dig(0, 'detail')).to eq("Nickname can't be blank") 36 | expect(errors.dig(0, 'code')).to eq('100') 37 | expect(errors.dig(0, 'source', 'pointer')).to eq('/data/attributes/nickname') 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, or religion. 6 | 7 | Examples of unacceptable behavior by participants include the use of sexual language or imagery, derogatory comments or personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. 8 | 9 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. Project maintainers who do not follow the Code of Conduct may be removed from the project team. 10 | 11 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 12 | 13 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.0.0, available at [http://contributor-covenant.org/version/1/0/0/](http://contributor-covenant.org/version/1/0/0/) 14 | -------------------------------------------------------------------------------- /jsonapi-utils.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/utils/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'jsonapi-utils' 8 | spec.version = JSONAPI::Utils::VERSION 9 | spec.authors = ['Tiago Guedes', 'Douglas André'] 10 | spec.email = ['tiagopog@gmail.com', 'douglas@beautydate.com.br'] 11 | 12 | spec.summary = "JSON::Utils is a simple way to get a full-featured JSON API on your Rails application." 13 | spec.description = "Build JSON API-compliant APIs on Rails with no (or less) learning curve." 14 | spec.homepage = 'https://github.com/b2beauty/jsonapi-utils' 15 | spec.license = 'MIT' 16 | 17 | spec.files = Dir.glob('{bin,lib}/**/*') + %w(LICENSE.txt README.md CODE_OF_CONDUCT.md) 18 | spec.bindir = 'exe' 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ['lib'] 21 | 22 | spec.add_runtime_dependency 'jsonapi-resources', '~> 0.10.5' 23 | 24 | spec.add_development_dependency 'bundler', '~> 2.0' 25 | spec.add_development_dependency 'rake', '~> 12.3', '>= 12.3.3' 26 | spec.add_development_dependency 'rails', (ENV['RAILS_VERSION'] || '~> 6.0') 27 | spec.add_development_dependency 'sqlite3', '~> 1.4' 28 | spec.add_development_dependency 'rspec-rails', '~> 3.9', '>= 3.9.0' 29 | spec.add_development_dependency 'factory_bot', '~> 5.1' 30 | spec.add_development_dependency 'smart_rspec', '~> 0.1.6' 31 | spec.add_development_dependency 'pry', '~> 0.12', '>= 0.12.2' 32 | spec.add_development_dependency 'pry-byebug', '~> 3.7', '>= 3.7.0' 33 | end 34 | -------------------------------------------------------------------------------- /lib/jsonapi/utils/support/sort.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI::Utils::Support 2 | module Sort 3 | # Apply sort on result set (ascending by default). 4 | # e.g.: User.order(:first_name) 5 | # 6 | # @param records [ActiveRecord::Relation, Array] collection of records 7 | # e.g.: User.all or [{ id: 1, name: 'Tiago' }, { id: 2, name: 'Doug' }] 8 | # 9 | # @return [ActiveRecord::Relation, Array] 10 | # 11 | # @api public 12 | def apply_sort(records) 13 | return records unless params[:sort].present? 14 | 15 | if records.is_a?(Array) 16 | records.sort { |a, b| comp = 0; eval(sort_criteria) } 17 | elsif records.respond_to?(:order) 18 | records.order(sort_params) 19 | end 20 | end 21 | 22 | # Build the criteria to be evaluated wthen applying sort 23 | # on Array of Hashes (ascending by default). 24 | # 25 | # @return [String] 26 | # 27 | # @api public 28 | def sort_criteria 29 | @sort_criteria ||= 30 | sort_params.reduce('') do |sum, (key, value)| 31 | comparables = ["a[:#{key}]", "b[:#{key}]"] 32 | comparables.reverse! if value == :desc 33 | sum + "comp = comp == 0 ? #{comparables.join(' <=> ')} : comp; " 34 | end 35 | end 36 | 37 | # Build a Hash with the sort criteria. 38 | # 39 | # @return [Hash, NilClass] 40 | # 41 | # @api public 42 | def sort_params 43 | @_sort_params ||= 44 | if params[:sort].present? 45 | params[:sort].split(',').each_with_object({}) do |field, hash| 46 | unformatted_field = @request.unformat_key(field) 47 | desc, field = unformatted_field.to_s.match(/^([-_])?(\w+)$/i)[1..2] 48 | hash[field.to_sym] = desc.present? ? :desc : :asc 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/support/models.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | 3 | ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') 4 | 5 | # Tables 6 | ActiveRecord::Schema.define do 7 | create_table :users do |t| 8 | t.string :first_name 9 | t.string :last_name 10 | t.boolean :admin 11 | t.timestamps null: false 12 | end 13 | 14 | create_table :profiles, force: true do |t| 15 | t.references :user, index: true, foreign_key: true 16 | t.string :nickname 17 | t.string :location 18 | t.timestamps null: false 19 | end 20 | 21 | create_table :categories, force: true do |t| 22 | t.string :title 23 | t.timestamps null: false 24 | end 25 | 26 | create_table :posts, force: true do |t| 27 | t.string :title 28 | t.text :body 29 | t.string :content_type 30 | t.string :hidden_field 31 | t.integer :user_id 32 | t.integer :category_id 33 | t.timestamps null: false 34 | end 35 | 36 | add_index :posts, :user_id 37 | add_index :posts, :category_id 38 | end 39 | 40 | # Models 41 | class User < ActiveRecord::Base 42 | has_one :profile 43 | has_many :posts 44 | validates :first_name, :last_name, presence: true 45 | 46 | def full_name 47 | "#{first_name} #{last_name}" 48 | end 49 | end 50 | 51 | class Post < ActiveRecord::Base 52 | belongs_to :author, class_name: 'User', foreign_key: :user_id 53 | belongs_to :category 54 | validates :title, :body, :content_type, :hidden_field, :author, :category_id, presence: true 55 | validate :trip_hidden_error 56 | 57 | private 58 | 59 | def trip_hidden_error 60 | errors.add(:hidden_field, 'error was tripped') if title == 'Fail Hidden' 61 | end 62 | end 63 | 64 | class Category < ActiveRecord::Base 65 | has_many :posts 66 | validates :title, presence: true 67 | end 68 | 69 | class Profile < ActiveRecord::Base 70 | belongs_to :user 71 | validates :nickname, :location, presence: true 72 | end 73 | -------------------------------------------------------------------------------- /lib/jsonapi/utils/support/filter/default.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI::Utils::Support::Filter 2 | module Default 3 | # Apply default equality filters. 4 | # e.g.: User.where(name: 'Foobar') 5 | # 6 | # @param records [ActiveRecord::Relation, Array] collection of records 7 | # e.g.: User.all or [{ id: 1, name: 'Tiago' }, { id: 2, name: 'Doug' }] 8 | # 9 | # @param options [Hash] JU's options 10 | # e.g.: { filter: false, paginate: false } 11 | # 12 | # @return [ActiveRecord::Relation, Array] 13 | # 14 | # @api public 15 | def apply_filter(records, options = {}) 16 | if apply_filter?(records, options) 17 | records.where(filter_params) 18 | else 19 | records 20 | end 21 | end 22 | 23 | # Check whether default filters should be applied. 24 | # 25 | # @param records [ActiveRecord::Relation, Array] collection of records 26 | # e.g.: User.all or [{ id: 1, name: 'Tiago' }, { id: 2, name: 'Doug' }] 27 | # 28 | # @param options [Hash] JU's options 29 | # e.g.: { filter: false, paginate: false } 30 | # 31 | # @return [Boolean] 32 | # 33 | # @api public 34 | def apply_filter?(records, options = {}) 35 | params[:filter].present? && records.respond_to?(:where) && 36 | (options[:filter].nil? || options[:filter]) 37 | end 38 | 39 | # Build a Hash with the default filters. 40 | # 41 | # @return [Hash, NilClass] 42 | # 43 | # @api public 44 | def filter_params 45 | @_filter_params ||= 46 | case params[:filter] 47 | when Hash, ActionController::Parameters 48 | default_filters.each_with_object({}) do |field, hash| 49 | unformatted_field = @request.unformat_key(field) 50 | hash[unformatted_field] = params[:filter][field] 51 | end 52 | end 53 | end 54 | 55 | private 56 | 57 | # Take all allowed filters and remove the custom ones. 58 | # 59 | # @return [Array] 60 | # 61 | # @api private 62 | def default_filters 63 | params[:filter].keys.map(&:to_sym) - @request.resource_klass._custom_filters 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/test_app.rb: -------------------------------------------------------------------------------- 1 | ## 2 | # General configs 3 | ## 4 | 5 | JSONAPI.configure do |config| 6 | config.json_key_format = :underscored_key 7 | 8 | config.allow_include = true 9 | config.allow_sort = true 10 | config.allow_filter = true 11 | 12 | config.default_page_size = 10 13 | config.maximum_page_size = 10 14 | config.default_paginator = :paged 15 | config.top_level_links_include_pagination = true 16 | 17 | config.top_level_meta_include_record_count = true 18 | config.top_level_meta_record_count_key = :record_count 19 | 20 | config.top_level_meta_include_page_count = true 21 | config.top_level_meta_page_count_key = :page_count 22 | end 23 | 24 | ## 25 | # Rails application 26 | ## 27 | 28 | Rails.env = 'test' 29 | 30 | class TestApp < Rails::Application 31 | config.eager_load = false 32 | config.root = File.dirname(__FILE__) 33 | config.session_store :cookie_store, key: 'session' 34 | config.secret_key_base = 'secret' 35 | 36 | # Raise errors on unsupported parameters 37 | config.action_controller.action_on_unpermitted_parameters = :log 38 | 39 | ActiveRecord::Schema.verbose = false 40 | config.active_record.schema_format = :none 41 | config.active_support.test_order = :random 42 | 43 | # Turn off millisecond precision to maintain Rails 4.0 and 4.1 compatibility in test results 44 | Rails::VERSION::MAJOR >= 4 && Rails::VERSION::MINOR >= 1 && 45 | ActiveSupport::JSON::Encoding.time_precision = 0 46 | 47 | I18n.enforce_available_locales = false 48 | I18n.available_locales = [:en, :ru] 49 | I18n.default_locale = :en 50 | I18n.load_path += Dir[Rails.root.join('config', 'locales', '**', '*.{rb,yml}')] 51 | end 52 | 53 | ## 54 | # Routes 55 | ## 56 | 57 | def TestApp.draw_app_routes 58 | JSONAPI.configuration.route_format = :dasherized_route 59 | 60 | TestApp.routes.draw do 61 | jsonapi_resources :users do 62 | jsonapi_links :profile 63 | jsonapi_related_resources :posts 64 | end 65 | 66 | jsonapi_resource :profile 67 | jsonapi_resources :posts 68 | 69 | patch :update_with_error_on_base, to: 'posts#update_with_error_on_base' 70 | 71 | get :index_with_hash, to: 'posts#index_with_hash' 72 | get :show_with_hash, to: 'posts#show_with_hash' 73 | end 74 | end 75 | 76 | puts "\nRunning Rails #{Rails.version} on #{Rails.env} environment" 77 | -------------------------------------------------------------------------------- /spec/features/page_count_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | ## 4 | # Configs 5 | ## 6 | 7 | # Resource 8 | class PageCountTestResource < JSONAPI::Resource; end 9 | 10 | # Controller 11 | class PageCountTestController < BaseController 12 | def index 13 | jsonapi_render json: User.all, options: { resource: UserResource } 14 | end 15 | end 16 | 17 | # Routes 18 | def TestApp.draw_page_count_test_routes 19 | JSONAPI.configuration.json_key_format = :underscored_key 20 | 21 | TestApp.routes.draw do 22 | controller :page_count_test do 23 | get :index 24 | end 25 | end 26 | end 27 | 28 | ## 29 | # Feature Tests 30 | ## 31 | 32 | describe PageCountTestController, type: :controller do 33 | include_context 'JSON API headers' 34 | 35 | before(:all) do 36 | TestApp.draw_page_count_test_routes 37 | FactoryBot.create_list(:user, 3, :with_posts) 38 | end 39 | 40 | describe 'page count with a paged paginator' do 41 | it 'returns the correct count' do 42 | JSONAPI.configuration.default_paginator = :paged 43 | 44 | get :index, params: { page: { size: 2, number: 1 } } 45 | 46 | expect(json.dig('meta', 'page_count')).to eq(2) 47 | end 48 | end 49 | 50 | describe 'page count with an offset paginator' do 51 | it 'returns the correct count' do 52 | JSONAPI.configuration.default_paginator = :offset 53 | 54 | get :index, params: { page: { limit: 2, offset: 1 } } 55 | 56 | expect(json.dig('meta', 'page_count')).to eq(2) 57 | end 58 | end 59 | 60 | describe 'page count with a custom paginator' do 61 | it 'returns the correct count' do 62 | JSONAPI.configuration.default_paginator = :custom_offset 63 | 64 | get :index, params: { page: { limit: 2, offset: 1 } } 65 | 66 | expect(json.dig('meta', 'page_count')).to eq(2) 67 | end 68 | end 69 | 70 | describe 'using default limit param' do 71 | it 'returns the correct count' do 72 | JSONAPI.configuration.default_paginator = :offset 73 | 74 | get :index, params: { page: { offset: 1 } } 75 | 76 | expect(json.dig('meta', 'page_count')).to eq(1) 77 | end 78 | end 79 | 80 | describe 'using a custom page_count key' do 81 | it 'returns the count with the correct key' do 82 | JSONAPI.configuration.default_paginator = :paged 83 | JSONAPI.configuration.top_level_meta_page_count_key = :total_pages 84 | 85 | get :index, params: { page: { limit: 2, offset: 1 } } 86 | 87 | expect(json.dig('meta', 'total_pages')).to eq(2) 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /spec/features/record_count_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | ## 4 | # Configs 5 | ## 6 | 7 | # Resource 8 | class RecordCountTestResource < JSONAPI::Resource; end 9 | 10 | # Controller 11 | class RecordCountTestController < BaseController 12 | def explicit_count 13 | jsonapi_render json: User.all, options: { count: 42, resource: UserResource } 14 | end 15 | 16 | def array_count 17 | jsonapi_render json: User.all.to_a, options: { resource: UserResource } 18 | end 19 | 20 | def active_record_count 21 | jsonapi_render json: User.all, options: { resource: UserResource } 22 | end 23 | 24 | def active_record_count_with_eager_load 25 | users = User.all.includes(:posts) 26 | jsonapi_render json: users, options: { resource: UserResource } 27 | end 28 | 29 | def active_record_count_with_eager_load_and_where_clause 30 | users = User.all.includes(:posts).where(posts: { id: Post.first.id }) 31 | jsonapi_render json: users, options: { resource: UserResource } 32 | end 33 | end 34 | 35 | # Routes 36 | def TestApp.draw_record_count_test_routes 37 | JSONAPI.configuration.json_key_format = :underscored_key 38 | 39 | TestApp.routes.draw do 40 | controller :record_count_test do 41 | get :explicit_count 42 | get :array_count 43 | get :active_record_count 44 | get :active_record_count_with_eager_load 45 | get :active_record_count_with_eager_load_and_where_clause 46 | end 47 | end 48 | end 49 | 50 | ## 51 | # Feature tests 52 | ## 53 | 54 | describe RecordCountTestController, type: :controller do 55 | include_context 'JSON API headers' 56 | 57 | before(:all) do 58 | TestApp.draw_record_count_test_routes 59 | FactoryBot.create_list(:user, 3, :with_posts) 60 | end 61 | 62 | describe 'explicit count' do 63 | it 'returns the count based on the passed "options"' do 64 | get :explicit_count 65 | expect(response).to have_meta_record_count(42) 66 | end 67 | end 68 | 69 | describe 'array count' do 70 | it 'returns the count based on the array length' do 71 | get :array_count 72 | expect(response).to have_meta_record_count(User.count) 73 | end 74 | end 75 | 76 | describe 'active record count' do 77 | it 'returns the count based on the AR\'s query result' do 78 | get :active_record_count 79 | expect(response).to have_meta_record_count(User.count) 80 | end 81 | end 82 | 83 | describe 'active record count with eager load' do 84 | it 'returns the count based on the AR\'s query result' do 85 | get :active_record_count_with_eager_load 86 | expect(response).to have_meta_record_count(User.count) 87 | end 88 | end 89 | 90 | describe 'active record count with eager load and where clause' do 91 | it 'returns the count based on the AR\'s query result' do 92 | get :active_record_count_with_eager_load_and_where_clause 93 | count = User.joins(:posts).where(posts: { id: Post.first.id }).count 94 | expect(response).to have_meta_record_count(count) 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/jsonapi/utils/request.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI::Utils 2 | module Request 3 | # Setup and check request before action gets actually evaluated. 4 | # 5 | # @api public 6 | def jsonapi_request_handling 7 | setup_request 8 | check_request 9 | rescue JSONAPI::Exceptions::InvalidResource, 10 | JSONAPI::Exceptions::InvalidField, 11 | JSONAPI::Exceptions::InvalidInclude, 12 | JSONAPI::Exceptions::InvalidSortCriteria => err 13 | jsonapi_render_errors(json: err) 14 | end 15 | 16 | # Instantiate the request object. 17 | # 18 | # @return [JSONAPI::RequestParser] 19 | # 20 | # @api public 21 | def setup_request 22 | @request ||= JSONAPI::RequestParser.new( 23 | params, 24 | context: context, 25 | key_formatter: key_formatter, 26 | server_error_callbacks: (self.class.server_error_callbacks || []) 27 | ) 28 | end 29 | 30 | # Render an error response if the parsed request got any error. 31 | # 32 | # @api public 33 | def check_request 34 | @request.errors.blank? || jsonapi_render_errors(json: @request) 35 | end 36 | 37 | # Override the JSONAPI::ActsAsResourceController#process_request method. 38 | # 39 | # It might be removed when the following line on JR is fixed: 40 | # https://github.com/cerebris/jsonapi-resources/blob/release-0-8/lib/jsonapi/acts_as_resource_controller.rb#L62 41 | # 42 | # @return [String] 43 | # 44 | # @api public 45 | def process_request 46 | operations = @request.operations 47 | unless JSONAPI.configuration.resource_cache.nil? 48 | operations.each {|op| op.options[:cache_serializer] = resource_serializer } 49 | end 50 | results = process_operations(operations) 51 | render_results(results) 52 | rescue => e 53 | handle_exceptions(e) 54 | end 55 | 56 | # Helper to get params for the main resource. 57 | # 58 | # @return [Hash] 59 | # 60 | # @api public 61 | def resource_params 62 | build_params_for(:resource) 63 | end 64 | 65 | # Helper to get params for relationship params. 66 | # 67 | # @return [Hash] 68 | # 69 | # @api public 70 | def relationship_params 71 | build_params_for(:relationship) 72 | end 73 | 74 | private 75 | 76 | # Extract params from request and build a Hash with params 77 | # for either the main resource or relationships. 78 | # 79 | # @return [Hash] 80 | # 81 | # @api private 82 | def build_params_for(param_type) 83 | return {} if @request.operations.empty? 84 | 85 | keys = %i(attributes to_one to_many) 86 | operation = @request.operations.find { |e| e.options[:data].keys & keys == keys } 87 | 88 | if operation.nil? 89 | {} 90 | elsif param_type == :relationship 91 | operation.options[:data].values_at(:to_one, :to_many).compact.reduce(&:merge) 92 | else 93 | operation.options[:data][:attributes] 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/jsonapi/utils/response/renders.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | module Utils 3 | module Response 4 | module Renders 5 | # Helper method to render JSON API-compliant responses. 6 | # 7 | # @param json [ActiveRecord::Base, ActiveRecord::Relation, Hash, Array] 8 | # Object to be serialized into JSON 9 | # e.g.: User.first, User.all, { data: { id: 1, first_name: 'Tiago' } }, 10 | # [{ data: { id: 1, first_name: 'Tiago' } }] 11 | # 12 | # @param status [Integer, String, Symbol] HTTP status code 13 | # e.g.: 201, '201', :created 14 | # 15 | # @option options [JSONAPI::Resource] resource: it tells the render which resource 16 | # class to be used rather than use an infered one (default behaviour) 17 | # 18 | # @option options [JSONAPI::Resource] source_resource: it tells the render that this response is from a related resource 19 | # and the result should be interpreted as a related resources response 20 | # 21 | # @option options [String, Symbol] relationship_type: it tells that the render which relationship the data is from 22 | # 23 | # @option options [ActiveRecord::Base] model: ActiveRecord model class to be instantiated 24 | # when a Hash or Array of Hashes is passed to the "json" key argument 25 | # 26 | # @option options [Integer] count: if it's rendering a collection of resources, the default 27 | # gem's counting method can be bypassed by the use of this options. It's shows then the total 28 | # records resulting from that request and also calculates the pagination. 29 | # 30 | # @return [String] 31 | # 32 | # @api public 33 | def jsonapi_render(json:, status: nil, options: {}) 34 | body = jsonapi_format(json, options) 35 | render json: body, status: (status || @_response_document.status) 36 | rescue => e 37 | handle_exceptions(e) # http://bit.ly/2sEEGTN 38 | ensure 39 | correct_media_type 40 | end 41 | 42 | # Helper method to render JSON API-compliant error responses. 43 | # 44 | # @param error [ActiveRecord::Base or any object that responds to #errors] 45 | # Error object to be serialized into JSON 46 | # e.g.: User.new(name: nil).tap(&:save), MyErrorDecorator.new(invalid_object) 47 | # 48 | # @param json [ActiveRecord::Base or any object that responds to #errors] 49 | # Error object to be serialized into JSON 50 | # e.g.: User.new(name: nil).tap(&:save), MyErrorDecorator.new(invalid_object) 51 | # 52 | # @param status [Integer, String, Symbol] HTTP status code 53 | # e.g.: 422, '422', :unprocessable_entity 54 | # 55 | # @return [String] 56 | # 57 | # @api public 58 | def jsonapi_render_errors(error = nil, json: nil, status: nil) 59 | body = jsonapi_format_errors(error || json) 60 | status = status || body.try(:first).try(:[], :status) || :bad_request 61 | render json: { errors: body }, status: status 62 | ensure 63 | correct_media_type 64 | end 65 | 66 | # Helper method to render HTTP 500 Interval Server Error. 67 | # 68 | # @api public 69 | def jsonapi_render_internal_server_error 70 | jsonapi_render_errors(::JSONAPI::Utils::Exceptions::InternalServerError.new) 71 | end 72 | 73 | # Helper method to render HTTP 400 Bad Request. 74 | # 75 | # @api public 76 | def jsonapi_render_bad_request 77 | jsonapi_render_errors(::JSONAPI::Utils::Exceptions::BadRequest.new) 78 | end 79 | 80 | # Helper method to render HTTP 404 Bad Request. 81 | # 82 | # @api public 83 | def jsonapi_render_not_found(exception) 84 | id = exception.message =~ /=([\w-]+)/ && $1 || '(no identifier)' 85 | jsonapi_render_errors(JSONAPI::Exceptions::RecordNotFound.new(id)) 86 | end 87 | 88 | # Helper method to render HTTP 404 Bad Request with null "data". 89 | # 90 | # @api public 91 | def jsonapi_render_not_found_with_null 92 | render json: { data: nil }, status: 200 93 | end 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /spec/support/controllers.rb: -------------------------------------------------------------------------------- 1 | require 'support/exceptions' 2 | 3 | class BaseController < ActionController::Base 4 | include JSONAPI::Utils 5 | protect_from_forgery with: :null_session 6 | rescue_from ActiveRecord::RecordNotFound, with: :jsonapi_render_not_found 7 | end 8 | 9 | class PostsController < BaseController 10 | before_action :load_user, only: %i(index get_related_resources) 11 | 12 | # GET /users/:user_id/posts 13 | def index 14 | jsonapi_render json: @user.posts, options: { count: 100 } 15 | end 16 | 17 | # GET /users/:user_id/index_with_hash 18 | def index_with_hash 19 | @posts = { data: [ 20 | { id: 1, title: 'Lorem Ipsum', body: 'Body 4' }, 21 | { id: 2, title: 'Dolor Sit', body: 'Body 2' }, 22 | { id: 3, title: 'Dolor Sit', body: 'Body 3' }, 23 | { id: 4, title: 'Dolor Sit', body: 'Body 1' } 24 | ]} 25 | # Example of response rendering from Hash + options: 26 | jsonapi_render json: @posts, options: { model: Post } 27 | end 28 | 29 | # GET /posts/:id 30 | def show 31 | jsonapi_render json: Post.find(params[:id]) 32 | end 33 | 34 | # GET /show_with_hash/:id 35 | def show_with_hash 36 | # Example of response rendering from Hash + options: (2) 37 | jsonapi_render json: { data: { id: params[:id], title: 'Lorem ipsum' } }, 38 | options: { model: Post, resource: ::V2::PostResource } 39 | end 40 | 41 | # POST /users/:user_id/posts 42 | def create 43 | post = Post.new(post_params) 44 | post.hidden_field = 1 45 | if post.save 46 | jsonapi_render json: post, status: :created 47 | else 48 | jsonapi_render_errors json: post 49 | end 50 | end 51 | 52 | # GET /users/:user_id/posts 53 | def get_related_resources 54 | if params[:source] == "users" && params[:relationship] == "posts" 55 | # Example for custom method to fetch related resources 56 | @posts = @user.posts 57 | 58 | jsonapi_render json: @posts, options: { 59 | source: (params[:use_resource] == "true") ? UserResource.new(@user, context) : @user, 60 | relationship: (params[:explicit_relationship] == "true") ? 'posts' : nil 61 | } 62 | else 63 | # handle other requests with default method 64 | process_request 65 | end 66 | end 67 | 68 | # PATCH /posts/:id 69 | def update_with_error_on_base 70 | post = Post.find(params[:id]) 71 | # Example of response rendering with error on base 72 | post.errors.add(:base, 'This is an error on the base') 73 | jsonapi_render_errors json: post 74 | end 75 | 76 | private 77 | 78 | def post_params 79 | resource_params.merge(user_id: relationship_params[:author], category_id: relationship_params[:category]) 80 | end 81 | 82 | def load_user 83 | @user = User.find(params[:user_id]) 84 | end 85 | end 86 | 87 | class UsersController < BaseController 88 | # GET /users 89 | def index 90 | users = User.all 91 | 92 | # Simulate a custom filter: 93 | if full_name = params[:filter] && params[:filter][:full_name] 94 | first_name, *last_name = full_name.split 95 | users = users.where(first_name: first_name, last_name: last_name.join(' ')) 96 | end 97 | 98 | jsonapi_render json: users 99 | end 100 | 101 | # GET /users/:id 102 | def show 103 | user = User.find(params[:id]) 104 | jsonapi_render json: user 105 | end 106 | 107 | # POST /users 108 | def create 109 | user = User.new(resource_params) 110 | if user.save 111 | jsonapi_render json: user, status: :created 112 | else 113 | # Example of error rendering for Array of Hashes: 114 | errors = [ 115 | { id: 'first_name', code: '100', title: 'can\'t be blank', detail: 'First name can\'t be blank' }, 116 | { id: 'last_name', code: '100', title: 'can\'t be blank', detail: 'Last name can\'t be blank' } 117 | ] 118 | jsonapi_render_errors json: errors 119 | end 120 | end 121 | 122 | # PATCH /users/:id 123 | def update 124 | user = User.find(params[:id]) 125 | if user.update(resource_params) 126 | update_relationships(user) 127 | jsonapi_render json: user 128 | else 129 | # Example of error rendering for exceptions or any object 130 | # that implements the "errors" method. 131 | jsonapi_render_errors ::Exceptions::MyCustomError.new(user) 132 | end 133 | end 134 | 135 | private 136 | 137 | def update_relationships(user) 138 | if relationship_params[:posts].present? 139 | user.post_ids = relationship_params[:posts] 140 | end 141 | end 142 | end 143 | 144 | class ProfileController < BaseController 145 | # GET /profile 146 | def show 147 | profile = Profile.new 148 | profile.id = '1234' 149 | profile.location = 'Springfield, USA' 150 | jsonapi_render json: profile 151 | end 152 | 153 | # PATCH /profile 154 | def update 155 | profile = Profile.new 156 | profile.valid? 157 | jsonapi_render_errors json: profile 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /spec/jsonapi/utils/support/pagination_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe JSONAPI::Utils::Support::Pagination do 4 | subject do 5 | OpenStruct.new(params: {}).extend(JSONAPI::Utils::Support::Pagination) 6 | end 7 | 8 | before(:all) do 9 | FactoryBot.create_list(:user, 2) 10 | end 11 | 12 | let(:options) { {} } 13 | 14 | ## 15 | # Public API 16 | ## 17 | 18 | describe '#record_count_for' do 19 | context 'with array' do 20 | let(:records) { User.all.to_a } 21 | 22 | it 'applies memoization on the record count' do 23 | expect(records).to receive(:length).and_return(records.length).once 24 | 2.times { subject.record_count_for(records, options) } 25 | end 26 | end 27 | 28 | context 'with ActiveRecord object' do 29 | let(:records) { User.all } 30 | 31 | it 'applies memoization on the record count' do 32 | expect(records).to receive(:except).and_return(records).once 33 | 2.times { subject.record_count_for(records, options) } 34 | end 35 | end 36 | end 37 | 38 | ## 39 | # Private API 40 | ## 41 | 42 | describe '#count_records' do 43 | shared_examples_for 'counting records' do 44 | it 'counts records' do 45 | expect(subject.send(:count_records, records, options)).to eq(count) 46 | end 47 | end 48 | 49 | context 'with count present within the options' do 50 | let(:records) { User.all } 51 | let(:options) { { count: 999 } } 52 | let(:count) { 999 } 53 | it_behaves_like 'counting records' 54 | end 55 | 56 | context 'with array' do 57 | let(:records) { User.all.to_a } 58 | let(:count) { records.length } 59 | it_behaves_like 'counting records' 60 | end 61 | 62 | context 'with ActiveRecord object' do 63 | let(:records) { User.all } 64 | let(:count) { records.count } 65 | it_behaves_like 'counting records' 66 | end 67 | 68 | context 'when no strategy can be applied' do 69 | let(:records) { Object.new } 70 | let(:count) { } 71 | 72 | it 'raises an error' do 73 | expect { 74 | subject.send(:count_records, records, options) 75 | }.to raise_error(JSONAPI::Utils::Support::Pagination::RecordCountError) 76 | end 77 | end 78 | end 79 | 80 | describe '#count_pages_for' do 81 | shared_examples_for 'counting pages' do 82 | it 'returns the correct page count' do 83 | allow(subject).to receive(:page_params).and_return(page_params) 84 | expect(subject.send(:page_count_for, record_count)).to eq(page_count) 85 | end 86 | end 87 | 88 | context 'with paged paginator' do 89 | let(:record_count) { 10 } 90 | let(:page_count) { 2 } 91 | let(:page_params) { { 'size' => 5 } } 92 | it_behaves_like 'counting pages' 93 | end 94 | 95 | context 'with offset paginator' do 96 | let(:record_count) { 10 } 97 | let(:page_count) { 2 } 98 | let(:page_params) { { 'limit' => 5 } } 99 | it_behaves_like 'counting pages' 100 | end 101 | 102 | context 'with 0 records' do 103 | let(:record_count) { 0 } 104 | let(:page_count) { 0 } 105 | let(:page_params) { {} } 106 | it_behaves_like 'counting pages' 107 | end 108 | 109 | context 'with no limit param' do 110 | let(:record_count) { 10 } 111 | let(:page_count) { 1 } 112 | let(:page_params) { {} } 113 | it_behaves_like 'counting pages' 114 | end 115 | end 116 | 117 | describe '#count_records_from_database' do 118 | shared_examples_for 'skipping eager load SQL when counting records' do 119 | it 'skips any eager load for the SQL count query (default)' do 120 | expect(records).to receive(:except) 121 | .with(:includes, :group, :order) 122 | .and_return(User.all) 123 | .once 124 | expect(records).to receive(:except) 125 | .with(:group, :order) 126 | .and_return(User.all) 127 | .exactly(0) 128 | .times 129 | subject.send(:count_records_from_database, records, options) 130 | end 131 | end 132 | 133 | context 'when not eager loading records' do 134 | let(:records) { User.all } 135 | it_behaves_like 'skipping eager load SQL when counting records' 136 | end 137 | 138 | context 'when eager loading records' do 139 | let(:records) { User.includes(:posts) } 140 | it_behaves_like 'skipping eager load SQL when counting records' 141 | end 142 | 143 | context 'when eager loading records and using where clause on associations' do 144 | let(:records) { User.includes(:posts).where(posts: { id: 1 }) } 145 | 146 | it 'fallbacks to the SQL count query with eager load' do 147 | expect(records).to receive(:except) 148 | .with(:includes, :group, :order) 149 | .and_raise(ActiveRecord::StatementInvalid) 150 | .once 151 | expect(records).to receive(:except) 152 | .with(:group, :order) 153 | .and_return(User.all) 154 | .once 155 | subject.send(:count_records_from_database, records, options) 156 | end 157 | end 158 | end 159 | 160 | describe '#distinct_count_sql' do 161 | let(:records) { OpenStruct.new(table_name: 'foos', primary_key: 'id') } 162 | 163 | it 'builds the distinct count SQL query' do 164 | expect(subject.send(:distinct_count_sql, records)).to eq('DISTINCT foos.id') 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /spec/support/shared/jsonapi_errors.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for 'JSON API invalid request' do 2 | context 'when request is invalid' do 3 | context 'with "include"' do 4 | context 'when resource does not exist' do 5 | it 'renders a 400 response' do 6 | get :index, params: { include: :foobar } 7 | expect(response).to have_http_status :bad_request 8 | expect(error['title']).to eq('Invalid field') 9 | expect(error['code']).to eq('112') 10 | end 11 | end 12 | end 13 | 14 | context 'with "fields"' do 15 | context 'when resource does not exist' do 16 | it 'renders a 400 response' do 17 | get :index, params: { fields: { foo: 'bar' } } 18 | expect(response).to have_http_status :bad_request 19 | expect(error['title']).to eq('Invalid resource') 20 | expect(error['code']).to eq('101') 21 | end 22 | end 23 | 24 | context 'when field does not exist' do 25 | it 'renders a 400 response' do 26 | get :index, params: { fields: { users: 'bar' } } 27 | expect(response).to have_http_status :bad_request 28 | expect(error['title']).to eq('Invalid field') 29 | expect(error['code']).to eq('104') 30 | end 31 | end 32 | end 33 | 34 | context 'with "filter"' do 35 | context 'when filter is not allowed' do 36 | it 'renders a 400 response' do 37 | get :index, params: { filter: { foo: 'bar' } } 38 | expect(response).to have_http_status :bad_request 39 | expect(error['title']).to eq('Filter not allowed') 40 | expect(error['code']).to eq('102') 41 | end 42 | end 43 | end 44 | 45 | context 'with "page"' do 46 | context 'when using "paged" paginator' do 47 | before(:all) { UserResource.paginator :paged } 48 | 49 | context 'with invalid number' do 50 | it 'renders a 400 response' do 51 | get :index, params: { page: { number: 'foo' } } 52 | expect(response).to have_http_status :bad_request 53 | expect(error['title']).to eq('Invalid page value') 54 | expect(error['code']).to eq('118') 55 | end 56 | end 57 | 58 | context 'with invalid size' do 59 | it 'renders a 400 response' do 60 | get :index, params: { page: { size: 'foo' } } 61 | expect(response).to have_http_status :bad_request 62 | expect(error['title']).to eq('Invalid page value') 63 | expect(error['code']).to eq('118') 64 | end 65 | end 66 | 67 | context 'with invalid page param' do 68 | it 'renders a 400 response' do 69 | get :index, params: { page: { offset: 1 } } 70 | expect(response).to have_http_status :ok 71 | end 72 | end 73 | 74 | context 'with a "size" greater than the max limit' do 75 | it 'returns the amount of results based on "JSONAPI.configuration.maximum_page_size"' do 76 | get :index, params: { page: { size: 999 } } 77 | expect(response).to have_http_status :bad_request 78 | expect(error['title']).to eq('Invalid page value') 79 | expect(error['code']).to eq('118') 80 | end 81 | end 82 | end 83 | 84 | context 'when using "offset" paginator' do 85 | before(:all) { UserResource.paginator :offset } 86 | 87 | context 'with invalid offset' do 88 | it 'renders a 400 response' do 89 | get :index, params: { page: { offset: -1 } } 90 | expect(response).to have_http_status :bad_request 91 | expect(error['title']).to eq('Invalid page value') 92 | expect(error['code']).to eq('118') 93 | end 94 | end 95 | 96 | context 'with invalid limit' do 97 | it 'renders a 400 response' do 98 | get :index, params: { page: { limit: 'foo' } } 99 | expect(response).to have_http_status :bad_request 100 | expect(error['title']).to eq('Invalid page value') 101 | expect(error['code']).to eq('118') 102 | end 103 | end 104 | 105 | context 'with invalid page param' do 106 | it 'renders a 400 response' do 107 | get :index, params: { page: { size: 1 } } 108 | expect(response).to have_http_status :ok 109 | end 110 | end 111 | 112 | context 'with a "limit" greater than the max limit' do 113 | it 'returns the amount of results based on "JSONAPI.configuration.maximum_page_size"' do 114 | get :index, params: { page: { limit: 999 } } 115 | expect(response).to have_http_status :bad_request 116 | expect(error['title']).to eq('Invalid page value') 117 | expect(error['code']).to eq('118') 118 | end 119 | end 120 | end 121 | 122 | context 'when using "custom" paginator' do 123 | before(:all) { UserResource.paginator :custom_offset } 124 | 125 | context 'with invalid offset' do 126 | it 'renders a 400 response' do 127 | get :index, params: { page: { offset: -1 } } 128 | expect(response).to have_http_status :bad_request 129 | expect(error['title']).to eq('Invalid page value') 130 | expect(error['code']).to eq('118') 131 | end 132 | end 133 | 134 | context 'with invalid limit' do 135 | it 'renders a 400 response' do 136 | get :index, params: { page: { limit: 'foo' } } 137 | expect(response).to have_http_status :bad_request 138 | expect(error['title']).to eq('Invalid page value') 139 | expect(error['code']).to eq('118') 140 | end 141 | end 142 | 143 | context 'with invalid page param' do 144 | it 'renders a 400 response' do 145 | get :index, params: { page: { size: 1 } } 146 | expect(response).to have_http_status :ok 147 | end 148 | end 149 | 150 | context 'with a "limit" greater than the max limit' do 151 | it 'returns the amount of results based on "JSONAPI.configuration.maximum_page_size"' do 152 | get :index, params: { page: { limit: 999 } } 153 | expect(response).to have_http_status :bad_request 154 | expect(error['title']).to eq('Invalid page value') 155 | expect(error['code']).to eq('118') 156 | end 157 | end 158 | end 159 | end 160 | 161 | context 'with "sort"' do 162 | context 'when sort criteria is invalid' do 163 | it 'renders a 400 response' do 164 | get :index, params: { sort: 'foo' } 165 | expect(response).to have_http_status :bad_request 166 | expect(error['title']).to eq('Invalid sort criteria') 167 | expect(error['code']).to eq('114') 168 | end 169 | end 170 | end 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /lib/jsonapi/utils/exceptions/active_record.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | module Utils 3 | module Exceptions 4 | class ActiveRecord < ::JSONAPI::Exceptions::Error 5 | attr_reader :object, :resource, :relationships, :relationship_names, :foreign_keys 6 | 7 | # Construct an error decorator over ActiveRecord objects. 8 | # 9 | # @param object [ActiveRecord::Base] Invalid ActiveRecord object. 10 | # e.g.: User.new(name: nil).tap(&:save) 11 | # 12 | # @param resource_klass [JSONAPI::Resource] Resource class to be used for reflection. 13 | # e.g.: UserResuource 14 | # 15 | # @return [JSONAPI::Utils::Exceptions::ActiveRecord] 16 | # 17 | # @api public 18 | def initialize(object, resource_klass, context) 19 | @object = object 20 | @resource = resource_klass.new(object, context) 21 | 22 | # Need to reflect on resource's relationships for error reporting. 23 | @relationships = resource_klass._relationships.values 24 | @relationship_names = @relationships.map(&:name).map(&:to_sym) 25 | @foreign_keys = @relationships.map(&:foreign_key).map(&:to_sym) 26 | @resource_key_for = {} 27 | @formatted_key = {} 28 | end 29 | 30 | # Decorate errors for AR invalid objects. 31 | # 32 | # @note That's the method used by formatters to build the response's error body. 33 | # 34 | # @return [Array] 35 | # 36 | # @api public 37 | def errors 38 | object.errors.messages.flat_map do |field, messages| 39 | messages.map.with_index do |message, index| 40 | build_error(field, message, index) 41 | end 42 | end 43 | end 44 | 45 | private 46 | 47 | # Turn AR error into JSONAPI::Error. 48 | # 49 | # @param field [Symbol] Name of the invalid field 50 | # e.g.: :title 51 | # 52 | # @param message [String] Error message 53 | # e.g.: "can't be blank" 54 | # 55 | # @param index [Integer] Index of the error detail 56 | # 57 | # @return [JSONAPI::Error] 58 | # 59 | # @api private 60 | def build_error(field, message, index = 0) 61 | error = error_base 62 | .merge( 63 | id: id_member(field, index), 64 | title: message, 65 | detail: detail_member(field, message) 66 | ).merge(source_member(field)) 67 | JSONAPI::Error.new(error) 68 | end 69 | 70 | # Build the "id" member value for the JSON API error object. 71 | # e.g.: for :first_name, :too_short => "first-name#too-short" 72 | # 73 | # @note The returned value depends on the key formatter type defined 74 | # via configuration, e.g.: config.json_key_format = :dasherized_key 75 | # 76 | # @param field [Symbol] Name of the invalid field 77 | # e.g.: :first_name 78 | # 79 | # @param index [Integer] Index of the error detail 80 | # 81 | # @return [String] 82 | # 83 | # @api private 84 | def id_member(field, index) 85 | [ 86 | key_format(field), 87 | key_format( 88 | object.errors.details 89 | .dig(field, index, :error) 90 | .to_s.downcase 91 | .split 92 | .join('_') 93 | ) 94 | ].join('#') 95 | end 96 | 97 | # Bring the formatted resource key for a given field. 98 | # e.g.: for :first_name => :"first-name" 99 | # 100 | # @note The returned value depends on the key formatter type defined 101 | # via configuration, e.g.: config.json_key_format = :dasherized_key 102 | # 103 | # @param field [Symbol] Name of the invalid field 104 | # e.g.: :title 105 | # 106 | # @return [Symbol] 107 | # 108 | # @api private 109 | def key_format(field) 110 | @formatted_key[field] ||= JSONAPI.configuration 111 | .key_formatter 112 | .format(resource_key_for(field)) 113 | .to_sym 114 | end 115 | 116 | # Build the "source" member value for the JSON API error object. 117 | # e.g.: :title => "/data/attributes/title" 118 | # 119 | # @param field [Symbol] Name of the invalid field 120 | # e.g.: :title 121 | # 122 | # @return [Hash] 123 | # 124 | # @api private 125 | def source_member(field) 126 | resource_key = resource_key_for(field) 127 | return {} unless field == :base || resource.fetchable_fields.include?(resource_key) 128 | id = key_format(field) 129 | 130 | pointer = 131 | if field == :base then '/data' 132 | elsif relationship_names.include?(resource_key) then "/data/relationships/#{id}" 133 | else "/data/attributes/#{id}" 134 | end 135 | 136 | { source: { pointer: pointer } } 137 | end 138 | 139 | # Build the "detail" member value for the JSON API error object. 140 | # e.g.: :first_name, "can't be blank" => "First name can't be blank" 141 | # 142 | # @param field [Symbol] Name of the invalid field 143 | # e.g.: :first_name 144 | # 145 | # @return [String] 146 | # 147 | # @api private 148 | def detail_member(field, message) 149 | return message if field == :base 150 | resource_key = resource_key_for(field) 151 | [translation_for(resource_key), message].join(' ') 152 | end 153 | 154 | # Return the resource's attribute or relationship key name for a given field name. 155 | # e.g.: :title => :title, :user_id => :author 156 | # 157 | # @param field [Symbol] Name of the invalid field 158 | # e.g.: :title 159 | # 160 | # @return [Symbol] 161 | # 162 | # @api private 163 | def resource_key_for(field) 164 | @resource_key_for[field] ||= begin 165 | return field unless foreign_keys.include?(field) 166 | relationships.find { |r| r.foreign_key == field }.name.to_sym 167 | end 168 | end 169 | 170 | # Turn the field name into human-friendly one. 171 | # e.g.: :first_name => "First name" 172 | # 173 | # @param field [Symbol] Name of the invalid field 174 | # e.g.: :first_name 175 | # 176 | # @return [String] 177 | # 178 | # @api private 179 | def translation_for(field) 180 | object.class.human_attribute_name(field) 181 | end 182 | 183 | # Return the base data used for all errors of this kind. 184 | # 185 | # @return [Hash] 186 | # 187 | # @api private 188 | def error_base 189 | { 190 | code: JSONAPI::VALIDATION_ERROR, 191 | status: :unprocessable_entity 192 | } 193 | end 194 | end 195 | end 196 | end 197 | end 198 | -------------------------------------------------------------------------------- /lib/jsonapi/utils/support/pagination.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | module Utils 3 | module Support 4 | module Pagination 5 | RecordCountError = Class.new(ArgumentError) 6 | 7 | # Check whether pagination links should be included. 8 | # 9 | # @api public 10 | # @return [Boolean] 11 | def include_pagination_links? 12 | JSONAPI.configuration.default_paginator != :none && 13 | JSONAPI.configuration.top_level_links_include_pagination 14 | end 15 | 16 | # Check whether pagination's page count should be included 17 | # on the "meta" key. 18 | # 19 | # @api public 20 | # @return [Boolean] 21 | def include_page_count? 22 | JSONAPI.configuration.top_level_meta_include_page_count 23 | end 24 | 25 | # Apply proper pagination to the records. 26 | # 27 | # @param records [ActiveRecord::Relation, Array] collection of records 28 | # e.g.: User.all or [{ id: 1, name: 'Tiago' }, { id: 2, name: 'Doug' }] 29 | # 30 | # @param options [Hash] JSONAPI::Utils' options 31 | # e.g.: { resource: V2::UserResource, count: 100 } 32 | # 33 | # @return [ActiveRecord::Relation, Array] 34 | # 35 | # @api public 36 | def apply_pagination(records, options = {}) 37 | if !apply_pagination?(options) then records 38 | elsif records.is_a?(Array) then records[paginate_with(:range)] 39 | else paginate_with(:paginator).apply(records, nil) 40 | end 41 | end 42 | 43 | # Mount pagination params for JSONAPI::ResourcesOperationResult. 44 | # It can also be used anywhere else as a helper method. 45 | # 46 | # @param records [ActiveRecord::Relation, Array] collection of records 47 | # e.g.: User.all or [{ id: 1, name: 'Tiago' }, { id: 2, name: 'Doug' }] 48 | # 49 | # @param options [Hash] JU's options 50 | # e.g.: { resource: V2::UserResource, count: 100 } 51 | # 52 | # @return [Hash] 53 | # e.g.: {"first"=>{"number"=>1, "size"=>2}, "next"=>{"number"=>2, "size"=>2}, "last"=>{"number"=>2, "size"=>2}} 54 | # 55 | # 56 | # @api public 57 | def pagination_params(records, options) 58 | return {} unless include_pagination_links? 59 | paginator.links_page_params(record_count: record_count_for(records, options)) 60 | end 61 | 62 | # Apply memoization to the record count result avoiding duplicate counts. 63 | # 64 | # @param records [ActiveRecord::Relation, Array] collection of records 65 | # e.g.: User.all or [{ id: 1, name: 'Tiago' }, { id: 2, name: 'Doug' }] 66 | # 67 | # @param options [Hash] JU's options 68 | # e.g.: { resource: V2::UserResource, count: 100 } 69 | # 70 | # @return [Integer] 71 | # e.g.: 42 72 | # 73 | # @api public 74 | def record_count_for(records, options) 75 | @record_count ||= count_records(records, options) 76 | end 77 | 78 | private 79 | 80 | # Define the paginator object to be used in the response's pagination. 81 | # 82 | # @return [PagedPaginator, OffsetPaginator] 83 | # 84 | # @api private 85 | def paginator 86 | @paginator ||= paginator_klass.new(page_params) 87 | end 88 | 89 | # Return the paginator class to be used in the response's pagination. 90 | # 91 | # @return [Paginator] 92 | # 93 | # @api private 94 | def paginator_klass 95 | "#{JSONAPI.configuration.default_paginator}_paginator".classify.constantize 96 | end 97 | 98 | # Check whether pagination should be applied to the response. 99 | # 100 | # @return [Boolean] 101 | # 102 | # @api private 103 | def apply_pagination?(options) 104 | JSONAPI.configuration.default_paginator != :none && 105 | (options[:paginate].nil? || options[:paginate]) 106 | end 107 | 108 | # Creates an instance of ActionController::Parameters for page params. 109 | # 110 | # @return [ActionController::Parameters] 111 | # 112 | # @api private 113 | def page_params 114 | @page_params ||= begin 115 | page = @request.params.to_unsafe_hash['page'] || {} 116 | ActionController::Parameters.new(page) 117 | end 118 | end 119 | 120 | # Define the paginator or range according to the pagination strategy. 121 | # 122 | # @param kind [Symbol] pagination object's kind 123 | # e.g.: :paginator or :range 124 | # 125 | # @return [PagedPaginator, OffsetPaginator, Range] 126 | # e.g.: # 127 | # 0..9 128 | # 129 | # @api private 130 | def paginate_with(kind) 131 | @pagination ||= 132 | case kind 133 | when :paginator then paginator 134 | when :range then pagination_range 135 | end 136 | end 137 | 138 | # Define a pagination range for objects which quack like Arrays. 139 | # 140 | # @return [Range] 141 | # e.g.: 0..9 142 | # 143 | # @api private 144 | def pagination_range 145 | case JSONAPI.configuration.default_paginator 146 | when :paged 147 | number = page_params['number'].to_i.nonzero? || 1 148 | size = page_params['size'].to_i.nonzero? || JSONAPI.configuration.default_page_size 149 | (number - 1) * size..number * size - 1 150 | when :offset 151 | offset = page_params['offset'].to_i.nonzero? || 0 152 | limit = page_params['limit'].to_i.nonzero? || JSONAPI.configuration.default_page_size 153 | offset..offset + limit - 1 154 | else 155 | paginator.pagination_range(page_params) 156 | end 157 | end 158 | 159 | # Count records in order to build a proper pagination and to fill up the "record_count" response's member. 160 | # 161 | # @param records [ActiveRecord::Relation, Array] collection of records 162 | # e.g.: User.all or [{ id: 1, name: 'Tiago' }, { id: 2, name: 'Doug' }] 163 | # 164 | # @param options [Hash] JU's options 165 | # e.g.: { resource: V2::UserResource, count: 100 } 166 | # 167 | # @return [Integer] 168 | # e.g.: 42 169 | # 170 | # @api private 171 | def count_records(records, options) 172 | return options[:count].to_i if options[:count].is_a?(Numeric) 173 | 174 | case records 175 | when ActiveRecord::Relation then count_records_from_database(records, options) 176 | when Array then records.length 177 | else raise RecordCountError, "Can't count records with the given options" 178 | end 179 | end 180 | 181 | # Count pages in order to build a proper pagination and to fill up the "page_count" response's member. 182 | # 183 | # @param record_count [Integer] number of records 184 | # e.g.: 42 185 | # 186 | # @return [Integer] 187 | # e.g 5 188 | # 189 | # @api private 190 | def page_count_for(record_count) 191 | return 0 if record_count.to_i < 1 192 | 193 | size = (page_params['size'] || page_params['limit']).to_i 194 | size = JSONAPI.configuration.default_page_size unless size.nonzero? 195 | (record_count.to_f / size).ceil 196 | end 197 | 198 | # Count records from the datatase applying the given request filters 199 | # and skipping things like eager loading, grouping and sorting. 200 | # 201 | # @param records [ActiveRecord::Relation, Array] collection of records 202 | # e.g.: User.all or [{ id: 1, name: 'Tiago' }, { id: 2, name: 'Doug' }] 203 | # 204 | # @param options [Hash] JU's options 205 | # e.g.: { resource: V2::UserResource, count: 100 } 206 | # 207 | # @return [Integer] 208 | # e.g.: 42 209 | # 210 | # @api private 211 | def count_records_from_database(records, options) 212 | records = apply_filter(records, options) if params[:filter].present? 213 | count = -> (records, except:) do 214 | records.except(*except).count(distinct_count_sql(records)) 215 | end 216 | count.(records, except: %i(includes group order)) 217 | rescue ActiveRecord::StatementInvalid 218 | count.(records, except: %i(group order)) 219 | end 220 | 221 | # Build the SQL distinct count with some reflection on the "records" object. 222 | # 223 | # @param records [ActiveRecord::Relation] collection of records 224 | # e.g.: User.all 225 | # 226 | # @return [String] 227 | # e.g.: "DISTINCT users.id" 228 | # 229 | # @api private 230 | def distinct_count_sql(records) 231 | "DISTINCT #{records.table_name}.#{records.primary_key}" 232 | end 233 | end 234 | end 235 | end 236 | end 237 | -------------------------------------------------------------------------------- /lib/jsonapi/utils/response/formatters.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | module Utils 3 | module Response 4 | module Formatters 5 | # Helper method to format ActiveRecord or Hash objects into JSON API-compliant ones. 6 | # 7 | # @note The return of this method represents what will actually be displayed in the response body. 8 | # @note It can also be called as #jsonapi_serialize due to backward compatibility issues. 9 | # 10 | # @param object [ActiveRecord::Base, ActiveRecord::Relation, Hash, Array] 11 | # Object to be formatted into JSON 12 | # e.g.: User.first, User.all, { data: { id: 1, first_name: 'Tiago' } }, 13 | # [{ data: { id: 1, first_name: 'Tiago' } }] 14 | # 15 | # @option options [JSONAPI::Resource] resource: it tells the formatter which resource 16 | # class to be used rather than use an infered one (default behaviour) 17 | # 18 | # @option options [JSONAPI::Resource] source: it tells the formatter that this response is from a related resource 19 | # and the result should be interpreted as a related resources response 20 | # 21 | # @option options [String, Symbol] relationship_type: it tells that the formatter which relationship the data is from 22 | # 23 | # @option options [ActiveRecord::Base] model: ActiveRecord model class to be instantiated 24 | # when a Hash or Array of Hashes is passed as the "object" argument 25 | # 26 | # @option options [Integer] count: if it's rendering a collection of resources, the default 27 | # gem's counting method can be bypassed by the use of this options. It's shows then the total 28 | # records resulting from that request and also calculates the pagination. 29 | # 30 | # @return [Hash] 31 | # 32 | # @api public 33 | def jsonapi_format(object, options = {}) 34 | if object.is_a?(Hash) 35 | hash = object.with_indifferent_access 36 | object = hash_to_active_record(hash[:data], options[:model]) 37 | end 38 | fix_custom_request_options(object) 39 | build_response_document(object, options).contents 40 | end 41 | 42 | alias_method :jsonapi_serialize, :jsonapi_format 43 | 44 | # Helper method to format ActiveRecord or any object that responds to #errors 45 | # into JSON API-compliant error response bodies. 46 | # 47 | # @note The return of this method represents what will actually be displayed in the response body. 48 | # @note It can also be called as #jsonapi_serialize_errors due to backward compatibility issues. 49 | # 50 | # @param object [ActiveRecord::Base or any object that responds to #errors] 51 | # Error object to be serialized into JSON 52 | # e.g.: User.new(name: nil).tap(&:save), MyErrorDecorator.new(invalid_object) 53 | # 54 | # @return [Array] 55 | # 56 | # @api public 57 | def jsonapi_format_errors(object) 58 | if active_record_obj?(object) 59 | object = JSONAPI::Utils::Exceptions::ActiveRecord.new(object, @request.resource_klass, context) 60 | end 61 | errors = object.respond_to?(:errors) ? object.errors : object 62 | JSONAPI::Utils::Support::Error.sanitize(errors).uniq 63 | end 64 | 65 | alias_method :jsonapi_serialize_errors, :jsonapi_format_errors 66 | 67 | private 68 | 69 | # Check whether the given object is an ActiveRecord-like one. 70 | # 71 | # @param object [Object] Object to be checked 72 | # 73 | # @return [TrueClass, FalseClass] 74 | # 75 | # @api private 76 | def active_record_obj?(object) 77 | defined?(ActiveRecord::Base) && 78 | (object.is_a?(ActiveRecord::Base) || 79 | object.singleton_class.include?(ActiveModel::Model)) 80 | end 81 | 82 | # Build the full response document. 83 | # 84 | # @param object [ActiveRecord::Base, ActiveRecord::Relation, Hash, Array] 85 | # Object to be formatted into JSON. 86 | # 87 | # @option options [JSONAPI::Resource] :resource which resource class to be used 88 | # rather than using the default one (inferred) 89 | # 90 | # @option options [ActiveRecord::Base, JSONAPI::Resource] :source source of related resource, 91 | # the result should be interpreted as a related resources response 92 | # 93 | # @option options [String, Symbol] :relationship which relationship the data is from 94 | # 95 | # @option options [Integer] count: if it's rendering a collection of resources, the default 96 | # gem's counting method can be bypassed by the use of this options. It's shows then the total 97 | # records resulting from that request and also calculates the pagination. 98 | # 99 | # @return [JSONAPI::ResponseDocument] 100 | # 101 | # @api private 102 | def build_response_document(object, options) 103 | results = JSONAPI::OperationResults.new 104 | 105 | if object.respond_to?(:to_ary) 106 | results.add_result(build_collection_result(object, options)) 107 | else 108 | record = turn_into_resource(object, options) 109 | results.add_result(JSONAPI::ResourceOperationResult.new(:ok, record)) 110 | end 111 | 112 | @_response_document = create_response_document(results) 113 | end 114 | 115 | # Build the result operation object for collection actions. 116 | # 117 | # @param object [ActiveRecord::Relation, Array] 118 | # Object to be formatted into JSON. 119 | # 120 | # @option options [JSONAPI::Resource] :resource which resource class to be used 121 | # rather than using the default one (inferred) 122 | # 123 | # @option options [ActiveRecord::Base, JSONAPI::Resource] :source parent model/resource 124 | # of the related resource 125 | # 126 | # @option options [String, Symbol] :relationship which relationship the data is from 127 | # 128 | # @option options [Integer] count: if it's rendering a collection of resources, the default 129 | # gem's counting method can be bypassed by the use of this options. It's shows then the total 130 | # records resulting from that request and also calculates the pagination. 131 | # 132 | # @return [JSONAPI::ResourcesOperationResult, JSONAPI::RelatedResourcesOperationResult] 133 | # 134 | # @api private 135 | def build_collection_result(object, options) 136 | records = build_collection(object, options) 137 | result_options = result_options(object, options) 138 | 139 | if related_resource_operation?(options) 140 | source_resource = turn_source_into_resource(options[:source]) 141 | relationship_type = get_source_relationship(options) 142 | 143 | JSONAPI::RelatedResourcesOperationResult.new( 144 | :ok, 145 | source_resource, 146 | relationship_type, 147 | records, 148 | result_options 149 | ) 150 | else 151 | JSONAPI::ResourcesOperationResult.new(:ok, records, result_options) 152 | end 153 | end 154 | 155 | # Is this a request for related resources? 156 | # 157 | # In order to answer that it needs to check for some {options} 158 | # controller params like {params[:source]} and {params[:relationship]}. 159 | # 160 | # @option options [Boolean] :related when true, jsonapi-utils infers the parent and 161 | # related resources from controller's {params} values. 162 | # 163 | # @option options [ActiveRecord::Base, JSONAPI::Resource] :source parent model/resource 164 | # of the related resource 165 | # 166 | # @option options [String, Symbol] :relationship which relationship the data is from 167 | # 168 | # @return [Boolean] 169 | # 170 | # @api private 171 | def related_resource_operation?(options) 172 | (options[:related] || options[:source].present?) && 173 | params[:source].present? && 174 | params[:relationship].present? 175 | end 176 | 177 | # Apply a proper action setup for custom requests/actions. 178 | # 179 | # @note The setup_(index|show)_action comes from JSONAPI::Resources' API. 180 | # 181 | # @param object [ActiveRecord::Base, ActiveRecord::Relation, Hash, Array] 182 | # It's checked whether this object refers to a collection or not. 183 | # 184 | # @api private 185 | def fix_custom_request_options(object) 186 | return unless custom_get_request_with_params? 187 | action = object.respond_to?(:to_ary) ? 'index' : 'show' 188 | @request.send("setup_#{action}_action", params) 189 | end 190 | 191 | # Check whether it's a custom GET request with params. 192 | # 193 | # @return [TrueClass, FalseClass] 194 | # 195 | # @api private 196 | def custom_get_request_with_params? 197 | request.method =~ /get/i && !%w(index show).include?(params[:action]) && !params.nil? 198 | end 199 | 200 | # Turn a collection of AR or Hash objects into a collection of JSONAPI::Resource ones. 201 | # 202 | # @param records [ActiveRecord::Relation, Hash, Array] 203 | # Objects to be instantiated as JSONAPI::Resource ones. 204 | # e.g.: User.all, [{ data: { id: 1, first_name: 'Tiago' } }] 205 | # 206 | # @option options [JSONAPI::Resource] :resource it resource class to be used rather than default one (infered) 207 | # 208 | # @option options [Integer] :count if it's rendering a collection of resources, the default 209 | # gem's counting method can be bypassed by the use of this options. It's shows then the total 210 | # records resulting from that request and also calculates the pagination. 211 | # 212 | # @return [Array] 213 | # 214 | # @api private 215 | def build_collection(records, options) 216 | records = apply_filter(records, options) 217 | records = apply_sort(records) 218 | records = apply_pagination(records, options) 219 | records.respond_to?(:to_ary) ? records.map { |record| turn_into_resource(record, options) } : [] 220 | end 221 | 222 | # Turn an AR or Hash object into a JSONAPI::Resource one. 223 | # 224 | # @param records [ActiveRecord::Relation, Hash, Array] 225 | # Object to be instantiated as a JSONAPI::Resource one. 226 | # e.g.: User.first, { data: { id: 1, first_name: 'Tiago' } } 227 | # 228 | # @option options [JSONAPI::Resource] resource: it tells which resource 229 | # class to be used rather than use an infered one (default behaviour) 230 | # 231 | # @return [JSONAPI::Resource] 232 | # 233 | # @api private 234 | def turn_into_resource(record, options) 235 | if options[:resource] 236 | options[:resource].to_s.constantize.new(record, context) 237 | else 238 | @request.resource_klass.new(record, context) 239 | end 240 | end 241 | 242 | # Get JSONAPI::Resource for source object 243 | # 244 | # @param record [ActiveRecord::Base, JSONAPI::Resource] 245 | # 246 | # @return [JSONAPI::Resource] 247 | # 248 | # @api private 249 | def turn_source_into_resource(record) 250 | return record if record.kind_of?(JSONAPI::Resource) 251 | @request.source_klass.new(record, context) 252 | end 253 | 254 | # Get relationship type of source object 255 | # 256 | # @option options [Symbol] relationship: it tells which relationship 257 | # to be used rather than use an infered one (default behaviour) 258 | # 259 | # @return [Symbol] 260 | # 261 | # @api private 262 | def get_source_relationship(options) 263 | options[:relationship]&.to_sym || @request.resource_klass._type 264 | end 265 | 266 | # Apply some result options like pagination params and record count to collection responses. 267 | # 268 | # @param records [ActiveRecord::Relation, Hash, Array] 269 | # Object to be formatted into JSON 270 | # e.g.: User.all, [{ data: { id: 1, first_name: 'Tiago' } }] 271 | # 272 | # @option options [Integer] count: if it's rendering a collection of resources, the default 273 | # gem's counting method can be bypassed by the use of this options. It's shows then the total 274 | # records resulting from that request and also calculates the pagination. 275 | # 276 | # @return [Hash] 277 | # 278 | # @api private 279 | def result_options(records, options) 280 | {}.tap do |data| 281 | if include_pagination_links? 282 | data[:pagination_params] = pagination_params(records, options) 283 | end 284 | 285 | if JSONAPI.configuration.top_level_meta_include_record_count 286 | data[:record_count] = record_count_for(records, options) 287 | end 288 | 289 | if include_page_count? 290 | data[:page_count] = page_count_for(data[:record_count]) 291 | end 292 | end 293 | end 294 | 295 | # Convert Hash or collection of Hashes into AR objects. 296 | # 297 | # @param data [Hash, Array] Hash or collection to be converted 298 | # e.g.: { data: { id: 1, first_name: 'Tiago' } }, 299 | # [{ data: { id: 1, first_name: 'Tiago' } }], 300 | # 301 | # @option options [ActiveRecord::Base] model: ActiveRecord model class to be 302 | # used as base for the objects' intantialization. 303 | # 304 | # @return [ActiveRecord::Base, ActiveRecord::Relation] 305 | # 306 | # @api private 307 | def hash_to_active_record(data, model) 308 | return data if model.nil? 309 | coerced = [data].flatten.map { |hash| model.new(hash) } 310 | data.is_a?(Array) ? coerced : coerced.first 311 | rescue ActiveRecord::UnknownAttributeError 312 | if data.is_a?(Array) 313 | ids = data.map { |e| e[:id] } 314 | model.where(id: ids) 315 | else 316 | model.find_by(id: data[:id]) 317 | end 318 | end 319 | end 320 | end 321 | end 322 | end 323 | -------------------------------------------------------------------------------- /spec/controllers/posts_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe PostsController, type: :controller do 4 | include_context 'JSON API headers' 5 | 6 | before(:all) do 7 | @post = FactoryBot.create_list(:post, 3).first 8 | end 9 | 10 | before(:each) do 11 | JSONAPI.configuration.json_key_format = :underscored_key 12 | end 13 | 14 | let(:relationships) { PostResource._relationships.keys.map(&:to_s) } 15 | let(:fields) { PostResource.fields.reject { |e| e == :id }.map(&:to_s) - relationships } 16 | let(:blog_post) { @post } 17 | let(:parent_id) { blog_post.user_id } 18 | let(:category_id) { blog_post.category_id } 19 | 20 | let(:attributes) do 21 | { title: 'Lorem ipsum', body: 'Lorem ipsum dolor sit amet.', content_type: 'article' } 22 | end 23 | 24 | let(:author) do 25 | { data: { type: 'users', id: parent_id } } 26 | end 27 | 28 | let(:category) do 29 | { data: { type: 'categories', id: category_id } } 30 | end 31 | 32 | let(:body) do 33 | { 34 | data: { 35 | type: 'posts', 36 | attributes: attributes, 37 | relationships: { author: author, category: category } 38 | } 39 | } 40 | end 41 | 42 | describe 'GET #index' do 43 | subject { get :index, params: params } 44 | 45 | let(:params) { { user_id: parent_id } } 46 | 47 | context 'with ActiveRecord::Relation' do 48 | it 'renders a collection of users' do 49 | expect(subject).to have_http_status :ok 50 | expect(subject).to have_primary_data('posts') 51 | expect(subject).to have_data_attributes(fields) 52 | expect(subject).to have_relationships(relationships) 53 | expect(subject).to have_meta_record_count(100) 54 | end 55 | end 56 | 57 | context 'with Hash' do 58 | subject { get :index_with_hash, params: params } 59 | 60 | it 'renders a collection of users' do 61 | expect(subject).to have_http_status :ok 62 | expect(subject).to have_primary_data('posts') 63 | expect(subject).to have_data_attributes(fields) 64 | expect(subject).to have_relationships(relationships) 65 | end 66 | 67 | context 'with sort' do 68 | let(:params) { { user_id: parent_id, sort: 'title,-body' } } 69 | 70 | it 'sorts Hashes by asc/desc order' do 71 | expect(subject).to have_http_status :ok 72 | 73 | sorted_data = data.sort do |a, b| 74 | comp = a.dig('attributes', 'title') <=> b.dig('attributes', 'title') 75 | comp == 0 ? b.dig('attributes', 'body') <=> a.dig('attributes', 'body') : comp 76 | end 77 | 78 | expect(data).to eq(sorted_data) 79 | end 80 | end 81 | 82 | context 'when using custom global paginator' do 83 | before(:all) do 84 | JSONAPI.configuration.default_paginator = :custom_offset 85 | end 86 | 87 | let(:params) { { user_id: parent_id, page: { offset: offset, limit: limit } } } 88 | let(:offset) { 0 } 89 | let(:limit) { 2 } 90 | 91 | it 'returns paginated results' do 92 | expect(subject).to have_http_status :ok 93 | 94 | expect(data.size).to eq(2) 95 | expect(response).to have_meta_record_count(4) 96 | 97 | expect(json.dig('meta', 'page_count')).to be(2) 98 | expect(json.dig('links', 'first')).to be_present 99 | expect(json.dig('links', 'next')).to be_present 100 | expect(json.dig('links', 'last')).to be_present 101 | end 102 | 103 | context 'at the middle' do 104 | let(:offset) { 1 } 105 | let(:limit) { 1 } 106 | 107 | it 'returns paginated results' do 108 | expect(subject).to have_http_status :ok 109 | 110 | expect(data.size).to eq(1) 111 | expect(response).to have_meta_record_count(4) 112 | 113 | expect(json.dig('meta', 'page_count')).to be(4) 114 | expect(json.dig('links', 'first')).to be_present 115 | expect(json.dig('links', 'prev')).to be_present 116 | expect(json.dig('links', 'next')).to be_present 117 | expect(json.dig('links', 'last')).to be_present 118 | end 119 | end 120 | 121 | context 'at the last page' do 122 | let(:offset) { 3 } 123 | let(:limit) { 1 } 124 | 125 | it 'returns the paginated results' do 126 | expect(subject).to have_http_status :ok 127 | expect(subject).to have_meta_record_count(4) 128 | 129 | expect(data.size).to eq(1) 130 | 131 | expect(json.dig('meta', 'page_count')).to be(4) 132 | expect(json.dig('links', 'first')).to be_present 133 | expect(json.dig('links', 'prev')).to be_present 134 | expect(json.dig('links', 'next')).not_to be_present 135 | expect(json.dig('links', 'last')).to be_present 136 | end 137 | end 138 | 139 | context 'without "limit"' do 140 | let(:offset) { 1 } 141 | 142 | before { params[:page].delete(:limit) } 143 | 144 | it 'returns the amount of results based on "JSONAPI.configuration.default_page_size"' do 145 | expect(subject).to have_http_status :ok 146 | expect(subject).to have_meta_record_count(4) 147 | expect(data.size).to be <= JSONAPI.configuration.default_page_size 148 | expect(json.dig('meta', 'page_count')).to be(1) 149 | end 150 | end 151 | end 152 | end 153 | end 154 | 155 | describe 'GET #show' do 156 | context 'with ActiveRecord' do 157 | subject { get :show, params: { id: blog_post.id } } 158 | 159 | it 'renders a single post' do 160 | expect(subject).to have_http_status :ok 161 | expect(subject).to have_primary_data('posts') 162 | expect(subject).to have_data_attributes(fields) 163 | expect(subject).to have_relationships(relationships) 164 | expect(data.dig('attributes', 'title')).to eq("Title for Post #{blog_post.id}") 165 | end 166 | end 167 | 168 | context 'with Hash' do 169 | subject { get :show_with_hash, params: { id: blog_post.id } } 170 | 171 | it 'renders a single post' do 172 | expect(subject).to have_http_status :ok 173 | expect(subject).to have_primary_data('posts') 174 | expect(subject).to have_data_attributes(fields) 175 | expect(json).to_not have_key('relationships') 176 | expect(data.dig('attributes', 'title')).to eq('Lorem ipsum') 177 | end 178 | end 179 | 180 | context 'when resource was not found' do 181 | context 'with conventional id' do 182 | subject { get :show, params: { id: 999 } } 183 | 184 | it 'renders a 404 response' do 185 | expect(subject).to have_http_status :not_found 186 | expect(error['title']).to eq('Record not found') 187 | expect(error['detail']).to include('999') 188 | expect(error['code']).to eq('404') 189 | end 190 | end 191 | 192 | context 'with uuid' do 193 | subject { get :show, params: { id: uuid } } 194 | 195 | let(:uuid) { SecureRandom.uuid } 196 | 197 | it 'renders a 404 response' do 198 | expect(subject).to have_http_status :not_found 199 | expect(error['title']).to eq('Record not found') 200 | expect(error['detail']).to include(uuid) 201 | expect(error['code']).to eq('404') 202 | end 203 | end 204 | 205 | context 'with slug' do 206 | subject { get :show, params: { id: slug } } 207 | 208 | let(:slug) { 'some-awesome-slug' } 209 | 210 | it 'renders a 404 response' do 211 | expect(subject).to have_http_status :not_found 212 | expect(error['title']).to eq('Record not found') 213 | expect(error['detail']).to include(slug) 214 | expect(error['code']).to eq('404') 215 | end 216 | end 217 | end 218 | end 219 | 220 | describe 'POST #create' do 221 | subject { post :create, params: params.merge(body) } 222 | 223 | let (:params) { { user_id: parent_id } } 224 | 225 | it 'creates a new post' do 226 | expect { subject }.to change(Post, :count).by(1) 227 | expect(subject).to have_http_status :created 228 | expect(subject).to have_primary_data('posts') 229 | expect(subject).to have_data_attributes(fields) 230 | expect(data.dig('attributes', 'title')).to eq(body.dig(:data, :attributes, :title)) 231 | end 232 | 233 | context 'when validation fails on an attribute' do 234 | subject { post :create, params: params.merge(invalid_body) } 235 | 236 | let(:invalid_body) do 237 | body.tap { |b| b[:data][:attributes][:title] = nil } 238 | end 239 | 240 | it 'renders a 422 response' do 241 | expect { subject }.to change(Post, :count).by(0) 242 | expect(response).to have_http_status :unprocessable_entity 243 | expect(errors.dig(0, 'id')).to eq('title#blank') 244 | expect(errors.dig(0, 'title')).to eq('can\'t be blank') 245 | expect(errors.dig(0, 'detail')).to eq('Title can\'t be blank') 246 | expect(errors.dig(0, 'code')).to eq('100') 247 | expect(errors.dig(0, 'source', 'pointer')).to eq('/data/attributes/title') 248 | end 249 | end 250 | 251 | context 'when validation fails on a relationship' do 252 | subject { post :create, params: params.merge(invalid_body) } 253 | 254 | let(:invalid_body) do 255 | body.tap { |b| b[:data][:relationships][:author] = nil } 256 | end 257 | 258 | it 'renders a 422 response' do 259 | expect { subject }.to change(Post, :count).by(0) 260 | expect(subject).to have_http_status :unprocessable_entity 261 | 262 | expect(errors.dig(0, 'id')).to eq('author#blank') 263 | expect(errors.dig(0, 'title')).to eq('can\'t be blank') 264 | expect(errors.dig(0, 'detail')).to eq('Author can\'t be blank') 265 | expect(errors.dig(0, 'code')).to eq('100') 266 | expect(errors.dig(0, 'source', 'pointer')).to eq('/data/relationships/author') 267 | end 268 | end 269 | 270 | context 'when validation fails on a foreign key' do 271 | subject { post :create, params: params.merge(invalid_body) } 272 | 273 | let(:invalid_body) do 274 | body.tap { |b| b[:data][:relationships][:category] = nil } 275 | end 276 | 277 | it 'renders a 422 response' do 278 | expect { subject }.to change(Post, :count).by(0) 279 | expect(subject).to have_http_status :unprocessable_entity 280 | 281 | expect(errors.dig(0, 'id')).to eq('category#blank') 282 | expect(errors.dig(0, 'title')).to eq('can\'t be blank') 283 | expect(errors.dig(0, 'detail')).to eq('Category can\'t be blank') 284 | expect(errors.dig(0, 'code')).to eq('100') 285 | expect(errors.dig(0, 'source', 'pointer')).to eq('/data/relationships/category') 286 | end 287 | end 288 | 289 | context 'when validation fails on a private attribute' do 290 | subject { post :create, params: params.merge(invalid_body) } 291 | 292 | let(:invalid_body) do 293 | body.tap { |body| body[:data][:attributes][:title] = 'Fail Hidden' } 294 | end 295 | 296 | it 'renders a 422 response' do 297 | expect { subject }.to change(Post, :count).by(0) 298 | expect(subject).to have_http_status :unprocessable_entity 299 | 300 | expect(errors.dig(0, 'id')).to eq('hidden_field#error_was_tripped') 301 | expect(errors.dig(0, 'title')).to eq('error was tripped') 302 | expect(errors.dig(0, 'detail')).to eq('Hidden field error was tripped') 303 | expect(errors.dig(0, 'code')).to eq('100') 304 | expect(errors.dig(0, 'source', 'pointer')).to be_nil 305 | end 306 | end 307 | 308 | context 'when validation fails with a formatted attribute key' do 309 | subject { post :create, params: params.merge(invalid_body) } 310 | 311 | let(:invalid_body) do 312 | body.tap { |b| b[:data][:attributes][:title] = 'Fail Hidden' } 313 | end 314 | 315 | let!(:key_format_was) { JSONAPI.configuration.json_key_format } 316 | 317 | before { JSONAPI.configure { |config| config.json_key_format = :dasherized_key } } 318 | after { JSONAPI.configure { |config| config.json_key_format = key_format_was } } 319 | 320 | let(:attributes) do 321 | { title: 'Lorem ipsum', body: 'Lorem ipsum dolor sit amet.' } 322 | end 323 | 324 | it 'renders a 422 response' do 325 | expect { subject }.to change(Post, :count).by(0) 326 | expect(subject).to have_http_status :unprocessable_entity 327 | 328 | expect(errors.dig(0, 'id')).to eq('content-type#blank') 329 | expect(errors.dig(0, 'title')).to eq('can\'t be blank') 330 | expect(errors.dig(0, 'detail')).to eq('Content type can\'t be blank') 331 | expect(errors.dig(0, 'code')).to eq('100') 332 | expect(errors.dig(0, 'source', 'pointer')).to eq('/data/attributes/content-type') 333 | end 334 | end 335 | 336 | context 'when validation fails with a locale other than :en' do 337 | subject { post :create, params: params.merge(invalid_body) } 338 | 339 | let(:invalid_body) do 340 | body.tap { |b| b[:data][:attributes][:title] = nil } 341 | end 342 | 343 | before { I18n.locale = :ru } 344 | after { I18n.locale = :en } 345 | 346 | it 'renders a 422 response' do 347 | expect { subject }.to change(Post, :count).by(0) 348 | expect(response).to have_http_status :unprocessable_entity 349 | expect(errors.dig(0, 'id')).to eq('title#blank') 350 | expect(errors.dig(0, 'title')).to eq('не может быть пустым') 351 | expect(errors.dig(0, 'detail')).to eq('Заголовок не может быть пустым') 352 | expect(errors.dig(0, 'code')).to eq('100') 353 | expect(errors.dig(0, 'source', 'pointer')).to eq('/data/attributes/title') 354 | end 355 | end 356 | end 357 | 358 | describe 'GET #related_resources' do 359 | shared_context 'related_resources request' do |use_resource:, explicit_relationship:| 360 | subject { get :get_related_resources, params: params } 361 | let (:params) { { 362 | source: "users", 363 | user_id: parent_id, 364 | relationship: "posts", 365 | use_resource: use_resource, 366 | explicit_relationship: explicit_relationship 367 | } } 368 | end 369 | 370 | context 'using model as source' do 371 | include_context 'related_resources request', use_resource: false, explicit_relationship: false 372 | 373 | it 'loads all posts of a user' do 374 | expect(subject).to have_http_status :ok 375 | expect(subject).to have_primary_data('posts') 376 | expect(subject).to have_data_attributes(fields) 377 | expect(subject).to have_relationships(relationships) 378 | 379 | # it should use nested url 380 | expect(json.dig('links', 'first')).to include("/users/#{parent_id}/posts") 381 | expect(json.dig('links', 'last')).to include("/users/#{parent_id}/posts") 382 | end 383 | end 384 | 385 | context 'using model as source and relationship from options' do 386 | include_context 'related_resources request', use_resource: false, explicit_relationship: true 387 | 388 | it 'loads all posts of a user' do 389 | expect(subject).to have_http_status :ok 390 | expect(subject).to have_primary_data('posts') 391 | expect(subject).to have_data_attributes(fields) 392 | expect(subject).to have_relationships(relationships) 393 | 394 | # it should use nested url 395 | expect(json.dig('links', 'first')).to include("/users/#{parent_id}/posts") 396 | expect(json.dig('links', 'last')).to include("/users/#{parent_id}/posts") 397 | end 398 | end 399 | 400 | 401 | context 'using resource as source' do 402 | include_context 'related_resources request', use_resource: true, explicit_relationship: false 403 | 404 | it 'loads all posts of a user' do 405 | expect(subject).to have_http_status :ok 406 | expect(subject).to have_primary_data('posts') 407 | expect(subject).to have_data_attributes(fields) 408 | expect(subject).to have_relationships(relationships) 409 | 410 | # it should use nested url 411 | expect(json.dig('links', 'first')).to include("/users/#{parent_id}/posts") 412 | expect(json.dig('links', 'last')).to include("/users/#{parent_id}/posts") 413 | end 414 | end 415 | 416 | context 'using resource as source and relationship from options' do 417 | include_context 'related_resources request', use_resource: true, explicit_relationship: true 418 | 419 | it 'loads all posts of a user' do 420 | expect(subject).to have_http_status :ok 421 | expect(subject).to have_primary_data('posts') 422 | expect(subject).to have_data_attributes(fields) 423 | expect(subject).to have_relationships(relationships) 424 | 425 | # it should use nested url 426 | expect(json.dig('links', 'first')).to include("/users/#{parent_id}/posts") 427 | expect(json.dig('links', 'last')).to include("/users/#{parent_id}/posts") 428 | end 429 | end 430 | end 431 | 432 | describe 'PATCH #update' do 433 | shared_context 'update request' do |action:| 434 | subject { patch action, params: params.merge(body) } 435 | 436 | let(:params) { { id: 1 } } 437 | let(:body) { { data: { id: 1, type: 'posts', attributes: { title: 'Foo' } } } } 438 | end 439 | 440 | context 'when using JR\'s default action' do 441 | include_context 'update request', action: :update 442 | it { expect(response).to have_http_status :ok } 443 | end 444 | 445 | context 'when validation fails on base' do 446 | include_context 'update request', action: :update_with_error_on_base 447 | 448 | it 'renders a 422 response' do 449 | expect { subject }.to change(Post, :count).by(0) 450 | expect(response).to have_http_status :unprocessable_entity 451 | 452 | expect(errors.dig(0, 'id')).to eq('base#this_is_an_error_on_the_base') 453 | expect(errors.dig(0, 'title')).to eq('This is an error on the base') 454 | expect(errors.dig(0, 'code')).to eq('100') 455 | expect(errors.dig(0, 'source', 'pointer')).to eq('/data') 456 | end 457 | end 458 | end 459 | end 460 | -------------------------------------------------------------------------------- /spec/controllers/users_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe UsersController, type: :controller do 4 | include_context 'JSON API headers' 5 | 6 | before(:all) do 7 | @user = FactoryBot.create_list(:user, 3, :with_posts).first 8 | end 9 | 10 | before(:each) do 11 | JSONAPI.configuration.json_key_format = :underscored_key 12 | end 13 | 14 | let(:user) { @user } 15 | let(:relationships) { UserResource._relationships.keys.map(&:to_s) } 16 | let(:fields) { UserResource.fields.reject { |e| e == :id }.map(&:to_s) - relationships } 17 | let(:attributes) { { first_name: 'Yehuda', last_name: 'Katz' } } 18 | 19 | let(:user_params) do 20 | { data: { type: 'users', attributes: attributes } } 21 | end 22 | 23 | include_examples 'JSON API invalid request', resource: :users 24 | 25 | describe '#index' do 26 | it 'renders a collection of users' do 27 | get :index 28 | expect(response).to have_http_status :ok 29 | expect(response).to have_primary_data('users') 30 | expect(response).to have_data_attributes(fields) 31 | expect(response).to have_relationships(relationships - ['profile']) 32 | end 33 | 34 | context 'with "include"' do 35 | it 'returns only the required relationships in the "included" member' do 36 | get :index, params: { include: 'profile,posts' } 37 | expect(response).to have_http_status :ok 38 | expect(response).to have_primary_data('users') 39 | expect(response).to have_data_attributes(fields) 40 | expect(response).to have_relationships(relationships) 41 | expect(response).to have_included_relationships 42 | end 43 | end 44 | 45 | context 'with "fields"' do 46 | it 'returns only the required fields in the "attributes" member' do 47 | get :index, params: { fields: { users: :first_name } } 48 | expect(response).to have_http_status :ok 49 | expect(response).to have_primary_data('users') 50 | expect(response).to have_data_attributes(%w(first_name)) 51 | end 52 | end 53 | 54 | context 'with "filter"' do 55 | let(:first_name) { user.first_name } 56 | let(:full_name) { "#{user.first_name} #{user.last_name}" } 57 | 58 | it 'returns only results corresponding to the applied filter' do 59 | get :index, params: { filter: { first_name: first_name } } 60 | expect(response).to have_http_status :ok 61 | expect(response).to have_primary_data('users') 62 | expect(response).to have_meta_record_count(1) 63 | expect(data.dig(0, 'attributes', 'first_name')).to eq(first_name) 64 | end 65 | 66 | it 'returns only results corresponding to the applied custom filter' do 67 | get :index, params: { filter: { full_name: full_name } } 68 | expect(response).to have_http_status :ok 69 | expect(response).to have_primary_data('users') 70 | expect(response).to have_meta_record_count(1) 71 | expect(data.dig(0, 'attributes', 'full_name')).to eq(full_name) 72 | end 73 | 74 | context 'when using "dasherized_key"' do 75 | before do 76 | JSONAPI.configuration.json_key_format = :dasherized_key 77 | end 78 | 79 | it 'returns only results corresponding to the applied filter' do 80 | get :index, params: { filter: { 'first-name' => first_name } } 81 | expect(response).to have_http_status :ok 82 | expect(response).to have_primary_data('users') 83 | expect(data.dig(0, 'attributes', 'first-name')).to eq(first_name) 84 | end 85 | end 86 | 87 | context 'when using "camelized_key"' do 88 | before do 89 | JSONAPI.configuration.json_key_format = :camelized_key 90 | end 91 | 92 | it 'returns only results corresponding to the applied filter' do 93 | get :index, params: { filter: { 'firstName' => first_name } } 94 | expect(response).to have_http_status :ok 95 | expect(response).to have_primary_data('users') 96 | expect(data.dig(0, 'attributes', 'firstName')).to eq(first_name) 97 | end 98 | end 99 | end 100 | 101 | context 'with "page"' do 102 | context 'when using "paged" paginator' do 103 | before(:all) do 104 | JSONAPI.configuration.default_paginator = :paged 105 | end 106 | 107 | context 'at the first page' do 108 | it 'returns paginated results' do 109 | get :index, params: { page: { number: 1, size: 2 } } 110 | 111 | expect(response).to have_http_status :ok 112 | expect(response).to have_primary_data('users') 113 | expect(data.size).to eq(2) 114 | expect(response).to have_meta_record_count(3) 115 | 116 | expect(json.dig('meta', 'page_count')).to eq(2) 117 | expect(json.dig('links', 'first')).to be_present 118 | expect(json.dig('links', 'next')).to be_present 119 | expect(json.dig('links', 'last')).to be_present 120 | end 121 | end 122 | 123 | context 'at the middle' do 124 | it 'returns paginated results' do 125 | get :index, params: { page: { number: 2, size: 1 } } 126 | 127 | expect(response).to have_http_status :ok 128 | expect(response).to have_primary_data('users') 129 | expect(data.size).to eq(1) 130 | expect(response).to have_meta_record_count(User.count) 131 | 132 | expect(json.dig('meta', 'page_count')).to eq(3) 133 | expect(json.dig('links', 'first')).to be_present 134 | expect(json.dig('links', 'prev')).to be_present 135 | expect(json.dig('links', 'next')).to be_present 136 | expect(json.dig('links', 'last')).to be_present 137 | end 138 | end 139 | 140 | context 'at the last page' do 141 | it 'returns paginated results' do 142 | get :index, params: { page: { number: 3, size: 1 } } 143 | 144 | expect(response).to have_http_status :ok 145 | expect(response).to have_primary_data('users') 146 | expect(data.size).to eq(1) 147 | expect(response).to have_meta_record_count(User.count) 148 | 149 | expect(json.dig('meta', 'page_count')).to eq(3) 150 | expect(json.dig('links', 'first')).to be_present 151 | expect(json.dig('links', 'prev')).to be_present 152 | expect(json.dig('links', 'last')).to be_present 153 | end 154 | end 155 | 156 | 157 | context 'when filtering with pagination' do 158 | let(:count) { User.where(user.slice(:first_name, :last_name)).count } 159 | 160 | it 'returns paginated results according to the given filter' do 161 | get :index, params: { filter: { full_name: user.full_name }, page: { number: 1, size: 2 } } 162 | 163 | expect(response).to have_http_status :ok 164 | expect(response).to have_primary_data('users') 165 | expect(data.size).to eq(1) 166 | expect(response).to have_meta_record_count(count) 167 | 168 | expect(json.dig('meta', 'page_count')).to eq(1) 169 | expect(data.dig(0, 'attributes', 'full_name')).to eq(user.full_name) 170 | end 171 | end 172 | 173 | context 'without "size"' do 174 | it 'returns the amount of results based on "JSONAPI.configuration.default_page_size"' do 175 | get :index, params: { page: { number: 1 } } 176 | expect(response).to have_http_status :ok 177 | expect(data.size).to be <= JSONAPI.configuration.default_page_size 178 | expect(response).to have_meta_record_count(User.count) 179 | expect(json.dig('meta', 'page_count')).to eq(1) 180 | end 181 | end 182 | end 183 | 184 | context 'when using "offset" paginator' do 185 | before(:all) do 186 | JSONAPI.configuration.default_paginator = :offset 187 | end 188 | 189 | context 'at the first page' do 190 | it 'returns paginated results' do 191 | get :index, params: { page: { offset: 0, limit: 2 } } 192 | 193 | expect(response).to have_http_status :ok 194 | expect(response).to have_primary_data('users') 195 | expect(data.size).to eq(2) 196 | expect(response).to have_meta_record_count(User.count) 197 | 198 | expect(json.dig('meta', 'page_count')).to eq(2) 199 | expect(json.dig('links', 'first')).to be_present 200 | expect(json.dig('links', 'next')).to be_present 201 | expect(json.dig('links', 'last')).to be_present 202 | end 203 | end 204 | 205 | context 'at the middle' do 206 | it 'returns paginated results' do 207 | get :index, params: { page: { offset: 1, limit: 1 } } 208 | 209 | expect(response).to have_http_status :ok 210 | expect(response).to have_primary_data('users') 211 | expect(data.size).to eq(1) 212 | expect(response).to have_meta_record_count(User.count) 213 | 214 | expect(json.dig('meta', 'page_count')).to eq(3) 215 | expect(json.dig('links', 'first')).to be_present 216 | expect(json.dig('links', 'prev')).to be_present 217 | expect(json.dig('links', 'next')).to be_present 218 | expect(json.dig('links', 'last')).to be_present 219 | end 220 | end 221 | 222 | context 'at the last page' do 223 | it 'returns the paginated results' do 224 | get :index, params: { page: { offset: 2, limit: 1 } } 225 | 226 | expect(response).to have_http_status :ok 227 | expect(response).to have_primary_data('users') 228 | expect(data.size).to eq(1) 229 | expect(response).to have_meta_record_count(User.count) 230 | 231 | expect(json.dig('meta', 'page_count')).to eq(3) 232 | expect(json.dig('links', 'first')).to be_present 233 | expect(json.dig('links', 'prev')).to be_present 234 | expect(json.dig('links', 'last')).to be_present 235 | end 236 | end 237 | 238 | context 'without "limit"' do 239 | it 'returns the amount of results based on "JSONAPI.configuration.default_page_size"' do 240 | get :index, params: { page: { offset: 1 } } 241 | expect(response).to have_http_status :ok 242 | expect(data.size).to be <= JSONAPI.configuration.default_page_size 243 | expect(response).to have_meta_record_count(User.count) 244 | expect(json.dig('meta', 'page_count')).to eq(1) 245 | end 246 | end 247 | end 248 | 249 | context 'when using custom global paginator' do 250 | before(:all) do 251 | JSONAPI.configuration.default_paginator = :custom_offset 252 | end 253 | 254 | context 'at the first page' do 255 | it 'returns paginated results' do 256 | get :index, params: { page: { offset: 0, limit: 2 } } 257 | 258 | expect(response).to have_http_status :ok 259 | expect(response).to have_primary_data('users') 260 | expect(data.size).to eq(2) 261 | expect(response).to have_meta_record_count(User.count) 262 | 263 | expect(json.dig('meta', 'page_count')).to eq(2) 264 | expect(json.dig('links', 'first')).to be_present 265 | expect(json.dig('links', 'next')).to be_present 266 | expect(json.dig('links', 'last')).to be_present 267 | end 268 | end 269 | 270 | context 'at the middle' do 271 | it 'returns paginated results' do 272 | get :index, params: { page: { offset: 1, limit: 1 } } 273 | 274 | expect(response).to have_http_status :ok 275 | expect(response).to have_primary_data('users') 276 | expect(data.size).to eq(1) 277 | expect(response).to have_meta_record_count(User.count) 278 | 279 | expect(json.dig('meta', 'page_count')).to eq(3) 280 | expect(json.dig('links', 'first')).to be_present 281 | expect(json.dig('links', 'prev')).to be_present 282 | expect(json.dig('links', 'next')).to be_present 283 | expect(json.dig('links', 'last')).to be_present 284 | end 285 | end 286 | 287 | context 'at the last page' do 288 | it 'returns the paginated results' do 289 | get :index, params: { page: { offset: 2, limit: 1 } } 290 | 291 | expect(response).to have_http_status :ok 292 | expect(response).to have_primary_data('users') 293 | expect(data.size).to eq(1) 294 | expect(response).to have_meta_record_count(User.count) 295 | 296 | expect(json.dig('meta', 'page_count')).to eq(3) 297 | expect(json.dig('links', 'first')).to be_present 298 | expect(json.dig('links', 'prev')).to be_present 299 | expect(json.dig('links', 'last')).to be_present 300 | end 301 | end 302 | 303 | context 'without "limit"' do 304 | it 'returns the amount of results based on "JSONAPI.configuration.default_page_size"' do 305 | get :index, params: { page: { offset: 1 } } 306 | expect(response).to have_http_status :ok 307 | expect(data.size).to be <= JSONAPI.configuration.default_page_size 308 | expect(response).to have_meta_record_count(User.count) 309 | expect(json.dig('meta', 'page_count')).to eq(1) 310 | end 311 | end 312 | end 313 | end 314 | 315 | context 'with "sort"' do 316 | context 'when asc' do 317 | it 'returns sorted results' do 318 | get :index, params: { sort: :first_name } 319 | 320 | first_name1 = data.dig(0, 'attributes', 'first_name') 321 | first_name2 = data.dig(1, 'attributes', 'first_name') 322 | 323 | expect(response).to have_http_status :ok 324 | expect(response).to have_primary_data('users') 325 | expect(first_name1).to be <= first_name2 326 | end 327 | end 328 | 329 | context 'when desc' do 330 | it 'returns sorted results' do 331 | get :index, params: { sort: '-first_name,-last_name' } 332 | 333 | first_name_1, last_name_1 = data.dig(0, 'attributes').values_at('first_name', 'last_name') 334 | first_name_2, last_name_2 = data.dig(1, 'attributes').values_at('first_name', 'last_name') 335 | sorted = first_name_1 > first_name_2 || (first_name_1 == first_name_2 && last_name_1 >= last_name_2) 336 | 337 | expect(response).to have_http_status :ok 338 | expect(response).to have_primary_data('users') 339 | expect(sorted).to be_truthy 340 | end 341 | end 342 | 343 | context 'when using "dasherized_key"' do 344 | before do 345 | JSONAPI.configuration.json_key_format = :dasherized_key 346 | end 347 | 348 | it 'returns sorted results' do 349 | get :index, params: { sort: 'first-name' } 350 | 351 | first_name_1 = data.dig(0, 'attributes', 'first-name') 352 | first_name_2 = data.dig(1, 'attributes', 'first-name') 353 | 354 | expect(response).to have_http_status :ok 355 | expect(response).to have_primary_data('users') 356 | expect(first_name_1 < first_name_2).to be_truthy 357 | end 358 | end 359 | 360 | context 'when using "camelized_key"' do 361 | before do 362 | JSONAPI.configuration.json_key_format = :camelized_key 363 | end 364 | 365 | it 'returns sorted results' do 366 | get :index, params: { sort: 'firstName' } 367 | 368 | first_name_1 = data.dig(0, 'attributes', 'firstName') 369 | first_name_2 = data.dig(1, 'attributes', 'firstName') 370 | 371 | expect(response).to have_http_status :ok 372 | expect(response).to have_primary_data('users') 373 | expect(first_name_1 < first_name_2).to be_truthy 374 | end 375 | end 376 | end 377 | end 378 | 379 | describe '#show' do 380 | it 'renders a single user' do 381 | get :show, params: { id: user.id } 382 | expect(response).to have_http_status :ok 383 | expect(response).to have_primary_data('users') 384 | expect(response).to have_data_attributes(fields) 385 | expect(data.dig('attributes', 'first_name')).to eq("User##{user.id}") 386 | end 387 | 388 | context 'when resource was not found' do 389 | it 'renders a 404 response' do 390 | get :show, params: { id: 999 } 391 | expect(response).to have_http_status :not_found 392 | expect(error['title']).to eq('Record not found') 393 | expect(error['code']).to eq('404') 394 | end 395 | end 396 | end 397 | 398 | describe '#create' do 399 | it 'creates a new user' do 400 | expect { post :create, params: user_params }.to change(User, :count).by(1) 401 | expect(response).to have_http_status :created 402 | expect(response).to have_primary_data('users') 403 | expect(response).to have_data_attributes(fields) 404 | expect(data.dig('attributes', 'first_name')).to eq(user_params.dig(:data, :attributes, :first_name)) 405 | end 406 | 407 | shared_examples_for '400 response' do |hash| 408 | before { user_params.dig(:data, :attributes).merge!(hash) } 409 | 410 | it 'renders a 400 response' do 411 | expect { post :create, params: user_params }.to change(User, :count).by(0) 412 | expect(response).to have_http_status :bad_request 413 | expect(error['title']).to eq('Param not allowed') 414 | expect(error['code']).to eq('105') 415 | end 416 | end 417 | 418 | context 'with a not permitted param' do 419 | it_behaves_like '400 response', foo: 'bar' 420 | end 421 | 422 | context 'with a param not present in resource\'s attribute list' do 423 | it_behaves_like '400 response', admin: true 424 | end 425 | 426 | context 'with validation error and no status code set' do 427 | before { user_params.dig(:data, :attributes).merge!(first_name: nil, last_name: nil) } 428 | 429 | it 'renders a 400 response by default' do 430 | expect { post :create, params: user_params }.to change(User, :count).by(0) 431 | expect(response).to have_http_status :bad_request 432 | 433 | expect(errors.dig(0, 'id')).to eq('first_name') 434 | expect(errors.dig(0, 'title')).to eq('can\'t be blank') 435 | expect(errors.dig(0, 'detail')).to eq('First name can\'t be blank') 436 | expect(errors.dig(0, 'code')).to eq('100') 437 | expect(errors.dig(0, 'source')).to be_nil 438 | 439 | expect(errors.dig(1, 'id')).to eq('last_name') 440 | expect(errors.dig(1, 'title')).to eq('can\'t be blank') 441 | expect(errors.dig(1, 'detail')).to eq('Last name can\'t be blank') 442 | expect(errors.dig(1, 'code')).to eq('100') 443 | expect(errors.dig(1, 'source')).to be_nil 444 | end 445 | end 446 | end 447 | 448 | describe '#update' do 449 | let(:post) { user.posts.first } 450 | 451 | let(:update_params) do 452 | user_params.tap do |params| 453 | params[:data][:id] = user.id 454 | params[:data][:attributes][:first_name] = 'Yukihiro' 455 | params[:data][:relationships] = relationship_params 456 | params.merge!(id: user.id) 457 | end 458 | end 459 | 460 | let(:relationship_params) do 461 | { posts: { data: [{ id: post.id, type: 'posts' }] } } 462 | end 463 | 464 | it 'update an existing user' do 465 | patch :update, params: update_params 466 | 467 | expect(response).to have_http_status :ok 468 | expect(response).to have_primary_data('users') 469 | expect(response).to have_data_attributes(fields) 470 | expect(data['attributes']['first_name']).to eq(user_params[:data][:attributes][:first_name]) 471 | 472 | expect(user.reload.posts.count).to eq(1) 473 | expect(user.posts.first).to eq(post) 474 | end 475 | 476 | context 'when resource was not found' do 477 | before { update_params[:data][:id] = 999 } 478 | 479 | it 'renders a 404 response' do 480 | patch :update, params: update_params.merge(id: 999) 481 | expect(response).to have_http_status :not_found 482 | expect(error['title']).to eq('Record not found') 483 | expect(error['code']).to eq('404') 484 | end 485 | end 486 | 487 | context 'when validation fails' do 488 | before { update_params[:data][:attributes].merge!(first_name: nil, last_name: nil) } 489 | 490 | it 'render a 422 response' do 491 | patch :update, params: update_params 492 | expect(response).to have_http_status :unprocessable_entity 493 | expect(errors[0]['id']).to eq('my_custom_validation_error') 494 | expect(errors[0]['title']).to eq('My custom error message') 495 | expect(errors[0]['code']).to eq('125') 496 | expect(errors[0]['source']).to be_nil 497 | end 498 | end 499 | end 500 | 501 | describe 'use of JSONAPI::Resources\' default actions' do 502 | describe '#show_relationship' do 503 | it 'renders the user\'s profile' do 504 | get :show_relationship, params: { user_id: user.id, relationship: 'profile' } 505 | expect(response).to have_http_status :ok 506 | expect(response).to have_primary_data('profiles') 507 | end 508 | end 509 | end 510 | end 511 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSONAPI::Utils 2 | 3 | [![Code Climate](https://codeclimate.com/github/tiagopog/jsonapi-utils/badges/gpa.svg)](https://codeclimate.com/github/tiagopog/jsonapi-utils) 4 | [![Gem Version](https://badge.fury.io/rb/jsonapi-utils.svg)](https://badge.fury.io/rb/jsonapi-utils) 5 | [![Build Status](https://travis-ci.org/tiagopog/jsonapi-utils.svg?branch=master)](https://travis-ci.org/tiagopog/jsonapi-utils) 6 | 7 | Simple yet powerful way to get your Rails API compliant with [JSON API](http://jsonapi.org). 8 | 9 | `JSONAPI::Utils` (JU) is built on top of [JSONAPI::Resources](https://github.com/cerebris/jsonapi-resources) 10 | taking advantage of its resource-driven style and bringing a set of helpers to easily build modern JSON APIs 11 | with no or less learning curve. 12 | 13 | After installing the gem and defining the resources/routes, it's as simple as calling a render helper: 14 | 15 | ```ruby 16 | class UsersController < ActionController::Base 17 | include JSONAPI::Utils 18 | 19 | def index 20 | jsonapi_render json: User.all 21 | end 22 | end 23 | ``` 24 | 25 | ## Table of Contents 26 | 27 | * [Installation](#installation) 28 | * [Why JSONAPI::Utils?](#why-jsonapiutils) 29 | * [Usage](#usage) 30 | * [Response](#response) 31 | * [Renders](#renders) 32 | * [Formatters](#formatters) 33 | * [Paginators](#paginators) 34 | * [Request](#request) 35 | * [Params helpers](#params-helpers) 36 | * [Full example](#full-example) 37 | * [Models](#models) 38 | * [Resources](#resources) 39 | * [Routes & Controllers](#routes--controllers) 40 | * [Initializer](#initializer) 41 | * [Requests & Responses](#requests--responses) 42 | * [Index](#index) 43 | * [Index (options)](#index-options) 44 | * [Show](#show) 45 | * [Show (options)](#show-options) 46 | * [Relationships (identifier objects)](#relationships-identifier-objects) 47 | * [Nested resources](#nested-resources) 48 | * [Development](#development) 49 | * [Contributing](#contributing) 50 | * [License](#license) 51 | 52 | ## Installation 53 | 54 | Support: 55 | 56 | * Ruby 1.9+ with Rails 4 57 | * Ruby 2.4+ with Rails 5 58 | 59 | For Rails 4 add this to your application's Gemfile: 60 | 61 | ```ruby 62 | gem 'jsonapi-utils', '~> 0.4.9' 63 | ``` 64 | 65 | For Rails 5+: 66 | 67 | ```ruby 68 | gem 'jsonapi-utils', '~> 0.7.3' 69 | ``` 70 | 71 | And then execute: 72 | 73 | ```shell 74 | $ bundle 75 | ``` 76 | 77 | ## Why JSONAPI::Utils? 78 | 79 | One of the main motivations behind `JSONAPI::Utils` is to keep things explicit in controllers (no hidden actions :-) so that developers can easily understand and maintain their code. 80 | 81 | Unlike `JSONAPI::Resources` (JR), JU doesn't care about how you will operate your controller actions. The gem deals only with the request validation and response rendering (via JR's objects) and provides a set of helpers (renders, formatters etc) along the way. Thus developers can decide how to actually operate their actions: service objects, interactors etc. 82 | 83 | ## Usage 84 | 85 | ### Response 86 | 87 | #### Renders 88 | 89 | JU brings two main renders to the game, working pretty much the same way as Rails' `ActionController#render` method: 90 | 91 | - jsonapi_render 92 | - jsonapi_render_errors 93 | 94 | **jsonapi_render** 95 | 96 | It renders a JSON API-compliant response. 97 | 98 | ```ruby 99 | # app/controllers/users_controller.rb 100 | # GET /users 101 | def index 102 | jsonapi_render json: User.all 103 | end 104 | 105 | # GET /users/:id 106 | def show 107 | jsonapi_render json: User.find(params[:id]) 108 | end 109 | ``` 110 | 111 | Arguments: 112 | 113 | - `json`: object to be rendered as a JSON document: ActiveRecord object, Hash or Array; 114 | - `status`: HTTP status code (Integer, String or Symbol). If ommited a status code will be automatically infered; 115 | - `options`: 116 | - `resource`: explicitly points the resource to be used in the serialization. By default, JU will select resources by inferencing from controller's name. 117 | - `count`: explicitly points the total count of records for the request in order to build a proper pagination. By default, JU will count the total number of records. 118 | - `model`: sets the model reference in cases when `json` is a Hash or a collection of Hashes. 119 | 120 | Other examples: 121 | 122 | ```ruby 123 | # Specify a particular HTTP status code 124 | jsonapi_render json: new_user, status: :created 125 | 126 | # Forcing a different resource 127 | jsonapi_render json: User.all, options: { resource: V2::UserResource } 128 | 129 | # Using a specific count 130 | jsonapi_render json: User.some_weird_scope, options: { count: User.some_weird_scope_count } 131 | 132 | # Hash rendering 133 | jsonapi_render json: { data: { id: 1, first_name: 'Tiago' } }, options: { model: User } 134 | 135 | # Collection of Hashes rendering 136 | jsonapi_render json: { data: [{ id: 1, first_name: 'Tiago' }, { id: 2, first_name: 'Doug' }] }, options: { model: User } 137 | ``` 138 | 139 | **jsonapi_render_errors** 140 | 141 | It renders a JSON API-compliant error response. 142 | 143 | ```ruby 144 | # app/controllers/users_controller.rb 145 | # POST /users 146 | def create 147 | user = User.new(user_params) 148 | if user.save 149 | jsonapi_render json: user, status: :created 150 | else 151 | jsonapi_render_errors json: user, status: :unprocessable_entity 152 | end 153 | end 154 | ``` 155 | 156 | Arguments: 157 | - Exception 158 | - `json`: object to be rendered as a JSON document: ActiveRecord, Exception, Array or any object which implements the `errors` method; 159 | - `status`: HTTP status code (Integer, String or Symbol). If ommited a status code will be automatically infered from the error body. 160 | 161 | Other examples: 162 | 163 | ```ruby 164 | # Render errors from a custom exception: 165 | jsonapi_render_errors Exceptions::MyCustomError.new(user) 166 | 167 | # Render errors from an Array: 168 | errors = [{ id: 'validation', title: 'Something went wrong', code: '100' }] 169 | jsonapi_render_errors json: errors, status: :unprocessable_entity 170 | ``` 171 | 172 | #### Formatters 173 | 174 | In the backstage these are the guys which actually parse the ActiveRecord/Hash object to build a new Hash compliant with JSON API's specs. Formatters can be called anywhere in controllers being very useful if you need to do some work with the response's body before rendering the actual response. 175 | 176 | > Note: the resulting Hash from those methods can not be passed as argument to `JSONAPI::Utils#jsonapi_render` or `JSONAPI::Utils#jsonapi_render_error`, instead it needs to be rendered by the usual `ActionController#render`. 177 | 178 | **jsonapi_format** 179 | 180 | > Because of semantic reasons `JSONAPI::Utils#jsonapi_serialize` was renamed to `JSONAPI::Utils#jsonapi_format`. 181 | 182 | ```ruby 183 | # app/controllers/users_controller.rb 184 | def index 185 | body = jsonapi_format(User.all) 186 | render json: do_some_magic_with(body) 187 | end 188 | ``` 189 | 190 | Arguments: 191 | - First: ActiveRecord object, Hash or Array; 192 | - Last: Hash of options (same as `JSONAPI::Utils#jsonapi_render`). 193 | 194 | #### Paginators 195 | 196 | Pagination works out of the box on JU, you just need to decide which kind of paginator you'd like to use. 197 | 198 | It's really easy to work with pagination on JU, actually it's just a matter of chosing the [paginator you wish](http://jsonapi-resources.com/v0.8/guide/configuration.html#Defaults) in your JR's config file: 199 | 200 | ```ruby 201 | # config/initializers/jsonapi_resources.rb 202 | JSONAPI.configure do |config| 203 | # :none, :offset, :paged, or a custom paginator name 204 | config.default_paginator = :paged 205 | 206 | # Output pagination links at top level 207 | config.top_level_links_include_pagination = true 208 | 209 | # Default sizes 210 | config.default_page_size = 70 211 | config.maximum_page_size = 100 212 | end 213 | ``` 214 | 215 | As you may have noticed above, it's possible to use custom paginators. In order to create your own paginator your just need to define a class which inherits from `JSONAPI::Paginator` and implements the `#pagination_range` method which in turn must return the range to be applied over the resulting collection. 216 | 217 | For example, if you would like to paginate over a collection of hashes, you may implement the `#pagination_range` method as below: 218 | 219 | ```ruby 220 | class CustomPaginator < JSONAPI::Paginator 221 | def pagination_range(page_params) 222 | offset = page_params['offset'] 223 | limit = JSONAPI.configuration.default_page_size 224 | offset..offset + limit - 1 # resulting range 225 | end 226 | ``` 227 | 228 | And then it can be either set at the resource class level (e.g. UserResource.paginator :custom) or via config initializer: 229 | 230 | ```ruby 231 | # config/initializers/jsonapi_resources.rb 232 | JSONAPI.configure do |config| 233 | config.default_paginator = :custom 234 | end 235 | ``` 236 | 237 | ### Request 238 | 239 | Before a controller action gets executed, `JSONAPI::Utils` will validate the request against JSON API's specs as well as evaluating the eventual query string params to check if they match the resource's definition. If something goes wrong during the validation process, JU will render an error response like this examples below: 240 | 241 | ```json 242 | HTTP/1.1 400 Bad Request 243 | Content-Type: application/vnd.api+json 244 | 245 | { 246 | "errors": [ 247 | { 248 | "title": "Invalid resource", 249 | "detail": "foo is not a valid resource.", 250 | "code": "101", 251 | "status": "400" 252 | }, 253 | { 254 | "title": "Invalid resource", 255 | "detail": "foobar is not a valid resource.", 256 | "code": "101", 257 | "status": "400" 258 | }, 259 | { 260 | "title": "Invalid field", 261 | "detail": "bar is not a valid relationship of users", 262 | "code": "112", 263 | "status": "400" 264 | } 265 | ] 266 | } 267 | ``` 268 | 269 | #### Params helpers 270 | 271 | JU brings helper methods as a shortcut to get values from permitted params based on the resource's configuration. 272 | 273 | - `resource_params`: 274 | - Returns the permitted params present in the `attributes` JSON member; 275 | - Example: `{ name: 'Bilbo', gender: 'male', city: 'Shire' }` 276 | - Same of calling: `params.require(:data).require(:attributes).permit(:name, :gender, :city)` 277 | - `relationship_params`: 278 | - Returns the relationship `id`s, distinguished by key, present in `relationships` JSON member; 279 | - Example: `{ author: 1, posts: [1, 2, 3] }` 280 | - Same as calling: `params.require(:relationships).require(:author).require(:data).permit(:id)` 281 | 282 | ## Full example 283 | 284 | After installing the gem you simply need to: 285 | 286 | 1. Include the gem's module (`include JSONAPI::Utils`) in a controller (eg. `BaseController`); 287 | 2. Define the resources which will be exposed via REST API; 288 | 3. Define the application's routes; 289 | 4. Use JSONAPI Utils' helper methods (eg. renders, formatters, params helpers etc). 290 | 291 | Ok, now it's time to start our complete example. So, let's say we have a Rails application for a super simple blog: 292 | 293 | ### Models 294 | 295 | ```ruby 296 | # app/models/user.rb 297 | class User < ActiveRecord::Base 298 | has_many :posts 299 | validates :first_name, :last_name, presence: true 300 | end 301 | 302 | # app/models/post.rb 303 | class Post < ActiveRecord::Base 304 | belongs_to :author, class_name: 'User', foreign_key: 'user_id' 305 | validates :title, :body, presence: true 306 | end 307 | ``` 308 | 309 | ### Resources 310 | 311 | Here is where we define how our models are exposed as resources on the API: 312 | 313 | ```ruby 314 | # app/resources/user_resource.rb 315 | class UserResource < JSONAPI::Resource 316 | attributes :first_name, :last_name, :full_name, :birthday 317 | 318 | has_many :posts 319 | 320 | def full_name 321 | "#{@model.first_name} #{@model.last_name}" 322 | end 323 | end 324 | 325 | # app/resources/post_resource.rb 326 | class PostResource < JSONAPI::Resource 327 | attributes :title, :body 328 | has_one :author 329 | end 330 | ``` 331 | 332 | ### Routes & Controllers 333 | 334 | Let's define the routes using the `jsonapi_resources` method provided by JR: 335 | 336 | ```ruby 337 | Rails.application.routes.draw do 338 | jsonapi_resources :users do 339 | jsonapi_resources :posts 340 | end 341 | end 342 | ``` 343 | 344 | In controllers we just need to include the `JSONAPI::Utils` module. 345 | 346 | > Note: some default rendering can be set like the below example where `jsonapi_render_not_found` is used when a record is not found in the database. 347 | 348 | ```ruby 349 | # app/controllers/base_controller.rb 350 | class BaseController < ActionController::Base 351 | include JSONAPI::Utils 352 | protect_from_forgery with: :null_session 353 | rescue_from ActiveRecord::RecordNotFound, with: :jsonapi_render_not_found 354 | end 355 | ``` 356 | 357 | With the helper methods inhirited from `JSONAPI::Utils` in our `BaseController`, now it's all about to write our actions like the following: 358 | 359 | ```ruby 360 | # app/controllers/users_controller.rb 361 | class UsersController < BaseController 362 | # GET /users 363 | def index 364 | users = User.all 365 | jsonapi_render json: users 366 | end 367 | 368 | # GET /users/:id 369 | def show 370 | user = User.find(params[:id]) 371 | jsonapi_render json: user 372 | end 373 | 374 | # POST /users 375 | def create 376 | user = User.new(resource_params) 377 | if user.save 378 | jsonapi_render json: user, status: :created 379 | else 380 | jsonapi_render_errors json: user, status: :unprocessable_entity 381 | end 382 | end 383 | 384 | # PATCH /users/:id 385 | def update 386 | user = User.find(params[:id]) 387 | if user.update(resource_params) 388 | jsonapi_render json: user 389 | else 390 | jsonapi_render_errors json: user, status: :unprocessable_entity 391 | end 392 | end 393 | 394 | # DELETE /users/:id 395 | def destroy 396 | User.find(params[:id]).destroy 397 | head :no_content 398 | end 399 | end 400 | ``` 401 | 402 | And: 403 | 404 | ```ruby 405 | # app/controllers/posts_controller.rb 406 | class PostsController < BaseController 407 | before_action :load_user, except: :create 408 | 409 | # GET /users/:user_id/posts 410 | def index 411 | jsonapi_render json: @user.posts, options: { count: 100 } 412 | end 413 | 414 | # GET /users/:user_id/posts/:id 415 | def show 416 | jsonapi_render json: @user.posts.find(params[:id]) 417 | end 418 | 419 | # POST /posts 420 | def create 421 | post = Post.new(post_params) 422 | if post.save 423 | jsonapi_render json: post, status: :created 424 | else 425 | jsonapi_render_errors json: post, status: :unprocessable_entity 426 | end 427 | end 428 | 429 | private 430 | 431 | def post_params 432 | resource_params.merge(user_id: relationship_params[:author]) 433 | end 434 | 435 | def load_user 436 | @user = User.find(params[:user_id]) 437 | end 438 | end 439 | ``` 440 | 441 | ### Initializer 442 | 443 | In order to enable a proper pagination, record count etc, an initializer could be defined such as: 444 | 445 | ```ruby 446 | # config/initializers/jsonapi_resources.rb 447 | JSONAPI.configure do |config| 448 | config.json_key_format = :underscored_key 449 | config.route_format = :dasherized_route 450 | 451 | config.allow_include = true 452 | config.allow_sort = true 453 | config.allow_filter = true 454 | 455 | config.raise_if_parameters_not_allowed = true 456 | 457 | config.default_paginator = :paged 458 | 459 | config.top_level_links_include_pagination = true 460 | 461 | config.default_page_size = 10 462 | config.maximum_page_size = 20 463 | 464 | config.top_level_meta_include_record_count = true 465 | config.top_level_meta_record_count_key = :record_count 466 | 467 | config.top_level_meta_include_page_count = true 468 | config.top_level_meta_page_count_key = :page_count 469 | 470 | config.use_text_errors = false 471 | 472 | config.exception_class_whitelist = [] 473 | 474 | config.always_include_to_one_linkage_data = false 475 | end 476 | ``` 477 | 478 | You may want a different configuration for your API. For more information check [this](https://github.com/cerebris/jsonapi-resources/#configuration). 479 | 480 | ### Requests & Responses 481 | 482 | Here are examples of requests – based on those sample [controllers](#routes--controllers) – and their respective JSON responses. 483 | 484 | * [Collection](#collection) 485 | * [Collection (options)](#collection-options) 486 | * [Single record](#single-record) 487 | * [Record (options)](#single-record-options) 488 | * [Relationships (identifier objects)](#relationships-identifier-objects) 489 | * [Nested resources](#nested-resources) 490 | 491 | #### Index 492 | 493 | Request: 494 | 495 | ``` 496 | GET /users HTTP/1.1 497 | Accept: application/vnd.api+json 498 | ``` 499 | 500 | Response: 501 | 502 | ```json 503 | HTTP/1.1 200 OK 504 | Content-Type: application/vnd.api+json 505 | 506 | { 507 | "data": [ 508 | { 509 | "id": "1", 510 | "type": "users", 511 | "links": { 512 | "self": "http://api.myblog.com/users/1" 513 | }, 514 | "attributes": { 515 | "first_name": "Tiago", 516 | "last_name": "Guedes", 517 | "full_name": "Tiago Guedes", 518 | "birthday": null 519 | }, 520 | "relationships": { 521 | "posts": { 522 | "links": { 523 | "self": "http://api.myblog.com/users/1/relationships/posts", 524 | "related": "http://api.myblog.com/users/1/posts" 525 | } 526 | } 527 | } 528 | }, 529 | { 530 | "id": "2", 531 | "type": "users", 532 | "links": { 533 | "self": "http://api.myblog.com/users/2" 534 | }, 535 | "attributes": { 536 | "first_name": "Douglas", 537 | "last_name": "André", 538 | "full_name": "Douglas André", 539 | "birthday": null 540 | }, 541 | "relationships": { 542 | "posts": { 543 | "links": { 544 | "self": "http://api.myblog.com/users/2/relationships/posts", 545 | "related": "http://api.myblog.com/users/2/posts" 546 | } 547 | } 548 | } 549 | } 550 | ], 551 | "meta": { 552 | "record_count": 2 553 | }, 554 | "links": { 555 | "first": "http://api.myblog.com/users?page%5Bnumber%5D=1&page%5Bsize%5D=10", 556 | "last": "http://api.myblog.com/users?page%5Bnumber%5D=1&page%5Bsize%5D=10" 557 | } 558 | } 559 | ``` 560 | 561 | #### Index (options) 562 | 563 | Request: 564 | 565 | ``` 566 | GET /users?include=posts&fields[users]=first_name,last_name,posts&fields[posts]=title&sort=first_name,last_name&page[number]=1&page[size]=1 HTTP/1.1 567 | Accept: application/vnd.api+json 568 | ``` 569 | 570 | Response: 571 | 572 | ```json 573 | HTTP/1.1 200 OK 574 | Content-Type: application/vnd.api+json 575 | 576 | { 577 | "data": [ 578 | { 579 | "id": "2", 580 | "type": "users", 581 | "links": { 582 | "self": "http://api.myblog.com/users/2" 583 | }, 584 | "attributes": { 585 | "first_name": "Douglas", 586 | "last_name": "André" 587 | }, 588 | "relationships": { 589 | "posts": { 590 | "links": { 591 | "self": "http://api.myblog.com/users/2/relationships/posts", 592 | "related": "http://api.myblog.com/users/2/posts" 593 | }, 594 | "data": [] 595 | } 596 | } 597 | }, 598 | { 599 | "id": "1", 600 | "type": "users", 601 | "links": { 602 | "self": "http://api.myblog.com/users/1" 603 | }, 604 | "attributes": { 605 | "first_name": "Tiago", 606 | "last_name": "Guedes" 607 | }, 608 | "relationships": { 609 | "posts": { 610 | "links": { 611 | "self": "http://api.myblog.com/users/1/relationships/posts", 612 | "related": "http://api.myblog.com/users/1/posts" 613 | }, 614 | "data": [ 615 | { 616 | "type": "posts", 617 | "id": "1" 618 | } 619 | ] 620 | } 621 | } 622 | } 623 | ], 624 | "included": [ 625 | { 626 | "id": "1", 627 | "type": "posts", 628 | "links": { 629 | "self": "http://api.myblog.com/posts/1" 630 | }, 631 | "attributes": { 632 | "title": "An awesome post" 633 | } 634 | } 635 | ], 636 | "meta": { 637 | "record_count": 2 638 | }, 639 | "links": { 640 | "first": "http://api.myblog.com/users?fields%5Bposts%5D=title&fields%5Busers%5D=first_name%2Clast_name%2Cposts&include=posts&page%5Blimit%5D=2&page%5Boffset%5D=0&sort=first_name%2Clast_name", 641 | "last": "http://api.myblog.com/users?fields%5Bposts%5D=title&fields%5Busers%5D=first_name%2Clast_name%2Cposts&include=posts&page%5Blimit%5D=2&page%5Boffset%5D=0&sort=first_name%2Clast_name" 642 | } 643 | } 644 | ``` 645 | 646 | #### Show 647 | 648 | Request: 649 | 650 | ``` 651 | GET /users/1 HTTP/1.1 652 | Accept: application/vnd.api+json 653 | ``` 654 | 655 | Response: 656 | 657 | ```json 658 | HTTP/1.1 200 OK 659 | Content-Type: application/vnd.api+json 660 | 661 | { 662 | "data": { 663 | "id": "1", 664 | "type": "users", 665 | "links": { 666 | "self": "http://api.myblog.com/users/1" 667 | }, 668 | "attributes": { 669 | "first_name": "Tiago", 670 | "last_name": "Guedes", 671 | "full_name": "Tiago Guedes", 672 | "birthday": null 673 | }, 674 | "relationships": { 675 | "posts": { 676 | "links": { 677 | "self": "http://api.myblog.com/users/1/relationships/posts", 678 | "related": "http://api.myblog.com/users/1/posts" 679 | } 680 | } 681 | } 682 | } 683 | } 684 | ``` 685 | 686 | #### Show (options) 687 | 688 | Request: 689 | 690 | ``` 691 | GET /users/1?include=posts&fields[users]=full_name,posts&fields[posts]=title HTTP/1.1 692 | Accept: application/vnd.api+json 693 | ``` 694 | 695 | Response: 696 | 697 | ```json 698 | HTTP/1.1 200 OK 699 | Content-Type: application/vnd.api+json 700 | 701 | { 702 | "data": { 703 | "id": "1", 704 | "type": "users", 705 | "links": { 706 | "self": "http://api.myblog.com/users/1" 707 | }, 708 | "attributes": { 709 | "full_name": "Tiago Guedes" 710 | }, 711 | "relationships": { 712 | "posts": { 713 | "links": { 714 | "self": "http://api.myblog.com/users/1/relationships/posts", 715 | "related": "http://api.myblog.com/users/1/posts" 716 | }, 717 | "data": [ 718 | { 719 | "type": "posts", 720 | "id": "1" 721 | } 722 | ] 723 | } 724 | } 725 | }, 726 | "included": [ 727 | { 728 | "id": "1", 729 | "type": "posts", 730 | "links": { 731 | "self": "http://api.myblog.com/posts/1" 732 | }, 733 | "attributes": { 734 | "title": "An awesome post" 735 | } 736 | } 737 | ] 738 | } 739 | ``` 740 | 741 | #### Relationships (identifier objects) 742 | 743 | Request: 744 | 745 | ``` 746 | GET /users/1/relationships/posts HTTP/1.1 747 | Accept: application/vnd.api+json 748 | ``` 749 | 750 | Response: 751 | 752 | ```json 753 | HTTP/1.1 200 OK 754 | Content-Type: application/vnd.api+json 755 | 756 | { 757 | "links": { 758 | "self": "http://api.myblog.com/users/1/relationships/posts", 759 | "related": "http://api.myblog.com/users/1/posts" 760 | }, 761 | "data": [ 762 | { 763 | "type": "posts", 764 | "id": "1" 765 | } 766 | ] 767 | } 768 | ``` 769 | 770 | #### Nested resources 771 | 772 | Request: 773 | 774 | ``` 775 | GET /users/1/posts HTTP/1.1 776 | Accept: application/vnd.api+json 777 | ``` 778 | 779 | Response: 780 | 781 | ```json 782 | HTTP/1.1 200 OK 783 | Content-Type: application/vnd.api+json 784 | 785 | { 786 | "data": [ 787 | { 788 | "id": "1", 789 | "type": "posts", 790 | "links": { 791 | "self": "http://api.myblog.com/posts/1" 792 | }, 793 | "attributes": { 794 | "title": "An awesome post", 795 | "body": "Lorem ipsum dolot sit amet" 796 | }, 797 | "relationships": { 798 | "author": { 799 | "links": { 800 | "self": "http://api.myblog.com/posts/1/relationships/author", 801 | "related": "http://api.myblog.com/posts/1/author" 802 | } 803 | } 804 | } 805 | } 806 | ], 807 | "meta": { 808 | "record_count": 1 809 | }, 810 | "links": { 811 | "first": "http://api.myblog.com/posts?page%5Bnumber%5D=1&page%5Bsize%5D=10", 812 | "last": "http://api.myblog.com/posts?page%5Bnumber%5D=1&page%5Bsize%5D=10" 813 | } 814 | } 815 | ``` 816 | 817 | ## Development 818 | 819 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 820 | 821 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 822 | 823 | ## Contributing 824 | 825 | Bug reports and pull requests are welcome on GitHub at https://github.com/tiagopog/jsonapi-utils. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](https://contributor-covenant.org) code of conduct. 826 | 827 | 828 | ## License 829 | 830 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 831 | 832 | 833 | --------------------------------------------------------------------------------