diff --git a/README.md b/README.md index 2d8c179..b395fed 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,15 @@ do: const workflow = Classes.Workflow.deserialize(text); ``` +Deserialization validates the document by default. Pass `{ validate: false }` to load a work in progress definition, which is what an editor holds most of the time: + +```typescript +const draft = Classes.Workflow.deserialize(text, { validate: false }); +``` + +> [!NOTE] +> Opting out skips *validation*, not *hydration*. A definition whose shape is structurally impossible, for instance `do` written as a mapping rather than a sequence, still throws while the classes are built. + #### Create a Workflow Definition by Casting an Object You can type-cast an object to match the structure of a workflow definition: @@ -241,14 +250,43 @@ import { Classes } from '@openworkflowspec/sdk'; // const workflow = ; if (workflow instanceof Classes.Workflow) { - const yaml = workflow.serialize(/*'yaml' | 'json' */); + const yaml = workflow.serialize(/*{ format: 'yaml' | 'json' }*/); } else { - const json = Classes.Workflow.serialize(workflow, 'json'); + const json = Classes.Workflow.serialize(workflow, { format: 'json' }); } ``` +Both accept a `SerializationOptions` payload, mirroring the `build({ validate, normalize })` options above: + +```typescript +import { Classes } from '@openworkflowspec/sdk'; +import type { SerializationOptions } from '@openworkflowspec/sdk'; + +const options: SerializationOptions = { + format: 'yaml', // default 'yaml' + normalize: true, // default true + validate: true, // default true + yaml: { + indent: 2, // default 2 + lineWidth: 80, // default 80, -1 for unlimited + sortKeys: false, // default false + flowLevel: -1, // default -1, never switch to flow style + }, +}; +const text = workflow.serialize(options); +``` + +Set `validate: false` to save a work in progress definition, and `yaml: { lineWidth: -1 }` to stop long runtime expressions being folded into block scalars, which is usually what you want for a definition kept under version control: + +```typescript +const draft = workflow.serialize({ validate: false, yaml: { lineWidth: -1 } }); +``` + > [!NOTE] -> The default serialization format is YAML. +> The default serialization format is YAML. The `yaml` options are ignored when the format is `json`. + +> [!TIP] +> The positional form, `serialize('json')` and `serialize('yaml', false)`, still works but is deprecated in favour of the options payload. #### Validate Workflow Definitions diff --git a/examples/browser/umd/using-class.html b/examples/browser/umd/using-class.html index e7aff9a..2340e12 100644 --- a/examples/browser/umd/using-class.html +++ b/examples/browser/umd/using-class.html @@ -34,7 +34,7 @@ try { workflow.validate(); document.getElementById('output').innerHTML = - `--- YAML ---\n${workflow.serialize()}\n\n--- JSON ---\n${workflow.serialize('json')}`; + `--- YAML ---\n${workflow.serialize()}\n\n--- JSON ---\n${workflow.serialize({ format: 'json' })}`; } catch (ex) { console.error('Invalid workflow', ex); } diff --git a/examples/browser/umd/using-fluent-api.html b/examples/browser/umd/using-fluent-api.html index 6f65f4d..eb14d76 100644 --- a/examples/browser/umd/using-fluent-api.html +++ b/examples/browser/umd/using-fluent-api.html @@ -32,7 +32,7 @@ ) .build(); document.getElementById('output').innerHTML = - `--- YAML ---\n${workflow.serialize()}\n\n--- JSON ---\n${workflow.serialize('json')}`; + `--- YAML ---\n${workflow.serialize()}\n\n--- JSON ---\n${workflow.serialize({ format: 'json' })}`; } catch (ex) { console.error('Invalid workflow', ex); } diff --git a/examples/browser/umd/using-json.html b/examples/browser/umd/using-json.html index 35f5352..4faa348 100644 --- a/examples/browser/umd/using-json.html +++ b/examples/browser/umd/using-json.html @@ -33,7 +33,7 @@ }`; const workflow = Workflow.deserialize(myJsonWorkflow); document.getElementById('output').innerHTML = - `--- YAML ---\n${workflow.serialize()}\n\n--- JSON ---\n${workflow.serialize('json')}`; + `--- YAML ---\n${workflow.serialize()}\n\n--- JSON ---\n${workflow.serialize({ format: 'json' })}`; })(); diff --git a/examples/browser/umd/using-plain-object.html b/examples/browser/umd/using-plain-object.html index d03f7e3..b7f5666 100644 --- a/examples/browser/umd/using-plain-object.html +++ b/examples/browser/umd/using-plain-object.html @@ -33,7 +33,7 @@ try { validate('Workflow', workflowDefinition); document.getElementById('output').innerHTML = - `--- YAML ---\n${Classes.Workflow.serialize(workflowDefinition)}\n\n--- JSON ---\n${Classes.Workflow.serialize(workflowDefinition, 'json')}`; + `--- YAML ---\n${Classes.Workflow.serialize(workflowDefinition)}\n\n--- JSON ---\n${Classes.Workflow.serialize(workflowDefinition, { format: 'json' })}`; } catch (ex) { console.error('Invalid workflow', ex); } diff --git a/examples/browser/using-class.html b/examples/browser/using-class.html index efd65e6..a7cd2f4 100644 --- a/examples/browser/using-class.html +++ b/examples/browser/using-class.html @@ -34,7 +34,7 @@ try { workflow.validate(); document.getElementById('output').innerHTML = - `--- YAML ---\n${workflow.serialize()}\n\n--- JSON ---\n${workflow.serialize('json')}`; + `--- YAML ---\n${workflow.serialize()}\n\n--- JSON ---\n${workflow.serialize({ format: 'json' })}`; } catch (ex) { console.error('Invalid workflow', ex); } diff --git a/examples/browser/using-fluent-api.html b/examples/browser/using-fluent-api.html index 086f426..9ea6e28 100644 --- a/examples/browser/using-fluent-api.html +++ b/examples/browser/using-fluent-api.html @@ -31,7 +31,7 @@ ) .build(); document.getElementById('output').innerHTML = - `--- YAML ---\n${workflow.serialize()}\n\n--- JSON ---\n${workflow.serialize('json')}`; + `--- YAML ---\n${workflow.serialize()}\n\n--- JSON ---\n${workflow.serialize({ format: 'json' })}`; } catch (ex) { console.error('Invalid workflow', ex); } diff --git a/examples/browser/using-json.html b/examples/browser/using-json.html index a59ac59..1c0e76f 100644 --- a/examples/browser/using-json.html +++ b/examples/browser/using-json.html @@ -33,7 +33,7 @@ }`; const workflow = Workflow.deserialize(myJsonWorkflow); document.getElementById('output').innerHTML = - `--- YAML ---\n${workflow.serialize()}\n\n--- JSON ---\n${workflow.serialize('json')}`; + `--- YAML ---\n${workflow.serialize()}\n\n--- JSON ---\n${workflow.serialize({ format: 'json' })}`; })(); diff --git a/examples/browser/using-plain-object.html b/examples/browser/using-plain-object.html index c50f351..3590cce 100644 --- a/examples/browser/using-plain-object.html +++ b/examples/browser/using-plain-object.html @@ -33,7 +33,7 @@ try { validate('Workflow', workflowDefinition); document.getElementById('output').innerHTML = - `--- YAML ---\n${Classes.Workflow.serialize(workflowDefinition)}\n\n--- JSON ---\n${Classes.Workflow.serialize(workflowDefinition, 'json')}`; + `--- YAML ---\n${Classes.Workflow.serialize(workflowDefinition)}\n\n--- JSON ---\n${Classes.Workflow.serialize(workflowDefinition, { format: 'json' })}`; } catch (ex) { console.error('Invalid workflow', ex); } diff --git a/examples/node/using-class.ts b/examples/node/using-class.ts index 5e2a52a..b900276 100644 --- a/examples/node/using-class.ts +++ b/examples/node/using-class.ts @@ -36,7 +36,7 @@ const workflow = new Workflow({ try { workflow.validate(); - console.log(`--- YAML ---\n${workflow.serialize()}\n\n--- JSON ---\n${workflow.serialize('json')}`); + console.log(`--- YAML ---\n${workflow.serialize()}\n\n--- JSON ---\n${workflow.serialize({ format: 'json' })}`); } catch (ex) { console.error('Invalid workflow', ex); } diff --git a/examples/node/using-fluent-api.ts b/examples/node/using-fluent-api.ts index 3956ebd..830c862 100644 --- a/examples/node/using-fluent-api.ts +++ b/examples/node/using-fluent-api.ts @@ -31,7 +31,7 @@ try { .build(), ) .build(); - console.log(`--- YAML ---\n${workflow.serialize()}\n\n--- JSON ---\n${workflow.serialize('json')}`); + console.log(`--- YAML ---\n${workflow.serialize()}\n\n--- JSON ---\n${workflow.serialize({ format: 'json' })}`); } catch (ex) { console.error('Invalid workflow', ex); } diff --git a/examples/node/using-json.ts b/examples/node/using-json.ts index aac0a83..7d1f0dd 100644 --- a/examples/node/using-json.ts +++ b/examples/node/using-json.ts @@ -19,4 +19,4 @@ const myJsonWorkflow = ` ] }`; const workflow = Classes.Workflow.deserialize(myJsonWorkflow); -console.log(`--- YAML ---\n${workflow.serialize()}\n\n--- JSON ---\n${workflow.serialize('json')}`); +console.log(`--- YAML ---\n${workflow.serialize()}\n\n--- JSON ---\n${workflow.serialize({ format: 'json' })}`); diff --git a/examples/node/using-plain-object.ts b/examples/node/using-plain-object.ts index 875ed5c..00bbc39 100644 --- a/examples/node/using-plain-object.ts +++ b/examples/node/using-plain-object.ts @@ -20,7 +20,7 @@ const workflowDefinition = { try { validate('Workflow', workflowDefinition); console.log( - `--- YAML ---\n${Classes.Workflow.serialize(workflowDefinition)}\n\n--- JSON ---\n${Classes.Workflow.serialize(workflowDefinition, 'json')}`, + `--- YAML ---\n${Classes.Workflow.serialize(workflowDefinition)}\n\n--- JSON ---\n${Classes.Workflow.serialize(workflowDefinition, { format: 'json' })}`, ); } catch (ex) { console.error('Invalid workflow', ex); diff --git a/src/lib/generated/classes/workflow.ts b/src/lib/generated/classes/workflow.ts index 1bcf981..75748a1 100644 --- a/src/lib/generated/classes/workflow.ts +++ b/src/lib/generated/classes/workflow.ts @@ -33,6 +33,12 @@ import { getLifecycleHooks } from '../../lifecycle-hooks'; import { validate } from '../../validation'; import { isObject } from '../../utils'; import * as yaml from 'js-yaml'; +import { + DeserializationOptions, + SerializationOptions, + toSerializationOptions, + toYamlDumpOptions, +} from '../../serialization'; import { buildGraph, Graph, GraphBuildOptions } from '../../graph-builder'; import { convertToMermaidCode } from '../../mermaid-converter'; @@ -95,13 +101,24 @@ export class Workflow extends ObjectHydrator { } /** - * Deserializes the provided string as a Workflow + * Deserializes the provided string as a Workflow. + * + * When validation is skipped, the parsed document is still checked to be a mapping: hydration + * ignores anything else, so a scalar or a sequence would otherwise yield a blank Workflow rather + * than an error. See issue #309. + * * @param text The YAML or JSON representation of a workflow + * @param options The deserialization options, e.g. to opt out of validation * @returns A new Workflow instance */ - static deserialize(text: string): WorkflowIntersection { + static deserialize(text: string, options?: DeserializationOptions): WorkflowIntersection { const model = yaml.load(text) as Partial; - validate('Workflow', model); + if (options?.validate ?? true) { + validate('Workflow', model); + } else if (!isObject(model)) { + const actual = model === null ? 'null' : Array.isArray(model) ? 'a sequence' : `a ${typeof model}`; + throw new Error(`The provided text does not describe a workflow: expected a mapping, got ${actual}`); + } return new Workflow(model) as WorkflowIntersection; } @@ -112,19 +129,38 @@ export class Workflow extends ObjectHydrator { * `asPlainObject()`: js-yaml cannot dump hydrated class instances. See issue #308. * * @param model The workflow to serialize + * @param options The serialization options, e.g. the format or whether to validate + * @returns A string representation of the workflow + */ + static serialize(model: Partial, options?: SerializationOptions): string; + + /** + * Serializes the provided workflow to YAML or JSON + * @deprecated Pass a `SerializationOptions` object instead, e.g. `serialize(workflow, { format: 'json' })` + * @param model The workflow to serialize * @param format The format, 'yaml' or 'json', default is 'yaml' * @param normalize If the workflow should be normalized before serialization, default true * @returns A string representation of the workflow */ + static serialize(model: Partial, format?: 'yaml' | 'json', normalize?: boolean): string; + static serialize( model: Partial, - format: 'yaml' | 'json' = 'yaml', - normalize: boolean = true, + formatOrOptions?: 'yaml' | 'json' | SerializationOptions, + legacyNormalize?: boolean, ): string { + const options = toSerializationOptions(formatOrOptions, legacyNormalize); + const format = options.format ?? 'yaml'; + const shouldNormalize = options.normalize ?? true; + const shouldValidate = options.validate ?? true; const workflow = new Workflow(model); - workflow.validate(); - const plainWorkflow = (normalize ? workflow.normalize() : workflow).asPlainObject(); - return format === 'json' ? JSON.stringify(plainWorkflow) : yaml.dump(plainWorkflow); + if (shouldValidate) { + workflow.validate(); + } + const plainWorkflow = (shouldNormalize ? workflow.normalize() : workflow).asPlainObject(); + return format === 'json' + ? JSON.stringify(plainWorkflow) + : yaml.dump(plainWorkflow, toYamlDumpOptions(options.yaml)); } /** @@ -148,12 +184,25 @@ export class Workflow extends ObjectHydrator { /** * Serializes the workflow to YAML or JSON + * @param options The serialization options, e.g. the format or whether to validate + * @returns A string representation of the workflow + */ + serialize(options?: SerializationOptions): string; + + /** + * Serializes the workflow to YAML or JSON + * @deprecated Pass a `SerializationOptions` object instead, e.g. `serialize({ format: 'json' })` * @param format The format, 'yaml' or 'json', default is 'yaml' * @param normalize If the workflow should be normalized before serialization, default true * @returns A string representation of the workflow */ - serialize(format: 'yaml' | 'json' = 'yaml', normalize: boolean = true): string { - return Workflow.serialize(this as unknown as WorkflowIntersection, format, normalize); + serialize(format?: 'yaml' | 'json', normalize?: boolean): string; + + serialize(formatOrOptions?: 'yaml' | 'json' | SerializationOptions, legacyNormalize?: boolean): string { + return Workflow.serialize( + this as unknown as WorkflowIntersection, + toSerializationOptions(formatOrOptions, legacyNormalize), + ); } /** @@ -178,12 +227,22 @@ export const _Workflow = Workflow as WorkflowConstructor & { /** * Deserializes the provided string as a Workflow * @param text The YAML or JSON representation of a workflow + * @param options The deserialization options, e.g. to opt out of validation * @returns A new Workflow instance */ - deserialize(text: string): WorkflowIntersection; + deserialize(text: string, options?: DeserializationOptions): WorkflowIntersection; + + /** + * Serializes the provided Workflow to YAML or JSON + * @param workflow The workflow to serialize + * @param options The serialization options, e.g. the format or whether to validate + * @returns A string representation of the workflow + */ + serialize(workflow: Partial, options?: SerializationOptions): string; /** * Serializes the provided Workflow to YAML or JSON + * @deprecated Pass a `SerializationOptions` object instead, e.g. `serialize(workflow, { format: 'json' })` * @param workflow The workflow to serialize * @param format The format, 'yaml' or 'json', default is 'yaml' * @param normalize If the workflow should be normalized before serialization, default true diff --git a/src/lib/hydrator.ts b/src/lib/hydrator.ts index c2d9d65..8266fac 100644 --- a/src/lib/hydrator.ts +++ b/src/lib/hydrator.ts @@ -56,19 +56,26 @@ export class ArrayHydrator extends Array { * Instanciates a new instance of the ArrayHydrator class. * Copies the elements of the provided model onto the instance if it is an array. * - * @param model - Optional array or number to initialize the instance. + * Discriminates on the model's runtime type rather than on its numeric coercion. `Number([])` is 0 + * and `Number(['5'])` is 5, so an isNaN based test routed empty and single element arrays into the + * `Array(length)` constructor, hydrating `[]` as `[[]]` and `null` as `[null]`. The elements are + * assigned by index rather than spread as arguments, which would exceed the engine's argument limit + * for a large model. + * + * @param model - Optional array to copy, or a number to preallocate the instance's length. */ constructor(model?: Array | number) { - if (!isNaN(model as number)) { - super(model as number); + if (model == null) { + super(); + } else if (typeof model === 'number') { + super(model); + } else if (Array.isArray(model)) { + super(model.length); + model.forEach((item, index) => { + this[index] = item; + }); } else { - super(...((model as Array) || [])); - if (!model) { - model = []; - } - if (!Array.isArray(model)) { - throw new Error('The provided model should be an array'); - } + throw new Error('The provided model should be an array'); } } } diff --git a/src/lib/serialization.ts b/src/lib/serialization.ts new file mode 100644 index 0000000..998cf07 --- /dev/null +++ b/src/lib/serialization.ts @@ -0,0 +1,140 @@ +/* + * Copyright 2021-Present The Open Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import type { DumpOptions } from 'js-yaml'; + +/** + * The options shared by every operation that can validate a workflow as part of its work + */ +export type ValidationOptions = { + /** + * Whether the workflow should be validated. Default true. + * + * Set to false to process a work in progress document, which is structurally invalid most of the + * time. Note that this skips *validation*, not *hydration*: a document whose shape is structurally + * impossible, e.g. `do` as a mapping rather than a sequence, still throws while the class graph is + * built. + */ + validate?: boolean; +}; + +/** + * The options used to customize the YAML output. + * + * Deliberately a curated subset of, and independent from, js-yaml's own `DumpOptions`: v5 renamed + * several of those options and moved others onto `PresenterOptions`, so re-exporting that type would + * make the SDK's public API change shape on every js-yaml major. + */ +export type YamlSerializationOptions = { + /** + * The indentation width, in spaces. Default 2, minimum 1. + * + * Values below 1 are raised to 1: js-yaml no longer clamps them, and an indent of 0 emits nested + * mappings at column 0, which parses back as a different document. + */ + indent?: number; + /** + * The maximum line width before long scalars are folded into a block, -1 for unlimited. Default 80 + */ + lineWidth?: number; + /** + * Whether the mapping keys should be sorted. Default false + */ + sortKeys?: boolean; + /** + * The nesting level at which collections switch to flow style, -1 to never switch. Default -1 + */ + flowLevel?: number; +}; + +/** + * The options used when serializing a workflow + */ +export type SerializationOptions = ValidationOptions & { + /** + * The output format. Default 'yaml' + */ + format?: 'yaml' | 'json'; + /** + * Whether the workflow should be normalized before serialization. Default true + */ + normalize?: boolean; + /** + * The options used to customize the YAML output. Ignored when the format is 'json' + */ + yaml?: YamlSerializationOptions; +}; + +/** + * The options used when deserializing a workflow. + * + * `format` is excluded because `yaml.load` parses both YAML and JSON, JSON being a subset of YAML, so + * the reader does not need to be told which one it was handed. `normalize` is excluded because + * normalizing on read would silently rewrite the caller's document; normalization stays an explicit, + * caller driven step. + */ +export type DeserializationOptions = ValidationOptions; + +/** + * Maps the SDK's YAML output options onto the options expected by js-yaml's dumper. + * + * The fields are mapped one by one rather than spread, so that a js-yaml release renaming or dropping + * one of them fails this file at compile time instead of silently becoming a no-op. Only the options + * the caller actually set are forwarded: js-yaml merges what it is given over its own defaults, so an + * explicitly undefined field would overwrite a default and rely on the dumper filtering it back out. + * + * @param options The SDK's YAML output options + * @returns The equivalent js-yaml dump options + */ +export const toYamlDumpOptions = (options?: YamlSerializationOptions | null): DumpOptions => { + const dumpOptions: DumpOptions = {}; + if (options?.indent != null) { + dumpOptions.indent = Math.max(1, options.indent); + } + if (options?.lineWidth != null) { + dumpOptions.lineWidth = options.lineWidth; + } + if (options?.sortKeys != null) { + dumpOptions.sortKeys = options.sortKeys; + } + if (options?.flowLevel != null) { + dumpOptions.flowLevel = options.flowLevel; + } + return dumpOptions; +}; + +/** + * Normalizes the arguments of `serialize` into a single options object, accepting both the current + * options payload and the deprecated positional form. + * + * The positional form is recognized by the absence of an options payload rather than by the presence + * of a format, because the format was optional there: `serialize(undefined, false)` is a legacy call + * that asks not to normalize, and reading it as an empty payload would silently normalize anyway. + * + * @param formatOrOptions The serialization options, or the deprecated positional format argument + * @param legacyNormalize The deprecated positional normalize argument, only read for the positional form + * @returns The equivalent serialization options + */ +export const toSerializationOptions = ( + formatOrOptions?: 'yaml' | 'json' | SerializationOptions | null, + legacyNormalize?: boolean, +): SerializationOptions => { + if (formatOrOptions != null && typeof formatOrOptions !== 'string') { + return formatOrOptions; + } + return { format: formatOrOptions ?? undefined, normalize: legacyNormalize }; +}; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 516fe10..5885142 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -48,10 +48,3 @@ export const escapeJsonPointerSegment = (segment: string | number): string => */ export const appendJsonPointerSegment = (base: string, segment: string | number): string => `${base}/${escapeJsonPointerSegment(segment)}`; - -/** - * Checks the provided array is an array - * @param arr - * @returns - */ -export const isArray = (arr: Array | number | undefined): arr is Array => !!arr && isNaN(arr as number); diff --git a/src/open-workflow-sdk.ts b/src/open-workflow-sdk.ts index 6cb5d31..b129043 100644 --- a/src/open-workflow-sdk.ts +++ b/src/open-workflow-sdk.ts @@ -6,3 +6,9 @@ export * from './lib/validation'; export * from './lib/graph-builder'; export * from './lib/mermaid-converter'; export * from './lib/schema'; +export type { + DeserializationOptions, + SerializationOptions, + ValidationOptions, + YamlSerializationOptions, +} from './lib/serialization'; diff --git a/tests/classes/array-hydrator.spec.ts b/tests/classes/array-hydrator.spec.ts new file mode 100644 index 0000000..026f7a2 --- /dev/null +++ b/tests/classes/array-hydrator.spec.ts @@ -0,0 +1,139 @@ +/* + * Copyright 2021-Present The Open Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { ArrayHydrator } from '../../src/lib/hydrator'; +import { Classes } from '../../src/lib/generated/classes'; +import { Specification } from '../../src/lib/generated/definitions'; + +import { schemaVersion } from '../../package.json'; + +/** + * The message ArrayHydrator raises for a model it cannot treat as an array. + */ +const notAnArray = 'The provided model should be an array'; + +/** + * ArrayHydrator used to discriminate a length from a list of elements with `!isNaN(model)`, which + * coerces the model to a number first. `Number([])` is 0 and `Number(['5'])` is 5, so empty and + * single element arrays were routed into the `Array(length)` constructor. + * + * These assert the base class directly, because that is the only place the single element case is + * observable: every generated subclass immediately splices its contents away and re-pushes hydrated + * elements, which repaired the damage for a non-empty model but never for an empty one. + */ +describe('ArrayHydrator - model coercion', () => { + it('should hydrate an empty array as an empty instance', () => { + expect([...new ArrayHydrator([])]).toStrictEqual([]); + }); + + it('should preserve a single element whose value coerces to a number', () => { + expect([...new ArrayHydrator(['5'])]).toStrictEqual(['5']); + expect([...new ArrayHydrator([''])]).toStrictEqual(['']); + expect([...new ArrayHydrator([3])]).toStrictEqual([3]); + }); + + it('should hydrate null and undefined as an empty instance', () => { + expect([...new ArrayHydrator(null as unknown as Array)]).toStrictEqual([]); + expect([...new ArrayHydrator(undefined)]).toStrictEqual([]); + }); + + it('should treat a number as a length rather than as an element', () => { + expect(new ArrayHydrator(3).length).toBe(3); + }); + + it('should reject a model it cannot treat as an array', () => { + expect(() => new ArrayHydrator({} as unknown as Array)).toThrow(notAnArray); + expect(() => new ArrayHydrator('abc' as unknown as Array)).toThrow(notAnArray); + }); + + it('should copy a large model without exceeding the argument limit', () => { + const large = Array.from({ length: 200_000 }, (_, index) => `task-${index}`); + const hydrated = new ArrayHydrator(large); + expect(hydrated.length).toBe(200_000); + expect(hydrated[199_999]).toBe('task-199999'); + }); +}); + +/** + * The same behaviour reached the way the SDK actually reaches it. Only the empty, null and non-array + * models discriminate here; the rest document that the subclass hydration layer composes correctly + * over the base class rather than proving the base class fix. + */ +describe('ArrayHydrator - generated subclasses', () => { + it('should hydrate an empty array as an empty instance', () => { + const taskList = new Classes.TaskList([]); + expect(taskList).toBeInstanceOf(Classes.TaskList); + expect(taskList.length).toBe(0); + expect([...taskList]).toStrictEqual([]); + }); + + it('should hydrate null as an empty instance', () => { + const taskList = new Classes.TaskList(null as unknown as Array); + expect(taskList.length).toBe(0); + expect([...taskList]).toStrictEqual([]); + }); + + it('should hydrate undefined as an empty instance', () => { + const taskList = new Classes.TaskList(undefined); + expect(taskList.length).toBe(0); + expect([...taskList]).toStrictEqual([]); + }); + + it('should treat a number as a length rather than as an element', () => { + const taskList = new Classes.TaskList(3); + expect(taskList.length).toBe(3); + }); + + it('should hydrate the elements of a populated array', () => { + const taskList = new Classes.TaskList([{ step1: { set: { foo: 'bar' } } }, { step2: { set: { foo: 'baz' } } }]); + expect(taskList.length).toBe(2); + expect(Object.keys(taskList[0]!)).toStrictEqual(['step1']); + expect(Object.keys(taskList[1]!)).toStrictEqual(['step2']); + }); + + it('should throw its own error rather than a TypeError when given a mapping', () => { + expect(() => new Classes.TaskList({} as unknown as Array)).toThrow(notAnArray); + }); + + it('should throw rather than split a string into characters', () => { + expect(() => new Classes.TaskList('abc' as unknown as Array)).toThrow(notAnArray); + }); + + it('should round-trip an existing instance without altering it', () => { + const source = new Classes.TaskList([{ step1: { set: { foo: 'bar' } } }]); + const copy = new Classes.TaskList(source); + expect(copy.length).toBe(1); + expect(JSON.stringify(copy)).toBe(JSON.stringify(source)); + }); + + it('should hydrate a workflow holding an empty task list without inventing a task', () => { + const workflow = new Classes.Workflow({ + document: { dsl: schemaVersion, name: 'test', version: '1.0.0', namespace: 'default' }, + do: [], + }); + expect(workflow.do.length).toBe(0); + expect(workflow.serialize({ validate: false })).toBe( + `document: + dsl: ${schemaVersion} + name: test + version: 1.0.0 + namespace: default +do: [] +`, + ); + }); +}); diff --git a/tests/serialization/workflow-serialization-options.spec.ts b/tests/serialization/workflow-serialization-options.spec.ts new file mode 100644 index 0000000..822957d --- /dev/null +++ b/tests/serialization/workflow-serialization-options.spec.ts @@ -0,0 +1,511 @@ +/* + * Copyright 2021-Present The Open Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import * as yaml from 'js-yaml'; + +import { Specification } from '../../src/lib/generated/definitions'; +import { Classes } from '../../src/lib/generated/classes'; +import { DslValidationError, SchemaValidationError, WorkflowValidationError } from '../../src/lib/errors'; +import { + DeserializationOptions, + SerializationOptions, + toSerializationOptions, + toYamlDumpOptions, +} from '../../src/lib/serialization'; + +import { schemaVersion } from '../../package.json'; + +/** + * The smallest valid workflow. + */ +const minimalWorkflow: Specification.Workflow = { + document: { + dsl: schemaVersion, + name: 'test', + version: '1.0.0', + namespace: 'default', + }, + do: [ + { + step1: { + set: { + foo: 'bar', + }, + }, + }, + ], +}; + +/** + * A valid workflow whose summary is longer than the eighty character line width js-yaml folds at, + * used to exercise the YAML output options. + */ +const summary = + 'A workflow whose summary is deliberately longer than the eighty character line width used by js-yaml, so that folded block scalars are exercised.'; +const verboseWorkflow: Specification.Workflow = { + ...minimalWorkflow, + document: { ...minimalWorkflow.document, summary }, +}; + +/** + * A workflow missing the required 'do' property, which fails JSON schema validation. + */ +const invalidWorkflow = { + document: { + dsl: schemaVersion, + name: 'test', + version: '1.0.0', + namespace: 'default', + }, +} as unknown as Specification.Workflow; + +/** + * A workflow declaring a DSL version this SDK does not support. Rejected by the Workflow lifecycle + * hook rather than by the schema, so it fails as a DslValidationError rather than a + * SchemaValidationError. + */ +const unsupportedDslWorkflow: Specification.Workflow = { + ...minimalWorkflow, + document: { ...minimalWorkflow.document, dsl: '99.0.0' }, +}; + +/** + * A workflow whose tasks share a name. Rejected by the TaskList lifecycle hook, which the validation + * walk reaches by recursing into the workflow's descendants rather than at its root. + */ +const duplicateTaskNamesWorkflow: Specification.Workflow = { + ...minimalWorkflow, + do: [{ step1: { set: { foo: 'bar' } } }, { step1: { set: { foo: 'baz' } } }], +}; + +/** + * A half-written document of the kind an editor holds: a partial header and no tasks at all. Invalid + * against the schema, and the reason `validate: false` exists. + */ +const wipDraft = { document: { name: 'work-in-progress' } } as unknown as Partial; + +/** + * A work in progress document that has reached an empty task list, the shape that used to hydrate + * into a task list holding one empty array. + */ +const wipEmptyTaskList = { + document: { dsl: schemaVersion, name: 'work-in-progress' }, + do: [], +} as unknown as Partial; + +/** + * A work in progress document whose task has been named but not yet given a body. + */ +const wipEmptyTask = { + document: { dsl: schemaVersion, name: 'work-in-progress' }, + do: [{ step1: {} }], +} as unknown as Partial; + +describe('Workflow serialization - options payload', () => { + it('should default to YAML, normalized and validated, when no options are passed', () => { + const workflow = new Classes.Workflow(minimalWorkflow); + expect(workflow.serialize({})).toBe(workflow.serialize()); + expect(workflow.serialize({ format: 'yaml', normalize: true, validate: true })).toBe(workflow.serialize()); + }); + + it('should honour the format option', () => { + const workflow = new Classes.Workflow(minimalWorkflow); + expect(workflow.serialize({ format: 'json' })).toBe(JSON.stringify(minimalWorkflow)); + expect(yaml.load(workflow.serialize({ format: 'yaml' }))).toStrictEqual(minimalWorkflow); + }); + + it('should honour the normalize option', () => { + const workflow = new Classes.Workflow(minimalWorkflow); + expect(yaml.load(workflow.serialize({ normalize: false }))).toStrictEqual(minimalWorkflow); + expect(workflow.serialize({ format: 'json', normalize: false })).toBe(JSON.stringify(minimalWorkflow)); + }); + + it('should accept the options payload on the static method', () => { + const workflow = new Classes.Workflow(minimalWorkflow); + expect(Classes.Workflow.serialize(workflow, {})).toBe(Classes.Workflow.serialize(workflow)); + expect(Classes.Workflow.serialize(minimalWorkflow, { format: 'json' })).toBe(JSON.stringify(minimalWorkflow)); + }); +}); + +/** + * The compatibility guard for the deprecated positional signature: every call that compiled before the + * options payload existed must keep producing exactly what it produced then. + */ +describe('Workflow serialization - legacy positional signature', () => { + const cases: Array<{ + name: string; + legacy: (workflow: InstanceType) => string; + options: (workflow: InstanceType) => string; + }> = [ + { + name: 'instance, no argument', + legacy: (workflow) => workflow.serialize(), + options: (workflow) => workflow.serialize({}), + }, + { + name: "instance, 'yaml'", + legacy: (workflow) => workflow.serialize('yaml'), + options: (workflow) => workflow.serialize({ format: 'yaml' }), + }, + { + name: "instance, 'json'", + legacy: (workflow) => workflow.serialize('json'), + options: (workflow) => workflow.serialize({ format: 'json' }), + }, + { + name: "instance, 'yaml' without normalizing", + legacy: (workflow) => workflow.serialize('yaml', false), + options: (workflow) => workflow.serialize({ format: 'yaml', normalize: false }), + }, + { + name: "instance, 'json' without normalizing", + legacy: (workflow) => workflow.serialize('json', false), + options: (workflow) => workflow.serialize({ format: 'json', normalize: false }), + }, + { + name: 'static, no argument', + legacy: (workflow) => Classes.Workflow.serialize(workflow), + options: (workflow) => Classes.Workflow.serialize(workflow, {}), + }, + { + name: "static, 'yaml'", + legacy: (workflow) => Classes.Workflow.serialize(workflow, 'yaml'), + options: (workflow) => Classes.Workflow.serialize(workflow, { format: 'yaml' }), + }, + { + name: "static, 'json'", + legacy: (workflow) => Classes.Workflow.serialize(workflow, 'json'), + options: (workflow) => Classes.Workflow.serialize(workflow, { format: 'json' }), + }, + { + name: "static, 'yaml' without normalizing", + legacy: (workflow) => Classes.Workflow.serialize(workflow, 'yaml', false), + options: (workflow) => Classes.Workflow.serialize(workflow, { format: 'yaml', normalize: false }), + }, + { + name: "static, 'json' without normalizing", + legacy: (workflow) => Classes.Workflow.serialize(workflow, 'json', false), + options: (workflow) => Classes.Workflow.serialize(workflow, { format: 'json', normalize: false }), + }, + ]; + + for (const { name, legacy, options } of cases) { + it(`should produce the same output for the options payload and the positional form - ${name}`, () => { + const workflow = new Classes.Workflow(verboseWorkflow); + expect(legacy(workflow)).toBe(options(workflow)); + }); + } + + it('should keep validating by default through the positional form', () => { + const workflow = new Classes.Workflow(invalidWorkflow); + expect(() => workflow.serialize('yaml')).toThrow(SchemaValidationError); + expect(() => Classes.Workflow.serialize(workflow, 'json', false)).toThrow(SchemaValidationError); + }); + + /** + * The positional `format` was optional with a default before the overloads existed, so a caller + * forwarding a possibly undefined format compiled. Declaring it required on the deprecated overload + * would break those call sites at compile time, which the literal-argument cases above cannot catch. + */ + it('should still accept an explicitly undefined format', () => { + const workflow = new Classes.Workflow(minimalWorkflow); + const format: 'yaml' | 'json' | undefined = undefined; + expect(workflow.serialize(format)).toBe(workflow.serialize()); + expect(Classes.Workflow.serialize(workflow, format)).toBe(workflow.serialize()); + expect(workflow.serialize(format, false)).toBe(workflow.serialize({ normalize: false })); + }); + + /** + * Asserted on the normalizer rather than through serialize, because no normalize lifecycle hook is + * registered for Workflow: normalizing is currently a no-op, so comparing serialized output cannot + * tell whether the flag survived the conversion. + */ + it('should carry every positional argument into the options payload', () => { + expect(toSerializationOptions()).toStrictEqual({ format: undefined, normalize: undefined }); + expect(toSerializationOptions('json')).toStrictEqual({ format: 'json', normalize: undefined }); + expect(toSerializationOptions('json', false)).toStrictEqual({ format: 'json', normalize: false }); + expect(toSerializationOptions(undefined, false)).toStrictEqual({ format: undefined, normalize: false }); + expect(toSerializationOptions(null, false)).toStrictEqual({ format: undefined, normalize: false }); + }); + + it('should pass an options payload through untouched', () => { + const options: SerializationOptions = { format: 'json', normalize: false, validate: false }; + expect(toSerializationOptions(options)).toBe(options); + expect(toSerializationOptions({})).toStrictEqual({}); + }); +}); + +/** + * The options are a public payload, and the UMD bundle is consumed as untyped JavaScript, so a null + * field has to mean the same thing everywhere rather than being read as "off" on one entry point and + * "unset" on the other. + */ +describe('Workflow (de)serialization - null and missing options', () => { + it('should treat a null option field as unset when serializing', () => { + const workflow = new Classes.Workflow(invalidWorkflow); + const nulled = { format: null, normalize: null, validate: null } as unknown as SerializationOptions; + expect(() => workflow.serialize(nulled)).toThrow(SchemaValidationError); + }); + + it('should treat a null option field as unset when deserializing', () => { + const text = Classes.Workflow.serialize(invalidWorkflow, { validate: false }); + const nulled = { validate: null } as unknown as DeserializationOptions; + expect(() => Classes.Workflow.deserialize(text, nulled)).toThrow(SchemaValidationError); + }); + + it('should accept a null options payload', () => { + const workflow = new Classes.Workflow(minimalWorkflow); + const text = Classes.Workflow.serialize(minimalWorkflow); + expect(workflow.serialize(null as unknown as SerializationOptions)).toBe(workflow.serialize()); + expect(Classes.Workflow.deserialize(text, null as unknown as DeserializationOptions).asPlainObject()).toStrictEqual( + minimalWorkflow, + ); + }); + + it('should accept a null yaml sub-block', () => { + const workflow = new Classes.Workflow(minimalWorkflow); + expect(toYamlDumpOptions(null)).toStrictEqual({}); + expect(toYamlDumpOptions()).toStrictEqual({}); + expect(workflow.serialize({ yaml: null } as unknown as SerializationOptions)).toBe(workflow.serialize()); + }); +}); + +/** + * Hydration silently ignores anything that is not a mapping, so without validation to reject it first + * a scalar or a sequence would deserialize into a blank Workflow and lose the caller's document. + */ +describe('Workflow deserialization - non-mapping documents', () => { + const documents: Array<{ name: string; text: string; reported: string }> = [ + { name: 'a scalar string', text: 'just a string', reported: 'a string' }, + { name: 'a number', text: '42', reported: 'a number' }, + { name: 'a sequence', text: '- a\n- b', reported: 'a sequence' }, + { name: 'an explicit null', text: 'null', reported: 'null' }, + ]; + + for (const { name, text, reported } of documents) { + it(`should refuse to deserialize ${name}, even with validation off, and say so`, () => { + expect(() => Classes.Workflow.deserialize(text, { validate: false })).toThrow( + `The provided text does not describe a workflow: expected a mapping, got ${reported}`, + ); + }); + + it(`should still report ${name} as a validation failure by default`, () => { + expect(() => Classes.Workflow.deserialize(text)).toThrow(WorkflowValidationError); + }); + } +}); + +describe('Workflow serialization - validation opt-out', () => { + it('should serialize an invalid workflow to YAML without throwing', () => { + const workflow = new Classes.Workflow(invalidWorkflow); + const serialized = workflow.serialize({ validate: false }); + expect(yaml.load(serialized)).toStrictEqual(invalidWorkflow); + }); + + it('should serialize an invalid workflow to JSON without throwing', () => { + const workflow = new Classes.Workflow(invalidWorkflow); + expect(workflow.serialize({ format: 'json', validate: false })).toBe(JSON.stringify(invalidWorkflow)); + }); + + it('should serialize an invalid workflow without throwing from the static method', () => { + expect(yaml.load(Classes.Workflow.serialize(invalidWorkflow, { validate: false }))).toStrictEqual(invalidWorkflow); + }); + + it('should bypass the DSL version lifecycle hook, not only the schema', () => { + const workflow = new Classes.Workflow(unsupportedDslWorkflow); + expect(() => workflow.serialize()).toThrow(DslValidationError); + expect(yaml.load(workflow.serialize({ validate: false }))).toStrictEqual(unsupportedDslWorkflow); + }); + + it('should bypass the lifecycle hooks of nested types too', () => { + const workflow = new Classes.Workflow(duplicateTaskNamesWorkflow); + expect(() => workflow.serialize()).toThrow(DslValidationError); + expect(yaml.load(workflow.serialize({ validate: false }))).toStrictEqual(duplicateTaskNamesWorkflow); + }); + + it('should still validate when validate is omitted or explicitly true', () => { + const workflow = new Classes.Workflow(invalidWorkflow); + expect(() => workflow.serialize({})).toThrow(SchemaValidationError); + expect(() => workflow.serialize({ validate: true })).toThrow(SchemaValidationError); + expect(() => Classes.Workflow.serialize(invalidWorkflow, { validate: true })).toThrow(SchemaValidationError); + }); + + it('should leave normalization on when validation is turned off', () => { + const workflow = new Classes.Workflow(invalidWorkflow); + expect(workflow.serialize({ validate: false })).toBe(workflow.serialize({ validate: false, normalize: true })); + }); +}); + +describe('Workflow deserialization - validation opt-out', () => { + it('should deserialize an invalid document without throwing', () => { + const text = Classes.Workflow.serialize(invalidWorkflow, { validate: false }); + const workflow = Classes.Workflow.deserialize(text, { validate: false }); + expect(workflow).toBeInstanceOf(Classes.Workflow); + expect(workflow.asPlainObject()).toStrictEqual(invalidWorkflow); + }); + + it('should bypass the DSL version lifecycle hook', () => { + const text = Classes.Workflow.serialize(unsupportedDslWorkflow, { validate: false }); + expect(() => Classes.Workflow.deserialize(text)).toThrow(DslValidationError); + expect(Classes.Workflow.deserialize(text, { validate: false }).asPlainObject()).toStrictEqual( + unsupportedDslWorkflow, + ); + }); + + it('should bypass the lifecycle hooks of nested types too', () => { + const text = Classes.Workflow.serialize(duplicateTaskNamesWorkflow, { validate: false }); + expect(() => Classes.Workflow.deserialize(text)).toThrow(DslValidationError); + expect(Classes.Workflow.deserialize(text, { validate: false }).asPlainObject()).toStrictEqual( + duplicateTaskNamesWorkflow, + ); + }); + + it('should still validate when validate is omitted or explicitly true', () => { + const text = Classes.Workflow.serialize(invalidWorkflow, { validate: false }); + expect(() => Classes.Workflow.deserialize(text)).toThrow(SchemaValidationError); + expect(() => Classes.Workflow.deserialize(text, {})).toThrow(SchemaValidationError); + expect(() => Classes.Workflow.deserialize(text, { validate: true })).toThrow(SchemaValidationError); + }); + + it('should keep deserializing a valid document unchanged', () => { + const text = Classes.Workflow.serialize(minimalWorkflow); + expect(Classes.Workflow.deserialize(text, { validate: false }).asPlainObject()).toStrictEqual(minimalWorkflow); + }); +}); + +/** + * The save then reload cycle of a work in progress document, which is what the whole option exists + * for: a draft the editor can persist but never reopen is of no use to it. + */ +describe('Workflow (de)serialization - work in progress round-trip', () => { + const drafts: Array<{ name: string; draft: Partial }> = [ + { name: 'a partial header and no tasks', draft: wipDraft }, + { name: 'an empty task list', draft: wipEmptyTaskList }, + { name: 'a named task with no body', draft: wipEmptyTask }, + ]; + + for (const { name, draft } of drafts) { + it(`should survive a save and reload cycle - ${name}`, () => { + const saved = Classes.Workflow.serialize(draft, { validate: false }); + const reloaded = Classes.Workflow.deserialize(saved, { validate: false }); + expect(reloaded).toBeInstanceOf(Classes.Workflow); + expect(reloaded.asPlainObject()).toStrictEqual(draft); + }); + + it(`should re-save a reloaded draft byte for byte - ${name}`, () => { + const saved = Classes.Workflow.serialize(draft, { validate: false }); + const reloaded = Classes.Workflow.deserialize(saved, { validate: false }); + expect(reloaded.serialize({ validate: false })).toBe(saved); + }); + } + + it('should reject a draft whose shape cannot be hydrated, even with validation off', () => { + const impossible = { document: { name: 'wip' }, do: {} } as unknown as Partial; + expect(() => Classes.Workflow.serialize(impossible, { validate: false })).toThrow( + 'The provided model should be an array', + ); + }); +}); + +describe('Workflow serialization - YAML output options', () => { + it('should produce byte-identical output when no YAML options are passed', () => { + const workflow = new Classes.Workflow(minimalWorkflow); + expect(workflow.serialize({ yaml: {} })).toBe(workflow.serialize()); + expect(workflow.serialize()).toBe( + `document: + dsl: ${schemaVersion} + name: test + version: 1.0.0 + namespace: default +do: + - step1: + set: + foo: bar +`, + ); + }); + + it('should fold long scalars at the default line width', () => { + const serialized = new Classes.Workflow(verboseWorkflow).serialize(); + expect(serialized).toMatch(/summary: >-/); + expect((yaml.load(serialized) as Specification.Workflow).document.summary).toBe(summary); + }); + + it('should keep long scalars on a single line with an unlimited line width', () => { + const serialized = new Classes.Workflow(verboseWorkflow).serialize({ yaml: { lineWidth: -1 } }); + expect(serialized).not.toMatch(/>-/); + expect(serialized).toContain(`summary: ${summary}`); + expect(yaml.load(serialized)).toStrictEqual(verboseWorkflow); + }); + + it('should honour the indentation width', () => { + const serialized = new Classes.Workflow(minimalWorkflow).serialize({ yaml: { indent: 4 } }); + expect(serialized).toContain(`document:\n dsl: ${schemaVersion}`); + expect(yaml.load(serialized)).toStrictEqual(minimalWorkflow); + }); + + /** + * js-yaml v5 no longer clamps the indent, so an indent of 0 emits nested mappings at column 0, + * which parses back as a flat document, and a negative one raises a RangeError from inside the + * dumper. Neither can be allowed to reach it. + */ + it('should raise an indentation width below one to one', () => { + const workflow = new Classes.Workflow(minimalWorkflow); + for (const indent of [0, -2]) { + const serialized = workflow.serialize({ yaml: { indent } }); + expect(serialized).toBe(workflow.serialize({ yaml: { indent: 1 } })); + expect(yaml.load(serialized)).toStrictEqual(minimalWorkflow); + } + }); + + it('should sort mapping keys on request while preserving sequence order', () => { + const serialized = new Classes.Workflow(duplicateTaskNamesWorkflow).serialize({ + validate: false, + yaml: { sortKeys: true }, + }); + expect(serialized.indexOf('do:')).toBeLessThan(serialized.indexOf('document:')); + expect(yaml.load(serialized)).toStrictEqual(duplicateTaskNamesWorkflow); + }); + + it('should switch to flow style on request', () => { + const serialized = new Classes.Workflow(minimalWorkflow).serialize({ yaml: { flowLevel: 0 } }); + expect(serialized.startsWith('{')).toBe(true); + expect(yaml.load(serialized)).toStrictEqual(minimalWorkflow); + }); + + it('should ignore the YAML options when the format is json', () => { + const workflow = new Classes.Workflow(verboseWorkflow); + expect(workflow.serialize({ format: 'json', yaml: { indent: 4, lineWidth: -1, flowLevel: 0 } })).toBe( + workflow.serialize({ format: 'json' }), + ); + }); + + /** + * js-yaml emits `&ref_0`/`*ref_0` anchors for subtrees that are the same object, which `noRefs` + * exists to suppress. `asPlainObject()` runs the document through JSON first and so destroys + * reference identity before the dumper ever sees it, which is why `noRefs` is not offered. If this + * test fails, that conversion has changed and the option is worth adding back. + */ + it('should never emit anchors, which is what makes a noRefs option unnecessary', () => { + const shared = { foo: 'bar' }; + const workflow = new Classes.Workflow({ + ...minimalWorkflow, + do: [{ step1: { set: shared } }, { step2: { set: shared } }], + }); + expect(workflow.serialize()).not.toMatch(/[&*]ref_/); + }); +}); diff --git a/tests/serialization/workflow-serialization.spec.ts b/tests/serialization/workflow-serialization.spec.ts index 6b25acf..6de8ec7 100644 --- a/tests/serialization/workflow-serialization.spec.ts +++ b/tests/serialization/workflow-serialization.spec.ts @@ -189,7 +189,7 @@ describe('Workflow (de)serialization', () => { it('should deserialize JSON', () => { const workflow = Classes.Workflow.deserialize(JSON.stringify(minimalWorkflow)); expect(workflow).toBeInstanceOf(Classes.Workflow); - expect(workflow.serialize('json')).toBe(JSON.stringify(minimalWorkflow)); + expect(workflow.serialize({ format: 'json' })).toBe(JSON.stringify(minimalWorkflow)); }); it('should deserialize YAML', () => { @@ -204,41 +204,45 @@ do: set: foo: bar`); expect(workflow).toBeInstanceOf(Classes.Workflow); - expect(workflow.serialize('json')).toBe(JSON.stringify(minimalWorkflow)); + expect(workflow.serialize({ format: 'json' })).toBe(JSON.stringify(minimalWorkflow)); }); it('should serialize as JSON from static method', () => { const workflow = new Classes.Workflow(minimalWorkflow); - expect(Classes.Workflow.serialize(workflow, 'json')).toEqual(JSON.stringify(minimalWorkflow)); + expect(Classes.Workflow.serialize(workflow, { format: 'json' })).toEqual(JSON.stringify(minimalWorkflow)); }); it('should serialize as JSON from instance method', () => { const workflow = new Classes.Workflow(minimalWorkflow); - expect(workflow.serialize('json')).toEqual(JSON.stringify(minimalWorkflow)); + expect(workflow.serialize({ format: 'json' })).toEqual(JSON.stringify(minimalWorkflow)); }); it('should serialize as JSON from static method from fluently built workflow', () => { const workflow = buildMinimalWorkflow(); - expect(Classes.Workflow.serialize(workflow, 'json')).toEqual(JSON.stringify(workflow)); + expect(Classes.Workflow.serialize(workflow, { format: 'json' })).toEqual(JSON.stringify(workflow)); }); }); describe('Workflow serialization - YAML format', () => { it('should serialize to YAML by default from the instance method', () => { const workflow = new Classes.Workflow(minimalWorkflow); - expectFormatsToAgree(workflow.serialize(), workflow.serialize('json'), minimalWorkflow); + expectFormatsToAgree(workflow.serialize(), workflow.serialize({ format: 'json' }), minimalWorkflow); }); it('should serialize to YAML when the format is explicitly set to yaml', () => { const workflow = new Classes.Workflow(minimalWorkflow); - expectFormatsToAgree(workflow.serialize('yaml'), workflow.serialize('json'), minimalWorkflow); + expectFormatsToAgree( + workflow.serialize({ format: 'yaml' }), + workflow.serialize({ format: 'json' }), + minimalWorkflow, + ); }); it('should serialize to YAML by default from the static method given a class instance', () => { const workflow = new Classes.Workflow(minimalWorkflow); expectFormatsToAgree( Classes.Workflow.serialize(workflow), - Classes.Workflow.serialize(workflow, 'json'), + Classes.Workflow.serialize(workflow, { format: 'json' }), minimalWorkflow, ); }); @@ -246,14 +250,14 @@ describe('Workflow serialization - YAML format', () => { it('should serialize to YAML by default from the static method given a plain object', () => { expectFormatsToAgree( Classes.Workflow.serialize(minimalWorkflow), - Classes.Workflow.serialize(minimalWorkflow, 'json'), + Classes.Workflow.serialize(minimalWorkflow, { format: 'json' }), minimalWorkflow, ); }); it('should serialize a fluently built workflow to YAML', () => { const workflow = buildMinimalWorkflow(); - expectFormatsToAgree(workflow.serialize(), workflow.serialize('json'), minimalWorkflow); + expectFormatsToAgree(workflow.serialize(), workflow.serialize({ format: 'json' }), minimalWorkflow); }); it('should emit block-style YAML with the same key order as the JSON output', () => { @@ -274,7 +278,7 @@ do: it('should serialize a multi-task workflow with switch, try/catch, fork, nested for and timeout to YAML', () => { const workflow = new Classes.Workflow(complexWorkflow); - expectFormatsToAgree(workflow.serialize(), workflow.serialize('json'), complexWorkflow); + expectFormatsToAgree(workflow.serialize(), workflow.serialize({ format: 'json' }), complexWorkflow); }); it('should preserve the types of YAML-ambiguous scalars', () => { @@ -324,33 +328,39 @@ do: const workflow = new Classes.Workflow(complexWorkflow); const roundTripped = Classes.Workflow.deserialize(workflow.serialize()); expect(roundTripped).toBeInstanceOf(Classes.Workflow); - expect(roundTripped.serialize('json')).toBe(workflow.serialize('json')); + expect(roundTripped.serialize({ format: 'json' })).toBe(workflow.serialize({ format: 'json' })); }); }); describe('Workflow serialization - normalize flag', () => { it('should serialize without normalizing when normalize is false (static method)', () => { const workflow = new Classes.Workflow(minimalWorkflow); - expect(JSON.parse(Classes.Workflow.serialize(workflow, 'json', false))).toMatchObject(minimalWorkflow); + expect(JSON.parse(Classes.Workflow.serialize(workflow, { format: 'json', normalize: false }))).toMatchObject( + minimalWorkflow, + ); }); it('should serialize without normalizing when normalize is false (instance method)', () => { const workflow = new Classes.Workflow(minimalWorkflow); - expect(JSON.parse(workflow.serialize('json', false))).toMatchObject(minimalWorkflow); + expect(JSON.parse(workflow.serialize({ format: 'json', normalize: false }))).toMatchObject(minimalWorkflow); }); it('should serialize to YAML without normalizing when normalize is false (static method)', () => { const workflow = new Classes.Workflow(complexWorkflow); expectFormatsToAgree( - Classes.Workflow.serialize(workflow, 'yaml', false), - Classes.Workflow.serialize(workflow, 'json', false), + Classes.Workflow.serialize(workflow, { format: 'yaml', normalize: false }), + Classes.Workflow.serialize(workflow, { format: 'json', normalize: false }), complexWorkflow, ); }); it('should serialize to YAML without normalizing when normalize is false (instance method)', () => { const workflow = new Classes.Workflow(complexWorkflow); - expectFormatsToAgree(workflow.serialize('yaml', false), workflow.serialize('json', false), complexWorkflow); + expectFormatsToAgree( + workflow.serialize({ format: 'yaml', normalize: false }), + workflow.serialize({ format: 'json', normalize: false }), + complexWorkflow, + ); }); }); diff --git a/tools/4_generate-classes.ts b/tools/4_generate-classes.ts index fbb90b4..50a7326 100644 --- a/tools/4_generate-classes.ts +++ b/tools/4_generate-classes.ts @@ -49,6 +49,12 @@ ${hydrationResult.code ? `import { isObject } from '../../utils';` : ''} ${ name === 'Workflow' ? `import * as yaml from 'js-yaml'; +import { + DeserializationOptions, + SerializationOptions, + toSerializationOptions, + toYamlDumpOptions, +} from '../../serialization'; import { buildGraph, Graph, GraphBuildOptions } from '../../graph-builder'; import { convertToMermaidCode } from '../../mermaid-converter';` : '' @@ -113,13 +119,24 @@ export class ${name} extends ${baseClass ? '_' + baseClass : `ObjectHydrator; - validate('Workflow', model); + if (options?.validate ?? true) { + validate('Workflow', model); + } else if (!isObject(model)) { + const actual = model === null ? 'null' : Array.isArray(model) ? 'a sequence' : \`a \${typeof model}\`; + throw new Error(\`The provided text does not describe a workflow: expected a mapping, got \${actual}\`); + } return new Workflow(model) as WorkflowIntersection; } @@ -130,19 +147,38 @@ export class ${name} extends ${baseClass ? '_' + baseClass : `ObjectHydrator, options?: SerializationOptions): string; + + /** + * Serializes the provided workflow to YAML or JSON + * @deprecated Pass a \`SerializationOptions\` object instead, e.g. \`serialize(workflow, { format: 'json' })\` + * @param model The workflow to serialize * @param format The format, 'yaml' or 'json', default is 'yaml' * @param normalize If the workflow should be normalized before serialization, default true * @returns A string representation of the workflow */ + static serialize(model: Partial, format?: 'yaml' | 'json', normalize?: boolean): string; + static serialize( model: Partial, - format: 'yaml' | 'json' = 'yaml', - normalize: boolean = true, + formatOrOptions?: 'yaml' | 'json' | SerializationOptions, + legacyNormalize?: boolean, ): string { + const options = toSerializationOptions(formatOrOptions, legacyNormalize); + const format = options.format ?? 'yaml'; + const shouldNormalize = options.normalize ?? true; + const shouldValidate = options.validate ?? true; const workflow = new Workflow(model); - workflow.validate(); - const plainWorkflow = (normalize ? workflow.normalize() : workflow).asPlainObject(); - return format === 'json' ? JSON.stringify(plainWorkflow) : yaml.dump(plainWorkflow); + if (shouldValidate) { + workflow.validate(); + } + const plainWorkflow = (shouldNormalize ? workflow.normalize() : workflow).asPlainObject(); + return format === 'json' + ? JSON.stringify(plainWorkflow) + : yaml.dump(plainWorkflow, toYamlDumpOptions(options.yaml)); } /** @@ -166,12 +202,25 @@ export class ${name} extends ${baseClass ? '_' + baseClass : `ObjectHydrator, options?: SerializationOptions): string; + /** * Serializes the provided Workflow to YAML or JSON + * @deprecated Pass a \`SerializationOptions\` object instead, e.g. \`serialize(workflow, { format: 'json' })\` * @param workflow The workflow to serialize * @param format The format, 'yaml' or 'json', default is 'yaml' * @param normalize If the workflow should be normalized before serialization, default true