implement injectIdentityAuth

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2022-01-07 20:46:52 +01:00
parent 19ea047412
commit fb565073ec
10 changed files with 177 additions and 137 deletions
@@ -34,31 +34,51 @@ describe('MockFetchApi', () => {
await expect(response.json()).resolves.toEqual({ a: 'foo' });
});
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('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('setAuthorization', () => {
it('works with a static token', async () => {
describe('resolvePluginProtocol', () => {
it('works', async () => {
const inner = jest.fn();
const m = new MockFetchApi({
baseImplementation: inner,
authorization: { token: 'hello' },
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(
expect(inner.mock.calls[0][0].headers?.get('authorization')).toBe(
'Bearer hello',
);
});
it('works with an identity api', async () => {
it('works with identityApi', async () => {
const inner = jest.fn();
const m = new MockFetchApi({
baseImplementation: inner,
authorization: {
injectIdentityAuth: {
identityApi: {
async getCredentials() {
return { token: 'hello2' };
@@ -67,7 +87,7 @@ describe('MockFetchApi', () => {
},
});
await m.fetch('http://example.com/data.json');
expect(inner.mock.calls[0][0].headers.get('authorization')).toBe(
expect(inner.mock.calls[0][0].headers?.get('authorization')).toBe(
'Bearer hello2',
);
});
@@ -14,8 +14,17 @@
* limitations under the License.
*/
import { FetchApi, IdentityApi } from '@backstage/core-plugin-api';
import crossFetch, { Request, Response } from 'cross-fetch';
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}.
@@ -26,11 +35,11 @@ export interface MockFetchApiOptions {
/**
* Define the underlying base `fetch` implementation.
*
* @defaultValue 'fetch'
* @defaultValue undefined
* @remarks
*
* `'fetch'` uses the global `fetch` implementation to make real network
* requests. This is the default.
* 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.
*
@@ -38,22 +47,44 @@ export interface MockFetchApiOptions {
* `jest.fn()`, if you want to use a custom implementation or to just track
* and assert on calls.
*/
baseImplementation?: 'fetch' | 'none' | typeof crossFetch | undefined;
baseImplementation?: undefined | 'none' | typeof crossFetch;
/**
* If defined, adds token based Authorization headers to requests, basically
* Add translation from `plugin://` URLs to concrete http(s) URLs, basically
* simulating what
* {@link @backstage/core-app-api#FetchMiddlewares.injectIdentityAuth} does.
* {@link @backstage/core-app-api#FetchMiddlewares.resolvePluginProtocol}
* does.
*
* @defaultValue undefined
* @remarks
*
* You can supply either a static token or an identity API.
* 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.
*/
authorization?:
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'> }
| undefined;
| { identityApi: Pick<IdentityApi, 'getCredentials'> };
}
/**
@@ -62,7 +93,7 @@ export interface MockFetchApiOptions {
* @public
*/
export class MockFetchApi implements FetchApi {
private readonly implementation: typeof crossFetch;
private readonly implementation: FetchApi;
/**
* Creates a mock {@link @backstage/core-plugin-api#FetchApi}.
@@ -73,7 +104,7 @@ export class MockFetchApi implements FetchApi {
/** {@inheritdoc @backstage/core-plugin-api#FetchApi.fetch} */
get fetch(): typeof crossFetch {
return this.implementation;
return this.implementation.fetch;
}
}
@@ -81,51 +112,56 @@ export class MockFetchApi implements FetchApi {
// Helpers
//
const dummyFetch: typeof crossFetch = () => Promise.resolve(new Response(null));
function build(options?: MockFetchApiOptions): typeof crossFetch {
let implementation = baseImplementation(options);
implementation = authorization(options, implementation);
return implementation;
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 ?? 'fetch';
if (implementation === 'fetch') {
const implementation = options?.baseImplementation;
if (!implementation) {
return crossFetch;
} else if (implementation === 'none') {
return dummyFetch;
return () => Promise.resolve(new Response());
}
return implementation;
}
function authorization(
options: MockFetchApiOptions | undefined,
next: typeof crossFetch,
): typeof crossFetch {
const auth = options?.authorization;
if (!auth) {
return next;
function resolvePluginProtocol(
allOptions: MockFetchApiOptions | undefined,
): FetchMiddleware | undefined {
const options = allOptions?.resolvePluginProtocol;
if (!options) {
return undefined;
}
const getToken = async () => {
if ('token' in auth) {
return auth.token;
}
const { token } = await auth.identityApi.getCredentials();
return token;
};
return async (input, init) => {
const request = new Request(input, init);
if (!request.headers.get('authorization')) {
const token = await getToken();
if (token) {
request.headers.set('authorization', `Bearer ${token}`);
}
}
return next(request);
};
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,
});
}