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_FDM = path.join(ROOT, "solver-c/solver_main");
|
||||||
const SOLVER_BINARY_FEA = path.join(ROOT, "solver-c/solver_fea_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) {
|
function resolveSolverModel(raw) {
|
||||||
const model = (raw || "fdm").toLowerCase();
|
const model = (raw || "fdm").toLowerCase();
|
||||||
if (model !== "fdm" && model !== "fea" && model !== "both") {
|
if (model !== "fdm" && model !== "fea" && model !== "both") {
|
||||||
@@ -196,7 +223,7 @@ function stableStringify(value) {
|
|||||||
|
|
||||||
export function buildApp() {
|
export function buildApp() {
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors(buildCorsOptions()));
|
||||||
app.use(express.json({ limit: "4mb" }));
|
app.use(express.json({ limit: "4mb" }));
|
||||||
|
|
||||||
app.get("/health", (_req, res) => {
|
app.get("/health", (_req, res) => {
|
||||||
@@ -209,7 +236,7 @@ export function buildApp() {
|
|||||||
const parsed = await parseCaseXml(xml);
|
const parsed = await parseCaseXml(xml);
|
||||||
res.json({ ...parsed, fieldTraceability: buildFieldTraceability(parsed) });
|
res.json({ ...parsed, fieldTraceability: buildFieldTraceability(parsed) });
|
||||||
} catch (error) {
|
} 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 {
|
try {
|
||||||
const xml = req.body?.xml;
|
const xml = req.body?.xml;
|
||||||
if (typeof xml !== "string" || !xml.trim()) {
|
if (typeof xml !== "string" || !xml.trim()) {
|
||||||
return res
|
return clientError(res, 400, "Request body must include xml string", "missing_xml");
|
||||||
.status(400)
|
|
||||||
.json({ error: "Request body must include xml string", schemaVersion: 2 });
|
|
||||||
}
|
}
|
||||||
const parsed = await parseCaseXml(xml);
|
const parsed = await parseCaseXml(xml);
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -228,9 +253,7 @@ export function buildApp() {
|
|||||||
fieldTraceability: buildFieldTraceability(parsed)
|
fieldTraceability: buildFieldTraceability(parsed)
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res
|
return clientError(res, 400, String(error.message || error), "parse_failed");
|
||||||
.status(400)
|
|
||||||
.json({ error: String(error.message || error), schemaVersion: 2 });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -240,7 +263,7 @@ export function buildApp() {
|
|||||||
const qa = validateSurfaceCard(surfaceCard, req.body?.options || {});
|
const qa = validateSurfaceCard(surfaceCard, req.body?.options || {});
|
||||||
return res.json({ ok: qa.ok, qa, schemaVersion: 2 });
|
return res.json({ ok: qa.ok, qa, schemaVersion: 2 });
|
||||||
} catch (error) {
|
} 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 {
|
try {
|
||||||
const xml = req.body?.xml;
|
const xml = req.body?.xml;
|
||||||
if (!xml || typeof xml !== "string") {
|
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 solverModel = resolveSolverModel(req.body?.solverModel);
|
||||||
const workflow = resolveWorkflow(req.body?.workflow);
|
const workflow = resolveWorkflow(req.body?.workflow);
|
||||||
const parsed = await parseCaseXml(xml);
|
const parsed = await parseCaseXml(xml);
|
||||||
let surfaceCardQa = null;
|
let surfaceCardQa = null;
|
||||||
if (workflow === "diagnostic") {
|
if (workflow === "diagnostic") {
|
||||||
|
if (!req.body?.surfaceCard) {
|
||||||
|
return clientError(res, 400, "diagnostic workflow requires surfaceCard data", "missing_surface_card");
|
||||||
|
}
|
||||||
surfaceCardQa = validateSurfaceCard(req.body?.surfaceCard);
|
surfaceCardQa = validateSurfaceCard(req.body?.surfaceCard);
|
||||||
}
|
}
|
||||||
const runResults = await runRequestedModelsWithWorkflow(
|
const runResults = await runRequestedModelsWithWorkflow(
|
||||||
@@ -304,7 +330,7 @@ export function buildApp() {
|
|||||||
fingerprint
|
fingerprint
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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 solverModel = resolveSolverModel(_req.query?.solverModel);
|
||||||
const workflow = resolveWorkflow(_req.query?.workflow);
|
const workflow = resolveWorkflow(_req.query?.workflow);
|
||||||
if (workflow === "diagnostic") {
|
if (workflow === "diagnostic") {
|
||||||
return res.status(400).json({
|
return clientError(
|
||||||
error: "diagnostic workflow requires POST /solve with surfaceCard data",
|
res,
|
||||||
schemaVersion: 2
|
400,
|
||||||
});
|
"diagnostic workflow requires POST /solve with surfaceCard data",
|
||||||
|
"workflow_not_supported"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const xml = fs.readFileSync(DEFAULT_XML, "utf-8");
|
const xml = fs.readFileSync(DEFAULT_XML, "utf-8");
|
||||||
const parsed = await parseCaseXml(xml);
|
const parsed = await parseCaseXml(xml);
|
||||||
@@ -364,7 +392,7 @@ export function buildApp() {
|
|||||||
fingerprint
|
fingerprint
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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 stderr = "";
|
||||||
let outBytes = 0;
|
let outBytes = 0;
|
||||||
let errBytes = 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.stdout.setEncoding("utf8");
|
||||||
child.stderr.setEncoding("utf8");
|
child.stderr.setEncoding("utf8");
|
||||||
@@ -253,7 +266,7 @@ function runSolverProcess(solverBinaryPath, jsonPayload) {
|
|||||||
outBytes += Buffer.byteLength(chunk, "utf8");
|
outBytes += Buffer.byteLength(chunk, "utf8");
|
||||||
if (outBytes > SOLVER_STDIO_MAX_BYTES) {
|
if (outBytes > SOLVER_STDIO_MAX_BYTES) {
|
||||||
child.kill("SIGKILL");
|
child.kill("SIGKILL");
|
||||||
reject(new Error("solver stdout exceeded max buffer"));
|
failOnce(new Error("solver stdout exceeded max buffer"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stdout += chunk;
|
stdout += chunk;
|
||||||
@@ -263,20 +276,21 @@ function runSolverProcess(solverBinaryPath, jsonPayload) {
|
|||||||
errBytes += Buffer.byteLength(chunk, "utf8");
|
errBytes += Buffer.byteLength(chunk, "utf8");
|
||||||
if (errBytes > SOLVER_STDIO_MAX_BYTES) {
|
if (errBytes > SOLVER_STDIO_MAX_BYTES) {
|
||||||
child.kill("SIGKILL");
|
child.kill("SIGKILL");
|
||||||
reject(new Error("solver stderr exceeded max buffer"));
|
failOnce(new Error("solver stderr exceeded max buffer"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stderr += chunk;
|
stderr += chunk;
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on("error", (error) => reject(error));
|
child.on("error", (error) => failOnce(error));
|
||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
|
if (settled) return;
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
const detail = stderr.trim() || `solver exited with code ${code}`;
|
const detail = stderr.trim() || `solver exited with code ${code}`;
|
||||||
reject(new Error(detail));
|
failOnce(new Error(detail));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolve(stdout);
|
resolveOnce(stdout);
|
||||||
});
|
});
|
||||||
|
|
||||||
child.stdin.write(jsonPayload);
|
child.stdin.write(jsonPayload);
|
||||||
|
|||||||
@@ -8,10 +8,16 @@ const PSI_TO_PA = 6894.757293168;
|
|||||||
const MPSI_TO_PA = 6.894757293168e9;
|
const MPSI_TO_PA = 6.894757293168e9;
|
||||||
|
|
||||||
function parseArrayValue(raw) {
|
function parseArrayValue(raw) {
|
||||||
return String(raw)
|
const out = [];
|
||||||
.split(":")
|
for (const item of String(raw).split(":")) {
|
||||||
.filter(Boolean)
|
if (!item) continue;
|
||||||
.map((item) => Number(item));
|
const value = Number(item);
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
throw new Error(`Invalid numeric array value: ${item}`);
|
||||||
|
}
|
||||||
|
out.push(value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseNumeric(raw) {
|
function parseNumeric(raw) {
|
||||||
|
|||||||
@@ -78,6 +78,23 @@ describe("solver-api", () => {
|
|||||||
expect(Array.isArray(response.body.parsed.unsupportedFields)).toBe(true);
|
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 () => {
|
it("is deterministic for the same input", async () => {
|
||||||
const app = buildApp();
|
const app = buildApp();
|
||||||
const a = await request(app).post("/solve").send({ xml });
|
const a = await request(app).post("/solve").send({ xml });
|
||||||
|
|||||||
Reference in New Issue
Block a user