diff --git a/app-config.yaml b/app-config.yaml index 03d13443dd..88df40813e 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -42,7 +42,7 @@ organization: techdocs: storageUrl: http://localhost:7000/api/techdocs/static/docs - requestUrl: http://localhost:7000/api/techdocs/docs + requestUrl: http://localhost:7000/api/techdocs generators: techdocs: 'docker' diff --git a/plugins/techdocs/src/reader/components/TechDocsPageWrapper.tsx b/plugins/techdocs-backend/src/service/metadata.ts similarity index 53% rename from plugins/techdocs/src/reader/components/TechDocsPageWrapper.tsx rename to plugins/techdocs-backend/src/service/metadata.ts index b511fde1e7..671fcdda17 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPageWrapper.tsx +++ b/plugins/techdocs-backend/src/service/metadata.ts @@ -1,3 +1,4 @@ +import fetch from 'node-fetch'; /* * Copyright 2020 Spotify AB * @@ -14,24 +15,26 @@ * limitations under the License. */ -import React from 'react'; -import { Header, Content, Page, pageTheme } from '@backstage/core'; +export class TechDocsMetadata { + private async getMetadataFile(docsUrl: String) { + const metadataURL = `${docsUrl}/techdocs_metadata.json`; -type TechDocsPageWrapperProps = { - title: string; - subtitle: string; - children: any; -}; + try { + const req = await fetch(metadataURL); -export const TechDocsPageWrapper = ({ - children, - title, - subtitle, -}: TechDocsPageWrapperProps) => { - return ( - -
- {children} - - ); -}; + return await req.json(); + } catch (error) { + throw new Error(error); + } + } + + public async getMkDocsMetaData(docsUrl: any) { + const mkDocsMetadata = await this.getMetadataFile(docsUrl); + + if (!mkDocsMetadata) return null; + + return { + ...mkDocsMetadata, + }; + } +} diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index bd9e71cb8b..365534b9d0 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -32,6 +32,7 @@ import { } from '@backstage/backend-common'; import { Entity } from '@backstage/catalog-model'; import { DocsBuilder } from './helpers'; +import { getLocationForEntity } from '../helpers'; type RouterOptions = { preparers: PreparerBuilder; @@ -60,6 +61,46 @@ export async function createRouter({ }: RouterOptions): Promise { const router = Router(); + router.get('/metadata/mkdocs/*', async (req, res) => { + const storageUrl = config.getString('techdocs.storageUrl'); + const { '0': path } = req.params; + + const metadataURL = `${storageUrl}/${path}/techdocs_metadata.json`; + + try { + const mkDocsMetadata = await (await fetch(metadataURL)).json(); + res.send(mkDocsMetadata); + } catch (err) { + logger.info( + `[TechDocs] Unable to get metadata for ${path} with error ${err}`, + ); + throw new Error(`Unable to get metadata for ${path} with error ${err}`); + } + }); + + router.get('/metadata/entity/:kind/:namespace/:name', async (req, res) => { + const baseUrl = config.getString('backend.baseUrl'); + const { kind, namespace, name } = req.params; + + try { + const entity = (await ( + await fetch( + `${baseUrl}/api/catalog/entities/by-name/${kind}/${namespace}/${name}`, + ) + ).json()) as Entity; + + const locationMetadata = getLocationForEntity(entity); + res.send({ ...entity, locationMetadata }); + } catch (err) { + logger.info( + `[TechDocs] Unable to get metadata for ${kind}/${namespace}/${name} with error ${err}`, + ); + throw new Error( + `Unable to get metadata for ${kind}/${namespace}/${name} with error ${err}`, + ); + } + }); + router.get('/docs/:kind/:namespace/:name/*', async (req, res) => { const storageUrl = config.getString('techdocs.storageUrl'); diff --git a/plugins/techdocs/src/api.test.ts b/plugins/techdocs/src/api.test.ts index 0ab05d5ecd..f7c52f8a7d 100644 --- a/plugins/techdocs/src/api.test.ts +++ b/plugins/techdocs/src/api.test.ts @@ -28,7 +28,7 @@ describe('TechDocsStorageApi', () => { const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL }); expect(storageApi.getBaseUrl('test.js', mockEntity, '')).toEqual( - `${DOC_STORAGE_URL}/${mockEntity.kind}/${mockEntity.namespace}/${mockEntity.name}/test.js`, + `${DOC_STORAGE_URL}/docs/${mockEntity.kind}/${mockEntity.namespace}/${mockEntity.name}/test.js`, ); }); @@ -36,7 +36,7 @@ describe('TechDocsStorageApi', () => { const storageApi = new TechDocsStorageApi({ apiOrigin: DOC_STORAGE_URL }); expect(storageApi.getBaseUrl('test/', mockEntity, '')).toEqual( - `${DOC_STORAGE_URL}/${mockEntity.kind}/${mockEntity.namespace}/${mockEntity.name}/test/`, + `${DOC_STORAGE_URL}/docs/${mockEntity.kind}/${mockEntity.namespace}/${mockEntity.name}/test/`, ); }); }); diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts index 6fc913e5df..87a0a56a01 100644 --- a/plugins/techdocs/src/api.ts +++ b/plugins/techdocs/src/api.ts @@ -23,6 +23,11 @@ export const techdocsStorageApiRef = createApiRef({ description: 'Used to make requests towards the techdocs storage', }); +export const techdocsApiRef = createApiRef({ + id: 'plugin.techdocs.service', + description: 'Used to make requests towards techdocs API', +}); + export interface TechDocsStorage { getEntityDocs(entityId: ParsedEntityId, path: string): Promise; getBaseUrl( @@ -32,6 +37,31 @@ export interface TechDocsStorage { ): string; } +export interface TechDocs { + getMetadata(metadataType: string, entityId: ParsedEntityId): Promise; +} + +export class TechDocsApi implements TechDocs { + public apiOrigin: string; + + constructor({ apiOrigin }: { apiOrigin: string }) { + this.apiOrigin = apiOrigin; + } + + async getMetadata(metadataType: string, entityId: ParsedEntityId) { + const { kind, namespace, name } = entityId; + + const requestUrl = `${this.apiOrigin}/metadata/${metadataType}/${kind}/${ + namespace ? namespace : 'default' + }/${name}`; + + const request = await fetch(`${requestUrl}`); + const res = await request.json(); + + return res; + } +} + export class TechDocsStorageApi implements TechDocsStorage { public apiOrigin: string; @@ -42,7 +72,7 @@ export class TechDocsStorageApi implements TechDocsStorage { async getEntityDocs(entityId: ParsedEntityId, path: string) { const { kind, namespace, name } = entityId; - const url = `${this.apiOrigin}/${kind}/${ + const url = `${this.apiOrigin}/docs/${kind}/${ namespace ? namespace : 'default' }/${name}/${path}`; @@ -66,7 +96,7 @@ export class TechDocsStorageApi implements TechDocsStorage { return new URL( oldBaseUrl, - `${this.apiOrigin}/${kind}/${ + `${this.apiOrigin}/docs/${kind}/${ namespace ? namespace : 'default' }/${name}/${path}`, ).toString(); diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index 1f15e9ebd6..b197bdfb76 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -35,7 +35,12 @@ import { createApiFactory, configApiRef, } from '@backstage/core'; -import { techdocsStorageApiRef, TechDocsStorageApi } from './api'; +import { + techdocsStorageApiRef, + TechDocsStorageApi, + techdocsApiRef, + TechDocsApi, +} from './api'; export const rootRouteRef = createRouteRef({ path: '', @@ -63,5 +68,13 @@ export const plugin = createPlugin({ apiOrigin: configApi.getString('techdocs.requestUrl'), }), }), + createApiFactory({ + api: techdocsApiRef, + deps: { configApi: configApiRef }, + factory: ({ configApi }) => + new TechDocsApi({ + apiOrigin: configApi.getString('techdocs.requestUrl'), + }), + }), ], }); diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index f2c6d1e2da..550b8df923 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -39,9 +39,10 @@ import { TechDocsNotFound } from './TechDocsNotFound'; type Props = { entityId: ParsedEntityId; + onReady?: () => void; }; -export const Reader = ({ entityId }: Props) => { +export const Reader = ({ entityId, onReady }: Props) => { const { kind, namespace, name } = entityId; const { '*': path } = useParams(); const theme = useTheme(); @@ -58,7 +59,9 @@ export const Reader = ({ entityId }: Props) => { if (!shadowRoot || loading || error) { return; // Shadow DOM isn't ready / It's not ready / Docs was not found } - + if (onReady) { + onReady(); + } // Pre-render const transformedElement = transformer(value as string, [ sanitizeDOM(), @@ -143,6 +146,7 @@ export const Reader = ({ entityId }: Props) => { navigate, techdocsStorageApi, theme, + onReady, ]); if (error) { diff --git a/plugins/techdocs/src/reader/components/TechDocsHome.tsx b/plugins/techdocs/src/reader/components/TechDocsHome.tsx index e51de8f57f..c478a52127 100644 --- a/plugins/techdocs/src/reader/components/TechDocsHome.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsHome.tsx @@ -18,8 +18,15 @@ import React from 'react'; import { useAsync } from 'react-use'; import { useNavigate, generatePath } from 'react-router-dom'; import { Grid } from '@material-ui/core'; -import { ItemCard, Progress, useApi } from '@backstage/core'; -import { TechDocsPageWrapper } from './TechDocsPageWrapper'; +import { + ItemCard, + Progress, + useApi, + Content, + Page, + pageTheme, + Header, +} from '@backstage/core'; import { catalogApiRef } from '@backstage/plugin-catalog'; import { rootDocsRouteRef } from '../../plugin'; @@ -36,53 +43,62 @@ export const TechDocsHome = () => { if (loading) { return ( - - - + +
+ + + + ); } if (error) { return ( - -

{error.message}

-
+ +
+ +

{error.message}

+
+ ); } return ( - - - {value?.length - ? value.map((entity, index: number) => ( - - - navigate( - generatePath(rootDocsRouteRef.path, { - entityId: `${entity.kind}:${ - entity.metadata.namespace ?? '' - }:${entity.metadata.name}`, - }), - ) - } - title={entity.metadata.name} - label="Read Docs" - description={entity.metadata.description} - /> - - )) - : null} - - + +
+ + + {value?.length + ? value.map((entity, index: number) => ( + + + navigate( + generatePath(rootDocsRouteRef.path, { + entityId: `${entity.kind}:${ + entity.metadata.namespace ?? '' + }:${entity.metadata.name}`, + }), + ) + } + title={entity.metadata.name} + label="Read Docs" + description={entity.metadata.description} + /> + + )) + : null} + + + ); }; diff --git a/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx new file mode 100644 index 0000000000..464115bab7 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsPage.test.tsx @@ -0,0 +1,77 @@ +/* + * 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 React from 'react'; +import { TechDocsPage } from './TechDocsPage'; +import { render, act } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { ApiRegistry, ApiProvider } from '@backstage/core-api'; +import { + techdocsApiRef, + TechDocsApi, + techdocsStorageApiRef, + TechDocsStorageApi, +} from '../../api'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { + ...actual, + useParams: jest.fn(), + }; +}); + +jest.mock('./TechDocsPageHeader', () => { + return { + __esModule: true, + TechDocsPageHeader: () =>
, + }; +}); + +const { useParams }: { useParams: jest.Mock } = jest.requireMock( + 'react-router-dom', +); + +describe('', () => { + it('should render techdocs page', async () => { + useParams.mockReturnValue({ + entityId: 'Component::backstage', + }); + + const techDocsApi: Partial = { + getMetadata: () => Promise.resolve([]), + }; + const techDocsStorageApi: Partial = { + getEntityDocs: (): Promise => Promise.resolve('String'), + getBaseUrl: (): string => '', + }; + + const apiRegistry = ApiRegistry.from([ + [techdocsApiRef, techDocsApi], + [techdocsStorageApiRef, techDocsStorageApi], + ]); + + await act(async () => { + const rendered = render( + wrapInTestApp( + + + , + ), + ); + expect(rendered.getByTestId('techdocs-content')).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/techdocs/src/reader/components/TechDocsPage.tsx b/plugins/techdocs/src/reader/components/TechDocsPage.tsx index 1328d13ee9..a70ce76191 100644 --- a/plugins/techdocs/src/reader/components/TechDocsPage.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsPage.tsx @@ -14,26 +14,60 @@ * limitations under the License. */ -import React from 'react'; +import React, { useState } from 'react'; import { useParams } from 'react-router-dom'; - -import { TechDocsPageWrapper } from './TechDocsPageWrapper'; +import { Content, Page, pageTheme, useApi } from '@backstage/core'; import { Reader } from './Reader'; +import { useAsync } from 'react-use'; +import { TechDocsPageHeader } from './TechDocsPageHeader'; +import { techdocsApiRef } from '../../api'; export const TechDocsPage = () => { + const [documentReady, setDocumentReady] = useState(false); const { entityId } = useParams(); - const [kind, namespace, name] = entityId.split(':'); + const techDocsApi = useApi(techdocsApiRef); + + const mkdocsMetadataRequest = useAsync(() => { + if (documentReady) { + return techDocsApi.getMetadata('mkdocs', { kind, namespace, name }); + } + + return Promise.resolve({ loading: true }); + }, [kind, namespace, name, techDocsApi, documentReady]); + + const entityMetadataRequest = useAsync(() => { + return techDocsApi.getMetadata('entity', { kind, namespace, name }); + }, [kind, namespace, name, techDocsApi]); + + const onReady = () => { + setDocumentReady(true); + }; + return ( - - + - + + + + ); }; diff --git a/plugins/techdocs/src/reader/components/TechDocsPageHeader.test.tsx b/plugins/techdocs/src/reader/components/TechDocsPageHeader.test.tsx new file mode 100644 index 0000000000..70388e81c2 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsPageHeader.test.tsx @@ -0,0 +1,87 @@ +/* + * 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 React from 'react'; +import { TechDocsPageHeader } from './TechDocsPageHeader'; +import { render, act } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; + +describe('', () => { + it('should render a techdocs page header', async () => { + await act(async () => { + const rendered = render( + wrapInTestApp( + , + ), + ); + expect(rendered.container.innerHTML).toContain('header'); + expect(rendered.getByText('test-site-name')).toBeDefined(); + expect(rendered.getByText('test-site-desc')).toBeDefined(); + }); + }); + + it('should render a techdocs page header even if metadata is missing', async () => { + await act(async () => { + const rendered = render( + wrapInTestApp( + , + ), + ); + + expect(rendered.container.innerHTML).toContain('header'); + }); + }); +}); diff --git a/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx new file mode 100644 index 0000000000..b01ace4994 --- /dev/null +++ b/plugins/techdocs/src/reader/components/TechDocsPageHeader.tsx @@ -0,0 +1,88 @@ +/* + * 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 React from 'react'; +import GitHubIcon from '@material-ui/icons/GitHub'; +import { Header, HeaderLabel, Link } from '@backstage/core'; +import { CircularProgress } from '@material-ui/core'; +import { ParsedEntityId } from '../../types'; +import { AsyncState } from 'react-use/lib/useAsync'; + +type TechDocsPageHeaderProps = { + entityId: ParsedEntityId; + metadataRequest: { + entity: AsyncState; + mkdocs: AsyncState; + }; +}; + +export const TechDocsPageHeader = ({ + entityId, + metadataRequest, +}: TechDocsPageHeaderProps) => { + const { mkdocs: mkdocsMetadata, entity: entityMetadata } = metadataRequest; + + const { value: mkDocsMetadataValues } = mkdocsMetadata; + const { value: entityMetadataValues } = entityMetadata; + + const { kind, name } = entityId; + + const { site_name: siteName, site_description: siteDescription } = + mkDocsMetadataValues || {}; + + const { + locationMetadata, + spec: { owner, lifecycle }, + } = entityMetadataValues || { spec: {} }; + + const labels = ( + <> + + {name} + + } + /> + {owner ? : null} + {lifecycle ? : null} + {locationMetadata && + locationMetadata.type !== 'dir' && + locationMetadata.type !== 'file' ? ( + + + + } + /> + ) : null} + + ); + + return ( +
} + subtitle={ + siteDescription && siteDescription !== 'None' ? siteDescription : '' + } + > + {labels} +
+ ); +}; diff --git a/plugins/techdocs/src/reader/components/TechDocsPageWrapper.test.tsx b/plugins/techdocs/src/reader/components/TechDocsPageWrapper.test.tsx deleted file mode 100644 index 5265492fb3..0000000000 --- a/plugins/techdocs/src/reader/components/TechDocsPageWrapper.test.tsx +++ /dev/null @@ -1,33 +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 { TechDocsPageWrapper } from './TechDocsPageWrapper'; -import React from 'react'; -import { render } from '@testing-library/react'; -import { wrapInTestApp } from '@backstage/test-utils'; - -describe('TechDocs Page Wrapper', () => { - it('should render a TechDocs Page Wrapper', async () => { - const { queryByText } = render( - wrapInTestApp( - - Test - , - ), - ); - expect(queryByText(/test-title/i)).toBeInTheDocument(); - expect(queryByText(/test-subtitle/i)).toBeInTheDocument(); - }); -});