diff --git a/app/abilities/blogger_ability.rb b/app/abilities/blogger_ability.rb index 4977157..5ad4c55 100644 --- a/app/abilities/blogger_ability.rb +++ b/app/abilities/blogger_ability.rb @@ -1,6 +1,7 @@ class BloggerAbility < Ability def initialize(user) can [:show, :destroy, :update], User, { id: user.id } + can [:create, :destroy, :update], Blog, { user_id: user.id } super end end diff --git a/app/controllers/api/v1/blogs_controller.rb b/app/controllers/api/v1/blogs_controller.rb new file mode 100644 index 0000000..dc1593f --- /dev/null +++ b/app/controllers/api/v1/blogs_controller.rb @@ -0,0 +1,20 @@ +class Api::V1::BlogsController < Api::V1::ResourceController + skip_before_action :authenticate_user!, only: [:show, :index] + + before_action :set_blogs, only: :index + load_resource only: :show + + with_options only: [:create, :update, :destroy] do + load_and_authorize_resource :user + load_and_authorize_resource :blog, through: :user, shallow: true + end + + private + def set_blogs + @blogs = Blog.all.includes(:author) + end + + def resource_params + params.require(:blog).permit(:title, :description) + end +end diff --git a/app/controllers/api/v1/resource_controller.rb b/app/controllers/api/v1/resource_controller.rb new file mode 100644 index 0000000..4e72269 --- /dev/null +++ b/app/controllers/api/v1/resource_controller.rb @@ -0,0 +1,56 @@ +class Api::V1::ResourceController < Api::V1::BaseController + def index + resources = paginate(self.resources) + render json: collection_serializer_klass.new( + resources, + each_serializer: collection_each_serializer_klass, + root: controller_name, + meta: meta_attributes(resources) + ) + end + + def show + render json: resource + end + + def create + if resource.save + render json: resource, status: :created + else + render json: resource.errors.full_messages, status: :unprocessable_entity + end + end + + def update + if resource.update(resource_params) + render json: resource, status: :ok + else + render json: resource.errors.full_messages, status: :unprocessable_entity + end + end + + def destroy + if resource.destroy + render json: { message: 'resource deleted successfully' }, status: :ok + else + render json: resource.errors.full_messages, status: :unprocessable_entity + end + end + + protected + def resource + instance_variable_get("@#{ controller_name.singularize }") + end + + def resources + instance_variable_get("@#{ controller_name }") + end + + def collection_each_serializer_klass + Api::V1.const_get("#{ controller_name.classify }Serializer") + end + + def collection_serializer_klass + ActiveModel::ArraySerializer + end +end diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb index 4b70df6..320c854 100644 --- a/app/controllers/api/v1/users_controller.rb +++ b/app/controllers/api/v1/users_controller.rb @@ -1,45 +1,6 @@ -class Api::V1::UsersController < Api::V1::BaseController +class Api::V1::UsersController < Api::V1::ResourceController load_and_authorize_resource - def show - render json: @user - end - - def index - users = paginate(User.all) - render json: ActiveModel::ArraySerializer.new( - users, - each_serializer: Api::V1::UserSerializer, - root: 'users', - meta: meta_attributes(users) - ) - end - - def destroy - if @user.destroy - render json: { message: 'resource deleted successfully' }, status: :ok - else - render json: @user.errors.full_messages, status: :unprocessable_entity - end - end - - def create - user = User.new(resource_params) - if user.save - render json: user, status: :created - else - render json: user.errors.full_messages, status: :unprocessable_entity - end - end - - def update - if @user.update(resource_params) - render json: @user, status: :ok - else - render json: @user.errors.full_messages, status: :unprocessable_entity - end - end - private def resource_params params.require(:user) diff --git a/app/controllers/concerns/pagination_helpers.rb b/app/controllers/concerns/pagination_helpers.rb index a590e97..e1ff8de 100644 --- a/app/controllers/concerns/pagination_helpers.rb +++ b/app/controllers/concerns/pagination_helpers.rb @@ -2,6 +2,7 @@ module PaginationHelpers extend ActiveSupport::Concern private + # collection must be a pagination object def meta_attributes(collection) { current_page: collection.current_page, @@ -13,8 +14,10 @@ def meta_attributes(collection) end def paginate(collection, options = {}) - collection - .page(params[:page] || options[:page]) - .per_page(params[:per_page] || options[:per_page] || WillPaginate.per_page) + require 'will_paginate/array' if collection.is_a?(Array) + collection.paginate({ + page: params[:page] || options[:page], + per_page: params[:per_page] || options[:per_page] || WillPaginate.per_page + }) end end diff --git a/app/models/ability.rb b/app/models/ability.rb index 9adb590..0c7b833 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -3,6 +3,7 @@ class Ability def initialize(user) cannot [:index], User + can [:show, :index], Blog end def self.for_user(user) diff --git a/app/models/blog.rb b/app/models/blog.rb new file mode 100644 index 0000000..68e2039 --- /dev/null +++ b/app/models/blog.rb @@ -0,0 +1,7 @@ +class Blog < ApplicationRecord + # Associations + belongs_to :author, foreign_key: :user_id, class_name: :User + + # Validations + validates :title, :description, presence: true +end diff --git a/app/models/user.rb b/app/models/user.rb index bbd1473..b718581 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,6 +6,9 @@ class User < ApplicationRecord # Enums enum role: { guest: 3, blogger: 4, admin: 5 } + # Associations + has_many :blogs, dependent: :destroy + # Validations validates :email, :first_name, presence: true with_options allow_blank: true do diff --git a/app/serializers/api/v1/blog_serializer.rb b/app/serializers/api/v1/blog_serializer.rb new file mode 100644 index 0000000..4999d31 --- /dev/null +++ b/app/serializers/api/v1/blog_serializer.rb @@ -0,0 +1,5 @@ +class Api::V1::BlogSerializer < ActiveModel::Serializer + attributes :id, :title, :description + + has_one :author, serializer: UserSerializer +end diff --git a/config/initializers/active_record_belongs_to_required_by_default.rb b/config/initializers/active_record_belongs_to_required_by_default.rb index f613b40..b6fab2b 100644 --- a/config/initializers/active_record_belongs_to_required_by_default.rb +++ b/config/initializers/active_record_belongs_to_required_by_default.rb @@ -3,4 +3,8 @@ # Require `belongs_to` associations by default. This is a new Rails 5.0 # default, so it is introduced as a configuration option to ensure that apps # made on earlier versions of Rails are not affected when upgrading. -Rails.application.config.active_record.belongs_to_required_by_default = true +# Rails.application.config.active_record.belongs_to_required_by_default = true +# +# [HACK]: `cancancan` gem hinder the loading sequence. +# see: https://github.com/CanCanCommunity/cancancan/pull/292/files +ActiveRecord::Base.belongs_to_required_by_default = true diff --git a/config/initializers/overrides/strong_parameters.rb b/config/initializers/overrides/strong_parameters.rb new file mode 100644 index 0000000..a65e66f --- /dev/null +++ b/config/initializers/overrides/strong_parameters.rb @@ -0,0 +1,6 @@ +# https://github.com/rails/rails/pull/22830 +module ActionController + class Parameters + delegate :include?, to: :@parameters + end +end diff --git a/config/routes.rb b/config/routes.rb index 08dc19b..0ad1ca6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,15 +1,15 @@ Rails.application.routes.draw do # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html - - # Serve websocket cable requests in-process - # mount ActionCable.server => '/cable' - namespace :api do namespace :v1 do controller :sessions do post :login, action: :create end - resources :users, only: [:index, :create, :show, :update, :destroy] + + resources :users, only: [:index, :create, :show, :update, :destroy] do + resources :blogs, only: [:create, :show, :update, :destroy], shallow: true + end + resources :blogs, only: [:index] end end end diff --git a/db/migrate/20160128192002_create_blogs.rb b/db/migrate/20160128192002_create_blogs.rb new file mode 100644 index 0000000..7faffa3 --- /dev/null +++ b/db/migrate/20160128192002_create_blogs.rb @@ -0,0 +1,11 @@ +class CreateBlogs < ActiveRecord::Migration[5.0] + def change + create_table :blogs do |t| + t.references :user, index: true, foreign_key: true + t.string :title + t.text :description + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 84137f8..599205f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,16 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20160128165609) do +ActiveRecord::Schema.define(version: 20160128192002) do + + create_table "blogs", force: :cascade do |t| + t.integer "user_id" + t.string "title" + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_blogs_on_user_id" + end create_table "users", force: :cascade do |t| t.string "email" diff --git a/spec/controllers/api/v1/blogs_controller_spec.rb b/spec/controllers/api/v1/blogs_controller_spec.rb new file mode 100644 index 0000000..e11c944 --- /dev/null +++ b/spec/controllers/api/v1/blogs_controller_spec.rb @@ -0,0 +1,322 @@ +require 'rails_helper' + +RSpec.describe Api::V1::BlogsController, type: :api do + let(:admin) { FactoryGirl.create(:user, :admin) } + let(:blogger) { FactoryGirl.create(:user, :blogger) } + + describe '#index' do + let!(:blogs) { FactoryGirl.create_list(:blog, 2) } + let(:response) do + ActiveModel::ArraySerializer.new( + blogs, + each_serializer: Api::V1::BlogSerializer, + root: 'blogs', + meta: meta_attributes(blogs) + ).to_json + end + + shared_examples_for 'guest_index_visit' do + it 'returns all the blogs details' do + get api_v1_blogs_path + expect(last_response.body).to eq(response) + end + end + + context 'when guest' do + it_behaves_like 'guest_index_visit' + end + + context 'when blogger' do + sign_in(:blogger) + + it_behaves_like 'guest_index_visit' + end + + context 'when admin' do + sign_in(:admin) + + it_behaves_like 'guest_index_visit' + end + end + + describe '#show' do + let(:blog) { FactoryGirl.create(:blog) } + let(:response) { Api::V1::BlogSerializer.new(blog).to_json } + + shared_examples_for 'guest_show_visit' do + it 'returns blog details' do + get api_v1_blog_path(blog) + expect(last_response.body).to eq(response) + end + end + + context 'when guest' do + it_behaves_like 'guest_show_visit' + end + + context 'when blogger' do + sign_in(:blogger) + + it_behaves_like 'guest_show_visit' + end + + context 'when admin' do + sign_in(:admin) + + it_behaves_like 'guest_show_visit' + end + end + + describe '#create' do + let(:blog_attributes) { FactoryGirl.attributes_for(:blog) } + let(:response) { Api::V1::BlogSerializer.new(blog).to_json } + + shared_examples_for 'user_creates_blog' do + context 'when valid paramaters' do + let(:blog) { Blog.last } + before do + post api_v1_user_blogs_path(user, params: { blog: blog_attributes }) + end + + it 'returns 201 status code' do + expect(last_response.status).to eq(201) + end + + it 'returns blog details' do + expect(last_response.body).to eq(response) + end + end + + context 'when invalid paramaters' do + before do + post api_v1_user_blogs_path(user, params: { blog: { title: '' } }) + end + + it 'returns 422 status code' do + expect(last_response.status).to eq(422) + end + + it 'returns error messages' do + errors = ["Title can't be blank", "Description can't be blank"] + expect(last_response.body).to eq(errors.to_json) + end + end + end + + context 'when guest' do + before do + post api_v1_user_blogs_path(blogger, params: { blog: blog_attributes }) + end + + it 'returns authentication error' do + expect(last_response.status).to eq(401) + end + end + + context 'when blogger' do + sign_in(:blogger) + + context 'when creating for self' do + let(:user) { blogger } + + it_behaves_like 'user_creates_blog' + end + + context 'when creating for other' do + before do + post api_v1_user_blogs_path(admin, params: { blog: blog_attributes }) + end + + it 'returns unauthorized error' do + expect(last_response.status).to eq(403) + end + end + end + + context 'when admin' do + sign_in(:admin) + + context 'when creating for self' do + let(:user) { admin } + + it_behaves_like 'user_creates_blog' + end + + context 'when creating for other' do + let(:user) { blogger } + + it_behaves_like 'user_creates_blog' + end + end + end + + describe '#update' do + let(:blog) do + _blog = FactoryGirl.build(:blog).tap do |blog| + blog.author = user if defined?(user) + end + _blog.save! + _blog + end + let(:blog_attributes) { blog.attributes } + let(:response) { Api::V1::BlogSerializer.new(blog).to_json } + + shared_examples_for 'user_updates_blog' do + context 'when valid paramaters' do + before do + put api_v1_blog_path(blog, params: { blog: blog_attributes }) + end + + it 'returns 200 status code' do + expect(last_response.status).to eq(200) + end + + it 'returns blog details' do + expect(last_response.body).to eq(response) + end + end + + context 'when invalid paramaters' do + before do + put api_v1_blog_path(blog, params: { blog: { title: '' } }) + end + + it 'returns 422 status code' do + expect(last_response.status).to eq(422) + end + + it 'returns error messages' do + errors = ["Title can't be blank"] + expect(last_response.body).to eq(errors.to_json) + end + end + end + + context 'when guest visits' do + before do + put api_v1_blog_path(blog, params: { blog: blog_attributes }) + end + + it 'returns authentication error' do + expect(last_response.status).to eq(401) + end + end + + context 'when blogger visits' do + sign_in(:blogger) + + context 'when updating for self' do + let(:user) { blogger } + + it_behaves_like 'user_updates_blog' + end + + context 'when updating for other' do + let(:user) { admin } + + before do + put api_v1_blog_path(blog, params: { blog: blog_attributes }) + end + + it 'returns unauthorized error' do + expect(last_response.status).to eq(403) + end + end + end + + context 'when admins visits' do + sign_in(:admin) + + context 'when updating for self' do + let(:user) { admin } + + it_behaves_like 'user_updates_blog' + end + + context 'when updating for other' do + let(:user) { blogger } + + it_behaves_like 'user_updates_blog' + end + end + end + + describe '#destroy' do + let(:blog) do + _blog = FactoryGirl.build(:blog).tap do |blog| + blog.author = user if defined?(user) + end + _blog.save! + _blog + end + let(:response) { { message: 'resource deleted successfully' }.to_json } + + shared_examples_for 'user_deletes_blog' do + context 'when successful' do + before do + delete api_v1_blog_path(blog) + end + + it 'returns 200 status code' do + expect(last_response.status).to eq(200) + end + + it 'returns blog details' do + expect(last_response.body).to eq(response) + end + end + + context 'when unsuccessful' do + pending "not possible" + end + end + + context 'when guest visits' do + before do + delete api_v1_blog_path(blog) + end + + it 'returns authentication error' do + expect(last_response.status).to eq(401) + end + end + + context 'when blogger visits' do + sign_in(:blogger) + + context 'when deleting for self' do + let(:user) { blogger } + + it_behaves_like 'user_deletes_blog' + end + + context 'when deleting for other' do + let(:user) { admin } + + before do + delete api_v1_blog_path(blog) + end + + it 'returns unauthorized error' do + expect(last_response.status).to eq(403) + end + end + end + + context 'when admins visits' do + sign_in(:admin) + + context 'when deleting for self' do + let(:user) { admin } + + it_behaves_like 'user_deletes_blog' + end + + context 'when deleting for other' do + let(:user) { blogger } + + it_behaves_like 'user_deletes_blog' + end + end + end +end diff --git a/spec/factories/blogs.rb b/spec/factories/blogs.rb new file mode 100644 index 0000000..c43b8cc --- /dev/null +++ b/spec/factories/blogs.rb @@ -0,0 +1,10 @@ +FactoryGirl.define do + factory :blog do + title { Faker::Lorem.word } + description { Faker::Lorem.sentences(10).join("\n") } + + after(:build) do |blog| + blog.author = FactoryGirl.build(:user) + end + end +end diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb index 31c6148..c29c090 100644 --- a/spec/models/ability_spec.rb +++ b/spec/models/ability_spec.rb @@ -5,6 +5,8 @@ let(:blogger) { FactoryGirl.create(:user, :blogger) } let(:guest) { FactoryGirl.build(:user, :guest) } let(:subject) { Ability.for_user(user) } + let(:blogger_blog) { blogger.blogs.build } + let(:admin_blog) { admin.blogs.build } context '.for_user' do it { expect(Ability.for_user(admin)).to be_kind_of(AdminAbility) } @@ -17,6 +19,9 @@ let(:user) { guest } it { should_not be_able_to(:index, User) } + it { should have_abilities([:show, :index], Blog) } + it { should not_have_abilities([:create, :destroy, :update], blogger_blog) } + it { should not_have_abilities([:create, :destroy, :update], admin_blog) } end context 'when admin' do @@ -31,5 +36,8 @@ it { should_not be_able_to(:index, User) } it { should have_abilities([:show, :destroy, :update], user) } it { should not_have_abilities([:show, :destroy, :update], admin) } + it { should have_abilities([:show, :index], Blog) } + it { should have_abilities([:create, :destroy, :update], blogger_blog) } + it { should not_have_abilities([:create, :destroy, :update], admin_blog) } end end diff --git a/spec/models/blog_spec.rb b/spec/models/blog_spec.rb new file mode 100644 index 0000000..bef191d --- /dev/null +++ b/spec/models/blog_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +RSpec.describe Blog, type: :model do + describe 'Associations' do + it { should belong_to(:author).class_name('User') } + end + + describe 'Validations' do + it { should validate_presence_of(:title) } + it { should validate_presence_of(:description) } + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 6c18421..2dfc248 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -13,6 +13,10 @@ it { should allow_values('a@abc.com', 'kd@yahoo.co').for(:email) } end + describe 'Associations' do + it { should have_many(:blogs).dependent(:destroy) } + end + describe 'Callbacks' do describe 'before_create' do it "will be treated as Blogger, if don't have any role" do diff --git a/spec/serializers/api/v1/blog_serializer_spec.rb b/spec/serializers/api/v1/blog_serializer_spec.rb new file mode 100644 index 0000000..1fcff78 --- /dev/null +++ b/spec/serializers/api/v1/blog_serializer_spec.rb @@ -0,0 +1,16 @@ +require 'rails_helper' + +RSpec.describe Api::V1::BlogSerializer, type: :serializer do + let(:resource) { FactoryGirl.create(:blog) } + let(:serializer) { described_class.new(resource) } + let(:serialization) { serializer.as_json } + + it "returns id, title, description & author's details" do + expect(serialization).to eq({ + id: resource.id, + title: resource.title, + description: resource.description, + author: Api::V1::UserSerializer.new(resource.author).as_json + }) + end +end diff --git a/spec/support/api_helper.rb b/spec/support/api_helper.rb index f97d1b2..7725e7f 100644 --- a/spec/support/api_helper.rb +++ b/spec/support/api_helper.rb @@ -1,6 +1,7 @@ module ApiHelper include Rack::Test::Methods include Rails.application.routes.url_helpers + include PaginationHelpers def default_url_options {} @@ -9,4 +10,16 @@ def default_url_options def app Rails.application end + + def params + HashWithIndifferentAccess.new(last_request.params) + end + + private + def meta_attributes(collection) + if collection.is_a?(Array) + collection = paginate(collection) + end + super + end end diff --git a/spec/support/authentication_helper.rb b/spec/support/authentication_helper.rb index ded008c..5c9caa0 100644 --- a/spec/support/authentication_helper.rb +++ b/spec/support/authentication_helper.rb @@ -1,12 +1,25 @@ module AuthenticationHelper + extend ActiveSupport::Concern + + module ClassMethods + def create_and_sign_in_user(role = :blogger) + before { create_and_sign_in_user(role) } + end + alias_method :create_and_sign_in_blogger, :create_and_sign_in_user + + def sign_in(user_symbol) + before { sign_in(send(user_symbol)) } + end + end + def sign_in(user) __set_header__("Token token=\"#{user.authentication_token}\", email=\"#{user.email}\"") end - def create_and_sign_in_user - FactoryGirl.create(:user).tap { |user| sign_in(user) } + def create_and_sign_in_user(role = :blogger) + FactoryGirl.create(:user, role).tap { |user| sign_in(user) } end - alias_method :create_and_sign_in_another_user, :create_and_sign_in_user + alias_method :create_and_sign_in_blogger, :create_and_sign_in_user private def __set_header__(value)