§ 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? }. Validation failures are HTTP 422 witherrorskeyed by field name. - 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 Authentication and Social Auth tags, and bearer auth.
Each controller route carries @ApiOperation + @ApiResponse decorators that wrap
a shared successEnvelope()/errorEnvelope schema helper
(src/common/swagger/), and every DTO field has an @ApiProperty example — so the
"Try it out" panel is pre-filled with a valid snake_case body.
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 — exactly mirroring the reference doc's structure (base URL + relative paths).
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)
The reconciliation covered the implemented surface (auth + social). These doc'd
endpoints are intentionally not built yet and are tracked in docs/API-CONTRACT.md:
POST /correct-email, GET /employerProfile/{id}, register's optional
educations/skills arrays, and every non-auth section (master data, blog, posts,
jobs, chat, settings, subscriptions, …). They land with their feature modules.