Error response từ Rails API
I. Mở đầu Xin chào các bác (lay2) Hôm nay em sẽ xin trình bày một vấn đề khi xây dựng API với Rails - Errors response Đối với những newbie, khi lần đầu viết Rails API để phục vụ cho Mobile client, ta sẽ gặp vấn đề khi định nghĩa response trả về cho phía client. Với cá nhân em, thì ...
I. Mở đầu
Xin chào các bác (lay2)
Hôm nay em sẽ xin trình bày một vấn đề khi xây dựng API với Rails - Errors response
Đối với những newbie, khi lần đầu viết Rails API để phục vụ cho Mobile client, ta sẽ gặp vấn đề khi định nghĩa response trả về cho phía client.
Với cá nhân em, thì (boiroivl) vì:
- Không biết format response trả về thế nào.
- Thông tin errors trả về bị thiếu.
- Không biết xử lý đối với từng loại Errors khác nhau.
- Nếu xử lý được, thì code bị trùng lặp quá nhiều.
Nói qua một chút về errors. Hồi trước, khi làm web form thì các errors trả về chỉ lấy từ nguồn Model validation.
Để lấy ra và sử dụng errors đấy thế nào thì Rails nó hỗ trợ tận răng.
Trường hợp dùng Ajax với Jquery để xử lý thì ít gặp, và nó không khó.
Nhưng khi làm API, ngoài các lỗi Validation, còn có các lỗi khác xảy ra trong quá trình viết service và yêu cầu cần response đầy đủ lại cho Client dưới dạng JSON.
Khi đó, nếu render trực tiếp object errors thì ... lỗi validation nó 1 kiểu, mà các lỗi custom nó 1 kiểu, Client không biết đường nào mà lần.
Nếu đọc gem active_model_serializers thì nó cũng hỗ trợ viết response trả về theo kiểu :json_api, nhưng format trả ra theo em thì nó không đẹp, và khó custom nếu muốn thay đổi.
Sau quá trình ăn hành, google, học lỏm các kiểu, em xin đúc rút ra 1 vài cách để giải quyết vấn đề trên.
Nếu có sai sót ở đâu, xin các bác chỉ giúp (lay2)
II. Triển
Để giải quyết bài toán trên, ta đưa ra list công việc như sau:
- Thống nhất format trả về cho client.
- Add gem active_model_serializer để hỗ trợ xử lý đầu ra.
- Tạo class và method để parse errors từ kiểu của validation theo format.
- Tạo class và method để xử lý errors từ các custom errors khác format.
1. Thống nhất format trả về:
Với API url ta đã có RESTFUL làm chuẩn, tuy nhiên đối với response trả về thì hiện tại chưa có chuẩn chung. Nếu các bác search google với từ khóa API response nó sẽ ra rất nhiều bài viết và cũng rất nhiều format trả về khác nhau.
Việc lựa chọn format trả về nào là tùy bạn, cái quan trọng là response của mình phải đủ thông tin cần thiết, và viết theo 1 format thống nhất.
Dạo quanh phố phường, dạo quanh thị trường vài vòng, đối với những ứng dụng cỡ nhỏ và vừa, em xin mạn phép suggest format sau:
Đối với trường hợp request success, response trả về sẽ như sau:
{
"success": true,
"data": {"id": 1, "name": "item_name"}
}
Trong đó,
- success: có 2 giá trị là true và false - thể hiện là request đó có thành công không.
- data: là dữ liệu trả về cho client. Tùy thuộc vào request của client mà ta trả về 1 hash, 1 mảng các hash, hay chỉ là hash rỗng.
Đối với trường hợp request errors (không phải do sự cố hạ tầng), thì response trả về sẽ như sau:
Với các lỗi ta tự định nghĩa:
{
"success": false
"errors": [{
"message": "Cannot update this record",
"code": "1000"
}]
}
Trong đó:
- success: thể hiện success hay fail, giống như thằng bên trên.
- errors: bao gồm 2 phần là:
- message: Nội dung cụ thể của lỗi.
- code: Mã lỗi trả về - client chủ yếu dựa vào cái này để xử lý lỗi, vì message khó so sánh và có khả năng bị thay đổi nhiều hơn.
Nhưng đối với các lỗi validation thì hơi khác 1 chút:
{
"success": false,
"errors": [{
"resource": "user",
"fields": "email",
"code": "2000",
"message": "Email has taken"
}]
}
Bên trong key errors, ngoài code và message giống ở trên, ta còn có thêm
- resource: Chỉ ra model nào bị lỗi.
- field: Chỉ ra trường nào bị lỗi.
Ngoài ra, khi trả về nó còn có đính kèm HTTP status code, các bác có thể xem thêm ở đây http://guides.rubyonrails.org/layouts_and_rendering.html
Format ta để tạm thế đã, giờ bắt tay để xử lý nó (yaoming)
2. Active model serializer
Để hỗ trợ xử lý response trả về, ta add thêm gem active_model_serializers, một công cụ mạnh mẽ và có performance tốt.
Cụ thể cách sử dụng các bạn đọc thêm ở: https://github.com/rails-api/active_model_serializers
Sau khi cài gem xong, ta viết 1 serializer base cho response errors trả về
class BaseErrorsSerializer < ActiveModel::Serializer
attribute :success
attribute :errors
def success
false
end
end
Các Serializer khác dùng để xử lý response errors thì kế thừa từ nó.
3. Xử lý lỗi validation
Nếu ta viết API phục vụ cho Mobile app thì các lỗi validation không nhất thiết phải bắt chặt toàn bộ vì bản thân phía bên Client cũng đã validate trước khi gửi lên rồi.
Tuy nhiên, với quan điểm của người làm Server thì - không tin bất cứ bố con thằng nào cả (yaoming), các validation ta xử lý đầy đủ như bình thường.
Các bước thực hiện:
- Phân tích errors trả về từ validation.
- Lọc lấy từng lỗi, với mỗi lỗi gọi tới hàm để xử lý.
- Tại hàm xử lý lỗi, tìm ra trường, code, message tương ứng với nó được lưu trong file I18n.
- Refactor.
Khi có lỗi validation xảy ra, rails sẽ trả về dưới dạng như sau:
(Giả dụ ta có record user, sau khi gán và gọi user.valid?)
#<ActiveModel::Errors:0x000000012345678
@base=
#< id: nil, name: nil, email: "", created_at: nil, updated_at: nil>,
@details={:email=>[{:error=>:blank}]},
@messages={:email=>["Email is blank"]}>
Như vậy, để tạo ra format
{
"success": false,
"errors": [{
"resource": "user",
"field": "email",
"code": "2000",
"message": "Email has taken"
}]
}
Ta xác định cần lấy value cho key field bên trong @details, message lỗi bên trong @messages
Tạo 1 class Serializer cho việc xử lý này, với đầu vào là record bị lỗi
class ValidationErrorsSerializer < BaseErrorsSerializer
def errors
object.errors.details.map do |field, details|
details.map.with_index do |error_details, index|
EachValidationErrorSerializer.new(
object, field, error_details, object.errors[field][index]).generate
end
end.flatten
end
end
Vì nó trả về nhiều lỗi 1 lúc, nên ta phải tạo vòng lặp để lọc lấy từng cái ra một.
Đối với mỗi lỗi lấy ra được, ta viết EachValidationErrorSerializer để xử lý.
Các argument truyền vào là:
- object: là record bị lỗi.
- field: trường bị lỗi, lấy được từ details - như ví dụ ở trên thì nó là :email
- error_details: tên loại lỗi validate gặp phải - ở đây là :blank
- object.errors[field][index]: message lỗi gặp phải - ở đây là "Email is blank"
Tiếp theo, ta viết class EachValidationErrorSerializer để generate ra 1 hash tương ứng với argument truyền vào bên trên
class EachValidationErrorSerializer
def initialize record, error_field, details, message
@record = record
@error_field = error_field
@details = details
@message = message
end
def generate
{
resource: resource,
field: field,
code: code,
message: @message
}
end
private
def resource
# TODO get resource
end
def field
# TODO get field
end
def code
# TODO get code
end
end
Trong file I18n ta tự định nghĩa mã code lỗi tương ứng với từng trường hợp và viết theo cấu trúc sau đây:
ja:
# API Validation code and message
api_validation:
resources:
user: user
fields:
user:
email: email
codes:
blank: 1000
taken: 1001
Dựa vào cấu trúc trên, tại EachValidationErrorSerializer ta lấy ra dữ liệu tương ứng với nó như sau:
class EachValidationErrorSerializer
...
private
def resource
I18n.t(
underscored_resource_name,
scope: [:api_validation, :resources]
)
end
def field
I18n.t(
@error_field,
scope: [:api_validation, :fields, underscored_resource_name]
)
end
def code
I18n.t(
@details[:error],
scope: [:api_validation, :codes]
)
end
def underscored_resource_name
@record.class.to_s.gsub("::", "").underscore
end
end
Trong đó, method underscored_resource_name lấy ra tên class của record đó dưới dạng underscore
- method resource sẽ tìm tới I18n.t "api_validation.resources.user" = "user"
- method field sẽ tìm tới I18n.t "api_validation.fields.user.email" = "email"
- method code sẽ tìm tới I18n.t "api_validation.codes.blank" = 1000
Như vậy, kết quả khi ta gọi generate từ object của class này sẽ là:
{
resource: "user",
field: "email",
code: 1000,
message: "Email is blank"
}
Và kết quả của cả ValidationErrorsSerializer sẽ là 1 mảng
errors: [
{
resource: "user",
field: "email",
code: 1000,
message: "Email is blank"
},
{...}
]
Nếu cần bắt thêm các lỗi validation khác, chỉ cần cập nhật thêm vào bên trong file I18n là xong.
Vậy là ta đã parse xong các errors thuộc ActiveModel::Errors, trên Controller ta render ra messages như thế này ư?
class UsersController < BaseController
def create
user = User.new user_params
if user.save
render json: {success: true, data: {}}
else
render json: {success: fail, errors: ValidationErrorsSerializer.new user}
end
end
private
def user_params
params.permit :email, :name
end
end
Không!
Mỗi khi có lỗi ta phải viết lại cái render kia thì nó không được DRY, nếu controller có nhiều xử lý thì càng rối mắt hơn.
Để xử lý việc đó, ta sẽ làm 1 trick nhỏ như sau.
Mỗi khi dùng hàm save data theo kiểu trên, bản thân bên trong nó đã có 1 Transaction để rescue đối với trường hợp bị invalid.
Thay vì dùng hàm save, ta sẽ dùng save! hoặc update!.
Khi đó, nếu record bị invalid, nó sẽ Raise trực tiếp lên chứ không bắt rescue nữa. Class bị raise lên khi save! lỗi tên là ActiveRecord::RecordInvalid.
Lợi dụng điều đó, tại BaseController ta viết thêm 1 đoạn xử lý nhỏ:
class BaseController < ActionController::API
rescue_from ActiveRecord::RecordInvalid, with: :render_invalidation_response
def render_invalidation_response exception
render json: exception.record, serializer: ValidationErrorsSerializer, status: :bad_request
end
end
Tại UsersController ta sửa lại để nó raise lỗi lên:
class UsersController < BaseController
def create
User.create! user_params
render json: {"success": true, data: {}}
end
private
def user_params
params.permit :email, :name
end
end
4. Custom errors
Ý tưởng thực hiện:
- Viết 1 class trong folder lib gọi là APIError::Base
- Đối với mỗi 1 custom errors ta viết cho nó 1 class có kế thừa từ thằng APIError::Base ở trên
- Mỗi khi gặp lỗi, ta raise lên 1 object tương ứng với class đã viết.
- Định nghĩa code, message lỗi trong file I18n theo cấu trúc định sẵn.
- Tại APIError::Base hàm initialize, xử lý để bóc tách, tìm đúng code và message đã được viết trong I18n
Tại folder lib ta viết class như sau:
module APIError
class Base < StandardError
include ActiveModel::Serialization
attr_reader :code, :message
def initialize
# TODO perform
end
end
end
Tạo file Serializer để định nghĩa response cho các lỗi này:
class ApiErrorsSerializer < BaseErrorsSerializer
def errors
[{code: object.code, message: object.message}]
end
end
Giả sử giờ ta muốn có custom errors nếu như record không tìm thấy trong database chẳng hạn.
class UsersController < BaseController
def show
user = User.find_by id: params[:id]
# raise
raise APIError::Record::NotFound.new unless user
end
end
Trong APIError, ta định nghĩa cho class đó
module APIError
class Base < StandardError
include ActiveModel::Serialization
attr_reader :code, :message
def initialize
# TODO perform
end
module Record
class NotFound < APIError::Base
end
end
end
end
Trong file I18n, viết ra code và message tương ứng với trường hợp đó như sau:
api_error:
record:
not_found:
code: 2000
message: Record not found
Quay lại cái lib, ta định nghĩa hàm initialize để mỗi khi khởi tạo object lỗi, sẽ tìm tới code và message tương ứng dựa vào chính tên class được raise lên
module APIError
class Base < StandardError
include ActiveModel::Serialization
attr_reader :code, :message
def initialize
error_type = I18n.t self.class.name.underscore.gsub(%r{/}, ".")
error_type.each do |attr, value|
instance_variable_set("@#{attr}".to_sym, value)
end
end
module Record
class NotFound < APIError::Base
end
end
end
end
Kết quả của self.class.name.underscore.gsub(%r{/}, ".") = api_error.record.not_found
Kết quả khi gọi I18n sẽ là 1 hash
{code: 2000, message: "Record not found"}
Tương tự như thằng errors validation, tại base Controller ta viết 1 cái rescue_from chung cho tất cả các custom errors này
rescue_from APIError::Base, with: :render_api_error_response
def render_api_error_response exception
render json: exception, serializer: ApiErrorsSerializer,
status: :bad_request
end
Vậy là xong rồi (honho).
Từ giờ để render custom errors, ta chỉ cần thêm 1 class trong lib APIError và định nghĩa message trong I18n là được.
Tham khảo:
- https://github.com/rails-api/active_model_serializers
- http://stackoverflow.com/questions/12806386/standard-json-api-response-format
- https://google.github.io/styleguide/jsoncstyleguide.xml
- http://www.thegreatcodeadventure.com/rails-api-painless-error-handling-and-rendering-2/
- https://blog.rebased.pl/2016/11/07/api-error-handling.html