+24
-2
src/cache.ts
+24
-2
src/cache.ts
···
618
618
}
619
619
620
620
/**
621
+
* Get all emojis from the cache
622
+
* @returns Array of all non-expired emojis
623
+
*/
624
+
async getAllEmojis(): Promise<Emoji[]> {
625
+
const results = this.db
626
+
.query("SELECT * FROM emojis WHERE expiration > ?")
627
+
.all(Date.now()) as Emoji[];
628
+
629
+
return results.map(result => ({
630
+
type: "emoji",
631
+
id: result.id,
632
+
name: result.name,
633
+
alias: result.alias || null,
634
+
imageUrl: result.imageUrl,
635
+
expiration: new Date(result.expiration),
636
+
}));
637
+
}
638
+
639
+
/**
621
640
* Records a request for analytics
622
641
* @param endpoint The endpoint that was accessed
623
642
* @param method HTTP method
···
1564
1583
1565
1584
// Clean up old cache entries (keep only last 5)
1566
1585
if (this.analyticsCache.size > 5) {
1567
-
const oldestKey = Array.from(this.analyticsCache.keys())[0];
1568
-
this.analyticsCache.delete(oldestKey);
1586
+
const keys = Array.from(this.analyticsCache.keys());
1587
+
const oldestKey = keys[0];
1588
+
if (oldestKey) {
1589
+
this.analyticsCache.delete(oldestKey);
1590
+
}
1569
1591
}
1570
1592
1571
1593
return result;
+295
src/handlers/index.ts
+295
src/handlers/index.ts
···
1
+
/**
2
+
* All route handler functions extracted for reuse
3
+
*/
4
+
5
+
import * as Sentry from "@sentry/bun";
6
+
import type { SlackUser } from "../slack";
7
+
import type { RouteHandlerWithAnalytics } from "../lib/analytics-wrapper";
8
+
9
+
// These will be injected by the route system
10
+
let cache: any;
11
+
let slackApp: any;
12
+
13
+
export function injectDependencies(cacheInstance: any, slackInstance: any) {
14
+
cache = cacheInstance;
15
+
slackApp = slackInstance;
16
+
}
17
+
18
+
export const handleHealthCheck: RouteHandlerWithAnalytics = async (
19
+
request,
20
+
recordAnalytics,
21
+
) => {
22
+
const isHealthy = await cache.healthCheck();
23
+
if (isHealthy) {
24
+
await recordAnalytics(200);
25
+
return Response.json({
26
+
status: "healthy",
27
+
cache: true,
28
+
uptime: process.uptime(),
29
+
});
30
+
} else {
31
+
await recordAnalytics(503);
32
+
return Response.json(
33
+
{ status: "unhealthy", error: "Cache connection failed" },
34
+
{ status: 503 },
35
+
);
36
+
}
37
+
};
38
+
39
+
export const handleGetUser: RouteHandlerWithAnalytics = async (
40
+
request,
41
+
recordAnalytics,
42
+
) => {
43
+
const url = new URL(request.url);
44
+
const userId = url.pathname.split("/").pop() || "";
45
+
const user = await cache.getUser(userId);
46
+
47
+
if (!user || !user.imageUrl) {
48
+
let slackUser: SlackUser;
49
+
try {
50
+
slackUser = await slackApp.getUserInfo(userId);
51
+
} catch (e) {
52
+
if (e instanceof Error && e.message === "user_not_found") {
53
+
await recordAnalytics(404);
54
+
return Response.json({ message: "User not found" }, { status: 404 });
55
+
}
56
+
57
+
Sentry.withScope((scope) => {
58
+
scope.setExtra("url", request.url);
59
+
scope.setExtra("user", userId);
60
+
Sentry.captureException(e);
61
+
});
62
+
63
+
await recordAnalytics(500);
64
+
return Response.json(
65
+
{ message: "Internal server error" },
66
+
{ status: 500 },
67
+
);
68
+
}
69
+
70
+
await cache.insertUser(
71
+
slackUser.id,
72
+
slackUser.real_name || slackUser.name || "Unknown",
73
+
slackUser.profile?.pronouns || "",
74
+
slackUser.profile?.image_512 || slackUser.profile?.image_192 || "",
75
+
);
76
+
77
+
await recordAnalytics(200);
78
+
return Response.json({
79
+
id: slackUser.id,
80
+
userId: slackUser.id,
81
+
displayName: slackUser.real_name || slackUser.name || "Unknown",
82
+
pronouns: slackUser.profile?.pronouns || "",
83
+
imageUrl: slackUser.profile?.image_512 || slackUser.profile?.image_192 || "",
84
+
});
85
+
}
86
+
87
+
await recordAnalytics(200);
88
+
return Response.json(user);
89
+
};
90
+
91
+
export const handleUserRedirect: RouteHandlerWithAnalytics = async (
92
+
request,
93
+
recordAnalytics,
94
+
) => {
95
+
const url = new URL(request.url);
96
+
const parts = url.pathname.split("/");
97
+
const userId = parts[2] || "";
98
+
const user = await cache.getUser(userId);
99
+
100
+
if (!user || !user.imageUrl) {
101
+
let slackUser: SlackUser;
102
+
try {
103
+
slackUser = await slackApp.getUserInfo(userId.toUpperCase());
104
+
} catch (e) {
105
+
if (e instanceof Error && e.message === "user_not_found") {
106
+
console.warn(`⚠️ WARN user not found: ${userId}`);
107
+
108
+
await recordAnalytics(307);
109
+
return new Response(null, {
110
+
status: 307,
111
+
headers: {
112
+
Location: "https://ca.slack-edge.com/T0266FRGM-U0266FRGP-g28a1f281330-512",
113
+
},
114
+
});
115
+
}
116
+
117
+
Sentry.withScope((scope) => {
118
+
scope.setExtra("url", request.url);
119
+
scope.setExtra("user", userId);
120
+
Sentry.captureException(e);
121
+
});
122
+
123
+
await recordAnalytics(500);
124
+
return Response.json(
125
+
{ message: "Internal server error" },
126
+
{ status: 500 },
127
+
);
128
+
}
129
+
130
+
await cache.insertUser(
131
+
slackUser.id,
132
+
slackUser.real_name || slackUser.name || "Unknown",
133
+
slackUser.profile?.pronouns || "",
134
+
slackUser.profile?.image_512 || slackUser.profile?.image_192 || "",
135
+
);
136
+
137
+
await recordAnalytics(302);
138
+
return new Response(null, {
139
+
status: 302,
140
+
headers: {
141
+
Location: slackUser.profile?.image_512 || slackUser.profile?.image_192 || "",
142
+
},
143
+
});
144
+
}
145
+
146
+
await recordAnalytics(302);
147
+
return new Response(null, {
148
+
status: 302,
149
+
headers: { Location: user.imageUrl },
150
+
});
151
+
};
152
+
153
+
export const handlePurgeUser: RouteHandlerWithAnalytics = async (
154
+
request,
155
+
recordAnalytics,
156
+
) => {
157
+
const authHeader = request.headers.get("authorization") || "";
158
+
if (authHeader !== `Bearer ${process.env.BEARER_TOKEN}`) {
159
+
await recordAnalytics(401);
160
+
return new Response("Unauthorized", { status: 401 });
161
+
}
162
+
163
+
const url = new URL(request.url);
164
+
const userId = url.pathname.split("/")[2] || "";
165
+
const result = await cache.purgeUserCache(userId);
166
+
167
+
await recordAnalytics(200);
168
+
return Response.json({
169
+
message: "User cache purged",
170
+
userId,
171
+
success: result,
172
+
});
173
+
};
174
+
175
+
export const handleListEmojis: RouteHandlerWithAnalytics = async (
176
+
request,
177
+
recordAnalytics,
178
+
) => {
179
+
const emojis = await cache.getAllEmojis();
180
+
await recordAnalytics(200);
181
+
return Response.json(emojis);
182
+
};
183
+
184
+
export const handleGetEmoji: RouteHandlerWithAnalytics = async (
185
+
request,
186
+
recordAnalytics,
187
+
) => {
188
+
const url = new URL(request.url);
189
+
const emojiName = url.pathname.split("/").pop() || "";
190
+
const emoji = await cache.getEmoji(emojiName);
191
+
192
+
if (!emoji) {
193
+
await recordAnalytics(404);
194
+
return Response.json({ message: "Emoji not found" }, { status: 404 });
195
+
}
196
+
197
+
await recordAnalytics(200);
198
+
return Response.json(emoji);
199
+
};
200
+
201
+
export const handleEmojiRedirect: RouteHandlerWithAnalytics = async (
202
+
request,
203
+
recordAnalytics,
204
+
) => {
205
+
const url = new URL(request.url);
206
+
const parts = url.pathname.split("/");
207
+
const emojiName = parts[2] || "";
208
+
const emoji = await cache.getEmoji(emojiName);
209
+
210
+
if (!emoji) {
211
+
await recordAnalytics(404);
212
+
return Response.json({ message: "Emoji not found" }, { status: 404 });
213
+
}
214
+
215
+
await recordAnalytics(302);
216
+
return new Response(null, {
217
+
status: 302,
218
+
headers: { Location: emoji.imageUrl },
219
+
});
220
+
};
221
+
222
+
export const handleResetCache: RouteHandlerWithAnalytics = async (
223
+
request,
224
+
recordAnalytics,
225
+
) => {
226
+
const authHeader = request.headers.get("authorization") || "";
227
+
if (authHeader !== `Bearer ${process.env.BEARER_TOKEN}`) {
228
+
await recordAnalytics(401);
229
+
return new Response("Unauthorized", { status: 401 });
230
+
}
231
+
const result = await cache.purgeAll();
232
+
await recordAnalytics(200);
233
+
return Response.json(result);
234
+
};
235
+
236
+
export const handleGetEssentialStats: RouteHandlerWithAnalytics = async (
237
+
request,
238
+
recordAnalytics,
239
+
) => {
240
+
const url = new URL(request.url);
241
+
const params = new URLSearchParams(url.search);
242
+
const days = params.get("days") ? parseInt(params.get("days")!) : 7;
243
+
244
+
const stats = await cache.getEssentialStats(days);
245
+
await recordAnalytics(200);
246
+
return Response.json(stats);
247
+
};
248
+
249
+
export const handleGetChartData: RouteHandlerWithAnalytics = async (
250
+
request,
251
+
recordAnalytics,
252
+
) => {
253
+
const url = new URL(request.url);
254
+
const params = new URLSearchParams(url.search);
255
+
const days = params.get("days") ? parseInt(params.get("days")!) : 7;
256
+
257
+
const chartData = await cache.getChartData(days);
258
+
await recordAnalytics(200);
259
+
return Response.json(chartData);
260
+
};
261
+
262
+
export const handleGetUserAgents: RouteHandlerWithAnalytics = async (
263
+
request,
264
+
recordAnalytics,
265
+
) => {
266
+
const url = new URL(request.url);
267
+
const params = new URLSearchParams(url.search);
268
+
const days = params.get("days") ? parseInt(params.get("days")!) : 7;
269
+
270
+
const userAgents = await cache.getUserAgents(days);
271
+
await recordAnalytics(200);
272
+
return Response.json(userAgents);
273
+
};
274
+
275
+
export const handleGetStats: RouteHandlerWithAnalytics = async (
276
+
request,
277
+
recordAnalytics,
278
+
) => {
279
+
const url = new URL(request.url);
280
+
const params = new URLSearchParams(url.search);
281
+
const days = params.get("days") ? parseInt(params.get("days")!) : 7;
282
+
283
+
const [essentialStats, chartData, userAgents] = await Promise.all([
284
+
cache.getEssentialStats(days),
285
+
cache.getChartData(days),
286
+
cache.getUserAgents(days),
287
+
]);
288
+
289
+
await recordAnalytics(200);
290
+
return Response.json({
291
+
...essentialStats,
292
+
chartData,
293
+
userAgents,
294
+
});
295
+
};
+44
-765
src/index.ts
+44
-765
src/index.ts
···
3
3
import { SlackCache } from "./cache";
4
4
import { SlackWrapper } from "./slackWrapper";
5
5
import { getEmojiUrl } from "../utils/emojiHelper";
6
-
import type { SlackUser } from "./slack";
7
-
import swaggerSpec from "./swagger";
6
+
import { createApiRoutes } from "./routes/api-routes";
7
+
import { buildRoutes, getSwaggerSpec } from "./lib/route-builder";
8
8
import dashboard from "./dashboard.html";
9
9
import swagger from "./swagger.html";
10
10
···
15
15
environment: process.env.NODE_ENV,
16
16
dsn: process.env.SENTRY_DSN,
17
17
tracesSampleRate: 0.5,
18
-
ignoreErrors: [
19
-
// Ignore all 404-related errors
20
-
"Not Found",
21
-
"404",
22
-
"user_not_found",
23
-
"emoji_not_found",
24
-
],
18
+
ignoreErrors: ["Not Found", "404", "user_not_found", "emoji_not_found"],
25
19
});
26
20
} else {
27
21
console.warn("Sentry DSN not provided, error monitoring is disabled");
···
38
32
const emojiEntries = Object.entries(emojis)
39
33
.map(([name, url]) => {
40
34
if (typeof url === "string" && url.startsWith("alias:")) {
41
-
const aliasName = url.substring(6); // Remove 'alias:' prefix
35
+
const aliasName = url.substring(6);
42
36
const aliasUrl = emojis[aliasName] ?? getEmojiUrl(aliasName) ?? null;
43
37
44
38
if (aliasUrl === null) {
···
74
68
// Inject SlackWrapper into cache for background user updates
75
69
cache.setSlackWrapper(slackApp);
76
70
77
-
// Cache maintenance is now handled automatically by cache.ts scheduled tasks
78
-
79
-
// Start the server
80
-
const server = serve({
81
-
routes: {
82
-
// HTML routes
83
-
"/dashboard": dashboard,
84
-
"/swagger": swagger,
85
-
"/swagger.json": async (request) => {
86
-
return Response.json(swaggerSpec);
87
-
},
88
-
"/favicon.ico": async (request) => {
89
-
return new Response(Bun.file("./favicon.ico"));
90
-
},
91
-
92
-
// Root route - redirect to dashboard for browsers
93
-
"/": async (request) => {
94
-
const startTime = Date.now();
95
-
const recordAnalytics = async (statusCode: number) => {
96
-
const userAgent = request.headers.get("user-agent") || "";
97
-
const ipAddress =
98
-
request.headers.get("x-forwarded-for") ||
99
-
request.headers.get("x-real-ip") ||
100
-
"unknown";
101
-
102
-
await cache.recordRequest(
103
-
"/",
104
-
request.method,
105
-
statusCode,
106
-
userAgent,
107
-
ipAddress,
108
-
Date.now() - startTime,
109
-
);
110
-
};
111
-
112
-
const userAgent = request.headers.get("user-agent") || "";
113
-
if (
114
-
userAgent.toLowerCase().includes("mozilla") ||
115
-
userAgent.toLowerCase().includes("chrome") ||
116
-
userAgent.toLowerCase().includes("safari")
117
-
) {
118
-
recordAnalytics(302);
119
-
return new Response(null, {
120
-
status: 302,
121
-
headers: { Location: "/dashboard" },
122
-
});
123
-
}
71
+
// Create the typed API routes with injected dependencies
72
+
const apiRoutes = createApiRoutes(cache, slackApp);
124
73
125
-
recordAnalytics(200);
126
-
return new Response(
127
-
"Hello World from Cachet 😊\n\n---\nSee /swagger for docs\nSee /dashboard for analytics\n---",
128
-
);
129
-
},
74
+
// Build Bun-compatible routes and generate Swagger
75
+
const typedRoutes = buildRoutes(apiRoutes);
76
+
const generatedSwagger = getSwaggerSpec();
130
77
131
-
// Health check endpoint
132
-
"/health": {
133
-
async GET(request) {
134
-
const startTime = Date.now();
135
-
const recordAnalytics = async (statusCode: number) => {
136
-
const userAgent = request.headers.get("user-agent") || "";
137
-
const ipAddress =
138
-
request.headers.get("x-forwarded-for") ||
139
-
request.headers.get("x-real-ip") ||
140
-
"unknown";
141
-
142
-
await cache.recordRequest(
143
-
"/health",
144
-
"GET",
145
-
statusCode,
146
-
userAgent,
147
-
ipAddress,
148
-
Date.now() - startTime,
149
-
);
150
-
};
151
-
152
-
return handleHealthCheck(request, recordAnalytics);
153
-
},
154
-
},
155
-
156
-
// User endpoints
157
-
"/users/:id": {
158
-
async GET(request) {
159
-
const startTime = Date.now();
160
-
const recordAnalytics = async (statusCode: number) => {
161
-
const userAgent = request.headers.get("user-agent") || "";
162
-
const ipAddress =
163
-
request.headers.get("x-forwarded-for") ||
164
-
request.headers.get("x-real-ip") ||
165
-
"unknown";
166
-
167
-
await cache.recordRequest(
168
-
request.url,
169
-
"GET",
170
-
statusCode,
171
-
userAgent,
172
-
ipAddress,
173
-
Date.now() - startTime,
174
-
);
175
-
};
176
-
177
-
return handleGetUser(request, recordAnalytics);
178
-
},
179
-
},
180
-
181
-
"/users/:id/r": {
182
-
async GET(request) {
183
-
const startTime = Date.now();
184
-
const recordAnalytics = async (statusCode: number) => {
185
-
const userAgent = request.headers.get("user-agent") || "";
186
-
const ipAddress =
187
-
request.headers.get("x-forwarded-for") ||
188
-
request.headers.get("x-real-ip") ||
189
-
"unknown";
190
-
191
-
await cache.recordRequest(
192
-
request.url,
193
-
"GET",
194
-
statusCode,
195
-
userAgent,
196
-
ipAddress,
197
-
Date.now() - startTime,
198
-
);
199
-
};
200
-
201
-
return handleUserRedirect(request, recordAnalytics);
202
-
},
203
-
},
204
-
205
-
"/users/:id/purge": {
206
-
async POST(request) {
207
-
const startTime = Date.now();
208
-
const recordAnalytics = async (statusCode: number) => {
209
-
const userAgent = request.headers.get("user-agent") || "";
210
-
const ipAddress =
211
-
request.headers.get("x-forwarded-for") ||
212
-
request.headers.get("x-real-ip") ||
213
-
"unknown";
214
-
215
-
await cache.recordRequest(
216
-
request.url,
217
-
"POST",
218
-
statusCode,
219
-
userAgent,
220
-
ipAddress,
221
-
Date.now() - startTime,
222
-
);
223
-
};
224
-
225
-
return handlePurgeUser(request, recordAnalytics);
226
-
},
227
-
},
228
-
229
-
// Emoji endpoints
230
-
"/emojis": {
231
-
async GET(request) {
232
-
const startTime = Date.now();
233
-
const recordAnalytics = async (statusCode: number) => {
234
-
const userAgent = request.headers.get("user-agent") || "";
235
-
const ipAddress =
236
-
request.headers.get("x-forwarded-for") ||
237
-
request.headers.get("x-real-ip") ||
238
-
"unknown";
239
-
240
-
await cache.recordRequest(
241
-
"/emojis",
242
-
"GET",
243
-
statusCode,
244
-
userAgent,
245
-
ipAddress,
246
-
Date.now() - startTime,
247
-
);
248
-
};
249
-
250
-
return handleListEmojis(request, recordAnalytics);
251
-
},
252
-
},
253
-
254
-
"/emojis/:name": {
255
-
async GET(request) {
256
-
const startTime = Date.now();
257
-
const recordAnalytics = async (statusCode: number) => {
258
-
const userAgent = request.headers.get("user-agent") || "";
259
-
const ipAddress =
260
-
request.headers.get("x-forwarded-for") ||
261
-
request.headers.get("x-real-ip") ||
262
-
"unknown";
263
-
264
-
await cache.recordRequest(
265
-
request.url,
266
-
"GET",
267
-
statusCode,
268
-
userAgent,
269
-
ipAddress,
270
-
Date.now() - startTime,
271
-
);
272
-
};
273
-
274
-
return handleGetEmoji(request, recordAnalytics);
275
-
},
276
-
},
277
-
278
-
"/emojis/:name/r": {
279
-
async GET(request) {
280
-
const startTime = Date.now();
281
-
const recordAnalytics = async (statusCode: number) => {
282
-
const userAgent = request.headers.get("user-agent") || "";
283
-
const ipAddress =
284
-
request.headers.get("x-forwarded-for") ||
285
-
request.headers.get("x-real-ip") ||
286
-
"unknown";
287
-
288
-
await cache.recordRequest(
289
-
request.url,
290
-
"GET",
291
-
statusCode,
292
-
userAgent,
293
-
ipAddress,
294
-
Date.now() - startTime,
295
-
);
296
-
};
297
-
298
-
return handleEmojiRedirect(request, recordAnalytics);
299
-
},
300
-
},
301
-
302
-
// Reset cache endpoint
303
-
"/reset": {
304
-
async POST(request) {
305
-
const startTime = Date.now();
306
-
const recordAnalytics = async (statusCode: number) => {
307
-
const userAgent = request.headers.get("user-agent") || "";
308
-
const ipAddress =
309
-
request.headers.get("x-forwarded-for") ||
310
-
request.headers.get("x-real-ip") ||
311
-
"unknown";
312
-
313
-
await cache.recordRequest(
314
-
"/reset",
315
-
"POST",
316
-
statusCode,
317
-
userAgent,
318
-
ipAddress,
319
-
Date.now() - startTime,
320
-
);
321
-
};
322
-
323
-
return handleResetCache(request, recordAnalytics);
324
-
},
325
-
},
326
-
327
-
// Fast essential stats endpoint - loads immediately
328
-
"/api/stats/essential": {
329
-
async GET(request) {
330
-
const startTime = Date.now();
331
-
const recordAnalytics = async (statusCode: number) => {
332
-
const userAgent = request.headers.get("user-agent") || "";
333
-
const ipAddress =
334
-
request.headers.get("x-forwarded-for") ||
335
-
request.headers.get("x-real-ip") ||
336
-
"unknown";
337
-
338
-
await cache.recordRequest(
339
-
"/api/stats/essential",
340
-
"GET",
341
-
statusCode,
342
-
userAgent,
343
-
ipAddress,
344
-
Date.now() - startTime,
345
-
);
346
-
};
347
-
348
-
return handleGetEssentialStats(request, recordAnalytics);
349
-
},
350
-
},
351
-
352
-
// Chart data endpoint - loads after essential stats
353
-
"/api/stats/charts": {
354
-
async GET(request) {
355
-
const startTime = Date.now();
356
-
const recordAnalytics = async (statusCode: number) => {
357
-
const userAgent = request.headers.get("user-agent") || "";
358
-
const ipAddress =
359
-
request.headers.get("x-forwarded-for") ||
360
-
request.headers.get("x-real-ip") ||
361
-
"unknown";
362
-
363
-
await cache.recordRequest(
364
-
"/api/stats/charts",
365
-
"GET",
366
-
statusCode,
367
-
userAgent,
368
-
ipAddress,
369
-
Date.now() - startTime,
370
-
);
371
-
};
372
-
373
-
return handleGetChartData(request, recordAnalytics);
374
-
},
375
-
},
376
-
377
-
// User agents endpoint - loads last
378
-
"/api/stats/useragents": {
379
-
async GET(request) {
380
-
const startTime = Date.now();
381
-
const recordAnalytics = async (statusCode: number) => {
382
-
const userAgent = request.headers.get("user-agent") || "";
383
-
const ipAddress =
384
-
request.headers.get("x-forwarded-for") ||
385
-
request.headers.get("x-real-ip") ||
386
-
"unknown";
387
-
388
-
await cache.recordRequest(
389
-
"/api/stats/useragents",
390
-
"GET",
391
-
statusCode,
392
-
userAgent,
393
-
ipAddress,
394
-
Date.now() - startTime,
395
-
);
396
-
};
397
-
398
-
return handleGetUserAgents(request, recordAnalytics);
399
-
},
400
-
},
401
-
402
-
// Original stats endpoint (for backwards compatibility)
403
-
"/stats": {
404
-
async GET(request) {
405
-
const startTime = Date.now();
406
-
const recordAnalytics = async (statusCode: number) => {
407
-
const userAgent = request.headers.get("user-agent") || "";
408
-
const ipAddress =
409
-
request.headers.get("x-forwarded-for") ||
410
-
request.headers.get("x-real-ip") ||
411
-
"unknown";
412
-
413
-
await cache.recordRequest(
414
-
"/stats",
415
-
"GET",
416
-
statusCode,
417
-
userAgent,
418
-
ipAddress,
419
-
Date.now() - startTime,
420
-
);
421
-
};
422
-
423
-
return handleGetStats(request, recordAnalytics);
424
-
},
425
-
},
78
+
// Legacy routes (non-API)
79
+
const legacyRoutes = {
80
+
"/dashboard": dashboard,
81
+
"/swagger": swagger,
82
+
"/swagger.json": async (request: Request) => {
83
+
return Response.json(generatedSwagger);
426
84
},
427
-
428
-
// Enable development mode for hot reloading
429
-
development: {
430
-
hmr: true,
431
-
console: true,
85
+
"/favicon.ico": async (request: Request) => {
86
+
return new Response(Bun.file("./favicon.ico"));
432
87
},
433
88
434
-
// Fallback fetch handler for unmatched routes and error handling
435
-
async fetch(request) {
436
-
const url = new URL(request.url);
437
-
const path = url.pathname;
438
-
const method = request.method;
439
-
const startTime = Date.now();
89
+
// Root route - redirect to dashboard for browsers
90
+
"/": async (request: Request) => {
91
+
const userAgent = request.headers.get("user-agent") || "";
440
92
441
-
// Record request analytics (except for favicon and swagger)
442
-
const recordAnalytics = async (statusCode: number) => {
443
-
if (path !== "/favicon.ico" && !path.startsWith("/swagger")) {
444
-
const userAgent = request.headers.get("user-agent") || "";
445
-
const ipAddress =
446
-
request.headers.get("x-forwarded-for") ||
447
-
request.headers.get("x-real-ip") ||
448
-
"unknown";
449
-
450
-
await cache.recordRequest(
451
-
path,
452
-
method,
453
-
statusCode,
454
-
userAgent,
455
-
ipAddress,
456
-
Date.now() - startTime,
457
-
);
458
-
}
459
-
};
460
-
461
-
try {
462
-
// Not found
463
-
recordAnalytics(404);
464
-
return new Response("Not Found", { status: 404 });
465
-
} catch (error) {
466
-
console.error(
467
-
`\x1b[31m x\x1b[0m unhandled error: \x1b[31m${error instanceof Error ? error.message : String(error)}\x1b[0m`,
468
-
);
469
-
470
-
// Don't send 404 errors to Sentry
471
-
const is404 =
472
-
error instanceof Error &&
473
-
(error.message === "Not Found" ||
474
-
error.message === "user_not_found" ||
475
-
error.message === "emoji_not_found");
476
-
477
-
if (!is404 && error instanceof Error) {
478
-
Sentry.withScope((scope) => {
479
-
scope.setExtra("url", request.url);
480
-
Sentry.captureException(error);
481
-
});
482
-
}
483
-
484
-
recordAnalytics(500);
485
-
return new Response("Internal Server Error", { status: 500 });
486
-
}
487
-
},
488
-
489
-
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
490
-
});
491
-
492
-
console.log(
493
-
`\n---\n\n🐰 Bun server is running at ${server.url} on ${process.env.NODE_ENV}\n\n---\n`,
494
-
);
495
-
496
-
// Handler functions
497
-
async function handleHealthCheck(
498
-
request: Request,
499
-
recordAnalytics: (statusCode: number) => Promise<void>,
500
-
) {
501
-
const slackConnection = await slackApp.testAuth();
502
-
const databaseConnection = await cache.healthCheck();
503
-
504
-
if (!slackConnection || !databaseConnection) {
505
-
await recordAnalytics(500);
506
-
return Response.json(
507
-
{
508
-
http: false,
509
-
slack: slackConnection,
510
-
database: databaseConnection,
511
-
},
512
-
{ status: 500 },
513
-
);
514
-
}
515
-
516
-
await recordAnalytics(200);
517
-
return Response.json({
518
-
http: true,
519
-
slack: true,
520
-
database: true,
521
-
});
522
-
}
523
-
524
-
async function handleGetUser(
525
-
request: Request,
526
-
recordAnalytics: (statusCode: number) => Promise<void>,
527
-
) {
528
-
const url = new URL(request.url);
529
-
const userId = url.pathname.split("/").pop() || "";
530
-
const user = await cache.getUser(userId);
531
-
532
-
// If not found then check slack first
533
-
if (!user || !user.imageUrl) {
534
-
let slackUser: SlackUser;
535
-
try {
536
-
slackUser = await slackApp.getUserInfo(userId);
537
-
} catch (e) {
538
-
if (e instanceof Error && e.message === "user_not_found") {
539
-
await recordAnalytics(404);
540
-
return Response.json({ message: "User not found" }, { status: 404 });
541
-
}
542
-
543
-
Sentry.withScope((scope) => {
544
-
scope.setExtra("url", request.url);
545
-
scope.setExtra("user", userId);
546
-
Sentry.captureException(e);
93
+
if (
94
+
userAgent.toLowerCase().includes("mozilla") ||
95
+
userAgent.toLowerCase().includes("chrome") ||
96
+
userAgent.toLowerCase().includes("safari")
97
+
) {
98
+
return new Response(null, {
99
+
status: 302,
100
+
headers: { Location: "/dashboard" },
547
101
});
548
-
549
-
if (e instanceof Error)
550
-
console.warn(
551
-
`\x1b[38;5;214m ⚠️ WARN\x1b[0m error on fetching user from slack: \x1b[38;5;208m${e.message}\x1b[0m`,
552
-
);
553
-
554
-
await recordAnalytics(500);
555
-
return Response.json(
556
-
{ message: `Error fetching user from Slack: ${e}` },
557
-
{ status: 500 },
558
-
);
559
102
}
560
103
561
-
const displayName =
562
-
slackUser.profile.display_name_normalized ||
563
-
slackUser.profile.real_name_normalized;
564
-
565
-
await cache.insertUser(
566
-
slackUser.id,
567
-
displayName,
568
-
slackUser.profile.pronouns,
569
-
slackUser.profile.image_512,
104
+
return new Response(
105
+
"Hello World from Cachet 😊\n\n---\nSee /swagger for docs\nSee /dashboard for analytics\n---",
570
106
);
571
-
572
-
await recordAnalytics(200);
573
-
return Response.json({
574
-
id: slackUser.id,
575
-
expiration: new Date().toISOString(),
576
-
user: slackUser.id,
577
-
displayName: displayName,
578
-
pronouns: slackUser.profile.pronouns || null,
579
-
image: slackUser.profile.image_512,
580
-
});
581
-
}
582
-
583
-
await recordAnalytics(200);
584
-
return Response.json({
585
-
id: user.id,
586
-
expiration: user.expiration.toISOString(),
587
-
user: user.userId,
588
-
displayName: user.displayName,
589
-
pronouns: user.pronouns,
590
-
image: user.imageUrl,
591
-
});
592
-
}
593
-
594
-
async function handleUserRedirect(
595
-
request: Request,
596
-
recordAnalytics: (statusCode: number) => Promise<void>,
597
-
) {
598
-
const url = new URL(request.url);
599
-
const parts = url.pathname.split("/");
600
-
const userId = parts[2] || "";
601
-
const user = await cache.getUser(userId);
602
-
603
-
// If not found then check slack first
604
-
if (!user || !user.imageUrl) {
605
-
let slackUser: SlackUser;
606
-
try {
607
-
slackUser = await slackApp.getUserInfo(userId.toUpperCase());
608
-
} catch (e) {
609
-
if (e instanceof Error && e.message === "user_not_found") {
610
-
console.warn(
611
-
`\x1b[38;5;214m ⚠️ WARN\x1b[0m user not found: \x1b[38;5;208m${userId}\x1b[0m`,
612
-
);
613
-
614
-
await recordAnalytics(307);
615
-
return new Response(null, {
616
-
status: 307,
617
-
headers: {
618
-
Location:
619
-
"https://api.dicebear.com/9.x/thumbs/svg?seed={username_hash}",
620
-
},
621
-
});
622
-
}
623
-
624
-
Sentry.withScope((scope) => {
625
-
scope.setExtra("url", request.url);
626
-
scope.setExtra("user", userId);
627
-
Sentry.captureException(e);
628
-
});
629
-
630
-
if (e instanceof Error)
631
-
console.warn(
632
-
`\x1b[38;5;214m ⚠️ WARN\x1b[0m error on fetching user from slack: \x1b[38;5;208m${e.message}\x1b[0m`,
633
-
);
634
-
635
-
await recordAnalytics(500);
636
-
return Response.json(
637
-
{ message: `Error fetching user from Slack: ${e}` },
638
-
{ status: 500 },
639
-
);
640
-
}
641
-
642
-
await cache.insertUser(
643
-
slackUser.id,
644
-
slackUser.profile.display_name_normalized ||
645
-
slackUser.profile.real_name_normalized,
646
-
slackUser.profile.pronouns,
647
-
slackUser.profile.image_512,
648
-
);
649
-
650
-
await recordAnalytics(302);
651
-
return new Response(null, {
652
-
status: 302,
653
-
headers: { Location: slackUser.profile.image_512 },
654
-
});
655
-
}
656
-
657
-
await recordAnalytics(302);
658
-
return new Response(null, {
659
-
status: 302,
660
-
headers: { Location: user.imageUrl },
661
-
});
662
-
}
663
-
664
-
async function handleListEmojis(
665
-
request: Request,
666
-
recordAnalytics: (statusCode: number) => Promise<void>,
667
-
) {
668
-
const emojis = await cache.listEmojis();
669
-
670
-
await recordAnalytics(200);
671
-
return Response.json(
672
-
emojis.map((emoji) => ({
673
-
id: emoji.id,
674
-
expiration: emoji.expiration.toISOString(),
675
-
name: emoji.name,
676
-
...(emoji.alias ? { alias: emoji.alias } : {}),
677
-
image: emoji.imageUrl,
678
-
})),
679
-
);
680
-
}
681
-
682
-
async function handleGetEmoji(
683
-
request: Request,
684
-
recordAnalytics: (statusCode: number) => Promise<void>,
685
-
) {
686
-
const url = new URL(request.url);
687
-
const emojiName = url.pathname.split("/").pop() || "";
688
-
const emoji = await cache.getEmoji(emojiName);
689
-
690
-
if (!emoji) {
691
-
const fallbackUrl = getEmojiUrl(emojiName);
692
-
if (!fallbackUrl) {
693
-
await recordAnalytics(404);
694
-
return Response.json({ message: "Emoji not found" }, { status: 404 });
695
-
}
696
-
697
-
await recordAnalytics(200);
698
-
return Response.json({
699
-
id: null,
700
-
expiration: new Date().toISOString(),
701
-
name: emojiName,
702
-
image: fallbackUrl,
703
-
});
704
-
}
705
-
706
-
await recordAnalytics(200);
707
-
return Response.json({
708
-
id: emoji.id,
709
-
expiration: emoji.expiration.toISOString(),
710
-
name: emoji.name,
711
-
...(emoji.alias ? { alias: emoji.alias } : {}),
712
-
image: emoji.imageUrl,
713
-
});
714
-
}
715
-
716
-
async function handleEmojiRedirect(
717
-
request: Request,
718
-
recordAnalytics: (statusCode: number) => Promise<void>,
719
-
) {
720
-
const url = new URL(request.url);
721
-
const parts = url.pathname.split("/");
722
-
const emojiName = parts[2] || "";
723
-
const emoji = await cache.getEmoji(emojiName);
724
-
725
-
if (!emoji) {
726
-
const fallbackUrl = getEmojiUrl(emojiName);
727
-
if (!fallbackUrl) {
728
-
await recordAnalytics(404);
729
-
return Response.json({ message: "Emoji not found" }, { status: 404 });
730
-
}
731
-
732
-
await recordAnalytics(302);
733
-
return new Response(null, {
734
-
status: 302,
735
-
headers: { Location: fallbackUrl },
736
-
});
737
-
}
738
-
739
-
await recordAnalytics(302);
740
-
return new Response(null, {
741
-
status: 302,
742
-
headers: { Location: emoji.imageUrl },
743
-
});
744
-
}
745
-
746
-
async function handleResetCache(
747
-
request: Request,
748
-
recordAnalytics: (statusCode: number) => Promise<void>,
749
-
) {
750
-
const authHeader = request.headers.get("authorization") || "";
751
-
752
-
if (authHeader !== `Bearer ${process.env.BEARER_TOKEN}`) {
753
-
await recordAnalytics(401);
754
-
return new Response("Unauthorized", { status: 401 });
755
-
}
107
+
},
108
+
};
756
109
757
-
const result = await cache.purgeAll();
758
-
await recordAnalytics(200);
759
-
return Response.json(result);
760
-
}
110
+
// Merge all routes
111
+
const allRoutes = {
112
+
...legacyRoutes,
113
+
...typedRoutes,
114
+
};
761
115
762
-
async function handlePurgeUser(
763
-
request: Request,
764
-
recordAnalytics: (statusCode: number) => Promise<void>,
765
-
) {
766
-
const authHeader = request.headers.get("authorization") || "";
116
+
// Start the server
117
+
const server = serve({
118
+
routes: allRoutes,
119
+
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
120
+
});
767
121
768
-
if (authHeader !== `Bearer ${process.env.BEARER_TOKEN}`) {
769
-
await recordAnalytics(401);
770
-
return new Response("Unauthorized", { status: 401 });
771
-
}
122
+
console.log(`🚀 Server running on http://localhost:${server.port}`);
772
123
773
-
const url = new URL(request.url);
774
-
const parts = url.pathname.split("/");
775
-
const userId = parts[2] || "";
776
-
const success = await cache.purgeUserCache(userId);
777
-
778
-
await recordAnalytics(200);
779
-
return Response.json({
780
-
message: success ? "User cache purged" : "User not found in cache",
781
-
userId: userId,
782
-
success,
783
-
});
784
-
}
785
-
786
-
async function handleGetStats(
787
-
request: Request,
788
-
recordAnalytics: (statusCode: number) => Promise<void>,
789
-
) {
790
-
const url = new URL(request.url);
791
-
const params = new URLSearchParams(url.search);
792
-
const days = params.get("days") ? parseInt(params.get("days")!) : 7;
793
-
const analytics = await cache.getAnalytics(days);
794
-
795
-
await recordAnalytics(200);
796
-
return Response.json(analytics);
797
-
}
798
-
799
-
// Fast essential stats - just the 3 key metrics
800
-
async function handleGetEssentialStats(
801
-
request: Request,
802
-
recordAnalytics: (statusCode: number) => Promise<void>,
803
-
) {
804
-
const url = new URL(request.url);
805
-
const params = new URLSearchParams(url.search);
806
-
const days = params.get("days") ? parseInt(params.get("days")!) : 7;
807
-
808
-
const essentialStats = await cache.getEssentialStats(days);
809
-
810
-
await recordAnalytics(200);
811
-
return Response.json(essentialStats);
812
-
}
813
-
814
-
// Chart data - requests and latency over time
815
-
async function handleGetChartData(
816
-
request: Request,
817
-
recordAnalytics: (statusCode: number) => Promise<void>,
818
-
) {
819
-
const url = new URL(request.url);
820
-
const params = new URLSearchParams(url.search);
821
-
const days = params.get("days") ? parseInt(params.get("days")!) : 7;
822
-
823
-
const chartData = await cache.getChartData(days);
824
-
825
-
await recordAnalytics(200);
826
-
return Response.json(chartData);
827
-
}
828
-
829
-
// User agents data - slowest loading part
830
-
async function handleGetUserAgents(
831
-
request: Request,
832
-
recordAnalytics: (statusCode: number) => Promise<void>,
833
-
) {
834
-
const url = new URL(request.url);
835
-
const params = new URLSearchParams(url.search);
836
-
const days = params.get("days") ? parseInt(params.get("days")!) : 7;
837
-
838
-
const userAgents = await cache.getUserAgents(days);
839
-
840
-
await recordAnalytics(200);
841
-
return Response.json(userAgents);
842
-
}
843
-
844
-
// Cache maintenance is now handled by scheduled tasks in cache.ts
845
-
// No aggressive daily purge needed - users will lazy load with longer TTL
124
+
export { cache, slackApp };
+56
src/lib/analytics-wrapper.ts
+56
src/lib/analytics-wrapper.ts
···
1
+
/**
2
+
* Analytics wrapper utility to eliminate boilerplate in route handlers
3
+
*/
4
+
5
+
// Cache will be injected by the route system
6
+
7
+
export type AnalyticsRecorder = (statusCode: number) => Promise<void>;
8
+
export type RouteHandlerWithAnalytics = (request: Request, recordAnalytics: AnalyticsRecorder) => Promise<Response> | Response;
9
+
10
+
/**
11
+
* Creates analytics wrapper with injected cache
12
+
*/
13
+
export function createAnalyticsWrapper(cache: any) {
14
+
return function withAnalytics(
15
+
path: string,
16
+
method: string,
17
+
handler: RouteHandlerWithAnalytics
18
+
) {
19
+
return async (request: Request): Promise<Response> => {
20
+
const startTime = Date.now();
21
+
22
+
const recordAnalytics: AnalyticsRecorder = async (statusCode: number) => {
23
+
const userAgent = request.headers.get("user-agent") || "";
24
+
const ipAddress =
25
+
request.headers.get("x-forwarded-for") ||
26
+
request.headers.get("x-real-ip") ||
27
+
"unknown";
28
+
29
+
// Use the actual request URL for dynamic paths, fallback to provided path
30
+
const analyticsPath = path.includes(":") ? request.url : path;
31
+
32
+
await cache.recordRequest(
33
+
analyticsPath,
34
+
method,
35
+
statusCode,
36
+
userAgent,
37
+
ipAddress,
38
+
Date.now() - startTime,
39
+
);
40
+
};
41
+
42
+
return handler(request, recordAnalytics);
43
+
};
44
+
};
45
+
}
46
+
47
+
/**
48
+
* Type-safe analytics wrapper that automatically infers path and method
49
+
*/
50
+
export function createAnalyticsHandler(
51
+
path: string,
52
+
method: string
53
+
) {
54
+
return (handler: RouteHandlerWithAnalytics) =>
55
+
withAnalytics(path, method, handler);
56
+
}
+56
src/lib/route-builder.ts
+56
src/lib/route-builder.ts
···
1
+
/**
2
+
* Utility to build Bun-compatible routes from typed route definitions
3
+
* and generate Swagger documentation
4
+
*/
5
+
6
+
import type { RouteDefinition } from "../types/routes";
7
+
import { swaggerGenerator } from "./swagger-generator";
8
+
9
+
/**
10
+
* Convert typed routes to Bun server format and generate Swagger
11
+
*/
12
+
export function buildRoutes(typedRoutes: Record<string, RouteDefinition>) {
13
+
// Generate Swagger from typed routes
14
+
swaggerGenerator.addRoutes(typedRoutes);
15
+
16
+
// Convert to Bun server format
17
+
const bunRoutes: Record<string, any> = {};
18
+
19
+
Object.entries(typedRoutes).forEach(([path, routeConfig]) => {
20
+
const bunRoute: Record<string, any> = {};
21
+
22
+
// Convert each HTTP method
23
+
Object.entries(routeConfig).forEach(([method, typedRoute]) => {
24
+
if (typedRoute && 'handler' in typedRoute) {
25
+
bunRoute[method] = typedRoute.handler;
26
+
}
27
+
});
28
+
29
+
bunRoutes[path] = bunRoute;
30
+
});
31
+
32
+
return bunRoutes;
33
+
}
34
+
35
+
/**
36
+
* Get the generated Swagger specification
37
+
*/
38
+
export function getSwaggerSpec() {
39
+
return swaggerGenerator.getSpec();
40
+
}
41
+
42
+
/**
43
+
* Merge typed routes with existing legacy routes
44
+
* This allows gradual migration
45
+
*/
46
+
export function mergeRoutes(
47
+
typedRoutes: Record<string, RouteDefinition>,
48
+
legacyRoutes: Record<string, any>
49
+
) {
50
+
const builtRoutes = buildRoutes(typedRoutes);
51
+
52
+
return {
53
+
...legacyRoutes,
54
+
...builtRoutes,
55
+
};
56
+
}
+203
src/lib/swagger-generator.ts
+203
src/lib/swagger-generator.ts
···
1
+
/**
2
+
* Generates Swagger/OpenAPI specifications from typed route definitions
3
+
*/
4
+
5
+
import { version } from "../../package.json";
6
+
import type { RouteDefinition, RouteMetadata, RouteParam, HttpMethod } from "../types/routes";
7
+
8
+
interface SwaggerSpec {
9
+
openapi: string;
10
+
info: {
11
+
title: string;
12
+
version: string;
13
+
description: string;
14
+
contact: {
15
+
name: string;
16
+
email: string;
17
+
};
18
+
license: {
19
+
name: string;
20
+
url: string;
21
+
};
22
+
};
23
+
paths: Record<string, any>;
24
+
components?: {
25
+
securitySchemes?: any;
26
+
};
27
+
}
28
+
29
+
export class SwaggerGenerator {
30
+
private spec: SwaggerSpec;
31
+
32
+
constructor() {
33
+
this.spec = {
34
+
openapi: "3.0.0",
35
+
info: {
36
+
title: "Cachet",
37
+
version: version,
38
+
description: "A high-performance cache and proxy for Slack profile pictures and emojis with comprehensive analytics.",
39
+
contact: {
40
+
name: "Kieran Klukas",
41
+
email: "me@dunkirk.sh",
42
+
},
43
+
license: {
44
+
name: "AGPL 3.0",
45
+
url: "https://github.com/taciturnaxolotl/cachet/blob/main/LICENSE.md",
46
+
},
47
+
},
48
+
paths: {},
49
+
components: {
50
+
securitySchemes: {
51
+
bearerAuth: {
52
+
type: "http",
53
+
scheme: "bearer",
54
+
},
55
+
},
56
+
},
57
+
};
58
+
}
59
+
60
+
/**
61
+
* Add routes to the Swagger specification
62
+
*/
63
+
addRoutes(routes: Record<string, RouteDefinition | any>) {
64
+
Object.entries(routes).forEach(([path, routeConfig]) => {
65
+
// Skip non-API routes
66
+
if (typeof routeConfig === 'function' ||
67
+
path.includes('dashboard') ||
68
+
path.includes('swagger') ||
69
+
path.includes('favicon')) {
70
+
return;
71
+
}
72
+
73
+
this.addRoute(path, routeConfig);
74
+
});
75
+
}
76
+
77
+
/**
78
+
* Add a single route to the specification
79
+
*/
80
+
private addRoute(path: string, routeConfig: RouteDefinition) {
81
+
const swaggerPath = this.convertPathToSwagger(path);
82
+
83
+
if (!this.spec.paths[swaggerPath]) {
84
+
this.spec.paths[swaggerPath] = {};
85
+
}
86
+
87
+
// Process each HTTP method
88
+
Object.entries(routeConfig).forEach(([method, typedRoute]) => {
89
+
if (typeof typedRoute === 'object' && 'handler' in typedRoute && 'metadata' in typedRoute) {
90
+
const swaggerMethod = method.toLowerCase();
91
+
this.spec.paths[swaggerPath][swaggerMethod] = this.buildMethodSpec(
92
+
method as HttpMethod,
93
+
typedRoute.metadata
94
+
);
95
+
}
96
+
});
97
+
}
98
+
99
+
/**
100
+
* Convert Express-style path to Swagger format
101
+
* /users/:id -> /users/{id}
102
+
*/
103
+
private convertPathToSwagger(path: string): string {
104
+
return path.replace(/:([^/]+)/g, '{$1}');
105
+
}
106
+
107
+
/**
108
+
* Build Swagger specification for a single method
109
+
*/
110
+
private buildMethodSpec(method: HttpMethod, metadata: RouteMetadata) {
111
+
const spec: any = {
112
+
summary: metadata.summary,
113
+
description: metadata.description,
114
+
tags: metadata.tags || ['API'],
115
+
responses: {},
116
+
};
117
+
118
+
// Add parameters
119
+
if (metadata.parameters) {
120
+
spec.parameters = [];
121
+
122
+
// Path parameters
123
+
if (metadata.parameters.path) {
124
+
metadata.parameters.path.forEach(param => {
125
+
spec.parameters.push(this.buildParameterSpec(param, 'path'));
126
+
});
127
+
}
128
+
129
+
// Query parameters
130
+
if (metadata.parameters.query) {
131
+
metadata.parameters.query.forEach(param => {
132
+
spec.parameters.push(this.buildParameterSpec(param, 'query'));
133
+
});
134
+
}
135
+
136
+
// Request body
137
+
if (metadata.parameters.body && ['POST', 'PUT', 'PATCH'].includes(method)) {
138
+
spec.requestBody = {
139
+
required: true,
140
+
content: {
141
+
'application/json': {
142
+
schema: metadata.parameters.body,
143
+
},
144
+
},
145
+
};
146
+
}
147
+
}
148
+
149
+
// Add responses
150
+
Object.entries(metadata.responses).forEach(([status, response]) => {
151
+
spec.responses[status] = {
152
+
description: response.description,
153
+
...(response.schema && {
154
+
content: {
155
+
'application/json': {
156
+
schema: response.schema,
157
+
},
158
+
},
159
+
}),
160
+
};
161
+
});
162
+
163
+
// Add security if required
164
+
if (metadata.requiresAuth) {
165
+
spec.security = [{ bearerAuth: [] }];
166
+
}
167
+
168
+
return spec;
169
+
}
170
+
171
+
/**
172
+
* Build parameter specification
173
+
*/
174
+
private buildParameterSpec(param: RouteParam, location: 'path' | 'query') {
175
+
return {
176
+
name: param.name,
177
+
in: location,
178
+
required: param.required,
179
+
description: param.description,
180
+
schema: {
181
+
type: param.type,
182
+
...(param.example && { example: param.example }),
183
+
},
184
+
};
185
+
}
186
+
187
+
/**
188
+
* Get the complete Swagger specification
189
+
*/
190
+
getSpec(): SwaggerSpec {
191
+
return this.spec;
192
+
}
193
+
194
+
/**
195
+
* Generate JSON string of the specification
196
+
*/
197
+
toJSON(): string {
198
+
return JSON.stringify(this.spec, null, 2);
199
+
}
200
+
}
201
+
202
+
// Export singleton instance
203
+
export const swaggerGenerator = new SwaggerGenerator();
+310
src/routes/api-routes.ts
+310
src/routes/api-routes.ts
···
1
+
/**
2
+
* Complete typed route definitions for all Cachet API endpoints
3
+
*/
4
+
5
+
import {
6
+
createRoute,
7
+
pathParam,
8
+
queryParam,
9
+
apiResponse,
10
+
type RouteDefinition
11
+
} from "../types/routes";
12
+
import { createAnalyticsWrapper } from "../lib/analytics-wrapper";
13
+
import * as handlers from "../handlers";
14
+
15
+
// Factory function to create all routes with injected dependencies
16
+
export function createApiRoutes(cache: any, slackApp: any) {
17
+
// Inject dependencies into handlers
18
+
handlers.injectDependencies(cache, slackApp);
19
+
20
+
const withAnalytics = createAnalyticsWrapper(cache);
21
+
22
+
return {
23
+
"/health": {
24
+
GET: createRoute(
25
+
withAnalytics("/health", "GET", handlers.handleHealthCheck),
26
+
{
27
+
summary: "Health check",
28
+
description: "Check if the service is healthy and operational",
29
+
tags: ["Health"],
30
+
responses: Object.fromEntries([
31
+
apiResponse(200, "Service is healthy", {
32
+
type: "object",
33
+
properties: {
34
+
status: { type: "string", example: "healthy" },
35
+
cache: { type: "boolean", example: true },
36
+
uptime: { type: "number", example: 123456 }
37
+
}
38
+
}),
39
+
apiResponse(503, "Service is unhealthy")
40
+
])
41
+
}
42
+
)
43
+
},
44
+
45
+
"/users/:id": {
46
+
GET: createRoute(
47
+
withAnalytics("/users/:id", "GET", handlers.handleGetUser),
48
+
{
49
+
summary: "Get user information",
50
+
description: "Retrieve cached user profile information from Slack",
51
+
tags: ["Users"],
52
+
parameters: {
53
+
path: [pathParam("id", "string", "Slack user ID", "U062UG485EE")]
54
+
},
55
+
responses: Object.fromEntries([
56
+
apiResponse(200, "User information retrieved successfully", {
57
+
type: "object",
58
+
properties: {
59
+
id: { type: "string", example: "U062UG485EE" },
60
+
userId: { type: "string", example: "U062UG485EE" },
61
+
displayName: { type: "string", example: "Kieran Klukas" },
62
+
pronouns: { type: "string", example: "he/him" },
63
+
imageUrl: { type: "string", example: "https://avatars.slack-edge.com/..." }
64
+
}
65
+
}),
66
+
apiResponse(404, "User not found")
67
+
])
68
+
}
69
+
)
70
+
},
71
+
72
+
"/users/:id/r": {
73
+
GET: createRoute(
74
+
withAnalytics("/users/:id/r", "GET", handlers.handleUserRedirect),
75
+
{
76
+
summary: "Redirect to user profile image",
77
+
description: "Direct redirect to the user's cached profile image URL",
78
+
tags: ["Users"],
79
+
parameters: {
80
+
path: [pathParam("id", "string", "Slack user ID", "U062UG485EE")]
81
+
},
82
+
responses: Object.fromEntries([
83
+
apiResponse(302, "Redirect to user image"),
84
+
apiResponse(307, "Temporary redirect to default avatar"),
85
+
apiResponse(404, "User not found")
86
+
])
87
+
}
88
+
)
89
+
},
90
+
91
+
"/users/:id/purge": {
92
+
POST: createRoute(
93
+
withAnalytics("/users/:id/purge", "POST", handlers.handlePurgeUser),
94
+
{
95
+
summary: "Purge user cache",
96
+
description: "Remove a specific user from the cache (requires authentication)",
97
+
tags: ["Users", "Admin"],
98
+
requiresAuth: true,
99
+
parameters: {
100
+
path: [pathParam("id", "string", "Slack user ID to purge", "U062UG485EE")]
101
+
},
102
+
responses: Object.fromEntries([
103
+
apiResponse(200, "User cache purged successfully", {
104
+
type: "object",
105
+
properties: {
106
+
message: { type: "string", example: "User cache purged" },
107
+
userId: { type: "string", example: "U062UG485EE" },
108
+
success: { type: "boolean", example: true }
109
+
}
110
+
}),
111
+
apiResponse(401, "Unauthorized")
112
+
])
113
+
}
114
+
)
115
+
},
116
+
117
+
"/emojis": {
118
+
GET: createRoute(
119
+
withAnalytics("/emojis", "GET", handlers.handleListEmojis),
120
+
{
121
+
summary: "List all emojis",
122
+
description: "Get a list of all cached custom emojis from the Slack workspace",
123
+
tags: ["Emojis"],
124
+
responses: Object.fromEntries([
125
+
apiResponse(200, "List of emojis retrieved successfully", {
126
+
type: "array",
127
+
items: {
128
+
type: "object",
129
+
properties: {
130
+
name: { type: "string", example: "hackshark" },
131
+
imageUrl: { type: "string", example: "https://emoji.slack-edge.com/..." },
132
+
alias: { type: "string", nullable: true, example: null }
133
+
}
134
+
}
135
+
})
136
+
])
137
+
}
138
+
)
139
+
},
140
+
141
+
"/emojis/:name": {
142
+
GET: createRoute(
143
+
withAnalytics("/emojis/:name", "GET", handlers.handleGetEmoji),
144
+
{
145
+
summary: "Get emoji information",
146
+
description: "Retrieve information about a specific custom emoji",
147
+
tags: ["Emojis"],
148
+
parameters: {
149
+
path: [pathParam("name", "string", "Emoji name (without colons)", "hackshark")]
150
+
},
151
+
responses: Object.fromEntries([
152
+
apiResponse(200, "Emoji information retrieved successfully", {
153
+
type: "object",
154
+
properties: {
155
+
name: { type: "string", example: "hackshark" },
156
+
imageUrl: { type: "string", example: "https://emoji.slack-edge.com/..." },
157
+
alias: { type: "string", nullable: true, example: null }
158
+
}
159
+
}),
160
+
apiResponse(404, "Emoji not found")
161
+
])
162
+
}
163
+
)
164
+
},
165
+
166
+
"/emojis/:name/r": {
167
+
GET: createRoute(
168
+
withAnalytics("/emojis/:name/r", "GET", handlers.handleEmojiRedirect),
169
+
{
170
+
summary: "Redirect to emoji image",
171
+
description: "Direct redirect to the emoji's cached image URL",
172
+
tags: ["Emojis"],
173
+
parameters: {
174
+
path: [pathParam("name", "string", "Emoji name (without colons)", "hackshark")]
175
+
},
176
+
responses: Object.fromEntries([
177
+
apiResponse(302, "Redirect to emoji image"),
178
+
apiResponse(404, "Emoji not found")
179
+
])
180
+
}
181
+
)
182
+
},
183
+
184
+
"/reset": {
185
+
POST: createRoute(
186
+
withAnalytics("/reset", "POST", handlers.handleResetCache),
187
+
{
188
+
summary: "Reset entire cache",
189
+
description: "Clear all cached data (requires authentication)",
190
+
tags: ["Admin"],
191
+
requiresAuth: true,
192
+
responses: Object.fromEntries([
193
+
apiResponse(200, "Cache reset successfully", {
194
+
type: "object",
195
+
properties: {
196
+
message: { type: "string", example: "Cache has been reset" },
197
+
users: { type: "number", example: 42 },
198
+
emojis: { type: "number", example: 1337 }
199
+
}
200
+
}),
201
+
apiResponse(401, "Unauthorized")
202
+
])
203
+
}
204
+
)
205
+
},
206
+
207
+
"/api/stats/essential": {
208
+
GET: createRoute(
209
+
withAnalytics("/api/stats/essential", "GET", handlers.handleGetEssentialStats),
210
+
{
211
+
summary: "Get essential analytics",
212
+
description: "Fast-loading essential statistics for the dashboard",
213
+
tags: ["Analytics"],
214
+
parameters: {
215
+
query: [queryParam("days", "number", "Number of days to analyze", false, 7)]
216
+
},
217
+
responses: Object.fromEntries([
218
+
apiResponse(200, "Essential stats retrieved successfully", {
219
+
type: "object",
220
+
properties: {
221
+
totalRequests: { type: "number", example: 12345 },
222
+
averageResponseTime: { type: "number", example: 23.5 },
223
+
uptime: { type: "number", example: 99.9 },
224
+
period: { type: "string", example: "7 days" }
225
+
}
226
+
})
227
+
])
228
+
}
229
+
)
230
+
},
231
+
232
+
"/api/stats/charts": {
233
+
GET: createRoute(
234
+
withAnalytics("/api/stats/charts", "GET", handlers.handleGetChartData),
235
+
{
236
+
summary: "Get chart data",
237
+
description: "Time-series data for request and latency charts",
238
+
tags: ["Analytics"],
239
+
parameters: {
240
+
query: [queryParam("days", "number", "Number of days to analyze", false, 7)]
241
+
},
242
+
responses: Object.fromEntries([
243
+
apiResponse(200, "Chart data retrieved successfully", {
244
+
type: "array",
245
+
items: {
246
+
type: "object",
247
+
properties: {
248
+
time: { type: "string", example: "2024-01-01T12:00:00Z" },
249
+
count: { type: "number", example: 42 },
250
+
averageResponseTime: { type: "number", example: 25.3 }
251
+
}
252
+
}
253
+
})
254
+
])
255
+
}
256
+
)
257
+
},
258
+
259
+
"/api/stats/useragents": {
260
+
GET: createRoute(
261
+
withAnalytics("/api/stats/useragents", "GET", handlers.handleGetUserAgents),
262
+
{
263
+
summary: "Get user agents statistics",
264
+
description: "List of user agents accessing the service with request counts",
265
+
tags: ["Analytics"],
266
+
parameters: {
267
+
query: [queryParam("days", "number", "Number of days to analyze", false, 7)]
268
+
},
269
+
responses: Object.fromEntries([
270
+
apiResponse(200, "User agents data retrieved successfully", {
271
+
type: "array",
272
+
items: {
273
+
type: "object",
274
+
properties: {
275
+
userAgent: { type: "string", example: "Mozilla/5.0..." },
276
+
count: { type: "number", example: 123 }
277
+
}
278
+
}
279
+
})
280
+
])
281
+
}
282
+
)
283
+
},
284
+
285
+
"/stats": {
286
+
GET: createRoute(
287
+
withAnalytics("/stats", "GET", handlers.handleGetStats),
288
+
{
289
+
summary: "Get complete analytics (legacy)",
290
+
description: "Legacy endpoint returning all analytics data in one response",
291
+
tags: ["Analytics", "Legacy"],
292
+
parameters: {
293
+
query: [queryParam("days", "number", "Number of days to analyze", false, 7)]
294
+
},
295
+
responses: Object.fromEntries([
296
+
apiResponse(200, "Complete analytics data retrieved", {
297
+
type: "object",
298
+
properties: {
299
+
totalRequests: { type: "number" },
300
+
averageResponseTime: { type: "number" },
301
+
chartData: { type: "array" },
302
+
userAgents: { type: "array" }
303
+
}
304
+
})
305
+
])
306
+
}
307
+
)
308
+
}
309
+
};
310
+
}
-553
src/swagger.ts
-553
src/swagger.ts
···
1
-
import { version } from "../package.json";
2
-
3
-
// Define the Swagger specification
4
-
const swaggerSpec = {
5
-
openapi: "3.0.0",
6
-
info: {
7
-
title: "Cachet",
8
-
version: version,
9
-
description:
10
-
"Hi 👋\n\nThis is a pretty simple API that acts as a middleman caching layer between slack and the outside world. There may be authentication in the future, but for now, it's just a simple cache.\n\nThe `/r` endpoints are redirects to the actual image URLs, so you can use them as direct image links.",
11
-
contact: {
12
-
name: "Kieran Klukas",
13
-
email: "me@dunkirk.sh",
14
-
},
15
-
license: {
16
-
name: "AGPL 3.0",
17
-
url: "https://github.com/taciturnaxolotl/cachet/blob/main/LICENSE.md",
18
-
},
19
-
},
20
-
tags: [
21
-
{
22
-
name: "The Cache!",
23
-
description: "*must be read in an ominous voice*",
24
-
},
25
-
{
26
-
name: "Status",
27
-
description: "*Rather boring status endpoints :(*",
28
-
},
29
-
],
30
-
paths: {
31
-
"/users/{user}": {
32
-
get: {
33
-
tags: ["The Cache!"],
34
-
summary: "Get user information",
35
-
description:
36
-
"Retrieves user information from the cache or from Slack if not cached",
37
-
parameters: [
38
-
{
39
-
name: "user",
40
-
in: "path",
41
-
required: true,
42
-
schema: {
43
-
type: "string",
44
-
},
45
-
description: "Slack user ID",
46
-
},
47
-
],
48
-
responses: {
49
-
"200": {
50
-
description: "User information",
51
-
content: {
52
-
"application/json": {
53
-
schema: {
54
-
type: "object",
55
-
properties: {
56
-
id: {
57
-
type: "string",
58
-
example: "90750e24-c2f0-4c52-8681-e6176da6e7ab",
59
-
},
60
-
expiration: {
61
-
type: "string",
62
-
format: "date-time",
63
-
example: new Date().toISOString(),
64
-
},
65
-
user: {
66
-
type: "string",
67
-
example: "U12345678",
68
-
},
69
-
displayName: {
70
-
type: "string",
71
-
example: "krn",
72
-
},
73
-
pronouns: {
74
-
type: "string",
75
-
nullable: true,
76
-
example: "possibly/blank",
77
-
},
78
-
image: {
79
-
type: "string",
80
-
example:
81
-
"https://avatars.slack-edge.com/2024-11-30/8105375749571_53898493372773a01a1f_original.jpg",
82
-
},
83
-
},
84
-
},
85
-
},
86
-
},
87
-
},
88
-
"404": {
89
-
description: "User not found",
90
-
content: {
91
-
"application/json": {
92
-
schema: {
93
-
type: "object",
94
-
properties: {
95
-
message: {
96
-
type: "string",
97
-
example: "User not found",
98
-
},
99
-
},
100
-
},
101
-
},
102
-
},
103
-
},
104
-
"500": {
105
-
description: "Error fetching user from Slack",
106
-
content: {
107
-
"application/json": {
108
-
schema: {
109
-
type: "object",
110
-
properties: {
111
-
message: {
112
-
type: "string",
113
-
example: "Error fetching user from Slack",
114
-
},
115
-
},
116
-
},
117
-
},
118
-
},
119
-
},
120
-
},
121
-
},
122
-
},
123
-
"/users/{user}/r": {
124
-
get: {
125
-
tags: ["The Cache!"],
126
-
summary: "Redirect to user profile image",
127
-
description: "Redirects to the user's profile image URL",
128
-
parameters: [
129
-
{
130
-
name: "user",
131
-
in: "path",
132
-
required: true,
133
-
schema: {
134
-
type: "string",
135
-
},
136
-
description: "Slack user ID",
137
-
},
138
-
],
139
-
responses: {
140
-
"302": {
141
-
description: "Redirect to user profile image",
142
-
},
143
-
"307": {
144
-
description: "Redirect to default image when user not found",
145
-
},
146
-
"500": {
147
-
description: "Error fetching user from Slack",
148
-
content: {
149
-
"application/json": {
150
-
schema: {
151
-
type: "object",
152
-
properties: {
153
-
message: {
154
-
type: "string",
155
-
example: "Error fetching user from Slack",
156
-
},
157
-
},
158
-
},
159
-
},
160
-
},
161
-
},
162
-
},
163
-
},
164
-
},
165
-
"/users/{user}/purge": {
166
-
post: {
167
-
tags: ["The Cache!"],
168
-
summary: "Purge user cache",
169
-
description: "Purges a specific user's cache",
170
-
parameters: [
171
-
{
172
-
name: "user",
173
-
in: "path",
174
-
required: true,
175
-
schema: {
176
-
type: "string",
177
-
},
178
-
description: "Slack user ID",
179
-
},
180
-
{
181
-
name: "authorization",
182
-
in: "header",
183
-
required: true,
184
-
schema: {
185
-
type: "string",
186
-
example: "Bearer <token>",
187
-
},
188
-
description: "Bearer token for authentication",
189
-
},
190
-
],
191
-
responses: {
192
-
"200": {
193
-
description: "User cache purged",
194
-
content: {
195
-
"application/json": {
196
-
schema: {
197
-
type: "object",
198
-
properties: {
199
-
message: {
200
-
type: "string",
201
-
example: "User cache purged",
202
-
},
203
-
userId: {
204
-
type: "string",
205
-
example: "U12345678",
206
-
},
207
-
success: {
208
-
type: "boolean",
209
-
example: true,
210
-
},
211
-
},
212
-
},
213
-
},
214
-
},
215
-
},
216
-
"401": {
217
-
description: "Unauthorized",
218
-
content: {
219
-
"text/plain": {
220
-
schema: {
221
-
type: "string",
222
-
example: "Unauthorized",
223
-
},
224
-
},
225
-
},
226
-
},
227
-
},
228
-
},
229
-
},
230
-
"/emojis": {
231
-
get: {
232
-
tags: ["The Cache!"],
233
-
summary: "Get all emojis",
234
-
description: "Retrieves all emojis from the cache",
235
-
responses: {
236
-
"200": {
237
-
description: "List of emojis",
238
-
content: {
239
-
"application/json": {
240
-
schema: {
241
-
type: "array",
242
-
items: {
243
-
type: "object",
244
-
properties: {
245
-
id: {
246
-
type: "string",
247
-
example: "5427fe70-686f-4684-9da5-95d9ef4c1090",
248
-
},
249
-
expiration: {
250
-
type: "string",
251
-
format: "date-time",
252
-
example: new Date().toISOString(),
253
-
},
254
-
name: {
255
-
type: "string",
256
-
example: "blahaj-heart",
257
-
},
258
-
alias: {
259
-
type: "string",
260
-
nullable: true,
261
-
example: "blobhaj-heart",
262
-
},
263
-
image: {
264
-
type: "string",
265
-
example:
266
-
"https://emoji.slack-edge.com/T0266FRGM/blahaj-heart/db9adf8229e9a4fb.png",
267
-
},
268
-
},
269
-
},
270
-
},
271
-
},
272
-
},
273
-
},
274
-
},
275
-
},
276
-
},
277
-
"/emojis/{emoji}": {
278
-
get: {
279
-
tags: ["The Cache!"],
280
-
summary: "Get emoji information",
281
-
description: "Retrieves information about a specific emoji",
282
-
parameters: [
283
-
{
284
-
name: "emoji",
285
-
in: "path",
286
-
required: true,
287
-
schema: {
288
-
type: "string",
289
-
},
290
-
description: "Emoji name",
291
-
},
292
-
],
293
-
responses: {
294
-
"200": {
295
-
description: "Emoji information",
296
-
content: {
297
-
"application/json": {
298
-
schema: {
299
-
type: "object",
300
-
properties: {
301
-
id: {
302
-
type: "string",
303
-
example: "9ed0a560-928d-409c-89fc-10fe156299da",
304
-
},
305
-
expiration: {
306
-
type: "string",
307
-
format: "date-time",
308
-
example: new Date().toISOString(),
309
-
},
310
-
name: {
311
-
type: "string",
312
-
example: "orphmoji-yay",
313
-
},
314
-
image: {
315
-
type: "string",
316
-
example:
317
-
"https://emoji.slack-edge.com/T0266FRGM/orphmoji-yay/23a37f4af47092d3.png",
318
-
},
319
-
},
320
-
},
321
-
},
322
-
},
323
-
},
324
-
"404": {
325
-
description: "Emoji not found",
326
-
content: {
327
-
"application/json": {
328
-
schema: {
329
-
type: "object",
330
-
properties: {
331
-
message: {
332
-
type: "string",
333
-
example: "Emoji not found",
334
-
},
335
-
},
336
-
},
337
-
},
338
-
},
339
-
},
340
-
},
341
-
},
342
-
},
343
-
"/emojis/{emoji}/r": {
344
-
get: {
345
-
tags: ["The Cache!"],
346
-
summary: "Redirect to emoji image",
347
-
description: "Redirects to the emoji image URL",
348
-
parameters: [
349
-
{
350
-
name: "emoji",
351
-
in: "path",
352
-
required: true,
353
-
schema: {
354
-
type: "string",
355
-
},
356
-
description: "Emoji name",
357
-
},
358
-
],
359
-
responses: {
360
-
"302": {
361
-
description: "Redirect to emoji image",
362
-
},
363
-
"404": {
364
-
description: "Emoji not found",
365
-
content: {
366
-
"application/json": {
367
-
schema: {
368
-
type: "object",
369
-
properties: {
370
-
message: {
371
-
type: "string",
372
-
example: "Emoji not found",
373
-
},
374
-
},
375
-
},
376
-
},
377
-
},
378
-
},
379
-
},
380
-
},
381
-
},
382
-
"/reset": {
383
-
post: {
384
-
tags: ["The Cache!"],
385
-
summary: "Reset cache",
386
-
description: "Purges all items from the cache",
387
-
parameters: [
388
-
{
389
-
name: "authorization",
390
-
in: "header",
391
-
required: true,
392
-
schema: {
393
-
type: "string",
394
-
example: "Bearer <token>",
395
-
},
396
-
description: "Bearer token for authentication",
397
-
},
398
-
],
399
-
responses: {
400
-
"200": {
401
-
description: "Cache purged",
402
-
content: {
403
-
"application/json": {
404
-
schema: {
405
-
type: "object",
406
-
properties: {
407
-
message: {
408
-
type: "string",
409
-
example: "Cache purged",
410
-
},
411
-
users: {
412
-
type: "number",
413
-
example: 10,
414
-
},
415
-
emojis: {
416
-
type: "number",
417
-
example: 100,
418
-
},
419
-
},
420
-
},
421
-
},
422
-
},
423
-
},
424
-
"401": {
425
-
description: "Unauthorized",
426
-
content: {
427
-
"text/plain": {
428
-
schema: {
429
-
type: "string",
430
-
example: "Unauthorized",
431
-
},
432
-
},
433
-
},
434
-
},
435
-
},
436
-
},
437
-
},
438
-
"/health": {
439
-
get: {
440
-
tags: ["Status"],
441
-
summary: "Health check",
442
-
description:
443
-
"Checks the health of the API, Slack connection, and database",
444
-
responses: {
445
-
"200": {
446
-
description: "Health check passed",
447
-
content: {
448
-
"application/json": {
449
-
schema: {
450
-
type: "object",
451
-
properties: {
452
-
http: {
453
-
type: "boolean",
454
-
example: true,
455
-
},
456
-
slack: {
457
-
type: "boolean",
458
-
example: true,
459
-
},
460
-
database: {
461
-
type: "boolean",
462
-
example: true,
463
-
},
464
-
},
465
-
},
466
-
},
467
-
},
468
-
},
469
-
"500": {
470
-
description: "Health check failed",
471
-
content: {
472
-
"application/json": {
473
-
schema: {
474
-
type: "object",
475
-
properties: {
476
-
http: {
477
-
type: "boolean",
478
-
example: false,
479
-
},
480
-
slack: {
481
-
type: "boolean",
482
-
example: false,
483
-
},
484
-
database: {
485
-
type: "boolean",
486
-
example: false,
487
-
},
488
-
},
489
-
},
490
-
},
491
-
},
492
-
},
493
-
},
494
-
},
495
-
},
496
-
"/stats": {
497
-
get: {
498
-
tags: ["Status"],
499
-
summary: "Get analytics statistics",
500
-
description: "Retrieves analytics statistics for the API",
501
-
parameters: [
502
-
{
503
-
name: "days",
504
-
in: "query",
505
-
required: false,
506
-
schema: {
507
-
type: "string",
508
-
},
509
-
description: "Number of days to look back (default: 7)",
510
-
},
511
-
],
512
-
responses: {
513
-
"200": {
514
-
description: "Analytics statistics",
515
-
content: {
516
-
"application/json": {
517
-
schema: {
518
-
type: "object",
519
-
properties: {
520
-
totalRequests: {
521
-
type: "number",
522
-
},
523
-
requestsByEndpoint: {
524
-
type: "array",
525
-
items: {
526
-
type: "object",
527
-
properties: {
528
-
endpoint: {
529
-
type: "string",
530
-
},
531
-
count: {
532
-
type: "number",
533
-
},
534
-
averageResponseTime: {
535
-
type: "number",
536
-
},
537
-
},
538
-
},
539
-
},
540
-
// Additional properties omitted for brevity
541
-
},
542
-
},
543
-
},
544
-
},
545
-
},
546
-
},
547
-
},
548
-
},
549
-
},
550
-
};
551
-
552
-
// Export the Swagger specification for use in other files
553
-
export default swaggerSpec;
+93
src/types/routes.ts
+93
src/types/routes.ts
···
1
+
/**
2
+
* Type-safe route system that generates Swagger documentation from route definitions
3
+
* This ensures the Swagger docs stay in sync with the actual API implementation
4
+
*/
5
+
6
+
// Base types for HTTP methods
7
+
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
8
+
9
+
// Parameter types
10
+
export interface RouteParam {
11
+
name: string;
12
+
type: 'string' | 'number' | 'boolean';
13
+
required: boolean;
14
+
description: string;
15
+
example?: any;
16
+
}
17
+
18
+
// Response types
19
+
export interface ApiResponse {
20
+
status: number;
21
+
description: string;
22
+
schema?: any; // JSON Schema or example object
23
+
}
24
+
25
+
// Route metadata for Swagger generation
26
+
export interface RouteMetadata {
27
+
summary: string;
28
+
description?: string;
29
+
tags?: string[];
30
+
parameters?: {
31
+
path?: RouteParam[];
32
+
query?: RouteParam[];
33
+
body?: any; // JSON Schema for request body
34
+
};
35
+
responses: Record<number, ApiResponse>;
36
+
requiresAuth?: boolean;
37
+
}
38
+
39
+
// Handler function type
40
+
export type RouteHandler = (request: Request) => Promise<Response> | Response;
41
+
42
+
// Enhanced route definition that includes metadata
43
+
export interface TypedRoute {
44
+
handler: RouteHandler;
45
+
metadata: RouteMetadata;
46
+
}
47
+
48
+
// Method-specific route definitions (matching Bun's pattern)
49
+
export interface RouteDefinition {
50
+
GET?: TypedRoute;
51
+
POST?: TypedRoute;
52
+
PUT?: TypedRoute;
53
+
DELETE?: TypedRoute;
54
+
PATCH?: TypedRoute;
55
+
}
56
+
57
+
// Type helper to create routes with metadata
58
+
export function createRoute(
59
+
handler: RouteHandler,
60
+
metadata: RouteMetadata
61
+
): TypedRoute {
62
+
return { handler, metadata };
63
+
}
64
+
65
+
// Type helper for path parameters
66
+
export function pathParam(
67
+
name: string,
68
+
type: RouteParam['type'] = 'string',
69
+
description: string,
70
+
example?: any
71
+
): RouteParam {
72
+
return { name, type, required: true, description, example };
73
+
}
74
+
75
+
// Type helper for query parameters
76
+
export function queryParam(
77
+
name: string,
78
+
type: RouteParam['type'] = 'string',
79
+
description: string,
80
+
required = false,
81
+
example?: any
82
+
): RouteParam {
83
+
return { name, type, required, description, example };
84
+
}
85
+
86
+
// Type helper for API responses
87
+
export function apiResponse(
88
+
status: number,
89
+
description: string,
90
+
schema?: any
91
+
): [number, ApiResponse] {
92
+
return [status, { status, description, schema }];
93
+
}