@onebun/core 0.2.7 → 0.2.8

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,172 @@
1
+ import {
2
+ describe,
3
+ expect,
4
+ it,
5
+ } from 'bun:test';
6
+
7
+ import type { OneBunRequest } from '../types';
8
+
9
+ import { NotFoundError, HttpStatusCode } from '@onebun/requests';
10
+
11
+ import { HttpExecutionContextImpl } from '../http-guards/http-guards';
12
+
13
+ import { createExceptionFilter, defaultExceptionFilter } from './exception-filters';
14
+ import { HttpException } from './http-exception';
15
+
16
+ // ============================================================================
17
+ // Helpers
18
+ // ============================================================================
19
+
20
+ function makeContext(): HttpExecutionContextImpl {
21
+ const req = new Request('http://localhost/test') as unknown as OneBunRequest;
22
+
23
+ return new HttpExecutionContextImpl(req, 'testHandler', 'TestController');
24
+ }
25
+
26
+ // ============================================================================
27
+ // HttpException
28
+ // ============================================================================
29
+
30
+ describe('HttpException', () => {
31
+ it('stores statusCode and message', () => {
32
+ const ex = new HttpException(400, 'Bad request');
33
+ expect(ex).toBeInstanceOf(Error);
34
+ expect(ex.statusCode).toBe(400);
35
+ expect(ex.message).toBe('Bad request');
36
+ expect(ex.name).toBe('HttpException');
37
+ });
38
+
39
+ it('works with instanceof check', () => {
40
+ const ex = new HttpException(404, 'Not found');
41
+ expect(ex instanceof HttpException).toBe(true);
42
+ expect(ex instanceof Error).toBe(true);
43
+ });
44
+ });
45
+
46
+ // ============================================================================
47
+ // createExceptionFilter
48
+ // ============================================================================
49
+
50
+ describe('createExceptionFilter', () => {
51
+ it('creates a filter that calls the provided function', async () => {
52
+ let caught: unknown;
53
+ const filter = createExceptionFilter((error, _ctx) => {
54
+ caught = error;
55
+
56
+ return new Response('handled', { status: 200 });
57
+ });
58
+
59
+ const err = new Error('boom');
60
+ const ctx = makeContext();
61
+ const response = await filter.catch(err, ctx);
62
+
63
+ expect(caught).toBe(err);
64
+ expect(response.status).toBe(200);
65
+ expect(await response.text()).toBe('handled');
66
+ });
67
+
68
+ it('receives the execution context', async () => {
69
+ let capturedHandler = '';
70
+ let capturedController = '';
71
+
72
+ const filter = createExceptionFilter((_error, ctx) => {
73
+ capturedHandler = ctx.getHandler();
74
+ capturedController = ctx.getController();
75
+
76
+ return new Response('ok');
77
+ });
78
+
79
+ const ctx = makeContext();
80
+ await filter.catch(new Error('test'), ctx);
81
+
82
+ expect(capturedHandler).toBe('testHandler');
83
+ expect(capturedController).toBe('TestController');
84
+ });
85
+
86
+ it('supports async filter functions', async () => {
87
+ const filter = createExceptionFilter(async () => {
88
+ await Promise.resolve();
89
+
90
+ return new Response('async', { status: 418 });
91
+ });
92
+
93
+ const response = await filter.catch(new Error('test'), makeContext());
94
+
95
+ expect(response.status).toBe(418);
96
+ });
97
+ });
98
+
99
+ // ============================================================================
100
+ // defaultExceptionFilter
101
+ // ============================================================================
102
+
103
+ describe('defaultExceptionFilter', () => {
104
+ it('returns HTTP 200 with serialised OneBunBaseError', async () => {
105
+ const error = new NotFoundError('Not found');
106
+ const response = await defaultExceptionFilter.catch(error, makeContext());
107
+
108
+ expect(response.status).toBe(HttpStatusCode.OK);
109
+ const body = await response.json() as { success: boolean };
110
+ expect(body.success).toBe(false);
111
+ });
112
+
113
+ it('returns HTTP 200 with generic error details for plain Error', async () => {
114
+ const error = new Error('Something went wrong');
115
+ const response = await defaultExceptionFilter.catch(error, makeContext());
116
+
117
+ expect(response.status).toBe(HttpStatusCode.OK);
118
+ const body = await response.json() as { success: boolean; error: string };
119
+ expect(body.success).toBe(false);
120
+ expect(body.error).toBe('Something went wrong');
121
+ });
122
+
123
+ it('returns HTTP 200 for non-Error values', async () => {
124
+ const response = await defaultExceptionFilter.catch('string error', makeContext());
125
+
126
+ expect(response.status).toBe(HttpStatusCode.OK);
127
+ const body = await response.json() as { success: boolean; error: string };
128
+ expect(body.success).toBe(false);
129
+ expect(body.error).toBe('string error');
130
+ });
131
+
132
+ it('sets Content-Type to application/json', async () => {
133
+ const response = await defaultExceptionFilter.catch(new Error('test'), makeContext());
134
+
135
+ expect(response.headers.get('content-type')).toContain('application/json');
136
+ });
137
+
138
+ it('returns actual HTTP status for HttpException', async () => {
139
+ const error = new HttpException(400, 'Validation failed');
140
+ const response = await defaultExceptionFilter.catch(error, makeContext());
141
+ expect(response.status).toBe(400);
142
+ const body = await response.json() as { success: boolean; error: string };
143
+ expect(body.success).toBe(false);
144
+ expect(body.error).toBe('Validation failed');
145
+ });
146
+
147
+ it('returns 404 for HttpException with 404 status', async () => {
148
+ const error = new HttpException(404, 'Not found');
149
+ const response = await defaultExceptionFilter.catch(error, makeContext());
150
+ expect(response.status).toBe(404);
151
+ const body = await response.json() as { success: boolean; error: string };
152
+ expect(body.success).toBe(false);
153
+ expect(body.error).toBe('Not found');
154
+ });
155
+ });
156
+
157
+ // ============================================================================
158
+ // Validation error via HttpException (bug reproduction)
159
+ // ============================================================================
160
+
161
+ describe('Validation error via HttpException (bug reproduction)', () => {
162
+ it('returns 400 with JSON body for validation HttpException', async () => {
163
+ const error = new HttpException(400, 'Parameter body validation failed: name must be a string (was missing)');
164
+ const response = await defaultExceptionFilter.catch(error, makeContext());
165
+
166
+ expect(response.status).toBe(400);
167
+ const body = await response.json() as { success: boolean; error: string; statusCode: number };
168
+ expect(body.success).toBe(false);
169
+ expect(body.error).toContain('validation failed');
170
+ expect(response.headers.get('content-type')).toContain('application/json');
171
+ });
172
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Exception Filters
3
+ *
4
+ * Intercept and transform errors thrown by route handlers.
5
+ * Apply with `@UseFilters()` on controllers or individual routes,
6
+ * or globally via `ApplicationOptions.filters`.
7
+ */
8
+
9
+ import type { HttpExecutionContext, OneBunResponse } from '../types';
10
+
11
+ import {
12
+ createErrorResponse,
13
+ HttpStatusCode,
14
+ OneBunBaseError,
15
+ } from '@onebun/requests';
16
+
17
+ import { HttpException } from './http-exception';
18
+
19
+ // ============================================================================
20
+ // Interfaces
21
+ // ============================================================================
22
+
23
+ /**
24
+ * Exception Filter interface — implement to handle errors thrown by route handlers.
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * class HttpExceptionFilter implements ExceptionFilter {
29
+ * catch(error: unknown, ctx: HttpExecutionContext): Response {
30
+ * const status = error instanceof OneBunBaseError ? error.code : 500;
31
+ * return new Response(JSON.stringify({ message: String(error) }), {
32
+ * status,
33
+ * headers: { 'Content-Type': 'application/json' },
34
+ * });
35
+ * }
36
+ * }
37
+ * ```
38
+ */
39
+ export interface ExceptionFilter {
40
+ catch(error: unknown, context: HttpExecutionContext): OneBunResponse | Promise<OneBunResponse>;
41
+ }
42
+
43
+ // ============================================================================
44
+ // Factory
45
+ // ============================================================================
46
+
47
+ /**
48
+ * Create a custom exception filter from a plain function.
49
+ *
50
+ * @param fn - Filter function receiving the error and execution context
51
+ * @returns An ExceptionFilter instance
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * const logAndForwardFilter = createExceptionFilter((error, ctx) => {
56
+ * console.error(`[${ctx.getController()}#${ctx.getHandler()}]`, error);
57
+ * return new Response('Internal Error', { status: 500 });
58
+ * });
59
+ *
60
+ * @UseFilters(logAndForwardFilter)
61
+ * @Get('/risky')
62
+ * riskyRoute() { ... }
63
+ * ```
64
+ */
65
+ export function createExceptionFilter(
66
+ fn: (error: unknown, context: HttpExecutionContext) => OneBunResponse | Promise<OneBunResponse>,
67
+ ): ExceptionFilter {
68
+ return { catch: fn };
69
+ }
70
+
71
+ // ============================================================================
72
+ // Default filter (wraps existing error-handling logic)
73
+ // ============================================================================
74
+
75
+ /**
76
+ * Default exception filter — mirrors the built-in error handling behaviour.
77
+ * - `OneBunBaseError` instances are serialised with their own `toErrorResponse()`.
78
+ * - Every other error is converted to a generic 500 response.
79
+ * All responses use HTTP 200 with the standardised `ApiResponse` envelope,
80
+ * consistent with the rest of the framework.
81
+ */
82
+ export const defaultExceptionFilter: ExceptionFilter = {
83
+ catch(error: unknown): OneBunResponse {
84
+ if (error instanceof HttpException) {
85
+ const errorResponse = createErrorResponse(
86
+ error.message,
87
+ error.statusCode,
88
+ error.message,
89
+ );
90
+
91
+ return new Response(JSON.stringify(errorResponse), {
92
+ status: error.statusCode,
93
+ headers: {
94
+ // eslint-disable-next-line @typescript-eslint/naming-convention
95
+ 'Content-Type': 'application/json',
96
+ },
97
+ });
98
+ }
99
+
100
+ if (error instanceof OneBunBaseError) {
101
+ return new Response(JSON.stringify(error.toErrorResponse()), {
102
+ status: HttpStatusCode.OK,
103
+ headers: {
104
+ // eslint-disable-next-line @typescript-eslint/naming-convention
105
+ 'Content-Type': 'application/json',
106
+ },
107
+ });
108
+ }
109
+
110
+ const message = error instanceof Error ? error.message : String(error);
111
+ const code =
112
+ error instanceof Error && 'code' in error
113
+ ? Number((error as { code: unknown }).code)
114
+ : HttpStatusCode.INTERNAL_SERVER_ERROR;
115
+
116
+ const errorResponse = createErrorResponse(message, code, message, undefined, {
117
+ originalErrorName: error instanceof Error ? error.name : 'UnknownError',
118
+ stack: error instanceof Error ? error.stack : undefined,
119
+ });
120
+
121
+ return new Response(JSON.stringify(errorResponse), {
122
+ status: HttpStatusCode.OK,
123
+ headers: {
124
+ // eslint-disable-next-line @typescript-eslint/naming-convention
125
+ 'Content-Type': 'application/json',
126
+ },
127
+ });
128
+ },
129
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * HTTP exception that carries a status code.
3
+ *
4
+ * Throw from route handlers, guards, or middleware to return
5
+ * a specific HTTP status. The default exception filter converts
6
+ * these into JSON responses with the matching status code.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * throw new HttpException(404, 'User not found');
11
+ * // → HTTP 404 { success: false, error: "User not found", ... }
12
+ * ```
13
+ */
14
+ export class HttpException extends Error {
15
+ constructor(
16
+ public readonly statusCode: number,
17
+ message: string,
18
+ ) {
19
+ super(message);
20
+ this.name = 'HttpException';
21
+ }
22
+ }
@@ -0,0 +1,2 @@
1
+ export * from './exception-filters';
2
+ export * from './http-exception';
@@ -1,3 +1,7 @@
1
+ import { HttpStatusCode } from '@onebun/requests';
2
+
3
+ import { HttpException } from '../exception-filters/http-exception';
4
+
1
5
  /**
2
6
  * OneBunFile - Unified file wrapper for file uploads
3
7
  *
@@ -287,7 +291,8 @@ export function validateFile(
287
291
 
288
292
  // Validate file size
289
293
  if (options.maxSize !== undefined && file.size > options.maxSize) {
290
- throw new Error(
294
+ throw new HttpException(
295
+ HttpStatusCode.BAD_REQUEST,
291
296
  `${prefix} exceeds maximum size. Got ${file.size} bytes, max is ${options.maxSize} bytes`,
292
297
  );
293
298
  }
@@ -296,7 +301,8 @@ export function validateFile(
296
301
  if (options.mimeTypes && options.mimeTypes.length > 0) {
297
302
  const matches = options.mimeTypes.some((pattern) => matchMimeType(file.type, pattern));
298
303
  if (!matches) {
299
- throw new Error(
304
+ throw new HttpException(
305
+ HttpStatusCode.BAD_REQUEST,
300
306
  `${prefix} has invalid MIME type "${file.type}". Allowed: ${options.mimeTypes.join(', ')}`,
301
307
  );
302
308
  }
@@ -0,0 +1,230 @@
1
+ import {
2
+ describe,
3
+ expect,
4
+ it,
5
+ } from 'bun:test';
6
+
7
+ import type {
8
+ HttpExecutionContext,
9
+ HttpGuard,
10
+ OneBunRequest,
11
+ } from '../types';
12
+
13
+ import {
14
+ AuthGuard,
15
+ createHttpGuard,
16
+ executeHttpGuards,
17
+ HttpExecutionContextImpl,
18
+ RolesGuard,
19
+ } from './http-guards';
20
+
21
+ // ============================================================================
22
+ // Helpers
23
+ // ============================================================================
24
+
25
+ function makeRequest(headers?: Headers): OneBunRequest {
26
+ return new Request('http://localhost/test', { headers }) as unknown as OneBunRequest;
27
+ }
28
+
29
+ function makeHeaders(entries: [string, string][]): Headers {
30
+ const h = new Headers();
31
+ for (const [key, value] of entries) {
32
+ h.set(key, value);
33
+ }
34
+
35
+ return h;
36
+ }
37
+
38
+ function makeContext(
39
+ headers: [string, string][] = [],
40
+ handler = 'testHandler',
41
+ controller = 'TestController',
42
+ ): HttpExecutionContext {
43
+ return new HttpExecutionContextImpl(makeRequest(makeHeaders(headers)), handler, controller);
44
+ }
45
+
46
+ // ============================================================================
47
+ // HttpExecutionContextImpl
48
+ // ============================================================================
49
+
50
+ describe('HttpExecutionContextImpl', () => {
51
+ it('returns request from getRequest()', () => {
52
+ const req = makeRequest(makeHeaders([['authorization', 'Bearer token']]));
53
+ const ctx = new HttpExecutionContextImpl(req, 'myHandler', 'MyController');
54
+
55
+ expect(ctx.getRequest()).toBe(req);
56
+ });
57
+
58
+ it('returns handler name from getHandler()', () => {
59
+ const ctx = makeContext([], 'getUser', 'UserController');
60
+
61
+ expect(ctx.getHandler()).toBe('getUser');
62
+ });
63
+
64
+ it('returns controller name from getController()', () => {
65
+ const ctx = makeContext([], 'getUser', 'UserController');
66
+
67
+ expect(ctx.getController()).toBe('UserController');
68
+ });
69
+ });
70
+
71
+ // ============================================================================
72
+ // executeHttpGuards
73
+ // ============================================================================
74
+
75
+ describe('executeHttpGuards', () => {
76
+ it('returns true when there are no guards', async () => {
77
+ const ctx = makeContext();
78
+
79
+ expect(await executeHttpGuards([], ctx)).toBe(true);
80
+ });
81
+
82
+ it('returns true when all guards pass', async () => {
83
+ const passGuard = createHttpGuard(() => true);
84
+ const ctx = makeContext();
85
+
86
+ expect(await executeHttpGuards([passGuard, passGuard], ctx)).toBe(true);
87
+ });
88
+
89
+ it('returns false when any guard fails', async () => {
90
+ const passGuard = createHttpGuard(() => true);
91
+ const failGuard = createHttpGuard(() => false);
92
+ const ctx = makeContext();
93
+
94
+ expect(await executeHttpGuards([passGuard, failGuard, passGuard], ctx)).toBe(false);
95
+ });
96
+
97
+ it('short-circuits on first failing guard', async () => {
98
+ let secondCalled = false;
99
+
100
+ const failGuard = createHttpGuard(() => false);
101
+ const trackGuard = createHttpGuard(() => {
102
+ secondCalled = true;
103
+
104
+ return true;
105
+ });
106
+ const ctx = makeContext();
107
+
108
+ await executeHttpGuards([failGuard, trackGuard], ctx);
109
+
110
+ expect(secondCalled).toBe(false);
111
+ });
112
+
113
+ it('accepts guard instances (not just class constructors)', async () => {
114
+ const instance: HttpGuard = { canActivate: () => true };
115
+ const ctx = makeContext();
116
+
117
+ expect(await executeHttpGuards([instance], ctx)).toBe(true);
118
+ });
119
+
120
+ it('accepts async guards', async () => {
121
+ const asyncPassGuard = createHttpGuard(async () => {
122
+ await Promise.resolve();
123
+
124
+ return true;
125
+ });
126
+ const ctx = makeContext();
127
+
128
+ expect(await executeHttpGuards([asyncPassGuard], ctx)).toBe(true);
129
+ });
130
+ });
131
+
132
+ // ============================================================================
133
+ // createHttpGuard
134
+ // ============================================================================
135
+
136
+ describe('createHttpGuard', () => {
137
+ it('returns a class constructor', () => {
138
+ const guardClass = createHttpGuard(() => true);
139
+
140
+ expect(typeof guardClass).toBe('function');
141
+ });
142
+
143
+ it('instantiated class calls the provided function', async () => {
144
+ let called = false;
145
+ const guardClass = createHttpGuard((ctx) => {
146
+ called = true;
147
+
148
+ return ctx.getHandler() === 'target';
149
+ });
150
+ const ctx = makeContext([], 'target');
151
+ const instance = new guardClass();
152
+
153
+ expect(await instance.canActivate(ctx)).toBe(true);
154
+ expect(called).toBe(true);
155
+ });
156
+ });
157
+
158
+ // ============================================================================
159
+ // AuthGuard
160
+ // ============================================================================
161
+
162
+ describe('AuthGuard', () => {
163
+ it('allows request with Bearer token', () => {
164
+ const guard = new AuthGuard();
165
+ const ctx = makeContext([['authorization', 'Bearer my-token']]);
166
+
167
+ expect(guard.canActivate(ctx)).toBe(true);
168
+ });
169
+
170
+ it('blocks request without Authorization header', () => {
171
+ const guard = new AuthGuard();
172
+ const ctx = makeContext();
173
+
174
+ expect(guard.canActivate(ctx)).toBe(false);
175
+ });
176
+
177
+ it('blocks request with non-Bearer Authorization header', () => {
178
+ const guard = new AuthGuard();
179
+ const ctx = makeContext([['authorization', 'Basic dXNlcjpwYXNz']]);
180
+
181
+ expect(guard.canActivate(ctx)).toBe(false);
182
+ });
183
+ });
184
+
185
+ // ============================================================================
186
+ // RolesGuard
187
+ // ============================================================================
188
+
189
+ describe('RolesGuard', () => {
190
+ it('allows when user has all required roles (default extractor)', () => {
191
+ const guard = new RolesGuard(['admin', 'editor']);
192
+ const headers = makeHeaders([['x-user-roles', 'admin, editor, viewer']]);
193
+ const ctx = new HttpExecutionContextImpl(makeRequest(headers), 'handler', 'Controller');
194
+
195
+ expect(guard.canActivate(ctx)).toBe(true);
196
+ });
197
+
198
+ it('blocks when user is missing a required role', () => {
199
+ const guard = new RolesGuard(['admin']);
200
+ const headers = makeHeaders([['x-user-roles', 'viewer']]);
201
+ const ctx = new HttpExecutionContextImpl(makeRequest(headers), 'handler', 'Controller');
202
+
203
+ expect(guard.canActivate(ctx)).toBe(false);
204
+ });
205
+
206
+ it('blocks when x-user-roles header is absent', () => {
207
+ const guard = new RolesGuard(['admin']);
208
+ const ctx = makeContext();
209
+
210
+ expect(guard.canActivate(ctx)).toBe(false);
211
+ });
212
+
213
+ it('uses custom roles extractor when provided', () => {
214
+ const headers = makeHeaders([['x-roles', 'admin|user']]);
215
+ const guard = new RolesGuard(['admin'], (ctx) =>
216
+ ctx.getRequest().headers.get('x-roles')?.split('|') ?? [],
217
+ );
218
+ const ctx = new HttpExecutionContextImpl(makeRequest(headers), 'handler', 'Controller');
219
+
220
+ expect(guard.canActivate(ctx)).toBe(true);
221
+ });
222
+
223
+ it('requires ALL roles to be present (not just one)', () => {
224
+ const guard = new RolesGuard(['admin', 'superuser']);
225
+ const headers = makeHeaders([['x-user-roles', 'admin']]);
226
+ const ctx = new HttpExecutionContextImpl(makeRequest(headers), 'handler', 'Controller');
227
+
228
+ expect(guard.canActivate(ctx)).toBe(false);
229
+ });
230
+ });