Skip to content

Commit ebdbeb0

Browse files
improvement(api): consolidate public v2 route handling
1 parent 69e3e17 commit ebdbeb0

9 files changed

Lines changed: 543 additions & 224 deletions

File tree

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

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,

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ export async function checkRateLimit(
146146
}
147147

148148
/**
149-
* Authenticates and rate-limits a v1 API request.
149+
* Authenticates and rate-limits a public API request.
150150
* Returns NextResponse on failure, AuthorizedRequest on success.
151151
*/
152152
export async function authenticateRequest(
@@ -158,7 +158,10 @@ export async function authenticateRequest(
158158
if (!rateLimit.allowed) {
159159
return createRateLimitResponse(rateLimit)
160160
}
161-
return { requestId, userId: rateLimit.userId!, rateLimit }
161+
if (!rateLimit.userId) {
162+
throw new Error('Allowed public API request is missing a user ID')
163+
}
164+
return { requestId, userId: rateLimit.userId, rateLimit }
162165
}
163166

164167
export function createRateLimitResponse(result: RateLimitResult): NextResponse {

0 commit comments

Comments
 (0)