§ unswayed-backend
API contract & docs
How the HTTP surface is shaped, why it looks the way it does, and where the always-current reference lives. This is the contract every client (and the frontend) sees.
The reference is read-only
endpoint_documentation.md in the repo is the legacy Laravel contract the
mobile/web frontend was built against. We treat it as a read-only reference:
the NestJS backend is reconciled to it, but we never edit it. The living,
machine-checked version of the contract is the OpenAPI document the server
generates at /docs (Swagger UI) and /docs-json (raw OpenAPI 3).
Four rules that hold for every endpoint
- Everything is under
/api.POST /api/applicant/register,GET /api/auth/social/providers, and so on. The lone exception is the rootGET /health ping, which stays at/for uptime checks. - Bodies and payloads are snake_case.
last_name,password_confirmation,zip_code,company_name,current_password,refresh_token; theuserobject returnsuser_type,is_verified,is_completed,profile_photo,created_at. This mirrors the legacy API exactly, so the frontend is a drop-in. - One envelope. Success is always
{ status: "success", message, data }; failure is always{ status: "error", message, errors? }. Theerrorsmap appears only on the 422 validation body (keyed by field name); a 401/403/ 404/409/500 carries just{ status, message }. - Bearer auth (
Authorization: Bearer <access token>) guards the authenticated routes; Swagger marks exactly which ones.
How the /api prefix works
A single helper, configureApp(app) in src/configure-app.ts, calls
app.setGlobalPrefix('api', { exclude: [GET /] }). It is invoked from two
places: main.ts (production) and every e2e suite's beforeAll. That shared call
is deliberate — it means the supertest end-to-end tests hit /api/... exactly like
the internet does, so the prefix can never silently regress.
How snake_case stays clean inside
The wire is snake_case, but the internal TypeScript stays idiomatic camelCase. The
boundary is the DTO: request DTOs declare snake_case fields (last_name), and the
controller/service maps them into camelCase internal inputs
({ lastName: dto.last_name }) before they touch persistence. Responses go the
other way — toUserResource() projects a Prisma User into the snake_case shape.
So only the edge speaks snake_case; the core never has to.
Gotcha that motivated all of this: the global
ValidationPiperuns withwhitelist: true, which strips any property not declared on the DTO. When the DTOs were camelCase, an incoming snake_caselast_namewas silently dropped and registration failed validation with a confusing "lastName required". Matching the DTO field names to the wire names is what makes the body bind at all.
The one status-code subtlety: 206
social-login returns 206 Partial Content when the signed-in user's profile is
not yet complete (a brand-new social account), and 200 when it is. The
controller sets this with a passthrough response —
res.status(data.is_completed ? 200 : 206) — while still returning the normal
envelope. A client that sees 206 knows to send the user through
complete-profile next.
How the docs are generated
main.ts builds the OpenAPI document with DocumentBuilder: it declares the
/api server, the tags, and bearer auth. The @nestjs/swagger CLI plugin
(enabled in nest-cli.json) auto-documents every DTO class used as a @Body()
or @Query() param — it reads the TypeScript types and the @ApiProperty examples,
so the "Try it out" panel is pre-filled with a valid snake_case body. Each route
adds @ApiOperation + per-status @ApiResponse decorators on top.
The schema helpers live in src/common/swagger/:
successEnvelope(data)— wraps a route's data schema in{ status, message, data }.messageError(msg | msg[])— the{ status, message }error body (noerrorsmap) for 401/403/404/409/500/etc; multiple possible messages render as themessagefield'senum.validationError({ example })— the keyed 422 body with a route-specific field example.ApiPaginationQuery()— the universalpage/limitquery-param pair.ApiFileUpload({ files, fields })— themultipart/form-databody for upload routes (file fields render as a picker).
Gotcha: because the global prefix is set,
SwaggerModule.createDocumentwould also fold/apiinto every path, and combined with the declared/apiserver you'd get a doubled/api/api/.... The fix iscreateDocument(app, config, { ignoreGlobalPrefix: true }): operation paths stay relative (/applicant/register) and the/apiserver supplies the base.
Complete params & per-endpoint errors (ADR-0037)
A documentation-only sweep made /docs an accurate request/response contract for
every endpoint, fixing two systematic gaps.
1 — the plugin only sees DTO classes. It cannot introspect an inline
@Query('page') p: string or an anonymous @Query() q: { page?; limit? }
type. About forty list endpoints read pagination that way, so their page/limit
(and search/type/cursor) params rendered as nothing in /docs — pagination
was invisible. Now every endpoint declares exactly the params it reads:
ApiPaginationQuery() for page+limit, plus an explicit @ApiQuery for the rest
(reels documents its keyset last_seen_id cursor and limit, deliberately no
page). The single inline @Body got an @ApiBody; DTO-typed params stay
plugin-covered.
2 — one error shape was reused for everything. Previously every error response
(401/403/404/409/422/500) was documented with one shared errorEnvelope whose
example was a { email, password } validation body. But the global
AllExceptionsFilter attaches an errors map only to the 422 body — so the
docs advertised the wrong shape and a wrong message on nearly every non-422 error.
Each error response now reflects what the route actually emits: the real status code
and the exact message from its guards, validation, and service exceptions. The
canonical guard messages are 401 Unauthorized; 403 Account is not active /
Wrong account type / User does not have the right permissions. /
Password reset required. Change your password to continue.. Impossible statuses
were removed; genuinely-thrown-but-undocumented ones were added (service 404s, the
105 MB media 413, the Lexi 429 throttle + 503 taxonomy, the admin master-data
delete 409). The admin master-data write CRUD is documented once in the shared
master-data.swagger.ts bundle (child resources surface the verbatim Laravel
exists: 422 keyed under the parent FK). Two deliberate deviations keep their
shapes: the §45 Stripe webhook (RAW 200/400 bodies, no envelope) and the §27
feedback list (the bare Laravel { data, links, meta } paginator body).
How it's kept honest:
test/openapi.e2e-spec.tsbuilds the real OpenAPI document from the booted app and asserts the invariants document-wide — no 401/403 carries anerrorsmap, every 422 does, no error leaks the old generic example, every error response has a message example, and the previously-broken list endpoints expose their pagination params. A regression in any controller's Swagger decorators fails this test.
Deliberate deviations from legacy (kept on purpose)
The reconciliation is not a blind copy — it keeps the security improvements made earlier, and documents them rather than reverting:
- Rotating refresh tokens +
POST /refreshandrefresh_tokenin the register/login envelope (legacy had opaque, non-rotating Passport tokens). - Anti-enumeration —
send-reset-otpalways 200, login always 401, so the API never reveals which emails exist (legacy leaked existence via 404). logouttakes arefresh_tokenbody to revoke the specific rotating token.- Social providers scoped to
google+linkedin; the other legacy providers return 422/501 until built.
Documented but not built yet (known gaps)
These doc'd endpoints are intentionally not built yet and are tracked in
docs/API-CONTRACT.md: POST /correct-email, register's optional
educations/skills arrays, and the dead §24 chat schema (read receipts / delivery
status / media messages / group chat). They land with their feature modules.
Subpages