diff --git a/.travis.yml b/.travis.yml index b7eb6a9..07b0fb6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,4 +6,4 @@ env: rvm: - 2.2 before_install: gem install bundler -v 1.10.4 -script: bundle exec rspec spec/controllers/{user,post}s_controller_spec.rb +script: bundle exec rspec spec/controllers diff --git a/README.md b/README.md index a4036d6..c8ed488 100644 --- a/README.md +++ b/README.md @@ -68,13 +68,15 @@ jsonapi_render json: { data: { id: 1, first_name: 'Tiago' } }, options: { model: jsonapi_render json: { data: [{ id: 1, first_name: 'Tiago' }, { id: 2, first_name: 'Doug' }] }, options: { model: User } ``` -### jsonapi_serialize +### jsonapi_format In the backstage this is the method that actually parses ActiveRecord/Hash objects and builds a new Hash compliant with JSON API. It can be called anywhere in your controllers being very useful whenever you need to work with a JSON API "serialized" version of your object before rendering it. +Note: because of semantic reasons `JSONAPI::Utils#jsonapi_serialize` was renamed being now just an alias to `JSONAPI::Utils#jsonapi_format`. + ```ruby def index - result = do_some_magic(jsonapi_serialize(User.all)) + result = do_some_magic(jsonapi_format(User.all)) render json: result end ``` @@ -228,12 +230,8 @@ Content-Type: application/vnd.api+json { "title": "Record not found", "detail": "The record identified by 3 could not be found.", - "id": null, - "href": null, - "code": 404, - "source": null, - "links": null, - "status": "not_found" + "code": "404", + "status": "404" } ] } diff --git a/lib/jsonapi/utils/exceptions.rb b/lib/jsonapi/utils/exceptions.rb index 58eb897..a20a498 100644 --- a/lib/jsonapi/utils/exceptions.rb +++ b/lib/jsonapi/utils/exceptions.rb @@ -1,25 +1,52 @@ require 'jsonapi/utils/version' -module JSONAPI::Utils::Exceptions - class BadRequest < ::JSONAPI::Exceptions::Error - def code; 400 end +module JSONAPI + module Utils + module Exceptions + class ActiveRecord < ::JSONAPI::Exceptions::Error + attr_accessor :object - def errors - [JSONAPI::Error.new(code: 400, - status: :bad_request, - title: 'Bad Request', - detail: 'This request is not supported.')] - end - end + def initialize(object) + @object = object + end + + def errors + object.errors.keys.map do |key| + JSONAPI::Error.new( + code: JSONAPI::VALIDATION_ERROR, + status: :unprocessable_entity, + id: key, + title: object.errors.full_messages_for(key).first + ) + end + end + end + + class BadRequest < ::JSONAPI::Exceptions::Error + def code; '400' end + + def errors + [JSONAPI::Error.new( + code: code, + status: :bad_request, + title: 'Bad Request', + detail: 'This request is not supported.' + )] + end + end - class InternalServerError < ::JSONAPI::Exceptions::Error - def code; 500 end + class InternalServerError < ::JSONAPI::Exceptions::Error + def code; '500' end - def errors - [JSONAPI::Error.new(code: 500, - status: :internal_server_error, - title: 'Internal Server Error', - detail: 'An internal error ocurred while processing the request.')] + def errors + [JSONAPI::Error.new( + code: code, + status: :internal_server_error, + title: 'Internal Server Error', + detail: 'An internal error ocurred while processing the request.' + )] + end + end end end end diff --git a/lib/jsonapi/utils/request.rb b/lib/jsonapi/utils/request.rb index 9b06bba..17b2bec 100644 --- a/lib/jsonapi/utils/request.rb +++ b/lib/jsonapi/utils/request.rb @@ -12,7 +12,7 @@ def setup_request end def check_request - @request.errors.blank? || render_errors(@request.errors) + @request.errors.blank? || jsonapi_render_errors(json: @request) end end end diff --git a/lib/jsonapi/utils/response/formatters.rb b/lib/jsonapi/utils/response/formatters.rb index 156a163..21c6d65 100644 --- a/lib/jsonapi/utils/response/formatters.rb +++ b/lib/jsonapi/utils/response/formatters.rb @@ -2,11 +2,7 @@ module JSONAPI module Utils module Response module Formatters - def jsonapi_format_errors(exception) - JSONAPI::ErrorsOperationResult.new(exception.errors[0].code, exception.errors) - end - - def jsonapi_serialize(records, options = {}) + def jsonapi_format(records, options = {}) if records.is_a?(Hash) hash = records.with_indifferent_access records = hash_to_active_record(hash[:data], options[:model]) @@ -15,6 +11,16 @@ def jsonapi_serialize(records, options = {}) build_response_document(records, options).contents end + alias_method :jsonapi_serialize, :jsonapi_format + + def jsonapi_format_errors(data) + data = JSONAPI::Utils::Exceptions::ActiveRecord.new(data) if data.is_a?(ActiveRecord::Base) + errors = data.respond_to?(:errors) ? data.errors : data + JSONAPI::Utils::Support::Error.sanitize(errors).uniq + end + + protected + def build_response_document(records, options) results = JSONAPI::OperationResults.new diff --git a/lib/jsonapi/utils/response/renders.rb b/lib/jsonapi/utils/response/renders.rb index 5be712b..b7d6e92 100644 --- a/lib/jsonapi/utils/response/renders.rb +++ b/lib/jsonapi/utils/response/renders.rb @@ -3,20 +3,20 @@ module Utils module Response module Renders def jsonapi_render(json:, status: nil, options: {}) - body = jsonapi_serialize(json, options) + body = jsonapi_format(json, options) render json: body, status: status || @_response_document.status rescue => e handle_exceptions(e) ensure - if response.body.size > 0 - response.headers['Content-Type'] = JSONAPI::MEDIA_TYPE - end + correct_media_type end - def jsonapi_render_errors(exception) - result = jsonapi_format_errors(exception) - errors = result.errors - render json: { errors: errors }, status: errors.first.status + def jsonapi_render_errors(exception = nil, json: nil, status: nil) + body = jsonapi_format_errors(exception || json) + status = status || body.try(:first).try(:[], :status) + render json: { errors: body }, status: status + ensure + correct_media_type end def jsonapi_render_internal_server_error diff --git a/lib/jsonapi/utils/response/support.rb b/lib/jsonapi/utils/response/support.rb index b012035..512e16c 100644 --- a/lib/jsonapi/utils/response/support.rb +++ b/lib/jsonapi/utils/response/support.rb @@ -1,14 +1,24 @@ require 'jsonapi/utils/support/filter' require 'jsonapi/utils/support/pagination' require 'jsonapi/utils/support/sort' +require 'jsonapi/utils/support/error' module JSONAPI module Utils module Response module Support + include ::JSONAPI::Utils::Support::Error include ::JSONAPI::Utils::Support::Filter include ::JSONAPI::Utils::Support::Pagination include ::JSONAPI::Utils::Support::Sort + + protected + + def correct_media_type + if response.body.size > 0 + response.headers['Content-Type'] = JSONAPI::MEDIA_TYPE + end + end end end end diff --git a/lib/jsonapi/utils/support/error.rb b/lib/jsonapi/utils/support/error.rb new file mode 100644 index 0000000..f47700e --- /dev/null +++ b/lib/jsonapi/utils/support/error.rb @@ -0,0 +1,25 @@ +module JSONAPI + module Utils + module Support + module Error + MEMBERS = %i(title detail id code source links status meta) + + module_function + + def sanitize(errors) + Array(errors).map do |error| + MEMBERS.reduce({}) do |sum, key| + value = error.try(key) || error.try(:[], key) + if value.nil? + sum + else + value = value.to_s if key == :code + sum.merge(key => value) + end + end + end + end + end + end + end +end diff --git a/lib/jsonapi/utils/version.rb b/lib/jsonapi/utils/version.rb index a439975..6c3d264 100644 --- a/lib/jsonapi/utils/version.rb +++ b/lib/jsonapi/utils/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Utils - VERSION = '0.4.4' + VERSION = '0.4.5' end end diff --git a/spec/controllers/posts_controller_spec.rb b/spec/controllers/posts_controller_spec.rb index 16b69f6..7930c1e 100644 --- a/spec/controllers/posts_controller_spec.rb +++ b/spec/controllers/posts_controller_spec.rb @@ -1,5 +1,4 @@ require 'spec_helper' -require 'rspec/expectations' describe PostsController, type: :controller do include_context 'JSON API headers' @@ -8,12 +7,28 @@ let(:fields) { (PostResource.fetchable_fields - %i(id author)).map(&:to_s) } let(:relationships) { %w(author) } - let(:post) { Post.first } + let(:first_post) { Post.first } + let(:user_id) { first_post.user_id } + + let(:attributes) do + { title: 'Lorem ipsum', body: 'Lorem ipsum dolor sit amet.' } + end + + let(:author_params) do + { data: { type: 'users', id: user_id } } + end + + let(:post_params) do + { + data: { type: 'posts', attributes: attributes }, + relationships: { author: author_params } + } + end describe '#index' do context 'with ActiveRecord::Relation' do it 'renders a collection of users' do - get :index, user_id: post.user_id + get :index, user_id: user_id expect(response).to have_http_status :ok expect(response).to have_primary_data('posts') expect(response).to have_data_attributes(fields) @@ -36,12 +51,12 @@ describe '#show' do context 'with ActiveRecord' do it 'renders a single post' do - get :show, user_id: post.user_id, id: post.id + get :show, user_id: user_id, id: first_post.id expect(response).to have_http_status :ok expect(response).to have_primary_data('posts') expect(response).to have_data_attributes(fields) expect(response).to have_relationships(relationships) - expect(data['attributes']['title']).to eq("Title for Post #{post.id}") + expect(data['attributes']['title']).to eq("Title for Post #{first_post.id}") end end @@ -59,11 +74,11 @@ context 'when resource was not found' do context 'with conventional id' do it 'renders a 404 response' do - get :show, user_id: post.user_id, id: 999 + get :show, user_id: user_id, id: 999 expect(response).to have_http_status :not_found expect(error['title']).to eq('Record not found') expect(error['detail']).to include('999') - expect(error['code']).to eq(404) + expect(error['code']).to eq('404') end end @@ -71,11 +86,11 @@ let(:uuid) { SecureRandom.uuid } it 'renders a 404 response' do - get :show, user_id: post.user_id, id: uuid + get :show, user_id: user_id, id: uuid expect(response).to have_http_status :not_found expect(error['title']).to eq('Record not found') expect(error['detail']).to include(uuid) - expect(error['code']).to eq(404) + expect(error['code']).to eq('404') end end @@ -83,13 +98,36 @@ let(:slug) { 'some-awesome-slug' } it 'renders a 404 response' do - get :show, user_id: post.user_id, id: slug + get :show, user_id: user_id, id: slug expect(response).to have_http_status :not_found expect(error['title']).to eq('Record not found') expect(error['detail']).to include(slug) - expect(error['code']).to eq(404) + expect(error['code']).to eq('404') end end end end + + describe '#create' do + it 'creates a new post' do + expect { post :create, post_params }.to change(Post, :count).by(1) + expect(response).to have_http_status :created + expect(response).to have_primary_data('posts') + expect(response).to have_data_attributes(fields) + expect(data['attributes']['title']).to eq(post_params[:data][:attributes][:title]) + end + + context 'when validation fails' do + it 'render a 422 response' do + post_params[:data][:attributes].merge!(title: nil) + + expect { post :create, post_params }.to change(Post, :count).by(0) + expect(response).to have_http_status :unprocessable_entity + + expect(errors[0]['id']).to eq('title') + expect(errors[0]['title']).to eq('Title can\'t be blank') + expect(errors[0]['code']).to eq('100') + end + end + end end diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 4e20397..5e1aba5 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -228,7 +228,7 @@ get :show, id: 999 expect(response).to have_http_status :not_found expect(error['title']).to eq('Record not found') - expect(error['code']).to eq(404) + expect(error['code']).to eq('404') end end end @@ -248,7 +248,7 @@ expect { post :create, user_params }.to change(User, :count).by(0) expect(response).to have_http_status :bad_request expect(error['title']).to eq('Param not allowed') - expect(error['code']).to eq(105) + expect(error['code']).to eq('105') end end @@ -262,11 +262,62 @@ context 'when validation fails' do it 'render a 422 response' do - user_params[:data][:attributes].merge!(first_name: nil) + user_params[:data][:attributes].merge!(first_name: nil, last_name: nil) + expect { post :create, user_params }.to change(User, :count).by(0) expect(response).to have_http_status :unprocessable_entity - expect(error['title']).to eq('Can\'t change this User') - expect(error['code']).to eq(125) + + expect(errors[0]['id']).to eq('first_name') + expect(errors[0]['title']).to eq('First name can\'t be blank') + expect(errors[0]['code']).to eq('100') + expect(errors[0]['source']).to be_nil + + expect(errors[1]['id']).to eq('last_name') + expect(errors[1]['title']).to eq('Last name can\'t be blank') + expect(errors[1]['code']).to eq('100') + expect(errors[1]['source']).to be_nil + end + end + end + + describe '#update' do + let(:user) { User.first } + + let(:update_params) do + user_params.tap do |params| + params[:data][:id] = user.id + params[:data][:attributes].merge!(first_name: 'Yukihiro') + params.merge!(id: user.id) + end + end + + it 'update an existing user' do + patch :update, update_params + expect(response).to have_http_status :ok + expect(response).to have_primary_data('users') + expect(response).to have_data_attributes(fields) + expect(data['attributes']['first_name']).to eq(user_params[:data][:attributes][:first_name]) + end + + context 'when resource was not found' do + it 'renders a 404 response' do + update_params[:data][:id] = 999 + patch :update, update_params.merge(id: 999) + expect(response).to have_http_status :not_found + expect(error['title']).to eq('Record not found') + expect(error['code']).to eq('404') + end + end + + context 'when validation fails' do + it 'render a 422 response' do + update_params[:data][:attributes].merge!(first_name: nil, last_name: nil) + patch :update, update_params + expect(response).to have_http_status :unprocessable_entity + expect(errors[0]['id']).to eq('my_custom_validation_error') + expect(errors[0]['title']).to eq('My custom error message') + expect(errors[0]['code']).to eq('125') + expect(errors[0]['source']).to be_nil end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 6d34af0..f226ecc 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -63,7 +63,7 @@ class TestApp < Rails::Application config.secret_key_base = 'secret' # Raise errors on unsupported parameters - config.action_controller.action_on_unpermitted_parameters = :raise + config.action_controller.action_on_unpermitted_parameters = :log ActiveRecord::Schema.verbose = false config.active_record.schema_format = :none @@ -91,9 +91,11 @@ class TestApp < Rails::Application TestApp.routes.draw do jsonapi_resources :users do - jsonapi_resources :posts + jsonapi_resources :posts, only: %i(index show) end + jsonapi_resources :posts, only: %i(create) + get :index_with_hash, to: 'posts#index_with_hash' get :show_with_hash, to: 'posts#show_with_hash' end diff --git a/spec/support/controllers.rb b/spec/support/controllers.rb index 25bbd5d..51336e2 100644 --- a/spec/support/controllers.rb +++ b/spec/support/controllers.rb @@ -7,7 +7,7 @@ class BaseController < JSONAPI::ResourceController end class PostsController < BaseController - before_action :load_user, except: %i(index_with_hash show_with_hash) + before_action :load_user, except: %i(create index_with_hash show_with_hash) # GET /users/:user_id/posts def index @@ -20,6 +20,7 @@ def index_with_hash { id: 1, title: 'Lorem Ipsum' }, { id: 2, title: 'Dolor Sit' } ]} + # Example of response rendering from Hash + options: jsonapi_render json: @posts, options: { model: Post } end @@ -30,18 +31,34 @@ def show # GET /show_with_hash/:id def show_with_hash + # Example of response rendering from Hash + options: (2) jsonapi_render json: { data: { id: params[:id], title: 'Lorem ipsum' } }, options: { model: Post, resource: ::V2::PostResource } end - # POST /users/:user_id/posts + # POST /users def create - new_post = FactoryGirl.create(:post, user: @user) - jsonapi_render json: new_post, status: :created + post = Post.new(post_params) + if post.save + jsonapi_render json: post, status: :created + else + # Example of error rendering for Array of Hashes: + errors = [{ id: 'title', title: 'Title can\'t be blank', code: '100' }] + jsonapi_render_errors json: errors, status: :unprocessable_entity + end end protected + def post_params + params.require(:data).require(:attributes).permit(:title, :body) + .merge(user_id: author_params[:id]) + end + + def author_params + params.require(:relationships).require(:author).require(:data).permit(:id) + end + def load_user @user = User.find(params[:user_id]) end @@ -66,7 +83,20 @@ def create if user.save jsonapi_render json: user, status: :created else - jsonapi_render_errors ::Exceptions::ActiveRecordError.new(user) + # Example of error rendering for ActiveRecord objects: + jsonapi_render_errors json: user, status: :unprocessable_entity + end + end + + # PATCH /users/:id + def update + user = User.find(params[:id]) + if user.update(user_params) + jsonapi_render json: user + else + # Example of error rendering for exceptions or any object + # that implements the "errors" method. + jsonapi_render_errors ::Exceptions::MyCustomError.new(user) end end diff --git a/spec/support/exceptions.rb b/spec/support/exceptions.rb index 1a64de7..c88ccad 100644 --- a/spec/support/exceptions.rb +++ b/spec/support/exceptions.rb @@ -1,5 +1,5 @@ module Exceptions - class ActiveRecordError < ::JSONAPI::Exceptions::Error + class MyCustomError < ::JSONAPI::Exceptions::Error attr_accessor :object def initialize(object) @@ -8,10 +8,10 @@ def initialize(object) def errors [JSONAPI::Error.new( - code: 125, + code: '125', status: :unprocessable_entity, - title: "Can't change this #{@object.class.name}", - detail: @object.errors)] + id: 'my_custom_validation_error', + title: 'My custom error message')] end end end diff --git a/spec/support/helpers.rb b/spec/support/helpers.rb index 1309cec..2d65c9a 100644 --- a/spec/support/helpers.rb +++ b/spec/support/helpers.rb @@ -6,8 +6,12 @@ def json @json ||= JSON.parse(response.body) end + def errors + @errors ||= json['errors'] + end + def error - @error ||= json['errors'].first + @error ||= errors.first end def data diff --git a/spec/support/shared/jsonapi_errors.rb b/spec/support/shared/jsonapi_errors.rb index 662f1d3..8428242 100644 --- a/spec/support/shared/jsonapi_errors.rb +++ b/spec/support/shared/jsonapi_errors.rb @@ -6,7 +6,7 @@ get :index, include: :foobar expect(response).to have_http_status :bad_request expect(error['title']).to eq('Invalid field') - expect(error['code']).to eq(112) + expect(error['code']).to eq('112') end end end @@ -17,7 +17,7 @@ get :index, fields: { foo: 'bar' } expect(response).to have_http_status :bad_request expect(error['title']).to eq('Invalid resource') - expect(error['code']).to eq(101) + expect(error['code']).to eq('101') end end @@ -26,7 +26,7 @@ get :index, fields: { users: 'bar' } expect(response).to have_http_status :bad_request expect(error['title']).to eq('Invalid field') - expect(error['code']).to eq(104) + expect(error['code']).to eq('104') end end end @@ -37,7 +37,7 @@ get :index, filter: { foo: 'bar' } expect(response).to have_http_status :bad_request expect(error['title']).to eq('Filter not allowed') - expect(error['code']).to eq(102) + expect(error['code']).to eq('102') end end end @@ -49,7 +49,7 @@ get :index, page: { number: 'foo' } expect(response).to have_http_status :bad_request expect(error['title']).to eq('Invalid page value') - expect(error['code']).to eq(118) + expect(error['code']).to eq('118') end end @@ -58,16 +58,14 @@ get :index, page: { size: 'foo' } expect(response).to have_http_status :bad_request expect(error['title']).to eq('Invalid page value') - expect(error['code']).to eq(118) + expect(error['code']).to eq('118') end end context 'with invalid page param' do it 'renders a 400 response' do get :index, page: { offset: 1 } - expect(response).to have_http_status :bad_request - expect(error['title']).to eq('Page parameter not allowed') - expect(error['code']).to eq(105) + expect(response).to have_http_status :ok end end @@ -76,7 +74,7 @@ get :index, page: { size: 999 } expect(response).to have_http_status :bad_request expect(error['title']).to eq('Invalid page value') - expect(error['code']).to eq(118) + expect(error['code']).to eq('118') end end end @@ -89,7 +87,7 @@ get :index, page: { offset: -1 } expect(response).to have_http_status :bad_request expect(error['title']).to eq('Invalid page value') - expect(error['code']).to eq(118) + expect(error['code']).to eq('118') end end @@ -98,25 +96,23 @@ get :index, page: { limit: 'foo' } expect(response).to have_http_status :bad_request expect(error['title']).to eq('Invalid page value') - expect(error['code']).to eq(118) + expect(error['code']).to eq('118') end end context 'with invalid page param' do it 'renders a 400 response' do get :index, page: { size: 1 } - expect(response).to have_http_status :bad_request - expect(error['title']).to eq('Page parameter not allowed') - expect(error['code']).to eq(105) + expect(response).to have_http_status :ok end end - context 'with a "size" greater than the max limit' do + context 'with a "limit" greater than the max limit' do it 'returns the amount of results based on "JSONAPI.configuration.maximum_page_size"' do get :index, page: { limit: 999 } expect(response).to have_http_status :bad_request expect(error['title']).to eq('Invalid page value') - expect(error['code']).to eq(118) + expect(error['code']).to eq('118') end end end @@ -128,7 +124,7 @@ get :index, sort: 'foo' expect(response).to have_http_status :bad_request expect(error['title']).to eq('Invalid sort criteria') - expect(error['code']).to eq(114) + expect(error['code']).to eq('114') end end end