frontend-app-api: extract extension config reading into separate module
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -35,7 +35,6 @@ import {
|
||||
import {
|
||||
ExtensionInstanceParameters,
|
||||
mergeExtensionParameters,
|
||||
readAppExtensionParameters,
|
||||
} from './parameters';
|
||||
import {
|
||||
AnyApiFactory,
|
||||
@@ -96,6 +95,7 @@ import { AppRouteBinder } from '../routing';
|
||||
import { RoutingProvider } from '../routing/RoutingProvider';
|
||||
import { resolveRouteBindings } from '../routing/resolveRouteBindings';
|
||||
import { collectRouteIds } from '../routing/collectRouteIds';
|
||||
import { readAppExtensionsConfig } from './graph/readAppExtensionsConfig';
|
||||
|
||||
/** @public */
|
||||
export interface ExtensionTreeNode {
|
||||
@@ -200,7 +200,7 @@ export function createInstances(options: {
|
||||
const extensionParams = mergeExtensionParameters({
|
||||
features: options.features,
|
||||
builtinExtensions,
|
||||
parameters: readAppExtensionParameters(options.config),
|
||||
parameters: readAppExtensionsConfig(options.config),
|
||||
});
|
||||
|
||||
// TODO: validate the config of all extension instances
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* 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 { ConfigReader } from '@backstage/config';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import {
|
||||
expandShorthandExtensionParameters,
|
||||
readAppExtensionsConfig,
|
||||
} from './readAppExtensionsConfig';
|
||||
|
||||
describe('readAppExtensionsConfig', () => {
|
||||
it('should disable extension with shorthand notation', () => {
|
||||
expect(
|
||||
readAppExtensionsConfig(
|
||||
new ConfigReader({ app: { extensions: [{ 'core.router': false }] } }),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
id: 'core.router',
|
||||
disabled: true,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
readAppExtensionsConfig(
|
||||
new ConfigReader({
|
||||
app: { extensions: [{ 'core.router': { disabled: true } }] },
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
at: undefined,
|
||||
config: undefined,
|
||||
disabled: true,
|
||||
id: 'core.router',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should enable extension with shorthand notation', () => {
|
||||
expect(
|
||||
readAppExtensionsConfig(
|
||||
new ConfigReader({ app: { extensions: ['core.router'] } }),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
id: 'core.router',
|
||||
disabled: false,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
readAppExtensionsConfig(
|
||||
new ConfigReader({ app: { extensions: [{ 'core.router': true }] } }),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
id: 'core.router',
|
||||
disabled: false,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
readAppExtensionsConfig(
|
||||
new ConfigReader({
|
||||
app: { extensions: [{ 'core.router': { disabled: false } }] },
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
id: 'core.router',
|
||||
disabled: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not allow string keys', () => {
|
||||
expect(() =>
|
||||
readAppExtensionsConfig(
|
||||
new ConfigReader({
|
||||
app: {
|
||||
extensions: [{ 'core.router': 'some-string' }],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(
|
||||
'Invalid extension configuration at app.extensions[0][core.router], value must be a boolean or object',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not allow invalid keys', () => {
|
||||
expect(() =>
|
||||
readAppExtensionsConfig(
|
||||
new ConfigReader({
|
||||
app: {
|
||||
extensions: [
|
||||
{
|
||||
'core.router/routes': {
|
||||
extension: 'example-package#MyPage',
|
||||
config: { foo: 'bar' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[0], extension ID must not contain slashes; got 'core.router/routes', did you mean 'core.router'?"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandShorthandExtensionParameters', () => {
|
||||
const run = (value: JsonValue) => {
|
||||
return expandShorthandExtensionParameters(value, 1);
|
||||
};
|
||||
|
||||
it('rejects unknown keys', () => {
|
||||
expect(() => run(null)).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1], must be a string or an object"`,
|
||||
);
|
||||
expect(() => run(1)).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1], must be a string or an object"`,
|
||||
);
|
||||
expect(() => run([])).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1], must be a string or an object"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects the wrong number of keys', () => {
|
||||
expect(() => run({})).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1], must have exactly one key, got none"`,
|
||||
);
|
||||
expect(() => run({ a: {}, b: {} })).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1], must have exactly one key, got 'a', 'b'"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unknown values', () => {
|
||||
expect(() => run({ a: 1 })).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][a], value must be a boolean or object"`,
|
||||
);
|
||||
expect(() => run({ a: [] })).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][a], value must be a boolean or object"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('supports string key', () => {
|
||||
expect(run('core.router')).toEqual({
|
||||
id: 'core.router',
|
||||
disabled: false,
|
||||
});
|
||||
expect(() => run('')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1], extension ID must not be empty or contain whitespace"`,
|
||||
);
|
||||
expect(() => run(' a')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1], extension ID must not be empty or contain whitespace"`,
|
||||
);
|
||||
expect(() => run('core.router/routes')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1], extension ID must not contain slashes; got 'core.router/routes', did you mean 'core.router'?"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('supports null value', () => {
|
||||
// this is the result of typing:
|
||||
// - core.router:
|
||||
// The missing value is interpreted as null by the yaml parser so we deal with that
|
||||
expect(run({ 'core.router': null })).toEqual({
|
||||
id: 'core.router',
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('supports boolean value', () => {
|
||||
expect(run({ 'core.router': true })).toEqual({
|
||||
id: 'core.router',
|
||||
disabled: false,
|
||||
});
|
||||
expect(run({ 'core.router': false })).toEqual({
|
||||
id: 'core.router',
|
||||
disabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not support string values', () => {
|
||||
expect(() =>
|
||||
run({ 'core.router': 'example-package#MyRouter' }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][core.router], value must be a boolean or object"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('supports object id only in the key', () => {
|
||||
expect(() =>
|
||||
run({ 'core.router': { id: 'some.id' } }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][core.router].id, unknown parameter; expected one of 'attachTo', 'disabled', 'config'"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('supports object attachTo', () => {
|
||||
expect(
|
||||
run({
|
||||
'core.router': { attachTo: { id: 'other.root', input: 'inputs' } },
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'core.router',
|
||||
attachTo: { id: 'other.root', input: 'inputs' },
|
||||
});
|
||||
expect(() =>
|
||||
run({
|
||||
'core.router': {
|
||||
id: 'other-id',
|
||||
},
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][core.router].id, unknown parameter; expected one of 'attachTo', 'disabled', 'config'"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('supports object disabled', () => {
|
||||
expect(run({ 'core.router': { disabled: true } })).toEqual({
|
||||
id: 'core.router',
|
||||
disabled: true,
|
||||
});
|
||||
expect(run({ 'core.router': { disabled: false } })).toEqual({
|
||||
id: 'core.router',
|
||||
disabled: false,
|
||||
});
|
||||
expect(() =>
|
||||
run({ 'core.router': { disabled: 0 } }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][core.router].disabled, must be a boolean"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('supports object config', () => {
|
||||
expect(
|
||||
run({ 'core.router': { config: { disableRedirects: true } } }),
|
||||
).toEqual({
|
||||
id: 'core.router',
|
||||
config: { disableRedirects: true },
|
||||
});
|
||||
expect(() =>
|
||||
run({ 'core.router': { config: 0 } }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][core.router].config, must be an object"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unknown object keys', () => {
|
||||
expect(() =>
|
||||
run({ 'core.router': { foo: { settings: true } } }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][core.router].foo, unknown parameter; expected one of 'attachTo', 'disabled', 'config'"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* 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 { Config } from '@backstage/config';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
|
||||
export interface ExtensionParameters {
|
||||
id: string;
|
||||
attachTo?: { id: string; input: string };
|
||||
disabled?: boolean;
|
||||
config?: unknown;
|
||||
}
|
||||
|
||||
const knownExtensionParameters = ['attachTo', 'disabled', 'config'];
|
||||
|
||||
// Since we'll never merge arrays in config the config reader context
|
||||
// isn't too much of a help. Fall back to manual config reading logic
|
||||
// as the Config interface makes it quite hard for us otherwise.
|
||||
/** @internal */
|
||||
export function readAppExtensionsConfig(
|
||||
rootConfig: Config,
|
||||
): ExtensionParameters[] {
|
||||
const arr = rootConfig.getOptional('app.extensions');
|
||||
if (!Array.isArray(arr)) {
|
||||
if (arr === undefined) {
|
||||
return [];
|
||||
}
|
||||
// This will throw, and show which part of config had the wrong type
|
||||
rootConfig.getConfigArray('app.extensions');
|
||||
return [];
|
||||
}
|
||||
|
||||
return arr.map((arrayEntry, arrayIndex) =>
|
||||
expandShorthandExtensionParameters(arrayEntry, arrayIndex),
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function expandShorthandExtensionParameters(
|
||||
arrayEntry: JsonValue,
|
||||
arrayIndex: number,
|
||||
): ExtensionParameters {
|
||||
function errorMsg(msg: string, key?: string, prop?: string) {
|
||||
return `Invalid extension configuration at app.extensions[${arrayIndex}]${
|
||||
key ? `[${key}]` : ''
|
||||
}${prop ? `.${prop}` : ''}, ${msg}`;
|
||||
}
|
||||
|
||||
// NOTE(freben): This check is intentionally not complete and doesn't check
|
||||
// whether letters and digits are used, etc. It's not up to the config reading
|
||||
// logic to decide what constitutes a valid extension ID; that should be
|
||||
// decided by the logic that loads and instantiates the extensions. This check
|
||||
// is just here to catch real mistakes or truly conceptually wrong input.
|
||||
function assertValidId(id: string) {
|
||||
if (!id || id !== id.trim()) {
|
||||
throw new Error(
|
||||
errorMsg('extension ID must not be empty or contain whitespace'),
|
||||
);
|
||||
}
|
||||
|
||||
if (id.includes('/')) {
|
||||
let message = `extension ID must not contain slashes; got '${id}'`;
|
||||
const good = id.split('/')[0];
|
||||
if (good) {
|
||||
message += `, did you mean '${good}'?`;
|
||||
}
|
||||
throw new Error(errorMsg(message));
|
||||
}
|
||||
}
|
||||
|
||||
// Example YAML:
|
||||
// - entity.card.about
|
||||
if (typeof arrayEntry === 'string') {
|
||||
assertValidId(arrayEntry);
|
||||
return {
|
||||
id: arrayEntry,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
// All remaining cases are single-key objects
|
||||
if (
|
||||
typeof arrayEntry !== 'object' ||
|
||||
arrayEntry === null ||
|
||||
Array.isArray(arrayEntry)
|
||||
) {
|
||||
throw new Error(errorMsg('must be a string or an object'));
|
||||
}
|
||||
const keys = Object.keys(arrayEntry);
|
||||
if (keys.length !== 1) {
|
||||
const joinedKeys = keys.length ? `'${keys.join("', '")}'` : 'none';
|
||||
throw new Error(errorMsg(`must have exactly one key, got ${joinedKeys}`));
|
||||
}
|
||||
|
||||
const id = String(keys[0]);
|
||||
const value = arrayEntry[id];
|
||||
assertValidId(id);
|
||||
|
||||
// This example covers a potentially common mistake in the syntax
|
||||
// Example YAML:
|
||||
// - entity.card.about:
|
||||
if (value === null) {
|
||||
return {
|
||||
id,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Example YAML:
|
||||
// - catalog.page.cicd: false
|
||||
if (typeof value === 'boolean') {
|
||||
return {
|
||||
id,
|
||||
disabled: !value,
|
||||
};
|
||||
}
|
||||
|
||||
// The remaining case is the generic object. Example YAML:
|
||||
// - tech-radar.page:
|
||||
// at: core.router/routes
|
||||
// disabled: false
|
||||
// config:
|
||||
// path: /tech-radar
|
||||
// width: 1500
|
||||
// height: 800
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
// We don't mention null here - we don't want people to explicitly enter
|
||||
// - entity.card.about: null
|
||||
throw new Error(errorMsg('value must be a boolean or object', id));
|
||||
}
|
||||
|
||||
const attachTo = value.attachTo as { id: string; input: string } | undefined;
|
||||
const disabled = value.disabled;
|
||||
const config = value.config;
|
||||
|
||||
if (attachTo !== undefined) {
|
||||
if (
|
||||
attachTo === null ||
|
||||
typeof attachTo !== 'object' ||
|
||||
Array.isArray(attachTo)
|
||||
) {
|
||||
throw new Error(errorMsg('must be an object', id, 'attachTo'));
|
||||
}
|
||||
if (typeof attachTo.id !== 'string' || attachTo.id === '') {
|
||||
throw new Error(
|
||||
errorMsg('must be a non-empty string', id, 'attachTo.id'),
|
||||
);
|
||||
}
|
||||
if (typeof attachTo.input !== 'string' || attachTo.input === '') {
|
||||
throw new Error(
|
||||
errorMsg('must be a non-empty string', id, 'attachTo.input'),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (disabled !== undefined && typeof disabled !== 'boolean') {
|
||||
throw new Error(errorMsg('must be a boolean', id, 'disabled'));
|
||||
}
|
||||
if (
|
||||
config !== undefined &&
|
||||
(typeof config !== 'object' || config === null || Array.isArray(config))
|
||||
) {
|
||||
throw new Error(errorMsg('must be an object', id, 'config'));
|
||||
}
|
||||
|
||||
const unknownKeys = Object.keys(value).filter(
|
||||
k => !knownExtensionParameters.includes(k),
|
||||
);
|
||||
if (unknownKeys.length > 0) {
|
||||
throw new Error(
|
||||
errorMsg(
|
||||
`unknown parameter; expected one of '${knownExtensionParameters.join(
|
||||
"', '",
|
||||
)}'`,
|
||||
id,
|
||||
unknownKeys.join(', '),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
attachTo,
|
||||
disabled,
|
||||
config,
|
||||
};
|
||||
}
|
||||
@@ -14,18 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
createExtensionOverrides,
|
||||
createPlugin,
|
||||
Extension,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import {
|
||||
expandShorthandExtensionParameters,
|
||||
mergeExtensionParameters,
|
||||
readAppExtensionParameters,
|
||||
} from './parameters';
|
||||
import { mergeExtensionParameters } from './parameters';
|
||||
|
||||
function makeExt(
|
||||
id: string,
|
||||
@@ -208,249 +202,3 @@ describe('mergeExtensionParameters', () => {
|
||||
expect(result.map(r => r.extension.id)).toEqual(['b', 'c', 'a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readAppExtensionParameters', () => {
|
||||
it('should disable extension with shorthand notation', () => {
|
||||
expect(
|
||||
readAppExtensionParameters(
|
||||
new ConfigReader({ app: { extensions: [{ 'core.router': false }] } }),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
id: 'core.router',
|
||||
disabled: true,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
readAppExtensionParameters(
|
||||
new ConfigReader({
|
||||
app: { extensions: [{ 'core.router': { disabled: true } }] },
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
at: undefined,
|
||||
config: undefined,
|
||||
disabled: true,
|
||||
id: 'core.router',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should enable extension with shorthand notation', () => {
|
||||
expect(
|
||||
readAppExtensionParameters(
|
||||
new ConfigReader({ app: { extensions: ['core.router'] } }),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
id: 'core.router',
|
||||
disabled: false,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
readAppExtensionParameters(
|
||||
new ConfigReader({ app: { extensions: [{ 'core.router': true }] } }),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
id: 'core.router',
|
||||
disabled: false,
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
readAppExtensionParameters(
|
||||
new ConfigReader({
|
||||
app: { extensions: [{ 'core.router': { disabled: false } }] },
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
id: 'core.router',
|
||||
disabled: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not allow string keys', () => {
|
||||
expect(() =>
|
||||
readAppExtensionParameters(
|
||||
new ConfigReader({
|
||||
app: {
|
||||
extensions: [{ 'core.router': 'some-string' }],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(
|
||||
'Invalid extension configuration at app.extensions[0][core.router], value must be a boolean or object',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not allow invalid keys', () => {
|
||||
expect(() =>
|
||||
readAppExtensionParameters(
|
||||
new ConfigReader({
|
||||
app: {
|
||||
extensions: [
|
||||
{
|
||||
'core.router/routes': {
|
||||
extension: 'example-package#MyPage',
|
||||
config: { foo: 'bar' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[0], extension ID must not contain slashes; got 'core.router/routes', did you mean 'core.router'?"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandShorthandExtensionParameters', () => {
|
||||
const run = (value: JsonValue) => {
|
||||
return expandShorthandExtensionParameters(value, 1);
|
||||
};
|
||||
|
||||
it('rejects unknown keys', () => {
|
||||
expect(() => run(null)).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1], must be a string or an object"`,
|
||||
);
|
||||
expect(() => run(1)).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1], must be a string or an object"`,
|
||||
);
|
||||
expect(() => run([])).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1], must be a string or an object"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects the wrong number of keys', () => {
|
||||
expect(() => run({})).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1], must have exactly one key, got none"`,
|
||||
);
|
||||
expect(() => run({ a: {}, b: {} })).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1], must have exactly one key, got 'a', 'b'"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unknown values', () => {
|
||||
expect(() => run({ a: 1 })).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][a], value must be a boolean or object"`,
|
||||
);
|
||||
expect(() => run({ a: [] })).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][a], value must be a boolean or object"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('supports string key', () => {
|
||||
expect(run('core.router')).toEqual({
|
||||
id: 'core.router',
|
||||
disabled: false,
|
||||
});
|
||||
expect(() => run('')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1], extension ID must not be empty or contain whitespace"`,
|
||||
);
|
||||
expect(() => run(' a')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1], extension ID must not be empty or contain whitespace"`,
|
||||
);
|
||||
expect(() => run('core.router/routes')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1], extension ID must not contain slashes; got 'core.router/routes', did you mean 'core.router'?"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('supports null value', () => {
|
||||
// this is the result of typing:
|
||||
// - core.router:
|
||||
// The missing value is interpreted as null by the yaml parser so we deal with that
|
||||
expect(run({ 'core.router': null })).toEqual({
|
||||
id: 'core.router',
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('supports boolean value', () => {
|
||||
expect(run({ 'core.router': true })).toEqual({
|
||||
id: 'core.router',
|
||||
disabled: false,
|
||||
});
|
||||
expect(run({ 'core.router': false })).toEqual({
|
||||
id: 'core.router',
|
||||
disabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not support string values', () => {
|
||||
expect(() =>
|
||||
run({ 'core.router': 'example-package#MyRouter' }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][core.router], value must be a boolean or object"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('supports object id only in the key', () => {
|
||||
expect(() =>
|
||||
run({ 'core.router': { id: 'some.id' } }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][core.router].id, unknown parameter; expected one of 'attachTo', 'disabled', 'config'"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('supports object attachTo', () => {
|
||||
expect(
|
||||
run({
|
||||
'core.router': { attachTo: { id: 'other.root', input: 'inputs' } },
|
||||
}),
|
||||
).toEqual({
|
||||
id: 'core.router',
|
||||
attachTo: { id: 'other.root', input: 'inputs' },
|
||||
});
|
||||
expect(() =>
|
||||
run({
|
||||
'core.router': {
|
||||
id: 'other-id',
|
||||
},
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][core.router].id, unknown parameter; expected one of 'attachTo', 'disabled', 'config'"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('supports object disabled', () => {
|
||||
expect(run({ 'core.router': { disabled: true } })).toEqual({
|
||||
id: 'core.router',
|
||||
disabled: true,
|
||||
});
|
||||
expect(run({ 'core.router': { disabled: false } })).toEqual({
|
||||
id: 'core.router',
|
||||
disabled: false,
|
||||
});
|
||||
expect(() =>
|
||||
run({ 'core.router': { disabled: 0 } }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][core.router].disabled, must be a boolean"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('supports object config', () => {
|
||||
expect(
|
||||
run({ 'core.router': { config: { disableRedirects: true } } }),
|
||||
).toEqual({
|
||||
id: 'core.router',
|
||||
config: { disableRedirects: true },
|
||||
});
|
||||
expect(() =>
|
||||
run({ 'core.router': { config: 0 } }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][core.router].config, must be an object"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unknown object keys', () => {
|
||||
expect(() =>
|
||||
run({ 'core.router': { foo: { settings: true } } }),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid extension configuration at app.extensions[1][core.router].foo, unknown parameter; expected one of 'attachTo', 'disabled', 'config'"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config } from '@backstage/config';
|
||||
import {
|
||||
BackstagePlugin,
|
||||
Extension,
|
||||
@@ -22,188 +21,7 @@ import {
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
|
||||
export interface ExtensionParameters {
|
||||
id: string;
|
||||
attachTo?: { id: string; input: string };
|
||||
disabled?: boolean;
|
||||
config?: unknown;
|
||||
}
|
||||
|
||||
const knownExtensionParameters = ['attachTo', 'disabled', 'config'];
|
||||
|
||||
// Since we'll never merge arrays in config the config reader context
|
||||
// isn't too much of a help. Fall back to manual config reading logic
|
||||
// as the Config interface makes it quite hard for us otherwise.
|
||||
/** @internal */
|
||||
export function readAppExtensionParameters(
|
||||
rootConfig: Config,
|
||||
): ExtensionParameters[] {
|
||||
const arr = rootConfig.getOptional('app.extensions');
|
||||
if (!Array.isArray(arr)) {
|
||||
if (arr === undefined) {
|
||||
return [];
|
||||
}
|
||||
// This will throw, and show which part of config had the wrong type
|
||||
rootConfig.getConfigArray('app.extensions');
|
||||
return [];
|
||||
}
|
||||
|
||||
return arr.map((arrayEntry, arrayIndex) =>
|
||||
expandShorthandExtensionParameters(arrayEntry, arrayIndex),
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function expandShorthandExtensionParameters(
|
||||
arrayEntry: JsonValue,
|
||||
arrayIndex: number,
|
||||
): ExtensionParameters {
|
||||
function errorMsg(msg: string, key?: string, prop?: string) {
|
||||
return `Invalid extension configuration at app.extensions[${arrayIndex}]${
|
||||
key ? `[${key}]` : ''
|
||||
}${prop ? `.${prop}` : ''}, ${msg}`;
|
||||
}
|
||||
|
||||
// NOTE(freben): This check is intentionally not complete and doesn't check
|
||||
// whether letters and digits are used, etc. It's not up to the config reading
|
||||
// logic to decide what constitutes a valid extension ID; that should be
|
||||
// decided by the logic that loads and instantiates the extensions. This check
|
||||
// is just here to catch real mistakes or truly conceptually wrong input.
|
||||
function assertValidId(id: string) {
|
||||
if (!id || id !== id.trim()) {
|
||||
throw new Error(
|
||||
errorMsg('extension ID must not be empty or contain whitespace'),
|
||||
);
|
||||
}
|
||||
|
||||
if (id.includes('/')) {
|
||||
let message = `extension ID must not contain slashes; got '${id}'`;
|
||||
const good = id.split('/')[0];
|
||||
if (good) {
|
||||
message += `, did you mean '${good}'?`;
|
||||
}
|
||||
throw new Error(errorMsg(message));
|
||||
}
|
||||
}
|
||||
|
||||
// Example YAML:
|
||||
// - entity.card.about
|
||||
if (typeof arrayEntry === 'string') {
|
||||
assertValidId(arrayEntry);
|
||||
return {
|
||||
id: arrayEntry,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
// All remaining cases are single-key objects
|
||||
if (
|
||||
typeof arrayEntry !== 'object' ||
|
||||
arrayEntry === null ||
|
||||
Array.isArray(arrayEntry)
|
||||
) {
|
||||
throw new Error(errorMsg('must be a string or an object'));
|
||||
}
|
||||
const keys = Object.keys(arrayEntry);
|
||||
if (keys.length !== 1) {
|
||||
const joinedKeys = keys.length ? `'${keys.join("', '")}'` : 'none';
|
||||
throw new Error(errorMsg(`must have exactly one key, got ${joinedKeys}`));
|
||||
}
|
||||
|
||||
const id = String(keys[0]);
|
||||
const value = arrayEntry[id];
|
||||
assertValidId(id);
|
||||
|
||||
// This example covers a potentially common mistake in the syntax
|
||||
// Example YAML:
|
||||
// - entity.card.about:
|
||||
if (value === null) {
|
||||
return {
|
||||
id,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Example YAML:
|
||||
// - catalog.page.cicd: false
|
||||
if (typeof value === 'boolean') {
|
||||
return {
|
||||
id,
|
||||
disabled: !value,
|
||||
};
|
||||
}
|
||||
|
||||
// The remaining case is the generic object. Example YAML:
|
||||
// - tech-radar.page:
|
||||
// at: core.router/routes
|
||||
// disabled: false
|
||||
// config:
|
||||
// path: /tech-radar
|
||||
// width: 1500
|
||||
// height: 800
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
// We don't mention null here - we don't want people to explicitly enter
|
||||
// - entity.card.about: null
|
||||
throw new Error(errorMsg('value must be a boolean or object', id));
|
||||
}
|
||||
|
||||
const attachTo = value.attachTo as { id: string; input: string } | undefined;
|
||||
const disabled = value.disabled;
|
||||
const config = value.config;
|
||||
|
||||
if (attachTo !== undefined) {
|
||||
if (
|
||||
attachTo === null ||
|
||||
typeof attachTo !== 'object' ||
|
||||
Array.isArray(attachTo)
|
||||
) {
|
||||
throw new Error(errorMsg('must be an object', id, 'attachTo'));
|
||||
}
|
||||
if (typeof attachTo.id !== 'string' || attachTo.id === '') {
|
||||
throw new Error(
|
||||
errorMsg('must be a non-empty string', id, 'attachTo.id'),
|
||||
);
|
||||
}
|
||||
if (typeof attachTo.input !== 'string' || attachTo.input === '') {
|
||||
throw new Error(
|
||||
errorMsg('must be a non-empty string', id, 'attachTo.input'),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (disabled !== undefined && typeof disabled !== 'boolean') {
|
||||
throw new Error(errorMsg('must be a boolean', id, 'disabled'));
|
||||
}
|
||||
if (
|
||||
config !== undefined &&
|
||||
(typeof config !== 'object' || config === null || Array.isArray(config))
|
||||
) {
|
||||
throw new Error(errorMsg('must be an object', id, 'config'));
|
||||
}
|
||||
|
||||
const unknownKeys = Object.keys(value).filter(
|
||||
k => !knownExtensionParameters.includes(k),
|
||||
);
|
||||
if (unknownKeys.length > 0) {
|
||||
throw new Error(
|
||||
errorMsg(
|
||||
`unknown parameter; expected one of '${knownExtensionParameters.join(
|
||||
"', '",
|
||||
)}'`,
|
||||
id,
|
||||
unknownKeys.join(', '),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
attachTo,
|
||||
disabled,
|
||||
config,
|
||||
};
|
||||
}
|
||||
import { ExtensionParameters } from './graph/readAppExtensionsConfig';
|
||||
|
||||
export interface ExtensionInstanceParameters {
|
||||
extension: Extension<unknown>;
|
||||
|
||||
Reference in New Issue
Block a user