Merge branch 'master' into bugMemberListCard
Signed-off-by: Stefan Petrovic <stefanp0618@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Bump to a newer version of the `concurrently` library
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Internal refactor for load test
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-newrelic': patch
|
||||
---
|
||||
|
||||
The newrelic plugin now supports pagination when retrieving results from newrelic. It will no longer truncate results. To see all applications, the link header will need to be allowed through the proxy (see the newrelic plugin readme).
|
||||
+1
-1
@@ -43,7 +43,7 @@ _You can do this by using the [Adopter form](https://info.backstage.spotify.com/
|
||||
| [MavTek](https://www.mavtek.com/) | [@fgascon](https://github.com/fgascon) | Developer portal focused on standardizing practices, centralizing documentation and streamlining developer practices. |
|
||||
| [QuintoAndar](https://www.quintoandar.com.br/) | [@quintoandar](https://github.com/quintoandar) | Developer portal, services catalog and centralization of service metrics. |
|
||||
| [empathy.co](https://empathy.co/) | [@guillermotti](https://github.com/guillermotti) | Developer portal for tech docs, service catalog, plugin discovery and much more. |
|
||||
| [creditas.com](https://creditas.com/) | [@aureliosaraiva](https://github.com/aureliosaraiva) [@Creditas](https://github.com/creditas) | Centralization of all services, standards, documentation, etc. We started the deployment process. |
|
||||
| [creditas.com](https://creditas.com/) | [@rebender](https://github.com/rebender) [@Creditas](https://github.com/creditas) | Centralization of all services, standards, documentation, etc. We started the deployment process. |
|
||||
| [Prisjakt](https://www.prisjakt.nu) / [PriceSpy](https://pricespy.co.uk) | [@kennylindahl](https://github.com/kennylindahl) | Internal developer portal - Documentation, scaffolding, software catalog, TechRadar, Gitlab org data integration |
|
||||
| [Powerspike](https://powerspike.tv/) | [@trelore](https://github.com/trelore) | Developer portal for documentation of core libraries and repositories. |
|
||||
| [2U](https://2u.com) | [@danielleEriksen](https://github.com/danielleEriksen), [@sbhatia](https://github.com/sbhatia) | Development team home-base, promoting service discoverability, resource dependencies, and tech radar |
|
||||
|
||||
@@ -73,6 +73,8 @@ proxy:
|
||||
target: https://api.newrelic.com/v2
|
||||
headers:
|
||||
X-Api-Key: ${NEW_RELIC_REST_API_KEY}
|
||||
allowedHeaders:
|
||||
- link
|
||||
|
||||
'/newrelic/api':
|
||||
target: https://api.newrelic.com
|
||||
|
||||
+12
-12
@@ -2,25 +2,25 @@
|
||||
id: glossary
|
||||
title: Backstage Glossary
|
||||
# prettier-ignore
|
||||
description: List of all the terms, abbreviations, and phrases used in Backstage, together with their explanations.
|
||||
description: List of terms, abbreviations, and phrases used in Backstage, together with their explanations.
|
||||
---
|
||||
|
||||
The Backstage Glossary lists all the terms, abbreviations, and phrases used in
|
||||
The Backstage Glossary lists terms, abbreviations, and phrases used in
|
||||
Backstage, together with their explanations. We encourage you to use the
|
||||
terminology below for clarity and consistency when discussing Backstage.
|
||||
|
||||
### Authentication Glossary
|
||||
|
||||
This [page](../auth/glossary.md) directs to the terms and phrases related to
|
||||
authentication and identity section of Backstage.
|
||||
See also [Authentication Glossary](../auth/glossary.md), a separate glossary of terms and phrases
|
||||
specifically related to the authentication and identity section of Backstage.
|
||||
|
||||
### Backstage User Profiles
|
||||
|
||||
There are three main user profiles for Backstage: the integrator, the
|
||||
contributor, and the software engineer.
|
||||
contributor, and the end user (typically a software engineer).
|
||||
|
||||
| Term | Explanation |
|
||||
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Integrator | The **integrator** hosts the Backstage app and configures which plugins are available to use in the app. |
|
||||
| Contributor | The **contributor** adds functionality to the app by writing plugins. |
|
||||
| Software Engineer | The **software engineer** uses the app's functionality and interacts with its plugins. In practice, this profile covers the various roles that help deliver software, from the Software Engineer themselves, to Designers, Data Scientists, Product Owners, Engineering Managers, etc. |
|
||||
| Term | Explanation |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Integrator | The **integrator** hosts the Backstage app and configures which plugins are available to use in the app. |
|
||||
| Contributor | The **contributor** adds functionality to the app by writing plugins. |
|
||||
| End user | The **end user** uses the app's functionality and interacts with its plugins. This profile covers the various roles that help deliver software. The typical end user is a **software engineer**, but users might also consider themselves _designers_, _data scientists_, _product owners_, _engineering managers_, _technical writers_, and so on. |
|
||||
| Software engineer | The **software engineer** is an **end user** who uses the app's functionality and interacts with its plugins in the course of writing and documenting code. This user is more likely to embed documentation in the code files they produce, and create rough drafts of conceptual pages in collaboration with a **technical writer** or _technical editor_. |
|
||||
| Technical writer | The **technical writer** is an **end user** who uses the app's functionality and interacts with its plugins in the course of writing and editing documentation. This user is more likely to produce and customize templates and produce conceptual pages to supplement documentation embedded in code files. |
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@
|
||||
"@types/node": "^16.11.26",
|
||||
"@types/webpack": "^5.28.0",
|
||||
"command-exists": "^1.2.9",
|
||||
"concurrently": "^7.0.0",
|
||||
"concurrently": "^8.0.0",
|
||||
"cross-env": "^7.0.0",
|
||||
"e2e-test": "workspace:*",
|
||||
"eslint": "^8.6.0",
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^{{version '@backstage/cli'}}",
|
||||
"@spotify/prettier-config": "^12.0.0",
|
||||
"concurrently": "^6.0.0",
|
||||
"concurrently": "^8.0.0",
|
||||
"lerna": "^4.0.0",
|
||||
"node-gyp": "^9.0.0",
|
||||
"prettier": "^2.3.2",
|
||||
|
||||
+10
-30
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createBackendModule } from '@backstage/backend-plugin-api';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { LocationSpec } from '@backstage/plugin-catalog-common';
|
||||
import {
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
EntityProviderConnection,
|
||||
processingResult,
|
||||
} from '@backstage/plugin-catalog-node';
|
||||
import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha';
|
||||
|
||||
/**
|
||||
* Options for a fixed initial load of entities.
|
||||
@@ -143,12 +141,15 @@ export const common = {
|
||||
|
||||
/**
|
||||
* The entity provider that drives the initial base entity injection
|
||||
* @internal
|
||||
*/
|
||||
class SyntheticLoadEntitiesProvider implements EntityProvider {
|
||||
export class SyntheticLoadEntitiesProvider implements EntityProvider {
|
||||
constructor(
|
||||
private readonly load: SyntheticLoadOptions,
|
||||
private readonly events: SyntheticLoadEvents,
|
||||
) {}
|
||||
) {
|
||||
validateSyntheticLoadOptions(load);
|
||||
}
|
||||
|
||||
getProviderName(): string {
|
||||
return 'SyntheticLoadEntitiesProvider';
|
||||
@@ -180,9 +181,12 @@ class SyntheticLoadEntitiesProvider implements EntityProvider {
|
||||
|
||||
/**
|
||||
* Supporting processor for emitting children and relations
|
||||
* @internal
|
||||
*/
|
||||
class SyntheticLoadEntitiesProcessor implements CatalogProcessor {
|
||||
constructor(private readonly load: SyntheticLoadOptions) {}
|
||||
export class SyntheticLoadEntitiesProcessor implements CatalogProcessor {
|
||||
constructor(private readonly load: SyntheticLoadOptions) {
|
||||
validateSyntheticLoadOptions(load);
|
||||
}
|
||||
|
||||
getProcessorName(): string {
|
||||
return 'SyntheticLoadEntitiesProcessor';
|
||||
@@ -231,27 +235,3 @@ class SyntheticLoadEntitiesProcessor implements CatalogProcessor {
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
export const catalogModuleSyntheticLoadEntities = createBackendModule(
|
||||
(options: { load: SyntheticLoadOptions; events?: SyntheticLoadEvents }) => ({
|
||||
moduleId: 'syntheticLoadEntities',
|
||||
pluginId: 'catalog',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: {
|
||||
catalog: catalogProcessingExtensionPoint,
|
||||
},
|
||||
async init({ catalog }) {
|
||||
const { load, events = {} } = options;
|
||||
|
||||
validateSyntheticLoadOptions(load);
|
||||
const provider = new SyntheticLoadEntitiesProvider(load, events);
|
||||
const processor = new SyntheticLoadEntitiesProcessor(load);
|
||||
|
||||
catalog.addEntityProvider(provider);
|
||||
catalog.addProcessor(processor);
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -16,16 +16,19 @@
|
||||
|
||||
import {
|
||||
coreServices,
|
||||
createBackendModule,
|
||||
createServiceFactory,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { TestDatabases, startTestBackend } from '@backstage/backend-test-utils';
|
||||
import { catalogPlugin } from '@backstage/plugin-catalog-backend/alpha';
|
||||
import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha';
|
||||
import { Knex } from 'knex';
|
||||
import { applyDatabaseMigrations } from '../../database/migrations';
|
||||
import {
|
||||
SyntheticLoadEvents,
|
||||
SyntheticLoadOptions,
|
||||
catalogModuleSyntheticLoadEntities,
|
||||
SyntheticLoadEntitiesProvider,
|
||||
SyntheticLoadEntitiesProcessor,
|
||||
} from './lib/catalogModuleSyntheticLoadEntities';
|
||||
import { describePerformanceTest, performanceTraceEnabled } from './lib/env';
|
||||
|
||||
@@ -180,10 +183,25 @@ describePerformanceTest('stitchingPerformance', () => {
|
||||
services: [staticDatabase(knex)],
|
||||
features: [
|
||||
catalogPlugin(),
|
||||
catalogModuleSyntheticLoadEntities({
|
||||
load,
|
||||
events: tracker.events(),
|
||||
}),
|
||||
createBackendModule({
|
||||
moduleId: 'syntheticLoadEntities',
|
||||
pluginId: 'catalog',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: {
|
||||
catalog: catalogProcessingExtensionPoint,
|
||||
},
|
||||
async init({ catalog }) {
|
||||
catalog.addEntityProvider(
|
||||
new SyntheticLoadEntitiesProvider(load, tracker.events()),
|
||||
);
|
||||
catalog.addProcessor(
|
||||
new SyntheticLoadEntitiesProcessor(load),
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
})(),
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ APIs.
|
||||
target: https://api.newrelic.com/v2
|
||||
headers:
|
||||
X-Api-Key: ${NEW_RELIC_REST_API_KEY}
|
||||
allowedHeaders:
|
||||
- link
|
||||
```
|
||||
|
||||
There is some types of api key on new relic, to this use must be `User` type of key, In your production deployment of Backstage, you would also need to ensure that
|
||||
@@ -33,6 +35,8 @@ APIs.
|
||||
'/newrelic/apm/api':
|
||||
headers:
|
||||
X-Api-Key: NRRA-YourActualApiKey
|
||||
allowedHeaders:
|
||||
- link
|
||||
```
|
||||
|
||||
Read more about how to find or generate this key in
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.61",
|
||||
"parse-link-header": "^2.0.0",
|
||||
"react-use": "^17.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -47,6 +48,7 @@
|
||||
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-test-utils": "workspace:^",
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/dev-utils": "workspace:^",
|
||||
@@ -56,9 +58,10 @@
|
||||
"@testing-library/react": "^12.1.3",
|
||||
"@testing-library/user-event": "^14.0.0",
|
||||
"@types/node": "^16.11.26",
|
||||
"@types/parse-link-header": "^2.0.1",
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
"cross-fetch": "^3.1.5",
|
||||
"msw": "^1.0.0"
|
||||
"msw": "^1.2.3"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
/*
|
||||
* Copyright 2023 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 { NewRelicApplication, NewRelicClient } from '.';
|
||||
import { DiscoveryApi } from '@backstage/core-plugin-api';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils';
|
||||
|
||||
const mockedDiscoveryApi: DiscoveryApi = {
|
||||
getBaseUrl: async () => 'https://test.test',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('NewRelicClient', () => {
|
||||
const server = setupServer();
|
||||
setupRequestMockHandlers(server);
|
||||
|
||||
beforeEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
test.each([
|
||||
['https://test.test/BASEPATH/apm/api/applications.json', '/BASEPATH'],
|
||||
['https://test.test/BASEPATH2/apm/api/applications.json', '/BASEPATH2'],
|
||||
['https://test.testBASEPATH3/apm/api/applications.json', 'BASEPATH3'],
|
||||
['https://test.test/newrelic/apm/api/applications.json', undefined],
|
||||
])(
|
||||
'It correctly forms the request url (%p) when proxyPathBase is %p',
|
||||
async (expectedUrl, basePathOverride) => {
|
||||
server.use(
|
||||
rest.get(expectedUrl, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ applications: [] })),
|
||||
),
|
||||
);
|
||||
|
||||
const mockedFetchApi = new MockFetchApi();
|
||||
const fetchSpy = jest.spyOn(mockedFetchApi, 'fetch');
|
||||
|
||||
const client = new NewRelicClient({
|
||||
discoveryApi: mockedDiscoveryApi,
|
||||
fetchApi: mockedFetchApi,
|
||||
proxyPathBase: basePathOverride,
|
||||
});
|
||||
await client.getApplications();
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(expectedUrl);
|
||||
},
|
||||
);
|
||||
|
||||
it('Correctly reads all pages of results and returns the expected results', async () => {
|
||||
const mockedApplicationOne: NewRelicApplication = {
|
||||
id: 1,
|
||||
application_summary: {
|
||||
apdex_score: 0,
|
||||
error_rate: 100,
|
||||
host_count: 500,
|
||||
instance_count: 5000,
|
||||
response_time: 20,
|
||||
throughput: 500000,
|
||||
},
|
||||
name: 'Testing Application #1',
|
||||
language: 'en-us',
|
||||
health_status: 'Failing',
|
||||
reporting: true,
|
||||
settings: {
|
||||
app_apdex_threshold: 0,
|
||||
end_user_apdex_threshold: 0,
|
||||
enable_real_user_monitoring: true,
|
||||
use_server_side_config: true,
|
||||
},
|
||||
};
|
||||
|
||||
const mockedApplicationTwo: NewRelicApplication = {
|
||||
id: 2,
|
||||
application_summary: {
|
||||
apdex_score: -900,
|
||||
error_rate: 0,
|
||||
host_count: 0,
|
||||
instance_count: 0,
|
||||
response_time: 0,
|
||||
throughput: 0,
|
||||
},
|
||||
name: 'Testing Application #2',
|
||||
language: 'en-us',
|
||||
health_status: 'Working',
|
||||
reporting: true,
|
||||
settings: {
|
||||
app_apdex_threshold: 0,
|
||||
end_user_apdex_threshold: 0,
|
||||
enable_real_user_monitoring: true,
|
||||
use_server_side_config: true,
|
||||
},
|
||||
};
|
||||
|
||||
const mockedApplicationThree: NewRelicApplication = {
|
||||
id: 3,
|
||||
application_summary: {
|
||||
apdex_score: -900,
|
||||
error_rate: 0,
|
||||
host_count: 0,
|
||||
instance_count: 0,
|
||||
response_time: 0,
|
||||
throughput: 0,
|
||||
},
|
||||
name: 'Testing Application #3',
|
||||
language: 'en-us',
|
||||
health_status: 'Waiting',
|
||||
reporting: false,
|
||||
settings: {
|
||||
app_apdex_threshold: 1000,
|
||||
end_user_apdex_threshold: 500,
|
||||
enable_real_user_monitoring: false,
|
||||
use_server_side_config: false,
|
||||
},
|
||||
};
|
||||
|
||||
const queryToRequestData = new Map<
|
||||
string | null,
|
||||
{ link?: string | string[]; apps: NewRelicApplication[] }
|
||||
>([
|
||||
[
|
||||
null,
|
||||
{
|
||||
link: [
|
||||
'<https://next.page/page2?page=2>; rel="next"',
|
||||
'<https://badsite.dontgohere>; rel="bad"',
|
||||
],
|
||||
apps: [mockedApplicationOne],
|
||||
},
|
||||
],
|
||||
[
|
||||
'2',
|
||||
{
|
||||
link: '<https://next.page/page3?page=3>; rel="next",',
|
||||
apps: [],
|
||||
},
|
||||
],
|
||||
[
|
||||
'3',
|
||||
{
|
||||
apps: [mockedApplicationTwo, mockedApplicationThree],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const mockedFetchApi = new MockFetchApi();
|
||||
const fetchSpy = jest.spyOn(mockedFetchApi, 'fetch');
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
'https://test.test/newrelic/apm/api/applications.json',
|
||||
(req, res, ctx) => {
|
||||
const nextPageNumber = req.url.searchParams.get('page');
|
||||
const requestData = queryToRequestData.get(nextPageNumber) ?? {
|
||||
apps: [],
|
||||
};
|
||||
|
||||
const { link, apps: applications } = requestData;
|
||||
const statusTransform = ctx.status(200);
|
||||
const responseBody = ctx.json({ applications });
|
||||
|
||||
if (!!link) {
|
||||
return res(statusTransform, ctx.set({ link }), responseBody);
|
||||
}
|
||||
|
||||
return res(statusTransform, responseBody);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const client = new NewRelicClient({
|
||||
discoveryApi: mockedDiscoveryApi,
|
||||
fetchApi: mockedFetchApi,
|
||||
});
|
||||
|
||||
const actual = await client.getApplications();
|
||||
const expected = {
|
||||
applications: [
|
||||
mockedApplicationOne,
|
||||
mockedApplicationTwo,
|
||||
mockedApplicationThree,
|
||||
],
|
||||
};
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://test.test/newrelic/apm/api/applications.json',
|
||||
);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://test.test/newrelic/apm/api/applications.json?page=2',
|
||||
);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://test.test/newrelic/apm/api/applications.json?page=3',
|
||||
);
|
||||
expect(actual).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
test.each([['Link'], ['LINK'], ['lINK']])(
|
||||
'It does not attempt pagination when the link header name is invalid (%p)',
|
||||
async linkHeaderName => {
|
||||
const mockedFetchApi = new MockFetchApi();
|
||||
const fetchSpy = jest.spyOn(mockedFetchApi, 'fetch');
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
'https://test.test/newrelic/apm/api/applications.json',
|
||||
(_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.set(
|
||||
linkHeaderName,
|
||||
'<https://test.test/badroute>; rel="next"',
|
||||
),
|
||||
ctx.json({ applications: [] }),
|
||||
),
|
||||
),
|
||||
rest.get('https://test.test/badroute', () => {
|
||||
throw new Error(
|
||||
'NewRelicClient attempted to paginate when it should not have',
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new NewRelicClient({
|
||||
discoveryApi: mockedDiscoveryApi,
|
||||
fetchApi: mockedFetchApi,
|
||||
});
|
||||
await client.getApplications();
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://test.test/newrelic/apm/api/applications.json',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
[''],
|
||||
['<> rel=""'],
|
||||
['<>; rel=""'],
|
||||
['<https://next.page>; rel=""'],
|
||||
['<>; rel:"value"'],
|
||||
['<https://next.page>; rel: "next"'],
|
||||
['ABCDE'],
|
||||
['<https://next.page/page3?page=>; rel="next",'],
|
||||
])(
|
||||
'It does not attempt pagination when the link header value is invalid (%p)',
|
||||
async linkHeaderValue => {
|
||||
const mockedFetchApi = new MockFetchApi();
|
||||
const fetchSpy = jest.spyOn(mockedFetchApi, 'fetch');
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
'https://test.test/newrelic/apm/api/applications.json',
|
||||
(_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.set('link', linkHeaderValue),
|
||||
ctx.json({ applications: [] }),
|
||||
),
|
||||
),
|
||||
rest.get('https://test.test/badroute', () => {
|
||||
throw new Error(
|
||||
'NewRelicClient attempted to paginate when it should not have',
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new NewRelicClient({
|
||||
discoveryApi: mockedDiscoveryApi,
|
||||
fetchApi: mockedFetchApi,
|
||||
});
|
||||
await client.getApplications();
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://test.test/newrelic/apm/api/applications.json',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
['Error communicating with New Relic: Not Found', 404, JSON.stringify({})],
|
||||
[
|
||||
'Error communicating with New Relic: ERROR TITLE',
|
||||
404,
|
||||
JSON.stringify({
|
||||
error: {
|
||||
title: 'ERROR TITLE',
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
'Error communicating with New Relic: Internal Server Error',
|
||||
500,
|
||||
JSON.stringify(undefined),
|
||||
],
|
||||
[
|
||||
'Error communicating with New Relic: Internal Server Error',
|
||||
500,
|
||||
JSON.stringify(null),
|
||||
],
|
||||
[
|
||||
'Error communicating with New Relic: Internal Server Error',
|
||||
500,
|
||||
'<invalid></invalid',
|
||||
],
|
||||
])(
|
||||
'It throws this error: %p when the status code is %p and the body is %j',
|
||||
async (expectedErrorMessage, statusCode, body) => {
|
||||
server.use(
|
||||
rest.get(
|
||||
'https://test.test/newrelic/apm/api/applications.json',
|
||||
(_, res, ctx) => res(ctx.status(statusCode), ctx.body(body)),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new NewRelicClient({
|
||||
discoveryApi: mockedDiscoveryApi,
|
||||
fetchApi: new MockFetchApi(),
|
||||
});
|
||||
|
||||
await expect(client.getApplications()).rejects.toThrow(
|
||||
expectedErrorMessage,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('Throws an error when the body is invalid json but the status code is 200', async () => {
|
||||
server.use(
|
||||
rest.get(
|
||||
'https://test.test/newrelic/apm/api/applications.json',
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.body('<Invalid></Invalid')),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new NewRelicClient({
|
||||
discoveryApi: mockedDiscoveryApi,
|
||||
fetchApi: new MockFetchApi(),
|
||||
});
|
||||
|
||||
await expect(client.getApplications()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('Generates the base url only once', async () => {
|
||||
const getBaseUrlSpy = jest.spyOn(mockedDiscoveryApi, 'getBaseUrl');
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
'https://test.test/newrelic/apm/api/applications.json',
|
||||
(_, res, ctx) => res(ctx.status(200), ctx.json({ applications: [] })),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new NewRelicClient({
|
||||
discoveryApi: mockedDiscoveryApi,
|
||||
fetchApi: new MockFetchApi(),
|
||||
});
|
||||
|
||||
await client.getApplications();
|
||||
await client.getApplications();
|
||||
await client.getApplications();
|
||||
await client.getApplications();
|
||||
|
||||
expect(getBaseUrlSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createApiRef, DiscoveryApi } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
createApiRef,
|
||||
DiscoveryApi,
|
||||
FetchApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
import parseLinkHeader from 'parse-link-header';
|
||||
|
||||
export type NewRelicApplication = {
|
||||
id: number;
|
||||
@@ -61,6 +67,7 @@ const DEFAULT_PROXY_PATH_BASE = '/newrelic';
|
||||
|
||||
type Options = {
|
||||
discoveryApi: DiscoveryApi;
|
||||
fetchApi: FetchApi;
|
||||
/**
|
||||
* Path to use for requests via the proxy, defaults to /newrelic
|
||||
*/
|
||||
@@ -71,39 +78,72 @@ export interface NewRelicApi {
|
||||
getApplications(): Promise<NewRelicApplications>;
|
||||
}
|
||||
|
||||
interface NewRelicPageReadResult {
|
||||
nextPageUrl: string | undefined;
|
||||
applicationsFromReadPage: NewRelicApplication[];
|
||||
}
|
||||
|
||||
export class NewRelicClient implements NewRelicApi {
|
||||
private readonly discoveryApi: DiscoveryApi;
|
||||
private readonly fetchApi: FetchApi;
|
||||
private readonly proxyPathBase: string;
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(options: Options) {
|
||||
this.discoveryApi = options.discoveryApi;
|
||||
this.fetchApi = options.fetchApi;
|
||||
this.proxyPathBase = options.proxyPathBase ?? DEFAULT_PROXY_PATH_BASE;
|
||||
this.baseUrl = '';
|
||||
}
|
||||
|
||||
async getApplications(): Promise<NewRelicApplications> {
|
||||
const url = await this.getApiUrl('apm', 'applications.json');
|
||||
const response = await fetch(url);
|
||||
let responseJson;
|
||||
|
||||
try {
|
||||
responseJson = await response.json();
|
||||
} catch (e) {
|
||||
responseJson = { applications: [] };
|
||||
if (!this.baseUrl) {
|
||||
const proxyUrl = await this.discoveryApi.getBaseUrl('proxy');
|
||||
this.baseUrl = `${proxyUrl}${this.proxyPathBase}/apm/api/applications.json`;
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
const applications: NewRelicApplication[] = [];
|
||||
let targetUrl = this.baseUrl;
|
||||
|
||||
do {
|
||||
const { nextPageUrl, applicationsFromReadPage } =
|
||||
await this.fetchNewRelic(targetUrl);
|
||||
|
||||
targetUrl = nextPageUrl ?? '';
|
||||
applications.push(...applicationsFromReadPage);
|
||||
} while (!!targetUrl);
|
||||
|
||||
return { applications };
|
||||
}
|
||||
|
||||
private async fetchNewRelic(
|
||||
targetUrl: string,
|
||||
): Promise<NewRelicPageReadResult> {
|
||||
const response = await this.fetchApi.fetch(targetUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
let specificErrorTitle = undefined;
|
||||
try {
|
||||
specificErrorTitle = (await response.json())?.error?.title;
|
||||
} catch (e) {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Error communicating with New Relic: ${
|
||||
responseJson?.error?.title || response.statusText
|
||||
specificErrorTitle || response.statusText
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return responseJson;
|
||||
}
|
||||
const readResponse = (await response.json()) as NewRelicApplications;
|
||||
const linkHeader = response.headers.get('link');
|
||||
const parseResult = parseLinkHeader(linkHeader);
|
||||
const nextPageNumber = parseResult?.next?.page;
|
||||
|
||||
private async getApiUrl(product: string, path: string) {
|
||||
const proxyUrl = await this.discoveryApi.getBaseUrl('proxy');
|
||||
return `${proxyUrl}${this.proxyPathBase}/${product}/api/${path}`;
|
||||
return {
|
||||
nextPageUrl: nextPageNumber && `${this.baseUrl}?page=${nextPageNumber}`,
|
||||
applicationsFromReadPage: readResponse.applications,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
createPlugin,
|
||||
createRouteRef,
|
||||
discoveryApiRef,
|
||||
fetchApiRef,
|
||||
createRoutableExtension,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
@@ -33,8 +34,12 @@ export const newRelicPlugin = createPlugin({
|
||||
apis: [
|
||||
createApiFactory({
|
||||
api: newRelicApiRef,
|
||||
deps: { discoveryApi: discoveryApiRef },
|
||||
factory: ({ discoveryApi }) => new NewRelicClient({ discoveryApi }),
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
fetchApi: fetchApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi, fetchApi }) =>
|
||||
new NewRelicClient({ discoveryApi, fetchApi }),
|
||||
}),
|
||||
],
|
||||
routes: {
|
||||
|
||||
@@ -7530,6 +7530,7 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@backstage/plugin-newrelic@workspace:plugins/newrelic"
|
||||
dependencies:
|
||||
"@backstage/backend-test-utils": "workspace:^"
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/core-app-api": "workspace:^"
|
||||
"@backstage/core-components": "workspace:^"
|
||||
@@ -7545,9 +7546,11 @@ __metadata:
|
||||
"@testing-library/react": ^12.1.3
|
||||
"@testing-library/user-event": ^14.0.0
|
||||
"@types/node": ^16.11.26
|
||||
"@types/parse-link-header": ^2.0.1
|
||||
"@types/react": ^16.13.1 || ^17.0.0
|
||||
cross-fetch: ^3.1.5
|
||||
msw: ^1.0.0
|
||||
msw: ^1.2.3
|
||||
parse-link-header: ^2.0.0
|
||||
react-use: ^17.2.4
|
||||
peerDependencies:
|
||||
react: ^16.13.1 || ^17.0.0
|
||||
@@ -17602,6 +17605,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/parse-link-header@npm:^2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "@types/parse-link-header@npm:2.0.1"
|
||||
checksum: f76678612511365aefc23704f00f3262fcb1d6bd9c4dd83f783a38e50e3ccf26615f9a62c757952cab5af6f2bd8b3b1763ce391376458afce5fe47c6fe2b80e1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/passport-auth0@npm:^1.0.5":
|
||||
version: 1.0.5
|
||||
resolution: "@types/passport-auth0@npm:1.0.5"
|
||||
@@ -21309,6 +21319,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"cliui@npm:^8.0.1":
|
||||
version: 8.0.1
|
||||
resolution: "cliui@npm:8.0.1"
|
||||
dependencies:
|
||||
string-width: ^4.2.0
|
||||
strip-ansi: ^6.0.1
|
||||
wrap-ansi: ^7.0.0
|
||||
checksum: 79648b3b0045f2e285b76fb2e24e207c6db44323581e421c3acbd0e86454cba1b37aea976ab50195a49e7384b871e6dfb2247ad7dec53c02454ac6497394cb56
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"clone-buffer@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "clone-buffer@npm:1.0.0"
|
||||
@@ -21865,23 +21886,23 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"concurrently@npm:^7.0.0":
|
||||
version: 7.6.0
|
||||
resolution: "concurrently@npm:7.6.0"
|
||||
"concurrently@npm:^8.0.0":
|
||||
version: 8.2.0
|
||||
resolution: "concurrently@npm:8.2.0"
|
||||
dependencies:
|
||||
chalk: ^4.1.0
|
||||
date-fns: ^2.29.1
|
||||
chalk: ^4.1.2
|
||||
date-fns: ^2.30.0
|
||||
lodash: ^4.17.21
|
||||
rxjs: ^7.0.0
|
||||
shell-quote: ^1.7.3
|
||||
spawn-command: ^0.0.2-1
|
||||
supports-color: ^8.1.0
|
||||
rxjs: ^7.8.1
|
||||
shell-quote: ^1.8.1
|
||||
spawn-command: 0.0.2
|
||||
supports-color: ^8.1.1
|
||||
tree-kill: ^1.2.2
|
||||
yargs: ^17.3.1
|
||||
yargs: ^17.7.2
|
||||
bin:
|
||||
conc: dist/bin/concurrently.js
|
||||
concurrently: dist/bin/concurrently.js
|
||||
checksum: f705c9a7960f1b16559ca64958043faeeef6385c0bf30a03d1375e15ab2d96dba4f8166f1bbbb1c85e8da35ca0ce3c353875d71dff2aa132b2357bb533b3332e
|
||||
checksum: eafe6a4d9b7fda87f55ea285cfc6acd937a5286ceec8991ab48e6cc27c45fce6a5c6f45e18d7555defa15dc7d7e8941bc5a9d1ceaf182e31441d420e00333434
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -22924,10 +22945,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"date-fns@npm:^2.16.1, date-fns@npm:^2.18.0, date-fns@npm:^2.29.1":
|
||||
version: 2.29.3
|
||||
resolution: "date-fns@npm:2.29.3"
|
||||
checksum: e01cf5b62af04e05dfff921bb9c9933310ed0e1ae9a81eb8653452e64dc841acf7f6e01e1a5ae5644d0337e9a7f936175fd2cb6819dc122fdd9c5e86c56be484
|
||||
"date-fns@npm:^2.16.1, date-fns@npm:^2.18.0, date-fns@npm:^2.30.0":
|
||||
version: 2.30.0
|
||||
resolution: "date-fns@npm:2.30.0"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.21.0
|
||||
checksum: f7be01523282e9bb06c0cd2693d34f245247a29098527d4420628966a2d9aad154bd0e90a6b1cf66d37adcb769cd108cf8a7bd49d76db0fb119af5cdd13644f4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -32858,7 +32881,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"msw@npm:^1.0.0, msw@npm:^1.0.1, msw@npm:^1.2.1":
|
||||
"msw@npm:^1.0.0, msw@npm:^1.0.1, msw@npm:^1.2.1, msw@npm:^1.2.3":
|
||||
version: 1.2.3
|
||||
resolution: "msw@npm:1.2.3"
|
||||
dependencies:
|
||||
@@ -34359,6 +34382,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"parse-link-header@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "parse-link-header@npm:2.0.0"
|
||||
dependencies:
|
||||
xtend: ~4.0.1
|
||||
checksum: 0e96c6af9910e8f92084b49b8dc6a10dd58db470847d1499f562576180c1ac5e49d18007697f0d538e5f3efdc8ce1d8777641f3ae225302b74af0dd0578b628e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"parse-path@npm:^7.0.0":
|
||||
version: 7.0.0
|
||||
resolution: "parse-path@npm:7.0.0"
|
||||
@@ -38117,7 +38149,7 @@ __metadata:
|
||||
"@types/node": ^16.11.26
|
||||
"@types/webpack": ^5.28.0
|
||||
command-exists: ^1.2.9
|
||||
concurrently: ^7.0.0
|
||||
concurrently: ^8.0.0
|
||||
cross-env: ^7.0.0
|
||||
e2e-test: "workspace:*"
|
||||
eslint: ^8.6.0
|
||||
@@ -38201,7 +38233,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rxjs@npm:7.8.0, rxjs@npm:^7.0.0, rxjs@npm:^7.2.0, rxjs@npm:^7.5.1, rxjs@npm:^7.5.5, rxjs@npm:^7.8.0":
|
||||
"rxjs@npm:7.8.0":
|
||||
version: 7.8.0
|
||||
resolution: "rxjs@npm:7.8.0"
|
||||
dependencies:
|
||||
@@ -38219,6 +38251,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rxjs@npm:^7.2.0, rxjs@npm:^7.5.1, rxjs@npm:^7.5.5, rxjs@npm:^7.8.0, rxjs@npm:^7.8.1":
|
||||
version: 7.8.1
|
||||
resolution: "rxjs@npm:7.8.1"
|
||||
dependencies:
|
||||
tslib: ^2.1.0
|
||||
checksum: de4b53db1063e618ec2eca0f7965d9137cabe98cf6be9272efe6c86b47c17b987383df8574861bcced18ebd590764125a901d5506082be84a8b8e364bf05f119
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"sade@npm:^1.7.3":
|
||||
version: 1.8.1
|
||||
resolution: "sade@npm:1.8.1"
|
||||
@@ -38730,10 +38771,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"shell-quote@npm:^1.7.3":
|
||||
version: 1.7.3
|
||||
resolution: "shell-quote@npm:1.7.3"
|
||||
checksum: aca58e73a3a5d933d02e0bdddedc53ee14f7c2ec264f97ac915b9d4482d077a38e422aa664631d60a672cd3cdb4054eb2e6c0303f54882453dacb6483e482d34
|
||||
"shell-quote@npm:^1.7.3, shell-quote@npm:^1.8.1":
|
||||
version: 1.8.1
|
||||
resolution: "shell-quote@npm:1.8.1"
|
||||
checksum: 5f01201f4ef504d4c6a9d0d283fa17075f6770bfbe4c5850b074974c68062f37929ca61700d95ad2ac8822e14e8c4b990ca0e6e9272e64befd74ce5e19f0736b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -39119,10 +39160,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"spawn-command@npm:^0.0.2-1":
|
||||
version: 0.0.2-1
|
||||
resolution: "spawn-command@npm:0.0.2-1"
|
||||
checksum: 2cac8519332193d1ed37d57298c4a1f73095e9edd20440fbab4aa47f531da83831734f2b51c44bb42b2747bf3485dec3fa2b0a1003f74c67561f2636622e328b
|
||||
"spawn-command@npm:0.0.2, spawn-command@npm:^0.0.2-1":
|
||||
version: 0.0.2
|
||||
resolution: "spawn-command@npm:0.0.2"
|
||||
checksum: e35c5d28177b4d461d33c88cc11f6f3a5079e2b132c11e1746453bbb7a0c0b8a634f07541a2a234fa4758239d88203b758def509161b651e81958894c0b4b64b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -42736,7 +42777,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"xtend@npm:^4.0.0":
|
||||
"xtend@npm:^4.0.0, xtend@npm:~4.0.1":
|
||||
version: 4.0.2
|
||||
resolution: "xtend@npm:4.0.2"
|
||||
checksum: ac5dfa738b21f6e7f0dd6e65e1b3155036d68104e67e5d5d1bde74892e327d7e5636a076f625599dc394330a731861e87343ff184b0047fef1360a7ec0a5a36a
|
||||
@@ -42823,7 +42864,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"yargs-parser@npm:^21.0.0":
|
||||
"yargs-parser@npm:^21.1.1":
|
||||
version: 21.1.1
|
||||
resolution: "yargs-parser@npm:21.1.1"
|
||||
checksum: ed2d96a616a9e3e1cc7d204c62ecc61f7aaab633dcbfab2c6df50f7f87b393993fe6640d017759fe112d0cb1e0119f2b4150a87305cc873fd90831c6a58ccf1c
|
||||
@@ -42874,18 +42915,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"yargs@npm:^17.0.0, yargs@npm:^17.1.1, yargs@npm:^17.2.1, yargs@npm:^17.3.1":
|
||||
version: 17.5.1
|
||||
resolution: "yargs@npm:17.5.1"
|
||||
"yargs@npm:^17.0.0, yargs@npm:^17.1.1, yargs@npm:^17.2.1, yargs@npm:^17.3.1, yargs@npm:^17.7.2":
|
||||
version: 17.7.2
|
||||
resolution: "yargs@npm:17.7.2"
|
||||
dependencies:
|
||||
cliui: ^7.0.2
|
||||
cliui: ^8.0.1
|
||||
escalade: ^3.1.1
|
||||
get-caller-file: ^2.0.5
|
||||
require-directory: ^2.1.1
|
||||
string-width: ^4.2.3
|
||||
y18n: ^5.0.5
|
||||
yargs-parser: ^21.0.0
|
||||
checksum: 00d58a2c052937fa044834313f07910fd0a115dec5ee35919e857eeee3736b21a4eafa8264535800ba8bac312991ce785ecb8a51f4d2cc8c4676d865af1cfbde
|
||||
yargs-parser: ^21.1.1
|
||||
checksum: 73b572e863aa4a8cbef323dd911d79d193b772defd5a51aab0aca2d446655216f5002c42c5306033968193bdbf892a7a4c110b0d77954a7fdf563e653967b56a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user