Refactor the implicit logic from <Reader /> into an explicit state machine

Signed-off-by: Dominik Henneke <dominik.henneke@sda-se.com>
This commit is contained in:
Dominik Henneke
2021-06-09 10:49:10 +02:00
parent fea7fa0ba6
commit 1dfec7a2ae
10 changed files with 1053 additions and 264 deletions
-149
View File
@@ -1,149 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { EntityName } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { DiscoveryApi, IdentityApi } from '@backstage/core';
import { NotFoundError } from '@backstage/errors';
import { TechDocsStorageApi } from '../src/api';
export class TechDocsDevStorageApi implements TechDocsStorageApi {
public configApi: Config;
public discoveryApi: DiscoveryApi;
public identityApi: IdentityApi;
constructor({
configApi,
discoveryApi,
identityApi,
}: {
configApi: Config;
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
}) {
this.configApi = configApi;
this.discoveryApi = discoveryApi;
this.identityApi = identityApi;
}
async getApiOrigin() {
return (
this.configApi.getOptionalString('techdocs.requestUrl') ??
(await this.discoveryApi.getBaseUrl('techdocs'))
);
}
async getStorageUrl() {
return (
this.configApi.getOptionalString('techdocs.storageUrl') ??
`${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs`
);
}
async getBuilder() {
return this.configApi.getString('techdocs.builder');
}
async fetchUrl(url: string) {
const token = await this.identityApi.getIdToken();
return fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
}
async getEntityDocs(entityId: EntityName, path: string) {
const { kind, namespace, name } = entityId;
const storageUrl = await this.getStorageUrl();
const url = `${storageUrl}/${namespace}/${kind}/${name}/${path}`;
const token = await this.identityApi.getIdToken();
const request = await fetch(
`${url.endsWith('/') ? url : `${url}/`}index.html`,
{
headers: token ? { Authorization: `Bearer ${token}` } : {},
},
);
let errorMessage = '';
switch (request.status) {
case 404:
errorMessage = 'Page not found. ';
// path is empty for the home page of an entity's docs site
if (!path) {
errorMessage +=
'This could be because there is no index.md file in the root of the docs directory of this repository.';
}
throw new NotFoundError(errorMessage);
case 500:
errorMessage =
'Could not generate documentation or an error in the TechDocs backend. ';
throw new Error(errorMessage);
default:
// Do nothing
break;
}
return request.text();
}
/**
* Check if docs are the latest version and trigger rebuilds if not
*
* @param {EntityName} entityId Object containing entity data like name, namespace, etc.
* @returns {boolean} Whether documents are currently synchronized to newest version
* @throws {Error} Throws error on error from sync endpoint
*/
async syncEntityDocs(entityId: EntityName) {
const { kind, namespace, name } = entityId;
const apiOrigin = await this.getApiOrigin();
const url = `${apiOrigin}/sync/${namespace}/${kind}/${name}`;
let request;
let attempts: number = 0;
// retry if request times out, up to 5 times
// can happen due to docs taking too long to generate
while (!request || (request.status === 408 && attempts < 5)) {
attempts++;
request = await this.fetchUrl(
`${url.endsWith('/') ? url : `${url}/`}index.html`,
);
}
switch (request.status) {
case 404:
throw (await request.json()).error;
case 200:
case 201:
return true;
// for timeout and misc errors, handle without error to allow viewing older docs
// if older docs not available,
// Reader will show 404 error coming from getEntityDocs
case 408:
default:
return false;
}
}
async getBaseUrl(
oldBaseUrl: string,
entityId: EntityName,
path: string,
): Promise<string> {
const { name } = entityId;
const apiOrigin = await this.getApiOrigin();
return new URL(oldBaseUrl, `${apiOrigin}/${name}/${path}`).toString();
}
}
+182 -11
View File
@@ -14,11 +14,105 @@
* limitations under the License.
*/
import { configApiRef, discoveryApiRef, identityApiRef } from '@backstage/core';
import {
configApiRef,
discoveryApiRef,
Header,
identityApiRef,
Page,
TabbedLayout,
} from '@backstage/core';
import { createDevApp } from '@backstage/dev-utils';
import { techdocsPlugin } from '../src/plugin';
import { TechDocsDevStorageApi } from './api';
import { techdocsStorageApiRef } from '../src';
import { NotFoundError } from '@backstage/errors';
import React from 'react';
import {
Reader,
SyncResult,
TechDocsStorageApi,
techdocsStorageApiRef,
} from '../src';
// used so each route can provide it's own implementation in the constructor of the react component
let apiHolder: TechDocsStorageApi | undefined = undefined;
const apiBridge: TechDocsStorageApi = {
getApiOrigin: async () => '',
getBaseUrl: (...args) => apiHolder!.getBaseUrl(...args),
getBuilder: () => apiHolder!.getBuilder(),
getStorageUrl: () => apiHolder!.getStorageUrl(),
getEntityDocs: (...args) => apiHolder!.getEntityDocs(...args),
syncEntityDocs: (...args) => apiHolder!.syncEntityDocs(...args),
};
const mockContent = `
<h1>Hello World!</h1>
<p>This is an example content that will actually be provided by a MkDocs powered site</p>
`;
function createPage({
entityDocs,
syncDocs,
syncDocsDelay,
}: {
entityDocs?: (props: {
called: number;
content: string;
}) => string | Promise<string>;
syncDocs: () => SyncResult;
syncDocsDelay?: number;
}) {
class Api implements TechDocsStorageApi {
private entityDocsCallCount: number = 0;
getApiOrigin = async () => '';
getBaseUrl = async () => '';
getBuilder = async () => 'local';
getStorageUrl = async () => '';
async getEntityDocs() {
await new Promise(resolve => setTimeout(resolve, 500));
if (!entityDocs) {
return mockContent;
}
return entityDocs({
called: this.entityDocsCallCount++,
content: mockContent,
});
}
async syncEntityDocs() {
if (syncDocsDelay) {
await new Promise(resolve => setTimeout(resolve, syncDocsDelay));
}
return syncDocs();
}
}
class Component extends React.Component {
constructor(props: {}) {
super(props);
apiHolder = new Api();
}
render() {
return (
<Reader
entityId={{
kind: 'Component',
namespace: 'default',
name: 'my-docs',
}}
/>
);
}
}
return <Component />;
}
createDevApp()
.registerApi({
@@ -28,12 +122,89 @@ createDevApp()
discoveryApi: discoveryApiRef,
identityApi: identityApiRef,
},
factory: ({ configApi, discoveryApi, identityApi }) =>
new TechDocsDevStorageApi({
configApi,
discoveryApi,
identityApi,
}),
factory: () => apiBridge,
})
.addPage({
title: 'TechDocs',
element: (
<Page themeId="home">
<Header title="TechDocs" />
<TabbedLayout>
<TabbedLayout.Route path="/fresh" title="Fresh">
{createPage({
syncDocs: () => 'cached',
})}
</TabbedLayout.Route>
<TabbedLayout.Route path="/stale" title="Stale">
{createPage({
syncDocs: () => 'updated',
syncDocsDelay: 2000,
})}
</TabbedLayout.Route>
<TabbedLayout.Route path="/initial" title="Initial Build">
{createPage({
entityDocs: ({ called, content }) => {
if (called < 1) {
throw new NotFoundError();
}
return content;
},
syncDocs: () => 'updated',
syncDocsDelay: 10000,
})}
</TabbedLayout.Route>
<TabbedLayout.Route path="/not-found" title="Not Found">
{createPage({
entityDocs: () => {
throw new NotFoundError('Not found, some error message...');
},
syncDocs: () => 'cached',
})}
</TabbedLayout.Route>
<TabbedLayout.Route path="/error" title="Error">
{createPage({
entityDocs: () => {
throw new Error('Another more critical error');
},
syncDocs: () => 'cached',
})}
</TabbedLayout.Route>
<TabbedLayout.Route path="/serror" title="Sync Error">
{createPage({
syncDocs: () => {
throw new Error('Some random error');
},
syncDocsDelay: 2000,
})}
</TabbedLayout.Route>
<TabbedLayout.Route path="/berror" title="Both Error">
{createPage({
entityDocs: () => {
throw new Error('Some random error');
},
syncDocs: () => {
throw new Error('Some random error');
},
syncDocsDelay: 2000,
})}
</TabbedLayout.Route>
<TabbedLayout.Route path="/timeout" title="Sync Timeout">
{createPage({
syncDocs: () => 'timeout',
syncDocsDelay: 2000,
})}
</TabbedLayout.Route>
</TabbedLayout>
</Page>
),
})
.registerPlugin(techdocsPlugin)
.render();