From 79177e92fad7dd6658e6a96f0e141b14d263cbf5 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 6 Aug 2026 22:28:46 -0700 Subject: [PATCH] improvement(api): consolidate public v2 route handling --- .../app/api/public-api-route-handler.test.ts | 272 +++++++++++ apps/sim/app/api/public-api-route-handler.ts | 79 ++++ apps/sim/app/api/v1/middleware.test.ts | 24 + apps/sim/app/api/v1/middleware.ts | 14 +- apps/sim/app/api/v2/audit-logs/[id]/route.ts | 90 ++-- apps/sim/app/api/v2/audit-logs/route.ts | 53 +-- apps/sim/app/api/v2/billing/logs/route.ts | 51 +- apps/sim/app/api/v2/billing/status/route.ts | 47 +- apps/sim/app/api/v2/credentials/route.ts | 50 +- .../sim/app/api/v2/custom-tools/[id]/route.ts | 208 +++------ apps/sim/app/api/v2/custom-tools/route.ts | 166 +++---- .../api/v2/files/[fileId]/content/route.ts | 56 +-- .../api/v2/files/[fileId]/metadata/route.ts | 72 +-- apps/sim/app/api/v2/files/[fileId]/route.ts | 98 +--- .../app/api/v2/files/[fileId]/share/route.ts | 80 +--- .../sim/app/api/v2/files/bulk-delete/route.ts | 49 +- apps/sim/app/api/v2/files/folders/route.ts | 182 +++----- apps/sim/app/api/v2/files/move/route.ts | 49 +- apps/sim/app/api/v2/files/route.ts | 147 +++--- .../uploads/[uploadId]/complete/route.ts | 46 +- .../files/uploads/[uploadId]/parts/route.ts | 48 +- .../api/v2/files/uploads/[uploadId]/route.ts | 46 +- apps/sim/app/api/v2/files/uploads/route.ts | 101 ++-- .../[id]/documents/[documentId]/route.ts | 217 ++++----- .../api/v2/knowledge/[id]/documents/route.ts | 86 +--- .../uploads/[uploadId]/complete/route.ts | 59 +-- .../uploads/[uploadId]/parts/route.ts | 58 +-- .../documents/uploads/[uploadId]/route.ts | 53 +-- .../knowledge/[id]/documents/uploads/route.ts | 51 +- apps/sim/app/api/v2/knowledge/[id]/route.ts | 166 ++----- .../sim/app/api/v2/knowledge/folders/route.ts | 212 +++------ apps/sim/app/api/v2/knowledge/route.ts | 84 +--- apps/sim/app/api/v2/knowledge/search/route.ts | 437 ++++++++---------- apps/sim/app/api/v2/logs/[runId]/route.ts | 134 +++--- apps/sim/app/api/v2/logs/route.ts | 55 +-- apps/sim/app/api/v2/mcp-servers/[id]/route.ts | 116 +---- apps/sim/app/api/v2/mcp-servers/route.ts | 95 +--- apps/sim/app/api/v2/secrets/[name]/route.ts | 74 +-- apps/sim/app/api/v2/secrets/route.ts | 49 +- apps/sim/app/api/v2/skills/[id]/route.ts | 116 +---- apps/sim/app/api/v2/skills/route.ts | 91 +--- .../v2/tables/[tableId]/cancel-runs/route.ts | 121 ++--- .../api/v2/tables/[tableId]/columns/route.ts | 210 +++------ .../v2/tables/[tableId]/columns/run/route.ts | 145 +++--- .../api/v2/tables/[tableId]/exports/route.ts | 83 ++-- .../api/v2/tables/[tableId]/groups/route.ts | 407 +++++++--------- .../api/v2/tables/[tableId]/query/route.ts | 198 ++++---- apps/sim/app/api/v2/tables/[tableId]/route.ts | 349 ++++++-------- .../[rowId]/enrichment/[groupId]/route.ts | 52 +-- .../v2/tables/[tableId]/rows/[rowId]/route.ts | 226 ++++----- .../v2/tables/[tableId]/rows/find/route.ts | 148 +++--- .../app/api/v2/tables/[tableId]/rows/route.ts | 310 +++++-------- .../v2/tables/[tableId]/rows/upsert/route.ts | 122 ++--- .../tables/[tableId]/views/[viewId]/route.ts | 146 ++---- .../api/v2/tables/[tableId]/views/route.ts | 145 ++---- .../exports/[exportId]/download/route.ts | 42 +- .../api/v2/tables/exports/[exportId]/route.ts | 69 +-- apps/sim/app/api/v2/tables/folders/route.ts | 202 +++----- .../imports/[importId]/complete/route.ts | 46 +- .../tables/imports/[importId]/parts/route.ts | 48 +- .../api/v2/tables/imports/[importId]/route.ts | 80 +--- apps/sim/app/api/v2/tables/imports/route.ts | 86 ++-- apps/sim/app/api/v2/tables/route.ts | 178 +++---- .../app/api/v2/workflows/[id]/deploy/route.ts | 75 +-- .../app/api/v2/workflows/[id]/export/route.ts | 119 ++--- .../api/v2/workflows/[id]/rollback/route.ts | 43 +- apps/sim/app/api/v2/workflows/[id]/route.ts | 326 ++++++------- .../[id]/versions/[version]/route.ts | 81 ++-- .../api/v2/workflows/[id]/versions/route.ts | 136 ++---- .../sim/app/api/v2/workflows/folders/route.ts | 207 +++------ apps/sim/app/api/v2/workflows/import/route.ts | 52 +-- apps/sim/app/api/v2/workflows/route.ts | 161 +++---- .../workspaces/[workspaceId]/members/route.ts | 100 ++-- .../api/v2/workspaces/[workspaceId]/route.ts | 48 +- apps/sim/lib/api/server/validation.ts | 11 +- apps/sim/lib/core/utils/with-route-handler.ts | 22 +- scripts/check-api-validation-contracts.ts | 10 + 77 files changed, 3307 insertions(+), 5678 deletions(-) create mode 100644 apps/sim/app/api/public-api-route-handler.test.ts create mode 100644 apps/sim/app/api/public-api-route-handler.ts diff --git a/apps/sim/app/api/public-api-route-handler.test.ts b/apps/sim/app/api/public-api-route-handler.test.ts new file mode 100644 index 00000000000..33757c33864 --- /dev/null +++ b/apps/sim/app/api/public-api-route-handler.test.ts @@ -0,0 +1,272 @@ +/** + * @vitest-environment node + */ +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import { recordRateLimitSnapshot } from '@/lib/api/server/rate-limit-context' + +const { + mockCheckRateLimit, + mockGate, + mockHandler, + mockLoggerError, + mockLoggerInfo, + requestContextState, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockGate: vi.fn(), + mockHandler: vi.fn(), + mockLoggerError: vi.fn(), + mockLoggerInfo: vi.fn(), + requestContextState: { + current: undefined as { requestId: string; method?: string; path?: string } | undefined, + }, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: () => ({ + info: (...arguments_: unknown[]) => + mockLoggerInfo(requestContextState.current?.requestId, ...arguments_), + warn: vi.fn(), + error: (...arguments_: unknown[]) => + mockLoggerError(requestContextState.current?.requestId, ...arguments_), + }), + getRequestContext: () => requestContextState.current, + runWithRequestContext: async ( + context: { requestId: string; method?: string; path?: string }, + callback: () => T | Promise + ): Promise => { + requestContextState.current = context + try { + return await callback() + } finally { + requestContextState.current = undefined + } + }, +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: () => requestContextState.current?.requestId ?? 'outer-request-id', +})) + +vi.mock('@/app/api/v1/middleware', () => ({ + checkRateLimit: mockCheckRateLimit, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: mockGate, +})) + +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' + +const RATE_LIMIT = { + allowed: true, + limit: 400, + remaining: 399, + resetAt: new Date('2026-08-06T20:00:00.000Z'), + userId: 'user-1', + keyType: 'personal' as const, +} + +const queryContract = defineRouteContract({ + method: 'POST', + path: '/api/test/:itemId', + params: z.object({ itemId: z.string().min(1) }), + query: z.object({ limit: z.coerce.number().int().positive() }), + body: z.object({ name: z.string().min(1) }), + response: { mode: 'json', schema: z.object({ ok: z.boolean() }) }, +}) + +const listContract = defineRouteContract({ + method: 'GET', + path: '/api/test', + query: z.object({ workspaceId: z.string().min(1) }), + response: { mode: 'json', schema: z.object({ ok: z.boolean() }) }, +}) + +const POST = withPublicApiRouteHandler({ + contract: queryContract, + rateLimitEndpoint: 'table-rows', + parseOptions: { + maxBodyBytes: 32, + payloadTooLargeResponse: () => + NextResponse.json({ error: 'Custom payload limit response' }, { status: 413 }), + }, + handler: async (arguments_) => { + mockHandler(arguments_) + return NextResponse.json({ ok: true }) + }, +}) + +const GET = withPublicApiRouteHandler({ + contract: listContract, + rateLimitEndpoint: 'tables', + handler: async (arguments_) => { + mockHandler(arguments_) + return NextResponse.json({ ok: true }) + }, +}) + +const FAILING_GET = withPublicApiRouteHandler({ + contract: listContract, + rateLimitEndpoint: 'tables', + handler: async () => { + throw new Error('handler failed') + }, +}) + +function postRequest(body: string): NextRequest { + return new NextRequest('http://localhost:3000/api/test/item-1?limit=10', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }) +} + +function listRequest(query = 'workspaceId=workspace-1'): NextRequest { + return new NextRequest(`http://localhost:3000/api/test?${query}`) +} + +describe('withPublicApiRouteHandler', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGate.mockResolvedValue(null) + mockCheckRateLimit.mockImplementation(async (request: NextRequest) => { + recordRateLimitSnapshot(request, RATE_LIMIT) + return RATE_LIMIT + }) + }) + + it.each([ + ['authentication failure', 401], + ['rate-limit denial', 429], + ])('short-circuits %s before reading or parsing the body', async (_label, status) => { + mockCheckRateLimit.mockImplementation(async (request: NextRequest) => { + if (status === 401) { + return { + allowed: false, + limit: 0, + remaining: 0, + resetAt: new Date('2026-08-06T20:00:00.000Z'), + error: 'API key required', + } + } + + recordRateLimitSnapshot(request, RATE_LIMIT) + return { ...RATE_LIMIT, allowed: false, remaining: 0, retryAfterMs: 30_000 } + }) + const request = postRequest('{not valid json') + + const response = await POST(request, { params: { itemId: 'item-1' } }) + + expect(response.status).toBe(status) + expect(request.bodyUsed).toBe(false) + expect(mockHandler).not.toHaveBeenCalled() + expect(mockCheckRateLimit).toHaveBeenCalledWith(request, 'table-rows') + expect(mockGate).not.toHaveBeenCalled() + if (status === 401) { + expect(response.headers.get('X-RateLimit-Limit')).toBe('0') + } else { + expect(response.headers.get('Retry-After')).toBe('30') + expect(response.headers.get('X-RateLimit-Limit')).toBe('400') + } + }) + + it('checks the v2 rollout gate before reading or parsing the body', async () => { + mockGate.mockResolvedValue(NextResponse.json({ error: 'Not found' }, { status: 404 })) + const request = postRequest('{not valid json') + + const response = await POST(request, { params: { itemId: 'item-1' } }) + + expect(response.status).toBe(404) + expect(request.bodyUsed).toBe(false) + expect(mockGate).toHaveBeenCalledWith('user-1') + expect(mockHandler).not.toHaveBeenCalled() + }) + + it('fails fast when an allowed rate-limit result has no user ID', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, userId: undefined }) + + const response = await GET(listRequest()) + + expect(response.status).toBe(500) + expect(mockGate).not.toHaveBeenCalled() + expect(mockHandler).not.toHaveBeenCalled() + }) + + it('returns a contract validation response after authentication', async () => { + const response = await POST(postRequest(JSON.stringify({ name: '' })), { + params: { itemId: 'item-1' }, + }) + + expect(response.status).toBe(400) + expect(response.headers.get('X-RateLimit-Limit')).toBe('400') + expect(mockHandler).not.toHaveBeenCalled() + }) + + it('forwards the body-size parse option', async () => { + const response = await POST(postRequest(JSON.stringify({ name: 'x'.repeat(40) })), { + params: { itemId: 'item-1' }, + }) + + expect(response.status).toBe(413) + expect(response.headers.get('X-RateLimit-Remaining')).toBe('399') + await expect(response.json()).resolves.toEqual({ error: 'Custom payload limit response' }) + expect(mockHandler).not.toHaveBeenCalled() + }) + + it('provides parsed params, query, body, and auth to the handler', async () => { + const request = postRequest(JSON.stringify({ name: 'Ada' })) + const response = await POST(request, { params: Promise.resolve({ itemId: 'item-1' }) }) + + expect(response.status).toBe(200) + expect(mockHandler).toHaveBeenCalledWith({ + request, + input: { + params: { itemId: 'item-1' }, + query: { limit: 10 }, + body: { name: 'Ada' }, + headers: undefined, + }, + auth: { + requestId: 'outer-request-id', + userId: 'user-1', + rateLimit: RATE_LIMIT, + }, + }) + expect(response.headers.get('x-request-id')).toBe('outer-request-id') + expect(response.headers.get('X-RateLimit-Reset')).toBe(RATE_LIMIT.resetAt.toISOString()) + expect(mockLoggerInfo).toHaveBeenCalledWith( + 'outer-request-id', + 'OK', + expect.objectContaining({ status: 200 }) + ) + }) + + it('supports direct invocation without a route context', async () => { + const request = listRequest() + const response = await GET(request) + + expect(response.status).toBe(200) + expect(mockHandler.mock.calls[0][0].input.query).toEqual({ workspaceId: 'workspace-1' }) + expect(mockCheckRateLimit).toHaveBeenCalledWith(request, 'tables') + }) + + it('keeps rate-limit and request headers on unhandled endpoint errors', async () => { + const response = await FAILING_GET(listRequest()) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, + }) + expect(response.headers.get('x-request-id')).toBe('outer-request-id') + expect(response.headers.get('X-RateLimit-Limit')).toBe('400') + expect(mockLoggerError).toHaveBeenCalledWith( + 'outer-request-id', + 'Unhandled route error', + expect.objectContaining({ error: 'handler failed' }) + ) + }) +}) diff --git a/apps/sim/app/api/public-api-route-handler.ts b/apps/sim/app/api/public-api-route-handler.ts new file mode 100644 index 00000000000..af25a4fe3b4 --- /dev/null +++ b/apps/sim/app/api/public-api-route-handler.ts @@ -0,0 +1,79 @@ +import type { NextRequest, NextResponse } from 'next/server' +import type { AnyApiRouteContract } from '@/lib/api/contracts' +import { type ParsedRequest, type ParseRequestOptions, parseRequest } from '@/lib/api/server' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { type ApiEndpoint, type AuthorizedRequest, checkRateLimit } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' + +interface PublicApiRouteContext { + params?: + | Promise> + | Record +} + +interface PublicApiRouteHandlerArguments { + request: NextRequest + input: ParsedRequest + auth: AuthorizedRequest +} + +interface PublicApiRouteHandlerOptions { + contract: C + rateLimitEndpoint: ApiEndpoint + parseOptions?: ParseRequestOptions + handler: ( + arguments_: PublicApiRouteHandlerArguments + ) => Promise | NextResponse | Response +} + +type PublicApiNextRouteHandler = ( + request: NextRequest, + context?: PublicApiRouteContext +) => Promise + +/** + * Wraps an API-key-authenticated public route with request context, rate + * limiting, authentication, and contract parsing before invoking the route's + * authorization and business logic. Unexpected endpoint errors are logged once + * by the shared route handler and rendered as the canonical v2 500 envelope. + */ +export function withPublicApiRouteHandler({ + contract, + rateLimitEndpoint, + parseOptions, + handler, +}: PublicApiRouteHandlerOptions): PublicApiNextRouteHandler { + const wrapped = withRouteHandler( + async (request, context) => { + const requestId = generateRequestId() + const rateLimit = await checkRateLimit(request, rateLimitEndpoint) + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + if (!rateLimit.userId) { + throw new Error('Allowed public API request is missing a user ID') + } + const userId = rateLimit.userId + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(contract, request, context ?? {}, { + validationErrorResponse: v2ValidationError, + ...parseOptions, + }) + if (!parsed.success) return parsed.response + + return handler({ + request, + input: parsed.data, + auth: { requestId, userId, rateLimit }, + }) + }, + { + unhandledErrorResponse: () => v2Error('INTERNAL_ERROR', 'Internal server error'), + } + ) + + return async (request, context) => wrapped(request, context) +} diff --git a/apps/sim/app/api/v1/middleware.test.ts b/apps/sim/app/api/v1/middleware.test.ts index c49850c6a45..94c0790b274 100644 --- a/apps/sim/app/api/v1/middleware.test.ts +++ b/apps/sim/app/api/v1/middleware.test.ts @@ -41,6 +41,7 @@ vi.mock('@/lib/core/rate-limiter', () => ({ })) import { + authenticateRequest, checkRateLimit, createRateLimitResponse, v1ValidationErrorResponse, @@ -107,6 +108,29 @@ describe('checkRateLimit', () => { }) }) +describe('authenticateRequest', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthenticateV1Request.mockResolvedValue({ + authenticated: true, + keyType: 'personal', + }) + mockGetSubscription.mockResolvedValue({ plan: 'team' }) + mockGetRateLimit.mockReturnValue(TEAM_BUCKET) + mockCheckRateLimit.mockResolvedValue({ + allowed: true, + remaining: 399, + resetAt: new Date('2026-07-28T18:28:48.354Z'), + }) + }) + + it('fails fast when an allowed result has no user ID', async () => { + await expect(authenticateRequest(request(), 'workflows')).rejects.toThrow( + 'Allowed public API request is missing a user ID' + ) + }) +}) + describe('createRateLimitResponse', () => { const throttled = { allowed: false, diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 0398b55364a..86e3012a4d6 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -94,6 +94,16 @@ export interface AuthorizedRequest { rateLimit: RateLimitResult } +export function requireRateLimitUserId(rateLimit: RateLimitResult): string { + if (!rateLimit.allowed) { + throw new Error('Cannot authorize a denied public API request') + } + if (!rateLimit.userId) { + throw new Error('Allowed public API request is missing a user ID') + } + return rateLimit.userId +} + export async function checkRateLimit( request: NextRequest, endpoint: ApiEndpoint = 'logs' @@ -170,7 +180,7 @@ export async function checkRateLimit( } /** - * Authenticates and rate-limits a v1 API request. + * Authenticates and rate-limits a public API request. * Returns NextResponse on failure, AuthorizedRequest on success. */ export async function authenticateRequest( @@ -182,7 +192,7 @@ export async function authenticateRequest( if (!rateLimit.allowed) { return createRateLimitResponse(rateLimit) } - return { requestId, userId: rateLimit.userId!, rateLimit } + return { requestId, userId: requireRateLimitUserId(rateLimit), rateLimit } } export function createRateLimitResponse(result: RateLimitResult): NextResponse { diff --git a/apps/sim/app/api/v2/audit-logs/[id]/route.ts b/apps/sim/app/api/v2/audit-logs/[id]/route.ts index 6d6f819dd8b..242f07ada89 100644 --- a/apps/sim/app/api/v2/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v2/audit-logs/[id]/route.ts @@ -1,21 +1,12 @@ import { db } from '@sim/db' import { auditLog } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' import { v2GetAuditLogContract } from '@/lib/api/contracts/v2/audit-logs' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query' -import { checkRateLimit } from '@/app/api/v1/middleware' import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' - -const logger = createLogger('V2AuditLogDetailAPI') +import { v2Data, v2Error } from '@/app/api/v2/lib/response' export const revalidate = 0 @@ -26,59 +17,36 @@ export const revalidate = 0 * organization. Audit logs are personal-key-only because a workspace-scoped * key must never expand into organization-wide visibility. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'audit-logs') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetAuditLogContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - if (rateLimit.keyType !== 'personal') { - return v2Error('FORBIDDEN', 'Audit logs require a personal API key') - } +export const GET = withPublicApiRouteHandler({ + contract: v2GetAuditLogContract, + rateLimitEndpoint: 'audit-logs', + handler: async ({ input, auth: { userId, rateLimit } }) => { + if (rateLimit.keyType !== 'personal') { + return v2Error('FORBIDDEN', 'Audit logs require a personal API key') + } - const authResult = await resolveEnterpriseAuditAccess( - userId, - parsed.data.query.organizationId - ) - if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) + const authResult = await resolveEnterpriseAuditAccess(userId, input.query.organizationId) + if (!authResult.success) return v2Error('FORBIDDEN', authResult.message) - const { id } = parsed.data.params - const { organizationId, orgMemberIds } = authResult.context + const { id } = input.params + const { organizationId, orgMemberIds } = authResult.context - const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) - const scopeCondition = buildOrgScopeCondition({ - organizationId, - orgWorkspaceIds, - orgMemberIds, - includeDeparted: true, - }) + const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) + const scopeCondition = buildOrgScopeCondition({ + organizationId, + orgWorkspaceIds, + orgMemberIds, + includeDeparted: true, + }) - const [log] = await db - .select() - .from(auditLog) - .where(and(eq(auditLog.id, id), scopeCondition)) - .limit(1) + const [log] = await db + .select() + .from(auditLog) + .where(and(eq(auditLog.id, id), scopeCondition)) + .limit(1) - if (!log) return v2Error('NOT_FOUND', 'Audit log not found') + if (!log) return v2Error('NOT_FOUND', 'Audit log not found') - return v2Data(formatV2AuditLogEntry(log), { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Audit log detail fetch error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } - } -) + return v2Data(formatV2AuditLogEntry(log), { rateLimit }) + }, +}) diff --git a/apps/sim/app/api/v2/audit-logs/route.ts b/apps/sim/app/api/v2/audit-logs/route.ts index c6b730b8087..dca2168878e 100644 --- a/apps/sim/app/api/v2/audit-logs/route.ts +++ b/apps/sim/app/api/v2/audit-logs/route.ts @@ -1,10 +1,5 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { resolveEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' import { buildFilterConditions, @@ -12,17 +7,8 @@ import { getOrgWorkspaceIds, queryAuditLogs, } from '@/app/api/v1/audit-logs/query' -import { checkRateLimit } from '@/app/api/v1/middleware' import { formatV2AuditLogEntry } from '@/app/api/v2/audit-logs/format' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Error, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2AuditLogsAPI') +import { v2CursorList, v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -34,29 +20,11 @@ export const revalidate = 0 * are personal-key-only because a workspace-scoped key must never expand into * organization-wide visibility. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'audit-logs') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListAuditLogsContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const params = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListAuditLogsContract, + rateLimitEndpoint: 'audit-logs', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const params = input.query if (rateLimit.keyType !== 'personal') { return v2Error('FORBIDDEN', 'Audit logs require a personal API key') @@ -96,10 +64,5 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) return v2CursorList(data.map(formatV2AuditLogEntry), nextCursor ?? null, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Audit logs fetch error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/billing/logs/route.ts b/apps/sim/app/api/v2/billing/logs/route.ts index 9ba119213c3..b2893ec95b7 100644 --- a/apps/sim/app/api/v2/billing/logs/route.ts +++ b/apps/sim/app/api/v2/billing/logs/route.ts @@ -1,50 +1,20 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2ListBillingLogsContract } from '@/lib/api/contracts/v2/billing' -import { parseRequest } from '@/lib/api/server' import { getUsageCreditsByLogId, getUserUsageLogs } from '@/lib/billing/core/usage-log' import { toBillingUsageLogSource, toInternalUsageLogSources } from '@/lib/billing/usage-sources' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared' -import { checkRateLimit } from '@/app/api/v1/middleware' import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Error, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2BillingLogsAPI') +import { v2CursorList } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** Cursor-paged, credit-denominated billing ledger. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'billing-usage') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListBillingLogsContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { source, workspaceId, period, startDate, endDate, limit, cursor } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListBillingLogsContract, + rateLimitEndpoint: 'billing-usage', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { source, workspaceId, period, startDate, endDate, limit, cursor } = input.query const workspaceFilter = await v2BillingWorkspaceFilter(rateLimit, workspaceId) if (!workspaceFilter.ok) return workspaceFilter.response @@ -77,10 +47,5 @@ export const GET = withRouteHandler(async (request: NextRequest) => { result.pagination.hasMore ? (result.pagination.nextCursor ?? null) : null, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error listing billing logs`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/billing/status/route.ts b/apps/sim/app/api/v2/billing/status/route.ts index 8936001c50e..868e7dd0c06 100644 --- a/apps/sim/app/api/v2/billing/status/route.ts +++ b/apps/sim/app/api/v2/billing/status/route.ts @@ -1,11 +1,7 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { type V2BillingStatusData, v2GetBillingStatusContract, } from '@/lib/api/contracts/v2/billing' -import { parseRequest } from '@/lib/api/server' import { checkBillingBlocked, checkBillingEntityBlocked, @@ -19,41 +15,19 @@ import { import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { deriveBillingContext } from '@/lib/billing/core/usage-log' import { dollarsToCredits } from '@/lib/billing/credits/conversion' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { checkRateLimit } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { v2BillingWorkspaceFilter } from '@/app/api/v2/billing/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' - -const logger = createLogger('V2BillingStatusAPI') +import { v2Data } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** Current billing standing; ledger events are exposed separately by `/billing/logs`. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'billing-usage') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2GetBillingStatusContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const workspaceFilter = await v2BillingWorkspaceFilter(rateLimit, parsed.data.query.workspaceId) +export const GET = withPublicApiRouteHandler({ + contract: v2GetBillingStatusContract, + rateLimitEndpoint: 'billing-usage', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const workspaceFilter = await v2BillingWorkspaceFilter(rateLimit, input.query.workspaceId) if (!workspaceFilter.ok) return workspaceFilter.response let data: V2BillingStatusData @@ -109,10 +83,5 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } return v2Data(data, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error building billing status`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index 9e687641f42..c61307198f1 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -1,47 +1,20 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2Credential } from '@/app/api/v2/credentials/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2CredentialsAPI') +import { v2CursorList, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/credentials — List the credentials the caller can see in a workspace. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'credentials') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListCredentialsContract, - request, - {}, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, type, providerId, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListCredentialsContract, + rateLimitEndpoint: 'credentials', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, type, providerId, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -65,10 +38,5 @@ export const GET = withRouteHandler(async (request: NextRequest) => { // The per-workspace credential set is small and bounded → a single full page. return v2CursorList(credentials.map(toV2Credential), null, { rateLimit }) - } catch (error) { - logger.error('Error listing credentials', { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/custom-tools/[id]/route.ts b/apps/sim/app/api/v2/custom-tools/[id]/route.ts index e793c31c3ea..a20c9933873 100644 --- a/apps/sim/app/api/v2/custom-tools/[id]/route.ts +++ b/apps/sim/app/api/v2/custom-tools/[id]/route.ts @@ -1,33 +1,19 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2DeleteCustomToolContract, v2GetCustomToolContract, v2UpdateCustomToolContract, } from '@/lib/api/contracts/v2/custom-tools' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { deleteWorkspaceCustomTool, getWorkspaceCustomTool, getWorkspaceCustomToolByTitle, updateWorkspaceCustomTool, } from '@/lib/workflows/custom-tools/operations' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2CustomToolDetailAPI') +import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -37,25 +23,12 @@ interface RouteContext { } /** GET /api/v2/custom-tools/[id] — Fetch a single custom tool. */ -export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'custom-tool-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetCustomToolContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2GetCustomToolContract, + rateLimitEndpoint: 'custom-tool-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { id } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -64,108 +37,76 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RouteC if (!tool) return v2Error('NOT_FOUND', 'Custom tool not found') return v2Data({ customTool: toV2CustomTool(tool) }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error fetching custom tool`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** PATCH /api/v2/custom-tools/[id] — Update a custom tool. Omitted fields keep their values. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'custom-tool-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateCustomToolContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId, title, schema, code } = parsed.data.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const current = await getWorkspaceCustomTool({ workspaceId, toolId: id }) - if (!current) return v2Error('NOT_FOUND', 'Custom tool not found') - - /** - * `upsertCustomTools` replaces title/schema/code wholesale and checks for a - * duplicate title only when inserting, so a rename onto an existing title - * would hit the `custom_tools_workspace_title_unique` index as a 500. Merge - * the partial body against the stored row and check the rename here. - */ - if (title !== undefined && title !== current.title) { - if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) { - return v2Error( - 'CONFLICT', - `A custom tool titled "${title}" already exists in this workspace` - ) +export const PATCH = withPublicApiRouteHandler({ + contract: v2UpdateCustomToolContract, + rateLimitEndpoint: 'custom-tool-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + try { + const { id } = input.params + const { workspaceId, title, schema, code } = input.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const current = await getWorkspaceCustomTool({ workspaceId, toolId: id }) + if (!current) return v2Error('NOT_FOUND', 'Custom tool not found') + + /** + * `upsertCustomTools` replaces title/schema/code wholesale and checks for a + * duplicate title only when inserting, so a rename onto an existing title + * would hit the `custom_tools_workspace_title_unique` index as a 500. Merge + * the partial body against the stored row and check the rename here. + */ + if (title !== undefined && title !== current.title) { + if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) { + return v2Error( + 'CONFLICT', + `A custom tool titled "${title}" already exists in this workspace` + ) + } } - } - const updated = await updateWorkspaceCustomTool({ - workspaceId, - toolId: id, - title: title ?? current.title, - schema: schema ?? current.schema, - code: code ?? current.code, - }) - if (!updated) return v2Error('NOT_FOUND', 'Custom tool not found') - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.CUSTOM_TOOL_UPDATED, - resourceType: AuditResourceType.CUSTOM_TOOL, - resourceId: updated.id, - resourceName: updated.title, - description: `Updated custom tool "${updated.title}" via API`, - request, - }) - - return v2Data({ customTool: toV2CustomTool(updated) }, { rateLimit }) - } catch (error) { - const writeError = v2CustomToolWriteError(error) - if (writeError) return writeError - - logger.error(`[${requestId}] Error updating custom tool`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + const updated = await updateWorkspaceCustomTool({ + workspaceId, + toolId: id, + title: title ?? current.title, + schema: schema ?? current.schema, + code: code ?? current.code, + }) + if (!updated) return v2Error('NOT_FOUND', 'Custom tool not found') + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.CUSTOM_TOOL_UPDATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: updated.id, + resourceName: updated.title, + description: `Updated custom tool "${updated.title}" via API`, + request, + }) + + return v2Data({ customTool: toV2CustomTool(updated) }, { rateLimit }) + } catch (error) { + const writeError = v2CustomToolWriteError(error) + if (writeError) return writeError + + throw error + } + }, }) /** DELETE /api/v2/custom-tools/[id] — Delete a custom tool. */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'custom-tool-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteCustomToolContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId } = parsed.data.query +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteCustomToolContract, + rateLimitEndpoint: 'custom-tool-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { id } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -188,10 +129,5 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou }) return v2Data({ id, deleted: true as const }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error deleting custom tool`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/custom-tools/route.ts b/apps/sim/app/api/v2/custom-tools/route.ts index 96b678a5078..37899f53a00 100644 --- a/apps/sim/app/api/v2/custom-tools/route.ts +++ b/apps/sim/app/api/v2/custom-tools/route.ts @@ -1,58 +1,27 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateCustomToolContract, v2ListCustomToolsContract, } from '@/lib/api/contracts/v2/custom-tools' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getWorkspaceCustomToolByTitle, listWorkspaceCustomTools, upsertCustomTools, } from '@/lib/workflows/custom-tools/operations' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2CustomTool, v2CustomToolWriteError } from '@/app/api/v2/custom-tools/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2CustomToolsAPI') +import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/custom-tools — List custom tools in a workspace. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'custom-tools') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListCustomToolsContract, - request, - {}, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListCustomToolsContract, + rateLimitEndpoint: 'custom-tools', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -61,76 +30,59 @@ export const GET = withRouteHandler(async (request: NextRequest) => { // The per-workspace tool set is small and bounded → a single full page. return v2CursorList(rows.map(toV2CustomTool), null, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error listing custom tools`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** POST /api/v2/custom-tools — Create a custom tool. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'custom-tools') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CreateCustomToolContract, - request, - {}, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, title, schema, code } = parsed.data.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - /** - * Titles are unique per workspace and tools resolve by title at call time, - * so a collision is reported rather than surfacing as a unique-index 500. - */ - if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) { - return v2Error('CONFLICT', `A custom tool titled "${title}" already exists in this workspace`) +export const POST = withPublicApiRouteHandler({ + contract: v2CreateCustomToolContract, + rateLimitEndpoint: 'custom-tools', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + try { + const { workspaceId, title, schema, code } = input.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + /** + * Titles are unique per workspace and tools resolve by title at call time, + * so a collision is reported rather than surfacing as a unique-index 500. + */ + if (await getWorkspaceCustomToolByTitle({ workspaceId, title })) { + return v2Error( + 'CONFLICT', + `A custom tool titled "${title}" already exists in this workspace` + ) + } + + const tools = await upsertCustomTools({ + tools: [{ title, schema, code }], + workspaceId, + userId, + requestId, + }) + const created = tools.find((tool) => tool.title === title) + if (!created) { + throw new Error(`Custom tool "${title}" missing after a successful write`) + } + + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.CUSTOM_TOOL_CREATED, + resourceType: AuditResourceType.CUSTOM_TOOL, + resourceId: created.id, + resourceName: created.title, + description: `Created custom tool "${created.title}" via API`, + request, + }) + + return v2Data({ customTool: toV2CustomTool(created) }, { rateLimit, status: 201 }) + } catch (error) { + const writeError = v2CustomToolWriteError(error) + if (writeError) return writeError + + throw error } - - const tools = await upsertCustomTools({ - tools: [{ title, schema, code }], - workspaceId, - userId, - requestId, - }) - const created = tools.find((tool) => tool.title === title) - if (!created) return v2Error('INTERNAL_ERROR', 'Internal server error') - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.CUSTOM_TOOL_CREATED, - resourceType: AuditResourceType.CUSTOM_TOOL, - resourceId: created.id, - resourceName: created.title, - description: `Created custom tool "${created.title}" via API`, - request, - }) - - return v2Data({ customTool: toV2CustomTool(created) }, { rateLimit, status: 201 }) - } catch (error) { - const writeError = v2CustomToolWriteError(error) - if (writeError) return writeError - - logger.error(`[${requestId}] Error creating custom tool`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts index 10fab0ec45a..3c295bd1306 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -1,35 +1,22 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2UpdateFileContentContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, performUpdateWorkspaceFileContent, } from '@/lib/workspace-files/orchestration' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2File } from '@/app/api/v2/files/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Data, v2Error, v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2FileContentAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface FileRouteParams { - params: Promise<{ fileId: string }> -} - /** * PUT /api/v2/files/[fileId]/content — Replace a file's bytes. * @@ -38,29 +25,17 @@ interface FileRouteParams { * 50 MB and still debits the workspace storage quota, so a write that would push * the payer past its limit fails with 413. */ -export const PUT = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'file-content') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateFileContentContract, request, context, { - invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), - maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) { - return parsed.response.status === 413 - ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') - : parsed.response - } - - const { fileId } = parsed.data.params - const { workspaceId, content, encoding } = parsed.data.body +export const PUT = withPublicApiRouteHandler({ + contract: v2UpdateFileContentContract, + rateLimitEndpoint: 'file-content', + parseOptions: { + invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), + maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + payloadTooLargeResponse: () => v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large'), + }, + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { fileId } = input.params + const { workspaceId, content, encoding } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -82,8 +57,5 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: FileRo } return v2Data(await toV2File(result.file), { rateLimit }) - } catch (error) { - logger.error('Error updating file content', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts index 2ba48d09070..f74c7fe55be 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts @@ -1,61 +1,27 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2GetFileContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2File } from '@/app/api/v2/files/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FileMetadataAPI') +import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -interface FileMetadataRouteParams { - params: Promise<{ fileId: string }> -} - /** GET /api/v2/files/[fileId]/metadata — Return file metadata without downloading its bytes. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: FileMetadataRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'file-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetFileContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { fileId } = parsed.data.params - const { workspaceId } = parsed.data.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) - if (!file) return v2Error('NOT_FOUND', 'File not found') - - return v2Data(await toV2File(file), { rateLimit }) - } catch (error) { - logger.error('Error fetching file metadata', { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } - } -) +export const GET = withPublicApiRouteHandler({ + contract: v2GetFileContract, + rateLimitEndpoint: 'file-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { fileId } = input.params + const { workspaceId } = input.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) + if (!file) return v2Error('NOT_FOUND', 'File not found') + + return v2Data(await toV2File(file), { rateLimit }) + }, +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index 24c9ddc56ee..53dec1d5470 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -1,29 +1,23 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2DeleteFileContract, v2DownloadFileContract, v2RenameFileContract, } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { performDeleteWorkspaceFileItems, performRenameWorkspaceFile, } from '@/lib/workspace-files/orchestration' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2File } from '@/app/api/v2/files/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { rateLimitHeaders, v2Data, v2Error, v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' @@ -32,10 +26,6 @@ const logger = createLogger('V2FileDetailAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -interface FileRouteParams { - params: Promise<{ fileId: string }> -} - /** * GET /api/v2/files/[fileId] — Download file content (binary). * @@ -43,23 +33,12 @@ interface FileRouteParams { * `X-RateLimit-*` headers. Errors still render the canonical v2 JSON error body. * Lookups are workspace-scoped (IDOR-safe): a file in another workspace 404s. */ -export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'file-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DownloadFileContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { fileId } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2DownloadFileContract, + rateLimitEndpoint: 'file-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { fileId } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -78,10 +57,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo ...rateLimitHeaders(rateLimit), }, }) - } catch (error) { - logger.error('Error downloading file', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** @@ -91,23 +67,12 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo * Names that collide within the destination folder are rejected as `CONFLICT` — * unlike upload, which auto-suffixes on the internal surface. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'file-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2RenameFileContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { fileId } = parsed.data.params - const { workspaceId, name } = parsed.data.body +export const PATCH = withPublicApiRouteHandler({ + contract: v2RenameFileContract, + rateLimitEndpoint: 'file-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { fileId } = input.params + const { workspaceId, name } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -122,10 +87,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: File } return v2Data(await toV2File(result.file), { rateLimit }) - } catch (error) { - logger.error('Error renaming file', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** @@ -136,23 +98,12 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: File * IP / user agent). Orchestration `errorCode`s map to specific v2 codes rather * than v1's blanket 500. */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'file-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteFileContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { fileId } = parsed.data.params - const { workspaceId } = parsed.data.query +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteFileContract, + rateLimitEndpoint: 'file-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { fileId } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -174,8 +125,5 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil logger.info(`Deleted file ${fileId} from workspace ${workspaceId}`) return v2Data({ id: fileId, deleted: true as const }, { rateLimit }) - } catch (error) { - logger.error('Error deleting file', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.ts index 088672432c5..22ff6225615 100644 --- a/apps/sim/app/api/v2/files/[fileId]/share/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.ts @@ -1,57 +1,28 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2GetFileShareContract, v2UpsertFileShareContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performGetWorkspaceFileShare, performUpsertWorkspaceFileShare, } from '@/lib/workspace-files/orchestration' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FileShareAPI') +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2ErrorForOrchestration, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -interface FileRouteParams { - params: Promise<{ fileId: string }> -} - /** * GET /api/v2/files/[fileId]/share — Read a file's public share state. * * `null` means the file has never been shared. `hasPassword` is the only signal * carried for a password-gated share; the ciphertext is never exposed. */ -export const GET = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'file-share') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetFileShareContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { fileId } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2GetFileShareContract, + rateLimitEndpoint: 'file-share', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { fileId } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -66,10 +37,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo } return v2Data({ share: result.share ?? null }, { rateLimit }) - } catch (error) { - logger.error('Error fetching file share', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** @@ -83,23 +51,12 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo * `isActive: false` disables, it does not revoke — the token and the stored * password / allow-list survive, so re-enabling resurrects the same URL. */ -export const PUT = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'file-share') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpsertFileShareContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { fileId } = parsed.data.params - const { workspaceId, isActive, authType, password, allowedEmails } = parsed.data.body +export const PUT = withPublicApiRouteHandler({ + contract: v2UpsertFileShareContract, + rateLimitEndpoint: 'file-share', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { fileId } = input.params + const { workspaceId, isActive, authType, password, allowedEmails } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -123,8 +80,5 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: FileRo } return v2Data({ share: result.share }, { rateLimit }) - } catch (error) { - logger.error('Error updating file share', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/files/bulk-delete/route.ts b/apps/sim/app/api/v2/files/bulk-delete/route.ts index e0bd99a4b89..accd962d9dc 100644 --- a/apps/sim/app/api/v2/files/bulk-delete/route.ts +++ b/apps/sim/app/api/v2/files/bulk-delete/route.ts @@ -1,23 +1,9 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2BulkDeleteFilesContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FileBulkDeleteAPI') +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2ErrorForOrchestration, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -26,25 +12,11 @@ export const revalidate = 0 * POST /api/v2/files/bulk-delete — Delete files. Folder deletion is owned by * `/api/v2/files/folders` so this resource operation never accepts folder ids. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'file-bulk-delete') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2BulkDeleteFilesContract, - request, - {}, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, fileIds } = parsed.data.body +export const POST = withPublicApiRouteHandler({ + contract: v2BulkDeleteFilesContract, + rateLimitEndpoint: 'file-bulk-delete', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { workspaceId, fileIds } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -64,8 +36,5 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } return v2Data({ deletedItems: { files: result.deletedItems.files } }, { rateLimit }) - } catch (error) { - logger.error('Error deleting files', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/files/folders/route.ts b/apps/sim/app/api/v2/files/folders/route.ts index 12b73d316f1..7ed1d0b4aee 100644 --- a/apps/sim/app/api/v2/files/folders/route.ts +++ b/apps/sim/app/api/v2/files/folders/route.ts @@ -1,15 +1,9 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateFileFolderContract, v2DeleteFileFolderContract, v2ListFileFoldersContract, v2RelocateFileFolderContract, } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { toFolderPathView } from '@/lib/folders/paths' import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' import { @@ -17,45 +11,23 @@ import { performDeleteWorkspaceFileFolderByPath, performRelocateWorkspaceFileFolderByPath, } from '@/lib/workspace-files/orchestration/file-folder-lifecycle' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { resolveFolderPathId, toV2PathFolder, v2FolderPathMutationError, } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FileFoldersAPI') +import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2ListFileFoldersContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListFileFoldersContract, + rateLimitEndpoint: 'files', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, parentPath, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -75,98 +47,66 @@ export const GET = withRouteHandler(async (request: NextRequest) => { null, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error listing file folders`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) -export const POST = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2CreateFileFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, +export const POST = withPublicApiRouteHandler({ + contract: v2CreateFileFolderContract, + rateLimitEndpoint: 'files', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path } = input.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await performCreateWorkspaceFileFolderAtPath({ workspaceId, userId, path }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await performCreateWorkspaceFileFolderAtPath({ workspaceId, userId, path }) - if (!result.success || !result.folder || !result.path) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') - } - return v2Data( - { folder: toFolderPathView(result.folder, result.path) }, - { rateLimit, status: 201 } - ) + return v2Data( + { folder: toFolderPathView(result.folder, result.path) }, + { rateLimit, status: 201 } + ) + }, }) -export const PATCH = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2RelocateFileFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, +export const PATCH = withPublicApiRouteHandler({ + contract: v2RelocateFileFolderContract, + rateLimitEndpoint: 'files', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path, destinationPath } = input.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await performRelocateWorkspaceFileFolderByPath({ + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path, destinationPath } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await performRelocateWorkspaceFileFolderByPath({ - workspaceId, - userId, - path, - destinationPath, - }) - if (!result.success || !result.folder || !result.path) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') - } - return v2Data({ folder: toFolderPathView(result.folder, result.path) }, { rateLimit }) + return v2Data({ folder: toFolderPathView(result.folder, result.path) }, { rateLimit }) + }, }) -export const DELETE = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2DeleteFileFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteFileFolderContract, + rateLimitEndpoint: 'files', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path, recursive } = input.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await performDeleteWorkspaceFileFolderByPath({ + workspaceId, + userId, + path, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path, recursive } = parsed.data.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await performDeleteWorkspaceFileFolderByPath({ - workspaceId, - userId, - path, - recursive, - }) - if (!result.success || !result.deletedItems) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') - } - return v2Data({ path, deleted: true as const, deletedItems: result.deletedItems }, { rateLimit }) + return v2Data( + { path, deleted: true as const, deletedItems: result.deletedItems }, + { rateLimit } + ) + }, }) diff --git a/apps/sim/app/api/v2/files/move/route.ts b/apps/sim/app/api/v2/files/move/route.ts index fa4f8b0053c..9d306d1269b 100644 --- a/apps/sim/app/api/v2/files/move/route.ts +++ b/apps/sim/app/api/v2/files/move/route.ts @@ -1,23 +1,9 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2MoveFileItemsContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FileMoveAPI') +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2ErrorForOrchestration, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -30,25 +16,11 @@ export const revalidate = 0 * collision at the destination fails the request as `CONFLICT` rather than * partially applying. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'file-move') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2MoveFileItemsContract, - request, - {}, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, fileIds, targetFolderPath } = parsed.data.body +export const POST = withPublicApiRouteHandler({ + contract: v2MoveFileItemsContract, + rateLimitEndpoint: 'file-move', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, fileIds, targetFolderPath } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -68,8 +40,5 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } return v2Data({ movedItems: { files: result.movedItems.files } }, { rateLimit }) - } catch (error) { - logger.error('Error moving file items', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index a87882803af..bd8fa79e89a 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -1,14 +1,9 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { type V2File, v2CreateFileContract, v2ListFilesContract, } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { queryWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' @@ -16,10 +11,10 @@ import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, performCreateWorkspaceFile, } from '@/lib/workspace-files/orchestration' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2File, toV2Files } from '@/app/api/v2/files/utils' import { resolveFolderPathId } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { cursorSortKey, decodeSortedCursor, @@ -30,13 +25,9 @@ import { v2Data, v2Error, v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2FilesAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -48,92 +39,61 @@ export const revalidate = 0 * {@link queryWorkspaceFiles}' query. The route only translates the validated * params and the opaque cursor, so a `search` never costs a full-workspace read. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListFilesContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, +export const GET = withPublicApiRouteHandler({ + contract: v2ListFilesContract, + rateLimitEndpoint: 'files', + handler: async ({ input, auth: { userId, rateLimit } }) => { + try { + const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = input.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file') + const folderId = + folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath) + if (folderPath !== undefined && folderId === undefined) { + return v2Error('NOT_FOUND', 'Folder not found') } - ) - if (!parsed.success) return parsed.response - const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = parsed.data.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file') - const folderId = - folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath) - if (folderPath !== undefined && folderId === undefined) { - return v2Error('NOT_FOUND', 'Folder not found') + const sort = cursorSortKey(sortBy, sortOrder) + const decoded = decodeSortedCursor(cursor, sort) + if (decoded.status === 'invalid') return v2CursorSortError() + + const { files, nextKeys } = await queryWorkspaceFiles(workspaceId, { + folderId, + search, + sortBy, + sortOrder, + limit, + after: decoded.status === 'ok' ? decoded.keys : undefined, + }) + + const items: V2File[] = await toV2Files(files) + const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null + + return v2CursorList(items, nextCursor, { rateLimit }) + } catch (error) { + // A cursor that doesn't fit the requested sort arrives classified as `validation` → 400. + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + throw error } - - const sort = cursorSortKey(sortBy, sortOrder) - const decoded = decodeSortedCursor(cursor, sort) - if (decoded.status === 'invalid') return v2CursorSortError() - - const { files, nextKeys } = await queryWorkspaceFiles(workspaceId, { - folderId, - search, - sortBy, - sortOrder, - limit, - after: decoded.status === 'ok' ? decoded.keys : undefined, - }) - - const items: V2File[] = await toV2Files(files) - const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null - - return v2CursorList(items, nextCursor, { rateLimit }) - } catch (error) { - // A cursor that doesn't fit the requested sort arrives classified as `validation` → 400. - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - logger.error('Error listing files', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** POST /api/v2/files — Create an authored workspace file, optionally with initial content. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CreateFileContract, - request, - {}, - { - invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), - maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) { - return parsed.response.status === 413 - ? v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large') - : parsed.response - } - - const { workspaceId, name, contentType, folderPath, content, encoding } = parsed.data.body +export const POST = withPublicApiRouteHandler({ + contract: v2CreateFileContract, + rateLimitEndpoint: 'files', + parseOptions: { + invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), + maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + payloadTooLargeResponse: () => v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large'), + }, + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { workspaceId, name, contentType, folderPath, content, encoding } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -155,8 +115,5 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } return v2Data(await toV2File(result.file), { rateLimit, status: 201 }) - } catch (error) { - logger.error('Error creating file', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts index c6339746dfb..87d477083ab 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts @@ -1,43 +1,22 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CompleteFileUploadContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { completeUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' import { finalizeWorkspaceFileUpload } from '@/app/api/files/uploads/finalizers' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CaughtOrchestrationError, v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2CompleteFileUploadAPI') - -interface FileUploadRouteParams { - params: Promise<{ uploadId: string }> -} - -export const POST = withRouteHandler( - async (request: NextRequest, context: FileUploadRouteParams) => { +export const POST = withPublicApiRouteHandler({ + contract: v2CompleteFileUploadContract, + rateLimitEndpoint: 'files', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2CompleteFileUploadContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { uploadId } = parsed.data.params - const { workspaceId } = parsed.data.query + const { uploadId } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) const session = await getOwnedUploadSession({ @@ -45,7 +24,7 @@ export const POST = withRouteHandler( workspaceId, userId, purpose: 'workspace_file', - uploadToken: parsed.data.headers['upload-token'], + uploadToken: input.headers['upload-token'], }) const result = await completeUploadSession({ session, @@ -63,8 +42,7 @@ export const POST = withRouteHandler( } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to complete file upload', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts index bec4c2dc8fa..926feab0e97 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts @@ -1,41 +1,20 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateFileUploadPartUrlsContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createUploadPartUrls, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2FileUploadPartsAPI') - -interface FileUploadRouteParams { - params: Promise<{ uploadId: string }> -} - -export const POST = withRouteHandler( - async (request: NextRequest, context: FileUploadRouteParams) => { +export const POST = withPublicApiRouteHandler({ + contract: v2CreateFileUploadPartUrlsContract, + rateLimitEndpoint: 'files', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2CreateFileUploadPartUrlsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { uploadId } = parsed.data.params - const { workspaceId } = parsed.data.query + const { uploadId } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) const session = await getOwnedUploadSession({ @@ -43,19 +22,18 @@ export const POST = withRouteHandler( workspaceId, userId, purpose: 'workspace_file', - uploadToken: parsed.data.headers['upload-token'], + uploadToken: input.headers['upload-token'], }) const parts = await createUploadPartUrls({ session, - partNumbers: parsed.data.body.partNumbers, + partNumbers: input.body.partNumbers, localOrigin: request.nextUrl.origin, }) return v2Data({ parts }, { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to create file upload part URLs', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts index f06579dcc35..49d085ffe68 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts @@ -1,42 +1,21 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2AbortFileUploadContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CaughtOrchestrationError, v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2FileUploadAPI') - -interface FileUploadRouteParams { - params: Promise<{ uploadId: string }> -} - -export const DELETE = withRouteHandler( - async (request: NextRequest, context: FileUploadRouteParams) => { +export const DELETE = withPublicApiRouteHandler({ + contract: v2AbortFileUploadContract, + rateLimitEndpoint: 'files', + handler: async ({ input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2AbortFileUploadContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { uploadId } = parsed.data.params - const { workspaceId } = parsed.data.query + const { uploadId } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) const session = await getOwnedUploadSession({ @@ -44,15 +23,14 @@ export const DELETE = withRouteHandler( workspaceId, userId, purpose: 'workspace_file', - uploadToken: parsed.data.headers['upload-token'], + uploadToken: input.headers['upload-token'], }) const aborted = await abortUploadSession(session) return v2Data(await toV2FileUpload(aborted, null), { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to abort file upload session', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/files/uploads/route.ts b/apps/sim/app/api/v2/files/uploads/route.ts index b1daf54bcd8..60ef13d4bd0 100644 --- a/apps/sim/app/api/v2/files/uploads/route.ts +++ b/apps/sim/app/api/v2/files/uploads/route.ts @@ -1,73 +1,52 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateFileUploadContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createUploadSession } from '@/lib/uploads/upload-session/service' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' import { resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2FileUploadsAPI') - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'files') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CreateFileUploadContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, name, contentType, size, folderPath } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const resolution = await resolveFolderPathIdentity({ - workspaceId, - resourceType: 'file', - path: folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - const session = await createUploadSession({ - workspaceId, - userId, - purpose: 'workspace_file', - fileName: name, - contentType, - fileSize: size, - metadata: { folderId: resolution.folderId }, - localOrigin: request.nextUrl.origin, - }) - return v2Data( - { - session: await toV2FileUpload(session, null), - uploadToken: session.uploadToken, - transfer: session.transfer, - }, - { rateLimit, status: 201 } - ) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - logger.error('Failed to create file upload session', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } +export const POST = withPublicApiRouteHandler({ + contract: v2CreateFileUploadContract, + rateLimitEndpoint: 'files', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + try { + const { workspaceId, name, contentType, size, folderPath } = input.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const resolution = await resolveFolderPathIdentity({ + workspaceId, + resourceType: 'file', + path: folderPath ?? '/', + }) + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') + const session = await createUploadSession({ + workspaceId, + userId, + purpose: 'workspace_file', + fileName: name, + contentType, + fileSize: size, + metadata: { folderId: resolution.folderId }, + localOrigin: request.nextUrl.origin, + }) + return v2Data( + { + session: await toV2FileUpload(session, null), + uploadToken: session.uploadToken, + transfer: session.transfer, + }, + { rateLimit, status: 201 } + ) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + throw error + } + }, }) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts index 8f80e98e33e..079e4d18311 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/[documentId]/route.ts @@ -1,37 +1,20 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' import { type V2KnowledgeDocument, v2DeleteKnowledgeDocumentContract, v2GetKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getKnowledgeDocument } from '@/lib/knowledge/documents/service' import { performDeleteKnowledgeDocument } from '@/lib/knowledge/orchestration' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' -import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2KnowledgeDocumentDetailAPI') +import type { RateLimitResult } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -interface DocumentDetailRouteParams { - params: Promise<{ id: string; documentId: string }> -} - /** * Resolves a knowledge base via the shared v1 ownership invariant * ({@link resolveKnowledgeBase}) and renders any failure in the v2 envelope. A @@ -54,123 +37,83 @@ async function resolveKnowledgeBaseScoped( } /** GET /api/v2/knowledge/[id]/documents/[documentId] — Get document details. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: DocumentDetailRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetKnowledgeDocumentContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id: knowledgeBaseId, documentId } = parsed.data.params - - const result = await resolveKnowledgeBaseScoped( - knowledgeBaseId, - parsed.data.query.workspaceId, - userId, - rateLimit, - 'read' - ) - if (result instanceof NextResponse) return result - - const doc = await getKnowledgeDocument(knowledgeBaseId, documentId) - if (!doc) return v2Error('NOT_FOUND', 'Document not found') - - const documentDetail: V2KnowledgeDocument = { - id: doc.id, - knowledgeBaseId: doc.knowledgeBaseId, - filename: doc.filename, - fileSize: doc.fileSize, - mimeType: doc.mimeType, - processingStatus: doc.processingStatus as V2KnowledgeDocument['processingStatus'], - processingError: doc.processingError, - processingStartedAt: serializeDate(doc.processingStartedAt), - processingCompletedAt: serializeDate(doc.processingCompletedAt), - chunkCount: doc.chunkCount, - tokenCount: doc.tokenCount, - characterCount: doc.characterCount, - enabled: doc.enabled, - connectorId: doc.connectorId, - connectorType: doc.connectorType ?? null, - sourceUrl: doc.sourceUrl, - createdAt: serializeDate(doc.uploadedAt), - } - - return v2Data({ document: documentDetail }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error getting document`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') +export const GET = withPublicApiRouteHandler({ + contract: v2GetKnowledgeDocumentContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { id: knowledgeBaseId, documentId } = input.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + input.query.workspaceId, + userId, + rateLimit, + 'read' + ) + if (result instanceof NextResponse) return result + + const doc = await getKnowledgeDocument(knowledgeBaseId, documentId) + if (!doc) return v2Error('NOT_FOUND', 'Document not found') + + const documentDetail: V2KnowledgeDocument = { + id: doc.id, + knowledgeBaseId: doc.knowledgeBaseId, + filename: doc.filename, + fileSize: doc.fileSize, + mimeType: doc.mimeType, + processingStatus: doc.processingStatus as V2KnowledgeDocument['processingStatus'], + processingError: doc.processingError, + processingStartedAt: serializeDate(doc.processingStartedAt), + processingCompletedAt: serializeDate(doc.processingCompletedAt), + chunkCount: doc.chunkCount, + tokenCount: doc.tokenCount, + characterCount: doc.characterCount, + enabled: doc.enabled, + connectorId: doc.connectorId, + connectorType: doc.connectorType ?? null, + sourceUrl: doc.sourceUrl, + createdAt: serializeDate(doc.uploadedAt), } - } -) - -/** DELETE /api/v2/knowledge/[id]/documents/[documentId] — Delete a document. */ -export const DELETE = withRouteHandler( - async (request: NextRequest, context: DocumentDetailRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2DeleteKnowledgeDocumentContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response + return v2Data({ document: documentDetail }, { rateLimit }) + }, +}) - const { id: knowledgeBaseId, documentId } = parsed.data.params - - const result = await resolveKnowledgeBaseScoped( - knowledgeBaseId, - parsed.data.query.workspaceId, - userId, - rateLimit, - 'write' - ) - if (result instanceof NextResponse) return result - - const doc = await getKnowledgeDocument(knowledgeBaseId, documentId) - if (!doc) return v2Error('NOT_FOUND', 'Document not found') - - const outcome = await performDeleteKnowledgeDocument({ - knowledgeBase: { - id: knowledgeBaseId, - name: result.kb.name, - workspaceId: parsed.data.query.workspaceId, - }, - document: { id: documentId, filename: doc.filename }, - userId, - source: 'api', - requestId, - request, - }) - if (!outcome.success) { - return v2ErrorForOrchestration(outcome.errorCode, outcome.error) - } - - return v2Data({ id: documentId, deleted: true as const }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error deleting document`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') +/** DELETE /api/v2/knowledge/[id]/documents/[documentId] — Delete a document. */ +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteKnowledgeDocumentContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + const { id: knowledgeBaseId, documentId } = input.params + + const result = await resolveKnowledgeBaseScoped( + knowledgeBaseId, + input.query.workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result + + const doc = await getKnowledgeDocument(knowledgeBaseId, documentId) + if (!doc) return v2Error('NOT_FOUND', 'Document not found') + + const outcome = await performDeleteKnowledgeDocument({ + knowledgeBase: { + id: knowledgeBaseId, + name: result.kb.name, + workspaceId: input.query.workspaceId, + }, + document: { id: documentId, filename: doc.filename }, + userId, + source: 'api', + requestId, + request, + }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error) } - } -) + + return v2Data({ id: documentId, deleted: true as const }, { rateLimit }) + }, +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts index 54be2ec3882..c189de04389 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -1,24 +1,19 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' import { type V2KnowledgeDocumentSummary, v2ListKnowledgeDocumentsContract, v2UploadKnowledgeDocumentContract, } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' import { checkAttributedUsageLimits, resolveBillingAttribution, resolveSystemBillingAttribution, } from '@/lib/billing/core/billing-attribution' -import { generateRequestId } from '@/lib/core/utils/request' import { isPayloadSizeLimitError, readFileToBufferWithLimit, readFormDataWithLimit, } from '@/lib/core/utils/stream-limits' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getDocuments } from '@/lib/knowledge/documents/service' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration' @@ -26,9 +21,9 @@ import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' import { validateFileType } from '@/lib/uploads/utils/validation' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { resolveKnowledgeBase, serializeDate } from '@/app/api/v1/knowledge/utils' -import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import type { RateLimitResult } from '@/app/api/v1/middleware' import { decodeCursor, encodeCursor, @@ -36,22 +31,14 @@ import { v2Data, v2Error, v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2KnowledgeDocumentsAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 const MAX_FILE_SIZE = MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024 -interface DocumentsRouteParams { - params: Promise<{ id: string }> -} - /** * Resolves a knowledge base via the shared v1 ownership invariant * ({@link resolveKnowledgeBase}) and renders any failure in the v2 envelope. A @@ -74,26 +61,12 @@ async function resolveKnowledgeBaseScoped( } /** GET /api/v2/knowledge/[id]/documents — List documents in a knowledge base. */ -export const GET = withRouteHandler(async (request: NextRequest, context: DocumentsRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2ListKnowledgeDocumentsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { workspaceId, limit, cursor, search, enabledFilter, sortBy, sortOrder } = - parsed.data.query - const { id: knowledgeBaseId } = parsed.data.params +export const GET = withPublicApiRouteHandler({ + contract: v2ListKnowledgeDocumentsContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + const { workspaceId, limit, cursor, search, enabledFilter, sortBy, sortOrder } = input.query + const { id: knowledgeBaseId } = input.params const result = await resolveKnowledgeBaseScoped( knowledgeBaseId, @@ -144,12 +117,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Docume ? encodeCursor({ offset: offset + limit }) : null return v2CursorList(documents, nextCursor, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error listing documents`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** @@ -160,26 +128,13 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Docume * unauthorized caller never streams a file into memory. Order: rate limit → * KB ownership (write) → usage gate → buffered multipart read. */ -export const POST = withRouteHandler( - async (request: NextRequest, context: DocumentsRouteParams) => { - const requestId = generateRequestId() - +export const POST = withPublicApiRouteHandler({ + contract: v2UploadKnowledgeDocumentContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UploadKnowledgeDocumentContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id: knowledgeBaseId } = parsed.data.params - const { workspaceId } = parsed.data.query + const { id: knowledgeBaseId } = input.params + const { workspaceId } = input.query const result = await resolveKnowledgeBaseScoped( knowledgeBaseId, @@ -292,10 +247,7 @@ export const POST = withRouteHandler( return v2Error('PAYLOAD_TOO_LARGE', error.message) } - logger.error(`[${requestId}] Error uploading document`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts index 92bc7c69b8a..05b111c7cbf 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts @@ -1,13 +1,7 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { v2CompleteKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { completeUploadSession } from '@/lib/uploads/upload-session/service' -import { checkRateLimit } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { finalizeKnowledgeDocumentUpload, getOwnedKnowledgeDocumentUpload, @@ -15,41 +9,15 @@ import { resolveKnowledgeDocumentUploadAttribution, toV2KnowledgeDocumentUpload, } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2CompleteKnowledgeDocumentUploadAPI') - -interface KnowledgeDocumentUploadRouteParams { - params: Promise<{ id: string; uploadId: string }> -} - -export const POST = withRouteHandler( - async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { - const requestId = generateRequestId() +import { v2CaughtOrchestrationError, v2Data } from '@/app/api/v2/lib/response' +export const POST = withPublicApiRouteHandler({ + contract: v2CompleteKnowledgeDocumentUploadContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CompleteKnowledgeDocumentUploadContract, - request, - context, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - const { id: knowledgeBaseId, uploadId } = parsed.data.params - const { workspaceId } = parsed.data.query + const { id: knowledgeBaseId, uploadId } = input.params + const { workspaceId } = input.query const access = await resolveKnowledgeDocumentUploadAccess({ knowledgeBaseId, @@ -64,7 +32,7 @@ export const POST = withRouteHandler( uploadId, workspaceId, userId, - uploadToken: parsed.data.headers['upload-token'], + uploadToken: input.headers['upload-token'], }) const result = await completeUploadSession({ session, @@ -87,10 +55,7 @@ export const POST = withRouteHandler( } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error(`[${requestId}] Failed to complete knowledge-document upload`, { - error: getErrorMessage(error), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts index 2bf941b0eb0..8b7320f397a 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts @@ -1,49 +1,20 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { v2CreateKnowledgeDocumentUploadPartUrlsContract } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createUploadPartUrls } from '@/lib/uploads/upload-session/service' -import { checkRateLimit } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { getOwnedKnowledgeDocumentUpload, resolveKnowledgeDocumentUploadAccess, } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2KnowledgeDocumentUploadPartsAPI') - -interface KnowledgeDocumentUploadRouteParams { - params: Promise<{ id: string; uploadId: string }> -} +import { v2CaughtOrchestrationError, v2Data } from '@/app/api/v2/lib/response' -export const POST = withRouteHandler( - async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { +export const POST = withPublicApiRouteHandler({ + contract: v2CreateKnowledgeDocumentUploadPartUrlsContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CreateKnowledgeDocumentUploadPartUrlsContract, - request, - context, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - const { id: knowledgeBaseId, uploadId } = parsed.data.params - const { workspaceId } = parsed.data.query + const { id: knowledgeBaseId, uploadId } = input.params + const { workspaceId } = input.query const access = await resolveKnowledgeDocumentUploadAccess({ knowledgeBaseId, @@ -58,21 +29,18 @@ export const POST = withRouteHandler( uploadId, workspaceId, userId, - uploadToken: parsed.data.headers['upload-token'], + uploadToken: input.headers['upload-token'], }) const parts = await createUploadPartUrls({ session, - partNumbers: parsed.data.body.partNumbers, + partNumbers: input.body.partNumbers, localOrigin: request.nextUrl.origin, }) return v2Data({ parts }, { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to create knowledge-document upload part URLs', { - error: getErrorMessage(error), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts index 9fd10cad84e..a7ba8567f2a 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts @@ -1,47 +1,21 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { v2AbortKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { checkRateLimit } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { abortKnowledgeDocumentUpload, getOwnedKnowledgeDocumentUpload, resolveKnowledgeDocumentUploadAccess, toV2KnowledgeDocumentUpload, } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2KnowledgeDocumentUploadAPI') - -interface KnowledgeDocumentUploadRouteParams { - params: Promise<{ id: string; uploadId: string }> -} +import { v2CaughtOrchestrationError, v2Data } from '@/app/api/v2/lib/response' -export const DELETE = withRouteHandler( - async (request: NextRequest, context: KnowledgeDocumentUploadRouteParams) => { +export const DELETE = withPublicApiRouteHandler({ + contract: v2AbortKnowledgeDocumentUploadContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2AbortKnowledgeDocumentUploadContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { id: knowledgeBaseId, uploadId } = parsed.data.params - const { workspaceId } = parsed.data.query + const { id: knowledgeBaseId, uploadId } = input.params + const { workspaceId } = input.query const access = await resolveKnowledgeDocumentUploadAccess({ knowledgeBaseId, @@ -56,17 +30,14 @@ export const DELETE = withRouteHandler( uploadId, workspaceId, userId, - uploadToken: parsed.data.headers['upload-token'], + uploadToken: input.headers['upload-token'], }) const aborted = await abortKnowledgeDocumentUpload(session, knowledgeBaseId) return v2Data(toV2KnowledgeDocumentUpload(aborted, null), { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to abort knowledge-document upload session', { - error: getErrorMessage(error), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts index e736548fd98..509a01f6de0 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts @@ -1,48 +1,22 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { v2CreateKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { validateFileType } from '@/lib/uploads/utils/validation' -import { checkRateLimit } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { createKnowledgeDocumentUploadSession, resolveKnowledgeDocumentUploadAccess, resolveKnowledgeDocumentUploadBilling, toV2KnowledgeDocumentUpload, } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2KnowledgeDocumentUploadsAPI') - -interface KnowledgeDocumentUploadsRouteParams { - params: Promise<{ id: string }> -} +import { v2CaughtOrchestrationError, v2Data, v2Error } from '@/app/api/v2/lib/response' -export const POST = withRouteHandler( - async (request: NextRequest, context: KnowledgeDocumentUploadsRouteParams) => { +export const POST = withPublicApiRouteHandler({ + contract: v2CreateKnowledgeDocumentUploadContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2CreateKnowledgeDocumentUploadContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { id: knowledgeBaseId } = parsed.data.params - const { workspaceId, name, contentType, size, ...metadata } = parsed.data.body + const { id: knowledgeBaseId } = input.params + const { workspaceId, name, contentType, size, ...metadata } = input.body const access = await resolveKnowledgeDocumentUploadAccess({ knowledgeBaseId, @@ -85,10 +59,7 @@ export const POST = withRouteHandler( } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to create knowledge-document upload session', { - error: getErrorMessage(error), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/knowledge/[id]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/route.ts index a98d877d330..f2da8d298cb 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/route.ts @@ -1,41 +1,24 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' import { v2DeleteKnowledgeBaseContract, v2GetKnowledgeBaseContract, v2UpdateKnowledgeBaseContract, } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { performDeleteKnowledgeBase, performUpdateKnowledgeBase, } from '@/lib/knowledge/orchestration' import type { KnowledgeBaseWithCounts } from '@/lib/knowledge/types' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { formatKnowledgeBase, resolveKnowledgeBase } from '@/app/api/v1/knowledge/utils' -import { checkRateLimit, type RateLimitResult } from '@/app/api/v1/middleware' +import type { RateLimitResult } from '@/app/api/v1/middleware' import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2KnowledgeDetailAPI') +import { v2Data, v2Error, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -interface KnowledgeRouteParams { - params: Promise<{ id: string }> -} - /** * Resolves a knowledge base via the shared v1 ownership invariant * ({@link resolveKnowledgeBase}: workspace access + KB-belongs-to-workspace) and @@ -60,37 +43,21 @@ async function resolveKnowledgeBaseScoped( } /** GET /api/v2/knowledge/[id] — Get knowledge base details. */ -export const GET = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetKnowledgeBaseContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params +export const GET = withPublicApiRouteHandler({ + contract: v2GetKnowledgeBaseContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { id } = input.params const result = await resolveKnowledgeBaseScoped( id, - parsed.data.query.workspaceId, + input.query.workspaceId, userId, rateLimit, 'read' ) if (result instanceof NextResponse) return result - const folderIndex = await loadActiveFolderPathIndex( - parsed.data.query.workspaceId, - 'knowledge_base' - ) + const folderIndex = await loadActiveFolderPathIndex(input.query.workspaceId, 'knowledge_base') return v2Data( { @@ -101,34 +68,16 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Knowle }, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error getting knowledge base`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** PUT /api/v2/knowledge/[id] — Update a knowledge base. */ -export const PUT = withRouteHandler(async (request: NextRequest, context: KnowledgeRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateKnowledgeBaseContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId, name, description, chunkingConfig, folderPath } = parsed.data.body +export const PUT = withPublicApiRouteHandler({ + contract: v2UpdateKnowledgeBaseContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + const { id } = input.params + const { workspaceId, name, description, chunkingConfig, folderPath } = input.body const result = await resolveKnowledgeBaseScoped(id, workspaceId, userId, rateLimit, 'write') if (result instanceof NextResponse) return result @@ -168,60 +117,35 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: Knowle }, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error updating knowledge base`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** DELETE /api/v2/knowledge/[id] — Delete a knowledge base. */ -export const DELETE = withRouteHandler( - async (request: NextRequest, context: KnowledgeRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'knowledge-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteKnowledgeBaseContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const result = await resolveKnowledgeBaseScoped( - id, - parsed.data.query.workspaceId, - userId, - rateLimit, - 'write' - ) - if (result instanceof NextResponse) return result - - const outcome = await performDeleteKnowledgeBase({ - knowledgeBase: { id, name: result.kb.name, workspaceId: parsed.data.query.workspaceId }, - userId, - source: 'api', - requestId, - request, - }) - if (!outcome.success) { - return v2ErrorForOrchestration(outcome.errorCode, outcome.error) - } +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteKnowledgeBaseContract, + rateLimitEndpoint: 'knowledge-detail', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + const { id } = input.params + const result = await resolveKnowledgeBaseScoped( + id, + input.query.workspaceId, + userId, + rateLimit, + 'write' + ) + if (result instanceof NextResponse) return result - return v2Data({ id, deleted: true as const }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error deleting knowledge base`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + const outcome = await performDeleteKnowledgeBase({ + knowledgeBase: { id, name: result.kb.name, workspaceId: input.query.workspaceId }, + userId, + source: 'api', + requestId, + request, + }) + if (!outcome.success) { + return v2ErrorForOrchestration(outcome.errorCode, outcome.error) } - } -) + + return v2Data({ id, deleted: true as const }, { rateLimit }) + }, +}) diff --git a/apps/sim/app/api/v2/knowledge/folders/route.ts b/apps/sim/app/api/v2/knowledge/folders/route.ts index 4995d5cc486..3d5ff54a79a 100644 --- a/apps/sim/app/api/v2/knowledge/folders/route.ts +++ b/apps/sim/app/api/v2/knowledge/folders/route.ts @@ -1,60 +1,32 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateKnowledgeFolderContract, v2DeleteKnowledgeFolderContract, v2ListKnowledgeFoldersContract, v2RelocateKnowledgeFolderContract, } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createFolderAtPath, deleteFolderByPath, relocateFolderByPath, } from '@/lib/folders/orchestration' import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { resolveFolderPathId, toV2PathFolder, v2FolderPathMutationError, } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2KnowledgeFoldersAPI') +import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const rateLimit = await checkRateLimit(request, 'knowledge') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2ListKnowledgeFoldersContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListKnowledgeFoldersContract, + rateLimitEndpoint: 'knowledge', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, parentPath, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -74,114 +46,82 @@ export const GET = withRouteHandler(async (request: NextRequest) => { null, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error listing knowledge folders`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) -export const POST = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'knowledge') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2CreateKnowledgeFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, +export const POST = withPublicApiRouteHandler({ + contract: v2CreateKnowledgeFolderContract, + rateLimitEndpoint: 'knowledge', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path } = input.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await createFolderAtPath({ + resourceType: 'knowledge_base', + workspaceId, + userId, + path, + }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await createFolderAtPath({ - resourceType: 'knowledge_base', - workspaceId, - userId, - path, - }) - if (!result.success || !result.folder) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') - return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit, status: 201 }) + const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') + return v2Data( + { folder: toV2PathFolder(result.folder, index, false) }, + { rateLimit, status: 201 } + ) + }, }) -export const PATCH = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'knowledge') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2RelocateKnowledgeFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, +export const PATCH = withPublicApiRouteHandler({ + contract: v2RelocateKnowledgeFolderContract, + rateLimitEndpoint: 'knowledge', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path, destinationPath } = input.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await relocateFolderByPath({ + resourceType: 'knowledge_base', + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path, destinationPath } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await relocateFolderByPath({ - resourceType: 'knowledge_base', - workspaceId, - userId, - path, - destinationPath, - }) - if (!result.success || !result.folder) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') - return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit }) + const index = await loadActiveFolderPathIndex(workspaceId, 'knowledge_base') + return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit }) + }, }) -export const DELETE = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'knowledge') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2DeleteKnowledgeFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path, recursive } = parsed.data.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await deleteFolderByPath({ - resourceType: 'knowledge_base', - workspaceId, - userId, - path, - recursive, - }) - if (!result.success || !result.deletedItems) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') - } - return v2Data( - { +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteKnowledgeFolderContract, + rateLimitEndpoint: 'knowledge', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path, recursive } = input.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await deleteFolderByPath({ + resourceType: 'knowledge_base', + workspaceId, + userId, path, - deleted: true as const, - deletedItems: { - folders: result.deletedItems.folders, - knowledgeBases: result.deletedItems.knowledgeBases ?? 0, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + return v2Data( + { + path, + deleted: true as const, + deletedItems: { + folders: result.deletedItems.folders, + knowledgeBases: result.deletedItems.knowledgeBases ?? 0, + }, }, - }, - { rateLimit } - ) + { rateLimit } + ) + }, }) diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts index 12720efaa46..8b20a6b7d3d 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -1,63 +1,35 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateKnowledgeBaseContract, v2ListKnowledgeBasesContract, } from '@/lib/api/contracts/v2/knowledge' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { performCreateKnowledgeBase } from '@/lib/knowledge/orchestration' import { getKnowledgeBases } from '@/lib/knowledge/service' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { formatKnowledgeBase } from '@/app/api/v1/knowledge/utils' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { folderPathForId, resolveFolderPathId, resolveFolderPathIdentity, } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CursorList, v2Data, v2Error, v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2KnowledgeAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/knowledge — List knowledge bases in a workspace. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'knowledge') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListKnowledgeBasesContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, folderPath, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListKnowledgeBasesContract, + rateLimitEndpoint: 'knowledge', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, folderPath, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -82,38 +54,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { // `getKnowledgeBases` returns the full bounded workspace set → single page. return v2CursorList(items, null, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error listing knowledge bases`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** POST /api/v2/knowledge — Create a new knowledge base. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'knowledge') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CreateKnowledgeBaseContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, name, description, chunkingConfig, folderPath } = parsed.data.body +export const POST = withPublicApiRouteHandler({ + contract: v2CreateKnowledgeBaseContract, + rateLimitEndpoint: 'knowledge', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + const { workspaceId, name, description, chunkingConfig, folderPath } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -149,10 +98,5 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, { rateLimit, status: 201 } ) - } catch (error) { - logger.error(`[${requestId}] Error creating knowledge base`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index ac8ca1c4842..dc6f3fdc829 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -1,18 +1,13 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { type V2KnowledgeSearchResult, v2SearchKnowledgeContract, } from '@/lib/api/contracts/v2/knowledge' -import { isZodError, parseRequest } from '@/lib/api/server' +import { isZodError } from '@/lib/api/server' import { checkAttributedUsageLimits, resolveBillingAttribution, resolveSystemBillingAttribution, } from '@/lib/billing/core/billing-attribution' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' import { @@ -25,274 +20,252 @@ import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service' import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' import type { StructuredFilter } from '@/lib/knowledge/types' import { checkKnowledgeBaseAccess, type KnowledgeBaseAccessResult } from '@/app/api/knowledge/utils' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { v2Data, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2KnowledgeSearchAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 /** POST /api/v2/knowledge/search — Vector / tag search across knowledge bases. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'knowledge-search') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) +export const POST = withPublicApiRouteHandler({ + contract: v2SearchKnowledgeContract, + rateLimitEndpoint: 'knowledge-search', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { workspaceId, topK, query, tagFilters } = input.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + /** + * A query incurs hosted embedding (+ optional rerank) cost — gate the + * actor's usage before spending; tag-only search is free. Workspace keys + * resolve their system actor and immutable payer from one workspace read. + */ + const hasBillableQuery = Boolean(query?.trim()) + const billingAttribution = hasBillableQuery + ? rateLimit.keyType === 'workspace' + ? await resolveSystemBillingAttribution(workspaceId) + : await resolveBillingAttribution({ actorUserId: userId, workspaceId }) + : undefined + const billingActorUserId = billingAttribution?.actorUserId ?? userId + if (billingAttribution) { + const usage = await checkAttributedUsageLimits(billingAttribution) + if (usage.isExceeded) { + return v2Error( + 'USAGE_LIMIT_EXCEEDED', + usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + ) + } + } - const userId = rateLimit.userId! + const knowledgeBaseIds = Array.isArray(input.body.knowledgeBaseIds) + ? input.body.knowledgeBaseIds + : [input.body.knowledgeBaseIds] - const gate = await v2ApiGateError(userId) - if (gate) return gate + const accessChecks = await Promise.all( + knowledgeBaseIds.map((kbId) => checkKnowledgeBaseAccess(kbId, userId)) + ) + const accessibleKbs = accessChecks + .filter( + (ac): ac is KnowledgeBaseAccessResult => + ac.hasAccess === true && ac.knowledgeBase.workspaceId === workspaceId + ) + .map((ac) => ac.knowledgeBase) + const accessibleKbIds = accessibleKbs.map((kb) => kb.id) - const parsed = await parseRequest( - v2SearchKnowledgeContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, + if (accessibleKbIds.length === 0) { + return v2Error('NOT_FOUND', 'Knowledge base not found or access denied') } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, topK, query, tagFilters } = parsed.data.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - /** - * A query incurs hosted embedding (+ optional rerank) cost — gate the - * actor's usage before spending; tag-only search is free. Workspace keys - * resolve their system actor and immutable payer from one workspace read. - */ - const hasBillableQuery = Boolean(query?.trim()) - const billingAttribution = hasBillableQuery - ? rateLimit.keyType === 'workspace' - ? await resolveSystemBillingAttribution(workspaceId) - : await resolveBillingAttribution({ actorUserId: userId, workspaceId }) - : undefined - const billingActorUserId = billingAttribution?.actorUserId ?? userId - if (billingAttribution) { - const usage = await checkAttributedUsageLimits(billingAttribution) - if (usage.isExceeded) { + const inaccessibleKbIds = knowledgeBaseIds.filter((id) => !accessibleKbIds.includes(id)) + if (inaccessibleKbIds.length > 0) { return v2Error( - 'USAGE_LIMIT_EXCEEDED', - usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.' + 'NOT_FOUND', + `Knowledge bases not found or access denied: ${inaccessibleKbIds.join(', ')}` ) } - } - - const knowledgeBaseIds = Array.isArray(parsed.data.body.knowledgeBaseIds) - ? parsed.data.body.knowledgeBaseIds - : [parsed.data.body.knowledgeBaseIds] - const accessChecks = await Promise.all( - knowledgeBaseIds.map((kbId) => checkKnowledgeBaseAccess(kbId, userId)) - ) - const accessibleKbs = accessChecks - .filter( - (ac): ac is KnowledgeBaseAccessResult => - ac.hasAccess === true && ac.knowledgeBase.workspaceId === workspaceId - ) - .map((ac) => ac.knowledgeBase) - const accessibleKbIds = accessibleKbs.map((kb) => kb.id) + let structuredFilters: StructuredFilter[] = [] + const tagDefsCache = new Map>>() - if (accessibleKbIds.length === 0) { - return v2Error('NOT_FOUND', 'Knowledge base not found or access denied') - } - - const inaccessibleKbIds = knowledgeBaseIds.filter((id) => !accessibleKbIds.includes(id)) - if (inaccessibleKbIds.length > 0) { - return v2Error( - 'NOT_FOUND', - `Knowledge bases not found or access denied: ${inaccessibleKbIds.join(', ')}` - ) - } - - let structuredFilters: StructuredFilter[] = [] - const tagDefsCache = new Map>>() - - if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 1) { - return v2Error( - 'BAD_REQUEST', - 'Tag filters are only supported when searching a single knowledge base' - ) - } + if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 1) { + return v2Error( + 'BAD_REQUEST', + 'Tag filters are only supported when searching a single knowledge base' + ) + } - if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 0) { - const kbId = accessibleKbIds[0] - const tagDefs = await getDocumentTagDefinitions(kbId) - tagDefsCache.set(kbId, tagDefs) + if (tagFilters && tagFilters.length > 0 && accessibleKbIds.length > 0) { + const kbId = accessibleKbIds[0] + const tagDefs = await getDocumentTagDefinitions(kbId) + tagDefsCache.set(kbId, tagDefs) + + const displayNameToTagDef: Record = {} + tagDefs.forEach((def) => { + displayNameToTagDef[def.displayName] = { + tagSlot: def.tagSlot, + fieldType: def.fieldType, + } + }) + + const undefinedTags: string[] = [] + const typeErrors: string[] = [] + + for (const filter of tagFilters) { + const tagDef = displayNameToTagDef[filter.tagName] + if (!tagDef) { + undefinedTags.push(filter.tagName) + continue + } + const validationError = validateTagValue( + filter.tagName, + String(filter.value), + tagDef.fieldType + ) + if (validationError) { + typeErrors.push(validationError) + } + } - const displayNameToTagDef: Record = {} - tagDefs.forEach((def) => { - displayNameToTagDef[def.displayName] = { - tagSlot: def.tagSlot, - fieldType: def.fieldType, + if (undefinedTags.length > 0 || typeErrors.length > 0) { + const errorParts: string[] = [] + if (undefinedTags.length > 0) { + errorParts.push(buildUndefinedTagsError(undefinedTags)) + } + if (typeErrors.length > 0) { + errorParts.push(...typeErrors) + } + return v2Error('BAD_REQUEST', errorParts.join('\n')) } - }) - const undefinedTags: string[] = [] - const typeErrors: string[] = [] + structuredFilters = tagFilters.map((filter) => { + const tagDef = displayNameToTagDef[filter.tagName]! + return { + tagSlot: tagDef.tagSlot, + fieldType: tagDef.fieldType, + operator: filter.operator, + value: filter.value, + valueTo: filter.valueTo, + } + }) + } - for (const filter of tagFilters) { - const tagDef = displayNameToTagDef[filter.tagName] - if (!tagDef) { - undefinedTags.push(filter.tagName) - continue - } - const validationError = validateTagValue( - filter.tagName, - String(filter.value), - tagDef.fieldType + const hasQuery = Boolean(query && query.trim().length > 0) + const hasFilters = structuredFilters.length > 0 + + const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel))) + if (hasQuery && embeddingModels.length > 1) { + return v2Error( + 'BAD_REQUEST', + 'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.' ) - if (validationError) { - typeErrors.push(validationError) - } } + const queryEmbeddingModel = embeddingModels[0] - if (undefinedTags.length > 0 || typeErrors.length > 0) { - const errorParts: string[] = [] - if (undefinedTags.length > 0) { - errorParts.push(buildUndefinedTagsError(undefinedTags)) - } - if (typeErrors.length > 0) { - errorParts.push(...typeErrors) - } - return v2Error('BAD_REQUEST', errorParts.join('\n')) + if (!hasQuery && !hasFilters) { + return v2Error('BAD_REQUEST', 'Either query or tagFilters must be provided') } - structuredFilters = tagFilters.map((filter) => { - const tagDef = displayNameToTagDef[filter.tagName]! - return { - tagSlot: tagDef.tagSlot, - fieldType: tagDef.fieldType, - operator: filter.operator, - value: filter.value, - valueTo: filter.valueTo, - } - }) - } - - const hasQuery = Boolean(query && query.trim().length > 0) - const hasFilters = structuredFilters.length > 0 + let queryEmbeddingIsBYOK: boolean | null = null + let queryVector: string | undefined - const embeddingModels = Array.from(new Set(accessibleKbs.map((kb) => kb.embeddingModel))) - if (hasQuery && embeddingModels.length > 1) { - return v2Error( - 'BAD_REQUEST', - 'Selected knowledge bases use different embedding models and cannot be searched together. Search them separately.' - ) - } - const queryEmbeddingModel = embeddingModels[0] + if (hasQuery) { + const queryEmbeddingResult = await generateSearchEmbedding( + query!, + queryEmbeddingModel, + workspaceId + ) + queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK + queryVector = JSON.stringify(queryEmbeddingResult.embedding) + } - if (!hasQuery && !hasFilters) { - return v2Error('BAD_REQUEST', 'Either query or tagFilters must be provided') - } + const results: SearchResult[] = await executeKnowledgeSearch({ + knowledgeBaseIds: accessibleKbIds, + topK, + searchMode: 'vector', + query, + queryVector, + structuredFilters, + }) - let queryEmbeddingIsBYOK: boolean | null = null - let queryVector: string | undefined + if (queryEmbeddingIsBYOK !== null) { + await recordSearchEmbeddingUsage({ + userId: billingActorUserId, + workspaceId, + embeddingModel: queryEmbeddingModel, + query: query!, + isBYOK: queryEmbeddingIsBYOK, + sourceReference: `v2-kb-search:${requestId}`, + billingAttribution, + }) + } - if (hasQuery) { - const queryEmbeddingResult = await generateSearchEmbedding( - query!, - queryEmbeddingModel, - workspaceId + const tagDefsResults = await Promise.all( + accessibleKbIds.map(async (kbId) => { + try { + const tagDefs = tagDefsCache.get(kbId) ?? (await getDocumentTagDefinitions(kbId)) + const map: Record = {} + tagDefs.forEach((def) => { + map[def.tagSlot] = def.displayName + }) + return { kbId, map } + } catch { + return { kbId, map: {} as Record } + } + }) ) - queryEmbeddingIsBYOK = queryEmbeddingResult.isBYOK - queryVector = JSON.stringify(queryEmbeddingResult.embedding) - } - - const results: SearchResult[] = await executeKnowledgeSearch({ - knowledgeBaseIds: accessibleKbIds, - topK, - searchMode: 'vector', - query, - queryVector, - structuredFilters, - }) - - if (queryEmbeddingIsBYOK !== null) { - await recordSearchEmbeddingUsage({ - userId: billingActorUserId, - workspaceId, - embeddingModel: queryEmbeddingModel, - query: query!, - isBYOK: queryEmbeddingIsBYOK, - sourceReference: `v2-kb-search:${requestId}`, - billingAttribution, + const tagDefinitionsMap: Record> = {} + tagDefsResults.forEach(({ kbId, map }) => { + tagDefinitionsMap[kbId] = map }) - } - const tagDefsResults = await Promise.all( - accessibleKbIds.map(async (kbId) => { - try { - const tagDefs = tagDefsCache.get(kbId) ?? (await getDocumentTagDefinitions(kbId)) - const map: Record = {} - tagDefs.forEach((def) => { - map[def.tagSlot] = def.displayName - }) - return { kbId, map } - } catch { - return { kbId, map: {} as Record } - } - }) - ) - const tagDefinitionsMap: Record> = {} - tagDefsResults.forEach(({ kbId, map }) => { - tagDefinitionsMap[kbId] = map - }) + const documentIds = results.map((r) => r.documentId) + const documentMetadataMap = await getDocumentMetadataByIds(documentIds) - const documentIds = results.map((r) => r.documentId) - const documentMetadataMap = await getDocumentMetadataByIds(documentIds) + const searchResults: V2KnowledgeSearchResult[] = results.map((result) => { + const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} + const metadata: Record = {} - const searchResults: V2KnowledgeSearchResult[] = results.map((result) => { - const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} - const metadata: Record = {} + ALL_TAG_SLOTS.forEach((slot) => { + const tagValue = result[slot as keyof SearchResult] + if (tagValue !== null && tagValue !== undefined) { + const displayName = kbTagMap[slot] || slot + metadata[displayName] = tagValue + } + }) - ALL_TAG_SLOTS.forEach((slot) => { - const tagValue = result[slot as keyof SearchResult] - if (tagValue !== null && tagValue !== undefined) { - const displayName = kbTagMap[slot] || slot - metadata[displayName] = tagValue + const docMeta = documentMetadataMap[result.documentId] + return { + documentId: result.documentId, + documentName: docMeta?.filename ?? null, + sourceUrl: docMeta?.sourceUrl ?? null, + content: result.content, + chunkIndex: result.chunkIndex, + metadata, + similarity: hasQuery ? 1 - result.distance : 1, } }) - const docMeta = documentMetadataMap[result.documentId] - return { - documentId: result.documentId, - documentName: docMeta?.filename ?? null, - sourceUrl: docMeta?.sourceUrl ?? null, - content: result.content, - chunkIndex: result.chunkIndex, - metadata, - similarity: hasQuery ? 1 - result.distance : 1, - } - }) - - return v2Data( - { - results: searchResults, - query: query || '', - knowledgeBaseIds: accessibleKbIds, - topK, - totalResults: results.length, - }, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - logger.error(`[${requestId}] Knowledge search error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + return v2Data( + { + results: searchResults, + query: query || '', + knowledgeBaseIds: accessibleKbIds, + topK, + totalResults: results.length, + }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + throw error + } + }, }) diff --git a/apps/sim/app/api/v2/logs/[runId]/route.ts b/apps/sim/app/api/v2/logs/[runId]/route.ts index a1753f70769..811637abd8f 100644 --- a/apps/sim/app/api/v2/logs/[runId]/route.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.ts @@ -1,19 +1,11 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { traceSpansSchema } from '@/lib/api/contracts/logs' import { type V2LogDetail, v2GetLogContract, v2LogStatusSchema } from '@/lib/api/contracts/v2/logs' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { getPublicWorkflowLog } from '@/lib/logs/public-queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' - -const logger = createLogger('V2LogDetailAPI') +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error } from '@/app/api/v2/lib/response' export const revalidate = 0 @@ -22,79 +14,59 @@ export const revalidate = 0 * public identity; the workflow-execution-log row key remains an internal * storage and pagination detail. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ runId: string }> }) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'logs-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetLogContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response +export const GET = withPublicApiRouteHandler({ + contract: v2GetLogContract, + rateLimitEndpoint: 'logs-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { runId } = input.params - const { runId } = parsed.data.params + const log = await getPublicWorkflowLog({ column: 'executionId', value: runId }) - const log = await getPublicWorkflowLog({ column: 'executionId', value: runId }) + if (!log) return v2Error('NOT_FOUND', 'Log not found') - if (!log) return v2Error('NOT_FOUND', 'Log not found') + const access = await resolveWorkspaceAccess(rateLimit, userId, log.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Log not found') - const access = await resolveWorkspaceAccess(rateLimit, userId, log.workspaceId) - if (access) return v2Error('NOT_FOUND', 'Log not found') - - const folderIndex = await loadActiveFolderPathIndex(log.workspaceId, 'workflow') - const executionData = await materializeExecutionData( - log.executionData as Record | null, - { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } - ) - if (log.workflowUserId && !log.workflowOwnerEmail) { - throw new Error(`Unable to resolve workflow owner email for ${log.workflowUserId}`) - } - - const detail: V2LogDetail = { - runId: log.executionId, - workflowId: log.workflowId, - deploymentVersionId: log.deploymentVersionId, - status: v2LogStatusSchema.parse(log.status), - level: log.level, - trigger: log.trigger, - startedAt: log.startedAt.toISOString(), - endedAt: log.endedAt ? log.endedAt.toISOString() : null, - totalDurationMs: log.totalDurationMs, - files: (log.files as unknown[] | null) ?? null, - workflow: { - id: log.workflowId, - name: log.workflowName || 'Deleted Workflow', - description: log.workflowDescription, - folderPath: log.workflowFolderId - ? (folderIndex.pathById.get(log.workflowFolderId) ?? null) - : null, - ownerEmail: log.workflowOwnerEmail, - workspaceId: log.workflowWorkspaceId, - createdAt: log.workflowCreatedAt ? log.workflowCreatedAt.toISOString() : null, - updatedAt: log.workflowUpdatedAt ? log.workflowUpdatedAt.toISOString() : null, - deleted: !log.workflowName || log.workflowArchivedAt !== null, - }, - workflowState: log.workflowState, - traceSpans: traceSpansSchema.parse(executionData.traceSpans ?? []), - finalOutput: executionData.finalOutput ?? null, - cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, - createdAt: log.createdAt.toISOString(), - } + const folderIndex = await loadActiveFolderPathIndex(log.workspaceId, 'workflow') + const executionData = await materializeExecutionData( + log.executionData as Record | null, + { workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId } + ) + if (log.workflowUserId && !log.workflowOwnerEmail) { + throw new Error(`Unable to resolve workflow owner email for ${log.workflowUserId}`) + } - return v2Data(detail, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Log detail fetch error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + const detail: V2LogDetail = { + runId: log.executionId, + workflowId: log.workflowId, + deploymentVersionId: log.deploymentVersionId, + status: v2LogStatusSchema.parse(log.status), + level: log.level, + trigger: log.trigger, + startedAt: log.startedAt.toISOString(), + endedAt: log.endedAt ? log.endedAt.toISOString() : null, + totalDurationMs: log.totalDurationMs, + files: (log.files as unknown[] | null) ?? null, + workflow: { + id: log.workflowId, + name: log.workflowName || 'Deleted Workflow', + description: log.workflowDescription, + folderPath: log.workflowFolderId + ? (folderIndex.pathById.get(log.workflowFolderId) ?? null) + : null, + ownerEmail: log.workflowOwnerEmail, + workspaceId: log.workflowWorkspaceId, + createdAt: log.workflowCreatedAt ? log.workflowCreatedAt.toISOString() : null, + updatedAt: log.workflowUpdatedAt ? log.workflowUpdatedAt.toISOString() : null, + deleted: !log.workflowName || log.workflowArchivedAt !== null, + }, + workflowState: log.workflowState, + traceSpans: traceSpansSchema.parse(executionData.traceSpans ?? []), + finalOutput: executionData.finalOutput ?? null, + cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, + createdAt: log.createdAt.toISOString(), } - } -) + + return v2Data(detail, { rateLimit }) + }, +}) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index 1035846846a..5e65d643894 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -1,58 +1,26 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { traceSpansSchema } from '@/lib/api/contracts/logs' import { type V2LogListItem, v2ListLogsContract, v2LogStatusSchema, } from '@/lib/api/contracts/v2/logs' -import { parseRequest } from '@/lib/api/server' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { materializeExecutionData } from '@/lib/logs/execution/trace-store' import { decodePublicLogCursor, listPublicWorkflowLogs } from '@/lib/logs/public-queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { resolveFolderPathId } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2LogsAPI') +import { v2CursorList, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'logs') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListLogsContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const params = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListLogsContract, + rateLimitEndpoint: 'logs', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const params = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -155,10 +123,5 @@ export const GET = withRouteHandler(async (request: NextRequest) => { : data.map(buildItem) return v2CursorList(formattedLogs, nextCursor, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Logs fetch error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/mcp-servers/[id]/route.ts b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts index 22231ef8eba..4e61d698811 100644 --- a/apps/sim/app/api/v2/mcp-servers/[id]/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/[id]/route.ts @@ -1,29 +1,15 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2DeleteMcpServerContract, v2GetMcpServerContract, v2UpdateMcpServerContract, } from '@/lib/api/contracts/v2/mcp-servers' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performDeleteMcpServer, performUpdateMcpServer } from '@/lib/mcp/orchestration' import { getWorkspaceMcpServer } from '@/lib/mcp/queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import { toV2McpServer, v2McpOrchestrationError } from '@/app/api/v2/mcp-servers/utils' -const logger = createLogger('V2McpServerDetailAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -32,25 +18,12 @@ interface RouteContext { } /** GET /api/v2/mcp-servers/[id] — Fetch a single MCP server. */ -export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'mcp-server-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetMcpServerContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2GetMcpServerContract, + rateLimitEndpoint: 'mcp-server-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { id } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -59,34 +32,16 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RouteC if (!server) return v2Error('NOT_FOUND', 'MCP server not found') return v2Data({ mcpServer: toV2McpServer(server) }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error fetching MCP server`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** PATCH /api/v2/mcp-servers/[id] — Update an MCP server's configuration. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'mcp-server-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateMcpServerContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId, ...body } = parsed.data.body +export const PATCH = withPublicApiRouteHandler({ + contract: v2UpdateMcpServerContract, + rateLimitEndpoint: 'mcp-server-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { id } = input.params + const { workspaceId, ...body } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -134,34 +89,16 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout } return v2Data({ mcpServer: toV2McpServer(result.server) }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error updating MCP server`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** DELETE /api/v2/mcp-servers/[id] — Remove an MCP server from the workspace. */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'mcp-server-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteMcpServerContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId } = parsed.data.query +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteMcpServerContract, + rateLimitEndpoint: 'mcp-server-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { id } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -172,10 +109,5 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou } return v2Data({ id, deleted: true as const }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error deleting MCP server`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/mcp-servers/route.ts b/apps/sim/app/api/v2/mcp-servers/route.ts index e9a32f7251f..c14c89233c7 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.ts @@ -1,13 +1,7 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateMcpServerContract, v2ListMcpServersContract, } from '@/lib/api/contracts/v2/mcp-servers' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performCreateMcpServer } from '@/lib/mcp/orchestration' import { getMcpServerIdState, @@ -15,47 +9,20 @@ import { listWorkspaceMcpServers, } from '@/lib/mcp/queries' import { generateMcpServerId } from '@/lib/mcp/utils' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import { toV2McpServer, v2McpOrchestrationError } from '@/app/api/v2/mcp-servers/utils' -const logger = createLogger('V2McpServersAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/mcp-servers — List MCP servers in a workspace. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'mcp-servers') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListMcpServersContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListMcpServersContract, + rateLimitEndpoint: 'mcp-servers', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -64,38 +31,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { // The per-workspace server set is small and bounded → a single full page. return v2CursorList(rows.map(toV2McpServer), null, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error listing MCP servers`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** POST /api/v2/mcp-servers — Register a new MCP server. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'mcp-servers') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CreateMcpServerContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, ...body } = parsed.data.body +export const POST = withPublicApiRouteHandler({ + contract: v2CreateMcpServerContract, + rateLimitEndpoint: 'mcp-servers', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { workspaceId, ...body } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -155,13 +99,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const created = await getWorkspaceMcpServer({ workspaceId, serverId: result.serverId }) - if (!created) return v2Error('INTERNAL_ERROR', 'Internal server error') + if (!created) { + throw new Error(`MCP server ${result.serverId} missing after a successful registration`) + } return v2Data({ mcpServer: toV2McpServer(created) }, { rateLimit, status: 201 }) - } catch (error) { - logger.error(`[${requestId}] Error creating MCP server`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/secrets/[name]/route.ts b/apps/sim/app/api/v2/secrets/[name]/route.ts index a54adb9d509..ca559713c46 100644 --- a/apps/sim/app/api/v2/secrets/[name]/route.ts +++ b/apps/sim/app/api/v2/secrets/[name]/route.ts @@ -1,15 +1,11 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest, NextResponse } from 'next/server' +import type { NextResponse } from 'next/server' import { type V2Secret, type V2SecretScope, v2DeleteSecretContract, v2SetSecretContract, } from '@/lib/api/contracts/v2/secrets' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getWorkspaceEnvKeyAdminAccess } from '@/lib/credentials/environment' import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' import { @@ -19,19 +15,11 @@ import { setWorkspaceSecret, } from '@/lib/credentials/secret-values' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import { secretCredentialTypes, toV2Secret } from '@/app/api/v2/secrets/utils' -const logger = createLogger('V2SecretAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -92,22 +80,12 @@ async function getSecretMetadata(params: { } /** PUT /api/v2/secrets/[name] — Create or replace a write-only secret value. */ -export const PUT = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - try { - const rateLimit = await checkRateLimit(request, 'secret-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2SetSecretContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { name } = parsed.data.params - const { workspaceId, scope, value } = parsed.data.body +export const PUT = withPublicApiRouteHandler({ + contract: v2SetSecretContract, + rateLimitEndpoint: 'secret-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { name } = input.params + const { workspaceId, scope, value } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -142,29 +120,16 @@ export const PUT = withRouteHandler(async (request: NextRequest, context: RouteC }) return v2Data({ secret }, { rateLimit, status: result.created ? 201 : 200 }) - } catch (error) { - logger.error('Error setting secret', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** DELETE /api/v2/secrets/[name] — Delete a secret without reading its value. */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - try { - const rateLimit = await checkRateLimit(request, 'secret-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteSecretContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { name } = parsed.data.params - const { workspaceId, scope } = parsed.data.query +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteSecretContract, + rateLimitEndpoint: 'secret-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { name } = input.params + const { workspaceId, scope } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -199,8 +164,5 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou }) return v2Data({ name, scope, deleted: true as const }, { rateLimit }) - } catch (error) { - logger.error('Error deleting secret', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/secrets/route.ts b/apps/sim/app/api/v2/secrets/route.ts index 3b33ee60741..a2ca345b892 100644 --- a/apps/sim/app/api/v2/secrets/route.ts +++ b/apps/sim/app/api/v2/secrets/route.ts @@ -1,48 +1,20 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2ListSecretsContract } from '@/lib/api/contracts/v2/secrets' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2CursorList, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import { secretCredentialTypes, toV2Secret } from '@/app/api/v2/secrets/utils' -const logger = createLogger('V2SecretsAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/secrets — List secret names and metadata without reading their values. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'secrets') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListSecretsContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, scope, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListSecretsContract, + rateLimitEndpoint: 'secrets', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, scope, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -61,8 +33,5 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .map((row) => toV2Secret(row, userId)) return v2CursorList(secrets, null, { rateLimit }) - } catch (error) { - logger.error('Error listing secrets', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/skills/[id]/route.ts b/apps/sim/app/api/v2/skills/[id]/route.ts index 2cb7d1f2017..00dae3b6bce 100644 --- a/apps/sim/app/api/v2/skills/[id]/route.ts +++ b/apps/sim/app/api/v2/skills/[id]/route.ts @@ -1,29 +1,15 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2DeleteSkillContract, v2GetSkillContract, v2UpdateSkillContract, } from '@/lib/api/contracts/v2/skills' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performDeleteSkill, performUpdateSkill } from '@/lib/skills/orchestration' import { getSkillById } from '@/lib/workflows/skills/operations' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import { toV2Skill, v2SkillOrchestrationError } from '@/app/api/v2/skills/utils' -const logger = createLogger('V2SkillDetailAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -32,25 +18,12 @@ interface RouteContext { } /** GET /api/v2/skills/[id] — Fetch a single skill, including its body. */ -export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'skill-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetSkillContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2GetSkillContract, + rateLimitEndpoint: 'skill-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { id } = input.params + const { workspaceId } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -59,34 +32,16 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RouteC if (!skill) return v2Error('NOT_FOUND', 'Skill not found') return v2Data({ skill: toV2Skill(skill) }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error fetching skill`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** PATCH /api/v2/skills/[id] — Update a skill. Omitted fields keep their values. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'skill-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateSkillContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId, name, description, content } = parsed.data.body +export const PATCH = withPublicApiRouteHandler({ + contract: v2UpdateSkillContract, + rateLimitEndpoint: 'skill-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { id } = input.params + const { workspaceId, name, description, content } = input.body /** * Editing an existing skill is gated per skill, not per workspace: an @@ -114,34 +69,16 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout } return v2Data({ skill: toV2Skill(result.skill) }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error updating skill`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** DELETE /api/v2/skills/[id] — Delete a skill. */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'skill-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteSkillContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { workspaceId } = parsed.data.query +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteSkillContract, + rateLimitEndpoint: 'skill-detail', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { id } = input.params + const { workspaceId } = input.query // Gated per skill by `performDeleteSkill`, same as PATCH above. const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') @@ -160,10 +97,5 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou } return v2Data({ id, deleted: true as const }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error deleting skill`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/skills/route.ts b/apps/sim/app/api/v2/skills/route.ts index 1541ca2be7f..868c1c54979 100644 --- a/apps/sim/app/api/v2/skills/route.ts +++ b/apps/sim/app/api/v2/skills/route.ts @@ -1,53 +1,20 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateSkillContract, v2ListSkillsContract } from '@/lib/api/contracts/v2/skills' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { performCreateSkill } from '@/lib/skills/orchestration' import { listSkills } from '@/lib/workflows/skills/operations' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2CursorList, v2Data, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import { toV2Skill, toV2SkillSummary, v2SkillOrchestrationError } from '@/app/api/v2/skills/utils' -const logger = createLogger('V2SkillsAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/skills — List skills in a workspace, built-ins included. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'skills') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListSkillsContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListSkillsContract, + rateLimitEndpoint: 'skills', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -56,38 +23,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { // The per-workspace skill set is small and bounded → a single full page. return v2CursorList(skills.map(toV2SkillSummary), null, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error listing skills`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** POST /api/v2/skills — Create a skill. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'skills') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CreateSkillContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, name, description, content } = parsed.data.body +export const POST = withPublicApiRouteHandler({ + contract: v2CreateSkillContract, + rateLimitEndpoint: 'skills', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + const { workspaceId, name, description, content } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -107,10 +51,5 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } return v2Data({ skill: toV2Skill(result.skill) }, { rateLimit, status: 201 }) - } catch (error) { - logger.error(`[${requestId}] Error creating skill`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts index 69fe9094e67..1ef1f1f6e09 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts @@ -1,21 +1,16 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CancelTableRunsContract } from '@/lib/api/contracts/v2/tables' -import { isZodError, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isZodError } from '@/lib/api/server' import type { Filter, TableSchema } from '@/lib/table' import { TableQueryValidationError } from '@/lib/table/errors' import { signalTableRowsChanged } from '@/lib/table/events' import { cancelWorkflowGroupRuns } from '@/lib/table/workflow-columns' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2Data, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' @@ -26,10 +21,6 @@ const logger = createLogger('V2TableCancelRunsAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - /** * POST /api/v2/tables/[tableId]/cancel-runs — Stop in-flight cell runs. * @@ -38,63 +29,53 @@ interface TableRouteParams { * every running and pending cell (optionally narrowed by `filter`); `row` * cancels one row's cells. */ -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-enrichment') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2CancelTableRunsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId, scope, rowId, filter, excludeRowIds } = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const access = await checkAccess(tableId, userId, 'write') - if (!access.ok) return v2TableAccessError(access) - - if (access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - // The public predicate is column-NAME keyed; the runners compile the - // storage-keyed legacy filter. Translating up front makes an unknown field - // a 400 rather than a cancel that silently matches nothing. - let legacyFilter: Filter | undefined - if (filter) { - legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema) +export const POST = withPublicApiRouteHandler({ + contract: v2CancelTableRunsContract, + rateLimitEndpoint: 'table-enrichment', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const { workspaceId, scope, rowId, filter, excludeRowIds } = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + if (access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // The public predicate is column-NAME keyed; the runners compile the + // storage-keyed legacy filter. Translating up front makes an unknown field + // a 400 rather than a cancel that silently matches nothing. + let legacyFilter: Filter | undefined + if (filter) { + legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema) + } + + const cancelled = await cancelWorkflowGroupRuns( + tableId, + scope === 'row' ? rowId : undefined, + { + filter: legacyFilter, + excludeRowIds, + } + ) + + // Cancelling clears the affected rows' exec state, so open readers must + // refetch to pick up the cleared cells. + signalTableRowsChanged(tableId) + + logger.info(`[${requestId}] Cancelled table runs`, { tableId, scope, rowId, cancelled }) + + return v2Data({ cancelled }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + throw error } - - const cancelled = await cancelWorkflowGroupRuns(tableId, scope === 'row' ? rowId : undefined, { - filter: legacyFilter, - excludeRowIds, - }) - - // Cancelling clears the affected rows' exec state, so open readers must - // refetch to pick up the cleared cells. - signalTableRowsChanged(tableId) - - logger.info(`[${requestId}] Cancelled table runs`, { tableId, scope, rowId, cancelled }) - - return v2Data({ cancelled }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - logger.error(`[${requestId}] Error cancelling table runs`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts index 77a3f1f5e1c..1c6d1092e17 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts @@ -1,174 +1,121 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2AddTableColumnContract, v2DeleteTableColumnContract, v2UpdateTableColumnContract, } from '@/lib/api/contracts/v2/tables' -import { isZodError, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isZodError } from '@/lib/api/server' import { addTableColumn, deleteColumn } from '@/lib/table' import { performUpdateTableColumn } from '@/lib/table/orchestration' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess, normalizeColumn } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' import { v2TableAccessError, v2TableOrchestrationError } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableColumnsAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface ColumnsRouteParams { - params: Promise<{ tableId: string }> -} - /** POST /api/v2/tables/[tableId]/columns — Add a column to the table schema. */ -export const POST = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => { - const requestId = generateRequestId() +export const POST = withPublicApiRouteHandler({ + contract: v2AddTableColumnContract, + rateLimitEndpoint: 'table-columns', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const validated = input.body - try { - const rateLimit = await checkRateLimit(request, 'table-columns') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) - const userId = rateLimit.userId! + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) - const gate = await v2ApiGateError(userId) - if (gate) return gate + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } - const parsed = await parseRequest(v2AddTableColumnContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response + const updatedTable = await addTableColumn(tableId, validated.column, requestId) - const { tableId } = parsed.data.params - const validated = parsed.data.body + recordAudit({ + workspaceId: validated.workspaceId, + actorId: userId, + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: tableId, + resourceName: table.name, + description: `Added column "${validated.column.name}" to table "${table.name}"`, + metadata: { column: validated.column }, + request, + }) - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) + return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') + throw error } - - const updatedTable = await addTableColumn(tableId, validated.column, requestId) - - recordAudit({ - workspaceId: validated.workspaceId, - actorId: userId, - action: AuditAction.TABLE_UPDATED, - resourceType: AuditResourceType.TABLE, - resourceId: tableId, - resourceName: table.name, - description: `Added column "${validated.column.name}" to table "${table.name}"`, - metadata: { column: validated.column }, - request, - }) - - return v2Data({ columns: updatedTable.schema.columns.map(normalizeColumn) }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - logger.error(`[${requestId}] Error adding column to table`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** PATCH /api/v2/tables/[tableId]/columns — Update a column (rename, type change, constraints). */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: ColumnsRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-columns') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateTableColumnContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response +export const PATCH = withPublicApiRouteHandler({ + contract: v2UpdateTableColumnContract, + rateLimitEndpoint: 'table-columns', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const validated = input.body - const { tableId } = parsed.data.params - const validated = parsed.data.body + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } + const outcome = await performUpdateTableColumn({ + table, + columnName: validated.columnName, + userId, + updates: validated.updates, + requestId, + request, + }) + if (!outcome.success || !outcome.table) { + return v2TableOrchestrationError(outcome, 'Failed to update column') + } - const outcome = await performUpdateTableColumn({ - table, - columnName: validated.columnName, - userId, - updates: validated.updates, - requestId, - request, - }) - if (!outcome.success || !outcome.table) { - return v2TableOrchestrationError(outcome, 'Failed to update column') + return v2Data({ columns: outcome.table.schema.columns.map(normalizeColumn) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + throw error } - - return v2Data({ columns: outcome.table.schema.columns.map(normalizeColumn) }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - logger.error(`[${requestId}] Error updating column in table`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** DELETE /api/v2/tables/[tableId]/columns — Delete a column from the table schema. */ -export const DELETE = withRouteHandler( - async (request: NextRequest, context: ColumnsRouteParams) => { - const requestId = generateRequestId() - +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteTableColumnContract, + rateLimitEndpoint: 'table-columns', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-columns') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteTableColumnContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const validated = parsed.data.body + const { tableId } = input.params + const validated = input.body const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -205,10 +152,7 @@ export const DELETE = withRouteHandler( const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error(`[${requestId}] Error deleting column from table`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts index f534f57f5fd..7f2345f73be 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts @@ -1,22 +1,16 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2RunTableColumnContract } from '@/lib/api/contracts/v2/tables' -import { isZodError, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isZodError } from '@/lib/api/server' import type { Filter, TableSchema } from '@/lib/table' import { TableQueryValidationError } from '@/lib/table/errors' import { signalTableRowsChanged } from '@/lib/table/events' import { runWorkflowColumn } from '@/lib/table/workflow-columns' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' @@ -26,15 +20,9 @@ import { v2TableLockError, } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableRunColumnAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - /** * POST /api/v2/tables/[tableId]/columns/run — Run workflow/enrichment groups. * @@ -43,76 +31,61 @@ interface TableRouteParams { * poll the row endpoints. `dispatchId` is `null` where no background runner is * configured and cells execute inline. */ -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-enrichment') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2RunTableColumnContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId, groupIds, runMode, rowIds, filter, excludeRowIds, limit } = - parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const access = await checkAccess(tableId, userId, 'write') - if (!access.ok) return v2TableAccessError(access) - - if (access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - // The public predicate is column-NAME keyed; the dispatcher compiles the - // storage-keyed legacy filter. Translating up front also makes an unknown - // field a 400 here rather than a dispatch that silently matches nothing. - let legacyFilter: Filter | undefined - if (filter) { - legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema) +export const POST = withPublicApiRouteHandler({ + contract: v2RunTableColumnContract, + rateLimitEndpoint: 'table-enrichment', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const { workspaceId, groupIds, runMode, rowIds, filter, excludeRowIds, limit } = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const access = await checkAccess(tableId, userId, 'write') + if (!access.ok) return v2TableAccessError(access) + + if (access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + // The public predicate is column-NAME keyed; the dispatcher compiles the + // storage-keyed legacy filter. Translating up front also makes an unknown + // field a 400 here rather than a dispatch that silently matches nothing. + let legacyFilter: Filter | undefined + if (filter) { + legacyFilter = v2BulkPredicateToFilter(filter, access.table.schema as TableSchema) + } + + const { dispatchId } = await runWorkflowColumn({ + tableId, + workspaceId, + groupIds, + mode: runMode, + rowIds, + filter: legacyFilter, + excludeRowIds, + limit, + requestId, + triggeredByUserId: userId, + }) + + // Starting a run clears the target groups' cells to pending — a row change + // open readers must pick up. + signalTableRowsChanged(tableId) + + return v2Data({ dispatchId }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + const lockError = v2TableLockError(error) + if (lockError) return lockError + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + throw error } - - const { dispatchId } = await runWorkflowColumn({ - tableId, - workspaceId, - groupIds, - mode: runMode, - rowIds, - filter: legacyFilter, - excludeRowIds, - limit, - requestId, - triggeredByUserId: userId, - }) - - // Starting a run clears the target groups' cells to pending — a row change - // open readers must pick up. - signalTableRowsChanged(tableId) - - return v2Data({ dispatchId }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - const lockError = v2TableLockError(error) - if (lockError) return lockError - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - logger.error(`[${requestId}] Error running table columns`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts index 63bf970f013..1582877d704 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts @@ -1,67 +1,48 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateTableExportContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createTableExportResource, toV2TableExport, } from '@/lib/table/orchestration/export-resource' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2TableExportsAPI') - -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'table-export') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2CreateTableExportContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { workspaceId, format } = parsed.data.body - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - const access = await checkAccess(parsed.data.params.tableId, userId, 'read') - if (!access.ok || access.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') +export const POST = withPublicApiRouteHandler({ + contract: v2CreateTableExportContract, + rateLimitEndpoint: 'table-export', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + try { + const { workspaceId, format } = input.body + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + const access = await checkAccess(input.params.tableId, userId, 'read') + if (!access.ok || access.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + const record = await createTableExportResource({ table: access.table, format }) + recordAudit({ + workspaceId, + actorId: userId, + action: AuditAction.TABLE_EXPORTED, + resourceType: AuditResourceType.TABLE, + resourceId: access.table.id, + resourceName: access.table.name, + description: `Exported table "${access.table.name}" as ${format.toUpperCase()}`, + metadata: { format, rowCount: access.table.rowCount }, + request, + }) + return v2Data(toV2TableExport(record, true), { rateLimit, status: 201 }) + } catch (error) { + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + throw error } - const record = await createTableExportResource({ table: access.table, format }) - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.TABLE_EXPORTED, - resourceType: AuditResourceType.TABLE, - resourceId: access.table.id, - resourceName: access.table.name, - description: `Exported table "${access.table.name}" as ${format.toUpperCase()}`, - metadata: { format, rowCount: access.table.rowCount }, - request, - }) - return v2Data(toV2TableExport(record, true), { rateLimit, status: 201 }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - logger.error('Failed to create table export', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts index 2d96bf9149a..66d06b07f0c 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts @@ -1,17 +1,11 @@ -import { createLogger } from '@sim/logger' import { getActiveWorkflowContext } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { v2AddWorkflowGroupContract, v2DeleteWorkflowGroupContract, v2ListWorkflowGroupsContract, v2UpdateWorkflowGroupContract, } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { TableDefinition, TableSchema } from '@/lib/table' import { signalTableSchemaChanged } from '@/lib/table/events' import { @@ -19,28 +13,15 @@ import { deleteWorkflowGroup, updateWorkflowGroup, } from '@/lib/table/workflow-groups/service' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess, normalizeColumn } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import { v2TableLockError } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableGroupsAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - /** * GET /api/v2/tables/[tableId]/groups — The table's workflow/enrichment groups. * @@ -50,25 +31,12 @@ interface TableRouteParams { * the already-loaded definition rather than a second query, and the set is * bounded per table — one full page, `nextCursor` always `null`. */ -export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-groups') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2ListWorkflowGroupsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListWorkflowGroupsContract, + rateLimitEndpoint: 'table-groups', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { tableId } = input.params + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -82,21 +50,16 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR const groups = (result.table.schema as TableSchema).workflowGroups ?? [] return v2CursorList(groups, null, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error listing workflow groups`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** - * Renders a group-service failure in the v2 envelope. The service signals - * through thrown `Error` messages rather than classified codes, so the string - * matching mirrors the first-party mapper — the two surfaces must agree on - * which failures are the caller's fault. + * Maps expected group-service failures into the v2 envelope. The service + * signals through thrown `Error` messages rather than classified codes, so the + * string matching mirrors the first-party mapper. Unexpected errors keep + * bubbling to the public route wrapper for centralized logging and rendering. */ -function groupMutationError(error: unknown, requestId: string, fallback: string) { +function groupMutationError(error: unknown) { const lockError = v2TableLockError(error) if (lockError) return lockError @@ -115,8 +78,7 @@ function groupMutationError(error: unknown, requestId: string, fallback: string) } } - logger.error(`[${requestId}] ${fallback}`, { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } /** @@ -154,80 +116,69 @@ function groupResponse(table: TableDefinition, groupId: string) { * POST /api/v2/tables/[tableId]/groups — Bind a workflow or enrichment to the * table and create the columns its runs populate, in one call. */ -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-groups') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2AddWorkflowGroupContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const validated = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok || result.table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - if (validated.group.workflowId) { - const workflowError = await assertWorkflowInWorkspace( - validated.group.workflowId, - result.table.workspaceId +export const POST = withPublicApiRouteHandler({ + contract: v2AddWorkflowGroupContract, + rateLimitEndpoint: 'table-groups', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const validated = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok || result.table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + if (validated.group.workflowId) { + const workflowError = await assertWorkflowInWorkspace( + validated.group.workflowId, + result.table.workspaceId + ) + if (workflowError) return workflowError + } + + /** + * `outputs` and `outputColumns` are two arrays joined by column name, so a + * typo in either silently creates a column nothing feeds. The first-party + * client builds both from one picker and can't desync; a public caller can, + * so the mismatch is rejected rather than persisted. + */ + const outputNames = new Set(validated.group.outputs.map((output) => output.columnName)) + const orphan = validated.outputColumns.find((column) => !outputNames.has(column.name)) + if (orphan) { + return v2Error( + 'BAD_REQUEST', + `outputColumns entry "${orphan.name}" has no matching group.outputs[].columnName` + ) + } + + const groupId = validated.group.id ?? generateId() + + const updatedTable = await addWorkflowGroup( + { + tableId, + group: { ...validated.group, id: groupId }, + // Stamped from the resolved group rather than trusted from the caller. + outputColumns: validated.outputColumns.map((column) => ({ + ...column, + workflowGroupId: groupId, + })), + autoRun: validated.autoRun, + actorUserId: userId, + }, + requestId ) - if (workflowError) return workflowError - } - /** - * `outputs` and `outputColumns` are two arrays joined by column name, so a - * typo in either silently creates a column nothing feeds. The first-party - * client builds both from one picker and can't desync; a public caller can, - * so the mismatch is rejected rather than persisted. - */ - const outputNames = new Set(validated.group.outputs.map((output) => output.columnName)) - const orphan = validated.outputColumns.find((column) => !outputNames.has(column.name)) - if (orphan) { - return v2Error( - 'BAD_REQUEST', - `outputColumns entry "${orphan.name}" has no matching group.outputs[].columnName` - ) - } + signalTableSchemaChanged(tableId) - const groupId = validated.group.id ?? generateId() - - const updatedTable = await addWorkflowGroup( - { - tableId, - group: { ...validated.group, id: groupId }, - // Stamped from the resolved group rather than trusted from the caller. - outputColumns: validated.outputColumns.map((column) => ({ - ...column, - workflowGroupId: groupId, - })), - autoRun: validated.autoRun, - actorUserId: userId, - }, - requestId - ) - - signalTableSchemaChanged(tableId) - - return v2Data(groupResponse(updatedTable, groupId), { rateLimit, status: 201 }) - } catch (error) { - return groupMutationError(error, requestId, 'Failed to add workflow group') - } + return v2Data(groupResponse(updatedTable, groupId), { rateLimit, status: 201 }) + } catch (error) { + return groupMutationError(error) + } + }, }) /** @@ -237,80 +188,69 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Table * Removing an output **deletes that column and its values** — the same * behavior as `DELETE /columns` on a bound column. There is no detach. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-groups') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateWorkflowGroupContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const validated = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) +export const PATCH = withPublicApiRouteHandler({ + contract: v2UpdateWorkflowGroupContract, + rateLimitEndpoint: 'table-groups', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const validated = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok || result.table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + if (validated.workflowId !== undefined) { + const workflowError = await assertWorkflowInWorkspace( + validated.workflowId, + result.table.workspaceId + ) + if (workflowError) return workflowError + } + + const updatedTable = await updateWorkflowGroup( + { + tableId, + groupId: validated.groupId, + actorUserId: userId, + ...(validated.workflowId !== undefined ? { workflowId: validated.workflowId } : {}), + ...(validated.name !== undefined ? { name: validated.name } : {}), + ...(validated.dependencies !== undefined ? { dependencies: validated.dependencies } : {}), + ...(validated.outputs !== undefined ? { outputs: validated.outputs } : {}), + ...(validated.newOutputColumns !== undefined + ? { + newOutputColumns: validated.newOutputColumns.map((column) => ({ + ...column, + workflowGroupId: validated.groupId, + })), + } + : {}), + ...(validated.mappingUpdates !== undefined + ? { mappingUpdates: validated.mappingUpdates } + : {}), + ...(validated.inputMappings !== undefined + ? { inputMappings: validated.inputMappings } + : {}), + ...(validated.deploymentMode !== undefined + ? { deploymentMode: validated.deploymentMode } + : {}), + ...(validated.type !== undefined ? { type: validated.type } : {}), + ...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}), + }, + requestId + ) - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok || result.table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } + signalTableSchemaChanged(tableId) - if (validated.workflowId !== undefined) { - const workflowError = await assertWorkflowInWorkspace( - validated.workflowId, - result.table.workspaceId - ) - if (workflowError) return workflowError + return v2Data(groupResponse(updatedTable, validated.groupId), { rateLimit }) + } catch (error) { + return groupMutationError(error) } - - const updatedTable = await updateWorkflowGroup( - { - tableId, - groupId: validated.groupId, - actorUserId: userId, - ...(validated.workflowId !== undefined ? { workflowId: validated.workflowId } : {}), - ...(validated.name !== undefined ? { name: validated.name } : {}), - ...(validated.dependencies !== undefined ? { dependencies: validated.dependencies } : {}), - ...(validated.outputs !== undefined ? { outputs: validated.outputs } : {}), - ...(validated.newOutputColumns !== undefined - ? { - newOutputColumns: validated.newOutputColumns.map((column) => ({ - ...column, - workflowGroupId: validated.groupId, - })), - } - : {}), - ...(validated.mappingUpdates !== undefined - ? { mappingUpdates: validated.mappingUpdates } - : {}), - ...(validated.inputMappings !== undefined - ? { inputMappings: validated.inputMappings } - : {}), - ...(validated.deploymentMode !== undefined - ? { deploymentMode: validated.deploymentMode } - : {}), - ...(validated.type !== undefined ? { type: validated.type } : {}), - ...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}), - }, - requestId - ) - - signalTableSchemaChanged(tableId) - - return v2Data(groupResponse(updatedTable, validated.groupId), { rateLimit }) - } catch (error) { - return groupMutationError(error, requestId, 'Failed to update workflow group') - } + }, }) /** @@ -318,50 +258,39 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Tabl * fed**, along with their values. The surviving column list comes back so a * caller does not have to re-read the table to see what is left. */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-groups') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteWorkflowGroupContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const validated = parsed.data.body +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteWorkflowGroupContract, + rateLimitEndpoint: 'table-groups', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const validated = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok || result.table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const updatedTable = await deleteWorkflowGroup( + { tableId, groupId: validated.groupId }, + requestId + ) - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) + signalTableSchemaChanged(tableId) - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok || result.table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') + return v2Data( + { + id: validated.groupId, + deleted: true as const, + columns: (updatedTable.schema as TableSchema).columns.map(normalizeColumn), + }, + { rateLimit } + ) + } catch (error) { + return groupMutationError(error) } - - const updatedTable = await deleteWorkflowGroup( - { tableId, groupId: validated.groupId }, - requestId - ) - - signalTableSchemaChanged(tableId) - - return v2Data( - { - id: validated.groupId, - deleted: true as const, - columns: (updatedTable.schema as TableSchema).columns.map(normalizeColumn), - }, - { rateLimit } - ) - } catch (error) { - return groupMutationError(error, requestId, 'Failed to delete workflow group') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts index 495c8aeff48..a38f876dfbb 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts @@ -1,11 +1,6 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { TABLE_QUERY_MAX_BODY_BYTES } from '@/lib/api/contracts/tables' import { V2_DEFAULT_ROW_LIMIT, v2QueryRowsContract } from '@/lib/api/contracts/v2/tables' -import { isZodError, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isZodError } from '@/lib/api/server' import type { Sort, TablePredicate, TableSchema } from '@/lib/table' import { buildIdByName, sortSpecNamesToIds } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' @@ -14,127 +9,108 @@ import { validatePredicate, validateSortSpec } from '@/lib/table/query-builder/v import { assertCursorSortBinding, decodeCursor } from '@/lib/table/rows/cursor' import { queryRows } from '@/lib/table/rows/service' import { predicateToStorage } from '@/lib/table/select-values' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CursorList, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' import { toApiRow } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableQueryAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface QueryRouteParams { - params: Promise<{ tableId: string }> -} - /** * POST /api/v2/tables/[tableId]/query — public row query. Typed `predicate`/`sort` * objects + opaque cursor pagination. Default page {@link V2_DEFAULT_ROW_LIMIT}; * `limit=0` = unbounded (whole result or 400). */ -export const POST = withRouteHandler(async (request: NextRequest, context: QueryRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2QueryRowsContract, request, context, { - maxBodyBytes: TABLE_QUERY_MAX_BODY_BYTES, - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId, sort, cursor: cursorToken, limit } = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const accessResult = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') - - const { table } = accessResult - if (workspaceId !== table.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') +export const POST = withPublicApiRouteHandler({ + contract: v2QueryRowsContract, + rateLimitEndpoint: 'table-rows', + parseOptions: { + maxBodyBytes: TABLE_QUERY_MAX_BODY_BYTES, + }, + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const { workspaceId, sort, cursor: cursorToken, limit } = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') + + const { table } = accessResult + if (workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const schema = table.schema as TableSchema + const cursor = cursorToken ? decodeCursor(cursorToken) : undefined + + const idByName = buildIdByName(schema) + // Fuses the id→name key remap with select-cell value formatting, so a select + // cell surfaces its option NAME rather than the stored option id. + const toNamedRow = namedRowMapper(schema.columns) + let predicate: TablePredicate | undefined = input.body.predicate + if (predicate) { + validatePredicate(predicate, schema.columns) + predicate = predicateToStorage(predicate, schema) + } + let sortSpec = sort + if (sortSpec?.length) { + validateSortSpec(sortSpec, schema.columns) + sortSpec = sortSpecNamesToIds(sortSpec, idByName) + } + const sortObj: Sort | undefined = sortSpec?.length + ? Object.fromEntries(sortSpec.map((s) => [s.field, s.direction])) + : undefined + + // A cursor is only valid for the query shape it was minted under: keyset + // cursors bind to the default order, offset cursors to their sort. Runs on + // the STORAGE-keyed sort so the fingerprint matches what queryRows stamped. + if (cursor) assertCursorSortBinding(cursor, sortObj) + + // Public default is a bounded page (unlike the internal surface's unbounded + // omit). `limit=0` is the explicit unbounded opt-in. + const effectiveLimit = + limit === undefined ? V2_DEFAULT_ROW_LIMIT : limit === 0 ? undefined : limit + + const result = await queryRows( + table, + { + predicate, + sort: sortObj, + limit: effectiveLimit, + after: cursor?.after, + offset: cursor?.offset, + includeTotal: false, + withExecutions: false, + }, + requestId + ) + + return v2CursorList( + result.rows.map((r) => toApiRow(r, toNamedRow)), + result.nextCursor, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + if (error instanceof TableQueryValidationError) { + return v2Error('BAD_REQUEST', error.message, { + details: error.code ? { code: error.code } : undefined, + }) + } + + throw error } - - const schema = table.schema as TableSchema - const cursor = cursorToken ? decodeCursor(cursorToken) : undefined - - const idByName = buildIdByName(schema) - // Fuses the id→name key remap with select-cell value formatting, so a select - // cell surfaces its option NAME rather than the stored option id. - const toNamedRow = namedRowMapper(schema.columns) - let predicate: TablePredicate | undefined = parsed.data.body.predicate - if (predicate) { - validatePredicate(predicate, schema.columns) - predicate = predicateToStorage(predicate, schema) - } - let sortSpec = sort - if (sortSpec?.length) { - validateSortSpec(sortSpec, schema.columns) - sortSpec = sortSpecNamesToIds(sortSpec, idByName) - } - const sortObj: Sort | undefined = sortSpec?.length - ? Object.fromEntries(sortSpec.map((s) => [s.field, s.direction])) - : undefined - - // A cursor is only valid for the query shape it was minted under: keyset - // cursors bind to the default order, offset cursors to their sort. Runs on - // the STORAGE-keyed sort so the fingerprint matches what queryRows stamped. - if (cursor) assertCursorSortBinding(cursor, sortObj) - - // Public default is a bounded page (unlike the internal surface's unbounded - // omit). `limit=0` is the explicit unbounded opt-in. - const effectiveLimit = - limit === undefined ? V2_DEFAULT_ROW_LIMIT : limit === 0 ? undefined : limit - - const result = await queryRows( - table, - { - predicate, - sort: sortObj, - limit: effectiveLimit, - after: cursor?.after, - offset: cursor?.offset, - includeTotal: false, - withExecutions: false, - }, - requestId - ) - - return v2CursorList( - result.rows.map((r) => toApiRow(r, toNamedRow)), - result.nextCursor, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - if (error instanceof TableQueryValidationError) { - return v2Error('BAD_REQUEST', error.message, { - details: error.code ? { code: error.code } : undefined, - }) - } - - logger.error(`[${requestId}] Error querying rows (v2 public)`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index 4df088ba0ce..2f41f7ff910 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -1,15 +1,11 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2DeleteTableContract, v2GetTableContract, v2UpdateTableContract, } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { getTableById } from '@/lib/table' import { signalTableSchemaChanged } from '@/lib/table/events' @@ -19,17 +15,11 @@ import { performRenameTable, performUpdateTableDescription, } from '@/lib/table/orchestration' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import type { OrchestrationOutcome } from '@/app/api/v2/tables/utils' import { toApiTable, @@ -54,30 +44,13 @@ function appliedDetails( export const dynamic = 'force-dynamic' export const revalidate = 0 -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - /** GET /api/v2/tables/[tableId] — Get table details. */ -export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetTableContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2GetTableContract, + rateLimitEndpoint: 'table-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { tableId } = input.params + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -95,12 +68,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR { table: toApiTable(result.table, folderPathForId(folderIndex, result.table.folderId)) }, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error getting table`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** @@ -115,183 +83,160 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR * a first-party admin action. The contract body is `.strict()`, so a request * carrying `locks` is rejected rather than silently ignored. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - /** - * Hoisted above the `try` so every exit path can report it. Once a write has - * committed, the response must say so even when the failure came *after* the - * writes — a throw in the final re-read, or the re-read finding the table - * archived. Reporting a bare 500 there tells the caller nothing landed, and - * it retries into a duplicate-name conflict or a repeated move. - */ - const applied: ('name' | 'description' | 'folderPath')[] = [] - - try { - const rateLimit = await checkRateLimit(request, 'table-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateTableContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const validated = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const resolution = - validated.folderPath === undefined - ? undefined - : await resolveFolderPathIdentity({ - workspaceId: table.workspaceId, - resourceType: 'table', - path: validated.folderPath, - }) - if (resolution && !resolution.found) { - return v2Error('NOT_FOUND', 'Folder not found in this workspace') - } +export const PATCH = withPublicApiRouteHandler({ + contract: v2UpdateTableContract, + rateLimitEndpoint: 'table-detail', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + /** + * Hoisted above the `try` so every exit path can report it. Once a write has + * committed, the response must say so even when the failure came *after* the + * writes — a throw in the final re-read, or the re-read finding the table + * archived. Reporting a bare 500 there tells the caller nothing landed, and + * it retries into a duplicate-name conflict or a repeated move. + */ + const applied: ('name' | 'description' | 'folderPath')[] = [] + + try { + const { tableId } = input.params + const validated = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } - let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null + const resolution = + validated.folderPath === undefined + ? undefined + : await resolveFolderPathIdentity({ + workspaceId: table.workspaceId, + resourceType: 'table', + path: validated.folderPath, + }) + if (resolution && !resolution.found) { + return v2Error('NOT_FOUND', 'Folder not found in this workspace') + } - if (validated.name !== undefined) { - const outcome = await performRenameTable({ - table, - newName: validated.name, - userId, - requestId, - request, - }) - if (outcome.success) applied.push('name') - else failure = { outcome, fallback: 'Failed to rename table' } - } + let failure: { outcome: OrchestrationOutcome; fallback: string } | null = null + + if (validated.name !== undefined) { + const outcome = await performRenameTable({ + table, + newName: validated.name, + userId, + requestId, + request, + }) + if (outcome.success) applied.push('name') + else failure = { outcome, fallback: 'Failed to rename table' } + } - if (!failure && validated.description !== undefined) { - const outcome = await performUpdateTableDescription({ - table, - description: validated.description, - userId, - requestId, - request, - }) - if (outcome.success) applied.push('description') - else failure = { outcome, fallback: 'Failed to update table description' } - } + if (!failure && validated.description !== undefined) { + const outcome = await performUpdateTableDescription({ + table, + description: validated.description, + userId, + requestId, + request, + }) + if (outcome.success) applied.push('description') + else failure = { outcome, fallback: 'Failed to update table description' } + } - if (!failure && validated.folderPath !== undefined) { - const outcome = await performMoveTableToFolder({ - table, - folderId: resolution?.folderId ?? null, - userId, - requestId, - request, - }) - if (outcome.success) { - applied.push('folderPath') - } else { - failure = { - outcome: - outcome.errorCode === 'not_found' ? { ...outcome, error: 'Table not found' } : outcome, - fallback: 'Failed to move table', + if (!failure && validated.folderPath !== undefined) { + const outcome = await performMoveTableToFolder({ + table, + folderId: resolution?.folderId ?? null, + userId, + requestId, + request, + }) + if (outcome.success) { + applied.push('folderPath') + } else { + failure = { + outcome: + outcome.errorCode === 'not_found' + ? { ...outcome, error: 'Table not found' } + : outcome, + fallback: 'Failed to move table', + } } } - } - - if (applied.length > 0) signalTableSchemaChanged(tableId) - if (failure) { - return v2TableOrchestrationError(failure.outcome, failure.fallback, appliedDetails(applied)) - } - - const updated = await getTableById(tableId) - if (!updated) { - return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) }) - } - const folderIndex = await loadActiveFolderPathIndex(table.workspaceId, 'table') - return v2Data( - { table: toApiTable(updated, folderPathForId(folderIndex, updated.folderId)) }, - { rateLimit } - ) - } catch (error) { - const details = appliedDetails(applied) + if (applied.length > 0) signalTableSchemaChanged(tableId) + if (failure) { + return v2TableOrchestrationError(failure.outcome, failure.fallback, appliedDetails(applied)) + } - const lockError = v2TableLockError(error, details) - if (lockError) return lockError + const updated = await getTableById(tableId) + if (!updated) { + return v2Error('NOT_FOUND', 'Table not found', { details: appliedDetails(applied) }) + } - const classified = asOrchestrationError(error) - if (classified) { - return v2TableOrchestrationError( - { errorCode: classified.code, error: classified.message }, - 'Failed to update table', - details + const folderIndex = await loadActiveFolderPathIndex(table.workspaceId, 'table') + return v2Data( + { table: toApiTable(updated, folderPathForId(folderIndex, updated.folderId)) }, + { rateLimit } ) - } + } catch (error) { + const details = appliedDetails(applied) + + const lockError = v2TableLockError(error, details) + if (lockError) return lockError + + const classified = asOrchestrationError(error) + if (classified) { + return v2TableOrchestrationError( + { errorCode: classified.code, error: classified.message }, + 'Failed to update table', + details + ) + } - logger.error(`[${requestId}] Error updating table`, { - error: getErrorMessage(error, 'Unknown error'), - applied, - }) - return v2Error('INTERNAL_ERROR', 'Internal server error', { details }) - } + logger.error(`[${requestId}] Error updating table`, { + error: getErrorMessage(error, 'Unknown error'), + applied, + }) + return v2Error('INTERNAL_ERROR', 'Internal server error', { details }) + } + }, }) -export const DELETE = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteTableContract, + rateLimitEndpoint: 'table-detail', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const { workspaceId } = input.query - const gate = await v2ApiGateError(userId) - if (gate) return gate + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) - const parsed = await parseRequest(v2DeleteTableContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId } = parsed.data.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } + const outcome = await performDeleteTable({ table: result.table, userId, requestId, request }) + if (!outcome.success) { + return v2TableOrchestrationError(outcome, 'Failed to delete table') + } - const outcome = await performDeleteTable({ table: result.table, userId, requestId, request }) - if (!outcome.success) { - return v2TableOrchestrationError(outcome, 'Failed to delete table') + return v2Data({ id: tableId, deleted: true }, { rateLimit }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + throw error } - - return v2Data({ id: tableId, deleted: true }, { rateLimit }) - } catch (error) { - const lockError = v2TableLockError(error) - if (lockError) return lockError - logger.error(`[${requestId}] Error deleting table`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts index 9f3e7a27b69..121c88c7e42 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts @@ -1,34 +1,20 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2RunRowEnrichmentContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { signalTableRowsChanged } from '@/lib/table/events' import { runWorkflowColumn } from '@/lib/table/workflow-columns' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' import { v2TableAccessError, v2TableLockError } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableRowEnrichmentAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface RowEnrichmentRouteParams { - params: Promise<{ tableId: string; rowId: string; groupId: string }> -} - /** * POST /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId] * @@ -36,26 +22,13 @@ interface RowEnrichmentRouteParams { * `mode: 'all'` because naming a specific cell is an explicit re-run request — * an already-populated cell must recompute rather than be skipped. */ -export const POST = withRouteHandler( - async (request: NextRequest, context: RowEnrichmentRouteParams) => { - const requestId = generateRequestId() - +export const POST = withPublicApiRouteHandler({ + contract: v2RunRowEnrichmentContract, + rateLimitEndpoint: 'table-enrichment', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-enrichment') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2RunRowEnrichmentContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId, rowId, groupId } = parsed.data.params - const { workspaceId } = parsed.data.body + const { tableId, rowId, groupId } = input.params + const { workspaceId } = input.body const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -87,10 +60,7 @@ export const POST = withRouteHandler( const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error(`[${requestId}] Error running row enrichment`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts index 026348e69f9..8834f457a9e 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts @@ -1,29 +1,23 @@ import { db } from '@sim/db' import { userTableRows } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' import { v2DeleteTableRowContract, v2GetTableRowContract, v2UpdateTableRowContract, } from '@/lib/api/contracts/v2/tables' -import { isZodError, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isZodError } from '@/lib/api/server' import type { RowData, TableSchema } from '@/lib/table' import { buildIdByName, rowDataNameToId, updateRow } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' import { performDeleteTableRow } from '@/lib/table/orchestration' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' @@ -34,35 +28,16 @@ import { v2TableOrchestrationError, } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableRowAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface RowRouteParams { - params: Promise<{ tableId: string; rowId: string }> -} - /** GET /api/v2/tables/[tableId]/rows/[rowId] — Get a single row. */ -export const GET = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-row-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetTableRowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId, rowId } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2GetTableRowContract, + rateLimitEndpoint: 'table-row-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { tableId, rowId } = input.params + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -109,123 +84,90 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RowRou }, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error getting row`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** PATCH /api/v2/tables/[tableId]/rows/[rowId] — Partial update a single row. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-row-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateTableRowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId, rowId } = parsed.data.params - const validated = parsed.data.body +export const PATCH = withPublicApiRouteHandler({ + contract: v2UpdateTableRowContract, + rateLimitEndpoint: 'table-row-detail', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId, rowId } = input.params + const validated = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) + const updatedRow = await updateRow( + { + tableId, + rowId, + data: rowDataNameToId(validated.data as RowData, idByName), + workspaceId: validated.workspaceId, + actorUserId: userId, + }, + table, + requestId + ) + // No `cancellationGuard` is passed, so `updateRow` can't return null here. + // Defensive narrowing for TypeScript. + if (!updatedRow) return v2Error('NOT_FOUND', 'Row not found') - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) + return v2Data({ row: toApiRow(updatedRow, toNamedRow) }, { rateLimit }) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') + throw error } - - const idByName = buildIdByName(table.schema as TableSchema) - const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) - const updatedRow = await updateRow( - { - tableId, - rowId, - data: rowDataNameToId(validated.data as RowData, idByName), - workspaceId: validated.workspaceId, - actorUserId: userId, - }, - table, - requestId - ) - // No `cancellationGuard` is passed, so `updateRow` can't return null here. - // Defensive narrowing for TypeScript. - if (!updatedRow) return v2Error('NOT_FOUND', 'Row not found') - - return v2Data({ row: toApiRow(updatedRow, toNamedRow) }, { rateLimit }) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - logger.error(`[${requestId}] Error updating row`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** DELETE /api/v2/tables/[tableId]/rows/[rowId] — Delete a single row. */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: RowRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-row-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteTableRowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId, rowId } = parsed.data.params - const { workspaceId } = parsed.data.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } - - const outcome = await performDeleteTableRow({ table: result.table, rowId, requestId }) - if (!outcome.success) { - return v2TableOrchestrationError(outcome, 'Failed to delete row') +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteTableRowContract, + rateLimitEndpoint: 'table-row-detail', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId, rowId } = input.params + const { workspaceId } = input.query + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const outcome = await performDeleteTableRow({ table: result.table, rowId, requestId }) + if (!outcome.success) { + return v2TableOrchestrationError(outcome, 'Failed to delete row') + } + + // v2 mirrors the bulk delete shape: always returns `deletedRowIds`. + return v2Data({ deletedCount: 1, deletedRowIds: [rowId] }, { rateLimit }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + throw error } - - // v2 mirrors the bulk delete shape: always returns `deletedRowIds`. - return v2Data({ deletedCount: 1, deletedRowIds: [rowId] }, { rateLimit }) - } catch (error) { - const lockError = v2TableLockError(error) - if (lockError) return lockError - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - logger.error(`[${requestId}] Error deleting row`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts index 68d86f3dea5..18ebe72ea1f 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts @@ -1,36 +1,24 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2FindTableRowsContract } from '@/lib/api/contracts/v2/tables' -import { isZodError, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isZodError } from '@/lib/api/server' import type { Filter, Sort, TableSchema } from '@/lib/table' import { buildIdByName, sortSpecNamesToIds } from '@/lib/table' import { TableQueryValidationError } from '@/lib/table/errors' import { validateSortSpec } from '@/lib/table/query-builder/validate' import { findRowMatches } from '@/lib/table/rows/service' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2Data, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' import { columnNameById, v2BulkPredicateToFilter } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableRowsFindAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - /** * POST /api/v2/tables/[tableId]/rows/find — Case-insensitive substring search * across every cell, narrowed by the same predicate/sort grammar as @@ -40,77 +28,63 @@ interface TableRouteParams { * same filtered+sorted view a `POST /query` with these arguments would return, * so a caller can jump straight to the page holding it. */ -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-rows-find') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2FindTableRowsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId, q, predicate, sort } = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const accessResult = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!accessResult.ok || accessResult.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') +export const POST = withPublicApiRouteHandler({ + contract: v2FindTableRowsContract, + rateLimitEndpoint: 'table-rows-find', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const { workspaceId, q, predicate, sort } = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const accessResult = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!accessResult.ok || accessResult.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const { table } = accessResult + const schema = table.schema as TableSchema + + // The public wire is column-NAME keyed both ways: translate the predicate + // and sort down to storage ids on the way in, and the matched column id + // back to its name on the way out. + let filter: Filter | undefined + if (predicate) filter = v2BulkPredicateToFilter(predicate, schema) + + let sortObj: Sort | undefined + if (sort?.length) { + validateSortSpec(sort, schema.columns) + const storageSort = sortSpecNamesToIds(sort, buildIdByName(schema)) + sortObj = Object.fromEntries(storageSort.map((s) => [s.field, s.direction])) + } + + const { matches, truncated } = await findRowMatches( + table, + { q, filter, sort: sortObj }, + requestId + ) + + const toColumnName = columnNameById(schema) + + return v2Data( + { + matches: matches.map((match) => ({ + ordinal: match.ordinal, + rowId: match.rowId, + column: toColumnName(match.column), + })), + truncated, + }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + + throw error } - - const { table } = accessResult - const schema = table.schema as TableSchema - - // The public wire is column-NAME keyed both ways: translate the predicate - // and sort down to storage ids on the way in, and the matched column id - // back to its name on the way out. - let filter: Filter | undefined - if (predicate) filter = v2BulkPredicateToFilter(predicate, schema) - - let sortObj: Sort | undefined - if (sort?.length) { - validateSortSpec(sort, schema.columns) - const storageSort = sortSpecNamesToIds(sort, buildIdByName(schema)) - sortObj = Object.fromEntries(storageSort.map((s) => [s.field, s.direction])) - } - - const { matches, truncated } = await findRowMatches( - table, - { q, filter, sort: sortObj }, - requestId - ) - - const toColumnName = columnNameById(schema) - - return v2Data( - { - matches: matches.map((match) => ({ - ordinal: match.ordinal, - rowId: match.rowId, - column: toColumnName(match.column), - })), - truncated, - }, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - logger.error(`[${requestId}] Error finding rows`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts index a2677af979c..0be13bffdb4 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts @@ -1,6 +1,4 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest, NextResponse } from 'next/server' +import type { NextResponse } from 'next/server' import type { V1BatchInsertTableRowsBody } from '@/lib/api/contracts/v1/tables' import { v2CreateTableRowsContract, @@ -8,9 +6,7 @@ import { v2ListTableRowsContract, v2UpdateRowsByFilterContract, } from '@/lib/api/contracts/v2/tables' -import { isZodError, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isZodError } from '@/lib/api/server' import type { RowData, TableSchema } from '@/lib/table' import { batchInsertRows, @@ -27,20 +23,15 @@ import { import { namedRowMapper } from '@/lib/table/cell-format' import { TableQueryValidationError } from '@/lib/table/errors' import { queryRows } from '@/lib/table/rows/service' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { - checkRateLimit, - type RateLimitResult, - resolveWorkspaceScope, -} from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { type RateLimitResult, resolveWorkspaceScope } from '@/app/api/v1/middleware' import { decodeCursor, encodeCursor, v2CursorList, v2Data, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' @@ -52,15 +43,9 @@ import { v2TableAccessError, } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableRowsAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface TableRowsRouteParams { - params: Promise<{ tableId: string }> -} - /** * Inserts a validated batch of rows. Authorizes against the table's own * workspace (IDOR guard) before any write, translates name-keyed row data to @@ -111,10 +96,7 @@ async function handleBatchInsert( const response = v2RowWriteError(error) if (response) return response - logger.error(`[${requestId}] Error batch inserting rows`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } } @@ -122,107 +104,80 @@ async function handleBatchInsert( * GET /api/v2/tables/[tableId]/rows — Plain cursor page over the default row * order. Filtered/sorted reads go through `POST /query`. */ -export const GET = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2ListTableRowsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const validated = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListTableRowsContract, + rateLimitEndpoint: 'table-rows', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const validated = input.query - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) - const accessResult = await checkAccess(tableId, userId, 'read') - // Mask not-authorized and not-found alike so cross-workspace existence never leaks. - if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') + const accessResult = await checkAccess(tableId, userId, 'read') + // Mask not-authorized and not-found alike so cross-workspace existence never leaks. + if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') - const { table } = accessResult - if (validated.workspaceId !== table.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } - const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) - // Cursor-uniform v2 pagination: the opaque cursor encodes the underlying - // offset (upgradeable to keyset later without an interface change). Total row - // count is intentionally omitted here — it's available as `rowCount` on the table. - const offset = validated.cursor - ? (decodeCursor<{ offset: number }>(validated.cursor)?.offset ?? 0) - : 0 + // Cursor-uniform v2 pagination: the opaque cursor encodes the underlying + // offset (upgradeable to keyset later without an interface change). Total row + // count is intentionally omitted here — it's available as `rowCount` on the table. + const offset = validated.cursor + ? (decodeCursor<{ offset: number }>(validated.cursor)?.offset ?? 0) + : 0 - const result = await queryRows( - table, - { - limit: validated.limit, - offset, - includeTotal: true, - withExecutions: false, - }, - requestId - ) + const result = await queryRows( + table, + { + limit: validated.limit, + offset, + includeTotal: true, + withExecutions: false, + }, + requestId + ) - const total = result.totalCount ?? 0 - const hasMore = offset + result.rowCount < total - const nextCursor = hasMore ? encodeCursor({ offset: offset + validated.limit }) : null + const total = result.totalCount ?? 0 + const hasMore = offset + result.rowCount < total + const nextCursor = hasMore ? encodeCursor({ offset: offset + validated.limit }) : null - return v2CursorList( - result.rows.map((r) => toApiRow(r, toNamedRow)), - nextCursor, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) + return v2CursorList( + result.rows.map((r) => toApiRow(r, toNamedRow)), + nextCursor, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - logger.error(`[${requestId}] Error querying rows`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + throw error + } + }, }) /** POST /api/v2/tables/[tableId]/rows — Insert row(s). Supports single or batch. */ -export const POST = withRouteHandler( - async (request: NextRequest, context: TableRowsRouteParams) => { - const requestId = generateRequestId() - +export const POST = withPublicApiRouteHandler({ + contract: v2CreateTableRowsContract, + rateLimitEndpoint: 'table-rows', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2CreateTableRowsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response + const { tableId } = input.params - const { tableId } = parsed.data.params - - if ('rows' in parsed.data.body) { - const batchValidated = parsed.data.body + if ('rows' in input.body) { + const batchValidated = input.body const scopeError = await resolveWorkspaceScope(rateLimit, batchValidated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) return handleBatchInsert(requestId, tableId, batchValidated, userId, rateLimit) } - const validated = parsed.data.body + const validated = input.body const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -258,106 +213,76 @@ export const POST = withRouteHandler( const response = v2RowWriteError(error) if (response) return response - logger.error(`[${requestId}] Error inserting row`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) /** PUT /api/v2/tables/[tableId]/rows — Bulk update rows by predicate filter. */ -export const PUT = withRouteHandler(async (request: NextRequest, context: TableRowsRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) +export const PUT = withPublicApiRouteHandler({ + contract: v2UpdateRowsByFilterContract, + rateLimitEndpoint: 'table-rows', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const validated = input.body - const userId = rateLimit.userId! + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) - const gate = await v2ApiGateError(userId) - if (gate) return gate + const accessResult = await checkAccess(tableId, userId, 'write') + if (!accessResult.ok) return v2TableAccessError(accessResult) - const parsed = await parseRequest(v2UpdateRowsByFilterContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response + const { table } = accessResult + if (validated.workspaceId !== table.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } - const { tableId } = parsed.data.params - const validated = parsed.data.body + const idByName = buildIdByName(table.schema as TableSchema) + const patchData = rowDataNameToId(validated.data as RowData, idByName) - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) + const sizeValidation = validateRowSize(patchData) + if (!sizeValidation.valid) { + return v2Error('BAD_REQUEST', 'Invalid row data', { details: sizeValidation.errors }) + } - const accessResult = await checkAccess(tableId, userId, 'write') - if (!accessResult.ok) return v2TableAccessError(accessResult) + const result = await updateRowsByFilter( + table, + { + filter: v2BulkPredicateToFilter(validated.filter, table.schema as TableSchema), + data: patchData, + limit: validated.limit, + actorUserId: userId, + }, + requestId + ) - const { table } = accessResult - if (validated.workspaceId !== table.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } + // v2 always returns `updatedRowIds` ([] when nothing matched); v1 dropped it + // on the zero-match branch. + return v2Data( + { updatedCount: result.affectedCount, updatedRowIds: result.affectedRowIds }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - const idByName = buildIdByName(table.schema as TableSchema) - const patchData = rowDataNameToId(validated.data as RowData, idByName) + const response = v2RowWriteError(error) + if (response) return response - const sizeValidation = validateRowSize(patchData) - if (!sizeValidation.valid) { - return v2Error('BAD_REQUEST', 'Invalid row data', { details: sizeValidation.errors }) + throw error } - - const result = await updateRowsByFilter( - table, - { - filter: v2BulkPredicateToFilter(validated.filter, table.schema as TableSchema), - data: patchData, - limit: validated.limit, - actorUserId: userId, - }, - requestId - ) - - // v2 always returns `updatedRowIds` ([] when nothing matched); v1 dropped it - // on the zero-match branch. - return v2Data( - { updatedCount: result.affectedCount, updatedRowIds: result.affectedRowIds }, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - if (error instanceof TableQueryValidationError) return v2Error('BAD_REQUEST', error.message) - - const response = v2RowWriteError(error) - if (response) return response - - logger.error(`[${requestId}] Error updating rows by filter`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** DELETE /api/v2/tables/[tableId]/rows — Delete rows by predicate filter or IDs. */ -export const DELETE = withRouteHandler( - async (request: NextRequest, context: TableRowsRouteParams) => { - const requestId = generateRequestId() - +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteTableRowsContract, + rateLimitEndpoint: 'table-rows', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteTableRowsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const validated = parsed.data.body + const { tableId } = input.params + const validated = input.body const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -411,10 +336,7 @@ export const DELETE = withRouteHandler( const response = v2RowWriteError(error) if (response) return response - logger.error(`[${requestId}] Error deleting rows`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts index a8b4c21593b..346b8a74b15 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts @@ -1,94 +1,68 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2UpsertTableRowContract } from '@/lib/api/contracts/v2/tables' -import { isZodError, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isZodError } from '@/lib/api/server' import type { RowData, TableSchema } from '@/lib/table' import { buildIdByName, rowDataNameToId, upsertRow } from '@/lib/table' import { namedRowMapper } from '@/lib/table/cell-format' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' import { toApiRow, v2TableAccessError } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableUpsertAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface UpsertRouteParams { - params: Promise<{ tableId: string }> -} - /** POST /api/v2/tables/[tableId]/rows/upsert — Insert or update a row based on unique columns. */ -export const POST = withRouteHandler(async (request: NextRequest, context: UpsertRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-rows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpsertTableRowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const validated = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - const { table } = result - if (table.workspaceId !== validated.workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') +export const POST = withPublicApiRouteHandler({ + contract: v2UpsertTableRowContract, + rateLimitEndpoint: 'table-rows', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { tableId } = input.params + const validated = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + const { table } = result + if (table.workspaceId !== validated.workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const idByName = buildIdByName(table.schema as TableSchema) + const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) + const upsertResult = await upsertRow( + { + tableId, + workspaceId: validated.workspaceId, + data: rowDataNameToId(validated.data as RowData, idByName), + userId, + conflictTarget: validated.conflictTarget, + }, + table, + requestId + ) + + return v2Data( + { row: toApiRow(upsertResult.row, toNamedRow), operation: upsertResult.operation }, + { rateLimit } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + throw error } - - const idByName = buildIdByName(table.schema as TableSchema) - const toNamedRow = namedRowMapper((table.schema as TableSchema).columns) - const upsertResult = await upsertRow( - { - tableId, - workspaceId: validated.workspaceId, - data: rowDataNameToId(validated.data as RowData, idByName), - userId, - conflictTarget: validated.conflictTarget, - }, - table, - requestId - ) - - return v2Data( - { row: toApiRow(upsertResult.row, toNamedRow), operation: upsertResult.operation }, - { rateLimit } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - logger.error(`[${requestId}] Error upserting row`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts index 85e9921266b..0df7c7243c9 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts @@ -1,14 +1,8 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2DeleteTableViewContract, v2GetTableViewContract, v2UpdateTableViewContract, } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { TableSchema } from '@/lib/table' import { deleteTableView, @@ -17,47 +11,22 @@ import { updateTableView, } from '@/lib/table' import { getRequiredUserEmail } from '@/lib/users/queries' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import { toApiView, v2TableAccessError } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableViewDetailAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface TableViewRouteParams { - params: Promise<{ tableId: string; viewId: string }> -} - /** GET /api/v2/tables/[tableId]/views/[viewId] — One saved view. */ -export const GET = withRouteHandler(async (request: NextRequest, context: TableViewRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-view-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetTableViewContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId, viewId } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2GetTableViewContract, + rateLimitEndpoint: 'table-view-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { tableId, viewId } = input.params + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -75,38 +44,20 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableV { view: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null) }, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error getting table view`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** * PATCH /api/v2/tables/[tableId]/views/[viewId] — Rename, replace or merge the * config, or promote the view to the table's default. */ -export const PATCH = withRouteHandler( - async (request: NextRequest, context: TableViewRouteParams) => { - const requestId = generateRequestId() - +export const PATCH = withPublicApiRouteHandler({ + contract: v2UpdateTableViewContract, + rateLimitEndpoint: 'table-view-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-view-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateTableViewContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId, viewId } = parsed.data.params - const { workspaceId, name, config, configPatch, isDefault } = parsed.data.body + const { tableId, viewId } = input.params + const { workspaceId, name, config, configPatch, isDefault } = input.body const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -138,55 +89,32 @@ export const PATCH = withRouteHandler( } catch (error) { if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) - logger.error(`[${requestId}] Error updating table view`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) /** DELETE /api/v2/tables/[tableId]/views/[viewId] — Remove a saved view. */ -export const DELETE = withRouteHandler( - async (request: NextRequest, context: TableViewRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-view-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteTableViewContract, + rateLimitEndpoint: 'table-view-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { tableId, viewId } = input.params + const { workspaceId } = input.query - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteTableViewContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId, viewId } = parsed.data.params - const { workspaceId } = parsed.data.query - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') - } + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } - const deleted = await deleteTableView(viewId, tableId) - if (!deleted) return v2Error('NOT_FOUND', 'View not found') + const deleted = await deleteTableView(viewId, tableId) + if (!deleted) return v2Error('NOT_FOUND', 'View not found') - return v2Data({ id: viewId }, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error deleting table view`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } - } -) + return v2Data({ id: viewId }, { rateLimit }) + }, +}) diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts index 0dacc9e619f..841a5c8947e 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts @@ -1,10 +1,4 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateTableViewContract, v2ListTableViewsContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { TableSchema } from '@/lib/table' import { createTableView, listTableViews, TableViewValidationError } from '@/lib/table' import { @@ -12,53 +6,27 @@ import { getUserEmailsByIds, requireResolvedUserEmail, } from '@/lib/users/queries' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import { toApiView, v2TableAccessError } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableViewsAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -interface TableRouteParams { - params: Promise<{ tableId: string }> -} - /** * GET /api/v2/tables/[tableId]/views — Every saved view on the table. * * A table carries a bounded set of views, so this is one full page and * `nextCursor` is always `null`. */ -export const GET = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-views') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2ListTableViewsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListTableViewsContract, + rateLimitEndpoint: 'table-views', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { tableId } = input.params + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) @@ -84,64 +52,47 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR null, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error listing table views`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** POST /api/v2/tables/[tableId]/views — Save a filter/sort/layout as a named view. */ -export const POST = withRouteHandler(async (request: NextRequest, context: TableRouteParams) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'table-views') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2CreateTableViewContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { tableId } = parsed.data.params - const { workspaceId, name, config } = parsed.data.body - - const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - - const result = await checkAccess(tableId, userId, 'write') - if (!result.ok) return v2TableAccessError(result) - - if (result.table.workspaceId !== workspaceId) { - return v2Error('NOT_FOUND', 'Table not found') +export const POST = withPublicApiRouteHandler({ + contract: v2CreateTableViewContract, + rateLimitEndpoint: 'table-views', + handler: async ({ input, auth: { userId, rateLimit } }) => { + try { + const { tableId } = input.params + const { workspaceId, name, config } = input.body + + const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + + const result = await checkAccess(tableId, userId, 'write') + if (!result.ok) return v2TableAccessError(result) + + if (result.table.workspaceId !== workspaceId) { + return v2Error('NOT_FOUND', 'Table not found') + } + + const view = await createTableView({ + tableId, + workspaceId, + name, + config, + userId, + columns: (result.table.schema as TableSchema).columns, + }) + + return v2Data( + { + view: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null), + }, + { rateLimit, status: 201 } + ) + } catch (error) { + if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) + + throw error } - - const view = await createTableView({ - tableId, - workspaceId, - name, - config, - userId, - columns: (result.table.schema as TableSchema).columns, - }) - - return v2Data( - { view: toApiView(view, view.createdBy ? await getRequiredUserEmail(view.createdBy) : null) }, - { rateLimit, status: 201 } - ) - } catch (error) { - if (error instanceof TableViewValidationError) return v2Error('BAD_REQUEST', error.message) - - logger.error(`[${requestId}] Error creating table view`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts index 87268ab070f..d6902239d8f 100644 --- a/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts @@ -1,46 +1,27 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2TableExportDownloadContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { requireTableExport, tableExportResult } from '@/lib/table/orchestration/export-resource' import { generatePresignedDownloadUrl } from '@/lib/uploads/core/storage-service' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2TableExportDownloadAPI') const DOWNLOAD_TTL_SECONDS = 60 * 60 -interface TableExportRouteParams { - params: Promise<{ exportId: string }> -} - -export const GET = withRouteHandler( - async (request: NextRequest, context: TableExportRouteParams) => { +export const GET = withPublicApiRouteHandler({ + contract: v2TableExportDownloadContract, + rateLimitEndpoint: 'table-export', + handler: async ({ input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-export') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2TableExportDownloadContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const record = await requireTableExport(parsed.data.params.exportId, workspaceId) + const record = await requireTableExport(input.params.exportId, workspaceId) const access = await checkAccess(record.tableId, userId, 'read') if (!access.ok || access.table.workspaceId !== workspaceId) { return v2Error('NOT_FOUND', 'Table export not found') @@ -62,8 +43,7 @@ export const GET = withRouteHandler( } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to issue table export download', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts index 4fa1032e782..721652921f4 100644 --- a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts @@ -1,35 +1,22 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CancelTableExportContract, v2GetTableExportContract, } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { cancelTableExportResource, requireTableExport, toV2TableExport, } from '@/lib/table/orchestration/export-resource' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { checkAccess } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2TableExportAPI') - -interface TableExportRouteParams { - params: Promise<{ exportId: string }> -} - async function authorizeExport(exportId: string, workspaceId: string, userId: string) { const record = await requireTableExport(exportId, workspaceId) const access = await checkAccess(record.tableId, userId, 'read') @@ -37,56 +24,40 @@ async function authorizeExport(exportId: string, workspaceId: string, userId: st return record } -export const GET = withRouteHandler( - async (request: NextRequest, context: TableExportRouteParams) => { +export const GET = withPublicApiRouteHandler({ + contract: v2GetTableExportContract, + rateLimitEndpoint: 'table-export', + handler: async ({ input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-export') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2GetTableExportContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const record = await authorizeExport(parsed.data.params.exportId, workspaceId, userId) + const record = await authorizeExport(input.params.exportId, workspaceId, userId) if (!record) return v2Error('NOT_FOUND', 'Table export not found') return v2Data(toV2TableExport(record), { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to read table export', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) -export const DELETE = withRouteHandler( - async (request: NextRequest, context: TableExportRouteParams) => { +export const DELETE = withPublicApiRouteHandler({ + contract: v2CancelTableExportContract, + rateLimitEndpoint: 'table-export', + handler: async ({ input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-export') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2CancelTableExportContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const record = await authorizeExport(parsed.data.params.exportId, workspaceId, userId) + const record = await authorizeExport(input.params.exportId, workspaceId, userId) if (!record) return v2Error('NOT_FOUND', 'Table export not found') return v2Data(toV2TableExport(await cancelTableExportResource(record)), { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to cancel table export', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/tables/folders/route.ts b/apps/sim/app/api/v2/tables/folders/route.ts index c9744bf395b..3e885727a91 100644 --- a/apps/sim/app/api/v2/tables/folders/route.ts +++ b/apps/sim/app/api/v2/tables/folders/route.ts @@ -1,60 +1,32 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateTableFolderContract, v2DeleteTableFolderContract, v2ListTableFoldersContract, v2RelocateTableFolderContract, } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createFolderAtPath, deleteFolderByPath, relocateFolderByPath, } from '@/lib/folders/orchestration' import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { resolveFolderPathId, toV2PathFolder, v2FolderPathMutationError, } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2TableFoldersAPI') +import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const rateLimit = await checkRateLimit(request, 'tables') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2ListTableFoldersContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListTableFoldersContract, + rateLimitEndpoint: 'tables', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, parentPath, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -74,109 +46,77 @@ export const GET = withRouteHandler(async (request: NextRequest) => { null, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error listing table folders`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) -export const POST = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'tables') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2CreateTableFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, +export const POST = withPublicApiRouteHandler({ + contract: v2CreateTableFolderContract, + rateLimitEndpoint: 'tables', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path } = input.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await createFolderAtPath({ resourceType: 'table', workspaceId, userId, path }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await createFolderAtPath({ resourceType: 'table', workspaceId, userId, path }) - if (!result.success || !result.folder) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'table') - return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit, status: 201 }) + const index = await loadActiveFolderPathIndex(workspaceId, 'table') + return v2Data( + { folder: toV2PathFolder(result.folder, index, false) }, + { rateLimit, status: 201 } + ) + }, }) -export const PATCH = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'tables') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2RelocateTableFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, +export const PATCH = withPublicApiRouteHandler({ + contract: v2RelocateTableFolderContract, + rateLimitEndpoint: 'tables', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path, destinationPath } = input.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await relocateFolderByPath({ + resourceType: 'table', + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path, destinationPath } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await relocateFolderByPath({ - resourceType: 'table', - workspaceId, - userId, - path, - destinationPath, - }) - if (!result.success || !result.folder) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'table') - return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit }) + const index = await loadActiveFolderPathIndex(workspaceId, 'table') + return v2Data({ folder: toV2PathFolder(result.folder, index, false) }, { rateLimit }) + }, }) -export const DELETE = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'tables') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2DeleteTableFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path, recursive } = parsed.data.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await deleteFolderByPath({ - resourceType: 'table', - workspaceId, - userId, - path, - recursive, - }) - if (!result.success || !result.deletedItems) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') - } - return v2Data( - { +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteTableFolderContract, + rateLimitEndpoint: 'tables', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path, recursive } = input.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + const result = await deleteFolderByPath({ + resourceType: 'table', + workspaceId, + userId, path, - deleted: true as const, - deletedItems: { - folders: result.deletedItems.folders, - tables: result.deletedItems.tables ?? 0, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + return v2Data( + { + path, + deleted: true as const, + deletedItems: { + folders: result.deletedItems.folders, + tables: result.deletedItems.tables ?? 0, + }, }, - }, - { rateLimit } - ) + { rateLimit } + ) + }, }) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts index b001a3a053d..626b4cc2f8b 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts @@ -1,9 +1,4 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CompleteTableImportContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { findOwnedTableImport, getOwnedTableImportUpload, @@ -11,44 +6,28 @@ import { toV2TableImport, } from '@/lib/table/orchestration/import-resource' import { completeUploadSession } from '@/lib/uploads/upload-session/service' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' import { v2TableLockError } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2CompleteTableImportAPI') - -interface TableImportRouteParams { - params: Promise<{ importId: string }> -} - -export const POST = withRouteHandler( - async (request: NextRequest, context: TableImportRouteParams) => { +export const POST = withPublicApiRouteHandler({ + contract: v2CompleteTableImportContract, + rateLimitEndpoint: 'table-import', + handler: async ({ input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-import') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2CompleteTableImportContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) const upload = await getOwnedTableImportUpload({ - importId: parsed.data.params.importId, + importId: input.params.importId, workspaceId, userId, - uploadToken: parsed.data.headers['upload-token'], + uploadToken: input.headers['upload-token'], }) const existing = await findOwnedTableImport({ importId: upload.id, @@ -67,8 +46,7 @@ export const POST = withRouteHandler( if (lockError) return lockError const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to complete table import upload', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts index 6481d8ba478..779c90acb5e 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts @@ -1,60 +1,38 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateTableImportPartUrlsContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getOwnedTableImportUpload } from '@/lib/table/orchestration/import-resource' import { createUploadPartUrls } from '@/lib/uploads/upload-session/service' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2TableImportPartsAPI') - -interface TableImportRouteParams { - params: Promise<{ importId: string }> -} - -export const POST = withRouteHandler( - async (request: NextRequest, context: TableImportRouteParams) => { +export const POST = withPublicApiRouteHandler({ + contract: v2CreateTableImportPartUrlsContract, + rateLimitEndpoint: 'table-import', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-import') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2CreateTableImportPartUrlsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) const session = await getOwnedTableImportUpload({ - importId: parsed.data.params.importId, + importId: input.params.importId, workspaceId, userId, - uploadToken: parsed.data.headers['upload-token'], + uploadToken: input.headers['upload-token'], }) const parts = await createUploadPartUrls({ session, - partNumbers: parsed.data.body.partNumbers, + partNumbers: input.body.partNumbers, localOrigin: request.nextUrl.origin, }) return v2Data({ parts }, { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to create table import part URLs', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts index 22005ef907e..7b5587a3ff0 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts @@ -1,90 +1,61 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CancelTableImportContract, v2GetTableImportContract, } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { abortTableImportUpload, cancelTableImportResource, getOwnedTableImport, toV2TableImport, } from '@/lib/table/orchestration/import-resource' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { v2CaughtOrchestrationError, v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2TableImportAPI') - -interface TableImportRouteParams { - params: Promise<{ importId: string }> -} - -export const GET = withRouteHandler( - async (request: NextRequest, context: TableImportRouteParams) => { +export const GET = withPublicApiRouteHandler({ + contract: v2GetTableImportContract, + rateLimitEndpoint: 'table-import', + handler: async ({ input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-import') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2GetTableImportContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.query.workspaceId) + const scopeError = await resolveWorkspaceScope(rateLimit, input.query.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) const record = await getOwnedTableImport({ - importId: parsed.data.params.importId, - workspaceId: parsed.data.query.workspaceId, + importId: input.params.importId, + workspaceId: input.query.workspaceId, userId, }) return v2Data(await toV2TableImport(record), { rateLimit }) } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to read table import', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) -export const DELETE = withRouteHandler( - async (request: NextRequest, context: TableImportRouteParams) => { +export const DELETE = withPublicApiRouteHandler({ + contract: v2CancelTableImportContract, + rateLimitEndpoint: 'table-import', + handler: async ({ input, auth: { userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'table-import') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest(v2CancelTableImportContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.query.workspaceId) + const scopeError = await resolveWorkspaceScope(rateLimit, input.query.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const uploadToken = parsed.data.headers['upload-token'] + const uploadToken = input.headers['upload-token'] const record = uploadToken ? await abortTableImportUpload({ - importId: parsed.data.params.importId, - workspaceId: parsed.data.query.workspaceId, + importId: input.params.importId, + workspaceId: input.query.workspaceId, userId, uploadToken, }) : await cancelTableImportResource( await getOwnedTableImport({ - importId: parsed.data.params.importId, - workspaceId: parsed.data.query.workspaceId, + importId: input.params.importId, + workspaceId: input.query.workspaceId, userId, }) ) @@ -92,8 +63,7 @@ export const DELETE = withRouteHandler( } catch (error) { const classified = v2CaughtOrchestrationError(error) if (classified) return classified - logger.error('Failed to cancel table import', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/tables/imports/route.ts b/apps/sim/app/api/v2/tables/imports/route.ts index 43ce013fa3b..8a97f9f36ad 100644 --- a/apps/sim/app/api/v2/tables/imports/route.ts +++ b/apps/sim/app/api/v2/tables/imports/route.ts @@ -1,70 +1,50 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateTableImportContract } from '@/lib/api/contracts/v2/tables' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createTableImportResource, toV2CreateTableImport, } from '@/lib/table/orchestration/import-resource' -import { checkRateLimit, resolveWorkspaceScope } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceScope } from '@/app/api/v1/middleware' import { resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CaughtOrchestrationError, v2Data, v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' import { v2TableLockError } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TableImportsAPI') - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'table-import') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2CreateTableImportContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, +export const POST = withPublicApiRouteHandler({ + contract: v2CreateTableImportContract, + rateLimitEndpoint: 'table-import', + handler: async ({ request, input, auth: { userId, rateLimit } }) => { + try { + const scopeError = await resolveWorkspaceScope(rateLimit, input.body.workspaceId) + if (scopeError) return v2WorkspaceAccessError(scopeError) + let created: Awaited> + if (input.body.target.type === 'new') { + const resolution = await resolveFolderPathIdentity({ + workspaceId: input.body.workspaceId, + resourceType: 'table', + path: input.body.target.folderPath ?? '/', + }) + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') + created = await createTableImportResource( + input.body, + userId, + request.nextUrl.origin, + resolution.folderId + ) + } else { + created = await createTableImportResource(input.body, userId, request.nextUrl.origin) } - ) - if (!parsed.success) return parsed.response - const scopeError = await resolveWorkspaceScope(rateLimit, parsed.data.body.workspaceId) - if (scopeError) return v2WorkspaceAccessError(scopeError) - let created: Awaited> - if (parsed.data.body.target.type === 'new') { - const resolution = await resolveFolderPathIdentity({ - workspaceId: parsed.data.body.workspaceId, - resourceType: 'table', - path: parsed.data.body.target.folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - created = await createTableImportResource( - parsed.data.body, - userId, - request.nextUrl.origin, - resolution.folderId - ) - } else { - created = await createTableImportResource(parsed.data.body, userId, request.nextUrl.origin) + return v2Data(toV2CreateTableImport(created), { rateLimit, status: 201 }) + } catch (error) { + const lockError = v2TableLockError(error) + if (lockError) return lockError + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + throw error } - return v2Data(toV2CreateTableImport(created), { rateLimit, status: 201 }) - } catch (error) { - const lockError = v2TableLockError(error) - if (lockError) return lockError - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - logger.error('Failed to create table import', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/tables/route.ts b/apps/sim/app/api/v2/tables/route.ts index c3d319bbafd..7caef0960a8 100644 --- a/apps/sim/app/api/v2/tables/route.ts +++ b/apps/sim/app/api/v2/tables/route.ts @@ -1,21 +1,16 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateTableContract, v2ListTablesContract } from '@/lib/api/contracts/v2/tables' -import { isZodError, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isZodError } from '@/lib/api/server' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { createTable, getWorkspaceTableLimits, queryTables, type TableSchema } from '@/lib/table' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { normalizeColumn } from '@/app/api/table/utils' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { folderPathForId, resolveFolderPathId, resolveFolderPathIdentity, } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { cursorSortKey, decodeSortedCursor, @@ -25,41 +20,20 @@ import { v2CursorSortError, v2Data, v2Error, - v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' import { toApiTable } from '@/app/api/v2/tables/utils' -const logger = createLogger('V2TablesAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/tables — List all tables in a workspace. */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'tables') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListTablesContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListTablesContract, + rateLimitEndpoint: 'tables', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -90,93 +64,69 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null return v2CursorList(items, nextCursor, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Error listing tables`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** POST /api/v2/tables — Create a new table. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const rateLimit = await checkRateLimit(request, 'tables') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) +export const POST = withPublicApiRouteHandler({ + contract: v2CreateTableContract, + rateLimitEndpoint: 'tables', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + try { + const params = input.body - const userId = rateLimit.userId! + const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) - const gate = await v2ApiGateError(userId) - if (gate) return gate + const planLimits = await getWorkspaceTableLimits(params.workspaceId) - const parsed = await parseRequest( - v2CreateTableContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, + const normalizedSchema: TableSchema = { + columns: params.schema.columns.map(normalizeColumn), } - ) - if (!parsed.success) return parsed.response - - const params = parsed.data.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const planLimits = await getWorkspaceTableLimits(params.workspaceId) - const normalizedSchema: TableSchema = { - columns: params.schema.columns.map(normalizeColumn), - } - - const resolution = await resolveFolderPathIdentity({ - workspaceId: params.workspaceId, - resourceType: 'table', - path: params.folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - - const table = await createTable( - { - name: params.name, - description: params.description, - schema: normalizedSchema, + const resolution = await resolveFolderPathIdentity({ workspaceId: params.workspaceId, - userId, - maxTables: planLimits.maxTables, - folderId: resolution.folderId, - }, - requestId - ) - - recordAudit({ - workspaceId: params.workspaceId, - actorId: userId, - action: AuditAction.TABLE_CREATED, - resourceType: AuditResourceType.TABLE, - resourceId: table.id, - resourceName: table.name, - description: `Created table "${table.name}" via API`, - metadata: { columnCount: params.schema.columns.length }, - request, - }) - - return v2Data( - { table: toApiTable(table, folderPathForId(resolution.index, table.folderId)) }, - { rateLimit, status: 201 } - ) - } catch (error) { - if (isZodError(error)) return v2ValidationError(error) - - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - logger.error(`[${requestId}] Error creating table`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + resourceType: 'table', + path: params.folderPath ?? '/', + }) + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') + + const table = await createTable( + { + name: params.name, + description: params.description, + schema: normalizedSchema, + workspaceId: params.workspaceId, + userId, + maxTables: planLimits.maxTables, + folderId: resolution.folderId, + }, + requestId + ) + + recordAudit({ + workspaceId: params.workspaceId, + actorId: userId, + action: AuditAction.TABLE_CREATED, + resourceType: AuditResourceType.TABLE, + resourceId: table.id, + resourceName: table.name, + description: `Created table "${table.name}" via API`, + metadata: { columnCount: params.schema.columns.length }, + request, + }) + + return v2Data( + { table: toApiTable(table, folderPathForId(resolution.index, table.folderId)) }, + { rateLimit, status: 201 } + ) + } catch (error) { + if (isZodError(error)) return v2ValidationError(error) + + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + + throw error + } + }, }) diff --git a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts index 9089d6a47b6..0bf14474706 100644 --- a/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/deploy/route.ts @@ -1,20 +1,15 @@ import { createLogger } from '@sim/logger' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v1DeployWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' import { v2DeployWorkflowContract, v2UndeployWorkflowContract, } from '@/lib/api/contracts/v2/workflows' -import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { parseOptionalJsonBody } from '@/lib/api/server' import { captureServerEvent } from '@/lib/posthog/server' import { performFullDeploy, performFullUndeploy } from '@/lib/workflows/orchestration' -import { checkRateLimit } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' const logger = createLogger('V2WorkflowDeployAPI') @@ -23,25 +18,12 @@ export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const maxDuration = 120 -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - +export const POST = withPublicApiRouteHandler({ + contract: v2DeployWorkflowContract, + rateLimitEndpoint: 'workflow-deploy', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'workflow-deploy') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeployWorkflowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params + const { id } = input.params const rawBody = await parseOptionalJsonBody(request) if (!rawBody.success) { @@ -102,33 +84,17 @@ export const POST = withRouteHandler( if (error instanceof WorkflowLockedError) { return v2Error('LOCKED', error.message) } - logger.error(`[${requestId}] Workflow deploy error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) - -export const DELETE = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() + }, +}) +export const DELETE = withPublicApiRouteHandler({ + contract: v2UndeployWorkflowContract, + rateLimitEndpoint: 'workflow-deploy', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'workflow-deploy') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UndeployWorkflowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params + const { id } = input.params const target = await resolveV2WorkflowTarget(rateLimit, userId, id, 'admin') if (!target) return v2Error('NOT_FOUND', 'Workflow not found') @@ -167,10 +133,7 @@ export const DELETE = withRouteHandler( if (error instanceof WorkflowLockedError) { return v2Error('LOCKED', error.message) } - logger.error(`[${requestId}] Workflow undeploy error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/export/route.ts b/apps/sim/app/api/v2/workflows/[id]/export/route.ts index c4caa0ec7d8..113b19fd3fa 100644 --- a/apps/sim/app/api/v2/workflows/[id]/export/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/export/route.ts @@ -1,18 +1,13 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getActiveWorkflowRecord } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { v2ExportWorkflowContract } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { folderPathForId } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { v2Data, v2Error } from '@/app/api/v2/lib/response' const logger = createLogger('V2WorkflowExportAPI') @@ -28,75 +23,55 @@ export const revalidate = 0 * {@link buildWorkflowExportPayload}; this route authenticates and renders the * v2 envelope. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) +export const GET = withPublicApiRouteHandler({ + contract: v2ExportWorkflowContract, + rateLimitEndpoint: 'workflow-export', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { + const { id } = input.params - try { - const rateLimit = await checkRateLimit(request, 'workflow-export') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + logger.info(`[${requestId}] Exporting workflow ${id}`, { userId }) - const userId = rateLimit.userId! + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') - const gate = await v2ApiGateError(userId) - if (gate) return gate + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') - const parsed = await parseRequest(v2ExportWorkflowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response + const payload = await buildWorkflowExportPayload(workflowData) + if (!payload) return v2Error('NOT_FOUND', 'Workflow state not found') + const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') + const folderPath = folderPathForId(folderIndex, workflowData.folderId) - const { id } = parsed.data.params - - logger.info(`[${requestId}] Exporting workflow ${id}`, { userId }) - - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') - - // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) - if (access) return v2Error('NOT_FOUND', 'Workflow not found') - - const payload = await buildWorkflowExportPayload(workflowData) - if (!payload) return v2Error('NOT_FOUND', 'Workflow state not found') - const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') - const folderPath = folderPathForId(folderIndex, workflowData.folderId) - - recordAudit({ + recordAudit({ + workspaceId: workflowData.workspaceId, + actorId: userId, + action: AuditAction.WORKFLOW_EXPORTED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: workflowData.id, + resourceName: workflowData.name, + description: `Exported workflow "${workflowData.name}" via the API`, + metadata: { workspaceId: workflowData.workspaceId, - actorId: userId, - action: AuditAction.WORKFLOW_EXPORTED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowData.id, - resourceName: workflowData.name, - description: `Exported workflow "${workflowData.name}" via the API`, - metadata: { - workspaceId: workflowData.workspaceId, - folderPath, - blocksCount: Object.keys(payload.state.blocks).length, - edgesCount: payload.state.edges.length, - }, - request, - }) + folderPath, + blocksCount: Object.keys(payload.state.blocks).length, + edgesCount: payload.state.edges.length, + }, + request, + }) - return v2Data( - { - ...payload, - workflow: { - id: payload.workflow.id, - name: payload.workflow.name, - description: payload.workflow.description, - workspaceId: payload.workflow.workspaceId, - folderPath, - }, + return v2Data( + { + ...payload, + workflow: { + id: payload.workflow.id, + name: payload.workflow.name, + description: payload.workflow.description, + workspaceId: payload.workflow.workspaceId, + folderPath, }, - { rateLimit } - ) - } catch (error) { - logger.error(`[${requestId}] Workflow export error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } - } -) + }, + { rateLimit } + ) + }, +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts index d1cd5ec5a21..09fc344c878 100644 --- a/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/rollback/route.ts @@ -1,18 +1,13 @@ import { createLogger } from '@sim/logger' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v1RollbackWorkflowBodySchema } from '@/lib/api/contracts/v1/workflows' import { v2RollbackWorkflowContract } from '@/lib/api/contracts/v2/workflows' -import { parseOptionalJsonBody, parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { parseOptionalJsonBody } from '@/lib/api/server' import { captureServerEvent } from '@/lib/posthog/server' import { performActivateVersion } from '@/lib/workflows/orchestration' import { findPreviousDeploymentVersion } from '@/lib/workflows/persistence/utils' -import { checkRateLimit } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' const logger = createLogger('V2WorkflowRollbackAPI') @@ -21,25 +16,12 @@ export const dynamic = 'force-dynamic' export const runtime = 'nodejs' export const maxDuration = 120 -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - +export const POST = withPublicApiRouteHandler({ + contract: v2RollbackWorkflowContract, + rateLimitEndpoint: 'workflow-rollback', + handler: async ({ request, input, auth: { requestId, userId, rateLimit } }) => { try { - const rateLimit = await checkRateLimit(request, 'workflow-rollback') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2RollbackWorkflowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params + const { id } = input.params const rawBody = await parseOptionalJsonBody(request) if (!rawBody.success) { @@ -116,10 +98,7 @@ export const POST = withRouteHandler( if (error instanceof WorkflowLockedError) { return v2Error('LOCKED', error.message) } - logger.error(`[${requestId}] Workflow rollback error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') + throw error } - } -) + }, +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index 41a1d478044..f2f77b0f8e7 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -1,4 +1,3 @@ -import { createLogger } from '@sim/logger' import { assertFolderMutable, assertWorkflowMutable, @@ -6,9 +5,6 @@ import { getActiveWorkflowRecord, WorkflowLockedError, } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { type V2WorkflowDetail, type V2WorkflowListItem, @@ -16,24 +12,14 @@ import { v2GetWorkflowContract, v2UpdateWorkflowContract, } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' import { loadWorkflowReadSnapshot } from '@/lib/workflows/queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2WorkflowDetailAPI') +import { v2Data, v2Error, v2ErrorForOrchestration } from '@/app/api/v2/lib/response' export const revalidate = 0 @@ -41,208 +27,166 @@ interface RouteContext { params: Promise<{ id: string }> } -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) +export const GET = withPublicApiRouteHandler({ + contract: v2GetWorkflowContract, + rateLimitEndpoint: 'workflow-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { id } = input.params - try { - const rateLimit = await checkRateLimit(request, 'workflow-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + const snapshot = await loadWorkflowReadSnapshot(id) + const workflowData = snapshot.workflowRecord + if (!workflowData?.workspaceId || workflowData.archivedAt) { + return v2Error('NOT_FOUND', 'Workflow not found') + } - const userId = rateLimit.userId! + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') - const gate = await v2ApiGateError(userId) - if (gate) return gate + const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') + const inputs = extractInputFieldsFromBlocks(snapshot.normalizedData?.blocks ?? {}) - const parsed = await parseRequest(v2GetWorkflowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response + const detail: V2WorkflowDetail = { + id: workflowData.id, + name: workflowData.name, + description: workflowData.description, + folderPath: folderPathForId(folderIndex, workflowData.folderId), + workspaceId: workflowData.workspaceId, + isDeployed: workflowData.isDeployed, + deployedAt: workflowData.deployedAt?.toISOString() ?? null, + runCount: workflowData.runCount, + lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, + variables: (workflowData.variables as Record | null) ?? {}, + inputs, + createdAt: workflowData.createdAt.toISOString(), + updatedAt: workflowData.updatedAt.toISOString(), + } - const { id } = parsed.data.params + return v2Data(detail, { rateLimit }) + }, +}) - const snapshot = await loadWorkflowReadSnapshot(id) - const workflowData = snapshot.workflowRecord - if (!workflowData?.workspaceId || workflowData.archivedAt) { - return v2Error('NOT_FOUND', 'Workflow not found') - } +/** PATCH /api/v2/workflows/[id] — Rename, re-describe, or move a workflow. */ +export const PATCH = withPublicApiRouteHandler({ + contract: v2UpdateWorkflowContract, + rateLimitEndpoint: 'workflow-detail', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { id } = input.params + const { name, description, folderPath } = input.body + + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess(rateLimit, userId, workflowData.workspaceId) + const access = await resolveWorkspaceAccess( + rateLimit, + userId, + workflowData.workspaceId, + 'write' + ) if (access) return v2Error('NOT_FOUND', 'Workflow not found') - const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') - const inputs = extractInputFieldsFromBlocks(snapshot.normalizedData?.blocks ?? {}) + const resolution = + folderPath === undefined + ? undefined + : await resolveFolderPathIdentity({ + workspaceId: workflowData.workspaceId, + resourceType: 'workflow', + path: folderPath, + }) + if (resolution && !resolution.found) { + return v2Error('NOT_FOUND', 'Folder not found') + } + + const folderId = resolution?.folderId + await assertWorkflowMutable(id) + if (folderId !== undefined) await assertFolderMutable(folderId) - const detail: V2WorkflowDetail = { - id: workflowData.id, - name: workflowData.name, - description: workflowData.description, - folderPath: folderPathForId(folderIndex, workflowData.folderId), + const result = await performUpdateWorkflow({ + workflowId: id, + userId, workspaceId: workflowData.workspaceId, + currentName: workflowData.name, + currentFolderId: workflowData.folderId, + name, + description, + folderId, + requestId, + }) + + if (!result.success || !result.workflow) { + return v2ErrorForOrchestration( + result.errorCode, + result.error ?? 'Failed to update workflow' + ) + } + + const updated = result.workflow + const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') + /** + * Deployment and run counters are untouched by a metadata update, so they + * come from the record read above rather than a second query. + */ + const item: V2WorkflowListItem = { + id: updated.id, + name: updated.name, + description: updated.description, + folderPath: folderPathForId(folderIndex, updated.folderId), + workspaceId: updated.workspaceId ?? workflowData.workspaceId, isDeployed: workflowData.isDeployed, deployedAt: workflowData.deployedAt?.toISOString() ?? null, runCount: workflowData.runCount, lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, - variables: (workflowData.variables as Record | null) ?? {}, - inputs, - createdAt: workflowData.createdAt.toISOString(), - updatedAt: workflowData.updatedAt.toISOString(), + createdAt: updated.createdAt.toISOString(), + updatedAt: updated.updatedAt.toISOString(), } - return v2Data(detail, { rateLimit }) + return v2Data(item, { rateLimit }) } catch (error) { - logger.error(`[${requestId}] Workflow details fetch error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } - } -) - -/** PATCH /api/v2/workflows/[id] — Rename, re-describe, or move a workflow. */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'workflow-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateWorkflowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { name, description, folderPath } = parsed.data.body - - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') - - // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess( - rateLimit, - userId, - workflowData.workspaceId, - 'write' - ) - if (access) return v2Error('NOT_FOUND', 'Workflow not found') - - const resolution = - folderPath === undefined - ? undefined - : await resolveFolderPathIdentity({ - workspaceId: workflowData.workspaceId, - resourceType: 'workflow', - path: folderPath, - }) - if (resolution && !resolution.found) { - return v2Error('NOT_FOUND', 'Folder not found') - } - - const folderId = resolution?.folderId - await assertWorkflowMutable(id) - if (folderId !== undefined) await assertFolderMutable(folderId) - - const result = await performUpdateWorkflow({ - workflowId: id, - userId, - workspaceId: workflowData.workspaceId, - currentName: workflowData.name, - currentFolderId: workflowData.folderId, - name, - description, - folderId, - requestId, - }) - - if (!result.success || !result.workflow) { - return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to update workflow') - } - - const updated = result.workflow - const folderIndex = await loadActiveFolderPathIndex(workflowData.workspaceId, 'workflow') - /** - * Deployment and run counters are untouched by a metadata update, so they - * come from the record read above rather than a second query. - */ - const item: V2WorkflowListItem = { - id: updated.id, - name: updated.name, - description: updated.description, - folderPath: folderPathForId(folderIndex, updated.folderId), - workspaceId: updated.workspaceId ?? workflowData.workspaceId, - isDeployed: workflowData.isDeployed, - deployedAt: workflowData.deployedAt?.toISOString() ?? null, - runCount: workflowData.runCount, - lastRunAt: workflowData.lastRunAt?.toISOString() ?? null, - createdAt: updated.createdAt.toISOString(), - updatedAt: updated.updatedAt.toISOString(), - } + if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { + return v2Error('LOCKED', error.message) + } - return v2Data(item, { rateLimit }) - } catch (error) { - if (error instanceof WorkflowLockedError || error instanceof FolderLockedError) { - return v2Error('LOCKED', error.message) + throw error } - - logger.error(`[${requestId}] Workflow update error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'workflow-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteWorkflowContract, + rateLimitEndpoint: 'workflow-detail', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { id } = input.params - const parsed = await parseRequest(v2DeleteWorkflowContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response + const workflowData = await getActiveWorkflowRecord(id) + if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') - const { id } = parsed.data.params + // Mask an authorization failure as 404 so existence is not leaked. + const access = await resolveWorkspaceAccess( + rateLimit, + userId, + workflowData.workspaceId, + 'write' + ) + if (access) return v2Error('NOT_FOUND', 'Workflow not found') - const workflowData = await getActiveWorkflowRecord(id) - if (!workflowData?.workspaceId) return v2Error('NOT_FOUND', 'Workflow not found') + await assertWorkflowMutable(id) - // Mask an authorization failure as 404 so existence is not leaked. - const access = await resolveWorkspaceAccess( - rateLimit, - userId, - workflowData.workspaceId, - 'write' - ) - if (access) return v2Error('NOT_FOUND', 'Workflow not found') + const result = await performDeleteWorkflow({ workflowId: id, userId, requestId }) + if (!result.success) { + return v2ErrorForOrchestration( + result.errorCode, + result.error ?? 'Failed to delete workflow' + ) + } - await assertWorkflowMutable(id) + return v2Data({ id, deleted: true as const }, { rateLimit }) + } catch (error) { + if (error instanceof WorkflowLockedError) return v2Error('LOCKED', error.message) - const result = await performDeleteWorkflow({ workflowId: id, userId, requestId }) - if (!result.success) { - return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to delete workflow') + throw error } - - return v2Data({ id, deleted: true as const }, { rateLimit }) - } catch (error) { - if (error instanceof WorkflowLockedError) return v2Error('LOCKED', error.message) - - logger.error(`[${requestId}] Workflow delete error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts index 1ed021c974b..f1fe2633758 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/[version]/route.ts @@ -1,21 +1,12 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { type V2WorkflowVersionDetail, v2GetWorkflowVersionContract, } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getWorkflowDeploymentVersion } from '@/lib/workflows/persistence/utils' -import { checkRateLimit } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { v2Data, v2Error, v2RateLimitError, v2ValidationError } from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { v2Data, v2Error } from '@/app/api/v2/lib/response' import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' -const logger = createLogger('V2WorkflowVersionDetailAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -23,48 +14,28 @@ export const revalidate = 0 * GET /api/v2/workflows/[id]/versions/[version] — Fetch one deployment version * and the workflow state it pins. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; version: string }> }) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'workflow-version-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetWorkflowVersionContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id, version } = parsed.data.params - - const target = await resolveV2WorkflowTarget(rateLimit, userId, id) - if (!target) return v2Error('NOT_FOUND', 'Workflow not found') - - const row = await getWorkflowDeploymentVersion(id, version) - if (!row?.state) return v2Error('NOT_FOUND', 'Deployment version not found') - - const detail: V2WorkflowVersionDetail = { - id: row.id, - version: row.version, - name: row.name, - description: row.description, - isActive: row.isActive, - createdAt: row.createdAt.toISOString(), - state: row.state as V2WorkflowVersionDetail['state'], - } - - return v2Data(detail, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Workflow version fetch error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') +export const GET = withPublicApiRouteHandler({ + contract: v2GetWorkflowVersionContract, + rateLimitEndpoint: 'workflow-version-detail', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { id, version } = input.params + + const target = await resolveV2WorkflowTarget(rateLimit, userId, id) + if (!target) return v2Error('NOT_FOUND', 'Workflow not found') + + const row = await getWorkflowDeploymentVersion(id, version) + if (!row?.state) return v2Error('NOT_FOUND', 'Deployment version not found') + + const detail: V2WorkflowVersionDetail = { + id: row.id, + version: row.version, + name: row.name, + description: row.description, + isActive: row.isActive, + createdAt: row.createdAt.toISOString(), + state: row.state as V2WorkflowVersionDetail['state'], } - } -) + + return v2Data(detail, { rateLimit }) + }, +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts index 6d50be6db75..1a75e45cd16 100644 --- a/apps/sim/app/api/v2/workflows/[id]/versions/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/versions/route.ts @@ -1,28 +1,12 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { type V2WorkflowVersion, v2ListWorkflowVersionsContract, } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { listWorkflowVersions } from '@/lib/workflows/persistence/utils' -import { checkRateLimit } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - decodeCursor, - encodeCursor, - v2CursorList, - v2Error, - v2RateLimitError, - v2ValidationError, -} from '@/app/api/v2/lib/response' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { decodeCursor, encodeCursor, v2CursorList, v2Error } from '@/app/api/v2/lib/response' import { resolveV2WorkflowTarget } from '@/app/api/v2/workflows/utils' -const logger = createLogger('V2WorkflowVersionsAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -36,72 +20,52 @@ interface WorkflowVersionCursor { * newest first. These are the versions `POST /api/v2/workflows/[id]/rollback` * accepts, so a caller no longer has to guess a version number. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'workflow-versions') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2ListWorkflowVersionsContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const { limit, cursor } = parsed.data.query - - const target = await resolveV2WorkflowTarget(rateLimit, userId, id) - if (!target) return v2Error('NOT_FOUND', 'Workflow not found') - - /** - * A cursor that decodes to anything other than a version number is - * rejected rather than ignored: comparing every row against a missing - * `version` yields an empty page with `nextCursor: null`, which reads to - * the caller as a clean end-of-list while versions are still pending. - */ - const after = cursor ? decodeCursor(cursor) : null - if (cursor && (!after || !Number.isInteger(after.version) || after.version < 1)) { - return v2Error('BAD_REQUEST', 'Invalid cursor') - } - - // One extra row is the has-more probe, matching the other v2 cursor lists. - const { versions: rows } = await listWorkflowVersions(id, { - limit: limit + 1, - afterVersion: after?.version, - }) - - const hasMore = rows.length > limit - const page = rows.slice(0, limit) - - const data: V2WorkflowVersion[] = page.map((row) => ({ - id: row.id, - version: row.version, - name: row.name, - description: row.description, - isActive: row.isActive, - createdAt: row.createdAt.toISOString(), - deployedBy: row.deployedByName, - // The shared helper widens the operation-status pg enum to `string`. - latestOperationStatus: - row.latestOperationStatus as V2WorkflowVersion['latestOperationStatus'], - })) - - const nextCursor = - hasMore && data.length > 0 ? encodeCursor({ version: data[data.length - 1].version }) : null - - return v2CursorList(data, nextCursor, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Workflow versions fetch error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') +export const GET = withPublicApiRouteHandler({ + contract: v2ListWorkflowVersionsContract, + rateLimitEndpoint: 'workflow-versions', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { id } = input.params + const { limit, cursor } = input.query + + const target = await resolveV2WorkflowTarget(rateLimit, userId, id) + if (!target) return v2Error('NOT_FOUND', 'Workflow not found') + + /** + * A cursor that decodes to anything other than a version number is + * rejected rather than ignored: comparing every row against a missing + * `version` yields an empty page with `nextCursor: null`, which reads to + * the caller as a clean end-of-list while versions are still pending. + */ + const after = cursor ? decodeCursor(cursor) : null + if (cursor && (!after || !Number.isInteger(after.version) || after.version < 1)) { + return v2Error('BAD_REQUEST', 'Invalid cursor') } - } -) + + // One extra row is the has-more probe, matching the other v2 cursor lists. + const { versions: rows } = await listWorkflowVersions(id, { + limit: limit + 1, + afterVersion: after?.version, + }) + + const hasMore = rows.length > limit + const page = rows.slice(0, limit) + + const data: V2WorkflowVersion[] = page.map((row) => ({ + id: row.id, + version: row.version, + name: row.name, + description: row.description, + isActive: row.isActive, + createdAt: row.createdAt.toISOString(), + deployedBy: row.deployedByName, + // The shared helper widens the operation-status pg enum to `string`. + latestOperationStatus: + row.latestOperationStatus as V2WorkflowVersion['latestOperationStatus'], + })) + + const nextCursor = + hasMore && data.length > 0 ? encodeCursor({ version: data[data.length - 1].version }) : null + + return v2CursorList(data, nextCursor, { rateLimit }) + }, +}) diff --git a/apps/sim/app/api/v2/workflows/folders/route.ts b/apps/sim/app/api/v2/workflows/folders/route.ts index bb791ec6dce..81fb20fa35a 100644 --- a/apps/sim/app/api/v2/workflows/folders/route.ts +++ b/apps/sim/app/api/v2/workflows/folders/route.ts @@ -1,61 +1,32 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2CreateWorkflowFolderContract, v2DeleteWorkflowFolderContract, v2ListWorkflowFoldersContract, v2RelocateWorkflowFolderContract, } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createFolderAtPath, deleteFolderByPath, relocateFolderByPath, } from '@/lib/folders/orchestration' import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { resolveFolderPathId, toV2PathFolder, v2FolderPathMutationError, } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2WorkflowFoldersAPI') +import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { - const rateLimit = await checkRateLimit(request, 'workflows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListWorkflowFoldersContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, parentPath, search, sortBy, sortOrder } = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListWorkflowFoldersContract, + rateLimitEndpoint: 'workflows', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, parentPath, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -75,112 +46,80 @@ export const GET = withRouteHandler(async (request: NextRequest) => { null, { rateLimit } ) - } catch (error) { - logger.error(`[${requestId}] Error listing workflow folders`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) -export const POST = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'workflows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2CreateWorkflowFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) +export const POST = withPublicApiRouteHandler({ + contract: v2CreateWorkflowFolderContract, + rateLimitEndpoint: 'workflows', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path } = input.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) - const result = await createFolderAtPath({ resourceType: 'workflow', workspaceId, userId, path }) - if (!result.success || !result.folder || !result.path) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') - return v2Data({ folder: toV2PathFolder(result.folder, index, true) }, { rateLimit, status: 201 }) + const result = await createFolderAtPath({ resourceType: 'workflow', workspaceId, userId, path }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') + return v2Data( + { folder: toV2PathFolder(result.folder, index, true) }, + { rateLimit, status: 201 } + ) + }, }) -export const PATCH = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'workflows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2RelocateWorkflowFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path, destinationPath } = parsed.data.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) +export const PATCH = withPublicApiRouteHandler({ + contract: v2RelocateWorkflowFolderContract, + rateLimitEndpoint: 'workflows', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path, destinationPath } = input.body + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) - const result = await relocateFolderByPath({ - resourceType: 'workflow', - workspaceId, - userId, - path, - destinationPath, - }) - if (!result.success || !result.folder || !result.path) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') - } - const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') - return v2Data({ folder: toV2PathFolder(result.folder, index, true) }, { rateLimit }) + const result = await relocateFolderByPath({ + resourceType: 'workflow', + workspaceId, + userId, + path, + destinationPath, + }) + if (!result.success || !result.folder || !result.path) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') + } + const index = await loadActiveFolderPathIndex(workspaceId, 'workflow') + return v2Data({ folder: toV2PathFolder(result.folder, index, true) }, { rateLimit }) + }, }) -export const DELETE = withRouteHandler(async (request: NextRequest) => { - const rateLimit = await checkRateLimit(request, 'workflows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - const gate = await v2ApiGateError(userId) - if (gate) return gate - const parsed = await parseRequest( - v2DeleteWorkflowFolderContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - const { workspaceId, path, recursive } = parsed.data.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) +export const DELETE = withPublicApiRouteHandler({ + contract: v2DeleteWorkflowFolderContract, + rateLimitEndpoint: 'workflows', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId, path, recursive } = input.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) - const result = await deleteFolderByPath({ - resourceType: 'workflow', - workspaceId, - userId, - path, - recursive, - }) - if (!result.success || !result.deletedItems) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') - } - return v2Data( - { + const result = await deleteFolderByPath({ + resourceType: 'workflow', + workspaceId, + userId, path, - deleted: true as const, - deletedItems: { - folders: result.deletedItems.folders, - workflows: result.deletedItems.workflows ?? 0, + recursive, + }) + if (!result.success || !result.deletedItems) { + return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') + } + return v2Data( + { + path, + deleted: true as const, + deletedItems: { + folders: result.deletedItems.folders, + workflows: result.deletedItems.workflows ?? 0, + }, }, - }, - { rateLimit } - ) + { rateLimit } + ) + }, }) diff --git a/apps/sim/app/api/v2/workflows/import/route.ts b/apps/sim/app/api/v2/workflows/import/route.ts index e70da75d915..8ddd288d6a3 100644 --- a/apps/sim/app/api/v2/workflows/import/route.ts +++ b/apps/sim/app/api/v2/workflows/import/route.ts @@ -1,23 +1,16 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { v2ImportWorkflowContract } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { importWorkflowIntoWorkspace, MAX_IMPORT_BODY_BYTES, } from '@/lib/workflows/operations/import-workflow' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { folderPathForId, resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { type V2ErrorCode, v2Data, v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' @@ -42,30 +35,14 @@ const ERROR_CODE_BY_STATUS: Record = { * {@link importWorkflowIntoWorkspace} pipeline does the heavy lifting; this * route authenticates and renders the v2 envelope. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'workflow-import') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ImportWorkflowContract, - request, - {}, - { - maxBodyBytes: MAX_IMPORT_BODY_BYTES, - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, folderPath, name, description } = parsed.data.body +export const POST = withPublicApiRouteHandler({ + contract: v2ImportWorkflowContract, + rateLimitEndpoint: 'workflow-import', + parseOptions: { + maxBodyBytes: MAX_IMPORT_BODY_BYTES, + }, + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + const { workspaceId, folderPath, name, description } = input.body logger.info(`[${requestId}] Importing workflow into workspace ${workspaceId}`, { userId, @@ -87,7 +64,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { folderId: resolution.folderId ?? undefined, name, description, - workflow: parsed.data.body.workflow, + workflow: input.body.workflow, userId, requestId, }) @@ -111,10 +88,5 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }, { rateLimit, status: 201 } ) - } catch (error) { - logger.error(`[${requestId}] Workflow import error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index 0d77ccc618d..3e0a42c8171 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -1,25 +1,19 @@ -import { createLogger } from '@sim/logger' import { assertFolderMutable, FolderLockedError } from '@sim/platform-authz/workflow' -import { getErrorMessage } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import type { NextRequest } from 'next/server' import { type V2WorkflowListItem, v2CreateWorkflowContract, v2ListWorkflowsContract, } from '@/lib/api/contracts/v2/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { performCreateWorkflow } from '@/lib/workflows/orchestration' import { InvalidWorkflowListCursorError, listWorkspaceWorkflows } from '@/lib/workflows/queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { folderPathForId, resolveFolderPathId, resolveFolderPathIdentity, } from '@/app/api/v2/lib/folders' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { cursorSortKey, decodeSortedCursor, @@ -29,39 +23,17 @@ import { v2Data, v2Error, v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2WorkflowsAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'workflows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListWorkflowsContract, - request, - {}, - { - validationErrorResponse: v2ValidationError, - } - ) - if (!parsed.success) return parsed.response - - const params = parsed.data.query +export const GET = withPublicApiRouteHandler({ + contract: v2ListWorkflowsContract, + rateLimitEndpoint: 'workflows', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const params = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, params.workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -115,83 +87,64 @@ export const GET = withRouteHandler(async (request: NextRequest) => { })) return v2CursorList(formatted, nextCursor, { rateLimit }) - } catch (error) { - logger.error(`[${requestId}] Workflows fetch error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) /** POST /api/v2/workflows — Create an empty workflow in a workspace. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateId().slice(0, 8) - - try { - const rateLimit = await checkRateLimit(request, 'workflows') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! +export const POST = withPublicApiRouteHandler({ + contract: v2CreateWorkflowContract, + rateLimitEndpoint: 'workflows', + handler: async ({ input, auth: { requestId, userId, rateLimit } }) => { + try { + const { workspaceId, name, description, folderPath } = input.body - const gate = await v2ApiGateError(userId) - if (gate) return gate + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) - const parsed = await parseRequest( - v2CreateWorkflowContract, - request, - {}, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response + const resolution = await resolveFolderPathIdentity({ + workspaceId, + resourceType: 'workflow', + path: folderPath ?? '/', + }) + if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') + + await assertFolderMutable(resolution.folderId) + const result = await performCreateWorkflow({ + userId, + workspaceId, + name, + description, + folderId: resolution.folderId, + requestId, + }) - const { workspaceId, name, description, folderPath } = parsed.data.body + if (!result.success || !result.workflow) { + return v2ErrorForOrchestration( + result.errorCode, + result.error ?? 'Failed to create workflow' + ) + } - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) + const created = result.workflow + const item: V2WorkflowListItem = { + id: created.id, + name: created.name, + description: created.description ?? null, + folderPath: folderPathForId(resolution.index, created.folderId), + workspaceId: created.workspaceId, + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + createdAt: created.createdAt.toISOString(), + updatedAt: created.updatedAt.toISOString(), + } - const resolution = await resolveFolderPathIdentity({ - workspaceId, - resourceType: 'workflow', - path: folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - - await assertFolderMutable(resolution.folderId) - const result = await performCreateWorkflow({ - userId, - workspaceId, - name, - description, - folderId: resolution.folderId, - requestId, - }) - - if (!result.success || !result.workflow) { - return v2ErrorForOrchestration(result.errorCode, result.error ?? 'Failed to create workflow') - } + return v2Data(item, { rateLimit, status: 201 }) + } catch (error) { + if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) - const created = result.workflow - const item: V2WorkflowListItem = { - id: created.id, - name: created.name, - description: created.description ?? null, - folderPath: folderPathForId(resolution.index, created.folderId), - workspaceId: created.workspaceId, - isDeployed: false, - deployedAt: null, - runCount: 0, - lastRunAt: null, - createdAt: created.createdAt.toISOString(), - updatedAt: created.updatedAt.toISOString(), + throw error } - - return v2Data(item, { rateLimit, status: 201 }) - } catch (error) { - if (error instanceof FolderLockedError) return v2Error('LOCKED', error.message) - - logger.error(`[${requestId}] Workflow create error`, { - error: getErrorMessage(error, 'Unknown error'), - }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts index 856aecf5992..ba109669a1a 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts @@ -1,78 +1,50 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2ListWorkspaceMembersContract, v2WorkspaceMemberCursorSchema, } from '@/lib/api/contracts/v2/workspaces' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { queryPublicWorkspaceMembers } from '@/lib/workspaces/public-queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { decodeCursor, encodeCursor, v2CursorList, v2Error, - v2RateLimitError, - v2ValidationError, v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' -const logger = createLogger('V2WorkspaceMembersAPI') - -interface WorkspaceMembersRouteParams { - params: Promise<{ workspaceId: string }> -} - /** GET /api/v2/workspaces/[workspaceId]/members — Effective member roster. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: WorkspaceMembersRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'workspace-members') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2ListWorkspaceMembersContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { workspaceId } = parsed.data.params - const { cursor, limit } = parsed.data.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const decoded = cursor - ? v2WorkspaceMemberCursorSchema.safeParse(decodeCursor(cursor)) - : undefined - if (decoded && !decoded.success) return v2Error('BAD_REQUEST', 'Invalid cursor') - - const page = await queryPublicWorkspaceMembers(workspaceId, { - limit, - afterEmail: decoded?.data.email, - }) - if (!page) return v2Error('NOT_FOUND', 'Workspace not found') - - return v2CursorList( - page.members.map((member) => ({ - email: member.email, - name: member.name, - image: member.image, - role: member.role, - isExternal: member.isExternal, - joinedAt: member.joinedAt.toISOString(), - })), - page.nextEmail ? encodeCursor({ email: page.nextEmail }) : null, - { rateLimit } - ) - } catch (error) { - logger.error('Failed to list workspace members', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } - } -) +export const GET = withPublicApiRouteHandler({ + contract: v2ListWorkspaceMembersContract, + rateLimitEndpoint: 'workspace-members', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId } = input.params + const { cursor, limit } = input.query + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const decoded = cursor + ? v2WorkspaceMemberCursorSchema.safeParse(decodeCursor(cursor)) + : undefined + if (decoded && !decoded.success) return v2Error('BAD_REQUEST', 'Invalid cursor') + + const page = await queryPublicWorkspaceMembers(workspaceId, { + limit, + afterEmail: decoded?.data.email, + }) + if (!page) return v2Error('NOT_FOUND', 'Workspace not found') + + return v2CursorList( + page.members.map((member) => ({ + email: member.email, + name: member.name, + image: member.image, + role: member.role, + isExternal: member.isExternal, + joinedAt: member.joinedAt.toISOString(), + })), + page.nextEmail ? encodeCursor({ email: page.nextEmail }) : null, + { rateLimit } + ) + }, +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts index 6776b83b6f7..54272638421 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts @@ -1,42 +1,15 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' import { v2GetWorkspaceContract } from '@/lib/api/contracts/v2/workspaces' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getPublicWorkspaceDetail } from '@/lib/workspaces/public-queries' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2WorkspaceDetailAPI') - -interface WorkspaceRouteParams { - params: Promise<{ workspaceId: string }> -} +import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' +import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' /** GET /api/v2/workspaces/[workspaceId] — Public workspace metadata. */ -export const GET = withRouteHandler(async (request: NextRequest, context: WorkspaceRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'workspaces') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2GetWorkspaceContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { workspaceId } = parsed.data.params +export const GET = withPublicApiRouteHandler({ + contract: v2GetWorkspaceContract, + rateLimitEndpoint: 'workspaces', + handler: async ({ input, auth: { userId, rateLimit } }) => { + const { workspaceId } = input.params const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) @@ -51,8 +24,5 @@ export const GET = withRouteHandler(async (request: NextRequest, context: Worksp }, { rateLimit } ) - } catch (error) { - logger.error('Failed to get workspace', { error: getErrorMessage(error) }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } + }, }) diff --git a/apps/sim/lib/api/server/validation.ts b/apps/sim/lib/api/server/validation.ts index 6d5799de4ae..06b433c7652 100644 --- a/apps/sim/lib/api/server/validation.ts +++ b/apps/sim/lib/api/server/validation.ts @@ -51,6 +51,7 @@ interface RouteContextWithParams { export interface ParseRequestOptions { validationErrorResponse?: (error: z.ZodError) => NextResponse invalidJsonResponse?: () => NextResponse + payloadTooLargeResponse?: () => NextResponse invalidJson?: 'response' | 'throw' /** * Maximum number of bytes to read for the JSON body before rejecting with a @@ -245,9 +246,13 @@ export async function parseRequest( if (shouldReadJsonBody(contract)) { const parsedBody = await parseJsonBody(request, options?.invalidJson, options?.maxBodyBytes) if (!parsedBody.success) { - return options?.invalidJsonResponse && parsedBody.reason === 'invalid_json' - ? { success: false, response: options.invalidJsonResponse() } - : parsedBody + if (options?.invalidJsonResponse && parsedBody.reason === 'invalid_json') { + return { success: false, response: options.invalidJsonResponse() } + } + if (options?.payloadTooLargeResponse && parsedBody.reason === 'too_large') { + return { success: false, response: options.payloadTooLargeResponse() } + } + return parsedBody } body = parsedBody.data } diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts index 2c4bc973ce2..32ccc7fc788 100644 --- a/apps/sim/lib/core/utils/with-route-handler.ts +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -13,6 +13,15 @@ type RouteHandler = ( context: T ) => Promise | NextResponse | Response +interface RouteHandlerErrorContext { + error: unknown + requestId: string +} + +interface RouteHandlerOptions { + unhandledErrorResponse?: (context: RouteHandlerErrorContext) => NextResponse | Response +} + /** * Reads a numeric `statusCode` (4xx or 5xx) off an `HttpError` so typed domain * errors (e.g. `WorkspaceAccessDeniedError`, `InvalidFieldError`) map to the @@ -63,10 +72,14 @@ function applyResponseHeaders( * logger in the request lifecycle automatically includes it * - Logs all 4xx and 5xx responses with method, path, status, duration * - Catches unhandled errors, logs them, and returns a 500 with the request ID + * - Supports a route-family-specific unhandled-error response envelope * - Attaches `x-request-id`, plus the rate-limit headers when the route * recorded a snapshot for the request */ -export function withRouteHandler(handler: RouteHandler): RouteHandler { +export function withRouteHandler( + handler: RouteHandler, + options: RouteHandlerOptions = {} +): RouteHandler { return async (request: NextRequest, context: T) => { const requestId = generateRequestId() const startTime = Date.now() @@ -81,6 +94,13 @@ export function withRouteHandler(handler: RouteHandler): RouteHandler { } catch (error) { const duration = Date.now() - startTime const message = getErrorMessage(error, 'Unknown error') + if (options.unhandledErrorResponse) { + logger.error('Unhandled route error', { duration, error: message }) + response = options.unhandledErrorResponse({ error, requestId }) + applyResponseHeaders(response, request, requestId) + return response + } + const typedStatus = readTypedErrorStatus(error) if (typedStatus !== undefined) { if (typedStatus >= 500) { diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 001621c4f3a..156960f1a5a 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -147,6 +147,9 @@ const RAW_JSON_BASELINE_ROUTES = new Set([ ]) const CONTRACT_IMPORT_PATTERN = /\bfrom\s+['"]@\/lib\/api\/contracts(?:\/[^'"]*)?['"]/ +const PUBLIC_API_ROUTE_HANDLER_IMPORT_PATTERN = + /\bimport\s*\{[^}]*\bwithPublicApiRouteHandler\b[^}]*\}\s*from\s*['"]@\/app\/api\/public-api-route-handler['"]/ +const PUBLIC_API_ROUTE_HANDLER_USAGE_PATTERN = /\bwithPublicApiRouteHandler\s*\(/ const SERVER_VALIDATION_IMPORT_PATTERN = /\bfrom\s+['"]@\/lib\/api\/server(?:\/validation)?['"]/ const SCHEMA_PARSE_PATTERN = /\b\w+Schema\.(?:safeParse|parse)\(/ const CONTRACT_SERVER_HELPER_PATTERN = /\bparseToolRequest\(/ @@ -717,6 +720,13 @@ function hasZodUsage(relativePath: string, content: string): boolean { ) { return true } + if ( + CONTRACT_IMPORT_PATTERN.test(content) && + PUBLIC_API_ROUTE_HANDLER_IMPORT_PATTERN.test(content) && + PUBLIC_API_ROUTE_HANDLER_USAGE_PATTERN.test(content) + ) { + return true + } if ( CONTRACT_IMPORT_PATTERN.test(content) && (SCHEMA_PARSE_PATTERN.test(content) || CONTRACT_MAP_PARSE_PATTERN.test(content))