Skip to content

Hợp đồng dữ liệu

Hình dạng request/response mà framework trông đợi. Backend theo được thì trang không phải viết code quy đổi; không theo được thì quy đổi ở fetcher — đừng để định dạng lạ lan vào component.

Response của một route

Trả object có khoá đặt tên, không trả giá trị trần:

ts
// Tốt
return { user }          // { user: AuthUser | null }
return { clients }       // { clients: AuthClientOption[] }

// Tránh
return user              // thêm field sau này là breaking
return clients           // mảng trần, không chỗ gắn metadata

Không có dữ liệu thì trả null trong đúng khoá đó, không trả 404. 404 dành cho đường dẫn không tồn tại, không phải cho "bản ghi trống".

Danh sách có phân trang

ts
interface Paginated<T> {
  items: T[]
  total: number     // tổng số bản ghi khớp điều kiện, không phải số bản ghi trang này
}

Tham số truy vấn mà useTable gửi cho fetcher:

ts
interface TableQuery {
  page: number                        // tính từ 1
  pageSize: number
  sortField?: string                  // dataIndex của cột; cột lồng nối bằng '.'
  sortOrder?: 'ascend' | 'descend'    // không sắp xếp → cả hai field là undefined
  filters?: Record<string, unknown>   // filter cột gộp với bộ lọc form
}
TrườngLưu ý
pageBackend đếm từ 0 thì trừ 1 trong fetcher, đừng đổi ở component
sortOrderDùng nguyên từ vựng của antdv (ascend/descend); backend dùng asc/desc thì quy đổi ở fetcher
filtersTrùng khoá thì giá trị từ bộ lọc form thắng filter cột. Không có bộ lọc nào thì là undefined

DTO

  • Khai bằng interface trong một file *.types.ts cạnh composable của nghiệp vụ.
  • Field camelCase. Dữ liệu snake_case từ bên ngoài giữ trong type riêng và map sang ở fetcher (hoặc ở route Nitro nếu dự án tự viết).
  • Field có thể vắng mặt dùng ?:; field luôn có mặt nhưng có thể trống dùng T | null.
  • Dữ liệu chưa tin cậy nhận kiểu unknown rồi kiểm tra bằng type guard, không ép kiểu bằng as.
  • Chuyển tiếp dữ liệu đi nơi khác thì chỉ giữ các field được phép, không đẩy nguyên object.
ts
// app/composables/orders.types.ts
export interface Order {
  id: number
  code: string
  customer: string
  amount: number
  status: 'new' | 'processing' | 'done' | 'cancelled'
  note?: string           // có thể không có trong response
  closedAt: string | null // luôn có field, chưa đóng thì null
}

Dùng union chuỗi cho tập giá trị cố định, không dùng enum — quy ước chung của core.

Lỗi

Mọi lỗi HTTP về tới client đều được useApi chuyển thành AppError với mã nằm ở field status (không phải statusCode).

Khi nàoFramework làm gì
400Dữ liệu vào sai định dạngHiện message dạng toast
401Chưa đăng nhập / phiên hết hạnĐiều hướng về /auth/login
403Thiếu quyềnHiện toast; route thì ném lỗi 403
422Dữ liệu không hợp lệ theo nghiệp vụKhông toast — form tự hiện lỗi từng field
5xxLỗi phía serverHiện notification "Lỗi hệ thống"
không có mãMất mạng, timeoutHiện notification

Thân lỗi 422

Field errors phải nằm ở gốc body:

json
{
  "message": "Dữ liệu không hợp lệ",
  "errors": {
    "customer": ["Khách hàng không tồn tại"],
    "amount": ["Giá trị phải lớn hơn 0"]
  }
}

getFieldErrors(error) đọc errors (hoặc fields) ở gốc và trả Record<string, string[]> để gắn vào form. Xem Lỗi 422 và form.

Đừng dùng createError({ data }) cho 422

h3 lồng data vào trong body ({ statusCode, statusMessage, data: { errors } }), nên getFieldErrors không tìm thấy errors ở gốc. Trong route Nitro của dự án, đặt status rồi trả body thẳng:

ts
setResponseStatus(event, 422)
return { message: 'Dữ liệu không hợp lệ', errors: { amount: ['Giá trị phải lớn hơn 0'] } }

Với các mã còn lại thì createError là đúng:

ts
throw createError({ statusCode: 403, statusMessage: 'Bạn không có quyền thực hiện thao tác này.' })

statusMessage viết tiếng Việt, đủ nghĩa để hiện thẳng cho người dùng.

Không để lộ định dạng của hệ thống ngoài

Envelope của IAM/OIDC ({ code: 'API000', body: {...} }) là chuyện giữa BFF và nhà cung cấp. Route của dự án bóc lấy phần cần dùng rồi trả DTO sạch:

ts
// Tránh
return iamResponse                       // client phải biết 'API000' nghĩa là gì

// Tốt
return { user: mapIamUser(iamResponse) } // client chỉ thấy DTO của mình

Cũng vì vậy /api/** chuyển tiếp nguyên trạng: phần quy đổi là trách nhiệm của fetcher hoặc route riêng, không phải của proxy.

Danh sách rà soát khi chốt API với backend

  • [ ] Danh sách trả { items, total }; total là tổng toàn bộ.
  • [ ] Trang tính từ 1, hoặc đã thống nhất quy đổi ở fetcher.
  • [ ] Lỗi nghiệp vụ dùng 422 với errors ở gốc body, thông báo tiếng Việt.
  • [ ] Không có dữ liệu → null, không phải 404.
  • [ ] Field ngày giờ thống nhất một định dạng (ISO 8601) và một múi giờ.
  • [ ] Thao tác ghi trả về bản ghi sau khi ghi, để trang không phải gọi lại.

Liên quan