diff --git a/README.md b/README.md index b5b270a6..2d69d188 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ yarn openapi-codegen generate --config my-config.ts --splitByTags Organize output into separate folders based on OpenAPI operation tags (default: true) --defaultTag (Requires `--splitByTags`) Default tag for shared code across multiple tags (default: 'Common') + --includeTags Comma-separated list of tags to include in generation --excludeTags Comma-separated list of tags to exclude from generation --excludePathRegex Exclude operations whose paths match the given regular expression --excludeRedundantZodSchemas Exclude any redundant Zod schemas (default: true) @@ -115,6 +116,7 @@ yarn openapi-codegen generate --config my-config.ts --splitByTags Organize output into separate folders based on OpenAPI operation tags (default: true) --defaultTag (Requires `--splitByTags`) Default tag for shared code across multiple tags (default: 'Common') + --includeTags Comma-separated list of tags to include in generation --excludeTags Comma-separated list of tags to exclude from generation --excludePathRegex Exclude operations whose paths match the given regular expression --excludeRedundantZodSchemas Exclude any redundant Zod schemas (default: true) diff --git a/openapi-codegen.config.mjs b/openapi-codegen.config.mjs index b108e6ed..71b80682 100644 --- a/openapi-codegen.config.mjs +++ b/openapi-codegen.config.mjs @@ -2,7 +2,7 @@ const config = { input: "http://127.0.0.1:4000/docs-json", output: "./test/generated/next", - excludeTags: ["auth"], + includeTags: ["auth"], replaceOptionalWithNullish: true, builderConfigs: true, infiniteQueries: true, diff --git a/package.json b/package.json index 2843d676..ecda0eef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.8-rc.26", + "version": "2.0.8-rc.33", "keywords": [ "codegen", "openapi", @@ -107,7 +107,7 @@ "@tanstack/react-query": "^5.90.21", "axios": "^1.13.1", "react": "^19.1.0", - "vite": "^6.0.0 || ^7.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "zod": "^4.1.12" }, "peerDependenciesMeta": { diff --git a/src/commands/check.command.ts b/src/commands/check.command.ts index 086bfda0..22a25880 100644 --- a/src/commands/check.command.ts +++ b/src/commands/check.command.ts @@ -19,6 +19,9 @@ class CheckOptions implements CheckParams { @YargOption({ envAlias: "defaultTag" }) defaultTag?: string; + @YargOption({ envAlias: "includeTags" }) + includeTags?: string; + @YargOption({ envAlias: "excludeTags" }) excludeTags?: string; diff --git a/src/commands/check.ts b/src/commands/check.ts index 7d33b5ff..74ac6f90 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -9,11 +9,18 @@ import SwaggerParser from "@apidevtools/swagger-parser"; export type CheckParams = { config?: string; + includeTags?: string; excludeTags?: string; verbose?: boolean; } & Partial>; -export async function check({ verbose, config: configParam, excludeTags: _excludeTagsParam, ...params }: CheckParams) { +export async function check({ + verbose, + config: configParam, + includeTags: _includeTagsParam, + excludeTags: _excludeTagsParam, + ...params +}: CheckParams) { const start = Date.now(); if (verbose) { diff --git a/src/commands/generate.command.ts b/src/commands/generate.command.ts index f3181adf..39b60156 100644 --- a/src/commands/generate.command.ts +++ b/src/commands/generate.command.ts @@ -16,6 +16,9 @@ class GenerateOptions implements GenerateParams { @YargOption({ envAlias: "output" }) output?: string; + @YargOption({ envAlias: "clearOutput", type: "boolean" }) + clearOutput?: boolean; + @YargOption({ envAlias: "incremental", type: "boolean" }) incremental?: boolean; @@ -31,6 +34,9 @@ class GenerateOptions implements GenerateParams { @YargOption({ envAlias: "defaultTag" }) defaultTag?: string; + @YargOption({ envAlias: "includeTags" }) + includeTags?: string; + @YargOption({ envAlias: "excludeTags" }) excludeTags?: string; diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 421b0790..1f81e08c 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -7,6 +7,7 @@ import { Profiler } from "@/helpers/profile.helper"; export type GenerateParams = { config?: string; + includeTags?: string; excludeTags?: string; inlineEndpointsExcludeModules?: string; prettier?: boolean; @@ -16,6 +17,7 @@ export type GenerateParams = { GenerateOptions, | "input" | "output" + | "clearOutput" | "incremental" | "tsNamespaces" | "tsPath" diff --git a/src/generators/const/options.const.ts b/src/generators/const/options.const.ts index fcf5d473..9fca246e 100644 --- a/src/generators/const/options.const.ts +++ b/src/generators/const/options.const.ts @@ -8,9 +8,11 @@ export const DEFAULT_GENERATE_OPTIONS: GenerateOptions = { // Base options input: "http://localhost:4000/docs-json/", output: "output", + clearOutput: false, incremental: true, splitByTags: true, defaultTag: "Common", + includeTags: [], excludeTags: [], excludePathRegex: "", excludeRedundantZodSchemas: true, diff --git a/src/generators/core/SchemaResolver.class.ts b/src/generators/core/SchemaResolver.class.ts index 0612642f..5c66829e 100644 --- a/src/generators/core/SchemaResolver.class.ts +++ b/src/generators/core/SchemaResolver.class.ts @@ -179,6 +179,10 @@ export class SchemaResolver { return this.options.defaultTag; } + if (this.options.modelsInCommon) { + return formatTag(this.options.defaultTag); + } + const extractedEnumZodSchema = this.extractedEnumZodSchemaData.find((data) => data.zodSchemaName === zodSchemaName); if (extractedEnumZodSchema) { return formatTag(extractedEnumZodSchema.tag ?? this.options.defaultTag); diff --git a/src/generators/core/getMetadataFromOpenAPIDoc.test.ts b/src/generators/core/getMetadataFromOpenAPIDoc.test.ts index bd2a4db7..673adce1 100644 --- a/src/generators/core/getMetadataFromOpenAPIDoc.test.ts +++ b/src/generators/core/getMetadataFromOpenAPIDoc.test.ts @@ -428,4 +428,23 @@ describe("getMetadataFromOpenAPIDoc", () => { expect(metadata.models).toEqual(models(extractEnums)); expect(metadata.queries).toEqual(queries); }); + + test("uses common model namespace and import path when modelsInCommon is enabled with includeTags", async () => { + const openApiDoc = (await SwaggerParser.bundle("./test/petstore.yaml")) as OpenAPIV3.Document; + + const metadata = await getMetadataFromOpenAPIDoc(openApiDoc, { + ...DEFAULT_GENERATE_OPTIONS, + includeTags: ["pet"], + modelsInCommon: true, + excludeRedundantZodSchemas: false, + }); + + expect(metadata.models.length).toBeGreaterThan(0); + expect(metadata.models.every((model) => model.namespace === "CommonModels")).toBe(true); + expect(metadata.models.every((model) => model.importPath === "common/common.models")).toBe(true); + + const petQuery = metadata.queries.find((query) => query.name === "useGetById"); + expect(petQuery?.response.namespace).toBe("CommonModels"); + expect(petQuery?.response.importPath).toBe("common/common.models"); + }); }); diff --git a/src/generators/core/resolveConfig.ts b/src/generators/core/resolveConfig.ts index 13927846..e8035c31 100644 --- a/src/generators/core/resolveConfig.ts +++ b/src/generators/core/resolveConfig.ts @@ -4,11 +4,12 @@ import { deepMerge } from "@/generators/utils/object.utils"; export function resolveConfig({ fileConfig = {}, - params: { excludeTags, inlineEndpointsExcludeModules, ...options }, + params: { includeTags, excludeTags, inlineEndpointsExcludeModules, ...options }, }: { fileConfig?: Partial | null; params: Partial< - Omit & { + Omit & { + includeTags: string; excludeTags: string; inlineEndpointsExcludeModules: string; } @@ -16,6 +17,7 @@ export function resolveConfig({ }) { const resolvedConfig = deepMerge(DEFAULT_GENERATE_OPTIONS, fileConfig ?? {}, { ...options, + includeTags: includeTags?.split(","), excludeTags: excludeTags?.split(","), inlineEndpointsExcludeModules: inlineEndpointsExcludeModules?.split(","), }); diff --git a/src/generators/run/generate.runner.ts b/src/generators/run/generate.runner.ts index e51f55e1..6706f762 100644 --- a/src/generators/run/generate.runner.ts +++ b/src/generators/run/generate.runner.ts @@ -1,6 +1,4 @@ -import fs from "fs"; import path from "path"; - import SwaggerParser from "@apidevtools/swagger-parser"; import { OpenAPIV3 } from "openapi-types"; @@ -8,16 +6,9 @@ import { resolveConfig } from "@/generators/core/resolveConfig"; import { generateCodeFromOpenAPIDoc } from "@/generators/generateCodeFromOpenAPIDoc"; import { GenerateFileFormatter } from "@/generators/types/generate"; import { GenerateOptions } from "@/generators/types/options"; -import { writeGenerateFileData } from "@/generators/utils/file.utils"; +import { removeStaleGeneratedFiles, writeGenerateFileData } from "@/generators/utils/file.utils"; import { Profiler } from "@/helpers/profile.helper"; -const CACHE_FILE_NAME = ".openapi-codegen-cache.json"; - -type CacheData = { - openApiHash: string; - optionsHash: string; -}; - type GenerateStats = { generatedFilesCount: number; generatedModulesCount: number; @@ -31,7 +22,8 @@ export async function runGenerate({ }: { fileConfig?: Partial | null; params?: Partial< - Omit & { + Omit & { + includeTags: string; excludeTags: string; inlineEndpointsExcludeModules: string; } @@ -42,27 +34,17 @@ export async function runGenerate({ const config = profiler.runSync("config.resolve", () => resolveConfig({ fileConfig, params: params ?? {} })); const openApiDoc = await getOpenApiDoc(config.input, profiler); - const openApiHash = hashString(stableStringify(openApiDoc)); - const optionsHash = hashString(stableStringify(getCacheableConfig(config))); - const cacheFilePath = path.resolve(config.output, CACHE_FILE_NAME); - - if (config.incremental) { - const cached = readCache(cacheFilePath); - if (cached && cached.openApiHash === openApiHash && cached.optionsHash === optionsHash) { - return { skipped: true, config, stats: { generatedFilesCount: 0, generatedModulesCount: 0 } }; - } - } - const filesData = profiler.runSync("generate.total", () => generateCodeFromOpenAPIDoc(openApiDoc, config, profiler)); + if (config.clearOutput) { + profiler.runSync("files.removeStaleGenerated", () => { + removeStaleGeneratedFiles({ output: config.output, filesData, options: config }); + }); + } await profiler.runAsync("files.write", async () => { await writeGenerateFileData(filesData, { formatGeneratedFile }); }); const stats = getGenerateStats(filesData, config); - if (config.incremental) { - await writeCache(cacheFilePath, { openApiHash, optionsHash }, formatGeneratedFile); - } - return { skipped: false, config, stats }; } @@ -115,53 +97,6 @@ function hasExternalRef(value: unknown): boolean { return false; } -function getCacheableConfig(config: GenerateOptions) { - const { output, incremental, ...cacheableConfig } = config; - void output; - void incremental; - return cacheableConfig; -} - -function readCache(filePath: string): CacheData | null { - if (!fs.existsSync(filePath)) { - return null; - } - try { - return JSON.parse(fs.readFileSync(filePath, "utf-8")) as CacheData; - } catch { - return null; - } -} - -async function writeCache(filePath: string, data: CacheData, formatGeneratedFile?: GenerateFileFormatter) { - await writeGenerateFileData([{ fileName: filePath, content: JSON.stringify(data) }], { - formatGeneratedFile, - }); -} - -function hashString(input: string) { - let hash = 2166136261; - for (let i = 0; i < input.length; i += 1) { - hash ^= input.charCodeAt(i); - hash = Math.imul(hash, 16777619); - } - return (hash >>> 0).toString(16); -} - -function stableStringify(input: unknown): string { - if (input === null || typeof input !== "object") { - return JSON.stringify(input); - } - - if (Array.isArray(input)) { - return `[${input.map((item) => stableStringify(item)).join(",")}]`; - } - - const obj = input as Record; - const keys = Object.keys(obj).sort((a, b) => a.localeCompare(b)); - return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`).join(",")}}`; -} - function getGenerateStats(filesData: { fileName: string }[], config: GenerateOptions): GenerateStats { const generatedFilesCount = filesData.length; if (generatedFilesCount === 0) { diff --git a/src/generators/types/options.ts b/src/generators/types/options.ts index 5b2e38a7..e4cf9a55 100644 --- a/src/generators/types/options.ts +++ b/src/generators/types/options.ts @@ -65,9 +65,11 @@ interface GenerateConfig { interface BaseGenerateOptions { input: string; output: string; + clearOutput?: boolean; incremental?: boolean; splitByTags: boolean; defaultTag: string; + includeTags: string[]; excludeTags: string[]; excludePathRegex: string; excludeRedundantZodSchemas: boolean; diff --git a/src/generators/utils/file.utils.ts b/src/generators/utils/file.utils.ts index 0a037d7f..cbb16b51 100644 --- a/src/generators/utils/file.utils.ts +++ b/src/generators/utils/file.utils.ts @@ -3,6 +3,7 @@ import path from "path"; import { fileURLToPath } from "url"; import { GenerateFileData, GenerateFileFormatter } from "@/generators/types/generate"; +import { GenerateOptions } from "@/generators/types/options"; function readFileSync(filePath: string) { const moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -75,3 +76,91 @@ export async function writeGenerateFileData(filesData: GenerateFileData[], optio await writeFile(file, options); } } + +export function removeStaleGeneratedFiles({ + output, + filesData, + options, +}: { + output: string; + filesData: GenerateFileData[]; + options: Pick; +}) { + if (!fs.existsSync(output)) { + return; + } + + const expectedFiles = new Set(filesData.map((file) => path.resolve(file.fileName))); + const generatedSuffixes = new Set(Object.values(options.configs).map((config) => config.outputFileNameSuffix)); + const staleFiles: string[] = []; + + const visit = (dirPath: string) => { + for (const dirent of fs.readdirSync(dirPath, { withFileTypes: true })) { + const entryPath = path.join(dirPath, dirent.name); + if (dirent.isDirectory()) { + visit(entryPath); + continue; + } + + if (isGeneratedFile(entryPath, output, generatedSuffixes) && !expectedFiles.has(path.resolve(entryPath))) { + staleFiles.push(entryPath); + } + } + }; + + visit(output); + + staleFiles.forEach((filePath) => fs.rmSync(filePath, { force: true })); + removeEmptyDirectories(output); +} + +function isGeneratedFile(filePath: string, output: string, generatedSuffixes: Set) { + const relativePath = path.relative(output, filePath); + if (relativePath === ".openapi-codegen-cache.json") { + return true; + } + + const normalizedRelativePath = relativePath.split(path.sep).join("/"); + if (["app-rest-client.ts", "queryModules.ts", "acl/app.ability.ts"].includes(normalizedRelativePath)) { + return true; + } + + const parsedPath = path.parse(filePath); + if (parsedPath.ext !== ".ts") { + return false; + } + + const segments = relativePath.split(path.sep).filter(Boolean); + if (segments.length < 2) { + return false; + } + + const moduleName = segments[0]; + const fileName = segments[segments.length - 1]; + if (!fileName.startsWith(`${moduleName}.`)) { + return false; + } + + const suffix = fileName.slice(moduleName.length + 1).replace(/\.tsx?$/, ""); + return generatedSuffixes.has(suffix); +} + +function removeEmptyDirectories(root: string) { + if (!fs.existsSync(root)) { + return; + } + + const removeIfEmpty = (dirPath: string) => { + for (const dirent of fs.readdirSync(dirPath, { withFileTypes: true })) { + if (dirent.isDirectory()) { + removeIfEmpty(path.join(dirPath, dirent.name)); + } + } + + if (dirPath !== root && fs.readdirSync(dirPath).length === 0) { + fs.rmdirSync(dirPath); + } + }; + + removeIfEmpty(root); +} diff --git a/src/generators/utils/object.utils.test.ts b/src/generators/utils/object.utils.test.ts index ad888243..5acc9c33 100644 --- a/src/generators/utils/object.utils.test.ts +++ b/src/generators/utils/object.utils.test.ts @@ -162,7 +162,7 @@ describe("Utils: object", () => { output: "output", splitByTags: true, defaultTag: "Common", - excludeTags: [], + includeTags: [], excludePathRegex: "", excludeRedundantZodSchemas: true, tsNamespaces: true, @@ -200,7 +200,7 @@ describe("Utils: object", () => { tsNamespaces: undefined, splitByTags: undefined, defaultTag: undefined, - excludeTags: undefined, + includeTags: undefined, excludePathRegex: undefined, excludeRedundantZodSchemas: undefined, importPath: undefined, diff --git a/src/generators/utils/operation.utils.ts b/src/generators/utils/operation.utils.ts index 857cf6a8..97479d5a 100644 --- a/src/generators/utils/operation.utils.ts +++ b/src/generators/utils/operation.utils.ts @@ -9,13 +9,13 @@ import { invalidVariableNameCharactersToCamel } from "./js.utils"; import { pick } from "./object.utils"; import { isPathExcluded, pathToVariableName } from "./openapi.utils"; import { capitalize, removeWord } from "./string.utils"; -import { getOperationTag, isTagExcluded } from "./tag.utils"; +import { getOperationTag, isTagIncluded } from "./tag.utils"; export function isOperationExcluded(operation: OperationObject, options: GenerateOptions) { const isDeprecated = operation.deprecated && !options.withDeprecatedEndpoints; const tag = getOperationTag(operation, options); - const isExcluded = isTagExcluded(tag, options); - return isDeprecated || isExcluded; + const isIncluded = isTagIncluded(tag, options); + return isDeprecated || !isIncluded; } export function getOperationName({ diff --git a/src/generators/utils/tag.utils.test.ts b/src/generators/utils/tag.utils.test.ts new file mode 100644 index 00000000..928253a7 --- /dev/null +++ b/src/generators/utils/tag.utils.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "vitest"; +import { DEFAULT_GENERATE_OPTIONS } from "@/generators/const/options.const"; +import { isTagIncluded } from "./tag.utils"; + +describe("Utils: tag", () => { + describe("isTagIncluded", () => { + const options = DEFAULT_GENERATE_OPTIONS; + + test("includes all when both are empty", () => { + expect(isTagIncluded("auth", { ...options, includeTags: [], excludeTags: [] })).toBe(true); + }); + + test("includes only includeTags when specified", () => { + const config = { ...options, includeTags: ["auth", "user"], excludeTags: [] }; + expect(isTagIncluded("auth", config)).toBe(true); + expect(isTagIncluded("user", config)).toBe(true); + expect(isTagIncluded("other", config)).toBe(false); + }); + + test("excludes excludeTags when includeTags is empty", () => { + const config = { ...options, includeTags: [], excludeTags: ["auth", "user"] }; + expect(isTagIncluded("auth", config)).toBe(false); + expect(isTagIncluded("user", config)).toBe(false); + expect(isTagIncluded("other", config)).toBe(true); + }); + + test("includeTags has high priority over excludeTags", () => { + const config = { ...options, includeTags: ["auth"], excludeTags: ["auth"] }; + // If it's in includeTags, it should be included regardless of excludeTags + expect(isTagIncluded("auth", config)).toBe(true); + }); + + test("includeTags overrides excludeTags (only includeTags are considered)", () => { + const config = { ...options, includeTags: ["auth"], excludeTags: ["user"] }; + expect(isTagIncluded("auth", config)).toBe(true); + expect(isTagIncluded("user", config)).toBe(false); // Not in includeTags + expect(isTagIncluded("other", config)).toBe(false); // Not in includeTags + }); + + test("case insensitive matching", () => { + expect(isTagIncluded("AUTH", { ...options, includeTags: ["auth"], excludeTags: [] })).toBe(true); + expect(isTagIncluded("auth", { ...options, includeTags: ["AUTH"], excludeTags: [] })).toBe(true); + expect(isTagIncluded("AUTH", { ...options, includeTags: [], excludeTags: ["auth"] })).toBe(false); + }); + + test("matches human-readable includeTags against normalized operation tags", () => { + expect( + isTagIncluded("WorkingDocumentsTemplatedDocument", { + ...options, + includeTags: ["WorkingDocuments - templated-document"], + excludeTags: [], + }), + ).toBe(true); + }); + + test("matches human-readable excludeTags against normalized operation tags", () => { + expect( + isTagIncluded("WorkingDocumentsTemplatedDocument", { + ...options, + includeTags: [], + excludeTags: ["WorkingDocuments - templated-document"], + }), + ).toBe(false); + }); + }); +}); diff --git a/src/generators/utils/tag.utils.ts b/src/generators/utils/tag.utils.ts index d46cdcbe..1e670116 100644 --- a/src/generators/utils/tag.utils.ts +++ b/src/generators/utils/tag.utils.ts @@ -18,8 +18,15 @@ export function getEndpointTag(endpoint: Endpoint, options: GenerateOptions) { return formatTag(tag ?? options.defaultTag); } -export function isTagExcluded(tag: string, options: GenerateOptions) { - return options.excludeTags.some((excludeTag) => excludeTag.toLowerCase() === tag.toLowerCase()); +export function isTagIncluded(tag: string, options: GenerateOptions) { + const normalizedTag = formatTag(tag).toLowerCase(); + if (options.includeTags.some((includeTag) => formatTag(includeTag).toLowerCase() === normalizedTag)) { + return true; + } + if (options.excludeTags.some((excludeTag) => formatTag(excludeTag).toLowerCase() === normalizedTag)) { + return false; + } + return options.includeTags.length === 0; } export function shouldInlineEndpointsForTag(tag: string, options: GenerateOptions) { diff --git a/src/vite/openapi-codegen.plugin.ts b/src/vite/openapi-codegen.plugin.ts index 108f1299..ae923564 100644 --- a/src/vite/openapi-codegen.plugin.ts +++ b/src/vite/openapi-codegen.plugin.ts @@ -28,6 +28,7 @@ export function openApiCodegen(config: OpenApiCodegenViteConfig): Plugin { const profiler = new Profiler(process.env.OPENAPI_CODEGEN_PROFILE === "1"); await runGenerate({ fileConfig, formatGeneratedFile, profiler }); }); + return queue; }; const setupWatcher = (server: ViteDevServer) => { @@ -49,10 +50,11 @@ export function openApiCodegen(config: OpenApiCodegenViteConfig): Plugin { configResolved(config) { resolvedViteConfig = config; }, - buildStart() { - enqueueGenerate(); + async buildStart() { + await enqueueGenerate(); }, - configureServer(server) { + async configureServer(server) { + await enqueueGenerate(); setupWatcher(server); }, }; diff --git a/test/config.ts b/test/config.ts index f4b09b72..531b4c21 100644 --- a/test/config.ts +++ b/test/config.ts @@ -3,7 +3,7 @@ import { OpenAPICodegenConfig } from "../src/generators/types/config"; export const config: OpenAPICodegenConfig = { input: "http://127.0.0.1:4000/docs-json", output: "./output", - excludeTags: ["auth"], + includeTags: ["auth"], replaceOptionalWithNullish: true, builderConfigs: true, infiniteQueries: true,