@kaito-http/core 3.0.0-beta.16 → 3.0.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,269 @@
1
+ import assert from 'node:assert';
2
+ import {describe, it} from 'node:test';
3
+ import {z} from 'zod';
4
+ import {KaitoError} from '../error.ts';
5
+ import {Router} from './router.ts';
6
+
7
+ type Context = {
8
+ userId: string;
9
+ };
10
+
11
+ type AuthContext = Context & {
12
+ isAdmin: boolean;
13
+ };
14
+
15
+ describe('Router', () => {
16
+ describe('create', () => {
17
+ it('should create an empty router', () => {
18
+ const router = Router.create<Context>();
19
+ assert.strictEqual(router.routes.size, 0);
20
+ });
21
+ });
22
+
23
+ describe('route handling', () => {
24
+ it('should handle GET requests', async () => {
25
+ const router = Router.create<Context>().get('/users', {
26
+ run: async () => ({users: []}),
27
+ });
28
+
29
+ const handler = router.freeze({
30
+ getContext: async () => ({userId: '123'}),
31
+ onError: async () => ({status: 500, message: 'Internal Server Error'}),
32
+ });
33
+
34
+ const response = await handler(new Request('http://localhost/users', {method: 'GET'}));
35
+ const data = await response.json();
36
+
37
+ assert.strictEqual(response.status, 200);
38
+ assert.deepStrictEqual(data, {
39
+ success: true,
40
+ data: {users: []},
41
+ message: 'OK',
42
+ });
43
+ });
44
+
45
+ it('should handle POST requests with body parsing', async () => {
46
+ const router = Router.create<Context>().post('/users', {
47
+ body: z.object({name: z.string()}),
48
+ run: async ({body}) => ({id: '1', name: body.name}),
49
+ });
50
+
51
+ const handler = router.freeze({
52
+ getContext: async () => ({userId: '123'}),
53
+ onError: async () => ({status: 500, message: 'Internal Server Error'}),
54
+ });
55
+
56
+ const response = await handler(
57
+ new Request('http://localhost/users', {
58
+ method: 'POST',
59
+ headers: {'Content-Type': 'application/json'},
60
+ body: JSON.stringify({name: 'John'}),
61
+ }),
62
+ );
63
+ const data = await response.json();
64
+
65
+ assert.strictEqual(response.status, 200);
66
+ assert.deepStrictEqual(data, {
67
+ success: true,
68
+ data: {id: '1', name: 'John'},
69
+ message: 'OK',
70
+ });
71
+ });
72
+
73
+ it('should handle URL parameters', async () => {
74
+ const router = Router.create<Context>().get('/users/:id', {
75
+ run: async ({params}) => ({id: params.id}),
76
+ });
77
+
78
+ const handler = router.freeze({
79
+ getContext: async () => ({userId: '123'}),
80
+ onError: async () => ({status: 500, message: 'Internal Server Error'}),
81
+ });
82
+
83
+ const response = await handler(new Request('http://localhost/users/456', {method: 'GET'}));
84
+ const data = await response.json();
85
+
86
+ assert.strictEqual(response.status, 200);
87
+ assert.deepStrictEqual(data, {
88
+ success: true,
89
+ data: {id: '456'},
90
+ message: 'OK',
91
+ });
92
+ });
93
+
94
+ it('should handle query parameters', async () => {
95
+ const router = Router.create<Context>().get('/search', {
96
+ query: {
97
+ q: z.string(),
98
+ limit: z
99
+ .string()
100
+ .transform(value => Number(value))
101
+ .pipe(z.number()),
102
+ },
103
+ run: async ({query}) => ({
104
+ query: query.q,
105
+ limit: query.limit,
106
+ }),
107
+ });
108
+
109
+ const handler = router.freeze({
110
+ getContext: async () => ({userId: '123'}),
111
+ onError: async () => ({status: 500, message: 'Internal Server Error'}),
112
+ });
113
+
114
+ const response = await handler(new Request('http://localhost/search?q=test&limit=10', {method: 'GET'}));
115
+ const data = await response.json();
116
+
117
+ assert.strictEqual(response.status, 200);
118
+ assert.deepStrictEqual(data, {
119
+ success: true,
120
+ data: {query: 'test', limit: 10},
121
+ message: 'OK',
122
+ });
123
+ });
124
+ });
125
+
126
+ describe('middleware and context', () => {
127
+ it('should transform context through middleware', async () => {
128
+ const router = Router.create<Context>()
129
+ .through(async ctx => ({
130
+ ...ctx,
131
+ isAdmin: ctx.userId === 'admin',
132
+ }))
133
+ .get('/admin', {
134
+ run: async ({ctx}) => ({
135
+ isAdmin: (ctx as AuthContext).isAdmin,
136
+ }),
137
+ });
138
+
139
+ const handler = router.freeze({
140
+ getContext: async () => ({userId: 'admin'}),
141
+ onError: async () => ({status: 500, message: 'Internal Server Error'}),
142
+ });
143
+
144
+ const response = await handler(new Request('http://localhost/admin', {method: 'GET'}));
145
+ const data = await response.json();
146
+
147
+ assert.strictEqual(response.status, 200);
148
+ assert.deepStrictEqual(data, {
149
+ success: true,
150
+ data: {isAdmin: true},
151
+ message: 'OK',
152
+ });
153
+ });
154
+ });
155
+
156
+ describe('error handling', () => {
157
+ it('should handle KaitoError with custom status', async () => {
158
+ const router = Router.create<Context>().get('/error', {
159
+ run: async () => {
160
+ throw new KaitoError(403, 'Forbidden');
161
+ },
162
+ });
163
+
164
+ const handler = router.freeze({
165
+ getContext: async () => ({userId: '123'}),
166
+ onError: async () => ({status: 500, message: 'Internal Server Error'}),
167
+ });
168
+
169
+ const response = await handler(new Request('http://localhost/error', {method: 'GET'}));
170
+ const data = await response.json();
171
+
172
+ assert.strictEqual(response.status, 403);
173
+ assert.deepStrictEqual(data, {
174
+ success: false,
175
+ data: null,
176
+ message: 'Forbidden',
177
+ });
178
+ });
179
+
180
+ it('should handle generic errors with server error handler', async () => {
181
+ const router = Router.create<Context>().get('/error', {
182
+ run: async () => {
183
+ throw new Error('Something went wrong');
184
+ },
185
+ });
186
+
187
+ const handler = router.freeze({
188
+ getContext: async () => ({userId: '123'}),
189
+ onError: async () => ({status: 500, message: 'Custom Error Message'}),
190
+ });
191
+
192
+ const response = await handler(new Request('http://localhost/error', {method: 'GET'}));
193
+ const data = await response.json();
194
+
195
+ assert.strictEqual(response.status, 500);
196
+ assert.deepStrictEqual(data, {
197
+ success: false,
198
+ data: null,
199
+ message: 'Custom Error Message',
200
+ });
201
+ });
202
+ });
203
+
204
+ describe('router merging', () => {
205
+ it('should merge routers with prefix', async () => {
206
+ const userRouter = Router.create<Context>().get('/me', {
207
+ run: async ({ctx}) => ({id: ctx.userId}),
208
+ });
209
+
210
+ const mainRouter = Router.create<Context>().merge('/api', userRouter);
211
+
212
+ const handler = mainRouter.freeze({
213
+ getContext: async () => ({userId: '123'}),
214
+ onError: async () => ({status: 500, message: 'Internal Server Error'}),
215
+ });
216
+
217
+ const response = await handler(new Request('http://localhost/api/me', {method: 'GET'}));
218
+ const data = await response.json();
219
+
220
+ assert.strictEqual(response.status, 200);
221
+ assert.deepStrictEqual(data, {
222
+ success: true,
223
+ data: {id: '123'},
224
+ message: 'OK',
225
+ });
226
+ });
227
+ });
228
+
229
+ describe('404 handling', () => {
230
+ it('should return 404 for non-existent routes', async () => {
231
+ const router = Router.create<Context>();
232
+ const handler = router.freeze({
233
+ getContext: async () => ({userId: '123'}),
234
+ onError: async () => ({status: 500, message: 'Internal Server Error'}),
235
+ });
236
+
237
+ const response = await handler(new Request('http://localhost/not-found', {method: 'GET'}));
238
+ const data = await response.json();
239
+
240
+ assert.strictEqual(response.status, 404);
241
+ assert.deepStrictEqual(data, {
242
+ success: false,
243
+ data: null,
244
+ message: 'Cannot GET /not-found',
245
+ });
246
+ });
247
+
248
+ it('should return 404 for wrong method on existing path', async () => {
249
+ const router = Router.create<Context>().get('/users', {
250
+ run: async () => ({users: []}),
251
+ });
252
+
253
+ const handler = router.freeze({
254
+ getContext: async () => ({userId: '123'}),
255
+ onError: async () => ({status: 500, message: 'Internal Server Error'}),
256
+ });
257
+
258
+ const response = await handler(new Request('http://localhost/users', {method: 'POST'}));
259
+ const data = await response.json();
260
+
261
+ assert.strictEqual(response.status, 404);
262
+ assert.deepStrictEqual(data, {
263
+ success: false,
264
+ data: null,
265
+ message: 'Cannot POST /users',
266
+ });
267
+ });
268
+ });
269
+ });
@@ -0,0 +1,254 @@
1
+ /* eslint-disable @typescript-eslint/member-ordering */
2
+ import {KaitoError, WrappedError} from '../error.ts';
3
+ import {KaitoRequest} from '../request.ts';
4
+ import {KaitoResponse} from '../response.ts';
5
+ import type {AnyQueryDefinition, AnyRoute, Route} from '../route.ts';
6
+ import type {ServerConfig} from '../server.ts';
7
+ import type {ErroredAPIResponse, Parsable} from '../util.ts';
8
+ import type {KaitoMethod} from './types.ts';
9
+
10
+ type PrefixRoutesPathInner<R extends AnyRoute, Prefix extends `/${string}`> =
11
+ R extends Route<
12
+ infer ContextFrom,
13
+ infer ContextTo,
14
+ infer Result,
15
+ infer Path,
16
+ infer Method,
17
+ infer Query,
18
+ infer BodyOutput
19
+ >
20
+ ? Route<ContextFrom, ContextTo, Result, `${Prefix}${Path}`, Method, Query, BodyOutput>
21
+ : never;
22
+
23
+ type PrefixRoutesPath<Prefix extends `/${string}`, R extends AnyRoute> = R extends R
24
+ ? PrefixRoutesPathInner<R, Prefix>
25
+ : never;
26
+
27
+ export type RouterState<Routes extends AnyRoute, ContextFrom, ContextTo> = {
28
+ routes: Set<Routes>;
29
+ through: (context: ContextFrom) => Promise<ContextTo>;
30
+ };
31
+
32
+ export type InferRoutes<R extends Router<any, any, any>> = R extends Router<any, any, infer R> ? R : never;
33
+
34
+ export class Router<ContextFrom, ContextTo, R extends AnyRoute> {
35
+ private readonly state: RouterState<R, ContextFrom, ContextTo>;
36
+
37
+ public static create = <Context>(): Router<Context, Context, never> =>
38
+ new Router<Context, Context, never>({
39
+ through: async context => context,
40
+ routes: new Set(),
41
+ });
42
+
43
+ private static parseQuery<T extends AnyQueryDefinition>(schema: T | undefined, url: URL) {
44
+ if (!schema) {
45
+ return {};
46
+ }
47
+
48
+ const result: Record<PropertyKey, unknown> = {};
49
+ for (const key in schema) {
50
+ if (!schema.hasOwnProperty(key)) continue;
51
+ const value = url.searchParams.get(key);
52
+ result[key] = (schema[key] as Parsable).parse(value);
53
+ }
54
+
55
+ return result as {
56
+ [Key in keyof T]: ReturnType<T[Key]['parse']>;
57
+ };
58
+ }
59
+
60
+ public constructor(options: RouterState<R, ContextFrom, ContextTo>) {
61
+ this.state = options;
62
+ }
63
+
64
+ public get routes() {
65
+ return this.state.routes;
66
+ }
67
+
68
+ public add = <
69
+ Result,
70
+ Path extends string,
71
+ Method extends KaitoMethod,
72
+ Query extends AnyQueryDefinition = {},
73
+ Body extends Parsable = never,
74
+ >(
75
+ method: Method,
76
+ path: Path,
77
+ route:
78
+ | (Method extends 'GET'
79
+ ? Omit<
80
+ Route<ContextFrom, ContextTo, Result, Path, Method, Query, Body>,
81
+ 'body' | 'path' | 'method' | 'through'
82
+ >
83
+ : Omit<Route<ContextFrom, ContextTo, Result, Path, Method, Query, Body>, 'path' | 'method' | 'through'>)
84
+ | Route<ContextFrom, ContextTo, Result, Path, Method, Query, Body>['run'],
85
+ ): Router<ContextFrom, ContextTo, R | Route<ContextFrom, ContextTo, Result, Path, Method, Query, Body>> => {
86
+ const merged: Route<ContextFrom, ContextTo, Result, Path, Method, Query, Body> = {
87
+ // TODO: Ideally fix the typing here, but this will be replaced in Kaito v4 where all routes must return a Response (which we can type)
88
+ ...((typeof route === 'object' ? route : {run: route}) as {run: never}),
89
+ method,
90
+ path,
91
+ through: this.state.through,
92
+ };
93
+
94
+ return new Router({
95
+ ...this.state,
96
+ routes: new Set([...this.state.routes, merged]),
97
+ });
98
+ };
99
+
100
+ public readonly merge = <PathPrefix extends `/${string}`, OtherRoutes extends AnyRoute>(
101
+ pathPrefix: PathPrefix,
102
+ other: Router<ContextFrom, unknown, OtherRoutes>,
103
+ ): Router<ContextFrom, ContextTo, Extract<R | PrefixRoutesPath<PathPrefix, OtherRoutes>, AnyRoute>> => {
104
+ const newRoutes = [...other.state.routes].map(route => ({
105
+ ...route,
106
+ path: `${pathPrefix}${route.path as string}`,
107
+ }));
108
+
109
+ return new Router<ContextFrom, ContextTo, Extract<R | PrefixRoutesPath<PathPrefix, OtherRoutes>, AnyRoute>>({
110
+ ...this.state,
111
+ routes: new Set([...this.state.routes, ...newRoutes] as never),
112
+ });
113
+ };
114
+
115
+ public freeze = (server: Omit<ServerConfig<ContextFrom>, 'router'>) => {
116
+ const routes = new Map<string, Map<KaitoMethod, AnyRoute>>();
117
+
118
+ for (const route of this.state.routes) {
119
+ if (!routes.has(route.path)) {
120
+ routes.set(route.path, new Map());
121
+ }
122
+
123
+ routes.get(route.path)!.set(route.method, route);
124
+ }
125
+
126
+ const findRoute = (method: KaitoMethod, path: string): {route?: AnyRoute; params: Record<string, string>} => {
127
+ const params: Record<string, string> = {};
128
+ const pathParts = path.split('/').filter(Boolean);
129
+
130
+ for (const [routePath, methodHandlers] of routes) {
131
+ const routeParts = routePath.split('/').filter(Boolean);
132
+
133
+ if (routeParts.length !== pathParts.length) continue;
134
+
135
+ let matches = true;
136
+ for (let i = 0; i < routeParts.length; i++) {
137
+ const routePart = routeParts[i];
138
+ const pathPart = pathParts[i];
139
+
140
+ if (routePart && pathPart && routePart.startsWith(':')) {
141
+ params[routePart.slice(1)] = pathPart;
142
+ } else if (routePart !== pathPart) {
143
+ matches = false;
144
+ break;
145
+ }
146
+ }
147
+
148
+ if (matches) {
149
+ const route = methodHandlers.get(method);
150
+ if (route) return {route, params};
151
+ }
152
+ }
153
+
154
+ return {params};
155
+ };
156
+
157
+ return async (req: Request): Promise<Response> => {
158
+ const url = new URL(req.url);
159
+ const method = req.method as KaitoMethod;
160
+
161
+ const {route, params} = findRoute(method, url.pathname);
162
+
163
+ if (!route) {
164
+ const body: ErroredAPIResponse = {
165
+ success: false,
166
+ data: null,
167
+ message: `Cannot ${method} ${url.pathname}`,
168
+ };
169
+
170
+ return Response.json(body, {status: 404});
171
+ }
172
+
173
+ const request = new KaitoRequest(url, req);
174
+ const response = new KaitoResponse();
175
+
176
+ try {
177
+ const body = route.body ? await route.body.parse(await req.json()) : undefined;
178
+ const query = Router.parseQuery(route.query, url);
179
+
180
+ const rootCtx = await server.getContext(request, response);
181
+ const ctx = await route.through(rootCtx);
182
+
183
+ const result = await route.run({
184
+ ctx,
185
+ body,
186
+ query,
187
+ params,
188
+ });
189
+
190
+ if (result instanceof Response) {
191
+ return result;
192
+ }
193
+
194
+ return response.toResponse({
195
+ success: true,
196
+ data: result,
197
+ message: 'OK',
198
+ });
199
+ } catch (e) {
200
+ const error = WrappedError.maybe(e);
201
+
202
+ if (error instanceof KaitoError) {
203
+ return response.status(error.status).toResponse({
204
+ success: false,
205
+ data: null,
206
+ message: error.message,
207
+ });
208
+ }
209
+
210
+ const {status, message} = await server
211
+ .onError({error, req: request})
212
+ .catch(() => ({status: 500, message: 'Internal Server Error'}));
213
+
214
+ return response.status(status).toResponse({
215
+ success: false,
216
+ data: null,
217
+ message,
218
+ });
219
+ }
220
+ };
221
+ };
222
+
223
+ private readonly method =
224
+ <M extends KaitoMethod>(method: M) =>
225
+ <Result, Path extends string, Query extends AnyQueryDefinition = {}, Body extends Parsable = never>(
226
+ path: Path,
227
+ route:
228
+ | (M extends 'GET'
229
+ ? Omit<Route<ContextFrom, ContextTo, Result, Path, M, Query, Body>, 'body' | 'path' | 'method' | 'through'>
230
+ : Omit<Route<ContextFrom, ContextTo, Result, Path, M, Query, Body>, 'path' | 'method' | 'through'>)
231
+ | Route<ContextFrom, ContextTo, Result, Path, M, Query, Body>['run'],
232
+ ) => {
233
+ return this.add<Result, Path, M, Query, Body>(method, path, route);
234
+ };
235
+
236
+ public get = this.method('GET');
237
+ public post = this.method('POST');
238
+ public put = this.method('PUT');
239
+ public patch = this.method('PATCH');
240
+ public delete = this.method('DELETE');
241
+ public head = this.method('HEAD');
242
+ public options = this.method('OPTIONS');
243
+
244
+ public through = <NextContext>(
245
+ transform: (context: ContextTo) => Promise<NextContext>,
246
+ ): Router<ContextFrom, NextContext, R> =>
247
+ new Router<ContextFrom, NextContext, R>({
248
+ routes: this.state.routes,
249
+ through: async context => {
250
+ const fromCurrentRouter = await this.state.through(context);
251
+ return transform(fromCurrentRouter);
252
+ },
253
+ });
254
+ }
@@ -0,0 +1 @@
1
+ export type KaitoMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'CONNECT' | 'TRACE';
package/src/server.ts ADDED
@@ -0,0 +1,87 @@
1
+ import type {KaitoError} from './error.ts';
2
+ import type {KaitoRequest} from './request.ts';
3
+ import type {Router} from './router/router.ts';
4
+ import type {GetContext} from './util.ts';
5
+
6
+ export type Before = (req: Request) => Promise<Response | void | undefined>;
7
+
8
+ export type ServerConfig<ContextFrom> = {
9
+ /**
10
+ * The root router to mount on this server.
11
+ */
12
+ router: Router<ContextFrom, unknown, any>;
13
+
14
+ /**
15
+ * A function that is called to get the context for a request.
16
+ *
17
+ * This is useful for things like authentication, to pass in a database connection, etc.
18
+ *
19
+ * It's fine for this function to throw; if it does, the error is passed to the `onError` function.
20
+ */
21
+ getContext: GetContext<ContextFrom>;
22
+
23
+ /**
24
+ * A function that is called when an error occurs inside a route handler.
25
+ *
26
+ * The result of this function is used to determine the response status and message, and is
27
+ * always sent to the client. You could include logic to check for production vs development
28
+ * environments here, and this would also be a good place to include error tracking
29
+ * like Sentry or Rollbar.
30
+ *
31
+ * @param arg - The error and the request
32
+ * @returns A KaitoError or an object with a status and message
33
+ */
34
+ onError: (arg: {error: Error; req: KaitoRequest}) => Promise<KaitoError | {status: number; message: string}>;
35
+
36
+ /**
37
+ * A function that is called before every request. Most useful for bailing out early in the case of an OPTIONS request.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * before: async req => {
42
+ * if (req.method === 'OPTIONS') {
43
+ * return new Response(null, {status: 204});
44
+ * }
45
+ * }
46
+ * ```
47
+ */
48
+ before?: Before;
49
+
50
+ /**
51
+ * Transforms the response before it is sent to the client. Very useful for settings headers like CORS.
52
+ *
53
+ * You can also return a new response in this function, or just mutate the current one.
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * transform: async (req, res) => {
58
+ * res.headers.set('Access-Control-Allow-Origin', 'http://localhost:3000');
59
+ * res.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
60
+ * res.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
61
+ * res.headers.set('Access-Control-Max-Age', '86400');
62
+ * res.headers.set('Access-Control-Allow-Credentials', 'true');
63
+ * }
64
+ * ```
65
+ */
66
+ transform?: (req: Request, res: Response) => Promise<Response | void | undefined>;
67
+ };
68
+
69
+ export function createKaitoHandler<Context>(config: ServerConfig<Context>) {
70
+ const handle = config.router.freeze(config);
71
+
72
+ return async (request: Request): Promise<Response> => {
73
+ if (config.before) {
74
+ const result = await config.before(request);
75
+ if (result instanceof Response) return result;
76
+ }
77
+
78
+ const response = await handle(request);
79
+
80
+ if (config.transform) {
81
+ const result = await config.transform(request, response);
82
+ if (result instanceof Response) return result;
83
+ }
84
+
85
+ return response;
86
+ };
87
+ }