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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

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.

ACL features require `@casl/ability@^7.1.1` and `@casl/react@^7.1.0`. When migrating to CASL 7, regenerate the client so `AppAbility` uses `Ability` instead of `PureAbility`, and update any handwritten CASL types. The runtime continues to use `createMongoAbility` for condition matching. CASL 7 treats empty conditions (`{}`) as matching everything. The `Can` helper keeps its `use` tuple and `(isAllowed, ability)` render callback.

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 Down
30 changes: 20 additions & 10 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions native/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2428,7 +2428,7 @@ fn render_app_acl(endpoints: &[Value]) -> Option<String> {
}
let forced = if additional { ", ForcedSubject" } else { "" };
let mut lines = vec![
format!("import type {{ PureAbility, AbilityTuple{forced} }} from \"@casl/ability\";"),
format!("import type {{ Ability, AbilityTuple{forced} }} from \"@casl/ability\";"),
String::new(),
"export type AppAbilities = ".into(),
];
Expand All @@ -2439,7 +2439,7 @@ fn render_app_acl(endpoints: &[Value]) -> Option<String> {
));
}
lines.push(String::new());
lines.push("export type AppAbility = PureAbility<AppAbilities>;".into());
lines.push("export type AppAbility = Ability<AppAbilities>;".into());
Some(lines.join("\n"))
}

Expand Down
10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,14 @@
"yargs": "^18.0.0"
},
"devDependencies": {
"@casl/ability": "6.8.0",
"@casl/react": "5.0.1",
"@casl/ability": "7.1.1",
"@casl/react": "7.1.0",
"@tanstack/react-query": "~5.90.21",
"@types/bun": "1.4.1",
"@types/node": "^25.3.0",
"@types/prompt-sync": "^4.2.3",
"@types/react": "^19.2.14",
"@types/react-test-renderer": "^19.1.0",
"@types/yargs": "^17.0.35",
"@vitejs/plugin-react": "^5.1.4",
"axios": "^1.13.1",
Expand All @@ -140,6 +141,7 @@
"oxlint": "1.50.0",
"oxlint-tsgolint": "^0.15.0",
"react": "^19.2.4",
"react-test-renderer": "19.2.4",
"tsdown": "^0.22.14",
"tsx": "^4.21.0",
"type-fest": "^5.4.4",
Expand All @@ -150,8 +152,8 @@
"zod": "^4.5.0"
},
"peerDependencies": {
"@casl/ability": "^6.7.3",
"@casl/react": "^5.0.0",
"@casl/ability": "^7.1.1",
"@casl/react": "^7.1.0",
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.1",
"i18next": "^25.8.11",
Expand Down
4 changes: 2 additions & 2 deletions src/generators/const/acl.const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ export const ACL_CHECK_HOOK = "useAclCheck";

export const CASL_ABILITY_BINDING = {
abilityTuple: "AbilityTuple",
pureAbility: "PureAbility",
ability: "Ability",
forcedSubject: "ForcedSubject",
subjectType: "Subject",
subject: "subject",
};
export const CASL_ABILITY_IMPORT: Import = {
bindings: [
CASL_ABILITY_BINDING.abilityTuple,
CASL_ABILITY_BINDING.pureAbility,
CASL_ABILITY_BINDING.ability,
CASL_ABILITY_BINDING.forcedSubject,
CASL_ABILITY_BINDING.subjectType,
CASL_ABILITY_BINDING.subject,
Expand Down
4 changes: 2 additions & 2 deletions src/generators/generate/generateAcl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export function generateAppAcl({ resolver, data }: Omit<GenerateTypeParams, "tag
const caslAbilityTupleImport: Import = {
bindings: [],
typeBindings: [
CASL_ABILITY_BINDING.pureAbility,
CASL_ABILITY_BINDING.ability,
CASL_ABILITY_BINDING.abilityTuple,
...(!appAbilitiesType ? [CASL_ABILITY_BINDING.subjectType] : []),
...(hasAdditionalAbilityImports ? [CASL_ABILITY_BINDING.forcedSubject] : []),
Expand All @@ -109,7 +109,7 @@ export function generateAppAcl({ resolver, data }: Omit<GenerateTypeParams, "tag
);
}
lines.push("");
lines.push(`export type AppAbility = PureAbility<${ACL_APP_ABILITIES}>;`);
lines.push(`export type AppAbility = ${CASL_ABILITY_BINDING.ability}<${ACL_APP_ABILITIES}>;`);
return lines.join("\n");
}

Expand Down
122 changes: 122 additions & 0 deletions src/generators/generated-casl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";

import type { OpenAPIV3 } from "openapi-types";
import { describe, expect, it } from "vitest";

import { compileNativeData } from "../native/native-bindings";

import { DEFAULT_GENERATE_OPTIONS } from "./const/options.const";
import { generateCodeFromOpenAPIDoc } from "./generateCodeFromOpenAPIDoc";

function fixture(withAcl: boolean): OpenAPIV3.Document {
return {
openapi: "3.0.3",
info: { title: "CASL 7", version: "1" },
paths: {
"/items/{workspaceId}": {
get: {
tags: ["items"],
operationId: "readItem",
parameters: [{ name: "workspaceId", in: "path", required: true, schema: { type: "string" } }],
...(withAcl
? { "x-acl": [{ action: "read", subject: "Item", conditions: { workspaceId: "$params.workspaceId" } }] }
: {}),
responses: { "204": { description: "OK" } },
},
},
},
} as OpenAPIV3.Document;
}

describe("generated CASL 7 ACL", () => {
it.each([false, true])(
"compiles and executes generated ACL (withAcl=%s)",
async (withAcl) => {
const output = await mkdtemp(join(process.cwd(), ".casl-generated-"));
try {
const document = fixture(withAcl);
const source = JSON.stringify(document);
const nativeSource = process.env.OPENAPI_CODEGEN_NATIVE === "1" ? { source, yaml: false } : undefined;
const options = {
...DEFAULT_GENERATE_OPTIONS,
output,
acl: true,
checkAcl: false,
tsNamespaces: false,
importPath: "relative" as const,
};
const files = generateCodeFromOpenAPIDoc(document, options, undefined, nativeSource).filter((file) =>
/app\.ability\.ts$|\.acl\.ts$/.test(file.fileName),
);
const appAbility = files.find((file) => file.fileName.endsWith("app.ability.ts"));
expect(appAbility).toBeDefined();
expect(appAbility!.content).not.toContain("PureAbility");
if (nativeSource && withAcl) {
const native = compileNativeData(source, false, JSON.stringify(options)).data as {
renderedShared: Record<string, string>;
};
expect(native.renderedShared.appAcl).toContain("AppAbility = Ability<");
expect(appAbility!.content).toBe(native.renderedShared.appAcl);
}
const domainAcl = files.find((file) => file.fileName.endsWith(".acl.ts"));
expect(Boolean(domainAcl)).toBe(withAcl);
for (const file of files) {
await mkdir(dirname(file.fileName), { recursive: true });
await writeFile(file.fileName, file.content);
}
await writeFile(
join(output, "consumer.ts"),
`
import { strict as assert } from "node:assert";
import { AbilityBuilder, createMongoAbility } from "@casl/ability";
import { packRules, unpackRules } from "@casl/ability/extra";
import type { AppAbility } from "./acl/app.ability";
${withAcl ? 'import { canUseReadItem } from "./items/items.acl";' : ""}
const builder = new AbilityBuilder<AppAbility>(createMongoAbility);
builder.can("read", "Item", { workspaceId: "mine" });
const ability = builder.build();
ability.update(unpackRules(packRules(ability.rules)));
assert.equal(ability.can("read", "Item"), true);
${withAcl ? "" : 'assert.equal(ability.can("delete", "Item"), false);'}
${
withAcl
? `
assert.equal(ability.can(...canUseReadItem({ workspaceId: "mine" })), true);
assert.equal(ability.can(...canUseReadItem({ workspaceId: "other" })), false);
// @ts-expect-error Generated abilities restrict actions.
const invalid: Parameters<AppAbility["can"]>[0] = "delete";
void invalid;
`
: ""
}
`,
);
await writeFile(
join(output, "tsconfig.json"),
JSON.stringify({
compilerOptions: {
strict: true,
noEmit: true,
target: "ESNext",
module: "ESNext",
moduleResolution: "bundler",
skipLibCheck: true,
types: ["node"],
},
include: ["**/*.ts"],
}),
);
execFileSync("bun", ["run", "tsc", "-p", join(output, "tsconfig.json")], {
encoding: "utf8",
stdio: "inherit",
});
execFileSync("bun", ["run", join(output, "consumer.ts")], { encoding: "utf8", stdio: "inherit" });
} finally {
await rm(output, { recursive: true, force: true });
}
},
30_000,
);
});
27 changes: 17 additions & 10 deletions src/lib/acl/Can.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import type { AbilityTuple, PureAbility } from "@casl/ability";
import { type BoundCanProps, createContextualCan } from "@casl/react";
import type { ReactNode } from "react";

import { AbilityContext } from "./ability.context";
import type { AppAbilities } from "./appAbility.types";
import { Can as CaslCan, type CanProps as CaslCanProps } from "@casl/react";

type CanAbility = PureAbility<AbilityTuple<AppAbilities[0], AppAbilities[1]>>;

const ContextualCan = createContextualCan<CanAbility>(AbilityContext.Consumer);
import type { AppAbilities, AppAbility } from "./appAbility.types";

type CanProps<TAppAbilities extends AppAbilities = AppAbilities> = {
use: TAppAbilities;
} & Omit<BoundCanProps<CanAbility>, "do" | "I" | "on" | "a" | "an" | "this">;
children: ReactNode | ((isAllowed: boolean, ability: AppAbility) => ReactNode);
} & Omit<CaslCanProps<AppAbility>, "do" | "I" | "on" | "a" | "an" | "this" | "children">;

export const Can = <TAppAbilities extends AppAbilities = AppAbilities>({ use, ...props }: CanProps<TAppAbilities>) => {
export const Can = <TAppAbilities extends AppAbilities = AppAbilities>({
use,
children,
...props
}: CanProps<TAppAbilities>) => {
const [action, subject] = use;

return <ContextualCan {...props} do={action} on={subject} />;
return (
<CaslCan<AppAbility> {...props} do={action} on={subject}>
{typeof children === "function"
? ({ isAllowed, ability }) => (props.passThrough || isAllowed ? children(isAllowed, ability) : null)
: children}
</CaslCan>
);
};
14 changes: 9 additions & 5 deletions src/lib/acl/ability.context.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { type PropsWithChildren, createContext, useMemo } from "react";

import { AbilityBuilder, type PureAbility, type RawRuleOf, createMongoAbility } from "@casl/ability";
import { AbilityBuilder, type Ability, type RawRuleOf, createMongoAbility } from "@casl/ability";
import { type PackRule, unpackRules } from "@casl/ability/extra";
import { useAbility as useCaslAbility } from "@casl/react";
import { AbilityProvider, useAbility as useCaslAbility } from "@casl/react";

import { AuthContext } from "../auth/auth.context";
import type { AppAbilities, AppAbility } from "./appAbility.types";
Expand Down Expand Up @@ -36,11 +36,15 @@ export namespace AbilityContext {
return build();
}, [user]);

return <Context.Provider value={ability}>{children}</Context.Provider>;
return (
<Context.Provider value={ability}>
<AbilityProvider value={ability}>{children}</AbilityProvider>
</Context.Provider>
);
};

export const useAbility = <TAppAbilities extends AppAbilities = AppAbilities>() => {
const ability = useCaslAbility(Context);
return ability as PureAbility<TAppAbilities>;
const ability = useCaslAbility<AppAbility>();
return ability as Ability<TAppAbilities>;
};
}
Loading
Loading