From c0ef5fa33a22e2dc9f0ca3f4c7030892738aab92 Mon Sep 17 00:00:00 2001 From: Jure Rotar Date: Thu, 19 Mar 2026 15:35:34 +0100 Subject: [PATCH 01/27] feat: replaced excludeTags with includeTags --- README.md | 4 ++-- openapi-codegen.config.mjs | 2 +- src/commands/check.command.ts | 4 ++-- src/commands/check.ts | 4 ++-- src/commands/generate.command.ts | 4 ++-- src/commands/generate.ts | 2 +- src/generators/const/options.const.ts | 2 +- src/generators/core/resolveConfig.ts | 8 ++++---- src/generators/run/generate.runner.ts | 4 ++-- src/generators/types/options.ts | 2 +- src/generators/utils/object.utils.test.ts | 4 ++-- src/generators/utils/operation.utils.ts | 6 +++--- src/generators/utils/tag.utils.ts | 7 +++++-- test/config.ts | 2 +- 14 files changed, 29 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index b5b270a6..3ee7a8c8 100644 --- a/README.md +++ b/README.md @@ -76,7 +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') - --excludeTags Comma-separated list of tags to exclude from generation + --includeTags Comma-separated list of tags to include in generation --excludePathRegex Exclude operations whose paths match the given regular expression --excludeRedundantZodSchemas Exclude any redundant Zod schemas (default: true) @@ -115,7 +115,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') - --excludeTags Comma-separated list of tags to exclude from generation + --includeTags Comma-separated list of tags to include in 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/src/commands/check.command.ts b/src/commands/check.command.ts index 086bfda0..bbe719d2 100644 --- a/src/commands/check.command.ts +++ b/src/commands/check.command.ts @@ -19,8 +19,8 @@ class CheckOptions implements CheckParams { @YargOption({ envAlias: "defaultTag" }) defaultTag?: string; - @YargOption({ envAlias: "excludeTags" }) - excludeTags?: string; + @YargOption({ envAlias: "includeTags" }) + includeTags?: string; @YargOption({ envAlias: "excludePathRegex" }) excludePathRegex?: string; diff --git a/src/commands/check.ts b/src/commands/check.ts index 7d33b5ff..09959c61 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -9,11 +9,11 @@ import SwaggerParser from "@apidevtools/swagger-parser"; export type CheckParams = { config?: string; - excludeTags?: string; + includeTags?: string; verbose?: boolean; } & Partial>; -export async function check({ verbose, config: configParam, excludeTags: _excludeTagsParam, ...params }: CheckParams) { +export async function check({ verbose, config: configParam, includeTags: _includeTagsParam, ...params }: CheckParams) { const start = Date.now(); if (verbose) { diff --git a/src/commands/generate.command.ts b/src/commands/generate.command.ts index f3181adf..31367ffc 100644 --- a/src/commands/generate.command.ts +++ b/src/commands/generate.command.ts @@ -31,8 +31,8 @@ class GenerateOptions implements GenerateParams { @YargOption({ envAlias: "defaultTag" }) defaultTag?: string; - @YargOption({ envAlias: "excludeTags" }) - excludeTags?: string; + @YargOption({ envAlias: "includeTags" }) + includeTags?: string; @YargOption({ envAlias: "excludePathRegex" }) excludePathRegex?: string; diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 421b0790..d6689c4c 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -7,7 +7,7 @@ import { Profiler } from "@/helpers/profile.helper"; export type GenerateParams = { config?: string; - excludeTags?: string; + includeTags?: string; inlineEndpointsExcludeModules?: string; prettier?: boolean; verbose?: boolean; diff --git a/src/generators/const/options.const.ts b/src/generators/const/options.const.ts index fcf5d473..51b509a1 100644 --- a/src/generators/const/options.const.ts +++ b/src/generators/const/options.const.ts @@ -11,7 +11,7 @@ export const DEFAULT_GENERATE_OPTIONS: GenerateOptions = { incremental: true, splitByTags: true, defaultTag: "Common", - excludeTags: [], + includeTags: [], excludePathRegex: "", excludeRedundantZodSchemas: true, tsNamespaces: true, diff --git a/src/generators/core/resolveConfig.ts b/src/generators/core/resolveConfig.ts index 13927846..13040e4d 100644 --- a/src/generators/core/resolveConfig.ts +++ b/src/generators/core/resolveConfig.ts @@ -4,19 +4,19 @@ import { deepMerge } from "@/generators/utils/object.utils"; export function resolveConfig({ fileConfig = {}, - params: { excludeTags, inlineEndpointsExcludeModules, ...options }, + params: { includeTags, inlineEndpointsExcludeModules, ...options }, }: { fileConfig?: Partial | null; params: Partial< - Omit & { - excludeTags: string; + Omit & { + includeTags: string; inlineEndpointsExcludeModules: string; } >; }) { const resolvedConfig = deepMerge(DEFAULT_GENERATE_OPTIONS, fileConfig ?? {}, { ...options, - excludeTags: excludeTags?.split(","), + includeTags: includeTags?.split(","), inlineEndpointsExcludeModules: inlineEndpointsExcludeModules?.split(","), }); resolvedConfig.checkAcl = resolvedConfig.acl && resolvedConfig.checkAcl; diff --git a/src/generators/run/generate.runner.ts b/src/generators/run/generate.runner.ts index e51f55e1..11293340 100644 --- a/src/generators/run/generate.runner.ts +++ b/src/generators/run/generate.runner.ts @@ -31,8 +31,8 @@ export async function runGenerate({ }: { fileConfig?: Partial | null; params?: Partial< - Omit & { - excludeTags: string; + Omit & { + includeTags: string; inlineEndpointsExcludeModules: string; } >; diff --git a/src/generators/types/options.ts b/src/generators/types/options.ts index 5b2e38a7..07569ce4 100644 --- a/src/generators/types/options.ts +++ b/src/generators/types/options.ts @@ -68,7 +68,7 @@ interface BaseGenerateOptions { incremental?: boolean; splitByTags: boolean; defaultTag: string; - excludeTags: string[]; + includeTags: string[]; excludePathRegex: string; excludeRedundantZodSchemas: boolean; tsNamespaces: boolean; 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.ts b/src/generators/utils/tag.utils.ts index d46cdcbe..a9a52bb2 100644 --- a/src/generators/utils/tag.utils.ts +++ b/src/generators/utils/tag.utils.ts @@ -18,8 +18,11 @@ 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) { + if (options.includeTags.length === 0) { + return true; + } + return options.includeTags.some((includeTag) => includeTag.toLowerCase() === tag.toLowerCase()); } export function shouldInlineEndpointsForTag(tag: string, options: GenerateOptions) { 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, From 2726fd7ec71d94611b631e8f888304d94fc43a5b Mon Sep 17 00:00:00 2001 From: Jure Rotar Date: Thu, 19 Mar 2026 15:41:15 +0100 Subject: [PATCH 02/27] feat: added both includeTags and excludeTags --- README.md | 2 ++ src/commands/check.command.ts | 3 ++ src/commands/check.ts | 9 ++++- src/commands/generate.command.ts | 3 ++ src/commands/generate.ts | 1 + src/generators/const/options.const.ts | 1 + src/generators/core/resolveConfig.ts | 6 ++-- src/generators/run/generate.runner.ts | 3 +- src/generators/types/options.ts | 1 + src/generators/utils/tag.utils.test.ts | 46 ++++++++++++++++++++++++++ src/generators/utils/tag.utils.ts | 7 ++-- 11 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 src/generators/utils/tag.utils.test.ts diff --git a/README.md b/README.md index 3ee7a8c8..2d69d188 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ yarn openapi-codegen generate --config my-config.ts --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) @@ -116,6 +117,7 @@ yarn openapi-codegen generate --config my-config.ts --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/src/commands/check.command.ts b/src/commands/check.command.ts index bbe719d2..22a25880 100644 --- a/src/commands/check.command.ts +++ b/src/commands/check.command.ts @@ -22,6 +22,9 @@ class CheckOptions implements CheckParams { @YargOption({ envAlias: "includeTags" }) includeTags?: string; + @YargOption({ envAlias: "excludeTags" }) + excludeTags?: string; + @YargOption({ envAlias: "excludePathRegex" }) excludePathRegex?: string; diff --git a/src/commands/check.ts b/src/commands/check.ts index 09959c61..74ac6f90 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -10,10 +10,17 @@ 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, includeTags: _includeTagsParam, ...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 31367ffc..6ff2da7e 100644 --- a/src/commands/generate.command.ts +++ b/src/commands/generate.command.ts @@ -34,6 +34,9 @@ class GenerateOptions implements GenerateParams { @YargOption({ envAlias: "includeTags" }) includeTags?: string; + @YargOption({ envAlias: "excludeTags" }) + excludeTags?: string; + @YargOption({ envAlias: "excludePathRegex" }) excludePathRegex?: string; diff --git a/src/commands/generate.ts b/src/commands/generate.ts index d6689c4c..773feee2 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -8,6 +8,7 @@ import { Profiler } from "@/helpers/profile.helper"; export type GenerateParams = { config?: string; includeTags?: string; + excludeTags?: string; inlineEndpointsExcludeModules?: string; prettier?: boolean; verbose?: boolean; diff --git a/src/generators/const/options.const.ts b/src/generators/const/options.const.ts index 51b509a1..41a6101a 100644 --- a/src/generators/const/options.const.ts +++ b/src/generators/const/options.const.ts @@ -12,6 +12,7 @@ export const DEFAULT_GENERATE_OPTIONS: GenerateOptions = { splitByTags: true, defaultTag: "Common", includeTags: [], + excludeTags: [], excludePathRegex: "", excludeRedundantZodSchemas: true, tsNamespaces: true, diff --git a/src/generators/core/resolveConfig.ts b/src/generators/core/resolveConfig.ts index 13040e4d..e8035c31 100644 --- a/src/generators/core/resolveConfig.ts +++ b/src/generators/core/resolveConfig.ts @@ -4,12 +4,13 @@ import { deepMerge } from "@/generators/utils/object.utils"; export function resolveConfig({ fileConfig = {}, - params: { includeTags, inlineEndpointsExcludeModules, ...options }, + params: { includeTags, excludeTags, inlineEndpointsExcludeModules, ...options }, }: { fileConfig?: Partial | null; params: Partial< - Omit & { + Omit & { includeTags: string; + excludeTags: string; inlineEndpointsExcludeModules: string; } >; @@ -17,6 +18,7 @@ export function resolveConfig({ const resolvedConfig = deepMerge(DEFAULT_GENERATE_OPTIONS, fileConfig ?? {}, { ...options, includeTags: includeTags?.split(","), + excludeTags: excludeTags?.split(","), inlineEndpointsExcludeModules: inlineEndpointsExcludeModules?.split(","), }); resolvedConfig.checkAcl = resolvedConfig.acl && resolvedConfig.checkAcl; diff --git a/src/generators/run/generate.runner.ts b/src/generators/run/generate.runner.ts index 11293340..ed5ca39d 100644 --- a/src/generators/run/generate.runner.ts +++ b/src/generators/run/generate.runner.ts @@ -31,8 +31,9 @@ export async function runGenerate({ }: { fileConfig?: Partial | null; params?: Partial< - Omit & { + Omit & { includeTags: string; + excludeTags: string; inlineEndpointsExcludeModules: string; } >; diff --git a/src/generators/types/options.ts b/src/generators/types/options.ts index 07569ce4..2a2aa029 100644 --- a/src/generators/types/options.ts +++ b/src/generators/types/options.ts @@ -69,6 +69,7 @@ interface BaseGenerateOptions { splitByTags: boolean; defaultTag: string; includeTags: string[]; + excludeTags: string[]; excludePathRegex: string; excludeRedundantZodSchemas: boolean; tsNamespaces: boolean; diff --git a/src/generators/utils/tag.utils.test.ts b/src/generators/utils/tag.utils.test.ts new file mode 100644 index 00000000..6cb23df9 --- /dev/null +++ b/src/generators/utils/tag.utils.test.ts @@ -0,0 +1,46 @@ +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); + }); + }); +}); diff --git a/src/generators/utils/tag.utils.ts b/src/generators/utils/tag.utils.ts index a9a52bb2..05eb2056 100644 --- a/src/generators/utils/tag.utils.ts +++ b/src/generators/utils/tag.utils.ts @@ -19,10 +19,13 @@ export function getEndpointTag(endpoint: Endpoint, options: GenerateOptions) { } export function isTagIncluded(tag: string, options: GenerateOptions) { - if (options.includeTags.length === 0) { + if (options.includeTags.some((includeTag) => includeTag.toLowerCase() === tag.toLowerCase())) { return true; } - return options.includeTags.some((includeTag) => includeTag.toLowerCase() === tag.toLowerCase()); + if (options.excludeTags.some((excludeTag) => excludeTag.toLowerCase() === tag.toLowerCase())) { + return false; + } + return options.includeTags.length === 0; } export function shouldInlineEndpointsForTag(tag: string, options: GenerateOptions) { From 365073153988da149fa460081cd19174abfec7a7 Mon Sep 17 00:00:00 2001 From: Jure Rotar Date: Thu, 19 Mar 2026 15:42:23 +0100 Subject: [PATCH 03/27] chore: bumped version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2843d676..9ae019ec 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.27", "keywords": [ "codegen", "openapi", From 3c24f7eca1ba5605a8f39337992fcecfc1040a4f Mon Sep 17 00:00:00 2001 From: Jure Rotar Date: Fri, 20 Mar 2026 07:15:42 +0100 Subject: [PATCH 04/27] chore: bumped vite peer dep version --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 9ae019ec..49d8ea71 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.8-rc.27", + "version": "2.0.8-rc.28", "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": { From f63edf4b7357fa9aef4a15dcda298d05ddd330a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urban=20Lavbi=C4=8D?= Date: Sat, 21 Mar 2026 19:51:44 +0100 Subject: [PATCH 05/27] release: v2.0.8-rc.29 --- package.json | 2 +- src/generators/core/SchemaResolver.class.ts | 4 ++++ .../core/getMetadataFromOpenAPIDoc.test.ts | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 49d8ea71..21abc389 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.8-rc.28", + "version": "2.0.8-rc.29", "keywords": [ "codegen", "openapi", 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"); + }); }); From 915f9fd5dc69d44cfb0ae74f7b32ffa4c17a9aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urban=20Lavbi=C4=8D?= Date: Sat, 21 Mar 2026 20:00:02 +0100 Subject: [PATCH 06/27] release: v2.0.8-rc.30 --- package.json | 2 +- src/generators/utils/tag.utils.test.ts | 20 ++++++++++++++++++++ src/generators/utils/tag.utils.ts | 5 +++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 21abc389..d43c022d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.8-rc.29", + "version": "2.0.8-rc.30", "keywords": [ "codegen", "openapi", diff --git a/src/generators/utils/tag.utils.test.ts b/src/generators/utils/tag.utils.test.ts index 6cb23df9..928253a7 100644 --- a/src/generators/utils/tag.utils.test.ts +++ b/src/generators/utils/tag.utils.test.ts @@ -42,5 +42,25 @@ describe("Utils: tag", () => { 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 05eb2056..1e670116 100644 --- a/src/generators/utils/tag.utils.ts +++ b/src/generators/utils/tag.utils.ts @@ -19,10 +19,11 @@ export function getEndpointTag(endpoint: Endpoint, options: GenerateOptions) { } export function isTagIncluded(tag: string, options: GenerateOptions) { - if (options.includeTags.some((includeTag) => includeTag.toLowerCase() === tag.toLowerCase())) { + const normalizedTag = formatTag(tag).toLowerCase(); + if (options.includeTags.some((includeTag) => formatTag(includeTag).toLowerCase() === normalizedTag)) { return true; } - if (options.excludeTags.some((excludeTag) => excludeTag.toLowerCase() === tag.toLowerCase())) { + if (options.excludeTags.some((excludeTag) => formatTag(excludeTag).toLowerCase() === normalizedTag)) { return false; } return options.includeTags.length === 0; From 5f43b321decd6394e77ea49aff8c1d3cf793c469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urban=20Lavbi=C4=8D?= Date: Sat, 21 Mar 2026 20:08:52 +0100 Subject: [PATCH 07/27] release: v2.0.8-rc.31 --- package.json | 2 +- src/commands/generate.command.ts | 3 ++ src/commands/generate.ts | 1 + src/generators/const/options.const.ts | 1 + src/generators/run/generate.runner.ts | 76 ++------------------------- src/generators/types/options.ts | 1 + 6 files changed, 12 insertions(+), 72 deletions(-) diff --git a/package.json b/package.json index d43c022d..3370cd8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.8-rc.30", + "version": "2.0.8-rc.31", "keywords": [ "codegen", "openapi", diff --git a/src/commands/generate.command.ts b/src/commands/generate.command.ts index 6ff2da7e..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; diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 773feee2..1f81e08c 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -17,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 41a6101a..9fca246e 100644 --- a/src/generators/const/options.const.ts +++ b/src/generators/const/options.const.ts @@ -8,6 +8,7 @@ export const DEFAULT_GENERATE_OPTIONS: GenerateOptions = { // Base options input: "http://localhost:4000/docs-json/", output: "output", + clearOutput: false, incremental: true, splitByTags: true, defaultTag: "Common", diff --git a/src/generators/run/generate.runner.ts b/src/generators/run/generate.runner.ts index ed5ca39d..6cc653aa 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"; @@ -11,13 +9,6 @@ import { GenerateOptions } from "@/generators/types/options"; import { 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; @@ -43,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.clearOutput", () => { + fs.rmSync(config.output, { force: true, recursive: true }); + }); + } 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 }; } @@ -116,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 2a2aa029..e4cf9a55 100644 --- a/src/generators/types/options.ts +++ b/src/generators/types/options.ts @@ -65,6 +65,7 @@ interface GenerateConfig { interface BaseGenerateOptions { input: string; output: string; + clearOutput?: boolean; incremental?: boolean; splitByTags: boolean; defaultTag: string; From 9fcdd897f957ed95970b9df27f72bfc5004316a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urban=20Lavbi=C4=8D?= Date: Sat, 21 Mar 2026 20:12:19 +0100 Subject: [PATCH 08/27] release: v2.0.8-rc.32 --- package.json | 2 +- src/generators/run/generate.runner.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 3370cd8b..57cca498 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.8-rc.31", + "version": "2.0.8-rc.32", "keywords": [ "codegen", "openapi", diff --git a/src/generators/run/generate.runner.ts b/src/generators/run/generate.runner.ts index 6cc653aa..d6dca3e0 100644 --- a/src/generators/run/generate.runner.ts +++ b/src/generators/run/generate.runner.ts @@ -1,4 +1,5 @@ import fs from "fs"; +import path from "path"; import SwaggerParser from "@apidevtools/swagger-parser"; import { OpenAPIV3 } from "openapi-types"; From 040123b829ce2e0db729f5e6f0a918a1ee701567 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urban=20Lavbi=C4=8D?= Date: Sat, 21 Mar 2026 20:23:50 +0100 Subject: [PATCH 09/27] release: v2.0.8-rc.33 --- package.json | 2 +- src/generators/run/generate.runner.ts | 7 +-- src/generators/utils/file.utils.ts | 89 +++++++++++++++++++++++++++ src/vite/openapi-codegen.plugin.ts | 8 ++- 4 files changed, 98 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 57cca498..ecda0eef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.8-rc.32", + "version": "2.0.8-rc.33", "keywords": [ "codegen", "openapi", diff --git a/src/generators/run/generate.runner.ts b/src/generators/run/generate.runner.ts index d6dca3e0..6706f762 100644 --- a/src/generators/run/generate.runner.ts +++ b/src/generators/run/generate.runner.ts @@ -1,4 +1,3 @@ -import fs from "fs"; import path from "path"; import SwaggerParser from "@apidevtools/swagger-parser"; import { OpenAPIV3 } from "openapi-types"; @@ -7,7 +6,7 @@ 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"; type GenerateStats = { @@ -37,8 +36,8 @@ export async function runGenerate({ const filesData = profiler.runSync("generate.total", () => generateCodeFromOpenAPIDoc(openApiDoc, config, profiler)); if (config.clearOutput) { - profiler.runSync("files.clearOutput", () => { - fs.rmSync(config.output, { force: true, recursive: true }); + profiler.runSync("files.removeStaleGenerated", () => { + removeStaleGeneratedFiles({ output: config.output, filesData, options: config }); }); } await profiler.runAsync("files.write", async () => { 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/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); }, }; From b683a859c7e54faa0133235ba1aeae0c41df8768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urban=20Lavbi=C4=8D?= Date: Mon, 30 Mar 2026 08:52:20 +0200 Subject: [PATCH 10/27] Improve workspace context --- package.json | 2 +- .../generate/generateQueries.test.ts | 127 ++++++++++++++++++ src/generators/generate/generateQueries.ts | 18 ++- 3 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 src/generators/generate/generateQueries.test.ts diff --git a/package.json b/package.json index ecda0eef..9de294bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.8-rc.33", + "version": "2.0.8-rc.34", "keywords": [ "codegen", "openapi", diff --git a/src/generators/generate/generateQueries.test.ts b/src/generators/generate/generateQueries.test.ts new file mode 100644 index 00000000..c3e86ec1 --- /dev/null +++ b/src/generators/generate/generateQueries.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { OpenAPIV3 } from "openapi-types"; + +import { DEFAULT_GENERATE_OPTIONS } from "@/generators/const/options.const"; +import { generateCodeFromOpenAPIDoc } from "@/generators/generateCodeFromOpenAPIDoc"; + +const openApiDoc = { + openapi: "3.0.0", + info: { + title: "Workspace Context Test", + version: "1.0.0", + }, + paths: { + "/offices/{officeId}/positions/{positionId}": { + get: { + tags: ["Workspace"], + operationId: "getPosition", + "x-acl": [ + { + action: "read", + subject: "Position", + conditions: { + officeId: "$params.officeId", + positionId: "$params.positionId", + }, + }, + ], + parameters: [ + { + name: "officeId", + in: "path", + required: true, + schema: { type: "string" }, + }, + { + name: "positionId", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + 200: { + description: "OK", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Position" }, + }, + }, + }, + 403: { + description: "Forbidden", + }, + }, + } as OpenAPIV3.OperationObject, + }, + "/items/{id}": { + get: { + tags: ["Workspace"], + operationId: "findById", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + 200: { + description: "OK", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Item" }, + }, + }, + }, + }, + } as OpenAPIV3.OperationObject, + }, + }, + components: { + schemas: { + Position: { + type: "object", + properties: { + id: { type: "string" }, + }, + required: ["id"], + }, + Item: { + type: "object", + properties: { + id: { type: "string" }, + }, + required: ["id"], + }, + }, + }, +} as OpenAPIV3.Document; + +describe("generateQueries workspaceContext", () => { + it("maps workspace-resolved values back to the original helper property names", () => { + const files = generateCodeFromOpenAPIDoc(openApiDoc, { + ...DEFAULT_GENERATE_OPTIONS, + output: "test-output", + workspaceContext: true, + builderConfigs: false, + prefetchQueries: false, + }); + + const queriesFile = files.find((file) => file.fileName.endsWith("/workspace/workspace.queries.ts")); + + expect(queriesFile?.content).toContain( + "getPositionQueryOptions({ officeId: officeIdFromWorkspace, positionId: positionIdFromWorkspace })", + ); + expect(queriesFile?.content).toContain( + "return getPositionQueryOptions({ officeId: officeIdFromWorkspace, positionId: positionIdFromWorkspace }).queryFn();", + ); + expect(queriesFile?.content).toContain("findByIdQueryOptions({ id: idFromWorkspace })"); + + expect(queriesFile?.content).not.toContain( + "getPositionQueryOptions({ officeIdFromWorkspace, positionIdFromWorkspace })", + ); + expect(queriesFile?.content).not.toContain("findByIdQueryOptions({ idFromWorkspace })"); + }); +}); diff --git a/src/generators/generate/generateQueries.ts b/src/generators/generate/generateQueries.ts index 622bc720..d38a9409 100644 --- a/src/generators/generate/generateQueries.ts +++ b/src/generators/generate/generateQueries.ts @@ -351,6 +351,20 @@ function renderEndpointArgs( .join(", "); } +function renderEndpointObjectArgs( + resolver: SchemaResolver, + endpoint: Endpoint, + options: Parameters[2], + replacements?: Record, +) { + return getEndpointParamMapping(resolver, endpoint, options) + .map((param) => { + const replacement = replacements?.[param.name]; + return replacement && replacement !== param.name ? `${param.name}: ${replacement}` : param.name; + }) + .join(", "); +} + function renderEndpointParamDescription(endpointParam: ReturnType[0]) { const strs = [`${endpointParam.paramType} parameter`]; const description = endpointParam.parameterObject?.description || endpointParam.bodyObject?.description; @@ -790,7 +804,7 @@ function renderQuery({ ? getWorkspaceParamReplacements(resolver, endpoint) : {}; const endpointArgs = renderEndpointArgs(resolver, endpoint, {}); - const resolvedEndpointArgs = renderEndpointArgs(resolver, endpoint, {}, workspaceParamReplacements); + const resolvedEndpointArgs = renderEndpointObjectArgs(resolver, endpoint, {}, workspaceParamReplacements); const endpointParams = renderEndpointParams(resolver, endpoint, { optionalPathParams: resolver.options.workspaceContext, modelNamespaceTag: tag, @@ -1060,7 +1074,7 @@ function renderInfiniteQuery({ modelNamespaceTag: tag, }); const endpointArgsWithoutPage = renderEndpointArgs(resolver, endpoint, { excludePageParam: true }); - const resolvedEndpointArgsWithoutPage = renderEndpointArgs( + const resolvedEndpointArgsWithoutPage = renderEndpointObjectArgs( resolver, endpoint, { excludePageParam: true }, From 985a6f91826a9d64c36a5e6278d863a5eb5035a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Urban=20Lavbi=C4=8D?= Date: Mon, 30 Mar 2026 09:12:36 +0200 Subject: [PATCH 11/27] Improve workspace context --- README.md | 8 ++--- package.json | 2 +- src/commands/generate.command.ts | 4 +-- src/commands/generate.ts | 2 +- src/generators/const/options.const.ts | 2 +- src/generators/core/resolveConfig.test.ts | 15 +++++++++ src/generators/core/resolveConfig.ts | 9 ++++-- .../generate/generateQueries.test.ts | 32 ++++++++++++++++++- src/generators/generate/generateQueries.ts | 9 ++++-- src/generators/run/generate.runner.ts | 3 +- src/generators/types/options.ts | 2 +- .../generate/generate.endpoints.utils.ts | 6 ++-- 12 files changed, 76 insertions(+), 18 deletions(-) create mode 100644 src/generators/core/resolveConfig.test.ts diff --git a/README.md b/README.md index 2d69d188..d7a5aaf0 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ yarn openapi-codegen generate --config my-config.ts --axiosRequestConfig Include Axios request config parameters in query hooks (default: false) --infiniteQueries Generate infinite queries for paginated API endpoints (default: false) --mutationEffects Add mutation effects options to mutation hooks (default: true) - --workspaceContext Allow generated hooks to resolve path/ACL params from OpenApiWorkspaceContext (default: false) + --workspaceContext Comma-separated list of path/ACL params that generated hooks may resolve from OpenApiWorkspaceContext --inlineEndpoints Inline endpoint implementations into generated query files (default: false) --inlineEndpointsExcludeModules Comma-separated modules/tags to keep as separate API files while inlineEndpoints=true --modelsOnly Generate only model files (default: false) @@ -196,18 +196,18 @@ export default config; ### OpenApiWorkspaceContext (Path + ACL defaults) -Enable `workspaceContext: true` in codegen config (or pass `--workspaceContext`) and wrap your app subtree with `OpenApiWorkspaceContext.Provider` if generated hooks frequently repeat workspace-scoped params (for example `officeId`). +Set `workspaceContext` to a list of param names in codegen config (or pass `--workspaceContext officeId,projectId`) and wrap your app subtree with `OpenApiWorkspaceContext.Provider` if generated hooks frequently repeat workspace-scoped params. ```tsx import { OpenApiWorkspaceContext } from "@povio/openapi-codegen-cli"; -// openapi-codegen.config.ts -> { workspaceContext: true } +// openapi-codegen.config.ts -> { workspaceContext: ["officeId", "projectId"] } ; ``` -Generated query/mutation hooks can then omit matching path/ACL params and resolve them from `OpenApiWorkspaceContext`. +Generated query/mutation hooks can then omit only those matching path/ACL params and resolve them from `OpenApiWorkspaceContext`. Params not listed in `workspaceContext` remain explicit and required. ### Generation Modes diff --git a/package.json b/package.json index 9de294bb..e009b3d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.8-rc.34", + "version": "2.0.8-rc.35", "keywords": [ "codegen", "openapi", diff --git a/src/commands/generate.command.ts b/src/commands/generate.command.ts index 39b60156..a16b5db2 100644 --- a/src/commands/generate.command.ts +++ b/src/commands/generate.command.ts @@ -76,8 +76,8 @@ class GenerateOptions implements GenerateParams { @YargOption({ envAlias: "mutationEffects", type: "boolean" }) mutationEffects?: boolean; - @YargOption({ envAlias: "workspaceContext", type: "boolean" }) - workspaceContext?: boolean; + @YargOption({ envAlias: "workspaceContext" }) + workspaceContext?: string; @YargOption({ envAlias: "parseRequestParams", type: "boolean" }) parseRequestParams?: boolean; diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 1f81e08c..cfb4944e 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -10,6 +10,7 @@ export type GenerateParams = { includeTags?: string; excludeTags?: string; inlineEndpointsExcludeModules?: string; + workspaceContext?: string; prettier?: boolean; verbose?: boolean; } & Partial< @@ -35,7 +36,6 @@ export type GenerateParams = { | "infiniteQueries" | "axiosRequestConfig" | "mutationEffects" - | "workspaceContext" | "parseRequestParams" | "inlineEndpoints" | "builderConfigs" diff --git a/src/generators/const/options.const.ts b/src/generators/const/options.const.ts index 9fca246e..e610f68d 100644 --- a/src/generators/const/options.const.ts +++ b/src/generators/const/options.const.ts @@ -62,7 +62,7 @@ export const DEFAULT_GENERATE_OPTIONS: GenerateOptions = { queryTypesImportPath: PACKAGE_IMPORT_PATH, axiosRequestConfig: false, mutationEffects: true, - workspaceContext: false, + workspaceContext: [], prefetchQueries: true, // Infinite queries options infiniteQueries: false, diff --git a/src/generators/core/resolveConfig.test.ts b/src/generators/core/resolveConfig.test.ts new file mode 100644 index 00000000..5c7d008e --- /dev/null +++ b/src/generators/core/resolveConfig.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; + +import { resolveConfig } from "@/generators/core/resolveConfig"; + +describe("resolveConfig", () => { + it("normalizes workspaceContext from comma-separated CLI input", () => { + const config = resolveConfig({ + params: { + workspaceContext: " officeId,positionId,officeId ,, ", + }, + }); + + expect(config.workspaceContext).toEqual(["officeId", "positionId"]); + }); +}); diff --git a/src/generators/core/resolveConfig.ts b/src/generators/core/resolveConfig.ts index e8035c31..7b8cfe87 100644 --- a/src/generators/core/resolveConfig.ts +++ b/src/generators/core/resolveConfig.ts @@ -4,14 +4,15 @@ import { deepMerge } from "@/generators/utils/object.utils"; export function resolveConfig({ fileConfig = {}, - params: { includeTags, excludeTags, inlineEndpointsExcludeModules, ...options }, + params: { includeTags, excludeTags, inlineEndpointsExcludeModules, workspaceContext, ...options }, }: { fileConfig?: Partial | null; params: Partial< - Omit & { + Omit & { includeTags: string; excludeTags: string; inlineEndpointsExcludeModules: string; + workspaceContext: string; } >; }) { @@ -20,7 +21,11 @@ export function resolveConfig({ includeTags: includeTags?.split(","), excludeTags: excludeTags?.split(","), inlineEndpointsExcludeModules: inlineEndpointsExcludeModules?.split(","), + workspaceContext: workspaceContext?.split(","), }); resolvedConfig.checkAcl = resolvedConfig.acl && resolvedConfig.checkAcl; + resolvedConfig.workspaceContext = Array.from( + new Set((resolvedConfig.workspaceContext ?? []).map((value) => value.trim()).filter(Boolean)), + ); return resolvedConfig; } diff --git a/src/generators/generate/generateQueries.test.ts b/src/generators/generate/generateQueries.test.ts index c3e86ec1..644f3498 100644 --- a/src/generators/generate/generateQueries.test.ts +++ b/src/generators/generate/generateQueries.test.ts @@ -104,7 +104,7 @@ describe("generateQueries workspaceContext", () => { const files = generateCodeFromOpenAPIDoc(openApiDoc, { ...DEFAULT_GENERATE_OPTIONS, output: "test-output", - workspaceContext: true, + workspaceContext: ["officeId", "positionId", "id"], builderConfigs: false, prefetchQueries: false, }); @@ -124,4 +124,34 @@ describe("generateQueries workspaceContext", () => { ); expect(queriesFile?.content).not.toContain("findByIdQueryOptions({ idFromWorkspace })"); }); + + it("only replaces allowlisted workspace context params", () => { + const files = generateCodeFromOpenAPIDoc(openApiDoc, { + ...DEFAULT_GENERATE_OPTIONS, + output: "test-output", + workspaceContext: ["officeId"], + acl: false, + checkAcl: false, + builderConfigs: false, + prefetchQueries: false, + }); + + const queriesFile = files.find((file) => file.fileName.endsWith("/workspace/workspace.queries.ts")); + + expect(queriesFile?.content).toContain( + "export const useGetPosition = ({ officeId, positionId }: { officeId?: string, positionId: string }, options?: AppQueryOptions) => {", + ); + expect(queriesFile?.content).toContain( + "...getPositionQueryOptions({ officeId: officeIdFromWorkspace, positionId }),", + ); + expect(queriesFile?.content).toContain( + 'const officeIdFromWorkspace = OpenApiWorkspaceContext.resolveParam(workspaceContext, "officeId", officeId);', + ); + expect(queriesFile?.content).not.toContain("const positionIdFromWorkspace ="); + expect(queriesFile?.content).not.toContain("positionId: positionIdFromWorkspace"); + expect(queriesFile?.content).toContain( + "export const useFindById = ({ id }: { id: string }, options?: AppQueryOptions) => {", + ); + expect(queriesFile?.content).not.toContain("const idFromWorkspace ="); + }); }); diff --git a/src/generators/generate/generateQueries.ts b/src/generators/generate/generateQueries.ts index d38a9409..9564a984 100644 --- a/src/generators/generate/generateQueries.ts +++ b/src/generators/generate/generateQueries.ts @@ -306,7 +306,7 @@ function getEndpointParamMapping( const key = JSON.stringify( Object.entries(options ?? {}) .sort(([left], [right]) => left.localeCompare(right)) - .map(([optionName, optionValue]) => [optionName, Boolean(optionValue)]), + .map(([optionName, optionValue]) => [optionName, optionValue]), ); const cached = endpointCache.get(key); if (cached) { @@ -318,6 +318,10 @@ function getEndpointParamMapping( return computed; } +function getWorkspaceContextAllowList(workspaceContext: SchemaResolver["options"]["workspaceContext"]) { + return new Set(workspaceContext); +} + function renderImport(importData: Import) { const namedImports = [ ...importData.bindings, @@ -398,6 +402,7 @@ function renderEndpointParamDescription(endpointParam: ReturnType param.name)); const workspaceParamNames = endpointParams.filter((param) => param.paramType === "Path").map((param) => param.name); @@ -406,7 +411,7 @@ function getWorkspaceParamNames(resolver: SchemaResolver, endpoint: Endpoint) { .map((condition) => invalidVariableNameCharactersToCamel(condition.name)) .filter((name) => endpointParamNames.has(name)); - return getUniqueArray([...workspaceParamNames, ...aclParamNames]); + return getUniqueArray([...workspaceParamNames, ...aclParamNames]).filter((name) => allowList.has(name)); } function getWorkspaceParamReplacements(resolver: SchemaResolver, endpoint: Endpoint) { diff --git a/src/generators/run/generate.runner.ts b/src/generators/run/generate.runner.ts index 6706f762..6d038221 100644 --- a/src/generators/run/generate.runner.ts +++ b/src/generators/run/generate.runner.ts @@ -22,10 +22,11 @@ export async function runGenerate({ }: { fileConfig?: Partial | null; params?: Partial< - Omit & { + Omit & { includeTags: string; excludeTags: string; inlineEndpointsExcludeModules: string; + workspaceContext: string; } >; formatGeneratedFile?: GenerateFileFormatter; diff --git a/src/generators/types/options.ts b/src/generators/types/options.ts index e4cf9a55..1a3e26c0 100644 --- a/src/generators/types/options.ts +++ b/src/generators/types/options.ts @@ -26,7 +26,7 @@ interface QueriesGenerateOptions { queryTypesImportPath: string; axiosRequestConfig?: boolean; mutationEffects?: boolean; - workspaceContext?: boolean; + workspaceContext?: string[]; prefetchQueries?: boolean; } diff --git a/src/generators/utils/generate/generate.endpoints.utils.ts b/src/generators/utils/generate/generate.endpoints.utils.ts index c2e54d76..adf961f9 100644 --- a/src/generators/utils/generate/generate.endpoints.utils.ts +++ b/src/generators/utils/generate/generate.endpoints.utils.ts @@ -48,10 +48,12 @@ export function mapEndpointParamsToFunctionParams( includeFileParam?: boolean; includeOnlyRequiredParams?: boolean; pathParamsRequiredOnly?: boolean; - optionalPathParams?: boolean; + optionalPathParams?: string[]; modelNamespaceTag?: string; }, ) { + const optionalPathParams = options?.optionalPathParams ? new Set(options.optionalPathParams) : undefined; + const params = endpoint.parameters.map((param) => { let type = "string"; if (isNamedZodSchema(param.zodSchema)) { @@ -105,7 +107,7 @@ export function mapEndpointParamsToFunctionParams( ? "pageParam" : param.name, required: - options?.optionalPathParams && param.paramType === "Path" + param.paramType === "Path" && optionalPathParams?.has(param.name) ? false : param.required && (param.paramType === "Path" || !options?.pathParamsRequiredOnly), })); From 42e91180549a94a8991df460fe6efded17c7dfbc Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Thu, 14 May 2026 15:45:15 +0200 Subject: [PATCH 12/27] feat: option defaultOnError config --- README.md | 17 +++++ src/commands/generate.command.ts | 3 + src/generators/const/options.const.ts | 1 + src/generators/generate/generateConfigs.ts | 11 +++- .../generate/generateQueries.test.ts | 63 +++++++++++++++++++ src/generators/generate/generateQueries.ts | 12 +++- src/generators/types/options.ts | 1 + 7 files changed, 103 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d7a5aaf0..51bb63df 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ yarn openapi-codegen generate --config my-config.ts --axiosRequestConfig Include Axios request config parameters in query hooks (default: false) --infiniteQueries Generate infinite queries for paginated API endpoints (default: false) --mutationEffects Add mutation effects options to mutation hooks (default: true) + --mutationDefaultOnError Use OpenApiQueryConfig.onError as the default onError for mutation hooks (default: false) --workspaceContext Comma-separated list of path/ACL params that generated hooks may resolve from OpenApiWorkspaceContext --inlineEndpoints Inline endpoint implementations into generated query files (default: false) --inlineEndpointsExcludeModules Comma-separated modules/tags to keep as separate API files while inlineEndpoints=true @@ -194,6 +195,22 @@ const config: OpenAPICodegenConfig = { export default config; ``` +### Default mutation errors + +Set `mutationDefaultOnError: true` in codegen config (or pass `--mutationDefaultOnError`) to let generated mutation hooks fall back to `OpenApiQueryConfig.Provider` when a mutation call does not define its own `onError`. + +```tsx +import { ErrorHandler, OpenApiQueryConfig } from "@povio/openapi-codegen-cli"; + + { + errorToast({ text: ErrorHandler.getErrorMessage(error) }); + }} +> + +; +``` + ### OpenApiWorkspaceContext (Path + ACL defaults) Set `workspaceContext` to a list of param names in codegen config (or pass `--workspaceContext officeId,projectId`) and wrap your app subtree with `OpenApiWorkspaceContext.Provider` if generated hooks frequently repeat workspace-scoped params. diff --git a/src/commands/generate.command.ts b/src/commands/generate.command.ts index a16b5db2..caa3beac 100644 --- a/src/commands/generate.command.ts +++ b/src/commands/generate.command.ts @@ -76,6 +76,9 @@ class GenerateOptions implements GenerateParams { @YargOption({ envAlias: "mutationEffects", type: "boolean" }) mutationEffects?: boolean; + @YargOption({ envAlias: "mutationDefaultOnError", type: "boolean" }) + mutationDefaultOnError?: boolean; + @YargOption({ envAlias: "workspaceContext" }) workspaceContext?: string; diff --git a/src/generators/const/options.const.ts b/src/generators/const/options.const.ts index e610f68d..233f2adb 100644 --- a/src/generators/const/options.const.ts +++ b/src/generators/const/options.const.ts @@ -62,6 +62,7 @@ export const DEFAULT_GENERATE_OPTIONS: GenerateOptions = { queryTypesImportPath: PACKAGE_IMPORT_PATH, axiosRequestConfig: false, mutationEffects: true, + mutationDefaultOnError: false, workspaceContext: [], prefetchQueries: true, // Infinite queries options diff --git a/src/generators/generate/generateConfigs.ts b/src/generators/generate/generateConfigs.ts index 9883fbe5..96704b0c 100644 --- a/src/generators/generate/generateConfigs.ts +++ b/src/generators/generate/generateConfigs.ts @@ -42,6 +42,7 @@ export function generateConfigs(generateTypeParams: GenerateTypeParams) { const hasMutation = endpoints.length > 0; const hasAclCheck = resolver.options.checkAcl && endpoints.some((e) => e.acl); const hasMutationEffects = resolver.options.mutationEffects && hasMutation; + const hasMutationDefaultOnError = resolver.options.mutationDefaultOnError && hasMutation; const hasWorkspaceContext = resolver.options.workspaceContext && endpoints.some((e) => resolver.options.workspaceContext); // Simplified check @@ -57,7 +58,7 @@ export function generateConfigs(generateTypeParams: GenerateTypeParams) { }; const queryTypesImport: Import = { - bindings: ["OpenApiQueryConfig"], + bindings: [...(hasMutationDefaultOnError ? ["OpenApiQueryConfig"] : [])], typeBindings: [QUERY_OPTIONS_TYPES.mutation], from: getQueryTypesImportPath(resolver.options), }; @@ -199,6 +200,7 @@ function renderColumnsConfig(columnsConfig: DynamicColumnsConfig) { function renderMutationContent(resolver: any, endpoint: Endpoint, tag: string) { const hasAclCheck = resolver.options.checkAcl && endpoint.acl; const hasMutationEffects = resolver.options.mutationEffects; + const hasMutationDefaultOnError = resolver.options.mutationDefaultOnError; const hasAxiosRequestConfig = resolver.options.axiosRequestConfig; const endpointTag = getEndpointTag(endpoint, resolver.options); @@ -220,7 +222,9 @@ function renderMutationContent(resolver: any, endpoint: Endpoint, tag: string) { lines.push( `(options?: AppMutationOptions${hasAxiosRequestConfig ? `, config?: AxiosRequestConfig` : ""}) => {`, ); - lines.push(" const queryConfig = OpenApiQueryConfig.useConfig();"); + if (hasMutationDefaultOnError) { + lines.push(" const queryConfig = OpenApiQueryConfig.useConfig();"); + } if (hasMutationEffects) { lines.push( @@ -252,6 +256,9 @@ function renderMutationContent(resolver: any, endpoint: Endpoint, tag: string) { lines.push(" },"); } lines.push(" ...options,"); + if (hasMutationDefaultOnError) { + lines.push(" onError: options?.onError ?? queryConfig.onError,"); + } lines.push(" });"); lines.push("}"); return lines diff --git a/src/generators/generate/generateQueries.test.ts b/src/generators/generate/generateQueries.test.ts index 644f3498..6abbecff 100644 --- a/src/generators/generate/generateQueries.test.ts +++ b/src/generators/generate/generateQueries.test.ts @@ -78,6 +78,30 @@ const openApiDoc = { }, } as OpenAPIV3.OperationObject, }, + "/items": { + post: { + tags: ["Workspace"], + operationId: "createItem", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Item" }, + }, + }, + }, + responses: { + 200: { + description: "OK", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Item" }, + }, + }, + }, + }, + } as OpenAPIV3.OperationObject, + }, }, components: { schemas: { @@ -154,4 +178,43 @@ describe("generateQueries workspaceContext", () => { ); expect(queriesFile?.content).not.toContain("const idFromWorkspace ="); }); + + it("does not emit provider onError fallback for mutations by default", () => { + const files = generateCodeFromOpenAPIDoc(openApiDoc, { + ...DEFAULT_GENERATE_OPTIONS, + output: "test-output", + acl: false, + checkAcl: false, + mutationEffects: false, + builderConfigs: false, + prefetchQueries: false, + }); + + const queriesFile = files.find((file) => file.fileName.endsWith("/workspace/workspace.queries.ts")); + + expect(queriesFile?.content).not.toContain("OpenApiQueryConfig"); + expect(queriesFile?.content).not.toContain("const queryConfig = OpenApiQueryConfig.useConfig();"); + expect(queriesFile?.content).not.toContain("onError: options?.onError ?? queryConfig.onError"); + }); + + it("can use OpenApiQueryConfig.onError as the default onError for mutations", () => { + const files = generateCodeFromOpenAPIDoc(openApiDoc, { + ...DEFAULT_GENERATE_OPTIONS, + output: "test-output", + acl: false, + checkAcl: false, + mutationEffects: false, + mutationDefaultOnError: true, + builderConfigs: false, + prefetchQueries: false, + }); + + const queriesFile = files.find((file) => file.fileName.endsWith("/workspace/workspace.queries.ts")); + + expect(queriesFile?.content).toContain( + "import { OpenApiQueryConfig, type AppQueryOptions, type AppMutationOptions }", + ); + expect(queriesFile?.content).toContain("const queryConfig = OpenApiQueryConfig.useConfig();"); + expect(queriesFile?.content).toContain("onError: options?.onError ?? queryConfig.onError"); + }); }); diff --git a/src/generators/generate/generateQueries.ts b/src/generators/generate/generateQueries.ts index 9564a984..2d649887 100644 --- a/src/generators/generate/generateQueries.ts +++ b/src/generators/generate/generateQueries.ts @@ -95,6 +95,7 @@ export function generateQueries(params: GenerateTypeParams) { }; const { queryEndpoints, infiniteQueryEndpoints, mutationEndpoints, aclEndpoints } = endpointGroups; + const hasMutationDefaultOnError = resolver.options.mutationDefaultOnError && mutationEndpoints.length > 0; const queryImport: Import = { bindings: [ @@ -126,7 +127,7 @@ export function generateQueries(params: GenerateTypeParams) { }; const queryTypesImport: Import = { - bindings: [...(mutationEndpoints.length > 0 ? ["OpenApiQueryConfig"] : [])], + bindings: [...(hasMutationDefaultOnError ? ["OpenApiQueryConfig"] : [])], typeBindings: [ ...(queryEndpoints.length > 0 ? [QUERY_OPTIONS_TYPES.query] : []), ...(resolver.options.infiniteQueries && infiniteQueryEndpoints.length > 0 @@ -857,6 +858,7 @@ function renderMutation({ }) { const hasAclCheck = resolver.options.checkAcl && endpoint.acl; const hasMutationEffects = resolver.options.mutationEffects; + const hasMutationDefaultOnError = resolver.options.mutationDefaultOnError; const hasAxiosRequestConfig = resolver.options.axiosRequestConfig; const tag = getEndpointTag(endpoint, resolver.options); const workspaceParamReplacements = resolver.options.workspaceContext @@ -887,7 +889,9 @@ function renderMutation({ lines.push( `export const ${getQueryName(endpoint, true)} = (options?: AppMutationOptions${hasMutationEffects ? ` & ${MUTATION_EFFECTS.optionsType}` : ""}${hasAxiosRequestConfig ? `, ${AXIOS_REQUEST_CONFIG_NAME}?: ${AXIOS_REQUEST_CONFIG_TYPE}` : ""}) => {`, ); - lines.push(" const queryConfig = OpenApiQueryConfig.useConfig();"); + if (hasMutationDefaultOnError) { + lines.push(" const queryConfig = OpenApiQueryConfig.useConfig();"); + } if (hasAclCheck) { lines.push(` const { checkAcl } = ${ACL_CHECK_HOOK}();`); } @@ -959,7 +963,9 @@ function renderMutation({ } lines.push(" ...options,"); - lines.push(" onError: options?.onError ?? queryConfig.onError,"); + if (hasMutationDefaultOnError) { + lines.push(" onError: options?.onError ?? queryConfig.onError,"); + } if (hasMutationEffects) { lines.push(" onSuccess: async (resData, variables, onMutateResult, context) => {"); if (updateQueryEndpoints.length > 0) { diff --git a/src/generators/types/options.ts b/src/generators/types/options.ts index 1a3e26c0..3b82e1be 100644 --- a/src/generators/types/options.ts +++ b/src/generators/types/options.ts @@ -26,6 +26,7 @@ interface QueriesGenerateOptions { queryTypesImportPath: string; axiosRequestConfig?: boolean; mutationEffects?: boolean; + mutationDefaultOnError?: boolean; workspaceContext?: string[]; prefetchQueries?: boolean; } From 9f8475d41be43f5fb599b0c109b6aa7be91897b3 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Fri, 15 May 2026 08:02:49 +0200 Subject: [PATCH 13/27] release: v2.0.8-rc.36 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e009b3d9..3f20a6e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.8-rc.35", + "version": "2.0.8-rc.36", "keywords": [ "codegen", "openapi", From 3bd691cfdfc7ca42264ab3708cfbff1c70730cd5 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Fri, 15 May 2026 08:44:34 +0200 Subject: [PATCH 14/27] feat: workspace context enchantments Allows creating workspace context so queries can be simpler. --- .../generate/generateQueries.test.ts | 94 +++++++++++++--- src/generators/generate/generateQueries.ts | 100 ++++++++++++++---- src/index.ts | 2 +- src/lib/config/workspace.context.tsx | 7 +- 4 files changed, 165 insertions(+), 38 deletions(-) diff --git a/src/generators/generate/generateQueries.test.ts b/src/generators/generate/generateQueries.test.ts index 6abbecff..5c1ad6c4 100644 --- a/src/generators/generate/generateQueries.test.ts +++ b/src/generators/generate/generateQueries.test.ts @@ -102,6 +102,47 @@ const openApiDoc = { }, } as OpenAPIV3.OperationObject, }, + "/offices/{officeId}/positions": { + post: { + tags: ["Workspace"], + operationId: "createPosition", + "x-acl": [ + { + action: "create", + subject: "Position", + conditions: { + officeId: "$params.officeId", + }, + }, + ], + parameters: [ + { + name: "officeId", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Position" }, + }, + }, + }, + responses: { + 200: { + description: "OK", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Position" }, + }, + }, + }, + }, + } as OpenAPIV3.OperationObject, + }, }, components: { schemas: { @@ -136,17 +177,20 @@ describe("generateQueries workspaceContext", () => { const queriesFile = files.find((file) => file.fileName.endsWith("/workspace/workspace.queries.ts")); expect(queriesFile?.content).toContain( - "getPositionQueryOptions({ officeId: officeIdFromWorkspace, positionId: positionIdFromWorkspace })", + "getPositionQueryOptions({ officeId: normalizeOfficeId, positionId: normalizePositionId })", ); expect(queriesFile?.content).toContain( - "return getPositionQueryOptions({ officeId: officeIdFromWorkspace, positionId: positionIdFromWorkspace }).queryFn();", + "return getPositionQueryOptions({ officeId: normalizeOfficeId, positionId: normalizePositionId }).queryFn();", ); - expect(queriesFile?.content).toContain("findByIdQueryOptions({ id: idFromWorkspace })"); - - expect(queriesFile?.content).not.toContain( - "getPositionQueryOptions({ officeIdFromWorkspace, positionIdFromWorkspace })", + expect(queriesFile?.content).toContain("findByIdQueryOptions({ id: normalizeId })"); + expect(queriesFile?.content).toContain( + "const { officeId: officeIdWorkspace, positionId: positionIdWorkspace } = useWorkspaceContext<{ officeId?: string; positionId?: string }>();", ); - expect(queriesFile?.content).not.toContain("findByIdQueryOptions({ idFromWorkspace })"); + expect(queriesFile?.content).toContain("const normalizeOfficeId = officeId ?? officeIdWorkspace;"); + expect(queriesFile?.content).toContain("throw Error(`OfficeId not provided`);"); + + expect(queriesFile?.content).not.toContain("getPositionQueryOptions({ normalizeOfficeId, normalizePositionId })"); + expect(queriesFile?.content).not.toContain("findByIdQueryOptions({ normalizeId })"); }); it("only replaces allowlisted workspace context params", () => { @@ -165,18 +209,42 @@ describe("generateQueries workspaceContext", () => { expect(queriesFile?.content).toContain( "export const useGetPosition = ({ officeId, positionId }: { officeId?: string, positionId: string }, options?: AppQueryOptions) => {", ); + expect(queriesFile?.content).toContain("...getPositionQueryOptions({ officeId: normalizeOfficeId, positionId }),"); expect(queriesFile?.content).toContain( - "...getPositionQueryOptions({ officeId: officeIdFromWorkspace, positionId }),", + "const { officeId: officeIdWorkspace } = useWorkspaceContext<{ officeId?: string }>();", ); + expect(queriesFile?.content).toContain("const normalizeOfficeId = officeId ?? officeIdWorkspace;"); + expect(queriesFile?.content).not.toContain("const normalizePositionId ="); + expect(queriesFile?.content).not.toContain("positionId: normalizePositionId"); expect(queriesFile?.content).toContain( - 'const officeIdFromWorkspace = OpenApiWorkspaceContext.resolveParam(workspaceContext, "officeId", officeId);', + "export const useFindById = ({ id }: { id: string }, options?: AppQueryOptions) => {", ); - expect(queriesFile?.content).not.toContain("const positionIdFromWorkspace ="); - expect(queriesFile?.content).not.toContain("positionId: positionIdFromWorkspace"); + expect(queriesFile?.content).not.toContain("const normalizeId ="); + }); + + it("resolves allowlisted workspace params inside mutation callbacks", () => { + const files = generateCodeFromOpenAPIDoc(openApiDoc, { + ...DEFAULT_GENERATE_OPTIONS, + output: "test-output", + workspaceContext: ["officeId"], + acl: false, + checkAcl: false, + mutationEffects: false, + builderConfigs: false, + prefetchQueries: false, + }); + + const queriesFile = files.find((file) => file.fileName.endsWith("/workspace/workspace.queries.ts")); + expect(queriesFile?.content).toContain( - "export const useFindById = ({ id }: { id: string }, options?: AppQueryOptions) => {", + "export const useCreatePosition = (options?: AppMutationOptions) => {", + ); + expect(queriesFile?.content).toContain( + "const { officeId: officeIdWorkspace } = useWorkspaceContext<{ officeId?: string }>();", ); - expect(queriesFile?.content).not.toContain("const idFromWorkspace ="); + expect(queriesFile?.content).toContain("mutationFn: ({ officeId, data }) => { "); + expect(queriesFile?.content).toContain("const normalizeOfficeId = officeId ?? officeIdWorkspace;"); + expect(queriesFile?.content).toContain("return WorkspaceApi.createPosition(normalizeOfficeId, data)"); }); it("does not emit provider onError fallback for mutations by default", () => { diff --git a/src/generators/generate/generateQueries.ts b/src/generators/generate/generateQueries.ts index 2d649887..a7037261 100644 --- a/src/generators/generate/generateQueries.ts +++ b/src/generators/generate/generateQueries.ts @@ -66,6 +66,7 @@ import { getNamespaceName } from "@/generators/utils/namespace.utils"; import { isSchemaObject } from "@/generators/utils/openapi-schema.utils"; import { isParamMediaTypeAllowed } from "@/generators/utils/openapi.utils"; import { getDestructuredVariables, isInfiniteQuery, isMutation, isQuery } from "@/generators/utils/query.utils"; +import { capitalize } from "@/generators/utils/string.utils"; import { getEndpointTag, shouldInlineEndpointsForTag } from "@/generators/utils/tag.utils"; import { isNamedZodSchema } from "@/generators/utils/zod-schema.utils"; import { invalidVariableNameCharactersToCamel } from "@/generators/utils/js.utils"; @@ -142,7 +143,7 @@ export function generateQueries(params: GenerateTypeParams) { resolver.options.workspaceContext && endpoints.some((endpoint) => getWorkspaceParamNames(resolver, endpoint).length > 0); const workspaceContextImport: Import = { - bindings: ["OpenApiWorkspaceContext"], + bindings: ["useWorkspaceContext"], from: PACKAGE_IMPORT_PATH, }; @@ -417,15 +418,26 @@ function getWorkspaceParamNames(resolver: SchemaResolver, endpoint: Endpoint) { function getWorkspaceParamReplacements(resolver: SchemaResolver, endpoint: Endpoint) { return Object.fromEntries( - getWorkspaceParamNames(resolver, endpoint).map((name) => [name, `${name}FromWorkspace`]), + getWorkspaceParamNames(resolver, endpoint).map((name) => [name, `normalize${capitalize(name)}`]), ) as Record; } -function renderWorkspaceParamResolutions({ +function getWorkspaceParamTypes(resolver: SchemaResolver, endpoint: Endpoint, modelNamespaceTag?: string) { + const workspaceParamNames = new Set(getWorkspaceParamNames(resolver, endpoint)); + return Object.fromEntries( + getEndpointParamMapping(resolver, endpoint, { modelNamespaceTag }) + .filter((param) => workspaceParamNames.has(param.name)) + .map((param) => [param.name, param.type]), + ) as Record; +} + +function renderWorkspaceContextDestructure({ replacements, + paramTypes, indent, }: { replacements: Record; + paramTypes: Record; indent: string; }) { const workspaceParamNames = Object.keys(replacements); @@ -433,15 +445,48 @@ function renderWorkspaceParamResolutions({ return []; } - const lines = [`${indent}const workspaceContext = OpenApiWorkspaceContext.useContext();`]; + const workspaceParamBindings = workspaceParamNames.map((paramName) => `${paramName}: ${paramName}Workspace`); + const workspaceContextType = workspaceParamNames + .map((paramName) => `${paramName}?: ${paramTypes[paramName] ?? "unknown"}`) + .join("; "); + return [ + `${indent}const { ${workspaceParamBindings.join(", ")} } = useWorkspaceContext<{ ${workspaceContextType} }>();`, + ]; +} + +function renderWorkspaceParamCoalescing({ + replacements, + indent, +}: { + replacements: Record; + indent: string; +}) { + const workspaceParamNames = Object.keys(replacements); + const lines: string[] = []; for (const paramName of workspaceParamNames) { - lines.push( - `${indent}const ${replacements[paramName]} = OpenApiWorkspaceContext.resolveParam(workspaceContext, "${paramName}", ${paramName});`, - ); + lines.push(`${indent}const ${replacements[paramName]} = ${paramName} ?? ${paramName}Workspace;`); + lines.push(`${indent}if (!${replacements[paramName]}) {`); + lines.push(`${indent} throw Error(\`${capitalize(paramName)} not provided\`);`); + lines.push(`${indent}}`); } return lines; } +function renderWorkspaceParamResolutions({ + replacements, + paramTypes, + indent, +}: { + replacements: Record; + paramTypes: Record; + indent: string; +}) { + return [ + ...renderWorkspaceContextDestructure({ replacements, paramTypes, indent }), + ...renderWorkspaceParamCoalescing({ replacements, indent }), + ]; +} + function renderAclCheckCall( resolver: SchemaResolver, endpoint: Endpoint, @@ -809,6 +854,7 @@ function renderQuery({ const workspaceParamReplacements = resolver.options.workspaceContext ? getWorkspaceParamReplacements(resolver, endpoint) : {}; + const workspaceParamTypes = getWorkspaceParamTypes(resolver, endpoint, tag); const endpointArgs = renderEndpointArgs(resolver, endpoint, {}); const resolvedEndpointArgs = renderEndpointObjectArgs(resolver, endpoint, {}, workspaceParamReplacements); const endpointParams = renderEndpointParams(resolver, endpoint, { @@ -827,7 +873,13 @@ function renderQuery({ if (hasAclCheck) { lines.push(` const { checkAcl } = ${ACL_CHECK_HOOK}();`); } - lines.push(...renderWorkspaceParamResolutions({ replacements: workspaceParamReplacements, indent: " " })); + lines.push( + ...renderWorkspaceParamResolutions({ + replacements: workspaceParamReplacements, + paramTypes: workspaceParamTypes, + indent: " ", + }), + ); lines.push(" "); lines.push(` return ${QUERY_HOOKS.query}({`); lines.push(` ...${queryOptionsName}(${queryOptionsArgs}),`); @@ -864,6 +916,7 @@ function renderMutation({ const workspaceParamReplacements = resolver.options.workspaceContext ? getWorkspaceParamReplacements(resolver, endpoint) : {}; + const workspaceParamTypes = getWorkspaceParamTypes(resolver, endpoint, tag); const endpointParams = renderEndpointParams(resolver, endpoint, { includeFileParam: true, optionalPathParams: resolver.options.workspaceContext, @@ -895,9 +948,13 @@ function renderMutation({ if (hasAclCheck) { lines.push(` const { checkAcl } = ${ACL_CHECK_HOOK}();`); } - if (Object.keys(workspaceParamReplacements).length > 0) { - lines.push(" const workspaceContext = OpenApiWorkspaceContext.useContext();"); - } + lines.push( + ...renderWorkspaceContextDestructure({ + replacements: workspaceParamReplacements, + paramTypes: workspaceParamTypes, + indent: " ", + }), + ); if (hasMutationEffects) { lines.push( ` const { runMutationEffects } = useMutationEffects({ currentModule: ${QUERIES_MODULE_NAME} });`, @@ -912,11 +969,7 @@ function renderMutation({ lines.push( ` mutationFn: ${endpoint.mediaUpload ? "async " : ""}(${mutationFnArg}) => ${hasMutationFnBody ? "{ " : ""}`, ); - for (const [paramName, resolvedParamName] of Object.entries(workspaceParamReplacements)) { - lines.push( - ` const ${resolvedParamName} = OpenApiWorkspaceContext.resolveParam(workspaceContext, "${paramName}", ${paramName});`, - ); - } + lines.push(...renderWorkspaceParamCoalescing({ replacements: workspaceParamReplacements, indent: " " })); if (hasAclCheck) { lines.push(renderAclCheckCall(resolver, endpoint, workspaceParamReplacements, " ")); } @@ -972,11 +1025,7 @@ function renderMutation({ if (destructuredVariables.length > 0) { lines.push(` const { ${destructuredVariables.join(", ")} } = variables;`); } - for (const [paramName, resolvedParamName] of Object.entries(workspaceParamReplacements)) { - lines.push( - ` const ${resolvedParamName} = OpenApiWorkspaceContext.resolveParam(workspaceContext, "${paramName}", ${paramName});`, - ); - } + lines.push(...renderWorkspaceParamCoalescing({ replacements: workspaceParamReplacements, indent: " " })); lines.push( ` const updateKeys = [${updateQueryEndpoints .map( @@ -1079,6 +1128,7 @@ function renderInfiniteQuery({ const workspaceParamReplacements = resolver.options.workspaceContext ? getWorkspaceParamReplacements(resolver, endpoint) : {}; + const workspaceParamTypes = getWorkspaceParamTypes(resolver, endpoint, tag); const endpointParams = renderEndpointParams(resolver, endpoint, { excludePageParam: true, optionalPathParams: resolver.options.workspaceContext, @@ -1103,7 +1153,13 @@ function renderInfiniteQuery({ if (hasAclCheck) { lines.push(` const { checkAcl } = ${ACL_CHECK_HOOK}();`); } - lines.push(...renderWorkspaceParamResolutions({ replacements: workspaceParamReplacements, indent: " " })); + lines.push( + ...renderWorkspaceParamResolutions({ + replacements: workspaceParamReplacements, + paramTypes: workspaceParamTypes, + indent: " ", + }), + ); lines.push(""); lines.push(` return ${QUERY_HOOKS.infiniteQuery}({`); lines.push(` ...${queryOptionsName}(${queryOptionsArgs}),`); diff --git a/src/index.ts b/src/index.ts index 043baa64..8f8243a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,7 @@ export type { MutationEffectsOptions } from "./lib/react-query/useMutationEffect export { OpenApiRouter } from "./lib/config/router.context"; export { OpenApiQueryConfig } from "./lib/config/queryConfig.context"; export type { InvalidationMap, InvalidationMapFunc, QueryModule } from "./lib/config/queryConfig.context"; -export { OpenApiWorkspaceContext } from "./lib/config/workspace.context"; +export { OpenApiWorkspaceContext, useWorkspaceContext } from "./lib/config/workspace.context"; // i18n resources (for consumer apps to merge into their i18n config) export { ns, resources } from "./lib/config/i18n"; diff --git a/src/lib/config/workspace.context.tsx b/src/lib/config/workspace.context.tsx index 7a616958..cf8776f1 100644 --- a/src/lib/config/workspace.context.tsx +++ b/src/lib/config/workspace.context.tsx @@ -14,8 +14,8 @@ export namespace OpenApiWorkspaceContext { return {children}; }; - export const useContext = () => { - return use(Context); + export const useContext = () => { + return use(Context) as TValues; }; export const resolveParam = (context: WorkspaceValues, name: string, value: T | null | undefined): T => { @@ -31,3 +31,6 @@ export namespace OpenApiWorkspaceContext { return workspaceValue as T; }; } + +export const useWorkspaceContext = () => + OpenApiWorkspaceContext.useContext(); From 80eb6d020ce1e4863d8831357361e4e756c1c1c2 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Fri, 15 May 2026 08:45:51 +0200 Subject: [PATCH 15/27] release: v2.0.8-rc.37 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3f20a6e1..a553fba3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.8-rc.36", + "version": "2.0.8-rc.37", "keywords": [ "codegen", "openapi", From db27d345608534c8c17c2d32a294caab50be2556 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Fri, 15 May 2026 09:18:47 +0200 Subject: [PATCH 16/27] feat: support workspaceContext on ACLs --- package.json | 2 +- src/generators/generate/generateAcl.ts | 86 ++++++++++++++++++- .../generate/generateQueries.test.ts | 24 ++++++ 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index a553fba3..c1363109 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.8-rc.37", + "version": "2.0.8-rc.38", "keywords": [ "codegen", "openapi", diff --git a/src/generators/generate/generateAcl.ts b/src/generators/generate/generateAcl.ts index fe453b1c..5f6f3e73 100644 --- a/src/generators/generate/generateAcl.ts +++ b/src/generators/generate/generateAcl.ts @@ -1,4 +1,5 @@ import { ACL_APP_ABILITIES, CASL_ABILITY_BINDING, CASL_ABILITY_IMPORT } from "@/generators/const/acl.const"; +import { PACKAGE_IMPORT_PATH } from "@/generators/const/package.const"; import { Endpoint } from "@/generators/types/endpoint"; import { GenerateType, GenerateTypeParams, Import } from "@/generators/types/generate"; import { @@ -14,6 +15,7 @@ import { } from "@/generators/utils/generate/generate.acl.utils"; import { getInfiniteQueryName, getQueryName } from "@/generators/utils/generate/generate.query.utils"; import { getNamespaceName } from "@/generators/utils/namespace.utils"; +import { capitalize } from "@/generators/utils/string.utils"; export function generateAcl({ resolver, data, tag }: GenerateTypeParams) { const aclData = getAclData({ resolver, data, tag }); @@ -22,6 +24,7 @@ export function generateAcl({ resolver, data, tag }: GenerateTypeParams) { } const { hasAdditionalAbilityImports, modelsImports, endpoints } = aclData; + const hasWorkspaceContext = endpoints.some((endpoint) => getWorkspaceConditionNames(resolver, endpoint).length > 0); const caslAbilityTupleImport: Import = { bindings: [ @@ -30,9 +33,16 @@ export function generateAcl({ resolver, data, tag }: GenerateTypeParams) { typeBindings: [CASL_ABILITY_BINDING.abilityTuple], from: CASL_ABILITY_IMPORT.from, }; + const workspaceContextImport: Import = { + bindings: ["useWorkspaceContext"], + from: PACKAGE_IMPORT_PATH, + }; const lines: string[] = []; lines.push(renderImport(caslAbilityTupleImport)); + if (hasWorkspaceContext) { + lines.push(renderImport(workspaceContextImport)); + } for (const modelsImport of modelsImports) { lines.push(renderImport(modelsImport)); } @@ -43,7 +53,7 @@ export function generateAcl({ resolver, data, tag }: GenerateTypeParams) { } for (const endpoint of endpoints) { - lines.push(renderAbilityFunction(endpoint)); + lines.push(renderAbilityFunction({ resolver, endpoint })); lines.push(""); } @@ -103,7 +113,74 @@ function renderImport(importData: Import) { return `import${importData.typeOnly ? " type" : ""} ${names} from "${importData.from}";`; } -function renderAbilityFunction(endpoint: Endpoint) { +function getWorkspaceContextAllowList(workspaceContext: GenerateTypeParams["resolver"]["options"]["workspaceContext"]) { + return new Set(workspaceContext); +} + +function getWorkspaceConditionNames(resolver: GenerateTypeParams["resolver"], endpoint: Endpoint) { + const allowList = getWorkspaceContextAllowList(resolver.options.workspaceContext); + return (getAbilityConditionsTypes(endpoint) ?? []) + .map((condition) => condition.name) + .filter((name) => allowList.has(name)); +} + +function renderWorkspaceAclHook({ + resolver, + endpoint, +}: { + resolver: GenerateTypeParams["resolver"]; + endpoint: Endpoint; +}) { + const abilityConditionsTypes = getAbilityConditionsTypes(endpoint) ?? []; + const workspaceConditionNames = getWorkspaceConditionNames(resolver, endpoint); + if (workspaceConditionNames.length === 0) { + return; + } + + const workspaceConditionNameSet = new Set(workspaceConditionNames); + const objectRequired = abilityConditionsTypes.some( + (propertyType) => propertyType.required && !workspaceConditionNameSet.has(propertyType.name), + ); + const objectParams = abilityConditionsTypes + .map((propertyType) => { + const isWorkspaceCondition = workspaceConditionNameSet.has(propertyType.name); + return `${propertyType.name}${propertyType.required && !isWorkspaceCondition ? "" : "?"}: ${(propertyType.type ?? "") + (propertyType.zodSchemaName ?? "")}, `; + }) + .join(""); + const contextType = abilityConditionsTypes + .filter((propertyType) => workspaceConditionNameSet.has(propertyType.name)) + .map((propertyType) => `${propertyType.name}?: ${(propertyType.type ?? "") + (propertyType.zodSchemaName ?? "")}`) + .join("; "); + const contextBindings = workspaceConditionNames.map((name) => `${name}: ${name}Workspace`).join(", "); + + const lines: string[] = []; + lines.push(`export const use${capitalize(getAbilityFunctionName(endpoint))} = (`); + lines.push(` object${objectRequired ? "" : "?"}: { ${objectParams} } `); + lines.push(") => {"); + lines.push(` const { ${contextBindings} } = useWorkspaceContext<{ ${contextType} }>();`); + for (const conditionName of workspaceConditionNames) { + const resolvedName = `normalize${capitalize(conditionName)}`; + lines.push(` const ${resolvedName} = object?.${conditionName} ?? ${conditionName}Workspace;`); + lines.push(` if (!${resolvedName}) {`); + lines.push(` throw Error(\`${capitalize(conditionName)} not provided\`);`); + lines.push(" }"); + } + lines.push( + ` return ${getAbilityFunctionName(endpoint)}({ ...object, ${workspaceConditionNames + .map((conditionName) => `${conditionName}: normalize${capitalize(conditionName)}`) + .join(", ")} });`, + ); + lines.push("};"); + return lines.join("\n"); +} + +function renderAbilityFunction({ + resolver, + endpoint, +}: { + resolver: GenerateTypeParams["resolver"]; + endpoint: Endpoint; +}) { const abilityConditionsTypes = getAbilityConditionsTypes(endpoint) ?? []; const hasConditions = hasAbilityConditions(endpoint); const lines: string[] = []; @@ -152,5 +229,10 @@ function renderAbilityFunction(endpoint: Endpoint) { lines.push( `] as ${CASL_ABILITY_BINDING.abilityTuple}<"${getAbilityAction(endpoint)}", ${getAbilitySubjectTypes(endpoint).join(" | ")}>;`, ); + const workspaceAclHook = renderWorkspaceAclHook({ resolver, endpoint }); + if (workspaceAclHook) { + lines.push(""); + lines.push(workspaceAclHook); + } return lines.join("\n"); } diff --git a/src/generators/generate/generateQueries.test.ts b/src/generators/generate/generateQueries.test.ts index 5c1ad6c4..ae62d800 100644 --- a/src/generators/generate/generateQueries.test.ts +++ b/src/generators/generate/generateQueries.test.ts @@ -222,6 +222,30 @@ describe("generateQueries workspaceContext", () => { expect(queriesFile?.content).not.toContain("const normalizeId ="); }); + it("emits context-aware ACL hooks for allowlisted workspace conditions", () => { + const files = generateCodeFromOpenAPIDoc(openApiDoc, { + ...DEFAULT_GENERATE_OPTIONS, + output: "test-output", + workspaceContext: ["officeId"], + builderConfigs: false, + prefetchQueries: false, + }); + + const aclFile = files.find((file) => file.fileName.endsWith("/workspace/workspace.acl.ts")); + + expect(aclFile?.content).toContain('import { useWorkspaceContext } from "@povio/openapi-codegen-cli";'); + expect(aclFile?.content).toContain("object?: { officeId: string, positionId: string, }"); + expect(aclFile?.content).toContain("export const useCanUseGetPosition = ("); + expect(aclFile?.content).toContain("object: { officeId?: string, positionId: string, }"); + expect(aclFile?.content).toContain( + "const { officeId: officeIdWorkspace } = useWorkspaceContext<{ officeId?: string }>();", + ); + expect(aclFile?.content).toContain("const normalizeOfficeId = object?.officeId ?? officeIdWorkspace;"); + expect(aclFile?.content).toContain("throw Error(`OfficeId not provided`);"); + expect(aclFile?.content).toContain("return canUseGetPosition({ ...object, officeId: normalizeOfficeId });"); + expect(aclFile?.content).not.toContain("positionId: normalizePositionId"); + }); + it("resolves allowlisted workspace params inside mutation callbacks", () => { const files = generateCodeFromOpenAPIDoc(openApiDoc, { ...DEFAULT_GENERATE_OPTIONS, From 35d20a05f404ad5013feeb160be109ef87d00b07 Mon Sep 17 00:00:00 2001 From: gent124 Date: Fri, 15 May 2026 14:20:12 +0200 Subject: [PATCH 17/27] feat: enhance endpoint response handling and add optional parameter support - Updated response handling in `getEndpointsFromOpenAPIDoc` to prefer schemas with body content over void responses. - Introduced a new utility function `endpointParamsAllOptional` to check if all endpoint parameters are optional. - Modified query and infinite query templates to handle optional parameters correctly. - Added tests to verify the behavior of endpoints with default responses and optional parameters. --- .../core/endpoints/getEndpointParameter.ts | 23 +++++- .../getEndpointsFromOpenAPIDoc.test.ts | 81 ++++++++++++++++++- .../endpoints/getEndpointsFromOpenAPIDoc.ts | 48 +++++++---- .../core/getMetadataFromOpenAPIDoc.test.ts | 2 +- .../partials/query-use-infinite-query.hbs | 5 +- .../templates/partials/query-use-query.hbs | 7 +- .../generate/generate.endpoints.utils.ts | 13 +++ .../utils/hbs/hbs.endpoints.utils.ts | 11 +++ 8 files changed, 167 insertions(+), 23 deletions(-) diff --git a/src/generators/core/endpoints/getEndpointParameter.ts b/src/generators/core/endpoints/getEndpointParameter.ts index ae670c57..5c64409a 100644 --- a/src/generators/core/endpoints/getEndpointParameter.ts +++ b/src/generators/core/endpoints/getEndpointParameter.ts @@ -8,6 +8,7 @@ import { getEnumZodSchemaCodeFromEnumNames, getZodSchema } from "@/generators/co import { resolveZodSchemaName } from "@/generators/core/zod/resolveZodSchemaName"; import { EndpointParameter } from "@/generators/types/endpoint"; import { ParameterObject } from "@/generators/types/openapi"; +import { isSchemaObject } from "@/generators/utils/openapi-schema.utils"; import { isParamMediaTypeAllowed, isSortingParameterObject, @@ -89,7 +90,25 @@ export function getEndpointParameter({ const schemaObject = resolver.resolveObject(schema); - const zodChain = getZodChain({ schema: schemaObject, meta: zodSchema.meta, options: resolver.options }); + /** + * Optional query/header object params (e.g. deepObject `filter`): OpenAPI marks the param + * `required: false`, so getZodChain would append `.optional()` to the named schema. The + * endpoints template already wraps named optional params with `.optional()` in + * `ZodExtended.parse`, which duplicates optionality and breaks consumers that expect a bare + * object schema (e.g. builder configs). Keep `.nullable()` / defaults / validations; only + * skip the root presence modifier for object-shaped schemas. + */ + const rootIsOptionalQueryOrHeaderObject = + (paramObj.in === "query" || paramObj.in === "header") && + !paramObj.required && + isSchemaObject(schemaObject) && + (schemaObject.type === "object" || (!!schemaObject.properties && Object.keys(schemaObject.properties).length > 0)); + + const zodChain = getZodChain({ + schema: schemaObject, + meta: rootIsOptionalQueryOrHeaderObject ? { ...zodSchema.meta, isRequired: true } : zodSchema.meta, + options: resolver.options, + }); const zodSchemaName = resolveZodSchemaName({ schema: schemaObject, @@ -110,6 +129,6 @@ export function getEndpointParameter({ .run() as "Header" | "Query" | "Path", zodSchema: zodSchemaName, parameterObject: paramObj, - parameterSortingEnumSchemaName, + ...(parameterSortingEnumSchemaName !== undefined ? { parameterSortingEnumSchemaName } : {}), }; } diff --git a/src/generators/core/endpoints/getEndpointsFromOpenAPIDoc.test.ts b/src/generators/core/endpoints/getEndpointsFromOpenAPIDoc.test.ts index a8059f7d..c4e0330b 100644 --- a/src/generators/core/endpoints/getEndpointsFromOpenAPIDoc.test.ts +++ b/src/generators/core/endpoints/getEndpointsFromOpenAPIDoc.test.ts @@ -766,6 +766,77 @@ describe("getEndpointsFromOpenAPIDoc", () => { ); }); + test("uses default response when 200 has no content but default has JSON body", () => { + const Widget = { + type: "object", + properties: { id: { type: "string" } }, + } as OpenAPIV3.SchemaObject; + + const openApiDoc: OpenAPIV3.Document = { + ...baseDoc, + components: { schemas: { Widget } }, + paths: { + "/widgets": { + get: { + tags: ["widget"], + operationId: "listWidgets", + responses: { + "200": { + description: "", + }, + default: { + description: "", + content: { + [JSON_APPLICATION_FORMAT]: { + schema: { + type: "array", + items: { $ref: "#/components/schemas/Widget" }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const resolver = new SchemaResolver(openApiDoc, generateOptions); + const endpoints = getEndpointsFromOpenAPIDoc(resolver); + + expect(endpoints).toEqual([ + { + description: undefined, + summary: undefined, + errors: [], + method: "get", + operationName: "listWidgets", + parameters: [], + path: "/widgets", + requestFormat: JSON_APPLICATION_FORMAT, + response: "ListWidgetsResponse", + responseDescription: "", + responseFormat: JSON_APPLICATION_FORMAT, + responseObject: { + content: { + [JSON_APPLICATION_FORMAT]: { + schema: { type: "array", items: { $ref: "#/components/schemas/Widget" } }, + }, + }, + description: "", + }, + tags: ["widget"], + responseStatusCodes: ["200", "default"], + mediaUpload: false, + mediaDownload: false, + }, + ]); + expect(resolver.getZodSchemas()).toMatchObject({ + Widget: expect.any(String), + ListWidgetsResponse: "z.array(Widget)", + }); + }); + test("petstore.yaml", async () => { const openApiDoc = (await SwaggerParser.parse("./test/petstore.yaml")) as OpenAPIV3.Document; const resolver = new SchemaResolver(openApiDoc, generateOptions); @@ -1404,8 +1475,16 @@ describe("getEndpointsFromOpenAPIDoc", () => { ], path: "/user", requestFormat: JSON_APPLICATION_FORMAT, - response: "z.void()", + response: "User", + responseDescription: "Successful operation", responseFormat: JSON_APPLICATION_FORMAT, + responseObject: { + content: { + [JSON_APPLICATION_FORMAT]: { schema: { $ref: "#/components/schemas/User" } }, + "application/xml": { schema: { $ref: "#/components/schemas/User" } }, + }, + description: "Successful operation", + }, tags: ["user"], responseStatusCodes: ["default"], mediaUpload: false, diff --git a/src/generators/core/endpoints/getEndpointsFromOpenAPIDoc.ts b/src/generators/core/endpoints/getEndpointsFromOpenAPIDoc.ts index 28f116ee..c662bc97 100644 --- a/src/generators/core/endpoints/getEndpointsFromOpenAPIDoc.ts +++ b/src/generators/core/endpoints/getEndpointsFromOpenAPIDoc.ts @@ -122,20 +122,23 @@ export function getEndpointsFromOpenAPIDoc(resolver: SchemaResolver) { const responseObj = resolver.resolveObject(operation.responses[statusCode]); const mediaTypes = Object.keys(responseObj?.content ?? {}); - const matchingMediaType = mediaTypes.find(isMediaTypeAllowed); + // Prefer any content entry that declares a body schema. Some specs use + // non-application media types (e.g. text/json) which would otherwise skip + // schema resolution and fall back to z.void() for the whole operation. + const matchingMediaType = + mediaTypes.find((mt) => { + const entry = responseObj.content?.[mt]; + return !!entry?.schema; + }) ?? mediaTypes.find(isMediaTypeAllowed); let schema: OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject | undefined; - let responseZodSchema: string | undefined; if (matchingMediaType) { endpoint.responseFormat = matchingMediaType; schema = responseObj.content?.[matchingMediaType]?.schema; - } else { - responseZodSchema = VOID_SCHEMA; - if (statusCode === "200") { - resolver.validationErrors.push( - getInvalidStatusCodeError({ received: "200", expected: "204" }, operation, endpoint), - ); - } + } else if (statusCode === "200") { + resolver.validationErrors.push( + getInvalidStatusCodeError({ received: "200", expected: "204" }, operation, endpoint), + ); } if (schema) { @@ -158,18 +161,32 @@ export function getEndpointsFromOpenAPIDoc(resolver: SchemaResolver) { tag, }); - responseZodSchema = + const responseZodSchema = zodSchemaName + getZodChain({ schema: schemaObject, meta: zodSchema.meta, options: resolver.options }); - } - if (responseZodSchema) { const status = Number(statusCode); if (isMainResponseStatus(status) && !endpoint.response) { endpoint.response = responseZodSchema; endpoint.responseObject = responseObj; endpoint.responseDescription = responseObj?.description; - } else if (statusCode !== "default" && isErrorStatus(status)) { + } else if (statusCode === "default" && !endpoint.response) { + // Nest/Swagger often puts the JSON body only under `default` while `200` has no content. + endpoint.response = responseZodSchema; + endpoint.responseObject = responseObj; + endpoint.responseDescription = responseObj?.description; + } else if (statusCode !== "default" && !Number.isNaN(status) && isErrorStatus(status)) { + endpoint.errors.push({ + zodSchema: responseZodSchema, + status, + description: responseObj?.description, + }); + } + } else { + const status = Number(statusCode); + const responseZodSchema = VOID_SCHEMA; + + if (statusCode !== "default" && !Number.isNaN(status) && isErrorStatus(status)) { endpoint.errors.push({ zodSchema: responseZodSchema, status, @@ -190,7 +207,10 @@ export function getEndpointsFromOpenAPIDoc(resolver: SchemaResolver) { ); } - endpoint.acl = getEndpointAcl({ resolver, endpoint, operation }); + const resolvedAcl = getEndpointAcl({ resolver, endpoint, operation }); + if (resolvedAcl?.length) { + endpoint.acl = resolvedAcl; + } if (operation.security?.[0].Authorization && !endpoint.responseStatusCodes.includes("401")) { resolver.validationErrors.push(getMissingStatusCodeError("401", operation, endpoint)); diff --git a/src/generators/core/getMetadataFromOpenAPIDoc.test.ts b/src/generators/core/getMetadataFromOpenAPIDoc.test.ts index bd2a4db7..217ab5da 100644 --- a/src/generators/core/getMetadataFromOpenAPIDoc.test.ts +++ b/src/generators/core/getMetadataFromOpenAPIDoc.test.ts @@ -339,7 +339,7 @@ describe("getMetadataFromOpenAPIDoc", () => { isQuery: false, isMutation: true, params: [{ name: "data", isRequired: true, ...User }], - response: { type: "void", metaType: "primitive" }, + response: { ...User }, }, { name: "useCreateWithListInput", diff --git a/src/generators/templates/partials/query-use-infinite-query.hbs b/src/generators/templates/partials/query-use-infinite-query.hbs index 130d77b2..987fc5b2 100644 --- a/src/generators/templates/partials/query-use-infinite-query.hbs +++ b/src/generators/templates/partials/query-use-infinite-query.hbs @@ -1,9 +1,10 @@ {{! Js docs }} {{{genQueryJsDocs endpoint infiniteQuery=true}}} {{! Infinite query definition}} -export const {{infiniteQueryName endpoint}} = ({{#if (endpointParams endpoint)}}{ {{{endpointArgs endpoint excludePageParam=true}}} }: { {{{genEndpointParams endpoint excludePageParam=true}}} }, {{/if}}options?: AppInfiniteQueryOptions{{#if hasAxiosRequestConfig}}, {{axiosRequestConfigName}}?: {{axiosRequestConfigType}}{{/if}}) => { +export const {{infiniteQueryName endpoint}} = ({{#if (endpointParams endpoint)}}{{#if (endpointParamsAllOptional endpoint excludePageParam=true)}}params: { {{{genEndpointParams endpoint excludePageParam=true}}} } = {}{{else}}{ {{{endpointArgs endpoint excludePageParam=true}}} }: { {{{genEndpointParams endpoint excludePageParam=true}}} }{{/if}}, {{/if}}options?: AppInfiniteQueryOptions{{#if hasAxiosRequestConfig}}, {{axiosRequestConfigName}}?: {{axiosRequestConfigType}}{{/if}}) => { {{! Use acl check }} {{#if hasAclCheck}}const { checkAcl } = {{aclCheckHook}}();{{/if}} + {{#if (endpointParams endpoint)}}{{#if (endpointParamsAllOptional endpoint excludePageParam=true)}}const { {{{endpointArgs endpoint excludePageParam=true}}} } = params;{{/if}}{{/if}} return {{infiniteQueryHook}}({ queryKey: keys.{{endpointName endpoint}}Infinite({{#if (endpointParams endpoint)}}{{{endpointArgs endpoint excludePageParam=true}}}{{/if}}), @@ -18,4 +19,4 @@ export const {{infiniteQueryName endpoint}} = ({{#if (endpointParams endp }, ...options, }); -}; \ No newline at end of file +}; diff --git a/src/generators/templates/partials/query-use-query.hbs b/src/generators/templates/partials/query-use-query.hbs index 9d94b930..96394f4b 100644 --- a/src/generators/templates/partials/query-use-query.hbs +++ b/src/generators/templates/partials/query-use-query.hbs @@ -1,10 +1,11 @@ {{! Js docs }} {{{genQueryJsDocs endpoint query=true}}} {{! Query definition}} -export const {{queryName endpoint}} = ({{#if (endpointParams endpoint)}}{ {{{endpointArgs endpoint}}} }: { {{{genEndpointParams endpoint}}} }, {{/if}}options?: AppQueryOptions{{#if hasAxiosRequestConfig}}, {{axiosRequestConfigName}}?: {{axiosRequestConfigType}}{{/if}}) => { +export const {{queryName endpoint}} = ({{#if (endpointParams endpoint)}}{{#if (endpointParamsAllOptional endpoint)}}params: { {{{genEndpointParams endpoint}}} } = {}{{else}}{ {{{endpointArgs endpoint}}} }: { {{{genEndpointParams endpoint}}} }{{/if}}, {{/if}}options?: AppQueryOptions{{#if hasAxiosRequestConfig}}, {{axiosRequestConfigName}}?: {{axiosRequestConfigType}}{{/if}}) => { {{! Use acl check }} {{#if hasAclCheck}}const { checkAcl } = {{aclCheckHook}}();{{/if}} - + {{#if (endpointParams endpoint)}}{{#if (endpointParamsAllOptional endpoint)}}const { {{{endpointArgs endpoint}}} } = params;{{/if}}{{/if}} + return {{queryHook}}({ queryKey: keys.{{endpointName endpoint}}({{#if (endpointParams endpoint)}}{{{endpointArgs endpoint}}}{{/if}}), queryFn: {{#if hasQueryFn}}() => {{#if hasQueryFnBody}}{ {{/if}} @@ -13,4 +14,4 @@ export const {{queryName endpoint}} = ({{#if (endpointParams endpoint)}}{ {{#if hasQueryFnBody}} }{{/if}}, ...options, }); -}; \ No newline at end of file +}; diff --git a/src/generators/utils/generate/generate.endpoints.utils.ts b/src/generators/utils/generate/generate.endpoints.utils.ts index 7cd205b7..8106d9c6 100644 --- a/src/generators/utils/generate/generate.endpoints.utils.ts +++ b/src/generators/utils/generate/generate.endpoints.utils.ts @@ -106,6 +106,19 @@ export function mapEndpointParamsToFunctionParams( })); } +/** True when the endpoint has at least one mapped param and every mapped param is optional (safe `= {}` default on the params object). */ +export function endpointParamsAllOptional( + resolver: SchemaResolver, + endpoint: Endpoint, + mapOptions?: Parameters[2], +): boolean { + const params = mapEndpointParamsToFunctionParams(resolver, endpoint, mapOptions); + if (params.length === 0) { + return false; + } + return params.every((p) => !p.required); +} + export function getEndpointConfig(endpoint: Endpoint) { const params = endpoint.parameters .filter((param) => param.type === "Query") diff --git a/src/generators/utils/hbs/hbs.endpoints.utils.ts b/src/generators/utils/hbs/hbs.endpoints.utils.ts index cc555710..aa14fac0 100644 --- a/src/generators/utils/hbs/hbs.endpoints.utils.ts +++ b/src/generators/utils/hbs/hbs.endpoints.utils.ts @@ -5,6 +5,7 @@ import { SchemaResolver } from "@/generators/core/SchemaResolver.class"; import { Endpoint } from "@/generators/types/endpoint"; import { GenerateOptions } from "@/generators/types/options"; import { + endpointParamsAllOptional, getEndpointBody, getEndpointName, getEndpointPath, @@ -23,6 +24,7 @@ enum EndpointsHelpers { EndpointBody = "endpointBody", EndpointArgs = "endpointArgs", EndpointParamDescription = "endpointParamDescription", + EndpointParamsAllOptional = "endpointParamsAllOptional", } export function registerEndpointsHbsHelpers(resolver: SchemaResolver) { @@ -33,6 +35,7 @@ export function registerEndpointsHbsHelpers(resolver: SchemaResolver) { registerEndpointBodyHelper(); registerEndpointArgsHelper(resolver); registerEndpointParamDescriptionHelper(); + registerEndpointParamsAllOptionalHelper(resolver); } function registerEndpointNameHelper() { @@ -71,6 +74,14 @@ function registerEndpointArgsHelper(resolver: SchemaResolver) { ); } +function registerEndpointParamsAllOptionalHelper(resolver: SchemaResolver) { + Handlebars.registerHelper( + EndpointsHelpers.EndpointParamsAllOptional, + (endpoint: Endpoint, options: { hash: Parameters[2] }) => + endpointParamsAllOptional(resolver, endpoint, options.hash), + ); +} + function registerEndpointParamDescriptionHelper() { Handlebars.registerHelper( EndpointsHelpers.EndpointParamDescription, From 54f7e8b61f35e177398841c9864dba5a79705142 Mon Sep 17 00:00:00 2001 From: gent124 Date: Tue, 19 May 2026 09:39:16 +0200 Subject: [PATCH 18/27] Bump Version to 2.0.8-rc.38 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1ee64416..5e040a5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.8-rc.5", + "version": "2.0.8-rc.38", "keywords": [ "codegen", "openapi", From 6ed58e2373de04f06a688f19cdc3db087418acfd Mon Sep 17 00:00:00 2001 From: gent124 Date: Tue, 19 May 2026 09:42:18 +0200 Subject: [PATCH 19/27] chore: bump version to 2.0.8-rc.39 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5e040a5f..43969845 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.8-rc.38", + "version": "2.0.8-rc.39", "keywords": [ "codegen", "openapi", From 69d3141c26ee571860be01637c18b123294f61d8 Mon Sep 17 00:00:00 2001 From: gent124 Date: Tue, 19 May 2026 10:58:41 +0200 Subject: [PATCH 20/27] fix: pin yargs to v17 so bundled CLI works --- package.json | 2 +- yarn.lock | 102 +++++++++++++++------------------------------------ 2 files changed, 30 insertions(+), 74 deletions(-) diff --git a/package.json b/package.json index 43969845..ac858e26 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "vite": "^7.3.1", "vite-plugin-dts": "^4.5.4", "vitest": "4.0.18", - "yargs": "^18.0.0", + "yargs": "^17.7.2", "zod": "^4.3.6" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 9f890977..f01bc8f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1180,7 +1180,7 @@ __metadata: vite: "npm:^7.3.1" vite-plugin-dts: "npm:^4.5.4" vitest: "npm:4.0.18" - yargs: "npm:^18.0.0" + yargs: "npm:^17.7.2" zod: "npm:^4.3.6" peerDependencies: "@casl/ability": ^6.7.3 @@ -1974,13 +1974,6 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^6.2.1": - version: 6.2.3 - resolution: "ansi-styles@npm:6.2.3" - checksum: 10c0/23b8a4ce14e18fb854693b95351e286b771d23d8844057ed2e7d083cd3e708376c3323707ec6a24365f7d7eda3ca00327fe04092e29e551499ec4c8b7bfac868 - languageName: node - linkType: hard - "argparse@npm:^1.0.7, argparse@npm:~1.0.9": version: 1.0.10 resolution: "argparse@npm:1.0.10" @@ -2141,14 +2134,14 @@ __metadata: languageName: node linkType: hard -"cliui@npm:^9.0.1": - version: 9.0.1 - resolution: "cliui@npm:9.0.1" +"cliui@npm:^8.0.1": + version: 8.0.1 + resolution: "cliui@npm:8.0.1" dependencies: - string-width: "npm:^7.2.0" - strip-ansi: "npm:^7.1.0" - wrap-ansi: "npm:^9.0.0" - checksum: 10c0/13441832e9efe7c7a76bd2b8e683555c478d461a9f249dc5db9b17fe8d4b47fa9277b503914b90bd00e4a151abb6b9b02b2288972ffe2e5e3ca40bcb1c2330d3 + string-width: "npm:^4.2.0" + strip-ansi: "npm:^6.0.1" + wrap-ansi: "npm:^7.0.0" + checksum: 10c0/4bda0f09c340cbb6dfdc1ed508b3ca080f12992c18d68c6be4d9cf51756033d5266e61ec57529e610dacbf4da1c634423b0c1b11037709cc6b09045cbd815df5 languageName: node linkType: hard @@ -2293,13 +2286,6 @@ __metadata: languageName: node linkType: hard -"emoji-regex@npm:^10.3.0": - version: 10.6.0 - resolution: "emoji-regex@npm:10.6.0" - checksum: 10c0/1e4aa097bb007301c3b4b1913879ae27327fdc48e93eeefefe3b87e495eb33c5af155300be951b4349ff6ac084f4403dc9eff970acba7c1c572d89396a9a32d7 - languageName: node - linkType: hard - "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -2760,13 +2746,6 @@ __metadata: languageName: node linkType: hard -"get-east-asian-width@npm:^1.0.0": - version: 1.5.0 - resolution: "get-east-asian-width@npm:1.5.0" - checksum: 10c0/bff8bbc8d81790b9477f7aa55b1806b9f082a8dc1359fff7bd8b96939622c86b729685afc2bfeb22def1fc6ef1e5228e4d87dd4e6da60bc43a5edfb03c4ee167 - languageName: node - linkType: hard - "get-intrinsic@npm:^1.2.6": version: 1.3.1 resolution: "get-intrinsic@npm:1.3.1" @@ -3793,6 +3772,13 @@ __metadata: languageName: node linkType: hard +"require-directory@npm:^2.1.1": + version: 2.1.1 + resolution: "require-directory@npm:2.1.1" + checksum: 10c0/83aa76a7bc1531f68d92c75a2ca2f54f1b01463cb566cf3fbc787d0de8be30c9dbc211d1d46be3497dac5785fe296f2dd11d531945ac29730643357978966e99 + languageName: node + linkType: hard + "require-from-string@npm:^2.0.2": version: 2.0.2 resolution: "require-from-string@npm:2.0.2" @@ -4089,7 +4075,7 @@ __metadata: languageName: node linkType: hard -"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0": +"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" dependencies: @@ -4111,17 +4097,6 @@ __metadata: languageName: node linkType: hard -"string-width@npm:^7.0.0, string-width@npm:^7.2.0": - version: 7.2.0 - resolution: "string-width@npm:7.2.0" - dependencies: - emoji-regex: "npm:^10.3.0" - get-east-asian-width: "npm:^1.0.0" - strip-ansi: "npm:^7.1.0" - checksum: 10c0/eb0430dd43f3199c7a46dcbf7a0b34539c76fe3aa62763d0b0655acdcbdf360b3f66f3d58ca25ba0205f42ea3491fa00f09426d3b7d3040e506878fc7664c9b9 - languageName: node - linkType: hard - "strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": version: 6.0.1 resolution: "strip-ansi@npm:6.0.1" @@ -4149,15 +4124,6 @@ __metadata: languageName: node linkType: hard -"strip-ansi@npm:^7.1.0": - version: 7.1.2 - resolution: "strip-ansi@npm:7.1.2" - dependencies: - ansi-regex: "npm:^6.0.1" - checksum: 10c0/0d6d7a023de33368fd042aab0bf48f4f4077abdfd60e5393e73c7c411e85e1b3a83507c11af2e656188511475776215df9ca589b4da2295c9455cc399ce1858b - languageName: node - linkType: hard - "strip-json-comments@npm:~3.1.1": version: 3.1.1 resolution: "strip-json-comments@npm:3.1.1" @@ -4568,7 +4534,7 @@ __metadata: languageName: node linkType: hard -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0": version: 7.0.0 resolution: "wrap-ansi@npm:7.0.0" dependencies: @@ -4590,17 +4556,6 @@ __metadata: languageName: node linkType: hard -"wrap-ansi@npm:^9.0.0": - version: 9.0.2 - resolution: "wrap-ansi@npm:9.0.2" - dependencies: - ansi-styles: "npm:^6.2.1" - string-width: "npm:^7.0.0" - strip-ansi: "npm:^7.1.0" - checksum: 10c0/3305839b9a0d6fb930cb63a52f34d3936013d8b0682ff3ec133c9826512620f213800ffa19ea22904876d5b7e9a3c1f40682f03597d986a4ca881fa7b033688c - languageName: node - linkType: hard - "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" @@ -4622,24 +4577,25 @@ __metadata: languageName: node linkType: hard -"yargs-parser@npm:^22.0.0": - version: 22.0.0 - resolution: "yargs-parser@npm:22.0.0" - checksum: 10c0/cb7ef81759c4271cb1d96b9351dbbc9a9ce35d3e1122d2b739bf6c432603824fa02c67cc12dcef6ea80283379d63495686e8f41cc7b06c6576e792aba4d33e1c +"yargs-parser@npm:^21.1.1": + version: 21.1.1 + resolution: "yargs-parser@npm:21.1.1" + checksum: 10c0/f84b5e48169479d2f402239c59f084cfd1c3acc197a05c59b98bab067452e6b3ea46d4dd8ba2985ba7b3d32a343d77df0debd6b343e5dae3da2aab2cdf5886b2 languageName: node linkType: hard -"yargs@npm:^18.0.0": - version: 18.0.0 - resolution: "yargs@npm:18.0.0" +"yargs@npm:^17.7.2": + version: 17.7.2 + resolution: "yargs@npm:17.7.2" dependencies: - cliui: "npm:^9.0.1" + cliui: "npm:^8.0.1" escalade: "npm:^3.1.1" get-caller-file: "npm:^2.0.5" - string-width: "npm:^7.2.0" + require-directory: "npm:^2.1.1" + string-width: "npm:^4.2.3" y18n: "npm:^5.0.5" - yargs-parser: "npm:^22.0.0" - checksum: 10c0/bf290e4723876ea9c638c786a5c42ac28e03c9ca2325e1424bf43b94e5876456292d3ed905b853ebbba6daf43ed29e772ac2a6b3c5fb1b16533245d6211778f3 + yargs-parser: "npm:^21.1.1" + checksum: 10c0/ccd7e723e61ad5965fffbb791366db689572b80cca80e0f96aad968dfff4156cd7cd1ad18607afe1046d8241e6fb2d6c08bf7fa7bfb5eaec818735d8feac8f05 languageName: node linkType: hard From 12dcf7bcb8364b1800e18a55dd0e3e2e88a4246c Mon Sep 17 00:00:00 2001 From: gent124 Date: Tue, 19 May 2026 10:59:50 +0200 Subject: [PATCH 21/27] chore: bump version to 2.0.8-rc.40 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ac858e26..bb6b4f10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.8-rc.39", + "version": "2.0.8-rc.40", "keywords": [ "codegen", "openapi", From 84a16c9a2f556de244c822b53aef46efa06bc969 Mon Sep 17 00:00:00 2001 From: gent124 Date: Tue, 19 May 2026 14:30:51 +0200 Subject: [PATCH 22/27] chore: bump version to 2.0.8-rc.41 --- .../utils/generate/generate.configs.utils.ts | 19 ++++++++++++++++++- src/lib/rest/error-handling.ts | 8 ++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/generators/utils/generate/generate.configs.utils.ts b/src/generators/utils/generate/generate.configs.utils.ts index 3cd3a308..0779ad7d 100644 --- a/src/generators/utils/generate/generate.configs.utils.ts +++ b/src/generators/utils/generate/generate.configs.utils.ts @@ -255,6 +255,23 @@ function getInputsConfig(resolver: SchemaResolver, endpointParameter?: EndpointP return { schema: getImportedZodSchemaName(resolver, endpointParameter.zodSchema), options: { inputs } }; } +function resolveReadAllItemZodSchema( + resolver: SchemaResolver, + endpoint: Endpoint, + itemSchema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject, + itemsPropertyKey: string, +): string { + if (isReferenceObject(itemSchema)) { + return resolver.getZodSchemaNameByRef(itemSchema.$ref); + } + + if (isNamedZodSchema(endpoint.response)) { + return `${getImportedZodSchemaName(resolver, endpoint.response)}.shape.${itemsPropertyKey}.element`; + } + + return ANY_SCHEMA; +} + function getColumnsConfig(resolver: SchemaResolver, endpoint: Endpoint) { const endpointResponse = endpoint.responseObject; if (!endpointResponse) { @@ -294,7 +311,7 @@ function getColumnsConfig(resolver: SchemaResolver, endpoint: Endpoint) { return; } - const zodSchema = isReferenceObject(itemSchema) ? resolver.getZodSchemaNameByRef(itemSchema.$ref) : ANY_SCHEMA; + const zodSchema = resolveReadAllItemZodSchema(resolver, endpoint, itemSchema, propertyKey); const columns = Object.keys(itemSchemaObj?.properties ?? {}).reduce((acc, key) => ({ ...acc, [key]: true }), {}); const sortableEnumSchemaName = endpoint.parameters.find( diff --git a/src/lib/rest/error-handling.ts b/src/lib/rest/error-handling.ts index 7e22b240..9ef98f24 100644 --- a/src/lib/rest/error-handling.ts +++ b/src/lib/rest/error-handling.ts @@ -28,18 +28,18 @@ export class ApplicationException extends Error { export interface ErrorEntry { code: CodeT; condition?: (error: unknown) => boolean; - getMessage: (t: TFunction, error: unknown) => string; + getMessage: (t: TFunction, error: unknown) => string; } export interface ErrorHandlerOptions { entries: ErrorEntry[]; - t?: TFunction; + t?: TFunction; onRethrowError?: (error: unknown, exception: ApplicationException) => void; } export class ErrorHandler { entries: ErrorEntry[] = []; - private t: TFunction; + private t: TFunction; private onRethrowError?: (error: unknown, exception: ApplicationException) => void; constructor({ entries, t = defaultT, onRethrowError }: ErrorHandlerOptions) { @@ -125,7 +125,7 @@ export class ErrorHandler { return code === entry.code; } - public setTranslateFunction(t: TFunction) { + public setTranslateFunction(t: TFunction) { this.t = t; } From 895de3bbefa698d3f74494205201d5bfee2e407f Mon Sep 17 00:00:00 2001 From: gent124 Date: Tue, 19 May 2026 14:31:34 +0200 Subject: [PATCH 23/27] chore: bump version to 2.0.8-rc.41 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bb6b4f10..793b800c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.8-rc.40", + "version": "2.0.8-rc.41", "keywords": [ "codegen", "openapi", From 6ea85b04daceb3a3130d6647c11096b32f33e828 Mon Sep 17 00:00:00 2001 From: arlindaxhemailii Date: Wed, 20 May 2026 13:19:08 +0200 Subject: [PATCH 24/27] feat: support nested response params in infinite query getNextPageParam --- package.json | 2 +- .../templates/partials/query-use-infinite-query.hbs | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 793b800c..429d4c36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.8-rc.41", + "version": "2.0.8-rc.42", "keywords": [ "codegen", "openapi", diff --git a/src/generators/templates/partials/query-use-infinite-query.hbs b/src/generators/templates/partials/query-use-infinite-query.hbs index 987fc5b2..e9fad559 100644 --- a/src/generators/templates/partials/query-use-infinite-query.hbs +++ b/src/generators/templates/partials/query-use-infinite-query.hbs @@ -13,9 +13,10 @@ export const {{infiniteQueryName endpoint}} = ({{#if (endpointParams endp {{#if hasQueryFnBody}}return {{/if}}{{importedEndpointName endpoint}}({{{endpointArgs endpoint replacePageParam=true}}}{{#if hasAxiosRequestConfig}}, {{axiosRequestConfigName}}{{/if}}) {{#if hasQueryFnBody}} }{{/if}}, initialPageParam: 1, - getNextPageParam: ({ {{pageParamName}}, {{totalItemsName}}, {{limitParamName}}: limitParam }) => { - const pageParam = {{pageParamName}} ?? 1; - return pageParam * limitParam < {{totalItemsName}} ? pageParam + 1 : null; + getNextPageParam: (response) => { + const pageParam = (response.{{pageParamName}} ?? 1); + const limitParam = response.{{limitParamName}}; + return pageParam * limitParam < response.{{totalItemsName}} ? pageParam + 1 : null; }, ...options, }); From 13f3ea33e7ae6adce2a8815784b55502dfaea880 Mon Sep 17 00:00:00 2001 From: arlindaxhemailii Date: Wed, 20 May 2026 15:38:05 +0200 Subject: [PATCH 25/27] chore: bump version to 2.0.9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 429d4c36..8957c4b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.8-rc.42", + "version": "2.0.9", "keywords": [ "codegen", "openapi", From d28b23aa71cdbe70c4b93f8ad9fd04e48fc967b1 Mon Sep 17 00:00:00 2001 From: arlindaxhemailii Date: Wed, 20 May 2026 15:55:00 +0200 Subject: [PATCH 26/27] chore: bump version to 2.0.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8957c4b4..7f126fdd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@povio/openapi-codegen-cli", - "version": "2.0.9", + "version": "2.0.4", "keywords": [ "codegen", "openapi", From 6a8d6f010a194802e3981d09d86a843d0482fab2 Mon Sep 17 00:00:00 2001 From: arlindaxhemailii Date: Tue, 26 May 2026 10:24:20 +0200 Subject: [PATCH 27/27] feat: add mutationScope option for sequential mutations via TanStack scope.id --- README.md | 1 + src/commands/generate.command.ts | 3 + src/commands/generate.ts | 1 + src/generators/const/options.const.ts | 1 + .../generateCodeFromOpenAPIDoc.test.ts | 196 ++++++++++++++++++ .../templates/partials/query-use-mutation.hbs | 7 +- src/generators/types/options.ts | 1 + .../generate/generate.endpoints.utils.ts | 4 +- .../utils/hbs/hbs.partials.utils.ts | 33 ++- 9 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 src/generators/generateCodeFromOpenAPIDoc.test.ts diff --git a/README.md b/README.md index 2c2cc05e..b92a2996 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ yarn openapi-codegen generate --config my-config.ts --axiosRequestConfig Include Axios request config parameters in query hooks (default: false) --infiniteQueries Generate infinite queries for paginated API endpoints (default: false) --mutationEffects Add mutation effects options to mutation hooks (default: true) + --mutationScope Serialize mutations for the same path-param resource via TanStack scope.id (default: false) --parseRequestParams Add Zod parsing to API endpoints (default: true) --acl Generate ACL related files (default: true) diff --git a/src/commands/generate.command.ts b/src/commands/generate.command.ts index 51af9c17..ccdfe82c 100644 --- a/src/commands/generate.command.ts +++ b/src/commands/generate.command.ts @@ -64,6 +64,9 @@ class GenerateOptions implements GenerateParams { @YargOption({ envAlias: "mutationEffects", type: "boolean" }) mutationEffects?: boolean; + @YargOption({ envAlias: "mutationScope", type: "boolean" }) + mutationScope?: boolean; + @YargOption({ envAlias: "parseRequestParams", type: "boolean" }) parseRequestParams?: boolean; diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 4117ec3a..84ad7f58 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -34,6 +34,7 @@ export type GenerateParams = { | "infiniteQueries" | "axiosRequestConfig" | "mutationEffects" + | "mutationScope" | "parseRequestParams" | "builderConfigs" > diff --git a/src/generators/const/options.const.ts b/src/generators/const/options.const.ts index 58f1aa18..710da59f 100644 --- a/src/generators/const/options.const.ts +++ b/src/generators/const/options.const.ts @@ -53,6 +53,7 @@ export const DEFAULT_GENERATE_OPTIONS: GenerateOptions = { queryTypesImportPath: PACKAGE_IMPORT_PATH, axiosRequestConfig: false, mutationEffects: true, + mutationScope: false, // Infinite queries options infiniteQueries: false, infiniteQueryParamNames: { diff --git a/src/generators/generateCodeFromOpenAPIDoc.test.ts b/src/generators/generateCodeFromOpenAPIDoc.test.ts new file mode 100644 index 00000000..5477eeeb --- /dev/null +++ b/src/generators/generateCodeFromOpenAPIDoc.test.ts @@ -0,0 +1,196 @@ +import { OpenAPIV3 } from "openapi-types"; +import { describe, expect, test } from "vitest"; + +import { DEFAULT_GENERATE_OPTIONS } from "./const/options.const"; +import { generateCodeFromOpenAPIDoc } from "./generateCodeFromOpenAPIDoc"; +import { GenerateOptions } from "./types/options"; + +const openApiDoc = { + openapi: "3.0.3", + info: { title: "Mutation Scope Test", version: "1.0.0" }, + paths: { + "/users/{userId}/documents/{documentId}": { + put: { + tags: ["documents"], + operationId: "updateDocument", + parameters: [ + { + name: "userId", + in: "path", + required: true, + schema: { type: "string" }, + }, + { + name: "documentId", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/UpdateDocumentBody" }, + }, + }, + }, + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Document" }, + }, + }, + }, + }, + }, + delete: { + tags: ["documents"], + operationId: "deleteDocument", + parameters: [ + { + name: "userId", + in: "path", + required: true, + schema: { type: "string" }, + }, + { + name: "documentId", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "204": { + description: "Deleted", + }, + }, + }, + }, + "/documents": { + post: { + tags: ["documents"], + operationId: "createDocument", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/UpdateDocumentBody" }, + }, + }, + }, + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Document" }, + }, + }, + }, + }, + }, + }, + "/users/{userId}/avatar": { + post: { + tags: ["documents"], + operationId: "uploadAvatar", + "x-media-upload": true, + parameters: [ + { + name: "userId", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + requestBody: { + required: true, + content: { + "application/octet-stream": { + schema: { type: "string", format: "binary" }, + }, + }, + }, + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/UploadInstructions" }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Document: { + type: "object", + properties: { + id: { type: "string" }, + title: { type: "string" }, + }, + }, + UpdateDocumentBody: { + type: "object", + required: ["title"], + properties: { + title: { type: "string" }, + }, + }, + UploadInstructions: { + type: "object", + properties: { + url: { type: "string" }, + method: { type: "string" }, + }, + }, + }, + }, +} as unknown as OpenAPIV3.Document; + +const options = { + ...DEFAULT_GENERATE_OPTIONS, + mutationEffects: false, + mutationScope: true, +} as GenerateOptions; + +describe("generateCodeFromOpenAPIDoc", () => { + test("generates resource-scoped mutations from path params", () => { + const files = generateCodeFromOpenAPIDoc(openApiDoc, options); + const queries = files.find(({ fileName }) => fileName === "output/documents/documents.queries.ts")?.content; + + expect(queries).toBeDefined(); + expect(queries).toContain( + "export const useUpdate = ({ userId, documentId }: { userId: string; documentId: string; }, options?: AppMutationOptions) => {", + ); + expect(queries).toContain("mutationFn: ( { data } ) =>"); + expect(queries).toContain("DocumentsApi.update(userId, documentId, data)"); + expect(queries).toContain("scope: { id: `update:${userId}:${documentId}` }"); + + expect(queries).toContain( + "export const useDeleteDocument = ({ userId, documentId }: { userId: string; documentId: string; }, options?: AppMutationOptions) => {", + ); + expect(queries).toContain("mutationFn: () =>"); + expect(queries).toContain("DocumentsApi.deleteDocument(userId, documentId)"); + expect(queries).toContain("scope: { id: `deleteDocument:${userId}:${documentId}` }"); + + expect(queries).toContain( + "export const useCreate = (options?: AppMutationOptions) => {", + ); + expect(queries).not.toContain("scope: { id: `create"); + + expect(queries).toContain( + "export const useUploadAvatar = (options?: AppMutationOptions void }>) => {", + ); + expect(queries).toContain("mutationFn: async ( { userId, data, file, abortController, onUploadProgress } ) =>"); + expect(queries).toContain("DocumentsApi.uploadAvatar(userId, data)"); + expect(queries).not.toContain("scope: { id: `uploadAvatar:${userId}` }"); + }); +}); diff --git a/src/generators/templates/partials/query-use-mutation.hbs b/src/generators/templates/partials/query-use-mutation.hbs index 0ad0b81e..3d22b851 100644 --- a/src/generators/templates/partials/query-use-mutation.hbs +++ b/src/generators/templates/partials/query-use-mutation.hbs @@ -1,14 +1,14 @@ {{! Js docs }} {{{genQueryJsDocs endpoint mutation=true}}} {{! Mutation definition}} -export const {{queryName endpoint mutation=true}} = (options?: AppMutationOptions void{{/if}} }>{{#if hasMutationEffects}} & {{mutationEffectsType}}{{/if}}{{#if hasAxiosRequestConfig}}, {{axiosRequestConfigName}}?: {{axiosRequestConfigType}}{{/if}}) => { +export const {{queryName endpoint mutation=true}} = ({{#if hasMutationScope}}{ {{#each mutationScopePathParams}}{{this.name}}{{#unless @last}}, {{/unless}}{{/each}} }: { {{#each mutationScopePathParams}}{{this.name}}: {{this.type}}; {{/each}} }, {{/if}}options?: AppMutationOptions void{{/if}} }{{/if}}>{{#if hasMutationEffects}} & {{mutationEffectsType}}{{/if}}{{#if hasAxiosRequestConfig}}, {{axiosRequestConfigName}}?: {{axiosRequestConfigType}}{{/if}}) => { {{! Use acl check }} {{#if hasAclCheck}}const { checkAcl } = {{aclCheckHook}}();{{/if}} {{! Use mutation effects }} {{#if hasMutationEffects}}const { runMutationEffects } = useMutationEffects({ currentModule: {{queriesModuleName}} });{{/if}} return {{queryHook}}({ - mutationFn: {{#if endpoint.mediaUpload}}async {{/if}}({{#if (endpointParams endpoint includeFileParam=true)}} { {{{endpointArgs endpoint includeFileParam=true}}}{{#if endpoint.mediaUpload}}, abortController, onUploadProgress{{/if}} } {{/if}}) => {{#if hasMutationFnBody}} { {{/if}} + mutationFn: {{#if endpoint.mediaUpload}}async {{/if}}({{#if hasMutationVariables}} { {{#if hasMutationScope}}{{{endpointArgs endpoint excludePathParams=true includeFileParam=true}}}{{else}}{{{endpointArgs endpoint includeFileParam=true}}}{{/if}}{{#if endpoint.mediaUpload}}, abortController, onUploadProgress{{/if}} } {{/if}}) => {{#if hasMutationFnBody}} { {{/if}} {{#if hasAclCheck}}{{{genAclCheckCall endpoint}}}{{/if}} {{#if endpoint.mediaUpload}}const uploadInstructions = await {{importedEndpointName endpoint}}({{{endpointArgs endpoint}}}{{#if hasAxiosRequestConfig}}{{#if (endpointArgs endpoint)}}, {{/if}}{{axiosRequestConfigName}}{{/if}}); @@ -40,7 +40,8 @@ export const {{queryName endpoint mutation=true}} = (options?: AppMutationOption {{#if hasMutationFnBody}}return {{/if}}{{importedEndpointName endpoint}}({{{endpointArgs endpoint}}}{{#if hasAxiosRequestConfig}}{{#if (endpointArgs endpoint)}}, {{/if}}{{axiosRequestConfigName}}{{/if}}) {{/if}} {{#if hasMutationFnBody}} }{{/if}}, - ...options, {{#if hasMutationEffects}} + {{#if hasMutationScope}}scope: { id: {{{mutationScopeExpression}}} }, + {{/if}}...options, {{#if hasMutationEffects}} onSuccess: async (resData, variables, onMutateResult, context) => { {{! Mutation effects }} {{#if updateQueryEndpoints}} diff --git a/src/generators/types/options.ts b/src/generators/types/options.ts index e9d68d32..82741cd7 100644 --- a/src/generators/types/options.ts +++ b/src/generators/types/options.ts @@ -22,6 +22,7 @@ interface QueriesGenerateOptions { queryTypesImportPath: string; axiosRequestConfig?: boolean; mutationEffects?: boolean; + mutationScope?: boolean; } interface InfiniteQueriesGenerateOptions { diff --git a/src/generators/utils/generate/generate.endpoints.utils.ts b/src/generators/utils/generate/generate.endpoints.utils.ts index 8106d9c6..3f97af19 100644 --- a/src/generators/utils/generate/generate.endpoints.utils.ts +++ b/src/generators/utils/generate/generate.endpoints.utils.ts @@ -48,6 +48,7 @@ export function mapEndpointParamsToFunctionParams( includeFileParam?: boolean; includeOnlyRequiredParams?: boolean; pathParamsRequiredOnly?: boolean; + excludePathParams?: boolean; }, ) { const params = endpoint.parameters.map((param) => { @@ -94,7 +95,8 @@ export function mapEndpointParamsToFunctionParams( (param) => (!options?.excludeBodyParam || param.name !== BODY_PARAMETER_NAME) && (!options?.excludePageParam || param.name !== resolver.options.infiniteQueryParamNames.page) && - (!options?.includeOnlyRequiredParams || param.required), + (!options?.includeOnlyRequiredParams || param.required) && + (!options?.excludePathParams || param.paramType !== "Path"), ) .map((param) => ({ ...param, diff --git a/src/generators/utils/hbs/hbs.partials.utils.ts b/src/generators/utils/hbs/hbs.partials.utils.ts index 143b8711..e00deb4d 100644 --- a/src/generators/utils/hbs/hbs.partials.utils.ts +++ b/src/generators/utils/hbs/hbs.partials.utils.ts @@ -1,4 +1,5 @@ import Handlebars from "handlebars"; +import { OpenAPIV3 } from "openapi-types"; import { ACL_CHECK_HOOK, CASL_ABILITY_BINDING } from "@/generators/const/acl.const"; import { MUTATION_EFFECTS, ZOD_EXTENDED } from "@/generators/const/deps.const"; @@ -13,6 +14,7 @@ import { getAbilityConditionsTypes, hasAbilityConditions } from "@/generators/ut import { getEndpointBody, getEndpointConfig, + getEndpointName, getUpdateQueryEndpoints, hasEndpointConfig, mapEndpointParamsToFunctionParams, @@ -172,9 +174,30 @@ function registerGenerateMutationHelper(resolver: SchemaResolver) { } const updateQueryEndpoints = getUpdateQueryEndpoints(endpoint, queryEndpoints); - const destructuredVariables = getDestructuredVariables(resolver, endpoint, updateQueryEndpoints); const hasAclCheck = resolver.options.checkAcl && endpoint.acl; + const pathFuncParams = mapEndpointParamsToFunctionParams(resolver, endpoint).filter( + (param) => param.paramType === "Path", + ); + const hasMutationScope = + resolver.options.mutationScope === true && + pathFuncParams.length > 0 && + endpoint.method !== OpenAPIV3.HttpMethods.POST; + const mutationScopePathParams = hasMutationScope ? pathFuncParams.map(({ name, type }) => ({ name, type })) : []; + const mutationScopeExpression = hasMutationScope + ? getMutationScopeExpression(getEndpointName(endpoint), mutationScopePathParams.map(({ name }) => name)) + : undefined; + const mutationVariablesParams = mapEndpointParamsToFunctionParams(resolver, endpoint, { + excludePathParams: hasMutationScope, + includeFileParam: true, + }); + + const allDestructuredVariables = getDestructuredVariables(resolver, endpoint, updateQueryEndpoints); + const pathParamNames = new Set(pathFuncParams.map(({ name }) => name)); + const destructuredVariables = hasMutationScope + ? allDestructuredVariables.filter((name) => !pathParamNames.has(name)) + : allDestructuredVariables; + return getHbsPartialTemplateDelegate("query-use-mutation")({ endpoint, queryHook: QUERY_HOOKS.mutation, @@ -189,10 +212,18 @@ function registerGenerateMutationHelper(resolver: SchemaResolver) { destructuredVariables, hasAclCheck, aclCheckHook: ACL_CHECK_HOOK, + hasMutationScope, + mutationScopeExpression, + mutationScopePathParams, + hasMutationVariables: mutationVariablesParams.length > 0, }); }); } +function getMutationScopeExpression(endpointName: string, pathParamNames: string[]) { + return `\`${[endpointName, ...pathParamNames.map((name) => `\${${name}}`)].join(":")}\``; +} + function registerGenerateInfiniteQueryHelper(resolver: SchemaResolver) { Handlebars.registerHelper(PartialsHelpers.InfiniteQuery, (endpoint: Endpoint) => { if (!resolver.options.infiniteQueries || !isInfiniteQuery(endpoint, resolver.options)) {