Open tmax22 opened 5 months ago
Hi @tmax22 , thanks for filing this. I think having a fetch-based client makes a lot of sense. Maybe we should make a separate plugin to generate it.
Right now, the makeUrl
utility resides in the plugin package, making it less ideal to reuse even if we export it. I'm thinking maybe we should move it to be part of the "server" package instead since it's not query-framework dependent. We can export it from something like @zenstackhq/server/api/rpc/client
.
But if we go the route of generating fetch client, the makeUrl util is not needed anymore.
For the time being, having a copy of makeUrl
should be fine since the server's contract is quite stable and we intend to keep backward compatibility.
a separate plugin would be really awesome, and would let anyone need it to opt into it. 😁
we've created our own function wrapper that reuses prisma types to query autogenerated zenstack api with:
// fetchAPIModel.ts
import type { Prisma } from "@prisma/client";
import type { ModelKey, Types } from "@prisma/client/runtime/library.js";
import {} from "@zenstackhq/tanstack-query/runtime";
import { fetcher, makeUrl } from "./zenstack/zenstack.js";
import type { PolicyCrudKind } from "@zenstackhq/runtime";
import { getInvalidationPredicate, setupInvalidation } from "./zenstack/common.js";
import metadata from "../hooks/generated/__model_meta.js";
import type { PrismaWriteActionType } from "@zenstackhq/runtime/cross";
import { capitalizeFirstLetter, unCapitalizeFirstLetter } from "shared/utils/string";
type PrismaModelFields = Prisma.TypeMap["meta"]["modelProps"];
type GetModelMap<Model extends PrismaModelFields> = Prisma.TypeMap["model"][ModelKey<Prisma.TypeMap, Model>];
type GetOperations<Model extends PrismaModelFields> = GetModelMap<Model>["operations"];
type StringOnly<T> = T extends string ? T : never;
type GetModalScalarUnion<Model extends PrismaModelFields> = StringOnly<
NotArray<GetOperations<Model>["findMany"]["args"]["distinct"]>
>;
type GetModalScalarProps<Model extends PrismaModelFields> = {
operation: PolicyCrudKind;
where?: {
[Scalar in GetModalScalarUnion<Model>]?: Scalar extends keyof NonNullable<
GetOperations<Model>["findMany"]["args"]["where"]
>
? NonNullable<GetOperations<Model>["findMany"]["args"]["where"]>[Scalar]
: never;
};
};
type NotArray<T> = T extends Array<infer U> ? never : T;
export async function fetchAPIModel<
const Model extends PrismaModelFields,
const Operation extends StringOnly<keyof GetOperations<Model>> | "check",
const Args extends Operation extends "check"
? GetModalScalarProps<Model>
: "args" extends keyof GetOperations<Model>[Operation &
/* type assertion because typescript is stupid */ keyof GetOperations<Model>]
? GetOperations<Model>[Operation & keyof GetOperations<Model>]["args"]
: never,
>(
model: Model,
operation: Operation,
args: Args,
options?: { fetch?: typeof fetch; queryClient?: any },
): Promise<Operation extends "check" ? boolean : Types.Result.GetResult<GetModelMap<Model>["payload"], Args>> {
//
let res: Operation extends "check" ? boolean : Types.Result.GetResult<GetModelMap<Model>["payload"], Args>;
if (operation === "check") {
res = (await fetcher<boolean, false>(
makeUrl(`/api/model/${model}/${operation}`, args),
undefined,
options?.fetch,
false,
)) as Operation extends "check" ? boolean : never; // helping typescript over here
} else {
if (operation.startsWith("find") || ["count", "groupBy", "aggregate"].includes(operation)) {
res = (await fetcher<Types.Result.GetResult<GetModelMap<Model>["payload"], Args>, false>(
makeUrl(`/api/model/${model}/${operation}`, args),
undefined,
options?.fetch,
false,
)) as Operation extends "check" ? never : Types.Result.GetResult<GetModelMap<Model>["payload"], Args>;
} else if (["updateMany", "update" /*"updateManyAndReturn"*/].includes(operation)) {
res = (await fetcher<Types.Result.GetResult<GetModelMap<Model>["payload"], Args>, false>(
`/api/model/${model}/${operation}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(args),
},
options?.fetch,
false,
)) as Operation extends "check" ? never : Types.Result.GetResult<GetModelMap<Model>["payload"], Args>; // helping typescript over here
} else if (["create", "createMany", "upsert" /*"createManyAndReturn"*/].includes(operation)) {
res = (await fetcher<Types.Result.GetResult<GetModelMap<Model>["payload"], Args>, false>(
`/api/model/${model}/${operation}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(args),
},
options?.fetch,
false,
)) as Operation extends "check" ? never : Types.Result.GetResult<GetModelMap<Model>["payload"], Args>; // helping typescript over here
} else if (["deleteMany", "delete"].includes(operation)) {
res = (await fetcher<Types.Result.GetResult<GetModelMap<Model>["payload"], Args>, false>(
makeUrl(`/api/model/${model}/${operation}`, args),
{
method: "DELETE",
},
options?.fetch,
false,
)) as Operation extends "check" ? never : Types.Result.GetResult<GetModelMap<Model>["payload"], Args>; // helping typescript over here
} else {
throw new Error(`Unknown operation: ${operation}`);
}
// invalidate queries
if (options?.queryClient) {
console.log("invalidating queries", model, operation as PrismaWriteActionType, args);
const predicate = await getInvalidationPredicate(
capitalizeFirstLetter(model),
operation as PrismaWriteActionType,
args,
metadata,
false,
);
await options.queryClient.invalidateQueries({ predicate });
}
}
return res;
}
This client is able to query zenstack autogenerated backend, in a type safe way, and also handles automatic query invalidation if queryClient
is passed.
example usage and passing tests:
describe("fetchAPIModel", () => {
beforeEach(() => {
// tell vitest we use mocked time
vi.useFakeTimers();
vi.setSystemTime(new Date(1720008142488)); // use time that matches the cookies time
});
afterEach(() => {
// restoring date after each test run
vi.useRealTimers();
});
it("findFirst", async () => {
try {
const findFirst = await fetchAPIModel("productClearance", "findFirst", {}, { fetch });
expect(findFirst).toHaveProperty("featuresGroups");
} catch (e) {
console.log(e);
throw e;
}
});
it("findMany", async () => {
const findMany = await fetchAPIModel("productClearance", "findMany", { take: 1 }, { fetch });
expect(findMany[0]).toHaveProperty("featuresGroups");
});
it("create", async () => {
try {
const create = await fetchAPIModel("role", "create", { data: { name: "test" } }, { fetch });
expect(create).toHaveProperty("name");
await fetchAPIModel("role", "delete", { where: { name: "test" } }, { fetch });
} catch (e) {
console.log(e);
throw e;
}
});
it("delete", async () => {
const upsert = await fetchAPIModel(
"role",
"upsert",
{ where: { name: "test" }, update: {}, create: { name: "test" } },
{ fetch },
);
try {
const deleted = await fetchAPIModel("role", "delete", { where: { id: upsert.id } }, { fetch });
expect(deleted).toHaveProperty("name");
} catch (e) {
console.log(e);
throw e;
}
});
it("findUnique", async () => {
const upsert = await fetchAPIModel(
"role",
"upsert",
{ where: { name: "test" }, update: {}, create: { name: "test" } },
{ fetch },
);
const findUnique = await fetchAPIModel(
"role",
"findUnique",
{
where: { id: upsert.id },
},
{ fetch },
);
expect(findUnique).toHaveProperty("name");
const deleted = await fetchAPIModel("role", "delete", { where: { id: upsert.id } }, { fetch });
});
it("createMany", async () => {
const createMany = await fetchAPIModel("role", "createMany", { data: [{ name: "test" }] }, { fetch });
expect(createMany).toEqual({
count: 1,
});
await fetchAPIModel("role", "delete", { where: { name: "test" } }, { fetch });
});
it("check", async () => {
try {
const check = await fetchAPIModel(
"clearancePDFFile",
"check",
{ operation: "delete", where: { name: "string" } },
{ fetch },
);
expect(check).toEqual(true);
} catch (e) {
console.log(e);
throw e;
}
});
});
complex component with inheritance that represent files from different tables, all using 'custom' react-queries, type safety is preserved, automatic query invalidation is not applied here(by design):
interface FileProps {
fileType: FileType;
file: { id: string; name: string };
disableActions?: boolean;
}
type FileType = "userPDFFile" | "userReportFile";
const FileItem = ({ file, fileType, disableActions }: FileProps) => {
const fileDeleteMutation = useMutation({
mutationKey: [fileType, "delete", file.id],
mutationFn: (args: any) => {
return fetchAPIModel(fileType, "delete", { where: { id: file.id } });
},
});
const hasDeletePDFPermission = useQuery({
queryKey: [fileType, "check", file.id],
queryFn: async () => {
return fetchAPIModel(fileType, "check", { operation: "delete" });
},
}).data;
const [openPreview, setOpenPreview] = useState(false);
const isPdf = file.name.endsWith(".pdf");
return (
<>
<PDFPreviewDialog open={openPreview} setOpen={setOpenPreview} pdf={file} />
<Paper key={file.id} sx={{ m: 1, p: 1, display: "flex", alignItems: "center" }}>
<Typography>{file.name}</Typography>
<Box sx={{ flexGrow: 1 }} />
{isPdf && (
<Tooltip title={"Preview PDF"}>
<IconButton
onClick={() => {
setOpenPreview(true);
}}
>
<PreviewIcon />
</IconButton>
</Tooltip>
)}
<Tooltip title={"Download PDF"}>
<IconButton
onClick={async () => {
const fileRes = await fetchAPIModel(fileType, "findUnique", { where: { id: file.id } });
const contentBinary = fileRes?.content;
if (contentBinary) {
downLoadBufferAsFile(contentBinary, file.name);
}
}}
>
<DownloadIcon />
</IconButton>
</Tooltip>
{hasDeletePDFPermission && (
<ConfirmDialogButton
dialogHandleConfirm={(close) => {
close();
fileDeleteMutation.mutateAsync({ where: { id: file.id } });
}}
buttonElement={(handleOpen) => (
<Tooltip title={disableActions ? "Cannot delete when stage is done" : "Delete PDF"}>
<span>
<IconButton onClick={handleOpen} disabled={disableActions}>
<DeleteIcon />
</IconButton>
</span>
</Tooltip>
)}
/>
)}
</Paper>
</>
);
};
this exact typesafe function can be used on all projects instead of generating fetch request per each model. we just reusing generated prisma types to tighten the types on our APIFunction.
@ymc9, what are your thoughts? it can be formalized to official API? At a later stage, it makes sense to reuse this client internally within ZenStack hooks. Eventually, we might even consider exposing a single ZenStack hook for queries and another for mutations, accepting models and operations as arguments. This approach would leverage local Prisma types without the need for code generation.
zenstack already provides amazing typesafe auto-generated hooks with react query or swr. but sometimes we simply need the raw fetch call used behind these hooks. in my case, i need to call fetch inside recoiljs effeect which is loaded outside of react component.
it could have been awesome if zenstack could provide plugin for generated fetch functions, like
fetchMany<model>
,fetchFindFirst<model>
and so on. i would accept that these fetch functions would receive the sameI noticed that almost all (or all) hooks are using
makeUrl(url, data)
utility, i can also see that theurl
is constructed by usingendpoint
which is extracted fromgetHooksContext
which uses useContext, can you provide some insights about how to use this utility to implement a non-hook typesafe fetch functions?thanks!
for now, i was able to make a not typesafe workaround:
update: example for typesafe update mutation: