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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -33,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/",
Expand Down Expand Up @@ -210,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";

Expand All @@ -227,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";
Expand Down Expand Up @@ -256,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";

<OpenApiQueryConfig.Provider
onError={(error) => {
Expand All @@ -282,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"] }

<OpenApiWorkspaceContext.Provider values={{ officeId: "office_123" }}>
Expand Down Expand Up @@ -554,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`.
15 changes: 12 additions & 3 deletions native/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -156,12 +156,21 @@ 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()
}
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");
}
}
6 changes: 5 additions & 1 deletion native/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions src/generators/const/package.const.ts
Original file line number Diff line number Diff line change
@@ -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`;
Expand Down
4 changes: 2 additions & 2 deletions src/generators/generate/generateAppRestClient.ts
Original file line number Diff line number Diff line change
@@ -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}({
Expand Down
4 changes: 4 additions & 0 deletions src/generators/generate/generateConfigs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<QueryModule.items>");
expect(configsFile?.content).not.toContain("useMutationEffects<typeof QueryModule.items>");
expect(configsFile?.content).toContain("& MutationEffectsOptions)");
Expand Down
29 changes: 29 additions & 0 deletions src/generators/generate/generateQueries.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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,
Expand Down
128 changes: 128 additions & 0 deletions src/generators/generated-runtime-imports.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string>();
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 ? "<default or namespace>" : "<side effect>"];
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 ?? "<dedicated subpath>"}`,
);
}
}
}
}
// Prove the fixture activates the transport, mutation, ACL and workspace paths.
expect([...runtimeBindings]).toEqual(
expect.arrayContaining([
"RestClient",
"useMutationEffects",
"OpenApiQueryConfig",
"useAclCheck",
"useWorkspaceContext",
]),
);
expect(violations).toEqual([]);
});
}
}
});
5 changes: 4 additions & 1 deletion src/generators/utils/generate/generate.utils.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
34 changes: 34 additions & 0 deletions src/lib/rest/rest.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
Loading
Loading