solver-api: standardize error contracts and harden parsing paths
Add structured 4xx responses and defensive parser/process behavior so clients receive consistent failures and malformed inputs are rejected deterministically. Made-with: Cursor
This commit is contained in:
@@ -13,6 +13,33 @@ const DEFAULT_XML = path.join(ROOT, "data/cases/base-case.xml");
|
||||
const SOLVER_BINARY_FDM = path.join(ROOT, "solver-c/solver_main");
|
||||
const SOLVER_BINARY_FEA = path.join(ROOT, "solver-c/solver_fea_main");
|
||||
|
||||
function parseCorsOrigins(raw) {
|
||||
if (!raw || typeof raw !== "string") return null;
|
||||
const origins = raw
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
return origins.length > 0 ? origins : null;
|
||||
}
|
||||
|
||||
function buildCorsOptions() {
|
||||
const allow = parseCorsOrigins(process.env.CORS_ORIGINS);
|
||||
if (!allow) return {};
|
||||
return {
|
||||
origin(origin, cb) {
|
||||
if (!origin || allow.includes(origin)) {
|
||||
cb(null, true);
|
||||
return;
|
||||
}
|
||||
cb(new Error("origin not allowed"));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function clientError(res, status, error, code = "bad_request") {
|
||||
return res.status(status).json({ error, code, schemaVersion: 2 });
|
||||
}
|
||||
|
||||
function resolveSolverModel(raw) {
|
||||
const model = (raw || "fdm").toLowerCase();
|
||||
if (model !== "fdm" && model !== "fea" && model !== "both") {
|
||||
@@ -196,7 +223,7 @@ function stableStringify(value) {
|
||||
|
||||
export function buildApp() {
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(cors(buildCorsOptions()));
|
||||
app.use(express.json({ limit: "4mb" }));
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
@@ -209,7 +236,7 @@ export function buildApp() {
|
||||
const parsed = await parseCaseXml(xml);
|
||||
res.json({ ...parsed, fieldTraceability: buildFieldTraceability(parsed) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: String(error.message || error) });
|
||||
clientError(res, 400, String(error.message || error), "parse_failed");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -217,9 +244,7 @@ export function buildApp() {
|
||||
try {
|
||||
const xml = req.body?.xml;
|
||||
if (typeof xml !== "string" || !xml.trim()) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Request body must include xml string", schemaVersion: 2 });
|
||||
return clientError(res, 400, "Request body must include xml string", "missing_xml");
|
||||
}
|
||||
const parsed = await parseCaseXml(xml);
|
||||
return res.json({
|
||||
@@ -228,9 +253,7 @@ export function buildApp() {
|
||||
fieldTraceability: buildFieldTraceability(parsed)
|
||||
});
|
||||
} catch (error) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: String(error.message || error), schemaVersion: 2 });
|
||||
return clientError(res, 400, String(error.message || error), "parse_failed");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -240,7 +263,7 @@ export function buildApp() {
|
||||
const qa = validateSurfaceCard(surfaceCard, req.body?.options || {});
|
||||
return res.json({ ok: qa.ok, qa, schemaVersion: 2 });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: String(error.message || error), schemaVersion: 2 });
|
||||
return clientError(res, 400, String(error.message || error), "invalid_surface_card");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -248,13 +271,16 @@ export function buildApp() {
|
||||
try {
|
||||
const xml = req.body?.xml;
|
||||
if (!xml || typeof xml !== "string") {
|
||||
return res.status(400).json({ error: "Request body must include xml string" });
|
||||
return clientError(res, 400, "Request body must include xml string", "missing_xml");
|
||||
}
|
||||
const solverModel = resolveSolverModel(req.body?.solverModel);
|
||||
const workflow = resolveWorkflow(req.body?.workflow);
|
||||
const parsed = await parseCaseXml(xml);
|
||||
let surfaceCardQa = null;
|
||||
if (workflow === "diagnostic") {
|
||||
if (!req.body?.surfaceCard) {
|
||||
return clientError(res, 400, "diagnostic workflow requires surfaceCard data", "missing_surface_card");
|
||||
}
|
||||
surfaceCardQa = validateSurfaceCard(req.body?.surfaceCard);
|
||||
}
|
||||
const runResults = await runRequestedModelsWithWorkflow(
|
||||
@@ -304,7 +330,7 @@ export function buildApp() {
|
||||
fingerprint
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: String(error.message || error), schemaVersion: 2 });
|
||||
return clientError(res, 400, String(error.message || error), "solve_failed");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -313,10 +339,12 @@ export function buildApp() {
|
||||
const solverModel = resolveSolverModel(_req.query?.solverModel);
|
||||
const workflow = resolveWorkflow(_req.query?.workflow);
|
||||
if (workflow === "diagnostic") {
|
||||
return res.status(400).json({
|
||||
error: "diagnostic workflow requires POST /solve with surfaceCard data",
|
||||
schemaVersion: 2
|
||||
});
|
||||
return clientError(
|
||||
res,
|
||||
400,
|
||||
"diagnostic workflow requires POST /solve with surfaceCard data",
|
||||
"workflow_not_supported"
|
||||
);
|
||||
}
|
||||
const xml = fs.readFileSync(DEFAULT_XML, "utf-8");
|
||||
const parsed = await parseCaseXml(xml);
|
||||
@@ -364,7 +392,7 @@ export function buildApp() {
|
||||
fingerprint
|
||||
});
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: String(error.message || error), schemaVersion: 2 });
|
||||
return clientError(res, 400, String(error.message || error), "solve_failed");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -245,6 +245,19 @@ function runSolverProcess(solverBinaryPath, jsonPayload) {
|
||||
let stderr = "";
|
||||
let outBytes = 0;
|
||||
let errBytes = 0;
|
||||
let settled = false;
|
||||
|
||||
const failOnce = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
};
|
||||
|
||||
const resolveOnce = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
@@ -253,7 +266,7 @@ function runSolverProcess(solverBinaryPath, jsonPayload) {
|
||||
outBytes += Buffer.byteLength(chunk, "utf8");
|
||||
if (outBytes > SOLVER_STDIO_MAX_BYTES) {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("solver stdout exceeded max buffer"));
|
||||
failOnce(new Error("solver stdout exceeded max buffer"));
|
||||
return;
|
||||
}
|
||||
stdout += chunk;
|
||||
@@ -263,20 +276,21 @@ function runSolverProcess(solverBinaryPath, jsonPayload) {
|
||||
errBytes += Buffer.byteLength(chunk, "utf8");
|
||||
if (errBytes > SOLVER_STDIO_MAX_BYTES) {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("solver stderr exceeded max buffer"));
|
||||
failOnce(new Error("solver stderr exceeded max buffer"));
|
||||
return;
|
||||
}
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
child.on("error", (error) => reject(error));
|
||||
child.on("error", (error) => failOnce(error));
|
||||
child.on("close", (code) => {
|
||||
if (settled) return;
|
||||
if (code !== 0) {
|
||||
const detail = stderr.trim() || `solver exited with code ${code}`;
|
||||
reject(new Error(detail));
|
||||
failOnce(new Error(detail));
|
||||
return;
|
||||
}
|
||||
resolve(stdout);
|
||||
resolveOnce(stdout);
|
||||
});
|
||||
|
||||
child.stdin.write(jsonPayload);
|
||||
|
||||
@@ -8,10 +8,16 @@ const PSI_TO_PA = 6894.757293168;
|
||||
const MPSI_TO_PA = 6.894757293168e9;
|
||||
|
||||
function parseArrayValue(raw) {
|
||||
return String(raw)
|
||||
.split(":")
|
||||
.filter(Boolean)
|
||||
.map((item) => Number(item));
|
||||
const out = [];
|
||||
for (const item of String(raw).split(":")) {
|
||||
if (!item) continue;
|
||||
const value = Number(item);
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new Error(`Invalid numeric array value: ${item}`);
|
||||
}
|
||||
out.push(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseNumeric(raw) {
|
||||
|
||||
@@ -78,6 +78,23 @@ describe("solver-api", () => {
|
||||
expect(Array.isArray(response.body.parsed.unsupportedFields)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects /solve without xml and returns schema metadata", async () => {
|
||||
const app = buildApp();
|
||||
const response = await request(app).post("/solve").send({});
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.schemaVersion).toBe(2);
|
||||
expect(response.body.code).toBe("missing_xml");
|
||||
});
|
||||
|
||||
it("rejects diagnostic workflow without surface card", async () => {
|
||||
const app = buildApp();
|
||||
const response = await request(app)
|
||||
.post("/solve")
|
||||
.send({ xml, solverModel: "fdm", workflow: "diagnostic" });
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.code).toBe("missing_surface_card");
|
||||
});
|
||||
|
||||
it("is deterministic for the same input", async () => {
|
||||
const app = buildApp();
|
||||
const a = await request(app).post("/solve").send({ xml });
|
||||
|
||||
Reference in New Issue
Block a user