Merge pull request #19937 from backstage/rugvip/input

frontend-plugin-api: add createExtensionInput + validation
This commit is contained in:
Patrik Oldsberg
2023-09-19 00:46:35 +02:00
committed by GitHub
18 changed files with 729 additions and 260 deletions
@@ -17,17 +17,16 @@
import {
coreExtensionData,
createExtension,
createExtensionInput,
} from '@backstage/frontend-plugin-api';
export const Core = createExtension({
id: 'core',
at: 'root',
inputs: {
apis: {
extensionData: {
api: coreExtensionData.apiFactory,
},
},
apis: createExtensionInput({
api: coreExtensionData.apiFactory,
}),
},
output: {},
factory() {},
@@ -18,6 +18,7 @@ import React from 'react';
import {
createExtension,
coreExtensionData,
createExtensionInput,
} from '@backstage/frontend-plugin-api';
import { SidebarPage } from '@backstage/core-components';
@@ -25,39 +26,28 @@ export const CoreLayout = createExtension({
id: 'core.layout',
at: 'root',
inputs: {
nav: {
extensionData: {
nav: createExtensionInput(
{
element: coreExtensionData.reactElement,
},
},
content: {
extensionData: {
{ singleton: true },
),
content: createExtensionInput(
{
element: coreExtensionData.reactElement,
},
},
{ singleton: true },
),
},
output: {
element: coreExtensionData.reactElement,
},
factory({ bind, inputs }) {
// TODO: Support this as part of the core system
if (inputs.nav.length !== 1) {
throw Error(
`Extension 'core.layout' did not receive exactly one 'nav' input, got ${inputs.nav.length}`,
);
}
if (inputs.content.length !== 1) {
throw Error(
`Extension 'core.layout' did not receive exactly one 'content' input, got ${inputs.content.length}`,
);
}
bind({
// TODO: set base path using the logic from AppRouter
element: (
<SidebarPage>
{inputs.nav[0].element}
{inputs.content[0].element}
{inputs.nav.element}
{inputs.content.element}
</SidebarPage>
),
});
@@ -18,6 +18,7 @@ import React from 'react';
import {
createExtension,
coreExtensionData,
createExtensionInput,
} from '@backstage/frontend-plugin-api';
import { makeStyles } from '@material-ui/core';
import {
@@ -66,11 +67,9 @@ export const CoreNav = createExtension({
id: 'core.nav',
at: 'core.layout/nav',
inputs: {
targets: {
extensionData: {
path: coreExtensionData.navTarget,
},
},
items: createExtensionInput({
path: coreExtensionData.navTarget,
}),
},
output: {
element: coreExtensionData.reactElement,
@@ -18,6 +18,7 @@ import React from 'react';
import {
createExtension,
coreExtensionData,
createExtensionInput,
} from '@backstage/frontend-plugin-api';
import { useRoutes } from 'react-router-dom';
@@ -25,13 +26,11 @@ export const CoreRoutes = createExtension({
id: 'core.routes',
at: 'core.layout/content',
inputs: {
routes: {
extensionData: {
path: coreExtensionData.routePath,
ref: coreExtensionData.routeRef,
element: coreExtensionData.reactElement,
},
},
routes: createExtensionInput({
path: coreExtensionData.routePath,
ref: coreExtensionData.routeRef.optional(),
element: coreExtensionData.reactElement,
}),
},
output: {
element: coreExtensionData.reactElement,
@@ -0,0 +1,370 @@
/*
* Copyright 2023 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 {
createExtension,
createExtensionDataRef,
createExtensionInput,
createSchemaFromZod,
} from '@backstage/frontend-plugin-api';
import { createExtensionInstance } from './createExtensionInstance';
const testDataRef = createExtensionDataRef<string>('test');
const otherDataRef = createExtensionDataRef<number>('other');
const inputMirrorDataRef = createExtensionDataRef<unknown>('mirror');
const simpleExtension = createExtension({
id: 'core.test',
at: 'ignored',
output: {
test: testDataRef,
other: otherDataRef.optional(),
},
configSchema: createSchemaFromZod(z =>
z.object({
output: z.string().default('test'),
other: z.number().optional(),
}),
),
factory({ bind, config }) {
bind({ test: config.output, other: config.other });
},
});
describe('createExtensionInstance', () => {
it('should create a simple extension instance', () => {
const attachments = new Map();
const instance = createExtensionInstance({
attachments,
config: undefined,
extension: simpleExtension,
});
expect(instance.id).toBe('core.test');
expect(instance.attachments).toBe(attachments);
expect(instance.getData(testDataRef)).toEqual('test');
});
it('should create an extension with different kind of inputs', () => {
const attachments = new Map([
[
'optionalSingletonPresent',
[
createExtensionInstance({
attachments: new Map(),
config: { output: 'optionalSingletonPresent' },
extension: simpleExtension,
}),
],
],
[
'singleton',
[
createExtensionInstance({
attachments: new Map(),
config: { output: 'singleton', other: 2 },
extension: simpleExtension,
}),
],
],
[
'many',
[
createExtensionInstance({
attachments: new Map(),
config: { output: 'many1' },
extension: simpleExtension,
}),
createExtensionInstance({
attachments: new Map(),
config: { output: 'many2', other: 3 },
extension: simpleExtension,
}),
],
],
]);
const instance = createExtensionInstance({
attachments,
config: undefined,
extension: createExtension({
id: 'core.test',
at: 'ignored',
inputs: {
optionalSingletonPresent: createExtensionInput(
{
test: testDataRef,
other: otherDataRef.optional(),
},
{ singleton: true, optional: true },
),
optionalSingletonMissing: createExtensionInput(
{
test: testDataRef,
other: otherDataRef.optional(),
},
{ singleton: true, optional: true },
),
singleton: createExtensionInput(
{
test: testDataRef,
other: otherDataRef.optional(),
},
{ singleton: true },
),
many: createExtensionInput({
test: testDataRef,
other: otherDataRef.optional(),
}),
},
output: {
inputMirror: inputMirrorDataRef,
},
factory({ bind, inputs }) {
bind({ inputMirror: inputs });
},
}),
});
expect(instance.id).toBe('core.test');
expect(instance.attachments).toBe(attachments);
expect(instance.getData(inputMirrorDataRef)).toEqual({
optionalSingletonPresent: { test: 'optionalSingletonPresent' },
singleton: { test: 'singleton', other: 2 },
many: [{ test: 'many1' }, { test: 'many2', other: 3 }],
});
});
it('should refuse to create an extension with invalid config', () => {
expect(() =>
createExtensionInstance({
attachments: new Map(),
config: { other: 'not-a-number' },
extension: simpleExtension,
}),
).toThrow(
"Invalid configuration for extension 'core.test'; caused by Error: Expected number, received string at 'other'",
);
});
it('should forward extension factory errors', () => {
expect(() =>
createExtensionInstance({
attachments: new Map(),
config: { other: 'not-a-number' },
extension: createExtension({
id: 'core.test',
at: 'ignored',
output: {},
factory() {
const error = new Error('NOPE');
error.name = 'NopeError';
throw error;
},
}),
}),
).toThrow(
"Failed to instantiate extension 'core.test'; caused by NopeError: NOPE",
);
});
it('should refuse to create an instance with duplicate output', () => {
const attachments = new Map();
expect(() =>
createExtensionInstance({
attachments,
config: undefined,
extension: createExtension({
id: 'core.test',
at: 'ignored',
output: {
test1: testDataRef,
test2: testDataRef,
},
factory({ bind }) {
bind({ test1: 'test', test2: 'test2' });
},
}),
}),
).toThrow(
"Failed to instantiate extension 'core.test', duplicate extension data 'test' received via output 'test2'",
);
});
it('should refuse to create an instance with disconnected output data', () => {
const attachments = new Map();
expect(() =>
createExtensionInstance({
attachments,
config: undefined,
extension: createExtension({
id: 'core.test',
at: 'ignored',
output: {
test: testDataRef,
},
factory({ bind }) {
bind({ nonexistent: 'test' } as any);
},
}),
}),
).toThrow(
"Failed to instantiate extension 'core.test', unknown output provided via 'nonexistent'",
);
});
it('should refuse to create an instance with missing required input', () => {
expect(() =>
createExtensionInstance({
attachments: new Map(),
config: undefined,
extension: createExtension({
id: 'core.test',
at: 'ignored',
inputs: {
singleton: createExtensionInput(
{
test: testDataRef,
},
{ singleton: true },
),
},
output: {},
factory() {},
}),
}),
).toThrow(
"Failed to instantiate extension 'core.test', input 'singleton' is required but was not received",
);
});
it('should refuse to create an instance with multiple inputs for required singleton', () => {
expect(() =>
createExtensionInstance({
attachments: new Map([
[
'singleton',
[
createExtensionInstance({
attachments: new Map(),
config: { output: 'many1' },
extension: simpleExtension,
}),
createExtensionInstance({
attachments: new Map(),
config: { output: 'many2' },
extension: simpleExtension,
}),
],
],
]),
config: undefined,
extension: createExtension({
id: 'core.test',
at: 'ignored',
inputs: {
singleton: createExtensionInput(
{
test: testDataRef,
},
{ singleton: true },
),
},
output: {},
factory() {},
}),
}),
).toThrow(
"Failed to instantiate extension 'core.test', expected exactly one 'singleton' input but received multiple: 'core.test', 'core.test'",
);
});
it('should refuse to create an instance with multiple inputs for optional singleton', () => {
expect(() =>
createExtensionInstance({
attachments: new Map([
[
'singleton',
[
createExtensionInstance({
attachments: new Map(),
config: { output: 'many1' },
extension: simpleExtension,
}),
createExtensionInstance({
attachments: new Map(),
config: { output: 'many2' },
extension: simpleExtension,
}),
],
],
]),
config: undefined,
extension: createExtension({
id: 'core.test',
at: 'ignored',
inputs: {
singleton: createExtensionInput(
{
test: testDataRef,
},
{ singleton: true, optional: true },
),
},
output: {},
factory() {},
}),
}),
).toThrow(
"Failed to instantiate extension 'core.test', expected at most one 'singleton' input but received multiple: 'core.test', 'core.test'",
);
});
it('should refuse to create an instance with multiple inputs that did not provide required data', () => {
expect(() =>
createExtensionInstance({
attachments: new Map([
[
'singleton',
[
createExtensionInstance({
attachments: new Map(),
config: undefined,
extension: simpleExtension,
}),
],
],
]),
config: undefined,
extension: createExtension({
id: 'core.test',
at: 'ignored',
inputs: {
singleton: createExtensionInput(
{
other: otherDataRef,
},
{ singleton: true },
),
},
output: {},
factory() {},
}),
}),
).toThrow(
"Failed to instantiate extension 'core.test', input 'singleton' did not receive required extension data 'other' from extension 'core.test'",
);
});
});
@@ -15,6 +15,8 @@
*/
import {
AnyExtensionDataMap,
AnyExtensionInputMap,
BackstagePlugin,
Extension,
ExtensionDataRef,
@@ -36,6 +38,56 @@ export interface ExtensionInstance {
readonly attachments: Map<string, ExtensionInstance[]>;
}
function resolveInputData(
dataMap: AnyExtensionDataMap,
attachment: ExtensionInstance,
inputName: string,
) {
return mapValues(dataMap, ref => {
const value = attachment.getData(ref);
if (value === undefined && !ref.config.optional) {
throw new Error(
`input '${inputName}' did not receive required extension data '${ref.id}' from extension '${attachment.id}'`,
);
}
return value;
});
}
function resolveInputs(
inputMap: AnyExtensionInputMap,
attachments: Map<string, ExtensionInstance[]>,
) {
return mapValues(inputMap, (input, inputName) => {
const attachedInstances = attachments.get(inputName) ?? [];
if (input.config.singleton) {
if (attachedInstances.length > 1) {
throw Error(
`expected ${
input.config.optional ? 'at most' : 'exactly'
} one '${inputName}' input but received multiple: '${attachedInstances
.map(e => e.id)
.join("', '")}'`,
);
} else if (attachedInstances.length === 0) {
if (input.config.optional) {
return undefined;
}
throw Error(`input '${inputName}' is required but was not received`);
}
return resolveInputData(
input.extensionData,
attachedInstances[0],
inputName,
);
}
return attachedInstances.map(attachment =>
resolveInputData(input.extensionData, attachment, inputName),
);
});
}
/** @internal */
export function createExtensionInstance(options: {
extension: Extension<unknown>;
@@ -51,7 +103,7 @@ export function createExtensionInstance(options: {
parsedConfig = extension.configSchema?.parse(config ?? {});
} catch (e) {
throw new Error(
`Invalid configuration for extension instance '${extension.id}', ${e}`,
`Invalid configuration for extension '${extension.id}'; caused by ${e}`,
);
}
@@ -63,26 +115,23 @@ export function createExtensionInstance(options: {
for (const [name, output] of Object.entries(namedOutputs)) {
const ref = extension.output[name];
if (!ref) {
throw new Error(`unknown output provided via '${name}'`);
}
if (extensionData.has(ref.id)) {
throw new Error(
`Extension instance '${extension.id}' tried to bind unknown output '${name}'`,
`duplicate extension data '${ref.id}' received via output '${name}'`,
);
}
extensionData.set(ref.id, output);
}
},
inputs: mapValues(
extension.inputs,
({ extensionData: pointData }, inputName) => {
// TODO: validation
return (attachments.get(inputName) ?? []).map(attachment =>
mapValues(pointData, ref => attachment.getData(ref)),
);
},
),
inputs: resolveInputs(extension.inputs, attachments),
});
} catch (e) {
throw new Error(
`Failed to instantiate extension instance '${extension.id}', ${e}`,
`Failed to instantiate extension '${extension.id}'${
e.name === 'Error' ? `, ${e.message}` : `; caused by ${e}`
}`,
);
}
+95 -79
View File
@@ -19,7 +19,23 @@ import { ZodTypeDef } from 'zod';
// @public (undocumented)
export type AnyExtensionDataMap = {
[name in string]: ExtensionDataRef<any, any>;
[name in string]: ExtensionDataRef<
unknown,
{
optional?: true;
}
>;
};
// @public (undocumented)
export type AnyExtensionInputMap = {
[inputName in string]: ExtensionInput<
AnyExtensionDataMap,
{
optional: boolean;
singleton: boolean;
}
>;
};
// @public (undocumented)
@@ -60,19 +76,14 @@ export const coreExtensionData: {
// @public (undocumented)
export function createApiExtension<
TConfig extends {},
TInputs extends Record<
string,
{
extensionData: AnyExtensionDataMap;
}
>,
TInputs extends AnyExtensionInputMap,
>(
options: (
| {
api: AnyApiRef;
factory: (options: {
config: TConfig;
inputs: ExtensionDataInputValues<TInputs>;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => AnyApiFactory;
}
| {
@@ -87,12 +98,7 @@ export function createApiExtension<
// @public (undocumented)
export function createExtension<
TOutput extends AnyExtensionDataMap,
TInputs extends Record<
string,
{
extensionData: AnyExtensionDataMap;
}
>,
TInputs extends AnyExtensionInputMap,
TConfig = never,
>(
options: CreateExtensionOptions<TOutput, TInputs, TConfig>,
@@ -103,15 +109,28 @@ export function createExtensionDataRef<TData>(
id: string,
): ConfigurableExtensionDataRef<TData>;
// @public (undocumented)
export function createExtensionInput<
TExtensionData extends AnyExtensionDataMap,
TConfig extends {
singleton?: boolean;
optional?: boolean;
},
>(
extensionData: TExtensionData,
config?: TConfig,
): ExtensionInput<
TExtensionData,
{
singleton: TConfig['singleton'] extends true ? true : false;
optional: TConfig['optional'] extends true ? true : false;
}
>;
// @public (undocumented)
export interface CreateExtensionOptions<
TOutput extends AnyExtensionDataMap,
TInputs extends Record<
string,
{
extensionData: AnyExtensionDataMap;
}
>,
TInputs extends AnyExtensionInputMap,
TConfig,
> {
// (undocumented)
@@ -123,9 +142,9 @@ export interface CreateExtensionOptions<
// (undocumented)
factory(options: {
source?: BackstagePlugin;
bind: ExtensionDataBind<TOutput>;
bind(values: Expand<ExtensionDataValues<TOutput>>): void;
config: TConfig;
inputs: ExtensionDataInputValues<TInputs>;
inputs: Expand<ExtensionInputValues<TInputs>>;
}): void;
// (undocumented)
id: string;
@@ -150,12 +169,7 @@ export function createPageExtension<
TConfig extends {
path: string;
},
TInputs extends Record<
string,
{
extensionData: AnyExtensionDataMap;
}
>,
TInputs extends AnyExtensionInputMap,
>(
options: (
| {
@@ -172,7 +186,7 @@ export function createPageExtension<
routeRef?: RouteRef;
loader: (options: {
config: TConfig;
inputs: ExtensionDataInputValues<TInputs>;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
},
): Extension<TConfig>;
@@ -198,19 +212,17 @@ export interface Extension<TConfig> {
// (undocumented)
factory(options: {
source?: BackstagePlugin;
bind: ExtensionDataBind<AnyExtensionDataMap>;
bind(values: ExtensionInputValues<any>): void;
config: TConfig;
inputs: Record<string, Array<Record<string, unknown>>>;
inputs: Record<
string,
undefined | Record<string, unknown> | Array<Record<string, unknown>>
>;
}): void;
// (undocumented)
id: string;
// (undocumented)
inputs: Record<
string,
{
extensionData: AnyExtensionDataMap;
}
>;
inputs: AnyExtensionInputMap;
// (undocumented)
output: AnyExtensionDataMap;
}
@@ -228,48 +240,6 @@ export interface ExtensionBoundaryProps {
source?: BackstagePlugin;
}
// @public (undocumented)
export type ExtensionDataBind<TMap extends AnyExtensionDataMap> = (
values: {
[DataName in keyof TMap as TMap[DataName]['config'] extends {
optional: true;
}
? never
: DataName]: TMap[DataName]['T'];
} & {
[DataName in keyof TMap as TMap[DataName]['config'] extends {
optional: true;
}
? DataName
: never]?: TMap[DataName]['T'];
},
) => void;
// @public (undocumented)
export type ExtensionDataInputValues<
TInputs extends {
[name in string]: {
extensionData: AnyExtensionDataMap;
};
},
> = {
[InputName in keyof TInputs]: Array<
{
[DataName in keyof TInputs[InputName]['extensionData'] as TInputs[InputName]['extensionData'][DataName]['config'] extends {
optional: true;
}
? never
: DataName]: TInputs[InputName]['extensionData'][DataName]['T'];
} & {
[DataName in keyof TInputs[InputName]['extensionData'] as TInputs[InputName]['extensionData'][DataName]['config'] extends {
optional: true;
}
? DataName
: never]?: TInputs[InputName]['extensionData'][DataName]['T'];
}
>;
};
// @public (undocumented)
export type ExtensionDataRef<
TData,
@@ -283,6 +253,52 @@ export type ExtensionDataRef<
$$type: '@backstage/ExtensionDataRef';
};
// @public
export type ExtensionDataValues<TExtensionData extends AnyExtensionDataMap> = {
[DataName in keyof TExtensionData as TExtensionData[DataName]['config'] extends {
optional: true;
}
? never
: DataName]: TExtensionData[DataName]['T'];
} & {
[DataName in keyof TExtensionData as TExtensionData[DataName]['config'] extends {
optional: true;
}
? DataName
: never]?: TExtensionData[DataName]['T'];
};
// @public (undocumented)
export interface ExtensionInput<
TExtensionData extends AnyExtensionDataMap,
TConfig extends {
singleton: boolean;
optional: boolean;
},
> {
// (undocumented)
$$type: '@backstage/ExtensionInput';
// (undocumented)
config: TConfig;
// (undocumented)
extensionData: TExtensionData;
}
// @public
export type ExtensionInputValues<
TInputs extends {
[name in string]: ExtensionInput<any, any>;
},
> = {
[InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton']
? Array<Expand<ExtensionDataValues<TInputs[InputName]['extensionData']>>>
: false extends TInputs[InputName]['config']['optional']
? Expand<ExtensionDataValues<TInputs[InputName]['extensionData']>>
: Expand<
ExtensionDataValues<TInputs[InputName]['extensionData']> | undefined
>;
};
// @public (undocumented)
export type NavTarget = {
title: string;
@@ -17,23 +17,23 @@
import { AnyApiFactory, AnyApiRef } from '@backstage/core-plugin-api';
import { PortableSchema } from '../schema';
import {
AnyExtensionDataMap,
ExtensionDataInputValues,
ExtensionInputValues,
createExtension,
coreExtensionData,
} from '../wiring';
import { AnyExtensionInputMap, Expand } from '../wiring/createExtension';
/** @public */
export function createApiExtension<
TConfig extends {},
TInputs extends Record<string, { extensionData: AnyExtensionDataMap }>,
TInputs extends AnyExtensionInputMap,
>(
options: (
| {
api: AnyApiRef;
factory: (options: {
config: TConfig;
inputs: ExtensionDataInputValues<TInputs>;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => AnyApiFactory;
}
| {
@@ -16,7 +16,7 @@
import React from 'react';
import { PortableSchema } from '../schema';
import { coreExtensionData } from '../wiring';
import { coreExtensionData, createExtensionInput } from '../wiring';
import { createPageExtension } from './createPageExtension';
describe('createPageExtension', () => {
@@ -54,9 +54,9 @@ describe('createPageExtension', () => {
disabled: true,
configSchema,
inputs: {
first: {
extensionData: { element: coreExtensionData.reactElement },
},
first: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
loader: async () => <div />,
}),
@@ -67,9 +67,9 @@ describe('createPageExtension', () => {
configSchema: expect.anything(),
disabled: true,
inputs: {
first: {
extensionData: { element: coreExtensionData.reactElement },
},
first: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
output: {
element: expect.anything(),
@@ -19,12 +19,12 @@ import React from 'react';
import { ExtensionBoundary } from '../components';
import { createSchemaFromZod, PortableSchema } from '../schema';
import {
AnyExtensionDataMap,
coreExtensionData,
createExtension,
Extension,
ExtensionDataInputValues,
ExtensionInputValues,
} from '../wiring';
import { AnyExtensionInputMap, Expand } from '../wiring/createExtension';
/**
* Helper for creating extensions for a routable React page component.
@@ -33,7 +33,7 @@ import {
*/
export function createPageExtension<
TConfig extends { path: string },
TInputs extends Record<string, { extensionData: AnyExtensionDataMap }>,
TInputs extends AnyExtensionInputMap,
>(
options: (
| {
@@ -50,7 +50,7 @@ export function createPageExtension<
routeRef?: RouteRef;
loader: (options: {
config: TConfig;
inputs: ExtensionDataInputValues<TInputs>;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
},
): Extension<TConfig> {
@@ -16,6 +16,7 @@
import { createExtension } from './createExtension';
import { createExtensionDataRef } from './createExtensionDataRef';
import { createExtensionInput } from './createExtensionInput';
const stringData = createExtensionDataRef<string>('string');
@@ -95,22 +96,16 @@ describe('createExtension', () => {
id: 'test',
at: 'root',
inputs: {
mixed: {
extensionData: {
required: stringData,
optional: stringData.optional(),
},
},
onlyRequired: {
extensionData: {
required: stringData,
},
},
onlyOptional: {
extensionData: {
optional: stringData.optional(),
},
},
mixed: createExtensionInput({
required: stringData,
optional: stringData.optional(),
}),
onlyRequired: createExtensionInput({
required: stringData,
}),
onlyOptional: createExtensionInput({
optional: stringData.optional(),
}),
},
output: {
foo: stringData,
@@ -15,51 +15,68 @@
*/
import { PortableSchema } from '../schema';
import { ExtensionDataRef } from './createExtensionDataRef';
import { ExtensionInput } from './createExtensionInput';
import { BackstagePlugin } from './createPlugin';
import { AnyExtensionDataMap, Extension } from './types';
/** @public */
export type ExtensionDataInputValues<
TInputs extends { [name in string]: { extensionData: AnyExtensionDataMap } },
> = {
[InputName in keyof TInputs]: Array<
{
[DataName in keyof TInputs[InputName]['extensionData'] as TInputs[InputName]['extensionData'][DataName]['config'] extends {
optional: true;
}
? never
: DataName]: TInputs[InputName]['extensionData'][DataName]['T'];
} & {
[DataName in keyof TInputs[InputName]['extensionData'] as TInputs[InputName]['extensionData'][DataName]['config'] extends {
optional: true;
}
? DataName
: never]?: TInputs[InputName]['extensionData'][DataName]['T'];
}
>;
export type AnyExtensionDataMap = {
[name in string]: ExtensionDataRef<unknown, { optional?: true }>;
};
/** @public */
export type ExtensionDataBind<TMap extends AnyExtensionDataMap> = (
values: {
[DataName in keyof TMap as TMap[DataName]['config'] extends {
optional: true;
}
? never
: DataName]: TMap[DataName]['T'];
} & {
[DataName in keyof TMap as TMap[DataName]['config'] extends {
optional: true;
}
? DataName
: never]?: TMap[DataName]['T'];
},
) => void;
export type AnyExtensionInputMap = {
[inputName in string]: ExtensionInput<
AnyExtensionDataMap,
{ optional: boolean; singleton: boolean }
>;
};
// TODO(Rugvip): This might be a quite useful utility type, maybe add to @backstage/types?
/**
* Utility type to expand type aliases into their equivalent type.
* @ignore
*/
export type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
/**
* Converts an extension data map into the matching concrete data values type.
* @public
*/
export type ExtensionDataValues<TExtensionData extends AnyExtensionDataMap> = {
[DataName in keyof TExtensionData as TExtensionData[DataName]['config'] extends {
optional: true;
}
? never
: DataName]: TExtensionData[DataName]['T'];
} & {
[DataName in keyof TExtensionData as TExtensionData[DataName]['config'] extends {
optional: true;
}
? DataName
: never]?: TExtensionData[DataName]['T'];
};
/**
* Converts an extension input map into the matching concrete input values type.
* @public
*/
export type ExtensionInputValues<
TInputs extends { [name in string]: ExtensionInput<any, any> },
> = {
[InputName in keyof TInputs]: false extends TInputs[InputName]['config']['singleton']
? Array<Expand<ExtensionDataValues<TInputs[InputName]['extensionData']>>>
: false extends TInputs[InputName]['config']['optional']
? Expand<ExtensionDataValues<TInputs[InputName]['extensionData']>>
: Expand<
ExtensionDataValues<TInputs[InputName]['extensionData']> | undefined
>;
};
/** @public */
export interface CreateExtensionOptions<
TOutput extends AnyExtensionDataMap,
TInputs extends Record<string, { extensionData: AnyExtensionDataMap }>,
TInputs extends AnyExtensionInputMap,
TConfig,
> {
id: string;
@@ -70,16 +87,36 @@ export interface CreateExtensionOptions<
configSchema?: PortableSchema<TConfig>;
factory(options: {
source?: BackstagePlugin;
bind: ExtensionDataBind<TOutput>;
bind(values: Expand<ExtensionDataValues<TOutput>>): void;
config: TConfig;
inputs: ExtensionDataInputValues<TInputs>;
inputs: Expand<ExtensionInputValues<TInputs>>;
}): void;
}
/** @public */
export interface Extension<TConfig> {
$$type: '@backstage/Extension';
id: string;
at: string;
disabled: boolean;
inputs: AnyExtensionInputMap;
output: AnyExtensionDataMap;
configSchema?: PortableSchema<TConfig>;
factory(options: {
source?: BackstagePlugin;
bind(values: ExtensionInputValues<any>): void;
config: TConfig;
inputs: Record<
string,
undefined | Record<string, unknown> | Array<Record<string, unknown>>
>;
}): void;
}
/** @public */
export function createExtension<
TOutput extends AnyExtensionDataMap,
TInputs extends Record<string, { extensionData: AnyExtensionDataMap }>,
TInputs extends AnyExtensionInputMap,
TConfig = never,
>(
options: CreateExtensionOptions<TOutput, TInputs, TConfig>,
@@ -94,7 +131,7 @@ export function createExtension<
return options.factory({
bind,
config,
inputs: inputs as ExtensionDataInputValues<TInputs>,
inputs: inputs as Expand<ExtensionInputValues<TInputs>>,
});
},
};
@@ -0,0 +1,55 @@
/*
* Copyright 2023 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 { AnyExtensionDataMap } from './createExtension';
/** @public */
export interface ExtensionInput<
TExtensionData extends AnyExtensionDataMap,
TConfig extends { singleton: boolean; optional: boolean },
> {
$$type: '@backstage/ExtensionInput';
extensionData: TExtensionData;
config: TConfig;
}
/** @public */
export function createExtensionInput<
TExtensionData extends AnyExtensionDataMap,
TConfig extends { singleton?: boolean; optional?: boolean },
>(
extensionData: TExtensionData,
config?: TConfig,
): ExtensionInput<
TExtensionData,
{
singleton: TConfig['singleton'] extends true ? true : false;
optional: TConfig['optional'] extends true ? true : false;
}
> {
return {
$$type: '@backstage/ExtensionInput',
extensionData,
config: {
singleton: Boolean(config?.singleton) as TConfig['singleton'] extends true
? true
: false,
optional: Boolean(config?.optional) as TConfig['optional'] extends true
? true
: false,
},
};
}
@@ -24,6 +24,7 @@ import { createExtension } from './createExtension';
import { createExtensionDataRef } from './createExtensionDataRef';
import { coreExtensionData } from './coreExtensionData';
import { MockConfigApi } from '@backstage/test-utils';
import { createExtensionInput } from './createExtensionInput';
const nameExtensionDataRef = createExtensionDataRef<string>('name');
@@ -70,11 +71,9 @@ const TechDocsPage = createExtension({
id: 'plugin.techdocs.page',
at: 'test.output/names',
inputs: {
addons: {
extensionData: {
name: nameExtensionDataRef,
},
},
addons: createExtensionInput({
name: nameExtensionDataRef,
}),
},
output: {
name: nameExtensionDataRef,
@@ -88,11 +87,9 @@ const outputExtension = createExtension({
id: 'test.output',
at: 'root',
inputs: {
names: {
extensionData: {
name: nameExtensionDataRef,
},
},
names: createExtensionInput({
name: nameExtensionDataRef,
}),
},
output: {
element: coreExtensionData.reactElement,
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Extension } from './types';
import { Extension } from './createExtension';
/** @public */
export interface PluginOptions {
@@ -17,10 +17,17 @@
export { coreExtensionData, type NavTarget } from './coreExtensionData';
export {
createExtension,
type Extension,
type CreateExtensionOptions,
type ExtensionDataBind,
type ExtensionDataInputValues,
type ExtensionDataValues,
type ExtensionInputValues,
type AnyExtensionInputMap,
type AnyExtensionDataMap,
} from './createExtension';
export {
createExtensionInput,
type ExtensionInput,
} from './createExtensionInput';
export {
createExtensionDataRef,
type ExtensionDataRef,
@@ -31,4 +38,3 @@ export {
type BackstagePlugin,
type PluginOptions,
} from './createPlugin';
export type { AnyExtensionDataMap, Extension } from './types';
@@ -1,42 +0,0 @@
/*
* Copyright 2023 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 { PortableSchema } from '../schema';
import { ExtensionDataBind } from './createExtension';
import { ExtensionDataRef } from './createExtensionDataRef';
import { BackstagePlugin } from './createPlugin';
/** @public */
export type AnyExtensionDataMap = {
[name in string]: ExtensionDataRef<any, any>;
};
/** @public */
export interface Extension<TConfig> {
$$type: '@backstage/Extension';
id: string;
at: string;
disabled: boolean;
inputs: Record<string, { extensionData: AnyExtensionDataMap }>;
output: AnyExtensionDataMap;
configSchema?: PortableSchema<TConfig>;
factory(options: {
source?: BackstagePlugin;
bind: ExtensionDataBind<AnyExtensionDataMap>;
config: TConfig;
inputs: Record<string, Array<Record<string, unknown>>>;
}): void;
}
+4 -5
View File
@@ -19,6 +19,7 @@ import {
createApiExtension,
createExtension,
createExtensionDataRef,
createExtensionInput,
createNavItemExtension,
createPageExtension,
createPlugin,
@@ -64,11 +65,9 @@ const endpointDataRef = createExtensionDataRef<GraphQLEndpoint>(
export const graphiqlBrowseApi = createApiExtension({
api: graphQlBrowseApiRef, // apis.plugin.graphiql.browse
inputs: {
endpoints: {
extensionData: {
endpoint: endpointDataRef,
},
},
endpoints: createExtensionInput({
endpoint: endpointDataRef,
}),
},
factory({ inputs }) {
return createApiFactory(