Skip to content

Commit 804fcc4

Browse files
improvement(api): consolidate public v2 route handling
1 parent 15efae7 commit 804fcc4

77 files changed

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

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)