CODEX // oaoisme wiki

§ unswayed-backend

API contract & docs

updated 2026-06-15

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

  1. Everything is under /api. POST /api/applicant/register, GET /api/auth/social/providers, and so on. The lone exception is the root GET / health ping, which stays at / for uptime checks.
  2. Bodies and payloads are snake_case. last_name, password_confirmation, zip_code, company_name, current_password, refresh_token; the user object returns user_type, is_verified, is_completed, profile_photo, created_at. This mirrors the legacy API exactly, so the frontend is a drop-in.
  3. One envelope. Success is always { status: "success", message, data }; failure is always { status: "error", message, errors? }. The errors map appears only on the 422 validation body (keyed by field name); a 401/403/ 404/409/500 carries just { status, message }.
  4. 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 ValidationPipe runs with whitelist: true, which strips any property not declared on the DTO. When the DTOs were camelCase, an incoming snake_case last_name was 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 (no errors map) for 401/403/404/409/500/etc; multiple possible messages render as the message field's enum.
  • validationError({ example }) — the keyed 422 body with a route-specific field example.
  • ApiPaginationQuery() — the universal page/limit query-param pair.
  • ApiFileUpload({ files, fields }) — the multipart/form-data body for upload routes (file fields render as a picker).

Gotcha: because the global prefix is set, SwaggerModule.createDocument would also fold /api into every path, and combined with the declared /api server you'd get a doubled /api/api/.... The fix is createDocument(app, config, { ignoreGlobalPrefix: true }): operation paths stay relative (/applicant/register) and the /api server 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.ts builds the real OpenAPI document from the booted app and asserts the invariants document-wide — no 401/403 carries an errors map, 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 /refresh and refresh_token in the register/login envelope (legacy had opaque, non-rotating Passport tokens).
  • Anti-enumerationsend-reset-otp always 200, login always 401, so the API never reveals which emails exist (legacy leaked existence via 404).
  • logout takes a refresh_token body 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