diff --git a/.changeset/itchy-grapes-think.md b/.changeset/itchy-grapes-think.md new file mode 100644 index 0000000000..c453b24922 --- /dev/null +++ b/.changeset/itchy-grapes-think.md @@ -0,0 +1,25 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Deprecated the `CatalogClientWrapper` class. + +The default implementation of `catalogApiRef` that this plugin exposes, is now powered by the new `fetchApiRef`. The default implementation of _that_ API, in turn, has the ability to inject the user's Backstage token in requests in a similar manner to what the deprecated `CatalogClientWrapper` used to do. The latter has therefore been taken out of the default catalog API implementation. + +If you use a custom `fetchApiRef` implementation that does NOT issue tokens, or use a custom `catalogApiRef` implementation which does NOT use the default `fetchApiRef`, you can still for some time wrap your catalog API in this class to get back the old behavior: + +```ts +// Add this to your packages/app/src/plugins.ts if you want to get back the old +// catalog client behavior: +createApiFactory({ + api: catalogApiRef, + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => + new CatalogClientWrapper({ + client: new CatalogClient({ discoveryApi }), + identityApi, + }), +}), +``` + +But do consider migrating to making use of the `fetchApiRef` as soon as convenient, since the wrapper class will be removed in a future release. diff --git a/packages/app-defaults/src/defaults/apis.ts b/packages/app-defaults/src/defaults/apis.ts index 06562caaef..29aa62bdab 100644 --- a/packages/app-defaults/src/defaults/apis.ts +++ b/packages/app-defaults/src/defaults/apis.ts @@ -34,9 +34,8 @@ import { OneLoginAuth, UnhandledErrorForwarder, AtlassianAuth, - FetchApiBuilder, - IdentityAwareFetchMiddleware, - BackstageProtocolResolverFetchMiddleware, + createFetchApi, + FetchMiddlewares, } from '@backstage/core-app-api'; import { @@ -99,20 +98,23 @@ export const apis = [ }), createApiFactory({ api: fetchApiRef, - deps: { identityApi: identityApiRef, discoveryApi: discoveryApiRef }, - factory: ({ identityApi, discoveryApi }) => { - return FetchApiBuilder.create() - .with( - new IdentityAwareFetchMiddleware(() => { - return identityApi.getCredentials().then(r => r.token); + deps: { + configApi: configApiRef, + identityApi: identityApiRef, + discoveryApi: discoveryApiRef, + }, + factory: ({ configApi, identityApi, discoveryApi }) => { + return createFetchApi({ + middleware: [ + FetchMiddlewares.resolvePluginProtocol({ + discoveryApi, }), - ) - .with( - new BackstageProtocolResolverFetchMiddleware(plugin => { - return discoveryApi.getBaseUrl(plugin); + FetchMiddlewares.injectIdentityAuth({ + identityApi, + config: configApi, }), - ) - .build(); + ], + }); }, }), createApiFactory({ diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index 41aea63716..495c98a104 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -5,7 +5,6 @@ ```ts import { Entity } from '@backstage/catalog-model'; import { EntityName } from '@backstage/catalog-model'; -import { default as fetch_2 } from 'cross-fetch'; import { Location as Location_2 } from '@backstage/catalog-model'; // @public @@ -159,6 +158,6 @@ export const ENTITY_STATUS_CATALOG_PROCESSING_TYPE = // @public export type FetchApi = { - fetch: typeof fetch_2; + fetch: typeof fetch; }; ``` diff --git a/packages/catalog-client/src/types/fetch.ts b/packages/catalog-client/src/types/fetch.ts index cdddd31810..59de6a68f1 100644 --- a/packages/catalog-client/src/types/fetch.ts +++ b/packages/catalog-client/src/types/fetch.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -import fetch from 'cross-fetch'; - /** * This is a copy of FetchApi, to avoid importing core-plugin-api. * diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 3277be7841..78bfd83251 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -24,9 +24,9 @@ import { BackstageIdentityApi } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { bitbucketAuthApiRef } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; +import { Config } from '@backstage/config'; import { ConfigReader } from '@backstage/config'; import { createApp as createApp_2 } from '@backstage/app-defaults'; -import crossFetch from 'cross-fetch'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { ErrorApi } from '@backstage/core-plugin-api'; import { ErrorApiError } from '@backstage/core-plugin-api'; @@ -290,14 +290,6 @@ export type BackstagePluginWithAnyOutput = Omit< )[]; }; -// @public -export class BackstageProtocolResolverFetchMiddleware - implements FetchMiddleware -{ - constructor(discovery: (pluginId: string) => Promise); - apply(next: FetchFunction): FetchFunction; -} - // @public export class BitbucketAuth { // (undocumented) @@ -328,6 +320,12 @@ export function createApp( options?: Parameters[0], ): BackstageApp & AppContext; +// @public +export function createFetchApi(options: { + baseImplementation?: typeof fetch | undefined; + middleware?: FetchMiddleware | FetchMiddleware[] | undefined; +}): FetchApi; + // @public export function createSpecializedApp(options: AppOptions): BackstageApp; @@ -380,21 +378,24 @@ export type FeatureFlaggedProps = { ); // @public -export class FetchApiBuilder { - // (undocumented) - build(): FetchApi; - // (undocumented) - static create(): FetchApiBuilder; - // (undocumented) - with(middleware: FetchMiddleware): FetchApiBuilder; +export interface FetchMiddleware { + apply(next: typeof fetch): typeof fetch; } // @public -export type FetchFunction = typeof crossFetch; - -// @public -export interface FetchMiddleware { - apply(next: FetchFunction): FetchFunction; +export class FetchMiddlewares { + static injectIdentityAuth(options: { + identityApi: IdentityApi; + config?: Config; + urlPrefixAllowlist?: string[]; + header?: { + name: string; + value: (backstageToken: string) => string; + }; + }): FetchMiddleware; + static resolvePluginProtocol(options: { + discoveryApi: DiscoveryApi; + }): FetchMiddleware; } // @public @@ -454,13 +455,6 @@ export class GoogleAuth { static create(options: OAuthApiCreateOptions): typeof googleAuthApiRef.T; } -// @public -export class IdentityAwareFetchMiddleware implements FetchMiddleware { - constructor(tokenFunction: () => Promise); - apply(next: FetchFunction): FetchFunction; - setHeaderName(name: string): IdentityAwareFetchMiddleware; -} - // @public export class LocalStorageFeatureFlags implements FeatureFlagsApi { // (undocumented) diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index d138417143..23079d7ba0 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -39,7 +39,6 @@ "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@types/prop-types": "^15.7.3", - "cross-fetch": "^3.0.6", "prop-types": "^15.7.2", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4", diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/FetchMiddlewares.ts b/packages/core-app-api/src/apis/implementations/FetchApi/FetchMiddlewares.ts new file mode 100644 index 0000000000..1cf09ff20d --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/FetchMiddlewares.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2021 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, IdentityApi } from '@backstage/core-plugin-api'; +import { IdentityAuthInjectorFetchMiddleware } from './IdentityAuthInjectorFetchMiddleware'; +import { PluginProtocolResolverFetchMiddleware } from './PluginProtocolResolverFetchMiddleware'; +import { FetchMiddleware } from './types'; + +/** + * A collection of common middlewares for the FetchApi. + * + * @public + */ +export class FetchMiddlewares { + /** + * Handles translation from `plugin://` URLs to concrete http(s) URLs based on + * the discovery API. + * + * @remarks + * + * If the request is for `plugin://catalog/entities?filter=x=y`, the discovery + * API will be queried for `'catalog'`. If it returned + * `https://backstage.example.net/api/catalog`, the resulting query would be + * `https://backstage.example.net/api/catalog/entities?filter=x=y`. + * + * If the incoming URL protocol was not `plugin`, the request is just passed + * through verbatim to the underlying implementation. + */ + static resolvePluginProtocol(options: { + discoveryApi: DiscoveryApi; + }): FetchMiddleware { + return new PluginProtocolResolverFetchMiddleware(options.discoveryApi); + } + + /** + * Injects a Backstage token header when the user is signed in. + * + * @remarks + * + * Per default, an `Authorization: Bearer ` is generated. This can be + * customized using the `header` option. + * + * The header injection only happens on allowlisted URLs. Per default, if the + * `config` option is passed in, the `backend.baseUrl` is allowlisted, unless + * the `urlPrefixAllowlist` option is passed in, in which case it takes + * precedence. If you pass in neither config nor an allowlist, the middleware + * will have no effect. + */ + static injectIdentityAuth(options: { + identityApi: IdentityApi; + config?: Config; + urlPrefixAllowlist?: string[]; + header?: { + name: string; + value: (backstageToken: string) => string; + }; + }): FetchMiddleware { + return IdentityAuthInjectorFetchMiddleware.create(options); + } + + private constructor() {} +} diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAuthInjectorFetchMiddleware.test.ts b/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAuthInjectorFetchMiddleware.test.ts new file mode 100644 index 0000000000..b69a4b9f7a --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAuthInjectorFetchMiddleware.test.ts @@ -0,0 +1,153 @@ +/* + * Copyright 2021 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 { IdentityApi } from '@backstage/core-plugin-api'; +import { IdentityAuthInjectorFetchMiddleware } from './IdentityAuthInjectorFetchMiddleware'; + +describe('IdentityAuthInjectorFetchMiddleware', () => { + it('creates using defaults', async () => { + const middleware = IdentityAuthInjectorFetchMiddleware.create({ + identityApi: undefined as any, + }); + expect(middleware.urlPrefixAllowlist).toEqual([]); + expect(middleware.headerName).toEqual('authorization'); + expect(middleware.headerValue('t')).toEqual('Bearer t'); + }); + + it('creates using config', async () => { + const middleware = IdentityAuthInjectorFetchMiddleware.create({ + identityApi: undefined as any, + config: new ConfigReader({ + backend: { baseUrl: 'https://example.com/api' }, + }), + header: { name: 'auth', value: t => `${t}!` }, + }); + expect(middleware.urlPrefixAllowlist).toEqual(['https://example.com/api']); + expect(middleware.headerName).toEqual('auth'); + expect(middleware.headerValue('t')).toEqual('t!'); + }); + + it('creates using explicit allowlist', async () => { + const middleware = IdentityAuthInjectorFetchMiddleware.create({ + identityApi: undefined as any, + config: new ConfigReader({ + backend: { baseUrl: 'https://example.com/api' }, + }), + urlPrefixAllowlist: ['https://a.com', 'http://b.com:8080/'], + }); + expect(middleware.urlPrefixAllowlist).toEqual([ + 'https://a.com', + 'http://b.com:8080', + ]); + }); + + it('injects the header only when a token is available', async () => { + const tokenFunction = jest.fn(); + const identityApi = { + getCredentials: tokenFunction, + } as unknown as IdentityApi; + + const middleware = new IdentityAuthInjectorFetchMiddleware( + identityApi, + ['https://example.com'], + 'Authorization', + token => `Bearer ${token}`, + ); + const inner = jest.fn(); + const outer = middleware.apply(inner); + + // No token available + tokenFunction.mockResolvedValueOnce({ token: undefined }); + await outer(new Request('https://example.com')); + expect([...inner.mock.calls[0][0].headers.entries()]).toEqual([]); + + // Supply a token, header gets added + tokenFunction.mockResolvedValueOnce({ token: 'token' }); + await outer(new Request('https://example.com')); + expect([...inner.mock.calls[1][0].headers.entries()]).toEqual([ + ['authorization', 'Bearer token'], + ]); + + // Token no longer available + tokenFunction.mockResolvedValueOnce({ token: undefined }); + await outer(new Request('https://example.com')); + expect([...inner.mock.calls[2][0].headers.entries()]).toEqual([]); + }); + + it('does not overwrite an existing header with the same name', async () => { + const identityApi = { + getCredentials: () => ({ token: 'token' }), + } as unknown as IdentityApi; + + const middleware = new IdentityAuthInjectorFetchMiddleware( + identityApi, + ['https://example.com'], + 'Authorization', + token => `Bearer ${token}`, + ); + const inner = jest.fn(); + const outer = middleware.apply(inner); + + // No token available + await outer(new Request('https://example.com')); + expect([...inner.mock.calls[0][0].headers.entries()]).toEqual([ + ['authorization', 'Bearer token'], + ]); + + // Supply a token, header gets added + await outer( + new Request('https://example.com', { + headers: { authorization: 'do-not-clobber' }, + }), + ); + expect([...inner.mock.calls[1][0].headers.entries()]).toEqual([ + ['authorization', 'do-not-clobber'], + ]); + }); + + it('does not affect requests outside the allowlist', async () => { + const identityApi = { + getCredentials: () => ({ token: 'token' }), + } as unknown as IdentityApi; + + const middleware = new IdentityAuthInjectorFetchMiddleware( + identityApi, + ['https://example.com:8080/root'], + 'Authorization', + token => `Bearer ${token}`, + ); + + const inner = jest.fn(); + const outer = middleware.apply(inner); + + await outer(new Request('https://example.com:8080/root')); + await outer(new Request('https://example.com:8080/root/sub')); + await outer(new Request('https://example.com:8080/root2')); + await outer(new Request('https://example.com/root')); + await outer(new Request('http://example.com:8080/root')); + await outer(new Request('https://example.com/root')); + + const no: string[][] = []; + const yes: string[][] = [['authorization', 'Bearer token']]; + expect([...inner.mock.calls[0][0].headers.entries()]).toEqual(yes); + expect([...inner.mock.calls[1][0].headers.entries()]).toEqual(yes); + expect([...inner.mock.calls[2][0].headers.entries()]).toEqual(no); + expect([...inner.mock.calls[3][0].headers.entries()]).toEqual(no); + expect([...inner.mock.calls[4][0].headers.entries()]).toEqual(no); + expect([...inner.mock.calls[5][0].headers.entries()]).toEqual(no); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAuthInjectorFetchMiddleware.ts b/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAuthInjectorFetchMiddleware.ts new file mode 100644 index 0000000000..46ada0a505 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAuthInjectorFetchMiddleware.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2021 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 { IdentityApi } from '@backstage/core-plugin-api'; +import { FetchMiddleware } from './types'; + +/** + * A fetch middleware, which injects a Backstage token header when the user is + * signed in. + */ +export class IdentityAuthInjectorFetchMiddleware implements FetchMiddleware { + static create(options: { + identityApi: IdentityApi; + config?: Config; + urlPrefixAllowlist?: string[]; + header?: { + name: string; + value: (backstageToken: string) => string; + }; + }): IdentityAuthInjectorFetchMiddleware { + const allowlist: string[] = []; + if (options.urlPrefixAllowlist) { + allowlist.push(...options.urlPrefixAllowlist); + } else if (options.config) { + allowlist.push(options.config.getString('backend.baseUrl')); + } + + const headerName = options.header?.name || 'authorization'; + const headerValue = options.header?.value || (token => `Bearer ${token}`); + + return new IdentityAuthInjectorFetchMiddleware( + options.identityApi, + allowlist.map(prefix => prefix.replace(/\/$/, '')), + headerName, + headerValue, + ); + } + + constructor( + public readonly identityApi: IdentityApi, + public readonly urlPrefixAllowlist: string[], + public readonly headerName: string, + public readonly headerValue: (pluginId: string) => string, + ) {} + + apply(next: typeof fetch): typeof fetch { + return async (input, init) => { + // Skip this middleware if the header already exists, or if the URL + // doesn't match any of the allowlist items, or if there was no token + const request = new Request(input, init); + const { token } = await this.identityApi.getCredentials(); + if ( + request.headers.get(this.headerName) || + !this.urlPrefixAllowlist.some( + prefix => + request.url === prefix || request.url.startsWith(`${prefix}/`), + ) || + typeof token !== 'string' || + !token + ) { + return next(input, init); + } + + request.headers.set(this.headerName, this.headerValue(token)); + return next(request); + }; + } +} diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAwareFetchMiddleware.test.ts b/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAwareFetchMiddleware.test.ts deleted file mode 100644 index 381ebd62a9..0000000000 --- a/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAwareFetchMiddleware.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2021 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 { IdentityAwareFetchMiddleware } from './IdentityAwareFetchMiddleware'; - -describe('IdentityAwareFetchMiddleware', () => { - it('injects the header only when a token is available', async () => { - const tokenFunction = jest.fn(); - const middleware = new IdentityAwareFetchMiddleware(tokenFunction); - const inner = jest.fn(); - const outer = middleware.apply(inner); - - // No token available - tokenFunction.mockResolvedValueOnce(undefined); - await outer(new Request('https://example.com')); - expect([...inner.mock.calls[0][0].headers.entries()]).toEqual([]); - - // Supply a token, header gets added - tokenFunction.mockResolvedValueOnce('token'); - await outer(new Request('https://example.com')); - expect([...inner.mock.calls[1][0].headers.entries()]).toEqual([ - ['backstage-token', 'token'], - ]); - - // Token no longer available - tokenFunction.mockResolvedValueOnce(undefined); - await outer(new Request('https://example.com')); - expect([...inner.mock.calls[2][0].headers.entries()]).toEqual([]); - }); -}); diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAwareFetchMiddleware.ts b/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAwareFetchMiddleware.ts deleted file mode 100644 index 474ca83894..0000000000 --- a/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAwareFetchMiddleware.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2021 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 { FetchFunction, FetchMiddleware } from './types'; - -const DEFAULT_HEADER_NAME = 'backstage-token'; - -/** - * A fetch middleware, which injects a Backstage token header when the user is - * signed in. - * - * @public - */ -export class IdentityAwareFetchMiddleware implements FetchMiddleware { - private headerName: string; - private tokenFunction: () => Promise; - - constructor(tokenFunction: () => Promise) { - this.headerName = DEFAULT_HEADER_NAME; - this.tokenFunction = tokenFunction; - } - - /** - * {@inheritdoc FetchMiddleware.apply} - */ - apply(next: FetchFunction): FetchFunction { - return async (input, init) => { - const token = await this.tokenFunction(); - if (typeof token !== 'string') { - return next(input, init); - } - - const request = new Request(input, init); - request.headers.set(this.headerName, token); - return next(request); - }; - } - - /** - * Changes the header name from the default value to a custom one. - */ - setHeaderName(name: string): IdentityAwareFetchMiddleware { - this.headerName = name; - return this; - } -} diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/BackstageProtocolResolverFetchMiddleware.test.ts b/packages/core-app-api/src/apis/implementations/FetchApi/PluginProtocolResolverFetchMiddleware.test.ts similarity index 69% rename from packages/core-app-api/src/apis/implementations/FetchApi/BackstageProtocolResolverFetchMiddleware.test.ts rename to packages/core-app-api/src/apis/implementations/FetchApi/PluginProtocolResolverFetchMiddleware.test.ts index 7dcb90573b..2f4ec6b536 100644 --- a/packages/core-app-api/src/apis/implementations/FetchApi/BackstageProtocolResolverFetchMiddleware.test.ts +++ b/packages/core-app-api/src/apis/implementations/FetchApi/PluginProtocolResolverFetchMiddleware.test.ts @@ -14,14 +14,18 @@ * limitations under the License. */ -import { BackstageProtocolResolverFetchMiddleware } from './BackstageProtocolResolverFetchMiddleware'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { PluginProtocolResolverFetchMiddleware } from './PluginProtocolResolverFetchMiddleware'; -describe('BackstageProtocolResolverFetchMiddleware', () => { +describe('PluginProtocolResolverFetchMiddleware', () => { it.each([['https://passthrough.com/a']])( 'passes through regular URLs, %p', - async (url: string) => { + async url => { const resolve = jest.fn(); - const middleware = new BackstageProtocolResolverFetchMiddleware(resolve); + const discoveryApi = { getBaseUrl: resolve } as unknown as DiscoveryApi; + const middleware = new PluginProtocolResolverFetchMiddleware( + discoveryApi, + ); const inner = jest.fn(); const outer = middleware.apply(inner); @@ -33,38 +37,38 @@ describe('BackstageProtocolResolverFetchMiddleware', () => { it.each([ [ - 'backstage://my-plugin/sub/path', + 'plugin://my-plugin/sub/path', 'my-plugin', 'https://real.com/base', 'https://real.com/base/sub/path', ], [ - 'backstage://my-plugin/sub/path/', + 'plugin://my-plugin/sub/path/', 'my-plugin', 'https://real.com/base/', 'https://real.com/base/sub/path/', ], - ['backstage://x', 'x', 'http://real.com:8080', 'http://real.com:8080'], + ['plugin://x', 'x', 'http://real.com:8080', 'http://real.com:8080'], [ - 'backstage://x/a/b?c=d&e=f#g', + 'plugin://x/a/b?c=d&e=f#g', 'x', 'https://real.com/base', 'https://real.com/base/a/b?c=d&e=f#g', ], [ - 'backstage://x?c=d&e=f#g', + 'plugin://x?c=d&e=f#g', 'x', 'https://real.com:8080/base', 'https://real.com:8080/base?c=d&e=f#g', ], [ - 'backstage://username:password@x?c=d&e=f#g', + 'plugin://username:password@x?c=d&e=f#g', 'x', 'https://real.com:8080/base', 'https://username:password@real.com:8080/base?c=d&e=f#g', ], [ - 'backstage://x?c=d&e=f#g', + 'plugin://x?c=d&e=f#g', 'x', 'https://username:password@real.com:8080/base', 'https://username:password@real.com:8080/base?c=d&e=f#g', @@ -73,7 +77,10 @@ describe('BackstageProtocolResolverFetchMiddleware', () => { 'resolves backstage URLs, %p', async (original, host, resolved, result) => { const resolve = jest.fn(); - const middleware = new BackstageProtocolResolverFetchMiddleware(resolve); + const discoveryApi = { getBaseUrl: resolve } as unknown as DiscoveryApi; + const middleware = new PluginProtocolResolverFetchMiddleware( + discoveryApi, + ); const inner = jest.fn(); const outer = middleware.apply(inner); diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/BackstageProtocolResolverFetchMiddleware.ts b/packages/core-app-api/src/apis/implementations/FetchApi/PluginProtocolResolverFetchMiddleware.ts similarity index 75% rename from packages/core-app-api/src/apis/implementations/FetchApi/BackstageProtocolResolverFetchMiddleware.ts rename to packages/core-app-api/src/apis/implementations/FetchApi/PluginProtocolResolverFetchMiddleware.ts index 2144d0058d..72834731fe 100644 --- a/packages/core-app-api/src/apis/implementations/FetchApi/BackstageProtocolResolverFetchMiddleware.ts +++ b/packages/core-app-api/src/apis/implementations/FetchApi/PluginProtocolResolverFetchMiddleware.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -import { FetchFunction, FetchMiddleware } from './types'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { FetchMiddleware } from './types'; function join(left: string, right: string): string { if (!right || right === '/') { @@ -25,25 +26,16 @@ function join(left: string, right: string): string { } /** - * Handles translation from backstage://some-plugin-id/ to concrete - * http(s) URLs. - * - * @public + * Handles translation from plugin://some-plugin-id/ to concrete http(s) + * URLs. */ -export class BackstageProtocolResolverFetchMiddleware - implements FetchMiddleware -{ - constructor( - private readonly discovery: (pluginId: string) => Promise, - ) {} +export class PluginProtocolResolverFetchMiddleware implements FetchMiddleware { + constructor(private readonly discoveryApi: DiscoveryApi) {} - /** - * {@inheritdoc FetchMiddleware.apply} - */ - apply(next: FetchFunction): FetchFunction { + apply(next: typeof fetch): typeof fetch { return async (input, init) => { const request = new Request(input, init); - const prefix = 'backstage://'; + const prefix = 'plugin://'; if (!request.url.startsWith(prefix)) { return next(input, init); @@ -55,7 +47,7 @@ export class BackstageProtocolResolverFetchMiddleware `http://${request.url.substring(prefix.length)}`, ); - let base = await this.discovery(hostname); + let base = await this.discoveryApi.getBaseUrl(hostname); if (username || password) { const baseUrl = new URL(base); const authority = `${username}${password ? `:${password}` : ''}@`; diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/FetchApiBuilder.ts b/packages/core-app-api/src/apis/implementations/FetchApi/createFetchApi.ts similarity index 54% rename from packages/core-app-api/src/apis/implementations/FetchApi/FetchApiBuilder.ts rename to packages/core-app-api/src/apis/implementations/FetchApi/createFetchApi.ts index 1c96eb1ad6..1b85695daa 100644 --- a/packages/core-app-api/src/apis/implementations/FetchApi/FetchApiBuilder.ts +++ b/packages/core-app-api/src/apis/implementations/FetchApi/createFetchApi.ts @@ -15,41 +15,32 @@ */ import { FetchApi } from '@backstage/core-plugin-api'; -import crossFetch from 'cross-fetch'; -import { FetchFunction, FetchMiddleware } from './types'; +import { FetchMiddleware } from './types'; /** * Builds a fetch API, based on the builtin fetch wrapped by a set of optional * middleware implementations that add behaviors. * + * @remarks + * + * The middleware are applied in reverse order, i.e. the last one will be + * "closest" to the base implementation. Passing in `[M1, M2, M3]` effectively + * leads to `M1(M2(M3(baseImplementation)))`. + * * @public */ -export class FetchApiBuilder { - static create(): FetchApiBuilder { - return new FetchApiBuilder(crossFetch, []); +export function createFetchApi(options: { + baseImplementation?: typeof fetch | undefined; + middleware?: FetchMiddleware | FetchMiddleware[] | undefined; +}): FetchApi { + let result = options.baseImplementation || global.fetch; + + const middleware = [options.middleware ?? []].flat().reverse(); + for (const m of middleware) { + result = m.apply(result); } - private constructor( - private readonly implementation: FetchFunction, - private readonly middleware: FetchMiddleware[], - ) {} - - with(middleware: FetchMiddleware): FetchApiBuilder { - return new FetchApiBuilder(this.implementation, [ - ...this.middleware, - middleware, - ]); - } - - build(): FetchApi { - let result = this.implementation; - - for (const m of this.middleware) { - result = m.apply(result); - } - - return { - fetch: result, - }; - } + return { + fetch: result, + }; } diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/index.ts b/packages/core-app-api/src/apis/implementations/FetchApi/index.ts index 7544bd8bf2..0f82a9020b 100644 --- a/packages/core-app-api/src/apis/implementations/FetchApi/index.ts +++ b/packages/core-app-api/src/apis/implementations/FetchApi/index.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -export { BackstageProtocolResolverFetchMiddleware } from './BackstageProtocolResolverFetchMiddleware'; -export { FetchApiBuilder } from './FetchApiBuilder'; -export { IdentityAwareFetchMiddleware } from './IdentityAwareFetchMiddleware'; -export type { FetchFunction, FetchMiddleware } from './types'; +export { createFetchApi } from './createFetchApi'; +export { FetchMiddlewares } from './FetchMiddlewares'; +export type { FetchMiddleware } from './types'; diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/types.ts b/packages/core-app-api/src/apis/implementations/FetchApi/types.ts index 4cf0bf47f4..5bf029cbf0 100644 --- a/packages/core-app-api/src/apis/implementations/FetchApi/types.ts +++ b/packages/core-app-api/src/apis/implementations/FetchApi/types.ts @@ -14,15 +14,6 @@ * limitations under the License. */ -import crossFetch from 'cross-fetch'; - -/** - * The type of a fetch call. - * - * @public - */ -export type FetchFunction = typeof crossFetch; - /** * A middleware that modifies the behavior of an ongoing fetch. * @@ -35,5 +26,5 @@ export interface FetchMiddleware { * @param next - The next, inner, implementation, that this middleware shall * call out to as part of the request cycle. */ - apply(next: FetchFunction): FetchFunction; + apply(next: typeof fetch): typeof fetch; } diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 148115fd69..67c10837ac 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -9,7 +9,6 @@ import { BackstagePlugin as BackstagePlugin_2 } from '@backstage/core-plugin-api import { BackstageTheme } from '@backstage/theme'; import { ComponentType } from 'react'; import { Config } from '@backstage/config'; -import { default as fetch_2 } from 'cross-fetch'; import { IconComponent as IconComponent_2 } from '@backstage/core-plugin-api'; import { IdentityApi as IdentityApi_2 } from '@backstage/core-plugin-api'; import { Observable as Observable_2 } from '@backstage/types'; @@ -508,7 +507,7 @@ export enum FeatureFlagState { // @public export type FetchApi = { - fetch: typeof fetch_2; + fetch: typeof fetch; }; // @public diff --git a/packages/core-plugin-api/package.json b/packages/core-plugin-api/package.json index 365cec3134..72827bde73 100644 --- a/packages/core-plugin-api/package.json +++ b/packages/core-plugin-api/package.json @@ -34,7 +34,6 @@ "@backstage/types": "^0.1.1", "@backstage/version-bridge": "^0.1.1", "@material-ui/core": "^4.12.2", - "cross-fetch": "^3.0.6", "history": "^5.0.0", "prop-types": "^15.7.2", "react-router-dom": "6.0.0-beta.0", @@ -57,6 +56,7 @@ "@types/node": "^14.14.32", "@types/prop-types": "^15.7.3", "@types/zen-observable": "^0.8.0", + "cross-fetch": "^3.0.6", "msw": "^0.35.0" }, "files": [ diff --git a/packages/core-plugin-api/src/apis/definitions/FetchApi.ts b/packages/core-plugin-api/src/apis/definitions/FetchApi.ts index 30eb950634..ba304157ea 100644 --- a/packages/core-plugin-api/src/apis/definitions/FetchApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/FetchApi.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import fetch from 'cross-fetch'; import { ApiRef, createApiRef } from '../system'; /** diff --git a/plugins/catalog/api-report.md b/plugins/catalog/api-report.md index 2be7fc8575..80271c89af 100644 --- a/plugins/catalog/api-report.md +++ b/plugins/catalog/api-report.md @@ -66,7 +66,7 @@ export type BackstageOverrides = Overrides & { // Warning: (ae-missing-release-tag) "CatalogClientWrapper" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public +// @public @deprecated export class CatalogClientWrapper implements CatalogApi { constructor(options: { client: CatalogClient; identityApi: IdentityApi }); // (undocumented) diff --git a/plugins/catalog/src/CatalogClientWrapper.ts b/plugins/catalog/src/CatalogClientWrapper.ts index 0463cbf30e..c5f072e96c 100644 --- a/plugins/catalog/src/CatalogClientWrapper.ts +++ b/plugins/catalog/src/CatalogClientWrapper.ts @@ -30,6 +30,13 @@ import { IdentityApi } from '@backstage/core-plugin-api'; /** * CatalogClient wrapper that injects identity token for all requests + * + * @deprecated The default catalog client now uses the `fetchApiRef` + * implementation, which in turn by default issues tokens just the same as this + * class used to assist in doing. If you use a custom `fetchApiRef` + * implementation that does NOT issue tokens, or use a custom `catalogApiRef` + * implementation which does not use the default `fetchApiRef`, you can wrap + * your catalog API in this class to get back the old behavior. */ export class CatalogClientWrapper implements CatalogApi { private readonly identityApi: IdentityApi; diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index 20f937b2e7..66fdf4638f 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -22,7 +22,6 @@ import { entityRouteRef, starredEntitiesApiRef, } from '@backstage/plugin-catalog-react'; -import { CatalogClientWrapper } from './CatalogClientWrapper'; import { createComponentRouteRef, viewTechDocRouteRef } from './routes'; import { createApiFactory, @@ -30,7 +29,7 @@ import { createPlugin, createRoutableExtension, discoveryApiRef, - identityApiRef, + fetchApiRef, storageApiRef, } from '@backstage/core-plugin-api'; @@ -39,14 +38,13 @@ export const catalogPlugin = createPlugin({ apis: [ createApiFactory({ api: catalogApiRef, - deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, - factory: ({ discoveryApi, identityApi }) => - new CatalogClientWrapper({ - client: new CatalogClient({ discoveryApi }), - identityApi, - }), + deps: { + discoveryApi: discoveryApiRef, + fetchApi: fetchApiRef, + }, + factory: ({ discoveryApi, fetchApi }) => + new CatalogClient({ discoveryApi, fetchApi }), }), - createApiFactory({ api: starredEntitiesApiRef, deps: { storageApi: storageApiRef },