Build API errors response

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:

  1. Thống nhất format trả về cho client.
  2. Add gem active_model_serializer để hỗ trợ xử lý đầu ra.
  3. Tạo class và method để parse errors từ kiểu của validation theo format.
  4. 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à truefalse - 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 codemessage 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: