Skip to content

Commit a2b79b1

Browse files
authored
feat: exclude internal schemas from components via @internal tag and excludeSchemas config (#139)
1 parent 641e5e2 commit a2b79b1

13 files changed

Lines changed: 674 additions & 18 deletions

File tree

README.md

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -276,19 +276,20 @@ Version guidance:
276276

277277
### Important options
278278

279-
| Option | Purpose |
280-
| ------------------------------------- | ---------------------------------------------------------------- |
281-
| `openapi` | Target `3.0.0`, `3.1.0`, or `3.2.0` output |
282-
| `apiDir` | Route directory to scan |
283-
| `routerType` | `"app"` or `"pages"` |
284-
| `schemaDir` | Directory or directories to search for schemas/types |
285-
| `schemaType` | `"zod"`, `"typescript"`, or both |
286-
| `schemaFiles` | YAML/JSON OpenAPI fragments to merge into the generated document |
287-
| `includeOpenApiRoutes` | Only include handlers tagged with `@openapi` |
288-
| `ignoreRoutes` | Exclude routes with wildcard support |
289-
| `defaultResponseSet` / `responseSets` | Reusable error-response groups |
290-
| `errorConfig` | Shared error schema templates |
291-
| `authPresets` | Override or extend the `@auth` keyword → scheme-name mapping |
279+
| Option | Purpose |
280+
| ------------------------------------- | --------------------------------------------------------------------------------------- |
281+
| `openapi` | Target `3.0.0`, `3.1.0`, or `3.2.0` output |
282+
| `apiDir` | Route directory to scan |
283+
| `routerType` | `"app"` or `"pages"` |
284+
| `schemaDir` | Directory or directories to search for schemas/types |
285+
| `schemaType` | `"zod"`, `"typescript"`, or both |
286+
| `schemaFiles` | YAML/JSON OpenAPI fragments to merge into the generated document |
287+
| `includeOpenApiRoutes` | Only include handlers tagged with `@openapi` |
288+
| `ignoreRoutes` | Exclude routes with wildcard support |
289+
| `excludeSchemas` | Exclude internal schemas from `components/schemas` by name or glob (e.g. `["*Params"]`) |
290+
| `defaultResponseSet` / `responseSets` | Reusable error-response groups |
291+
| `errorConfig` | Shared error schema templates |
292+
| `authPresets` | Override or extend the `@auth` keyword → scheme-name mapping |
292293

293294
For a fuller setup guide, Pages Router notes, response sets, and route exclusion
294295
patterns, see [docs/getting-started.md](./docs/getting-started.md).
@@ -315,6 +316,7 @@ patterns, see [docs/getting-started.md](./docs/getting-started.md).
315316
| `@openapi` | Explicit inclusion marker when `includeOpenApiRoutes` is enabled |
316317
| `@openapi-override` | Deep-merge extra OpenAPI fields onto the operation |
317318
| `@ignore` | Exclude a route from generation |
319+
| `@internal` | Exclude a schema/type declaration from `components/schemas` |
318320
| `@method` | Required HTTP method tag for Pages Router handlers |
319321

320322
For the complete tag guide and usage recipes, see

docs/jsdoc-reference.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,54 @@ export interface AudioInterface {
645645
In both cases, the component is registered as `Audio` and cross-type references resolve correctly
646646
to `#/components/schemas/Audio`.
647647

648+
## Excluding internal schemas from components
649+
650+
Inline Zod or TypeScript schemas used only as route-internal implementation details (path/query param
651+
shapes, bulk-payload bounds, etc.) are emitted into `components/schemas` by default. Two mechanisms
652+
let you suppress them.
653+
654+
### Per-schema: `@internal` JSDoc tag
655+
656+
Add `/** @internal */` as a leading comment on any Zod `const` declaration or TypeScript
657+
`type`/`interface`:
658+
659+
```ts
660+
/** @internal */
661+
const productIdParamsSchema = z.object({
662+
id: z.coerce.number().int().positive(),
663+
});
664+
665+
/** @internal */
666+
export type ProductIdParams = z.infer<typeof productIdParamsSchema>;
667+
```
668+
669+
Both forms suppress the schema from `components/schemas`. If the schema is still referenced by a
670+
route (e.g. via `@pathParams`), the `$ref` is automatically replaced with the inlined schema content
671+
so the generated spec remains valid.
672+
673+
`@schema false` is accepted as an alias for `@internal`.
674+
675+
### Project-wide: `excludeSchemas` config
676+
677+
Add `excludeSchemas` to your `openapi-gen.config.ts` (or `next.openapi.json`) to exclude schemas by
678+
exact name or simple glob (`*`):
679+
680+
```ts
681+
// openapi-gen.config.ts
682+
export default defineConfig({
683+
// ...
684+
excludeSchemas: ["*Params", "productBulkSchema"],
685+
});
686+
```
687+
688+
| Pattern | Matches |
689+
| ------------------------- | --------------------------------- |
690+
| `"productIdParamsSchema"` | Exact name |
691+
| `"*Params"` | Any name ending with `Params` |
692+
| `"Internal*"` | Any name starting with `Internal` |
693+
694+
References to excluded schemas in route operations are inlined automatically.
695+
648696
## Related guides
649697

650698
- [Getting started and configuration](./getting-started.md)

packages/openapi-core/src/config/normalize.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export function normalizeOpenApiConfig(
132132
outputDir: template.outputDir ?? DEFAULT_OUTPUT_DIR,
133133
includeOpenApiRoutes: template.includeOpenApiRoutes ?? DEFAULT_INCLUDE_OPENAPI_ROUTES,
134134
ignoreRoutes: template.ignoreRoutes ?? [],
135+
excludeSchemas: template.excludeSchemas ?? [],
135136
schemaType: template.schemaType ?? DEFAULT_RUNTIME_SCHEMA_TYPE,
136137
schemaBackends,
137138
schemaFiles: template.schemaFiles ?? [],
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { logger } from "../shared/logger.js";
2+
import type { OpenApiDocument, OpenApiSchema } from "../shared/types.js";
3+
4+
function patternToRegExp(pattern: string): RegExp {
5+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
6+
return new RegExp(`^${escaped.replace(/\*/g, ".*")}$`);
7+
}
8+
9+
export function matchExcludePatterns(names: string[], patterns: string[]): string[] {
10+
if (patterns.length === 0) return [];
11+
const regexes = patterns.map(patternToRegExp);
12+
return names.filter((name) => regexes.some((re) => re.test(name)));
13+
}
14+
15+
export function applyExcludeSchemas(
16+
document: OpenApiDocument,
17+
mergedSchemas: Record<string, unknown>,
18+
excludedSchemas: Record<string, OpenApiSchema>,
19+
): void {
20+
const excludedNames = new Set(Object.keys(excludedSchemas));
21+
if (excludedNames.size === 0) return;
22+
23+
walkAndInline(document, excludedSchemas, excludedNames, new Set());
24+
25+
for (const name of excludedNames) {
26+
delete mergedSchemas[name];
27+
}
28+
}
29+
30+
function walkAndInline(
31+
obj: unknown,
32+
excluded: Record<string, OpenApiSchema>,
33+
excludedNames: Set<string>,
34+
visiting: Set<string>,
35+
): void {
36+
if (!obj || typeof obj !== "object") return;
37+
38+
if (Array.isArray(obj)) {
39+
for (const item of obj) {
40+
walkAndInline(item, excluded, excludedNames, visiting);
41+
}
42+
return;
43+
}
44+
45+
const rec = obj as Record<string, unknown>;
46+
const ref = rec["$ref"];
47+
48+
if (typeof ref === "string") {
49+
const match = ref.match(/^#\/components\/schemas\/(.+)$/);
50+
const name = match?.[1];
51+
if (name && excludedNames.has(name)) {
52+
if (visiting.has(name)) {
53+
logger.warn(`Circular reference to internal schema "${name}", keeping $ref`);
54+
return;
55+
}
56+
const schemaDef = excluded[name];
57+
if (schemaDef) {
58+
const cloned = JSON.parse(JSON.stringify(schemaDef)) as Record<string, unknown>;
59+
delete rec["$ref"];
60+
Object.assign(rec, cloned);
61+
const newVisiting = new Set(visiting);
62+
newVisiting.add(name);
63+
for (const key of Object.keys(rec)) {
64+
walkAndInline(rec[key], excluded, excludedNames, newVisiting);
65+
}
66+
return;
67+
}
68+
}
69+
}
70+
71+
for (const key of Object.keys(rec)) {
72+
walkAndInline(rec[key], excluded, excludedNames, visiting);
73+
}
74+
}

packages/openapi-core/src/core/orchestrator.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
type GenerationPerformanceProfile,
2323
} from "./performance.js";
2424
import type { SharedGenerationRuntime } from "./runtime.js";
25+
import { applyExcludeSchemas, matchExcludePatterns } from "./exclude-schemas.js";
2526
export type OrchestratorPerformanceProfile = GenerationPerformanceProfile & {
2627
scanRoutesMs: number;
2728
};
@@ -132,11 +133,28 @@ export function runGenerationOrchestrator({
132133
profile.defaultComponentsAndErrorsMs = performance.now() - phaseStartedAt;
133134

134135
phaseStartedAt = performance.now();
135-
const definedSchemas = routeProcessor.getSchemaProcessor().getDefinedSchemas();
136+
const schemaProcessor = routeProcessor.getSchemaProcessor();
137+
const definedSchemas = schemaProcessor.getDefinedSchemas();
136138
const mergedSchemas: Record<string, unknown> = {
137139
...document.components.schemas,
138140
...definedSchemas,
139141
};
142+
143+
const internalSchemas = schemaProcessor.getInternalSchemas();
144+
const patternExcludedNames = matchExcludePatterns(
145+
Object.keys(mergedSchemas),
146+
config.excludeSchemas ?? [],
147+
);
148+
const allExcludedSchemas = {
149+
...internalSchemas,
150+
...Object.fromEntries(
151+
patternExcludedNames.map((name) => [name, mergedSchemas[name] as Record<string, unknown>]),
152+
),
153+
};
154+
if (Object.keys(allExcludedSchemas).length > 0) {
155+
applyExcludeSchemas(document, mergedSchemas, allExcludedSchemas);
156+
}
157+
140158
if (Object.keys(mergedSchemas).length > 0) {
141159
document.components.schemas = Object.fromEntries(
142160
Object.entries(mergedSchemas).sort(([a], [b]) =>

packages/openapi-core/src/schema/typescript/schema-discovery.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import * as t from "@babel/types";
44

55
import { traverse } from "../../shared/babel-traverse.js";
66
import { resolveTypeScriptModule } from "../../shared/typescript-project.js";
7-
import { extractSchemaIdFromComments } from "../../shared/utils.js";
7+
import {
8+
extractInternalFlagFromComments,
9+
extractSchemaIdFromComments,
10+
} from "../../shared/utils.js";
811

912
type TypeDefinitions = Record<string, any>;
1013

@@ -116,6 +119,7 @@ export function collectAllExportedDefinitions(
116119
typeDefinitions: TypeDefinitions,
117120
currentFile: string,
118121
schemaIdAliases?: Record<string, string>,
122+
internalSchemaNames?: Set<string>,
119123
): void {
120124
function registerDefinition(
121125
name: string,
@@ -132,6 +136,9 @@ export function collectAllExportedDefinitions(
132136
typeDefinitions[overrideId] = entry;
133137
}
134138
}
139+
if (internalSchemaNames && extractInternalFlagFromComments(allComments)) {
140+
internalSchemaNames.add(name);
141+
}
135142
}
136143

137144
traverse(ast, {

packages/openapi-core/src/schema/typescript/schema-processor.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export class SchemaProcessor {
8484
private schemaTypes: SchemaType[];
8585
private isResolvingPickOmitBase: boolean = false;
8686
private schemaIdAliases: Record<string, string> = {};
87+
private internalSchemaNames: Set<string> = new Set();
8788
private readonly fileAccess: SchemaProcessorFileAccess;
8889
private readonly symbolResolver: SymbolResolver;
8990

@@ -147,7 +148,8 @@ export class SchemaProcessor {
147148
!this.isGenericTypeParameter(key) &&
148149
!this.isInvalidSchemaName(key) &&
149150
!this.isBuiltInUtilityType(key) &&
150-
!this.isFunctionSchema(key)
151+
!this.isFunctionSchema(key) &&
152+
!this.internalSchemaNames.has(key)
151153
) {
152154
filteredSchemas[key] = value;
153155
}
@@ -160,6 +162,21 @@ export class SchemaProcessor {
160162
]);
161163
}
162164

165+
public getInternalSchemas(): Record<string, OpenAPIDefinition> {
166+
const result: Record<string, OpenAPIDefinition> = {};
167+
for (const name of this.internalSchemaNames) {
168+
const def = this.openapiDefinitions[name];
169+
if (def) result[name] = def;
170+
}
171+
if (this.zodSchemaConverter) {
172+
for (const name of this.zodSchemaConverter.internalSchemaNames) {
173+
const schema = this.zodSchemaConverter.zodSchemas[name];
174+
if (schema) result[name] = schema;
175+
}
176+
}
177+
return result;
178+
}
179+
163180
public findSchemaDefinition(schemaName: string, contentType: ContentType): OpenAPIDefinition {
164181
// Assign type that is actually processed
165182
this.contentType = contentType;
@@ -354,6 +371,7 @@ export class SchemaProcessor {
354371
this.typeDefinitions,
355372
filePath || this.currentFilePath,
356373
this.schemaIdAliases,
374+
this.internalSchemaNames,
357375
);
358376
}
359377

packages/openapi-core/src/schema/zod/zod-converter.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { NodePath } from "@babel/traverse";
44
import * as t from "@babel/types";
55

66
import { traverse } from "../../shared/babel-traverse.js";
7-
import { parseTypeScriptFile } from "../../shared/utils.js";
7+
import { extractInternalFlagFromComments, parseTypeScriptFile } from "../../shared/utils.js";
88
import { logger } from "../../shared/logger.js";
99
import { SymbolResolver } from "../../shared/symbol-resolver.js";
1010
import { DrizzleZodProcessor } from "./drizzle-zod-processor.js";
@@ -78,6 +78,8 @@ export class ZodSchemaConverter {
7878
/** Schema variable names whose component name was overridden via .meta({ id }). These must
7979
* NOT be copied back under the original variable name in the OpenAPI components object. */
8080
metaIdSchemaNames: Set<string> = new Set();
81+
/** Schema variable names marked @internal — excluded from components/schemas output. */
82+
internalSchemaNames: Set<string> = new Set();
8183
// Current processing context (set during file processing)
8284
currentFilePath?: string;
8385
currentAST?: t.File;
@@ -2272,7 +2274,13 @@ export class ZodSchemaConverter {
22722274
* Get all processed Zod schemas
22732275
*/
22742276
getProcessedSchemas(): Record<string, OpenApiSchema> {
2275-
return this.zodSchemas;
2277+
const result: Record<string, OpenApiSchema> = {};
2278+
for (const [name, schema] of Object.entries(this.zodSchemas)) {
2279+
if (!this.internalSchemaNames.has(name)) {
2280+
result[name] = schema;
2281+
}
2282+
}
2283+
return result;
22762284
}
22772285

22782286
/**
@@ -2346,6 +2354,15 @@ export class ZodSchemaConverter {
23462354

23472355
// Check if is Zod schema
23482356
if (this.isZodSchema(declaration.init)) {
2357+
const decl = path.node.declaration;
2358+
const allComments = [
2359+
...(path.node.leadingComments ?? []),
2360+
...(decl?.leadingComments ?? []),
2361+
...(declaration.leadingComments ?? []),
2362+
];
2363+
if (extractInternalFlagFromComments(allComments)) {
2364+
this.internalSchemaNames.add(schemaName);
2365+
}
23492366
if (!this.getStoredSchema(schemaName)) {
23502367
logger.debug(`Pre-processing Zod schema: ${schemaName}`);
23512368
this.processingSchemas.add(schemaName);
@@ -2371,6 +2388,13 @@ export class ZodSchemaConverter {
23712388
if (t.isIdentifier(declaration.id) && declaration.init) {
23722389
const schemaName = declaration.id.name;
23732390
if (this.isZodSchema(declaration.init)) {
2391+
const allComments = [
2392+
...(path.node.leadingComments ?? []),
2393+
...(declaration.leadingComments ?? []),
2394+
];
2395+
if (extractInternalFlagFromComments(allComments)) {
2396+
this.internalSchemaNames.add(schemaName);
2397+
}
23742398
if (!this.getStoredSchema(schemaName) && !this.processingSchemas.has(schemaName)) {
23752399
logger.debug(`Pre-processing Zod schema: ${schemaName}`);
23762400
this.processingSchemas.add(schemaName);

packages/openapi-core/src/shared/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export type OpenApiConfig = {
6363
outputDir: string;
6464
includeOpenApiRoutes: boolean;
6565
ignoreRoutes?: string[] | undefined;
66+
excludeSchemas?: string[] | undefined;
6667
schemaType: SchemaType | SchemaType[]; // Support both single type and array of types
6768
schemaFiles?: string[] | undefined; // Array of custom OpenAPI schema files (YAML/JSON)
6869
defaultResponseSet?: string | undefined;
@@ -173,6 +174,7 @@ export type OpenApiTemplate = OpenApiDocument & {
173174
outputDir?: string | undefined;
174175
includeOpenApiRoutes?: boolean | undefined;
175176
ignoreRoutes?: string[] | undefined;
177+
excludeSchemas?: string[] | undefined;
176178
schemaType?: SchemaType | SchemaType[] | undefined;
177179
schemaFiles?: string[] | undefined;
178180
defaultResponseSet?: string | undefined;

0 commit comments

Comments
 (0)