Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/parse-sdk-validation-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@serverlessworkflow/diagram-editor": minor
---

Parse SDK validation errors into an array and update it in the store.
208 changes: 204 additions & 4 deletions packages/serverless-workflow-diagram-editor/src/core/workflowSdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,218 @@ import yaml from "js-yaml";
import * as sdk from "@serverlessworkflow/sdk";
import { fixNodesConnections } from "./graph";

/**
* Sanitizes an object by removing dangerous prototype pollution keys
* and creating a new object with null prototype to prevent pollution attacks.
*
* @param obj - The object to sanitize
* @returns A sanitized object with null prototype
*/
function sanitizeObject(obj: Record<string, unknown>): Record<string, unknown> {
const dangerousKeys = ["__proto__", "constructor", "prototype"];
const sanitized = Object.create(null) as Record<string, unknown>;

for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key) && !dangerousKeys.includes(key)) {
sanitized[key] = obj[key];
}
}

return sanitized;
}

export type ValidationError = {
taskId?: string;
errorType?: string;
message: string;
object?: Record<string, unknown>;
};
Comment thread
handreyrc marked this conversation as resolved.

export type SdkError = Error | ValidationError;

export type WorkflowParseResult = {
model: sdk.Specification.Workflow | null;
errors: Error[];
errors: SdkError[];
};
Comment thread
handreyrc marked this conversation as resolved.

export function validateWorkflow(model: sdk.Specification.Workflow): Error[] {
/**
* Parses validation error messages from the Serverless Workflow SDK into structured error objects.
*
* The SDK produces validation errors in two distinct formats:
*
* **Format 1: Pipe-delimited with 4 fields (task-specific errors)**
* ```
* - taskId | errorType | message | object
* ```
* Example:
* ```
* - /do/0/task | #/required | must have property | {"missingProperty": "name"}
* ```
*
* **Format 2: Dash-separated with 2 fields (general errors)**
* ```
* errorType - message
* ```
* Example:
* ```
* #/required - must have required property 'document'
* ```
*
* @param message - The raw error message string from the SDK, typically containing multiple lines
* @returns Array of structured ValidationError objects. Each error is guaranteed to have:
* - `message`: The error description (always present)
* - `taskId`: The workflow task path (present only in Format 1)
* - `errorType`: The error type/schema reference (present in both formats)
* - `object`: Additional error context as a sanitized object with null prototype (present only in Format 1;
* empty object if JSON parsing fails or if the JSON is not a plain object)
*
* @remarks
* - Lines that don't match either format are silently ignored
* - Format 1 handles pipes within the message field by attempting to parse JSON from right to left
* - The `object` field is sanitized to prevent prototype pollution attacks by removing dangerous keys
* (`__proto__`, `constructor`, `prototype`) and creating an object with null prototype
* - Only plain objects are accepted in the JSON field; arrays, primitives, and null result in an empty object
*
* @example
* ```typescript
* const sdkError = `'Workflow' is invalid:
* - /do/0/call | #/required | must have property | {"missingProperty": "http"}
* #/document - must have required property 'document'`;
*
* const errors = parseValidationErrorMessage(sdkError);
* // [
* // { taskId: "/do/0/call", errorType: "#/required", message: "must have property",
* // object: { missingProperty: "http" } },
* // { errorType: "#/document", message: "must have required property 'document'" }
* // ]
* ```
*/
export function parseValidationErrorMessage(message: string): ValidationError[] {
const errors: ValidationError[] = [];

// Split message into lines
const lines = message.split("\n");
Comment thread
handreyrc marked this conversation as resolved.

for (const line of lines) {
const trimmedLine = line.trim();

// Format 1: Lines that begin with "-" and contain pipes (4-field format)
Comment thread
handreyrc marked this conversation as resolved.
if (trimmedLine.startsWith("-")) {
// Remove the leading "-" and trim
const content = trimmedLine.substring(1).trim();

// Find all pipe positions
const pipePositions: number[] = [];
let pos = -1;
while ((pos = content.indexOf("|", pos + 1)) !== -1) {
pipePositions.push(pos);
}

// We need at least 3 pipes to have 4 fields
if (pipePositions.length < 3) {
continue;
}

// Extract first two fields using first two pipes
const firstPipe = pipePositions[0]!;
const secondPipe = pipePositions[1]!;

const taskId = content.substring(0, firstPipe).trim();
const errorType = content.substring(firstPipe + 1, secondPipe).trim();

// Try to find the last pipe that separates valid JSON
// Work backwards from the last pipe to find where valid JSON starts
let errorMessage = "";
let objectStr = "";
let parsedObject: Record<string, unknown> = {};
let foundValidSplit = false;

// Try each remaining pipe position as the separator before the JSON field
for (let i = pipePositions.length - 1; i >= 2; i--) {
const candidatePipe = pipePositions[i]!;
const candidateMessage = content.substring(secondPipe + 1, candidatePipe).trim();
const candidateObjectStr = content.substring(candidatePipe + 1).trim();

if (!candidateMessage || !candidateObjectStr) {
continue;
}

// Try to parse the JSON
try {
const parsed = JSON.parse(candidateObjectStr);
// Validate that parsed result is a non-null plain object
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
// Found valid split
errorMessage = candidateMessage;
objectStr = candidateObjectStr;
// Sanitize the parsed object to prevent prototype pollution
parsedObject = sanitizeObject(parsed);
foundValidSplit = true;
break;
}
} catch {
// Not valid JSON, try next pipe position
continue;
}
Comment thread
handreyrc marked this conversation as resolved.
}

// If no valid JSON found, fall back to using the 3rd pipe and empty object
if (!foundValidSplit) {
const thirdPipe = pipePositions[2]!;
errorMessage = content.substring(secondPipe + 1, thirdPipe).trim();
objectStr = content.substring(thirdPipe + 1).trim();
parsedObject = {};
}

// Validate all required fields are non-empty
if (!taskId || !errorType || !errorMessage || !objectStr) {
continue;
}

errors.push({
taskId,
errorType,
message: errorMessage,
object: parsedObject,
});
}
// Format 2: Lines containing " - " separator (errorType - message format)
Comment thread
handreyrc marked this conversation as resolved.
else if (trimmedLine.includes(" - ")) {
const dashIndex = trimmedLine.indexOf(" - ");
const errorType = trimmedLine.substring(0, dashIndex).trim();
const errorMessage = trimmedLine.substring(dashIndex + 3).trim();

// Only add if both parts are non-empty
if (errorType && errorMessage) {
errors.push({
errorType,
message: errorMessage,
});
}
}
}
Comment thread
handreyrc marked this conversation as resolved.

return errors;
}

export function validateWorkflow(model: sdk.Specification.Workflow): SdkError[] {
Comment thread
handreyrc marked this conversation as resolved.
try {
sdk.validate("Workflow", model);
return [];
} catch (err) {
// TODO: Parse individual validation errors from the SDK into separate Error objects when we are ready to render them.
return [err instanceof Error ? err : new Error(String(err))];
const message = err instanceof Error ? err.message : String(err);
const parsedErrors = parseValidationErrorMessage(message);

// If parsing succeeded and returned errors, use them
if (parsedErrors.length > 0) {
return parsedErrors;
}

// Otherwise, return the original error as-is
if (err instanceof Error) {
return [err];
}
return [new Error(message)];
Comment thread
handreyrc marked this conversation as resolved.
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ const isYAMLException = (err: Error): err is YAMLExceptionLike => err.name === "
export const ParsingErrorPage = () => {
const { errors } = useDiagramEditorContext();
const { t } = useI18n();
// YAML parsing errors the only errors we expect for now so we will just take the first/only error
// Errors can be YAML parsing errors (Error instances) or validation errors (structured objects).
// We only handle YAML parsing errors in this component, so we take the first error.
const err = errors[0];

if (err && isYAMLException(err)) {
if (err && err instanceof Error && isYAMLException(err)) {
Comment thread
handreyrc marked this conversation as resolved.
return (
<ErrorPage
title={t("workflowError.parsing.title")}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@
import type { Specification } from "@serverlessworkflow/sdk";
import * as React from "react";
import type * as RF from "@xyflow/react";
import type { SdkError } from "../core";

export type DiagramEditorContextType = {
isReadOnly: boolean;
locale: string;
model: Specification.Workflow | null;
errors: Error[];
errors: SdkError[];
nodes: RF.Node[];
edges: RF.Edge[];
selectedNodeId: string | null;
Expand Down
Loading
Loading