rapidoc
Version:
RapiDoc - Open API spec viewer with built in console
197 lines (184 loc) • 6.94 kB
JavaScript
/**
* Arazzo document parsing and structural validation.
*/
import { parse as parseYaml } from "yaml";
import { collectRefs } from "./refs.js";
import { resolveComponentRef } from "./components.js";
export { resolveComponentRef };
const ACTION_TYPES = new Set(["end", "goto", "retry"]);
const SOURCE_TYPES = new Set(["openapi", "arazzo", "asyncapi"]);
/**
* Parses Arazzo (or OpenAPI) text as JSON or YAML. An object is returned as is.
*
* @param {string|object} input
* @returns {object}
*/
export function parseDocument(input) {
if (input && typeof input === "object") return input;
const text = String(input ?? "").trim();
if (!text) throw new Error("The document is empty");
const doc = text.startsWith("{") ? JSON.parse(text) : parseYaml(text);
if (!doc || typeof doc !== "object") throw new Error("The document is not an object");
return doc;
}
function problem(level, path, message) {
return { level, path, message };
}
function checkActions(doc, list, kind, path, stepIds, workflowIds, problems) {
if (list === undefined) return;
if (!Array.isArray(list)) {
problems.push(problem("error", path, "must be a list of actions"));
return;
}
list.forEach((raw, i) => {
const p = `${path}[${i}]`;
const action = resolveComponentRef(doc, raw, kind);
if (!action) {
problems.push(problem("error", p, `unresolved reference ${raw?.reference}`));
return;
}
if (!ACTION_TYPES.has(action.type)) {
problems.push(problem("error", `${p}.type`, "must be end, goto or retry"));
return;
}
if (action.type === "retry" && kind === "successActions") {
problems.push(problem("error", `${p}.type`, "retry is only allowed on failure"));
}
if (action.stepId && action.workflowId) {
problems.push(problem("error", p, "has both stepId and workflowId"));
}
if (action.type === "goto" && !action.stepId && !action.workflowId) {
problems.push(problem("error", p, "goto needs a stepId or a workflowId"));
}
if (action.stepId && !stepIds.has(action.stepId)) {
problems.push(problem("error", `${p}.stepId`, `no step "${action.stepId}"`));
}
if (
action.workflowId &&
!action.workflowId.startsWith("$") &&
!workflowIds.has(action.workflowId)
) {
problems.push(problem("error", `${p}.workflowId`, `no workflow "${action.workflowId}"`));
}
});
}
/**
* Checks the structure the visualizer relies on. Returns problems; an empty list means valid.
* Expression syntax and JSON Schema of `inputs` are not checked.
*
* @param {object} doc
* @returns {{ level: 'error'|'warning', path: string, message: string }[]}
*/
export function validateArazzo(doc) {
const problems = [];
if (!doc || typeof doc !== "object") return [problem("error", "", "not an object")];
if (typeof doc.arazzo !== "string" || !/^1\.\d+(\.\d+)?/.test(doc.arazzo)) {
problems.push(problem("error", "arazzo", "must be an Arazzo 1.x version string"));
}
if (!doc.info?.title) problems.push(problem("error", "info.title", "is required"));
if (!doc.info?.version) problems.push(problem("error", "info.version", "is required"));
const sources = Array.isArray(doc.sourceDescriptions) ? doc.sourceDescriptions : [];
if (!sources.length) {
problems.push(problem("error", "sourceDescriptions", "needs at least one source"));
}
const sourceNames = new Set();
sources.forEach((s, i) => {
const p = `sourceDescriptions[${i}]`;
if (!s?.name) problems.push(problem("error", `${p}.name`, "is required"));
else if (sourceNames.has(s.name))
problems.push(problem("error", `${p}.name`, "is a duplicate"));
else sourceNames.add(s.name);
if (!s?.url) problems.push(problem("error", `${p}.url`, "is required"));
if (s?.type && !SOURCE_TYPES.has(s.type)) {
problems.push(problem("warning", `${p}.type`, `unknown type "${s.type}"`));
}
});
const workflows = Array.isArray(doc.workflows) ? doc.workflows : [];
if (!workflows.length)
problems.push(problem("error", "workflows", "needs at least one workflow"));
const workflowIds = new Set();
workflows.forEach((w, i) => {
if (!w?.workflowId)
problems.push(problem("error", `workflows[${i}].workflowId`, "is required"));
else if (workflowIds.has(w.workflowId)) {
problems.push(problem("error", `workflows[${i}].workflowId`, "is a duplicate"));
} else workflowIds.add(w.workflowId);
});
workflows.forEach((w, wi) => {
const wp = `workflows[${wi}]`;
const steps = Array.isArray(w?.steps) ? w.steps : [];
if (!steps.length) problems.push(problem("error", `${wp}.steps`, "needs at least one step"));
const stepIds = new Set();
steps.forEach((s, si) => {
const sp = `${wp}.steps[${si}]`;
if (!s?.stepId) problems.push(problem("error", `${sp}.stepId`, "is required"));
else if (stepIds.has(s.stepId))
problems.push(problem("error", `${sp}.stepId`, "is a duplicate"));
else stepIds.add(s.stepId);
const targets = ["operationId", "operationPath", "workflowId"].filter((k) => s?.[k]);
if (targets.length !== 1) {
problems.push(
problem("error", sp, "needs exactly one of operationId, operationPath or workflowId"),
);
}
if (s?.workflowId && !s.workflowId.startsWith("$") && !workflowIds.has(s.workflowId)) {
problems.push(problem("error", `${sp}.workflowId`, `no workflow "${s.workflowId}"`));
}
});
(w?.dependsOn || []).forEach((id, di) => {
if (!String(id).startsWith("$") && !workflowIds.has(id)) {
problems.push(problem("error", `${wp}.dependsOn[${di}]`, `no workflow "${id}"`));
}
});
checkActions(
doc,
w?.successActions,
"successActions",
`${wp}.successActions`,
stepIds,
workflowIds,
problems,
);
checkActions(
doc,
w?.failureActions,
"failureActions",
`${wp}.failureActions`,
stepIds,
workflowIds,
problems,
);
steps.forEach((s, si) => {
const sp = `${wp}.steps[${si}]`;
checkActions(
doc,
s?.onSuccess,
"successActions",
`${sp}.onSuccess`,
stepIds,
workflowIds,
problems,
);
checkActions(
doc,
s?.onFailure,
"failureActions",
`${sp}.onFailure`,
stepIds,
workflowIds,
problems,
);
for (const ref of collectRefs([s?.parameters, s?.requestBody])) {
if (ref.kind === "step" && !stepIds.has(ref.stepId)) {
problems.push(problem("error", sp, `refers to unknown step "${ref.stepId}"`));
}
}
});
for (const ref of collectRefs(w?.outputs)) {
if (ref.kind === "step" && !stepIds.has(ref.stepId)) {
problems.push(problem("error", `${wp}.outputs`, `refers to unknown step "${ref.stepId}"`));
}
}
});
return problems;
}