feat: Nuxt server adapter and tanstack-query for "vue" hooks generation (#757)

This commit is contained in:
Yiming 2023-10-13 16:55:42 -07:00 committed by GitHub
parent 22b1bf9ddd
commit 033d95dcde
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 3751 additions and 239 deletions

View file

@ -158,10 +158,10 @@ The following diagram gives a high-level architecture overview of ZenStack.
### Framework adapters
- [Next.js](https://zenstack.dev/docs/reference/server-adapters/next) (including support for the new "app directory" in Next.js 13)
- [Nuxt](https://zenstack.dev/docs/reference/server-adapters/nuxt)
- [SvelteKit](https://zenstack.dev/docs/reference/server-adapters/sveltekit)
- [Fastify](https://zenstack.dev/docs/reference/server-adapters/fastify)
- [ExpressJS](https://zenstack.dev/docs/reference/server-adapters/express)
- Nuxt.js (Future)
- 🙋🏻 [Request for an adapter](https://go.zenstack.dev/chat)
### Prisma schema extensions
@ -179,6 +179,7 @@ Check out the [Collaborative Todo App](https://zenstack-todo.vercel.app/) for a
- [Next.js + SWR hooks](https://github.com/zenstackhq/sample-todo-nextjs)
- [Next.js + TanStack Query](https://github.com/zenstackhq/sample-todo-nextjs-tanstack)
- [Next.js + tRPC](https://github.com/zenstackhq/sample-todo-trpc)
- [Nuxt + TanStack Query](https://github.com/zenstackhq/sample-todo-nuxt)
- [SvelteKit + TanStack Query](https://github.com/zenstackhq/sample-todo-sveltekit)
## Community

View file

@ -23,6 +23,12 @@
"default": "./runtime/react.js",
"types": "./runtime/react.d.ts"
},
"./runtime/vue": {
"import": "./runtime/vue.mjs",
"require": "./runtime/vue.js",
"default": "./runtime/vue.js",
"types": "./runtime/vue.d.ts"
},
"./runtime/svelte": {
"import": "./runtime/svelte.mjs",
"require": "./runtime/svelte.js",
@ -55,6 +61,7 @@
"@zenstackhq/runtime": "workspace:*",
"@zenstackhq/sdk": "workspace:*",
"change-case": "^4.1.2",
"cross-fetch": "^4.0.0",
"decimal.js": "^10.4.2",
"lower-case-first": "^2.0.2",
"semver": "^7.3.8",
@ -63,8 +70,9 @@
"upper-case-first": "^2.0.2"
},
"devDependencies": {
"@tanstack/react-query": "4.29.7",
"@tanstack/svelte-query": "4.29.7",
"@tanstack/react-query": "^4.29.7",
"@tanstack/svelte-query": "^4.29.7",
"@tanstack/vue-query": "^4.37.0",
"@types/jest": "^29.5.0",
"@types/node": "^18.0.0",
"@types/react": "18.2.0",
@ -77,6 +85,7 @@
"rimraf": "^3.0.2",
"swr": "^2.0.3",
"ts-jest": "^29.0.5",
"typescript": "^4.9.4"
"typescript": "^4.9.4",
"vue": "^3.3.4"
}
}

View file

@ -19,7 +19,7 @@ import { Project, SourceFile, VariableDeclarationKind } from 'ts-morph';
import { upperCaseFirst } from 'upper-case-first';
import { name } from '.';
const supportedTargets = ['react', 'svelte'];
const supportedTargets = ['react', 'vue', 'svelte'];
type TargetFramework = (typeof supportedTargets)[number];
export async function generate(model: Model, options: PluginOptions, dmmf: DMMF.Document) {
@ -158,6 +158,7 @@ function generateMutationHook(
switch (target) {
case 'react':
case 'vue':
// override the mutateAsync function to return the correct type
func.addVariableStatement({
declarationKind: VariableDeclarationKind.Const,
@ -416,6 +417,9 @@ function generateIndex(project: Project, outDir: string, models: DataModel[], ta
case 'react':
sf.addStatements(`export { Provider } from '@zenstackhq/tanstack-query/runtime/react';`);
break;
case 'vue':
sf.addStatements(`export { VueQueryContextKey } from '@zenstackhq/tanstack-query/runtime/vue';`);
break;
case 'svelte':
sf.addStatements(`export { SvelteQueryContextKey } from '@zenstackhq/tanstack-query/runtime/svelte';`);
break;
@ -426,6 +430,8 @@ function makeGetContext(target: TargetFramework) {
switch (target) {
case 'react':
return 'const { endpoint, fetch } = useContext(RequestHandlerContext);';
case 'vue':
return 'const { endpoint, fetch } = getContext();';
case 'svelte':
return `const { endpoint, fetch } = getContext<RequestHandlerContext>(SvelteQueryContextKey);`;
default:
@ -446,6 +452,12 @@ function makeBaseImports(target: TargetFramework) {
`import { RequestHandlerContext } from '@zenstackhq/tanstack-query/runtime/${target}';`,
...shared,
];
case 'vue':
return [
`import type { UseMutationOptions, UseQueryOptions, UseInfiniteQueryOptions } from '@tanstack/vue-query';`,
`import { getContext } from '@zenstackhq/tanstack-query/runtime/${target}';`,
...shared,
];
case 'svelte':
return [
`import { getContext } from 'svelte';`,
@ -462,6 +474,7 @@ function makeBaseImports(target: TargetFramework) {
function makeQueryOptions(target: string, returnType: string, infinite: boolean) {
switch (target) {
case 'react':
case 'vue':
return `Use${infinite ? 'Infinite' : ''}QueryOptions<${returnType}>`;
case 'svelte':
return `${infinite ? 'CreateInfinite' : ''}QueryOptions<${returnType}>`;
@ -474,6 +487,8 @@ function makeMutationOptions(target: string, returnType: string, argsType: strin
switch (target) {
case 'react':
return `UseMutationOptions<${returnType}, unknown, ${argsType}>`;
case 'vue':
return `UseMutationOptions<${returnType}, unknown, ${argsType}, unknown>`;
case 'svelte':
return `MutationOptions<${returnType}, unknown, ${argsType}>`;
default:

View file

@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { serialize, deserialize } from '@zenstackhq/runtime/browser';
import { deserialize, serialize } from '@zenstackhq/runtime/browser';
import * as crossFetch from 'cross-fetch';
/**
* The default query endpoint.
@ -37,7 +38,7 @@ export async function fetcher<R, C extends boolean>(
fetch?: FetchFn,
checkReadBack?: C
): Promise<C extends true ? R | undefined : R> {
const _fetch = fetch ?? window.fetch;
const _fetch = fetch ?? crossFetch.fetch;
const res = await _fetch(url, options);
if (!res.ok) {
const errData = unmarshal(await res.text());

View file

@ -0,0 +1,199 @@
/* eslint-disable @typescript-eslint/ban-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
type MutateFunction,
type QueryClient,
type UseInfiniteQueryOptions,
type UseMutationOptions,
type UseQueryOptions,
} from '@tanstack/vue-query';
import { inject } from 'vue';
import { DEFAULT_QUERY_ENDPOINT, FetchFn, QUERY_KEY_PREFIX, fetcher, makeUrl, marshal } from './common';
import { RequestHandlerContext } from './svelte';
export { APIContext as RequestHandlerContext } from './common';
export const VueQueryContextKey = 'zenstack-vue-query-context';
export function getContext() {
return inject<RequestHandlerContext>(VueQueryContextKey, {
endpoint: DEFAULT_QUERY_ENDPOINT,
fetch: undefined,
});
}
/**
* Creates a vue-query query.
*
* @param model The name of the model under query.
* @param url The request URL.
* @param args The request args object, URL-encoded and appended as "?q=" parameter
* @param options The vue-query options object
* @returns useQuery hook
*/
export function query<R>(model: string, url: string, args?: unknown, options?: UseQueryOptions<R>, fetch?: FetchFn) {
const reqUrl = makeUrl(url, args);
return useQuery<R>({
queryKey: [QUERY_KEY_PREFIX + model, url, args],
queryFn: () => fetcher<R, false>(reqUrl, undefined, fetch, false),
...options,
});
}
/**
* Creates a vue-query infinite query.
*
* @param model The name of the model under query.
* @param url The request URL.
* @param args The initial request args object, URL-encoded and appended as "?q=" parameter
* @param options The vue-query infinite query options object
* @returns useInfiniteQuery hook
*/
export function infiniteQuery<R>(
model: string,
url: string,
args?: unknown,
options?: UseInfiniteQueryOptions<R>,
fetch?: FetchFn
) {
return useInfiniteQuery<R>({
queryKey: [QUERY_KEY_PREFIX + model, url, args],
queryFn: ({ pageParam }) => {
return fetcher<R, false>(makeUrl(url, pageParam ?? args), undefined, fetch, false);
},
...options,
});
}
/**
* Creates a POST mutation with vue-query.
*
* @param model The name of the model under mutation.
* @param url The request URL.
* @param options The vue-query options.
* @param invalidateQueries Whether to invalidate queries after mutation.
* @returns useMutation hooks
*/
export function postMutation<T, R = any, C extends boolean = boolean, Result = C extends true ? R | undefined : R>(
model: string,
url: string,
options?: Omit<UseMutationOptions<Result, unknown, T, unknown>, 'mutationFn'>,
fetch?: FetchFn,
invalidateQueries = true,
checkReadBack?: C
) {
const queryClient = useQueryClient();
const mutationFn = (data: any) =>
fetcher<R, C>(
url,
{
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: marshal(data),
},
fetch,
checkReadBack
) as Promise<Result>;
// TODO: figure out the typing problem
const finalOptions: any = mergeOptions<T, Result>(model, options, invalidateQueries, mutationFn, queryClient);
const mutation = useMutation<Result, unknown, T>(finalOptions);
return mutation;
}
/**
* Creates a PUT mutation with vue-query.
*
* @param model The name of the model under mutation.
* @param url The request URL.
* @param options The vue-query options.
* @param invalidateQueries Whether to invalidate queries after mutation.
* @returns useMutation hooks
*/
export function putMutation<T, R = any, C extends boolean = boolean, Result = C extends true ? R | undefined : R>(
model: string,
url: string,
options?: Omit<UseMutationOptions<Result, unknown, T, unknown>, 'mutationFn'>,
fetch?: FetchFn,
invalidateQueries = true,
checkReadBack?: C
) {
const queryClient = useQueryClient();
const mutationFn = (data: any) =>
fetcher<R, C>(
url,
{
method: 'PUT',
headers: {
'content-type': 'application/json',
},
body: marshal(data),
},
fetch,
checkReadBack
) as Promise<Result>;
// TODO: figure out the typing problem
const finalOptions: any = mergeOptions<T, Result>(model, options, invalidateQueries, mutationFn, queryClient);
const mutation = useMutation<Result, unknown, T>(finalOptions);
return mutation;
}
/**
* Creates a DELETE mutation with vue-query.
*
* @param model The name of the model under mutation.
* @param url The request URL.
* @param options The vue-query options.
* @param invalidateQueries Whether to invalidate queries after mutation.
* @returns useMutation hooks
*/
export function deleteMutation<T, R = any, C extends boolean = boolean, Result = C extends true ? R | undefined : R>(
model: string,
url: string,
options?: Omit<UseMutationOptions<Result, unknown, T, unknown>, 'mutationFn'>,
fetch?: FetchFn,
invalidateQueries = true,
checkReadBack?: C
) {
const queryClient = useQueryClient();
const mutationFn = (data: any) =>
fetcher<R, C>(
makeUrl(url, data),
{
method: 'DELETE',
},
fetch,
checkReadBack
) as Promise<Result>;
// TODO: figure out the typing problem
const finalOptions: any = mergeOptions<T, Result>(model, options, invalidateQueries, mutationFn, queryClient);
const mutation = useMutation<Result, unknown, T>(finalOptions);
return mutation;
}
function mergeOptions<T, R = any>(
model: string,
options: Omit<UseMutationOptions<R, unknown, T, unknown>, 'mutationFn'> | undefined,
invalidateQueries: boolean,
mutationFn: MutateFunction<R, unknown, T>,
queryClient: QueryClient
): UseMutationOptions<R, unknown, T, unknown> {
const result = { ...options, mutationFn };
if (options?.onSuccess || invalidateQueries) {
result.onSuccess = (...args) => {
if (invalidateQueries) {
queryClient.invalidateQueries([QUERY_KEY_PREFIX + model]);
}
return options?.onSuccess?.(...args);
};
}
return result;
}

View file

@ -70,6 +70,26 @@ ${sharedModel}
);
});
it('vue-query run plugin', async () => {
await loadSchema(
`
plugin tanstack {
provider = '${process.cwd()}/dist'
output = '$projectRoot/hooks'
target = 'vue'
}
${sharedModel}
`,
{
provider: 'postgresql',
pushDb: false,
extraDependencies: [`${origDir}/dist`, 'vue@^3.3.4', '@tanstack/vue-query@4.37.0'],
compile: true,
}
);
});
it('svelte-query run plugin', async () => {
await loadSchema(
`

View file

@ -1,7 +1,7 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/runtime/index.ts', 'src/runtime/react.ts', 'src/runtime/svelte.ts'],
entry: ['src/runtime/index.ts', 'src/runtime/react.ts', 'src/runtime/vue.ts', 'src/runtime/svelte.ts'],
outDir: 'dist/runtime',
splitting: false,
sourcemap: true,

View file

@ -1,9 +1,7 @@
// This is a sample model to get you started.
// Learn how to model you app: https://zenstack.dev/#/modeling-your-app.
/**
* A sample data source using local sqlite db.
* See how to use a different db: https://zenstack.dev/#/zmodel-data-source.
*/
datasource db {
provider = 'sqlite'
@ -40,8 +38,8 @@ model Post {
title String @length(1, 256)
content String
published Boolean @default(false)
author User? @relation(fields: [authorId], references: [id])
authorId String?
author User @relation(fields: [authorId], references: [id])
authorId String
// allow read for all signin users
@@allow('read', auth() != null && published)

View file

@ -53,9 +53,11 @@
"express": "^4.18.2",
"fastify": "^4.14.1",
"fastify-plugin": "^4.5.0",
"h3": "^1.8.2",
"isomorphic-fetch": "^3.0.0",
"jest": "^29.5.0",
"next": "^13.4.5",
"nuxt": "^3.7.4",
"rimraf": "^3.0.2",
"supertest": "^6.3.3",
"ts-jest": "^29.0.5",
@ -71,6 +73,7 @@
"./next": "./next/index.js",
"./next/pages-route-handler": "./next/pages-route-handler.js",
"./sveltekit": "./sveltekit/index.js",
"./nuxt": "./nuxt/index.js",
"./types": "./types.js"
}
}

View file

@ -0,0 +1,62 @@
import type { DbClientContract } from '@zenstackhq/runtime';
import {
EventHandlerRequest,
H3Event,
defineEventHandler,
getQuery,
getRouterParams,
readBody,
setResponseStatus,
} from 'h3';
import RPCApiHandler from '../api/rpc';
import { loadAssets } from '../shared';
import { AdapterBaseOptions } from '../types';
/**
* Nuxt request handler options
*/
export interface HandlerOptions extends AdapterBaseOptions {
/**
* Callback for getting a PrismaClient for the given request
*/
getPrisma: (event: H3Event<EventHandlerRequest>) => unknown | Promise<unknown>;
}
export function createEventHandler(options: HandlerOptions) {
return defineEventHandler(async (event) => {
const { modelMeta, zodSchemas } = loadAssets(options);
const requestHandler = options.handler ?? RPCApiHandler();
const prisma = (await options.getPrisma(event)) as DbClientContract;
if (!prisma) {
setResponseStatus(event, 500);
return { message: 'unable to get prisma from request context' };
}
const routerParam = getRouterParams(event);
const query = await getQuery(event);
let reqBody: unknown;
if (event.method === 'POST' || event.method === 'PUT' || event.method === 'PATCH') {
reqBody = await readBody(event);
}
try {
const { status, body } = await requestHandler({
method: event.method,
path: routerParam._,
query: query as Record<string, string | string[]>,
requestBody: reqBody,
prisma,
modelMeta,
zodSchemas,
});
setResponseStatus(event, status);
return body;
} catch (err) {
setResponseStatus(event, 500);
return { message: `An unhandled error occurred: ${err}` };
}
});
}

View file

@ -0,0 +1 @@
export * from './handler';

File diff suppressed because it is too large Load diff