Skip to content

Commit fd643e0

Browse files
feat: add elysiajs (#2114)
Co-authored-by: ymc9 <104139426+ymc9@users.noreply.github.com>
1 parent 922e3c4 commit fd643e0

File tree

5 files changed

+414
-5
lines changed

5 files changed

+414
-5
lines changed

packages/server/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"express",
2222
"nextjs",
2323
"sveltekit",
24-
"nuxtjs"
24+
"nuxtjs",
25+
"elysia"
2526
],
2627
"author": "ZenStack Team",
2728
"license": "MIT",
@@ -48,6 +49,7 @@
4849
"@types/supertest": "^2.0.12",
4950
"@zenstackhq/testtools": "workspace:*",
5051
"body-parser": "^1.20.2",
52+
"elysia": "^1.3.1",
5153
"express": "^4.19.2",
5254
"fastify": "^4.14.1",
5355
"fastify-plugin": "^4.5.0",

packages/server/src/elysia/handler.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { DbClientContract } from '@zenstackhq/runtime';
2+
import { Elysia, Context as ElysiaContext } from 'elysia';
3+
import { RPCApiHandler } from '../api';
4+
import { loadAssets } from '../shared';
5+
import { AdapterBaseOptions } from '../types';
6+
7+
/**
8+
* Options for initializing an Elysia middleware.
9+
*/
10+
export interface ElysiaOptions extends AdapterBaseOptions {
11+
/**
12+
* Callback method for getting a Prisma instance for the given request context.
13+
*/
14+
getPrisma: (context: ElysiaContext) => Promise<unknown> | unknown;
15+
/**
16+
* Optional base path to strip from the request path before passing to the API handler.
17+
*/
18+
basePath?: string;
19+
}
20+
21+
/**
22+
* Creates an Elysia middleware handler for ZenStack.
23+
* This handler provides automatic CRUD APIs through Elysia's routing system.
24+
*/
25+
export function createElysiaHandler(options: ElysiaOptions) {
26+
const { modelMeta, zodSchemas } = loadAssets(options);
27+
const requestHandler = options.handler ?? RPCApiHandler();
28+
29+
return async (app: Elysia) => {
30+
app.all('/*', async (ctx: ElysiaContext) => {
31+
const { request, body, set } = ctx;
32+
const prisma = (await options.getPrisma(ctx)) as DbClientContract;
33+
if (!prisma) {
34+
set.status = 500;
35+
return {
36+
message: 'unable to get prisma from request context',
37+
};
38+
}
39+
40+
const url = new URL(request.url);
41+
const query = Object.fromEntries(url.searchParams);
42+
let path = url.pathname;
43+
44+
if (options.basePath && path.startsWith(options.basePath)) {
45+
path = path.slice(options.basePath.length);
46+
if (!path.startsWith('/')) {
47+
path = '/' + path;
48+
}
49+
}
50+
51+
if (!path || path === '/') {
52+
set.status = 400;
53+
return {
54+
message: 'missing path parameter',
55+
};
56+
}
57+
58+
try {
59+
const r = await requestHandler({
60+
method: request.method,
61+
path,
62+
query,
63+
requestBody: body,
64+
prisma,
65+
modelMeta,
66+
zodSchemas,
67+
logger: options.logger,
68+
});
69+
70+
set.status = r.status;
71+
return r.body;
72+
} catch (err) {
73+
set.status = 500;
74+
return {
75+
message: 'An internal server error occurred',
76+
};
77+
}
78+
});
79+
80+
return app;
81+
};
82+
}

packages/server/src/elysia/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './handler';
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/* eslint-disable @typescript-eslint/no-var-requires */
2+
/* eslint-disable @typescript-eslint/no-explicit-any */
3+
/// <reference types="@types/jest" />
4+
import { loadSchema } from '@zenstackhq/testtools';
5+
import 'isomorphic-fetch';
6+
import path from 'path';
7+
import superjson from 'superjson';
8+
import Rest from '../../src/api/rest';
9+
import { createElysiaHandler } from '../../src/elysia';
10+
import { makeUrl, schema } from '../utils';
11+
import { Elysia } from 'elysia';
12+
13+
describe('Elysia adapter tests - rpc handler', () => {
14+
it('run hooks regular json', async () => {
15+
const { prisma, zodSchemas } = await loadSchema(schema);
16+
17+
const handler = await createElysiaApp(
18+
createElysiaHandler({ getPrisma: () => prisma, zodSchemas, basePath: '/api' })
19+
);
20+
21+
let r = await handler(makeRequest('GET', makeUrl('/api/post/findMany', { where: { id: { equals: '1' } } })));
22+
expect(r.status).toBe(200);
23+
expect((await unmarshal(r)).data).toHaveLength(0);
24+
25+
r = await handler(
26+
makeRequest('POST', '/api/user/create', {
27+
include: { posts: true },
28+
data: {
29+
id: 'user1',
30+
email: 'user1@abc.com',
31+
posts: {
32+
create: [
33+
{ title: 'post1', published: true, viewCount: 1 },
34+
{ title: 'post2', published: false, viewCount: 2 },
35+
],
36+
},
37+
},
38+
})
39+
);
40+
expect(r.status).toBe(201);
41+
expect((await unmarshal(r)).data).toMatchObject({
42+
email: 'user1@abc.com',
43+
posts: expect.arrayContaining([
44+
expect.objectContaining({ title: 'post1' }),
45+
expect.objectContaining({ title: 'post2' }),
46+
]),
47+
});
48+
49+
r = await handler(makeRequest('GET', makeUrl('/api/post/findMany')));
50+
expect(r.status).toBe(200);
51+
expect((await unmarshal(r)).data).toHaveLength(2);
52+
53+
r = await handler(makeRequest('GET', makeUrl('/api/post/findMany', { where: { viewCount: { gt: 1 } } })));
54+
expect(r.status).toBe(200);
55+
expect((await unmarshal(r)).data).toHaveLength(1);
56+
57+
r = await handler(
58+
makeRequest('PUT', '/api/user/update', { where: { id: 'user1' }, data: { email: 'user1@def.com' } })
59+
);
60+
expect(r.status).toBe(200);
61+
expect((await unmarshal(r)).data.email).toBe('user1@def.com');
62+
63+
r = await handler(makeRequest('GET', makeUrl('/api/post/count', { where: { viewCount: { gt: 1 } } })));
64+
expect(r.status).toBe(200);
65+
expect((await unmarshal(r)).data).toBe(1);
66+
67+
r = await handler(makeRequest('GET', makeUrl('/api/post/aggregate', { _sum: { viewCount: true } })));
68+
expect(r.status).toBe(200);
69+
expect((await unmarshal(r)).data._sum.viewCount).toBe(3);
70+
71+
r = await handler(
72+
makeRequest('GET', makeUrl('/api/post/groupBy', { by: ['published'], _sum: { viewCount: true } }))
73+
);
74+
expect(r.status).toBe(200);
75+
expect((await unmarshal(r)).data).toEqual(
76+
expect.arrayContaining([
77+
expect.objectContaining({ published: true, _sum: { viewCount: 1 } }),
78+
expect.objectContaining({ published: false, _sum: { viewCount: 2 } }),
79+
])
80+
);
81+
82+
r = await handler(makeRequest('DELETE', makeUrl('/api/user/deleteMany', { where: { id: 'user1' } })));
83+
expect(r.status).toBe(200);
84+
expect((await unmarshal(r)).data.count).toBe(1);
85+
});
86+
87+
it('custom load path', async () => {
88+
const { prisma, projectDir } = await loadSchema(schema, { output: './zen' });
89+
90+
const handler = await createElysiaApp(
91+
createElysiaHandler({
92+
getPrisma: () => prisma,
93+
basePath: '/api',
94+
modelMeta: require(path.join(projectDir, './zen/model-meta')).default,
95+
zodSchemas: require(path.join(projectDir, './zen/zod')),
96+
})
97+
);
98+
99+
const r = await handler(
100+
makeRequest('POST', '/api/user/create', {
101+
include: { posts: true },
102+
data: {
103+
id: 'user1',
104+
email: 'user1@abc.com',
105+
posts: {
106+
create: [
107+
{ title: 'post1', published: true, viewCount: 1 },
108+
{ title: 'post2', published: false, viewCount: 2 },
109+
],
110+
},
111+
},
112+
})
113+
);
114+
expect(r.status).toBe(201);
115+
});
116+
});
117+
118+
describe('Elysia adapter tests - rest handler', () => {
119+
it('run hooks', async () => {
120+
const { prisma, modelMeta, zodSchemas } = await loadSchema(schema);
121+
122+
const handler = await createElysiaApp(
123+
createElysiaHandler({
124+
getPrisma: () => prisma,
125+
basePath: '/api',
126+
handler: Rest({ endpoint: 'http://localhost/api' }),
127+
modelMeta,
128+
zodSchemas,
129+
})
130+
);
131+
132+
let r = await handler(makeRequest('GET', makeUrl('/api/post/1')));
133+
expect(r.status).toBe(404);
134+
135+
r = await handler(
136+
makeRequest('POST', '/api/user', {
137+
data: {
138+
type: 'user',
139+
attributes: { id: 'user1', email: 'user1@abc.com' },
140+
},
141+
})
142+
);
143+
expect(r.status).toBe(201);
144+
expect(await unmarshal(r)).toMatchObject({
145+
data: {
146+
id: 'user1',
147+
attributes: {
148+
email: 'user1@abc.com',
149+
},
150+
},
151+
});
152+
153+
r = await handler(makeRequest('GET', makeUrl('/api/user?filter[id]=user1')));
154+
expect(r.status).toBe(200);
155+
expect((await unmarshal(r)).data).toHaveLength(1);
156+
157+
r = await handler(makeRequest('GET', makeUrl('/api/user?filter[id]=user2')));
158+
expect(r.status).toBe(200);
159+
expect((await unmarshal(r)).data).toHaveLength(0);
160+
161+
r = await handler(makeRequest('GET', makeUrl('/api/user?filter[id]=user1&filter[email]=xyz')));
162+
expect(r.status).toBe(200);
163+
expect((await unmarshal(r)).data).toHaveLength(0);
164+
165+
r = await handler(
166+
makeRequest('PUT', makeUrl('/api/user/user1'), {
167+
data: { type: 'user', attributes: { email: 'user1@def.com' } },
168+
})
169+
);
170+
expect(r.status).toBe(200);
171+
expect((await unmarshal(r)).data.attributes.email).toBe('user1@def.com');
172+
173+
r = await handler(makeRequest('DELETE', makeUrl(makeUrl('/api/user/user1'))));
174+
expect(r.status).toBe(200);
175+
expect(await prisma.user.findMany()).toHaveLength(0);
176+
});
177+
});
178+
179+
function makeRequest(method: string, path: string, body?: any) {
180+
if (body) {
181+
return new Request(`http://localhost${path}`, {
182+
method,
183+
body: JSON.stringify(body),
184+
headers: { 'Content-Type': 'application/json' },
185+
});
186+
} else {
187+
return new Request(`http://localhost${path}`, { method });
188+
}
189+
}
190+
191+
async function unmarshal(r: Response, useSuperJson = false) {
192+
const text = await r.text();
193+
return (useSuperJson ? superjson.parse(text) : JSON.parse(text)) as any;
194+
}
195+
196+
async function createElysiaApp(middleware: (app: Elysia) => Promise<Elysia>) {
197+
const app = new Elysia();
198+
await middleware(app);
199+
return app.handle;
200+
}

0 commit comments

Comments
 (0)