app-defaults: use FrontendHostDiscovery by default

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-03-11 15:27:45 +01:00
parent ae33d1635a
commit 12f8e0170a
9 changed files with 111 additions and 17 deletions
+32
View File
@@ -0,0 +1,32 @@
---
'@backstage/app-defaults': minor
---
**BREAKING**: The default `DiscoveryApi` implementation has been switched to use `FrontendHostDiscovery`, which adds support for the `discovery.endpoints` configuration.
This is marked as a breaking change because it will cause any existing `discovery.endpoints` configuration to be picked up and used, which may break existing setups.
For example, consider the following configuration:
```yaml
app:
baseUrl: https://backstage.acme.org
backend:
baseUrl: https://backstage.internal.acme.org
discovery:
endpoints:
- target: https://catalog.internal.acme.org/api/{{pluginId}}
plugins: [catalog]
```
This will now cause requests from the frontend towards the `catalog` plugin to be routed to `https://internal-catalog.acme.org/api/catalog`, but this might not be reachable from the frontend. To fix this, you should update the `discovery.endpoints` configuration to only override the internal URL of the plugin:
```yaml
discovery:
endpoints:
- target:
internal: https://catalog.internal.acme.org/api/{{pluginId}}
plugins: [catalog]
```
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/core-app-api': minor
'@backstage/backend-defaults': patch
---
The `discovery.endpoints` configuration no longer requires both `internal` and `external` target when using the object form, instead falling back to the default.
+2 -5
View File
@@ -28,13 +28,13 @@ import {
BitbucketServerAuth,
OAuthRequestManager,
WebStorage,
UrlPatternDiscovery,
OneLoginAuth,
UnhandledErrorForwarder,
AtlassianAuth,
createFetchApi,
FetchMiddlewares,
VMwareCloudAuth,
FrontendHostDiscovery,
} from '@backstage/core-app-api';
import {
@@ -68,10 +68,7 @@ export const apis = [
createApiFactory({
api: discoveryApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) =>
UrlPatternDiscovery.compile(
`${configApi.getString('backend.baseUrl')}/api/{{ pluginId }}`,
),
factory: ({ configApi }) => FrontendHostDiscovery.fromConfig(configApi),
}),
createApiFactory({
api: alertApiRef,
+1 -1
View File
@@ -652,7 +652,7 @@ export interface Config {
* 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 plugin ID.
*/
target: string | { internal: string; external: string };
target: string | { internal?: string; external?: string };
/**
* Array of plugins which use the target base URL.
*/
@@ -162,6 +162,42 @@ describe('HostDiscovery', () => {
);
});
it('allows plugin overrides to only override either internal or external targets', async () => {
const discovery = HostDiscovery.fromConfig(
new ConfigReader({
backend: {
baseUrl: 'http://localhost:40',
listen: { port: 80, host: 'localhost' },
},
discovery: {
endpoints: [
{
target: { internal: 'http://catalog-backend:8080/api/catalog' },
plugins: ['catalog'],
},
{
target: { external: 'http://frontend/api/scaffolder' },
plugins: ['scaffolder'],
},
],
},
}),
);
await expect(discovery.getBaseUrl('catalog')).resolves.toBe(
'http://catalog-backend:8080/api/catalog',
);
await expect(discovery.getExternalBaseUrl('catalog')).resolves.toBe(
'http://localhost:40/api/catalog',
);
await expect(discovery.getBaseUrl('scaffolder')).resolves.toBe(
'http://localhost:80/api/scaffolder',
);
await expect(discovery.getExternalBaseUrl('scaffolder')).resolves.toBe(
'http://frontend/api/scaffolder',
);
});
it('replaces {{pluginId}} or {{ pluginId }} in the target', async () => {
const discovery = HostDiscovery.fromConfig(
new ConfigReader({
@@ -102,10 +102,13 @@ export class HostDiscovery implements DiscoveryService {
private getTargetFromConfig(pluginId: string, type: 'internal' | 'external') {
const endpoints = this.discoveryConfig?.getOptionalConfigArray('endpoints');
const target = endpoints
const targetOrObj = endpoints
?.find(endpoint => endpoint.getStringArray('plugins').includes(pluginId))
?.get<Target>('target');
const target =
typeof targetOrObj === 'string' ? targetOrObj : targetOrObj?.[type];
if (!target) {
const baseUrl =
type === 'external' ? this.externalBaseUrl : this.internalBaseUrl;
@@ -113,14 +116,7 @@ export class HostDiscovery implements DiscoveryService {
return `${baseUrl}/${encodeURIComponent(pluginId)}`;
}
if (typeof target === 'string') {
return target.replace(
/\{\{\s*pluginId\s*\}\}/g,
encodeURIComponent(pluginId),
);
}
return target[type].replace(
return target.replace(
/\{\{\s*pluginId\s*\}\}/g,
encodeURIComponent(pluginId),
);
+1 -1
View File
@@ -163,7 +163,7 @@ export interface Config {
/**
* @visibility frontend
*/
external: string;
external?: string;
};
/**
* Array of plugins which use the target baseUrl.
@@ -72,6 +72,30 @@ describe('FrontendHostDiscovery', () => {
);
});
it('should not use internal plugin overrides', async () => {
const discovery = FrontendHostDiscovery.fromConfig(
new ConfigReader({
backend: {
baseUrl: 'http://localhost:40',
},
discovery: {
endpoints: [
{
target: {
internal: 'http://catalog-backend-internal:8080/api/catalog',
},
plugins: ['catalog'],
},
],
},
}),
);
await expect(discovery.getBaseUrl('catalog')).resolves.toBe(
'http://localhost:40/api/catalog',
);
});
it('uses a single target for internal and external for a plugin', async () => {
const discovery = FrontendHostDiscovery.fromConfig(
new ConfigReader({
@@ -54,8 +54,11 @@ export class FrontendHostDiscovery implements DiscoveryApi {
?.flatMap(e => {
const target =
typeof e.get('target') === 'object'
? e.getString('target.external')
? e.getOptionalString('target.external')
: e.getString('target');
if (!target) {
return [];
}
const discovery = UrlPatternDiscovery.compile(target);
return e
.getStringArray('plugins')