Skip to content

Commit 67cc45a

Browse files
authored
feat(orchestrator): form widgets templating, SchemaUpdater and retrigger (#697)
* feat(orchestrator): add templating and request composition tooling Signed-off-by: Marek Libra <marek.libra@gmail.com> * evaluateTemplate * yarn.lock * set backstage-plugin-orchestrator-form-widgets pckg to public * Introduce useTemplateUnitEvaluator() * API reports --------- Signed-off-by: Marek Libra <marek.libra@gmail.com>
1 parent 03f50e2 commit 67cc45a

14 files changed

Lines changed: 570 additions & 42 deletions

File tree

workspaces/orchestrator/plugins/README.md

Lines changed: 0 additions & 9 deletions
This file was deleted.

workspaces/orchestrator/plugins/orchestrator-form-widgets/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
"name": "@red-hat-developer-hub/backstage-plugin-orchestrator-form-widgets",
33
"version": "0.1.0",
44
"license": "Apache-2.0",
5-
"private": true,
65
"main": "src/index.ts",
76
"types": "src/index.ts",
87
"publishConfig": {
@@ -62,6 +61,7 @@
6261
"@red-hat-developer-hub/backstage-plugin-orchestrator-form-api": "workspace:^",
6362
"@rjsf/utils": "^5.21.2",
6463
"json-schema": "^0.4.0",
64+
"lodash": "^4.17.21",
6565
"react-use": "^17.2.4"
6666
},
6767
"peerDependencies": {

workspaces/orchestrator/plugins/orchestrator-form-widgets/report.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const orchestratorFormWidgetsPlugin: BackstagePlugin< {}, {}, {}>;
1313

1414
// Warnings were encountered during analysis:
1515
//
16-
// src/plugin.d.ts:6:22 - (ae-undocumented) Missing documentation for "orchestratorFormWidgetsPlugin".
16+
// src/plugin.d.ts:5:22 - (ae-undocumented) Missing documentation for "orchestratorFormWidgetsPlugin".
1717

1818
// (No @packageDocumentation comment for this package)
1919

workspaces/orchestrator/plugins/orchestrator-form-widgets/src/FormWidgetsApi.tsx

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from '@red-hat-developer-hub/backstage-plugin-orchestrator-form-api';
2424
import { ErrorSchema, FormValidation } from '@rjsf/utils';
2525
import { JsonObject, JsonValue } from '@backstage/types';
26+
2627
import { SchemaUpdater, ActiveTextInput } from './widgets';
2728

2829
const sleep = (ms: number) => {
@@ -33,7 +34,7 @@ const customValidate = (
3334
_formData: JsonObject | undefined,
3435
errors: FormValidation<JsonObject>,
3536
): FormValidation<JsonObject> => {
36-
// Trigger field validation
37+
// TODO: Trigger field validation
3738
// Called synchronously
3839
return errors;
3940
};
@@ -86,23 +87,19 @@ const safeSet: (errors: JsonObject, path: string, value: JsonValue) => void = (
8687
safeSet(safeObject, steps[1], value);
8788
}
8889
};
89-
export class FormWidgetsApi implements OrchestratorFormApi {
90-
// private readonly configApi: ConfigApi;
91-
// private readonly fetchApi: FetchApi;
9290

93-
// public constructor(options: { configApi: ConfigApi; fetchApi: FetchApi }) {
94-
// this.configApi = options.configApi;
95-
// this.fetchApi = options.fetchApi;
96-
// }
91+
const widgets = {
92+
SchemaUpdater,
93+
ActiveTextInput,
94+
};
9795

96+
export class FormWidgetsApi implements OrchestratorFormApi {
9897
getFormDecorator: OrchestratorFormApi['getFormDecorator'] = () => {
9998
return (FormComponent: React.ComponentType<FormDecoratorProps>) => {
10099
return () => {
101100
const { formData, setFormData, uiSchema } =
102101
useWrapperFormPropsContext();
103102

104-
const widgets = { SchemaUpdater, ActiveTextInput };
105-
106103
const onChange = useCallback(
107104
(data: JsonObject | undefined) => {
108105
if (data) {
@@ -118,6 +115,8 @@ export class FormWidgetsApi implements OrchestratorFormApi {
118115
currentFormData: JsonObject,
119116
) => {
120117
// Asynchronous validation on wizard step transition or submit
118+
119+
// TODO
121120
return sleep(1000 /* The sleep mimics async fetch, remove it */).then(
122121
() => {
123122
const errors: ErrorSchema<JsonObject> = {};

workspaces/orchestrator/plugins/orchestrator-form-widgets/src/plugin.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,14 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
import {
17-
configApiRef,
18-
createApiFactory,
19-
createPlugin,
20-
fetchApiRef,
21-
} from '@backstage/core-plugin-api';
16+
import { createApiFactory, createPlugin } from '@backstage/core-plugin-api';
2217
import { orchestratorFormApiRef } from '@red-hat-developer-hub/backstage-plugin-orchestrator-form-api';
2318
import { FormWidgetsApi } from './FormWidgetsApi';
2419

2520
export const formApiFactory = createApiFactory({
2621
api: orchestratorFormApiRef,
27-
deps: { configApi: configApiRef, fetchApi: fetchApiRef },
28-
factory(_options) {
22+
deps: {},
23+
factory() {
2924
return new FormWidgetsApi();
3025
},
3126
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { evaluateTemplate, evaluateTemplateProps } from './evaluateTemplate';
18+
19+
const unitEvaluator: evaluateTemplateProps['unitEvaluator'] = async (
20+
unit,
21+
_,
22+
) => {
23+
if (!unit) {
24+
throw new Error('Template unit can not be empty');
25+
}
26+
27+
// Just a copy for testing purposes
28+
return Promise.resolve(unit);
29+
};
30+
31+
describe('evaluate template', () => {
32+
const props = { unitEvaluator, key: 'myKey', formData: {} };
33+
34+
it('fails on incorrect input', async () => {
35+
const cases = [
36+
{ input: '', throws: 'Template can be a string only, key: myKey' },
37+
{ input: undefined, throws: 'Template can be a string only, key: myKey' },
38+
{ input: '$${{}}', throws: 'Template unit can not be empty' },
39+
{ input: '$${{foo', throws: 'Template unit is not closed by }}' },
40+
];
41+
42+
await Promise.all(
43+
cases.map(c =>
44+
expect(
45+
evaluateTemplate({ ...props, template: c.input }),
46+
).rejects.toThrow(c.throws),
47+
),
48+
);
49+
});
50+
51+
it('can parse input template to units', async () => {
52+
const cases = [
53+
{ input: 'zz', expected: 'zz' },
54+
{ input: '}}', expected: '}}' },
55+
{ input: '${{foo}}', expected: '${{foo}}' },
56+
{ input: '$${{foo}}', expected: 'foo' },
57+
{ input: '$${{foo}} $${{bar}}', expected: 'foo bar' },
58+
{ input: '$${{foo}}$${{bar}}', expected: 'foobar' },
59+
{ input: ' $${{foo}}$${{bar}} ', expected: ' foobar ' },
60+
{ input: 'a$${{foo}}$${{bar}} b', expected: 'afoobar b' },
61+
{ input: 'a$${{foo}}$${{bar}} b$${{zz}}', expected: 'afoobar bzz' },
62+
];
63+
64+
await Promise.all(
65+
cases.map(c =>
66+
expect(evaluateTemplate({ ...props, template: c.input })).resolves.toBe(
67+
c.expected,
68+
),
69+
),
70+
);
71+
});
72+
73+
it('can parse input template to units without nesting', async () => {
74+
await expect(
75+
evaluateTemplate({ ...props, template: 'a$${{foo}}$${{$${{xx}}}}b' }),
76+
).resolves.toBe('afoo$${{xx}}b');
77+
});
78+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { JsonObject, JsonValue } from '@backstage/types';
18+
19+
export type evaluateTemplateProps = {
20+
template?: JsonValue;
21+
key: string;
22+
unitEvaluator: (
23+
unit: string,
24+
formData: JsonObject,
25+
) => Promise<JsonValue | undefined>;
26+
formData: JsonObject;
27+
};
28+
29+
export const evaluateTemplate = async (
30+
props: evaluateTemplateProps,
31+
): Promise<string> => {
32+
const { template, key, unitEvaluator, formData } = props;
33+
34+
if (!template || typeof template !== 'string') {
35+
throw new Error(`Template can be a string only, key: ${key}`);
36+
}
37+
38+
let evaluated;
39+
const startIndex = template.indexOf('$${{');
40+
if (startIndex < 0) {
41+
evaluated = template;
42+
} else {
43+
evaluated = template.substring(0, startIndex);
44+
const stopIndex = template.indexOf('}}');
45+
if (stopIndex < 0) {
46+
throw new Error(`Template unit is not closed by }}`);
47+
}
48+
evaluated += await unitEvaluator(
49+
template.substring(startIndex + 4, stopIndex),
50+
formData,
51+
);
52+
if (template.length > stopIndex + 2) {
53+
evaluated += await evaluateTemplate({
54+
...props,
55+
template: template.substring(stopIndex + 2),
56+
});
57+
}
58+
}
59+
60+
return evaluated;
61+
};
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import { JsonObject } from '@backstage/types';
17+
import { evaluateTemplate, evaluateTemplateProps } from './evaluateTemplate';
18+
19+
const ALLOWED_METHODS = ['GET', 'POST'];
20+
21+
export const getRequestInit = async (
22+
uiProps: JsonObject,
23+
prefix: string,
24+
unitEvaluator: evaluateTemplateProps['unitEvaluator'],
25+
formData: JsonObject,
26+
): Promise<RequestInit> => {
27+
const requestInit: RequestInit = {};
28+
29+
const method = uiProps[`${prefix}:method`]?.toString().toLocaleUpperCase();
30+
if (method) {
31+
if (!ALLOWED_METHODS.includes(method)) {
32+
throw new Error(
33+
`Unsupported HTTP method, use one of ${ALLOWED_METHODS.join(', ')}`,
34+
);
35+
}
36+
requestInit.method = method;
37+
}
38+
39+
const body = uiProps[`${prefix}:body`];
40+
if (body) {
41+
if (method === 'POST') {
42+
if (typeof body === 'object') {
43+
const bodyObject = body as JsonObject;
44+
const evaluated: JsonObject = {};
45+
46+
const keys = Object.keys(body);
47+
const values = await Promise.all(
48+
keys.map(key =>
49+
evaluateTemplate({
50+
unitEvaluator,
51+
key,
52+
formData,
53+
template: bodyObject[key],
54+
}),
55+
),
56+
);
57+
keys.forEach((key, idx) => {
58+
evaluated[key] = values[idx];
59+
});
60+
61+
const bodyInit: BodyInit = JSON.stringify(evaluated);
62+
requestInit.body = bodyInit;
63+
} else {
64+
throw new Error(`${prefix}:body must be object`);
65+
}
66+
} else {
67+
throw new Error(`${prefix}:body can be used with POST requests only`);
68+
}
69+
}
70+
71+
const headers = uiProps[`${prefix}:headers`];
72+
if (headers) {
73+
if (typeof headers === 'object') {
74+
const headersObject = headers as JsonObject;
75+
const headersInit: HeadersInit = {};
76+
77+
const keys = Object.keys(headers);
78+
const values = await Promise.all(
79+
keys.map(key =>
80+
evaluateTemplate({
81+
unitEvaluator,
82+
key,
83+
formData,
84+
template: headersObject[key],
85+
}),
86+
),
87+
);
88+
keys.forEach((key, idx) => {
89+
headersInit[key] = values[idx];
90+
});
91+
92+
requestInit.headers = headersInit;
93+
} else {
94+
throw new Error('fetch:body must be object for POST requests');
95+
}
96+
}
97+
98+
return requestInit;
99+
};
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
export * from './getRequestInit';
17+
export * from './evaluateTemplate';
18+
export * from './useRetriggerEvaluate';
19+
export * from './useTemplateUnitEvaluator';

0 commit comments

Comments
 (0)