feat(portal-bff): openapi spec + scalar api reference UI (dev-only) (#143)
## Summary Adds an OpenAPI 3 spec + a [Scalar API Reference](https://scalar.com/) UI to `portal-bff`, dev-only. The BFF previously had no way to *see* its HTTP surface short of grepping for `@Get` / `@Post`; this PR generates the spec from the existing Nest controllers via [`@nestjs/swagger`](https://docs.nestjs.com/openapi/introduction) and renders it through Scalar — a modern alternative to the classic Swagger UI (single-page, fast, dark-mode native, better typography). ## What lands ### Two new dev-only routes | Route | What it serves | | --- | --- | | `GET /api/openapi.json` | Raw OpenAPI 3 document. External tools (Bruno / Insomnia / Postman) import from here. | | `GET /api/docs` | Scalar API Reference HTML page. Loads the JSON spec at render time and renders the full endpoint catalogue with a "Try it" panel. | Both routes are gated behind `process.env.NODE_ENV !== 'production'` in [`setupOpenApi`](apps/portal-bff/src/openapi/openapi.ts) — production deployments don't need the docs surface, and publishing it would hand an attacker a curated map of every authenticated endpoint + every DTO shape. If a future ops use-case wants the spec in prod (internal gateway, contract testing), the gate is one line away from an opt-in `OPENAPI_PUBLISH=true` env knob. ### Core implementation — [`apps/portal-bff/src/openapi/openapi.ts`](apps/portal-bff/src/openapi/openapi.ts) Two exported helpers: - **`buildOpenApiDocument(app)`** — wraps Nest's `DocumentBuilder` + `SwaggerModule.createDocument`. Sets title, description (mentions the CSRF caveat — see below), version, and registers **two** cookie security schemes: - `portal_session` for the user-portal surface ([ADR-0009](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md)). - `portal_admin_session` for the admin-portal surface ([ADR-0020](docs/decisions/0020-portal-admin-app.md)). No `@ApiBearerAuth` is declared — the BFF never exposes a bearer-auth surface (SPA never holds tokens per ADR-0009; downstream OBO tokens are server-side only per ADR-0014). - **`setupOpenApi(app, globalPrefix)`** — short-circuits in production, otherwise binds the two routes via the Express adapter directly (`app.getHttpAdapter().get(...)` and `app.use(...)`). The OpenAPI JSON is a static asset and Scalar is a vanilla Express middleware — wrapping either in a Nest controller would add zero value and an extra layer of indirection. Wired into bootstrap at [`apps/portal-bff/src/main.ts:220`](apps/portal-bff/src/main.ts#L220), immediately after the JWKS endpoint mount and before `app.listen()`. ### Controllers decorated with `@ApiTags` / `@ApiOperation` / `@ApiCookieAuth` Annotations are cosmetic but make the spec actually browsable. Tag taxonomy: | Controller | Tag | Security | | --- | --- | --- | | [`AppController`](apps/portal-bff/src/app/app.controller.ts) | `app (scaffolding)` | — | | [`HealthController`](apps/portal-bff/src/health/health.controller.ts) | `health` | — | | [`AuthController`](apps/portal-bff/src/auth/auth.controller.ts) | `auth (user portal)` | `portal_session` on `/me` + `/logout` | | [`AdminAuthController`](apps/portal-bff/src/admin/admin-auth.controller.ts) | `auth (admin portal)` | `portal_admin_session` on `/me` + `/logout` | | [`AdminController`](apps/portal-bff/src/admin/admin.controller.ts) | `admin (self-test)` | class-level `portal_admin_session` | | [`AdminAuditController`](apps/portal-bff/src/admin/admin-audit.controller.ts) | `admin (audit log)` | class-level `portal_admin_session` | | [`AdminUsersController`](apps/portal-bff/src/admin/admin-users.controller.ts) | `admin (user directory)` | class-level `portal_admin_session` | `@ApiOperation({ summary: … })` added on every route — populates the one-line description Scalar shows in its left-rail TOC. ### Deps + Jest - `@nestjs/swagger ^11` (matches the Nest 11 major already pinned) and `@scalar/nestjs-api-reference` added to the workspace root. - [`jest.config.cts`](apps/portal-bff/jest.config.cts) — widened `transformIgnorePatterns` from `/node_modules/(?!.*jose)/` to `/node_modules/(?!.*(jose|@scalar/))/`. `@scalar/client-side-rendering` (a transitive dep) ships ESM-only; without this widening the spec suite fails to load the module under ts-jest. ## Notes for the reviewer - **Why two cookie schemes rather than one?** Scalar renders a per-endpoint lock icon driven by the security scheme name. Splitting `portal_session` / `portal_admin_session` keeps the indicator semantically truthful — `/api/auth/me` and `/api/admin/auth/me` look identical otherwise. - **CSRF caveat.** Mutating routes (`POST` / `PUT` / `PATCH` / `DELETE`) require `X-CSRF-Token` per [ADR-0009](docs/decisions/0009-auth-flow-oidc-pkce-msal-node.md). The header must be set manually in Scalar's "Try it" panel to the value of the `portal_csrf` cookie when exercising those routes. The spec description mentions it; auto-injecting the header from the cookie is a future polish. - **No ADR for this.** `@nestjs/swagger` is the framework's own first-party tooling; Scalar is a thin UI on top of a standard OpenAPI 3 document. Both replaceable without touching the controllers (the `@Api*` annotations are spec-standard). Dev-only, no prod surface — doesn't cross any of the bars that warrant an ADR per [CLAUDE.md](CLAUDE.md). - **Express-layer routing.** Same pattern as the JWKS endpoint (#139): the OpenAPI JSON is a static asset and Scalar a vanilla Express handler, so wiring through Nest's router adds no value. ## Test plan - [x] **5 new specs** in [`apps/portal-bff/src/openapi/openapi.spec.ts`](apps/portal-bff/src/openapi/openapi.spec.ts) — document shape (openapi version, title, version), both cookie schemes declared, smoke controller route captured in `paths`, production short-circuit (no routes mounted, no `app.use` called), dev mount (JSON at `/api/openapi.json` via the HTTP adapter, Scalar UI at `/api/docs` via `app.use`). - [x] `pnpm nx test portal-bff` — **396 specs pass** (was 391). - [x] `pnpm exec nx affected -t format:check lint test build --base=origin/main` — clean. - [x] Manual dev smoke: `pnpm nx serve portal-bff`, `curl /api/openapi.json | jq .info` returns title + version, open `/api/docs` in a browser, every controller's routes visible under their tag, lock icons match the cookie scheme on guarded routes. ## What's next — light follow-ups Not blocking this PR; mentioned so they're not lost: - Auto-inject the `X-CSRF-Token` header in Scalar from the `portal_csrf` cookie (custom Scalar config preset). - Promote `@ApiOperation` summaries with multi-line `description`s on the more involved routes (`/api/admin/audit`, `/api/admin/users`). - Annotate DTOs with `@ApiProperty` once the first contract-test consumer arrives — Nest can also pick them up automatically with the `@nestjs/swagger` ts-plugin if we wire it into the Nx build target. Deferred until the spec is consumed by tooling that benefits from the precision. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #143
This commit was merged in pull request #143.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import {
|
||||
ADMIN_SESSION_COOKIE_AUTH,
|
||||
USER_SESSION_COOKIE_AUTH,
|
||||
buildOpenApiDocument,
|
||||
setupOpenApi,
|
||||
} from './openapi';
|
||||
|
||||
// Minimal controller to feed SwaggerModule.createDocument — the
|
||||
// document builder needs a non-empty route table to produce a spec
|
||||
// it considers valid.
|
||||
@Controller('smoke')
|
||||
class SmokeController {
|
||||
@Get()
|
||||
hello(): { ok: boolean } {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
async function bootApp(): Promise<INestApplication> {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [SmokeController],
|
||||
}).compile();
|
||||
return moduleRef.createNestApplication({ logger: false });
|
||||
}
|
||||
|
||||
describe('buildOpenApiDocument', () => {
|
||||
it('returns a well-shaped OpenAPI 3 document with title + version', async () => {
|
||||
const app = await bootApp();
|
||||
const doc = buildOpenApiDocument(app);
|
||||
expect(doc.openapi).toMatch(/^3\./);
|
||||
expect(doc.info.title).toBe('APF Portal BFF API');
|
||||
expect(doc.info.version).toBe('0.0.0');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('declares both cookie-auth schemes (user + admin)', async () => {
|
||||
const app = await bootApp();
|
||||
const doc = buildOpenApiDocument(app);
|
||||
const schemes = doc.components?.securitySchemes;
|
||||
expect(schemes?.[USER_SESSION_COOKIE_AUTH]).toMatchObject({
|
||||
type: 'apiKey',
|
||||
in: 'cookie',
|
||||
name: USER_SESSION_COOKIE_AUTH,
|
||||
});
|
||||
expect(schemes?.[ADMIN_SESSION_COOKIE_AUTH]).toMatchObject({
|
||||
type: 'apiKey',
|
||||
in: 'cookie',
|
||||
name: ADMIN_SESSION_COOKIE_AUTH,
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('captures the smoke controller route in the paths map', async () => {
|
||||
const app = await bootApp();
|
||||
const doc = buildOpenApiDocument(app);
|
||||
expect(doc.paths['/smoke']).toBeDefined();
|
||||
expect(doc.paths['/smoke']?.get).toBeDefined();
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupOpenApi', () => {
|
||||
const originalNodeEnv = process.env['NODE_ENV'];
|
||||
|
||||
afterEach(() => {
|
||||
if (originalNodeEnv === undefined) {
|
||||
delete process.env['NODE_ENV'];
|
||||
} else {
|
||||
process.env['NODE_ENV'] = originalNodeEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('short-circuits in production — no routes mounted, no spec built', async () => {
|
||||
process.env['NODE_ENV'] = 'production';
|
||||
const httpAdapter = { get: jest.fn() };
|
||||
const app = {
|
||||
getHttpAdapter: jest.fn().mockReturnValue(httpAdapter),
|
||||
use: jest.fn(),
|
||||
} as unknown as INestApplication;
|
||||
setupOpenApi(app, 'api');
|
||||
expect(httpAdapter.get).not.toHaveBeenCalled();
|
||||
expect(app.use).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mounts the JSON spec + Scalar UI in dev', async () => {
|
||||
process.env['NODE_ENV'] = 'development';
|
||||
const app = await bootApp();
|
||||
const httpGetSpy = jest.spyOn(app.getHttpAdapter(), 'get');
|
||||
const useSpy = jest.spyOn(app, 'use');
|
||||
setupOpenApi(app, 'api');
|
||||
// JSON spec at /api/openapi.json (via the HTTP adapter directly,
|
||||
// bypassing Nest's router).
|
||||
expect(httpGetSpy).toHaveBeenCalledWith('/api/openapi.json', expect.any(Function));
|
||||
// Scalar UI mounted under /api/docs.
|
||||
expect(useSpy).toHaveBeenCalledWith('/api/docs', expect.any(Function));
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { DocumentBuilder, SwaggerModule, type OpenAPIObject } from '@nestjs/swagger';
|
||||
import { apiReference } from '@scalar/nestjs-api-reference';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* Cookie names declared as security schemes. Match the production
|
||||
* cookie names — Scalar's "Try it" panel offers to send them and
|
||||
* the SPA-side cookies already flow on same-origin calls.
|
||||
*/
|
||||
export const USER_SESSION_COOKIE_AUTH = 'portal_session';
|
||||
export const ADMIN_SESSION_COOKIE_AUTH = 'portal_admin_session';
|
||||
|
||||
/**
|
||||
* Builds the OpenAPI document from the running Nest application by
|
||||
* traversing the controller/DTO graph (Nest's standard
|
||||
* `SwaggerModule.createDocument`). Two cookie-auth schemes are
|
||||
* registered up front so endpoints can reference them via
|
||||
* `@ApiCookieAuth(name)`:
|
||||
*
|
||||
* - `portal_session` — user-portal surface (ADR-0009).
|
||||
* - `portal_admin_session` — admin-portal surface (ADR-0020).
|
||||
*
|
||||
* No `@ApiBearerAuth` — the BFF never exposes a bearer-auth surface
|
||||
* (per ADR-0009 the SPA never holds tokens; downstream OBO tokens
|
||||
* are server-side only per ADR-0014).
|
||||
*/
|
||||
export function buildOpenApiDocument(app: INestApplication): OpenAPIObject {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('APF Portal BFF API')
|
||||
.setDescription(
|
||||
'OpenAPI specification of the portal-bff HTTP surface. Auto-generated from ' +
|
||||
'Nest controllers + class-validator DTOs. Mutating routes (POST / PUT / PATCH / ' +
|
||||
'DELETE) require the `X-CSRF-Token` header per ADR-0009 §"Double-submit CSRF" — ' +
|
||||
'set it manually in Scalar to the value of the `portal_csrf` cookie when ' +
|
||||
'exercising those routes from this page.',
|
||||
)
|
||||
.setVersion('0.0.0')
|
||||
.addCookieAuth(
|
||||
USER_SESSION_COOKIE_AUTH,
|
||||
{ type: 'apiKey', in: 'cookie', name: USER_SESSION_COOKIE_AUTH },
|
||||
USER_SESSION_COOKIE_AUTH,
|
||||
)
|
||||
.addCookieAuth(
|
||||
ADMIN_SESSION_COOKIE_AUTH,
|
||||
{ type: 'apiKey', in: 'cookie', name: ADMIN_SESSION_COOKIE_AUTH },
|
||||
ADMIN_SESSION_COOKIE_AUTH,
|
||||
)
|
||||
.build();
|
||||
return SwaggerModule.createDocument(app, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the OpenAPI spec + the Scalar API Reference UI on the BFF.
|
||||
*
|
||||
* Routes:
|
||||
* - `GET /<globalPrefix>/openapi.json` — the raw OpenAPI 3 spec.
|
||||
* External tools (Bruno, Insomnia, Postman) import from here.
|
||||
* - `GET /<globalPrefix>/docs` — Scalar API Reference UI page,
|
||||
* loads the spec from the JSON endpoint at render time.
|
||||
*
|
||||
* **Dev-only by default.** Production deployments do not need a
|
||||
* docs surface served from the BFF itself — and exposing it would
|
||||
* give an attacker a curated map of every authenticated endpoint
|
||||
* + every DTO shape. Skipped entirely when `NODE_ENV === 'production'`.
|
||||
* If a future ops use-case wants the spec in prod (e.g. an internal
|
||||
* gateway), we can add an explicit `OPENAPI_PUBLISH=true` env knob
|
||||
* — opt-in, not default-on.
|
||||
*/
|
||||
export function setupOpenApi(app: INestApplication, globalPrefix: string): void {
|
||||
if (process.env['NODE_ENV'] === 'production') {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = buildOpenApiDocument(app);
|
||||
|
||||
// Serve the JSON spec via a plain Express handler. Bypasses Nest's
|
||||
// router because the URL is a static asset — no DTO, no guard,
|
||||
// no middleware interaction worth threading through.
|
||||
const httpAdapter = app.getHttpAdapter();
|
||||
httpAdapter.get(`/${globalPrefix}/openapi.json`, (_req: Request, res: Response) => {
|
||||
res.json(document);
|
||||
});
|
||||
|
||||
// Scalar API Reference UI. Same Express-level mount — its handler
|
||||
// emits a static HTML page that fetches the spec from the URL
|
||||
// above at render time. Lock the operationTitleSource to `path`
|
||||
// so endpoints sort + display by their URL (more useful for an
|
||||
// admin-API surface where the path encodes hierarchy).
|
||||
app.use(
|
||||
`/${globalPrefix}/docs`,
|
||||
apiReference({
|
||||
url: `/${globalPrefix}/openapi.json`,
|
||||
operationTitleSource: 'path',
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user