Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions packages/openapi-typescript/examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

These examples show the generation from an OpenAPI schema to its associated types. None of the example schemas here are associated with this project, and all code are © their respective owners.

| Generated Types | Source | License |
| :-------------------------------- | :-------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- |
| `digital-ocean-api.ts` | [GitHub](https://github.com/digitalocean/openapi) | [Apache 2.0](https://github.com/digitalocean/openapi/blob/main/LICENSE) |
| `github-api.ts` | [GitHub](https://github.com/github/rest-api-description/tree/main/descriptions/api.github.com) | [MIT](https://github.com/github/rest-api-description/blob/main/LICENSE.md) |
| `github-api-next.ts` | [GitHub](https://github.com/github/rest-api-description/tree/main/descriptions-next/api.github.com) | [MIT](https://github.com/github/rest-api-description/blob/main/LICENSE.md) |
| `octokit-ghes-3.6-diff-to-api.ts` | [GitHub](https://github.com/octokit/octokit-next.js/tree/main/packages/types-openapi-ghes-3.6-diff-to-api.github.com) | [MIT](https://github.com/octokit/octokit-next.js/blob/main/LICENSE) |
| `stripe-api.ts` | [GitHub](https://github.com/stripe/openapi) | [MIT](https://github.com/stripe/openapi/blob/master/LICENSE) |
| Generated Types | Source | License | Notes |
| :-------------------------------- | :-------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------- | :--------------------------------------------------- |
| `digital-ocean-api.ts` | [GitHub](https://github.com/digitalocean/openapi) | [Apache 2.0](https://github.com/digitalocean/openapi/blob/main/LICENSE) | |
| `github-api.ts` | [GitHub](https://github.com/github/rest-api-description/tree/main/descriptions/api.github.com) | [MIT](https://github.com/github/rest-api-description/blob/main/LICENSE.md) | |
| `github-api-next.ts` | [GitHub](https://github.com/github/rest-api-description/tree/main/descriptions-next/api.github.com) | [MIT](https://github.com/github/rest-api-description/blob/main/LICENSE.md) | |
| `github-api-pulls-only.ts` | [GitHub](https://github.com/github/rest-api-description/tree/main/descriptions/api.github.com) | [MIT](https://github.com/github/rest-api-description/blob/main/LICENSE.md) | `pathsFilter` — only `/repos/{owner}/{repo}/pulls*` |
| `octokit-ghes-3.6-diff-to-api.ts` | [GitHub](https://github.com/octokit/octokit-next.js/tree/main/packages/types-openapi-ghes-3.6-diff-to-api.github.com) | [MIT](https://github.com/octokit/octokit-next.js/blob/main/LICENSE) | |
| `stripe-api.ts` | [GitHub](https://github.com/stripe/openapi) | [MIT](https://github.com/stripe/openapi/blob/master/LICENSE) | |
4,163 changes: 4,163 additions & 0 deletions packages/openapi-typescript/examples/github-api-pulls-only.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Example: generate types for only the GitHub Pulls API routes.
*
* This demonstrates the `pathsFilter` option, which accepts a function
* `(pathname, method) => boolean` and removes all non-matching paths and
* their associated (now-unreferenced) component schemas from the output.
*
* Run with:
* vite-node ./scripts/generate-github-pulls-only.ts
*/

import fs from "node:fs";
import { performance } from "node:perf_hooks";
import openapiTS, { astToString, COMMENT_HEADER } from "../src/index.js";

const GITHUB_API_YAML = new URL("../examples/github-api.yaml", import.meta.url);
const OUTPUT = new URL("../examples/github-api-pulls-only.ts", import.meta.url);
const PULLS_PATH_PREFIX = "/repos/{owner}/{repo}/pulls";

const start = performance.now();

// biome-ignore lint/suspicious/noConsole: this is a script
console.log("Generating github-api-pulls-only.ts …");

const ast = await openapiTS(GITHUB_API_YAML, {
pathsFilter: (pathname) => pathname === PULLS_PATH_PREFIX || pathname.startsWith(`${PULLS_PATH_PREFIX}/`),
});

fs.writeFileSync(OUTPUT, COMMENT_HEADER + astToString(ast));

// biome-ignore lint/suspicious/noConsole: this is a script
console.log(`✔︎ Written to examples/github-api-pulls-only.ts (${Math.round(performance.now() - start)}ms)`);
30 changes: 29 additions & 1 deletion packages/openapi-typescript/scripts/update-examples.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import fs from "node:fs";
import path from "node:path";
import { performance } from "node:perf_hooks";
import { execa } from "execa";
import openapiTS, { astToString, COMMENT_HEADER } from "../src/index.js";
import { multiFile, singleFile } from "./schemas.js";

async function generateSchemas() {
Expand All @@ -16,7 +18,9 @@ async function generateSchemas() {
const cwd =
process.platform === "win32"
? // execa/cross-spawn can not handle URL objects on Windows, so convert it to string and cut away the protocol
rootCWD.toString().slice("file:///".length)
rootCWD
.toString()
.slice("file:///".length)
: rootCWD;

try {
Expand Down Expand Up @@ -70,9 +74,33 @@ async function generateSchemas() {
}),
]);

// programmatic examples (use JS API features not available via CLI, e.g. pathsFilter)
await generateGithubPullsOnly();

// biome-ignore lint/suspicious/noConsole: this is a script
console.log("Updating examples done.");
process.exit(0); // helps process close in npm script
}

async function generateGithubPullsOnly() {
const start = performance.now();
const PULLS_PATH_PREFIX = "/repos/{owner}/{repo}/pulls";
const schemaUrl = new URL("../examples/github-api.yaml", import.meta.url);
const output = new URL("../examples/github-api-pulls-only.ts", import.meta.url);

try {
const ast = await openapiTS(schemaUrl, {
pathsFilter: (pathname) => pathname === PULLS_PATH_PREFIX || pathname.startsWith(`${PULLS_PATH_PREFIX}/`),
});
fs.writeFileSync(output, COMMENT_HEADER + astToString(ast));
// biome-ignore lint/suspicious/noConsole: this is a script
console.log(`✔︎ Updated github-api-pulls-only (${Math.round(performance.now() - start)}ms)`);
} catch (err) {
// biome-ignore lint/suspicious/noConsole: this is a script
console.error("✘ Failed to update github-api-pulls-only", {
error: err instanceof Error ? err.message : err,
});
}
}

generateSchemas();
10 changes: 7 additions & 3 deletions packages/openapi-typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { performance } from "node:perf_hooks";
import type { Readable } from "node:stream";
import { createConfig } from "@redocly/openapi-core";
import type ts from "typescript";
import { applyPathsFilter } from "./lib/filter.js";
export type { PathsFilterFn } from "./lib/filter.js";
import { validateAndBundle } from "./lib/redoc.js";
import { debug, resolveRef, scanDiscriminators } from "./lib/utils.js";
import transformSchema from "./transform/index.js";
Expand Down Expand Up @@ -66,12 +68,14 @@ export default async function openapiTS(
silent: options.silent ?? false,
});

const filteredSchema = options.pathsFilter ? applyPathsFilter(schema, options.pathsFilter) : schema;

const ctx: GlobalContext = {
additionalProperties: options.additionalProperties ?? false,
alphabetize: options.alphabetize ?? false,
arrayLength: options.arrayLength ?? false,
defaultNonNullable: options.defaultNonNullable ?? true,
discriminators: scanDiscriminators(schema, options),
discriminators: scanDiscriminators(filteredSchema, options),
emptyObjectsUnknown: options.emptyObjectsUnknown ?? false,
enum: options.enum ?? false,
enumValues: options.enumValues ?? false,
Expand All @@ -96,12 +100,12 @@ export default async function openapiTS(
generatePathParams: options.generatePathParams ?? false,
readWriteMarkers: options.readWriteMarkers ?? false,
resolve($ref) {
return resolveRef(schema, $ref, { silent: options.silent ?? false });
return resolveRef(filteredSchema, $ref, { silent: options.silent ?? false });
},
};

const transformT = performance.now();
const result = transformSchema(schema, ctx);
const result = transformSchema(filteredSchema, ctx);
debug("Completed AST transformation for entire document", "ts", performance.now() - transformT);

return result;
Expand Down
140 changes: 140 additions & 0 deletions packages/openapi-typescript/src/lib/filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import type { ComponentsObject, OpenAPI3, PathsObject } from "../types.js";

export type PathsFilterFn = (pathname: string, method: string) => boolean;

const HTTP_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as const;

/**
* Pre-process an OpenAPI schema by filtering paths/methods and removing unreferenced components.
* Performs transitive $ref analysis so only components reachable from included paths are kept.
*/
export function applyPathsFilter(schema: OpenAPI3, pathsFilter: PathsFilterFn): OpenAPI3 {
// Step 1: filter paths and their HTTP methods
const filteredPaths: PathsObject = {};

for (const [pathname, pathItem] of Object.entries(schema.paths ?? {})) {
if (!pathItem || typeof pathItem !== "object") {
continue;
}

if ("$ref" in pathItem) {
// $ref path items are always included — individual methods can't be inspected
// without resolving the reference. See OpenAPITSOptions.pathsFilter docs.
filteredPaths[pathname] = pathItem;
continue;
}

const filteredItem = { ...pathItem };
let hasMethod = false;

for (const method of HTTP_METHODS) {
if (method in filteredItem) {
if (pathsFilter(pathname, method)) {
hasMethod = true;
} else {
delete (filteredItem as Record<string, unknown>)[method];
}
}
}

if (hasMethod) {
filteredPaths[pathname] = filteredItem;
}
}

const schemaWithFilteredPaths: OpenAPI3 = { ...schema, paths: filteredPaths };

if (!schema.components) {
return schemaWithFilteredPaths;
}

// Step 2: collect all $refs reachable from the filtered paths (transitively)
const usedRefs = new Set<string>();
collectRefs(filteredPaths, usedRefs);

const processed = new Set<string>();
const queue = [...usedRefs];

while (queue.length > 0) {
// biome-ignore lint/style/noNonNullAssertion: condition checked above
const ref = queue.pop()!;
if (processed.has(ref)) {
continue;
}
processed.add(ref);

if (!ref.startsWith("#/")) {
continue;
}

// Walk the original schema to resolve the ref
const parts = ref.slice(2).split("/");
let node: unknown = schema;
for (const part of parts) {
if (!node || typeof node !== "object") {
node = undefined;
break;
}
node = (node as Record<string, unknown>)[part];
}

if (node) {
const nested = new Set<string>();
collectRefs(node, nested);
for (const nestedRef of nested) {
if (!processed.has(nestedRef)) {
usedRefs.add(nestedRef);
queue.push(nestedRef);
}
}
}
}

// Step 3: keep only components that are referenced
const usedComponentPaths = new Set(
[...usedRefs].filter((ref) => ref.startsWith("#/")).map((ref) => ref.slice(2)), // e.g. "components/schemas/User"
);

const filteredComponents: Partial<ComponentsObject> = {};

for (const [componentType, componentItems] of Object.entries(schema.components)) {
if (!componentItems || typeof componentItems !== "object") {
continue;
}

const kept: Record<string, unknown> = {};
for (const [name, item] of Object.entries(componentItems as Record<string, unknown>)) {
if (usedComponentPaths.has(`components/${componentType}/${name}`)) {
kept[name] = item;
}
}

if (Object.keys(kept).length > 0) {
(filteredComponents as Record<string, unknown>)[componentType] = kept;
}
}

return {
...schemaWithFilteredPaths,
components: Object.keys(filteredComponents).length > 0 ? (filteredComponents as ComponentsObject) : undefined,
};
}

function collectRefs(obj: unknown, refs: Set<string>): void {
if (!obj || typeof obj !== "object") {
return;
}
if (Array.isArray(obj)) {
for (const item of obj) {
collectRefs(item, refs);
}
return;
}
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
if (key === "$ref" && typeof value === "string") {
refs.add(value);
} else {
collectRefs(value, refs);
}
}
}
21 changes: 21 additions & 0 deletions packages/openapi-typescript/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,8 +684,29 @@ export interface OpenAPITSOptions {
makePathsEnum?: boolean;
/** Generate path params based on path even if they are not defined in the open api schema */
generatePathParams?: boolean;

/** Generate $Read/$Write markers for readOnly/writeOnly properties (default: false) */
readWriteMarkers?: boolean;

/**
* Filter which paths and HTTP methods are included in the generated types.
* Return `true` to include a path+method combination, `false` to exclude it.
* Unreferenced components are automatically removed when this option is set.
* `method` is always lowercase (e.g. `"get"`, `"post"`). `pathname` is passed as-is
* from the schema — OpenAPI paths are case-sensitive and are not normalized.
*
* **Note:** Path items defined as a `$ref` (e.g. `"/users": { $ref: "..." }`) are
* always included regardless of the filter, because individual methods cannot be
* inspected without resolving the reference. Filter by pathname in a post-processing
* step if you need to exclude these paths.
* @example
* // Only include GET operations
* pathsFilter: (pathname, method) => method === "get"
* @example
* // Only include specific paths
* pathsFilter: (pathname) => pathname.startsWith("/users")
*/
pathsFilter?: (pathname: string, method: string) => boolean;
}

/** Context passed to all submodules */
Expand Down
Loading
Loading