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

285 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# The API contract
Everything that holds for **every** Balinyaar endpoint. The per-domain files in
[`domains/`](domains/index.md) assume all of this and never restate it.
> Last verified: 2026-07-30 against commit `d3ec723`. Wire facts were derived mechanically from
> [`openapi/swagger.v1.json`](openapi/swagger.v1.json) (2026-07-29) and read out of the code named
> beside each claim. **When the JSON and this file disagree, the JSON wins and this file is wrong.**
---
## Base, versioning, routing
| | |
| --- | --- |
| Client base URL | `NEXT_PUBLIC_API_URL` → [`client/src/config.ts`](../../client/src/config.ts) `API_URL` (required; boot fails without it) |
| Local | `http://localhost:5002`**plain HTTP**, per `launchSettings.json`. There is no local TLS and no certificate to trust |
| Deployed | `https://api.balinyaar.ir` → Caddy → `balinyaar-api:8080` |
| Route template | `api/v{version:apiVersion}/[controller]/[action]`, `DefaultApiVersion = 1.0` |
| Segment casing | snake_case, via `SnakeCaseParameterTransformer` (`RouteTokenTransformerConvention`) |
| Swagger UI | `/swagger` · ReDoc `/api-docs/{documentName}` · documents `v1` and `v1.1` |
Route strings are **never hardcoded** server-side — the `[controller]`/`[action]` tokens also derive the
dynamic-permission key, so renaming a handler renames its URL *and* its permission
([server/CLAUDE.md](../../server/CLAUDE.md) hard rule 4).
`v1.1` is registered (`AddSwagger("v1","v1.1")`) but **empty**: all 55 controllers are `[ApiVersion("1")]`
and `ApiVersionDocumentProcessor` drops every path whose URL lacks the document's version segment.
## The envelope
`Baya.Application/Models/ApiResult/ApiResult.cs`, applied by `ApiResultFilterAttribute` +
`base.OperationResult(result)`.
| Field | Type | Notes |
| --- | --- | --- |
| `isSuccess` | boolean | |
| `statusCode` | **integer** | `ApiResultStatusCode`: `200 400 401 403 404 406 409 422 424 500`. Not a string |
| `message` | string \| null | User-safe. Defaults to the status's display name (`"Success"`, `"Bad Request Error"`, …) |
| `requestId` | string \| null | `Activity.Current.TraceId` as hex — the W3C trace id, empty string if no activity |
| `code` | string \| null | Optional stable machine-readable error code. **Omitted from the wire when null** |
| `data` | `T` \| null | The payload. Present only on `ApiResult<T>` |
Failure responses use the same shape with `data` null (or the validation dictionary, below).
**Client-side drift:** `ApiEnvelope<T>` in
[`client/src/lib/api/types.ts`](../../client/src/lib/api/types.ts) declares `isSuccess`, `statusCode`,
`message`, `requestId`, `data` — but **not `code`**, even though `clientFetch` reads `body.code` at
runtime and threads it into `ApiError`. Harmless today, incomplete as a type.
## Casing
**JSON bodies are camelCase. URL segments are snake_case.** REQ-001 settled this; verified here
mechanically — across all 339 component schemas there are **427 distinct property names, 0 containing an
underscore and 0 in PascalCase**.
Query parameters are the exception with no single rule: most are camelCase, and `GET /search/nurses`
takes snake_case (`service_category_id`, `city_id`, `district_id`, `nurse_gender`, `min_price`,
`max_price`, `page_size`) — the only endpoint that does. See [domains/search.md](domains/search.md).
## Status codes
| Code | Meaning | Server source |
| --- | --- | --- |
| `200` | Success, payload in `data` | |
| `400` | Validation / business-rule failure | `data` is `{ "<field>": ["<message>", …] }` (`ApiResultOfDictionaryOfStringAndListOfString`), from `ModelStateValidationAttribute` + FluentValidation |
| `401` | Unauthenticated — missing, expired or unreadable token | |
| `403` | Authenticated but lacks the policy/permission | |
| `404` | Not found — **and a tenancy mismatch.** Deliberate: a 403 would confirm the row exists ([server/CLAUDE.md](../../server/CLAUDE.md) rule 20) | |
| `406` | Not acceptable | |
| `409` | Conflict — forward-only state-machine violation, duplicate, or converged idempotent replay | `OperationResult.ConflictResult` |
| `422` | Entity process error | |
| `424` | Failed dependency (an external rail refused) | |
| `429` | Rate limited | The rate limiter, below |
| `5xx` | Unexpected. Generic message; detail only in logs — **except** in Development, where the developer exception page returns a stack trace | `ExceptionHandler` |
Handlers **never throw for an expected failure** — they return `OperationResult.FailureResult` /
`NotFoundResult` / `ConflictResult`, which the filter maps to the codes above.
### What the client does with each
[`client/src/lib/api/client.ts`](../../client/src/lib/api/client.ts):
| | Behaviour |
| --- | --- |
| `401` | One silent refresh + retry (single-flight). On failure: clear both auth cookies, toast "session expired", `window.location.replace('/{locale}/login')`, **return `undefined` rather than throw** |
| `403` | Toast + throw `ApiError(403, message, code)` |
| `5xx` | Toast + throw `ApiError` |
| other `4xx` | **Throw without toasting** — the calling hook owns the user-facing message |
| network failure | Toast + `throw new ApiError(0, 'Network error')` |
| `204` | Returns `undefined` |
`serverFetch` ([`server.ts`](../../client/src/lib/api/server.ts)) never toasts and never redirects — every
non-OK response throws `ApiError` and the caller decides between `notFound()`, `redirect()` and an error
boundary.
## Auth
### Transport is a header, storage is a cookie
The JWE access token lives in a **client-readable cookie** (`access_token`) that the client reads itself
and re-sends as `Authorization: Bearer <token>`. **No cookie crosses the wire as an auth credential.**
Consequences:
- The server's CORS policy sets **no** `AllowCredentials()` — see [config-matrix.md](config-matrix.md#cors).
- Neither fetch layer sets `credentials: 'include'`.
- A cross-site cookie policy (`SameSite`) is irrelevant to API auth; the cookies are same-origin storage.
| Cookie | Options | Written by |
| --- | --- | --- |
| `access_token` | `path=/`, `maxAge=900` (15 min), `sameSite=lax`, `secure` | `persistAuthTokens` |
| `refresh_token` | `path=/`, `maxAge=604800` (7 days), `sameSite=lax`, `secure` | `persistAuthTokens` |
> **Asymmetry worth knowing:** the cookie's `maxAge` is 15 minutes but `IdentitySettings:ExpirationMinutes`
> is **60**. The token stays valid for an hour; the client simply stops having it after 15 minutes, so the
> next call goes out unauthenticated, gets a 401, and refreshes. It works, but the refresh cadence is set
> by the cookie, not the token.
### The token is opaque
It is a **JWE** — signed *and* AES-128-encrypted. The client cannot read a claim out of it and must not
try. Identity, roles and profile-completeness come from `GET /api/v1/me`; a user holding more than one
role commits to one with `POST /api/v1/me/select_role`. See [domains/auth.md](domains/auth.md) and
[docs/rules/client/auth.md](../rules/client/auth.md).
### Refresh, rotation, reuse detection
`POST /api/v1/auth/refresh` with `{ refreshToken }` returns a **new pair**; the old refresh token is
retired. Presenting a retired token is treated as theft and kills the session. The client coalesces
concurrent 401s into **one** refresh via a module-level in-flight promise
([`refresh.ts`](../../client/src/lib/api/refresh.ts)) and retries the original request exactly once.
`/auth/refresh`, `/auth/request_otp` and `/auth/verify_otp` are excluded from the retry — a 401 there is
terminal.
### Authorization model
Three levels, declared per controller:
| Attribute | Used by | Meaning |
| --- | --- | --- |
| *(none)* | 7 controllers | Anonymous — there is **no** `FallbackPolicy`, so an omitted attribute *is* the decision |
| `[Authorize]` | user-facing controllers | Any authenticated caller; tenancy is then resolved from `ICurrentUser` |
| `[Authorize(ConstantPolicies.DynamicPermission)]` | every `Admin*` controller + `Holidays`, `PlatformConfig`, `SupportAlerts`, `Audit`, `InternalCenters` | Dynamic permission keyed off the controller/action route |
### The anonymous surface
20 of 186 operations declare no security. This is the complete list:
```
POST /api/v1/auth/request_otp POST /api/v1/auth/verify_otp
GET /api/v1/catalog/categories GET /api/v1/catalog/option_groups
GET /api/v1/geo/provinces GET /api/v1/geo/cities
GET /api/v1/geo/districts GET /api/v1/geo/tree
GET /api/v1/nurses/{nurseId}/profile GET /api/v1/nurses/{nurseId}/trust_badge
GET /api/v1/nurses/{id}/reviews GET /api/v1/nurses/{id}/review_tags
GET /api/v1/nurse_variants/get/{id} GET /api/v1/search/nurses
GET /api/v1/ping/get_status GET /api/v1/ping/get_status_rate_limited
POST /api/v1/webhooks/payments/{provider} POST /api/v1/webhooks_bnpl/{provider}
POST /api/v1/webhooks/payouts/{provider} GET /api/v1/dev/last_otp/{phone}
```
Two things follow that the REQ ledger has not caught up with:
- **REQ-066/067** ask for anonymous nurse search + profile reads for guest browse and are filed *open*.
Those endpoints are **already anonymous**. What is genuinely missing is the rate limit the REQ asks for
(`SearchController` and `NursesController` carry no `[EnableRateLimiting]`, so they fall to the 100/min
per-IP global limiter) and the privacy review of the profile payload.
- `GET /api/v1/dev/last_otp/{phone}` returns any registered phone's login code. It is Development-only
code, and the deployment runs as Development — **so it is live on `api.balinyaar.ir`**. Recorded in
[DEPLOY.md](../../DEPLOY.md) as the deployment's largest exposure.
## Localisation
The client sends `Accept-Language` (`fa` default) on **every** call, taken from the URL's locale segment.
`serverFetch` reads the locale from its own `x-app-locale` request header (`HEADER_NAMES.LOCALE`, set by
the Next.js middleware) and forwards it as `Accept-Language`. Reference data carrying `nameFa`/`nameEn`
returns both and the client picks.
## Pagination
Every unbounded list is paginated. Payload: `{ items, total, page, pageSize }``total`, `page` and
`pageSize` are `integer/int32`, `items` is nullable.
29 operations take paging params, in three declared spellings:
| Declared | Count | Endpoints |
| --- | --- | --- |
| `Page`, `PageSize` | 25 | the default — every `*/list`, `admin_*` worklist, `tickets`, `notifications`, … |
| `page`, `pageSize` | 3 | `admin_payouts/batches/{id}` · `nurses/{id}/reviews` · `patients/{id}/care_records` |
| `page`, **`page_size`** | 1 | `search/nurses` |
The first two are interchangeable — ASP.NET model binding is case-insensitive, which is what REQ-010
recorded. **`page_size` is not**: it is a different name, and sending `pageSize` to `search/nurses` binds
nothing and silently yields the default page size. The client's search client already sends `page_size`
correctly.
## Idempotency
`Idempotency-Key`, a request header. **Read on exactly two endpoints**, both via
`Request.Headers["Idempotency-Key"].FirstOrDefault()`:
| Endpoint | Controller | Semantics |
| --- | --- | --- |
| `POST /api/v1/bookings/{bookingRequestId}/payments` | `PaymentsController` | One key per payment **attempt**, reused across retries of that attempt. A new attempt takes a new key. `409` means "already in progress / already captured" — a benign convergence, not an error |
| `POST /api/v1/checkout_bnpl/initiate` | `CheckoutBnplController` | Same, per BNPL attempt |
Because it is read from the header rather than bound as a parameter, **it appears nowhere in swagger.**
It *is* in the CORS allow-list, so the pre-flight passes.
Money-path writes are idempotent by construction regardless of the header: the webhook event is upserted
first and a duplicate no-ops, `bookings.booking_request_id` is `UNIQUE` so a replayed conversion cannot
create a second booking, and a unique-violation on confirm is treated as idempotent success. **The DB
constraint is the backstop, not the handler's `if`.**
Webhooks do **not** use the header — they dedupe on the provider's `external_event_id`.
## Money on the wire
| Direction | Type | Count | Rule |
| --- | --- | --- | --- |
| Outbound — DTOs, results | **digit string** (`"23300000"`) | 68 / 68 | Parse with the `@/utils` BigInt helpers. **Never `Number()`** |
| Inbound — `*Command` / `*Request` bodies | **`integer/int64`** | 3 / 3 | `UpsertCancellationPolicyCommand.feeAmountIrr`, `CreateRefundCommand.platformFeeRefundedIrr`, `CreateRefundCommand.nursePayoutRefundedIrr` |
Invariants the client must not recompute: `grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`;
VAT applies to Balinyaar's commission only, never the nurse payout; a rate change is never retroactive
(the rate is snapshotted onto the row at compute time). Toman↔IRR conversion happens only inside a
provider adapter. Payout dates are resolved server-side against the holiday calendar — the client never
computes one.
## Dates, ids, PII
- Timestamps are **UTC ISO-8601** strings. Shamsi rendering is a client concern.
- `dayOfWeek` for availability uses the **Shamsi week** (0 = Saturday … 6 = Friday), not ISO Monday-start.
- Entity ids are integers. Human-facing references (`referenceCode` on tickets, `invoiceNumber`) are
opaque strings.
- Encrypted-at-rest fields (phone, national id, IBAN, addresses, clinical notes) are returned only to
authorised callers and often masked. Each domain file states masked vs. full.
- **Two-stage clinical disclosure:** a booking *request* exposes only unencrypted `customerNotes` and a
city/district-coarse address; encrypted care instructions become readable only after confirmation, only
to the assigned nurse and admin, and are never projected into a list.
- `gender` is load-bearing — it drives same-gender caregiver matching. Never defaulted, never dropped.
## Enums
**Swagger carries no string enums.** Of 339 schemas exactly one has an `enum`, and it is the integer
`ApiResultStatusCode`. Every status/type/code field serialises as a bare `string`, so the JSON cannot
validate a vocabulary and **the domain files here are the only written record.**
Each vocabulary in [`domains/`](domains/index.md) was cross-checked against the server's `Baya.Domain`
code sets and the client's string-literal unions. Both sides agree on all 18 shared vocabularies at this
stamp, with one exception ([domains/tickets.md](domains/tickets.md): the client's `TicketAuthorRole`
carries a `system` member the server's `TicketCodes` does not define).
## Rate limits
`RateLimitingServiceExtension`. Over-limit → **429**. Partitioned on the client IP as resolved by the
forwarded-headers middleware, so behind Caddy each real client gets its own bucket.
| Policy | Limit | Applied to |
| --- | --- | --- |
| *(global, implicit)* | 100 / min per IP | every endpoint that opts into nothing else |
| `otp` | 5 / min | `auth/request_otp`, `auth/verify_otp` |
| `auth` | 10 / min | `auth/refresh` |
| `sensitive` | 20 / min | every `Admin*` controller, `checkout_bnpl`, `bookings` money actions, `booking_sessions` check-out, `nurse_bank_accounts` writes, `payments` |
| `webhook` | 120 / min | the three webhook controllers, partitioned **per provider × IP** so one PSP's burst cannot starve another |
| `global` (named) | 5 / 10 s | `ping/get_status_rate_limited` only — a demonstration endpoint |
`app.UseCors()` runs **after** `UseRouting()` and **before** the rate limiter and authentication, so a
pre-flight `OPTIONS` is answered rather than rejected as 429 or 401.
## Platform endpoints (outside every domain)
Not part of any `services/` domain, and intentionally so:
| Endpoint | Purpose |
| --- | --- |
| `GET /api/v1/ping/get_status` | Liveness smoke test (anonymous) |
| `GET /api/v1/ping/get_status_rate_limited` | Demonstrates a 429 (anonymous, 5/10 s) |
| `GET /healthz/live` | Process only — never touches a dependency |
| `GET /healthz/ready` | + app DB, log DB (deployed only), object-storage write probe |
| `GET /HealthCheck` | The aggregate, kept for existing probes |
| `GET /metrics` | Prometheus scrape (OpenTelemetry is the only metrics source) |
The health and metrics endpoints are **not** under `/api/v1` and carry no envelope.