frontend-plugin-api: switch Extension and ExtensionDefinition to be opaque types

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-12-13 16:28:40 +01:00
parent 85e9e8f1b7
commit 5cdf2b3d94
17 changed files with 188 additions and 96 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': minor
---
Changed `Extension` and `ExtensionDefinition` to use opaque types.
+5
View File
@@ -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,
},
});
+10 -30
View File
@@ -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>;
}