zenstackhq / zenstack

Fullstack TypeScript toolkit that enhances Prisma ORM with flexible Authorization layer for RBAC/ABAC/PBAC/ReBAC, offering auto-generated type-safe APIs and frontend hooks.
https://zenstack.dev
MIT License
2.15k stars 88 forks source link

[Feature Request] Querying genererated API with fetch #1470

Open tmax22 opened 5 months ago

tmax22 commented 5 months ago

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 same

I noticed that almost all (or all) hooks are using makeUrl(url, data) utility, i can also see that the url is constructed by using endpoint which is extracted from getHooksContext 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:

import { serialize } from "@zenstackhq/runtime/browser";

// i had to copy paste the implementation from https://github.com/zenstackhq/zenstack/blob/71a389c068ec3ca4962d9f05f9d69c0f81be114e/packages/plugins/tanstack-query/src/runtime/common.ts#L224
// makeUrl is not exported at the built version
export function makeUrl(url: string, args: unknown) {
  if (!args) {
    return url;
  }

  const { data, meta } = serialize(args);
  let result = `${url}?q=${encodeURIComponent(JSON.stringify(data))}`;
  if (meta) {
    result += `&meta=${encodeURIComponent(JSON.stringify({ serialization: meta }))}`;
  }
  return result;
}
const test= () =>{
    fetch(
      makeUrl(`/api/model/product/findUnique`, {
        where: {
          name: product,
        },
        select: {
          features: true,
        },
      }),
    ).then((res) => {
      res.json().then((data) => console.log(data));
    })
}

update: example for typesafe update mutation:

import { serialize } from "@zenstackhq/runtime/browser";
import type { CheckSelect, ExtraMutationOptions, QueryError } from "@zenstackhq/tanstack-query/runtime-v5";
import type { UseMutationOptions } from "@tanstack/react-query";
// this is mapped into the @prisma/client folder in the backend via vite.config
import type { Prisma, ProductClearance } from "prisma-models";

export async function updateProductClearance<T extends Prisma.ProductClearanceUpdateArgs>(
  args: Prisma.SelectSubset<T, Prisma.ProductClearanceUpdateArgs>,
  options?: Omit<
    UseMutationOptions<
      CheckSelect<T, ProductClearance, Prisma.ProductClearanceGetPayload<T>> | undefined,
      DefaultError,
      Prisma.SelectSubset<T, Prisma.ProductClearanceUpdateArgs>
    > &
      ExtraMutationOptions,
    "mutationFn"
  >,
): Promise<CheckSelect<T, ProductClearance, Prisma.ProductClearanceGetPayload<T>> | undefined> {
  const response = await fetch(`/api/model//productClearance/update`, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(args),
  });
  if (!response.ok) {
    throw new Error("Network response was not ok");
  }
  const data = await response.json();
  return data as CheckSelect<T, ProductClearance, Prisma.ProductClearanceGetPayload<T>> | undefined;
}
ymc9 commented 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.

tmax22 commented 5 months ago

a separate plugin would be really awesome, and would let anyone need it to opt into it. 😁

Eliav2 commented 4 months ago

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.