diff --git a/.changeset/hip-grapes-count.md b/.changeset/hip-grapes-count.md new file mode 100644 index 0000000000..7fb218e5d6 --- /dev/null +++ b/.changeset/hip-grapes-count.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Add an `allowUrl` callback option to `FetchMiddlewares.injectIdentityAuth` diff --git a/.changeset/modern-knives-roll.md b/.changeset/modern-knives-roll.md new file mode 100644 index 0000000000..9af062838c --- /dev/null +++ b/.changeset/modern-knives-roll.md @@ -0,0 +1,49 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +Make `ScaffolderClient` use the `FetchApi`. You now need to pass in an instance +of that API when constructing the client, if you create a custom instance in +your app. + +If you are replacing the factory: + +```diff ++import { fetchApiRef } from '@backstage/core-plugin-api'; + + createApiFactory({ + api: scaffolderApiRef, + deps: { + discoveryApi: discoveryApiRef, + scmIntegrationsApi: scmIntegrationsApiRef, +- identityApi: identityApiRef, ++ fetchApi: fetchApiRef, + }, + factory: ({ + discoveryApi, + scmIntegrationsApi, +- identityApi, ++ fetchApi, + }) => + new ScaffolderClient({ + discoveryApi, + scmIntegrationsApi, +- identityApi, ++ fetchApi, + }), + }), +``` + +If instantiating directly: + +```diff ++import { fetchApiRef } from '@backstage/core-plugin-api'; + ++const fetchApi = useApi(fetchApiRef); + const client = new ScaffolderClient({ + discoveryApi, + scmIntegrationsApi, +- identityApi, ++ fetchApi, + }), +``` diff --git a/.changeset/shiny-garlics-cough.md b/.changeset/shiny-garlics-cough.md new file mode 100644 index 0000000000..ea6634506f --- /dev/null +++ b/.changeset/shiny-garlics-cough.md @@ -0,0 +1,5 @@ +--- +'@backstage/test-utils': patch +--- + +Added a `MockFetchApi` diff --git a/.changeset/sweet-candles-greet.md b/.changeset/sweet-candles-greet.md new file mode 100644 index 0000000000..8ab38ca0da --- /dev/null +++ b/.changeset/sweet-candles-greet.md @@ -0,0 +1,76 @@ +--- +'@backstage/plugin-techdocs': minor +--- + +Make `TechDocsClient` and `TechDocsStorageClient` use the `FetchApi`. You now +need to pass in an instance of that API when constructing the client, if you +create a custom instance in your app. + +If you are replacing the factory: + +```diff ++import { fetchApiRef } from '@backstage/core-plugin-api'; + + createApiFactory({ + api: techdocsStorageApiRef, + deps: { + configApi: configApiRef, + discoveryApi: discoveryApiRef, + identityApi: identityApiRef, ++ fetchApi: fetchApiRef, + }, + factory: ({ + configApi, + discoveryApi, + identityApi, ++ fetchApi, + }) => + new TechDocsStorageClient({ + configApi, + discoveryApi, + identityApi, ++ fetchApi, + }), + }), + createApiFactory({ + api: techdocsApiRef, + deps: { + configApi: configApiRef, + discoveryApi: discoveryApiRef, +- identityApi: identityApiRef, ++ fetchApi: fetchApiRef, + }, + factory: ({ + configApi, + discoveryApi, +- identityApi, ++ fetchApi, + }) => + new TechDocsClient({ + configApi, + discoveryApi, +- identityApi, ++ fetchApi, + }), + }), +``` + +If instantiating directly: + +```diff ++import { fetchApiRef } from '@backstage/core-plugin-api'; + ++const fetchApi = useApi(fetchApiRef); + const storageClient = new TechDocsStorageClient({ + configApi, + discoveryApi, + identityApi, ++ fetchApi, + }); + const techdocsClient = new TechDocsClient({ + configApi, + discoveryApi, +- identityApi, ++ fetchApi, + }), +``` diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index b813d1a9a5..ba2ed8cb62 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -359,6 +359,7 @@ export class FetchMiddlewares { identityApi: IdentityApi; config?: Config; urlPrefixAllowlist?: string[]; + allowUrl?: (url: string) => boolean; header?: { name: string; value: (backstageToken: string) => string; diff --git a/packages/core-app-api/src/apis/implementations/FetchApi/FetchMiddlewares.ts b/packages/core-app-api/src/apis/implementations/FetchApi/FetchMiddlewares.ts index 1cf09ff20d..8073571a04 100644 --- a/packages/core-app-api/src/apis/implementations/FetchApi/FetchMiddlewares.ts +++ b/packages/core-app-api/src/apis/implementations/FetchApi/FetchMiddlewares.ts @@ -56,14 +56,16 @@ export class FetchMiddlewares { * * 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. + * the `urlPrefixAllowlist` or `allowUrl` options are passed in, in which case + * they take precedence. If you pass in neither config nor an + * allowlist/callback, the middleware will have no effect since effectively no + * request will match the (nonexistent) rules. */ static injectIdentityAuth(options: { identityApi: IdentityApi; config?: Config; urlPrefixAllowlist?: string[]; + allowUrl?: (url: string) => boolean; header?: { name: string; value: (backstageToken: string) => string; 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 index b69a4b9f7a..1e29f2548a 100644 --- a/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAuthInjectorFetchMiddleware.test.ts +++ b/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAuthInjectorFetchMiddleware.test.ts @@ -23,7 +23,7 @@ describe('IdentityAuthInjectorFetchMiddleware', () => { const middleware = IdentityAuthInjectorFetchMiddleware.create({ identityApi: undefined as any, }); - expect(middleware.urlPrefixAllowlist).toEqual([]); + expect(middleware.allowUrl('anything')).toEqual(false); expect(middleware.headerName).toEqual('authorization'); expect(middleware.headerValue('t')).toEqual('Bearer t'); }); @@ -36,7 +36,9 @@ describe('IdentityAuthInjectorFetchMiddleware', () => { }), header: { name: 'auth', value: t => `${t}!` }, }); - expect(middleware.urlPrefixAllowlist).toEqual(['https://example.com/api']); + expect(middleware.allowUrl('https://example.com/api')).toEqual(true); + expect(middleware.allowUrl('https://example.com/api/sss')).toEqual(true); + expect(middleware.allowUrl('https://evil.com/api')).toEqual(false); expect(middleware.headerName).toEqual('auth'); expect(middleware.headerValue('t')).toEqual('t!'); }); @@ -49,10 +51,10 @@ describe('IdentityAuthInjectorFetchMiddleware', () => { }), urlPrefixAllowlist: ['https://a.com', 'http://b.com:8080/'], }); - expect(middleware.urlPrefixAllowlist).toEqual([ - 'https://a.com', - 'http://b.com:8080', - ]); + expect(middleware.allowUrl('https://a.com')).toEqual(true); + expect(middleware.allowUrl('https://a.com:8080')).toEqual(false); + expect(middleware.allowUrl('https://a.com/sss')).toEqual(true); + expect(middleware.allowUrl('http://b.com:8080')).toEqual(true); }); it('injects the header only when a token is available', async () => { @@ -63,7 +65,7 @@ describe('IdentityAuthInjectorFetchMiddleware', () => { const middleware = new IdentityAuthInjectorFetchMiddleware( identityApi, - ['https://example.com'], + () => true, 'Authorization', token => `Bearer ${token}`, ); @@ -95,7 +97,7 @@ describe('IdentityAuthInjectorFetchMiddleware', () => { const middleware = new IdentityAuthInjectorFetchMiddleware( identityApi, - ['https://example.com'], + () => true, 'Authorization', token => `Bearer ${token}`, ); @@ -118,36 +120,4 @@ describe('IdentityAuthInjectorFetchMiddleware', () => { ['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 index 46ada0a505..810d9e76c1 100644 --- a/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAuthInjectorFetchMiddleware.ts +++ b/packages/core-app-api/src/apis/implementations/FetchApi/IdentityAuthInjectorFetchMiddleware.ts @@ -27,24 +27,19 @@ export class IdentityAuthInjectorFetchMiddleware implements FetchMiddleware { identityApi: IdentityApi; config?: Config; urlPrefixAllowlist?: string[]; + allowUrl?: (url: string) => boolean; 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 matcher = buildMatcher(options); const headerName = options.header?.name || 'authorization'; const headerValue = options.header?.value || (token => `Bearer ${token}`); return new IdentityAuthInjectorFetchMiddleware( options.identityApi, - allowlist.map(prefix => prefix.replace(/\/$/, '')), + matcher, headerName, headerValue, ); @@ -52,7 +47,7 @@ export class IdentityAuthInjectorFetchMiddleware implements FetchMiddleware { constructor( public readonly identityApi: IdentityApi, - public readonly urlPrefixAllowlist: string[], + public readonly allowUrl: (url: string) => boolean, public readonly headerName: string, public readonly headerValue: (pluginId: string) => string, ) {} @@ -65,12 +60,9 @@ export class IdentityAuthInjectorFetchMiddleware implements FetchMiddleware { 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 + !token || + !this.allowUrl(request.url) ) { return next(input, init); } @@ -80,3 +72,26 @@ export class IdentityAuthInjectorFetchMiddleware implements FetchMiddleware { }; } } + +function buildMatcher(options: { + config?: Config; + urlPrefixAllowlist?: string[]; + allowUrl?: (url: string) => boolean; +}): (url: string) => boolean { + if (options.allowUrl) { + return options.allowUrl; + } else if (options.urlPrefixAllowlist) { + return buildPrefixMatcher(options.urlPrefixAllowlist); + } else if (options.config) { + return buildPrefixMatcher([options.config.getString('backend.baseUrl')]); + } + return () => false; +} + +function buildPrefixMatcher(prefixes: string[]): (url: string) => boolean { + const trimmedPrefixes = prefixes.map(prefix => prefix.replace(/\/$/, '')); + return url => + trimmedPrefixes.some( + prefix => url === prefix || url.startsWith(`${prefix}/`), + ); +} diff --git a/packages/core-plugin-api/src/apis/definitions/FetchApi.ts b/packages/core-plugin-api/src/apis/definitions/FetchApi.ts index 67513b113a..b1a4dbbd0b 100644 --- a/packages/core-plugin-api/src/apis/definitions/FetchApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/FetchApi.ts @@ -23,6 +23,9 @@ import { ApiRef, createApiRef } from '../system'; * @public */ export type FetchApi = { + /** + * The `fetch` implementation. + */ fetch: typeof fetch; }; diff --git a/packages/test-utils/api-report.md b/packages/test-utils/api-report.md index 2039bc60b5..4e5361ff80 100644 --- a/packages/test-utils/api-report.md +++ b/packages/test-utils/api-report.md @@ -13,10 +13,14 @@ import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { ComponentType } from 'react'; import { Config } from '@backstage/config'; import { ConfigApi } from '@backstage/core-plugin-api'; +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'; import { ErrorApiErrorContext } from '@backstage/core-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; +import { FetchApi } from '@backstage/core-plugin-api'; +import { IdentityApi } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; import { Observable } from '@backstage/types'; @@ -117,6 +121,31 @@ export type MockErrorApiOptions = { collect?: boolean; }; +// @public +export class MockFetchApi implements FetchApi { + constructor(options?: MockFetchApiOptions); + // (undocumented) + get fetch(): typeof crossFetch; +} + +// @public +export interface MockFetchApiOptions { + baseImplementation?: undefined | 'none' | typeof crossFetch; + injectIdentityAuth?: + | undefined + | { + token: string; + } + | { + identityApi: Pick; + }; + resolvePluginProtocol?: + | undefined + | { + discoveryApi: Pick; + }; +} + // @public export class MockPermissionApi implements PermissionApi { constructor( diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 32d7010f53..b3f2b65aa5 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -41,6 +41,7 @@ "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", "@testing-library/user-event": "^13.1.8", + "cross-fetch": "^3.0.6", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "zen-observable": "^0.8.15" @@ -52,7 +53,8 @@ "devDependencies": { "@backstage/cli": "^0.12.0-next.0", "@types/jest": "^26.0.7", - "@types/node": "^14.14.32" + "@types/node": "^14.14.32", + "msw": "^0.35.0" }, "files": [ "dist" diff --git a/packages/test-utils/src/setupTests.ts b/packages/test-utils/src/setupTests.ts index 963c0f188b..c1d649f2ad 100644 --- a/packages/test-utils/src/setupTests.ts +++ b/packages/test-utils/src/setupTests.ts @@ -15,3 +15,4 @@ */ import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/packages/test-utils/src/testUtils/apis/FetchApi/MockFetchApi.test.ts b/packages/test-utils/src/testUtils/apis/FetchApi/MockFetchApi.test.ts new file mode 100644 index 0000000000..78f12a10f1 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/FetchApi/MockFetchApi.test.ts @@ -0,0 +1,95 @@ +/* + * Copyright 2022 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 { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '../../msw'; +import { MockFetchApi } from './MockFetchApi'; + +describe('MockFetchApi', () => { + const worker = setupServer(); + setupRequestMockHandlers(worker); + + it('works with default constructor', async () => { + worker.use( + rest.get('http://example.com/data.json', (_, res, ctx) => + res(ctx.status(200), ctx.json({ a: 'foo' })), + ), + ); + const m = new MockFetchApi(); + const response = await m.fetch('http://example.com/data.json'); + await expect(response.json()).resolves.toEqual({ a: 'foo' }); + }); + + describe('baseImplementation', () => { + it('works with a mock implementation', async () => { + const inner = jest.fn(); + const m = new MockFetchApi({ baseImplementation: inner }); + await m.fetch('http://example.com/data.json'); + expect(inner).lastCalledWith('http://example.com/data.json'); + }); + }); + + describe('resolvePluginProtocol', () => { + it('works', async () => { + const inner = jest.fn(); + const m = new MockFetchApi({ + baseImplementation: inner, + resolvePluginProtocol: { + discoveryApi: { + getBaseUrl: async id => `https://blah.com/api/${id}`, + }, + }, + }); + await m.fetch('plugin://the-plugin/a/data.json'); + expect(inner.mock.calls[0][0]).toBe( + 'https://blah.com/api/the-plugin/a/data.json', + ); + }); + }); + + describe('injectIdentityAuth', () => { + it('works with token', async () => { + const inner = jest.fn(); + const m = new MockFetchApi({ + baseImplementation: inner, + injectIdentityAuth: { token: 'hello' }, + }); + await m.fetch('http://example.com/data.json'); + expect(inner.mock.calls[0][0].headers?.get('authorization')).toBe( + 'Bearer hello', + ); + }); + + it('works with identityApi', async () => { + const inner = jest.fn(); + const m = new MockFetchApi({ + baseImplementation: inner, + injectIdentityAuth: { + identityApi: { + async getCredentials() { + return { token: 'hello2' }; + }, + }, + }, + }); + await m.fetch('http://example.com/data.json'); + expect(inner.mock.calls[0][0].headers?.get('authorization')).toBe( + 'Bearer hello2', + ); + }); + }); +}); diff --git a/packages/test-utils/src/testUtils/apis/FetchApi/MockFetchApi.ts b/packages/test-utils/src/testUtils/apis/FetchApi/MockFetchApi.ts new file mode 100644 index 0000000000..3d02db0124 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/FetchApi/MockFetchApi.ts @@ -0,0 +1,167 @@ +/* + * Copyright 2022 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 { + createFetchApi, + FetchMiddleware, + FetchMiddlewares, +} from '@backstage/core-app-api'; +import { + DiscoveryApi, + FetchApi, + IdentityApi, +} from '@backstage/core-plugin-api'; +import crossFetch, { Response } from 'cross-fetch'; + +/** + * The options given when constructing a {@link MockFetchApi}. + * + * @public + */ +export interface MockFetchApiOptions { + /** + * Define the underlying base `fetch` implementation. + * + * @defaultValue undefined + * @remarks + * + * Leaving out this parameter or passing `undefined`, makes the API use the + * global `fetch` implementation to make real network requests. + * + * `'none'` swallows all calls and makes no requests at all. + * + * You can also pass in any `fetch` compatible callback, such as a + * `jest.fn()`, if you want to use a custom implementation or to just track + * and assert on calls. + */ + baseImplementation?: undefined | 'none' | typeof crossFetch; + + /** + * Add translation from `plugin://` URLs to concrete http(s) URLs, basically + * simulating what + * {@link @backstage/core-app-api#FetchMiddlewares.resolvePluginProtocol} + * does. + * + * @defaultValue undefined + * @remarks + * + * Leaving out this parameter or passing `undefined`, disables plugin protocol + * translation. + * + * To enable the feature, pass in a discovery API which is then used to + * resolve the URLs. + */ + resolvePluginProtocol?: + | undefined + | { discoveryApi: Pick }; + + /** + * Add token based Authorization headers to requests, basically simulating + * what {@link @backstage/core-app-api#FetchMiddlewares.injectIdentityAuth} + * does. + * + * @defaultValue undefined + * @remarks + * + * Leaving out this parameter or passing `undefined`, disables auth injection. + * + * To enable the feature, pass in either a static token or an identity API + * which is queried on each request for a token. + */ + injectIdentityAuth?: + | undefined + | { token: string } + | { identityApi: Pick }; +} + +/** + * A test helper implementation of {@link @backstage/core-plugin-api#FetchApi}. + * + * @public + */ +export class MockFetchApi implements FetchApi { + private readonly implementation: FetchApi; + + /** + * Creates a mock {@link @backstage/core-plugin-api#FetchApi}. + */ + constructor(options?: MockFetchApiOptions) { + this.implementation = build(options); + } + + /** {@inheritdoc @backstage/core-plugin-api#FetchApi.fetch} */ + get fetch(): typeof crossFetch { + return this.implementation.fetch; + } +} + +// +// Helpers +// + +function build(options?: MockFetchApiOptions): FetchApi { + return createFetchApi({ + baseImplementation: baseImplementation(options), + middleware: [ + resolvePluginProtocol(options), + injectIdentityAuth(options), + ].filter((x): x is FetchMiddleware => Boolean(x)), + }); +} + +function baseImplementation( + options: MockFetchApiOptions | undefined, +): typeof crossFetch { + const implementation = options?.baseImplementation; + if (!implementation) { + return crossFetch; + } else if (implementation === 'none') { + return () => Promise.resolve(new Response()); + } + return implementation; +} + +function resolvePluginProtocol( + allOptions: MockFetchApiOptions | undefined, +): FetchMiddleware | undefined { + const options = allOptions?.resolvePluginProtocol; + if (!options) { + return undefined; + } + + return FetchMiddlewares.resolvePluginProtocol({ + discoveryApi: options.discoveryApi, + }); +} + +function injectIdentityAuth( + allOptions: MockFetchApiOptions | undefined, +): FetchMiddleware | undefined { + const options = allOptions?.injectIdentityAuth; + if (!options) { + return undefined; + } + + const identityApi: Pick = + 'token' in options + ? { getCredentials: async () => ({ token: options.token }) } + : options.identityApi; + + return FetchMiddlewares.injectIdentityAuth({ + identityApi: identityApi as IdentityApi, + allowUrl: () => true, + }); +} diff --git a/packages/test-utils/src/testUtils/apis/FetchApi/index.ts b/packages/test-utils/src/testUtils/apis/FetchApi/index.ts new file mode 100644 index 0000000000..5ac0c08ff1 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/FetchApi/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2022 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 { MockFetchApi } from './MockFetchApi'; +export type { MockFetchApiOptions } from './MockFetchApi'; diff --git a/packages/test-utils/src/testUtils/apis/index.ts b/packages/test-utils/src/testUtils/apis/index.ts index e59608d2b0..e122a4b432 100644 --- a/packages/test-utils/src/testUtils/apis/index.ts +++ b/packages/test-utils/src/testUtils/apis/index.ts @@ -17,5 +17,6 @@ export * from './AnalyticsApi'; export * from './ConfigApi'; export * from './ErrorApi'; +export * from './FetchApi'; export * from './PermissionApi'; export * from './StorageApi'; diff --git a/packages/test-utils/src/testUtils/msw/index.ts b/packages/test-utils/src/testUtils/msw/index.ts index 625ef0e0fc..29cbdf854a 100644 --- a/packages/test-utils/src/testUtils/msw/index.ts +++ b/packages/test-utils/src/testUtils/msw/index.ts @@ -14,17 +14,4 @@ * limitations under the License. */ -/** - * Sets up handlers for request mocking - * @public - * @param worker - service worker - */ -export function setupRequestMockHandlers(worker: { - listen: (t: any) => void; - close: () => void; - resetHandlers: () => void; -}) { - beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); - afterAll(() => worker.close()); - afterEach(() => worker.resetHandlers()); -} +export { setupRequestMockHandlers } from './setupRequestMockHandlers'; diff --git a/packages/test-utils/src/testUtils/msw/setupRequestMockHandlers.ts b/packages/test-utils/src/testUtils/msw/setupRequestMockHandlers.ts new file mode 100644 index 0000000000..625ef0e0fc --- /dev/null +++ b/packages/test-utils/src/testUtils/msw/setupRequestMockHandlers.ts @@ -0,0 +1,30 @@ +/* + * 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. + */ + +/** + * Sets up handlers for request mocking + * @public + * @param worker - service worker + */ +export function setupRequestMockHandlers(worker: { + listen: (t: any) => void; + close: () => void; + resetHandlers: () => void; +}) { + beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); + afterAll(() => worker.close()); + afterEach(() => worker.resetHandlers()); +} diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index d2bbb2a5e1..b77fb5f939 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -15,10 +15,10 @@ import { Entity } from '@backstage/catalog-model'; import { EntityName } from '@backstage/catalog-model'; import { Extension } from '@backstage/core-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; +import { FetchApi } from '@backstage/core-plugin-api'; import { FieldProps } from '@rjsf/core'; import { FieldValidation } from '@rjsf/core'; import { IconButton } from '@material-ui/core'; -import { IdentityApi } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; import { JSONSchema } from '@backstage/catalog-model'; import { Observable } from '@backstage/types'; @@ -166,9 +166,7 @@ export interface RepoUrlPickerUiOptions { allowedOwners?: string[]; } -// Warning: (ae-missing-release-tag) "ScaffolderApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export interface ScaffolderApi { // (undocumented) getIntegrationsList(options: { allowedHosts: string[] }): Promise< @@ -189,8 +187,6 @@ export interface ScaffolderApi { templateName: EntityName, ): Promise; // Warning: (ae-forgotten-export) The symbol "ListActionsResponse" needs to be exported by the entry point index.d.ts - // - // (undocumented) listActions(): Promise; scaffold( templateName: string, @@ -200,27 +196,17 @@ export interface ScaffolderApi { // Warning: (ae-forgotten-export) The symbol "LogEvent" needs to be exported by the entry point index.d.ts // // (undocumented) - streamLogs({ - taskId, - after, - }: { - taskId: string; - after?: number; - }): Observable; + streamLogs(options: { taskId: string; after?: number }): Observable; } -// Warning: (ae-missing-release-tag) "scaffolderApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export const scaffolderApiRef: ApiRef; -// Warning: (ae-missing-release-tag) "ScaffolderClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export class ScaffolderClient implements ScaffolderApi { constructor(options: { discoveryApi: DiscoveryApi; - identityApi: IdentityApi; + fetchApi: FetchApi; scmIntegrationsApi: ScmIntegrationRegistry; useLongPollingLogs?: boolean; }); @@ -246,7 +232,7 @@ export class ScaffolderClient implements ScaffolderApi { secrets?: Record, ): Promise; // (undocumented) - streamLogs(opts: { taskId: string; after?: number }): Observable; + streamLogs(options: { taskId: string; after?: number }): Observable; } // Warning: (ae-missing-release-tag) "ScaffolderFieldExtensions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/plugins/scaffolder/dev/index.tsx b/plugins/scaffolder/dev/index.tsx index f7c6aa62fc..78e6254e09 100644 --- a/plugins/scaffolder/dev/index.tsx +++ b/plugins/scaffolder/dev/index.tsx @@ -26,9 +26,8 @@ import React from 'react'; import { scaffolderApiRef, ScaffolderClient } from '../src'; import { ScaffolderPage } from '../src/plugin'; import { - configApiRef, discoveryApiRef, - identityApiRef, + fetchApiRef, storageApiRef, } from '@backstage/core-plugin-api'; import { CatalogEntityPage } from '@backstage/plugin-catalog'; @@ -52,12 +51,11 @@ createDevApp() api: scaffolderApiRef, deps: { discoveryApi: discoveryApiRef, - identityApi: identityApiRef, - configApi: configApiRef, + fetchApi: fetchApiRef, scmIntegrationsApi: scmIntegrationsApiRef, }, - factory: ({ discoveryApi, identityApi, scmIntegrationsApi }) => - new ScaffolderClient({ discoveryApi, identityApi, scmIntegrationsApi }), + factory: ({ discoveryApi, fetchApi, scmIntegrationsApi }) => + new ScaffolderClient({ discoveryApi, fetchApi, scmIntegrationsApi }), }) .addPage({ path: '/create', diff --git a/plugins/scaffolder/src/api.test.ts b/plugins/scaffolder/src/api.test.ts index 01463a6e1c..369722ec8f 100644 --- a/plugins/scaffolder/src/api.test.ts +++ b/plugins/scaffolder/src/api.test.ts @@ -16,7 +16,7 @@ import { ConfigReader } from '@backstage/core-app-api'; import { ScmIntegrations } from '@backstage/integration'; -import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { ScaffolderClient } from './api'; @@ -32,7 +32,7 @@ describe('api', () => { const mockBaseUrl = 'http://backstage/api'; const discoveryApi = { getBaseUrl: async () => mockBaseUrl }; - const identityApi = {} as any; + const fetchApi = new MockFetchApi(); const scmIntegrationsApi = ScmIntegrations.fromConfig( new ConfigReader({ integrations: { @@ -50,7 +50,7 @@ describe('api', () => { apiClient = new ScaffolderClient({ scmIntegrationsApi, discoveryApi, - identityApi, + fetchApi, }); }); @@ -126,7 +126,7 @@ describe('api', () => { apiClient = new ScaffolderClient({ scmIntegrationsApi, discoveryApi, - identityApi, + fetchApi, useLongPollingLogs: true, }); }); diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index 7f096a02f7..62fc06a6db 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -18,7 +18,7 @@ import { EntityName } from '@backstage/catalog-model'; import { createApiRef, DiscoveryApi, - IdentityApi, + FetchApi, } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; import { ScmIntegrationRegistry } from '@backstage/integration'; @@ -28,6 +28,11 @@ import qs from 'qs'; import ObservableImpl from 'zen-observable'; import { ListActionsResponse, ScaffolderTask, Status } from './types'; +/** + * Utility API reference for the {@link ScaffolderApi}. + * + * @public + */ export const scaffolderApiRef = createApiRef({ id: 'plugin.scaffolder.service', }); @@ -58,6 +63,11 @@ export type CustomField = { validation: (data: JsonValue, field: FieldValidation) => void; }; +/** + * An API to interact with the scaffolder backend. + * + * @public + */ export interface ScaffolderApi { getTemplateParameterSchema( templateName: EntityName, @@ -83,32 +93,33 @@ export interface ScaffolderApi { allowedHosts: string[]; }): Promise<{ type: string; title: string; host: string }[]>; - // Returns a list of all installed actions. + /** + * Returns a list of all installed actions. + */ listActions(): Promise; - streamLogs({ - taskId, - after, - }: { - taskId: string; - after?: number; - }): Observable; + streamLogs(options: { taskId: string; after?: number }): Observable; } +/** + * An API to interact with the scaffolder backend. + * + * @public + */ export class ScaffolderClient implements ScaffolderApi { private readonly discoveryApi: DiscoveryApi; - private readonly identityApi: IdentityApi; private readonly scmIntegrationsApi: ScmIntegrationRegistry; + private readonly fetchApi: FetchApi; private readonly useLongPollingLogs: boolean; constructor(options: { discoveryApi: DiscoveryApi; - identityApi: IdentityApi; + fetchApi: FetchApi; scmIntegrationsApi: ScmIntegrationRegistry; useLongPollingLogs?: boolean; }) { this.discoveryApi = options.discoveryApi; - this.identityApi = options.identityApi; + this.fetchApi = options.fetchApi ?? { fetch }; this.scmIntegrationsApi = options.scmIntegrationsApi; this.useLongPollingLogs = options.useLongPollingLogs ?? false; } @@ -129,19 +140,13 @@ export class ScaffolderClient implements ScaffolderApi { ): Promise { const { namespace, kind, name } = templateName; - const { token } = await this.identityApi.getCredentials(); const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); const templatePath = [namespace, kind, name] .map(s => encodeURIComponent(s)) .join('/'); const url = `${baseUrl}/v2/templates/${templatePath}/parameter-schema`; - const response = await fetch(url, { - headers: { - ...(token && { Authorization: `Bearer ${token}` }), - }, - }); - + const response = await this.fetchApi.fetch(url); if (!response.ok) { throw await ResponseError.fromResponse(response); } @@ -163,13 +168,11 @@ export class ScaffolderClient implements ScaffolderApi { values: Record, secrets: Record = {}, ): Promise { - const { token } = await this.identityApi.getCredentials(); const url = `${await this.discoveryApi.getBaseUrl('scaffolder')}/v2/tasks`; - const response = await fetch(url, { + const response = await this.fetchApi.fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', - ...(token && { Authorization: `Bearer ${token}` }), }, body: JSON.stringify({ templateName, @@ -189,13 +192,10 @@ export class ScaffolderClient implements ScaffolderApi { } async getTask(taskId: string) { - const { token } = await this.identityApi.getCredentials(); const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); const url = `${baseUrl}/v2/tasks/${encodeURIComponent(taskId)}`; - const response = await fetch(url, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); + const response = await this.fetchApi.fetch(url); if (!response.ok) { throw await ResponseError.fromResponse(response); } @@ -203,12 +203,15 @@ export class ScaffolderClient implements ScaffolderApi { return await response.json(); } - streamLogs(opts: { taskId: string; after?: number }): Observable { + streamLogs(options: { + taskId: string; + after?: number; + }): Observable { if (this.useLongPollingLogs) { - return this.streamLogsPolling(opts); + return this.streamLogsPolling(options); } - return this.streamLogsEventStream(opts); + return this.streamLogsEventStream(options); } private streamLogsEventStream({ @@ -276,7 +279,7 @@ export class ScaffolderClient implements ScaffolderApi { const url = `${baseUrl}/v2/tasks/${encodeURIComponent( taskId, )}/events?${qs.stringify({ after })}`; - const response = await fetch(url); + const response = await this.fetchApi.fetch(url); if (!response.ok) { // wait for one second to not run into an @@ -301,16 +304,9 @@ export class ScaffolderClient implements ScaffolderApi { }); } - /** - * @returns ListActionsResponse containing all registered actions. - */ async listActions(): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder'); - const { token } = await this.identityApi.getCredentials(); - const response = await fetch(`${baseUrl}/v2/actions`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - + const response = await this.fetchApi.fetch(`${baseUrl}/v2/actions`); if (!response.ok) { throw await ResponseError.fromResponse(response); } diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index 02b36c4bb6..ccf8d2ee34 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -33,7 +33,7 @@ import { createPlugin, createRoutableExtension, discoveryApiRef, - identityApiRef, + fetchApiRef, } from '@backstage/core-plugin-api'; import { OwnedEntityPicker } from './components/fields/OwnedEntityPicker'; import { EntityTagsPicker } from './components/fields/EntityTagsPicker'; @@ -45,11 +45,15 @@ export const scaffolderPlugin = createPlugin({ api: scaffolderApiRef, deps: { discoveryApi: discoveryApiRef, - identityApi: identityApiRef, scmIntegrationsApi: scmIntegrationsApiRef, + fetchApi: fetchApiRef, }, - factory: ({ discoveryApi, identityApi, scmIntegrationsApi }) => - new ScaffolderClient({ discoveryApi, identityApi, scmIntegrationsApi }), + factory: ({ discoveryApi, scmIntegrationsApi, fetchApi }) => + new ScaffolderClient({ + discoveryApi, + scmIntegrationsApi, + fetchApi, + }), }), ], routes: { diff --git a/plugins/techdocs/api-report.md b/plugins/techdocs/api-report.md index fc7b515167..9a25cb6375 100644 --- a/plugins/techdocs/api-report.md +++ b/plugins/techdocs/api-report.md @@ -13,6 +13,7 @@ import { CSSProperties } from '@material-ui/styles'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { EntityName } from '@backstage/catalog-model'; +import { FetchApi } from '@backstage/core-plugin-api'; import { IdentityApi } from '@backstage/core-plugin-api'; import { LocationSpec } from '@backstage/catalog-model'; import { default as React_2 } from 'react'; @@ -197,16 +198,11 @@ export const Reader: ({ // @public (undocumented) export const Router: () => JSX.Element; -// Warning: (ae-missing-release-tag) "SyncResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type SyncResult = 'cached' | 'updated'; -// Warning: (ae-missing-release-tag) "TechDocsApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export interface TechDocsApi { - // (undocumented) getApiOrigin(): Promise; // Warning: (ae-forgotten-export) The symbol "TechDocsEntityMetadata" needs to be exported by the entry point index.d.ts // @@ -218,23 +214,15 @@ export interface TechDocsApi { getTechDocsMetadata(entityId: EntityName): Promise; } -// Warning: (ae-missing-release-tag) "techdocsApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export const techdocsApiRef: ApiRef; -// Warning: (ae-missing-release-tag) "TechDocsClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class TechDocsClient implements TechDocsApi { - constructor({ - configApi, - discoveryApi, - identityApi, - }: { + constructor(options: { configApi: Config; discoveryApi: DiscoveryApi; - identityApi: IdentityApi; + fetchApi: FetchApi; }); // (undocumented) configApi: Config; @@ -244,8 +232,6 @@ export class TechDocsClient implements TechDocsApi { getApiOrigin(): Promise; getEntityMetadata(entityId: EntityName): Promise; getTechDocsMetadata(entityId: EntityName): Promise; - // (undocumented) - identityApi: IdentityApi; } // Warning: (ae-missing-release-tag) "TechDocsCustomHome" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -347,11 +333,8 @@ export const TechDocsReaderPage: ({ children, }: TechDocsPageProps) => JSX.Element; -// Warning: (ae-missing-release-tag) "TechDocsStorageApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export interface TechDocsStorageApi { - // (undocumented) getApiOrigin(): Promise; // (undocumented) getBaseUrl( @@ -372,23 +355,16 @@ export interface TechDocsStorageApi { ): Promise; } -// Warning: (ae-missing-release-tag) "techdocsStorageApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export const techdocsStorageApiRef: ApiRef; -// Warning: (ae-missing-release-tag) "TechDocsStorageClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export class TechDocsStorageClient implements TechDocsStorageApi { - constructor({ - configApi, - discoveryApi, - identityApi, - }: { + constructor(options: { configApi: Config; discoveryApi: DiscoveryApi; identityApi: IdentityApi; + fetchApi: FetchApi; }); // (undocumented) configApi: Config; diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts index cd130f083d..4566bbf028 100644 --- a/plugins/techdocs/src/api.ts +++ b/plugins/techdocs/src/api.ts @@ -18,17 +18,40 @@ import { EntityName } from '@backstage/catalog-model'; import { TechDocsEntityMetadata, TechDocsMetadata } from './types'; import { createApiRef } from '@backstage/core-plugin-api'; +/** + * Utility API reference for the {@link TechDocsStorageApi}. + * + * @public + */ export const techdocsStorageApiRef = createApiRef({ id: 'plugin.techdocs.storageservice', }); +/** + * Utility API reference for the {@link TechDocsApi}. + * + * @public + */ export const techdocsApiRef = createApiRef({ id: 'plugin.techdocs.service', }); +/** + * The outcome of a docs sync operation. + * + * @public + */ export type SyncResult = 'cached' | 'updated'; +/** + * API which talks to TechDocs storage to fetch files to render. + * + * @public + */ export interface TechDocsStorageApi { + /** + * Set to techdocs.requestUrl as the URL for techdocs-backend API. + */ getApiOrigin(): Promise; getStorageUrl(): Promise; getBuilder(): Promise; @@ -44,7 +67,15 @@ export interface TechDocsStorageApi { ): Promise; } +/** + * API to talk to techdocs-backend. + * + * @public + */ export interface TechDocsApi { + /** + * Set to techdocs.requestUrl as the URL for techdocs-backend API. + */ getApiOrigin(): Promise; getTechDocsMetadata(entityId: EntityName): Promise; getEntityMetadata(entityId: EntityName): Promise; diff --git a/plugins/techdocs/src/client.test.ts b/plugins/techdocs/src/client.test.ts index 076d90cdad..a88c724cc8 100644 --- a/plugins/techdocs/src/client.test.ts +++ b/plugins/techdocs/src/client.test.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { MockConfigApi } from '@backstage/test-utils'; import { UrlPatternDiscovery } from '@backstage/core-app-api'; import { IdentityApi } from '@backstage/core-plugin-api'; import { NotFoundError } from '@backstage/errors'; import { EventSourcePolyfill } from 'event-source-polyfill'; +import { MockConfigApi, MockFetchApi } from '@backstage/test-utils'; import { TechDocsStorageClient } from './client'; const MockedEventSource = EventSourcePolyfill as jest.MockedClass< @@ -36,17 +36,13 @@ const mockEntity = { describe('TechDocsStorageClient', () => { const mockBaseUrl = 'http://backstage:9191/api/techdocs'; const configApi = new MockConfigApi({ - techdocs: { - requestUrl: 'http://backstage:9191/api/techdocs', - }, + techdocs: { requestUrl: 'http://backstage:9191/api/techdocs' }, }); const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); const identityApi: jest.Mocked = { - signOut: jest.fn(), - getProfileInfo: jest.fn(), - getBackstageIdentity: jest.fn(), getCredentials: jest.fn(), - }; + } as unknown as jest.Mocked; + const fetchApi = new MockFetchApi({ injectIdentityAuth: { identityApi } }); beforeEach(() => { jest.resetAllMocks(); @@ -58,6 +54,7 @@ describe('TechDocsStorageClient', () => { configApi, discoveryApi, identityApi, + fetchApi, }); await expect( @@ -78,6 +75,7 @@ describe('TechDocsStorageClient', () => { configApi, discoveryApi, identityApi, + fetchApi, }); await expect( @@ -93,6 +91,7 @@ describe('TechDocsStorageClient', () => { configApi, discoveryApi, identityApi, + fetchApi, }); MockedEventSource.prototype.addEventListener.mockImplementation( @@ -103,6 +102,7 @@ describe('TechDocsStorageClient', () => { }, ); + identityApi.getCredentials.mockResolvedValue({}); await storageApi.syncEntityDocs(mockEntity); expect(MockedEventSource).toBeCalledWith( @@ -116,6 +116,7 @@ describe('TechDocsStorageClient', () => { configApi, discoveryApi, identityApi, + fetchApi, }); MockedEventSource.prototype.addEventListener.mockImplementation( @@ -127,7 +128,6 @@ describe('TechDocsStorageClient', () => { ); identityApi.getCredentials.mockResolvedValue({ token: 'token' }); - await storageApi.syncEntityDocs(mockEntity); expect(MockedEventSource).toBeCalledWith( @@ -141,6 +141,7 @@ describe('TechDocsStorageClient', () => { configApi, discoveryApi, identityApi, + fetchApi, }); MockedEventSource.prototype.addEventListener.mockImplementation( @@ -151,6 +152,7 @@ describe('TechDocsStorageClient', () => { }, ); + identityApi.getCredentials.mockResolvedValue({}); await expect(storageApi.syncEntityDocs(mockEntity)).resolves.toEqual( 'cached', ); @@ -161,6 +163,7 @@ describe('TechDocsStorageClient', () => { configApi, discoveryApi, identityApi, + fetchApi, }); MockedEventSource.prototype.addEventListener.mockImplementation( @@ -171,6 +174,7 @@ describe('TechDocsStorageClient', () => { }, ); + identityApi.getCredentials.mockResolvedValue({}); await expect(storageApi.syncEntityDocs(mockEntity)).resolves.toEqual( 'updated', ); @@ -181,6 +185,7 @@ describe('TechDocsStorageClient', () => { configApi, discoveryApi, identityApi, + fetchApi, }); MockedEventSource.prototype.addEventListener.mockImplementation( @@ -195,6 +200,7 @@ describe('TechDocsStorageClient', () => { }, ); + identityApi.getCredentials.mockResolvedValue({}); const logHandler = jest.fn(); await expect( storageApi.syncEntityDocs(mockEntity, logHandler), @@ -209,9 +215,11 @@ describe('TechDocsStorageClient', () => { configApi, discoveryApi, identityApi, + fetchApi, }); // we await later after we emitted the error + identityApi.getCredentials.mockResolvedValue({}); const promise = storageApi.syncEntityDocs(mockEntity).then(); // flush the event loop @@ -234,9 +242,11 @@ describe('TechDocsStorageClient', () => { configApi, discoveryApi, identityApi, + fetchApi, }); // we await later after we emitted the error + identityApi.getCredentials.mockResolvedValue({}); const promise = storageApi.syncEntityDocs(mockEntity).then(); // flush the event loop diff --git a/plugins/techdocs/src/client.ts b/plugins/techdocs/src/client.ts index 5cf33ac8d5..eac5da0b6b 100644 --- a/plugins/techdocs/src/client.ts +++ b/plugins/techdocs/src/client.ts @@ -16,7 +16,11 @@ import { EntityName } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; -import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import { + DiscoveryApi, + FetchApi, + IdentityApi, +} from '@backstage/core-plugin-api'; import { NotFoundError, ResponseError } from '@backstage/errors'; import { EventSourcePolyfill } from 'event-source-polyfill'; import { SyncResult, TechDocsApi, TechDocsStorageApi } from './api'; @@ -24,24 +28,22 @@ import { TechDocsEntityMetadata, TechDocsMetadata } from './types'; /** * API to talk to `techdocs-backend`. + * + * @public */ export class TechDocsClient implements TechDocsApi { public configApi: Config; public discoveryApi: DiscoveryApi; - public identityApi: IdentityApi; + private fetchApi: FetchApi; - constructor({ - configApi, - discoveryApi, - identityApi, - }: { + constructor(options: { configApi: Config; discoveryApi: DiscoveryApi; - identityApi: IdentityApi; + fetchApi: FetchApi; }) { - this.configApi = configApi; - this.discoveryApi = discoveryApi; - this.identityApi = identityApi; + this.configApi = options.configApi; + this.discoveryApi = options.discoveryApi; + this.fetchApi = options.fetchApi; } async getApiOrigin(): Promise { @@ -65,12 +67,7 @@ export class TechDocsClient implements TechDocsApi { const apiOrigin = await this.getApiOrigin(); const requestUrl = `${apiOrigin}/metadata/techdocs/${namespace}/${kind}/${name}`; - const { token } = await this.identityApi.getCredentials(); - - const request = await fetch(`${requestUrl}`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - + const request = await this.fetchApi.fetch(`${requestUrl}`); if (!request.ok) { throw await ResponseError.fromResponse(request); } @@ -93,12 +90,8 @@ export class TechDocsClient implements TechDocsApi { const apiOrigin = await this.getApiOrigin(); const requestUrl = `${apiOrigin}/metadata/entity/${namespace}/${kind}/${name}`; - const { token } = await this.identityApi.getCredentials(); - - const request = await fetch(`${requestUrl}`, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); + const request = await this.fetchApi.fetch(`${requestUrl}`); if (!request.ok) { throw await ResponseError.fromResponse(request); } @@ -109,24 +102,25 @@ export class TechDocsClient implements TechDocsApi { /** * API which talks to TechDocs storage to fetch files to render. + * + * @public */ export class TechDocsStorageClient implements TechDocsStorageApi { public configApi: Config; public discoveryApi: DiscoveryApi; public identityApi: IdentityApi; + private fetchApi: FetchApi; - constructor({ - configApi, - discoveryApi, - identityApi, - }: { + constructor(options: { configApi: Config; discoveryApi: DiscoveryApi; identityApi: IdentityApi; + fetchApi: FetchApi; }) { - this.configApi = configApi; - this.discoveryApi = discoveryApi; - this.identityApi = identityApi; + this.configApi = options.configApi; + this.discoveryApi = options.discoveryApi; + this.identityApi = options.identityApi; + this.fetchApi = options.fetchApi; } async getApiOrigin(): Promise { @@ -160,13 +154,9 @@ export class TechDocsStorageClient implements TechDocsStorageApi { const storageUrl = await this.getStorageUrl(); const url = `${storageUrl}/${namespace}/${kind}/${name}/${path}`; - const { token } = await this.identityApi.getCredentials(); - const request = await fetch( + const request = await this.fetchApi.fetch( `${url.endsWith('/') ? url : `${url}/`}index.html`, - { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }, ); let errorMessage = ''; diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index 844003987a..a37ad61f80 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -28,6 +28,7 @@ import { createPlugin, createRoutableExtension, discoveryApiRef, + fetchApiRef, identityApiRef, } from '@backstage/core-plugin-api'; @@ -40,12 +41,14 @@ export const techdocsPlugin = createPlugin({ configApi: configApiRef, discoveryApi: discoveryApiRef, identityApi: identityApiRef, + fetchApi: fetchApiRef, }, - factory: ({ configApi, discoveryApi, identityApi }) => + factory: ({ configApi, discoveryApi, identityApi, fetchApi }) => new TechDocsStorageClient({ configApi, discoveryApi, identityApi, + fetchApi, }), }), createApiFactory({ @@ -53,13 +56,13 @@ export const techdocsPlugin = createPlugin({ deps: { configApi: configApiRef, discoveryApi: discoveryApiRef, - identityApi: identityApiRef, + fetchApi: fetchApiRef, }, - factory: ({ configApi, discoveryApi, identityApi }) => + factory: ({ configApi, discoveryApi, fetchApi }) => new TechDocsClient({ configApi, discoveryApi, - identityApi, + fetchApi, }), }), ],