scaffolder: add new template parameter schema endpoint and usage in frontend
Co-authored-by: Johan Haals <johan.haals@gmail.com>
This commit is contained in:
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ScaffolderApi>({
|
||||
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<TemplateParameterSchema>;
|
||||
|
||||
/**
|
||||
* 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<TemplateParameterSchema> {
|
||||
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.
|
||||
|
||||
@@ -32,12 +32,12 @@ import React, { useState } from 'react';
|
||||
const Form = withTheme(MuiTheme);
|
||||
type Step = {
|
||||
schema: JSONSchema;
|
||||
label: string;
|
||||
title: string;
|
||||
} & Partial<Omit<FormProps<any>, '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<string, any>;
|
||||
@@ -66,31 +66,35 @@ export const MultistepJsonForm = ({
|
||||
return (
|
||||
<>
|
||||
<Stepper activeStep={activeStep} orientation="vertical">
|
||||
{steps.map(({ label, schema, ...formProps }) => (
|
||||
<StepUI key={label}>
|
||||
<StepLabel>{label}</StepLabel>
|
||||
<StepContent key={label}>
|
||||
<Form
|
||||
key={label}
|
||||
noHtml5Validate
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
schema={schema as FormProps<any>['schema']}
|
||||
onSubmit={e => {
|
||||
if (e.errors.length === 0) handleNext();
|
||||
}}
|
||||
{...formProps}
|
||||
>
|
||||
<Button disabled={activeStep === 0} onClick={handleBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="contained" color="primary" type="submit">
|
||||
Next step
|
||||
</Button>
|
||||
</Form>
|
||||
</StepContent>
|
||||
</StepUI>
|
||||
))}
|
||||
{steps.map(
|
||||
({ title, schema: { title: _, ...schema }, ...formProps }) => (
|
||||
<StepUI key={title}>
|
||||
<StepLabel>
|
||||
<Typography variant="h6">{title}</Typography>
|
||||
</StepLabel>
|
||||
<StepContent key={title}>
|
||||
<Form
|
||||
key={title}
|
||||
noHtml5Validate
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
schema={schema as FormProps<any>['schema']}
|
||||
onSubmit={e => {
|
||||
if (e.errors.length === 0) handleNext();
|
||||
}}
|
||||
{...formProps}
|
||||
>
|
||||
<Button disabled={activeStep === 0} onClick={handleBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="contained" color="primary" type="submit">
|
||||
Next step
|
||||
</Button>
|
||||
</Form>
|
||||
</StepContent>
|
||||
</StepUI>
|
||||
),
|
||||
)}
|
||||
</Stepper>
|
||||
{activeStep === steps.length && (
|
||||
<Content>
|
||||
|
||||
@@ -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 <Navigate to={rootLink()} />;
|
||||
}
|
||||
|
||||
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 <Navigate to={rootLink()} />;
|
||||
}
|
||||
|
||||
@@ -125,51 +127,24 @@ export const TemplatePage = () => {
|
||||
/>
|
||||
<Content>
|
||||
{loading && <LinearProgress data-testid="loading-progress" />}
|
||||
{template && (
|
||||
<InfoCard title={template.metadata.title} noPadding>
|
||||
{schema && (
|
||||
<InfoCard title={schema.title} noPadding>
|
||||
<MultistepJsonForm
|
||||
formData={formState}
|
||||
onChange={handleChange}
|
||||
onReset={handleFormReset}
|
||||
onFinish={handleCreate}
|
||||
steps={[
|
||||
{
|
||||
label: 'Fill in template parameters',
|
||||
schema: template.spec.schema,
|
||||
},
|
||||
{
|
||||
label: 'Choose owner and repo',
|
||||
schema: OWNER_REPO_SCHEMA,
|
||||
validate: (formData, errors) => {
|
||||
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;
|
||||
})}
|
||||
/>
|
||||
</InfoCard>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user