From d0f0e28c5826b178c5d6aad6e5b44c2937bc7303 Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Wed, 29 Mar 2023 12:56:49 +0100 Subject: [PATCH 01/15] feat: Add HostDiscovery to backend-common Signed-off-by: Jack Palmer --- .../src/discovery/HostDiscovery.test.ts | 105 ++++++++++++++++++ .../src/discovery/HostDiscovery.ts | 97 ++++++++++++++++ .../src/discovery/SingleHostDiscovery.ts | 1 + .../backend-common/src/discovery/index.ts | 1 + 4 files changed, 204 insertions(+) create mode 100644 packages/backend-common/src/discovery/HostDiscovery.test.ts create mode 100644 packages/backend-common/src/discovery/HostDiscovery.ts diff --git a/packages/backend-common/src/discovery/HostDiscovery.test.ts b/packages/backend-common/src/discovery/HostDiscovery.test.ts new file mode 100644 index 0000000000..c15ffd79e8 --- /dev/null +++ b/packages/backend-common/src/discovery/HostDiscovery.test.ts @@ -0,0 +1,105 @@ +/* + * Copyright 2020 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 { HostDiscovery } from './HostDiscovery'; + +describe('HostDiscovery', () => { + it('is created from config', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + }), + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://localhost:80/api/catalog', + ); + await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe( + 'http://localhost:40/api/catalog', + ); + }); + + it('can configure the base path', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + }), + { basePath: '/service' }, + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://localhost:80/service/catalog', + ); + await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe( + 'http://localhost:40/service/catalog', + ); + }); + + it.each([ + [{ listen: ':80' }, 'http://localhost:80'], + [{ listen: ':40', https: true }, 'https://localhost:40'], + [{ listen: '127.0.0.1:80' }, 'http://127.0.0.1:80'], + [{ listen: '127.0.0.1:80', https: true }, 'https://127.0.0.1:80'], + [{ listen: '0.0.0.0:40' }, 'http://127.0.0.1:40'], + [{ listen: { port: 80 } }, 'http://localhost:80'], + [{ listen: { port: 8000 } }, 'http://localhost:8000'], + [{ listen: { port: 80, host: '0.0.0.0' } }, 'http://127.0.0.1:80'], + [{ listen: { port: 80, host: '::' } }, 'http://localhost:80'], + [{ listen: { port: 80, host: '::1' } }, 'http://[::1]:80'], + [{ listen: { port: 90, host: '::2' }, https: true }, 'https://[::2]:90'], + ])('resolves internal baseUrl for %j as %s', async (config, expected) => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + ...config, + }, + }), + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + `${expected}/api/catalog`, + ); + }); + + it('uses plugin specific baseUrl from config if provided', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + discovery: { + catalog: 'http://catalog-backend:8080/api/catalog', + }, + }, + }), + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://catalog-backend:8080/api/catalog', + ); + await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe( + 'http://catalog-backend:8080/api/catalog', + ); + }); +}); diff --git a/packages/backend-common/src/discovery/HostDiscovery.ts b/packages/backend-common/src/discovery/HostDiscovery.ts new file mode 100644 index 0000000000..0fedf0643a --- /dev/null +++ b/packages/backend-common/src/discovery/HostDiscovery.ts @@ -0,0 +1,97 @@ +/* + * 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 { PluginEndpointDiscovery } from './types'; +import { readHttpServerOptions } from '@backstage/backend-app-api'; + +/** + * HostDiscovery is a basic PluginEndpointDiscovery implementation + * that assumes that all plugins are hosted in a single deployment. + * + * The deployment may be scaled horizontally, as long as the external URL + * is the same for all instances. However, internal URLs will always be + * resolved to the same host, so there won't be any balancing of internal traffic. + * + * @public + */ + +export class HostDiscovery implements PluginEndpointDiscovery { + /** + * Creates a new HostDiscovery discovery instance by reading + * from the `backend` config section, specifically the `.baseUrl` for + * discovering the external URL, and the `.listen` and `.https` config + * for the internal one. + * + * Can be overridden by in config by providing a `backend.discovery` section with a `` key. + * + * The basePath defaults to `/api`, meaning the default full internal + * path for the `catalog` plugin will be `http://localhost:7007/api/catalog`. + */ + static fromConfig(config: Config, options?: { basePath?: string }) { + const basePath = options?.basePath ?? '/api'; + const externalBaseUrl = config.getString('backend.baseUrl'); + + const { + listen: { host: listenHost = '::', port: listenPort }, + } = readHttpServerOptions(config.getConfig('backend')); + const protocol = config.has('backend.https') ? 'https' : 'http'; + + // Translate bind-all to localhost, and support IPv6 + let host = listenHost; + if (host === '::' || host === '') { + // We use localhost instead of ::1, since IPv6-compatible systems should default + // to using IPv6 when they see localhost, but if the system doesn't support IPv6 + // things will still work. + host = 'localhost'; + } else if (host === '0.0.0.0') { + host = '127.0.0.1'; + } + if (host.includes(':')) { + host = `[${host}]`; + } + + const internalBaseUrl = `${protocol}://${host}:${listenPort}`; + + return new HostDiscovery( + internalBaseUrl + basePath, + externalBaseUrl + basePath, + config.getOptionalConfig('backend.discovery'), + ); + } + + private constructor( + private readonly internalBaseUrl: string, + private readonly externalBaseUrl: string, + private readonly discoveryConfig: Config | undefined, + ) {} + + async getBaseUrl(pluginId: string): Promise { + if (this.discoveryConfig?.has(pluginId)) { + return this.discoveryConfig.getString(pluginId); + } + + return `${this.internalBaseUrl}/${pluginId}`; + } + + async getExternalBaseUrl(pluginId: string): Promise { + if (this.discoveryConfig?.has(pluginId)) { + return this.discoveryConfig.getString(pluginId); + } + + return `${this.externalBaseUrl}/${pluginId}`; + } +} diff --git a/packages/backend-common/src/discovery/SingleHostDiscovery.ts b/packages/backend-common/src/discovery/SingleHostDiscovery.ts index ded23b3374..1940773765 100644 --- a/packages/backend-common/src/discovery/SingleHostDiscovery.ts +++ b/packages/backend-common/src/discovery/SingleHostDiscovery.ts @@ -27,6 +27,7 @@ import { readHttpServerOptions } from '@backstage/backend-app-api'; * resolved to the same host, so there won't be any balancing of internal traffic. * * @public + * @deprecated Use `HostDiscovery` instead */ export class SingleHostDiscovery implements PluginEndpointDiscovery { /** diff --git a/packages/backend-common/src/discovery/index.ts b/packages/backend-common/src/discovery/index.ts index 5b62d6f4e4..8170409fc1 100644 --- a/packages/backend-common/src/discovery/index.ts +++ b/packages/backend-common/src/discovery/index.ts @@ -15,4 +15,5 @@ */ export { SingleHostDiscovery } from './SingleHostDiscovery'; +export { HostDiscovery } from './HostDiscovery'; export type { PluginEndpointDiscovery } from './types'; From d874992b4b976f3f8c8a22b50915c9e05bf5fe54 Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Wed, 29 Mar 2023 13:01:26 +0100 Subject: [PATCH 02/15] chore: changeset Signed-off-by: Jack Palmer --- .changeset/clever-jars-tie.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/clever-jars-tie.md diff --git a/.changeset/clever-jars-tie.md b/.changeset/clever-jars-tie.md new file mode 100644 index 0000000000..bdaa06e476 --- /dev/null +++ b/.changeset/clever-jars-tie.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Added HostDiscovery to supersede deprecated SingleHostDiscovery (deprecated due to name) From b58a2b7b6f317dacfd605702f4749d4b1459d406 Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Wed, 29 Mar 2023 13:40:09 +0100 Subject: [PATCH 03/15] refactor: Use single HostDiscovery and deprecate SingleHostDiscovery via re-exporting Signed-off-by: Jack Palmer --- .../src/discovery/SingleHostDiscovery.test.ts | 84 ------------------ .../src/discovery/SingleHostDiscovery.ts | 85 ------------------- .../backend-common/src/discovery/index.ts | 14 ++- 3 files changed, 13 insertions(+), 170 deletions(-) delete mode 100644 packages/backend-common/src/discovery/SingleHostDiscovery.test.ts delete mode 100644 packages/backend-common/src/discovery/SingleHostDiscovery.ts diff --git a/packages/backend-common/src/discovery/SingleHostDiscovery.test.ts b/packages/backend-common/src/discovery/SingleHostDiscovery.test.ts deleted file mode 100644 index ce4a5ae261..0000000000 --- a/packages/backend-common/src/discovery/SingleHostDiscovery.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2020 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 { SingleHostDiscovery } from './SingleHostDiscovery'; - -describe('SingleHostDiscovery', () => { - it('is created from config', async () => { - const discovery = SingleHostDiscovery.fromConfig( - new ConfigReader({ - backend: { - baseUrl: 'http://localhost:40', - listen: { port: 80, host: 'localhost' }, - }, - }), - ); - - await expect(discovery.getBaseUrl('catalog')).resolves.toBe( - 'http://localhost:80/api/catalog', - ); - await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe( - 'http://localhost:40/api/catalog', - ); - }); - - it('can configure the base path', async () => { - const discovery = SingleHostDiscovery.fromConfig( - new ConfigReader({ - backend: { - baseUrl: 'http://localhost:40', - listen: { port: 80, host: 'localhost' }, - }, - }), - { basePath: '/service' }, - ); - - await expect(discovery.getBaseUrl('catalog')).resolves.toBe( - 'http://localhost:80/service/catalog', - ); - await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe( - 'http://localhost:40/service/catalog', - ); - }); - - it.each([ - [{ listen: ':80' }, 'http://localhost:80'], - [{ listen: ':40', https: true }, 'https://localhost:40'], - [{ listen: '127.0.0.1:80' }, 'http://127.0.0.1:80'], - [{ listen: '127.0.0.1:80', https: true }, 'https://127.0.0.1:80'], - [{ listen: '0.0.0.0:40' }, 'http://127.0.0.1:40'], - [{ listen: { port: 80 } }, 'http://localhost:80'], - [{ listen: { port: 8000 } }, 'http://localhost:8000'], - [{ listen: { port: 80, host: '0.0.0.0' } }, 'http://127.0.0.1:80'], - [{ listen: { port: 80, host: '::' } }, 'http://localhost:80'], - [{ listen: { port: 80, host: '::1' } }, 'http://[::1]:80'], - [{ listen: { port: 90, host: '::2' }, https: true }, 'https://[::2]:90'], - ])('resolves internal baseUrl for %j as %s', async (config, expected) => { - const discovery = SingleHostDiscovery.fromConfig( - new ConfigReader({ - backend: { - baseUrl: 'http://localhost:40', - ...config, - }, - }), - ); - - await expect(discovery.getBaseUrl('catalog')).resolves.toBe( - `${expected}/api/catalog`, - ); - }); -}); diff --git a/packages/backend-common/src/discovery/SingleHostDiscovery.ts b/packages/backend-common/src/discovery/SingleHostDiscovery.ts deleted file mode 100644 index 1940773765..0000000000 --- a/packages/backend-common/src/discovery/SingleHostDiscovery.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2020 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 { PluginEndpointDiscovery } from './types'; -import { readHttpServerOptions } from '@backstage/backend-app-api'; - -/** - * SingleHostDiscovery is a basic PluginEndpointDiscovery implementation - * that assumes that all plugins are hosted in a single deployment. - * - * The deployment may be scaled horizontally, as long as the external URL - * is the same for all instances. However, internal URLs will always be - * resolved to the same host, so there won't be any balancing of internal traffic. - * - * @public - * @deprecated Use `HostDiscovery` instead - */ -export class SingleHostDiscovery implements PluginEndpointDiscovery { - /** - * Creates a new SingleHostDiscovery discovery instance by reading - * from the `backend` config section, specifically the `.baseUrl` for - * discovering the external URL, and the `.listen` and `.https` config - * for the internal one. - * - * The basePath defaults to `/api`, meaning the default full internal - * path for the `catalog` plugin will be `http://localhost:7007/api/catalog`. - */ - static fromConfig(config: Config, options?: { basePath?: string }) { - const basePath = options?.basePath ?? '/api'; - const externalBaseUrl = config.getString('backend.baseUrl'); - - const { - listen: { host: listenHost = '::', port: listenPort }, - } = readHttpServerOptions(config.getConfig('backend')); - const protocol = config.has('backend.https') ? 'https' : 'http'; - - // Translate bind-all to localhost, and support IPv6 - let host = listenHost; - if (host === '::' || host === '') { - // We use localhost instead of ::1, since IPv6-compatible systems should default - // to using IPv6 when they see localhost, but if the system doesn't support IPv6 - // things will still work. - host = 'localhost'; - } else if (host === '0.0.0.0') { - host = '127.0.0.1'; - } - if (host.includes(':')) { - host = `[${host}]`; - } - - const internalBaseUrl = `${protocol}://${host}:${listenPort}`; - - return new SingleHostDiscovery( - internalBaseUrl + basePath, - externalBaseUrl + basePath, - ); - } - - private constructor( - private readonly internalBaseUrl: string, - private readonly externalBaseUrl: string, - ) {} - - async getBaseUrl(pluginId: string): Promise { - return `${this.internalBaseUrl}/${pluginId}`; - } - - async getExternalBaseUrl(pluginId: string): Promise { - return `${this.externalBaseUrl}/${pluginId}`; - } -} diff --git a/packages/backend-common/src/discovery/index.ts b/packages/backend-common/src/discovery/index.ts index 8170409fc1..335199049f 100644 --- a/packages/backend-common/src/discovery/index.ts +++ b/packages/backend-common/src/discovery/index.ts @@ -14,6 +14,18 @@ * limitations under the License. */ -export { SingleHostDiscovery } from './SingleHostDiscovery'; +import { HostDiscovery } from './HostDiscovery'; +/** + * SingleHostDiscovery is a basic PluginEndpointDiscovery implementation + * that assumes that all plugins are hosted in a single deployment. + * + * The deployment may be scaled horizontally, as long as the external URL + * is the same for all instances. However, internal URLs will always be + * resolved to the same host, so there won't be any balancing of internal traffic. + * + * @public + * @deprecated Use {@link HostDiscovery} instead + */ +export const SingleHostDiscovery = HostDiscovery; export { HostDiscovery } from './HostDiscovery'; export type { PluginEndpointDiscovery } from './types'; From 5e01b931eeccb7bf4f15b9db61f584eebe3d551f Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Wed, 29 Mar 2023 13:44:50 +0100 Subject: [PATCH 04/15] chore: Update config types Signed-off-by: Jack Palmer --- packages/backend-common/config.d.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index 8dd2c2db84..5be1a76b54 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -203,5 +203,13 @@ export interface Config { * remove the default value that Backstage puts in place for that policy. */ csp?: { [policyId: string]: string[] | false }; + + /** + * Discovery options. + * + * The keys are the plugin IDs, and the values are their endpoint URLs which + * will be used by `HostDiscovery` implementation. + */ + discovery?: { [pluginId: string]: string }; }; } From b6d873c3abed8f40ba2e4a36d2c637938fb2891e Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Wed, 29 Mar 2023 20:40:49 +0100 Subject: [PATCH 05/15] chore: Address PR comments Signed-off-by: Jack Palmer --- packages/backend-common/src/discovery/HostDiscovery.test.ts | 6 +++--- packages/backend-common/src/discovery/HostDiscovery.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/backend-common/src/discovery/HostDiscovery.test.ts b/packages/backend-common/src/discovery/HostDiscovery.test.ts index c15ffd79e8..b07716352e 100644 --- a/packages/backend-common/src/discovery/HostDiscovery.test.ts +++ b/packages/backend-common/src/discovery/HostDiscovery.test.ts @@ -88,9 +88,9 @@ describe('HostDiscovery', () => { backend: { baseUrl: 'http://localhost:40', listen: { port: 80, host: 'localhost' }, - discovery: { - catalog: 'http://catalog-backend:8080/api/catalog', - }, + }, + discovery: { + catalog: 'http://catalog-backend:8080/api/catalog', }, }), ); diff --git a/packages/backend-common/src/discovery/HostDiscovery.ts b/packages/backend-common/src/discovery/HostDiscovery.ts index 0fedf0643a..d9f507139d 100644 --- a/packages/backend-common/src/discovery/HostDiscovery.ts +++ b/packages/backend-common/src/discovery/HostDiscovery.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2020 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. @@ -36,7 +36,7 @@ export class HostDiscovery implements PluginEndpointDiscovery { * discovering the external URL, and the `.listen` and `.https` config * for the internal one. * - * Can be overridden by in config by providing a `backend.discovery` section with a `` key. + * Can be overridden by in config by providing a `discovery` section with a `` key. * * The basePath defaults to `/api`, meaning the default full internal * path for the `catalog` plugin will be `http://localhost:7007/api/catalog`. @@ -69,7 +69,7 @@ export class HostDiscovery implements PluginEndpointDiscovery { return new HostDiscovery( internalBaseUrl + basePath, externalBaseUrl + basePath, - config.getOptionalConfig('backend.discovery'), + config.getOptionalConfig('discovery'), ); } From 960c1cf13d2bc29e88738fc4c69bd40d9d5e3910 Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Wed, 29 Mar 2023 21:45:50 +0100 Subject: [PATCH 06/15] refactor: Rework config to be driven by target baseUrls Signed-off-by: Jack Palmer --- packages/backend-common/config.d.ts | 20 +++- .../src/discovery/HostDiscovery.test.ts | 109 +++++++++++++++++- .../src/discovery/HostDiscovery.ts | 33 +++++- 3 files changed, 151 insertions(+), 11 deletions(-) diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index 5be1a76b54..c1542c7131 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -203,13 +203,25 @@ export interface Config { * remove the default value that Backstage puts in place for that policy. */ csp?: { [policyId: string]: string[] | false }; + }; + /** Discovery options. */ + discovery?: { /** - * Discovery options. + * Endpoints * - * The keys are the plugin IDs, and the values are their endpoint URLs which - * will be used by `HostDiscovery` implementation. + * A list of target baseUrls and the associated plugins. */ - discovery?: { [pluginId: string]: string }; + endpoints: { + /** + * The target baseUrl to use for the plugin + * + * Can be either a string or an object with internal and external keys. + * Targets with `{pluginId}` in the url will be replaced with the pluginId. + */ + target: string | { internal: string; external: string }; + /** Array of plugins which use the target baseUrl. */ + plugins: string[]; + }[]; }; } diff --git a/packages/backend-common/src/discovery/HostDiscovery.test.ts b/packages/backend-common/src/discovery/HostDiscovery.test.ts index b07716352e..a288764b4c 100644 --- a/packages/backend-common/src/discovery/HostDiscovery.test.ts +++ b/packages/backend-common/src/discovery/HostDiscovery.test.ts @@ -82,7 +82,7 @@ describe('HostDiscovery', () => { ); }); - it('uses plugin specific baseUrl from config if provided', async () => { + it('uses plugin specific targets from config if provided', async () => { const discovery = HostDiscovery.fromConfig( new ConfigReader({ backend: { @@ -90,7 +90,41 @@ describe('HostDiscovery', () => { listen: { port: 80, host: 'localhost' }, }, discovery: { - catalog: 'http://catalog-backend:8080/api/catalog', + endpoints: [ + { + target: { + internal: 'http://catalog-backend-internal:8080/api/catalog', + external: 'http://catalog-backend-external:8080/api/catalog', + }, + plugins: ['catalog'], + }, + ], + }, + }), + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://catalog-backend-internal:8080/api/catalog', + ); + await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe( + 'http://catalog-backend-external:8080/api/catalog', + ); + }); + + it('uses a single target for internal and external for a plugin', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + discovery: { + endpoints: [ + { + target: 'http://catalog-backend:8080/api/catalog', + plugins: ['catalog'], + }, + ], }, }), ); @@ -102,4 +136,75 @@ describe('HostDiscovery', () => { 'http://catalog-backend:8080/api/catalog', ); }); + + it('defaults to the backend baseUrl when there is not an endpoint for a plugin', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + discovery: { + endpoints: [ + { + target: 'http://catalog-backend:8080/api/catalog', + plugins: ['catalog'], + }, + ], + }, + }), + ); + + await expect(discovery.getBaseUrl('scaffolder')).resolves.toBe( + 'http://localhost:80/api/scaffolder', + ); + await expect(discovery.getExternalBaseUrl('scaffolder')).resolves.toBe( + 'http://localhost:40/api/scaffolder', + ); + }); + + it('replaces {pluginId} in the target', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + discovery: { + endpoints: [ + { + target: 'http://common-backend:8080/api/{pluginId}', + plugins: ['catalog', 'docs'], + }, + { + target: { + internal: 'http://scaffolder-internal:8080/api/{pluginId}', + external: 'http://scaffolder-external:8080/api/{pluginId}', + }, + plugins: ['scaffolder'], + }, + ], + }, + }), + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://common-backend:8080/api/catalog', + ); + await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe( + 'http://common-backend:8080/api/catalog', + ); + await expect(discovery.getBaseUrl('docs')).resolves.toBe( + 'http://common-backend:8080/api/docs', + ); + await expect(discovery.getExternalBaseUrl('docs')).resolves.toBe( + 'http://common-backend:8080/api/docs', + ); + await expect(discovery.getBaseUrl('scaffolder')).resolves.toBe( + 'http://scaffolder-internal:8080/api/scaffolder', + ); + await expect(discovery.getExternalBaseUrl('scaffolder')).resolves.toBe( + 'http://scaffolder-external:8080/api/scaffolder', + ); + }); }); diff --git a/packages/backend-common/src/discovery/HostDiscovery.ts b/packages/backend-common/src/discovery/HostDiscovery.ts index d9f507139d..e8af78b064 100644 --- a/packages/backend-common/src/discovery/HostDiscovery.ts +++ b/packages/backend-common/src/discovery/HostDiscovery.ts @@ -18,6 +18,8 @@ import { Config } from '@backstage/config'; import { PluginEndpointDiscovery } from './types'; import { readHttpServerOptions } from '@backstage/backend-app-api'; +type Target = string | { internal: string; external: string }; + /** * HostDiscovery is a basic PluginEndpointDiscovery implementation * that assumes that all plugins are hosted in a single deployment. @@ -28,7 +30,6 @@ import { readHttpServerOptions } from '@backstage/backend-app-api'; * * @public */ - export class HostDiscovery implements PluginEndpointDiscovery { /** * Creates a new HostDiscovery discovery instance by reading @@ -79,17 +80,39 @@ export class HostDiscovery implements PluginEndpointDiscovery { private readonly discoveryConfig: Config | undefined, ) {} + private getTargetFromConfig(pluginId: string, type: 'internal' | 'external') { + const endpoints = this.discoveryConfig?.getOptionalConfigArray('endpoints'); + + const target = endpoints + ?.find(endpoint => endpoint.getStringArray('plugins').includes(pluginId)) + ?.get('target'); + + if (!target) { + return null; + } + + if (typeof target === 'string') { + return target.replace('{pluginId}', pluginId); + } + + return target[type].replace('{pluginId}', pluginId); + } + async getBaseUrl(pluginId: string): Promise { - if (this.discoveryConfig?.has(pluginId)) { - return this.discoveryConfig.getString(pluginId); + const target = this.getTargetFromConfig(pluginId, 'internal'); + + if (target) { + return target; } return `${this.internalBaseUrl}/${pluginId}`; } async getExternalBaseUrl(pluginId: string): Promise { - if (this.discoveryConfig?.has(pluginId)) { - return this.discoveryConfig.getString(pluginId); + const target = this.getTargetFromConfig(pluginId, 'external'); + + if (target) { + return target; } return `${this.externalBaseUrl}/${pluginId}`; From 153f2427b4f5e230c43d754c9fe3c28a1dd815b9 Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Thu, 30 Mar 2023 09:50:25 +0100 Subject: [PATCH 07/15] fix: Export alias reference error Signed-off-by: Jack Palmer --- .../src/discovery/HostDiscovery.ts | 13 +++++++++++++ packages/backend-common/src/discovery/index.ts | 16 +--------------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/backend-common/src/discovery/HostDiscovery.ts b/packages/backend-common/src/discovery/HostDiscovery.ts index e8af78b064..e2bd79824a 100644 --- a/packages/backend-common/src/discovery/HostDiscovery.ts +++ b/packages/backend-common/src/discovery/HostDiscovery.ts @@ -118,3 +118,16 @@ export class HostDiscovery implements PluginEndpointDiscovery { return `${this.externalBaseUrl}/${pluginId}`; } } + +/** + * SingleHostDiscovery is a basic PluginEndpointDiscovery implementation + * that assumes that all plugins are hosted in a single deployment. + * + * The deployment may be scaled horizontally, as long as the external URL + * is the same for all instances. However, internal URLs will always be + * resolved to the same host, so there won't be any balancing of internal traffic. + * + * @public + * @deprecated Use {@link HostDiscovery} instead + */ +export const SingleHostDiscovery = HostDiscovery; diff --git a/packages/backend-common/src/discovery/index.ts b/packages/backend-common/src/discovery/index.ts index 335199049f..5c7f5300c0 100644 --- a/packages/backend-common/src/discovery/index.ts +++ b/packages/backend-common/src/discovery/index.ts @@ -13,19 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { HostDiscovery } from './HostDiscovery'; -/** - * SingleHostDiscovery is a basic PluginEndpointDiscovery implementation - * that assumes that all plugins are hosted in a single deployment. - * - * The deployment may be scaled horizontally, as long as the external URL - * is the same for all instances. However, internal URLs will always be - * resolved to the same host, so there won't be any balancing of internal traffic. - * - * @public - * @deprecated Use {@link HostDiscovery} instead - */ -export const SingleHostDiscovery = HostDiscovery; -export { HostDiscovery } from './HostDiscovery'; +export { HostDiscovery, SingleHostDiscovery } from './HostDiscovery'; export type { PluginEndpointDiscovery } from './types'; From 9bb32cd177189d609ce6d37f3d1c1dc9be875446 Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Thu, 30 Mar 2023 09:54:24 +0100 Subject: [PATCH 08/15] docs: Update api-report Signed-off-by: Jack Palmer --- packages/backend-common/api-report.md | 29 +++++++++++++++------------ 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index f1d8a74dbe..5420500ae8 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -471,6 +471,20 @@ export class GitlabUrlReader implements UrlReader { toString(): string; } +// @public +export class HostDiscovery implements PluginEndpointDiscovery { + static fromConfig( + config: Config, + options?: { + basePath?: string; + }, + ): HostDiscovery; + // (undocumented) + getBaseUrl(pluginId: string): Promise; + // (undocumented) + getExternalBaseUrl(pluginId: string): Promise; +} + export { isChildPath }; // @public @@ -726,19 +740,8 @@ export type ServiceBuilder = { // @public export function setRootLogger(newLogger: winston.Logger): void; -// @public -export class SingleHostDiscovery implements PluginEndpointDiscovery { - static fromConfig( - config: Config, - options?: { - basePath?: string; - }, - ): SingleHostDiscovery; - // (undocumented) - getBaseUrl(pluginId: string): Promise; - // (undocumented) - getExternalBaseUrl(pluginId: string): Promise; -} +// @public @deprecated +export const SingleHostDiscovery: typeof HostDiscovery; // @public export type StatusCheck = () => Promise; From cc19abd08bb749c09df89149878ef1543c65c141 Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Thu, 30 Mar 2023 10:04:36 +0100 Subject: [PATCH 09/15] refactor: Use {{pluginId}} pattern and update docs Signed-off-by: Jack Palmer --- packages/backend-common/config.d.ts | 2 +- .../src/discovery/HostDiscovery.test.ts | 8 ++++---- .../src/discovery/HostDiscovery.ts | 19 ++++++++++++++++--- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index c1542c7131..dc2e2fa133 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -217,7 +217,7 @@ export interface Config { * The target baseUrl to use for the plugin * * Can be either a string or an object with internal and external keys. - * Targets with `{pluginId}` in the url will be replaced with the pluginId. + * Targets with `{{pluginId}}` or `{{ pluginId }} in the url will be replaced with the pluginId. */ target: string | { internal: string; external: string }; /** Array of plugins which use the target baseUrl. */ diff --git a/packages/backend-common/src/discovery/HostDiscovery.test.ts b/packages/backend-common/src/discovery/HostDiscovery.test.ts index a288764b4c..381acbde19 100644 --- a/packages/backend-common/src/discovery/HostDiscovery.test.ts +++ b/packages/backend-common/src/discovery/HostDiscovery.test.ts @@ -163,7 +163,7 @@ describe('HostDiscovery', () => { ); }); - it('replaces {pluginId} in the target', async () => { + it('replaces {{pluginId}} or {{ pluginId }} in the target', async () => { const discovery = HostDiscovery.fromConfig( new ConfigReader({ backend: { @@ -173,13 +173,13 @@ describe('HostDiscovery', () => { discovery: { endpoints: [ { - target: 'http://common-backend:8080/api/{pluginId}', + target: 'http://common-backend:8080/api/{{pluginId}}', plugins: ['catalog', 'docs'], }, { target: { - internal: 'http://scaffolder-internal:8080/api/{pluginId}', - external: 'http://scaffolder-external:8080/api/{pluginId}', + internal: 'http://scaffolder-internal:8080/api/{{ pluginId }}', + external: 'http://scaffolder-external:8080/api/{{ pluginId }}', }, plugins: ['scaffolder'], }, diff --git a/packages/backend-common/src/discovery/HostDiscovery.ts b/packages/backend-common/src/discovery/HostDiscovery.ts index e2bd79824a..83def76aae 100644 --- a/packages/backend-common/src/discovery/HostDiscovery.ts +++ b/packages/backend-common/src/discovery/HostDiscovery.ts @@ -37,7 +37,20 @@ export class HostDiscovery implements PluginEndpointDiscovery { * discovering the external URL, and the `.listen` and `.https` config * for the internal one. * - * Can be overridden by in config by providing a `discovery` section with a `` key. + * Can be overridden by in config by providing a target and corresponding plugin in `discovery.endpoint`. + * eg. + * ```yaml + * discovery: + * endpoints: + * - target: https://internal.example.com/internal-catalog + * plugins: [catalog] + * - target: https://internal.example.com/secure/api/{{pluginId}} + * plugins: [auth, permissions] + * - target: + * internal: https://internal.example.com/search + * external: https://example.com/search + * plugins: [search] + * ``` * * The basePath defaults to `/api`, meaning the default full internal * path for the `catalog` plugin will be `http://localhost:7007/api/catalog`. @@ -92,10 +105,10 @@ export class HostDiscovery implements PluginEndpointDiscovery { } if (typeof target === 'string') { - return target.replace('{pluginId}', pluginId); + return target.replace(/\{\{\s*pluginId\s*\}\}/, pluginId); } - return target[type].replace('{pluginId}', pluginId); + return target[type].replace(/\{\{\s*pluginId\s*\}\}/, pluginId); } async getBaseUrl(pluginId: string): Promise { From 1364df62a19fda7c2964c0476fa6d21ad34e7ba0 Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Tue, 4 Apr 2023 13:36:08 +0100 Subject: [PATCH 10/15] chore: encode pluginId and address PR comments Signed-off-by: Jack Palmer --- .../src/discovery/HostDiscovery.test.ts | 29 ++++++++++++++++ .../src/discovery/HostDiscovery.ts | 33 +++++++++---------- 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/packages/backend-common/src/discovery/HostDiscovery.test.ts b/packages/backend-common/src/discovery/HostDiscovery.test.ts index 381acbde19..577da3a679 100644 --- a/packages/backend-common/src/discovery/HostDiscovery.test.ts +++ b/packages/backend-common/src/discovery/HostDiscovery.test.ts @@ -207,4 +207,33 @@ describe('HostDiscovery', () => { 'http://scaffolder-external:8080/api/scaffolder', ); }); + + it('encodes the pluginId', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + discovery: { + endpoints: [ + { + target: 'http://common-backend:8080/api/{{pluginId}}', + plugins: ['plugin/beta'], + }, + ], + }, + }), + ); + + await expect(discovery.getBaseUrl('plugin/beta')).resolves.toBe( + 'http://common-backend:8080/api/plugin%2Fbeta', + ); + await expect(discovery.getBaseUrl('plugin/alpha')).resolves.toBe( + 'http://localhost:80/api/plugin%2Falpha', + ); + await expect(discovery.getExternalBaseUrl('plugin/alpha')).resolves.toBe( + 'http://localhost:40/api/plugin%2Falpha', + ); + }); }); diff --git a/packages/backend-common/src/discovery/HostDiscovery.ts b/packages/backend-common/src/discovery/HostDiscovery.ts index 83def76aae..87089a0895 100644 --- a/packages/backend-common/src/discovery/HostDiscovery.ts +++ b/packages/backend-common/src/discovery/HostDiscovery.ts @@ -37,7 +37,7 @@ export class HostDiscovery implements PluginEndpointDiscovery { * discovering the external URL, and the `.listen` and `.https` config * for the internal one. * - * Can be overridden by in config by providing a target and corresponding plugin in `discovery.endpoint`. + * Can be overridden in config by providing a target and corresponding plugins in `discovery.endpoint`. * eg. * ```yaml * discovery: @@ -101,34 +101,31 @@ export class HostDiscovery implements PluginEndpointDiscovery { ?.get('target'); if (!target) { - return null; + const baseUrl = + type === 'external' ? this.externalBaseUrl : this.internalBaseUrl; + + return `${baseUrl}/${encodeURIComponent(pluginId)}`; } if (typeof target === 'string') { - return target.replace(/\{\{\s*pluginId\s*\}\}/, pluginId); + return target.replace( + /\{\{\s*pluginId\s*\}\}/g, + encodeURIComponent(pluginId), + ); } - return target[type].replace(/\{\{\s*pluginId\s*\}\}/, pluginId); + return target[type].replace( + /\{\{\s*pluginId\s*\}\}/g, + encodeURIComponent(pluginId), + ); } async getBaseUrl(pluginId: string): Promise { - const target = this.getTargetFromConfig(pluginId, 'internal'); - - if (target) { - return target; - } - - return `${this.internalBaseUrl}/${pluginId}`; + return this.getTargetFromConfig(pluginId, 'internal'); } async getExternalBaseUrl(pluginId: string): Promise { - const target = this.getTargetFromConfig(pluginId, 'external'); - - if (target) { - return target; - } - - return `${this.externalBaseUrl}/${pluginId}`; + return this.getTargetFromConfig(pluginId, 'external'); } } From d9849f34a6c6fb17960089dca4febcc37f4ec2ad Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Wed, 5 Apr 2023 09:43:17 +0100 Subject: [PATCH 11/15] chore: Update doc comments for HostDiscovery Signed-off-by: Jack Palmer --- packages/backend-common/src/discovery/HostDiscovery.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend-common/src/discovery/HostDiscovery.ts b/packages/backend-common/src/discovery/HostDiscovery.ts index 87089a0895..abac4413be 100644 --- a/packages/backend-common/src/discovery/HostDiscovery.ts +++ b/packages/backend-common/src/discovery/HostDiscovery.ts @@ -22,7 +22,7 @@ type Target = string | { internal: string; external: string }; /** * HostDiscovery is a basic PluginEndpointDiscovery implementation - * that assumes that all plugins are hosted in a single deployment. + * that can handle plugins that are hosted in a single or multiple deployments. * * The deployment may be scaled horizontally, as long as the external URL * is the same for all instances. However, internal URLs will always be @@ -37,7 +37,7 @@ export class HostDiscovery implements PluginEndpointDiscovery { * discovering the external URL, and the `.listen` and `.https` config * for the internal one. * - * Can be overridden in config by providing a target and corresponding plugins in `discovery.endpoint`. + * Can be overridden in config by providing a target and corresponding plugins in `discovery.endpoints`. * eg. * ```yaml * discovery: From 7d987c1c11bf00170efe4bcafb903248ea388e83 Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Wed, 5 Apr 2023 09:55:19 +0100 Subject: [PATCH 12/15] feat: Add HostDiscovery to core-app-api Signed-off-by: Jack Palmer --- packages/core-app-api/api-report.md | 12 ++ .../DiscoveryApi/HostDiscovery.test.ts | 178 ++++++++++++++++++ .../DiscoveryApi/HostDiscovery.ts | 86 +++++++++ .../implementations/DiscoveryApi/index.ts | 1 + 4 files changed, 277 insertions(+) create mode 100644 packages/core-app-api/src/apis/implementations/DiscoveryApi/HostDiscovery.test.ts create mode 100644 packages/core-app-api/src/apis/implementations/DiscoveryApi/HostDiscovery.ts diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 5b6282eddd..ed72270841 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -428,6 +428,18 @@ export class GoogleAuth { static create(options: OAuthApiCreateOptions): typeof googleAuthApiRef.T; } +// @public +export class HostDiscovery implements DiscoveryApi { + static fromConfig( + config: Config, + options?: { + basePath?: string; + }, + ): HostDiscovery; + // (undocumented) + getBaseUrl(pluginId: string): Promise; +} + // @public export class LocalStorageFeatureFlags implements FeatureFlagsApi { // (undocumented) diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/HostDiscovery.test.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/HostDiscovery.test.ts new file mode 100644 index 0000000000..942a1861f9 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/HostDiscovery.test.ts @@ -0,0 +1,178 @@ +/* + * Copyright 2020 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 { HostDiscovery } from './HostDiscovery'; + +describe('HostDiscovery', () => { + it('is created from config', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + }, + }), + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://localhost:40/api/catalog', + ); + }); + + it('can configure the base path', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + }, + }), + { basePath: '/service' }, + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://localhost:40/service/catalog', + ); + }); + + it('uses plugin specific targets from config if provided', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + }, + discovery: { + endpoints: [ + { + target: { + internal: 'http://catalog-backend-internal:8080/api/catalog', + external: 'http://catalog-backend-external:8080/api/catalog', + }, + plugins: ['catalog'], + }, + ], + }, + }), + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://catalog-backend-external:8080/api/catalog', + ); + }); + + it('uses a single target for internal and external for a plugin', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + }, + discovery: { + endpoints: [ + { + target: 'http://catalog-backend:8080/api/catalog', + plugins: ['catalog'], + }, + ], + }, + }), + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://catalog-backend:8080/api/catalog', + ); + }); + + it('defaults to the backend baseUrl when there is not an endpoint for a plugin', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + }, + discovery: { + endpoints: [ + { + target: 'http://catalog-backend:8080/api/catalog', + plugins: ['catalog'], + }, + ], + }, + }), + ); + + await expect(discovery.getBaseUrl('scaffolder')).resolves.toBe( + 'http://localhost:40/api/scaffolder', + ); + }); + + it('replaces {{pluginId}} or {{ pluginId }} in the target', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + }, + discovery: { + endpoints: [ + { + target: 'http://common-backend:8080/api/{{pluginId}}', + plugins: ['catalog', 'docs'], + }, + { + target: { + internal: 'http://scaffolder-internal:8080/api/{{ pluginId }}', + external: 'http://scaffolder-external:8080/api/{{ pluginId }}', + }, + plugins: ['scaffolder'], + }, + ], + }, + }), + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://common-backend:8080/api/catalog', + ); + await expect(discovery.getBaseUrl('docs')).resolves.toBe( + 'http://common-backend:8080/api/docs', + ); + await expect(discovery.getBaseUrl('scaffolder')).resolves.toBe( + 'http://scaffolder-external:8080/api/scaffolder', + ); + }); + + it('encodes the pluginId', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + }, + discovery: { + endpoints: [ + { + target: 'http://common-backend:8080/api/{{pluginId}}', + plugins: ['plugin/beta'], + }, + ], + }, + }), + ); + + await expect(discovery.getBaseUrl('plugin/beta')).resolves.toBe( + 'http://common-backend:8080/api/plugin%2Fbeta', + ); + await expect(discovery.getBaseUrl('plugin/alpha')).resolves.toBe( + 'http://localhost:40/api/plugin%2Falpha', + ); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/HostDiscovery.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/HostDiscovery.ts new file mode 100644 index 0000000000..b2f403cf76 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/HostDiscovery.ts @@ -0,0 +1,86 @@ +/* + * 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 { DiscoveryApi } from '@backstage/core-plugin-api'; + +type Target = string | { internal: string; external: string }; + +/** + * HostDiscovery is a config driven DiscoveryApi implementation. + * It uses the app-config to determine the url for a plugin. + * + * @public + */ +export class HostDiscovery implements DiscoveryApi { + /** + * Creates a new HostDiscovery discovery instance by reading + * from the `discovery.endpoints` config section. If both internal and external exist, then external is used. + * + * eg. + * ```yaml + * discovery: + * endpoints: + * - target: https://internal.example.com/internal-catalog + * plugins: [catalog] + * - target: https://internal.example.com/secure/api/{{pluginId}} + * plugins: [auth, permissions] + * - target: + * internal: https://internal.example.com/search + * external: https://example.com/search + * plugins: [search] + * ``` + * + * If a plugin is not declared in the config, the baseUrl is used, defaulting to "/api" unless `basePath` is provided in the options. + */ + static fromConfig(config: Config, options?: { basePath?: string }) { + const basePath = options?.basePath ?? '/api'; + const baseUrl = config.getString('backend.baseUrl'); + + return new HostDiscovery( + baseUrl + basePath, + config.getOptionalConfig('discovery'), + ); + } + + private constructor( + private readonly baseUrl: string, + private readonly discoveryConfig: Config | undefined, + ) {} + + async getBaseUrl(pluginId: string): Promise { + const endpoints = this.discoveryConfig?.getOptionalConfigArray('endpoints'); + + const target = endpoints + ?.find(endpoint => endpoint.getStringArray('plugins').includes(pluginId)) + ?.get('target'); + + if (!target) { + return `${this.baseUrl}/${encodeURIComponent(pluginId)}`; + } + + if (typeof target === 'string') { + return target.replace( + /\{\{\s*pluginId\s*\}\}/g, + encodeURIComponent(pluginId), + ); + } + + return target.external.replace( + /\{\{\s*pluginId\s*\}\}/g, + encodeURIComponent(pluginId), + ); + } +} diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/index.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/index.ts index 184346401f..be4b3a821c 100644 --- a/packages/core-app-api/src/apis/implementations/DiscoveryApi/index.ts +++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/index.ts @@ -15,3 +15,4 @@ */ export { UrlPatternDiscovery } from './UrlPatternDiscovery'; +export { HostDiscovery } from './HostDiscovery'; From 42d817e76ab9fc1c6bcb4dbaafd323dc0bdae6e6 Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Wed, 5 Apr 2023 09:59:12 +0100 Subject: [PATCH 13/15] chore: update changelogs Signed-off-by: Jack Palmer --- .changeset/quick-flies-guess.md | 5 +++++ .changeset/soft-readers-exist.md | 5 +++++ 2 files changed, 10 insertions(+) create mode 100644 .changeset/quick-flies-guess.md create mode 100644 .changeset/soft-readers-exist.md diff --git a/.changeset/quick-flies-guess.md b/.changeset/quick-flies-guess.md new file mode 100644 index 0000000000..8e6a7ead40 --- /dev/null +++ b/.changeset/quick-flies-guess.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Added HostDiscovery for config driven discovery implementation diff --git a/.changeset/soft-readers-exist.md b/.changeset/soft-readers-exist.md new file mode 100644 index 0000000000..bdaa06e476 --- /dev/null +++ b/.changeset/soft-readers-exist.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Added HostDiscovery to supersede deprecated SingleHostDiscovery (deprecated due to name) From 9a16d5b88baf4f8c9e605f40c36d249656f0a7d0 Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Wed, 5 Apr 2023 10:01:07 +0100 Subject: [PATCH 14/15] chore: Delete duplicate changeset Signed-off-by: Jack Palmer --- .changeset/clever-jars-tie.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/clever-jars-tie.md diff --git a/.changeset/clever-jars-tie.md b/.changeset/clever-jars-tie.md deleted file mode 100644 index bdaa06e476..0000000000 --- a/.changeset/clever-jars-tie.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/backend-common': patch ---- - -Added HostDiscovery to supersede deprecated SingleHostDiscovery (deprecated due to name) From 51329677e184472f045d184a31c16244cf425e15 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 25 Apr 2023 16:08:14 +0200 Subject: [PATCH 15/15] core-app-api: refactor into FrontendHostDiscovery Signed-off-by: Patrik Oldsberg --- .changeset/quick-flies-guess.md | 2 +- .changeset/soft-readers-exist.md | 2 +- packages/core-app-api/api-report.md | 24 +++--- ....test.ts => FrontendHostDiscovery.test.ts} | 20 ++--- .../DiscoveryApi/FrontendHostDiscovery.ts | 83 ++++++++++++++++++ .../DiscoveryApi/HostDiscovery.ts | 86 ------------------- .../DiscoveryApi/UrlPatternDiscovery.ts | 2 +- .../implementations/DiscoveryApi/index.ts | 2 +- 8 files changed, 109 insertions(+), 112 deletions(-) rename packages/core-app-api/src/apis/implementations/DiscoveryApi/{HostDiscovery.test.ts => FrontendHostDiscovery.test.ts} (89%) create mode 100644 packages/core-app-api/src/apis/implementations/DiscoveryApi/FrontendHostDiscovery.ts delete mode 100644 packages/core-app-api/src/apis/implementations/DiscoveryApi/HostDiscovery.ts diff --git a/.changeset/quick-flies-guess.md b/.changeset/quick-flies-guess.md index 8e6a7ead40..73e5589b98 100644 --- a/.changeset/quick-flies-guess.md +++ b/.changeset/quick-flies-guess.md @@ -2,4 +2,4 @@ '@backstage/core-app-api': patch --- -Added HostDiscovery for config driven discovery implementation +Added `FrontendHostDiscovery` for config driven discovery implementation diff --git a/.changeset/soft-readers-exist.md b/.changeset/soft-readers-exist.md index bdaa06e476..8fce13caca 100644 --- a/.changeset/soft-readers-exist.md +++ b/.changeset/soft-readers-exist.md @@ -2,4 +2,4 @@ '@backstage/backend-common': patch --- -Added HostDiscovery to supersede deprecated SingleHostDiscovery (deprecated due to name) +Added `HostDiscovery` to supersede deprecated `SingleHostDiscovery` (deprecated due to name) diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index ed72270841..514a58d289 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -410,6 +410,18 @@ export type FlatRoutesProps = { children: ReactNode; }; +// @public +export class FrontendHostDiscovery implements DiscoveryApi { + static fromConfig( + config: Config, + options?: { + pathPattern?: string; + }, + ): FrontendHostDiscovery; + // (undocumented) + getBaseUrl(pluginId: string): Promise; +} + // @public export class GithubAuth { // (undocumented) @@ -428,18 +440,6 @@ export class GoogleAuth { static create(options: OAuthApiCreateOptions): typeof googleAuthApiRef.T; } -// @public -export class HostDiscovery implements DiscoveryApi { - static fromConfig( - config: Config, - options?: { - basePath?: string; - }, - ): HostDiscovery; - // (undocumented) - getBaseUrl(pluginId: string): Promise; -} - // @public export class LocalStorageFeatureFlags implements FeatureFlagsApi { // (undocumented) diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/HostDiscovery.test.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/FrontendHostDiscovery.test.ts similarity index 89% rename from packages/core-app-api/src/apis/implementations/DiscoveryApi/HostDiscovery.test.ts rename to packages/core-app-api/src/apis/implementations/DiscoveryApi/FrontendHostDiscovery.test.ts index 942a1861f9..0ba6457a1b 100644 --- a/packages/core-app-api/src/apis/implementations/DiscoveryApi/HostDiscovery.test.ts +++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/FrontendHostDiscovery.test.ts @@ -15,11 +15,11 @@ */ import { ConfigReader } from '@backstage/config'; -import { HostDiscovery } from './HostDiscovery'; +import { FrontendHostDiscovery } from './FrontendHostDiscovery'; -describe('HostDiscovery', () => { +describe('FrontendHostDiscovery', () => { it('is created from config', async () => { - const discovery = HostDiscovery.fromConfig( + const discovery = FrontendHostDiscovery.fromConfig( new ConfigReader({ backend: { baseUrl: 'http://localhost:40', @@ -33,13 +33,13 @@ describe('HostDiscovery', () => { }); it('can configure the base path', async () => { - const discovery = HostDiscovery.fromConfig( + const discovery = FrontendHostDiscovery.fromConfig( new ConfigReader({ backend: { baseUrl: 'http://localhost:40', }, }), - { basePath: '/service' }, + { pathPattern: '/service/{{pluginId}}' }, ); await expect(discovery.getBaseUrl('catalog')).resolves.toBe( @@ -48,7 +48,7 @@ describe('HostDiscovery', () => { }); it('uses plugin specific targets from config if provided', async () => { - const discovery = HostDiscovery.fromConfig( + const discovery = FrontendHostDiscovery.fromConfig( new ConfigReader({ backend: { baseUrl: 'http://localhost:40', @@ -73,7 +73,7 @@ describe('HostDiscovery', () => { }); it('uses a single target for internal and external for a plugin', async () => { - const discovery = HostDiscovery.fromConfig( + const discovery = FrontendHostDiscovery.fromConfig( new ConfigReader({ backend: { baseUrl: 'http://localhost:40', @@ -95,7 +95,7 @@ describe('HostDiscovery', () => { }); it('defaults to the backend baseUrl when there is not an endpoint for a plugin', async () => { - const discovery = HostDiscovery.fromConfig( + const discovery = FrontendHostDiscovery.fromConfig( new ConfigReader({ backend: { baseUrl: 'http://localhost:40', @@ -117,7 +117,7 @@ describe('HostDiscovery', () => { }); it('replaces {{pluginId}} or {{ pluginId }} in the target', async () => { - const discovery = HostDiscovery.fromConfig( + const discovery = FrontendHostDiscovery.fromConfig( new ConfigReader({ backend: { baseUrl: 'http://localhost:40', @@ -152,7 +152,7 @@ describe('HostDiscovery', () => { }); it('encodes the pluginId', async () => { - const discovery = HostDiscovery.fromConfig( + const discovery = FrontendHostDiscovery.fromConfig( new ConfigReader({ backend: { baseUrl: 'http://localhost:40', diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/FrontendHostDiscovery.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/FrontendHostDiscovery.ts new file mode 100644 index 0000000000..950b746684 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/FrontendHostDiscovery.ts @@ -0,0 +1,83 @@ +/* + * 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 { DiscoveryApi } from '@backstage/core-plugin-api'; +import { UrlPatternDiscovery } from './UrlPatternDiscovery'; + +/** + * FrontendHostDiscovery is a config driven DiscoveryApi implementation. + * It uses the app-config to determine the url for a plugin. + * + * @public + */ +export class FrontendHostDiscovery implements DiscoveryApi { + /** + * Creates a new FrontendHostDiscovery discovery instance by reading + * the external target URL from the `discovery.endpoints` config section. + * + * eg. + * ```yaml + * discovery: + * endpoints: + * - target: https://internal.example.com/internal-catalog + * plugins: [catalog] + * - target: https://internal.example.com/secure/api/{{pluginId}} + * plugins: [auth, permissions] + * - target: + * internal: https://internal.example.com/search + * external: https://example.com/search + * plugins: [search] + * ``` + * + * If a plugin is not declared in the config, the discovery will fall back to using the baseUrl with + * the provided `pathPattern` appended. The default path pattern is `"/api/{{ pluginId }}"`. + */ + static fromConfig(config: Config, options?: { pathPattern?: string }) { + const path = options?.pathPattern ?? '/api/{{ pluginId }}'; + const baseUrl = config.getString('backend.baseUrl'); + + const endpoints = config + .getOptionalConfigArray('discovery.endpoints') + ?.flatMap(e => { + const target = + typeof e.get('target') === 'object' + ? e.getString('target.external') + : e.getString('target'); + const discovery = UrlPatternDiscovery.compile(target); + return e + .getStringArray('plugins') + .map(pluginId => [pluginId, discovery] as const); + }); + + return new FrontendHostDiscovery( + new Map(endpoints), + UrlPatternDiscovery.compile(`${baseUrl}${path}`), + ); + } + + private constructor( + private readonly endpoints: Map, + private readonly defaultEndpoint: DiscoveryApi, + ) {} + + async getBaseUrl(pluginId: string): Promise { + const endpoint = this.endpoints.get(pluginId); + if (endpoint) { + return endpoint.getBaseUrl(pluginId); + } + return this.defaultEndpoint.getBaseUrl(pluginId); + } +} diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/HostDiscovery.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/HostDiscovery.ts deleted file mode 100644 index b2f403cf76..0000000000 --- a/packages/core-app-api/src/apis/implementations/DiscoveryApi/HostDiscovery.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * 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 { DiscoveryApi } from '@backstage/core-plugin-api'; - -type Target = string | { internal: string; external: string }; - -/** - * HostDiscovery is a config driven DiscoveryApi implementation. - * It uses the app-config to determine the url for a plugin. - * - * @public - */ -export class HostDiscovery implements DiscoveryApi { - /** - * Creates a new HostDiscovery discovery instance by reading - * from the `discovery.endpoints` config section. If both internal and external exist, then external is used. - * - * eg. - * ```yaml - * discovery: - * endpoints: - * - target: https://internal.example.com/internal-catalog - * plugins: [catalog] - * - target: https://internal.example.com/secure/api/{{pluginId}} - * plugins: [auth, permissions] - * - target: - * internal: https://internal.example.com/search - * external: https://example.com/search - * plugins: [search] - * ``` - * - * If a plugin is not declared in the config, the baseUrl is used, defaulting to "/api" unless `basePath` is provided in the options. - */ - static fromConfig(config: Config, options?: { basePath?: string }) { - const basePath = options?.basePath ?? '/api'; - const baseUrl = config.getString('backend.baseUrl'); - - return new HostDiscovery( - baseUrl + basePath, - config.getOptionalConfig('discovery'), - ); - } - - private constructor( - private readonly baseUrl: string, - private readonly discoveryConfig: Config | undefined, - ) {} - - async getBaseUrl(pluginId: string): Promise { - const endpoints = this.discoveryConfig?.getOptionalConfigArray('endpoints'); - - const target = endpoints - ?.find(endpoint => endpoint.getStringArray('plugins').includes(pluginId)) - ?.get('target'); - - if (!target) { - return `${this.baseUrl}/${encodeURIComponent(pluginId)}`; - } - - if (typeof target === 'string') { - return target.replace( - /\{\{\s*pluginId\s*\}\}/g, - encodeURIComponent(pluginId), - ); - } - - return target.external.replace( - /\{\{\s*pluginId\s*\}\}/g, - encodeURIComponent(pluginId), - ); - } -} diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts index cba8344977..58ffc9441b 100644 --- a/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts +++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/UrlPatternDiscovery.ts @@ -58,6 +58,6 @@ export class UrlPatternDiscovery implements DiscoveryApi { private constructor(private readonly parts: string[]) {} async getBaseUrl(pluginId: string): Promise { - return this.parts.join(pluginId); + return this.parts.join(encodeURIComponent(pluginId)); } } diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/index.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/index.ts index be4b3a821c..6972ca7b85 100644 --- a/packages/core-app-api/src/apis/implementations/DiscoveryApi/index.ts +++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/index.ts @@ -15,4 +15,4 @@ */ export { UrlPatternDiscovery } from './UrlPatternDiscovery'; -export { HostDiscovery } from './HostDiscovery'; +export { FrontendHostDiscovery } from './FrontendHostDiscovery';