Merge branch 'master' of github.com:backstage/backstage into improve-errors

This commit is contained in:
Adam Harvey
2021-02-08 11:48:19 -05:00
420 changed files with 6609 additions and 2109 deletions
+9 -1
View File
@@ -16,6 +16,7 @@
import React from 'react';
import { Entity } from '@backstage/catalog-model';
import { useEntity } from '@backstage/plugin-catalog-react';
import { Route, Routes } from 'react-router-dom';
import { MissingAnnotationEmptyState } from '@backstage/core';
import {
@@ -38,7 +39,14 @@ export const Router = () => {
);
};
export const EmbeddedDocsRouter = ({ entity }: { entity: Entity }) => {
type Props = {
/** @deprecated The entity is now grabbed from context instead */
entity?: Entity;
};
export const EmbeddedDocsRouter = (_props: Props) => {
const { entity } = useEntity();
const projectId = entity.metadata.annotations?.[TECHDOCS_ANNOTATION];
if (!projectId) {
+22 -10
View File
@@ -13,10 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config } from '@backstage/config';
import { UrlPatternDiscovery } from '@backstage/core';
import { TechDocsStorageApi } from './api';
const DOC_STORAGE_URL = 'https://example-storage.com';
const mockEntity = {
kind: 'Component',
namespace: 'default',
@@ -24,19 +24,31 @@ const mockEntity = {
};
describe('TechDocsStorageApi', () => {
it('should return correct base url based on defined storage', () => {
const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL });
const mockBaseUrl = 'http://backstage:9191/api/techdocs';
const configApi = {
getOptionalString: () => 'http://backstage:9191/api/techdocs',
} as Partial<Config>;
const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl);
expect(storageApi.getBaseUrl('test.js', mockEntity, '')).toEqual(
`${DOC_STORAGE_URL}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test.js`,
it('should return correct base url based on defined storage', async () => {
// @ts-ignore Partial<Config> not assignable to Config.
const storageApi = new TechDocsStorageApi({ configApi, discoveryApi });
await expect(
storageApi.getBaseUrl('test.js', mockEntity, ''),
).resolves.toEqual(
`${mockBaseUrl}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test.js`,
);
});
it('should return base url with correct entity structure', () => {
const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL });
it('should return base url with correct entity structure', async () => {
// @ts-ignore Partial<Config> not assignable to Config.
const storageApi = new TechDocsStorageApi({ configApi, discoveryApi });
expect(storageApi.getBaseUrl('test/', mockEntity, '')).toEqual(
`${DOC_STORAGE_URL}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test/`,
await expect(
storageApi.getBaseUrl('test/', mockEntity, ''),
).resolves.toEqual(
`${mockBaseUrl}/docs/${mockEntity.namespace}/${mockEntity.kind}/${mockEntity.name}/test/`,
);
});
});
+74 -21
View File
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { createApiRef } from '@backstage/core';
import { createApiRef, DiscoveryApi } from '@backstage/core';
import { Config } from '@backstage/config';
import { EntityName } from '@backstage/catalog-model';
import { TechDocsMetadata } from './types';
@@ -30,7 +31,11 @@ export const techdocsApiRef = createApiRef<TechDocsApi>({
export interface TechDocsStorage {
getEntityDocs(entityId: EntityName, path: string): Promise<string>;
getBaseUrl(oldBaseUrl: string, entityId: EntityName, path: string): string;
getBaseUrl(
oldBaseUrl: string,
entityId: EntityName,
path: string,
): Promise<string>;
}
export interface TechDocs {
@@ -44,10 +49,25 @@ export interface TechDocs {
* @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API
*/
export class TechDocsApi implements TechDocs {
public apiOrigin: string;
public configApi: Config;
public discoveryApi: DiscoveryApi;
constructor({ apiOrigin }: { apiOrigin: string }) {
this.apiOrigin = apiOrigin;
constructor({
configApi,
discoveryApi,
}: {
configApi: Config;
discoveryApi: DiscoveryApi;
}) {
this.configApi = configApi;
this.discoveryApi = discoveryApi;
}
async getApiOrigin() {
return (
this.configApi.getOptionalString('techdocs.requestUrl') ??
(await this.discoveryApi.getBaseUrl('techdocs'))
);
}
/**
@@ -62,7 +82,8 @@ export class TechDocsApi implements TechDocs {
async getTechDocsMetadata(entityId: EntityName) {
const { kind, namespace, name } = entityId;
const requestUrl = `${this.apiOrigin}/metadata/techdocs/${namespace}/${kind}/${name}`;
const apiOrigin = await this.getApiOrigin();
const requestUrl = `${apiOrigin}/metadata/techdocs/${namespace}/${kind}/${name}`;
const request = await fetch(`${requestUrl}`);
const res = await request.json();
@@ -81,7 +102,8 @@ export class TechDocsApi implements TechDocs {
async getEntityMetadata(entityId: EntityName) {
const { kind, namespace, name } = entityId;
const requestUrl = `${this.apiOrigin}/metadata/entity/${namespace}/${kind}/${name}`;
const apiOrigin = await this.getApiOrigin();
const requestUrl = `${apiOrigin}/metadata/entity/${namespace}/${kind}/${name}`;
const request = await fetch(`${requestUrl}`);
const res = await request.json();
@@ -96,10 +118,25 @@ export class TechDocsApi implements TechDocs {
* @property {string} apiOrigin Set to techdocs.requestUrl as the URL for techdocs-backend API
*/
export class TechDocsStorageApi implements TechDocsStorage {
public apiOrigin: string;
public configApi: Config;
public discoveryApi: DiscoveryApi;
constructor({ apiOrigin }: { apiOrigin: string }) {
this.apiOrigin = apiOrigin;
constructor({
configApi,
discoveryApi,
}: {
configApi: Config;
discoveryApi: DiscoveryApi;
}) {
this.configApi = configApi;
this.discoveryApi = discoveryApi;
}
async getApiOrigin() {
return (
this.configApi.getOptionalString('techdocs.requestUrl') ??
(await this.discoveryApi.getBaseUrl('techdocs'))
);
}
/**
@@ -113,31 +150,47 @@ export class TechDocsStorageApi implements TechDocsStorage {
async getEntityDocs(entityId: EntityName, path: string) {
const { kind, namespace, name } = entityId;
const url = `${this.apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`;
const apiOrigin = await this.getApiOrigin();
const url = `${apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`;
const request = await fetch(
`${url.endsWith('/') ? url : `${url}/`}index.html`,
);
if (request.status === 404) {
let 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 Error(errorMessage);
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 Error(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();
}
getBaseUrl(oldBaseUrl: string, entityId: EntityName, path: string): string {
async getBaseUrl(
oldBaseUrl: string,
entityId: EntityName,
path: string,
): Promise<string> {
const { kind, namespace, name } = entityId;
const apiOrigin = await this.getApiOrigin();
return new URL(
oldBaseUrl,
`${this.apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`,
`${apiOrigin}/docs/${namespace}/${kind}/${name}/${path}`,
).toString();
}
}
+6 -1
View File
@@ -14,7 +14,12 @@
* limitations under the License.
*/
export { plugin } from './plugin';
export {
techdocsPlugin,
techdocsPlugin as plugin,
TechdocsPage,
EntityTechdocsContent,
} from './plugin';
export { Router, EmbeddedDocsRouter } from './Router';
export * from './reader';
export * from './api';
+2 -2
View File
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import { plugin } from './plugin';
import { techdocsPlugin } from './plugin';
describe('techdocs', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
expect(techdocsPlugin).toBeDefined();
});
});
+29 -8
View File
@@ -34,6 +34,8 @@ import {
createRouteRef,
createApiFactory,
configApiRef,
discoveryApiRef,
createRoutableExtension,
} from '@backstage/core';
import {
techdocsStorageApiRef,
@@ -57,25 +59,44 @@ export const rootCatalogDocsRouteRef = createRouteRef({
title: 'Docs',
});
// TODO: Use discovery API for frontend to get URL for techdocs-backend instead of requestUrl
export const plugin = createPlugin({
export const techdocsPlugin = createPlugin({
id: 'techdocs',
apis: [
createApiFactory({
api: techdocsStorageApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) =>
deps: { configApi: configApiRef, discoveryApi: discoveryApiRef },
factory: ({ configApi, discoveryApi }) =>
new TechDocsStorageApi({
apiOrigin: configApi.getString('techdocs.requestUrl'),
configApi,
discoveryApi,
}),
}),
createApiFactory({
api: techdocsApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) =>
deps: { configApi: configApiRef, discoveryApi: discoveryApiRef },
factory: ({ configApi, discoveryApi }) =>
new TechDocsApi({
apiOrigin: configApi.getString('techdocs.requestUrl'),
configApi,
discoveryApi,
}),
}),
],
routes: {
root: rootRouteRef,
entityContent: rootCatalogDocsRouteRef,
},
});
export const TechdocsPage = techdocsPlugin.provide(
createRoutableExtension({
component: () => import('./Router').then(m => m.Router),
mountPoint: rootRouteRef,
}),
);
export const EntityTechdocsContent = techdocsPlugin.provide(
createRoutableExtension({
component: () => import('./Router').then(m => m.EmbeddedDocsRouter),
mountPoint: rootCatalogDocsRouteRef,
}),
);
@@ -129,7 +129,7 @@ export const Reader = ({ entityId, onReady }: Props) => {
},
}),
onCssReady({
docStorageUrl: techdocsStorageApi.apiOrigin,
docStorageUrl: techdocsStorageApi.getApiOrigin(),
onLoading: (dom: Element) => {
(dom as HTMLElement).style.setProperty('opacity', '0');
},
@@ -155,7 +155,9 @@ export const Reader = ({ entityId, onReady }: Props) => {
]);
if (error) {
return <TechDocsNotFound errorMessage={error.message} />;
// TODO Enhance API call to return customize error objects so we can identify which we ran into
// For now this defaults to display error code 404
return <TechDocsNotFound statusCode={404} errorMessage={error.message} />;
}
return (
@@ -27,13 +27,12 @@ import {
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { Grid } from '@material-ui/core';
import React from 'react';
import { generatePath, useNavigate } from 'react-router-dom';
import { generatePath } from 'react-router-dom';
import { useAsync } from 'react-use';
import { rootDocsRouteRef } from '../../plugin';
export const TechDocsHome = () => {
const catalogApi = useApi(catalogApiRef);
const navigate = useNavigate();
const { value, loading, error } = useAsync(async () => {
const response = await catalogApi.getEntities();
@@ -87,15 +86,11 @@ export const TechDocsHome = () => {
? value.map((entity, index: number) => (
<Grid key={index} item xs={12} sm={6} md={3}>
<ItemCard
onClick={() =>
navigate(
generatePath(rootDocsRouteRef.path, {
namespace: entity.metadata.namespace ?? 'default',
kind: entity.kind,
name: entity.metadata.name,
}),
)
}
href={generatePath(rootDocsRouteRef.path, {
namespace: entity.metadata.namespace ?? 'default',
kind: entity.kind,
name: entity.metadata.name,
})}
title={entity.metadata.name}
label="Read Docs"
description={entity.metadata.description}
@@ -41,3 +41,20 @@ describe('<TechDocsNotFound errorMessage="This is a custom error message" />', (
expect(rendered.getByTestId('go-back-link')).toBeDefined();
});
});
describe('<TechDocsNotFound statusCode={500} errorMessage="This is a custom error message" />', () => {
it('should render with a custom status code, custom error message and go back link', () => {
const rendered = render(
wrapInTestApp(
<TechDocsNotFound
statusCode={500}
errorMessage="This is a custom error message"
/>,
),
);
rendered.getByText(/This is a custom error message/i);
rendered.getByText(/500/i);
rendered.getByText(/Looks like someone dropped the mic!/i);
expect(rendered.getByTestId('go-back-link')).toBeDefined();
});
});
@@ -19,9 +19,10 @@ import { ErrorPage, useApi, configApiRef } from '@backstage/core';
type Props = {
errorMessage?: string;
statusCode?: number;
};
export const TechDocsNotFound = ({ errorMessage }: Props) => {
export const TechDocsNotFound = ({ errorMessage, statusCode }: Props) => {
const techdocsBuilder = useApi(configApiRef).getOptionalString(
'techdocs.builder',
);
@@ -37,7 +38,7 @@ export const TechDocsNotFound = ({ errorMessage }: Props) => {
return (
<ErrorPage
status="404"
status={statusCode ? statusCode.toString() : '404'}
statusMessage={errorMessage || 'Documentation not found'}
additionalInfo={additionalInfo}
/>
@@ -56,7 +56,8 @@ describe('<TechDocsPage />', () => {
};
const techdocsStorageApi: Partial<TechDocsStorageApi> = {
getEntityDocs: (): Promise<string> => Promise.resolve('String'),
getBaseUrl: (): string => '',
getBaseUrl: (): Promise<string> => Promise.resolve('String'),
getApiOrigin: (): Promise<string> => Promise.resolve('String'),
};
const apiRegistry = ApiRegistry.from([
@@ -21,7 +21,7 @@ import { TechDocsStorage } from '../../api';
const DOC_STORAGE_URL = 'https://example-host.storage.googleapis.com';
const techdocsStorageApi: TechDocsStorage = {
getBaseUrl: jest.fn(() => DOC_STORAGE_URL),
getBaseUrl: jest.fn(() => Promise.resolve(DOC_STORAGE_URL)),
getEntityDocs: () => new Promise(resolve => resolve('yes!')),
};
@@ -35,12 +35,12 @@ export const addBaseUrl = ({
): void => {
Array.from(list)
.filter(elem => !!elem.getAttribute(attributeName))
.forEach((elem: T) => {
.forEach(async (elem: T) => {
const elemAttribute = elem.getAttribute(attributeName);
if (!elemAttribute) return;
elem.setAttribute(
attributeName,
techdocsStorageApi.getBaseUrl(elemAttribute, entityId, path),
await techdocsStorageApi.getBaseUrl(elemAttribute, entityId, path),
);
});
};
@@ -22,8 +22,9 @@ import {
} from '../../test-utils';
import { onCssReady } from '../transformers';
const docStorageUrl: string =
'https://techdocs-mock-sites.storage.googleapis.com';
const docStorageUrl: Promise<string> = Promise.resolve(
'https://techdocs-mock-sites.storage.googleapis.com',
);
const fixture = `
<link rel="stylesheet" href="${docStorageUrl}/test.css" />
@@ -17,7 +17,7 @@
import type { Transformer } from './index';
type OnCssReadyOptions = {
docStorageUrl: string;
docStorageUrl: Promise<string>;
onLoading: (dom: Element) => void;
onLoaded: (dom: Element) => void;
};
@@ -30,7 +30,9 @@ export const onCssReady = ({
return dom => {
const cssPages = Array.from(
dom.querySelectorAll('head > link[rel="stylesheet"]'),
).filter(elem => elem.getAttribute('href')?.startsWith(docStorageUrl));
).filter(async elem =>
elem.getAttribute('href')?.startsWith(await docStorageUrl),
);
let count = cssPages.length;