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 e640f7d..3884456 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) 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. The tool partially leverages code from [openapi-zod-client](https://github.com/astahmer/openapi-zod-client) repository. 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..f8b7815 --- /dev/null +++ b/scripts/verify-optional-axios.mjs @@ -0,0 +1,131 @@ +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 }); +} 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; }; }