nestjs-openapi-parser 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +213 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +54 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/defaults.d.ts +5 -0
- package/dist/config/defaults.js +14 -0
- package/dist/config/defaults.js.map +1 -0
- package/dist/config/index.d.ts +4 -0
- package/dist/config/index.js +24 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/loader.d.ts +11 -0
- package/dist/config/loader.js +160 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/types.d.ts +139 -0
- package/dist/config/types.js +8 -0
- package/dist/config/types.js.map +1 -0
- package/dist/lib.d.ts +12 -0
- package/dist/lib.js +22 -0
- package/dist/lib.js.map +1 -0
- package/dist/parser/ast-index.d.ts +38 -0
- package/dist/parser/ast-index.js +124 -0
- package/dist/parser/ast-index.js.map +1 -0
- package/dist/parser/index.d.ts +20 -0
- package/dist/parser/index.js +95 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/parser/path-builder.d.ts +87 -0
- package/dist/parser/path-builder.js +464 -0
- package/dist/parser/path-builder.js.map +1 -0
- package/dist/parser/schema-builder.d.ts +70 -0
- package/dist/parser/schema-builder.js +355 -0
- package/dist/parser/schema-builder.js.map +1 -0
- package/dist/parser/tags.d.ts +61 -0
- package/dist/parser/tags.js +163 -0
- package/dist/parser/tags.js.map +1 -0
- package/dist/types/openapi.d.ts +42 -0
- package/dist/types/openapi.js +3 -0
- package/dist/types/openapi.js.map +1 -0
- package/dist/validate.d.ts +13 -0
- package/dist/validate.js +30 -0
- package/dist/validate.js.map +1 -0
- package/docs/configuration.md +195 -0
- package/docs/library-usage.md +40 -0
- package/docs/parser.md +148 -0
- package/package.json +54 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { OpenApiDocument } from './types/openapi';
|
|
2
|
+
export interface ValidationResult {
|
|
3
|
+
valid: boolean;
|
|
4
|
+
/** Flattened, human-readable validation messages (empty when `valid`). */
|
|
5
|
+
errors: string[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Validate a generated document against the OpenAPI 3.x JSON Schema using
|
|
9
|
+
* `@seriousme/openapi-schema-validator`. Returns validity plus flattened error
|
|
10
|
+
* messages. The validator dependency is imported lazily, so callers that never
|
|
11
|
+
* validate don't load it.
|
|
12
|
+
*/
|
|
13
|
+
export declare function validateDocument(document: OpenApiDocument): Promise<ValidationResult>;
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateDocument = validateDocument;
|
|
4
|
+
// `@seriousme/openapi-schema-validator` is ESM-only. Under `module: commonjs`,
|
|
5
|
+
// `tsc` would downlevel a plain `import()` to `require()`, which throws on an
|
|
6
|
+
// ESM-only package (Node < 22). Routing the import through `Function` keeps a
|
|
7
|
+
// *native* dynamic import so it loads on every supported Node version.
|
|
8
|
+
const importEsm = new Function('specifier', 'return import(specifier)');
|
|
9
|
+
/**
|
|
10
|
+
* Validate a generated document against the OpenAPI 3.x JSON Schema using
|
|
11
|
+
* `@seriousme/openapi-schema-validator`. Returns validity plus flattened error
|
|
12
|
+
* messages. The validator dependency is imported lazily, so callers that never
|
|
13
|
+
* validate don't load it.
|
|
14
|
+
*/
|
|
15
|
+
async function validateDocument(document) {
|
|
16
|
+
const { Validator } = (await importEsm('@seriousme/openapi-schema-validator'));
|
|
17
|
+
const result = await new Validator().validate(document);
|
|
18
|
+
return result.valid
|
|
19
|
+
? { valid: true, errors: [] }
|
|
20
|
+
: { valid: false, errors: formatErrors(result) };
|
|
21
|
+
}
|
|
22
|
+
function formatErrors(result) {
|
|
23
|
+
const { errors } = result;
|
|
24
|
+
if (!errors)
|
|
25
|
+
return ['Document failed OpenAPI schema validation.'];
|
|
26
|
+
if (typeof errors === 'string')
|
|
27
|
+
return [errors];
|
|
28
|
+
return errors.map((e) => `${e.instancePath || '(root)'}: ${e.message ?? 'invalid'}`);
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=validate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate.js","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":";;AAiCA,4CAMC;AA/BD,+EAA+E;AAC/E,8EAA8E;AAC9E,8EAA8E;AAC9E,uEAAuE;AACvE,MAAM,SAAS,GAAG,IAAI,QAAQ,CAAC,WAAW,EAAE,0BAA0B,CAEjD,CAAC;AAatB;;;;;GAKG;AACI,KAAK,UAAU,gBAAgB,CAAC,QAAyB;IAC9D,MAAM,EAAE,SAAS,EAAE,GAAG,CAAC,MAAM,SAAS,CAAC,qCAAqC,CAAC,CAAoB,CAAC;IAClG,MAAM,MAAM,GAAG,MAAM,IAAI,SAAS,EAAE,CAAC,QAAQ,CAAC,QAA8C,CAAC,CAAC;IAC9F,OAAO,MAAM,CAAC,KAAK;QACjB,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE;QAC7B,CAAC,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,YAAY,CAAC,MAAM,CAAC,EAAE,CAAC;AACrD,CAAC;AAED,SAAS,YAAY,CAAC,MAAuB;IAC3C,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC;IAC1B,IAAI,CAAC,MAAM;QAAE,OAAO,CAAC,4CAA4C,CAAC,CAAC;IACnE,IAAI,OAAO,MAAM,KAAK,QAAQ;QAAE,OAAO,CAAC,MAAM,CAAC,CAAC;IAChD,OAAO,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,YAAY,IAAI,QAAQ,KAAK,CAAC,CAAC,OAAO,IAAI,SAAS,EAAE,CAAC,CAAC;AACvF,CAAC"}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
A complete `nestparser.config.ts`:
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { defineConfig } from 'nestjs-openapi-parser';
|
|
7
|
+
|
|
8
|
+
export default defineConfig({
|
|
9
|
+
openapi: {
|
|
10
|
+
title: 'My API',
|
|
11
|
+
version: '1.0.0',
|
|
12
|
+
description: 'Optional API description.',
|
|
13
|
+
servers: [{ url: 'http://localhost:3000' }],
|
|
14
|
+
securitySchemes: {
|
|
15
|
+
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
|
|
16
|
+
},
|
|
17
|
+
info: {
|
|
18
|
+
// Anything extra spliced onto `info`.
|
|
19
|
+
contact: { name: 'API team', email: 'api@example.com' },
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
project: {
|
|
24
|
+
tsConfigFilePath: 'tsconfig.json',
|
|
25
|
+
rootDir: 'src',
|
|
26
|
+
globalPrefix: 'v1',
|
|
27
|
+
excludeSuffixes: ['.spec.ts', '.test.ts', '.d.ts'],
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
conventions: {
|
|
31
|
+
excludeDecorator: 'Exclude', // class-transformer @Exclude
|
|
32
|
+
optionalDecorator: 'IsOptional', // class-validator @IsOptional
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
scopes: [], // see "Documentation variants" below
|
|
36
|
+
additionalModels: [], // force-include unreachable models — see docs/parser.md
|
|
37
|
+
|
|
38
|
+
hooks: {
|
|
39
|
+
// see "Hooks"
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Documentation variants — `@Scope`
|
|
45
|
+
|
|
46
|
+
Tag controllers, methods, models or fields with `@Scope` to make them appear in the spec only when the build is configured for a matching scope. Untagged items are always emitted.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
/**
|
|
50
|
+
* Admin-only operations.
|
|
51
|
+
*
|
|
52
|
+
* @Scope admin
|
|
53
|
+
*/
|
|
54
|
+
@Controller('admin')
|
|
55
|
+
export class AdminController {
|
|
56
|
+
/* ... */
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class User {
|
|
60
|
+
email!: string;
|
|
61
|
+
|
|
62
|
+
/** @Scope internal */
|
|
63
|
+
lastLoginIp?: string;
|
|
64
|
+
|
|
65
|
+
/** @Scope admin */
|
|
66
|
+
adminMeta?: AdminMeta;
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Activate scopes** via the CLI or the config:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
nestparser --scope internal,admin # comma-separated
|
|
74
|
+
nestparser --scope internal --scope admin # repeatable
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
defineConfig({
|
|
79
|
+
// ...
|
|
80
|
+
scopes: ['internal', 'admin'],
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- **Syntax.** The tag must be on its own JSDoc line. Inline mentions like `Comment with @Scope foo` are treated as description text. Multiple values can be comma-separated (`@Scope internal,admin`) or on separate lines.
|
|
85
|
+
- **Precedence.** The CLI `--scope` flag overrides `config.scopes` when present.
|
|
86
|
+
- **Soundness — no dangling refs.** If a visible item references a class whose scope wouldn't be emitted, the build fails fast with an error naming the offending class. Example: a `@Scope internal` method returns `Promise<AdminMeta>` where `AdminMeta` is `@Scope admin`; building with `--scope internal` alone throws. Building with `--scope internal,admin` succeeds.
|
|
87
|
+
|
|
88
|
+
## Scoped description fragments
|
|
89
|
+
|
|
90
|
+
Inside any JSDoc body (controller, method, model, field), you can mark text fragments that only appear under specific scopes:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
/**
|
|
94
|
+
* Generic description that always appears.
|
|
95
|
+
*
|
|
96
|
+
* <internal>
|
|
97
|
+
* Extra context that only shows when scope=internal is active.
|
|
98
|
+
* </internal>
|
|
99
|
+
*
|
|
100
|
+
* <admin>Inline fragments work too.</admin>
|
|
101
|
+
*/
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
- The item itself does **not** need a `@Scope` — fragments are independent of item visibility.
|
|
105
|
+
- Multiple fragments can appear in the same JSDoc; **nesting is not allowed**.
|
|
106
|
+
- Visibility follows the same rule as item-level `@Scope`: untagged text always appears; `<X>…</X>` appears only when `X` is in the active scopes; no active scope → all fragments are dropped.
|
|
107
|
+
- **Only known scope names are treated as fragments.** `X` is a fragment delimiter only when it's a scope declared somewhere in the project via `@Scope` (on any class, method, or property) or passed as an active scope. Any other angle-bracket text — generics in prose (`Array<string>`), placeholders (`<id>`, `<token>`), inline HTML (`<b>…</b>`) — is left **verbatim** and never triggers an error.
|
|
108
|
+
- Syntax errors (nesting, mismatched close, unclosed open) **throw** with the path of the offending item — but only for genuine scope fragments. A stray `<id>` in prose is not a scope, so it's ignored, not an error.
|
|
109
|
+
|
|
110
|
+
Known limitation: a fragment scope used **only** in `<X>…</X>` blocks — never as a `@Scope` tag and never activated — won't be in the project's scope vocabulary, so its text passes through as literal prose instead of being hidden. Declare the scope with a `@Scope` tag (or activate it) to make such fragments filterable.
|
|
111
|
+
|
|
112
|
+
## Hooks (project-specific glue)
|
|
113
|
+
|
|
114
|
+
Default behavior emits "vanilla NestJS" output: the method return type is the response body, every registered security scheme applies. Four hooks let you customize:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
defineConfig({
|
|
118
|
+
// ...
|
|
119
|
+
hooks: {
|
|
120
|
+
buildResponseSchema: (ctx) => {
|
|
121
|
+
/* ... */
|
|
122
|
+
},
|
|
123
|
+
resolveSecurity: (ctx) => {
|
|
124
|
+
/* ... */
|
|
125
|
+
},
|
|
126
|
+
controllerTag: (clazz) => {
|
|
127
|
+
/* ... */
|
|
128
|
+
},
|
|
129
|
+
endpointSummary: (ctx) => {
|
|
130
|
+
/* ... */
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### `buildResponseSchema(ctx)`
|
|
137
|
+
|
|
138
|
+
Wrap responses in a project-specific envelope (e.g. `{ success, data }`) or special-case wrapper types (e.g. `PaginatedResponse<T>`).
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
buildResponseSchema: ({ returnType, returnTypeName, defaultSchema }) => {
|
|
142
|
+
if (returnTypeName === 'PaginatedResponse') {
|
|
143
|
+
return {
|
|
144
|
+
type: 'object',
|
|
145
|
+
properties: {
|
|
146
|
+
success: { type: 'boolean' },
|
|
147
|
+
data: { type: 'array', items: { type: 'object' } },
|
|
148
|
+
pagination: { type: 'object', properties: { total: { type: 'integer' } } },
|
|
149
|
+
},
|
|
150
|
+
required: ['success', 'data', 'pagination'],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: { success: { type: 'boolean' }, data: defaultSchema() },
|
|
156
|
+
required: ['success', 'data'],
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
`ctx.defaultSchema()` lazily computes the bare return-type schema — calling it registers a `$ref` if the return type is a class. Don't call it if you're replacing the body entirely (otherwise you'll pull schemas you don't reference).
|
|
162
|
+
|
|
163
|
+
### `resolveSecurity(ctx)`
|
|
164
|
+
|
|
165
|
+
Read your own auth decorator (e.g. `@Public()`, `@Auth(...)`) and produce security requirements.
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
resolveSecurity: ({ controller, method }) => {
|
|
169
|
+
const isPublic = !!method.getDecorator('Public') || !!controller.getDecorator('Public');
|
|
170
|
+
return isPublic ? [] : [{ bearerAuth: [] }];
|
|
171
|
+
};
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Return:
|
|
175
|
+
|
|
176
|
+
- `[]` — endpoint is public (emits `security: []`)
|
|
177
|
+
- `[{ scheme: [] }, ...]` — explicit requirements
|
|
178
|
+
- `undefined` — fall back to the default (every registered scheme applies)
|
|
179
|
+
|
|
180
|
+
### `controllerTag(class)`
|
|
181
|
+
|
|
182
|
+
Override the default tag derivation:
|
|
183
|
+
|
|
184
|
+
- `controllerTag` — default strips the `Controller` suffix and splits PascalCase boundaries with spaces (`UserAuthController` → `"User Auth"`). For one-off overrides, prefer the `@Tag <name>` JSDoc tag on the controller (or method) — no hook needed.
|
|
185
|
+
|
|
186
|
+
### `endpointSummary(ctx)`
|
|
187
|
+
|
|
188
|
+
Build the `operation.summary` for each endpoint. `ctx` provides `{ controller, method, httpMethod, defaultSummary }` (`defaultSummary` is the humanized method name). Return a string to use it, or `null`/`undefined` to keep `defaultSummary`. A method-level `@Name <text>` JSDoc tag overrides both the hook and the default.
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
endpointSummary: ({ httpMethod, defaultSummary }) =>
|
|
192
|
+
`${httpMethod.toUpperCase()} — ${defaultSummary}`;
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The test fixture's config at [`tests/fixtures/example-app/nestparser.config.ts`](../tests/fixtures/example-app/nestparser.config.ts) demonstrates a working setup (`{ success, message, data }` envelope, `PaginatedResponse<T>` special-casing, `@Public()`-based security).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Library usage
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import {
|
|
5
|
+
parseNestProject,
|
|
6
|
+
loadConfig,
|
|
7
|
+
defineConfig,
|
|
8
|
+
validateDocument,
|
|
9
|
+
filterScopedComments,
|
|
10
|
+
getScopes,
|
|
11
|
+
getTags,
|
|
12
|
+
isVisible,
|
|
13
|
+
parseScopeList,
|
|
14
|
+
} from 'nestjs-openapi-parser';
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
NestParserConfig,
|
|
18
|
+
NestParserHooks,
|
|
19
|
+
ResponseSchemaContext,
|
|
20
|
+
SecurityContext,
|
|
21
|
+
OpenApiDocument,
|
|
22
|
+
OpenApiSchema,
|
|
23
|
+
} from 'nestjs-openapi-parser';
|
|
24
|
+
|
|
25
|
+
// Auto-discover + load nestparser.config.* from disk
|
|
26
|
+
const { config, filePath } = await loadConfig({ projectRoot });
|
|
27
|
+
|
|
28
|
+
// Or build the config inline
|
|
29
|
+
const inline = defineConfig({ openapi: { title: 'X', version: '1.0' } });
|
|
30
|
+
|
|
31
|
+
// Generate the document — async, and self-validates (throws on an invalid spec)
|
|
32
|
+
const doc: OpenApiDocument = await parseNestProject({ projectRoot, config });
|
|
33
|
+
|
|
34
|
+
// Or validate any document yourself against the OpenAPI schema
|
|
35
|
+
const { valid, errors } = await validateDocument(doc);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Public exports live in [`src/lib.ts`](../src/lib.ts): `parseNestProject`, `loadConfig`, `defineConfig`, the scope helpers (`getTags`, `getScopes`, `isVisible`, `filterScopedComments`, `parseScopeList`), and the relevant TypeScript types.
|
|
39
|
+
|
|
40
|
+
The internal builders (`AstIndex`, `SchemaBuilder`, `PathBuilder`) are also exported if you need fine-grained control, but the stable surface is `parseNestProject` + `loadConfig`.
|
package/docs/parser.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Parser
|
|
2
|
+
|
|
3
|
+
The parser walks `<projectRoot>/<rootDir>` (default `src/`), indexes every class and enum it sees, then emits paths and schemas from controllers and the types they reference.
|
|
4
|
+
|
|
5
|
+
**Deterministic output.** The source tree is walked in name-sorted order (not raw `fs.readdirSync` order, which is filesystem-dependent), so the order of paths, tags and schemas is identical on every machine — the generated JSON is safe to commit and diff in CI. Fields inside a model keep their **source-declaration order**, with inherited fields first (base class → subclass) when a class `extends` another.
|
|
6
|
+
|
|
7
|
+
## Routes
|
|
8
|
+
|
|
9
|
+
| Source | Becomes |
|
|
10
|
+
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
11
|
+
| `@Controller('users')` | base path + default tag derived from the class name (see below) |
|
|
12
|
+
| `@Get/@Post/@Put/@Delete/@Patch('path')` | OpenAPI operation under `paths[fullPath][httpMethod]` |
|
|
13
|
+
| `@Get(['a', 'b'])` / `@Controller(['x', 'y'])` arrays | one operation per path (full prefix × route cross-product), unique operationIds |
|
|
14
|
+
| `:id` route placeholders | rewritten to `{id}` in the OpenAPI path |
|
|
15
|
+
| `:id?` optional param | split into two paths — without the segment and with `{id}` (params are always required) |
|
|
16
|
+
| `:id(\d+)` regex / `*` wildcard / `:x+` modifier | **route skipped** with a warning — no faithful OpenAPI representation |
|
|
17
|
+
| Method's JSDoc | `operation.description` |
|
|
18
|
+
| Controller class JSDoc | `tags[].description` |
|
|
19
|
+
| Method name → `operation.summary` | humanized method name by default |
|
|
20
|
+
| `@Tag <name>` JSDoc tag (controller or method) | overrides the derived tag for that controller / operation |
|
|
21
|
+
| `@Scope <name>` JSDoc tag (controller or method) | emitted only when that scope is active — see [Configuration](configuration.md#documentation-variants--scope) |
|
|
22
|
+
| `@Name <text>` JSDoc tag (method) | overrides the operation `summary` |
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
/**
|
|
26
|
+
* @Tag System Health
|
|
27
|
+
*/
|
|
28
|
+
@Controller('health')
|
|
29
|
+
export class HealthController {
|
|
30
|
+
@Get()
|
|
31
|
+
/** @Tag Diagnostics */
|
|
32
|
+
ping() {
|
|
33
|
+
/* ... */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Every tag in play is also declared in the document's root `tags[]`. Controller tags come first (with the description from the class JSDoc, first controller winning on a shared name), followed by any method-level `@Tag` name no controller already declared. Those method-introduced tags carry no description — a method's JSDoc is its `operation.description`, not a tag description — but they're still declared so the operation's tag isn't dangling and tools order/group it like any other.
|
|
39
|
+
|
|
40
|
+
**Operation summary.** Every operation gets a `summary`. By default it's the **method name humanized** — camelCase/PascalCase split into words with the first letter capitalized (`findOne` → `"Find One"`, `remove` → `"Remove"`). Override it per-endpoint with a `@Name <text>` JSDoc tag on the method (single line, like `@Tag`), or globally with the [`endpointSummary`](configuration.md#hooks-project-specific-glue) hook. Precedence: **`@Name` > `endpointSummary` hook > default**; the hook returning `null`/`undefined` falls back to the default.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
@Controller('posts')
|
|
44
|
+
export class PostsController {
|
|
45
|
+
/** @Name Publish a post */
|
|
46
|
+
@Post()
|
|
47
|
+
create() {
|
|
48
|
+
// summary: "Publish a post"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@Get(':id')
|
|
52
|
+
findOne() {
|
|
53
|
+
// summary: "Publish a post"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
HTTP-method decorators (and `@Controller`, `@Body`, `@Query`, `@Param`, `@Headers`) are matched by **local identifier name**. Aliased imports like `import { Post as HttpPost }` won't be detected.
|
|
59
|
+
|
|
60
|
+
## Parameters & request body
|
|
61
|
+
|
|
62
|
+
| Source | Becomes |
|
|
63
|
+
| ----------------------------- | --------------------------------------------------------------- |
|
|
64
|
+
| `@Param('id')` | path parameter (`required: true`) |
|
|
65
|
+
| `@Param('id', ParseUUIDPipe)` | `{ type: 'string', format: 'uuid' }` |
|
|
66
|
+
| `@Param('id', ParseIntPipe)` | `{ type: 'integer' }` |
|
|
67
|
+
| `@Param('id', ParseBoolPipe)` | `{ type: 'boolean' }` |
|
|
68
|
+
| `@Query('q')` | named query parameter |
|
|
69
|
+
| `@Query() dto: SomeQueryDto` | expanded into individual query parameters from the DTO |
|
|
70
|
+
| `@Body() dto: SomeBodyDto` | `requestBody` with `application/json` schema (`required: true`) |
|
|
71
|
+
| `@Headers('x-foo')` | header parameter (`type: string`) |
|
|
72
|
+
|
|
73
|
+
Pipe detection is **textual** — it looks for `ParseUUIDPipe` / `ParseIntPipe` / `ParseBoolPipe` in the decorator's arguments source. Custom pipes fall back to the parameter's TypeScript type.
|
|
74
|
+
|
|
75
|
+
**Path parameters always match the route template.** Every `:placeholder` in the route (`@Controller`, `@Get`, the global prefix) is emitted as a `required: true` path parameter, in template order — so the document is never invalid for a missing parameter object. When a `:placeholder` has a matching `@Param('placeholder')`, its schema (incl. pipe-derived `uuid`/`integer`/`boolean`) is used; otherwise — the handler reads `@Param() all`, `@Req()`, or the names simply don't line up — it defaults to `{ type: 'string' }`. A `@Param('x')` whose name isn't in the route template is ignored (it can't be a valid path parameter).
|
|
76
|
+
|
|
77
|
+
## Responses
|
|
78
|
+
|
|
79
|
+
The default is "method return type **is** the response body" with status `201` for `POST` and `200` for everything else. `Promise<T>` is unwrapped to `T` first. Customize the body with the [`buildResponseSchema`](configuration.md#hooks-project-specific-glue) hook.
|
|
80
|
+
|
|
81
|
+
**`@HttpCode(...)` overrides the status.** A handler decorated with `@HttpCode(204)` (numeric literal) or `@HttpCode(HttpStatus.NO_CONTENT)` (the `HttpStatus` member is resolved from a built-in name→code table) uses that code as the response key instead of the 201/200 default — matching NestJS's own behavior.
|
|
82
|
+
|
|
83
|
+
## Schemas
|
|
84
|
+
|
|
85
|
+
The schema builder accepts TypeScript classes and produces OpenAPI object schemas:
|
|
86
|
+
|
|
87
|
+
| Source | Becomes |
|
|
88
|
+
| -------------------------------------------- | ------------------------------------------------------------------------------------ |
|
|
89
|
+
| Class instance property | entry in `properties` |
|
|
90
|
+
| `prop?: T` **or** `@IsOptional() prop: T` | excluded from `required` (decorator name configurable) |
|
|
91
|
+
| `@Exclude() prop: T` | omitted from the schema entirely (decorator name configurable) |
|
|
92
|
+
| `string` / `number` / `boolean` types | OpenAPI primitive |
|
|
93
|
+
| `Date` | `{ type: 'string', format: 'date-time' }` |
|
|
94
|
+
| `T[]` | `{ type: 'array', items: schemaOf(T) }` |
|
|
95
|
+
| TypeScript `enum` | `{ type, enum: [...] }` — `type` is `string`, `integer`, or `number` by member value |
|
|
96
|
+
| `"a" \| "b" \| "c"` string-literal union | `{ type: 'string', enum: ['a','b','c'] }` |
|
|
97
|
+
| Union of classes | `{ oneOf: [...] }` |
|
|
98
|
+
| `extends PartialType(X)` | all properties of `X` made optional |
|
|
99
|
+
| `extends PickType(X, ['a','b'])` | subset |
|
|
100
|
+
| `extends OmitType(X, ['a','b'])` | complement |
|
|
101
|
+
| `extends IntersectionType(A, B, ...)` | merged |
|
|
102
|
+
| Class JSDoc | `schema.description` |
|
|
103
|
+
| Property JSDoc | property-level `description` |
|
|
104
|
+
| Property JSDoc on a **class-typed** property | `{ allOf: [{ $ref }], description }` (see below) |
|
|
105
|
+
|
|
106
|
+
A `$ref` is an OpenAPI 3.0 Reference Object whose sibling keys are ignored, so a class-typed property that also carries a JSDoc description is emitted as `{ allOf: [{ $ref }], description }` rather than `{ $ref, description }` — otherwise the description would be silently dropped by tooling. Class-typed properties without a description stay a bare `{ $ref }`.
|
|
107
|
+
|
|
108
|
+
### `class-validator` constraints
|
|
109
|
+
|
|
110
|
+
Constraint decorators on a property are translated into the matching schema keywords (unknown decorators are ignored). These compose with the type-derived schema and propagate through `PartialType`/`PickType`/`OmitType`/`IntersectionType`:
|
|
111
|
+
|
|
112
|
+
| Decorator | Schema keyword(s) |
|
|
113
|
+
| --------------------------------------- | -------------------------------------- |
|
|
114
|
+
| `@Min(n)` / `@Max(n)` | `minimum` / `maximum` |
|
|
115
|
+
| `@MinLength(n)` / `@MaxLength(n)` | `minLength` / `maxLength` |
|
|
116
|
+
| `@Length(min, max)` | `minLength` + `maxLength` |
|
|
117
|
+
| `@ArrayMinSize(n)` / `@ArrayMaxSize(n)` | `minItems` / `maxItems` |
|
|
118
|
+
| `@IsEmail()` | `format: 'email'` |
|
|
119
|
+
| `@IsUrl()` | `format: 'uri'` |
|
|
120
|
+
| `@IsUUID()` | `format: 'uuid'` |
|
|
121
|
+
| `@IsDateString()` | `format: 'date-time'` |
|
|
122
|
+
| `@Matches(/re/)` | `pattern` (regex literal or string) |
|
|
123
|
+
| `@IsInt()` | narrows `number` → `integer` |
|
|
124
|
+
| `@IsPositive()` / `@IsNegative()` | exclusive `minimum` / `maximum` of `0` |
|
|
125
|
+
|
|
126
|
+
So `@IsInt() @Min(1) @Max(100) limit: number` becomes `{ type: 'integer', minimum: 1, maximum: 100 }`, and `@IsEmail() email: string` becomes `{ type: 'string', format: 'email' }`. Like everything else, decorators are matched by **local identifier name**.
|
|
127
|
+
|
|
128
|
+
### Schema reachability
|
|
129
|
+
|
|
130
|
+
Only classes that are **reachable from an endpoint** end up in `components.schemas`:
|
|
131
|
+
|
|
132
|
+
- Controller method return types (after `Promise<T>` unwrap) and their nested class properties — transitively.
|
|
133
|
+
- `@Body()` parameter types and their nested classes.
|
|
134
|
+
- DTOs used as `@Query()` are **inlined** as individual query parameters, not emitted as named schemas.
|
|
135
|
+
|
|
136
|
+
Classes that never reach the reference walk (orphan entities, error envelopes only used in interceptors, discriminated-union variants…) won't appear in the spec by default. Add them explicitly via `additionalModels`:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import { CommonError } from './src/common/common-error';
|
|
140
|
+
import { AuditEvent } from './src/audit/audit-event';
|
|
141
|
+
|
|
142
|
+
export default defineConfig({
|
|
143
|
+
// ...
|
|
144
|
+
additionalModels: [CommonError, AuditEvent],
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Pass the **class itself**, not its name — the parser resolves it via `klass.name` against the AST. Transitive references of each entry come along automatically. Build **throws** if a name isn't found in the source tree, so typos and out-of-tree classes fail loud.
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nestjs-openapi-parser",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "CLI that parses a NestJS project with ts-morph and generates an OpenAPI document.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"main": "dist/lib.js",
|
|
8
|
+
"types": "dist/lib.d.ts",
|
|
9
|
+
"bin": {
|
|
10
|
+
"nestparser": "dist/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"docs",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"workspaces": [
|
|
18
|
+
"tests/fixtures/*"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.json",
|
|
22
|
+
"dev": "tsx src/cli.ts",
|
|
23
|
+
"start": "node dist/cli.js",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:watch": "vitest",
|
|
26
|
+
"snapshot:serve": "tsx scripts/serve-snapshot.ts",
|
|
27
|
+
"lint": "eslint .",
|
|
28
|
+
"lint:fix": "eslint . --fix",
|
|
29
|
+
"format": "prettier --write .",
|
|
30
|
+
"format:check": "prettier --check .",
|
|
31
|
+
"prepublishOnly": "yarn build"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"packageManager": "yarn@4.16.0",
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@seriousme/openapi-schema-validator": "^2.9.0",
|
|
39
|
+
"commander": "^12.1.0",
|
|
40
|
+
"ts-morph": "^23.0.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@eslint/js": "^9.0.0",
|
|
44
|
+
"@types/node": "^20.0.0",
|
|
45
|
+
"@types/prompts": "^2.4.9",
|
|
46
|
+
"eslint": "^9.0.0",
|
|
47
|
+
"prettier": "^3.0.0",
|
|
48
|
+
"prompts": "^2.4.2",
|
|
49
|
+
"tsx": "^4.0.0",
|
|
50
|
+
"typescript": "^5.0.0",
|
|
51
|
+
"typescript-eslint": "^8.0.0",
|
|
52
|
+
"vitest": "^2.0.0"
|
|
53
|
+
}
|
|
54
|
+
}
|