Files
baya-monorepo/docs/integration/index.md
T
2026-07-30 12:49:46 +03:30

8.6 KiB

Integration — the client↔server seam

The two projects have no shared build. Everything that crosses between them is described here, as one thing. This page is the whole seam; the four files below are the detail.

Last verified: 2026-07-30 against commit d3ec723, swagger.v1.json (2026-07-29, 178 paths / 186 operations) and the code in client/src/lib/api/, client/src/services/*/, server/src/API/.

File Covers
api-contract.md Envelope, casing, pagination, errors, idempotency, auth, money, rate limits
domains/ 22 files, one per client services/ domain — every endpoint, verdicted against the live swagger
openapi/ The machine contract + how to regenerate it
config-matrix.md Every env var and appsettings key: client, server, docker, the bot
topology.md The runtime dependency graph — 3 containers, Caddy, remote SQL, the OTP relay

Transport

HTTP/JSON. The client reads one base URL — NEXT_PUBLIC_API_URL — and prefixes every path with /api/v1/. Locally that is http://localhost:5002 (plain HTTP; launchSettings.json binds no TLS); deployed it is https://api.balinyaar.ir, which Caddy terminates and forwards to balinyaar-api:8080.

gRPC exists and the client does not use it. Baya.Web.Plugins.Grpc serves exactly one service (User) over HTTP/2 on the same port. Nothing in client/ speaks it. Treat it as an internal affordance.

Two OpenAPI documents are served, v1 and v1.1. All 55 controllers declare [ApiVersion("1")], and ApiVersionDocumentProcessor keeps only paths whose URL contains the document's version segment — so v1.1 is served but contains zero paths. v1 is the contract.

The envelope

Every response — success and failure — is ApiResult. The payload is always under data.

{ "isSuccess": true, "statusCode": 200, "message": "Success",
  "requestId": "0af7651916cd43dd8448eb211c80319c", "code": null, "data": { } }

requestId is the W3C trace id of the request (Activity.Current.TraceId), the same id OpenTelemetry traces on — so a support ticket maps 1:1 to a trace. code is an optional stable machine-readable error code (otp_locked, …), omitted from the wire when null.

The client never unwraps centrally: clientFetch/serverFetch return the raw envelope, and each service calls unwrap() from client/src/lib/api/types.ts.

Casing — camelCase bodies, snake_case URLs

Verified mechanically against the live swagger: of 427 distinct property names, 0 contain an underscore and 0 are PascalCase. JSON bodies are camelCase. URL segments are snake_case, produced by the server's SnakeCaseParameterTransformer from [controller]/[action] tokens (SetPrimary/set_primary).

Three routes break the snake_case rule with hardcoded hyphens — admin/partner-centers, admin/tickets, admin/reviews — see domains/index.md.

Pagination

{ items, total, page, pageSize } (total/page/pageSize are int32). 29 operations are paginated. The declared query-param names are not uniform — 25 declare Page/PageSize, 3 declare page/pageSize, and GET /search/nurses declares page/page_size. Model binding is case-insensitive so the first two are interchangeable; page_size is a different name and is not. Full table in api-contract.md.

Errors

200 · 400 validation (field errors under data) · 401 unauthenticated · 403 forbidden · 404 not found (also returned for a tenancy mismatch, deliberately, so a 403 never confirms a row exists) · 409 state-machine/idempotency conflict · 422 · 424 · 429 rate-limited · 5xx.

clientFetch behaviour: 401 → one silent refresh + retry, then clear cookies, toast, redirect to login (no throw); 403 and 5xx → toast + throw ApiError; other 4xx → throw without toasting (the calling hook owns the message); network failure → toast + ApiError(0).

Auth

Bearer header, not cookie auth. The JWE access token is stored in a cookie the client reads itself and sent as Authorization: Bearer <token>. The server's CORS policy therefore does not allow credentials, and the client sends no credentials: 'include'.

The token is opaque to the client (signed + AES-encrypted). Role and identity come from GET /api/v1/me; a multi-role user picks one with POST /api/v1/me/select_role. On a 401 the client runs one single-flight silent refresh (POST /api/v1/auth/refresh) — the server rotates the pair and detects reuse, so a replayed refresh token kills the session. /auth/refresh, /auth/request_otp and /auth/verify_otp are excluded from the retry.

20 of 186 operations are anonymous — the OTP pair, catalog + geo reference reads, the public nurse search/profile/reviews/trust-badge reads, ping, the three webhooks, and Development's GET /api/v1/dev/last_otp/{phone}. Full list in api-contract.md.

Idempotency

Idempotency-Key is a request header. The server reads it on exactly two endpoints:

Endpoint Key scope
POST /api/v1/bookings/{bookingRequestId}/payments one key per payment attempt, reused across retries of that attempt
POST /api/v1/checkout_bnpl/initiate same, per BNPL attempt

The client also sends it on admin_payouts process/retry, where the server does not read it — see domains/payouts.md. Webhooks do not use the header; they dedupe on the provider's external_event_id. The header is allowed through CORS but is not declared in swagger (it is read from Request.Headers, not bound as a parameter).

Money

IRR Rials, integer, no floats, anywhere. On the wire the direction matters, and it is consistent:

  • Outbound (DTOs/results): a digit string. All 68 money properties on read models are type: string. Parse with the @/utils BigInt helpers — never Number().
  • Inbound (commands): integer/int64. All 3 money properties on command bodies.

Toman is display-only and is converted only inside a provider adapter at its boundary (ICurrencyNormalizer, Seams:Currency:TomanToIrrMultiplier). gross = commission + payout always, and VAT is on Balinyaar's commission only. See docs/rules/server/money.md and docs/rules/client/services.md.

Enums

Swagger declares no string enums — 1 of 339 schemas has an enum, and it is the integer ApiResultStatusCode. Every status/code field is a bare string on the wire. The vocabulary therefore lives in the domain files here, cross-checked against the server's Baya.Domain code sets and the client's string-literal unions. All 18 shared vocabularies match on both sides as of this stamp; the one exception is noted in domains/tickets.md.


What the server owes the client

  1. The ApiResult envelope on every response, with requestId populated and code set on any failure the client must branch on.
  2. camelCase bodies; snake_case URL segments; { items, total, page, pageSize } on every list.
  3. 404, never 403, for a row the caller does not own.
  4. Money as a digit string outbound; the three-amount split guaranteed server-side.
  5. A masked address and only unencrypted customerNotes before a booking is confirmed; full care instructions only after, only to the assigned nurse and admin.
  6. is_internal filtered at the query layer — a non-staff caller can never read or set one.
  7. Idempotent behaviour on the two keyed endpoints, and 409 (not 500) on a converged replay.
  8. Reachability: /healthz/live, /healthz/ready, and CORS origins that list the client's real origin.

What the client owes the server

  1. Authorization: Bearer <token> on every authenticated call — and nothing else for auth. No cookies cross the wire.
  2. Accept-Language (fa default) on every call, from the active locale segment.
  3. Content-Type: application/jsonexcept FormData bodies, where the browser sets the multipart boundary itself.
  4. One stable Idempotency-Key per payment/BNPL attempt; a new attempt gets a new key.
  5. page/pageSize within the server's cap; never an unbounded list request.
  6. IRR integers inbound; Toman conversion only at the UI boundary.
  7. Exactly one silent refresh per 401, single-flighted, and never on the refresh/OTP endpoints.
  8. No derived money. The client never computes commission, VAT, refund amounts or payout dates.