Merge pull request #8769 from backstage/freben/techdocs-clients

Some more usage of the `FetchApi`
This commit is contained in:
Fredrik Adelöw
2022-01-20 11:25:56 +01:00
committed by GitHub
28 changed files with 677 additions and 227 deletions
+1
View File
@@ -359,6 +359,7 @@ export class FetchMiddlewares {
identityApi: IdentityApi;
config?: Config;
urlPrefixAllowlist?: string[];
allowUrl?: (url: string) => boolean;
header?: {
name: string;
value: (backstageToken: string) => string;
@@ -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;
@@ -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);
});
});
@@ -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}/`),
);
}
@@ -23,6 +23,9 @@ import { ApiRef, createApiRef } from '../system';
* @public
*/
export type FetchApi = {
/**
* The `fetch` implementation.
*/
fetch: typeof fetch;
};
+29
View File
@@ -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<IdentityApi, 'getCredentials'>;
};
resolvePluginProtocol?:
| undefined
| {
discoveryApi: Pick<DiscoveryApi, 'getBaseUrl'>;
};
}
// @public
export class MockPermissionApi implements PermissionApi {
constructor(
+3 -1
View File
@@ -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"
+1
View File
@@ -15,3 +15,4 @@
*/
import '@testing-library/jest-dom';
import 'cross-fetch/polyfill';
@@ -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',
);
});
});
});
@@ -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<DiscoveryApi, 'getBaseUrl'> };
/**
* 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<IdentityApi, 'getCredentials'> };
}
/**
* 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<IdentityApi, 'getCredentials'> =
'token' in options
? { getCredentials: async () => ({ token: options.token }) }
: options.identityApi;
return FetchMiddlewares.injectIdentityAuth({
identityApi: identityApi as IdentityApi,
allowUrl: () => true,
});
}
@@ -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';
@@ -17,5 +17,6 @@
export * from './AnalyticsApi';
export * from './ConfigApi';
export * from './ErrorApi';
export * from './FetchApi';
export * from './PermissionApi';
export * from './StorageApi';
+1 -14
View File
@@ -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';
@@ -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());
}