Merge pull request #24861 from alexef/new-events-implementation

fix: move from event-source-polyfill to fetch-event-source
This commit is contained in:
Patrik Oldsberg
2024-06-03 15:00:29 +02:00
committed by GitHub
14 changed files with 152 additions and 264 deletions
+1 -4
View File
@@ -29,7 +29,6 @@ import {
createApiFactory,
discoveryApiRef,
fetchApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import {
compatWrapper,
@@ -55,14 +54,12 @@ const techDocsStorageApi = createApiExtension({
deps: {
configApi: configApiRef,
discoveryApi: discoveryApiRef,
identityApi: identityApiRef,
fetchApi: fetchApiRef,
},
factory: ({ configApi, discoveryApi, identityApi, fetchApi }) =>
factory: ({ configApi, discoveryApi, fetchApi }) =>
new TechDocsStorageClient({
configApi,
discoveryApi,
identityApi,
fetchApi,
}),
}),
+54 -97
View File
@@ -17,16 +17,15 @@
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 { fetchEventSource } from '@microsoft/fetch-event-source';
import { MockConfigApi, MockFetchApi } from '@backstage/test-utils';
import { TechDocsStorageClient } from './client';
const MockedEventSource = EventSourcePolyfill as jest.MockedClass<
typeof EventSourcePolyfill
jest.mock('@microsoft/fetch-event-source');
const mockFetchEventSource = fetchEventSource as jest.MockedFunction<
typeof fetchEventSource
>;
jest.mock('event-source-polyfill');
const mockEntity = {
kind: 'Component',
namespace: 'default',
@@ -51,7 +50,6 @@ describe('TechDocsStorageClient', () => {
const storageApi = new TechDocsStorageClient({
configApi,
discoveryApi,
identityApi,
fetchApi,
});
@@ -72,7 +70,6 @@ describe('TechDocsStorageClient', () => {
const storageApi = new TechDocsStorageClient({
configApi,
discoveryApi,
identityApi,
fetchApi,
});
@@ -84,71 +81,50 @@ describe('TechDocsStorageClient', () => {
});
describe('syncEntityDocs', () => {
it('should create eventsource without headers', async () => {
it('should create eventsource with fetch', async () => {
const storageApi = new TechDocsStorageClient({
configApi,
discoveryApi,
identityApi,
fetchApi,
});
MockedEventSource.prototype.addEventListener.mockImplementation(
(type, fn) => {
if (type === 'finish' && typeof fn === 'function') {
fn({ data: '{"updated": false}' } as any);
}
},
);
mockFetchEventSource.mockImplementation(async (_url, options) => {
const { onopen, onmessage } = options;
await Promise.resolve();
await onopen?.({ ok: true } as Response);
await Promise.resolve();
onmessage?.({ id: '', event: 'finish', data: '{"updated": false}' });
});
identityApi.getCredentials.mockResolvedValue({});
await storageApi.syncEntityDocs(mockEntity);
expect(MockedEventSource).toHaveBeenCalledWith(
expect(mockFetchEventSource).toHaveBeenCalledWith(
'http://backstage:9191/api/techdocs/sync/default/Component/test-component',
{ withCredentials: true, headers: {} },
);
});
it('should create eventsource with headers', async () => {
const storageApi = new TechDocsStorageClient({
configApi,
discoveryApi,
identityApi,
fetchApi,
});
MockedEventSource.prototype.addEventListener.mockImplementation(
(type, fn) => {
if (type === 'finish' && typeof fn === 'function') {
fn({ data: '{"updated": false}' } as any);
}
{
fetch: fetchApi.fetch,
onerror: expect.any(Function),
onmessage: expect.any(Function),
signal: expect.any(AbortSignal),
},
);
identityApi.getCredentials.mockResolvedValue({ token: 'token' });
await storageApi.syncEntityDocs(mockEntity);
expect(MockedEventSource).toHaveBeenCalledWith(
'http://backstage:9191/api/techdocs/sync/default/Component/test-component',
{ withCredentials: true, headers: { Authorization: 'Bearer token' } },
);
});
it('should resolve to cached', async () => {
const myFetchApi = new MockFetchApi({
injectIdentityAuth: { identityApi },
});
const storageApi = new TechDocsStorageClient({
configApi,
discoveryApi,
identityApi,
fetchApi,
fetchApi: myFetchApi,
});
mockFetchEventSource.mockImplementation(async (_url, options) => {
const { onopen, onmessage } = options;
await Promise.resolve();
await onopen?.({ ok: true } as Response);
await Promise.resolve();
onmessage?.({ id: '', event: 'finish', data: '{"updated": false}' });
});
MockedEventSource.prototype.addEventListener.mockImplementation(
(type, fn) => {
if (type === 'finish' && typeof fn === 'function') {
fn({ data: '{"updated": false}' } as any);
}
},
);
identityApi.getCredentials.mockResolvedValue({});
await expect(storageApi.syncEntityDocs(mockEntity)).resolves.toEqual(
@@ -160,17 +136,15 @@ describe('TechDocsStorageClient', () => {
const storageApi = new TechDocsStorageClient({
configApi,
discoveryApi,
identityApi,
fetchApi,
});
MockedEventSource.prototype.addEventListener.mockImplementation(
(type, fn) => {
if (type === 'finish' && typeof fn === 'function') {
fn({ data: '{"updated": true}' } as any);
}
},
);
mockFetchEventSource.mockImplementation(async (_url, options) => {
const { onopen, onmessage } = options;
await Promise.resolve();
await onopen?.({ ok: true } as Response);
await Promise.resolve();
onmessage?.({ id: '', event: 'finish', data: '{"updated": true}' });
});
identityApi.getCredentials.mockResolvedValue({});
await expect(storageApi.syncEntityDocs(mockEntity)).resolves.toEqual(
@@ -182,21 +156,18 @@ describe('TechDocsStorageClient', () => {
const storageApi = new TechDocsStorageClient({
configApi,
discoveryApi,
identityApi,
fetchApi,
});
MockedEventSource.prototype.addEventListener.mockImplementation(
(type, fn) => {
if (type === 'log' && typeof fn === 'function') {
fn({ data: '"A log message"' } as any);
}
if (type === 'finish' && typeof fn === 'function') {
fn({ data: '{"updated": false}' } as any);
}
},
);
mockFetchEventSource.mockImplementation(async (_url, options) => {
const { onopen, onmessage } = options;
await Promise.resolve();
await onopen?.({ ok: true } as Response);
await Promise.resolve();
onmessage?.({ id: '', event: 'log', data: '"A log message"' });
await Promise.resolve();
onmessage?.({ id: '', event: 'finish', data: '{"updated": false}' });
});
identityApi.getCredentials.mockResolvedValue({});
const logHandler = jest.fn();
@@ -212,25 +183,18 @@ describe('TechDocsStorageClient', () => {
const storageApi = new TechDocsStorageClient({
configApi,
discoveryApi,
identityApi,
fetchApi,
});
mockFetchEventSource.mockImplementation(async (_url, options) => {
const { onerror } = options;
onerror?.(new NotFoundError('Some not found warning'));
});
// we await later after we emitted the error
identityApi.getCredentials.mockResolvedValue({});
const promise = storageApi.syncEntityDocs(mockEntity).then();
// flush the event loop
await new Promise(r => setTimeout(r));
const instance = MockedEventSource.mock
.instances[0] as jest.Mocked<EventSource>;
instance.onerror?.({
status: 404,
message: 'Some not found warning',
} as any);
await expect(promise).rejects.toThrow(NotFoundError);
await expect(promise).rejects.toThrow('Some not found warning');
});
@@ -239,7 +203,6 @@ describe('TechDocsStorageClient', () => {
const storageApi = new TechDocsStorageClient({
configApi,
discoveryApi,
identityApi,
fetchApi,
});
@@ -247,16 +210,10 @@ describe('TechDocsStorageClient', () => {
identityApi.getCredentials.mockResolvedValue({});
const promise = storageApi.syncEntityDocs(mockEntity).then();
// flush the event loop
await new Promise(r => setTimeout(r));
const instance = MockedEventSource.mock
.instances[0] as jest.Mocked<EventSource>;
instance.onerror?.({
type: 'error',
data: 'Some other error',
} as any);
mockFetchEventSource.mockImplementation(async (_url, options) => {
const { onerror } = options;
onerror?.(new Error('Some other error'));
});
await expect(promise).rejects.toThrow(Error);
await expect(promise).rejects.toThrow('Some other error');
+26 -42
View File
@@ -29,7 +29,7 @@ import {
TechDocsMetadata,
TechDocsStorageApi,
} from '@backstage/plugin-techdocs-react';
import { EventSourcePolyfill } from 'event-source-polyfill';
import { fetchEventSource } from '@microsoft/fetch-event-source';
/**
* API to talk to `techdocs-backend`.
@@ -124,18 +124,17 @@ export class TechDocsClient implements TechDocsApi {
export class TechDocsStorageClient implements TechDocsStorageApi {
public configApi: Config;
public discoveryApi: DiscoveryApi;
public identityApi: IdentityApi;
private fetchApi: FetchApi;
constructor(options: {
configApi: Config;
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
fetchApi: FetchApi;
/** @deprecated identityApi is not needed any more */
identityApi?: IdentityApi;
}) {
this.configApi = options.configApi;
this.discoveryApi = options.discoveryApi;
this.identityApi = options.identityApi;
this.fetchApi = options.fetchApi;
}
@@ -213,47 +212,32 @@ export class TechDocsStorageClient implements TechDocsStorageApi {
const apiOrigin = await this.getApiOrigin();
const url = `${apiOrigin}/sync/${namespace}/${kind}/${name}`;
const { token } = await this.identityApi.getCredentials();
return new Promise((resolve, reject) => {
// Polyfill is used to add support for custom headers and auth
const source = new EventSourcePolyfill(url, {
withCredentials: true,
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
source.addEventListener('log', (e: any) => {
if (e.data) {
logHandler(JSON.parse(e.data));
}
});
source.addEventListener('finish', (e: any) => {
let updated: boolean = false;
if (e.data) {
({ updated } = JSON.parse(e.data));
}
resolve(updated ? 'updated' : 'cached');
});
source.onerror = (e: any) => {
source.close();
switch (e.status) {
// the endpoint returned a 404 status
case 404:
reject(new NotFoundError(e.message));
return;
// also handles the event-stream close. the reject is ignored if the Promise was already
// resolved by a finish event.
default:
const ctrl = new AbortController();
fetchEventSource(url, {
fetch: this.fetchApi.fetch,
signal: ctrl.signal,
onmessage(e: any) {
if (e.event === 'log') {
if (e.data) {
logHandler(JSON.parse(e.data));
}
} else if (e.event === 'finish') {
let updated: boolean = false;
if (e.data) {
({ updated } = JSON.parse(e.data));
}
resolve(updated ? 'updated' : 'cached');
} else if (e.event === 'error') {
reject(new Error(e.data));
return;
}
};
}
},
onerror(err) {
ctrl.abort();
reject(err);
},
});
});
}
-27
View File
@@ -1,27 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
declare module 'event-source-polyfill' {
export class EventSourcePolyfill extends EventSource {
constructor(
url: string,
options?: {
withCredentials?: boolean;
headers?: HeadersInit;
},
);
}
}
+1 -4
View File
@@ -31,7 +31,6 @@ import {
createRoutableExtension,
discoveryApiRef,
fetchApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import {
createSearchResultListItemExtension,
@@ -52,14 +51,12 @@ export const techdocsPlugin = createPlugin({
deps: {
configApi: configApiRef,
discoveryApi: discoveryApiRef,
identityApi: identityApiRef,
fetchApi: fetchApiRef,
},
factory: ({ configApi, discoveryApi, identityApi, fetchApi }) =>
factory: ({ configApi, discoveryApi, fetchApi }) =>
new TechDocsStorageClient({
configApi,
discoveryApi,
identityApi,
fetchApi,
}),
}),