# Swagger / OpenAPI Guide

Author: Nguyen Thi Nguyet Que Created on: May 2, 2026 Updated: May 2, 2026 — Migrated to Zod-based
contracts

The project uses **Zod schemas** with `@asteasolutions/zod-to-openapi` to generate type-safe OpenAPI
3.0 specs. All API contracts are defined in `src/contracts/` and the spec is generated by
`scripts/generate/generate-openapi.ts`.

## Commands

| Command                 | Description                                                     |
| ----------------------- | --------------------------------------------------------------- |
| `pnpm swagger:generate` | Generate `storage/swagger/openapi.json` from Zod contracts      |
| `pnpm swagger:diff`     | Compare current spec against baseline (detect breaking changes) |
| `pnpm swagger:check`    | Validate spec structure                                         |
| `pnpm swagger:validate` | Generate then validate in one step                              |
| `pnpm swagger:ci`       | Full CI pipeline: generate + validate + diff                    |

`swagger:check` runs inside `quality:check` and `ci:quality` — CI fails if the spec is broken.

---

## Architecture

```
src/contracts/
  shared.ts          ← ViolationSchema, ApiResponse<T> composer
  auth.schema.ts     ← Zod schemas (request/response types)
  auth.paths.ts      ← registerPath() definitions
  user.schema.ts
  user.paths.ts
  file.schema.ts
  file.paths.ts
  notification.schema.ts
  notification.paths.ts

scripts/
  generate/
    generate-openapi.ts ← Orchestrator that calls all registerPath() functions

storage/swagger/
  openapi.json        ← Generated, committed (Orval uses this)
  openapi.baseline.json ← Baseline for oasdiff breaking change detection
```

**Key principles:**

- Zod schemas define the shape and validation
- `zod-to-openapi` generates the OpenAPI spec automatically
- No JSDoc `@openapi` comments in controllers
- No hand-written `schemas.js` files
- Type safety enforced by `satisfies` keyword in controllers

---

## Adding a new API endpoint

### 1. Define Zod schemas in `src/contracts/<domain>.schema.ts`

```typescript
// src/contracts/product.schema.ts
import { z } from 'zod';
import { ApiResponse } from './shared';

export const ProductSchema = z
	.object({
		id: z.uuid(),
		name: z.string().min(1).max(200),
		price: z.number().positive(),
		created_at: z.iso.datetime(),
	})
	.openapi('Product');

export const CreateProductBodySchema = z
	.object({
		name: z.string().min(1).max(200),
		price: z.number().positive(),
	})
	.openapi('CreateProductBody');

export const ProductResponseSchema = ApiResponse(
	z.object({
		product: ProductSchema,
	}),
).openapi('ProductResponse');

export type Product = z.infer<typeof ProductSchema>;
export type CreateProductBody = z.infer<typeof CreateProductBodySchema>;
export type ProductResponse = z.infer<typeof ProductResponseSchema>;
```

### 2. Register the path in `src/contracts/<domain>.paths.ts`

```typescript
// src/contracts/product.paths.ts
import { type OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import { CreateProductBodySchema, ProductResponseSchema } from './product.schema';

export function registerProductPaths(registry: OpenAPIRegistry) {
	registry.registerPath({
		method: 'post',
		path: '/api/v1/products',
		operationId: 'createProduct',
		tags: ['Products'],
		request: {
			body: { content: { 'application/json': { schema: CreateProductBodySchema } } },
		},
		responses: {
			200: {
				description: 'Product created successfully',
				content: { 'application/json': { schema: ProductResponseSchema } },
			},
		},
	});
	// Add more paths here...
}
```

### 3. Add to `lib/generator/openapi/generate-openapi.ts`

```typescript
import { registerProductPaths } from '../src/contracts/product.paths';

// ... inside the script
registerProductPaths(registry);
```

### 4. Use in controller with `satisfies` for type safety

```typescript
import type { ProductResponse } from '#contracts/product.schema';

// In controller handler
const payload = {
	success: true,
	message: null,
	message_en: null,
	trace_id: requestId,
	timeStamp: new Date().toISOString(),
	violations: null,
	responseData: {
		product: createdProduct,
	},
} satisfies ProductResponse; // ← Compile-time check

return res.json(payload);
```

### 5. Generate and validate

```bash
pnpm swagger:generate
pnpm swagger:check
```

---

## Breaking change detection

The project uses `oasdiff` to detect breaking changes between the current spec and the baseline:

```bash
pnpm swagger:diff
```

**Baseline policy:** When `swagger:diff` fails, the PR must include:

1. FE impacted list — which hooks/types are affected
2. Migration note — what FE needs to do
3. `openapi.baseline.json` update in the same PR
4. PR title with `BREAKING:` prefix

---

## Using orval to generate frontend client code

After `pnpm swagger:validate`:

```typescript
// orval.config.ts (frontend project)
export default defineConfig({
	api: {
		input: 'http://localhost:3001/openapi.json',
		output: {
			mode: 'tags-split',
			target: 'src/api',
			schemas: 'src/api/models',
			client: 'react-query',
		},
	},
});
```

```bash
pnpm orval
```

The FE should run `pnpm orval` in CI and fail if generated changes are uncommitted.

---

## Available contracts

| Domain       | Schema file              | Paths file              |
| ------------ | ------------------------ | ----------------------- |
| Auth         | `auth.schema.ts`         | `auth.paths.ts`         |
| User         | `user.schema.ts`         | `user.paths.ts`         |
| File         | `file.schema.ts`         | `file.paths.ts`         |
| Notification | `notification.schema.ts` | `notification.paths.ts` |
| Shared       | `shared.ts`              | —                       |

---

## Common patterns

### Response envelope

All responses use the `ApiResponse<T>` composer from `shared.ts`:

```typescript
export const ApiResponse = <T extends ZodTypeAny>(data: T) =>
	z.object({
		success: z.boolean(),
		message: z.string().nullable(),
		message_en: z.string().nullable(),
		trace_id: z.string().nullable(),
		timeStamp: z.string(),
		violations: z.array(ViolationSchema).nullable(),
		responseData: data.nullable(),
	});
```

### Validation middleware

Use Zod schemas for request validation:

```typescript
// src/middlewares/validators/product.ts
import { CreateProductBodySchema } from '#contracts/product.schema';
export const validateCreateProduct = zodMiddleware({ body: CreateProductBodySchema });
```

### DTO mapping for security

To prevent leaking internal fields, parse with the schema before returning:

```typescript
const userDto = UserPublicSchema.parse(result.user); // throws if shape invalid
```
