Plugin Authoring โ
ng-openapi plugins are generator classes that run after the core type/service generation and emit additional files into the same output directory. The built-in HttpResourcePlugin and ZodPlugin are implemented against the exact contract described here โ a third-party plugin needs nothing beyond the public ng-openapi API.
The contract โ
A plugin is a class implementing IPluginGenerator, constructed by the orchestrator with a single PluginGeneratorContext argument:
typescript
import { IPluginGenerator, PluginGeneratorContext } from "ng-openapi";
import * as path from "path";
export class MyPlugin implements IPluginGenerator {
private readonly context: PluginGeneratorContext;
constructor(context: PluginGeneratorContext) {
this.context = context;
}
async generate(outputRoot: string): Promise<void> {
const { spec, project, onWarning } = this.context;
if (spec.operations.length === 0) {
onWarning?.("Nothing to generate: the specification has no operations");
return;
}
const file = project.createSourceFile(path.join(outputRoot, "my-plugin", "index.ts"), "", {
overwrite: true,
});
// ... build the file from spec.operations / spec.definitions ...
file.formatText();
// No save here. Generation writes the whole Project once, after every
// generator has succeeded โ saving inside a plugin would leave files on
// disk when a later generator fails.
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Users register the class in their config:
typescript
export default {
// ...
plugins: [MyPlugin],
} as GeneratorConfig;1
2
3
4
2
3
4
Rules a plugin must follow โ
These are invariants of the emitted code, not style preferences. The core generators follow them and the shared helpers exist so plugins need not re-derive them.
- Never write files yourself. Build into the
projectyou are given;generateFromConfigsaves once at the end, so a failed run leaves no partial output. The example above deliberately has nosavecall. - Never
camelCasea wire name to get an identifier. CallresolveArgumentNames(operation, config, profile)with a profile describing what your emitted method binds, and readnames.of(wireName). Wire names are free-form:filter[name]andfilter.nameboth camelCase tofilterName, and a parameter can land on a name your own method already uses. Resolving one name at a time cannot see either collision. - Never interpolate spec text into an emitted literal. Use
quoteLiteralfor a string literal,emitObjectKeyfor an object-literal key (a__proto__key invokes the prototype setter and creates no property),emitPropertyNamefor a declaration,escapeTemplateLiteralinside a template literal, andemitDocsfor anything you pass as ts-morphdocs. A quote is a syntax error; a backslash is worse, because it compiles and changes which value goes on the wire; and a description containing the comment terminator ends the JSDoc block, so whatever follows is emitted as code. - Throw typed errors from
@ng-openapi/sharedrather than bareErrors, so hosts can branch on the class. They carry a brand that survives bundling, soinstanceofworks across your published bundle and the host's copy. - Report non-fatal problems through
onWarning, neverconsole.*.
What the context provides โ
| Field | Type | Notes |
|---|---|---|
spec | NormalizedSpec | The version-free spec model. $refs are resolved and per-operation fields (pathParams, queryParams, hasBody, isMultipart, responseType, โฆ) are precomputed. Plugins never see Swagger 2.0 vs OpenAPI 3.x differences. |
project | Project (ts-morph) | The shared project every generator emits through. Create files via project.createSourceFile(...) so the orchestrator can report them in GenerationResult.filesWritten. |
config | GeneratorConfig | The full user-facing config. Read only the slice you need (e.g. config.clientName, config.options.dateType). |
onWarning | (message: string) => void (optional) | Sink for non-fatal diagnostics. Never console.* from a plugin โ warnings surface on GenerationResult.warnings and through the CLI's reporter. |
Rules of engagement โ
- Consume
NormalizedSpec, not the raw spec. All version quirks are resolved at parse time; if something you need is missing from the model, that is a gap to raise upstream, not a reason to re-parse the input. - Never log. The core is silent by design; the CLI owns presentation. Report problems through
onWarningor by throwing anError(which aborts generation with a clean CLI message). - Emit through the shared
project. Files written behind its back won't be tracked, formatted consistently, or visible tofixMissingImports(). That includes files that are not TypeScript:emitJsonFile(project, path, value)andemitTextFile(project, path, text)register a JSON or plain-text file (a config file, a Markdown page) so it is written with everything else โ never callformatText()on one of those. - Barrels are re-exported for you. If your plugin writes an
index.tsinto its own directory directly under the output root (as in the example above), the generated rootindex.tsautomatically re-exports that directory โ users can import your symbols from the client entrypoint. Three constraints: the directory must be a direct child of the output root (nested barrels are ignored), it must contain anindex.ts, and it must not be namedmodels,providers,services,tokens, orutilsโ those are reserved for the core generators and are skipped silently. This applies only to files emitted through the sharedproject; anything written straight to disk is invisible to the scan. Prefix your exported symbols distinctively, too: the root barrel re-exports plugin directories withexport *alongside./models, and a name exported by both is silently dropped rather than reported. - Validation is done for you. By the time a plugin is constructed, the spec has been parsed, validated, and normalized โ no need for your own guards.