chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeExpenseDocuments } from '@/app/api/tools/textract/analyze-expense/route'
|
||||
|
||||
describe('normalizeExpenseDocuments', () => {
|
||||
it('maps a documented AWS AnalyzeExpense response shape', () => {
|
||||
const result = normalizeExpenseDocuments([
|
||||
{
|
||||
ExpenseIndex: 1,
|
||||
SummaryFields: [
|
||||
{
|
||||
Type: { Text: 'VENDOR_NAME', Confidence: 98.1 },
|
||||
ValueDetection: { Text: 'Acme Corp', Confidence: 97.5 },
|
||||
LabelDetection: { Text: 'Vendor', Confidence: 90 },
|
||||
PageNumber: 1,
|
||||
Currency: { Code: 'USD', Confidence: 95 },
|
||||
GroupProperties: [{ Id: 'g1', Types: ['VENDOR'] }],
|
||||
},
|
||||
],
|
||||
LineItemGroups: [
|
||||
{
|
||||
LineItemGroupIndex: 1,
|
||||
LineItems: [
|
||||
{
|
||||
LineItemExpenseFields: [
|
||||
{
|
||||
Type: { Text: 'ITEM', Confidence: 91 },
|
||||
ValueDetection: { Text: 'Widget', Confidence: 93 },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
expenseIndex: 1,
|
||||
summaryFields: [
|
||||
{
|
||||
type: { text: 'VENDOR_NAME', confidence: 98.1 },
|
||||
valueDetection: { text: 'Acme Corp', confidence: 97.5 },
|
||||
labelDetection: { text: 'Vendor', confidence: 90 },
|
||||
pageNumber: 1,
|
||||
currency: { code: 'USD', confidence: 95 },
|
||||
groupProperties: [{ id: 'g1', types: ['VENDOR'] }],
|
||||
},
|
||||
],
|
||||
lineItemGroups: [
|
||||
{
|
||||
lineItemGroupIndex: 1,
|
||||
lineItems: [
|
||||
{
|
||||
lineItemExpenseFields: [
|
||||
{
|
||||
type: { text: 'ITEM', confidence: 91 },
|
||||
valueDetection: { text: 'Widget', confidence: 93 },
|
||||
labelDetection: undefined,
|
||||
pageNumber: undefined,
|
||||
currency: undefined,
|
||||
groupProperties: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('defaults missing arrays to empty arrays', () => {
|
||||
expect(normalizeExpenseDocuments([{ ExpenseIndex: 0 }])).toEqual([
|
||||
{ expenseIndex: 0, summaryFields: [], lineItemGroups: [] },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
AnalyzeExpenseCommand,
|
||||
type ExpenseDocument,
|
||||
GetExpenseAnalysisCommand,
|
||||
StartExpenseAnalysisCommand,
|
||||
TextractClient,
|
||||
} from '@aws-sdk/client-textract'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { textractAnalyzeExpenseContract } from '@/lib/api/contracts/tools/media/document-parse'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
mapTextractSdkError,
|
||||
parseS3Uri,
|
||||
pollTextractJob,
|
||||
resolveDocumentInput,
|
||||
textractErrorResponse,
|
||||
} from '@/app/api/tools/textract/shared'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
/** Mirrors maxDuration in ../parse/route.ts — see that file's TSDoc for details. */
|
||||
export const maxDuration = 5400
|
||||
|
||||
const logger = createLogger('TextractAnalyzeExpenseAPI')
|
||||
|
||||
/** Response shape shared by AnalyzeExpense and its async Get* counterpart. */
|
||||
interface TextractExpenseResult {
|
||||
JobStatus?: string
|
||||
StatusMessage?: string
|
||||
NextToken?: string
|
||||
ExpenseDocuments?: ExpenseDocument[]
|
||||
DocumentMetadata?: { Pages?: number }
|
||||
AnalyzeExpenseModelVersion?: string
|
||||
}
|
||||
|
||||
export function normalizeExpenseField(field: {
|
||||
Type?: { Text?: string; Confidence?: number }
|
||||
ValueDetection?: { Text?: string; Confidence?: number }
|
||||
LabelDetection?: { Text?: string; Confidence?: number }
|
||||
PageNumber?: number
|
||||
Currency?: { Code?: string; Confidence?: number }
|
||||
GroupProperties?: { Id?: string; Types?: string[] }[]
|
||||
}) {
|
||||
return {
|
||||
type: { text: field.Type?.Text, confidence: field.Type?.Confidence },
|
||||
valueDetection: {
|
||||
text: field.ValueDetection?.Text,
|
||||
confidence: field.ValueDetection?.Confidence,
|
||||
},
|
||||
labelDetection: field.LabelDetection
|
||||
? { text: field.LabelDetection.Text, confidence: field.LabelDetection.Confidence }
|
||||
: undefined,
|
||||
pageNumber: field.PageNumber,
|
||||
currency: field.Currency
|
||||
? { code: field.Currency.Code, confidence: field.Currency.Confidence }
|
||||
: undefined,
|
||||
groupProperties: field.GroupProperties?.map((group) => ({
|
||||
id: group.Id ?? '',
|
||||
types: group.Types ?? [],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeExpenseDocuments(documents: ExpenseDocument[]) {
|
||||
return documents.map((doc) => ({
|
||||
expenseIndex: doc.ExpenseIndex,
|
||||
summaryFields: (doc.SummaryFields ?? []).map(normalizeExpenseField),
|
||||
lineItemGroups: (doc.LineItemGroups ?? []).map((group) => ({
|
||||
lineItemGroupIndex: group.LineItemGroupIndex,
|
||||
lineItems: (group.LineItems ?? []).map((item) => ({
|
||||
lineItemExpenseFields: (item.LineItemExpenseFields ?? []).map(normalizeExpenseField),
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Textract analyze-expense attempt`, {
|
||||
error: authResult.error || 'Missing userId',
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
const userId = authResult.userId
|
||||
|
||||
const parsed = await parseRequest(
|
||||
textractAnalyzeExpenseContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const validatedData = parsed.data.body
|
||||
const processingMode = validatedData.processingMode || 'sync'
|
||||
|
||||
logger.info(`[${requestId}] Textract analyze-expense request`, {
|
||||
processingMode,
|
||||
hasFile: Boolean(validatedData.file),
|
||||
hasS3Uri: Boolean(validatedData.s3Uri),
|
||||
userId,
|
||||
})
|
||||
|
||||
const client = new TextractClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
if (processingMode === 'async') {
|
||||
if (!validatedData.s3Uri) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'S3 URI is required for multi-page processing (s3://bucket/key)',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { bucket, key } = parseS3Uri(validatedData.s3Uri)
|
||||
logger.info(`[${requestId}] Starting async Textract expense analysis job`, {
|
||||
s3Bucket: bucket,
|
||||
s3Key: key,
|
||||
})
|
||||
|
||||
const { JobId: jobId } = await client.send(
|
||||
new StartExpenseAnalysisCommand({
|
||||
DocumentLocation: { S3Object: { Bucket: bucket, Name: key } },
|
||||
})
|
||||
)
|
||||
if (!jobId) {
|
||||
throw new Error('Failed to start Textract expense analysis job: No JobId returned')
|
||||
}
|
||||
logger.info(`[${requestId}] Async expense analysis job started`, { jobId })
|
||||
|
||||
const result = await pollTextractJob<TextractExpenseResult>(
|
||||
requestId,
|
||||
logger,
|
||||
(nextToken) =>
|
||||
client.send(new GetExpenseAnalysisCommand({ JobId: jobId, NextToken: nextToken })),
|
||||
(accumulated, page) => ({
|
||||
...accumulated,
|
||||
...page,
|
||||
ExpenseDocuments: [
|
||||
...(accumulated.ExpenseDocuments ?? []),
|
||||
...(page.ExpenseDocuments ?? []),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
expenseDocuments: normalizeExpenseDocuments(result.ExpenseDocuments ?? []),
|
||||
documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 },
|
||||
modelVersion: result.AnalyzeExpenseModelVersion,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const resolved = await resolveDocumentInput(
|
||||
{ file: validatedData.file, filePath: validatedData.filePath },
|
||||
userId,
|
||||
requestId,
|
||||
logger
|
||||
)
|
||||
if (!resolved.ok) return resolved.response
|
||||
const { bytes, isPdf } = resolved.document
|
||||
|
||||
let result: TextractExpenseResult
|
||||
try {
|
||||
result = await client.send(new AnalyzeExpenseCommand({ Document: { Bytes: bytes } }))
|
||||
} catch (error) {
|
||||
throw mapTextractSdkError(error, isPdf)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Textract analyze-expense successful`, {
|
||||
pageCount: result.DocumentMetadata?.Pages ?? 0,
|
||||
expenseDocumentCount: result.ExpenseDocuments?.length ?? 0,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
expenseDocuments: normalizeExpenseDocuments(result.ExpenseDocuments ?? []),
|
||||
documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 },
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return textractErrorResponse(error, requestId, logger)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeIdentityDocuments } from '@/app/api/tools/textract/analyze-id/route'
|
||||
|
||||
describe('normalizeIdentityDocuments', () => {
|
||||
it('maps a documented AWS AnalyzeID response shape', () => {
|
||||
const result = normalizeIdentityDocuments([
|
||||
{
|
||||
DocumentIndex: 1,
|
||||
IdentityDocumentFields: [
|
||||
{
|
||||
Type: { Text: 'FIRST_NAME', Confidence: 99 },
|
||||
ValueDetection: { Text: 'Jane', Confidence: 98 },
|
||||
},
|
||||
{
|
||||
Type: {
|
||||
Text: 'DATE_OF_BIRTH',
|
||||
Confidence: 97,
|
||||
NormalizedValue: { Value: '1990-01-01', ValueType: 'Date' },
|
||||
},
|
||||
ValueDetection: {
|
||||
Text: '01/01/1990',
|
||||
Confidence: 96,
|
||||
NormalizedValue: { Value: '1990-01-01T00:00:00', ValueType: 'Date' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
documentIndex: 1,
|
||||
identityDocumentFields: [
|
||||
{
|
||||
type: { text: 'FIRST_NAME', confidence: 99, normalizedValue: undefined },
|
||||
valueDetection: { text: 'Jane', confidence: 98, normalizedValue: undefined },
|
||||
},
|
||||
{
|
||||
type: {
|
||||
text: 'DATE_OF_BIRTH',
|
||||
confidence: 97,
|
||||
normalizedValue: { value: '1990-01-01', valueType: 'Date' },
|
||||
},
|
||||
valueDetection: {
|
||||
text: '01/01/1990',
|
||||
confidence: 96,
|
||||
normalizedValue: { value: '1990-01-01T00:00:00', valueType: 'Date' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('defaults missing fields to an empty array', () => {
|
||||
expect(normalizeIdentityDocuments([{ DocumentIndex: 0 }])).toEqual([
|
||||
{ documentIndex: 0, identityDocumentFields: [] },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { AnalyzeIDCommand, type IdentityDocument, TextractClient } from '@aws-sdk/client-textract'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { textractAnalyzeIdContract } from '@/lib/api/contracts/tools/media/document-parse'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
mapTextractSdkError,
|
||||
resolveDocumentInput,
|
||||
textractErrorResponse,
|
||||
} from '@/app/api/tools/textract/shared'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('TextractAnalyzeIdAPI')
|
||||
|
||||
export function normalizeIdentityDocuments(documents: IdentityDocument[]) {
|
||||
return documents.map((doc) => ({
|
||||
documentIndex: doc.DocumentIndex,
|
||||
identityDocumentFields: (doc.IdentityDocumentFields ?? []).map((field) => ({
|
||||
type: {
|
||||
text: field.Type?.Text,
|
||||
confidence: field.Type?.Confidence,
|
||||
normalizedValue: field.Type?.NormalizedValue
|
||||
? {
|
||||
value: field.Type.NormalizedValue.Value,
|
||||
valueType: field.Type.NormalizedValue.ValueType,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
valueDetection: {
|
||||
text: field.ValueDetection?.Text,
|
||||
confidence: field.ValueDetection?.Confidence,
|
||||
normalizedValue: field.ValueDetection?.NormalizedValue
|
||||
? {
|
||||
value: field.ValueDetection.NormalizedValue.Value,
|
||||
valueType: field.ValueDetection.NormalizedValue.ValueType,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Textract analyze-id attempt`, {
|
||||
error: authResult.error || 'Missing userId',
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
const userId = authResult.userId
|
||||
|
||||
const parsed = await parseRequest(
|
||||
textractAnalyzeIdContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
logger.info(`[${requestId}] Textract analyze-id request`, {
|
||||
hasFile: Boolean(validatedData.file),
|
||||
hasBackFile: Boolean(validatedData.fileBack || validatedData.filePathBack),
|
||||
userId,
|
||||
})
|
||||
|
||||
const front = await resolveDocumentInput(
|
||||
{ file: validatedData.file, filePath: validatedData.filePath },
|
||||
userId,
|
||||
requestId,
|
||||
logger
|
||||
)
|
||||
if (!front.ok) return front.response
|
||||
|
||||
const documentPages = [{ Bytes: front.document.bytes }]
|
||||
let isPdf = front.document.isPdf
|
||||
|
||||
if (validatedData.fileBack || validatedData.filePathBack) {
|
||||
const back = await resolveDocumentInput(
|
||||
{ file: validatedData.fileBack, filePath: validatedData.filePathBack },
|
||||
userId,
|
||||
requestId,
|
||||
logger
|
||||
)
|
||||
if (!back.ok) return back.response
|
||||
documentPages.push({ Bytes: back.document.bytes })
|
||||
isPdf = isPdf || back.document.isPdf
|
||||
}
|
||||
|
||||
const client = new TextractClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
let result: {
|
||||
AnalyzeIDModelVersion?: string
|
||||
DocumentMetadata?: { Pages?: number }
|
||||
IdentityDocuments?: IdentityDocument[]
|
||||
}
|
||||
try {
|
||||
result = await client.send(new AnalyzeIDCommand({ DocumentPages: documentPages }))
|
||||
} catch (error) {
|
||||
throw mapTextractSdkError(error, isPdf, { hasAsyncMode: false })
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Textract analyze-id successful`, {
|
||||
pageCount: result.DocumentMetadata?.Pages ?? 0,
|
||||
documentCount: result.IdentityDocuments?.length ?? 0,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
identityDocuments: normalizeIdentityDocuments(result.IdentityDocuments ?? []),
|
||||
documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 },
|
||||
modelVersion: result.AnalyzeIDModelVersion,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return textractErrorResponse(error, requestId, logger)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
AnalyzeDocumentCommand,
|
||||
DetectDocumentTextCommand,
|
||||
type FeatureType,
|
||||
GetDocumentAnalysisCommand,
|
||||
GetDocumentTextDetectionCommand,
|
||||
StartDocumentAnalysisCommand,
|
||||
StartDocumentTextDetectionCommand,
|
||||
TextractClient,
|
||||
} from '@aws-sdk/client-textract'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { textractParseContract } from '@/lib/api/contracts/tools/media/document-parse'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import {
|
||||
mapTextractSdkError,
|
||||
parseS3Uri,
|
||||
pollTextractJob,
|
||||
resolveDocumentInput,
|
||||
textractErrorResponse,
|
||||
} from '@/app/api/tools/textract/shared'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
/**
|
||||
* Mirrors the maximum plan execution timeout (enterprise async, 90 minutes) used by
|
||||
* `getMaxExecutionTimeout()` for the job polling loop below. Next.js requires a static
|
||||
* literal for `maxDuration`, so this value must be kept in sync with that source.
|
||||
*/
|
||||
export const maxDuration = 5400
|
||||
|
||||
const logger = createLogger('TextractParseAPI')
|
||||
|
||||
/** Response shape shared by AnalyzeDocument/DetectDocumentText and their async Get* counterparts. */
|
||||
interface TextractDocumentResult {
|
||||
JobStatus?: string
|
||||
StatusMessage?: string
|
||||
NextToken?: string
|
||||
Blocks?: unknown[]
|
||||
DocumentMetadata?: { Pages?: number }
|
||||
AnalyzeDocumentModelVersion?: string
|
||||
DetectDocumentTextModelVersion?: string
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Textract parse attempt`, {
|
||||
error: authResult.error || 'Missing userId',
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
const userId = authResult.userId
|
||||
|
||||
const parsed = await parseRequest(
|
||||
textractParseContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const validatedData = parsed.data.body
|
||||
const processingMode = validatedData.processingMode || 'sync'
|
||||
const featureTypes = (validatedData.featureTypes ?? []) as FeatureType[]
|
||||
const useAnalyzeDocument = featureTypes.length > 0
|
||||
const queriesConfig =
|
||||
validatedData.queries && validatedData.queries.length > 0 && featureTypes.includes('QUERIES')
|
||||
? {
|
||||
Queries: validatedData.queries.map((q) => ({
|
||||
Text: q.Text,
|
||||
Alias: q.Alias,
|
||||
Pages: q.Pages,
|
||||
})),
|
||||
}
|
||||
: undefined
|
||||
|
||||
logger.info(`[${requestId}] Textract parse request`, {
|
||||
processingMode,
|
||||
hasFile: Boolean(validatedData.file),
|
||||
hasS3Uri: Boolean(validatedData.s3Uri),
|
||||
featureTypes,
|
||||
userId,
|
||||
})
|
||||
|
||||
const client = new TextractClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
if (processingMode === 'async') {
|
||||
if (!validatedData.s3Uri) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'S3 URI is required for multi-page processing (s3://bucket/key)',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { bucket, key } = parseS3Uri(validatedData.s3Uri)
|
||||
logger.info(`[${requestId}] Starting async Textract job`, { s3Bucket: bucket, s3Key: key })
|
||||
|
||||
const { JobId: jobId } = useAnalyzeDocument
|
||||
? await client.send(
|
||||
new StartDocumentAnalysisCommand({
|
||||
DocumentLocation: { S3Object: { Bucket: bucket, Name: key } },
|
||||
FeatureTypes: featureTypes,
|
||||
QueriesConfig: queriesConfig,
|
||||
})
|
||||
)
|
||||
: await client.send(
|
||||
new StartDocumentTextDetectionCommand({
|
||||
DocumentLocation: { S3Object: { Bucket: bucket, Name: key } },
|
||||
})
|
||||
)
|
||||
if (!jobId) {
|
||||
throw new Error('Failed to start Textract job: No JobId returned')
|
||||
}
|
||||
logger.info(`[${requestId}] Async job started`, { jobId })
|
||||
|
||||
const result = await pollTextractJob<TextractDocumentResult>(
|
||||
requestId,
|
||||
logger,
|
||||
async (nextToken) =>
|
||||
useAnalyzeDocument
|
||||
? await client.send(
|
||||
new GetDocumentAnalysisCommand({ JobId: jobId, NextToken: nextToken })
|
||||
)
|
||||
: await client.send(
|
||||
new GetDocumentTextDetectionCommand({ JobId: jobId, NextToken: nextToken })
|
||||
),
|
||||
(accumulated, page) => ({
|
||||
...accumulated,
|
||||
...page,
|
||||
Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])],
|
||||
})
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Textract async parse successful`, {
|
||||
pageCount: result.DocumentMetadata?.Pages ?? 0,
|
||||
blockCount: result.Blocks?.length ?? 0,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
blocks: result.Blocks ?? [],
|
||||
documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 },
|
||||
modelVersion: result.AnalyzeDocumentModelVersion ?? result.DetectDocumentTextModelVersion,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const resolved = await resolveDocumentInput(
|
||||
{ file: validatedData.file, filePath: validatedData.filePath },
|
||||
userId,
|
||||
requestId,
|
||||
logger
|
||||
)
|
||||
if (!resolved.ok) return resolved.response
|
||||
const { bytes, isPdf } = resolved.document
|
||||
|
||||
let result: TextractDocumentResult
|
||||
try {
|
||||
result = useAnalyzeDocument
|
||||
? await client.send(
|
||||
new AnalyzeDocumentCommand({
|
||||
Document: { Bytes: bytes },
|
||||
FeatureTypes: featureTypes,
|
||||
QueriesConfig: queriesConfig,
|
||||
})
|
||||
)
|
||||
: await client.send(new DetectDocumentTextCommand({ Document: { Bytes: bytes } }))
|
||||
} catch (error) {
|
||||
throw mapTextractSdkError(error, isPdf)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Textract parse successful`, {
|
||||
pageCount: result.DocumentMetadata?.Pages ?? 0,
|
||||
blockCount: result.Blocks?.length ?? 0,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
blocks: result.Blocks ?? [],
|
||||
documentMetadata: { pages: result.DocumentMetadata?.Pages ?? 0 },
|
||||
modelVersion: result.AnalyzeDocumentModelVersion ?? result.DetectDocumentTextModelVersion,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return textractErrorResponse(error, requestId, logger)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
mapTextractSdkError,
|
||||
parseS3Uri,
|
||||
pollTextractJob,
|
||||
TextractRouteError,
|
||||
} from '@/app/api/tools/textract/shared'
|
||||
|
||||
const logger = createLogger('TextractSharedTest')
|
||||
|
||||
describe('parseS3Uri', () => {
|
||||
it('parses a valid s3 URI', () => {
|
||||
expect(parseS3Uri('s3://my-bucket/path/to/doc.pdf')).toEqual({
|
||||
bucket: 'my-bucket',
|
||||
key: 'path/to/doc.pdf',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a malformed URI', () => {
|
||||
expect(() => parseS3Uri('not-an-s3-uri')).toThrow(TextractRouteError)
|
||||
})
|
||||
|
||||
it('rejects path traversal in the key', () => {
|
||||
expect(() => parseS3Uri('s3://my-bucket/../secrets.pdf')).toThrow('path traversal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapTextractSdkError', () => {
|
||||
it('gives a friendly hint for unsupported PDFs in single-page mode', () => {
|
||||
const mapped = mapTextractSdkError(
|
||||
{ name: 'UnsupportedDocumentException', message: 'Unsupported document' },
|
||||
true
|
||||
)
|
||||
expect(mapped.status).toBe(400)
|
||||
expect(mapped.message).toContain('Multi-Page (PDF, TIFF via S3)')
|
||||
})
|
||||
|
||||
it('omits the multi-page hint for operations without an async mode', () => {
|
||||
const mapped = mapTextractSdkError(
|
||||
{ name: 'UnsupportedDocumentException', message: 'Unsupported document' },
|
||||
true,
|
||||
{ hasAsyncMode: false }
|
||||
)
|
||||
expect(mapped.message).not.toContain('Multi-Page')
|
||||
expect(mapped.message).toContain('Only JPEG, PNG, and single-page PDF files are supported')
|
||||
})
|
||||
|
||||
it('does not rewrite the message for non-PDF unsupported documents', () => {
|
||||
const mapped = mapTextractSdkError(
|
||||
{ name: 'UnsupportedDocumentException', message: 'Unsupported document' },
|
||||
false
|
||||
)
|
||||
expect(mapped.message).toBe('Unsupported document')
|
||||
})
|
||||
|
||||
it('uses the SDK http status when under 500', () => {
|
||||
const mapped = mapTextractSdkError(
|
||||
{
|
||||
name: 'InvalidParameterException',
|
||||
message: 'Bad param',
|
||||
$metadata: { httpStatusCode: 400 },
|
||||
},
|
||||
false
|
||||
)
|
||||
expect(mapped.status).toBe(400)
|
||||
expect(mapped.message).toBe('Bad param')
|
||||
})
|
||||
|
||||
it('passes through a 5xx SDK status so tool-execution retry logic still fires', () => {
|
||||
const mapped = mapTextractSdkError(
|
||||
{ message: 'Internal failure', $metadata: { httpStatusCode: 500 } },
|
||||
false
|
||||
)
|
||||
expect(mapped.status).toBe(500)
|
||||
})
|
||||
|
||||
it('defaults to 500 when the SDK gives no http status, since that implies a server-side failure', () => {
|
||||
const mapped = mapTextractSdkError({ message: 'Unknown failure' }, false)
|
||||
expect(mapped.status).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pollTextractJob', () => {
|
||||
it('returns immediately on SUCCEEDED with no NextToken', async () => {
|
||||
const result = await pollTextractJob(
|
||||
'req-1',
|
||||
logger,
|
||||
async () => ({ JobStatus: 'SUCCEEDED', Blocks: [{ Id: '1' }] }),
|
||||
(accumulated, page) => ({
|
||||
...page,
|
||||
Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])],
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.JobStatus).toBe('SUCCEEDED')
|
||||
expect(result.Blocks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('follows NextToken pagination and merges pages', async () => {
|
||||
let calls = 0
|
||||
const result = await pollTextractJob(
|
||||
'req-2',
|
||||
logger,
|
||||
async (nextToken) => {
|
||||
calls += 1
|
||||
if (!nextToken) return { JobStatus: 'SUCCEEDED', Blocks: [{ Id: '1' }], NextToken: 'next' }
|
||||
return { JobStatus: 'SUCCEEDED', Blocks: [{ Id: '2' }] }
|
||||
},
|
||||
(accumulated, page) => ({
|
||||
...accumulated,
|
||||
...page,
|
||||
Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])],
|
||||
})
|
||||
)
|
||||
|
||||
expect(calls).toBe(2)
|
||||
expect(result.Blocks).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('preserves fields the first page has but a later page omits (e.g. DocumentMetadata)', async () => {
|
||||
const result = await pollTextractJob<{
|
||||
JobStatus?: string
|
||||
NextToken?: string
|
||||
Blocks?: unknown[]
|
||||
DocumentMetadata?: { Pages?: number }
|
||||
}>(
|
||||
'req-4',
|
||||
logger,
|
||||
async (nextToken) => {
|
||||
if (!nextToken) {
|
||||
return {
|
||||
JobStatus: 'SUCCEEDED',
|
||||
Blocks: [{ Id: '1' }],
|
||||
DocumentMetadata: { Pages: 3 },
|
||||
NextToken: 'next',
|
||||
}
|
||||
}
|
||||
return { JobStatus: 'SUCCEEDED', Blocks: [{ Id: '2' }] }
|
||||
},
|
||||
(accumulated, page) => ({
|
||||
...accumulated,
|
||||
...page,
|
||||
Blocks: [...(accumulated.Blocks ?? []), ...(page.Blocks ?? [])],
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.Blocks).toHaveLength(2)
|
||||
expect(result.DocumentMetadata).toEqual({ Pages: 3 })
|
||||
})
|
||||
|
||||
it('throws a TextractRouteError when the job fails', async () => {
|
||||
await expect(
|
||||
pollTextractJob(
|
||||
'req-3',
|
||||
logger,
|
||||
async () => ({ JobStatus: 'FAILED', StatusMessage: 'boom' }),
|
||||
(accumulated) => accumulated
|
||||
)
|
||||
).rejects.toThrow('Textract job failed: boom')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,305 @@
|
||||
import type { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
|
||||
import { validateS3BucketName } from '@/lib/core/security/input-validation'
|
||||
import {
|
||||
secureFetchWithPinnedIP,
|
||||
validateUrlWithDNS,
|
||||
} from '@/lib/core/security/input-validation.server'
|
||||
import type { RawFileInput } from '@/lib/uploads/utils/file-utils'
|
||||
import { isInternalFileUrl, processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
|
||||
import {
|
||||
downloadServableFileFromStorage,
|
||||
resolveInternalFileUrl,
|
||||
} from '@/lib/uploads/utils/file-utils.server'
|
||||
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
|
||||
import { assertToolFileAccess } from '@/app/api/files/authorization'
|
||||
|
||||
type RouteLogger = ReturnType<typeof createLogger>
|
||||
|
||||
/** Thrown by AWS SDK call sites so route handlers can map failures to the right HTTP status. */
|
||||
export class TextractRouteError extends Error {
|
||||
status: number
|
||||
|
||||
constructor(message: string, status = 500) {
|
||||
super(message)
|
||||
this.name = 'TextractRouteError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export function textractErrorResponse(
|
||||
error: unknown,
|
||||
requestId: string,
|
||||
logger: RouteLogger
|
||||
): NextResponse {
|
||||
const notReady = docNotReadyResponse(error)
|
||||
if (notReady) return notReady
|
||||
|
||||
logger.error(`[${requestId}] Error in Textract request:`, error)
|
||||
const status = error instanceof TextractRouteError ? error.status : 500
|
||||
return NextResponse.json(
|
||||
{ success: false, error: getErrorMessage(error, 'Internal server error') },
|
||||
{ status }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an AWS SDK TextractClient rejection to a client-facing error, with a friendly hint for the
|
||||
* common "PDF used in single-page mode" mistake. The real AWS HTTP status (including 5xx) is
|
||||
* passed through so the tool-execution layer's retry logic can still treat throttling/internal
|
||||
* errors as retryable, matching the pre-migration hand-rolled-signing behavior.
|
||||
*/
|
||||
export function mapTextractSdkError(
|
||||
error: unknown,
|
||||
isPdf: boolean,
|
||||
options?: { hasAsyncMode?: boolean }
|
||||
): TextractRouteError {
|
||||
const err = error as {
|
||||
name?: string
|
||||
message?: string
|
||||
$metadata?: { httpStatusCode?: number }
|
||||
}
|
||||
const hasAsyncMode = options?.hasAsyncMode ?? true
|
||||
|
||||
const isUnsupportedFormat =
|
||||
err.name === 'UnsupportedDocumentException' ||
|
||||
Boolean(err.message?.toLowerCase().includes('unsupported document'))
|
||||
|
||||
if (isUnsupportedFormat && isPdf) {
|
||||
const hint = hasAsyncMode
|
||||
? ' If this is a multi-page PDF, please use "Multi-Page (PDF, TIFF via S3)" mode instead, which requires uploading your document to S3 first. Single Page mode only supports JPEG, PNG, and single-page PDF files.'
|
||||
: ' Only JPEG, PNG, and single-page PDF files are supported.'
|
||||
return new TextractRouteError(`This document format is not supported.${hint}`, 400)
|
||||
}
|
||||
|
||||
const status = err.$metadata?.httpStatusCode ?? 500
|
||||
return new TextractRouteError(err.message || 'Textract API error', status)
|
||||
}
|
||||
|
||||
export interface ResolvedDocument {
|
||||
bytes: Buffer
|
||||
contentType: string
|
||||
isPdf: boolean
|
||||
}
|
||||
|
||||
export type ResolveDocumentResult =
|
||||
| { ok: true; document: ResolvedDocument }
|
||||
| { ok: false; response: NextResponse }
|
||||
|
||||
/** Passes through the document host's real HTTP status on failure, so tool-execution retry logic can still treat a transient 5xx as retryable. */
|
||||
async function fetchDocumentBytes(url: string): Promise<{ bytes: Buffer; contentType: string }> {
|
||||
const urlValidation = await validateUrlWithDNS(url, 'Document URL')
|
||||
if (!urlValidation.isValid) {
|
||||
throw new TextractRouteError(urlValidation.error || 'Invalid document URL', 400)
|
||||
}
|
||||
|
||||
const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, {
|
||||
method: 'GET',
|
||||
})
|
||||
if (!response.ok) {
|
||||
await response.text().catch(() => {})
|
||||
throw new TextractRouteError(
|
||||
`Failed to fetch document: ${response.statusText}`,
|
||||
response.status
|
||||
)
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const contentType = response.headers.get('content-type') || 'application/octet-stream'
|
||||
|
||||
return { bytes: Buffer.from(arrayBuffer), contentType }
|
||||
}
|
||||
|
||||
/** Resolves a document input (uploaded file reference or URL) to raw bytes for the Textract Document.Bytes field. */
|
||||
export async function resolveDocumentInput(
|
||||
input: { file?: RawFileInput; filePath?: string },
|
||||
userId: string,
|
||||
requestId: string,
|
||||
logger: RouteLogger
|
||||
): Promise<ResolveDocumentResult> {
|
||||
if (input.file) {
|
||||
let userFile: ReturnType<typeof processSingleFileToUserFile>
|
||||
try {
|
||||
userFile = processSingleFileToUserFile(input.file, requestId, logger)
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{ success: false, error: getErrorMessage(error, 'Failed to process file') },
|
||||
{ status: 400 }
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const denied = await assertToolFileAccess(userFile.key, userId, requestId, logger)
|
||||
if (denied) return { ok: false, response: denied }
|
||||
|
||||
const { buffer, contentType } = await downloadServableFileFromStorage(
|
||||
userFile,
|
||||
requestId,
|
||||
logger
|
||||
)
|
||||
const resolvedContentType = contentType || userFile.type || 'application/octet-stream'
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
document: {
|
||||
bytes: buffer,
|
||||
contentType: resolvedContentType,
|
||||
isPdf:
|
||||
resolvedContentType.includes('pdf') ||
|
||||
Boolean(userFile.name?.toLowerCase().endsWith('.pdf')),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (input.filePath) {
|
||||
let fileUrl = input.filePath
|
||||
const isInternalFilePath = isInternalFileUrl(fileUrl)
|
||||
|
||||
if (isInternalFilePath) {
|
||||
const resolution = await resolveInternalFileUrl(fileUrl, userId, requestId, logger)
|
||||
if (resolution.error) {
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{ success: false, error: resolution.error.message },
|
||||
{ status: resolution.error.status }
|
||||
),
|
||||
}
|
||||
}
|
||||
fileUrl = resolution.fileUrl || fileUrl
|
||||
} else if (fileUrl.startsWith('/')) {
|
||||
logger.warn(`[${requestId}] Invalid internal path`, {
|
||||
userId,
|
||||
path: fileUrl.substring(0, 50),
|
||||
})
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid file path. Only uploaded files are supported for internal paths.',
|
||||
},
|
||||
{ status: 400 }
|
||||
),
|
||||
}
|
||||
} else {
|
||||
const urlValidation = await validateUrlWithDNS(fileUrl, 'Document URL')
|
||||
if (!urlValidation.isValid) {
|
||||
logger.warn(`[${requestId}] SSRF attempt blocked`, {
|
||||
userId,
|
||||
url: fileUrl.substring(0, 100),
|
||||
error: urlValidation.error,
|
||||
})
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{ success: false, error: urlValidation.error },
|
||||
{ status: 400 }
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetched = await fetchDocumentBytes(fileUrl)
|
||||
return {
|
||||
ok: true,
|
||||
document: {
|
||||
bytes: fetched.bytes,
|
||||
contentType: fetched.contentType,
|
||||
isPdf: fetched.contentType.includes('pdf') || fileUrl.toLowerCase().endsWith('.pdf'),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
response: NextResponse.json(
|
||||
{ success: false, error: 'Document input is required' },
|
||||
{ status: 400 }
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseS3Uri(s3Uri: string): { bucket: string; key: string } {
|
||||
const match = s3Uri.match(/^s3:\/\/([^/]+)\/(.+)$/)
|
||||
if (!match) {
|
||||
throw new TextractRouteError(
|
||||
`Invalid S3 URI format: ${s3Uri}. Expected format: s3://bucket-name/path/to/object`,
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
const bucket = match[1]
|
||||
const key = match[2]
|
||||
|
||||
const bucketValidation = validateS3BucketName(bucket, 'S3 bucket name')
|
||||
if (!bucketValidation.isValid) {
|
||||
throw new TextractRouteError(bucketValidation.error || 'Invalid S3 bucket name', 400)
|
||||
}
|
||||
|
||||
if (key.includes('..') || key.startsWith('/')) {
|
||||
throw new TextractRouteError('S3 key contains invalid path traversal sequences', 400)
|
||||
}
|
||||
|
||||
return { bucket, key }
|
||||
}
|
||||
|
||||
interface PollableJobResult {
|
||||
JobStatus?: string
|
||||
StatusMessage?: string
|
||||
NextToken?: string
|
||||
}
|
||||
|
||||
/** Polls a started async Textract job (StartDocumentAnalysis/StartDocumentTextDetection/StartExpenseAnalysis) until it completes, following NextToken pagination on success. */
|
||||
export async function pollTextractJob<TResult extends PollableJobResult>(
|
||||
requestId: string,
|
||||
logger: RouteLogger,
|
||||
getPage: (nextToken?: string) => Promise<TResult>,
|
||||
mergePage: (accumulated: TResult, page: TResult) => TResult
|
||||
): Promise<TResult> {
|
||||
const pollIntervalMs = 5000
|
||||
const maxPollTimeMs = getMaxExecutionTimeout()
|
||||
const maxAttempts = Math.ceil(maxPollTimeMs / pollIntervalMs)
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const result = await getPage()
|
||||
const jobStatus = result.JobStatus
|
||||
|
||||
if (jobStatus === 'SUCCEEDED' || jobStatus === 'PARTIAL_SUCCESS') {
|
||||
if (jobStatus === 'PARTIAL_SUCCESS') {
|
||||
logger.warn(`[${requestId}] Job completed with partial success: ${result.StatusMessage}`)
|
||||
} else {
|
||||
logger.info(`[${requestId}] Async job completed successfully after ${attempt + 1} polls`)
|
||||
}
|
||||
|
||||
let merged = result
|
||||
let nextToken = result.NextToken
|
||||
while (nextToken) {
|
||||
const page = await getPage(nextToken)
|
||||
merged = mergePage(merged, page)
|
||||
nextToken = page.NextToken
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
if (jobStatus === 'FAILED') {
|
||||
throw new TextractRouteError(
|
||||
`Textract job failed: ${result.StatusMessage || 'Unknown error'}`,
|
||||
502
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Job status: ${jobStatus}, attempt ${attempt + 1}/${maxAttempts}`)
|
||||
await sleep(pollIntervalMs)
|
||||
}
|
||||
|
||||
throw new TextractRouteError(
|
||||
`Timeout waiting for Textract job to complete (max ${maxPollTimeMs / 1000} seconds)`,
|
||||
504
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user