Skip to content

Commit 9885bf7

Browse files
committed
fix(table): route boundary validation through Zod contracts
Switch the table query hooks and the import/export routes to the codebase's contract-based request pattern so the API validation audit and boundary policy ratchet pass. - `hooks/queries/tables.ts` now calls every endpoint via `requestJson(contract, ...)`; the only remaining raw `fetch` calls are the streaming export download and the multipart CSV upload, both annotated `boundary-raw-fetch:`. - The import and export routes parse `params`, the `format` query, and the multipart form fields with shared schemas from `@/lib/api/contracts/tables`. Two new contract schemas (`csvImportCreateColumnsSchema`, `tableExportFormatSchema`) cover the fields specific to these routes. - Bumps the audit baseline by one route to account for the new export endpoint.
1 parent 477fbac commit 9885bf7

5 files changed

Lines changed: 230 additions & 330 deletions

File tree

apps/sim/app/api/table/[tableId]/export/route.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
3+
import { tableExportFormatSchema, tableIdParamsSchema } from '@/lib/api/contracts/tables'
4+
import { getValidationErrorMessage } from '@/lib/api/server'
35
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
46
import { generateRequestId } from '@/lib/core/utils/request'
57
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -9,8 +11,8 @@ import { accessError, checkAccess } from '@/app/api/table/utils'
911
const logger = createLogger('TableExport')
1012

1113
const EXPORT_BATCH_SIZE = 1000
12-
const SUPPORTED_FORMATS = ['csv', 'json'] as const
13-
type ExportFormat = (typeof SUPPORTED_FORMATS)[number]
14+
15+
type ExportFormat = 'csv' | 'json'
1416

1517
interface RouteParams {
1618
params: Promise<{ tableId: string }>
@@ -19,22 +21,24 @@ interface RouteParams {
1921
/** GET /api/table/[tableId]/export - Streams the full table contents as CSV or JSON. */
2022
export const GET = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
2123
const requestId = generateRequestId()
22-
const { tableId } = await params
24+
const { tableId } = tableIdParamsSchema.parse(await params)
2325

2426
const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
2527
if (!auth.success || !auth.userId) {
2628
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
2729
}
2830

2931
const { searchParams } = new URL(request.url)
30-
const rawFormat = (searchParams.get('format') ?? 'csv').toLowerCase()
31-
if (!SUPPORTED_FORMATS.includes(rawFormat as ExportFormat)) {
32+
const formatValidation = tableExportFormatSchema.safeParse(
33+
searchParams.get('format') ?? undefined
34+
)
35+
if (!formatValidation.success) {
3236
return NextResponse.json(
33-
{ error: `Unsupported format. Use one of: ${SUPPORTED_FORMATS.join(', ')}` },
37+
{ error: getValidationErrorMessage(formatValidation.error) },
3438
{ status: 400 }
3539
)
3640
}
37-
const format = rawFormat as ExportFormat
41+
const format: ExportFormat = formatValidation.data
3842

3943
const access = await checkAccess(tableId, auth.userId, 'read')
4044
if (!access.ok) return accessError(access, requestId, tableId)

apps/sim/app/api/table/[tableId]/import/route.ts

Lines changed: 46 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ import { createLogger } from '@sim/logger'
33
import { toError } from '@sim/utils/errors'
44
import { generateId } from '@sim/utils/id'
55
import { type NextRequest, NextResponse } from 'next/server'
6+
import {
7+
csvExtensionSchema,
8+
csvImportCreateColumnsSchema,
9+
csvImportFormSchema,
10+
csvImportMappingSchema,
11+
csvImportModeSchema,
12+
tableIdParamsSchema,
13+
} from '@/lib/api/contracts/tables'
14+
import { getValidationErrorMessage } from '@/lib/api/server'
615
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
716
import { generateRequestId } from '@/lib/core/utils/request'
817
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -11,7 +20,6 @@ import {
1120
batchInsertRowsWithTx,
1221
buildAutoMapping,
1322
CSV_MAX_BATCH_SIZE,
14-
CSV_MAX_FILE_SIZE_BYTES,
1523
type CsvHeaderMapping,
1624
CsvImportValidationError,
1725
coerceRowsForTable,
@@ -27,15 +35,13 @@ import { accessError, checkAccess } from '@/app/api/table/utils'
2735

2836
const logger = createLogger('TableImportCSVExisting')
2937

30-
const IMPORT_MODES = new Set(['append', 'replace'])
31-
3238
interface RouteParams {
3339
params: Promise<{ tableId: string }>
3440
}
3541

3642
export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
3743
const requestId = generateRequestId()
38-
const { tableId } = await params
44+
const { tableId } = tableIdParamsSchema.parse(await params)
3945

4046
try {
4147
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
@@ -44,40 +50,39 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
4450
}
4551

4652
const formData = await request.formData()
47-
const file = formData.get('file')
48-
const workspaceId = formData.get('workspaceId') as string | null
49-
const rawMode = (formData.get('mode') as string | null) ?? 'append'
50-
const rawMapping = formData.get('mapping') as string | null
51-
const rawCreateColumns = formData.get('createColumns') as string | null
52-
53-
if (!file || !(file instanceof File)) {
54-
return NextResponse.json({ error: 'CSV file is required' }, { status: 400 })
55-
}
56-
57-
if (file.size > CSV_MAX_FILE_SIZE_BYTES) {
53+
const formValidation = csvImportFormSchema.safeParse({
54+
file: formData.get('file'),
55+
workspaceId: formData.get('workspaceId'),
56+
})
57+
const rawMode = formData.get('mode') ?? 'append'
58+
const rawMapping = formData.get('mapping')
59+
const rawCreateColumns = formData.get('createColumns')
60+
61+
if (!formValidation.success) {
5862
return NextResponse.json(
59-
{
60-
error: `File exceeds maximum allowed size of ${CSV_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB`,
61-
},
63+
{ error: getValidationErrorMessage(formValidation.error) },
6264
{ status: 400 }
6365
)
6466
}
6567

66-
if (!workspaceId) {
67-
return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 })
68-
}
68+
const { file, workspaceId } = formValidation.data
6969

70-
if (!IMPORT_MODES.has(rawMode)) {
70+
const modeValidation = csvImportModeSchema.safeParse(rawMode)
71+
if (!modeValidation.success) {
7172
return NextResponse.json(
72-
{ error: `Invalid mode "${rawMode}". Must be "append" or "replace".` },
73+
{ error: `Invalid mode "${String(rawMode)}". Must be "append" or "replace".` },
7374
{ status: 400 }
7475
)
7576
}
76-
const mode = rawMode as 'append' | 'replace'
77+
const mode = modeValidation.data
7778

7879
const ext = file.name.split('.').pop()?.toLowerCase()
79-
if (ext !== 'csv' && ext !== 'tsv') {
80-
return NextResponse.json({ error: 'Only CSV and TSV files are supported' }, { status: 400 })
80+
const extensionValidation = csvExtensionSchema.safeParse(ext)
81+
if (!extensionValidation.success) {
82+
return NextResponse.json(
83+
{ error: getValidationErrorMessage(extensionValidation.error) },
84+
{ status: 400 }
85+
)
8186
}
8287

8388
const accessResult = await checkAccess(tableId, authResult.userId, 'write')
@@ -98,38 +103,30 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
98103

99104
let mapping: CsvHeaderMapping | undefined
100105
if (rawMapping) {
101-
try {
102-
const parsed = JSON.parse(rawMapping)
103-
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
104-
return NextResponse.json(
105-
{ error: 'mapping must be a JSON object mapping CSV headers to column names' },
106-
{ status: 400 }
107-
)
108-
}
109-
mapping = parsed as CsvHeaderMapping
110-
} catch {
111-
return NextResponse.json({ error: 'mapping must be valid JSON' }, { status: 400 })
106+
const mappingValidation = csvImportMappingSchema.safeParse(rawMapping)
107+
if (!mappingValidation.success) {
108+
return NextResponse.json(
109+
{ error: getValidationErrorMessage(mappingValidation.error) },
110+
{ status: 400 }
111+
)
112112
}
113+
mapping = mappingValidation.data
113114
}
114115

115116
let createColumns: string[] | undefined
116117
if (rawCreateColumns) {
117-
try {
118-
const parsed = JSON.parse(rawCreateColumns)
119-
if (!Array.isArray(parsed) || parsed.some((h) => typeof h !== 'string')) {
120-
return NextResponse.json(
121-
{ error: 'createColumns must be a JSON array of CSV header names' },
122-
{ status: 400 }
123-
)
124-
}
125-
createColumns = parsed as string[]
126-
} catch {
127-
return NextResponse.json({ error: 'createColumns must be valid JSON' }, { status: 400 })
118+
const createColumnsValidation = csvImportCreateColumnsSchema.safeParse(rawCreateColumns)
119+
if (!createColumnsValidation.success) {
120+
return NextResponse.json(
121+
{ error: getValidationErrorMessage(createColumnsValidation.error) },
122+
{ status: 400 }
123+
)
128124
}
125+
createColumns = createColumnsValidation.data
129126
}
130127

131128
const buffer = Buffer.from(await file.arrayBuffer())
132-
const delimiter = ext === 'tsv' ? '\t' : ','
129+
const delimiter = extensionValidation.data === 'tsv' ? '\t' : ','
133130
const { headers, rows } = await parseCsvBuffer(buffer, delimiter)
134131

135132
let effectiveMapping = mapping ?? buildAutoMapping(headers, table.schema)

0 commit comments

Comments
 (0)