Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -49,3 +50,10 @@ group :test do
gem 'faker'
gem 'database_cleaner'
end

# Use ActiveModel has_secure_password
gem 'bcrypt', '~> 3.1.7'

gem 'jwt'

gem 'active_model_serializers', '~> 0.10.0'
13 changes: 13 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ GEM
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_model_serializers (0.10.12)
actionpack (>= 4.1, < 6.2)
activemodel (>= 4.1, < 6.2)
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (6.1.4.1)
activesupport (= 6.1.4.1)
globalid (>= 0.3.6)
Expand All @@ -60,10 +65,13 @@ 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)
byebug (11.1.3)
case_transform (0.2)
activesupport
concurrent-ruby (1.1.9)
crass (1.0.6)
database_cleaner (2.0.1)
Expand All @@ -86,6 +94,8 @@ GEM
activesupport (>= 5.0)
i18n (1.8.11)
concurrent-ruby (~> 1.0)
jsonapi-renderer (0.2.2)
jwt (2.3.0)
listen (3.7.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
Expand Down Expand Up @@ -178,11 +188,14 @@ PLATFORMS
x86_64-linux

DEPENDENCIES
active_model_serializers (~> 0.10.0)
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)
Expand Down
23 changes: 23 additions & 0 deletions app/auth/authenticate_user.rb
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions app/auth/authorize_api_request.rb
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,2 +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
15 changes: 15 additions & 0 deletions app/controllers/authentication_controller.rb
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions app/controllers/concerns/exception_handler.rb
Original file line number Diff line number Diff line change
@@ -0,0 +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
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
5 changes: 5 additions & 0 deletions app/controllers/concerns/response.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module Response
def json_response(object, status = :ok)
render json: object, status: status
end
end
47 changes: 47 additions & 0 deletions app/controllers/items_controller.rb
Original file line number Diff line number Diff line change
@@ -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

24 changes: 24 additions & 0 deletions app/controllers/todos_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
class TodosController < ApplicationController
# [...]
# GET /todos
def index
# get current user todos
@todos = current_user.todos
json_response(@todos)
end
# [...]
# POST /todos
def create
# create todos belonging to current user
@todo = current_user.todos.create!(todo_params)
json_response(@todo, :created)
end
# [...]
private

# remove `created_by` from list of permitted parameters
def todo_params
params.permit(:title)
end
# [...]
end
23 changes: 23 additions & 0 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions app/lib/json_web_token.rb
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions app/lib/message.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions app/models/item.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class Item < ApplicationRecord
# model association
belongs_to :todo

# validation
validates_presence_of :name
end
7 changes: 7 additions & 0 deletions app/models/todo.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class Todo < ApplicationRecord
# model association
has_many :items, dependent: :destroy

# validations
validates_presence_of :title, :created_by
end
9 changes: 9 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions app/serializers/todo_serializer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class TodoSerializer < ActiveModel::Serializer
# attributes to be serialized
attributes :id, :title, :created_by, :created_at, :updated_at
# model association
has_many :items
end
Loading