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';