From 7927005152203ac15b48645a9ca84536fe20ad58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 7 Jul 2021 17:10:36 +0200 Subject: [PATCH] Add `fetchApiRef` which implements fetch, plus Backstage token header when available. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This intends to be the basis for other plugins' data fetching needs, so that they can transparently interact with the catalog and other parts of the Backstage ecosystem without explicitly having to deal with authenticating themselves. Signed-off-by: Fredrik Adelöw --- .changeset/blue-queens-sniff.md | 7 ++ .changeset/lovely-drinks-kiss.md | 5 ++ packages/app-defaults/src/defaults/apis.ts | 23 +++++ packages/catalog-client/api-report.md | 8 +- packages/catalog-client/src/CatalogClient.ts | 17 ++-- .../catalog-client/src/types/discovery.ts | 2 +- packages/catalog-client/src/types/fetch.ts | 26 ++++++ packages/catalog-client/src/types/index.ts | 1 + packages/core-app-api/api-report.md | 35 ++++++++ packages/core-app-api/package.json | 1 + ...ageProtocolResolverFetchMiddleware.test.ts | 86 +++++++++++++++++++ ...ackstageProtocolResolverFetchMiddleware.ts | 64 ++++++++++++++ .../FetchApi/FetchApiBuilder.ts | 55 ++++++++++++ .../IdentityAwareFetchMiddleware.test.ts | 43 ++++++++++ .../FetchApi/IdentityAwareFetchMiddleware.ts | 59 +++++++++++++ .../apis/implementations/FetchApi/index.ts | 20 +++++ .../apis/implementations/FetchApi/types.ts | 39 +++++++++ .../src/apis/implementations/index.ts | 1 + packages/core-plugin-api/api-report.md | 9 ++ packages/core-plugin-api/package.json | 2 +- .../src/apis/definitions/FetchApi.ts | 38 ++++++++ .../src/apis/definitions/index.ts | 1 + 22 files changed, 532 insertions(+), 10 deletions(-) create mode 100644 .changeset/blue-queens-sniff.md create mode 100644 .changeset/lovely-drinks-kiss.md create mode 100644 packages/catalog-client/src/types/fetch.ts create mode 100644 packages/core-app-api/src/apis/implementations/FetchApi/BackstageProtocolResolverFetchMiddleware.test.ts create mode 100644 packages/core-app-api/src/apis/implementations/FetchApi/BackstageProtocolResolverFetchMiddleware.ts create mode 100644 packages/core-app-api/src/apis/implementations/FetchApi/FetchApiBuilder.ts create mode 100644 packages/core-app-api/src/apis/implementations/FetchApi/IdentityAwareFetchMiddleware.test.ts create mode 100644 packages/core-app-api/src/apis/implementations/FetchApi/IdentityAwareFetchMiddleware.ts create mode 100644 packages/core-app-api/src/apis/implementations/FetchApi/index.ts create mode 100644 packages/core-app-api/src/apis/implementations/FetchApi/types.ts create mode 100644 packages/core-plugin-api/src/apis/definitions/FetchApi.ts diff --git a/.changeset/blue-queens-sniff.md b/.changeset/blue-queens-sniff.md new file mode 100644 index 0000000000..9a26507f92 --- /dev/null +++ b/.changeset/blue-queens-sniff.md @@ -0,0 +1,7 @@ +--- +'@backstage/app-defaults': patch +'@backstage/core-app-api': patch +'@backstage/core-plugin-api': patch +--- + +Add `FetchApi` and related `fetchApiRef` which implement fetch, with an added Backstage token header when available. diff --git a/.changeset/lovely-drinks-kiss.md b/.changeset/lovely-drinks-kiss.md new file mode 100644 index 0000000000..f343888920 --- /dev/null +++ b/.changeset/lovely-drinks-kiss.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': patch +--- + +Add the ability to supply a custom `fetchApi`. In the default frontend app setup, this will use the `fetchApiRef` implementation. diff --git a/packages/app-defaults/src/defaults/apis.ts b/packages/app-defaults/src/defaults/apis.ts index d3c76d52bd..06562caaef 100644 --- a/packages/app-defaults/src/defaults/apis.ts +++ b/packages/app-defaults/src/defaults/apis.ts @@ -34,6 +34,9 @@ import { OneLoginAuth, UnhandledErrorForwarder, AtlassianAuth, + FetchApiBuilder, + IdentityAwareFetchMiddleware, + BackstageProtocolResolverFetchMiddleware, } from '@backstage/core-app-api'; import { @@ -42,6 +45,8 @@ import { analyticsApiRef, errorApiRef, discoveryApiRef, + fetchApiRef, + identityApiRef, oauthRequestApiRef, googleAuthApiRef, githubAuthApiRef, @@ -92,6 +97,24 @@ export const apis = [ deps: { errorApi: errorApiRef }, factory: ({ errorApi }) => WebStorage.create({ errorApi }), }), + createApiFactory({ + api: fetchApiRef, + deps: { identityApi: identityApiRef, discoveryApi: discoveryApiRef }, + factory: ({ identityApi, discoveryApi }) => { + return FetchApiBuilder.create() + .with( + new IdentityAwareFetchMiddleware(() => { + return identityApi.getCredentials().then(r => r.token); + }), + ) + .with( + new BackstageProtocolResolverFetchMiddleware(plugin => { + return discoveryApi.getBaseUrl(plugin); + }), + ) + .build(); + }, + }), createApiFactory({ api: oauthRequestApiRef, deps: {}, diff --git a/packages/catalog-client/api-report.md b/packages/catalog-client/api-report.md index e16ca740f6..41aea63716 100644 --- a/packages/catalog-client/api-report.md +++ b/packages/catalog-client/api-report.md @@ -5,6 +5,7 @@ ```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 @@ -71,7 +72,7 @@ export interface CatalogApi { // @public export class CatalogClient implements CatalogApi { - constructor(options: { discoveryApi: DiscoveryApi }); + constructor(options: { discoveryApi: DiscoveryApi; fetchApi?: FetchApi }); addLocation( { type, target, dryRun, presence }: AddLocationRequest, options?: CatalogRequestOptions, @@ -155,4 +156,9 @@ export type DiscoveryApi = { // @public export const ENTITY_STATUS_CATALOG_PROCESSING_TYPE = 'backstage.io/catalog-processing'; + +// @public +export type FetchApi = { + fetch: typeof fetch_2; +}; ``` diff --git a/packages/catalog-client/src/CatalogClient.ts b/packages/catalog-client/src/CatalogClient.ts index 29d01fbe5c..047a8c7cce 100644 --- a/packages/catalog-client/src/CatalogClient.ts +++ b/packages/catalog-client/src/CatalogClient.ts @@ -25,7 +25,7 @@ import { stringifyLocationReference, } from '@backstage/catalog-model'; import { ResponseError } from '@backstage/errors'; -import fetch from 'cross-fetch'; +import crossFetch from 'cross-fetch'; import { CATALOG_FILTER_EXISTS, AddLocationRequest, @@ -38,6 +38,7 @@ import { CatalogEntityAncestorsResponse, } from './types/api'; import { DiscoveryApi } from './types/discovery'; +import { FetchApi } from './types/fetch'; /** * A frontend and backend compatible client for communicating with the Backstage Catalog. @@ -46,9 +47,11 @@ import { DiscoveryApi } from './types/discovery'; * */ export class CatalogClient implements CatalogApi { private readonly discoveryApi: DiscoveryApi; + private readonly fetchApi: FetchApi; - constructor(options: { discoveryApi: DiscoveryApi }) { + constructor(options: { discoveryApi: DiscoveryApi; fetchApi?: FetchApi }) { this.discoveryApi = options.discoveryApi; + this.fetchApi = options.fetchApi || { fetch: crossFetch }; } /** @@ -206,7 +209,7 @@ export class CatalogClient implements CatalogApi { * @public */ async refreshEntity(entityRef: string, options?: CatalogRequestOptions) { - const response = await fetch( + const response = await this.fetchApi.fetch( `${await this.discoveryApi.getBaseUrl('catalog')}/refresh`, { headers: { @@ -237,7 +240,7 @@ export class CatalogClient implements CatalogApi { { type = 'url', target, dryRun, presence }: AddLocationRequest, options?: CatalogRequestOptions, ): Promise { - const response = await fetch( + const response = await this.fetchApi.fetch( `${await this.discoveryApi.getBaseUrl('catalog')}/locations${ dryRun ? '?dryRun=true' : '' }`, @@ -376,7 +379,7 @@ export class CatalogClient implements CatalogApi { const headers: Record = options?.token ? { Authorization: `Bearer ${options.token}` } : {}; - const response = await fetch(url, { method, headers }); + const response = await this.fetchApi.fetch(url, { method, headers }); if (!response.ok) { throw await ResponseError.fromResponse(response); @@ -392,7 +395,7 @@ export class CatalogClient implements CatalogApi { const headers: Record = options?.token ? { Authorization: `Bearer ${options.token}` } : {}; - const response = await fetch(url, { method, headers }); + const response = await this.fetchApi.fetch(url, { method, headers }); if (!response.ok) { throw await ResponseError.fromResponse(response); @@ -410,7 +413,7 @@ export class CatalogClient implements CatalogApi { const headers: Record = options?.token ? { Authorization: `Bearer ${options.token}` } : {}; - const response = await fetch(url, { method, headers }); + const response = await this.fetchApi.fetch(url, { method, headers }); if (!response.ok) { if (response.status === 404) { diff --git a/packages/catalog-client/src/types/discovery.ts b/packages/catalog-client/src/types/discovery.ts index 19ee5ed19c..5fa14372e3 100644 --- a/packages/catalog-client/src/types/discovery.ts +++ b/packages/catalog-client/src/types/discovery.ts @@ -15,7 +15,7 @@ */ /** - * This is a copy of the core DiscoveryApi, to avoid importing core. + * This is a copy of the DiscoveryApi, to avoid importing core-plugin-api. * * @public */ diff --git a/packages/catalog-client/src/types/fetch.ts b/packages/catalog-client/src/types/fetch.ts new file mode 100644 index 0000000000..cdddd31810 --- /dev/null +++ b/packages/catalog-client/src/types/fetch.ts @@ -0,0 +1,26 @@ +/* + * 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 fetch from 'cross-fetch'; + +/** + * This is a copy of FetchApi, to avoid importing core-plugin-api. + * + * @public + */ +export type FetchApi = { + fetch: typeof fetch; +}; diff --git a/packages/catalog-client/src/types/index.ts b/packages/catalog-client/src/types/index.ts index 3552a72e3c..f3c6fab4e9 100644 --- a/packages/catalog-client/src/types/index.ts +++ b/packages/catalog-client/src/types/index.ts @@ -25,5 +25,6 @@ export type { CatalogEntityAncestorsResponse, } from './api'; export type { DiscoveryApi } from './discovery'; +export type { FetchApi } from './fetch'; export { CATALOG_FILTER_EXISTS } from './api'; export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status'; diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 8a3f0edf14..3277be7841 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -26,6 +26,7 @@ import { bitbucketAuthApiRef } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; 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'; @@ -34,6 +35,7 @@ import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { FeatureFlag } from '@backstage/core-plugin-api'; import { FeatureFlagsApi } from '@backstage/core-plugin-api'; import { FeatureFlagsSaveOptions } from '@backstage/core-plugin-api'; +import { FetchApi } from '@backstage/core-plugin-api'; import { gitlabAuthApiRef } from '@backstage/core-plugin-api'; import { googleAuthApiRef } from '@backstage/core-plugin-api'; import { IconComponent } from '@backstage/core-plugin-api'; @@ -288,6 +290,14 @@ export type BackstagePluginWithAnyOutput = Omit< )[]; }; +// @public +export class BackstageProtocolResolverFetchMiddleware + implements FetchMiddleware +{ + constructor(discovery: (pluginId: string) => Promise); + apply(next: FetchFunction): FetchFunction; +} + // @public export class BitbucketAuth { // (undocumented) @@ -369,6 +379,24 @@ export type FeatureFlaggedProps = { } ); +// @public +export class FetchApiBuilder { + // (undocumented) + build(): FetchApi; + // (undocumented) + static create(): FetchApiBuilder; + // (undocumented) + with(middleware: FetchMiddleware): FetchApiBuilder; +} + +// @public +export type FetchFunction = typeof crossFetch; + +// @public +export interface FetchMiddleware { + apply(next: FetchFunction): FetchFunction; +} + // @public export const FlatRoutes: (props: FlatRoutesProps) => JSX.Element | null; @@ -426,6 +454,13 @@ 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 23079d7ba0..d138417143 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -39,6 +39,7 @@ "@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/BackstageProtocolResolverFetchMiddleware.test.ts b/packages/core-app-api/src/apis/implementations/FetchApi/BackstageProtocolResolverFetchMiddleware.test.ts new file mode 100644 index 0000000000..7dcb90573b --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/BackstageProtocolResolverFetchMiddleware.test.ts @@ -0,0 +1,86 @@ +/* + * 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 { BackstageProtocolResolverFetchMiddleware } from './BackstageProtocolResolverFetchMiddleware'; + +describe('BackstageProtocolResolverFetchMiddleware', () => { + it.each([['https://passthrough.com/a']])( + 'passes through regular URLs, %p', + async (url: string) => { + const resolve = jest.fn(); + const middleware = new BackstageProtocolResolverFetchMiddleware(resolve); + const inner = jest.fn(); + const outer = middleware.apply(inner); + + await outer(url); + expect(inner.mock.calls[0][0]).toBe(url); + expect(resolve).not.toBeCalled(); + }, + ); + + it.each([ + [ + 'backstage://my-plugin/sub/path', + 'my-plugin', + 'https://real.com/base', + 'https://real.com/base/sub/path', + ], + [ + 'backstage://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'], + [ + 'backstage://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', + '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', + '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', + 'x', + 'https://username:password@real.com:8080/base', + 'https://username:password@real.com:8080/base?c=d&e=f#g', + ], + ])( + 'resolves backstage URLs, %p', + async (original, host, resolved, result) => { + const resolve = jest.fn(); + const middleware = new BackstageProtocolResolverFetchMiddleware(resolve); + const inner = jest.fn(); + const outer = middleware.apply(inner); + + resolve.mockResolvedValueOnce(resolved); + await outer(original); + expect(inner.mock.calls[0][0]).toBe(result); + expect(resolve).toHaveBeenLastCalledWith(host); + }, + ); +}); diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/BackstageProtocolResolverFetchMiddleware.ts b/packages/core-app-api/src/apis/implementations/FetchApi/BackstageProtocolResolverFetchMiddleware.ts new file mode 100644 index 0000000000..03d08c3096 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/BackstageProtocolResolverFetchMiddleware.ts @@ -0,0 +1,64 @@ +/* + * 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'; + +function join(left: string, right: string): string { + if (!right || right === '/') { + return left; + } + + return `${left.replace(/\/$/, '')}/${right.replace(/^\//, '')}`; +} + +/** + * Handles translation from backstage://some-plugin-id/ to concrete + * http(s) URLs. + * + * @public + */ +export class BackstageProtocolResolverFetchMiddleware + implements FetchMiddleware +{ + constructor( + private readonly discovery: (pluginId: string) => Promise, + ) {} + + /** + * {@inheritdoc FetchMiddleware.apply} + */ + apply(next: FetchFunction): FetchFunction { + return async (input, init) => { + const request = new Request(input, init); + const { protocol, hostname, pathname, search, hash, username, password } = + new URL(request.url); + + if (protocol !== 'backstage:') { + return next(input, init); + } + + let base = await this.discovery(hostname); + if (username || password) { + const baseUrl = new URL(base); + const authority = `${username}${password ? `:${password}` : ''}@`; + base = `${baseUrl.protocol}//${authority}${baseUrl.host}${baseUrl.pathname}`; + } + + const target = `${join(base, pathname)}${search}${hash}`; + return next(target, request); + }; + } +} diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/FetchApiBuilder.ts b/packages/core-app-api/src/apis/implementations/FetchApi/FetchApiBuilder.ts new file mode 100644 index 0000000000..1c96eb1ad6 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/FetchApiBuilder.ts @@ -0,0 +1,55 @@ +/* + * 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 { FetchApi } from '@backstage/core-plugin-api'; +import crossFetch from 'cross-fetch'; +import { FetchFunction, FetchMiddleware } from './types'; + +/** + * Builds a fetch API, based on the builtin fetch wrapped by a set of optional + * middleware implementations that add behaviors. + * + * @public + */ +export class FetchApiBuilder { + static create(): FetchApiBuilder { + return new FetchApiBuilder(crossFetch, []); + } + + 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, + }; + } +} 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 new file mode 100644 index 0000000000..381ebd62a9 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAwareFetchMiddleware.test.ts @@ -0,0 +1,43 @@ +/* + * 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 new file mode 100644 index 0000000000..474ca83894 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAwareFetchMiddleware.ts @@ -0,0 +1,59 @@ +/* + * 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/index.ts b/packages/core-app-api/src/apis/implementations/FetchApi/index.ts new file mode 100644 index 0000000000..7544bd8bf2 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/index.ts @@ -0,0 +1,20 @@ +/* + * 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. + */ + +export { BackstageProtocolResolverFetchMiddleware } from './BackstageProtocolResolverFetchMiddleware'; +export { FetchApiBuilder } from './FetchApiBuilder'; +export { IdentityAwareFetchMiddleware } from './IdentityAwareFetchMiddleware'; +export type { FetchFunction, 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 new file mode 100644 index 0000000000..4cf0bf47f4 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/FetchApi/types.ts @@ -0,0 +1,39 @@ +/* + * 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 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. + * + * @public + */ +export interface FetchMiddleware { + /** + * Applies this middleware to an inner implementation. + * + * @param next - The next, inner, implementation, that this middleware shall + * call out to as part of the request cycle. + */ + apply(next: FetchFunction): FetchFunction; +} diff --git a/packages/core-app-api/src/apis/implementations/index.ts b/packages/core-app-api/src/apis/implementations/index.ts index c494f966f2..1c79d3d164 100644 --- a/packages/core-app-api/src/apis/implementations/index.ts +++ b/packages/core-app-api/src/apis/implementations/index.ts @@ -27,5 +27,6 @@ export * from './ConfigApi'; export * from './DiscoveryApi'; export * from './ErrorApi'; export * from './FeatureFlagsApi'; +export * from './FetchApi'; export * from './OAuthRequestApi'; export * from './StorageApi'; diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 9fd4f57ae9..148115fd69 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -9,6 +9,7 @@ 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'; @@ -505,6 +506,14 @@ export enum FeatureFlagState { None = 0, } +// @public +export type FetchApi = { + fetch: typeof fetch_2; +}; + +// @public +export const fetchApiRef: ApiRef; + // @public export function getComponentData( node: ReactNode, diff --git a/packages/core-plugin-api/package.json b/packages/core-plugin-api/package.json index 72827bde73..365cec3134 100644 --- a/packages/core-plugin-api/package.json +++ b/packages/core-plugin-api/package.json @@ -34,6 +34,7 @@ "@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", @@ -56,7 +57,6 @@ "@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 new file mode 100644 index 0000000000..30eb950634 --- /dev/null +++ b/packages/core-plugin-api/src/apis/definitions/FetchApi.ts @@ -0,0 +1,38 @@ +/* + * 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 fetch from 'cross-fetch'; +import { ApiRef, createApiRef } from '../system'; + +/** + * A wrapper for the fetch API, that has additional behaviors such as the + * ability to automatically inject auth information where necessary. + * + * @public + */ +export type FetchApi = { + fetch: typeof fetch; +}; + +/** + * A wrapper for the fetch API, that has additional behaviors such as the + * ability to automatically inject auth information where necessary. + * + * @public + */ +export const fetchApiRef: ApiRef = createApiRef({ + id: 'core.fetch', +}); diff --git a/packages/core-plugin-api/src/apis/definitions/index.ts b/packages/core-plugin-api/src/apis/definitions/index.ts index b7666bcb54..67d442587d 100644 --- a/packages/core-plugin-api/src/apis/definitions/index.ts +++ b/packages/core-plugin-api/src/apis/definitions/index.ts @@ -29,6 +29,7 @@ export * from './ConfigApi'; export * from './DiscoveryApi'; export * from './ErrorApi'; export * from './FeatureFlagsApi'; +export * from './FetchApi'; export * from './IdentityApi'; export * from './OAuthRequestApi'; export * from './StorageApi';