mirror of
https://github.com/zenstackhq/zenstack
synced 2026-05-24 10:08:55 +00:00
feat: Nuxt server adapter and tanstack-query for "vue" hooks generation (#757)
This commit is contained in:
parent
22b1bf9ddd
commit
033d95dcde
12 changed files with 3751 additions and 239 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
199
packages/plugins/tanstack-query/src/runtime/vue.ts
Normal file
199
packages/plugins/tanstack-query/src/runtime/vue.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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(
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
packages/server/src/nuxt/handler.ts
Normal file
62
packages/server/src/nuxt/handler.ts
Normal 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}` };
|
||||
}
|
||||
});
|
||||
}
|
||||
1
packages/server/src/nuxt/index.ts
Normal file
1
packages/server/src/nuxt/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './handler';
|
||||
3657
pnpm-lock.yaml
3657
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue