Merge pull request #5342 from backstage/feat/scaffolding-helpers

Added parsing of JSON + Scaffolding helpers
This commit is contained in:
Patrik Oldsberg
2021-04-15 13:46:41 +02:00
committed by GitHub
6 changed files with 284 additions and 13 deletions
+38
View File
@@ -0,0 +1,38 @@
---
'@backstage/plugin-scaffolder-backend': patch
---
Enable the JSON parsing of the response from templated variables in the `v2beta1` syntax. Previously if template parameters json strings they were left as strings, they are now parsed as JSON objects.
Before:
```yaml
- id: test
name: test-action
action: custom:run
input:
input: '{"hello":"ben"}'
```
Now:
```yaml
- id: test
name: test-action
action: custom:run
input:
input:
hello: ben
```
Also added the `parseRepoUrl` and `json` helpers to the parameters syntax. You can now use these helpers to parse work with some `json` or `repoUrl` strings in templates.
```yaml
- id: test
name: test-action
action: cookiecutter:fetch
input:
destination: '{{ parseRepoUrl parameters.repoUrl }}'
```
Will produce a parsed version of the `repoUrl` of type `{ repo: string, owner: string, host: string }` that you can use in your actions. Specifically `cookiecutter` with `{{ cookiecutter.destination.owner }}` like the `plugins/scaffolder-backend/sample-templates/v1beta2-demo/template.yaml` example.
@@ -50,6 +50,7 @@ spec:
values:
name: '{{ parameters.name }}'
owner: '{{ parameters.owner }}'
destination: '{{ parseRepoUrl parameters.repoUrl }}'
- id: fetch-docs
name: Fetch Docs
@@ -2,6 +2,7 @@ apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: {{cookiecutter.name | jsonify}}
github.com/project-slug: {{cookiecutter.destination.owner + "/" + cookiecutter.destination.repo}}
spec:
type: website
lifecycle: experimental
@@ -30,8 +30,14 @@ export const getRepoSourceDirectory = (
}
return workspacePath;
};
export type RepoSpec = {
repo: string;
host: string;
owner: string;
organization?: string;
};
export const parseRepoUrl = (repoUrl: string) => {
export const parseRepoUrl = (repoUrl: string): RepoSpec => {
let parsed;
try {
parsed = new URL(`https://${repoUrl}`);
@@ -55,7 +61,7 @@ export const parseRepoUrl = (repoUrl: string) => {
);
}
const organization = parsed.searchParams.get('organization');
const organization = parsed.searchParams.get('organization') ?? undefined;
return { host, owner, repo, organization };
};
@@ -24,6 +24,7 @@ import { ConfigReader, JsonObject } from '@backstage/config';
import { StorageTaskBroker } from './StorageTaskBroker';
import { DatabaseTaskStore } from './DatabaseTaskStore';
import { createTemplateAction, TemplateActionRegistry } from '../actions';
import { RepoSpec } from '../actions/builtin/publish/util';
async function createStore(): Promise<DatabaseTaskStore> {
const manager = SingleConnectionDatabaseManager.fromConfig(
@@ -41,20 +42,24 @@ async function createStore(): Promise<DatabaseTaskStore> {
describe('TaskWorker', () => {
let storage: DatabaseTaskStore;
let actionRegistry = new TemplateActionRegistry();
beforeAll(async () => {
storage = await createStore();
});
const logger = getVoidLogger();
const actionRegistry = new TemplateActionRegistry();
actionRegistry.register({
id: 'test-action',
handler: async ctx => {
ctx.output('testOutput', 'winning');
},
beforeEach(() => {
actionRegistry = new TemplateActionRegistry();
actionRegistry.register({
id: 'test-action',
handler: async ctx => {
ctx.output('testOutput', 'winning');
},
});
});
const logger = getVoidLogger();
it('should fail when action does not exist', async () => {
const broker = new StorageTaskBroker(storage, logger);
const taskWorker = new TaskWorker({
@@ -166,4 +171,194 @@ describe('TaskWorker', () => {
const event = events.find(e => e.type === 'completion');
expect((event?.body?.output as JsonObject).result).toBe('winning');
});
it('should parse strings as objects if possible', async () => {
const inputAction = createTemplateAction<{
address: { line1: string };
list: string[];
address2: string;
}>({
id: 'test-input',
schema: {
input: {
type: 'object',
required: ['address'],
properties: {
address: {
title: 'address',
description: 'Enter name',
type: 'object',
properties: {
line1: {
type: 'string',
},
},
},
address2: {
type: 'string',
},
list: {
type: 'array',
items: {
type: 'string',
},
},
},
},
},
async handler(ctx) {
if (ctx.input.list.length !== 1) {
throw new Error(
`expected list to have length "1" got ${ctx.input.list.length}`,
);
}
if (ctx.input.address.line1 !== 'line 1') {
throw new Error(
`expected address.line1 to be "line 1" got ${ctx.input.address.line1}`,
);
}
if (ctx.input.address2 !== '{"not valid"}') {
throw new Error(
`expected address2 to be "{"not valid"}" got ${ctx.input.address2}`,
);
}
ctx.output('address', ctx.input.address.line1);
},
});
actionRegistry.register(inputAction);
const broker = new StorageTaskBroker(storage, logger);
const taskWorker = new TaskWorker({
logger,
workingDirectory: os.tmpdir(),
actionRegistry,
taskBroker: broker,
});
const { taskId } = await broker.dispatch({
steps: [
{
id: 'test-input',
name: 'test-input',
action: 'test-input',
input: {
address: JSON.stringify({ line1: 'line 1' }),
list: JSON.stringify(['hey!']),
address2: '{"not valid"}',
},
},
],
output: {
result: '{{ steps.test-input.output.address }}',
},
values: {},
});
const task = await broker.claim();
await taskWorker.runOneTask(task);
const { events } = await storage.listEvents({ taskId });
const event = events.find(e => e.type === 'completion');
expect((event?.body?.output as JsonObject).result).toBe('line 1');
});
// TODO(blam): Can delete this test when we make the helpers a public API
it('should provide a repoUrlParse helper for the templates', async () => {
const inputAction = createTemplateAction<{
destination: RepoSpec;
}>({
id: 'test-input',
schema: {
input: {
type: 'object',
required: ['destination'],
properties: {
destination: {
title: 'destination',
type: 'object',
properties: {
repo: {
type: 'string',
},
host: {
type: 'string',
},
owner: {
type: 'string',
},
organization: {
type: 'string',
},
},
},
},
},
},
async handler(ctx) {
ctx.output('host', ctx.input.destination.host);
ctx.output('repo', ctx.input.destination.repo);
ctx.output('owner', ctx.input.destination.owner);
if (ctx.input.destination.host !== 'github.com') {
throw new Error(
`expected host to be "github.com" got ${ctx.input.destination.host}`,
);
}
if (ctx.input.destination.repo !== 'repo') {
throw new Error(
`expected repo to be "repo" got ${ctx.input.destination.repo}`,
);
}
if (ctx.input.destination.owner !== 'owner') {
throw new Error(
`expected repo to be "owner" got ${ctx.input.destination.owner}`,
);
}
},
});
actionRegistry.register(inputAction);
const broker = new StorageTaskBroker(storage, logger);
const taskWorker = new TaskWorker({
logger,
workingDirectory: os.tmpdir(),
actionRegistry,
taskBroker: broker,
});
const { taskId } = await broker.dispatch({
steps: [
{
id: 'test-input',
name: 'test-input',
action: 'test-input',
input: {
destination: '{{ parseRepoUrl parameters.repoUrl }}',
},
},
],
output: {
host: '{{ steps.test-input.output.host }}',
repo: '{{ steps.test-input.output.repo }}',
owner: '{{ steps.test-input.output.owner }}',
},
values: {
repoUrl: 'github.com?repo=repo&owner=owner',
},
});
const task = await broker.claim();
await taskWorker.runOneTask(task);
const { events } = await storage.listEvents({ taskId });
const event = events.find(e => e.type === 'completion');
expect((event?.body?.output as JsonObject).host).toBe('github.com');
expect((event?.body?.output as JsonObject).repo).toBe('repo');
expect((event?.body?.output as JsonObject).owner).toBe('owner');
});
});
@@ -23,8 +23,9 @@ import { TaskBroker, Task } from './types';
import fs from 'fs-extra';
import path from 'path';
import { TemplateActionRegistry } from '../actions/TemplateActionRegistry';
import * as handlebars from 'handlebars';
import * as Handlebars from 'handlebars';
import { InputError } from '@backstage/errors';
import { parseRepoUrl } from '../actions/builtin/publish/util';
type Options = {
logger: Logger;
@@ -34,7 +35,20 @@ type Options = {
};
export class TaskWorker {
constructor(private readonly options: Options) {}
private readonly handlebars: typeof Handlebars;
constructor(private readonly options: Options) {
this.handlebars = Handlebars.create();
// TODO(blam): this should be a public facing API but it's a little
// scary right now, so we're going to lock it off like the component API is
// in the frontend until we can work out a nice way to do it.
this.handlebars.registerHelper('parseRepoUrl', repoUrl => {
return JSON.stringify(parseRepoUrl(repoUrl));
});
this.handlebars.registerHelper('json', obj => JSON.stringify(obj));
}
start() {
(async () => {
@@ -102,13 +116,29 @@ export class TaskWorker {
step.input &&
JSON.parse(JSON.stringify(step.input), (_key, value) => {
if (typeof value === 'string') {
return handlebars.compile(value, {
const templated = this.handlebars.compile(value, {
noEscape: true,
strict: true,
data: false,
preventIndent: true,
})(templateCtx);
// If it smells like a JSON object then give it a parse as an object and if it fails return the string
if (
(templated.startsWith('{') && templated.endsWith('}')) ||
(templated.startsWith('[') && templated.endsWith(']'))
) {
try {
// Don't recursively JSON parse the values of this string.
// Shouldn't need to, don't want to encourage the use of returning handlebars from somewhere else
return JSON.parse(templated);
} catch {
return templated;
}
}
return templated;
}
return value;
});
@@ -171,7 +201,7 @@ export class TaskWorker {
JSON.stringify(task.spec.output),
(_key, value) => {
if (typeof value === 'string') {
return handlebars.compile(value, {
return this.handlebars.compile(value, {
noEscape: true,
strict: true,
data: false,