Skip to content

Commit 98aa51f

Browse files
feat(api): add file metadata endpoint
1 parent f01c4be commit 98aa51f

4 files changed

Lines changed: 278 additions & 0 deletions

File tree

apps/docs/openapi-v2-files-audit.json

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,85 @@
680680
}
681681
}
682682
},
683+
"/api/v2/files/{fileId}/metadata": {
684+
"get": {
685+
"operationId": "getFile",
686+
"summary": "Get File Metadata",
687+
"description": "Return one workspace file's metadata without downloading its content. Lookups are workspace-scoped: a file that belongs to another workspace returns `404`.",
688+
"tags": ["Files"],
689+
"x-codeSamples": [
690+
{
691+
"id": "curl",
692+
"label": "cURL",
693+
"lang": "bash",
694+
"source": "curl -X GET \\\n \"https://www.sim.ai/api/v2/files/{fileId}/metadata?workspaceId=YOUR_WORKSPACE_ID\" \\\n -H \"X-API-Key: YOUR_API_KEY\""
695+
}
696+
],
697+
"parameters": [
698+
{
699+
"$ref": "#/components/parameters/FileIdPath"
700+
},
701+
{
702+
"$ref": "#/components/parameters/WorkspaceIdQuery"
703+
}
704+
],
705+
"responses": {
706+
"200": {
707+
"description": "The file metadata.",
708+
"headers": {
709+
"X-RateLimit-Limit": {
710+
"$ref": "#/components/headers/X-RateLimit-Limit"
711+
},
712+
"X-RateLimit-Remaining": {
713+
"$ref": "#/components/headers/X-RateLimit-Remaining"
714+
},
715+
"X-RateLimit-Reset": {
716+
"$ref": "#/components/headers/X-RateLimit-Reset"
717+
}
718+
},
719+
"content": {
720+
"application/json": {
721+
"schema": {
722+
"$ref": "#/components/schemas/V2FileResponse"
723+
},
724+
"example": {
725+
"data": {
726+
"id": "wf_V1StGXR8z5jdHi6BmyT91",
727+
"name": "data.csv",
728+
"size": 1024,
729+
"type": "text/csv",
730+
"key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv",
731+
"folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu",
732+
"folderPath": "Reports/Q1",
733+
"uploadedBy": "user_abc123",
734+
"uploadedAt": "2026-01-15T10:30:00Z",
735+
"updatedAt": "2026-01-15T10:30:00Z"
736+
}
737+
}
738+
}
739+
}
740+
},
741+
"400": {
742+
"$ref": "#/components/responses/BadRequest"
743+
},
744+
"401": {
745+
"$ref": "#/components/responses/Unauthorized"
746+
},
747+
"403": {
748+
"$ref": "#/components/responses/Forbidden"
749+
},
750+
"404": {
751+
"$ref": "#/components/responses/NotFound"
752+
},
753+
"429": {
754+
"$ref": "#/components/responses/RateLimited"
755+
},
756+
"500": {
757+
"$ref": "#/components/responses/InternalError"
758+
}
759+
}
760+
}
761+
},
683762
"/api/v2/audit-logs": {
684763
"get": {
685764
"operationId": "listAuditLogs",
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { NextRequest } from 'next/server'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockGetWorkspaceFile } = vi.hoisted(() => ({
8+
mockCheckRateLimit: vi.fn(),
9+
mockResolveWorkspaceAccess: vi.fn(),
10+
mockGetWorkspaceFile: vi.fn(),
11+
}))
12+
13+
vi.mock('@/app/api/v1/middleware', () => ({
14+
checkRateLimit: mockCheckRateLimit,
15+
resolveWorkspaceAccess: mockResolveWorkspaceAccess,
16+
}))
17+
18+
vi.mock('@/app/api/v2/lib/gate', () => ({
19+
v2ApiGateError: vi.fn().mockResolvedValue(null),
20+
}))
21+
22+
vi.mock('@/lib/uploads/contexts/workspace', () => ({
23+
getWorkspaceFile: mockGetWorkspaceFile,
24+
}))
25+
26+
import { GET } from '@/app/api/v2/files/[fileId]/metadata/route'
27+
28+
const WORKSPACE_ID = 'workspace-1'
29+
const FILE_ID = 'wf_1'
30+
31+
const RATE_LIMIT_OK = {
32+
allowed: true,
33+
userId: 'user-1',
34+
keyType: 'workspace',
35+
limit: 100,
36+
remaining: 99,
37+
resetAt: new Date('2024-01-01T01:00:00Z'),
38+
}
39+
40+
const ctx = { params: Promise.resolve({ fileId: FILE_ID }) }
41+
42+
function buildRecord() {
43+
return {
44+
id: FILE_ID,
45+
workspaceId: WORKSPACE_ID,
46+
name: 'data.csv',
47+
key: 'workspace/ws/1-x-data.csv',
48+
path: '/api/files/serve/x',
49+
size: 1024,
50+
type: 'text/csv',
51+
uploadedBy: 'user-1',
52+
folderId: null,
53+
folderPath: null,
54+
uploadedAt: new Date('2024-01-01T00:00:00Z'),
55+
updatedAt: new Date('2024-01-02T00:00:00Z'),
56+
}
57+
}
58+
59+
const callGet = (query: string) =>
60+
GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/metadata?${query}`), ctx)
61+
62+
describe('GET /api/v2/files/[fileId]/metadata', () => {
63+
beforeEach(() => {
64+
vi.clearAllMocks()
65+
mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
66+
mockResolveWorkspaceAccess.mockResolvedValue(null)
67+
mockGetWorkspaceFile.mockResolvedValue(buildRecord())
68+
})
69+
70+
it('400s when workspaceId is missing', async () => {
71+
const response = await callGet('')
72+
73+
expect(response.status).toBe(400)
74+
expect(mockGetWorkspaceFile).not.toHaveBeenCalled()
75+
})
76+
77+
it('surfaces an access-denied failure', async () => {
78+
mockResolveWorkspaceAccess.mockResolvedValue({
79+
status: 403,
80+
code: 'FORBIDDEN',
81+
message: 'Access denied',
82+
})
83+
84+
const response = await callGet(`workspaceId=${WORKSPACE_ID}`)
85+
86+
expect(response.status).toBe(403)
87+
expect(mockGetWorkspaceFile).not.toHaveBeenCalled()
88+
})
89+
90+
it('404s when the workspace-scoped file does not exist', async () => {
91+
mockGetWorkspaceFile.mockResolvedValue(null)
92+
93+
const response = await callGet(`workspaceId=${WORKSPACE_ID}`)
94+
95+
expect(response.status).toBe(404)
96+
expect((await response.json()).error.code).toBe('NOT_FOUND')
97+
})
98+
99+
it('returns the public metadata projection without loading content', async () => {
100+
const response = await callGet(`workspaceId=${WORKSPACE_ID}`)
101+
102+
expect(response.status).toBe(200)
103+
expect(await response.json()).toEqual({
104+
data: {
105+
id: FILE_ID,
106+
name: 'data.csv',
107+
size: 1024,
108+
type: 'text/csv',
109+
key: 'workspace/ws/1-x-data.csv',
110+
folderId: null,
111+
folderPath: null,
112+
uploadedBy: 'user-1',
113+
uploadedAt: '2024-01-01T00:00:00.000Z',
114+
updatedAt: '2024-01-02T00:00:00.000Z',
115+
},
116+
})
117+
expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(
118+
expect.anything(),
119+
'user-1',
120+
WORKSPACE_ID,
121+
'read'
122+
)
123+
expect(mockGetWorkspaceFile).toHaveBeenCalledWith(WORKSPACE_ID, FILE_ID, {
124+
throwOnError: true,
125+
})
126+
})
127+
})
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import type { NextRequest } from 'next/server'
4+
import { v2GetFileContract } from '@/lib/api/contracts/v2/files'
5+
import { parseRequest } from '@/lib/api/server'
6+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
7+
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
8+
import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
9+
import { toV2File } from '@/app/api/v2/files/utils'
10+
import { v2ApiGateError } from '@/app/api/v2/lib/gate'
11+
import {
12+
v2Data,
13+
v2Error,
14+
v2RateLimitError,
15+
v2ValidationError,
16+
v2WorkspaceAccessError,
17+
} from '@/app/api/v2/lib/response'
18+
19+
const logger = createLogger('V2FileMetadataAPI')
20+
21+
export const dynamic = 'force-dynamic'
22+
export const revalidate = 0
23+
24+
interface FileMetadataRouteParams {
25+
params: Promise<{ fileId: string }>
26+
}
27+
28+
/** GET /api/v2/files/[fileId]/metadata — Return file metadata without downloading its bytes. */
29+
export const GET = withRouteHandler(
30+
async (request: NextRequest, context: FileMetadataRouteParams) => {
31+
try {
32+
const rateLimit = await checkRateLimit(request, 'file-detail')
33+
if (!rateLimit.allowed) return v2RateLimitError(rateLimit)
34+
35+
const userId = rateLimit.userId!
36+
const gate = await v2ApiGateError(userId)
37+
if (gate) return gate
38+
39+
const parsed = await parseRequest(v2GetFileContract, request, context, {
40+
validationErrorResponse: v2ValidationError,
41+
})
42+
if (!parsed.success) return parsed.response
43+
44+
const { fileId } = parsed.data.params
45+
const { workspaceId } = parsed.data.query
46+
47+
const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
48+
if (access) return v2WorkspaceAccessError(access)
49+
50+
const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true })
51+
if (!file) return v2Error('NOT_FOUND', 'File not found')
52+
53+
return v2Data(toV2File(file), { rateLimit })
54+
} catch (error) {
55+
logger.error('Error fetching file metadata', {
56+
error: getErrorMessage(error, 'Unknown error'),
57+
})
58+
return v2Error('INTERNAL_ERROR', 'Internal server error')
59+
}
60+
}
61+
)

apps/sim/lib/api/contracts/v2/files.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,17 @@ export const v2DownloadFileContract = defineRouteContract({
424424
},
425425
})
426426

427+
export const v2GetFileContract = defineRouteContract({
428+
method: 'GET',
429+
path: '/api/v2/files/[fileId]/metadata',
430+
params: v2FileParamsSchema,
431+
query: v2FileWorkspaceQuerySchema,
432+
response: {
433+
mode: 'json',
434+
schema: v2DataResponse(v2FileSchema),
435+
},
436+
})
437+
427438
export const v2RenameFileContract = defineRouteContract({
428439
method: 'PATCH',
429440
path: '/api/v2/files/[fileId]',

0 commit comments

Comments
 (0)