diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 358d571000..2cee30fc87 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -232,6 +232,77 @@ export async function createRouter( // NOTE: The v2 API is unstable router + .get( + '/v2/templates/:namespace/:kind/:name/parameter-schema', + async (req, res) => { + const { namespace, kind, name } = req.params; + + if (namespace !== 'default') { + throw new InputError( + `Invalid namespace, only 'default' namespace is supported`, + ); + } + if (kind.toLowerCase() !== 'template') { + throw new InputError( + `Invalid kind, only 'template' kind is supported`, + ); + } + + const template = await entityClient.findTemplate(name); + if (template.apiVersion === 'backstage.io/v1beta1') { + const betaTemplate = template as any; // TODO: proper type + const parameters = betaTemplate.spec.schema; + const steps = Array.isArray(parameters) ? parameters : [parameters]; + res.json({ + title: template.metadata.title ?? template.metadata.name, + steps: steps.map(schema => ({ + title: schema.title ?? 'Fill in template parameters', + schema, + })), + }); + } else if (template.apiVersion === 'backstage.io/v1alpha1') { + res.json({ + title: template.metadata.title ?? template.metadata.name, + steps: [ + { + title: 'Fill in template parameters', + schema: template.spec.schema, + }, + { + title: 'Choose owner and repo', + schema: { + type: 'object', + required: ['storePath', 'owner'], + properties: { + owner: { + type: 'string', + title: 'Owner', + description: 'Who is going to own this component', + }, + storePath: { + type: 'string', + title: 'Store path', + description: + 'A full URL to the repository that should be created. e.g https://github.com/backstage/new-repo', + }, + access: { + type: 'string', + title: 'Access', + description: + 'Who should have access, in org/team or user format', + }, + }, + }, + }, + ], + }); + } else { + throw new InputError( + `Unsupported apiVersion field in schema entity, ${template.apiVersion}`, + ); + } + }, + ) .post('/v2/tasks', async (req, res) => { const templateName: string = req.body.templateName; const values: TemplaterValues = { @@ -266,7 +337,7 @@ export async function createRouter( }; } else { throw new InputError( - `Unknown apiVersion field in schema entity, ${template.apiVersion}`, + `Unsupported apiVersion field in schema entity, ${template.apiVersion}`, ); } diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 20fca5b3f2..ac165e021a 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { EntityName } from '@backstage/catalog-model'; +import { JsonObject } from '@backstage/config'; import { createApiRef, DiscoveryApi, @@ -28,6 +30,14 @@ export const scaffolderApiRef = createApiRef({ description: 'Used to make requests towards the scaffolder backend', }); +type TemplateParameterSchema = { + title: string; + steps: Array<{ + title: string; + schema: JsonObject; + }>; +}; + export type LogEvent = { type: 'log' | 'completion'; body: { @@ -41,6 +51,10 @@ export type LogEvent = { }; export interface ScaffolderApi { + getTemplateParameterSchema( + templateName: EntityName, + ): Promise; + /** * Executes the scaffolding of a component, given a template and its * parameter values. @@ -72,6 +86,34 @@ export class ScaffolderClient implements ScaffolderApi { this.identityApi = options.identityApi; } + async getTemplateParameterSchema( + templateName: EntityName, + ): Promise { + const { namespace, kind, name } = templateName; + + const token = await this.identityApi.getIdToken(); + const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); + const templatePath = [namespace, kind, name] + .map(s => encodeURIComponent(s)) + .join('/'); + const url = `${baseUrl}/v2/templates/${templatePath}/parameter-schema`; + + const response = await fetch(url, { + headers: { + ...(token && { Authorization: `Bearer ${token}` }), + }, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch template parameter schema, ${await response.text()}`, + ); + } + + const schema: TemplateParameterSchema = await response.json(); + return schema; + } + /** * Executes the scaffolding of a component, given a template and its * parameter values. diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index 27c5b4f18a..17acc1e8dd 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -32,12 +32,12 @@ import React, { useState } from 'react'; const Form = withTheme(MuiTheme); type Step = { schema: JSONSchema; - label: string; + title: string; } & Partial, 'schema'>>; type Props = { /** - * Steps for the form, each contains label and form schema + * Steps for the form, each contains title and form schema */ steps: Step[]; formData: Record; @@ -66,31 +66,35 @@ export const MultistepJsonForm = ({ return ( <> - {steps.map(({ label, schema, ...formProps }) => ( - - {label} - -
['schema']} - onSubmit={e => { - if (e.errors.length === 0) handleNext(); - }} - {...formProps} - > - - -
-
-
- ))} + {steps.map( + ({ title, schema: { title: _, ...schema }, ...formProps }) => ( + + + {title} + + +
['schema']} + onSubmit={e => { + if (e.errors.length === 0) handleNext(); + }} + {...formProps} + > + + +
+
+
+ ), + )}
{activeStep === steps.length && ( diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 0fc1a3cf5d..1c8a662acb 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; import { Content, errorApiRef, @@ -24,9 +23,8 @@ import { useApi, useRouteRef, } from '@backstage/core'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { LinearProgress } from '@material-ui/core'; -import { IChangeEvent } from '@rjsf/core'; +import { FormValidation, IChangeEvent } from '@rjsf/core'; import parseGitUrl from 'git-url-parse'; import React, { useCallback, useState } from 'react'; import { generatePath, useNavigate, Navigate } from 'react-router'; @@ -36,50 +34,59 @@ import { scaffolderApiRef } from '../../api'; import { rootRouteRef } from '../../routes'; import { MultistepJsonForm } from '../MultistepJsonForm'; -const useTemplate = ( - templateName: string, - catalogApi: typeof catalogApiRef.T, -) => { - const { value, loading, error } = useAsync(async () => { - const response = await catalogApi.getEntities({ - filter: { kind: 'Template', 'metadata.name': templateName }, - }); - return response.items as TemplateEntityV1alpha1[]; - }, [catalogApi, templateName]); - return { template: value?.[0], loading, error }; +const useTemplateParameterSchema = (templateName: string) => { + const scaffolderApi = useApi(scaffolderApiRef); + const { value, loading, error } = useAsync( + () => + scaffolderApi.getTemplateParameterSchema({ + name: templateName, + kind: 'template', + namespace: 'default', + }), + [scaffolderApi, templateName], + ); + return { schema: value, loading, error }; }; -const OWNER_REPO_SCHEMA = { - $schema: 'http://json-schema.org/draft-07/schema#' as const, - required: ['storePath', 'owner'], - properties: { - owner: { - type: 'string' as const, - title: 'Owner', - description: 'Who is going to own this component', - }, - storePath: { - type: 'string' as const, - title: 'Store path', - description: - 'A full URL to the repository that should be created. e.g https://github.com/backstage/new-repo', - }, - access: { - type: 'string' as const, - title: 'Access', - description: 'Who should have access, in org/team or user format', - }, - }, +const storePathValidator = ( + formData: { storePath?: string }, + errors: FormValidation, +) => { + const { storePath } = formData; + if (!storePath) { + return errors; + } + + try { + const parsedUrl = parseGitUrl(storePath); + + if (!parsedUrl.resource || !parsedUrl.owner || !parsedUrl.name) { + if (parsedUrl.resource === 'dev.azure.com') { + errors.storePath.addError( + "The store path should be formatted like https://dev.azure.com/{org}/{project}/_git/{repo} for Azure URL's", + ); + } else { + errors.storePath.addError( + 'The store path should be a complete Git URL to the new repository location. For example: https://github.com/{owner}/{repo}', + ); + } + } + } catch (ex) { + errors.storePath.addError( + `Failed validation of the store path with message ${ex.message}`, + ); + } + + return errors; }; export const TemplatePage = () => { const errorApi = useApi(errorApiRef); - const catalogApi = useApi(catalogApiRef); const scaffolderApi = useApi(scaffolderApiRef); const { templateName } = useParams(); const navigate = useNavigate(); const rootLink = useRouteRef(rootRouteRef); - const { template, loading } = useTemplate(templateName, catalogApi); + const { schema, loading, error } = useTemplateParameterSchema(templateName); const [formState, setFormState] = useState({}); const handleFormReset = () => setFormState({}); @@ -98,17 +105,12 @@ export const TemplatePage = () => { } }; - if (!loading && !template) { - errorApi.post(new Error('Template was not found.')); + if (error) { + errorApi.post(new Error(`Failed to load template, ${error}`)); return ; } - - if (template && !template?.spec?.schema) { - errorApi.post( - new Error( - 'Template schema is corrupted, please check the template.yaml file.', - ), - ); + if (!loading && !schema) { + errorApi.post(new Error('Template was not found.')); return ; } @@ -125,51 +127,24 @@ export const TemplatePage = () => { /> {loading && } - {template && ( - + {schema && ( + { - const { storePath } = formData; - try { - const parsedUrl = parseGitUrl(storePath); - - if ( - !parsedUrl.resource || - !parsedUrl.owner || - !parsedUrl.name - ) { - if (parsedUrl.resource === 'dev.azure.com') { - errors.storePath.addError( - "The store path should be formatted like https://dev.azure.com/{org}/{project}/_git/{repo} for Azure URL's", - ); - } else { - errors.storePath.addError( - 'The store path should be a complete Git URL to the new repository location. For example: https://github.com/{owner}/{repo}', - ); - } - } - } catch (ex) { - errors.storePath.addError( - `Failed validation of the store pathn with message ${ex.message}`, - ); - } - - return errors; - }, - }, - ]} + steps={schema.steps.map(step => { + // TODO: Using this workaround to keep storePath validation, but we should replace + // it with a custom store path selection widget + if ((step.schema as any)?.properties?.storePath) { + return { + ...step, + validate: (a, b) => storePathValidator(a, b), + }; + } + return step; + })} /> )}