frontend-plugin-api: switch Extension and ExtensionDefinition to be opaque types
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-plugin-api': minor
|
||||
---
|
||||
|
||||
Changed `Extension` and `ExtensionDefinition` to use opaque types.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-app-api': patch
|
||||
---
|
||||
|
||||
Updated usage of `Extension` and `ExtensionDefinition` as they are now opaque.
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import mapValues from 'lodash/mapValues';
|
||||
import { AppNode, AppNodeInstance } from '@backstage/frontend-plugin-api';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
|
||||
|
||||
type Mutable<T> = {
|
||||
-readonly [P in keyof T]: T[P];
|
||||
@@ -122,14 +124,16 @@ export function createAppNodeInstance(options: {
|
||||
}
|
||||
|
||||
try {
|
||||
const namedOutputs = extension.factory({
|
||||
const internalExtension = toInternalExtension(extension);
|
||||
|
||||
const namedOutputs = internalExtension.factory({
|
||||
node,
|
||||
config: parsedConfig,
|
||||
inputs: resolveInputs(extension.inputs, attachments),
|
||||
inputs: resolveInputs(internalExtension.inputs, attachments),
|
||||
});
|
||||
|
||||
for (const [name, output] of Object.entries(namedOutputs)) {
|
||||
const ref = extension.output[name];
|
||||
const ref = internalExtension.output[name];
|
||||
if (!ref) {
|
||||
throw new Error(`unknown output provided via '${name}'`);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ function makeExt(
|
||||
) {
|
||||
return {
|
||||
$$type: '@backstage/Extension',
|
||||
version: 'v1',
|
||||
id,
|
||||
attachTo: { id: attachId, input: 'default' },
|
||||
disabled: status === 'disabled',
|
||||
@@ -42,6 +43,7 @@ function makeExtDef(
|
||||
) {
|
||||
return {
|
||||
$$type: '@backstage/ExtensionDefinition',
|
||||
version: 'v1',
|
||||
name,
|
||||
attachTo: { id: attachId, input: 'default' },
|
||||
disabled: status === 'disabled',
|
||||
|
||||
@@ -25,6 +25,8 @@ import { ExtensionParameters } from './readAppExtensionsConfig';
|
||||
import { AppNodeSpec } from '@backstage/frontend-plugin-api';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalBackstagePlugin } from '../../../frontend-plugin-api/src/wiring/createPlugin';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalExtension } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
|
||||
|
||||
/** @internal */
|
||||
export function resolveAppNodeSpecs(options: {
|
||||
@@ -88,45 +90,53 @@ export function resolveAppNodeSpecs(options: {
|
||||
}
|
||||
|
||||
const configuredExtensions = [
|
||||
...pluginExtensions.map(({ source, ...extension }) => ({
|
||||
extension,
|
||||
params: {
|
||||
source,
|
||||
attachTo: extension.attachTo,
|
||||
disabled: extension.disabled,
|
||||
config: undefined as unknown,
|
||||
},
|
||||
})),
|
||||
...builtinExtensions.map(extension => ({
|
||||
extension,
|
||||
params: {
|
||||
source: undefined,
|
||||
attachTo: extension.attachTo,
|
||||
disabled: extension.disabled,
|
||||
config: undefined as unknown,
|
||||
},
|
||||
})),
|
||||
...pluginExtensions.map(({ source, ...extension }) => {
|
||||
const internalExtension = toInternalExtension(extension);
|
||||
return {
|
||||
extension: internalExtension,
|
||||
params: {
|
||||
source,
|
||||
attachTo: internalExtension.attachTo,
|
||||
disabled: internalExtension.disabled,
|
||||
config: undefined as unknown,
|
||||
},
|
||||
};
|
||||
}),
|
||||
...builtinExtensions.map(extension => {
|
||||
const internalExtension = toInternalExtension(extension);
|
||||
return {
|
||||
extension: internalExtension,
|
||||
params: {
|
||||
source: undefined,
|
||||
attachTo: internalExtension.attachTo,
|
||||
disabled: internalExtension.disabled,
|
||||
config: undefined as unknown,
|
||||
},
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
// Install all extension overrides
|
||||
for (const extension of overrideExtensions) {
|
||||
const internalExtension = toInternalExtension(extension);
|
||||
|
||||
// Check if our override is overriding an extension that already exists
|
||||
const index = configuredExtensions.findIndex(
|
||||
e => e.extension.id === extension.id,
|
||||
);
|
||||
if (index !== -1) {
|
||||
// Only implementation, attachment point and default disabled status are overridden, the source is kept
|
||||
configuredExtensions[index].extension = extension;
|
||||
configuredExtensions[index].params.attachTo = extension.attachTo;
|
||||
configuredExtensions[index].params.disabled = extension.disabled;
|
||||
configuredExtensions[index].extension = internalExtension;
|
||||
configuredExtensions[index].params.attachTo = internalExtension.attachTo;
|
||||
configuredExtensions[index].params.disabled = internalExtension.disabled;
|
||||
} else {
|
||||
// Add the extension as a new one when not overriding an existing one
|
||||
configuredExtensions.push({
|
||||
extension,
|
||||
extension: internalExtension,
|
||||
params: {
|
||||
source: undefined,
|
||||
attachTo: extension.attachTo,
|
||||
disabled: extension.disabled,
|
||||
attachTo: internalExtension.attachTo,
|
||||
disabled: internalExtension.disabled,
|
||||
config: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -670,26 +670,16 @@ export interface Extension<TConfig> {
|
||||
// (undocumented)
|
||||
$$type: '@backstage/Extension';
|
||||
// (undocumented)
|
||||
attachTo: {
|
||||
readonly attachTo: {
|
||||
id: string;
|
||||
input: string;
|
||||
};
|
||||
// (undocumented)
|
||||
configSchema?: PortableSchema<TConfig>;
|
||||
readonly configSchema?: PortableSchema<TConfig>;
|
||||
// (undocumented)
|
||||
disabled: boolean;
|
||||
readonly disabled: boolean;
|
||||
// (undocumented)
|
||||
factory(options: {
|
||||
node: AppNode;
|
||||
config: TConfig;
|
||||
inputs: ResolvedExtensionInputs<any>;
|
||||
}): ExtensionDataValues<any>;
|
||||
// (undocumented)
|
||||
id: string;
|
||||
// (undocumented)
|
||||
inputs: AnyExtensionInputMap;
|
||||
// (undocumented)
|
||||
output: AnyExtensionDataMap;
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
@@ -740,30 +730,20 @@ export interface ExtensionDefinition<TConfig> {
|
||||
// (undocumented)
|
||||
$$type: '@backstage/ExtensionDefinition';
|
||||
// (undocumented)
|
||||
attachTo: {
|
||||
readonly attachTo: {
|
||||
id: string;
|
||||
input: string;
|
||||
};
|
||||
// (undocumented)
|
||||
configSchema?: PortableSchema<TConfig>;
|
||||
readonly configSchema?: PortableSchema<TConfig>;
|
||||
// (undocumented)
|
||||
disabled: boolean;
|
||||
readonly disabled: boolean;
|
||||
// (undocumented)
|
||||
factory(options: {
|
||||
node: AppNode;
|
||||
config: TConfig;
|
||||
inputs: ResolvedExtensionInputs<any>;
|
||||
}): ExtensionDataValues<any>;
|
||||
readonly kind?: string;
|
||||
// (undocumented)
|
||||
inputs: AnyExtensionInputMap;
|
||||
readonly name?: string;
|
||||
// (undocumented)
|
||||
kind?: string;
|
||||
// (undocumented)
|
||||
name?: string;
|
||||
// (undocumented)
|
||||
namespace?: string;
|
||||
// (undocumented)
|
||||
output: AnyExtensionDataMap;
|
||||
readonly namespace?: string;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
|
||||
@@ -32,6 +32,7 @@ describe('createApiExtension', () => {
|
||||
}),
|
||||
).toEqual({
|
||||
$$type: '@backstage/ExtensionDefinition',
|
||||
version: 'v1',
|
||||
kind: 'api',
|
||||
namespace: 'test',
|
||||
attachTo: { id: 'core', input: 'apis' },
|
||||
@@ -67,6 +68,7 @@ describe('createApiExtension', () => {
|
||||
// boo
|
||||
expect(extension).toEqual({
|
||||
$$type: '@backstage/ExtensionDefinition',
|
||||
version: 'v1',
|
||||
kind: 'api',
|
||||
namespace: 'test',
|
||||
attachTo: { id: 'core', input: 'apis' },
|
||||
|
||||
@@ -31,6 +31,7 @@ describe('createNavLogoExtension', () => {
|
||||
}),
|
||||
).toEqual({
|
||||
$$type: '@backstage/ExtensionDefinition',
|
||||
version: 'v1',
|
||||
kind: 'nav-logo',
|
||||
name: 'test',
|
||||
attachTo: { id: 'core/nav', input: 'logos' },
|
||||
|
||||
@@ -42,6 +42,7 @@ describe('createPageExtension', () => {
|
||||
}),
|
||||
).toEqual({
|
||||
$$type: '@backstage/ExtensionDefinition',
|
||||
version: 'v1',
|
||||
name: 'test',
|
||||
kind: 'page',
|
||||
attachTo: { id: 'core/routes', input: 'routes' },
|
||||
@@ -71,6 +72,7 @@ describe('createPageExtension', () => {
|
||||
}),
|
||||
).toEqual({
|
||||
$$type: '@backstage/ExtensionDefinition',
|
||||
version: 'v1',
|
||||
name: 'test',
|
||||
kind: 'page',
|
||||
attachTo: { id: 'other', input: 'place' },
|
||||
@@ -97,6 +99,7 @@ describe('createPageExtension', () => {
|
||||
}),
|
||||
).toEqual({
|
||||
$$type: '@backstage/ExtensionDefinition',
|
||||
version: 'v1',
|
||||
name: 'test',
|
||||
kind: 'page',
|
||||
attachTo: { id: 'core/routes', input: 'routes' },
|
||||
|
||||
@@ -41,6 +41,7 @@ describe('createTranslationExtension', () => {
|
||||
|
||||
expect(extension).toEqual({
|
||||
$$type: '@backstage/ExtensionDefinition',
|
||||
version: 'v1',
|
||||
kind: 'translation',
|
||||
namespace: 'test',
|
||||
attachTo: { id: 'core', input: 'translations' },
|
||||
@@ -52,7 +53,7 @@ describe('createTranslationExtension', () => {
|
||||
factory: expect.any(Function),
|
||||
});
|
||||
|
||||
expect(extension.factory({} as any)).toEqual({
|
||||
expect((extension as any).factory({} as any)).toEqual({
|
||||
resource: messages,
|
||||
});
|
||||
});
|
||||
@@ -77,6 +78,7 @@ describe('createTranslationExtension', () => {
|
||||
|
||||
expect(extension).toEqual({
|
||||
$$type: '@backstage/ExtensionDefinition',
|
||||
version: 'v1',
|
||||
kind: 'translation',
|
||||
namespace: 'test',
|
||||
attachTo: { id: 'core', input: 'translations' },
|
||||
@@ -88,7 +90,7 @@ describe('createTranslationExtension', () => {
|
||||
factory: expect.any(Function),
|
||||
});
|
||||
|
||||
expect(extension.factory({} as any)).toEqual({ resource });
|
||||
expect((extension as any).factory({} as any)).toEqual({ resource });
|
||||
});
|
||||
|
||||
it('creates a translation resource extension with a name', () => {
|
||||
@@ -113,6 +115,7 @@ describe('createTranslationExtension', () => {
|
||||
}),
|
||||
).toEqual({
|
||||
$$type: '@backstage/ExtensionDefinition',
|
||||
version: 'v1',
|
||||
kind: 'translation',
|
||||
namespace: 'test',
|
||||
name: 'sv',
|
||||
|
||||
@@ -101,14 +101,20 @@ export interface CreateExtensionOptions<
|
||||
/** @public */
|
||||
export interface ExtensionDefinition<TConfig> {
|
||||
$$type: '@backstage/ExtensionDefinition';
|
||||
kind?: string;
|
||||
namespace?: string;
|
||||
name?: string;
|
||||
attachTo: { id: string; input: string };
|
||||
disabled: boolean;
|
||||
inputs: AnyExtensionInputMap;
|
||||
output: AnyExtensionDataMap;
|
||||
configSchema?: PortableSchema<TConfig>;
|
||||
readonly kind?: string;
|
||||
readonly namespace?: string;
|
||||
readonly name?: string;
|
||||
readonly attachTo: { id: string; input: string };
|
||||
readonly disabled: boolean;
|
||||
readonly configSchema?: PortableSchema<TConfig>;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface InternalExtensionDefinition<TConfig>
|
||||
extends ExtensionDefinition<TConfig> {
|
||||
readonly version: 'v1';
|
||||
readonly inputs: AnyExtensionInputMap;
|
||||
readonly output: AnyExtensionDataMap;
|
||||
factory(options: {
|
||||
node: AppNode;
|
||||
config: TConfig;
|
||||
@@ -116,20 +122,22 @@ export interface ExtensionDefinition<TConfig> {
|
||||
}): ExtensionDataValues<any>;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface Extension<TConfig> {
|
||||
$$type: '@backstage/Extension';
|
||||
id: string;
|
||||
attachTo: { id: string; input: string };
|
||||
disabled: boolean;
|
||||
inputs: AnyExtensionInputMap;
|
||||
output: AnyExtensionDataMap;
|
||||
configSchema?: PortableSchema<TConfig>;
|
||||
factory(options: {
|
||||
node: AppNode;
|
||||
config: TConfig;
|
||||
inputs: ResolvedExtensionInputs<any>;
|
||||
}): ExtensionDataValues<any>;
|
||||
/** @internal */
|
||||
export function toInternalExtensionDefinition<TConfig>(
|
||||
overrides: ExtensionDefinition<TConfig>,
|
||||
): InternalExtensionDefinition<TConfig> {
|
||||
const internal = overrides as InternalExtensionDefinition<TConfig>;
|
||||
if (internal.$$type !== '@backstage/ExtensionDefinition') {
|
||||
throw new Error(
|
||||
`Invalid extension definition instance, bad type '${internal.$$type}'`,
|
||||
);
|
||||
}
|
||||
if (internal.version !== 'v1') {
|
||||
throw new Error(
|
||||
`Invalid extension definition instance, bad version '${internal.version}'`,
|
||||
);
|
||||
}
|
||||
return internal;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
@@ -142,6 +150,7 @@ export function createExtension<
|
||||
): ExtensionDefinition<TConfig> {
|
||||
return {
|
||||
$$type: '@backstage/ExtensionDefinition',
|
||||
version: 'v1',
|
||||
kind: options.kind,
|
||||
namespace: options.namespace,
|
||||
name: options.name,
|
||||
@@ -157,5 +166,5 @@ export function createExtension<
|
||||
...rest,
|
||||
});
|
||||
},
|
||||
};
|
||||
} as InternalExtensionDefinition<TConfig>;
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ describe('createExtensionOverrides', () => {
|
||||
"id": "a",
|
||||
"inputs": {},
|
||||
"output": {},
|
||||
"version": "v1",
|
||||
},
|
||||
{
|
||||
"$$type": "@backstage/Extension",
|
||||
@@ -87,6 +88,7 @@ describe('createExtensionOverrides', () => {
|
||||
"id": "b",
|
||||
"inputs": {},
|
||||
"output": {},
|
||||
"version": "v1",
|
||||
},
|
||||
{
|
||||
"$$type": "@backstage/Extension",
|
||||
@@ -100,6 +102,7 @@ describe('createExtensionOverrides', () => {
|
||||
"id": "k:c/n",
|
||||
"inputs": {},
|
||||
"output": {},
|
||||
"version": "v1",
|
||||
},
|
||||
],
|
||||
"featureFlags": [],
|
||||
|
||||
@@ -14,8 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Extension, ExtensionDefinition } from './createExtension';
|
||||
import { resolveExtensionDefinition } from './resolveExtensionDefinition';
|
||||
import { ExtensionDefinition } from './createExtension';
|
||||
import {
|
||||
Extension,
|
||||
resolveExtensionDefinition,
|
||||
} from './resolveExtensionDefinition';
|
||||
import { FeatureFlagConfig } from './types';
|
||||
|
||||
/** @public */
|
||||
|
||||
@@ -14,10 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Extension, ExtensionDefinition } from './createExtension';
|
||||
import { ExtensionDefinition } from './createExtension';
|
||||
import { ExternalRouteRef, RouteRef } from '../routing';
|
||||
import { FeatureFlagConfig } from './types';
|
||||
import { resolveExtensionDefinition } from './resolveExtensionDefinition';
|
||||
import {
|
||||
Extension,
|
||||
resolveExtensionDefinition,
|
||||
} from './resolveExtensionDefinition';
|
||||
|
||||
/** @public */
|
||||
export type AnyRoutes = { [name in string]: RouteRef };
|
||||
|
||||
@@ -21,7 +21,6 @@ export {
|
||||
} from './coreExtensionData';
|
||||
export {
|
||||
createExtension,
|
||||
type Extension,
|
||||
type ExtensionDefinition,
|
||||
type CreateExtensionOptions,
|
||||
type ExtensionDataValues,
|
||||
@@ -51,4 +50,5 @@ export {
|
||||
type ExtensionOverrides,
|
||||
type ExtensionOverridesOptions,
|
||||
} from './createExtensionOverrides';
|
||||
export { type Extension } from './resolveExtensionDefinition';
|
||||
export type { FeatureFlagConfig } from './types';
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
import { ExtensionDefinition } from './createExtension';
|
||||
import { resolveExtensionDefinition } from './resolveExtensionDefinition';
|
||||
|
||||
const baseDef = {
|
||||
$$type: '@backstage/ExtensionDefinition',
|
||||
version: 'v1',
|
||||
attachTo: { id: '', input: '' },
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
describe('resolveExtensionDefinition', () => {
|
||||
it.each([
|
||||
[{ namespace: 'ns' }, 'ns'],
|
||||
@@ -25,22 +32,24 @@ describe('resolveExtensionDefinition', () => {
|
||||
[{ kind: 'k', namespace: 'ns' }, 'k:ns'],
|
||||
[{ kind: 'k', namespace: 'ns', name: 'n' }, 'k:ns/n'],
|
||||
])(`should resolve extension IDs %s`, (definition, expected) => {
|
||||
const resolved = resolveExtensionDefinition(
|
||||
definition as ExtensionDefinition<unknown>,
|
||||
);
|
||||
const resolved = resolveExtensionDefinition({
|
||||
...baseDef,
|
||||
...definition,
|
||||
} as ExtensionDefinition<unknown>);
|
||||
expect(resolved.id).toBe(expected);
|
||||
});
|
||||
|
||||
it('should fail to resolve extension ID without namespace', () => {
|
||||
expect(() =>
|
||||
resolveExtensionDefinition({
|
||||
...baseDef,
|
||||
kind: 'k',
|
||||
} as ExtensionDefinition<unknown>),
|
||||
).toThrow(
|
||||
'Extension must declare an explicit namespace or name as it could not be resolved from context, kind=k namespace=undefined name=undefined',
|
||||
);
|
||||
expect(() =>
|
||||
resolveExtensionDefinition({} as ExtensionDefinition<unknown>),
|
||||
resolveExtensionDefinition(baseDef as ExtensionDefinition<unknown>),
|
||||
).toThrow(
|
||||
'Extension must declare an explicit namespace or name as it could not be resolved from context, kind=undefined namespace=undefined name=undefined',
|
||||
);
|
||||
|
||||
@@ -14,15 +14,64 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Extension, ExtensionDefinition } from './createExtension';
|
||||
import { AppNode } from '../apis';
|
||||
import {
|
||||
AnyExtensionDataMap,
|
||||
AnyExtensionInputMap,
|
||||
ExtensionDataValues,
|
||||
ExtensionDefinition,
|
||||
ResolvedExtensionInputs,
|
||||
toInternalExtensionDefinition,
|
||||
} from './createExtension';
|
||||
import { PortableSchema } from '../schema';
|
||||
|
||||
/** @public */
|
||||
export interface Extension<TConfig> {
|
||||
$$type: '@backstage/Extension';
|
||||
readonly id: string;
|
||||
readonly attachTo: { id: string; input: string };
|
||||
readonly disabled: boolean;
|
||||
readonly configSchema?: PortableSchema<TConfig>;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface InternalExtension<TConfig> extends Extension<TConfig> {
|
||||
readonly version: 'v1';
|
||||
readonly inputs: AnyExtensionInputMap;
|
||||
readonly output: AnyExtensionDataMap;
|
||||
factory(options: {
|
||||
node: AppNode;
|
||||
config: TConfig;
|
||||
inputs: ResolvedExtensionInputs<any>;
|
||||
}): ExtensionDataValues<any>;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function toInternalExtension<TConfig>(
|
||||
overrides: Extension<TConfig>,
|
||||
): InternalExtension<TConfig> {
|
||||
const internal = overrides as InternalExtension<TConfig>;
|
||||
if (internal.$$type !== '@backstage/Extension') {
|
||||
throw new Error(
|
||||
`Invalid extension instance, bad type '${internal.$$type}'`,
|
||||
);
|
||||
}
|
||||
if (internal.version !== 'v1') {
|
||||
throw new Error(
|
||||
`Invalid extension instance, bad version '${internal.version}'`,
|
||||
);
|
||||
}
|
||||
return internal;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function resolveExtensionDefinition<TConfig>(
|
||||
definition: ExtensionDefinition<TConfig>,
|
||||
context?: { namespace?: string },
|
||||
): Extension<TConfig> {
|
||||
const { name, kind, namespace: _, ...rest } = definition;
|
||||
const namespace = definition.namespace ?? context?.namespace;
|
||||
const internalDefinition = toInternalExtensionDefinition(definition);
|
||||
const { name, kind, namespace: _, ...rest } = internalDefinition;
|
||||
const namespace = internalDefinition.namespace ?? context?.namespace;
|
||||
|
||||
const namePart =
|
||||
name && namespace ? `${namespace}/${name}` : namespace || name;
|
||||
@@ -34,7 +83,8 @@ export function resolveExtensionDefinition<TConfig>(
|
||||
|
||||
return {
|
||||
...rest,
|
||||
id: kind ? `${kind}:${namePart}` : namePart,
|
||||
$$type: '@backstage/Extension',
|
||||
};
|
||||
version: 'v1',
|
||||
id: kind ? `${kind}:${namePart}` : namePart,
|
||||
} as InternalExtension<TConfig>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user