Skip to content

Commit 9a3d110

Browse files
committed
add errors
1 parent e589303 commit 9a3d110

9 files changed

Lines changed: 311 additions & 38 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# [WIP] lovat-backend
2+
23
lovat server v2 (in development)
34
To install dependencies:
45

src/middleware/error.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
export class HttpError extends Error {
2+
status: number;
3+
code: string;
4+
5+
constructor(message: string, status: number, code: string) {
6+
super(message);
7+
this.status = status;
8+
this.code = code;
9+
this.name = this.constructor.name;
10+
}
11+
}
12+
13+
export class BadRequest extends HttpError {
14+
constructor(message = 'Bad Request') {
15+
super(message, 400, 'BAD_REQUEST');
16+
}
17+
}
18+
19+
export class InvalidParams extends HttpError {
20+
constructor(message = 'Invalid request parameters') {
21+
super(message, 400, 'INVALID_PARAMS');
22+
}
23+
}
24+
25+
export class MissingParams extends HttpError {
26+
constructor(message = 'Missing required parameters') {
27+
super(message, 400, 'MISSING_PARAMS');
28+
}
29+
}
30+
31+
export class ValidationError extends HttpError {
32+
constructor(message = 'Validation failed') {
33+
super(message, 400, 'VALIDATION_ERROR');
34+
}
35+
}
36+
export class Unauthorized extends HttpError {
37+
constructor(message = 'Unauthorized') {
38+
super(message, 401, 'UNAUTHORIZED');
39+
}
40+
}
41+
export class Forbidden extends HttpError {
42+
constructor(message = 'Forbidden') {
43+
super(message, 403, 'FORBIDDEN');
44+
}
45+
}
46+
export class NotFound extends HttpError {
47+
constructor(message = 'Resource not found') {
48+
super(message, 404, 'NOT_FOUND');
49+
}
50+
}
51+
52+
export class RouteNotFound extends HttpError {
53+
constructor(message = 'Endpoint not found') {
54+
super(message, 404, 'ROUTE_NOT_FOUND');
55+
}
56+
}
57+
export class UnprocessableEntity extends HttpError {
58+
constructor(message = 'Unprocessable Entity') {
59+
super(message, 422, 'UNPROCESSABLE_ENTITY');
60+
}
61+
}
62+
export class TooManyRequests extends HttpError {
63+
constructor(message = 'Too many requests') {
64+
super(message, 429, 'RATE_LIMITED');
65+
}
66+
}
67+
68+
export class InternalServerError extends HttpError {
69+
constructor(message = 'Internal Server Error') {
70+
super(message, 500, 'INTERNAL_SERVER_ERROR');
71+
}
72+
}
73+
74+
export const handleErrors = async (ctx: any, next: any) => {
75+
try {
76+
await next();
77+
} catch (err) {
78+
if (err instanceof HttpError) {
79+
return ctx.json(
80+
{
81+
error: {
82+
message: err.message,
83+
code: err.code,
84+
},
85+
},
86+
err.status
87+
);
88+
}
89+
90+
console.error('UNHANDLED ERROR:', err);
91+
return ctx.json(
92+
{
93+
error: {
94+
message: 'Internal Server Error',
95+
code: 'INTERNAL_SERVER_ERROR',
96+
},
97+
},
98+
500
99+
);
100+
}
101+
};

src/openapi/examples.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
export const ErrorExamples = {
2+
BadRequest: { error: { message: 'Bad Request', code: 'BAD_REQUEST' } },
3+
InvalidParams: { error: { message: 'Invalid request parameters', code: 'INVALID_PARAMS' } },
4+
MissingParams: { error: { message: 'Missing required parameters', code: 'MISSING_PARAMS' } },
5+
ValidationError: { error: { message: 'Validation failed', code: 'VALIDATION_ERROR' } },
6+
Unauthorized: { error: { message: 'Unauthorized', code: 'UNAUTHORIZED' } },
7+
Forbidden: { error: { message: 'Forbidden', code: 'FORBIDDEN' } },
8+
NotFound: { error: { message: 'Resource not found', code: 'NOT_FOUND' } },
9+
RouteNotFound: { error: { message: 'Endpoint not found', code: 'ROUTE_NOT_FOUND' } },
10+
UnprocessableEntity: { error: { message: 'Unprocessable Entity', code: 'UNPROCESSABLE_ENTITY' } },
11+
TooManyRequests: { error: { message: 'Too many requests', code: 'RATE_LIMITED' } },
12+
InternalServerError: {
13+
error: { message: 'Internal Server Error', code: 'INTERNAL_SERVER_ERROR' },
14+
},
15+
} as const;
16+
17+
export const errorExamplesComponents = {
18+
BadRequestError: {
19+
summary: 'Bad Request',
20+
description: 'Generic 400 error for malformed requests',
21+
value: ErrorExamples.BadRequest,
22+
},
23+
InvalidParamsError: {
24+
summary: 'Invalid Params',
25+
description: '400 when provided parameters fail validation',
26+
value: ErrorExamples.InvalidParams,
27+
},
28+
MissingParamsError: {
29+
summary: 'Missing Params',
30+
description: '400 when required parameters are missing',
31+
value: ErrorExamples.MissingParams,
32+
},
33+
ValidationError: {
34+
summary: 'Validation Error',
35+
description: '400 when request body validation fails',
36+
value: ErrorExamples.ValidationError,
37+
},
38+
UnauthorizedError: {
39+
summary: 'Unauthorized',
40+
description: '401 when authentication fails or is missing',
41+
value: ErrorExamples.Unauthorized,
42+
},
43+
ForbiddenError: {
44+
summary: 'Forbidden',
45+
description: '403 when user lacks required permissions',
46+
value: ErrorExamples.Forbidden,
47+
},
48+
NotFoundError: {
49+
summary: 'Not Found',
50+
description: '404 when resource is not found',
51+
value: ErrorExamples.NotFound,
52+
},
53+
RouteNotFoundError: {
54+
summary: 'Route Not Found',
55+
description: '404 when endpoint path does not exist',
56+
value: ErrorExamples.RouteNotFound,
57+
},
58+
UnprocessableEntityError: {
59+
summary: 'Unprocessable Entity',
60+
description: '422 when request is well-formed but semantically invalid',
61+
value: ErrorExamples.UnprocessableEntity,
62+
},
63+
TooManyRequestsError: {
64+
summary: 'Too Many Requests',
65+
description: '429 when rate limits are exceeded',
66+
value: ErrorExamples.TooManyRequests,
67+
},
68+
InternalServerError: {
69+
summary: 'Internal Server Error',
70+
description: '500 for unexpected server errors',
71+
value: ErrorExamples.InternalServerError,
72+
},
73+
} as const;

src/openapi/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { OpenAPIHono } from '@hono/zod-openapi';
22
export const api = new OpenAPIHono();
3+
export * from './examples';
34

45
// Re-export all schemas for route validators to import
56
export * from './schemas';
7+
export * from './responses';

src/openapi/responses.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { ErrorResponseSchema } from './schemas/common';
2+
3+
const json = 'application/json' as const;
4+
5+
export const BadRequestResponse = {
6+
description: 'Bad Request',
7+
content: {
8+
[json]: {
9+
schema: ErrorResponseSchema,
10+
examples: {
11+
BadRequestError: { $ref: '#/components/examples/BadRequestError' },
12+
InvalidParamsError: { $ref: '#/components/examples/InvalidParamsError' },
13+
MissingParamsError: { $ref: '#/components/examples/MissingParamsError' },
14+
ValidationError: { $ref: '#/components/examples/ValidationError' },
15+
},
16+
},
17+
},
18+
} as const;
19+
20+
export const UnauthorizedResponse = {
21+
description: 'Unauthorized',
22+
content: {
23+
[json]: {
24+
schema: ErrorResponseSchema,
25+
examples: {
26+
UnauthorizedError: { $ref: '#/components/examples/UnauthorizedError' },
27+
},
28+
},
29+
},
30+
} as const;
31+
32+
export const ForbiddenResponse = {
33+
description: 'Forbidden',
34+
content: {
35+
[json]: {
36+
schema: ErrorResponseSchema,
37+
examples: {
38+
ForbiddenError: { $ref: '#/components/examples/ForbiddenError' },
39+
},
40+
},
41+
},
42+
} as const;
43+
44+
export const NotFoundResponse = {
45+
description: 'Not Found',
46+
content: {
47+
[json]: {
48+
schema: ErrorResponseSchema,
49+
examples: {
50+
NotFoundError: { $ref: '#/components/examples/NotFoundError' },
51+
},
52+
},
53+
},
54+
} as const;
55+
56+
export const UnprocessableEntityResponse = {
57+
description: 'Unprocessable Entity',
58+
content: {
59+
[json]: {
60+
schema: ErrorResponseSchema,
61+
examples: {
62+
UnprocessableEntityError: { $ref: '#/components/examples/UnprocessableEntityError' },
63+
},
64+
},
65+
},
66+
} as const;
67+
68+
export const TooManyRequestsResponse = {
69+
description: 'Too Many Requests',
70+
content: {
71+
[json]: {
72+
schema: ErrorResponseSchema,
73+
examples: {
74+
TooManyRequestsError: { $ref: '#/components/examples/TooManyRequestsError' },
75+
},
76+
},
77+
},
78+
} as const;
79+
80+
export const InternalServerErrorResponse = {
81+
description: 'Internal Server Error',
82+
content: {
83+
[json]: {
84+
schema: ErrorResponseSchema,
85+
examples: {
86+
InternalServerError: { $ref: '#/components/examples/InternalServerError' },
87+
},
88+
},
89+
},
90+
} as const;

src/openapi/schemas/common.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { z } from '@hono/zod-openapi';
22

33
export const ErrorResponseSchema = z
44
.object({
5-
error: z.string().openapi({ example: 'User not found' }),
5+
error: z.object({
6+
message: z.string().openapi({ example: 'User not found' }),
7+
code: z.string().openapi({ example: 'NOT_FOUND' }),
8+
}),
69
})
710
.openapi('ErrorResponse');

src/openapi/security.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export const securitySchemes = {
2+
DashboardAuth: {
3+
type: 'http',
4+
scheme: 'bearer',
5+
bearerFormat: 'JWT',
6+
description:
7+
'Dashboard auth: Bearer JWT from Auth0. Some endpoints may also accept API keys (lvt-...).',
8+
},
9+
ApiKeyAuth: {
10+
type: 'apiKey',
11+
in: 'header',
12+
name: 'Authorization',
13+
description: 'API Key auth: Authorization: Bearer lvt-<key>',
14+
},
15+
SlackAuth: {
16+
type: 'http',
17+
scheme: 'none',
18+
description:
19+
'Slack signed requests verified via x-slack-signature, x-slack-request-timestamp, and verification key.',
20+
} as any,
21+
LovatAuth: {
22+
type: 'http',
23+
scheme: 'none',
24+
description:
25+
'Lovat signed requests verified via x-signature and x-timestamp using server-side signing key.',
26+
} as any,
27+
} as const;

src/routes/index.ts

Lines changed: 9 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ import { logger } from '../middleware/logger';
22
import userRouter from './users.routes';
33
import { api } from '../openapi/registry';
44
import openapiDocHandler from '../openapi/doc';
5+
import { handleErrors } from '../middleware/error';
6+
import { version } from 'bun';
7+
import { securitySchemes } from '../openapi/security';
8+
import { errorExamplesComponents } from '../openapi/examples';
59

610
// Use OpenAPIHono as the central router
711
const router = api;
812
router.use('/*', logger);
13+
router.use('/*', handleErrors);
914

1015
// Health endpoint
1116
router.get('/health', (c) => c.json({ ok: true }));
@@ -18,37 +23,13 @@ const openapiConfig = {
1823
openapi: '3.1.0',
1924
info: {
2025
title: 'Lovat API',
21-
version: '1.0.0',
26+
version: version,
2227
},
2328
components: {
24-
securitySchemes: {
25-
DashboardAuth: {
26-
type: 'http',
27-
scheme: 'bearer',
28-
bearerFormat: 'JWT',
29-
description:
30-
'Dashboard auth: Bearer JWT from Auth0. Some endpoints may also accept API keys (lvt-...).',
31-
},
32-
ApiKeyAuth: {
33-
type: 'apiKey',
34-
in: 'header',
35-
name: 'Authorization',
36-
description: 'API Key auth: Authorization: Bearer lvt-<key>',
37-
},
38-
SlackAuth: {
39-
type: 'http',
40-
scheme: 'none',
41-
description:
42-
'Slack signed requests verified via x-slack-signature, x-slack-request-timestamp, and verification key.',
43-
} as any,
44-
LovatAuth: {
45-
type: 'http',
46-
scheme: 'none',
47-
description:
48-
'Lovat signed requests verified via x-signature and x-timestamp using server-side signing key.',
49-
} as any,
50-
},
29+
securitySchemes,
30+
examples: errorExamplesComponents as any,
5131
},
32+
security: [{ DashboardAuth: [] }, { ApiKeyAuth: [] }] as any,
5233
};
5334

5435
// Raw OpenAPI JSON

0 commit comments

Comments
 (0)