UNPKG

rapidoc

Version:

RapiDoc - Open API spec viewer with built in console

270 lines (252 loc) • 10.9 kB
/** * Turns one Arazzo workflow into the graph the diagram draws: steps in order, the values passed * between them, and each step's success and failure actions. */ import { collectRefs } from "./refs.js"; import { resolveComponentRef } from "./parse.js"; import { resolveOperation } from "./operations.js"; function normalizeCriteria(list) { return (Array.isArray(list) ? list : []) .filter((c) => c && typeof c.condition === "string") .map((c) => ({ condition: c.condition, context: c.context || "", type: typeof c.type === "string" ? c.type : c.type?.type || "simple", })); } function normalizeActions(doc, list, kind, outcome, inherited) { return (Array.isArray(list) ? list : []) .map((raw) => resolveComponentRef(doc, raw, kind)) .filter((a) => a && ["end", "goto", "retry"].includes(a.type)) .map((a) => { const criteria = normalizeCriteria(a.criteria); return { outcome, type: a.type, name: a.name || "", stepId: a.stepId || "", workflowId: a.workflowId || "", retryAfter: a.retryAfter, retryLimit: a.retryLimit, criteria, conditional: criteria.length > 0, inherited, }; }); } /** * A step's own actions, then the workflow's, minus those the step overrides by name. A runner * takes the first action whose criteria match, so every action after one without criteria is * marked `shadowed`: it can never run. */ function mergeActions(own, inherited) { const names = new Set(own.map((a) => a.name).filter(Boolean)); const merged = [...own, ...inherited.filter((a) => !a.name || !names.has(a.name))]; const first = merged.findIndex((a) => !a.conditional); return merged.map((a, i) => ({ ...a, shadowed: first >= 0 && i > first })); } /** The workflow's `inputs` JSON Schema (or a `$ref` to `#/components/inputs/<name>`) as rows. */ function describeInputs(doc, schema) { const ref = typeof schema?.$ref === "string" && schema.$ref.match(/^#\/components\/inputs\/(.+)$/); const resolved = ref ? doc?.components?.inputs?.[ref[1]] : schema; const required = new Set(Array.isArray(resolved?.required) ? resolved.required : []); return Object.entries(resolved?.properties || {}).map(([name, prop]) => ({ name, type: prop?.type ? [].concat(prop.type).join(" | ") : prop?.enum ? "enum" : "", format: prop?.format || "", required: required.has(name), default: prop?.default, enum: Array.isArray(prop?.enum) ? prop.enum : undefined, description: prop?.description || "", })); } function describeParameters(doc, list) { return (Array.isArray(list) ? list : []) .map((raw) => resolveComponentRef(doc, raw, "parameters")) .filter((p) => p && p.name) .map((p) => ({ name: p.name, in: p.in || "", value: p.value })); } /** * Where a value comes from, for one line in a step box: a workflow input, a step output, another * runtime expression, or a fixed value. */ function describeSource(value) { if (typeof value === "string") { const text = value.trim(); let m = text.match(/^\$inputs\.([A-Za-z0-9_-]+)$/); if (m) return { kind: "input", name: m[1], text: m[1] }; m = text.match(/^\$steps\.([A-Za-z0-9_-]+)\.outputs\.([A-Za-z0-9_-]+)$/); if (m) return { kind: "step", stepId: m[1], name: m[2], text: `${m[1]}.${m[2]}` }; if (text.includes("$")) return { kind: "expression", text }; return { kind: "value", text }; } return { kind: "value", text: value === undefined ? "" : JSON.stringify(value) }; } function flattenPayload(obj, prefix, rows) { for (const [key, value] of Object.entries(obj)) { const name = `${prefix}.${key}`; if (value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length) { flattenPayload(value, name, rows); } else rows.push({ name, in: "body", value }); } } /** A step's parameters and request body fields, one row per value it sends. */ function describeBindings(parameters, requestBody) { const rows = parameters.map((p) => ({ name: p.name, in: p.in, value: p.value })); const payload = requestBody?.payload; if (payload && typeof payload === "object" && !Array.isArray(payload)) { flattenPayload(payload, "body", rows); } else if (payload !== undefined && payload !== null) { rows.push({ name: "body", in: "body", value: payload }); } for (const r of requestBody?.replacements || []) { if (r?.target) rows.push({ name: `body ${r.target}`, in: "body", value: r.value }); } return rows.map((r) => { const stepRefs = collectRefs(r.value).filter((x) => x.kind === "step"); return { ...r, source: describeSource(r.value), fromSteps: [...new Set(stepRefs.map((x) => x.stepId))], fromOutputs: stepRefs.filter((x) => x.name).map((x) => ({ stepId: x.stepId, name: x.name })), }; }); } const listOf = (names) => names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names.at(-1)}` : names[0]; /** * One warning per step and outcome whose actions include some that can never run, because an * earlier action without criteria always matches first. */ function shadowWarnings(steps, workflowIndex) { const warnings = []; steps.forEach((step, si) => { for (const outcome of ["success", "failure"]) { const actions = step.actions.filter((a) => a.outcome === outcome); const never = actions.filter((a) => a.shadowed); if (!never.length) continue; const catchAll = actions.find((a) => !a.conditional); const names = never.map((a) => a.name || `an unnamed ${a.type}`); const whose = never.every((a) => a.inherited) ? "the workflow's " : ""; const verb = never.length > 1 ? "never run" : "never runs"; warnings.push({ level: "warning", path: `workflows[${workflowIndex}].steps[${si}].${outcome === "failure" ? "onFailure" : "onSuccess"}`, message: `${step.stepId}: ${whose}${listOf(names)} ${verb}, because ${catchAll?.name || "an action"} has no criteria and always matches first`, }); } }); return warnings; } function pushEdge(edges, from, to, label) { const edge = edges.find((e) => e.from === from && e.to === to); if (!edge) edges.push({ from, to, labels: [label] }); else if (!edge.labels.includes(label)) edge.labels.push(label); } /** * @param {object} doc Parsed Arazzo document * @param {string} [workflowId] Defaults to the first workflow * @param {object} [options] * @param {Map<string, {byId: Map, byPath: Map}>} [options.sources] operation indexes by source name * Each step's `bindings` list the values it sends (`{ name, in, value, source, fromSteps }`, with * body fields named `body.<path>`; `fromOutputs` the `{ stepId, name }` step outputs they read); * `usedOutputs` are the step's own outputs that a later step or the workflow reads; `source` is `{ kind: 'input'|'step'|'expression'|'value', text }`. * * @returns {object|null} `{ workflowId, summary, description, inputs, inputDetails, workflowActions, * operationsKnown, outputs, steps, dataEdges, warnings }`, * or null when there is no such workflow. Data edges go from a step id to a later step id or * to "outputs"; `labels` are the output names passed. */ export function buildFlowGraph(doc, workflowId, options = {}) { const workflows = Array.isArray(doc?.workflows) ? doc.workflows : []; const workflow = workflowId ? workflows.find((w) => w?.workflowId === workflowId) : workflows[0]; if (!workflow) return null; const rawSteps = Array.isArray(workflow.steps) ? workflow.steps : []; const stepIds = new Set(rawSteps.map((s) => s?.stepId).filter(Boolean)); const workflowSuccess = normalizeActions( doc, workflow.successActions, "successActions", "success", true, ); const workflowFailure = normalizeActions( doc, workflow.failureActions, "failureActions", "failure", true, ); const dataEdges = []; const steps = rawSteps.map((s) => { const onSuccess = normalizeActions(doc, s.onSuccess, "successActions", "success", false); const onFailure = normalizeActions(doc, s.onFailure, "failureActions", "failure", false); const operation = s.workflowId ? null : resolveOperation(s, options.sources); const refs = collectRefs([s.parameters, s.requestBody]); for (const ref of refs) { if (ref.kind === "step" && ref.name && ref.stepId !== s.stepId && stepIds.has(ref.stepId)) { pushEdge(dataEdges, ref.stepId, s.stepId, ref.name); } } const criteria = normalizeCriteria(s.successCriteria); const parameters = describeParameters(doc, s.parameters); return { stepId: s.stepId || "", description: s.description || "", kind: s.workflowId ? "workflow" : "operation", workflowId: s.workflowId || "", operationRef: s.operationId || s.operationPath || "", operation, inputs: refs.filter((r) => r.kind === "input").map((r) => r.name), outputs: Object.keys(s.outputs || {}), outputExprs: Object.entries(s.outputs || {}).map(([name, expr]) => ({ name, expr })), parameters, requestBody: s.requestBody || null, bindings: describeBindings(parameters, s.requestBody), criteria, successCriteria: criteria.length, actions: [ ...mergeActions(onSuccess, workflowSuccess), ...mergeActions(onFailure, workflowFailure), ], }; }); const inputDetails = describeInputs(doc, workflow.inputs); const outputs = Object.entries(workflow.outputs || {}).map(([name, expr]) => { const ref = collectRefs(expr).find((r) => r.kind === "step"); if (ref && stepIds.has(ref.stepId)) pushEdge(dataEdges, ref.stepId, "outputs", name); return { name, stepId: ref?.stepId || "", output: ref?.name || "", source: describeSource(expr), expr: typeof expr === "string" ? expr : JSON.stringify(expr), }; }); // Each step output something else reads: later steps' values and the workflow's outputs const used = new Map(); const markUsed = (stepId, name) => { if (stepId && name) used.set(stepId, (used.get(stepId) || new Set()).add(name)); }; for (const s of steps) for (const b of s.bindings) for (const r of b.fromOutputs) markUsed(r.stepId, r.name); for (const o of outputs) markUsed(o.stepId, o.output); for (const s of steps) { const names = used.get(s.stepId) || new Set(); s.usedOutputs = s.outputExprs.filter((o) => names.has(o.name)); } return { workflowId: workflow.workflowId || "", summary: workflow.summary || "", description: workflow.description || "", inputs: inputDetails.map((i) => i.name), inputDetails, workflowActions: [...workflowSuccess, ...workflowFailure], operationsKnown: Boolean(options.sources?.size), outputs, steps, dataEdges, warnings: shadowWarnings(steps, workflows.indexOf(workflow)), }; }