Skip to content

Commit b04394d

Browse files
authored
Merge pull request #247 from observerr411/front
feat(cache): add in-memory/Redis caching layer for contract read calls
2 parents 8f813b4 + 9171670 commit b04394d

File tree

15 files changed

+2311
-7
lines changed

15 files changed

+2311
-7
lines changed

app/api/cache/invalidate/route.ts

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
/**
2+
* Cache Invalidation API
3+
*
4+
* POST /api/cache/invalidate - Manual cache invalidation
5+
* GET /api/cache/invalidate - Cache statistics
6+
*
7+
* @security Rate limiting recommended in production
8+
* @security Authentication required in production
9+
*
10+
* Request body validation:
11+
* - pattern (optional): Invalidate entries matching pattern
12+
* - contractId (optional): Invalidate all entries for a contract
13+
* - method (optional): Invalidate specific method (requires contractId)
14+
* - args (optional): Invalidate specific call (requires contractId and method)
15+
* - clearAll (optional): Clear entire cache
16+
*/
17+
18+
import { NextRequest, NextResponse } from 'next/server';
19+
import {
20+
invalidate,
21+
invalidatePattern,
22+
clearCache,
23+
getCacheStats,
24+
getCacheKeys,
25+
CacheError,
26+
CacheErrorCode,
27+
} from '@/lib/cache/contract-cache';
28+
29+
// Maximum request body size (prevent DoS)
30+
const MAX_BODY_SIZE = 10240; // 10KB
31+
32+
/**
33+
* Validates request body size
34+
* @security Prevents DoS via large payloads
35+
*/
36+
async function validateRequestBody(request: NextRequest): Promise<unknown> {
37+
const text = await request.text();
38+
39+
if (text.length > MAX_BODY_SIZE) {
40+
throw new Error(`Request body exceeds maximum size of ${MAX_BODY_SIZE} bytes`);
41+
}
42+
43+
if (!text.trim()) {
44+
throw new Error('Request body is empty');
45+
}
46+
47+
try {
48+
return JSON.parse(text);
49+
} catch (error) {
50+
throw new Error('Invalid JSON in request body');
51+
}
52+
}
53+
54+
/**
55+
* POST /api/cache/invalidate
56+
*
57+
* Allows manual cache invalidation for testing and debugging.
58+
*
59+
* @security Should be protected with authentication in production
60+
* @security Should have rate limiting in production
61+
*/
62+
export async function POST(request: NextRequest) {
63+
try {
64+
// Validate and parse request body
65+
const body = await validateRequestBody(request);
66+
67+
// Type guard for body
68+
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
69+
return NextResponse.json(
70+
{
71+
success: false,
72+
error: 'Request body must be a JSON object',
73+
},
74+
{ status: 400 }
75+
);
76+
}
77+
78+
const typedBody = body as Record<string, unknown>;
79+
80+
// Clear all cache
81+
if (typedBody.clearAll === true) {
82+
clearCache();
83+
return NextResponse.json({
84+
success: true,
85+
message: 'Cache cleared completely',
86+
stats: getCacheStats(),
87+
});
88+
}
89+
90+
// Pattern-based invalidation
91+
if (typeof typedBody.pattern === 'string') {
92+
try {
93+
const count = invalidatePattern(typedBody.pattern);
94+
return NextResponse.json({
95+
success: true,
96+
message: `Invalidated ${count} cache entries matching pattern`,
97+
pattern: typedBody.pattern,
98+
count,
99+
stats: getCacheStats(),
100+
});
101+
} catch (error) {
102+
if (error instanceof CacheError) {
103+
return NextResponse.json(
104+
{
105+
success: false,
106+
error: error.message,
107+
code: error.code,
108+
},
109+
{ status: 400 }
110+
);
111+
}
112+
throw error;
113+
}
114+
}
115+
116+
// Specific invalidation
117+
if (typeof typedBody.contractId === 'string') {
118+
try {
119+
if (typeof typedBody.method === 'string') {
120+
// Validate args if provided
121+
const args = typedBody.args && typeof typedBody.args === 'object' && !Array.isArray(typedBody.args)
122+
? (typedBody.args as Record<string, unknown>)
123+
: {};
124+
125+
// Invalidate specific method call
126+
const existed = invalidate(typedBody.contractId, typedBody.method, args);
127+
return NextResponse.json({
128+
success: true,
129+
message: existed ? 'Cache entry invalidated' : 'Cache entry not found',
130+
contractId: typedBody.contractId,
131+
method: typedBody.method,
132+
args,
133+
existed,
134+
stats: getCacheStats(),
135+
});
136+
} else {
137+
// Invalidate all methods for a contract
138+
const count = invalidatePattern(typedBody.contractId);
139+
return NextResponse.json({
140+
success: true,
141+
message: `Invalidated ${count} cache entries for contract`,
142+
contractId: typedBody.contractId,
143+
count,
144+
stats: getCacheStats(),
145+
});
146+
}
147+
} catch (error) {
148+
if (error instanceof CacheError) {
149+
return NextResponse.json(
150+
{
151+
success: false,
152+
error: error.message,
153+
code: error.code,
154+
details: error.details,
155+
},
156+
{ status: 400 }
157+
);
158+
}
159+
throw error;
160+
}
161+
}
162+
163+
// No valid operation specified
164+
return NextResponse.json(
165+
{
166+
success: false,
167+
error: 'Invalid request. Provide clearAll, pattern, or contractId',
168+
validOperations: {
169+
clearAll: 'boolean - Clear entire cache',
170+
pattern: 'string - Invalidate entries matching pattern',
171+
contractId: 'string - Invalidate entries for contract (optionally with method and args)',
172+
},
173+
},
174+
{ status: 400 }
175+
);
176+
} catch (error) {
177+
// Handle validation errors
178+
if (error instanceof Error && error.message.includes('Request body')) {
179+
return NextResponse.json(
180+
{
181+
success: false,
182+
error: error.message,
183+
},
184+
{ status: 400 }
185+
);
186+
}
187+
188+
// Handle unexpected errors
189+
// In production, log to monitoring system without exposing details
190+
return NextResponse.json(
191+
{
192+
success: false,
193+
error: 'Internal server error',
194+
// Only include details in development
195+
...(process.env.NODE_ENV === 'development' && {
196+
details: error instanceof Error ? error.message : 'Unknown error',
197+
}),
198+
},
199+
{ status: 500 }
200+
);
201+
}
202+
}
203+
204+
/**
205+
* GET /api/cache/invalidate
206+
*
207+
* Returns cache statistics and keys for debugging.
208+
*
209+
* @security Should be protected with authentication in production
210+
* @security Consider disabling key listing in production
211+
*/
212+
export async function GET(request: NextRequest) {
213+
try {
214+
const stats = getCacheStats();
215+
216+
// Check if keys should be included (query param)
217+
const { searchParams } = new URL(request.url);
218+
const includeKeys = searchParams.get('includeKeys') === 'true';
219+
220+
// In production, consider restricting key listing
221+
const keys = includeKeys && process.env.NODE_ENV === 'development'
222+
? getCacheKeys()
223+
: undefined;
224+
225+
return NextResponse.json({
226+
success: true,
227+
stats,
228+
...(keys && {
229+
keys,
230+
keysCount: keys.length,
231+
}),
232+
timestamp: new Date().toISOString(),
233+
});
234+
} catch (error) {
235+
// Handle unexpected errors
236+
return NextResponse.json(
237+
{
238+
success: false,
239+
error: 'Failed to get cache stats',
240+
// Only include details in development
241+
...(process.env.NODE_ENV === 'development' && {
242+
details: error instanceof Error ? error.message : 'Unknown error',
243+
}),
244+
},
245+
{ status: 500 }
246+
);
247+
}
248+
}

app/api/insurance/[id]/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextRequest, NextResponse } from "next/server";
2-
import { getPolicy } from "@/lib/contracts/insurance";
2+
import { getPolicy } from "@/lib/contracts/insurance-cached";
33
import { validateAuth, unauthorizedResponse } from "@/lib/auth";
44

55
// GET /api/insurance/:id

app/api/insurance/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextRequest, NextResponse } from "next/server";
2-
import { getActivePolicies } from "@/lib/contracts/insurance";
2+
import { getActivePolicies } from "@/lib/contracts/insurance-cached";
33
import { validateAuth, unauthorizedResponse } from "@/lib/auth";
44

55
// GET /api/insurance

app/api/insurance/total-premium/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextRequest, NextResponse } from "next/server";
2-
import { getTotalMonthlyPremium } from "@/lib/contracts/insurance";
2+
import { getTotalMonthlyPremium } from "@/lib/contracts/insurance-cached";
33
import { validateAuth, unauthorizedResponse } from "@/lib/auth";
44

55
// GET /api/insurance/total-premium?owner=G...

app/api/split/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { NextRequest, NextResponse } from 'next/server';
22
import { withAuth, ApiError } from '@/lib/auth';
3-
import { getSplit } from '@/lib/contracts/remittance-split';
3+
import { getSplit } from '@/lib/contracts/remittance-split-cached';
44

55
async function getHandler(request: NextRequest, session: string) {
66
try {

0 commit comments

Comments
 (0)