From dc8dd4bea76934785b75acf9882d0db643b9aab5 Mon Sep 17 00:00:00 2001 From: Matt Benson Date: Fri, 7 Feb 2025 11:55:49 -0600 Subject: [PATCH] add enhanced/documented filter/global types and creator functions Signed-off-by: Matt Benson --- .changeset/late-cycles-teach.md | 7 + .../config/vocabularies/Backstage/accept.txt | 2 + .../software-templates/template-extensions.md | 502 ++++++++++++++++++ .../software-templates/writing-templates.md | 221 +------- mkdocs.yml | 1 + plugins/scaffolder-backend/report.api.md | 10 +- .../src/ScaffolderPlugin.ts | 21 +- .../scaffolder-backend/src/service/router.ts | 37 +- plugins/scaffolder-node/package.json | 4 +- plugins/scaffolder-node/report-alpha.api.md | 144 ++++- plugins/scaffolder-node/report.api.md | 5 +- .../src/alpha/filters/createTemplateFilter.ts | 32 ++ .../src/alpha/filters/index.ts | 17 + .../src/alpha/filters/types.ts | 60 +++ .../src/alpha/globals/createTemplateGlobal.ts | 48 ++ .../src/alpha/globals/index.ts | 17 + .../src/alpha/globals/types.ts | 74 +++ .../src/{alpha.ts => alpha/index.ts} | 14 +- plugins/scaffolder-node/src/index.ts | 2 +- 19 files changed, 983 insertions(+), 235 deletions(-) create mode 100644 .changeset/late-cycles-teach.md create mode 100644 docs/features/software-templates/template-extensions.md create mode 100644 plugins/scaffolder-node/src/alpha/filters/createTemplateFilter.ts create mode 100644 plugins/scaffolder-node/src/alpha/filters/index.ts create mode 100644 plugins/scaffolder-node/src/alpha/filters/types.ts create mode 100644 plugins/scaffolder-node/src/alpha/globals/createTemplateGlobal.ts create mode 100644 plugins/scaffolder-node/src/alpha/globals/index.ts create mode 100644 plugins/scaffolder-node/src/alpha/globals/types.ts rename plugins/scaffolder-node/src/{alpha.ts => alpha/index.ts} (90%) diff --git a/.changeset/late-cycles-teach.md b/.changeset/late-cycles-teach.md new file mode 100644 index 0000000000..6b9f3e1785 --- /dev/null +++ b/.changeset/late-cycles-teach.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +'@backstage/plugin-scaffolder-node': minor +--- + +New api functions createTemplateFilter, createTemplateGlobalFunction, createTemplateGlobalValue; +Core support for template extension documentation diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index 936b0ec8b6..354c47c70d 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -328,6 +328,7 @@ preconfigured prepack Preprarer productional +projectSlug Protobuf proxied proxying @@ -425,6 +426,7 @@ subheaders subkey subpath subroutes +substring subtree superfences Superfences diff --git a/docs/features/software-templates/template-extensions.md b/docs/features/software-templates/template-extensions.md new file mode 100644 index 0000000000..a43f590e9f --- /dev/null +++ b/docs/features/software-templates/template-extensions.md @@ -0,0 +1,502 @@ +--- +id: template-extensions +title: Template Extensions +description: Template extensions system +--- + +Backstage templating is powered by [Nunjucks][]. The basics: + +# Template Filters + +The [filter][] is a critical mechanism for the rendering of Nunjucks templates, +providing a means of transforming values in a familiar [piped][] fashion. +Template filters are functions that help you transform data, extract specific +information, and perform various operations in Scaffolder Templates. + +## Built-in + +Backstage provides out of the box the following set of "built-in" template +filters (to create your own custom filters, look to the section [Custom Filter](#custom-filter) hereafter): + +### parseRepoUrl + +The `parseRepoUrl` filter parses a repository URL into its constituent parts: +`owner`, repository name (`repo`), etc. + +**Usage Example:** + +```yaml +- id: log + name: Parse Repo URL + action: debug:log + input: + message: ${{ parameters.repoUrl | parseRepoUrl }} +``` + +- **Input**: `github.com?repo=backstage&owner=backstage` +- **Output**: "RepoSpec" (see [parseRepoUrl][]) + +### parseEntityRef + +The `parseEntityRef` filter allows you to extract different parts of +an entity reference, such as the `kind`, `namespace`, and `name`. + +**Usage example** + +1. Without context + +```yaml +- id: log + name: Parse Entity Reference + action: debug:log + input: + message: ${{ parameters.owner | parseEntityRef }} +``` + +- **Input**: `group:techdocs` +- **Output**: [CompoundEntityRef][] + +1. With context + +```yaml +- id: log + name: Parse Entity Reference + action: debug:log + input: + message: ${{ parameters.owner | parseEntityRef({ defaultKind:"group", defaultNamespace:"another-namespace" }) }} +``` + +- **Input**: `techdocs` +- **Output**: [CompoundEntityRef][] + +### pick + +The `pick` filter allows you to select a specific property (e.g. `kind`, `namespace`, `name`) from an object. + +**Usage Example** + +```yaml +- id: log + name: Pick + action: debug:log + input: + message: ${{ parameters.owner | parseEntityRef | pick('name') }} +``` + +- **Input**: `{ kind: 'Group', namespace: 'default', name: 'techdocs' }` +- **Output**: `techdocs` + +### projectSlug + +The `projectSlug` filter generates a project slug from a repository URL. + +**Usage Example** + +```yaml +- id: log + name: Project Slug + action: debug:log + input: + message: ${{ parameters.repoUrl | projectSlug }} +``` + +- **Input**: `github.com?repo=backstage&owner=backstage` +- **Output**: `backstage/backstage` + +# Template Globals + +In addition to its powerful filtering functionality, the Nunjucks engine allows +access from the template expression context to specified globally-accessible +references. Backstage propagates this capability via the scaffolder backend +plugin, which we shall soon see in action. + +# Customizing the templating environment + +Custom plugins make it possible to install your own template extensions, which +may be any combination of filters, global functions and global values. With the +new backend you would use a scaffolder plugin module for this; later we will +demonstrate the analogous approach with the old backend. + +## Streamlining Template Extension Module Creation with the Backstage CLI + +The creation of a "template environment customization" module in Backstage can +be accelerated using the Backstage CLI. + +Start by using the `yarn backstage-cli new` command to generate a scaffolder module. This command sets up the necessary boilerplate code, providing a smooth start: + +``` +$ yarn backstage-cli new +? What do you want to create? +> backend-module - A new backend module that extends an existing backend plugin with additional features + backend-plugin - A new backend plugin + plugin - A new frontend plugin + node-library - A new node-library package, exporting shared functionality for backend plugins and modules + plugin-common - A new isomorphic common plugin package + plugin-node - A new Node.js library plugin package + plugin-react - A new web library plugin package + scaffolder-module - An module exporting custom actions for @backstage/plugin-scaffolder-backend +``` + +When prompted, select the option to generate a backend module. +Since we want to extend the Scaffolder backend, enter `scaffolder` when prompted for the plugin to extend. +Next, enter a name for your module (relative to the generated `scaffolder-backend-module-` prefix), +and the CLI will generate the required files and directory structure. + +## Writing your Module + +Once the CLI has generated the essential structure for your new scaffolder +module, it's time to implement our template extensions. Here we'll demonstrate +how to create each of the supported extension types. + +`src/module.ts` is where the magic happens. First we prepare to utilize the +associated (_**alpha** phase_) API extension point by adding: + +```ts +import { scaffolderTemplatingExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha'; +``` + +Considering the generated code, you may observe that everything rests on the +`createBackendModule` call, which after providing some minimal metadata to +establish context, specifies a `register` callback whose sole responsibility +here is to call, in turn, `registerInit` against the +`BackendModuleRegistrationPoints` argument it receives. Modify this call to +make the `scaffolderTemplatingExtensionPoint` available to the specified `init` +function: + +```ts + register(reg) { + reg.registerInit({ + deps: { + ..., + templating: scaffolderTemplatingExtensionPoint, + }, + async init({ + ..., + templating + }) { + ... + }; + }); + }; +``` + +Now we're ready to extend the scaffolder templating engine. For our purposes +here we'll drop everything in `module.ts`; use your own judgment as to the +organization of your real-world plugin modules. + +### Custom Filter + +In this contrived example we add a filter to test whether the incoming string +value contains (at least) a specified number of occurrences of a given +substring. We can easily define this by adding code to our `init` callback: + +```ts +async init({ + ..., + templating, +}) { + ... + templating.addTemplateFilters({ + containsOccurrences: (arg: string, substring: string, times: number) => { + let pos = 0; + let count = 0; + while (pos < arg.length) { + pos = arg.indexOf(substring, pos); + if (pos < 0) { + break; + } + count++; + } + return count === times; + }, + }); +}, +``` + +This demonstrates the bare minimum: a TypeScript `Record` of named template +filter implementations to register. However, by adopting an alternate structure +we can document our filter with additional metadata; to utilize this capability +we begin by adding a new import: + +```ts +import { createTemplateFilter } from '@backstage/plugin-scaffolder-node/alpha'; +``` + +Then, update your `init` implementation to specify an array rather than an +object/record: + +```ts +async init({ + ..., + templating, +}) { + ... + templating.addTemplateFilters([ + createTemplateFilter({ + id: 'containsOccurrences', + description: 'determine whether filter input contains a substring N times', + filter: (arg: string, substring: string, times: number) => { + let pos = 0; + let count = 0; + while (pos < arg.length) { + pos = arg.indexOf(substring, pos); + if (pos < 0) { + break; + } + count++; + } + return count === times; + }, + }), + ]); +}, +``` + +With this we have added a `description` to our filter, which helps a template +author to understand the filter's purpose. + +#### Schema + +To enhance our filter documentation further, we will specify its `schema` +using a callback against the [Zod][] schema declaration library: + +```ts + createTemplateFilter({ + id: 'containsOccurrences', + description: 'determine whether filter input contains a substring N times', + schema: z => + z.function( + z.tuple([ + z.string().describe('input'), + z.string().describe('substring whose occurrences to find'), + z.number().describe('number of occurrences to check for'), + ]), + z.boolean(), + ), + ..., + }), +``` + +Because a filter is, in fact, a function, its schema is defined by generating a +[Zod function schema][zod-fn] against the parameter supplied to our schema +callback. A filter function is required to have at least one argument; in this +example, we have two additional arguments. But what if we modify our filter's +implementation function to make `times` optional? Code: + +```ts + createTemplateFilter({ + id: 'containsOccurrences', + ..., + filter: (arg: string, substring: string, times?: number) => { + if (times === undefined) { + // note that, in real life, simply calling this function directly with Nunjucks would suffice rather than implementing a filter: + return arg.includes(substring); + } + // original implementation follows + ... + }, + }), +``` + +In this case we should modify our `schema`: + +```ts + createTemplateFilter({ + ..., + schema: z => + z.function( + z.tuple([ + z.string().describe('input'), + z.string().describe('substring whose occurrences to find'), + z + .number() + .describe('number of occurrences to check for') + .optional(), + ]), + z.boolean(), + ), + ..., + }), +``` + +#### Filter Example Documentation + +Our filter documentation may benefit from examples which we specify thus: + +```ts + createTemplateFilter({ + ..., + examples: [ + { + description: 'Basic Usage', + example: `\ +- name: Contains Occurrences + action: debug:log + input: + message: \${{ parameters.projectName | containsOccurrences('-', 2) }} + `, + notes: `\ +- **Input**: \`foo-bar-baz\` +- **Output**: \`true\` + `, + }, + { + description: 'Omitting Optional Parameter', + example: `\ +- name: Contains baz + action: debug:log + input: + message: \${{ parameters.projectName | containsOccurrences('baz') | dump }} + `, + notes: `\ +- **Input**: \`foo-bar\` +- **Output**: \`false\` + `, + }, + ], + }), +``` + +### Custom Global Function + +In case your template needs access to a value generated from a function not +appropriately modeled as a filter, Nunjucks supports the direct invocation of +[global functions][global-fn]. We might, for example, add to `init`: + +```ts +async init({ + ..., + templating, +}) { + ... + templating.addTemplateGlobals({ + now: () => new Date().toISOString(), + }); +}, +``` + +Here we have implemented a simple mechanism to obtain a timestamp (note that +because we can only pass JSON-compatible--or `undefined`--values we have chosen +to model a date/time as an ISO string) using a globally available function. + +Again we have the option to make our global function self-documenting. Import: + +```ts +import { + ..., + createTemplateGlobalFunction, +} from '@backstage/plugin-scaffolder-node/alpha'; +``` + +Then modify: + +```ts + ... + templating.addTemplateGlobals([ + createTemplateGlobalFunction({ + id: 'now', + description: + 'obtain an ISO representation of the current date and time', + fn: () => new Date().toISOString(), + }), + ]); +``` + +#### Schema + +Declaring a global function schema is quite like the schema declaration for a +template filter: + +```ts + createTemplateGlobal({ + ..., + schema: z => z.function().args().returns(z.string()), + ..., + }), +``` + +#### Template Global Function Example Documentation + +Again, this works in the same way as filter examples: + +```ts + createTemplateGlobal({ + ..., + examples: [ + { + description: 'Obtain the current date/time', + example: `\ +- name: Log Timestamp + action: debug:log + input: + message: Current date/time: \${{ now() }} + `, + // optional `notes` omitted from this example + }, + ], + ..., + }), + +``` + +### Custom Global Value + +Alternatively, your template may need access to a simple JSON value, which can +be registered in this manner: + +```ts +async init({ + ..., + templating, +}) { + ... + templating.addTemplateGlobals({ + ..., + preferredMetasyntacticIdentifier: 'foo', + }); +}, +``` + +Or the documenting form: + +```ts +async init({ + ..., + templating, +}) { + ... + templating.addTemplateGlobals([ + ..., + createTemplateGlobalValue({ + id: 'preferredMetasyntacticVariable', + value: 'foo', + description: + 'This description is as contrived as the global value it documents', + }), + ]); +}, +``` + +## Register Template Extensions with the Legacy Backend System + +Users of the original Backstage backend can register template extensions by +specifying options to the scaffolder backend plugin's `createRouter` function +(customarily called in `packages/backend/src/plugins/scaffolder.ts`): + +- `additionalTemplateFilters` - either of: + - object mapping filter name to implementation function, or + - array of documented template filters as returned by the + utility function `createTemplateFilter` +- `additionalTemplateGlobals` - either of: + - object mapping global name to value or function, or + - array of documented global functions and values as returned by the utility + functions `createTemplateGlobalFunction` and `createTemplateGlobalValue` + +[nunjucks]: https://mozilla.github.io/nunjucks +[filter]: https://mozilla.github.io/nunjucks/templating.html#filters +[global-fn]: https://mozilla.github.io/nunjucks/templating.html#global-functions +[parseRepoUrl]: https://backstage.io/docs/reference/plugin-scaffolder-node.parserepourl +[CompoundEntityRef]: https://backstage.io/docs/reference/catalog-model.compoundentityref +[Zod]: https://zod.dev/ +[zod-fn]: https://zod.dev/?id=functions +[piped]: https://en.wikipedia.org/wiki/Pipeline_(Unix)#Pipelines_in_command_line_interfaces diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index 0f3d0af2e5..85f29cb359 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -631,7 +631,7 @@ output: ## The templating syntax -You might have noticed variables wrapped in `${{ }}` in the examples. These are +You might have noticed expressions wrapped in `${{ }}` in the examples. These are template strings for linking and gluing the different parts of the template together. All the form inputs from the `parameters` section will be available by using this template syntax (for example, `${{ parameters.firstName }}` inserts @@ -704,219 +704,16 @@ You can read more about all the `inputs` and `outputs` defined in the actions in code part of the `JSONSchema`, or you can read more about our [built in actions](./builtin-actions.md). -## Built in Filters +### More about expressions -Template filters are functions that help you transform data, extract specific information, -and perform various operations in Scaffolder Templates. +The `${{ }}` constructs in your template are evaluated using the +powerful [Nunjucks templating engine](https://mozilla.github.io/nunjucks/). +To learn more about basic Nunjucks templating please see +[templating documentation](https://mozilla.github.io/nunjucks/templating.html). -This section introduces the built-in filters provided by Backstage and offers examples of -how to use them in the Scaffolder templates. It's important to mention that Backstage also leverages the -native filters from the Nunjucks library. For a complete list of these native filters and their usage, -refer to the [Nunjucks documentation](https://mozilla.github.io/nunjucks/templating.html#builtin-filters). - -To create your own custom filters, look to the section [Custom Filters and Globals](#custom-filters-and-globals) hereafter. - -### parseRepoUrl - -The `parseRepoUrl` filter parse a repository URL into -its components, such as `owner`, repository `name`, and more. - -**Usage Example:** - -```yaml -- id: log - name: Parse Repo URL - action: debug:log - input: - extra: ${{ parameters.repoUrl | parseRepoUrl }} -``` - -- **Input**: `github.com?repo=backstage&org=backstage` -- **Output**: [RepoSpec](https://github.com/backstage/backstage/blob/v1.17.2/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/util.ts#L39) - -### parseEntityRef - -The `parseEntityRef` filter allows you to extract different parts of -an entity reference, such as the `kind`, `namespace`, and `name`. - -**Usage example** - -1. Without context - -```yaml -- id: log - name: Parse Entity Reference - action: debug:log - input: - extra: ${{ parameters.owner | parseEntityRef }} -``` - -- **Input**: `group:techdocs` -- **Output**: [CompoundEntityRef](https://github.com/backstage/backstage/blob/v1.17.2/packages/catalog-model/src/types.ts#L23) - -2. With context - -```yaml -- id: log - name: Parse Entity Reference - action: debug:log - input: - extra: ${{ parameters.owner | parseEntityRef({ defaultKind:"group", defaultNamespace:"another-namespace" }) }} -``` - -- **Input**: `techdocs` -- **Output**: [CompoundEntityRef](https://github.com/backstage/backstage/blob/v1.17.2/packages/catalog-model/src/types.ts#L23) - -### pick - -This `pick` filter allows you to select specific properties (`kind`, `namespace`, `name`) from an object. - -**Usage Example** - -```yaml -- id: log - name: Pick - action: debug:log - input: - extra: ${{ parameters.owner | parseEntityRef | pick('name') }} -``` - -- **Input**: `{ kind: 'Group', namespace: 'default', name: 'techdocs' }` -- **Output**: `techdocs` - -### projectSlug - -The `projectSlug` filter generates a project slug from a repository URL - -**Usage Example** - -```yaml -- id: log - name: Project Slug - action: debug:log - input: - extra: ${{ parameters.repoUrl | projectSlug }} -``` - -- **Input**: `github.com?repo=backstage&org=backstage` -- **Output**: `backstage/backstage` - -## Custom Filters and Globals - -You may wish to extend the filters and globals with your own custom ones. For example `${{ myGlobal | myFilter | myOtherFilter }}` or `${{ myFunctionGlobal(1,2) | myFilter }}`. -This can be achieved using the `additionalTemplateFilters` and `additionalTemplateGlobals` properties respectively. - -These properties accept a `Record` - -```ts title="plugins/scaffolder-backend/src/service/router.ts" - additionalTemplateFilters?: Record; - additionalTemplateGlobals?: Record; -``` - -where the first parameter is the identifier of the filter or global and the second is a `TemplateFilter` or a `TemplateGlobal` respectively. -A `TemplateFilter` is a function which will be called using the previous `JsonValue` objects and may return a `JsonValue` object. -A `TemplateGlobal` can either be a function which will be called using the passed `JsonValue` objects and may return a `JsonValue` object or it can be a `JsonValue` object itself. - -```ts title="plugins/scaffolder-node/src/types.ts" -export type TemplateFilter = (...args: JsonValue[]) => JsonValue | undefined; - -export type TemplateGlobal = - | ((...args: JsonValue[]) => JsonValue | undefined) - | JsonValue; -``` - -**Usage Example** - -Given you want to have the following filters and globals available in you template: - -```yaml -apiVersion: scaffolder.backstage.io/v1beta3 -kind: Template -metadata: - name: test - title: Test -spec: - owner: user:guest - type: service - - steps: - - id: debug1 - name: debug1 - action: debug:log - input: - message: ${{ myGlobal | myFilter | myOtherFilter }} - - - id: debug2 - name: debug2 - action: debug:log - input: - message: ${{ myFunctionGlobal(1,2) | myFilter }} -``` - -You will have to create a new [`BackendModule`](../../backend-system/architecture/06-modules.md) using the `scaffolderTemplatingExtensionPoint`. - -Here is a very simplified example of how to do that: - -```ts title="packages/backend-next/src/index.ts" -/* highlight-add-start */ -import { scaffolderTemplatingExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha'; -import { createBackendModule } from '@backstage/backend-plugin-api'; -/* highlight-add-end */ - -/* highlight-add-start */ -const scaffolderModuleCustomFilters = createBackendModule({ - pluginId: 'scaffolder', // name of the plugin that the module is targeting - moduleId: 'custom-filters', - register(env) { - env.registerInit({ - deps: { - scaffolder: scaffolderTemplatingExtensionPoint, - // ... and other dependencies as needed - }, - async init({ scaffolder /* ..., other dependencies */ }) { - scaffolder.addTemplateGlobals({ - myGlobal: () => 'myGlobal', - myFunctionGlobal: (...args: JsonValue[]) => args[0] + args[1], - }); - scaffolder.addTemplateFilters({ - myFilter: () => 'the value is this now', - myOtherFilter: (...args: JsonValue[]) => args.join(''), - }); - }, - }); - }, -}); -/* highlight-add-end */ - -const backend = createBackend(); -backend.add(import('@backstage/plugin-scaffolder-backend')); -/* highlight-add-next-line */ -backend.add(scaffolderModuleCustomFilters); -``` - -If you still use the legacy backend system, then you will use the `createRouter()` function of the `Scaffolder plugin` - -```ts title="packages/backend/src/plugins/scaffolder.ts" -export default async function createPlugin({ - logger, - config, -}: PluginEnvironment): Promise { - ... - return await createRouter({ - logger, - config, - - additionalTemplateFilters: { - - }, - additionalTemplateGlobals: { - - }, - }); -} -``` - -Note that additional template global functions are currently not supported in `fetch:template` (see #25445). +Information about Backstage's built-in Nunjucks extensions, as well as how to +create your own customizations, may be found at +[Template Extensions](./template-extensions.md). ## Template Editor diff --git a/mkdocs.yml b/mkdocs.yml index a7e693e5fc..96ba85991d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -71,6 +71,7 @@ nav: - Builtin Actions: 'features/software-templates/builtin-actions.md' - Writing Custom Actions: 'features/software-templates/writing-custom-actions.md' - Writing Custom Step Layouts: 'features/software-templates/writing-custom-step-layouts.md' + - Template Extensions: 'features/software-templates/template-extensions.md' - Migrating from v1beta2 to v1beta3 templates: 'features/software-templates/migrating-from-v1beta2-to-v1beta3.md' - Dry Run Testing: 'features/software-templates/dry-run-testing.md' - Backstage Search: diff --git a/plugins/scaffolder-backend/report.api.md b/plugins/scaffolder-backend/report.api.md index 8fe17d26f8..2b63c93132 100644 --- a/plugins/scaffolder-backend/report.api.md +++ b/plugins/scaffolder-backend/report.api.md @@ -13,6 +13,8 @@ import { BackendFeature } from '@backstage/backend-plugin-api'; import { BackstageCredentials } from '@backstage/backend-plugin-api'; import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; +import { CreatedTemplateFilter } from '@backstage/plugin-scaffolder-node/alpha'; +import { CreatedTemplateGlobal } from '@backstage/plugin-scaffolder-node/alpha'; import { createGithubActionsDispatchAction as createGithubActionsDispatchAction_2 } from '@backstage/plugin-scaffolder-backend-module-github'; import { createGithubDeployKeyAction as createGithubDeployKeyAction_2 } from '@backstage/plugin-scaffolder-backend-module-github'; import { createGithubEnvironmentAction as createGithubEnvironmentAction_2 } from '@backstage/plugin-scaffolder-backend-module-github'; @@ -549,9 +551,13 @@ export interface RouterOptions { // (undocumented) actions?: TemplateAction_2[]; // (undocumented) - additionalTemplateFilters?: Record; + additionalTemplateFilters?: + | Record + | CreatedTemplateFilter[]; // (undocumented) - additionalTemplateGlobals?: Record; + additionalTemplateGlobals?: + | Record + | CreatedTemplateGlobal[]; // (undocumented) additionalWorkspaceProviders?: Record; // (undocumented) diff --git a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts index a670c1a1c8..abb35d86c2 100644 --- a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts +++ b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts @@ -82,10 +82,27 @@ export const scaffolderPlugin = createBackendPlugin({ const additionalTemplateGlobals: Record = {}; env.registerExtensionPoint(scaffolderTemplatingExtensionPoint, { addTemplateFilters(newFilters) { - Object.assign(additionalTemplateFilters, newFilters); + Object.assign( + additionalTemplateFilters, + Array.isArray(newFilters) + ? Object.fromEntries( + newFilters.map(tf => [tf.id, tf.filter as TemplateFilter]), + ) + : newFilters, + ); }, addTemplateGlobals(newGlobals) { - Object.assign(additionalTemplateGlobals, newGlobals); + Object.assign( + additionalTemplateGlobals, + Array.isArray(newGlobals) + ? Object.fromEntries( + newGlobals.map(g => [ + g.id, + ('value' in g ? g.value : g.fn) as TemplateGlobal, + ]), + ) + : newGlobals, + ); }, }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index c39ee29a1e..bd04eaad1b 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -81,6 +81,8 @@ import { } from '@backstage/plugin-scaffolder-node'; import { AutocompleteHandler, + CreatedTemplateFilter, + CreatedTemplateGlobal, WorkspaceProvider, } from '@backstage/plugin-scaffolder-node/alpha'; import { HumanDuration, JsonObject, JsonValue } from '@backstage/types'; @@ -173,8 +175,12 @@ export interface RouterOptions { */ concurrentTasksLimit?: number; taskBroker?: TaskBroker; - additionalTemplateFilters?: Record; - additionalTemplateGlobals?: Record; + additionalTemplateFilters?: + | Record + | CreatedTemplateFilter[]; + additionalTemplateGlobals?: + | Record + | CreatedTemplateGlobal[]; additionalWorkspaceProviders?: Record; permissions?: PermissionsService; permissionRules?: Array< @@ -363,6 +369,24 @@ export async function createRouter( } const actionRegistry = new TemplateActionRegistry(); + const templateExtensions = { + additionalTemplateFilters: Array.isArray(additionalTemplateFilters) + ? Object.fromEntries( + additionalTemplateFilters.map(f => [ + f.id, + f.filter as TemplateFilter, + ]), + ) + : additionalTemplateFilters, + additionalTemplateGlobals: Array.isArray(additionalTemplateGlobals) + ? Object.fromEntries( + additionalTemplateGlobals.map(g => [ + g.id, + ('value' in g ? g.value : g.fn) as TemplateGlobal, + ]), + ) + : additionalTemplateGlobals, + }; const workers: TaskWorker[] = []; if (concurrentTasksLimit !== 0) { @@ -378,11 +402,10 @@ export async function createRouter( logger, auditor, workingDirectory, - additionalTemplateFilters, - additionalTemplateGlobals, concurrentTasksLimit, permissions, gracefulShutdown, + ...templateExtensions, }); workers.push(worker); } @@ -395,9 +418,8 @@ export async function createRouter( catalogClient, reader, config, - additionalTemplateFilters, - additionalTemplateGlobals, auth, + ...templateExtensions, }); actionsToRegister.forEach(action => actionRegistry.register(action)); @@ -421,9 +443,8 @@ export async function createRouter( logger, auditor, workingDirectory, - additionalTemplateFilters, - additionalTemplateGlobals, permissions, + ...templateExtensions, }); const templateRules: TemplatePermissionRuleInput[] = Object.values( diff --git a/plugins/scaffolder-node/package.json b/plugins/scaffolder-node/package.json index 23660f2d02..7e5dda417b 100644 --- a/plugins/scaffolder-node/package.json +++ b/plugins/scaffolder-node/package.json @@ -26,7 +26,7 @@ "license": "Apache-2.0", "exports": { ".": "./src/index.ts", - "./alpha": "./src/alpha.ts", + "./alpha": "./src/alpha/index.ts", "./package.json": "./package.json" }, "main": "src/index.ts", @@ -34,7 +34,7 @@ "typesVersions": { "*": { "alpha": [ - "src/alpha.ts" + "src/alpha/index.ts" ], "package.json": [ "package.json" diff --git a/plugins/scaffolder-node/report-alpha.api.md b/plugins/scaffolder-node/report-alpha.api.md index eef9acc1c1..b561fe93e1 100644 --- a/plugins/scaffolder-node/report-alpha.api.md +++ b/plugins/scaffolder-node/report-alpha.api.md @@ -6,10 +6,12 @@ /// import { ExtensionPoint } from '@backstage/backend-plugin-api'; +import { JsonValue } from '@backstage/types'; import { TaskBroker } from '@backstage/plugin-scaffolder-node'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; -import { TemplateFilter } from '@backstage/plugin-scaffolder-node'; -import { TemplateGlobal } from '@backstage/plugin-scaffolder-node'; +import { TemplateFilter as TemplateFilter_2 } from '@backstage/plugin-scaffolder-node'; +import { TemplateGlobal as TemplateGlobal_2 } from '@backstage/plugin-scaffolder-node'; +import { z } from 'zod'; // @alpha export type AutocompleteHandler = ({ @@ -27,6 +29,92 @@ export type AutocompleteHandler = ({ }[]; }>; +// @alpha (undocumented) +export type CreatedTemplateFilter< + TSchema extends + | TemplateFilterSchema + | undefined + | unknown = unknown, + TFilterSchema extends TSchema extends TemplateFilterSchema + ? z.infer> + : TSchema extends unknown + ? unknown + : TemplateFilter = TSchema extends TemplateFilterSchema + ? z.infer> + : TSchema extends unknown + ? unknown + : TemplateFilter, +> = { + id: string; + description?: string; + examples?: TemplateFilterExample[]; + schema?: TSchema; + filter: TFilterSchema; +}; + +// @alpha (undocumented) +export type CreatedTemplateGlobal = + | CreatedTemplateGlobalValue + | CreatedTemplateGlobalFunction; + +// @alpha (undocumented) +export type CreatedTemplateGlobalFunction< + TSchema extends + | TemplateGlobalFunctionSchema + | undefined + | unknown = unknown, + TFilterSchema extends TSchema extends TemplateGlobalFunctionSchema + ? z.infer> + : TSchema extends unknown + ? unknown + : Exclude< + TemplateGlobal, + JsonValue + > = TSchema extends TemplateGlobalFunctionSchema + ? z.infer> + : TSchema extends unknown + ? unknown + : Exclude, +> = { + id: string; + description?: string; + examples?: TemplateGlobalFunctionExample[]; + schema?: TSchema; + fn: TFilterSchema; +}; + +// @alpha (undocumented) +export type CreatedTemplateGlobalValue = { + id: string; + value: T; + description?: string; +}; + +// @alpha +export const createTemplateFilter: < + TSchema extends TemplateFilterSchema | undefined, + TFunctionSchema extends TSchema extends TemplateFilterSchema + ? z.TypeOf> + : (arg: JsonValue, ...rest: JsonValue[]) => JsonValue | undefined, +>( + filter: CreatedTemplateFilter, +) => CreatedTemplateFilter; + +// @alpha +export const createTemplateGlobalFunction: < + TSchema extends TemplateGlobalFunctionSchema | undefined, + TFilterSchema extends TSchema extends TemplateGlobalFunctionSchema + ? z.TypeOf> + : (...args: JsonValue[]) => JsonValue | undefined, +>( + fn: CreatedTemplateGlobalFunction, +) => CreatedTemplateGlobalFunction; + +// @alpha +export const createTemplateGlobalValue: ( + v: CreatedTemplateGlobalValue, +) => CreatedTemplateGlobalValue; + // @alpha export const restoreWorkspace: (opts: { path: string; @@ -69,9 +157,13 @@ export const scaffolderTaskBrokerExtensionPoint: ExtensionPoint): void; + addTemplateFilters( + filters: Record | CreatedTemplateFilter[], + ): void; // (undocumented) - addTemplateGlobals(filters: Record): void; + addTemplateGlobals( + globals: Record | CreatedTemplateGlobal[], + ): void; } // @alpha @@ -91,6 +183,50 @@ export const serializeWorkspace: (opts: { path: string }) => Promise<{ contents: Buffer; }>; +// @public (undocumented) +export type TemplateFilter = ( + arg: JsonValue, + ...rest: JsonValue[] +) => JsonValue | undefined; + +// @alpha (undocumented) +export type TemplateFilterExample = { + description?: string; + example: string; + notes?: string; +}; + +// @alpha (undocumented) +export type TemplateFilterSchema< + Args extends z.ZodTuple< + | [z.ZodType] + | [z.ZodType, ...(z.ZodType | z.ZodUnknown)[]], + z.ZodType | z.ZodUnknown | null + >, + Result extends z.ZodType | z.ZodUndefined, +> = (zod: typeof z) => z.ZodFunction; + +// @public (undocumented) +export type TemplateGlobal = + | ((...args: JsonValue[]) => JsonValue | undefined) + | JsonValue; + +// @alpha (undocumented) +export type TemplateGlobalFunctionExample = { + description?: string; + example: string; + notes?: string; +}; + +// @alpha (undocumented) +export type TemplateGlobalFunctionSchema< + Args extends z.ZodTuple< + [] | [z.ZodType, ...(z.ZodType | z.ZodUnknown)[]], + z.ZodType | z.ZodUnknown | null + >, + Result extends z.ZodType | z.ZodUndefined, +> = (zod: typeof z) => z.ZodFunction; + // @alpha export interface WorkspaceProvider { // (undocumented) diff --git a/plugins/scaffolder-node/report.api.md b/plugins/scaffolder-node/report.api.md index c02d36eb54..2d3e418efb 100644 --- a/plugins/scaffolder-node/report.api.md +++ b/plugins/scaffolder-node/report.api.md @@ -482,7 +482,10 @@ export type TemplateExample = { }; // @public (undocumented) -export type TemplateFilter = (...args: JsonValue[]) => JsonValue | undefined; +export type TemplateFilter = ( + arg: JsonValue, + ...rest: JsonValue[] +) => JsonValue | undefined; // @public (undocumented) export type TemplateGlobal = diff --git a/plugins/scaffolder-node/src/alpha/filters/createTemplateFilter.ts b/plugins/scaffolder-node/src/alpha/filters/createTemplateFilter.ts new file mode 100644 index 0000000000..1f4bac4ab7 --- /dev/null +++ b/plugins/scaffolder-node/src/alpha/filters/createTemplateFilter.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { JsonValue } from '@backstage/types'; +import { CreatedTemplateFilter, TemplateFilterSchema } from './types'; +import { z } from 'zod'; + +/** + * This function is used to create new template filters in type-safe manner. + * @alpha + */ +export const createTemplateFilter = < + TSchema extends TemplateFilterSchema | undefined, + TFunctionSchema extends TSchema extends TemplateFilterSchema + ? z.infer> + : (arg: JsonValue, ...rest: JsonValue[]) => JsonValue | undefined, +>( + filter: CreatedTemplateFilter, +): CreatedTemplateFilter => filter; diff --git a/plugins/scaffolder-node/src/alpha/filters/index.ts b/plugins/scaffolder-node/src/alpha/filters/index.ts new file mode 100644 index 0000000000..1df12e2483 --- /dev/null +++ b/plugins/scaffolder-node/src/alpha/filters/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './types'; +export * from './createTemplateFilter'; diff --git a/plugins/scaffolder-node/src/alpha/filters/types.ts b/plugins/scaffolder-node/src/alpha/filters/types.ts new file mode 100644 index 0000000000..2f65baaefc --- /dev/null +++ b/plugins/scaffolder-node/src/alpha/filters/types.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { z } from 'zod'; +import { TemplateFilter } from '../../types'; +import { JsonValue } from '@backstage/types'; + +export type { TemplateFilter } from '../../types'; + +/** @alpha */ +export type TemplateFilterSchema< + Args extends z.ZodTuple< + | [z.ZodType] + | [z.ZodType, ...(z.ZodType | z.ZodUnknown)[]], + z.ZodType | z.ZodUnknown | null + >, + Result extends z.ZodType | z.ZodUndefined, +> = (zod: typeof z) => z.ZodFunction; + +/** @alpha */ +export type TemplateFilterExample = { + description?: string; + example: string; + notes?: string; +}; + +/** @alpha */ +export type CreatedTemplateFilter< + TSchema extends + | TemplateFilterSchema + | undefined + | unknown = unknown, + TFilterSchema extends TSchema extends TemplateFilterSchema + ? z.infer> + : TSchema extends unknown + ? unknown + : TemplateFilter = TSchema extends TemplateFilterSchema + ? z.infer> + : TSchema extends unknown + ? unknown + : TemplateFilter, +> = { + id: string; + description?: string; + examples?: TemplateFilterExample[]; + schema?: TSchema; + filter: TFilterSchema; +}; diff --git a/plugins/scaffolder-node/src/alpha/globals/createTemplateGlobal.ts b/plugins/scaffolder-node/src/alpha/globals/createTemplateGlobal.ts new file mode 100644 index 0000000000..1f9e090d7f --- /dev/null +++ b/plugins/scaffolder-node/src/alpha/globals/createTemplateGlobal.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from 'zod'; +import { + CreatedTemplateGlobalFunction, + CreatedTemplateGlobalValue, + TemplateGlobalFunctionSchema, +} from './types'; +import { JsonValue } from '@backstage/types'; + +/** + * This function is used to create new template global values in type-safe manner. + * @param t - CreatedTemplateGlobalValue | CreatedTemplateGlobalFunction + * @returns t + * @alpha + */ +export const createTemplateGlobalValue = ( + v: CreatedTemplateGlobalValue, +): CreatedTemplateGlobalValue => v; + +/** + * This function is used to create new template global functions in type-safe manner. + * @param fn - CreatedTemplateGlobalFunction + * @returns fn + * @alpha + */ +export const createTemplateGlobalFunction = < + TSchema extends TemplateGlobalFunctionSchema | undefined, + TFilterSchema extends TSchema extends TemplateGlobalFunctionSchema + ? z.infer> + : (...args: JsonValue[]) => JsonValue | undefined, +>( + fn: CreatedTemplateGlobalFunction, +): CreatedTemplateGlobalFunction => fn; diff --git a/plugins/scaffolder-node/src/alpha/globals/index.ts b/plugins/scaffolder-node/src/alpha/globals/index.ts new file mode 100644 index 0000000000..6263a52728 --- /dev/null +++ b/plugins/scaffolder-node/src/alpha/globals/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './types'; +export * from './createTemplateGlobal'; diff --git a/plugins/scaffolder-node/src/alpha/globals/types.ts b/plugins/scaffolder-node/src/alpha/globals/types.ts new file mode 100644 index 0000000000..cbe74f1610 --- /dev/null +++ b/plugins/scaffolder-node/src/alpha/globals/types.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { JsonValue } from '@backstage/types'; +import { z } from 'zod'; +import { TemplateGlobal } from '../../types'; + +export type { TemplateGlobal } from '../../types'; + +/** @alpha */ +export type CreatedTemplateGlobalValue = { + id: string; + value: T; + description?: string; +}; + +/** @alpha */ +export type TemplateGlobalFunctionSchema< + Args extends z.ZodTuple< + [] | [z.ZodType, ...(z.ZodType | z.ZodUnknown)[]], + z.ZodType | z.ZodUnknown | null + >, + Result extends z.ZodType | z.ZodUndefined, +> = (zod: typeof z) => z.ZodFunction; + +/** @alpha */ +export type TemplateGlobalFunctionExample = { + description?: string; + example: string; + notes?: string; +}; + +/** @alpha */ +export type CreatedTemplateGlobalFunction< + TSchema extends + | TemplateGlobalFunctionSchema + | undefined + | unknown = unknown, + TFilterSchema extends TSchema extends TemplateGlobalFunctionSchema + ? z.infer> + : TSchema extends unknown + ? unknown + : Exclude< + TemplateGlobal, + JsonValue + > = TSchema extends TemplateGlobalFunctionSchema + ? z.infer> + : TSchema extends unknown + ? unknown + : Exclude, +> = { + id: string; + description?: string; + examples?: TemplateGlobalFunctionExample[]; + schema?: TSchema; + fn: TFilterSchema; +}; + +/** @alpha */ +export type CreatedTemplateGlobal = + | CreatedTemplateGlobalValue + | CreatedTemplateGlobalFunction; diff --git a/plugins/scaffolder-node/src/alpha.ts b/plugins/scaffolder-node/src/alpha/index.ts similarity index 90% rename from plugins/scaffolder-node/src/alpha.ts rename to plugins/scaffolder-node/src/alpha/index.ts index 38a43d4865..1b573d0650 100644 --- a/plugins/scaffolder-node/src/alpha.ts +++ b/plugins/scaffolder-node/src/alpha/index.ts @@ -21,8 +21,12 @@ import { TemplateFilter, TemplateGlobal, } from '@backstage/plugin-scaffolder-node'; +import { CreatedTemplateFilter } from './filters'; +import { CreatedTemplateGlobal } from './globals'; -export * from './tasks/alpha'; +export * from '../tasks/alpha'; +export * from './filters'; +export * from './globals'; /** * Extension point for managing scaffolder actions. @@ -68,9 +72,13 @@ export const scaffolderTaskBrokerExtensionPoint = * @alpha */ export interface ScaffolderTemplatingExtensionPoint { - addTemplateFilters(filters: Record): void; + addTemplateFilters( + filters: Record | CreatedTemplateFilter[], + ): void; - addTemplateGlobals(filters: Record): void; + addTemplateGlobals( + globals: Record | CreatedTemplateGlobal[], + ): void; } /** diff --git a/plugins/scaffolder-node/src/index.ts b/plugins/scaffolder-node/src/index.ts index 0ec492dd32..5d49ee874c 100644 --- a/plugins/scaffolder-node/src/index.ts +++ b/plugins/scaffolder-node/src/index.ts @@ -23,4 +23,4 @@ export * from './actions'; export * from './tasks'; export * from './files'; -export type { TemplateFilter, TemplateGlobal } from './types'; +export * from './types';