Skip to content

Commit b69fdbd

Browse files
authored
fix(api): give every v1 endpoint quota headers and errors that name the field (#6012)
* fix(api): give every v1 endpoint quota headers and errors that name the field Three consistency gaps found by probing the live v1 surface end to end. Rate-limit headers were only published by routes built on createApiResponse — workflows, logs and audit-logs. Tables, files and knowledge are rate limited by the same bucket and will return 429, but published no quota on success, so a client discovered the ceiling only by hitting it. Adds a shared rateLimitHeaders() builder, reused by createRateLimitResponse, and attaches it to all 31 success responses on those three families. Missing required fields did not name themselves. .min(1, '...') only fires for a present-but-empty string, so an omitted field fell through to Zod's default "Invalid input: expected string, received undefined". A previous pass fixed the shared id schemas, but 22 of 23 v1 workspaceId declarations bypassed them, so the fix reached almost nothing. Adds requiredFieldSchema(message) and routes every v1 request input through it, preserving each site's existing, more specific wording (for example "workspaceId query parameter is required") instead of flattening them to the generic one. Response schemas are left alone — "required" wording would be wrong there. Validation failures on tables, files and knowledge reported the literal "Validation error" and discarded the schema's message. Adds v1ValidationErrorResponse, which surfaces the first issue while keeping details, and wires it into the 19 parseRequest calls that had no handler. Routes with deliberately specific wording keep theirs. The global default is untouched, since routes outside v1 assert the current string. * fix(api): finish the v1 consistency sweep at call level, not file level Review found three places the first pass missed, all from filters that worked on whole files instead of individual call sites. - GET /api/v1/files/{fileId} returns the file bytes via `new Response`, not a `success: true` JSON body, so the header pass skipped it. The download now carries the same quota headers as the DELETE beside it. - Four parseRequest calls in the table-row routes still reported the generic "Validation error". The first pass skipped any file that already had a handler anywhere in it, which excluded these two files wholesale. The check is now per call site, and no bare call remains. - POST /api/v1/tables takes its body from the shared tables contract, which still used the bare `.min(1)` form, so an omitted workspaceId did not name itself. Converted there and in the other v1-reachable contracts. Scope note: roughly a thousand `.min(1, '... is required')` declarations remain under contracts/tools/**. Those are block and tool definitions rather than the public REST surface, and converting them belongs in its own change. * refactor(api): publish quota headers from one chokepoint, not 32 call sites A quality pass found the previous commit only made the happy path consistent. Those three route families have 117 response sites; 32 got headers. The other 85 are the error paths — 400/403/404/500 — which are exactly the responses a client is deciding whether to retry, and they published no quota at all. `checkRateLimit` now records the bucket snapshot against the request, and `withRouteHandler` attaches the headers next to the `x-request-id` it already sets, on both the success and the unhandled-error branch. Every v1 response carries the quota now, and a new v1 route gets it without remembering to. The carrier is a WeakMap keyed by the request, so it needs no cleanup and routes that never record a snapshot — everything outside v1 — are untouched. This deletes more than it adds: the 32 decorations are gone, and so is the `rateLimit` parameter that had been threaded into `handleBatchInsert` purely so a business-logic helper could decorate its own response. Also from the same pass: - One definition of the header trio. `createApiResponse` had its own copy, so after the last commit there were two; both now build from `buildRateLimitHeaders`. - `v1ValidationErrorResponse` delegates to the shared `validationErrorResponse` instead of hand-rolling the same body, and takes a fallback message, which collapses four route closures that differed only in that string. - 36 sites wrote `requiredFieldSchema('Workspace ID is required')` — the verbatim definition of the exported `workspaceIdSchema`. Using the primitive is the whole point of having it; they now import it. - Dropped TSDoc that had gone stale or contradicted the call sites it advised. * docs(api): reattach the withRouteHandler docblock and drop stale wording The comment pass caught a real casualty of the previous commit: inserting `applyResponseHeaders` put it between `withRouteHandler`'s docblock and the function itself, so the file's most-used export lost its documentation to the new private helper. Reattached, and its header bullet now mentions the rate-limit trio it also emits. Remaining edits are wording only. The WeakMap rationale moved off `RateLimitSnapshot` — three self-evident fields — onto the `snapshots` declaration it actually describes. The record site no longer restates that rationale; it keeps only the part unique to it. And three id-schema docs claimed "same constraint as nonEmptyIdSchema", which stopped being true when that schema was documented as deliberately message-less. * docs(api): document the quota headers on every v1 success response The spec already asserted, in the RateLimited description, that the X-RateLimit-* trio accompanies every authenticated response. Before this branch that was false for tables, files and knowledge; it is true now, but no operation documented it — only 1 of 40 v1 success responses carried the headers. All 40 now reference the shared header components. The shared BadRequest, Forbidden and NotFound components are deliberately left alone: they are also $ref-ed by non-v1 operations that publish no quota, so annotating them there would over-claim. The RateLimited description carries the general rule instead, now stating explicitly that the only responses without the headers are the ones that failed authentication. * fix(api): stop the last v1 validation paths from swallowing the message Bugbot found GET /api/v1/tables/{tableId}/rows still answering with the bare "Validation error". Its handler special-cases malformed filter/sort JSON and then falls back to the shared helper — so the site looked handled to a check that only asked whether a handler existed, which is why the earlier call-level sweep passed over it. Auditing the whole class turned up more of the same shape: - The optional-body parses on deploy and rollback, where a bad `version` lost "version must be a positive integer". - Eleven catch-block `validationErrorResponseFromError` handlers across the table routes, which discard the message of any ZodError thrown deeper. Adds `v1ValidationErrorResponseFromError` as the v1 counterpart for unknown caught values, and routes every remaining v1 validation path through the v1 helpers. No call to the generic helpers survives under app/api/v1 outside admin, which keeps its own error envelope.
1 parent 02311ca commit b69fdbd

39 files changed

Lines changed: 941 additions & 199 deletions

File tree

apps/docs/openapi.json

Lines changed: 439 additions & 1 deletion
Large diffs are not rendered by default.

apps/sim/app/api/v1/audit-logs/route.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const {
2626
vi.mock('@/app/api/v1/middleware', () => ({
2727
checkRateLimit: mockCheckRateLimit,
2828
createRateLimitResponse: vi.fn(),
29+
v1ValidationErrorResponse: (e: { issues: unknown[] }) =>
30+
NextResponse.json({ error: 'Validation error', details: e.issues }, { status: 400 }),
2931
}))
3032

3133
vi.mock('@/app/api/v1/audit-logs/auth', () => ({

apps/sim/app/api/v1/audit-logs/route.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { getErrorMessage } from '@sim/utils/errors'
2424
import { generateId } from '@sim/utils/id'
2525
import { type NextRequest, NextResponse } from 'next/server'
2626
import { v1ListAuditLogsContract } from '@/lib/api/contracts/v1/audit-logs'
27-
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
27+
import { parseRequest } from '@/lib/api/server'
2828
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2929
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
3030
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
@@ -35,7 +35,11 @@ import {
3535
queryAuditLogs,
3636
} from '@/app/api/v1/audit-logs/query'
3737
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
38-
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
38+
import {
39+
checkRateLimit,
40+
createRateLimitResponse,
41+
v1ValidationErrorResponse,
42+
} from '@/app/api/v1/middleware'
3943

4044
const logger = createLogger('V1AuditLogsAPI')
4145

@@ -65,14 +69,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
6569
request,
6670
{},
6771
{
68-
validationErrorResponse: (error) =>
69-
NextResponse.json(
70-
{
71-
error: getValidationErrorMessage(error, 'Invalid parameters'),
72-
details: error.issues,
73-
},
74-
{ status: 400 }
75-
),
72+
validationErrorResponse: (error) => v1ValidationErrorResponse(error, 'Invalid parameters'),
7673
}
7774
)
7875
if (!parsed.success) return parsed.response

apps/sim/app/api/v1/files/[fileId]/route.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ vi.mock('@/app/api/v1/middleware', () => ({
2020
checkRateLimit: mockCheckRateLimit,
2121
createRateLimitResponse: () => new Response('rate limited', { status: 429 }),
2222
validateWorkspaceAccess: mockValidateWorkspaceAccess,
23+
v1ValidationErrorResponse: (e: { issues: unknown[] }) =>
24+
NextResponse.json({ error: 'Validation error', details: e.issues }, { status: 400 }),
2325
}))
2426
vi.mock('@/lib/uploads/contexts/workspace', () => ({
2527
getWorkspaceFile: mockGetWorkspaceFile,

apps/sim/app/api/v1/files/[fileId]/route.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestra
1515
import {
1616
checkRateLimit,
1717
createRateLimitResponse,
18+
v1ValidationErrorResponse,
1819
validateWorkspaceAccess,
1920
} from '@/app/api/v1/middleware'
2021

@@ -38,7 +39,9 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo
3839
}
3940

4041
const userId = rateLimit.userId!
41-
const parsed = await parseRequest(v1DownloadFileContract, request, context)
42+
const parsed = await parseRequest(v1DownloadFileContract, request, context, {
43+
validationErrorResponse: v1ValidationErrorResponse,
44+
})
4245
if (!parsed.success) return parsed.response
4346

4447
const { fileId } = parsed.data.params
@@ -119,7 +122,9 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil
119122
}
120123

121124
const userId = rateLimit.userId!
122-
const parsed = await parseRequest(v1DeleteFileContract, request, context)
125+
const parsed = await parseRequest(v1DeleteFileContract, request, context, {
126+
validationErrorResponse: v1ValidationErrorResponse,
127+
})
123128
if (!parsed.success) return parsed.response
124129

125130
const { fileId } = parsed.data.params

apps/sim/app/api/v1/files/route.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
checkRateLimit,
2323
checkWorkspaceScope,
2424
createRateLimitResponse,
25+
v1ValidationErrorResponse,
2526
validateWorkspaceAccess,
2627
} from '@/app/api/v1/middleware'
2728

@@ -43,7 +44,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
4344
}
4445

4546
const userId = rateLimit.userId!
46-
const parsed = await parseRequest(v1ListFilesContract, request, {})
47+
const parsed = await parseRequest(
48+
v1ListFilesContract,
49+
request,
50+
{},
51+
{
52+
validationErrorResponse: v1ValidationErrorResponse,
53+
}
54+
)
4755
if (!parsed.success) return parsed.response
4856

4957
const { workspaceId } = parsed.data.query

apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { parseRequest } from '@/lib/api/server'
1111
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1212
import { deleteDocument } from '@/lib/knowledge/documents/service'
1313
import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils'
14-
import { authenticateRequest } from '@/app/api/v1/middleware'
14+
import { authenticateRequest, v1ValidationErrorResponse } from '@/app/api/v1/middleware'
1515

1616
export const dynamic = 'force-dynamic'
1717
export const revalidate = 0
@@ -28,7 +28,9 @@ export const GET = withRouteHandler(
2828
const { requestId, userId, rateLimit } = auth
2929

3030
try {
31-
const parsed = await parseRequest(v1GetKnowledgeDocumentContract, request, context)
31+
const parsed = await parseRequest(v1GetKnowledgeDocumentContract, request, context, {
32+
validationErrorResponse: v1ValidationErrorResponse,
33+
})
3234
if (!parsed.success) return parsed.response
3335
const { id: knowledgeBaseId, documentId } = parsed.data.params
3436

@@ -117,7 +119,9 @@ export const DELETE = withRouteHandler(
117119
const { requestId, userId, rateLimit } = auth
118120

119121
try {
120-
const parsed = await parseRequest(v1DeleteKnowledgeDocumentContract, request, context)
122+
const parsed = await parseRequest(v1DeleteKnowledgeDocumentContract, request, context, {
123+
validationErrorResponse: v1ValidationErrorResponse,
124+
})
121125
if (!parsed.success) return parsed.response
122126
const { id: knowledgeBaseId, documentId } = parsed.data.params
123127

apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ const SYSTEM_BILLING_ATTRIBUTION = {
4646

4747
vi.mock('@/app/api/v1/middleware', () => ({
4848
authenticateRequest: mockAuthenticateRequest,
49+
v1ValidationErrorResponse: (e: { issues: unknown[] }) =>
50+
NextResponse.json({ error: 'Validation error', details: e.issues }, { status: 400 }),
4951
}))
5052

5153
vi.mock('@/app/api/v1/knowledge/utils', () => ({

apps/sim/app/api/v1/knowledge/[id]/documents/route.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/typ
2626
import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace'
2727
import { validateFileType } from '@/lib/uploads/utils/validation'
2828
import { handleError, resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils'
29-
import { authenticateRequest } from '@/app/api/v1/middleware'
29+
import { authenticateRequest, v1ValidationErrorResponse } from '@/app/api/v1/middleware'
3030

3131
export const dynamic = 'force-dynamic'
3232
export const revalidate = 0
@@ -44,7 +44,9 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Docume
4444
const { requestId, userId, rateLimit } = auth
4545

4646
try {
47-
const parsed = await parseRequest(v1ListKnowledgeDocumentsContract, request, context)
47+
const parsed = await parseRequest(v1ListKnowledgeDocumentsContract, request, context, {
48+
validationErrorResponse: v1ValidationErrorResponse,
49+
})
4850
if (!parsed.success) return parsed.response
4951

5052
const { workspaceId, limit, offset, search, enabledFilter, sortBy, sortOrder } =
@@ -99,7 +101,9 @@ export const POST = withRouteHandler(
99101
const { requestId, userId, rateLimit } = auth
100102

101103
try {
102-
const parsed = await parseRequest(v1UploadKnowledgeDocumentContract, request, context)
104+
const parsed = await parseRequest(v1UploadKnowledgeDocumentContract, request, context, {
105+
validationErrorResponse: v1ValidationErrorResponse,
106+
})
103107
if (!parsed.success) return parsed.response
104108
const { id: knowledgeBaseId } = parsed.data.params
105109

apps/sim/app/api/v1/knowledge/[id]/route.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
handleError,
1414
resolveKnowledgeBase,
1515
} from '@/app/api/v1/knowledge/utils'
16-
import { authenticateRequest } from '@/app/api/v1/middleware'
16+
import { authenticateRequest, v1ValidationErrorResponse } from '@/app/api/v1/middleware'
1717

1818
export const dynamic = 'force-dynamic'
1919
export const revalidate = 0
@@ -29,7 +29,9 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Knowle
2929
const { requestId, userId, rateLimit } = auth
3030

3131
try {
32-
const parsed = await parseRequest(v1GetKnowledgeBaseContract, request, context)
32+
const parsed = await parseRequest(v1GetKnowledgeBaseContract, request, context, {
33+
validationErrorResponse: v1ValidationErrorResponse,
34+
})
3335
if (!parsed.success) return parsed.response
3436

3537
const { id } = parsed.data.params
@@ -54,7 +56,9 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle
5456
const { requestId, userId, rateLimit } = auth
5557

5658
try {
57-
const parsed = await parseRequest(v1UpdateKnowledgeBaseContract, request, context)
59+
const parsed = await parseRequest(v1UpdateKnowledgeBaseContract, request, context, {
60+
validationErrorResponse: v1ValidationErrorResponse,
61+
})
5862
if (!parsed.success) return parsed.response
5963

6064
const { id } = parsed.data.params
@@ -106,7 +110,9 @@ export const DELETE = withRouteHandler(
106110
const { requestId, userId, rateLimit } = auth
107111

108112
try {
109-
const parsed = await parseRequest(v1DeleteKnowledgeBaseContract, request, context)
113+
const parsed = await parseRequest(v1DeleteKnowledgeBaseContract, request, context, {
114+
validationErrorResponse: v1ValidationErrorResponse,
115+
})
110116
if (!parsed.success) return parsed.response
111117

112118
const { id } = parsed.data.params

0 commit comments

Comments
 (0)