Skip to content

Commit 8f813b4

Browse files
authored
Merge pull request #246 from Favourof/feature/global-error-handler
feat(api): add centralized API error handler with request-id logging
2 parents c149c4e + e7b071c commit 8f813b4

File tree

4 files changed

+177
-61
lines changed

4 files changed

+177
-61
lines changed

app/api/v1/bills/[id]/pay/route.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
import { NextResponse } from 'next/server'
22
import { buildPayBillTx } from '../../../../../../lib/contracts/bill-payments'
33
import { StrKey } from '@stellar/stellar-sdk'
4+
import { ApiRouteError, withApiErrorHandler } from '@/lib/api/error-handler'
45

5-
export async function POST(req: Request, { params }: { params: { id: string } }) {
6-
try {
7-
const caller = req.headers.get('x-user')
8-
if (!caller || !StrKey.isValidEd25519PublicKey(caller)) {
9-
return NextResponse.json({ error: 'Unauthorized: missing or invalid x-user header' }, { status: 401 })
10-
}
11-
12-
const billId = params?.id
13-
if (!billId) return NextResponse.json({ error: 'Missing bill id' }, { status: 400 })
6+
export const POST = withApiErrorHandler(async function POST(
7+
req: Request,
8+
{ params }: { params: { id: string } }
9+
) {
10+
const caller = req.headers.get('x-user')
11+
if (!caller || !StrKey.isValidEd25519PublicKey(caller)) {
12+
throw new ApiRouteError(401, 'UNAUTHORIZED', 'Unauthorized')
13+
}
1414

15-
const xdr = await buildPayBillTx(caller, billId)
16-
return NextResponse.json({ xdr })
17-
} catch (err: any) {
18-
return NextResponse.json({ error: err?.message || String(err) }, { status: 500 })
15+
const billId = params?.id
16+
if (!billId) {
17+
throw new ApiRouteError(400, 'VALIDATION_ERROR', 'Missing bill id')
1918
}
20-
}
19+
20+
const xdr = await buildPayBillTx(caller, billId)
21+
return NextResponse.json({ xdr })
22+
})

app/api/v1/bills/route.ts

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,31 @@
11
import { NextResponse } from 'next/server'
22
import { buildCreateBillTx } from '../../../../lib/contracts/bill-payments'
33
import { StrKey } from '@stellar/stellar-sdk'
4+
import { ApiRouteError, withApiErrorHandler } from '@/lib/api/error-handler'
45

5-
export async function POST(req: Request) {
6-
try {
7-
const caller = req.headers.get('x-user')
8-
if (!caller || !StrKey.isValidEd25519PublicKey(caller)) {
9-
return NextResponse.json({ error: 'Unauthorized: missing or invalid x-user header' }, { status: 401 })
10-
}
11-
12-
const body = await req.json()
13-
const { name, amount, dueDate, recurring = false, frequencyDays } = body || {}
6+
export const POST = withApiErrorHandler(async function POST(req: Request) {
7+
const caller = req.headers.get('x-user')
8+
if (!caller || !StrKey.isValidEd25519PublicKey(caller)) {
9+
throw new ApiRouteError(401, 'UNAUTHORIZED', 'Unauthorized')
10+
}
1411

15-
if (!name || typeof name !== 'string') {
16-
return NextResponse.json({ error: 'Invalid name' }, { status: 400 })
17-
}
18-
const numAmount = Number(amount)
19-
if (!(numAmount > 0)) {
20-
return NextResponse.json({ error: 'Invalid amount; must be > 0' }, { status: 400 })
21-
}
22-
if (recurring && !(frequencyDays && Number(frequencyDays) > 0)) {
23-
return NextResponse.json({ error: 'Invalid frequencyDays for recurring bill' }, { status: 400 })
24-
}
25-
if (!dueDate || Number.isNaN(Date.parse(dueDate))) {
26-
return NextResponse.json({ error: 'Invalid dueDate' }, { status: 400 })
27-
}
12+
const body = await req.json()
13+
const { name, amount, dueDate, recurring = false, frequencyDays } = body || {}
2814

29-
const xdr = await buildCreateBillTx(caller, name, numAmount, dueDate, Boolean(recurring), frequencyDays ? Number(frequencyDays) : undefined)
30-
return NextResponse.json({ xdr })
31-
} catch (err: any) {
32-
return NextResponse.json({ error: err?.message || String(err) }, { status: 500 })
15+
if (!name || typeof name !== 'string') {
16+
throw new ApiRouteError(400, 'VALIDATION_ERROR', 'Invalid name')
17+
}
18+
const numAmount = Number(amount)
19+
if (!(numAmount > 0)) {
20+
throw new ApiRouteError(400, 'VALIDATION_ERROR', 'Invalid amount; must be > 0')
3321
}
34-
}
22+
if (recurring && !(frequencyDays && Number(frequencyDays) > 0)) {
23+
throw new ApiRouteError(400, 'VALIDATION_ERROR', 'Invalid frequencyDays for recurring bill')
24+
}
25+
if (!dueDate || Number.isNaN(Date.parse(dueDate))) {
26+
throw new ApiRouteError(400, 'VALIDATION_ERROR', 'Invalid dueDate')
27+
}
28+
29+
const xdr = await buildCreateBillTx(caller, name, numAmount, dueDate, Boolean(recurring), frequencyDays ? Number(frequencyDays) : undefined)
30+
return NextResponse.json({ xdr })
31+
})

app/api/v1/insurance/route.ts

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,33 @@
11
import { NextResponse } from 'next/server'
22
import { buildCreatePolicyTx } from '../../../../lib/contracts/insurance'
33
import { StrKey } from '@stellar/stellar-sdk'
4+
import { ApiRouteError, withApiErrorHandler } from '@/lib/api/error-handler'
45

5-
export async function POST(req: Request) {
6-
try {
7-
const caller = req.headers.get('x-user')
8-
if (!caller || !StrKey.isValidEd25519PublicKey(caller)) {
9-
return NextResponse.json({ error: 'Unauthorized: missing or invalid x-user header' }, { status: 401 })
10-
}
11-
12-
const body = await req.json()
13-
const { name, coverageType, monthlyPremium, coverageAmount } = body || {}
6+
export const POST = withApiErrorHandler(async function POST(req: Request) {
7+
const caller = req.headers.get('x-user')
8+
if (!caller || !StrKey.isValidEd25519PublicKey(caller)) {
9+
throw new ApiRouteError(401, 'UNAUTHORIZED', 'Unauthorized')
10+
}
1411

15-
if (!name || typeof name !== 'string') return NextResponse.json({ error: 'Invalid name' }, { status: 400 })
16-
if (!coverageType || typeof coverageType !== 'string') return NextResponse.json({ error: 'Invalid coverageType' }, { status: 400 })
12+
const body = await req.json()
13+
const { name, coverageType, monthlyPremium, coverageAmount } = body || {}
1714

18-
const mp = Number(monthlyPremium)
19-
const ca = Number(coverageAmount)
20-
if (!(mp > 0)) return NextResponse.json({ error: 'Invalid monthlyPremium; must be > 0' }, { status: 400 })
21-
if (!(ca > 0)) return NextResponse.json({ error: 'Invalid coverageAmount; must be > 0' }, { status: 400 })
15+
if (!name || typeof name !== 'string') {
16+
throw new ApiRouteError(400, 'VALIDATION_ERROR', 'Invalid name')
17+
}
18+
if (!coverageType || typeof coverageType !== 'string') {
19+
throw new ApiRouteError(400, 'VALIDATION_ERROR', 'Invalid coverageType')
20+
}
2221

23-
const xdr = await buildCreatePolicyTx(caller, name, coverageType, mp, ca)
24-
return NextResponse.json({ xdr })
25-
} catch (err: any) {
26-
return NextResponse.json({ error: err?.message || String(err) }, { status: 500 })
22+
const mp = Number(monthlyPremium)
23+
const ca = Number(coverageAmount)
24+
if (!(mp > 0)) {
25+
throw new ApiRouteError(400, 'VALIDATION_ERROR', 'Invalid monthlyPremium; must be > 0')
26+
}
27+
if (!(ca > 0)) {
28+
throw new ApiRouteError(400, 'VALIDATION_ERROR', 'Invalid coverageAmount; must be > 0')
2729
}
28-
}
30+
31+
const xdr = await buildCreatePolicyTx(caller, name, coverageType, mp, ca)
32+
return NextResponse.json({ xdr })
33+
})

lib/api/error-handler.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { NextResponse } from 'next/server';
2+
import { ValidationError } from '@/utils/validation/preferences-validation';
3+
import type { ApiErrorCode } from '@/lib/api/types';
4+
5+
type KnownCode = Extract<ApiErrorCode, 'UNAUTHORIZED' | 'VALIDATION_ERROR' | 'NOT_FOUND' | 'INTERNAL_ERROR'>;
6+
7+
export class ApiRouteError extends Error {
8+
readonly status: number;
9+
readonly code: KnownCode;
10+
11+
constructor(status: number, code: KnownCode, message: string) {
12+
super(message);
13+
this.status = status;
14+
this.code = code;
15+
this.name = 'ApiRouteError';
16+
}
17+
}
18+
19+
type Handler<TContext = unknown> = (
20+
request: Request,
21+
context: TContext
22+
) => Promise<Response> | Response;
23+
24+
function getRequestId(request: Request): string {
25+
return request.headers.get('x-request-id') ?? crypto.randomUUID();
26+
}
27+
28+
function mapError(error: unknown): ApiRouteError {
29+
if (error instanceof ApiRouteError) {
30+
return error;
31+
}
32+
33+
if (error instanceof Response) {
34+
if (error.status === 400) return new ApiRouteError(400, 'VALIDATION_ERROR', 'Invalid request');
35+
if (error.status === 401) return new ApiRouteError(401, 'UNAUTHORIZED', 'Unauthorized');
36+
if (error.status === 404) return new ApiRouteError(404, 'NOT_FOUND', 'Resource not found');
37+
return new ApiRouteError(error.status || 500, 'INTERNAL_ERROR', 'Internal server error');
38+
}
39+
40+
if (error instanceof ValidationError) {
41+
return new ApiRouteError(400, 'VALIDATION_ERROR', error.message);
42+
}
43+
44+
if (
45+
error &&
46+
typeof error === 'object' &&
47+
'name' in error &&
48+
(error as { name?: string }).name === 'ApiError' &&
49+
'status' in error &&
50+
typeof (error as { status?: unknown }).status === 'number'
51+
) {
52+
const status = (error as { status: number }).status;
53+
const message =
54+
'message' in error && typeof (error as { message?: unknown }).message === 'string'
55+
? (error as { message: string }).message
56+
: 'Request failed';
57+
58+
if (status === 400) return new ApiRouteError(400, 'VALIDATION_ERROR', message);
59+
if (status === 401) return new ApiRouteError(401, 'UNAUTHORIZED', message);
60+
if (status === 404) return new ApiRouteError(404, 'NOT_FOUND', message);
61+
return new ApiRouteError(status, 'INTERNAL_ERROR', 'Internal server error');
62+
}
63+
64+
return new ApiRouteError(500, 'INTERNAL_ERROR', 'Internal server error');
65+
}
66+
67+
export function withApiErrorHandler<TContext = unknown>(handler: Handler<TContext>) {
68+
return async function wrappedHandler(request: Request, context: TContext): Promise<Response> {
69+
const requestId = getRequestId(request);
70+
const url = new URL(request.url);
71+
const path = url.pathname;
72+
const method = request.method;
73+
74+
try {
75+
const response = await handler(request, context);
76+
response.headers.set('x-request-id', requestId);
77+
return response;
78+
} catch (error) {
79+
const mapped = mapError(error);
80+
81+
if (mapped.code === 'INTERNAL_ERROR') {
82+
console.error('[api-error]', { requestId, path, method, error });
83+
} else {
84+
console.warn('[api-error]', {
85+
requestId,
86+
path,
87+
method,
88+
status: mapped.status,
89+
code: mapped.code,
90+
message: mapped.message,
91+
});
92+
}
93+
94+
return NextResponse.json(
95+
{
96+
success: false as const,
97+
error: {
98+
code: mapped.code,
99+
message: mapped.message,
100+
},
101+
requestId,
102+
},
103+
{
104+
status: mapped.status,
105+
headers: {
106+
'x-request-id': requestId,
107+
},
108+
}
109+
);
110+
}
111+
};
112+
}

0 commit comments

Comments
 (0)