diff --git a/.changeset/quick-flies-guess.md b/.changeset/quick-flies-guess.md new file mode 100644 index 0000000000..73e5589b98 --- /dev/null +++ b/.changeset/quick-flies-guess.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Added `FrontendHostDiscovery` 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..8fce13caca --- /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) diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 640358664c..d40027cba9 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -472,6 +472,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 @@ -728,19 +742,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; diff --git a/packages/backend-common/config.d.ts b/packages/backend-common/config.d.ts index 8dd2c2db84..dc2e2fa133 100644 --- a/packages/backend-common/config.d.ts +++ b/packages/backend-common/config.d.ts @@ -204,4 +204,24 @@ export interface Config { */ csp?: { [policyId: string]: string[] | false }; }; + + /** Discovery options. */ + discovery?: { + /** + * Endpoints + * + * A list of target baseUrls and the associated plugins. + */ + 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}}` 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. */ + plugins: string[]; + }[]; + }; } 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..577da3a679 --- /dev/null +++ b/packages/backend-common/src/discovery/HostDiscovery.test.ts @@ -0,0 +1,239 @@ +/* + * 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 targets from config if provided', async () => { + const discovery = HostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + listen: { port: 80, host: 'localhost' }, + }, + 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-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'], + }, + ], + }, + }), + ); + + 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', + ); + }); + + 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}} or {{ 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', + ); + }); + + 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/SingleHostDiscovery.ts b/packages/backend-common/src/discovery/HostDiscovery.ts similarity index 55% rename from packages/backend-common/src/discovery/SingleHostDiscovery.ts rename to packages/backend-common/src/discovery/HostDiscovery.ts index ded23b3374..abac4413be 100644 --- a/packages/backend-common/src/discovery/SingleHostDiscovery.ts +++ b/packages/backend-common/src/discovery/HostDiscovery.ts @@ -18,9 +18,11 @@ import { Config } from '@backstage/config'; import { PluginEndpointDiscovery } from './types'; import { readHttpServerOptions } from '@backstage/backend-app-api'; +type Target = string | { internal: string; external: string }; + /** - * SingleHostDiscovery is a basic PluginEndpointDiscovery implementation - * that assumes that all plugins are hosted in a single deployment. + * HostDiscovery is a basic PluginEndpointDiscovery implementation + * 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 @@ -28,13 +30,28 @@ import { readHttpServerOptions } from '@backstage/backend-app-api'; * * @public */ -export class SingleHostDiscovery implements PluginEndpointDiscovery { +export class HostDiscovery implements PluginEndpointDiscovery { /** - * Creates a new SingleHostDiscovery discovery instance by reading + * 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 in config by providing a target and corresponding plugins in `discovery.endpoints`. + * 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`. */ @@ -63,22 +80,64 @@ export class SingleHostDiscovery implements PluginEndpointDiscovery { const internalBaseUrl = `${protocol}://${host}:${listenPort}`; - return new SingleHostDiscovery( + return new HostDiscovery( internalBaseUrl + basePath, externalBaseUrl + basePath, + config.getOptionalConfig('discovery'), ); } private constructor( private readonly internalBaseUrl: string, private readonly externalBaseUrl: string, + 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) { + const baseUrl = + type === 'external' ? this.externalBaseUrl : this.internalBaseUrl; + + return `${baseUrl}/${encodeURIComponent(pluginId)}`; + } + + if (typeof target === 'string') { + return target.replace( + /\{\{\s*pluginId\s*\}\}/g, + encodeURIComponent(pluginId), + ); + } + + return target[type].replace( + /\{\{\s*pluginId\s*\}\}/g, + encodeURIComponent(pluginId), + ); + } + async getBaseUrl(pluginId: string): Promise { - return `${this.internalBaseUrl}/${pluginId}`; + return this.getTargetFromConfig(pluginId, 'internal'); } async getExternalBaseUrl(pluginId: string): Promise { - return `${this.externalBaseUrl}/${pluginId}`; + return this.getTargetFromConfig(pluginId, 'external'); } } + +/** + * 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/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/index.ts b/packages/backend-common/src/discovery/index.ts index 5b62d6f4e4..5c7f5300c0 100644 --- a/packages/backend-common/src/discovery/index.ts +++ b/packages/backend-common/src/discovery/index.ts @@ -13,6 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -export { SingleHostDiscovery } from './SingleHostDiscovery'; +export { HostDiscovery, SingleHostDiscovery } from './HostDiscovery'; export type { PluginEndpointDiscovery } from './types'; diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index b6d3722822..c899ccc065 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) diff --git a/packages/core-app-api/src/apis/implementations/DiscoveryApi/FrontendHostDiscovery.test.ts b/packages/core-app-api/src/apis/implementations/DiscoveryApi/FrontendHostDiscovery.test.ts new file mode 100644 index 0000000000..0ba6457a1b --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/DiscoveryApi/FrontendHostDiscovery.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 { FrontendHostDiscovery } from './FrontendHostDiscovery'; + +describe('FrontendHostDiscovery', () => { + it('is created from config', async () => { + const discovery = FrontendHostDiscovery.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 = FrontendHostDiscovery.fromConfig( + new ConfigReader({ + backend: { + baseUrl: 'http://localhost:40', + }, + }), + { pathPattern: '/service/{{pluginId}}' }, + ); + + await expect(discovery.getBaseUrl('catalog')).resolves.toBe( + 'http://localhost:40/service/catalog', + ); + }); + + it('uses plugin specific targets from config if provided', async () => { + const discovery = FrontendHostDiscovery.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 = FrontendHostDiscovery.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 = FrontendHostDiscovery.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 = FrontendHostDiscovery.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 = FrontendHostDiscovery.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/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/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 184346401f..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,3 +15,4 @@ */ export { UrlPatternDiscovery } from './UrlPatternDiscovery'; +export { FrontendHostDiscovery } from './FrontendHostDiscovery';