Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets': patch
---

Make fetch-driven form widgets resilient to invalid or partial JSONata and dynamic selector input so the UI no longer crashes while users edit fields.
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { applySelectorArray } from './applySelector';
import { applySelectorArray, applySelectorString } from './applySelector';
import { JsonObject } from '@backstage/types';

describe('applySelectorArray', () => {
Expand Down Expand Up @@ -258,6 +258,79 @@ describe('applySelectorArray', () => {
});
});

describe('applySelectorString', () => {
const data: JsonObject = { status: 'UP', nested: { name: 'x' } };

it('returns string when selector evaluates to a string', async () => {
await expect(applySelectorString(data, 'status')).resolves.toBe('UP');
});

it('throws when selector is missing and emptyStringWhenMissing is false', async () => {
await expect(applySelectorString(data, 'doesNotExist')).rejects.toThrow(
'Unexpected result of "doesNotExist" selector, expected string type',
);
});

it('returns empty string when selector is missing and emptyStringWhenMissing is true', async () => {
await expect(applySelectorString(data, 'doesNotExist', true)).resolves.toBe(
'',
);
});

it('returns empty string when JSONata yields null and emptyStringWhenMissing is true', async () => {
const withNull: JsonObject = { absent: null };
await expect(applySelectorString(withNull, 'absent', true)).resolves.toBe(
'',
);
});

it('returns empty string when JSONata yields a number and emptyStringWhenMissing is true', async () => {
const withNum: JsonObject = { n: 42 };
await expect(applySelectorString(withNum, 'n', true)).resolves.toBe('');
});

it('returns empty string for invalid JSONata syntax when emptyStringWhenMissing is true', async () => {
await expect(
applySelectorString({} as JsonObject, '.', true),
).resolves.toBe('');
await expect(
applySelectorString({} as JsonObject, '/', true),
).resolves.toBe('');
});

it('throws a clear error for invalid JSONata syntax when strict', async () => {
await expect(applySelectorString({} as JsonObject, '.')).rejects.toThrow(
'Invalid JSONata',
);
});
});

describe('applySelectorArray invalid JSONata', () => {
const data: JsonObject = { args: { tag: ['a'] } };

it('returns empty array for invalid syntax when emptyArrayIfNeeded is true', async () => {
await expect(
applySelectorArray(data, '.', true, true),
).resolves.toStrictEqual([]);
await expect(
applySelectorArray(data, '/', true, true),
).resolves.toStrictEqual([]);
});

it('throws for invalid syntax when not lenient', async () => {
await expect(applySelectorArray(data, '.')).rejects.toThrow(
'Invalid JSONata',
);
});

it('returns empty array when JSONata yields a number and emptyArrayIfNeeded is true', async () => {
const withNum: JsonObject = { n: 7 };
await expect(
applySelectorArray(withNum, 'n', true, true),
).resolves.toStrictEqual([]);
});
});

describe('applySelectorArray - complex queries', () => {
it('handles complex queries', async () => {
const data = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,46 @@ import jsonata from 'jsonata';
import { JsonArray, JsonObject } from '@backstage/types';
import { isJsonObject } from '@red-hat-developer-hub/backstage-plugin-orchestrator-common';

const jsonataReason = (reason: unknown): string =>
reason instanceof Error ? reason.message : String(reason);

const compileJsonata = (selector: string) => {
try {
return jsonata(selector);
} catch (reason) {
throw new Error(
`Invalid JSONata selector ${JSON.stringify(selector)}: ${jsonataReason(reason)}`,
);
}
};

export const applySelectorArray = async (
data: JsonObject | JsonArray,
selector: string,
createArrayIfNeeded: boolean = false,
emptyArrayIfNeeded: boolean = false,
): Promise<string[]> => {
const expression = jsonata(selector);
const value = await expression.evaluate(data);
let expression;
try {
expression = compileJsonata(selector);
} catch (reason) {
if (emptyArrayIfNeeded) {
return [];
}
throw reason;
}

let value;
try {
value = await expression.evaluate(data);
} catch (reason) {
if (emptyArrayIfNeeded) {
return [];
}
throw new Error(
`JSONata evaluation failed for ${JSON.stringify(selector)}: ${jsonataReason(reason)}`,
);
}

if (emptyArrayIfNeeded && !value) {
return [];
Expand All @@ -38,6 +70,11 @@ export const applySelectorArray = async (
return [...value];
}

if (emptyArrayIfNeeded) {
// Dynamic selectors while typing may yield numbers, objects, or mixed arrays.
return [];
}

throw new Error(
`Unexpected result of "${selector}" selector, expected string[] type. Value ${JSON.stringify(value)}`,
);
Expand All @@ -46,14 +83,39 @@ export const applySelectorArray = async (
export const applySelectorString = async (
data: JsonObject,
selector: string,
emptyStringWhenMissing: boolean = false,
): Promise<string> => {
const expression = jsonata(selector);
const value = await expression.evaluate(data);
let expression;
try {
expression = compileJsonata(selector);
} catch (reason) {
if (emptyStringWhenMissing) {
return '';
}
throw reason;
}

let value;
try {
value = await expression.evaluate(data);
} catch (reason) {
if (emptyStringWhenMissing) {
return '';
}
throw new Error(
`JSONata evaluation failed for ${JSON.stringify(selector)}: ${jsonataReason(reason)}`,
);
}

if (typeof value === 'string') {
return value;
}

if (emptyStringWhenMissing) {
// Missing path, numbers/booleans from literals (e.g. selector "1"), objects, etc.
return '';
}

throw new Error(
`Unexpected result of "${selector}" selector, expected string type. Value "${JSON.stringify(value)}"`,
);
Expand All @@ -63,8 +125,15 @@ export const applySelectorObject = async (
data: JsonObject,
selector: string,
): Promise<JsonObject> => {
const expression = jsonata(selector);
const value = await expression.evaluate(data);
const expression = compileJsonata(selector);
let value;
try {
value = await expression.evaluate(data);
} catch (reason) {
throw new Error(
`JSONata evaluation failed for ${JSON.stringify(selector)}: ${jsonataReason(reason)}`,
);
}

if (isJsonObject(value)) {
return value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,4 +278,48 @@ describe('evaluate template', () => {
}),
).resolves.toBe("a in $split(a,b, ',') ? 'update' : 'create'");
});

it('evaluateFetchResponseSelectorTemplate stringifies numeric template results for JSONata', async () => {
await expect(
evaluateFetchResponseSelectorTemplate({
unitEvaluator: unitEvaluatorAsInWidgets,
key: 'fetch:response:value',
formData: { step: { sel: 1 } },
template: '$${{current.step.sel}}',
}),
).resolves.toBe('1');
});

it('evaluateFetchResponseSelectorTemplate returns empty string for null template expansion', async () => {
await expect(
evaluateFetchResponseSelectorTemplate({
unitEvaluator: async () => null,
key: 'fetch:response:value',
formData: {},
template: '$${{x}}',
}),
).resolves.toBe('');
});

it('evaluateFetchResponseSelectorTemplate returns empty string for solo object expansion', async () => {
await expect(
evaluateFetchResponseSelectorTemplate({
unitEvaluator: async () => ({ a: 1 }),
key: 'fetch:response:value',
formData: {},
template: '$${{x}}',
}),
).resolves.toBe('');
});

it('evaluateFetchResponseSelectorTemplate returns empty string on malformed template', async () => {
await expect(
evaluateFetchResponseSelectorTemplate({
unitEvaluator: unitEvaluatorAsInWidgets,
key: 'fetch:response:value',
formData: {},
template: '$${{foo',
}),
).resolves.toBe('');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,25 @@ export const evaluateTemplateString = async (
export const evaluateFetchResponseSelectorTemplate = async (
props: evaluateTemplateStringProps,
): Promise<string> => {
const evaluated = await evaluateTemplateString(props);
if (typeof evaluated !== 'string') {
throw new Error(
`Template evaluation for "${props.key}" must produce a string (JSONata expression), got ${typeof evaluated}`,
);
let evaluated: JsonValue;
try {
evaluated = await evaluateTemplateString(props);
} catch {
// Malformed `$${{…}}` or evaluator errors while editing should not break fetch widgets.
return '';
}
return evaluated;
if (typeof evaluated === 'string') {
return evaluated;
}
if (evaluated === undefined || evaluated === null) {
return '';
}
if (typeof evaluated === 'number' || typeof evaluated === 'boolean') {
// Form fields may be numeric/boolean while the selector must be JSONata text.
return String(evaluated);
}
// Solo object/array from template expansion is not a usable JSONata selector string.
return '';
};

export const evaluateTemplate = async (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,26 @@ describe('getRequestInit', () => {
name: 'Zara',
});
});

it('omits fetch:body fields when jsonata: expression is invalid syntax', async () => {
const uiProps: JsonObject = {
'fetch:method': 'POST',
'fetch:body': {
ok: 'literal-only',
badCompile: 'jsonata:.',
badSlash: 'jsonata:/',
},
};
const formData: JsonObject = {};

const requestInit = await getRequestInit(
uiProps,
'fetch',
unitEvaluator,
formData,
);
expect(JSON.parse(requestInit.body as string)).toEqual({
ok: 'literal-only',
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,16 @@ const evaluateJsonataInValue = async (
if (!expression) {
return value;
}
const compiled = jsonata(expression);
const evaluated = await compiled.evaluate(formData);
return evaluated === undefined ? UNDEFINED_VALUE : (evaluated as JsonValue);
try {
const compiled = jsonata(expression);
const evaluated = await compiled.evaluate(formData);
return evaluated === undefined
? UNDEFINED_VALUE
: (evaluated as JsonValue);
} catch {
// Invalid or failing JSONata (e.g. user-typed fragments) must not break fetch body evaluation.
return UNDEFINED_VALUE;
}
}
if (Array.isArray(value)) {
const evaluatedArray = await Promise.all(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export const ActiveTextInput: Widget<
const fetchedValue = await applySelectorString(
data,
resolvedSelector,
true,
);

if (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,19 @@ export const SchemaUpdater: Widget<
responseData: data,
uiProps,
});
typedData = (await applySelectorObject(
data,
resolvedSelector,
)) as unknown as SchemaChunksResponse;
if (resolvedSelector.trim()) {
try {
typedData = (await applySelectorObject(
data,
resolvedSelector,
)) as unknown as SchemaChunksResponse;
} catch (reason) {
setLocalError(
reason instanceof Error ? reason.message : String(reason),
);
return;
}
}
}

// validate received response before updating
Expand Down
Loading