From 6c6f392c74bec7ad04d9d1ec3b95a170d1a08ed4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 15 Oct 2023 13:03:23 +0200 Subject: [PATCH] frontend-app-api: extract extension config reading into separate module Signed-off-by: Patrik Oldsberg --- .../frontend-app-api/src/wiring/createApp.tsx | 4 +- .../graph/readAppExtensionsConfig.test.ts | 268 ++++++++++++++++++ .../wiring/graph/readAppExtensionsConfig.ts | 199 +++++++++++++ .../src/wiring/parameters.test.ts | 254 +---------------- .../frontend-app-api/src/wiring/parameters.ts | 184 +----------- 5 files changed, 471 insertions(+), 438 deletions(-) create mode 100644 packages/frontend-app-api/src/wiring/graph/readAppExtensionsConfig.test.ts create mode 100644 packages/frontend-app-api/src/wiring/graph/readAppExtensionsConfig.ts diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index e770a6b4c1..dc132b6654 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -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 diff --git a/packages/frontend-app-api/src/wiring/graph/readAppExtensionsConfig.test.ts b/packages/frontend-app-api/src/wiring/graph/readAppExtensionsConfig.test.ts new file mode 100644 index 0000000000..91f6992f29 --- /dev/null +++ b/packages/frontend-app-api/src/wiring/graph/readAppExtensionsConfig.test.ts @@ -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'"`, + ); + }); +}); diff --git a/packages/frontend-app-api/src/wiring/graph/readAppExtensionsConfig.ts b/packages/frontend-app-api/src/wiring/graph/readAppExtensionsConfig.ts new file mode 100644 index 0000000000..2b5b51ce92 --- /dev/null +++ b/packages/frontend-app-api/src/wiring/graph/readAppExtensionsConfig.ts @@ -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, + }; +} diff --git a/packages/frontend-app-api/src/wiring/parameters.test.ts b/packages/frontend-app-api/src/wiring/parameters.test.ts index 4ee420f667..89d9c0c3f3 100644 --- a/packages/frontend-app-api/src/wiring/parameters.test.ts +++ b/packages/frontend-app-api/src/wiring/parameters.test.ts @@ -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'"`, - ); - }); -}); diff --git a/packages/frontend-app-api/src/wiring/parameters.ts b/packages/frontend-app-api/src/wiring/parameters.ts index 911f12f416..329c9ea42a 100644 --- a/packages/frontend-app-api/src/wiring/parameters.ts +++ b/packages/frontend-app-api/src/wiring/parameters.ts @@ -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;