Added api for adr plugin

Switched to using an AdrApi and removed the fetchers for adr reading.

Signed-off-by: Robert Bunning <rbunning@webstaurantstore.com>
This commit is contained in:
Robert Bunning
2022-12-23 13:11:49 -05:00
parent b267b6c045
commit 654e8ac5af
11 changed files with 184 additions and 452 deletions
+66
View File
@@ -0,0 +1,66 @@
/*
* Copyright 2022 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.
*/
import { DiscoveryApi } from '@backstage/core-plugin-api';
import { AdrApi, AdrListResult, AdrReadResult } from './types';
/**
* Options for creating an AdrClient.
*
* @public
*/
export interface AdrClientOptions {
discoveryApi: DiscoveryApi;
}
const readEndpoint = 'file';
const listEndpoint = 'list';
/**
* An implementation of the AdrApi that communicates with the ADR backend plugin.
*
* @public
*/
export class AdrClient implements AdrApi {
private readonly discoveryApi: DiscoveryApi;
constructor(options: AdrClientOptions) {
this.discoveryApi = options.discoveryApi;
}
private async fetchAdrApi<T>(endpoint: string, fileUrl: string): Promise<T> {
const baseUrl = await this.discoveryApi.getBaseUrl('adr');
const targetUrl = `${baseUrl}/${endpoint}?url=${encodeURIComponent(
fileUrl,
)}`;
const result = await fetch(targetUrl);
const data = await result.json();
if (!result.ok) {
throw new Error(data.error.message);
}
return data;
}
async listAdrs(url: string): Promise<AdrListResult> {
return this.fetchAdrApi<AdrListResult>(listEndpoint, url);
}
async readAdr(url: string): Promise<AdrReadResult> {
return this.fetchAdrApi<AdrReadResult>(readEndpoint, url);
}
}
@@ -13,4 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './useOctokitRequest';
export { AdrClient } from './AdrClient';
export type { AdrClientOptions } from './AdrClient';
export { adrApiRef } from './types';
export type {
AdrApi,
AdrFileInfo,
AdrListResult,
AdrReadResult,
} from './types';
+74
View File
@@ -0,0 +1,74 @@
/*
* Copyright 2022 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.
*/
import { createApiRef } from '@backstage/core-plugin-api';
/**
* Contains information about an ADR file.
*
* @public
*/
export type AdrFileInfo = {
/** The file type. */
type: string;
/** The relative path of the ADR file. */
path: string;
/** The name of the ADR file. */
name: string;
};
/**
* The result of listing ADRs.
*
* @public
*/
export type AdrListResult = {
data: AdrFileInfo[];
};
/**
* The result of reading an ADR.
*
* @public
*/
export type AdrReadResult = {
/** The contents of the read ADR file. */
data: string;
};
/**
* The API used by the adr plugin to list and read ADRs.
*
* @public
*/
export interface AdrApi {
/** Lists the ADRs at the provided url. */
listAdrs(url: string): Promise<AdrListResult>;
/** Reads the contents of the ADR at the provided url. */
readAdr(url: string): Promise<AdrReadResult>;
}
/**
* ApiRef for the AdrApi.
*
* @public
*/
export const adrApiRef = createApiRef<AdrApi>({
id: 'plugin.adr.api',
});
@@ -1,129 +0,0 @@
/*
* Copyright 2022 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.
*/
import React from 'react';
import { EntityLayout } from '@backstage/plugin-catalog';
import { Entity, ANNOTATION_SOURCE_LOCATION } from '@backstage/catalog-model';
import { ApiProvider } from '@backstage/core-app-api';
import { CatalogApi } from '@backstage/catalog-client';
import {
EntityProvider,
catalogApiRef,
starredEntitiesApiRef,
MockStarredEntitiesApi,
} from '@backstage/plugin-catalog-react';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
import { permissionApiRef } from '@backstage/plugin-permission-react';
import {
renderInTestApp,
TestApiRegistry,
MockPermissionApi,
} from '@backstage/test-utils';
import { AdrReader } from './AdrReader';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { ANNOTATION_ADR_LOCATION } from '@backstage/plugin-adr-common';
import {
octokitAdrFileFetcher,
urlReaderAdrFileFetcher,
} from '../../hooks/adrFileFetcher';
const mockApis = TestApiRegistry.from(
[catalogApiRef, {} as CatalogApi],
[starredEntitiesApiRef, new MockStarredEntitiesApi()],
[permissionApiRef, new MockPermissionApi()],
[
scmIntegrationsApiRef,
{
resolveUrl: options => `${options.url}`,
} as ScmIntegrationRegistry,
],
);
const mockEntity: Entity = {
kind: 'TestEntity',
metadata: {
name: 'Testing Entity 1',
annotations: {
[ANNOTATION_ADR_LOCATION]: 'testAdrFolder',
[ANNOTATION_SOURCE_LOCATION]: 'source:location',
},
},
apiVersion: '',
};
afterEach(() => {
jest.resetAllMocks();
});
describe('AdrReader', () => {
it('Falls back to octokitAdrFileFetcher when adrFileFetcher is not specified', async () => {
const spyInstance = jest
.spyOn(octokitAdrFileFetcher, 'useReadAdrFileAtUrl')
.mockImplementation(() => {
return { data: '' };
});
await renderInTestApp(
<ApiProvider apis={mockApis}>
<EntityProvider entity={mockEntity}>
<EntityLayout>
<EntityLayout.Route path="/" title="tabbed-test-title">
<AdrReader adr="testadr.md" />
</EntityLayout.Route>
</EntityLayout>
</EntityProvider>
</ApiProvider>,
);
expect(spyInstance).toHaveBeenCalled();
});
it('Uses an alternative AdrFileFetcher when provided', async () => {
const octokitSpyInstance = jest
.spyOn(octokitAdrFileFetcher, 'useReadAdrFileAtUrl')
.mockImplementation(() => {
return {
data: '',
};
});
const urlReadersSpyInstance = jest
.spyOn(urlReaderAdrFileFetcher, 'useReadAdrFileAtUrl')
.mockImplementation(() => {
return {
data: '',
};
});
await renderInTestApp(
<ApiProvider apis={mockApis}>
<EntityProvider entity={mockEntity}>
<EntityLayout>
<EntityLayout.Route path="/" title="tabbed-test-title">
<AdrReader
adr="testadr.md"
adrFileFetcher={urlReaderAdrFileFetcher}
/>
</EntityLayout.Route>
</EntityLayout>
</EntityProvider>
</ApiProvider>,
);
expect(octokitSpyInstance).not.toHaveBeenCalled();
expect(urlReadersSpyInstance).toHaveBeenCalled();
});
});
@@ -28,10 +28,8 @@ import { useEntity } from '@backstage/plugin-catalog-react';
import { adrDecoratorFactories } from './decorators';
import { AdrContentDecorator } from './types';
import {
AdrFileFetcher,
octokitAdrFileFetcher,
} from '../../hooks/adrFileFetcher';
import { adrApiRef } from '../../api';
import useAsync from 'react-use/lib/useAsync';
/**
* Component to fetch and render an ADR.
@@ -41,16 +39,17 @@ import {
export const AdrReader = (props: {
adr: string;
decorators?: AdrContentDecorator[];
adrFileFetcher?: AdrFileFetcher;
}) => {
const { adr, decorators, adrFileFetcher } = props;
const { adr, decorators } = props;
const { entity } = useEntity();
const scmIntegrations = useApi(scmIntegrationsApiRef);
const adrApi = useApi(adrApiRef);
const adrLocationUrl = getAdrLocationUrl(entity, scmIntegrations);
const targetAdrFileFetcher = adrFileFetcher ?? octokitAdrFileFetcher;
const { value, loading, error } = targetAdrFileFetcher.useReadAdrFileAtUrl(
`${adrLocationUrl.replace(/\/$/, '')}/${adr}`,
const url = `${adrLocationUrl.replace(/\/$/, '')}/${adr}`;
const { value, loading, error } = useAsync(
async () => adrApi.readAdr(url),
[url],
);
const adrContent = useMemo(() => {
@@ -1,139 +0,0 @@
/*
* Copyright 2022 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.
*/
import React from 'react';
import { EntityLayout } from '@backstage/plugin-catalog';
import { Entity, ANNOTATION_SOURCE_LOCATION } from '@backstage/catalog-model';
import { ApiProvider } from '@backstage/core-app-api';
import { CatalogApi } from '@backstage/catalog-client';
import {
EntityProvider,
catalogApiRef,
starredEntitiesApiRef,
MockStarredEntitiesApi,
} from '@backstage/plugin-catalog-react';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
import { permissionApiRef } from '@backstage/plugin-permission-react';
import {
renderInTestApp,
TestApiRegistry,
MockPermissionApi,
} from '@backstage/test-utils';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { ANNOTATION_ADR_LOCATION } from '@backstage/plugin-adr-common';
import {
octokitAdrFileFetcher,
urlReaderAdrFileFetcher,
} from '../../hooks/adrFileFetcher';
import { EntityAdrContent } from './EntityAdrContent';
import { rootRouteRef } from '../../routes';
const mockApis = TestApiRegistry.from(
[catalogApiRef, {} as CatalogApi],
[starredEntitiesApiRef, new MockStarredEntitiesApi()],
[permissionApiRef, new MockPermissionApi()],
[
scmIntegrationsApiRef,
{
resolveUrl: options => `${options.url}`,
} as ScmIntegrationRegistry,
],
);
const mockEntity: Entity = {
kind: 'TestEntity',
metadata: {
name: 'Testing Entity 1',
annotations: {
[ANNOTATION_ADR_LOCATION]: 'testAdrFolder',
[ANNOTATION_SOURCE_LOCATION]: 'source:location',
},
},
apiVersion: '',
};
afterEach(() => {
jest.resetAllMocks();
});
describe('EntityAdrContent', () => {
it('Falls back to octokitAdrFileFetcher when adrFileFetcher is not specified', async () => {
const getAdrFilesSpyInstance = jest
.spyOn(octokitAdrFileFetcher, 'useGetAdrFilesAtUrl')
.mockImplementation(() => {
return {
data: [],
};
});
await renderInTestApp(
<ApiProvider apis={mockApis}>
<EntityProvider entity={mockEntity}>
<EntityLayout>
<EntityLayout.Route path="/" title="tabbed-test-title">
<EntityAdrContent />
</EntityLayout.Route>
</EntityLayout>
</EntityProvider>
</ApiProvider>,
{
mountedRoutes: {
'/adr': rootRouteRef,
},
},
);
expect(getAdrFilesSpyInstance).toHaveBeenCalled();
});
it('Uses an alternative AdrFileFetcher when provided', async () => {
const octokitGetAdrFilesSpyInstance = jest
.spyOn(octokitAdrFileFetcher, 'useGetAdrFilesAtUrl')
.mockImplementation(() => {
return {
data: [],
};
});
const urlReadersGetAdrFilesSpyInstance = jest
.spyOn(urlReaderAdrFileFetcher, 'useGetAdrFilesAtUrl')
.mockImplementation(() => {
return {
data: [],
};
});
await renderInTestApp(
<ApiProvider apis={mockApis}>
<EntityProvider entity={mockEntity}>
<EntityLayout>
<EntityLayout.Route path="/" title="tabbed-test-title">
<EntityAdrContent adrFileFetcher={urlReaderAdrFileFetcher} />
</EntityLayout.Route>
</EntityLayout>
</EntityProvider>
</ApiProvider>,
{
mountedRoutes: {
'/adr': rootRouteRef,
},
},
);
expect(octokitGetAdrFilesSpyInstance).not.toHaveBeenCalled();
expect(urlReadersGetAdrFilesSpyInstance).toHaveBeenCalled();
});
});
@@ -46,10 +46,8 @@ import {
import { rootRouteRef } from '../../routes';
import { AdrContentDecorator, AdrReader } from '../AdrReader';
import {
AdrFileFetcher,
octokitAdrFileFetcher,
} from '../../hooks/adrFileFetcher';
import { adrApiRef } from '../../api';
import useAsync from 'react-use/lib/useAsync';
const useStyles = makeStyles((theme: Theme) => ({
adrMenu: {
@@ -64,20 +62,21 @@ const useStyles = makeStyles((theme: Theme) => ({
export const EntityAdrContent = (props: {
contentDecorators?: AdrContentDecorator[];
filePathFilterFn?: AdrFilePathFilterFn;
adrFileFetcher?: AdrFileFetcher;
}) => {
const { contentDecorators, filePathFilterFn, adrFileFetcher } = props;
const { contentDecorators, filePathFilterFn } = props;
const classes = useStyles();
const { entity } = useEntity();
const rootLink = useRouteRef(rootRouteRef);
const [adrList, setAdrList] = useState<string[]>([]);
const [searchParams, setSearchParams] = useSearchParams();
const scmIntegrations = useApi(scmIntegrationsApiRef);
const adrApi = useApi(adrApiRef);
const entityHasAdrs = isAdrAvailable(entity);
const targetAdrFileFetcher = adrFileFetcher ?? octokitAdrFileFetcher;
const { value, loading, error } = targetAdrFileFetcher.useGetAdrFilesAtUrl(
getAdrLocationUrl(entity, scmIntegrations),
const url = getAdrLocationUrl(entity, scmIntegrations);
const { value, loading, error } = useAsync(
async () => adrApi.listAdrs(url),
[url],
);
const selectedAdr =
@@ -147,11 +146,7 @@ export const EntityAdrContent = (props: {
</List>
</Grid>
<Grid item xs={9}>
<AdrReader
adr={selectedAdr}
adrFileFetcher={targetAdrFileFetcher}
decorators={contentDecorators}
/>
<AdrReader adr={selectedAdr} decorators={contentDecorators} />
</Grid>
</Grid>
) : (
-99
View File
@@ -1,99 +0,0 @@
/*
* Copyright 2022 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.
*/
import {
discoveryApiRef,
useApi,
DiscoveryApi,
} from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';
import { useOctokitRequest } from './useOctokitRequest';
const useAdrApi = (
endpoint: string,
fileUrl: string,
discoveryApi: DiscoveryApi,
) => {
return async () => {
const baseUrl = await discoveryApi.getBaseUrl('adr');
const targetUrl = `${baseUrl}/${endpoint}?url=${encodeURIComponent(
fileUrl,
)}`;
const result = await fetch(targetUrl);
const data = await result.json();
if (!result.ok) {
throw data;
}
return data;
};
};
/**
* Represents something that is capable of fetching a listing of adr files at a provided url
* and fetching the contents of an adr file at a provided url.
*
* @public
*/
export interface AdrFileFetcher {
/**
* A hook to get a listing of adr files that exist at the provided url
*
* @param url - The url to get files from
*/
useGetAdrFilesAtUrl: (url: string) => any;
/**
* A hook to get the contents of the adr file at the provided url
*
* @param url - The url of the adr file
*/
useReadAdrFileAtUrl: (url: string) => any;
}
const getAdrFilesEndpoint = 'list';
const readAdrFileEndpoint = 'file';
/**
* An AdrFileFetcher that uses UrlReaders to fetch adr files
*
* @public
*/
export const urlReaderAdrFileFetcher: AdrFileFetcher = {
useGetAdrFilesAtUrl(url: string) {
const discoveryApi = useApi(discoveryApiRef);
return useAsync<any>(useAdrApi(getAdrFilesEndpoint, url, discoveryApi), [
url,
]);
},
useReadAdrFileAtUrl(url: string) {
const discoveryApi = useApi(discoveryApiRef);
return useAsync<any>(useAdrApi(readAdrFileEndpoint, url, discoveryApi), [
url,
]);
},
};
/**
* An AdrFileFetcher that uses the useOctokitRequest hook for fetching adr files
*
* @public
*/
export const octokitAdrFileFetcher: AdrFileFetcher = {
useGetAdrFilesAtUrl: (url: string) => useOctokitRequest(url),
useReadAdrFileAtUrl: (url: string) => useOctokitRequest(url),
};
@@ -1,59 +0,0 @@
/*
* Copyright 2022 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.
*/
import parseGitUrl from 'git-url-parse';
import useAsync from 'react-use/lib/useAsync';
import { Octokit } from 'octokit';
import { useApi } from '@backstage/core-plugin-api';
import {
scmAuthApiRef,
scmIntegrationsApiRef,
} from '@backstage/integration-react';
/**
* Hook for triggering authenticated Octokit requests against the GitHub Content API
* @public
*/
export const useOctokitRequest = (request: string): any => {
const authApi = useApi(scmAuthApiRef);
const scmIntegrations = useApi(scmIntegrationsApiRef);
const { owner, name, ref, filepath } = parseGitUrl(request);
const path = filepath.replace(/^\//, '');
const baseUrl = scmIntegrations.github.byUrl(request)?.config.apiBaseUrl;
return useAsync<any>(async () => {
const { token } = await authApi.getCredentials({
url: request,
additionalScope: {
customScopes: {
github: ['repo'],
},
},
});
const octokit = new Octokit({
auth: token,
baseUrl,
});
return octokit.request(
`GET /repos/${owner}/${name}/contents/${path}?ref=${ref}`,
{
headers: { Accept: 'application/vnd.github.v3.raw' },
},
);
}, [request]);
};
+3 -1
View File
@@ -13,13 +13,15 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* ADR frontend plugin
*
* @packageDocumentation
*/
export { adrApiRef, AdrClient } from './api';
export { isAdrAvailable } from '@backstage/plugin-adr-common';
export * from './components/AdrReader';
export { adrPlugin, EntityAdrContent } from './plugin';
export * from './search';
export * from './hooks/adrFileFetcher';
+14 -1
View File
@@ -14,11 +14,13 @@
* limitations under the License.
*/
import { adrApiRef, AdrClient } from './api';
import {
createApiFactory,
createPlugin,
createRoutableExtension,
discoveryApiRef,
} from '@backstage/core-plugin-api';
import { rootRouteRef } from './routes';
/**
@@ -27,6 +29,17 @@ import { rootRouteRef } from './routes';
*/
export const adrPlugin = createPlugin({
id: 'adr',
apis: [
createApiFactory({
api: adrApiRef,
deps: {
discoveryApi: discoveryApiRef,
},
factory({ discoveryApi }) {
return new AdrClient({ discoveryApi });
},
}),
],
routes: {
root: rootRouteRef,
},