Skip to content

Commit 6ba7fd8

Browse files
improvement(api): consolidate public v2 route handling
1 parent 786e0a4 commit 6ba7fd8

74 files changed

Lines changed: 4408 additions & 5991 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { NextRequest, NextResponse } from 'next/server'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { z } from 'zod'
7+
import { defineRouteContract } from '@/lib/api/contracts'
8+
import { recordRateLimitSnapshot } from '@/lib/api/server/rate-limit-context'
9+
10+
const { mockCheckRateLimit, mockGate, mockHandler, mockLoggerInfo, requestContextState } =
11+
vi.hoisted(() => ({
12+
mockCheckRateLimit: vi.fn(),
13+
mockGate: vi.fn(),
14+
mockHandler: vi.fn(),
15+
mockLoggerInfo: vi.fn(),
16+
requestContextState: {
17+
current: undefined as { requestId: string; method?: string; path?: string } | undefined,
18+
},
19+
}))
20+
21+
vi.mock('@sim/logger', () => ({
22+
createLogger: () => ({
23+
info: (...arguments_: unknown[]) =>
24+
mockLoggerInfo(requestContextState.current?.requestId, ...arguments_),
25+
warn: vi.fn(),
26+
error: vi.fn(),
27+
}),
28+
getRequestContext: () => requestContextState.current,
29+
runWithRequestContext: async <T>(
30+
context: { requestId: string; method?: string; path?: string },
31+
callback: () => T | Promise<T>
32+
): Promise<T> => {
33+
requestContextState.current = context
34+
try {
35+
return await callback()
36+
} finally {
37+
requestContextState.current = undefined
38+
}
39+
},
40+
}))
41+
42+
vi.mock('@/lib/core/utils/request', () => ({
43+
generateRequestId: () => requestContextState.current?.requestId ?? 'outer-request-id',
44+
}))
45+
46+
vi.mock('@/app/api/v1/middleware', () => ({
47+
checkRateLimit: mockCheckRateLimit,
48+
}))
49+
50+
vi.mock('@/app/api/v2/lib/gate', () => ({
51+
v2ApiGateError: mockGate,
52+
}))
53+
54+
import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler'
55+
56+
const RATE_LIMIT = {
57+
allowed: true,
58+
limit: 400,
59+
remaining: 399,
60+
resetAt: new Date('2026-08-06T20:00:00.000Z'),
61+
userId: 'user-1',
62+
keyType: 'personal' as const,
63+
}
64+
65+
const queryContract = defineRouteContract({
66+
method: 'POST',
67+
path: '/api/test/:itemId',
68+
params: z.object({ itemId: z.string().min(1) }),
69+
query: z.object({ limit: z.coerce.number().int().positive() }),
70+
body: z.object({ name: z.string().min(1) }),
71+
response: { mode: 'json', schema: z.object({ ok: z.boolean() }) },
72+
})
73+
74+
const listContract = defineRouteContract({
75+
method: 'GET',
76+
path: '/api/test',
77+
query: z.object({ workspaceId: z.string().min(1) }),
78+
response: { mode: 'json', schema: z.object({ ok: z.boolean() }) },
79+
})
80+
81+
const POST = withPublicApiRouteHandler({
82+
contract: queryContract,
83+
rateLimitEndpoint: 'table-rows',
84+
parseOptions: {
85+
maxBodyBytes: 32,
86+
payloadTooLargeResponse: () =>
87+
NextResponse.json({ error: 'Custom payload limit response' }, { status: 413 }),
88+
},
89+
handler: async (arguments_) => {
90+
mockHandler(arguments_)
91+
return NextResponse.json({ ok: true })
92+
},
93+
})
94+
95+
const GET = withPublicApiRouteHandler({
96+
contract: listContract,
97+
rateLimitEndpoint: 'tables',
98+
handler: async (arguments_) => {
99+
mockHandler(arguments_)
100+
return NextResponse.json({ ok: true })
101+
},
102+
})
103+
104+
const FAILING_GET = withPublicApiRouteHandler({
105+
contract: listContract,
106+
rateLimitEndpoint: 'tables',
107+
handler: async () => {
108+
throw new Error('handler failed')
109+
},
110+
})
111+
112+
function postRequest(body: string): NextRequest {
113+
return new NextRequest('http://localhost:3000/api/test/item-1?limit=10', {
114+
method: 'POST',
115+
headers: { 'Content-Type': 'application/json' },
116+
body,
117+
})
118+
}
119+
120+
function listRequest(query = 'workspaceId=workspace-1'): NextRequest {
121+
return new NextRequest(`http://localhost:3000/api/test?${query}`)
122+
}
123+
124+
describe('withPublicApiRouteHandler', () => {
125+
beforeEach(() => {
126+
vi.clearAllMocks()
127+
mockGate.mockResolvedValue(null)
128+
mockCheckRateLimit.mockImplementation(async (request: NextRequest) => {
129+
recordRateLimitSnapshot(request, RATE_LIMIT)
130+
return RATE_LIMIT
131+
})
132+
})
133+
134+
it.each([
135+
['authentication failure', 401],
136+
['rate-limit denial', 429],
137+
])('short-circuits %s before reading or parsing the body', async (_label, status) => {
138+
mockCheckRateLimit.mockImplementation(async (request: NextRequest) => {
139+
if (status === 401) {
140+
return {
141+
allowed: false,
142+
limit: 0,
143+
remaining: 0,
144+
resetAt: new Date('2026-08-06T20:00:00.000Z'),
145+
error: 'API key required',
146+
}
147+
}
148+
149+
recordRateLimitSnapshot(request, RATE_LIMIT)
150+
return { ...RATE_LIMIT, allowed: false, remaining: 0, retryAfterMs: 30_000 }
151+
})
152+
const request = postRequest('{not valid json')
153+
154+
const response = await POST(request, { params: { itemId: 'item-1' } })
155+
156+
expect(response.status).toBe(status)
157+
expect(request.bodyUsed).toBe(false)
158+
expect(mockHandler).not.toHaveBeenCalled()
159+
expect(mockCheckRateLimit).toHaveBeenCalledWith(request, 'table-rows')
160+
expect(mockGate).not.toHaveBeenCalled()
161+
if (status === 401) {
162+
expect(response.headers.get('X-RateLimit-Limit')).toBe('0')
163+
} else {
164+
expect(response.headers.get('Retry-After')).toBe('30')
165+
expect(response.headers.get('X-RateLimit-Limit')).toBe('400')
166+
}
167+
})
168+
169+
it('checks the v2 rollout gate before reading or parsing the body', async () => {
170+
mockGate.mockResolvedValue(NextResponse.json({ error: 'Not found' }, { status: 404 }))
171+
const request = postRequest('{not valid json')
172+
173+
const response = await POST(request, { params: { itemId: 'item-1' } })
174+
175+
expect(response.status).toBe(404)
176+
expect(request.bodyUsed).toBe(false)
177+
expect(mockGate).toHaveBeenCalledWith('user-1')
178+
expect(mockHandler).not.toHaveBeenCalled()
179+
})
180+
181+
it('fails fast when an allowed rate-limit result has no user ID', async () => {
182+
mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, userId: undefined })
183+
184+
const response = await GET(listRequest())
185+
186+
expect(response.status).toBe(500)
187+
expect(mockGate).not.toHaveBeenCalled()
188+
expect(mockHandler).not.toHaveBeenCalled()
189+
})
190+
191+
it('returns a contract validation response after authentication', async () => {
192+
const response = await POST(postRequest(JSON.stringify({ name: '' })), {
193+
params: { itemId: 'item-1' },
194+
})
195+
196+
expect(response.status).toBe(400)
197+
expect(response.headers.get('X-RateLimit-Limit')).toBe('400')
198+
expect(mockHandler).not.toHaveBeenCalled()
199+
})
200+
201+
it('forwards the body-size parse option', async () => {
202+
const response = await POST(postRequest(JSON.stringify({ name: 'x'.repeat(40) })), {
203+
params: { itemId: 'item-1' },
204+
})
205+
206+
expect(response.status).toBe(413)
207+
expect(response.headers.get('X-RateLimit-Remaining')).toBe('399')
208+
await expect(response.json()).resolves.toEqual({ error: 'Custom payload limit response' })
209+
expect(mockHandler).not.toHaveBeenCalled()
210+
})
211+
212+
it('provides parsed params, query, body, and auth to the handler', async () => {
213+
const request = postRequest(JSON.stringify({ name: 'Ada' }))
214+
const response = await POST(request, { params: Promise.resolve({ itemId: 'item-1' }) })
215+
216+
expect(response.status).toBe(200)
217+
expect(mockHandler).toHaveBeenCalledWith({
218+
request,
219+
input: {
220+
params: { itemId: 'item-1' },
221+
query: { limit: 10 },
222+
body: { name: 'Ada' },
223+
headers: undefined,
224+
},
225+
auth: {
226+
requestId: 'outer-request-id',
227+
userId: 'user-1',
228+
rateLimit: RATE_LIMIT,
229+
},
230+
})
231+
expect(response.headers.get('x-request-id')).toBe('outer-request-id')
232+
expect(response.headers.get('X-RateLimit-Reset')).toBe(RATE_LIMIT.resetAt.toISOString())
233+
expect(mockLoggerInfo).toHaveBeenCalledWith(
234+
'outer-request-id',
235+
'OK',
236+
expect.objectContaining({ status: 200 })
237+
)
238+
})
239+
240+
it('supports direct invocation without a route context', async () => {
241+
const request = listRequest()
242+
const response = await GET(request)
243+
244+
expect(response.status).toBe(200)
245+
expect(mockHandler.mock.calls[0][0].input.query).toEqual({ workspaceId: 'workspace-1' })
246+
expect(mockCheckRateLimit).toHaveBeenCalledWith(request, 'tables')
247+
})
248+
249+
it('keeps rate-limit and request headers on unhandled endpoint errors', async () => {
250+
const response = await FAILING_GET(listRequest())
251+
252+
expect(response.status).toBe(500)
253+
expect(response.headers.get('x-request-id')).toBe('outer-request-id')
254+
expect(response.headers.get('X-RateLimit-Limit')).toBe('400')
255+
})
256+
})
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import type { NextRequest, NextResponse } from 'next/server'
2+
import type { AnyApiRouteContract } from '@/lib/api/contracts'
3+
import { type ParsedRequest, type ParseRequestOptions, parseRequest } from '@/lib/api/server'
4+
import { generateRequestId } from '@/lib/core/utils/request'
5+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
6+
import { type ApiEndpoint, type AuthorizedRequest, checkRateLimit } from '@/app/api/v1/middleware'
7+
import { v2ApiGateError } from '@/app/api/v2/lib/gate'
8+
import { v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response'
9+
10+
interface PublicApiRouteContext {
11+
params?:
12+
| Promise<Record<string, string | string[] | undefined>>
13+
| Record<string, string | string[] | undefined>
14+
}
15+
16+
interface PublicApiRouteHandlerArguments<C extends AnyApiRouteContract> {
17+
request: NextRequest
18+
input: ParsedRequest<C>
19+
auth: AuthorizedRequest
20+
}
21+
22+
interface PublicApiRouteHandlerOptions<C extends AnyApiRouteContract> {
23+
contract: C
24+
rateLimitEndpoint: ApiEndpoint
25+
parseOptions?: ParseRequestOptions
26+
handler: (
27+
arguments_: PublicApiRouteHandlerArguments<C>
28+
) => Promise<NextResponse | Response> | NextResponse | Response
29+
}
30+
31+
type PublicApiNextRouteHandler = (
32+
request: NextRequest,
33+
context?: PublicApiRouteContext
34+
) => Promise<NextResponse | Response>
35+
36+
/**
37+
* Wraps an API-key-authenticated public route with request context, rate
38+
* limiting, authentication, and contract parsing before invoking the route's
39+
* authorization and business logic.
40+
*/
41+
export function withPublicApiRouteHandler<C extends AnyApiRouteContract>({
42+
contract,
43+
rateLimitEndpoint,
44+
parseOptions,
45+
handler,
46+
}: PublicApiRouteHandlerOptions<C>): PublicApiNextRouteHandler {
47+
const wrapped = withRouteHandler<PublicApiRouteContext | undefined>(async (request, context) => {
48+
const requestId = generateRequestId()
49+
const rateLimit = await checkRateLimit(request, rateLimitEndpoint)
50+
if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
51+
52+
if (!rateLimit.userId) {
53+
throw new Error('Allowed public API request is missing a user ID')
54+
}
55+
const userId = rateLimit.userId
56+
const gate = await v2ApiGateError(userId)
57+
if (gate) return gate
58+
59+
const parsed = await parseRequest(contract, request, context ?? {}, {
60+
validationErrorResponse: v2ValidationError,
61+
...parseOptions,
62+
})
63+
if (!parsed.success) return parsed.response
64+
65+
return handler({
66+
request,
67+
input: parsed.data,
68+
auth: { requestId, userId, rateLimit },
69+
})
70+
})
71+
72+
return async (request, context) => wrapped(request, context)
73+
}

apps/sim/app/api/v1/middleware.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ vi.mock('@/lib/core/rate-limiter', () => ({
4141
}))
4242

4343
import {
44+
authenticateRequest,
4445
checkRateLimit,
4546
createRateLimitResponse,
4647
v1ValidationErrorResponse,
@@ -107,6 +108,29 @@ describe('checkRateLimit', () => {
107108
})
108109
})
109110

111+
describe('authenticateRequest', () => {
112+
beforeEach(() => {
113+
vi.clearAllMocks()
114+
mockAuthenticateV1Request.mockResolvedValue({
115+
authenticated: true,
116+
keyType: 'personal',
117+
})
118+
mockGetSubscription.mockResolvedValue({ plan: 'team' })
119+
mockGetRateLimit.mockReturnValue(TEAM_BUCKET)
120+
mockCheckRateLimit.mockResolvedValue({
121+
allowed: true,
122+
remaining: 399,
123+
resetAt: new Date('2026-07-28T18:28:48.354Z'),
124+
})
125+
})
126+
127+
it('fails fast when an allowed result has no user ID', async () => {
128+
await expect(authenticateRequest(request(), 'workflows')).rejects.toThrow(
129+
'Allowed public API request is missing a user ID'
130+
)
131+
})
132+
})
133+
110134
describe('createRateLimitResponse', () => {
111135
const throttled = {
112136
allowed: false,

0 commit comments

Comments
 (0)