Skip to content

Commit 359b587

Browse files
authored
feat: Add Zod-based typing for API responses across frontend and backend (#1892)
## Summary Define API response Zod schemas in common-utils for alerts, webhooks, team, installation, and me endpoints. Apply Response<T> typing on backend route handlers with explicit Mongoose-to-JSON serialization (ObjectId, Date, Map). Replace all `any` types and `as Promise<T>` casts in frontend TanStack Query hooks with proper generics. ### How to test locally or on Vercel 1. `yarn dev` 2. Interact with app, ensuring nothing is broken ### References - Linear Issue: HDX-3464
1 parent 9682eb4 commit 359b587

12 files changed

Lines changed: 498 additions & 240 deletions

File tree

‎.changeset/slow-insects-travel.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@hyperdx/common-utils': patch
3+
'@hyperdx/api': patch
4+
'@hyperdx/app': patch
5+
---
6+
7+
fix: add explicit api typing to all api routes and frontend hooks

‎packages/api/src/routers/api/alerts.ts‎

Lines changed: 71 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import type { AlertsApiResponse } from '@hyperdx/common-utils/dist/types';
12
import express from 'express';
2-
import _ from 'lodash';
33
import { ObjectId } from 'mongodb';
44
import { z } from 'zod';
55
import { processRequest, validateRequest } from 'zod-express-middleware';
@@ -17,7 +17,8 @@ import { alertSchema, objectIdSchema } from '@/utils/zod';
1717

1818
const router = express.Router();
1919

20-
router.get('/', async (req, res, next) => {
20+
type AlertsExpRes = express.Response<AlertsApiResponse>;
21+
router.get('/', async (req, res: AlertsExpRes, next) => {
2122
try {
2223
const teamId = req.user?.team;
2324
if (teamId == null) {
@@ -26,58 +27,87 @@ router.get('/', async (req, res, next) => {
2627

2728
const alerts = await getAlertsEnhanced(teamId);
2829

29-
const data = await Promise.all(
30+
const data: AlertsApiResponse['data'] = await Promise.all(
3031
alerts.map(async alert => {
3132
const history = await getRecentAlertHistories({
3233
alertId: new ObjectId(alert._id),
3334
limit: 20,
3435
});
3536

3637
return {
37-
history,
38+
_id: alert._id.toString(),
39+
interval: alert.interval,
40+
scheduleOffsetMinutes: alert.scheduleOffsetMinutes,
41+
scheduleStartAt: alert.scheduleStartAt?.toISOString() ?? undefined,
42+
threshold: alert.threshold,
43+
thresholdType: alert.thresholdType,
44+
channel: { type: alert.channel.type ?? undefined },
45+
state: alert.state,
46+
source: alert.source,
47+
tileId: alert.tileId,
48+
name: alert.name,
49+
message: alert.message,
50+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
51+
createdAt: (alert as any).createdAt?.toISOString?.() ?? '',
52+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
53+
updatedAt: (alert as any).updatedAt?.toISOString?.() ?? '',
54+
history: history.map(h => ({
55+
counts: h.counts,
56+
createdAt: h.createdAt.toISOString(),
57+
state: h.state,
58+
lastValues: h.lastValues.map(lv => ({
59+
startTime: lv.startTime.toISOString(),
60+
count: lv.count,
61+
})),
62+
})),
3863
silenced: alert.silenced
3964
? {
40-
by: alert.silenced.by?.email,
41-
at: alert.silenced.at,
42-
until: alert.silenced.until,
65+
by: alert.silenced.by?.email ?? '',
66+
at: alert.silenced.at.toISOString(),
67+
until: alert.silenced.until.toISOString(),
4368
}
4469
: undefined,
4570
createdBy: alert.createdBy
46-
? _.pick(alert.createdBy, ['email', 'name'])
71+
? {
72+
email: alert.createdBy.email,
73+
name: alert.createdBy.name,
74+
}
75+
: undefined,
76+
dashboardId: alert.dashboard
77+
? alert.dashboard._id.toString()
78+
: undefined,
79+
savedSearchId: alert.savedSearch
80+
? alert.savedSearch._id.toString()
81+
: undefined,
82+
dashboard: alert.dashboard
83+
? {
84+
_id: alert.dashboard._id.toString(),
85+
name: alert.dashboard.name,
86+
updatedAt:
87+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
88+
(alert as any).dashboard?.updatedAt?.toISOString?.() ?? '',
89+
tags: alert.dashboard.tags,
90+
tiles: alert.dashboard.tiles
91+
.filter(tile => tile.id === alert.tileId)
92+
.map(tile => ({
93+
id: tile.id,
94+
config: { name: tile.config.name },
95+
})),
96+
}
97+
: undefined,
98+
savedSearch: alert.savedSearch
99+
? {
100+
_id: alert.savedSearch._id.toString(),
101+
createdAt:
102+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
103+
(alert as any).savedSearch?.createdAt?.toISOString?.() ?? '',
104+
name: alert.savedSearch.name,
105+
updatedAt:
106+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
107+
(alert as any).savedSearch?.updatedAt?.toISOString?.() ?? '',
108+
tags: alert.savedSearch.tags,
109+
}
47110
: undefined,
48-
channel: _.pick(alert.channel, ['type']),
49-
...(alert.dashboard && {
50-
dashboardId: alert.dashboard._id,
51-
dashboard: {
52-
tiles: alert.dashboard.tiles
53-
.filter(tile => tile.id === alert.tileId)
54-
.map(tile => _.pick(tile, ['id', 'config.name'])),
55-
..._.pick(alert.dashboard, ['_id', 'name', 'updatedAt', 'tags']),
56-
},
57-
}),
58-
...(alert.savedSearch && {
59-
savedSearchId: alert.savedSearch._id,
60-
savedSearch: _.pick(alert.savedSearch, [
61-
'_id',
62-
'createdAt',
63-
'name',
64-
'updatedAt',
65-
'tags',
66-
]),
67-
}),
68-
..._.pick(alert, [
69-
'_id',
70-
'interval',
71-
'scheduleOffsetMinutes',
72-
'scheduleStartAt',
73-
'threshold',
74-
'thresholdType',
75-
'state',
76-
'source',
77-
'tileId',
78-
'createdAt',
79-
'updatedAt',
80-
]),
81111
};
82112
}),
83113
);

‎packages/api/src/routers/api/me.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import {
2+
type MeApiResponse,
3+
MeApiResponseSchema,
4+
} from '@hyperdx/common-utils/dist/types';
15
import express from 'express';
26

37
import { AI_API_KEY, ANTHROPIC_API_KEY, USAGE_STATS_ENABLED } from '@/config';
@@ -6,7 +10,7 @@ import { Api404Error } from '@/utils/errors';
610

711
const router = express.Router();
812

9-
router.get('/', async (req, res, next) => {
13+
router.get('/', async (req, res: express.Response<MeApiResponse>, next) => {
1014
try {
1115
if (req.user == null) {
1216
throw new Api404Error('Request without user found');
@@ -22,14 +26,17 @@ router.get('/', async (req, res, next) => {
2226
} = req.user;
2327

2428
const team = await getTeam(teamId);
29+
if (team == null) {
30+
throw new Api404Error(`Team not found for user ${id}`);
31+
}
2532

2633
return res.json({
2734
accessKey,
28-
createdAt,
35+
createdAt: createdAt.toISOString(),
2936
email,
30-
id,
37+
id: id.toString(),
3138
name,
32-
team,
39+
team: MeApiResponseSchema.shape.team.parse(team.toJSON()),
3340
usageStatsEnabled: USAGE_STATS_ENABLED,
3441
aiAssistantEnabled: !!(AI_API_KEY || ANTHROPIC_API_KEY),
3542
});

‎packages/api/src/routers/api/root.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { InstallationApiResponse } from '@hyperdx/common-utils/dist/types';
12
import express from 'express';
23
import { serializeError } from 'serialize-error';
34
import { z } from 'zod';
@@ -53,7 +54,8 @@ router.get('/health', async (req, res) => {
5354
});
5455
});
5556

56-
router.get('/installation', async (req, res, next) => {
57+
type InstallationEspRes = express.Response<InstallationApiResponse>;
58+
router.get('/installation', async (_, res: InstallationEspRes, next) => {
5759
try {
5860
const _isTeamExisting = await isTeamExisting();
5961
return res.json({

‎packages/api/src/routers/api/team.ts‎

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
import type {
2+
RotateApiKeyApiResponse,
3+
TeamApiResponse,
4+
TeamInvitationsApiResponse,
5+
TeamMembersApiResponse,
6+
TeamTagsApiResponse,
7+
UpdateClickHouseSettingsApiResponse,
8+
} from '@hyperdx/common-utils/dist/types';
19
import { TeamClickHouseSettingsSchema } from '@hyperdx/common-utils/dist/types';
210
import crypto from 'crypto';
311
import express from 'express';
@@ -23,7 +31,8 @@ import { objectIdSchema } from '@/utils/zod';
2331

2432
const router = express.Router();
2533

26-
router.get('/', async (req, res, next) => {
34+
type TeamApiExpRes = express.Response<TeamApiResponse>;
35+
router.get('/', async (req, res: TeamApiExpRes, next) => {
2736
try {
2837
const teamId = req.user?.team;
2938
const userId = req.user?._id;
@@ -46,20 +55,37 @@ router.get('/', async (req, res, next) => {
4655
throw new Error(`Team ${teamId} not found for user ${userId}`);
4756
}
4857

49-
res.json(team.toJSON());
58+
// createdAt comes from Mongoose timestamps but is not on the ITeam interface
59+
const createdAt =
60+
'createdAt' in team && team.createdAt instanceof Date
61+
? team.createdAt.toISOString()
62+
: '';
63+
64+
res.json({
65+
_id: team._id.toString(),
66+
allowedAuthMethods:
67+
'allowedAuthMethods' in team ? team.allowedAuthMethods : undefined,
68+
apiKey: team.apiKey,
69+
name: team.name,
70+
createdAt,
71+
});
5072
} catch (e) {
5173
next(e);
5274
}
5375
});
5476

55-
router.patch('/apiKey', async (req, res, next) => {
77+
type RotateApiKeyExpRes = express.Response<RotateApiKeyApiResponse>;
78+
router.patch('/apiKey', async (req, res: RotateApiKeyExpRes, next) => {
5679
try {
5780
const teamId = req.user?.team;
5881
if (teamId == null) {
5982
throw new Error(`User ${req.user?._id} not associated with a team`);
6083
}
6184
const team = await rotateTeamApiKey(teamId);
62-
res.json({ newApiKey: team?.apiKey });
85+
if (team?.apiKey == null) {
86+
throw new Error(`Failed to rotate API key for team ${teamId}`);
87+
}
88+
res.json({ newApiKey: team.apiKey });
6389
} catch (e) {
6490
next(e);
6591
}
@@ -92,7 +118,11 @@ router.patch(
92118
processRequest({
93119
body: TeamClickHouseSettingsSchema,
94120
}),
95-
async (req, res, next) => {
121+
async (
122+
req,
123+
res: express.Response<UpdateClickHouseSettingsApiResponse>,
124+
next,
125+
) => {
96126
try {
97127
const teamId = req.user?.team;
98128
if (teamId == null) {
@@ -105,14 +135,7 @@ router.patch(
105135

106136
const team = await updateTeamClickhouseSettings(teamId, req.body);
107137

108-
res.json(
109-
Object.entries(req.body).reduce((acc, cur) => {
110-
return {
111-
...acc,
112-
[cur[0]]: team?.[cur[0]],
113-
};
114-
}, {} as any),
115-
);
138+
res.json(pick(team, Object.keys(req.body)));
116139
} catch (e) {
117140
next(e);
118141
}
@@ -176,7 +199,8 @@ router.post(
176199
},
177200
);
178201

179-
router.get('/invitations', async (req, res, next) => {
202+
type TeamInviteExpressRes = express.Response<TeamInvitationsApiResponse>;
203+
router.get('/invitations', async (req, res: TeamInviteExpressRes, next) => {
180204
try {
181205
const teamId = req.user?.team;
182206
if (teamId == null) {
@@ -193,8 +217,8 @@ router.get('/invitations', async (req, res, next) => {
193217
);
194218
res.json({
195219
data: teamInvites.map(ti => ({
196-
_id: ti._id,
197-
createdAt: ti.createdAt,
220+
_id: ti._id.toString(),
221+
createdAt: ti.createdAt.toISOString(),
198222
email: ti.email,
199223
name: ti.name,
200224
url: `${config.FRONTEND_URL}/join-team?token=${ti.token}`,
@@ -225,7 +249,8 @@ router.delete(
225249
},
226250
);
227251

228-
router.get('/members', async (req, res, next) => {
252+
type TeamMembersExpRes = express.Response<TeamMembersApiResponse>;
253+
router.get('/members', async (req, res: TeamMembersExpRes, next) => {
229254
try {
230255
const teamId = req.user?.team;
231256
const userId = req.user?._id;
@@ -281,7 +306,8 @@ router.delete(
281306
},
282307
);
283308

284-
router.get('/tags', async (req, res, next) => {
309+
type TeamTagsExpRes = express.Response<TeamTagsApiResponse>;
310+
router.get('/tags', async (req, res: TeamTagsExpRes, next) => {
285311
try {
286312
const teamId = req.user?.team;
287313
if (teamId == null) {

0 commit comments

Comments
 (0)