From fae64b87126668c8a4e8d78ed4f0fbefa980da73 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Fri, 11 Sep 2026 07:27:16 +0200 Subject: [PATCH 1/5] docs: clarify optional Axios dependency --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e640f7d..694e520 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,11 @@ **NOTE:** Version 1+ requires zod v4 and is not compatible with zod v3. -**NOTE:** Version 2+ includes supporting classes/types/components for the generated code as well as auth, therefore it has peerDependencies for @tanstack/react-query, axios, react and zod. @casl/ability and @casl/react are also required if you are generating ACL checks and/or using imports from "@povio/openapi-codegen-cli/acl"! +**NOTE:** The package includes supporting classes, types, components, and auth utilities. Zod is a required peer dependency. Axios, @tanstack/react-query, react, i18next, @casl/ability, @casl/react, and Vite are optional peers; install the ones used by your chosen features. -Use this tool to generate code (Zod schemas, TypeScript types, API definitions, and React queries) from an OpenAPI v3 specification. API definitions are generated to use a REST client wrapper that utilizes Axios. React queries are generated in alignment with our code standards, without the need for explicit types. +Axios is required when using the Axios transport (the default), the `/axios` entry point, or the root runtime exports. Projects using the native transport can omit Axios: generate with `--restClient native` and import `NativeRestClient` from `@povio/openapi-codegen-cli/native`. CLI-only usage and the `/generator`, `/tiny`, `/vite`, and `/metro` entry points do not require Axios. + +Use this tool to generate code (Zod schemas, TypeScript types, API definitions, and React queries) from an OpenAPI v3 specification. API definitions use a REST client wrapper with either Axios or the native transport. React queries are generated in alignment with our code standards, without the need for explicit types. The tool partially leverages code from [openapi-zod-client](https://github.com/astahmer/openapi-zod-client) repository. From cd54b06809abc28a70424b207fd7d6a93ba723d9 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Fri, 11 Sep 2026 07:34:11 +0200 Subject: [PATCH 2/5] fix!: isolate Axios exports from shared runtime --- .github/workflows/check.yml | 2 + README.md | 2 +- package.json | 3 +- scripts/verify-optional-axios.mjs | 95 +++++++++++++++++++++++++++++++ src/index.ts | 3 - src/lib/rest/rest.utils.test.ts | 34 +++++++++++ src/lib/rest/rest.utils.ts | 12 ++-- 7 files changed, 141 insertions(+), 10 deletions(-) create mode 100644 scripts/verify-optional-axios.mjs create mode 100644 src/lib/rest/rest.utils.test.ts diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 4d4fd70..4224e23 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -27,3 +27,5 @@ jobs: - run: bun install --frozen-lockfile - run: bun run check + + - run: bun run verify:optional-axios diff --git a/README.md b/README.md index 694e520..31be4c5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **NOTE:** The package includes supporting classes, types, components, and auth utilities. Zod is a required peer dependency. Axios, @tanstack/react-query, react, i18next, @casl/ability, @casl/react, and Vite are optional peers; install the ones used by your chosen features. -Axios is required when using the Axios transport (the default), the `/axios` entry point, or the root runtime exports. Projects using the native transport can omit Axios: generate with `--restClient native` and import `NativeRestClient` from `@povio/openapi-codegen-cli/native`. CLI-only usage and the `/generator`, `/tiny`, `/vite`, and `/metro` entry points do not require Axios. +Axios is required when using the Axios transport (the default) or the `/axios` entry point. Projects using the native transport can omit Axios: generate with `--restClient native` and import `NativeRestClient` from `@povio/openapi-codegen-cli/native`. Import Axios-specific `RestClient`, `RestInterceptor`, `RequestInfo`, `RequestConfig`, `Response`, and `IRestClient` from `@povio/openapi-codegen-cli/axios`; these are no longer exported from the package root. CLI-only usage and the `/generator`, `/tiny`, `/vite`, and `/metro` entry points do not require Axios. Use this tool to generate code (Zod schemas, TypeScript types, API definitions, and React queries) from an OpenAPI v3 specification. API definitions use a REST client wrapper with either Axios or the native transport. React queries are generated in alignment with our code standards, without the need for explicit types. diff --git a/package.json b/package.json index 806ae67..2634c14 100644 --- a/package.json +++ b/package.json @@ -112,7 +112,8 @@ "dev:check": "bun run start check --config ./test/config.mjs", "snapshot:openapi-localhost": "bun ./scripts/snapshot-openapi-localhost.mjs", "bench:vite-codegen": "bun ./scripts/benchmark-vite-codegen.mjs", - "verify:tree-shaking": "bun run build:ts && bun ./scripts/verify-tree-shaking.mjs" + "verify:tree-shaking": "bun run build:ts && bun ./scripts/verify-tree-shaking.mjs", + "verify:optional-axios": "bun run build:ts && node ./scripts/verify-optional-axios.mjs" }, "dependencies": { "@orpc/contract": "^1.14.3", diff --git a/scripts/verify-optional-axios.mjs b/scripts/verify-optional-axios.mjs new file mode 100644 index 0000000..92890e4 --- /dev/null +++ b/scripts/verify-optional-axios.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { cpSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +// Copy published artifacts outside the repository so its development Axios cannot +// satisfy imports or declarations accidentally. Keep all other peers available. +const fixture = mkdtempSync(path.join(tmpdir(), "openapi-without-axios-")); +const manifest = JSON.parse(readFileSync("package.json", "utf8")); +const run = (args) => { + const result = spawnSync(process.execPath, args, { cwd: fixture, encoding: "utf8" }); + assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); +}; +try { + const packageDir = path.join(fixture, "node_modules", manifest.name); + mkdirSync(packageDir, { recursive: true }); + cpSync("dist", path.join(packageDir, "dist"), { recursive: true }); + writeFileSync(path.join(packageDir, "package.json"), JSON.stringify(manifest)); + for (const dependency of new Set([ + ...Object.keys(manifest.dependencies), + ...Object.keys(manifest.peerDependencies), + "@types/react", + ])) { + if (dependency === "axios") continue; + const destination = path.join(fixture, "node_modules", dependency); + mkdirSync(path.dirname(destination), { recursive: true }); + symlinkSync(path.resolve("node_modules", dependency), destination, "dir"); + } + writeFileSync(path.join(fixture, "package.json"), '{"type":"module"}'); + writeFileSync( + path.join(fixture, "runtime.mjs"), + ` + import assert from "node:assert/strict"; + import { registerHooks } from "node:module"; + registerHooks({ resolve(id, context, next) { + if (id === "axios" || id.startsWith("axios/")) throw new Error("AXIOS_UNAVAILABLE"); + return next(id, context); + }}); + for (const entry of ["", "/native", "/rest", "/errors", "/query", "/config", "/auth", "/generator", "/tiny", "/vite", "/metro", "/zod", "/acl"]) { + await import("${manifest.name}" + entry); + } + const { RestUtils } = await import("${manifest.name}/errors"); + assert.equal(RestUtils.extractContentDispositionFilename(new Headers({"content-disposition": 'attachment; filename="report.csv"'})), "report.csv"); + await assert.rejects(import("${manifest.name}/axios"), /AXIOS_UNAVAILABLE/); + `, + ); + run(["runtime.mjs"]); + writeFileSync( + path.join(fixture, "consumer.ts"), + ` + import { NativeRestClient, ErrorHandler, RestUtils } from "${manifest.name}"; + import { NativeHttpError } from "${manifest.name}/native"; + import { SharedErrorHandler } from "${manifest.name}/errors"; + export { NativeRestClient, NativeHttpError, ErrorHandler, SharedErrorHandler }; + RestUtils.doesServerErrorMessageContain(new Error("failure"), "failure"); + RestUtils.extractContentDispositionFilename(new Headers()); + `, + ); + writeFileSync( + path.join(fixture, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + target: "ESNext", + module: "NodeNext", + moduleResolution: "NodeNext", + strict: true, + skipLibCheck: false, + noEmit: true, + types: [], + lib: ["ESNext", "DOM"], + }, + files: ["consumer.ts"], + }), + ); + run([path.resolve("node_modules/typescript/bin/tsc"), "-p", "tsconfig.json"]); + // Existing Axios consumers retain the dedicated entry point and can pass + // Axios headers to the shared utilities without a type adapter. + symlinkSync(path.resolve("node_modules/axios"), path.join(fixture, "node_modules/axios"), "dir"); + writeFileSync( + path.join(fixture, "consumer.ts"), + ` + import { RestClient, RestInterceptor } from "${manifest.name}/axios"; + import { RestUtils } from "${manifest.name}/errors"; + import { AxiosHeaders } from "axios"; + export { RestClient, RestInterceptor }; + RestUtils.extractContentDispositionFilename(new AxiosHeaders()); + `, + ); + run([path.resolve("node_modules/typescript/bin/tsc"), "-p", "tsconfig.json"]); + run(["--input-type=module", "-e", `await import("${manifest.name}/axios")`]); + console.log("Runtime imports and consumer declarations pass without Axios; /axios works when installed."); +} finally { + rmSync(fixture, { recursive: true, force: true }); +} diff --git a/src/index.ts b/src/index.ts index e6684fe..5dc68af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,6 @@ export * from "./generators/types/config"; // REST client -export { RestClient } from "./lib/rest/rest-client"; export { NativeRestClient } from "./lib/rest/native-rest-client"; export { NativeRestInterceptor } from "./lib/rest/native-rest-interceptor"; export type { NativeInterceptor } from "./lib/rest/native-rest-interceptor"; @@ -13,8 +12,6 @@ export type { NativeUploadProgress, } from "./lib/rest/native-rest-client.types"; export { NativeHttpError } from "./lib/rest/native-rest-client.types"; -export type { RequestInfo, RequestConfig, Response, RestClient as IRestClient } from "./lib/rest/rest-client.types"; -export { RestInterceptor } from "./lib/rest/rest-interceptor"; export { RestUtils } from "./lib/rest/rest.utils"; // Error handling diff --git a/src/lib/rest/rest.utils.test.ts b/src/lib/rest/rest.utils.test.ts new file mode 100644 index 0000000..ee4aea1 --- /dev/null +++ b/src/lib/rest/rest.utils.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { NativeHttpError } from "./native-rest-client.types"; +import { RestUtils } from "./rest.utils"; + +describe("transport-neutral REST utilities", () => { + it("matches messages from both native and Axios-shaped errors", () => { + const response = { + data: { message: "Access Denied" }, + status: 403, + statusText: "Forbidden", + headers: new Headers(), + url: "/test", + }; + const errors = [new NativeHttpError("Forbidden", response), { isAxiosError: true, response }]; + for (const error of errors) { + expect(RestUtils.doesServerErrorMessageContain(error, "denied")).toBe(true); + expect(RestUtils.doesServerErrorMessageContain(error, "missing")).toBe(false); + } + expect(RestUtils.doesServerErrorMessageContain(null, "denied")).toBe(false); + }); + + it("extracts filenames from native Headers and Axios-style records", () => { + for (const headers of [ + new Headers({ "Content-Disposition": 'attachment; filename="report.csv"' }), + { "content-disposition": 'attachment; filename="report.csv"' }, + ]) { + expect(RestUtils.extractContentDispositionFilename(headers)).toBe("report.csv"); + } + for (const headers of [new Headers(), {}, { "content-disposition": 42 }]) { + expect(RestUtils.extractContentDispositionFilename(headers)).toBeUndefined(); + } + }); +}); diff --git a/src/lib/rest/rest.utils.ts b/src/lib/rest/rest.utils.ts index 6fee9df..4863f08 100644 --- a/src/lib/rest/rest.utils.ts +++ b/src/lib/rest/rest.utils.ts @@ -1,4 +1,3 @@ -import type { AxiosError, AxiosResponseHeaders } from "axios"; import { z } from "zod"; import { isAxiosErrorLike } from "./http-error.utils"; @@ -28,7 +27,7 @@ export namespace RestUtils { return null; }; - export const doesServerErrorMessageContain = (e: AxiosError, text: string): boolean => { + export const doesServerErrorMessageContain = (e: unknown, text: string): boolean => { const message = extractServerErrorMessage(e); if (message === null || message === undefined) { return false; @@ -51,8 +50,11 @@ export namespace RestUtils { return null; }; - export const extractContentDispositionFilename = (headers: AxiosResponseHeaders) => { - const contentDisposition = headers["content-disposition"] as string | undefined; - return contentDisposition ? /filename=["']?([^"';]+)/i.exec(contentDisposition)?.[1] : undefined; + export const extractContentDispositionFilename = (headers: Headers | Record) => { + const contentDisposition = + headers instanceof Headers ? headers.get("content-disposition") : headers["content-disposition"]; + return typeof contentDisposition === "string" + ? /filename=["']?([^"';]+)/i.exec(contentDisposition)?.[1] + : undefined; }; } From c68a9f8f184025b00e8e5d4088f7ac786f45c2e6 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Fri, 11 Sep 2026 07:39:42 +0200 Subject: [PATCH 3/5] fix: preserve root exports and verify consumer tree-shaking --- README.md | 2 +- scripts/verify-optional-axios.mjs | 44 ++++++++++++++++++++++++++++--- src/index.ts | 3 +++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 31be4c5..3884456 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **NOTE:** The package includes supporting classes, types, components, and auth utilities. Zod is a required peer dependency. Axios, @tanstack/react-query, react, i18next, @casl/ability, @casl/react, and Vite are optional peers; install the ones used by your chosen features. -Axios is required when using the Axios transport (the default) or the `/axios` entry point. Projects using the native transport can omit Axios: generate with `--restClient native` and import `NativeRestClient` from `@povio/openapi-codegen-cli/native`. Import Axios-specific `RestClient`, `RestInterceptor`, `RequestInfo`, `RequestConfig`, `Response`, and `IRestClient` from `@povio/openapi-codegen-cli/axios`; these are no longer exported from the package root. CLI-only usage and the `/generator`, `/tiny`, `/vite`, and `/metro` entry points do not require Axios. +Axios is required when using the Axios transport (the default) or the `/axios` entry point. Projects using the native transport can omit Axios: generate with `--restClient native` and import `NativeRestClient` from `@povio/openapi-codegen-cli/native`. The root exports remain available for compatibility and support tree-shaking in consumer bundles. For direct runtime imports without bundling, use `/native` and `/errors` to avoid loading the Axios client. The `/axios` entry point is also available for Axios-specific imports. CLI-only usage and the `/generator`, `/tiny`, `/vite`, and `/metro` entry points do not require Axios. Use this tool to generate code (Zod schemas, TypeScript types, API definitions, and React queries) from an OpenAPI v3 specification. API definitions use a REST client wrapper with either Axios or the native transport. React queries are generated in alignment with our code standards, without the need for explicit types. diff --git a/scripts/verify-optional-axios.mjs b/scripts/verify-optional-axios.mjs index 92890e4..f8b7815 100644 --- a/scripts/verify-optional-axios.mjs +++ b/scripts/verify-optional-axios.mjs @@ -1,3 +1,4 @@ +import { rolldown } from "rolldown"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { cpSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; @@ -37,7 +38,7 @@ try { if (id === "axios" || id.startsWith("axios/")) throw new Error("AXIOS_UNAVAILABLE"); return next(id, context); }}); - for (const entry of ["", "/native", "/rest", "/errors", "/query", "/config", "/auth", "/generator", "/tiny", "/vite", "/metro", "/zod", "/acl"]) { + for (const entry of ["/native", "/rest", "/errors", "/query", "/config", "/auth", "/generator", "/tiny", "/vite", "/metro", "/zod", "/acl"]) { await import("${manifest.name}" + entry); } const { RestUtils } = await import("${manifest.name}/errors"); @@ -46,10 +47,41 @@ try { `, ); run(["runtime.mjs"]); + // A root barrel needs a bundler to remove unused peer-dependent exports. + // Resolve against the isolated consumer, where Axios is actually absent. + writeFileSync( + path.join(fixture, "entry.mjs"), + ` + import { NativeRestClient, RestUtils } from "${manifest.name}"; + export { NativeRestClient, RestUtils }; + `, + ); + const bundle = await rolldown({ + input: path.join(fixture, "entry.mjs"), + platform: "browser", + external: ["zod"], + }); + try { + const { output } = await bundle.generate({ format: "esm" }); + const code = output + .filter((item) => item.type === "chunk") + .map((item) => item.code) + .join("\n"); + assert.doesNotMatch(code, /from\s+["']axios["']|import\(["']axios["']\)|require\(["']axios["']\)/); + writeFileSync(path.join(fixture, "bundle.mjs"), code); + run([ + "--input-type=module", + "-e", + 'const m = await import("./bundle.mjs"); if (typeof m.NativeRestClient !== "function" || !m.RestUtils) process.exit(1)', + ]); + } finally { + await bundle.close(); + } writeFileSync( path.join(fixture, "consumer.ts"), ` - import { NativeRestClient, ErrorHandler, RestUtils } from "${manifest.name}"; + import { NativeRestClient } from "${manifest.name}/native"; + import { ErrorHandler, RestUtils } from "${manifest.name}/errors"; import { NativeHttpError } from "${manifest.name}/native"; import { SharedErrorHandler } from "${manifest.name}/errors"; export { NativeRestClient, NativeHttpError, ErrorHandler, SharedErrorHandler }; @@ -80,7 +112,9 @@ try { writeFileSync( path.join(fixture, "consumer.ts"), ` - import { RestClient, RestInterceptor } from "${manifest.name}/axios"; + import { RestClient, RestInterceptor } from "${manifest.name}"; + import type { RequestInfo, RequestConfig, Response, IRestClient } from "${manifest.name}"; + export type { RequestInfo, RequestConfig, Response, IRestClient }; import { RestUtils } from "${manifest.name}/errors"; import { AxiosHeaders } from "axios"; export { RestClient, RestInterceptor }; @@ -89,7 +123,9 @@ try { ); run([path.resolve("node_modules/typescript/bin/tsc"), "-p", "tsconfig.json"]); run(["--input-type=module", "-e", `await import("${manifest.name}/axios")`]); - console.log("Runtime imports and consumer declarations pass without Axios; /axios works when installed."); + console.log( + "Native subpaths and tree-shaken root work without Axios; legacy root exports type-check with Axios installed.", + ); } finally { rmSync(fixture, { recursive: true, force: true }); } diff --git a/src/index.ts b/src/index.ts index 5dc68af..e6684fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export * from "./generators/types/config"; // REST client +export { RestClient } from "./lib/rest/rest-client"; export { NativeRestClient } from "./lib/rest/native-rest-client"; export { NativeRestInterceptor } from "./lib/rest/native-rest-interceptor"; export type { NativeInterceptor } from "./lib/rest/native-rest-interceptor"; @@ -12,6 +13,8 @@ export type { NativeUploadProgress, } from "./lib/rest/native-rest-client.types"; export { NativeHttpError } from "./lib/rest/native-rest-client.types"; +export type { RequestInfo, RequestConfig, Response, RestClient as IRestClient } from "./lib/rest/rest-client.types"; +export { RestInterceptor } from "./lib/rest/rest-interceptor"; export { RestUtils } from "./lib/rest/rest.utils"; // Error handling From dbabc973d764b28c7099d00e3b270d5a700f8440 Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Fri, 11 Sep 2026 08:30:32 +0200 Subject: [PATCH 4/5] fix: emit dedicated runtime imports in both generators --- README.md | 30 +++- native/src/config.rs | 15 +- native/src/render.rs | 6 +- src/generators/const/package.const.ts | 1 + .../generate/generateAppRestClient.ts | 4 +- .../generate/generateConfigs.test.ts | 4 + .../generate/generateQueries.test.ts | 29 ++++ .../generated-runtime-imports.test.ts | 128 ++++++++++++++++++ .../utils/generate/generate.utils.ts | 5 +- src/native/generateFilesFromNativeOpenAPI.ts | 2 +- 10 files changed, 211 insertions(+), 13 deletions(-) create mode 100644 src/generators/generated-runtime-imports.test.ts diff --git a/README.md b/README.md index 3884456..9373cf1 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ The CLI supports TypeScript configuration files to simplify command execution an Create an `openapi-codegen.config.ts` file: ```typescript -import { OpenAPICodegenConfig } from "@povio/openapi-codegen-cli"; +import type { OpenAPICodegenConfig } from "@povio/openapi-codegen-cli"; const config: OpenAPICodegenConfig = { input: "http://localhost:4000/docs-json/", @@ -212,7 +212,7 @@ directly in query callbacks, error boundaries, or their own normalization layer. In order to add interceptors to the used REST client, you must create your own instance of a RestClient and pass your implemented interceptors into the constructor. Make sure to set `restClientImportPath` in your openapi generation configuration too. ```ts -import { RestInterceptor } from "@povio/openapi-codegen-cli"; +import { RestInterceptor } from "@povio/openapi-codegen-cli/axios"; import { ACCESS_TOKEN_KEY } from "@/config/jwt.config"; @@ -229,7 +229,7 @@ export const AuthorizationHeaderInterceptor = new RestInterceptor((client) => { ``` ```ts -import { RestClient } from "@povio/openapi-codegen-cli"; +import { RestClient } from "@povio/openapi-codegen-cli/axios"; import { AuthorizationHeaderInterceptor } from "@/clients/rest/interceptors/authorization-header.interceptor"; import { AppConfig } from "@/config/app.config"; @@ -258,7 +258,8 @@ export default config; 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"; +import { ErrorHandler } from "@povio/openapi-codegen-cli/errors"; +import { OpenApiQueryConfig } from "@povio/openapi-codegen-cli/query"; { @@ -284,7 +285,7 @@ Use `OpenApiQueryConfig.Provider` to allow generated GET query hooks to return i 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"; +import { OpenApiWorkspaceContext } from "@povio/openapi-codegen-cli/config"; // openapi-codegen.config.ts -> { workspaceContext: ["officeId", "projectId"] } @@ -556,3 +557,22 @@ export class JSONDto { nested: NestedDto; } ``` + +### Runtime import paths + +Use package subpaths for runtime imports. Pure `import type` imports may use the package root. + +| Runtime exports | Subpath | +| --- | --- | +| `AuthContext`, `AuthGuard` | `/auth` | +| `AbilityContext`, `useAclCheck`, `Can`, `createAclGuard` | `/acl` | +| `ErrorHandler`, `SharedErrorHandler`, `ApplicationException`, `DomainErrorRegistry` | `/errors` | +| `useMutationEffects`, `OpenApiQueryConfig` | `/query` | +| `OpenApiRouter`, `OpenApiWorkspaceContext`, `useWorkspaceContext`, translation configuration | `/config` | +| `ZodExtended` | `/zod` | +| `HttpError`, transport types | `/rest` | +| `NativeRestClient` | `/native` | +| `RestClient`, `RestInterceptor` (Axios transport) | `/axios` | + +For example: `import { AuthContext, AuthGuard } from "@povio/openapi-codegen-cli/auth"`. +Custom application helpers should import error helpers from `/errors`, `AbilityContext` from `/acl`, and mutation effects from `/query`. diff --git a/native/src/config.rs b/native/src/config.rs index 751f90e..fa0b762 100644 --- a/native/src/config.rs +++ b/native/src/config.rs @@ -116,7 +116,7 @@ pub struct GenerateOptions { pub mutation_default_on_error: bool, #[serde(default)] pub query_types_import_path: String, - #[serde(default = "default_package_import_path")] + #[serde(default = "default_query_import_path")] pub mutation_effects_import_path: String, #[serde(default = "default_true")] pub check_acl: bool, @@ -156,8 +156,8 @@ fn default_total_items() -> String { fn default_limit() -> String { "limit".into() } -fn default_package_import_path() -> String { - "@povio/openapi-codegen-cli".into() +fn default_query_import_path() -> String { + "@povio/openapi-codegen-cli/query".into() } fn default_acl_import_path() -> String { "@povio/openapi-codegen-cli/acl".into() @@ -165,3 +165,12 @@ fn default_acl_import_path() -> String { fn default_zod_import_path() -> String { "@povio/openapi-codegen-cli/zod".into() } + +#[cfg(test)] +mod runtime_import_tests { + #[test] + fn mutation_effects_default_uses_query_subpath() { + let options: super::GenerateOptions = serde_json::from_str("{}").unwrap(); + assert_eq!(options.mutation_effects_import_path, "@povio/openapi-codegen-cli/query"); + } +} diff --git a/native/src/render.rs b/native/src/render.rs index 3e5eac3..28eee4f 100644 --- a/native/src/render.rs +++ b/native/src/render.rs @@ -2665,7 +2665,11 @@ fn render_query_module( lines.push(format!( "import {{ {} }} from \"{}\";", query_types.join(", "), - options.query_types_import_path + if options.query_types_import_path == "@povio/openapi-codegen-cli" { + "@povio/openapi-codegen-cli/query" + } else { + &options.query_types_import_path + } )); if endpoints .iter() diff --git a/src/generators/const/package.const.ts b/src/generators/const/package.const.ts index fe8ea70..3ab033b 100644 --- a/src/generators/const/package.const.ts +++ b/src/generators/const/package.const.ts @@ -1,4 +1,5 @@ export const PACKAGE_IMPORT_PATH = "@povio/openapi-codegen-cli"; +export const AXIOS_PACKAGE_IMPORT_PATH = `${PACKAGE_IMPORT_PATH}/axios`; export const NATIVE_PACKAGE_IMPORT_PATH = `${PACKAGE_IMPORT_PATH}/native`; export const REST_PACKAGE_IMPORT_PATH = `${PACKAGE_IMPORT_PATH}/rest`; export const ERRORS_PACKAGE_IMPORT_PATH = `${PACKAGE_IMPORT_PATH}/errors`; diff --git a/src/generators/generate/generateAppRestClient.ts b/src/generators/generate/generateAppRestClient.ts index 21dbb58..288da47 100644 --- a/src/generators/generate/generateAppRestClient.ts +++ b/src/generators/generate/generateAppRestClient.ts @@ -1,10 +1,10 @@ import { APP_REST_CLIENT_NAME } from "@/generators/const/deps.const"; -import { NATIVE_PACKAGE_IMPORT_PATH, PACKAGE_IMPORT_PATH } from "@/generators/const/package.const"; +import { NATIVE_PACKAGE_IMPORT_PATH, AXIOS_PACKAGE_IMPORT_PATH } from "@/generators/const/package.const"; import { SchemaResolver } from "@/generators/core/SchemaResolver.class"; export function generateAppRestClient(resolver: SchemaResolver) { const clientName = resolver.options.restClient === "native" ? "NativeRestClient" : "RestClient"; - const importPath = resolver.options.restClient === "native" ? NATIVE_PACKAGE_IMPORT_PATH : PACKAGE_IMPORT_PATH; + const importPath = resolver.options.restClient === "native" ? NATIVE_PACKAGE_IMPORT_PATH : AXIOS_PACKAGE_IMPORT_PATH; return `import { ${clientName} } from "${importPath}"; export const ${APP_REST_CLIENT_NAME} = new ${clientName}({ diff --git a/src/generators/generate/generateConfigs.test.ts b/src/generators/generate/generateConfigs.test.ts index a2a6256..c1c1fe3 100644 --- a/src/generators/generate/generateConfigs.test.ts +++ b/src/generators/generate/generateConfigs.test.ts @@ -106,6 +106,10 @@ describe("generateConfigs builderConfigs", () => { const configsFile = files.find((file) => file.fileName.endsWith("/items/items.configs.ts")); + expect(configsFile?.content).toContain( + 'import { useMutationEffects, type MutationEffectsOptions } from "@povio/openapi-codegen-cli/query";', + ); + expect(configsFile?.content).not.toContain('from "@povio/openapi-codegen-cli";'); expect(configsFile?.content).toContain("useMutationEffects"); expect(configsFile?.content).not.toContain("useMutationEffects"); expect(configsFile?.content).toContain("& MutationEffectsOptions)"); diff --git a/src/generators/generate/generateQueries.test.ts b/src/generators/generate/generateQueries.test.ts index 578d289..7104b2d 100644 --- a/src/generators/generate/generateQueries.test.ts +++ b/src/generators/generate/generateQueries.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { OpenAPIV3 } from "openapi-types"; +import { generateFilesFromNativeOpenAPI } from "@/native/generateFilesFromNativeOpenAPI"; + import { DEFAULT_GENERATE_OPTIONS } from "@/generators/const/options.const"; import { generateCodeFromOpenAPIDoc } from "@/generators/generateCodeFromOpenAPIDoc"; @@ -513,6 +515,33 @@ describe("generateQueries mutationEffects + infiniteQuery", () => { expect(apiFile?.content).toContain('from "@/data/zod.extended";'); }); + it.each(["native", "axios"] as const)("uses only package subpaths for %s runtime imports", (restClient) => { + const options = { + ...DEFAULT_GENERATE_OPTIONS, + output: "test-output", + restClient, + splitByTags: true, + modelsInCommon: true, + tsNamespaces: true, + inlineEndpoints: false, + workspaceContext: [], + builderConfigs: false, + }; + const tsFiles = generateCodeFromOpenAPIDoc(structuredClone(openApiDoc), options); + const nativeFiles = generateFilesFromNativeOpenAPI(JSON.stringify(openApiDoc), false, options); + expect(nativeFiles).toBeDefined(); + for (const files of [tsFiles, nativeFiles!]) { + const appClient = files.find((file) => file.fileName.endsWith("/app-rest-client.ts")); + expect(appClient?.content).toContain(`from "@povio/openapi-codegen-cli/${restClient}";`); + for (const file of files) { + expect(file.content).not.toMatch(/import\s+(?!type\b)[^;]*from\s+["']@povio\/openapi-codegen-cli["']/); + if (restClient === "native") { + expect(file.content).not.toContain('from "@povio/openapi-codegen-cli/axios"'); + } + } + } + }); + it("generates an Axios-free native REST client mode", () => { const files = generateCodeFromOpenAPIDoc(openApiDoc, { ...DEFAULT_GENERATE_OPTIONS, diff --git a/src/generators/generated-runtime-imports.test.ts b/src/generators/generated-runtime-imports.test.ts new file mode 100644 index 0000000..bc3ef51 --- /dev/null +++ b/src/generators/generated-runtime-imports.test.ts @@ -0,0 +1,128 @@ +import { generateFilesFromNativeOpenAPI } from "@/native/generateFilesFromNativeOpenAPI"; +import type { OpenAPIV3 } from "openapi-types"; +import { describe, expect, it } from "vitest"; + +import { DEFAULT_GENERATE_OPTIONS } from "./const/options.const"; +import { generateCodeFromOpenAPIDoc } from "./generateCodeFromOpenAPIDoc"; + +const packageRoot = "@povio/openapi-codegen-cli"; +const runtimeSubpaths: Record = { + ErrorHandler: "errors", + SharedErrorHandler: "errors", + ApplicationException: "errors", + DomainErrorRegistry: "errors", + AbilityContext: "acl", + useAclCheck: "acl", + AuthContext: "auth", + AuthGuard: "auth", + useMutationEffects: "query", + OpenApiQueryConfig: "query", + NativeRestClient: "native", + RestClient: "axios", + RestInterceptor: "axios", + useWorkspaceContext: "config", + OpenApiWorkspaceContext: "config", + OpenApiRouter: "config", + ZodExtended: "zod", +}; + +function fixture(): OpenAPIV3.Document { + const operation = { + tags: ["Items"], + parameters: [{ name: "workspaceId", in: "path", required: true, schema: { type: "string" } }], + "x-acl": [{ action: "read", subject: "Item", conditions: { workspaceId: "$params.workspaceId" } }], + responses: { + "200": { + description: "OK", + content: { "application/json": { schema: { type: "string" } } }, + }, + }, + }; + return { + openapi: "3.0.3", + info: { title: "Runtime import regression", version: "1.0.0" }, + paths: { + "/workspaces/{workspaceId}/items": { + get: { ...operation, operationId: "getItems" }, + post: { ...operation, operationId: "createItem" }, + }, + }, + } as OpenAPIV3.Document; +} + +describe("emitted runtime package imports", () => { + it.each([ + [packageRoot, `${packageRoot}/query`], + ["@/custom-query-types", "@/custom-query-types"], + ])("resolves queryTypesImportPath %s without changing custom modules", (queryTypesImportPath, expected) => { + const options = { + ...DEFAULT_GENERATE_OPTIONS, + queryTypesImportPath, + modelsInCommon: true, + tsNamespaces: true, + }; + const nativeFiles = generateFilesFromNativeOpenAPI(JSON.stringify(fixture()), false, options); + expect(nativeFiles).toBeDefined(); + for (const files of [generateCodeFromOpenAPIDoc(fixture(), options), nativeFiles!]) { + const query = files.find((file) => file.fileName.endsWith("items.queries.ts")); + expect(query?.content).toContain(`from "${expected}";`); + expect(query?.content).not.toContain(`from "${packageRoot}";`); + } + }); + + for (const inlineEndpoints of [false, true]) { + for (const standalone of [false, true]) { + it(`uses dedicated subpaths (inlineEndpoints=${inlineEndpoints}, standalone=${standalone})`, () => { + const files = generateCodeFromOpenAPIDoc(fixture(), { + ...DEFAULT_GENERATE_OPTIONS, + output: "runtime-import-test", + inlineEndpoints, + standalone, + mutationEffects: true, + mutationDefaultOnError: true, + acl: true, + checkAcl: true, + workspaceContext: ["workspaceId"], + }); + const violations: string[] = []; + const runtimeBindings = new Set(); + for (const file of files) { + // Generated imports are top-level declarations terminated by semicolons. + for (const match of file.content.matchAll(/^import\s+(?:(.*?)\s+from\s+)?["']([^"']+)["'];/gms)) { + const [, clause, from] = match; + if (from !== packageRoot && !from.startsWith(`${packageRoot}/`)) continue; + if (clause?.trimStart().startsWith("type ")) continue; + const named = clause?.match(/\{([^}]+)\}/s)?.[1]; + const bindings = named + ? named + .split(",") + .map((binding) => binding.trim()) + .filter((binding) => binding && !binding.startsWith("type ")) + .map((binding) => binding.split(/\s+as\s+/)[0]) + : [clause ? "" : ""]; + for (const binding of bindings) { + runtimeBindings.add(binding); + const expected = runtimeSubpaths[binding]; + if (from === packageRoot || (expected && from !== `${packageRoot}/${expected}`)) { + violations.push( + `${file.fileName}: ${binding} from ${from}; expected /${expected ?? ""}`, + ); + } + } + } + } + // Prove the fixture activates the transport, mutation, ACL and workspace paths. + expect([...runtimeBindings]).toEqual( + expect.arrayContaining([ + "RestClient", + "useMutationEffects", + "OpenApiQueryConfig", + "useAclCheck", + "useWorkspaceContext", + ]), + ); + expect(violations).toEqual([]); + }); + } + } +}); diff --git a/src/generators/utils/generate/generate.utils.ts b/src/generators/utils/generate/generate.utils.ts index 66248a2..b74b2a9 100644 --- a/src/generators/utils/generate/generate.utils.ts +++ b/src/generators/utils/generate/generate.utils.ts @@ -1,3 +1,4 @@ +import { PACKAGE_IMPORT_PATH, QUERY_PACKAGE_IMPORT_PATH } from "@/generators/const/package.const"; import { ACL_APP_ABILITY_FILE } from "@/generators/const/acl.const"; import { APP_REST_CLIENT_FILE, QUERY_MODULES_FILE } from "@/generators/const/deps.const"; import { DEFAULT_GENERATE_OPTIONS } from "@/generators/const/options.const"; @@ -49,7 +50,9 @@ export function getQueryModulesImportPath(options: GenerateOptions) { } export function getQueryTypesImportPath(options: GenerateOptions) { - return options.queryTypesImportPath; + return options.queryTypesImportPath === PACKAGE_IMPORT_PATH + ? QUERY_PACKAGE_IMPORT_PATH + : options.queryTypesImportPath; } export function getAppAbilitiesImportPath(options: GenerateOptions) { diff --git a/src/native/generateFilesFromNativeOpenAPI.ts b/src/native/generateFilesFromNativeOpenAPI.ts index 2fc62d1..f7c9245 100644 --- a/src/native/generateFilesFromNativeOpenAPI.ts +++ b/src/native/generateFilesFromNativeOpenAPI.ts @@ -71,7 +71,7 @@ export function generateFilesFromNativeOpenAPI( outputFile( options, "app-rest-client.ts", - `import { ${options.restClient === "native" ? "NativeRestClient" : "RestClient"} } from "@povio/openapi-codegen-cli${options.restClient === "native" ? "/native" : ""}";\n\nexport const AppRestClient = new ${options.restClient === "native" ? "NativeRestClient" : "RestClient"}({\n config: {\n baseURL: "${nativeData.baseUrl}"\n },\n});\n`, + `import { ${options.restClient === "native" ? "NativeRestClient" : "RestClient"} } from "@povio/openapi-codegen-cli${options.restClient === "native" ? "/native" : "/axios"}";\n\nexport const AppRestClient = new ${options.restClient === "native" ? "NativeRestClient" : "RestClient"}({\n config: {\n baseURL: "${nativeData.baseUrl}"\n },\n});\n`, ), ); } From b0dac2b221a71ca0442b20cf9d8cfd040057ffac Mon Sep 17 00:00:00 2001 From: Urban Krepel Date: Fri, 11 Sep 2026 08:34:28 +0200 Subject: [PATCH 5/5] chore: remove optional Axios consumer verification --- .github/workflows/check.yml | 2 - package.json | 3 +- scripts/verify-optional-axios.mjs | 131 ------------------------------ 3 files changed, 1 insertion(+), 135 deletions(-) delete mode 100644 scripts/verify-optional-axios.mjs diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 4224e23..4d4fd70 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -27,5 +27,3 @@ jobs: - run: bun install --frozen-lockfile - run: bun run check - - - run: bun run verify:optional-axios diff --git a/package.json b/package.json index 2634c14..806ae67 100644 --- a/package.json +++ b/package.json @@ -112,8 +112,7 @@ "dev:check": "bun run start check --config ./test/config.mjs", "snapshot:openapi-localhost": "bun ./scripts/snapshot-openapi-localhost.mjs", "bench:vite-codegen": "bun ./scripts/benchmark-vite-codegen.mjs", - "verify:tree-shaking": "bun run build:ts && bun ./scripts/verify-tree-shaking.mjs", - "verify:optional-axios": "bun run build:ts && node ./scripts/verify-optional-axios.mjs" + "verify:tree-shaking": "bun run build:ts && bun ./scripts/verify-tree-shaking.mjs" }, "dependencies": { "@orpc/contract": "^1.14.3", diff --git a/scripts/verify-optional-axios.mjs b/scripts/verify-optional-axios.mjs deleted file mode 100644 index f8b7815..0000000 --- a/scripts/verify-optional-axios.mjs +++ /dev/null @@ -1,131 +0,0 @@ -import { rolldown } from "rolldown"; -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { cpSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -// Copy published artifacts outside the repository so its development Axios cannot -// satisfy imports or declarations accidentally. Keep all other peers available. -const fixture = mkdtempSync(path.join(tmpdir(), "openapi-without-axios-")); -const manifest = JSON.parse(readFileSync("package.json", "utf8")); -const run = (args) => { - const result = spawnSync(process.execPath, args, { cwd: fixture, encoding: "utf8" }); - assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`); -}; -try { - const packageDir = path.join(fixture, "node_modules", manifest.name); - mkdirSync(packageDir, { recursive: true }); - cpSync("dist", path.join(packageDir, "dist"), { recursive: true }); - writeFileSync(path.join(packageDir, "package.json"), JSON.stringify(manifest)); - for (const dependency of new Set([ - ...Object.keys(manifest.dependencies), - ...Object.keys(manifest.peerDependencies), - "@types/react", - ])) { - if (dependency === "axios") continue; - const destination = path.join(fixture, "node_modules", dependency); - mkdirSync(path.dirname(destination), { recursive: true }); - symlinkSync(path.resolve("node_modules", dependency), destination, "dir"); - } - writeFileSync(path.join(fixture, "package.json"), '{"type":"module"}'); - writeFileSync( - path.join(fixture, "runtime.mjs"), - ` - import assert from "node:assert/strict"; - import { registerHooks } from "node:module"; - registerHooks({ resolve(id, context, next) { - if (id === "axios" || id.startsWith("axios/")) throw new Error("AXIOS_UNAVAILABLE"); - return next(id, context); - }}); - for (const entry of ["/native", "/rest", "/errors", "/query", "/config", "/auth", "/generator", "/tiny", "/vite", "/metro", "/zod", "/acl"]) { - await import("${manifest.name}" + entry); - } - const { RestUtils } = await import("${manifest.name}/errors"); - assert.equal(RestUtils.extractContentDispositionFilename(new Headers({"content-disposition": 'attachment; filename="report.csv"'})), "report.csv"); - await assert.rejects(import("${manifest.name}/axios"), /AXIOS_UNAVAILABLE/); - `, - ); - run(["runtime.mjs"]); - // A root barrel needs a bundler to remove unused peer-dependent exports. - // Resolve against the isolated consumer, where Axios is actually absent. - writeFileSync( - path.join(fixture, "entry.mjs"), - ` - import { NativeRestClient, RestUtils } from "${manifest.name}"; - export { NativeRestClient, RestUtils }; - `, - ); - const bundle = await rolldown({ - input: path.join(fixture, "entry.mjs"), - platform: "browser", - external: ["zod"], - }); - try { - const { output } = await bundle.generate({ format: "esm" }); - const code = output - .filter((item) => item.type === "chunk") - .map((item) => item.code) - .join("\n"); - assert.doesNotMatch(code, /from\s+["']axios["']|import\(["']axios["']\)|require\(["']axios["']\)/); - writeFileSync(path.join(fixture, "bundle.mjs"), code); - run([ - "--input-type=module", - "-e", - 'const m = await import("./bundle.mjs"); if (typeof m.NativeRestClient !== "function" || !m.RestUtils) process.exit(1)', - ]); - } finally { - await bundle.close(); - } - writeFileSync( - path.join(fixture, "consumer.ts"), - ` - import { NativeRestClient } from "${manifest.name}/native"; - import { ErrorHandler, RestUtils } from "${manifest.name}/errors"; - import { NativeHttpError } from "${manifest.name}/native"; - import { SharedErrorHandler } from "${manifest.name}/errors"; - export { NativeRestClient, NativeHttpError, ErrorHandler, SharedErrorHandler }; - RestUtils.doesServerErrorMessageContain(new Error("failure"), "failure"); - RestUtils.extractContentDispositionFilename(new Headers()); - `, - ); - writeFileSync( - path.join(fixture, "tsconfig.json"), - JSON.stringify({ - compilerOptions: { - target: "ESNext", - module: "NodeNext", - moduleResolution: "NodeNext", - strict: true, - skipLibCheck: false, - noEmit: true, - types: [], - lib: ["ESNext", "DOM"], - }, - files: ["consumer.ts"], - }), - ); - run([path.resolve("node_modules/typescript/bin/tsc"), "-p", "tsconfig.json"]); - // Existing Axios consumers retain the dedicated entry point and can pass - // Axios headers to the shared utilities without a type adapter. - symlinkSync(path.resolve("node_modules/axios"), path.join(fixture, "node_modules/axios"), "dir"); - writeFileSync( - path.join(fixture, "consumer.ts"), - ` - import { RestClient, RestInterceptor } from "${manifest.name}"; - import type { RequestInfo, RequestConfig, Response, IRestClient } from "${manifest.name}"; - export type { RequestInfo, RequestConfig, Response, IRestClient }; - import { RestUtils } from "${manifest.name}/errors"; - import { AxiosHeaders } from "axios"; - export { RestClient, RestInterceptor }; - RestUtils.extractContentDispositionFilename(new AxiosHeaders()); - `, - ); - run([path.resolve("node_modules/typescript/bin/tsc"), "-p", "tsconfig.json"]); - run(["--input-type=module", "-e", `await import("${manifest.name}/axios")`]); - console.log( - "Native subpaths and tree-shaken root work without Axios; legacy root exports type-check with Axios installed.", - ); -} finally { - rmSync(fixture, { recursive: true, force: true }); -}