Kontrak REST Surface /_ui/
Status: Draft · Kontrak: ../spec/backend/01-core-basic.md §8.1
Surface /_ui/ adalah kontrak publik untuk setiap klien FormSpec — SPA bawaan pun memakainya. Halaman ini menjelaskannya sekali, supaya klien baru tidak menemukannya dengan trial-and-error (gap #47).
Untuk kontrak satu entity, jangan menyalin dari sini — cetak dari sumber yang sama dengan yang mendaftarkan route:
formspec describe entity order --spec examples/kafe/specPerintah itu memakai generator route yang sama dengan server (api.UIRoutesForEntity), jadi ia tidak bisa menyebut endpoint yang tidak ada atau melewatkan yang ada.
1. Bentuk path
/{workspace}/_ui/entity/{module}/{entity}[/{id}][/{field}|/{action}]{entity}adalahmetadata.name(singular) — bukanplural. Plural hanya dipakai di nama permission dan URLapi/v1.- Semua endpoint entity berada di bawah prefix workspace. Workspace dapat berupa slug (
kafe) atau UUID.
| Method | Path | Aksi | Permission |
|---|---|---|---|
GET | …/{module}/{entity} | list | {module}.{plural}.list |
GET | …/{module}/{entity}/{id} | find | {module}.{plural}.view |
POST | …/{module}/{entity} | create | {module}.{plural}.create |
PATCH | …/{module}/{entity}/{id} | update | {module}.{plural}.update |
DELETE | …/{module}/{entity}/{id} | delete | {module}.{plural}.delete |
POST | …/{module}/{entity}/{id}/submit (juga cancel, amend) | lifecycle | {module}.{plural}.{action} |
POST | …/{module}/{entity}/{id}/{action} | custom action ber-impl | {module}.{plural}.{action} |
POST/GET | …/{module}/{entity}/{id}/{field} | upload / unduh field file | update / view |
Aturan yang sering mengejutkan, dan bukan detail implementasi:
- Route hanya ada kalau spec-nya memang menghasilkan aksi itu. Aksi
disabled: truetidak punya route (dan tidak punya permission terdaftar); entitylifecycle-freetidak punyasubmit/cancel/amend; entitycharacteristic: summaryhanyalist+find. - Transisi state machine tanpa
impltidak punya endpoint sendiri. Ia diterapkan lewatupdate; guard transisinya yang memvalidasi perpindahan. Hanya aksi denganimplyang punya route{id}/{action}. - Route
{id}/{field}hanya melayani field bertipefile/attachment; segmen lain di posisi itu menjawab404, bukan403([#52]).
2. Request body
Body bersifat flat — envelope ditolak.
// create / update / action
{ "number": "ORD-2026-00001", "channel": "qr_table" } // ✅
{ "data": { "number": "..." } } // ❌ 400 unknown field: "data"Nilai mengikuti tipe field (../spec/backend/05-field-types.md): money dikirim sebagai {"amount": "25000", "currency": "IDR"} (server menormalisasi angka/string telanjang, §2), decimal sebagai string desimal, relation sebagai id target, child sebagai array objek baris.
update mengirim hanya field yang berubah, dan wajib membawa version untuk optimistic concurrency — mismatch → 409 CONFLICT.
3. Response envelope
// list
{ "data": [ … ],
"meta": { "page": 1, "per_page": 20, "total": 137, "total_pages": 7 },
"links": { "first": "…", "last": "…" } }
// satu record (create / find / update)
{ "data": { … }, "meta": { "request_id": "…", "timestamp": "…" } }
// error
{ "error": { "code": "VALIDATION_ERROR", "message": "…", "details": { … } },
"meta": { "timestamp": "…" } }Kode error: VALIDATION_ERROR (422), UNAUTHORIZED (401), FORBIDDEN (403), NOT_FOUND (404), CONFLICT (409), STATE_TRANSITION_ERROR (422), INTERNAL_ERROR (500). Daftar lengkap + kode kanonik FORMSPEC.*: ../spec/backend/error-glossary.yaml.
4. Query untuk list
?page=1&per_page=20&sort=-transaction_date&direction=asc&fields=number,status&search=kopi
?filter[status][eq]=paid&filter[total_amount][gte]=50000per_pagedefault 20, maksimum 100 (di atas maksimum di-clamp, bukan ditolak).- Operator filter:
eq neq gt gte lt lte between in nin like ilike null notnull. sortatas field JSONB di-cast ke tipe field, jadi urutannya numerik/temporal — bukan leksikografis.- Parameter
row_scopebukan filter. Kalau sebuah entity mendeklarasikan scopefrom: route, parameter itu adalah konteks request: nilainya dipakai engine untuk membatasi baris, dan nilainya tidak bisa dilebarkan klien (../spec/backend/01-core-basic.md§1.7).
5. Surface approval — …/_ui/workflow/…
kind: ApprovalInbox (../kind/ui/ApprovalInbox.md) adalah satu-satunya kind yang sumbernya bukan entity: barisnya hidup di tabel framework formspec_workflow_approval, jadi tidak ada route entity yang bisa mengeksposnya. Karena itu ia punya surface sendiri, di /_ui/ dan bukan /api/v1/ — sama seperti print.
GET /{workspace}/_ui/workflow/approvals?app={app}
POST /{workspace}/_ui/workflow/approvals/{id} → {"decision":"approve"|"reject"}GET membalas { data: [ … ], meta: { … } } (envelope §3) dengan item:
| Field | Isi |
|---|---|
id | id baris approval — dipakai oleh POST |
entity, record_id | record yang sedang diputuskan |
workflow, workflow_module | approval gate yang mengawal transisi — namanya {entity}.{transition} (field dipertahankan demi kompatibilitas klien) |
from, to, active_step, total_steps | transisi yang menunggu dan posisi step |
title, description | label tugas dari steps[].title/description; diisi dari nama transisi bila tak dideklarasikan |
display_fields | [{ field, label?, type?, value }] — nilai record yang approver butuhkan (label/type diambil dari deklarasi field entity, jadi klien memformatnya seperti tabel) |
can_decide | pemanggil memegang permission yang menggerbangi transisi itu |
Aturan yang bukan detail implementasi:
- Barisnya sudah tersaring, bukan daftar penuh. Yang dikembalikan hanya tugas yang boleh ditindak pemanggil: App yang dipakai (module-nya), workspace-nya, dan langkah yang role-nya dipegang pemanggil. Pemohon tidak pernah melihat permintaannya sendiri (§ core-extended 7.4.5).
can_decidememisahkan dua pertanyaan. Role pada langkah menentukan apa yang terdaftar (list), sedangkan permission yang menggerbangi route transisi menentukan apa yang bisa dijalankan (POST). Tugas dengancan_decide: falsetetap terdaftar — itu antrean pemanggil — tetapiPOSTmembalas403.display_fieldshanya terisi bila pemanggil memegang{module}.{plural}.view. Tugasnya tetap terlihat; nilainya tidak. Membaca record demi label tugas tidak boleh menjadi jalan memutar izin baca.- Nilai
display_fieldsjatuh ke input pemohon. Transisi yang di-intercept tidak menulis apa pun sampai approval selesai, jadi sebuah field yang diisi pemohon (void_reason) hanya ada diparamsbaris approval. Nilai record tetap menang bila sudah ada. POSTmendelegasikan ke mesin approval yang sama dengan halaman record (PATCH/aksi transisi): quorum, larangan menyetujui permintaan sendiri, audit bertanda tangan, dan emit event transisi semuanya berlaku.403/409yang sama juga bisa muncul di sini — lihat §4 core-extended.- Idempotensi bukan milik endpoint ini. Tugas yang sudah diputuskan hilang dari daftar (
statusberubah daripending), jadi keputusan kedua membalas404alih-alih “sukses” kedua kali. appopsional bila workspace hanya punya satu App atau sesi sudah terikat pada satu App. Bila lebih dari satu dan tidak ada yang ditunjuk, permintaan ditolak — sama seperti/_meta/ui.- Realtime belum berlaku untuk permukaan ini: hub WS mendorong per
{module}/{entity}, sedangkan approval bukan entity (realtime: truepada kind belum dihormati — todo 5.13.7 ⏸️). Klien harus me-refresh.
GET /_ui/print/{module}/{name}/{id} (§7) adalah endpoint non-entity lain di surface yang sama; keduanya berada di /_ui/ karena memakai autentikasi sesi, bukan spec.expose.
6. Surface publik
App dengan access: public membuka sebagian endpoint untuk anonim, tetapi hanya entity+aksi yang dibaca view publiknya — grant itu diturunkan dari permukaan App, dan sebuah grant boleh membatasi baris-nya lewat scope yang berasal dari param route pada Table block (../spec/frontend/05-app-kinds.md §1.1). Ringkasnya: grant berlaku untuk anonim; pemanggil yang sudah terautentikasi tetap wajib memegang permission entity itu.
7. Di luar cakupan halaman ini
Surface eksternal (/{workspace}/api/v1/…) punya kontrak terpisah dan deny-by-default lewat spec.expose (§8.2/§8.4). Print, Report, dan dashboard memakai endpoint-nya sendiri di surface yang sama (§5 mencakup approval); kontrak HTTP per entity dicetak oleh formspec describe, sedangkan kontrak kind ada di ../kind/.