Skip to content

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.
    }
}

Users register the class in their config:

typescript
export default {
    // ...
    plugins: [MyPlugin],
} as GeneratorConfig;

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 project you are given; generateFromConfig saves once at the end, so a failed run leaves no partial output. The example above deliberately has no save call.
  • Never camelCase a wire name to get an identifier. Call resolveArgumentNames(operation, config, profile) with a profile describing what your emitted method binds, and read names.of(wireName). Wire names are free-form: filter[name] and filter.name both camelCase to filterName, 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 quoteLiteral for a string literal, emitObjectKey for an object-literal key (a __proto__ key invokes the prototype setter and creates no property), emitPropertyName for a declaration, escapeTemplateLiteral inside a template literal, and emitDocs for anything you pass as ts-morph docs. 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/shared rather than bare Errors, so hosts can branch on the class. They carry a brand that survives bundling, so instanceof works across your published bundle and the host's copy.
  • Report non-fatal problems through onWarning, never console.*.

What the context provides โ€‹

FieldTypeNotes
specNormalizedSpecThe 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.
projectProject (ts-morph)The shared project every generator emits through. Create files via project.createSourceFile(...) so the orchestrator can report them in GenerationResult.filesWritten.
configGeneratorConfigThe 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 onWarning or by throwing an Error (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 to fixMissingImports(). That includes files that are not TypeScript: emitJsonFile(project, path, value) and emitTextFile(project, path, text) register a JSON or plain-text file (a config file, a Markdown page) so it is written with everything else โ€” never call formatText() on one of those.
  • Barrels are re-exported for you. If your plugin writes an index.ts into its own directory directly under the output root (as in the example above), the generated root index.ts automatically 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 an index.ts, and it must not be named models, providers, services, tokens, or utils โ€” those are reserved for the core generators and are skipped silently. This applies only to files emitted through the shared project; anything written straight to disk is invisible to the scan. Prefix your exported symbols distinctively, too: the root barrel re-exports plugin directories with export * 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.

Released under the MIT License.
This site is powered by Netlify
About ยท Impressum