Hướng dẫn này mô tả cách xây dựng 1 số api cơ bản cho sample_app sử dung Grape
I. Cài đặt
Thêm gem:
gem "grape"
gem "grape_on_rails_routes"
gem "grape-entity"
- grape: Hỗ trợ để viết api https://github.com/ruby-grape/grape
- grape_on_rails_routes : Xem các routes api https://github.com/syedmusamah/grape_on_rails_routes
- grape-entity: Hỗ trợ việc xử lý các attributes ở response trả về https://github.com/ruby-grape/grape-entity
Nhớ chạy bundle install để thêm gem vào nhé
II. Build api trả về danh sách users
Sau khi cài đặt thì giờ chúng ta xây dựng 1 api trả về danh sách users
B1: Tạo thư mục api và base
Định nghĩa 1 thư mục api trong folder controller
mkdir -p app/controllers/api
Tiếp đến chúng ta xây dựng 1 file base trong folder api
touch app/controllers/api/base.rb
Sau đó chúng ta thêm đoạn code này vào base.rb
module API class Base < Grape::API mount API::V1::Base # mount API::V2::Base (next version) end
end
mount API::V1::Base
: Đây là đường dẫn để tìm api, có bao nhiêu api ta sẽ viết hết trong Base của V1
Khai báo routes:
mount API::Base, at: "/"
Các bạn có thể để ý thấy rằng mọi thứ đều bắt đầu từ base.rb
Chú ý: Các bạn phải thêm đoạn code này vào file inflections.rb
# config/initializers/inflections.rb
ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym "API"
end
Nếu k server sẽ báo lỗi
/home/vu.thi.ngoc/Projects/sample_app/config/routes.rb:3:in `block in <main>': uninitialized constant API (NameError)
Did you mean? Api
B2: Thêm version cho api
Tiếp đến ta define một version api có tên là V1
# controllers/api/v1/base.rb
module API module V1 class Base < Grape::API mount V1::Users # mount API::V1::AnotherResource end end
end
mount V1::Users
: Khai báo đường dẫn
Để lấy danh sách users chúng ta sẽ định nghĩa 1 class Users trong v1
# controllers/api/v1/users.rb
module API module V1 class Users < Grape::API prefix "api" version "v1", using: :path format :json resource :users do desc "Return all users" get "", root: :users do users = User.all present users end end end end
end
Ở đây
version ‘v1’, using: :path
: chỉ định version của API.format: json:
nói với các API rằng chúng ta chỉ sử dụng định dạng JSON.prefix: api
: hiểu đơn giản thì các path của chúng ta sẽ bắt đầu với /api. Nếu bạn chưa quên thì trong routes.rb chúng ta đã set route như này:mount API::Base, at: "/"
Với prefix như trên chúng ta sẽ truy cập vào API theo đường dẫn /api.resource :users
: nói rằng chúng ta sẽ sử dụng user routesdesc ‘Return all users’
: mô tả api sẽ trả về gìget do …end
: cái này thì khá cơ bản rồi, nó cũng giống như trong controller bình thường.
B3: Kiểm tra routes
Chạy rails grape:routes
để nhìn toàn bộ các api có trong hệ thống, ở đây ta đã implement api trả về danh sách các users
GET | /api/:version/users(.json) | v1 | Return all users
B4: Chạy kiểm tra kết quả
Dùng postman, chọn phương thức GET với đường dẫn localhost:3000/api/v1/users
, chọn Send
và nhìn kết quả trả về dưới dạng
[ { "id": 1, "name": "admin", "email": "_@.com", "created_at": "2020-09-28T04:31:45.741Z", "updated_at": "2020-09-29T03:08:56.596Z", "password_digest": "$2a$12$WlS2CtP5TIvJEVE4Gn9sQuexqTxRB8KSMoUq7DhVH.ufeet3foyP2", "remember_digest": "$2a$12$T9W6yDKhgDcU5evfp0M2HOzecCEyyJP4942bLse77ppS63ZLA1MTm", "admin": true, "activation_digest": "$2a$12$tBO3ucVUe1HHJduKIWf/pe4F8E8ayWseNiPsRqyY17WNkndTbLNfS", "activated": true, "activated_at": "2020-09-28T04:31:45.000Z", "reset_digest": null, "reset_sent_at": null }, { "id": 2, "name": "Fr. Efrain Lebsack", "email": "_@.com", "created_at": "2020-09-28T04:31:46.932Z", "updated_at": "2020-09-29T03:02:46.427Z", "password_digest": "$2a$12$zslwRliHClDLBeAR4bunUe2rSOOm0IqQ624/9ZcTjFiXX5ZGgiV46", "remember_digest": "$2a$12$0wdlrqbQKQRNvds4qMB3qutOo7i9q31unL2OaARGoJei5P3LYV5Te", "admin": false, "activation_digest": "$2a$12$wVtB1ngTBir2yFl4C63zGuSEAE9hmRXkHn/anuDKmF/Xs8tmWiuc2", "activated": false, "activated_at": "2020-09-28T04:31:46.000Z", "reset_digest": null, "reset_sent_at": null },... ]
là thành công rồi đó, ta đã lấy được danh sách các user có trong hệ thống
III. Xử lý response
Trong bài toán thực tế ít khi api trả về toàn bộ thông tin user như bên trên, 1 số trường sẽ k cần thiết phải trả về hay muốn custom trường trả về thì sẽ ntn. Giờ là lúc ta xử lý các entity với grape-entity. grape-entity
cho phép chúng ta định nghĩa các thuộc tính nào trả về.
Giả sử cần trả về danh sách user chỉ bao gồm tên và email
# app/controllers/api/entities/user.rb
module API module Entities class User < Grape::Entity expose :name expose :email end end
end
Sửa 1 chút phần api users, thêm with: API::Entities::User
để lọc các giá trị trả về
resource :users do desc "Return all users" get "", root: :users do users = User.all present users, with: API::Entities::User end
end
Nhìn kết quả trả về
[ { "name": "admin", "email": "_@.com" }, { "name": "Fr. Efrain Lebsack", "email": "_@.com" }, .. ]
vậy là đã xong rồi đó
IV. Build api trả về user detail
# app/controllers/api/v1/users.rb
module API module V1 class Users < Grape::API ... desc "Return a user" params do requires :id, type: String, desc: "ID of the user" end get ":id", root: "user" do user = User.find params[:id] present user, with: API::Entities::User end end end end
end
Gọi api localhost:3000/api/v1/users/1
sẽ trả về thông tin của user có Id là 1
{ "name": "admin", "email": "_@.com"
}
Nếu gọi 1 ID quá lớn không tồn tại thì sao? localhost:3000/api/v1/users/1111
Sẽ bị lỗi ngay lập tức
ActiveRecord::RecordNotFound (Couldn't find User with 'id'=1111):
V. Xử lý lỗi và ngoại lệ
Chúng ta sẽ tạo 1 module là default chứa tất cả các code dùng chung cho tất cả api hoặc 1 số
module API module V1 module Defaults extend ActiveSupport::Concern included do prefix "api" version "v1", using: :path format :json rescue_from ActiveRecord::RecordNotFound do |e| error_response(message: e.message, status: 404) end rescue_from :all do |e| if Rails.env.development? raise e else error_response(message: e.message, status: 500) end end end end end
end
Dùng rescue
để bắt 1 số ngoại lệ
Thay đổi users.rb
: Thêm include API::V1::Defaults
, nếu nó dc dùng cho tất cả các api trong v1 ta có thể include vào base.rb
module API module V1 class Users < Grape::API include API::V1::Defaults resource :users do desc "Return all users" get "", root: :users do users = User.all present users, with: API::Entities::User end desc "Return a user" params do requires :id, type: String, desc: "ID of the user" end get ":id", root: "user" do user = User.find params[:id] present user, with: API::Entities::User end end end end
end
Giờ chúng ta thử gọi api user detail vs 1 ID không tồn tại localhost:3000/api/v1/users/1111
Kết quả trả về, mặc định lỗi sẽ trả về ntn
{ "error": "Couldn't find User with 'id'=1111"
}
Chúng ta có thể kiểm soát các lỗi và custom chúng để trả về lỗi theo ý của mình
# app/controllers/api/error_formatter.rb
module API module ErrorFormatter def self.call message, backtrace, options, env, original_exception {response_type: "loi roi do", response: message}.to_json end end
end
Ta thêm nó vào base tổng để dùng cho toàn bộ api
# app/controllers/api/base.rb
module API class Base < Grape::API error_formatter :json, API::ErrorFormatter mount API::V1::Base end
end
Nếu chỉ override lỗi cho chỉ riêng V1 thì add vào base.rb của v1 error_formatter :json, API::ErrorFormatter
Giờ chúng ta nhìn message lỗi sau khi custom:
{ "response_type": "loi roi do", "response": "Couldn't find User with 'id'=1111122"
}
VI. Xác thực người dùng sử dụng JWT (Json web token)
JWT là gì ?
JWT là một phương tiện đại diện cho các yêu cầu chuyển giao giữa hai bên Client – Server , các thông tin trong chuỗi JWT được định dạng bằng JSON . Trong đó chuỗi Token phải có 3 phần là header , phần payload và phần signature được ngăn bằng dấu “.” Vậy theo lý thuyết trên thì mình sẽ có một chuỗi Token như sau :
header.payload.signature
Đọc thêm tại : https://viblo.asia/p/json-web-token-jwt-la-gi-3Q75wWOG5Wb
Tại sao lại cần sử dụng jwt
Ví dụ ta yêu cầu Server lấy User có Id là 01 như sau [GET] localhost:8080/users/01
hoặc xóa [DELETE] localhost:8080/users/01
. Ở đây nếu chúng ta không sử dụng bất kì phương thức nào để bảo mật API thì tất cả các User khác điều có thể gọi tới các Request này để lấy thông tin hoặc xoá User 01 và Server sẽ thực hiện yêu cầu mà không cần biết yêu cầu này có phải là của User 01 hay không . Điều này rất nguy hiểm , các hacker có thể xóa hết dữ liệu hoặc đánh cắp thông tin người dùng bằng cách truy cập vào các URL này , cho nên ta cần một phương pháp nào đó để Server xác định được yêu cầu đó là của User01 thì Server mới thực hiện , vì vậy ta sẽ sử dụng JWT
Cách thức hoạt động JWT
Bước 1 : Người dùng yêu cầu đăng nhập với Username , Password
Bước 2 : Server nhận được yêu cầu và kiểm tra Username , Password nếu đúng sẽ gửi cho người dùng một chuỗi JWT
Bước 3 Người dùng sẽ dùng JWT này kèm theo các yêu cầu kế tiếp
Bước 4: Server sẽ nhận yêu cầu và kiểm tra chuổi JWT , nếu chuỗi hợp lệ thì sẽ thực hiện yêu cầu
Bạn có thể đọc thêm về jwt tại đây https://viblo.asia/p/tai-sao-phai-su-dung-json-web-token-jwt-de-bao-mat-api-maGK787AZj2
Xây dựng thư viện JWT Authentication
- Đầu tiên chúng ta thêm gem jwt vào Gemfile
gem "jwt"
Vì chúng ta sử dụng encode và decode JWT khá nhiều, nên sẽ thuận tiện hơn nếu viết một class bao gồm các chức năng đó.
- Tạo 1 file với đường dẫn
lib/authentication.rb
- Sau đó thêm đường dẫn autoload trong config/application.rb
config.autoload_paths << Rails.root.join('lib')
Trong class authentication
ta định nghĩa 2 method chịu trách nhiệm tạo jwt từ thông tin người dùng và decode để giải mã jwt
# lib/authentication.rb
require "jwt" class Authentication ALGORITHM = "HS256" class << self def encode payload JWT.encode(payload, ENV["AUTH_SECRET"], ALGORITHM) end def decode token JWT.decode(token, ENV["AUTH_SECRET"], true, {algorithm: ALGORITHM}).first end end
end
Encode phục vụ để mã hóa trả về jwt
JWT.encode
: method này có 3 đối số
- Dữ liệu ở dạng băm, chúng ta sẽ mã hóa trong JWT
- Chìa khóa cho thuật toán băm của bạn
- Các loại thuật toán băm Ví dụ:
payload = {name: "sophie"}
secret_key = "masd82348$asldfja"
algorithm = "HS256" JWT.encode(payload, secret_key, algorithm)
=> "esdiva23euihrusdfcnkjz2snciusdhuihr7480y2qikjh8"
Chúng ta sẽ tạo secret_key bằng cách nào ?
Sử dụng module Digest có sẵn từ Ruby để tạo khóa bí mật. Chúng ta sẽ tạo khóa trên rails console, thêm nó vào môi trường của chúng ta dưới dạng một biến môi trường ENV["AUTH_SECRET"]
. Chúng ta sẽ sử dụng Figaro để đảm bảo rằng biến môi trường không bị đẩy lên GitHub.
irb(main):002:0> Digest::SHA256.digest('ngocvu')
=> "\x83\xAF6$\xDA^\x9B\nL%\x1F,q\xF4;}\xF1\x1F\x9B)3\x17}m\x98\x8C\x80\xDA\xB7\xAE\xF2\\"
Trong file application.yml (file này lưu biến môi trường k dc đẩy lên github đâu nha)
AUTH_SECRET: "\x83\xAF6$\xDA^\x9B\nL%\x1F,q\xF4;}\xF1\x1F\x9B)3\x17}m\x98\x8C\x80\xDA\xB7\xAE\xF2\\"
Decode phục vụ để giải mã
Phương thức này có ba đối số:
- JWT muốn giải mã,
- Khóa bí mật của thuật toán băm
- Các loại thuật toán băm
Thường chúng ta encode Authentication.encode({user_id: user.id})
đầu vào là user_id và giải mã decode để lấy user_id
Login trả về JWT
Ta định nghĩa
module API module V1 class Auth < Grape::API include API::V1::Defaults helpers do def represent_user_with_token user present jwt_token: Authentication.encode({user_id: user.id}) end end resources :auth do desc "Sign in" params do requires :email requires :password end post "/sign_in" do user = User.find_by email: params[:email] if user&.authenticate params[:password] represent_user_with_token user else error!("Invalid email/password combination", 401) end end end end end
end
trong base v1: thêm mount V1::Auth
module API module V1 class Base < Grape::API mount V1::Users mount V1::Auth # mount API::V1::AnotherResource end end
end
User đăng nhập mới lấy được danh sách user
Ở usesr.rb trước khi vào các action ta xác thực user trước bằng hàm authenticate_user!
# app/controllers/api/v1/users.rb
module API module V1 class Users < Grape::API include API::V1::Defaults before do authenticate_user! end resource :users do desc "Return all users" get "", root: :users do users = User.all present users, with: API::Entities::User end desc "Return a user" params do requires :id, type: String, desc: "ID of the user" end get ":id", root: "user" do user = User.find params[:id] present user, with: API::Entities::User end end end end
end
function authenticate_user!
sẽ được dùng ở nhiều nơi nên ta định nghĩa trong defaults.rb
module API module V1 module Defaults extend ActiveSupport::Concern #.... included do helpers do def authenticate_user! token = request.headers["Jwt-Token"] user_id = Authentication.decode(token)["user_id"] if token @current_user = User.find_by_id user_id unless @current_user api_error!("You need to log in to use the app", "failure", 401, {}) end end end end end end
end
Hàm authenticate_user! sẽ decode jwt-token để lấy dc user_id sau đó sẽ tìm kiếm trong database. nếu user đó không tồn tại thì sẽ báo lỗi
Giờ ta chạy lại ta cần gắn jwt-token vào header thì ms lấy được danh sách user nha
Thử không truyền lên jwt-token xem hệ thống báo gì nhé
Tất nhiên không chết rồi, chúng ta đã bắt lỗi api_error!("You need to log in to use the app", "failure", 401, {})