diff --git a/src/er/request-validation.ts b/src/er/request-validation.ts new file mode 100644 index 0000000..6bbe44c --- /dev/null +++ b/src/er/request-validation.ts @@ -0,0 +1,110 @@ +import { SiaxError } from "../platform/errors"; +import type { ErCandidateRequest, ErRecord } from "./entity-resolution-service"; + +function finiteThreshold(value: unknown, field: string, fallback: number): number { + if (value === undefined) return fallback; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) { + throw new SiaxError("INVALID_REQUEST", "0pt.er", `${field} must be a finite number between 0 and 1`); + } + return parsed; +} + +export function parseErCandidateRequest(raw: unknown): ErCandidateRequest { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new SiaxError("INVALID_REQUEST", "0pt.er", "JSON object body is required"); + } + const body = raw as Record; + const tenantId = typeof body.tenant_id === "string" ? body.tenant_id.trim() : ""; + const projectId = typeof body.project_id === "string" ? body.project_id.trim() : ""; + if (!tenantId) throw new SiaxError("INVALID_REQUEST", "0pt.er", "tenant_id is required"); + if (!projectId) throw new SiaxError("INVALID_REQUEST", "0pt.er", "project_id is required"); + + if (!Array.isArray(body.records) || body.records.length < 2 || body.records.length > 5_000) { + throw new SiaxError("INVALID_REQUEST", "0pt.er", "records must contain between 2 and 5000 items"); + } + if ( + !Array.isArray(body.blocking_fields) || + body.blocking_fields.length === 0 || + body.blocking_fields.length > 20 || + !body.blocking_fields.every((v) => typeof v === "string" && v.trim().length > 0) + ) { + throw new SiaxError("INVALID_REQUEST", "0pt.er", "blocking_fields must contain 1-20 non-empty strings"); + } + if (!Array.isArray(body.compare_fields) || body.compare_fields.length === 0 || body.compare_fields.length > 50) { + throw new SiaxError("INVALID_REQUEST", "0pt.er", "compare_fields must contain 1-50 items"); + } + + const records: ErRecord[] = body.records.map((value, index) => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new SiaxError("INVALID_REQUEST", "0pt.er", `records[${index}] must be an object`); + } + const record = value as Record; + if (typeof record.record_id !== "string" || !record.record_id.trim()) { + throw new SiaxError("INVALID_REQUEST", "0pt.er", `records[${index}].record_id is required`); + } + if (!record.fields || typeof record.fields !== "object" || Array.isArray(record.fields)) { + throw new SiaxError("INVALID_REQUEST", "0pt.er", `records[${index}].fields must be an object`); + } + const fields = record.fields as Record; + if (Object.keys(fields).length > 200) { + throw new SiaxError("INVALID_REQUEST", "0pt.er", `records[${index}].fields exceeds 200 fields`); + } + const normalizedFields: ErRecord["fields"] = {}; + for (const [field, fieldValue] of Object.entries(fields)) { + if (!field.trim()) throw new SiaxError("INVALID_REQUEST", "0pt.er", `records[${index}] contains an empty field name`); + if ( + fieldValue !== null && + fieldValue !== undefined && + typeof fieldValue !== "string" && + typeof fieldValue !== "number" + ) { + throw new SiaxError( + "INVALID_REQUEST", + "0pt.er", + `records[${index}].fields.${field} must be string, number, null or undefined`, + ); + } + normalizedFields[field.trim()] = fieldValue as string | number | null | undefined; + } + return { record_id: record.record_id.trim(), fields: normalizedFields }; + }); + + if (new Set(records.map((record) => record.record_id)).size !== records.length) { + throw new SiaxError("INVALID_REQUEST", "0pt.er", "record_id values must be unique within the request"); + } + + const compareFields = body.compare_fields.map((value, index) => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new SiaxError("INVALID_REQUEST", "0pt.er", `compare_fields[${index}] must be an object`); + } + const item = value as Record; + if (typeof item.field !== "string" || !item.field.trim()) { + throw new SiaxError("INVALID_REQUEST", "0pt.er", `compare_fields[${index}].field is required`); + } + const weight = item.weight === undefined ? undefined : Number(item.weight); + if (weight !== undefined && (!Number.isFinite(weight) || weight < 0 || weight > 100)) { + throw new SiaxError("INVALID_REQUEST", "0pt.er", `compare_fields[${index}].weight must be between 0 and 100`); + } + return { field: item.field.trim(), ...(weight !== undefined ? { weight } : {}) }; + }); + + const autoThreshold = finiteThreshold(body.auto_match_threshold, "auto_match_threshold", 0.9); + const reviewThreshold = finiteThreshold(body.review_threshold, "review_threshold", 0.6); + if (reviewThreshold >= autoThreshold) { + throw new SiaxError("INVALID_REQUEST", "0pt.er", "review_threshold must be lower than auto_match_threshold"); + } + + return { + tenant_id: tenantId, + project_id: projectId, + records, + blocking_fields: (body.blocking_fields as string[]).map((value) => value.trim()), + compare_fields: compareFields, + auto_match_threshold: autoThreshold, + review_threshold: reviewThreshold, + ...(typeof body.correlation_id === "string" && body.correlation_id.trim() + ? { correlation_id: body.correlation_id.trim() } + : {}), + }; +}