frontend-plugin-api: add support for accessing extensions from plugin instance

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-08-08 16:19:54 +02:00
parent 57cb03d6b6
commit a65cfc8149
25 changed files with 564 additions and 152 deletions
@@ -174,6 +174,9 @@ export type VerifyExtensionFactoryOutput<
/** @public */
export type CreateExtensionOptions<
TKind extends string | undefined,
TNamespace extends string | undefined,
TName extends string | undefined,
UOutput extends AnyExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
@@ -184,9 +187,9 @@ export type CreateExtensionOptions<
TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType },
UFactoryOutput extends ExtensionDataValue<any, any>,
> = {
kind?: string;
namespace?: string;
name?: string;
kind?: TKind;
namespace?: TNamespace;
name?: TName;
attachTo: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
@@ -214,11 +217,14 @@ export interface ExtensionDefinition<
{ optional: boolean; singleton: boolean }
>;
} = {},
TKind extends string | undefined = string | undefined,
TNamespace extends string | undefined = string | undefined,
TName extends string | undefined = string | undefined,
> {
$$type: '@backstage/ExtensionDefinition';
readonly kind?: string;
readonly namespace?: string;
readonly name?: string;
readonly kind?: TKind;
readonly namespace?: TNamespace;
readonly name?: TName;
readonly attachTo: { id: string; input: string };
readonly disabled: boolean;
readonly configSchema?: PortableSchema<TConfig, TConfigInput>;
@@ -284,45 +290,68 @@ export interface ExtensionDefinition<
> &
TConfigInput,
AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput,
TInputs & TExtraInputs
TInputs & TExtraInputs,
TKind,
TNamespace,
TName
>;
}
/** @internal */
export type InternalExtensionDefinition<TConfig, TConfigInput> =
ExtensionDefinition<TConfig, TConfigInput> &
(
| {
readonly version: 'v1';
readonly inputs: AnyExtensionInputMap;
readonly output: AnyExtensionDataMap;
factory(context: {
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs<AnyExtensionInputMap>;
}): ExtensionDataValues<any>;
}
| {
readonly version: 'v2';
readonly inputs: {
export type InternalExtensionDefinition<
TConfig,
TConfigInput = TConfig,
UOutput extends AnyExtensionDataRef = AnyExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
} = {},
TKind extends string | undefined = string | undefined,
TNamespace extends string | undefined = string | undefined,
TName extends string | undefined = string | undefined,
> = ExtensionDefinition<
TConfig,
TConfigInput,
UOutput,
TInputs,
TKind,
TNamespace,
TName
> &
(
| {
readonly version: 'v1';
readonly inputs: AnyExtensionInputMap;
readonly output: AnyExtensionDataMap;
factory(context: {
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs<AnyExtensionInputMap>;
}): ExtensionDataValues<any>;
}
| {
readonly version: 'v2';
readonly inputs: {
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
};
readonly output: Array<AnyExtensionDataRef>;
factory(context: {
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs<{
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
};
readonly output: Array<AnyExtensionDataRef>;
factory(context: {
node: AppNode;
config: TConfig;
inputs: ResolvedExtensionInputs<{
[inputName in string]: ExtensionInput<
AnyExtensionDataRef,
{ optional: boolean; singleton: boolean }
>;
}>;
}): Iterable<ExtensionDataValue<any, any>>;
}
);
}>;
}): Iterable<ExtensionDataValue<any, any>>;
}
);
/** @internal */
export function toInternalExtensionDefinition<TConfig, TConfigInput>(
@@ -357,8 +386,14 @@ export function createExtension<
},
TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType },
UFactoryOutput extends ExtensionDataValue<any, any>,
const TKind extends string | undefined = undefined,
const TNamespace extends string | undefined = undefined,
const TName extends string | undefined = undefined,
>(
options: CreateExtensionOptions<
TKind,
TNamespace,
TName,
UOutput,
TInputs,
TConfigSchema,
@@ -374,7 +409,10 @@ export function createExtension<
}>
>,
UOutput,
TInputs
TInputs,
string | undefined extends TKind ? undefined : TKind,
string | undefined extends TNamespace ? undefined : TNamespace,
string | undefined extends TName ? undefined : TName
>;
/**
* @public
@@ -394,6 +432,9 @@ export function createExtension<
>,
): ExtensionDefinition<TConfig, TConfigInput, never, never>;
export function createExtension<
const TKind extends string | undefined,
const TNamespace extends string | undefined,
const TName extends string | undefined,
UOutput extends AnyExtensionDataRef,
TInputs extends {
[inputName in string]: ExtensionInput<
@@ -408,7 +449,15 @@ export function createExtension<
UFactoryOutput extends ExtensionDataValue<any, any>,
>(
options:
| CreateExtensionOptions<UOutput, TInputs, TConfigSchema, UFactoryOutput>
| CreateExtensionOptions<
TKind,
TNamespace,
TName,
UOutput,
TInputs,
TConfigSchema,
UFactoryOutput
>
| LegacyCreateExtensionOptions<
AnyExtensionDataMap,
TLegacyInputs,
@@ -431,7 +480,10 @@ export function createExtension<
}>
>),
UOutput,
TInputs
TInputs,
TKind,
TNamespace,
TName
> {
if ('configSchema' in options && 'config' in options) {
throw new Error(`Cannot provide both configSchema and config.schema`);
@@ -542,7 +594,10 @@ export function createExtension<
>
>,
AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput,
TInputs & TExtraInputs
TInputs & TExtraInputs,
TKind,
TNamespace,
TName
> => {
if (!Array.isArray(options.output)) {
throw new Error(
@@ -550,6 +605,9 @@ export function createExtension<
);
}
const newOptions = options as CreateExtensionOptions<
TKind,
TNamespace,
TName,
UOutput,
TInputs,
TConfigSchema,
@@ -612,7 +670,7 @@ export function createExtension<
return deduplicatedResult.values() as Iterable<UOverrideFactoryOutput>;
},
} as CreateExtensionOptions<AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput, TInputs & TExtraInputs, TConfigSchema & TExtensionConfigSchema, UOverrideFactoryOutput>);
} as CreateExtensionOptions<TKind, TNamespace, TName, AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput, TInputs & TExtraInputs, TConfigSchema & TExtensionConfigSchema, UOverrideFactoryOutput>);
},
} as InternalExtensionDefinition<
TConfig &
@@ -630,6 +688,11 @@ export function createExtension<
z.ZodObject<{
[key in keyof TConfigSchema]: ReturnType<TConfigSchema[key]>;
}>
>)
>),
UOutput,
TInputs,
TKind,
TNamespace,
TName
>;
}
@@ -36,6 +36,9 @@ import {
* @public
*/
export type CreateExtensionBlueprintOptions<
TKind extends string,
TNamespace extends string | undefined,
TName extends string | undefined,
TParams,
UOutput extends AnyExtensionDataRef,
TInputs extends {
@@ -48,13 +51,13 @@ export type CreateExtensionBlueprintOptions<
UFactoryOutput extends ExtensionDataValue<any, any>,
TDataRefs extends { [name in string]: AnyExtensionDataRef },
> = {
kind: string;
namespace?: string | ((params: TParams) => string);
kind: TKind;
namespace?: TNamespace | ((params: TParams) => TNamespace);
attachTo: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
output: Array<UOutput>;
name?: string | ((params: TParams) => string);
name?: TName | ((params: TParams) => TName);
config?: {
schema: TConfigSchema | ((params: TParams) => TConfigSchema);
};
@@ -76,6 +79,9 @@ export type CreateExtensionBlueprintOptions<
* @public
*/
export interface ExtensionBlueprint<
TKind extends string,
TNamespace extends string | undefined,
TName extends string | undefined,
TParams,
UOutput extends AnyExtensionDataRef,
TInputs extends {
@@ -97,6 +103,8 @@ export interface ExtensionBlueprint<
* optionally call the original factory with the same params.
*/
make<
TNewNamespace extends string | undefined,
TNewName extends string | undefined,
TExtensionConfigSchema extends {
[key in string]: (zImpl: typeof z) => z.ZodType;
},
@@ -110,8 +118,8 @@ export interface ExtensionBlueprint<
},
>(
args: {
namespace?: string;
name?: string;
namespace?: TNewNamespace;
name?: TNewName;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TExtraInputs & {
@@ -166,7 +174,12 @@ export interface ExtensionBlueprint<
>;
}>
> &
TConfigInput
TConfigInput,
AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput,
TInputs & TExtraInputs,
TKind,
string | undefined extends TNewNamespace ? TNamespace : TNewNamespace,
string | undefined extends TNewName ? TName : TNewName
>;
}
@@ -198,6 +211,9 @@ export function createDataContainer<UData extends AnyExtensionDataRef>(
* @internal
*/
class ExtensionBlueprintImpl<
TKind extends string,
TNamespace extends string | undefined,
TName extends string | undefined,
TParams,
UOutput extends AnyExtensionDataRef,
TInputs extends {
@@ -211,6 +227,9 @@ class ExtensionBlueprintImpl<
> {
constructor(
private readonly options: CreateExtensionBlueprintOptions<
TKind,
TNamespace,
TName,
TParams,
UOutput,
TInputs,
@@ -236,9 +255,11 @@ class ExtensionBlueprintImpl<
{ optional: boolean; singleton: boolean }
>;
},
TNewNamespace extends string | undefined = undefined,
TNewName extends string | undefined = undefined,
>(args: {
namespace?: string;
name?: string;
namespace?: TNewNamespace;
name?: TNewName;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TExtraInputs;
@@ -360,26 +381,7 @@ class ExtensionBlueprintImpl<
}
throw new Error('Either params or factory must be provided');
},
} as CreateExtensionOptions<UOutput, TInputs & TExtraInputs, TConfigSchema & TExtensionConfigSchema, UFactoryOutput>) as ExtensionDefinition<
{
[key in keyof TExtensionConfigSchema]: z.infer<
ReturnType<TExtensionConfigSchema[key]>
>;
} & {
[key in keyof TConfigSchema]: z.infer<ReturnType<TConfigSchema[key]>>;
},
z.input<
z.ZodObject<
{
[key in keyof TExtensionConfigSchema]: ReturnType<
TExtensionConfigSchema[key]
>;
} & {
[key in keyof TConfigSchema]: ReturnType<TConfigSchema[key]>;
}
>
>
>;
} as CreateExtensionOptions<TKind, string | undefined extends TNewNamespace ? TNamespace : TNewNamespace, string | undefined extends TNewName ? TName : TNewName, AnyExtensionDataRef extends UNewOutput ? UOutput : UNewOutput, TInputs & TExtraInputs, TConfigSchema & TExtensionConfigSchema, UFactoryOutput>);
}
}
@@ -400,9 +402,15 @@ export function createExtensionBlueprint<
},
TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType },
UFactoryOutput extends ExtensionDataValue<any, any>,
TKind extends string,
TNamespace extends string | undefined = undefined,
TName extends string | undefined = undefined,
TDataRefs extends { [name in string]: AnyExtensionDataRef } = never,
>(
options: CreateExtensionBlueprintOptions<
TKind,
TNamespace,
TName,
TParams,
UOutput,
TInputs,
@@ -411,6 +419,9 @@ export function createExtensionBlueprint<
TDataRefs
>,
): ExtensionBlueprint<
TKind,
TNamespace,
TName,
TParams,
UOutput,
string extends keyof TInputs ? {} : TInputs,
@@ -427,6 +438,9 @@ export function createExtensionBlueprint<
TDataRefs
> {
return new ExtensionBlueprintImpl(options) as ExtensionBlueprint<
TKind,
TNamespace,
TName,
TParams,
UOutput,
string extends keyof TInputs ? {} : TInputs,
@@ -34,11 +34,9 @@ const nameExtensionDataRef = createExtensionDataRef<string>().with({
const Extension1 = createExtension({
name: '1',
attachTo: { id: 'test/output', input: 'names' },
output: {
name: nameExtensionDataRef,
},
output: [nameExtensionDataRef],
factory() {
return { name: 'extension-1' };
return [nameExtensionDataRef('extension-1')];
},
});
@@ -150,6 +148,8 @@ describe('createPlugin', () => {
});
expect(plugin).toBeDefined();
expect(plugin.getExtension('test/1')).toBe(Extension1);
await renderWithEffects(
createTestAppRoot({
features: [plugin],
@@ -17,6 +17,7 @@
import { ExtensionDefinition } from './createExtension';
import {
Extension,
ResolveExtensionId,
resolveExtensionDefinition,
} from './resolveExtensionDefinition';
import {
@@ -28,21 +29,23 @@ import {
/** @public */
export interface PluginOptions<
Routes extends AnyRoutes,
ExternalRoutes extends AnyExternalRoutes,
TId extends string,
TRoutes extends AnyRoutes,
TExternalRoutes extends AnyExternalRoutes,
TExtensions extends readonly ExtensionDefinition<any, any>[],
> {
id: string;
routes?: Routes;
externalRoutes?: ExternalRoutes;
extensions?: ExtensionDefinition<any, any>[];
id: TId;
routes?: TRoutes;
externalRoutes?: TExternalRoutes;
extensions?: TExtensions;
featureFlags?: FeatureFlagConfig[];
}
/** @public */
export interface InternalBackstagePlugin<
Routes extends AnyRoutes = AnyRoutes,
ExternalRoutes extends AnyExternalRoutes = AnyExternalRoutes,
> extends BackstagePlugin<Routes, ExternalRoutes> {
TRoutes extends AnyRoutes = AnyRoutes,
TExternalRoutes extends AnyExternalRoutes = AnyExternalRoutes,
> extends BackstagePlugin<TRoutes, TExternalRoutes> {
readonly version: 'v1';
readonly extensions: Extension<unknown>[];
readonly featureFlags: FeatureFlagConfig[];
@@ -50,17 +53,36 @@ export interface InternalBackstagePlugin<
/** @public */
export function createPlugin<
Routes extends AnyRoutes = {},
ExternalRoutes extends AnyExternalRoutes = {},
TId extends string,
TRoutes extends AnyRoutes = {},
TExternalRoutes extends AnyExternalRoutes = {},
TExtensions extends readonly ExtensionDefinition<any, any>[] = [],
>(
options: PluginOptions<Routes, ExternalRoutes>,
): BackstagePlugin<Routes, ExternalRoutes> {
const extensions = (options.extensions ?? []).map(def =>
resolveExtensionDefinition(def, { namespace: options.id }),
);
options: PluginOptions<TId, TRoutes, TExternalRoutes, TExtensions>,
): BackstagePlugin<
TRoutes,
TExternalRoutes,
{
[KExtension in TExtensions[number] as ResolveExtensionId<
KExtension,
TId
>]: KExtension;
}
> {
const extensions = new Array<Extension<unknown>>();
const extensionDefinitionsById = new Map<
string,
ExtensionDefinition<unknown>
>();
const extensionIds = extensions.map(e => e.id);
if (extensionIds.length !== new Set(extensionIds).size) {
for (const def of options.extensions ?? []) {
const ext = resolveExtensionDefinition(def, { namespace: options.id });
extensions.push(ext);
extensionDefinitionsById.set(ext.id, def);
}
if (extensions.length !== extensionDefinitionsById.size) {
const extensionIds = extensions.map(e => e.id);
const duplicates = Array.from(
new Set(
extensionIds.filter((id, index) => extensionIds.indexOf(id) !== index),
@@ -78,14 +100,17 @@ export function createPlugin<
$$type: '@backstage/BackstagePlugin',
version: 'v1',
id: options.id,
routes: options.routes ?? ({} as Routes),
externalRoutes: options.externalRoutes ?? ({} as ExternalRoutes),
routes: options.routes ?? ({} as TRoutes),
externalRoutes: options.externalRoutes ?? ({} as TExternalRoutes),
featureFlags: options.featureFlags ?? [],
extensions,
getExtension(id) {
return extensionDefinitionsById.get(id);
},
toString() {
return `Plugin{id=${options.id}}`;
},
} as InternalBackstagePlugin<Routes, ExternalRoutes>;
} as InternalBackstagePlugin<TRoutes, TExternalRoutes>;
}
/** @internal */
@@ -15,7 +15,10 @@
*/
import { ExtensionDefinition } from './createExtension';
import { resolveExtensionDefinition } from './resolveExtensionDefinition';
import {
ResolveExtensionId,
resolveExtensionDefinition,
} from './resolveExtensionDefinition';
describe('resolveExtensionDefinition', () => {
const baseDef = {
@@ -98,3 +101,103 @@ describe('old resolveExtensionDefinition', () => {
);
});
});
describe('ResolveExtensionId', () => {
it('should resolve extension IDs correctly', () => {
type NamedExtension<
TKind extends string | undefined,
TNamespace extends string | undefined,
TName extends string | undefined,
> = ExtensionDefinition<any, any, any, any, TKind, TNamespace, TName>;
const id1: 'k:ns' = {} as ResolveExtensionId<
NamedExtension<'k', 'ns', undefined>,
undefined
>;
const id2: 'k:n' = {} as ResolveExtensionId<
NamedExtension<'k', undefined, 'n'>,
undefined
>;
const id3: 'ns/n' = {} as ResolveExtensionId<
NamedExtension<undefined, 'ns', 'n'>,
undefined
>;
const id4: never = {} as ResolveExtensionId<
NamedExtension<'k', undefined, undefined>,
undefined
>;
const id5: 'ns' = {} as ResolveExtensionId<
NamedExtension<undefined, 'ns', undefined>,
undefined
>;
const id6: 'n' = {} as ResolveExtensionId<
NamedExtension<undefined, undefined, 'n'>,
undefined
>;
const id7: 'k:ns/n' = {} as ResolveExtensionId<
NamedExtension<'k', 'ns', 'n'>,
undefined
>;
const id8: 'k:ns2' = {} as ResolveExtensionId<
NamedExtension<'k', 'ns', undefined>,
'ns2'
>;
const id9: 'k:ns2/n' = {} as ResolveExtensionId<
NamedExtension<'k', undefined, 'n'>,
'ns2'
>;
const ida: 'ns2/n' = {} as ResolveExtensionId<
NamedExtension<undefined, 'ns', 'n'>,
'ns2'
>;
const idb: 'k:ns2' = {} as ResolveExtensionId<
NamedExtension<'k', undefined, undefined>,
'ns2'
>;
const idc: 'ns2' = {} as ResolveExtensionId<
NamedExtension<undefined, 'ns', undefined>,
'ns2'
>;
const idd: 'ns2/n' = {} as ResolveExtensionId<
NamedExtension<undefined, undefined, 'n'>,
'ns2'
>;
const ide: 'k:ns2/n' = {} as ResolveExtensionId<
NamedExtension<'k', 'ns', 'n'>,
'ns2'
>;
const invalid1: never = {} as ResolveExtensionId<
NamedExtension<string | undefined, 'ns', 'n'>,
undefined
>;
const invalid2: never = {} as ResolveExtensionId<
NamedExtension<'k', string | undefined, 'n'>,
undefined
>;
const invalid3: never = {} as ResolveExtensionId<
NamedExtension<'k', 'ns', string | undefined>,
undefined
>;
expect([
id1,
id2,
id3,
id4,
id5,
id6,
id7,
id8,
id9,
ida,
idb,
idc,
idd,
ide,
invalid1,
invalid2,
invalid3,
]).toBeDefined();
});
});
@@ -94,6 +94,36 @@ export function toInternalExtension<TConfig, TConfigInput>(
return internal;
}
/** @ignore */
export type ResolveExtensionId<
TExtension extends ExtensionDefinition<any>,
TDefaultNamespace extends string | undefined,
> = TExtension extends ExtensionDefinition<
any,
any,
any,
any,
infer IKind,
infer INamespace,
infer IName
>
? [string | undefined] extends [IKind | INamespace | IName]
? never
: (
(
undefined extends TDefaultNamespace ? INamespace : TDefaultNamespace
) extends infer ISelectedNamespace extends string
? undefined extends IName
? ISelectedNamespace
: `${ISelectedNamespace}/${IName}`
: IName
) extends infer INamePart extends string
? IKind extends string
? `${IKind}:${INamePart}`
: INamePart
: never
: never;
/** @internal */
export function resolveExtensionDefinition<TConfig, TConfigInput>(
definition: ExtensionDefinition<TConfig, TConfigInput>,
@@ -15,6 +15,7 @@
*/
import { ExternalRouteRef, RouteRef, SubRouteRef } from '../routing';
import { ExtensionDefinition } from './createExtension';
/**
* Feature flag configuration.
@@ -32,15 +33,24 @@ export type AnyRoutes = { [name in string]: RouteRef | SubRouteRef };
/** @public */
export type AnyExternalRoutes = { [name in string]: ExternalRouteRef };
/** @public */
export type ExtensionMap<
TExtensionMap extends { [id in string]: ExtensionDefinition<any, any> },
> = {
get<TId extends keyof TExtensionMap>(id: TId): TExtensionMap[TId];
};
/** @public */
export interface BackstagePlugin<
Routes extends AnyRoutes = AnyRoutes,
ExternalRoutes extends AnyExternalRoutes = AnyExternalRoutes,
TRoutes extends AnyRoutes = AnyRoutes,
TExternalRoutes extends AnyExternalRoutes = AnyExternalRoutes,
TExtensionMap extends { [id in string]: ExtensionDefinition<any, any> } = {},
> {
readonly $$type: '@backstage/BackstagePlugin';
readonly id: string;
readonly routes: Routes;
readonly externalRoutes: ExternalRoutes;
readonly routes: TRoutes;
readonly externalRoutes: TExternalRoutes;
getExtension<TId extends keyof TExtensionMap>(id: TId): TExtensionMap[TId];
}
/** @public */