Skip to content

Commit d4ad361

Browse files
fix(api): standardize error response envelope
1 parent 6db14d7 commit d4ad361

14 files changed

Lines changed: 193 additions & 230 deletions

backend/src/config/swagger.ts

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -481,27 +481,20 @@ See [Sandbox Mode Documentation](../docs/SANDBOX_MODE.md) for details.`,
481481
type: 'object',
482482
properties: {
483483
error: {
484-
type: 'string',
485-
description: 'Error message',
486-
example: 'Resource not found',
487-
},
488-
code: {
489-
type: 'string',
490-
description: 'Error code',
491-
example: 'NOT_FOUND',
492-
},
493-
message: {
494-
type: 'string',
495-
nullable: true,
496-
description: 'Human-readable detail (present on many error responses)',
497-
},
498-
details: {
499-
type: 'array',
500-
nullable: true,
501-
description: 'Structured validation issues (zod) when the error is a 400',
502-
items: { type: 'object' },
484+
type: 'object',
485+
required: ['code', 'message'],
486+
properties: {
487+
code: { type: 'string', example: 'NOT_FOUND' },
488+
message: { type: 'string', example: 'Resource not found' },
489+
details: {
490+
type: 'array',
491+
description: 'Structured validation issues when applicable',
492+
items: { type: 'object' },
493+
},
494+
},
503495
},
504496
},
497+
required: ['error'],
505498
},
506499
},
507500
},

backend/src/controllers/stream.controller.ts

Lines changed: 52 additions & 111 deletions
Large diffs are not rendered by default.

backend/src/controllers/stream/cancel.ts

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as sorobanService from '../../services/sorobanService.js';
55
import type { AuthenticatedRequest } from '../../types/auth.types.js';
66
import * as streamRepository from '../../repositories/stream.repository.js';
77
import { parseStreamId } from '../../lib/stream-id.js';
8+
import { sendApiError } from '../../types/api-error.js';
89

910
/**
1011
* @openapi
@@ -53,12 +54,12 @@ export const cancelStreamHandler = async (req: AuthenticatedRequest, res: Respon
5354

5455
const streamId = Array.isArray(streamIdParam) ? streamIdParam[0] : streamIdParam;
5556
if (!streamId) {
56-
return res.status(400).json({ error: 'Missing streamId parameter' });
57+
return sendApiError(res, 400, 'MISSING_STREAM_ID', 'Missing streamId parameter');
5758
}
5859

5960
const parsedStreamId = parseStreamId(streamId);
6061
if (parsedStreamId === null) {
61-
return res.status(400).json({ error: 'Invalid streamId parameter' });
62+
return sendApiError(res, 400, 'INVALID_STREAM_ID', 'Invalid streamId parameter');
6263
}
6364

6465
// 1. Fetch stream from DB
@@ -67,30 +68,24 @@ export const cancelStreamHandler = async (req: AuthenticatedRequest, res: Respon
6768
});
6869

6970
if (!stream) {
70-
return res.status(404).json({ error: 'Stream not found' });
71+
return sendApiError(res, 404, 'NOT_FOUND', 'Stream not found');
7172
}
7273

7374
// 2. Validate caller is sender
7475
if (stream.sender !== callerAddress) {
75-
return res.status(403).json({
76-
error: 'Forbidden',
77-
message: 'Only the sender can cancel the stream'
78-
});
76+
return sendApiError(res, 403, 'FORBIDDEN', 'Only the sender can cancel the stream');
7977
}
8078

8179
// 3. Check status
8280
if (!stream.isActive) {
83-
return res.status(409).json({
84-
error: 'Conflict',
85-
message: 'Stream is already cancelled or completed'
86-
});
81+
return sendApiError(res, 409, 'CONFLICT', 'Stream is already cancelled or completed');
8782
}
8883

8984
// 4. Call Soroban service to cancel on-chain
9085
const secretKey = process.env.KEEPER_SECRET_KEY;
9186
if (!secretKey) {
9287
logger.error('[CancelStream] KEEPER_SECRET_KEY not configured');
93-
return res.status(500).json({ error: 'Internal server error', message: 'Backend not configured for on-chain calls' });
88+
return sendApiError(res, 500, 'INTERNAL_SERVER_ERROR', 'Backend not configured for on-chain calls');
9489
}
9590

9691
const txHash = await sorobanService.cancelStream(parsedStreamId, secretKey);
@@ -107,8 +102,8 @@ export const cancelStreamHandler = async (req: AuthenticatedRequest, res: Respon
107102
} catch (error) {
108103
logger.error('Error cancelling stream:', error);
109104
if (error instanceof Error && error.message.includes('Simulation failed')) {
110-
return res.status(400).json({ error: 'Transaction simulation failed', message: error.message });
105+
return sendApiError(res, 400, 'TRANSACTION_SIMULATION_FAILED', error.message);
111106
}
112-
return res.status(500).json({ error: 'Internal server error' });
107+
return sendApiError(res, 500, 'INTERNAL_SERVER_ERROR', 'A technical error occurred. Please try again later.');
113108
}
114109
};

backend/src/controllers/user.controller.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
MAX_EVENTS_PAGE_SIZE,
1212
} from "../routes/v1/events.routes.js";
1313
import * as exportService from "../services/export.service.js";
14+
import { sendApiError } from "../types/api-error.js";
1415

1516
/**
1617
* Public shape of a Stream, used when embedding streams inside a public
@@ -104,12 +105,10 @@ export const getUser = async (
104105
try {
105106
const { publicKey } = req.params;
106107
if (typeof publicKey !== "string") {
107-
return res.status(400).json({ error: "Invalid publicKey parameter" });
108+
return sendApiError(res, 400, "INVALID_PUBLIC_KEY", "Invalid publicKey parameter");
108109
}
109110
if (!STELLAR_PUBLIC_KEY_REGEX.test(publicKey)) {
110-
return res
111-
.status(400)
112-
.json({ error: "Invalid Stellar public key format" });
111+
return sendApiError(res, 400, "INVALID_PUBLIC_KEY", "Invalid Stellar public key format");
113112
}
114113

115114
const user = await prisma.user.findUnique({
@@ -118,7 +117,7 @@ export const getUser = async (
118117
});
119118

120119
if (!user) {
121-
return res.status(404).json({ error: "User not found" });
120+
return sendApiError(res, 404, "NOT_FOUND", "User not found");
122121
}
123122

124123
return res.status(200).json(user);
@@ -138,12 +137,10 @@ export const getUserEvents = async (
138137
try {
139138
const { publicKey } = req.params;
140139
if (typeof publicKey !== "string") {
141-
return res.status(400).json({ error: "Invalid publicKey parameter" });
140+
return sendApiError(res, 400, "INVALID_PUBLIC_KEY", "Invalid publicKey parameter");
142141
}
143142
if (!STELLAR_PUBLIC_KEY_REGEX.test(publicKey)) {
144-
return res
145-
.status(400)
146-
.json({ error: "Invalid Stellar public key format" });
143+
return sendApiError(res, 400, "INVALID_PUBLIC_KEY", "Invalid Stellar public key format");
147144
}
148145

149146
const rawLimit = req.query["limit"];
@@ -255,7 +252,7 @@ export const exportTransactions = async (
255252
: addressParam;
256253

257254
if (!address || !STELLAR_PUBLIC_KEY_REGEX.test(address)) {
258-
return res.status(400).json({ error: "Invalid Stellar address" });
255+
return sendApiError(res, 400, "INVALID_ADDRESS", "Invalid Stellar address");
259256
}
260257

261258
const format = (req.query.format as string) || "csv";
@@ -270,15 +267,11 @@ export const exportTransactions = async (
270267
const tokenAddress = (req.query.tokenAddress as string | null) || null;
271268

272269
if (!["csv", "json"].includes(format)) {
273-
return res
274-
.status(400)
275-
.json({ error: "Invalid format. Must be csv or json" });
270+
return sendApiError(res, 400, "INVALID_FORMAT", "Invalid format. Must be csv or json");
276271
}
277272

278273
if (!["incoming", "outgoing", "all"].includes(direction)) {
279-
return res.status(400).json({
280-
error: "Invalid direction. Must be incoming, outgoing, or all",
281-
});
274+
return sendApiError(res, 400, "INVALID_DIRECTION", "Invalid direction. Must be incoming, outgoing, or all");
282275
}
283276

284277
const options: exportService.ExportOptions = {

backend/src/middleware/error.middleware.ts

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Request, Response, NextFunction } from 'express';
22
import { Prisma } from '../generated/prisma/index.js';
33
import { ZodError, type ZodIssue } from 'zod';
44
import logger from '../logger.js';
5+
import { ApiError, sendApiError } from '../types/api-error.js';
56

67
/**
78
* Global error handler middleware
@@ -18,43 +19,35 @@ export const errorHandler = (
1819
return next(err);
1920
}
2021

21-
// Handle Zod Validation Errors
2222
if (err instanceof ZodError) {
23-
return res.status(400).json({
24-
error: 'Validation Error',
25-
details: err.issues.map((e: ZodIssue) => ({
26-
path: e.path.join('.'),
27-
message: e.message
28-
}))
29-
});
23+
return sendApiError(res, 400, 'VALIDATION_ERROR', 'Request validation failed', err.issues.map((e: ZodIssue) => ({
24+
path: e.path.join('.'),
25+
message: e.message,
26+
code: e.code,
27+
})));
28+
}
29+
30+
if (err instanceof ApiError) {
31+
return sendApiError(res, err.statusCode, err.code, err.message, err.details);
3032
}
3133

3234
// Handle Prisma Errors
3335
if (err instanceof Prisma.PrismaClientKnownRequestError) {
3436
// Unique constraint violation
3537
if ((err as Prisma.PrismaClientKnownRequestError).code === 'P2002') {
3638
const target = ((err as Prisma.PrismaClientKnownRequestError).meta?.target as string[])?.join(', ') || 'field';
37-
return res.status(409).json({
38-
error: 'Conflict Error',
39-
message: `Record with this ${target} already exists.`
40-
});
39+
return sendApiError(res, 409, 'CONFLICT', `Record with this ${target} already exists.`);
4140
}
4241

4342
// Record not found
4443
if ((err as Prisma.PrismaClientKnownRequestError).code === 'P2025') {
45-
return res.status(404).json({
46-
error: 'Not Found',
47-
message: (err as Prisma.PrismaClientKnownRequestError).message || 'The requested record was not found.'
48-
});
44+
return sendApiError(res, 404, 'NOT_FOUND', 'The requested record was not found.');
4945
}
5046
}
5147

5248
// Default Error
5349
const statusCode = (err instanceof Error && (err as any).status) || (err instanceof Error && (err as any).statusCode) || 500;
54-
const message = err instanceof Error ? err.message : 'Internal Server Error';
55-
56-
return res.status(statusCode).json({
57-
error: statusCode === 500 ? 'Internal Server Error' : 'Error',
58-
message: statusCode === 500 ? 'A technical error occurred. Please try again later.' : message
59-
});
50+
const message = statusCode === 500 ? 'A technical error occurred. Please try again later.' : (err instanceof Error ? err.message : 'Request failed');
51+
const code = statusCode === 500 ? 'INTERNAL_SERVER_ERROR' : 'REQUEST_ERROR';
52+
return sendApiError(res, statusCode, code, message);
6053
};

backend/src/routes/v1/streams/withdraw.ts

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { claimableAmountService } from '../../../services/claimable.service.js';
55
import { withdraw as sorobanWithdraw } from '../../../services/sorobanService.js';
66
import type { AuthenticatedRequest } from '../../../types/auth.types.js';
77
import { parseStreamId } from '../../../lib/stream-id.js';
8+
import { sendApiError } from '../../../types/api-error.js';
89

910
/**
1011
* @openapi
@@ -51,7 +52,7 @@ export const withdrawHandler = async (req: AuthenticatedRequest, res: Response)
5152
const parsedStreamId = parseStreamId(streamIdParam);
5253

5354
if (parsedStreamId === null) {
54-
return res.status(400).json({ error: 'Invalid streamId parameter' });
55+
return sendApiError(res, 400, 'INVALID_STREAM_ID', 'Invalid streamId parameter');
5556
}
5657

5758
const stream = await prisma.stream.findUnique({
@@ -74,24 +75,18 @@ export const withdrawHandler = async (req: AuthenticatedRequest, res: Response)
7475
});
7576

7677
if (!stream) {
77-
return res.status(404).json({ error: 'Stream not found' });
78+
return sendApiError(res, 404, 'NOT_FOUND', 'Stream not found');
7879
}
7980

8081
// Verify the caller is the stream recipient
8182
if (stream.recipient !== req.user.publicKey) {
82-
return res.status(403).json({
83-
error: 'Forbidden',
84-
message: 'Only the stream recipient can withdraw from the stream',
85-
});
83+
return sendApiError(res, 403, 'FORBIDDEN', 'Only the stream recipient can withdraw from the stream');
8684
}
8785

8886
const claimable = claimableAmountService.getClaimableAmount(stream);
8987

9088
if (!claimable.actionable) {
91-
return res.status(409).json({
92-
error: 'Conflict',
93-
message: 'No claimable balance is currently available',
94-
});
89+
return sendApiError(res, 409, 'CONFLICT', 'No claimable balance is currently available');
9590
}
9691

9792
try {
@@ -167,13 +162,10 @@ export const withdrawHandler = async (req: AuthenticatedRequest, res: Response)
167162
});
168163
} catch (sorobanError) {
169164
logger.error(`Soroban withdraw failed for stream ${parsedStreamId}:`, sorobanError);
170-
return res.status(400).json({
171-
error: 'Failed to withdraw from stream on chain',
172-
message: sorobanError instanceof Error ? sorobanError.message : 'Unknown error',
173-
});
165+
return sendApiError(res, 400, 'WITHDRAWAL_FAILED', 'Failed to withdraw from stream on chain');
174166
}
175167
} catch (error) {
176168
logger.error('Error withdrawing from stream:', error);
177-
return res.status(500).json({ error: 'Internal server error' });
169+
return sendApiError(res, 500, 'INTERNAL_SERVER_ERROR', 'A technical error occurred. Please try again later.');
178170
}
179171
};

backend/src/types/api-error.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { Response } from "express";
2+
3+
export interface ApiErrorBody {
4+
code: string;
5+
message: string;
6+
details?: unknown;
7+
}
8+
9+
export class ApiError extends Error {
10+
readonly statusCode: number;
11+
readonly code: string;
12+
readonly details?: unknown;
13+
14+
constructor(statusCode: number, code: string, message: string, details?: unknown) {
15+
super(message);
16+
this.name = "ApiError";
17+
this.statusCode = statusCode;
18+
this.code = code;
19+
this.details = details;
20+
}
21+
}
22+
23+
export function sendApiError(
24+
res: Response,
25+
statusCode: number,
26+
code: string,
27+
message: string,
28+
details?: unknown,
29+
) {
30+
const error: ApiErrorBody = { code, message };
31+
if (details !== undefined) error.details = details;
32+
return res.status(statusCode).json({ error });
33+
}

backend/swagger/flowfi.openapi.json

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -828,29 +828,28 @@
828828
"type": "object",
829829
"properties": {
830830
"error": {
831-
"type": "string",
832-
"description": "Error message",
833-
"example": "Resource not found"
834-
},
835-
"code": {
836-
"type": "string",
837-
"description": "Error code",
838-
"example": "NOT_FOUND"
839-
},
840-
"message": {
841-
"type": "string",
842-
"nullable": true,
843-
"description": "Human-readable detail (present on many error responses)"
844-
},
845-
"details": {
846-
"type": "array",
847-
"nullable": true,
848-
"description": "Structured validation issues (zod) when the error is a 400",
849-
"items": {
850-
"type": "object"
831+
"type": "object",
832+
"required": ["code", "message"],
833+
"properties": {
834+
"code": {
835+
"type": "string",
836+
"example": "NOT_FOUND"
837+
},
838+
"message": {
839+
"type": "string",
840+
"example": "Resource not found"
841+
},
842+
"details": {
843+
"type": "array",
844+
"description": "Structured validation issues when applicable",
845+
"items": {
846+
"type": "object"
847+
}
848+
}
851849
}
852850
}
853-
}
851+
},
852+
"required": ["error"]
854853
}
855854
}
856855
},

0 commit comments

Comments
 (0)