Skip to content
Open
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
106 changes: 74 additions & 32 deletions packages/server/src/adapter/nuxt/handler.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,99 @@
import type { ClientContract } from '@zenstackhq/orm';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import {
defineEventHandler,
getQuery,
getRouterParams,
readBody,
setResponseStatus,
type H3Event,
type EventHandlerRequest,
} from 'h3';
import type { EventHandlerRequest, H3Event } from 'h3';
import type { RequestEvent } from 'nuxt/server';
import { logInternalError, type CommonAdapterOptions } from '../common';

/**
* The event passed to Nuxt server handlers: the portable `RequestEvent` from `nuxt/server` (Nuxt 4.6+),
* or an h3 v1 `H3Event` when the handler is used without `defineEventHandler` from `nuxt/server`.
*/
export type NuxtHandlerEvent = RequestEvent | H3Event<EventHandlerRequest>;

/**
* Nuxt request handler options
*/
export interface NuxtHandlerOptions<Schema extends SchemaDef> extends CommonAdapterOptions<Schema> {
/**
* Callback for getting a ZenStackClient for the given request
*/
getClient: (event: H3Event<EventHandlerRequest>) => ClientContract<Schema> | Promise<ClientContract<Schema>>;
// method syntax keeps parameter bivariance, so callbacks typed with a single event type remain assignable
getClient(event: NuxtHandlerEvent): ClientContract<Schema> | Promise<ClientContract<Schema>>;
}

interface ParsedRequest {
method: string;
path: string;
query: Record<string, string | string[]>;
requestBody: unknown;
}

const BODY_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);

function jsonResponse(body: unknown, status: number) {
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
}

function isPortableEvent(event: NuxtHandlerEvent): event is RequestEvent {
return typeof (event.req as { text?: unknown } | undefined)?.text === 'function';
}

async function parsePortableRequest(event: RequestEvent): Promise<ParsedRequest> {
const { req } = event;
const url = event.url ?? new URL(req.url);

const query: Record<string, string | string[]> = {};
for (const key of new Set(url.searchParams.keys())) {
const values = url.searchParams.getAll(key);
query[key] = values.length > 1 ? values : values[0]!;
}

let requestBody: unknown;
if (BODY_METHODS.has(req.method)) {
const text = await req.text();
if (text) {
requestBody = JSON.parse(text);
}
}

return { method: req.method, path: event.context.params?.['_'] ?? '', query, requestBody };
}

async function parseH3Request(event: H3Event<EventHandlerRequest>): Promise<ParsedRequest> {
const { getQuery, getRouterParams, readBody } = await import('h3');
const requestBody = BODY_METHODS.has(event.method) ? await readBody(event) : undefined;
return {
method: event.method,
path: getRouterParams(event)['_'] ?? '',
query: getQuery(event) as Record<string, string | string[]>,
requestBody,
};
}

/**
* Creates a Nuxt server route handler for handling CRUD requests. Wrap it with `defineEventHandler` from
* `nuxt/server` to run on the portable event, which also works with Nuxt 5.
*/
export function createEventHandler<Schema extends SchemaDef>(options: NuxtHandlerOptions<Schema>) {
return defineEventHandler(async (event) => {
return async (event: NuxtHandlerEvent): Promise<Response> => {
const client = await options.getClient(event);
if (!client) {
setResponseStatus(event, 500);
return { message: 'unable to get ZenStackClient from request context' };
return jsonResponse({ message: 'unable to get ZenStackClient from request context' }, 500);
}

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);
let request: ParsedRequest;
try {
request = isPortableEvent(event) ? await parsePortableRequest(event) : await parseH3Request(event);
} catch {
return jsonResponse({ message: 'invalid request payload' }, 400);
}

try {
const { status, body } = await options.apiHandler.handleRequest({
method: event.method,
path: routerParam['_']!,
query: query as Record<string, string | string[]>,
requestBody: reqBody,
client,
});

setResponseStatus(event, status);
return body;
const { status, body } = await options.apiHandler.handleRequest({ ...request, client });
return jsonResponse(body, status);
} catch (err) {
setResponseStatus(event, 500);
logInternalError(options.apiHandler.log, err);
return { message: 'An internal server error occurred' };
return jsonResponse({ message: 'An internal server error occurred' }, 500);
}
});
};
}
2 changes: 1 addition & 1 deletion packages/server/src/adapter/nuxt/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { createEventHandler, type NuxtHandlerOptions } from './handler';
export { createEventHandler, type NuxtHandlerEvent, type NuxtHandlerOptions } from './handler';
120 changes: 120 additions & 0 deletions packages/server/test/adapter/nuxt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { createTestClient } from '@zenstackhq/testtools';
import { createApp, createRouter, defineEventHandler, toNodeListener } from 'h3';
import type { RequestEvent } from 'nuxt/server';
import supertest from 'supertest';
import { describe, expect, it } from 'vitest';
import { createEventHandler } from '../../src/adapter/nuxt';
import { RPCApiHandler } from '../../src/api';
import { makeUrl, schema } from '../utils';

function makePortableEvent(method: string, path: string, body?: unknown) {
const url = new URL(`http://localhost${path}`);
const req = new Request(url, {
method,
headers: { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const [, , , ...rest] = url.pathname.split('/');
return { req, url, context: { params: { _: rest.join('/') } } } as unknown as RequestEvent;
}

describe('Nuxt adapter tests - portable event', () => {
it('properly handles requests', async () => {
const client = await createTestClient(schema);
const handler = createEventHandler({
getClient: () => client,
apiHandler: new RPCApiHandler({ schema: client.schema }),
});

let r = await handler(makePortableEvent('GET', makeUrl('/api/model/post/findMany')));
expect(r.status).toBe(200);
expect((await r.json()).data).toHaveLength(0);

r = await handler(
makePortableEvent('POST', '/api/model/user/create', { data: { id: 'user1', email: 'user1@abc.com' } }),
);
expect(r.status).toBe(201);
expect((await r.json()).data).toMatchObject({ email: 'user1@abc.com' });

r = await handler(
makePortableEvent('GET', makeUrl('/api/model/user/findMany', { where: { email: 'user1@abc.com' } })),
);
expect((await r.json()).data).toHaveLength(1);

r = await handler(
makePortableEvent('DELETE', makeUrl('/api/model/user/deleteMany', { where: { email: 'user1@abc.com' } })),
);
expect(r.status).toBe(200);
expect((await r.json()).data).toMatchObject({ count: 1 });
});

it('rejects invalid json and missing client', async () => {
const client = await createTestClient(schema);
const apiHandler = new RPCApiHandler({ schema: client.schema });

const event = makePortableEvent('POST', '/api/model/user/create');
const badEvent = {
...event,
req: new Request('http://localhost/api/model/user/create', { method: 'POST', body: '{bad' }),
};
const r = await createEventHandler({ getClient: () => client, apiHandler })(
badEvent as unknown as RequestEvent,
);
expect(r.status).toBe(400);

const r2 = await createEventHandler({ getClient: () => undefined as never, apiHandler })(event);
expect(r2.status).toBe(500);
});
});

describe('Nuxt adapter tests - DELETE request body', () => {
const echoApiHandler = {
handleRequest: async ({ requestBody }: { requestBody: unknown }) => ({ status: 200, body: { requestBody } }),
};
const payload = { data: [{ type: 'post', id: '1' }] };

it('passes the body on the portable event path', async () => {
const handler = createEventHandler({ getClient: () => ({}) as never, apiHandler: echoApiHandler as never });
const r = await handler(makePortableEvent('DELETE', '/api/model/user/1/relationships/posts', payload));
expect((await r.json()).requestBody).toEqual(payload);
});

it('passes the body on the h3 event path', async () => {
const handler = createEventHandler({ getClient: () => ({}) as never, apiHandler: echoApiHandler as never });
const router = createRouter().use('/api/model/**', defineEventHandler(handler as never));
const request = supertest(toNodeListener(createApp().use(router)));
const r = await request.delete('/api/model/user/1/relationships/posts').send(payload);
expect(r.body.requestBody).toEqual(payload);
});
});

describe('Nuxt adapter tests - h3 event', () => {
it('properly handles requests', async () => {
const client = await createTestClient(schema);
const handler = createEventHandler({
getClient: () => client,
apiHandler: new RPCApiHandler({ schema: client.schema }),
});

const router = createRouter().use('/api/model/**', defineEventHandler(handler as never));
const request = supertest(toNodeListener(createApp().use(router)));

let r = await request.get(makeUrl('/api/model/post/findMany'));
expect(r.status).toBe(200);
expect(r.body.data).toHaveLength(0);

r = await request.post('/api/model/user/create').send({ data: { id: 'user1', email: 'user1@abc.com' } });
expect(r.status).toBe(201);
expect(r.body.data).toMatchObject({ email: 'user1@abc.com' });

r = await request.get(makeUrl('/api/model/user/findMany', { where: { email: 'user1@abc.com' } }));
expect(r.body.data).toHaveLength(1);

r = await request.delete(makeUrl('/api/model/user/deleteMany', { where: { email: 'user1@abc.com' } }));
expect(r.status).toBe(200);
expect(r.body.data).toMatchObject({ count: 1 });

r = await request.post('/api/model/user/create').set('content-type', 'application/json').send('{bad');
expect(r.status).toBe(400);
});
});
Loading
Loading