285 lines
16 KiB
Markdown
285 lines
16 KiB
Markdown
# 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.
|