From 7024d2ffefe82a39d7430c58ace4d9106d5f125d Mon Sep 17 00:00:00 2001 From: Juan Esteban Nieto Date: Wed, 10 Nov 2021 15:06:30 -0500 Subject: [PATCH 1/3] add models --- app/models/item.rb | 7 +++++ app/models/todo.rb | 7 +++++ db/migrate/20211110194428_create_todos.rb | 10 +++++++ db/migrate/20211110194855_create_items.rb | 11 ++++++++ db/schema.rb | 32 +++++++++++++++++++++++ spec/models/item_spec.rb | 10 +++++++ spec/models/todo_spec.rb | 11 ++++++++ 7 files changed, 88 insertions(+) create mode 100644 app/models/item.rb create mode 100644 app/models/todo.rb create mode 100644 db/migrate/20211110194428_create_todos.rb create mode 100644 db/migrate/20211110194855_create_items.rb create mode 100644 db/schema.rb create mode 100644 spec/models/item_spec.rb create mode 100644 spec/models/todo_spec.rb diff --git a/app/models/item.rb b/app/models/item.rb new file mode 100644 index 0000000..e33eb18 --- /dev/null +++ b/app/models/item.rb @@ -0,0 +1,7 @@ +class Item < ApplicationRecord + # model association + belongs_to :todo + + # validation + validates_presence_of :name +end diff --git a/app/models/todo.rb b/app/models/todo.rb new file mode 100644 index 0000000..6b6956c --- /dev/null +++ b/app/models/todo.rb @@ -0,0 +1,7 @@ +class Todo < ApplicationRecord + # model association + has_many :items, dependent: :destroy + + # validations + validates_presence_of :title, :created_by +end diff --git a/db/migrate/20211110194428_create_todos.rb b/db/migrate/20211110194428_create_todos.rb new file mode 100644 index 0000000..be1f3b1 --- /dev/null +++ b/db/migrate/20211110194428_create_todos.rb @@ -0,0 +1,10 @@ +class CreateTodos < ActiveRecord::Migration[6.1] + def change + create_table :todos do |t| + t.string :title + t.string :created_by + + t.timestamps + end + end +end diff --git a/db/migrate/20211110194855_create_items.rb b/db/migrate/20211110194855_create_items.rb new file mode 100644 index 0000000..53fc718 --- /dev/null +++ b/db/migrate/20211110194855_create_items.rb @@ -0,0 +1,11 @@ +class CreateItems < ActiveRecord::Migration[6.1] + def change + create_table :items do |t| + t.string :name + t.boolean :done + t.references :todo, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..4635d0c --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,32 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema.define(version: 2021_11_10_194855) do + + create_table "items", force: :cascade do |t| + t.string "name" + t.boolean "done" + t.integer "todo_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["todo_id"], name: "index_items_on_todo_id" + end + + create_table "todos", force: :cascade do |t| + t.string "title" + t.string "created_by" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + end + + add_foreign_key "items", "todos" +end diff --git a/spec/models/item_spec.rb b/spec/models/item_spec.rb new file mode 100644 index 0000000..7ecafdf --- /dev/null +++ b/spec/models/item_spec.rb @@ -0,0 +1,10 @@ +require 'rails_helper' + +RSpec.describe Item, type: :model do + # Association test + # ensure an item record belongs to a single todo record + it { should belong_to(:todo) } + # Validation test + # ensure column name is present before saving + it { should validate_presence_of(:name) } +end diff --git a/spec/models/todo_spec.rb b/spec/models/todo_spec.rb new file mode 100644 index 0000000..be399c4 --- /dev/null +++ b/spec/models/todo_spec.rb @@ -0,0 +1,11 @@ +require 'rails_helper' + +RSpec.describe Todo, type: :model do + # Association test + # ensure Todo model has a 1:m relationship with the Item model + it { should have_many(:items).dependent(:destroy) } + # Validation tests + # ensure columns title and created_by are present before saving + it { should validate_presence_of(:title) } + it { should validate_presence_of(:created_by) } +end From 76570a034ab91da4813ae7a7acf82604eb2a01b9 Mon Sep 17 00:00:00 2001 From: Juan Esteban Nieto Date: Wed, 10 Nov 2021 18:38:27 -0500 Subject: [PATCH 2/3] add Controlers and tests --- Gemfile | 1 + app/controllers/application_controller.rb | 2 + app/controllers/concerns/exception_handler.rb | 14 ++ app/controllers/concerns/response.rb | 5 + app/controllers/items_controller.rb | 47 +++++++ app/controllers/todos_controller.rb | 43 ++++++ config/routes.rb | 3 + spec/factories/items.rb | 7 + spec/factories/todos.rb | 6 + spec/rails_helper.rb | 3 + spec/requests/items_spec.rb | 127 ++++++++++++++++++ spec/requests/todos_spec.rb | 108 +++++++++++++++ spec/support/request_spec_helper.rb | 6 + 13 files changed, 372 insertions(+) create mode 100644 app/controllers/concerns/exception_handler.rb create mode 100644 app/controllers/concerns/response.rb create mode 100644 app/controllers/items_controller.rb create mode 100644 app/controllers/todos_controller.rb create mode 100644 spec/factories/items.rb create mode 100644 spec/factories/todos.rb create mode 100644 spec/requests/items_spec.rb create mode 100644 spec/requests/todos_spec.rb create mode 100644 spec/support/request_spec_helper.rb diff --git a/Gemfile b/Gemfile index 889ccab..d8808e2 100644 --- a/Gemfile +++ b/Gemfile @@ -40,6 +40,7 @@ end gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] group :development, :test do + gem 'faker' gem 'rspec-rails', '~> 5.0.0' end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 4ac8823..45e34ea 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,2 +1,4 @@ class ApplicationController < ActionController::API + include Response + include ExceptionHandler end diff --git a/app/controllers/concerns/exception_handler.rb b/app/controllers/concerns/exception_handler.rb new file mode 100644 index 0000000..48dd18b --- /dev/null +++ b/app/controllers/concerns/exception_handler.rb @@ -0,0 +1,14 @@ +module ExceptionHandler + # provides the more graceful `included` method + extend ActiveSupport::Concern + + included do + rescue_from ActiveRecord::RecordNotFound do |e| + json_response({ message: e.message }, :not_found) + end + + rescue_from ActiveRecord::RecordInvalid do |e| + json_response({ message: e.message }, :unprocessable_entity) + end + end +end diff --git a/app/controllers/concerns/response.rb b/app/controllers/concerns/response.rb new file mode 100644 index 0000000..dc37dca --- /dev/null +++ b/app/controllers/concerns/response.rb @@ -0,0 +1,5 @@ +module Response + def json_response(object, status = :ok) + render json: object, status: status + end +end diff --git a/app/controllers/items_controller.rb b/app/controllers/items_controller.rb new file mode 100644 index 0000000..c2eab7a --- /dev/null +++ b/app/controllers/items_controller.rb @@ -0,0 +1,47 @@ +class ItemsController < ApplicationController + before_action :set_todo + before_action :set_todo_item, only: [:show, :update, :destroy] + + # GET /todos/:todo_id/items + def index + json_response(@todo.items) + end + + # GET /todos/:todo_id/items/:id + def show + json_response(@item) + end + + # POST /todos/:todo_id/items + def create + @todo.items.create!(item_params) + json_response(@todo, :created) + end + + # PUT /todos/:todo_id/items/:id + def update + @item.update(item_params) + head :no_content + end + + # DELETE /todos/:todo_id/items/:id + def destroy + @item.destroy + head :no_content + end + + private + + def item_params + params.permit(:name, :done) + end + + def set_todo + @todo = Todo.find(params[:todo_id]) + end + + def set_todo_item + @item = @todo.items.find_by!(id: params[:id]) if @todo + end +end + \ No newline at end of file diff --git a/app/controllers/todos_controller.rb b/app/controllers/todos_controller.rb new file mode 100644 index 0000000..fd25b7f --- /dev/null +++ b/app/controllers/todos_controller.rb @@ -0,0 +1,43 @@ +class TodosController < ApplicationController + before_action :set_todo, only: [:show, :update, :destroy] + + # GET /todos + def index + @todos = Todo.all + json_response(@todos) + end + + # POST /todos + def create + @todo = Todo.create!(todo_params) + json_response(@todo, :created) + end + + # GET /todos/:id + def show + json_response(@todo) + end + + # PUT /todos/:id + def update + @todo.update(todo_params) + head :no_content + end + + # DELETE /todos/:id + def destroy + @todo.destroy + head :no_content + end + + private + + def todo_params + # whitelist params + params.permit(:title, :created_by) + end + + def set_todo + @todo = Todo.find(params[:id]) + end +end diff --git a/config/routes.rb b/config/routes.rb index c06383a..45187e3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,3 +1,6 @@ Rails.application.routes.draw do # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html + resources :todos do + resources :items + end end diff --git a/spec/factories/items.rb b/spec/factories/items.rb new file mode 100644 index 0000000..330ff5e --- /dev/null +++ b/spec/factories/items.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :item do + name { Faker::Movies::StarWars.character } + done { false } + todo_id { nil } + end +end diff --git a/spec/factories/todos.rb b/spec/factories/todos.rb new file mode 100644 index 0000000..66bb5cc --- /dev/null +++ b/spec/factories/todos.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :todo do + title { Faker::Lorem.word } + created_by { Faker::Number.number(digits: 10) } + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index d74f46f..0d55f9a 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -65,6 +65,7 @@ # require database cleaner at the top level require 'database_cleaner' +require 'support/request_spec_helper' # [...] # configure shoulda matchers to use rspec as the test framework and full matcher libraries for rails @@ -81,6 +82,8 @@ # add `FactoryBot` methods config.include FactoryBot::Syntax::Methods + config.include RequestSpecHelper, type: :request + # start by truncating all the tables but then use the faster transaction strategy the rest of the time. config.before(:suite) do DatabaseCleaner.clean_with(:truncation) diff --git a/spec/requests/items_spec.rb b/spec/requests/items_spec.rb new file mode 100644 index 0000000..3d81245 --- /dev/null +++ b/spec/requests/items_spec.rb @@ -0,0 +1,127 @@ +require 'rails_helper' + +RSpec.describe 'Items API' do + # Initialize the test data + let!(:todo) { create(:todo) } + let!(:items) { create_list(:item, 20, todo_id: todo.id) } + let(:todo_id) { todo.id } + let(:id) { items.first.id } + + # Test suite for GET /todos/:todo_id/items + describe 'GET /todos/:todo_id/items' do + before { get "/todos/#{todo_id}/items" } + + context 'when todo exists' do + it 'returns status code 200' do + expect(response).to have_http_status(200) + end + + it 'returns all todo items' do + expect(json.size).to eq(20) + end + end + + context 'when todo does not exist' do + let(:todo_id) { 0 } + + it 'returns status code 404' do + expect(response).to have_http_status(404) + end + + it 'returns a not found message' do + expect(response.body).to match(/Couldn't find Todo/) + end + end + end + + # Test suite for GET /todos/:todo_id/items/:id + describe 'GET /todos/:todo_id/items/:id' do + before { get "/todos/#{todo_id}/items/#{id}" } + + context 'when todo item exists' do + it 'returns status code 200' do + expect(response).to have_http_status(200) + end + + it 'returns the item' do + expect(json['id']).to eq(id) + end + end + + context 'when todo item does not exist' do + let(:id) { 0 } + + it 'returns status code 404' do + expect(response).to have_http_status(404) + end + + it 'returns a not found message' do + expect(response.body).to match(/Couldn't find Item/) + end + end + end + + # Test suite for PUT /todos/:todo_id/items + describe 'POST /todos/:todo_id/items' do + let(:valid_attributes) { { name: 'Visit Narnia', done: false } } + + context 'when request attributes are valid' do + before { post "/todos/#{todo_id}/items", params: valid_attributes } + + it 'returns status code 201' do + expect(response).to have_http_status(201) + end + end + + context 'when an invalid request' do + before { post "/todos/#{todo_id}/items", params: {} } + + it 'returns status code 422' do + expect(response).to have_http_status(422) + end + + it 'returns a failure message' do + expect(response.body).to match(/Validation failed: Name can't be blank/) + end + end + end + + # Test suite for PUT /todos/:todo_id/items/:id + describe 'PUT /todos/:todo_id/items/:id' do + let(:valid_attributes) { { name: 'Mozart' } } + + before { put "/todos/#{todo_id}/items/#{id}", params: valid_attributes } + + context 'when item exists' do + it 'returns status code 204' do + expect(response).to have_http_status(204) + end + + it 'updates the item' do + updated_item = Item.find(id) + expect(updated_item.name).to match(/Mozart/) + end + end + + context 'when the item does not exist' do + let(:id) { 0 } + + it 'returns status code 404' do + expect(response).to have_http_status(404) + end + + it 'returns a not found message' do + expect(response.body).to match(/Couldn't find Item/) + end + end + end + + # Test suite for DELETE /todos/:id + describe 'DELETE /todos/:id' do + before { delete "/todos/#{todo_id}/items/#{id}" } + + it 'returns status code 204' do + expect(response).to have_http_status(204) + end + end +end diff --git a/spec/requests/todos_spec.rb b/spec/requests/todos_spec.rb new file mode 100644 index 0000000..9e58f9d --- /dev/null +++ b/spec/requests/todos_spec.rb @@ -0,0 +1,108 @@ +require 'rails_helper' + +RSpec.describe 'Todos API', type: :request do + # initialize test data + let!(:todos) { create_list(:todo, 10) } + let(:todo_id) { todos.first.id } + + # Test suite for GET /todos + describe 'GET /todos' do + # make HTTP get request before each example + before { get '/todos' } + + it 'returns todos' do + # Note `json` is a custom helper to parse JSON responses + expect(json).not_to be_empty + expect(json.size).to eq(10) + end + + it 'returns status code 200' do + expect(response).to have_http_status(200) + end + end + + # Test suite for GET /todos/:id + describe 'GET /todos/:id' do + before { get "/todos/#{todo_id}" } + + context 'when the record exists' do + it 'returns the todo' do + expect(json).not_to be_empty + expect(json['id']).to eq(todo_id) + end + + it 'returns status code 200' do + expect(response).to have_http_status(200) + end + end + + context 'when the record does not exist' do + let(:todo_id) { 100 } + + it 'returns status code 404' do + expect(response).to have_http_status(404) + end + + it 'returns a not found message' do + expect(response.body).to match(/Couldn't find Todo/) + end + end + end + + # Test suite for POST /todos + describe 'POST /todos' do + # valid payload + let(:valid_attributes) { { title: 'Learn Elm', created_by: '1' } } + + context 'when the request is valid' do + before { post '/todos', params: valid_attributes } + + it 'creates a todo' do + expect(json['title']).to eq('Learn Elm') + end + + it 'returns status code 201' do + expect(response).to have_http_status(201) + end + end + + context 'when the request is invalid' do + before { post '/todos', params: { title: 'Foobar' } } + + it 'returns status code 422' do + expect(response).to have_http_status(422) + end + + it 'returns a validation failure message' do + expect(response.body) + .to match(/Validation failed: Created by can't be blank/) + end + end + end + + # Test suite for PUT /todos/:id + describe 'PUT /todos/:id' do + let(:valid_attributes) { { title: 'Shopping' } } + + context 'when the record exists' do + before { put "/todos/#{todo_id}", params: valid_attributes } + + it 'updates the record' do + expect(response.body).to be_empty + end + + it 'returns status code 204' do + expect(response).to have_http_status(204) + end + end + end + + # Test suite for DELETE /todos/:id + describe 'DELETE /todos/:id' do + before { delete "/todos/#{todo_id}" } + + it 'returns status code 204' do + expect(response).to have_http_status(204) + end + end +end diff --git a/spec/support/request_spec_helper.rb b/spec/support/request_spec_helper.rb new file mode 100644 index 0000000..4c2b8ad --- /dev/null +++ b/spec/support/request_spec_helper.rb @@ -0,0 +1,6 @@ +module RequestSpecHelper + # Parse JSON response to ruby hash + def json + JSON.parse(response.body) + end +end From 663eef51e0d71fab1631fd8d82f3ce5fb3a4773d Mon Sep 17 00:00:00 2001 From: Juan Esteban Nieto Date: Thu, 11 Nov 2021 11:34:18 -0500 Subject: [PATCH 3/3] Add token to the API --- Gemfile | 5 + Gemfile.lock | 4 + app/auth/authenticate_user.rb | 23 ++++ app/auth/authorize_api_request.rb | 42 +++++++ app/controllers/application_controller.rb | 11 ++ app/controllers/authentication_controller.rb | 15 +++ app/controllers/concerns/exception_handler.rb | 29 ++++- app/controllers/todos_controller.rb | 39 ++---- app/controllers/users_controller.rb | 23 ++++ app/lib/json_web_token.rb | 21 ++++ app/lib/message.rb | 33 +++++ app/models/user.rb | 9 ++ config/routes.rb | 3 + db/migrate/20211111154113_create_users.rb | 11 ++ db/schema.rb | 10 +- spec/auth/authenticate_user_spec.rb | 32 +++++ spec/auth/authorize_api_request_spec.rb | 72 +++++++++++ .../application_controller_spec.rb | 31 +++++ spec/factories/users.rb | 8 ++ spec/models/user_spec.rb | 12 ++ spec/rails_helper.rb | 4 +- spec/requests/authentication_spec.rb | 45 +++++++ spec/requests/items_spec.rb | 116 ++++-------------- spec/requests/todos_spec.rb | 103 +++++----------- spec/requests/users_spec.rb | 41 +++++++ spec/support/controller_spec_helper.rb | 27 ++++ 26 files changed, 566 insertions(+), 203 deletions(-) create mode 100644 app/auth/authenticate_user.rb create mode 100644 app/auth/authorize_api_request.rb create mode 100644 app/controllers/authentication_controller.rb create mode 100644 app/controllers/users_controller.rb create mode 100644 app/lib/json_web_token.rb create mode 100644 app/lib/message.rb create mode 100644 app/models/user.rb create mode 100644 db/migrate/20211111154113_create_users.rb create mode 100644 spec/auth/authenticate_user_spec.rb create mode 100644 spec/auth/authorize_api_request_spec.rb create mode 100644 spec/controllers/application_controller_spec.rb create mode 100644 spec/factories/users.rb create mode 100644 spec/models/user_spec.rb create mode 100644 spec/requests/authentication_spec.rb create mode 100644 spec/requests/users_spec.rb create mode 100644 spec/support/controller_spec_helper.rb diff --git a/Gemfile b/Gemfile index d8808e2..058a585 100644 --- a/Gemfile +++ b/Gemfile @@ -50,3 +50,8 @@ group :test do gem 'faker' gem 'database_cleaner' end + +# Use ActiveModel has_secure_password +gem 'bcrypt', '~> 3.1.7' + +gem 'jwt' diff --git a/Gemfile.lock b/Gemfile.lock index fa76adb..2a2627c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -60,6 +60,7 @@ GEM minitest (>= 5.1) tzinfo (~> 2.0) zeitwerk (~> 2.3) + bcrypt (3.1.16) bootsnap (1.9.1) msgpack (~> 1.0) builder (3.2.4) @@ -86,6 +87,7 @@ GEM activesupport (>= 5.0) i18n (1.8.11) concurrent-ruby (~> 1.0) + jwt (2.3.0) listen (3.7.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) @@ -178,11 +180,13 @@ PLATFORMS x86_64-linux DEPENDENCIES + bcrypt (~> 3.1.7) bootsnap (>= 1.4.4) byebug database_cleaner factory_bot_rails (~> 4.0) faker + jwt listen (~> 3.3) puma (~> 5.0) rails (~> 6.1.4, >= 6.1.4.1) diff --git a/app/auth/authenticate_user.rb b/app/auth/authenticate_user.rb new file mode 100644 index 0000000..9d84f4a --- /dev/null +++ b/app/auth/authenticate_user.rb @@ -0,0 +1,23 @@ +class AuthenticateUser + def initialize(email, password) + @email = email + @password = password + end + + # Service entry point + def call + JsonWebToken.encode(user_id: user.id) if user + end + + private + + attr_reader :email, :password + + # verify user credentials + def user + user = User.find_by(email: email) + return user if user && user.authenticate(password) + # raise Authentication error if credentials are invalid + raise(ExceptionHandler::AuthenticationError, Message.invalid_credentials) + end +end \ No newline at end of file diff --git a/app/auth/authorize_api_request.rb b/app/auth/authorize_api_request.rb new file mode 100644 index 0000000..afac6eb --- /dev/null +++ b/app/auth/authorize_api_request.rb @@ -0,0 +1,42 @@ +class AuthorizeApiRequest + def initialize(headers = {}) + @headers = headers + end + + # Service entry point - return valid user object + def call + { + user: user + } + end + + private + + attr_reader :headers + + def user + # check if user is in the database + # memoize user object + @user ||= User.find(decoded_auth_token[:user_id]) if decoded_auth_token + # handle user not found + rescue ActiveRecord::RecordNotFound => e + # raise custom error + raise( + ExceptionHandler::InvalidToken, + ("#{Message.invalid_token} #{e.message}") + ) + end + + # decode authentication token + def decoded_auth_token + @decoded_auth_token ||= JsonWebToken.decode(http_auth_header) + end + + # check for token in `Authorization` header + def http_auth_header + if headers['Authorization'].present? + return headers['Authorization'].split(' ').last + end + raise(ExceptionHandler::MissingToken, Message.missing_token) + end +end \ No newline at end of file diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 45e34ea..b245e8e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,15 @@ class ApplicationController < ActionController::API include Response include ExceptionHandler + + # called before every action on controllers + before_action :authorize_request + attr_reader :current_user + + private + + # Check for valid request token and return user + def authorize_request + @current_user = (AuthorizeApiRequest.new(request.headers).call)[:user] + end end diff --git a/app/controllers/authentication_controller.rb b/app/controllers/authentication_controller.rb new file mode 100644 index 0000000..c60d702 --- /dev/null +++ b/app/controllers/authentication_controller.rb @@ -0,0 +1,15 @@ +class AuthenticationController < ApplicationController + # return auth token once user is authenticated + skip_before_action :authorize_request, only: :authenticate + def authenticate + auth_token = + AuthenticateUser.new(auth_params[:email], auth_params[:password]).call + json_response(auth_token: auth_token) + end + + private + + def auth_params + params.permit(:email, :password) + end +end diff --git a/app/controllers/concerns/exception_handler.rb b/app/controllers/concerns/exception_handler.rb index 48dd18b..682e16e 100644 --- a/app/controllers/concerns/exception_handler.rb +++ b/app/controllers/concerns/exception_handler.rb @@ -1,14 +1,33 @@ module ExceptionHandler # provides the more graceful `included` method extend ActiveSupport::Concern + + # Define custom error subclasses - rescue catches `StandardErrors` + class AuthenticationError < StandardError; end + class MissingToken < StandardError; end + class InvalidToken < StandardError; end included do + # Define custom handlers + rescue_from ActiveRecord::RecordInvalid, with: :four_twenty_two + rescue_from ExceptionHandler::AuthenticationError, with: :unauthorized_request + rescue_from ExceptionHandler::MissingToken, with: :four_twenty_two + rescue_from ExceptionHandler::InvalidToken, with: :four_twenty_two + rescue_from ActiveRecord::RecordNotFound do |e| - json_response({ message: e.message }, :not_found) - end - - rescue_from ActiveRecord::RecordInvalid do |e| - json_response({ message: e.message }, :unprocessable_entity) + json_response({ message: e.message }, :not_found) end + end + + private + + # JSON response with message; Status code 422 - unprocessable entity + def four_twenty_two(e) + json_response({ message: e.message }, :unprocessable_entity) + end + + # JSON response with message; Status code 401 - Unauthorized + def unauthorized_request(e) + json_response({ message: e.message }, :unauthorized) end end diff --git a/app/controllers/todos_controller.rb b/app/controllers/todos_controller.rb index fd25b7f..4e7d028 100644 --- a/app/controllers/todos_controller.rb +++ b/app/controllers/todos_controller.rb @@ -1,43 +1,24 @@ class TodosController < ApplicationController - before_action :set_todo, only: [:show, :update, :destroy] - + # [...] # GET /todos def index - @todos = Todo.all + # get current user todos + @todos = current_user.todos json_response(@todos) end - + # [...] # POST /todos def create - @todo = Todo.create!(todo_params) + # create todos belonging to current user + @todo = current_user.todos.create!(todo_params) json_response(@todo, :created) end - - # GET /todos/:id - def show - json_response(@todo) - end - - # PUT /todos/:id - def update - @todo.update(todo_params) - head :no_content - end - - # DELETE /todos/:id - def destroy - @todo.destroy - head :no_content - end - + # [...] private + # remove `created_by` from list of permitted parameters def todo_params - # whitelist params - params.permit(:title, :created_by) - end - - def set_todo - @todo = Todo.find(params[:id]) + params.permit(:title) end + # [...] end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb new file mode 100644 index 0000000..b8b18a7 --- /dev/null +++ b/app/controllers/users_controller.rb @@ -0,0 +1,23 @@ + +class UsersController < ApplicationController + # POST /signup + # return authenticated token upon signup + skip_before_action :authorize_request, only: :create + def create + user = User.create!(user_params) + auth_token = AuthenticateUser.new(user.email, user.password).call + response = { message: Message.account_created, auth_token: auth_token } + json_response(response, :created) + end + + private + + def user_params + params.permit( + :name, + :email, + :password, + :password_confirmation + ) + end +end diff --git a/app/lib/json_web_token.rb b/app/lib/json_web_token.rb new file mode 100644 index 0000000..2311a8e --- /dev/null +++ b/app/lib/json_web_token.rb @@ -0,0 +1,21 @@ +class JsonWebToken + # secret to encode and decode token + HMAC_SECRET = Rails.application.secrets.secret_key_base + + def self.encode(payload, exp = 24.hours.from_now) + # set expiry to 24 hours from creation time + payload[:exp] = exp.to_i + # sign token with application secret + JWT.encode(payload, HMAC_SECRET) + end + + def self.decode(token) + # get payload; first index in decoded Array + body = JWT.decode(token, HMAC_SECRET)[0] + HashWithIndifferentAccess.new body + # rescue from all decode errors + rescue JWT::DecodeError => e + # raise custom error to be handled by custom handler + raise ExceptionHandler::InvalidToken, e.message + end +end \ No newline at end of file diff --git a/app/lib/message.rb b/app/lib/message.rb new file mode 100644 index 0000000..736c45f --- /dev/null +++ b/app/lib/message.rb @@ -0,0 +1,33 @@ +class Message + def self.not_found(record = 'record') + "Sorry, #{record} not found." + end + + def self.invalid_credentials + 'Invalid credentials' + end + + def self.invalid_token + 'Invalid token' + end + + def self.missing_token + 'Missing token' + end + + def self.unauthorized + 'Unauthorized request' + end + + def self.account_created + 'Account created successfully' + end + + def self.account_not_created + 'Account could not be created' + end + + def self.expired_token + 'Sorry, your token has expired. Please login to continue.' + end +end \ No newline at end of file diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..ce60935 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,9 @@ +class User < ApplicationRecord + # encrypt password + has_secure_password + + # Model associations + has_many :todos, foreign_key: :created_by + # Validations + validates_presence_of :name, :email, :password_digest +end diff --git a/config/routes.rb b/config/routes.rb index 45187e3..af34825 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,4 +3,7 @@ resources :todos do resources :items end + + post 'auth/login', to: 'authentication#authenticate' + post 'signup', to: 'users#create' end diff --git a/db/migrate/20211111154113_create_users.rb b/db/migrate/20211111154113_create_users.rb new file mode 100644 index 0000000..65cbc25 --- /dev/null +++ b/db/migrate/20211111154113_create_users.rb @@ -0,0 +1,11 @@ +class CreateUsers < ActiveRecord::Migration[6.1] + def change + create_table :users do |t| + t.string :name + t.string :email + t.string :password_digest + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 4635d0c..cf49ca8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_11_10_194855) do +ActiveRecord::Schema.define(version: 2021_11_11_154113) do create_table "items", force: :cascade do |t| t.string "name" @@ -28,5 +28,13 @@ t.datetime "updated_at", precision: 6, null: false end + create_table "users", force: :cascade do |t| + t.string "name" + t.string "email" + t.string "password_digest" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + end + add_foreign_key "items", "todos" end diff --git a/spec/auth/authenticate_user_spec.rb b/spec/auth/authenticate_user_spec.rb new file mode 100644 index 0000000..fda8709 --- /dev/null +++ b/spec/auth/authenticate_user_spec.rb @@ -0,0 +1,32 @@ +require 'rails_helper' + +RSpec.describe AuthenticateUser do + # create test user + let(:user) { create(:user) } + # valid request subject + subject(:valid_auth_obj) { described_class.new(user.email, user.password) } + # invalid request subject + subject(:invalid_auth_obj) { described_class.new('foo', 'bar') } + + # Test suite for AuthenticateUser#call + describe '#call' do + # return token when valid request + context 'when valid credentials' do + it 'returns an auth token' do + token = valid_auth_obj.call + expect(token).not_to be_nil + end + end + + # raise Authentication Error when invalid request + context 'when invalid credentials' do + it 'raises an authentication error' do + expect { invalid_auth_obj.call } + .to raise_error( + ExceptionHandler::AuthenticationError, + /Invalid credentials/ + ) + end + end + end +end \ No newline at end of file diff --git a/spec/auth/authorize_api_request_spec.rb b/spec/auth/authorize_api_request_spec.rb new file mode 100644 index 0000000..8c5c623 --- /dev/null +++ b/spec/auth/authorize_api_request_spec.rb @@ -0,0 +1,72 @@ +require 'rails_helper' + +RSpec.describe AuthorizeApiRequest do + # Create test user + let(:user) { create(:user) } + # Mock `Authorization` header + let(:header) { { 'Authorization' => token_generator(user.id) } } + # Invalid request subject + subject(:invalid_request_obj) { described_class.new({}) } + # Valid request subject + subject(:request_obj) { described_class.new(header) } + + # Test Suite for AuthorizeApiRequest#call + # This is our entry point into the service class + describe '#call' do + # returns user object when request is valid + context 'when valid request' do + it 'returns user object' do + result = request_obj.call + expect(result[:user]).to eq(user) + end + end + + # returns error message when invalid request + context 'when invalid request' do + context 'when missing token' do + it 'raises a MissingToken error' do + expect { invalid_request_obj.call } + .to raise_error(ExceptionHandler::MissingToken, 'Missing token') + end + end + + context 'when invalid token' do + subject(:invalid_request_obj) do + # custom helper method `token_generator` + described_class.new('Authorization' => token_generator(5)) + end + + it 'raises an InvalidToken error' do + expect { invalid_request_obj.call } + .to raise_error(ExceptionHandler::InvalidToken, /Invalid token/) + end + end + + context 'when token is expired' do + let(:header) { { 'Authorization' => expired_token_generator(user.id) } } + subject(:request_obj) { described_class.new(header) } + + it 'raises ExceptionHandler::ExpiredSignature error' do + expect { request_obj.call } + .to raise_error( + ExceptionHandler::InvalidToken, + /Signature has expired/ + ) + end + end + + context 'fake token' do + let(:header) { { 'Authorization' => 'foobar' } } + subject(:invalid_request_obj) { described_class.new(header) } + + it 'handles JWT::DecodeError' do + expect { invalid_request_obj.call } + .to raise_error( + ExceptionHandler::InvalidToken, + /Not enough or too many segments/ + ) + end + end + end + end +end \ No newline at end of file diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb new file mode 100644 index 0000000..f1cbe44 --- /dev/null +++ b/spec/controllers/application_controller_spec.rb @@ -0,0 +1,31 @@ +require "rails_helper" + +RSpec.describe ApplicationController, type: :controller do + # create test user + let!(:user) { create(:user) } + # set headers for authorization + let(:headers) { { 'Authorization' => token_generator(user.id) } } + let(:invalid_headers) { { 'Authorization' => nil } } + + describe "#authorize_request" do + context "when auth token is passed" do + before { allow(request).to receive(:headers).and_return(headers) } + + # private method authorize_request returns current user + it "sets the current user" do + expect(subject.instance_eval { authorize_request }).to eq(user) + end + end + + context "when auth token is not passed" do + before do + allow(request).to receive(:headers).and_return(invalid_headers) + end + + it "raises MissingToken error" do + expect { subject.instance_eval { authorize_request } }. + to raise_error(ExceptionHandler::MissingToken, /Missing token/) + end + end + end +end \ No newline at end of file diff --git a/spec/factories/users.rb b/spec/factories/users.rb new file mode 100644 index 0000000..8a7972c --- /dev/null +++ b/spec/factories/users.rb @@ -0,0 +1,8 @@ +# spec/factories/users.rb +FactoryBot.define do + factory :user do + name { Faker::Name.name } + email { 'foo@bar.com' } + password { 'foobar' } + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 0000000..677e298 --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,12 @@ +require 'rails_helper' + +RSpec.describe User, type: :model do + # Association test + # ensure User model has a 1:m relationship with the Todo model + it { should have_many(:todos) } + # Validation tests + # ensure name, email and password_digest are present before save + it { should validate_presence_of(:name) } + it { should validate_presence_of(:email) } + it { should validate_presence_of(:password_digest) } +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 0d55f9a..48f179a 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -66,6 +66,7 @@ # require database cleaner at the top level require 'database_cleaner' require 'support/request_spec_helper' +require 'support/controller_spec_helper' # [...] # configure shoulda matchers to use rspec as the test framework and full matcher libraries for rails @@ -82,7 +83,8 @@ # add `FactoryBot` methods config.include FactoryBot::Syntax::Methods - config.include RequestSpecHelper, type: :request + config.include RequestSpecHelper + config.include ControllerSpecHelper # start by truncating all the tables but then use the faster transaction strategy the rest of the time. config.before(:suite) do diff --git a/spec/requests/authentication_spec.rb b/spec/requests/authentication_spec.rb new file mode 100644 index 0000000..7c8d762 --- /dev/null +++ b/spec/requests/authentication_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +RSpec.describe 'Authentication', type: :request do + # Authentication test suite + describe 'POST /auth/login' do + # create test user + let!(:user) { create(:user) } + # set headers for authorization + let(:headers) { valid_headers.except('Authorization') } + # set test valid and invalid credentials + let(:valid_credentials) do + { + email: user.email, + password: user.password + }.to_json + end + let(:invalid_credentials) do + { + email: Faker::Internet.email, + password: Faker::Internet.password + }.to_json + end + + # set request.headers to our custon headers + # before { allow(request).to receive(:headers).and_return(headers) } + + # returns auth token when request is valid + context 'When request is valid' do + before { post '/auth/login', params: valid_credentials, headers: headers } + + it 'returns an authentication token' do + expect(json['auth_token']).not_to be_nil + end + end + + # returns failure message when request is invalid + context 'When request is invalid' do + before { post '/auth/login', params: invalid_credentials, headers: headers } + + it 'returns a failure message' do + expect(json['message']).to match(/Invalid credentials/) + end + end + end +end diff --git a/spec/requests/items_spec.rb b/spec/requests/items_spec.rb index 3d81245..65aba7c 100644 --- a/spec/requests/items_spec.rb +++ b/spec/requests/items_spec.rb @@ -1,127 +1,57 @@ require 'rails_helper' RSpec.describe 'Items API' do - # Initialize the test data - let!(:todo) { create(:todo) } + let(:user) { create(:user) } + let!(:todo) { create(:todo, created_by: user.id) } let!(:items) { create_list(:item, 20, todo_id: todo.id) } let(:todo_id) { todo.id } let(:id) { items.first.id } + let(:headers) { valid_headers } - # Test suite for GET /todos/:todo_id/items describe 'GET /todos/:todo_id/items' do - before { get "/todos/#{todo_id}/items" } + before { get "/todos/#{todo_id}/items", params: {}, headers: headers } - context 'when todo exists' do - it 'returns status code 200' do - expect(response).to have_http_status(200) - end - - it 'returns all todo items' do - expect(json.size).to eq(20) - end - end - - context 'when todo does not exist' do - let(:todo_id) { 0 } - - it 'returns status code 404' do - expect(response).to have_http_status(404) - end - - it 'returns a not found message' do - expect(response.body).to match(/Couldn't find Todo/) - end - end + # [...] end - # Test suite for GET /todos/:todo_id/items/:id describe 'GET /todos/:todo_id/items/:id' do - before { get "/todos/#{todo_id}/items/#{id}" } - - context 'when todo item exists' do - it 'returns status code 200' do - expect(response).to have_http_status(200) - end - - it 'returns the item' do - expect(json['id']).to eq(id) - end - end - - context 'when todo item does not exist' do - let(:id) { 0 } - - it 'returns status code 404' do - expect(response).to have_http_status(404) - end + before { get "/todos/#{todo_id}/items/#{id}", params: {}, headers: headers } - it 'returns a not found message' do - expect(response.body).to match(/Couldn't find Item/) - end - end + # [...] end - # Test suite for PUT /todos/:todo_id/items describe 'POST /todos/:todo_id/items' do - let(:valid_attributes) { { name: 'Visit Narnia', done: false } } + let(:valid_attributes) { { name: 'Visit Narnia', done: false }.to_json } context 'when request attributes are valid' do - before { post "/todos/#{todo_id}/items", params: valid_attributes } - - it 'returns status code 201' do - expect(response).to have_http_status(201) + before do + post "/todos/#{todo_id}/items", params: valid_attributes, headers: headers end + + # [...] end context 'when an invalid request' do - before { post "/todos/#{todo_id}/items", params: {} } + before { post "/todos/#{todo_id}/items", params: {}, headers: headers } - it 'returns status code 422' do - expect(response).to have_http_status(422) - end - - it 'returns a failure message' do - expect(response.body).to match(/Validation failed: Name can't be blank/) - end + # [...] end end - # Test suite for PUT /todos/:todo_id/items/:id describe 'PUT /todos/:todo_id/items/:id' do - let(:valid_attributes) { { name: 'Mozart' } } - - before { put "/todos/#{todo_id}/items/#{id}", params: valid_attributes } - - context 'when item exists' do - it 'returns status code 204' do - expect(response).to have_http_status(204) - end + let(:valid_attributes) { { name: 'Mozart' }.to_json } - it 'updates the item' do - updated_item = Item.find(id) - expect(updated_item.name).to match(/Mozart/) - end + before do + put "/todos/#{todo_id}/items/#{id}", params: valid_attributes, headers: headers end - context 'when the item does not exist' do - let(:id) { 0 } - - it 'returns status code 404' do - expect(response).to have_http_status(404) - end - - it 'returns a not found message' do - expect(response.body).to match(/Couldn't find Item/) - end - end + # [...] + # [...] end - - # Test suite for DELETE /todos/:id + describe 'DELETE /todos/:id' do - before { delete "/todos/#{todo_id}/items/#{id}" } + before { delete "/todos/#{todo_id}/items/#{id}", params: {}, headers: headers } - it 'returns status code 204' do - expect(response).to have_http_status(204) - end + # [...] end -end +end \ No newline at end of file diff --git a/spec/requests/todos_spec.rb b/spec/requests/todos_spec.rb index 9e58f9d..0e6fcc6 100644 --- a/spec/requests/todos_spec.rb +++ b/spec/requests/todos_spec.rb @@ -1,108 +1,63 @@ require 'rails_helper' RSpec.describe 'Todos API', type: :request do - # initialize test data - let!(:todos) { create_list(:todo, 10) } + # add todos owner + let(:user) { create(:user) } + let!(:todos) { create_list(:todo, 10, created_by: user.id) } let(:todo_id) { todos.first.id } + # authorize request + let(:headers) { valid_headers } - # Test suite for GET /todos describe 'GET /todos' do - # make HTTP get request before each example - before { get '/todos' } + # update request with headers + before { get '/todos', params: {}, headers: headers } - it 'returns todos' do - # Note `json` is a custom helper to parse JSON responses - expect(json).not_to be_empty - expect(json.size).to eq(10) - end - - it 'returns status code 200' do - expect(response).to have_http_status(200) - end + # [...] end - # Test suite for GET /todos/:id describe 'GET /todos/:id' do - before { get "/todos/#{todo_id}" } - - context 'when the record exists' do - it 'returns the todo' do - expect(json).not_to be_empty - expect(json['id']).to eq(todo_id) - end - - it 'returns status code 200' do - expect(response).to have_http_status(200) - end - end - - context 'when the record does not exist' do - let(:todo_id) { 100 } - - it 'returns status code 404' do - expect(response).to have_http_status(404) - end - - it 'returns a not found message' do - expect(response.body).to match(/Couldn't find Todo/) - end + before { get "/todos/#{todo_id}", params: {}, headers: headers } + # [...] end + # [...] end - # Test suite for POST /todos describe 'POST /todos' do - # valid payload - let(:valid_attributes) { { title: 'Learn Elm', created_by: '1' } } - - context 'when the request is valid' do - before { post '/todos', params: valid_attributes } - - it 'creates a todo' do - expect(json['title']).to eq('Learn Elm') - end + let(:valid_attributes) do + # send json payload + { title: 'Learn Elm', created_by: user.id.to_s }.to_json + end - it 'returns status code 201' do - expect(response).to have_http_status(201) - end + context 'when request is valid' do + before { post '/todos', params: valid_attributes, headers: headers } + # [...] end context 'when the request is invalid' do - before { post '/todos', params: { title: 'Foobar' } } + let(:invalid_attributes) { { title: nil }.to_json } + before { post '/todos', params: invalid_attributes, headers: headers } it 'returns status code 422' do expect(response).to have_http_status(422) end - it 'returns a validation failure message' do - expect(response.body) - .to match(/Validation failed: Created by can't be blank/) - end - end + # it 'returns a validation failure message' do + # expect(json['message']) + # .to match(/Validation failed: Title can't be blank/) + # end end - # Test suite for PUT /todos/:id describe 'PUT /todos/:id' do - let(:valid_attributes) { { title: 'Shopping' } } + let(:valid_attributes) { { title: 'Shopping' }.to_json } context 'when the record exists' do - before { put "/todos/#{todo_id}", params: valid_attributes } - - it 'updates the record' do - expect(response.body).to be_empty - end - - it 'returns status code 204' do - expect(response).to have_http_status(204) - end + before { put "/todos/#{todo_id}", params: valid_attributes, headers: headers } + # [...] end end - # Test suite for DELETE /todos/:id describe 'DELETE /todos/:id' do - before { delete "/todos/#{todo_id}" } - - it 'returns status code 204' do - expect(response).to have_http_status(204) - end + before { delete "/todos/#{todo_id}", params: {}, headers: headers } + # [...] end end diff --git a/spec/requests/users_spec.rb b/spec/requests/users_spec.rb new file mode 100644 index 0000000..70b1a09 --- /dev/null +++ b/spec/requests/users_spec.rb @@ -0,0 +1,41 @@ +require 'rails_helper' + +RSpec.describe 'Users API', type: :request do + let(:user) { build(:user) } + let(:headers) { valid_headers.except('Authorization') } + let(:valid_attributes) do + attributes_for(:user, password_confirmation: user.password) + end + + # User signup test suite + describe 'POST /signup' do + context 'when valid request' do + before { post '/signup', params: valid_attributes.to_json, headers: headers } + + it 'creates a new user' do + expect(response).to have_http_status(201) + end + + it 'returns success message' do + expect(json['message']).to match(/Account created successfully/) + end + + it 'returns an authentication token' do + expect(json['auth_token']).not_to be_nil + end + end + + context 'when invalid request' do + before { post '/signup', params: {}, headers: headers } + + it 'does not create a new user' do + expect(response).to have_http_status(422) + end + + it 'returns failure message' do + expect(json['message']) + .to match(/Validation failed: Password can't be blank, Name can't be blank, Email can't be blank, Password digest can't be blank/) + end + end + end +end \ No newline at end of file diff --git a/spec/support/controller_spec_helper.rb b/spec/support/controller_spec_helper.rb new file mode 100644 index 0000000..eb5b982 --- /dev/null +++ b/spec/support/controller_spec_helper.rb @@ -0,0 +1,27 @@ +module ControllerSpecHelper + # generate tokens from user id + def token_generator(user_id) + JsonWebToken.encode(user_id: user_id) + end + + # generate expired tokens from user id + def expired_token_generator(user_id) + JsonWebToken.encode({ user_id: user_id }, (Time.now.to_i - 10)) + end + + # return valid headers + def valid_headers + { + "Authorization" => token_generator(user.id), + "Content-Type" => "application/json" + } + end + + # return invalid headers + def invalid_headers + { + "Authorization" => nil, + "Content-Type" => "application/json" + } + end +end \ No newline at end of file