From 15710d8a1756392716e038d8ef6f59785e709640 Mon Sep 17 00:00:00 2001 From: Brenda Sukh Date: Thu, 3 Dec 2020 16:40:27 -0500 Subject: [PATCH 01/42] Remove references to P1M duration --- .../cost-insights/src/api/CostInsightsApi.ts | 8 ++++---- .../components/CostGrowth/CostGrowth.test.tsx | 6 +++--- .../PeriodSelect/PeriodSelect.test.tsx | 1 - .../components/PeriodSelect/PeriodSelect.tsx | 9 +-------- .../ProductInsightsCard.test.tsx | 4 ++-- plugins/cost-insights/src/types/Duration.ts | 7 +++---- .../cost-insights/src/utils/change.test.ts | 2 +- plugins/cost-insights/src/utils/currency.ts | 1 - .../cost-insights/src/utils/duration.test.ts | 1 - plugins/cost-insights/src/utils/duration.ts | 11 ----------- .../src/utils/formatters.test.ts | 2 -- plugins/cost-insights/src/utils/formatters.ts | 19 ------------------- plugins/cost-insights/src/utils/mockData.ts | 2 +- 13 files changed, 15 insertions(+), 58 deletions(-) diff --git a/plugins/cost-insights/src/api/CostInsightsApi.ts b/plugins/cost-insights/src/api/CostInsightsApi.ts index 9baad5ffab..7c130e4ceb 100644 --- a/plugins/cost-insights/src/api/CostInsightsApi.ts +++ b/plugins/cost-insights/src/api/CostInsightsApi.ts @@ -38,7 +38,7 @@ export type ProductInsightsOptions = { group: string; /** - * A time duration, such as P1M. See the Duration type for a detailed explanation + * A time duration, such as P3M. See the Duration type for a detailed explanation * of how the durations are interpreted in Cost Insights. */ duration: Duration; @@ -90,7 +90,7 @@ export type CostInsightsApi = { * reduction) and compare it to metrics important to the business. * * @param group The group id from getUserGroups or query parameters - * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 + * @param intervals An ISO 8601 repeating interval string, such as R2/P30D/2020-09-01 * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals */ getGroupDailyCost(group: string, intervals: string): Promise; @@ -108,7 +108,7 @@ export type CostInsightsApi = { * (or reduction) and compare it to metrics important to the business. * * @param project The project id from getGroupProjects or query parameters - * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 + * @param intervals An ISO 8601 repeating interval string, such as R2/P30D/2020-09-01 * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals */ getProjectDailyCost(project: string, intervals: string): Promise; @@ -119,7 +119,7 @@ export type CostInsightsApi = { * (or reduction) of a project or group's daily costs. * * @param metric A metric from the cost-insights configuration in app-config.yaml. - * @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01 + * @param intervals An ISO 8601 repeating interval string, such as R2/P30D/2020-09-01 * https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals */ getDailyMetricData(metric: string, intervals: string): Promise; diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx index 9c76d6d998..12668140b6 100644 --- a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx +++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx @@ -54,7 +54,7 @@ describe.each` it(`formats ${engineers.unit}s correctly for ${expected}`, async () => { const { getByText } = await renderInTestApp( - + , ); expect(getByText(expected)).toBeInTheDocument(); @@ -73,7 +73,7 @@ describe.each` it(`formats ${usd.unit}s correctly for ${expected}`, async () => { const { getByText } = await renderInTestApp( - + , ); expect(getByText(expected)).toBeInTheDocument(); @@ -92,7 +92,7 @@ describe.each` it(`formats ${carbon.unit}s correctly for ${expected}`, async () => { const { getByText } = await renderInTestApp( - + , ); expect(getByText(expected)).toBeInTheDocument(); diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx index a3519ff61b..d8535cf791 100644 --- a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx @@ -66,7 +66,6 @@ describe('', () => { describe.each` duration - ${Duration.P1M} ${Duration.P3M} ${Duration.P90D} ${Duration.P30D} diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx index 4908641d6f..459de68921 100644 --- a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx @@ -17,10 +17,7 @@ import React from 'react'; import { MenuItem, Select, SelectProps } from '@material-ui/core'; import { Duration } from '../../types'; -import { - formatLastTwoLookaheadQuarters, - formatLastTwoMonths, -} from '../../utils/formatters'; +import { formatLastTwoLookaheadQuarters } from '../../utils/formatters'; import { findAlways } from '../../utils/assert'; import { useSelectStyles as useStyles } from '../../utils/styles'; import { useLastCompleteBillingDate } from '../../hooks'; @@ -42,10 +39,6 @@ export function getDefaultOptions( value: Duration.P30D, label: 'Past 60 Days', }, - { - value: Duration.P1M, - label: formatLastTwoMonths(lastCompleteBillingDate), - }, { value: Duration.P3M, label: formatLastTwoLookaheadQuarters(lastCompleteBillingDate), diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx index 385ff82f9b..c9c7618e1e 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.test.tsx @@ -81,7 +81,7 @@ describe('', () => { const rendered = await renderProductInsightsCardInTestApp( mockProductCost, MockComputeEngine, - Duration.P1M, + Duration.P30D, ); expect( rendered.queryByTestId(`scroll-test-compute-engine`), @@ -113,7 +113,7 @@ describe('', () => { const rendered = await renderProductInsightsCardInTestApp( entity, MockComputeEngine, - Duration.P1M, + Duration.P30D, ); const subheaderRgx = new RegExp(subheader); expect(rendered.getByText(subheaderRgx)).toBeInTheDocument(); diff --git a/plugins/cost-insights/src/types/Duration.ts b/plugins/cost-insights/src/types/Duration.ts index acf707dd1e..c0f03d5c27 100644 --- a/plugins/cost-insights/src/types/Duration.ts +++ b/plugins/cost-insights/src/types/Duration.ts @@ -15,15 +15,14 @@ */ /** - * Time periods for cost comparison; slight abuse of ISO 8601 periods. We take P1M and P3M to mean - * 'last completed [month|quarter]', and P30D/P90D to be '[month|quarter] relative to today'. So if - * it's September 15, P1M represents costs for the month of August and P30D represents August 16 - + * Time periods for cost comparison; slight abuse of ISO 8601 periods. We take P3M to mean + * 'last completed quarter', and P30D/P90D to be '[month|quarter] relative to today'. So if + * it's September 15, P3M represents costs for Q2 and P30D represents August 16 - * September 15. */ export enum Duration { P30D = 'P30D', P90D = 'P90D', - P1M = 'P1M', P3M = 'P3M', } diff --git a/plugins/cost-insights/src/utils/change.test.ts b/plugins/cost-insights/src/utils/change.test.ts index f9e8cc4d6d..7cc03caa0b 100644 --- a/plugins/cost-insights/src/utils/change.test.ts +++ b/plugins/cost-insights/src/utils/change.test.ts @@ -80,7 +80,7 @@ describe('getPreviousPeriodTotalCost', () => { expect( getPreviousPeriodTotalCost( mockGroupDailyCost.aggregation, - Duration.P1M, + Duration.P30D, exclusiveEndDate, ), ).toEqual(100_000); diff --git a/plugins/cost-insights/src/utils/currency.ts b/plugins/cost-insights/src/utils/currency.ts index 663a29a112..f1d67a14e4 100644 --- a/plugins/cost-insights/src/utils/currency.ts +++ b/plugins/cost-insights/src/utils/currency.ts @@ -18,7 +18,6 @@ import { assertNever } from '../utils/assert'; export const rateOf = (cost: number, duration: Duration) => { switch (duration) { - case Duration.P1M: case Duration.P30D: return cost / 12; case Duration.P90D: diff --git a/plugins/cost-insights/src/utils/duration.test.ts b/plugins/cost-insights/src/utils/duration.test.ts index a5509eda07..45d8e5b6a3 100644 --- a/plugins/cost-insights/src/utils/duration.test.ts +++ b/plugins/cost-insights/src/utils/duration.test.ts @@ -23,7 +23,6 @@ describe.each` duration | startDate | endDate ${Duration.P30D} | ${'2020-04-06'} | ${'2020-06-05'} ${Duration.P90D} | ${'2019-12-08'} | ${'2020-06-05'} - ${Duration.P1M} | ${'2020-04-01'} | ${'2020-05-31'} ${Duration.P3M} | ${'2019-10-01'} | ${'2020-03-31'} `('Calculates interval dates correctly', ({ duration, startDate, endDate }) => { it(`Calculates dates correctly for ${duration}`, () => { diff --git a/plugins/cost-insights/src/utils/duration.ts b/plugins/cost-insights/src/utils/duration.ts index 810160c7b6..5a3fb52271 100644 --- a/plugins/cost-insights/src/utils/duration.ts +++ b/plugins/cost-insights/src/utils/duration.ts @@ -37,12 +37,6 @@ export function inclusiveStartDateOf( .utc() .subtract(moment.duration(duration).add(moment.duration(duration))) .format(DEFAULT_DATE_FORMAT); - case Duration.P1M: - return moment(exclusiveEndDate) - .utc() - .startOf('month') - .subtract(moment.duration(duration).add(moment.duration(duration))) - .format(DEFAULT_DATE_FORMAT); case Duration.P3M: return moment(exclusiveEndDate) .utc() @@ -65,11 +59,6 @@ export function exclusiveEndDateOf( .utc() .add(1, 'day') .format(DEFAULT_DATE_FORMAT); - case Duration.P1M: - return moment(inclusiveEndDate) - .utc() - .startOf('month') - .format(DEFAULT_DATE_FORMAT); case Duration.P3M: return moment(inclusiveEndDate) .utc() diff --git a/plugins/cost-insights/src/utils/formatters.test.ts b/plugins/cost-insights/src/utils/formatters.test.ts index db25b6f3b7..a403f1ebba 100644 --- a/plugins/cost-insights/src/utils/formatters.test.ts +++ b/plugins/cost-insights/src/utils/formatters.test.ts @@ -57,8 +57,6 @@ describe('date formatters', () => { describe.each` duration | date | isEndDate | output - ${Duration.P1M} | ${'2020-10-11'} | ${true} | ${'September 2020'} - ${Duration.P1M} | ${'2020-10-11'} | ${false} | ${'August 2020'} ${Duration.P3M} | ${'2020-10-11'} | ${true} | ${'Q3 2020'} ${Duration.P3M} | ${'2020-10-11'} | ${false} | ${'Q2 2020'} ${Duration.P30D} | ${'2020-10-11'} | ${true} | ${'Last 30 Days'} diff --git a/plugins/cost-insights/src/utils/formatters.ts b/plugins/cost-insights/src/utils/formatters.ts index 9c3b2d643e..454f9301a4 100644 --- a/plugins/cost-insights/src/utils/formatters.ts +++ b/plugins/cost-insights/src/utils/formatters.ts @@ -104,19 +104,6 @@ export function formatLastTwoLookaheadQuarters(inclusiveEndDate: string) { return `${start} vs ${end}`; } -export function formatLastTwoMonths(inclusiveEndDate: string) { - const exclusiveEndDate = moment(inclusiveEndDate) - .add(1, 'day') - .format(DEFAULT_DATE_FORMAT); - const start = moment(inclusiveStartDateOf(Duration.P1M, exclusiveEndDate)) - .utc() - .format('MMMM'); - const end = moment(inclusiveEndDateOf(Duration.P1M, inclusiveEndDate)) - .utc() - .format('MMMM'); - return `${start} vs ${end}`; -} - const formatRelativePeriod = ( duration: Duration, date: string, @@ -137,12 +124,6 @@ export function formatPeriod( isEndDate: boolean, ) { switch (duration) { - case Duration.P1M: - return monthOf( - isEndDate - ? inclusiveEndDateOf(duration, date) - : inclusiveStartDateOf(duration, date), - ); case Duration.P3M: return quarterOf( isEndDate diff --git a/plugins/cost-insights/src/utils/mockData.ts b/plugins/cost-insights/src/utils/mockData.ts index 76bdf5f08f..6862d11141 100644 --- a/plugins/cost-insights/src/utils/mockData.ts +++ b/plugins/cost-insights/src/utils/mockData.ts @@ -143,7 +143,7 @@ export const MockProductTypes: Record = { export const MockProductFilters: ProductFilters = Object.keys( MockProductTypes, -).map(productType => ({ duration: Duration.P1M, productType })); +).map(productType => ({ duration: Duration.P30D, productType })); export const MockProducts: Product[] = Object.keys(MockProductTypes).map( productType => From c81456a175057de85d68e83c39b91c8173318fb7 Mon Sep 17 00:00:00 2001 From: Brenda Sukh Date: Fri, 4 Dec 2020 10:18:17 -0500 Subject: [PATCH 02/42] Update quarter end date logic --- .../components/PeriodSelect/PeriodSelect.test.tsx | 4 ++-- plugins/cost-insights/src/utils/duration.ts | 14 ++++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx index d8535cf791..ae513c04e0 100644 --- a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx @@ -73,8 +73,9 @@ describe('', () => { it(`Should select ${duration}`, async () => { const mockOnSelect = jest.fn(); const mockAggregation = + // Can't select an option that's already the default DefaultPageFilters.duration === duration - ? Duration.P1M + ? Duration.P30D : DefaultPageFilters.duration; const rendered = await renderInTestApp( @@ -88,7 +89,6 @@ describe('', () => { const button = getByRole(periodSelect, 'button'); UserEvent.click(button); - await waitFor(() => rendered.getByText('Past 60 Days')); UserEvent.click(rendered.getByTestId(`period-select-option-${duration}`)); expect(mockOnSelect).toHaveBeenLastCalledWith(duration); }); diff --git a/plugins/cost-insights/src/utils/duration.ts b/plugins/cost-insights/src/utils/duration.ts index 5a3fb52271..2ca2817324 100644 --- a/plugins/cost-insights/src/utils/duration.ts +++ b/plugins/cost-insights/src/utils/duration.ts @@ -60,10 +60,7 @@ export function exclusiveEndDateOf( .add(1, 'day') .format(DEFAULT_DATE_FORMAT); case Duration.P3M: - return moment(inclusiveEndDate) - .utc() - .startOf('quarter') - .format(DEFAULT_DATE_FORMAT); + return quarterEndDate(inclusiveEndDate); default: return assertNever(duration); } @@ -83,3 +80,12 @@ export function inclusiveEndDateOf( export function intervalsOf(duration: Duration, inclusiveEndDate: string) { return `R2/${duration}/${exclusiveEndDateOf(duration, inclusiveEndDate)}`; } + +function quarterEndDate(inclusiveEndDate: string): string { + const endDate = moment(inclusiveEndDate).utc(); + const endOfQuarter = endDate.endOf('quarter').format(DEFAULT_DATE_FORMAT); + if (endOfQuarter === inclusiveEndDate) { + return inclusiveEndDate; + } + return endDate.startOf('quarter').format(DEFAULT_DATE_FORMAT); +} From 88ef11b45d285d6b37a89efd6259b359953ed3d7 Mon Sep 17 00:00:00 2001 From: Brenda Sukh Date: Fri, 4 Dec 2020 10:20:00 -0500 Subject: [PATCH 03/42] Add changeset --- .changeset/cost-insights-wild-pumpkins-tie.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cost-insights-wild-pumpkins-tie.md diff --git a/.changeset/cost-insights-wild-pumpkins-tie.md b/.changeset/cost-insights-wild-pumpkins-tie.md new file mode 100644 index 0000000000..6fdc7c0a5a --- /dev/null +++ b/.changeset/cost-insights-wild-pumpkins-tie.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': minor +--- + +Remove calendar MoM period option and fix quarter end date logic From b81c973f2eeb52dd47d2f1f45c28064201578f91 Mon Sep 17 00:00:00 2001 From: Brenda Sukh Date: Fri, 4 Dec 2020 10:23:31 -0500 Subject: [PATCH 04/42] Add one day to get exclusive date --- plugins/cost-insights/src/utils/duration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/cost-insights/src/utils/duration.ts b/plugins/cost-insights/src/utils/duration.ts index 2ca2817324..0d9d6aa179 100644 --- a/plugins/cost-insights/src/utils/duration.ts +++ b/plugins/cost-insights/src/utils/duration.ts @@ -85,7 +85,7 @@ function quarterEndDate(inclusiveEndDate: string): string { const endDate = moment(inclusiveEndDate).utc(); const endOfQuarter = endDate.endOf('quarter').format(DEFAULT_DATE_FORMAT); if (endOfQuarter === inclusiveEndDate) { - return inclusiveEndDate; + return endDate.add(1, 'day').format(DEFAULT_DATE_FORMAT); } return endDate.startOf('quarter').format(DEFAULT_DATE_FORMAT); } From 12bbd748c58e1f783d30f2b1dd6f2c30b63e6dca Mon Sep 17 00:00:00 2001 From: Oliver Sand Date: Fri, 4 Dec 2020 17:00:39 +0100 Subject: [PATCH 05/42] Remove prometheus depenedency from backend-common --- .../rare-lemons-hug.md | 66 ++++++++++++++----- packages/backend-common/package.json | 2 - .../src/service/lib/ServiceBuilderImpl.ts | 8 --- .../src/service/lib/metrics.test.ts | 37 ----------- yarn.lock | 34 +--------- 5 files changed, 49 insertions(+), 98 deletions(-) rename packages/backend-common/src/service/lib/metrics.ts => .changeset/rare-lemons-hug.md (50%) delete mode 100644 packages/backend-common/src/service/lib/metrics.test.ts diff --git a/packages/backend-common/src/service/lib/metrics.ts b/.changeset/rare-lemons-hug.md similarity index 50% rename from packages/backend-common/src/service/lib/metrics.ts rename to .changeset/rare-lemons-hug.md index 37d441f53c..22933bf1c4 100644 --- a/packages/backend-common/src/service/lib/metrics.ts +++ b/.changeset/rare-lemons-hug.md @@ -1,21 +1,34 @@ -/* - * 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 prom from 'prom-client'; -import promBundle from 'express-prom-bundle'; +--- +'@backstage/backend-common': minor +--- + +Removes the Prometheus integration from `backend-common`. + +Rational behind this change is to keep the metrics integration of Backstage +generic. Instead of directly relying on Prometheus, Backstage will expose +metrics in a generic way. Integrators can then export the metrics in their +desired format. For example using Prometheus. + +To keep the existing behavior, you need to integrate Prometheus in your +backend: + +First, add a dependency on `express-prom-bundle` and `prom-client` to your backend. + +```diff +// packages/backend/package.json + "dependencies": { ++ "express-prom-bundle": "^6.1.0", ++ "prom-client": "^12.0.0", +``` + +Then, add a handler for metrics and a simple instrumentation for the endpoints. + +```typescript +// packages/backend/src/metrics.ts +import { useHotCleanup } from '@backstage/backend-common'; import { RequestHandler } from 'express'; +import promBundle from 'express-prom-bundle'; +import prom from 'prom-client'; import * as url from 'url'; const rootRegEx = new RegExp('^/([^/]*)/.*'); @@ -38,7 +51,7 @@ export function normalizePath(req: any): string { */ export function metricsHandler(): RequestHandler { // We can only initialize the metrics once and have to clean them up between hot reloads - prom.register.clear(); + useHotCleanup(module, () => prom.register.clear()); return promBundle({ includeMethod: true, @@ -51,3 +64,20 @@ export function metricsHandler(): RequestHandler { promClient: { collectDefaultMetrics: {} }, }); } +``` + +Last, extend your router configuration with the `metricsHandler`: + +```diff ++import { metricsHandler } from './metrics'; + +... + + const service = createServiceBuilder(module) + .loadConfig(config) + .addRouter('', await healthcheck(healthcheckEnv)) ++ .addRouter('', metricsHandler()) + .addRouter('/api', apiRouter); +``` + +Your Prometheus metrics will be available at the `/metrics` endpoint. diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 04e189abca..13b827762c 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -41,7 +41,6 @@ "cors": "^2.8.5", "cross-fetch": "^3.0.6", "express": "^4.17.1", - "express-prom-bundle": "^6.1.0", "express-promise-router": "^3.0.3", "fs-extra": "^9.0.1", "git-url-parse": "^11.4.0", @@ -51,7 +50,6 @@ "logform": "^2.1.1", "minimist": "^1.2.5", "morgan": "^1.10.0", - "prom-client": "^12.0.0", "selfsigned": "^1.10.7", "stoppable": "^1.1.0", "tar": "^6.0.5", diff --git a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index b47f4ef7e5..778e25f71c 100644 --- a/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -39,7 +39,6 @@ import { readHttpsSettings, } from './config'; import { createHttpServer, createHttpsServer } from './hostFactory'; -import { metricsHandler } from './metrics'; export const DEFAULT_PORT = 7000; // '' is express default, which listens to all interfaces @@ -66,7 +65,6 @@ export class ServiceBuilderImpl implements ServiceBuilder { private corsOptions: cors.CorsOptions | undefined; private cspOptions: Record | undefined; private httpsSettings: HttpsSettings | undefined; - private enableMetrics: boolean = true; private routers: [string, Router][]; // Reference to the module where builder is created - needed for hot module // reloading @@ -109,9 +107,6 @@ export class ServiceBuilderImpl implements ServiceBuilder { this.httpsSettings = httpsSettings; } - // For now, configuration of metrics is a simple boolean and active by default - this.enableMetrics = backendConfig.getOptionalBoolean('metrics') !== false; - return this; } @@ -166,9 +161,6 @@ export class ServiceBuilderImpl implements ServiceBuilder { app.use(cors(corsOptions)); } app.use(compression()); - if (this.enableMetrics) { - app.use(metricsHandler()); - } app.use(requestLoggingHandler()); for (const [root, route] of this.routers) { app.use(root, route); diff --git a/packages/backend-common/src/service/lib/metrics.test.ts b/packages/backend-common/src/service/lib/metrics.test.ts deleted file mode 100644 index 9126423b7e..0000000000 --- a/packages/backend-common/src/service/lib/metrics.test.ts +++ /dev/null @@ -1,37 +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 { normalizePath } from './metrics'; - -describe('normalizePath', () => { - it('should normalize /path to /path', async () => { - const path = normalizePath({ url: 'http://server/path' }); - - expect(path).toBe('/path'); - }); - - it('should normalize /path/test to /path', async () => { - const path = normalizePath({ url: 'http://server/path/test' }); - - expect(path).toBe('/path'); - }); - - it('should normalize /api/plugin-name/test to /api/plugin-name', async () => { - const path = normalizePath({ url: 'http://server/api/plugin-name/test' }); - - expect(path).toBe('/api/plugin-name'); - }); -}); diff --git a/yarn.lock b/yarn.lock index 44024b5952..71eccec1a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7922,11 +7922,6 @@ bindings@^1.5.0: dependencies: file-uri-to-path "1.0.0" -bintrees@1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz#0e655c9b9c2435eaab68bf4027226d2b55a34524" - integrity sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ= - bl@^1.0.0: version "1.2.2" resolved "https://registry.npmjs.org/bl/-/bl-1.2.2.tgz#a160911717103c07410cef63ef51b397c025af9c" @@ -11724,14 +11719,6 @@ expect@^26.5.3: jest-message-util "^26.5.2" jest-regex-util "^26.0.0" -express-prom-bundle@^6.1.0: - version "6.1.0" - resolved "https://registry.npmjs.org/express-prom-bundle/-/express-prom-bundle-6.1.0.tgz#8fd72e5bedbbd686b8e7c49e0aecbc1b14b28743" - integrity sha512-krlvp5r6sgJ1IwL6M6/coMrNbAlwtpk+uyivfeyRMCupTK4HzIEQHH0gwrNhLiKyPmSbtZcSmtO6s+PRumRp5g== - dependencies: - on-finished "^2.3.0" - url-value-parser "^2.0.0" - express-promise-router@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/express-promise-router/-/express-promise-router-3.0.3.tgz#5e6d22a5a3f013d71833172fe8d7ab780c3f6b70" @@ -17917,7 +17904,7 @@ oidc-token-hash@^5.0.0: resolved "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.0.tgz#acdfb1f4310f58e64d5d74a4e8671a426986e888" integrity sha512-8Yr4CZSv+Tn8ZkN3iN2i2w2G92mUKClp4z7EGUfdsERiYSbj7P4i/NHm72ft+aUdsiFx9UdIPSTwbyzQ6C4URg== -on-finished@^2.3.0, on-finished@~2.3.0: +on-finished@~2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= @@ -19514,13 +19501,6 @@ progress@^2.0.0: resolved "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== -prom-client@^12.0.0: - version "12.0.0" - resolved "https://registry.npmjs.org/prom-client/-/prom-client-12.0.0.tgz#9689379b19bd3f6ab88a9866124db9da3d76c6ed" - integrity sha512-JbzzHnw0VDwCvoqf8y1WDtq4wSBAbthMB1pcVI/0lzdqHGJI3KBJDXle70XK+c7Iv93Gihqo0a5LlOn+g8+DrQ== - dependencies: - tdigest "^0.1.1" - promise-inflight@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" @@ -22950,13 +22930,6 @@ tarn@^3.0.1: resolved "https://registry.npmjs.org/tarn/-/tarn-3.0.1.tgz#ebac2c6dbc6977d34d4526e0a7814200386a8aec" integrity sha512-6usSlV9KyHsspvwu2duKH+FMUhqJnAh6J5J/4MITl8s94iSUQTLkJggdiewKv4RyARQccnigV48Z+khiuVZDJw== -tdigest@^0.1.1: - version "0.1.1" - resolved "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz#2e3cb2c39ea449e55d1e6cd91117accca4588021" - integrity sha1-Ljyyw56kSeVdHmzZEReszKRYgCE= - dependencies: - bintrees "1.0.1" - telejson@^5.0.2: version "5.0.2" resolved "https://registry.npmjs.org/telejson/-/telejson-5.0.2.tgz#ed1e64be250cc1c757a53c19e1740b49832b3d51" @@ -23961,11 +23934,6 @@ url-parse@^1.4.3, url-parse@^1.4.7: querystringify "^2.1.1" requires-port "^1.0.0" -url-value-parser@^2.0.0: - version "2.0.1" - resolved "https://registry.npmjs.org/url-value-parser/-/url-value-parser-2.0.1.tgz#c8179a095ab9ec1f5aa17ca36af5af396b4e95ed" - integrity sha512-bexECeREBIueboLGM3Y1WaAzQkIn+Tca/Xjmjmfd0S/hFHSCEoFkNh0/D0l9G4K74MkEP/lLFRlYnxX3d68Qgw== - url@^0.11.0, url@~0.11.0: version "0.11.0" resolved "https://registry.npmjs.org/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" From a8b4dbcb6ba63f9e114ac7b9d5a939082f0336a3 Mon Sep 17 00:00:00 2001 From: AlleyWei <64219471+AlleyWei@users.noreply.github.com> Date: Fri, 4 Dec 2020 17:28:38 -0500 Subject: [PATCH 06/42] Update Footer.js Please make the <3 an emoji --- microsite/core/Footer.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/microsite/core/Footer.js b/microsite/core/Footer.js index 6ce3c3dd84..585a1c2091 100644 --- a/microsite/core/Footer.js +++ b/microsite/core/Footer.js @@ -1,4 +1,6 @@ /* +* Made with <3 at Spotify + * Copyright 2020 Backstage Project Authors. * * Licensed under the Apache License, Version 2.0 (the "License"); From 4ac602d6e84a728b1fceceba3d34d581e489d2d1 Mon Sep 17 00:00:00 2001 From: AlleyWei <64219471+AlleyWei@users.noreply.github.com> Date: Fri, 4 Dec 2020 17:34:40 -0500 Subject: [PATCH 07/42] Update Footer.js --- microsite/core/Footer.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/microsite/core/Footer.js b/microsite/core/Footer.js index 585a1c2091..ef7fdb7c3a 100644 --- a/microsite/core/Footer.js +++ b/microsite/core/Footer.js @@ -56,6 +56,13 @@ class Footer extends React.Component { Open Source @ {this.props.config.organizationName} + + Spotify Engineering Blog + + + Spotify for Developers + + GitHub Date: Sat, 5 Dec 2020 11:55:45 -0500 Subject: [PATCH 08/42] Update Footer.js --- microsite/core/Footer.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/microsite/core/Footer.js b/microsite/core/Footer.js index ef7fdb7c3a..fe4fbfaa80 100644 --- a/microsite/core/Footer.js +++ b/microsite/core/Footer.js @@ -57,11 +57,9 @@ class Footer extends React.Component { Open Source @ {this.props.config.organizationName} - Spotify Engineering Blog - - - Spotify for Developers - + Spotify Engineering Blog + Spotify for Developers + GitHub Date: Sat, 5 Dec 2020 11:58:13 -0500 Subject: [PATCH 09/42] Update Footer.js --- microsite/core/Footer.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/microsite/core/Footer.js b/microsite/core/Footer.js index fe4fbfaa80..4a5bca6071 100644 --- a/microsite/core/Footer.js +++ b/microsite/core/Footer.js @@ -57,8 +57,11 @@ class Footer extends React.Component { Open Source @ {this.props.config.organizationName} - Spotify Engineering Blog - Spotify for Developers + + Spotify Engineering Blog + + + Spotify for Developers GitHub From b8ecf6f4822a8beacf93eac15d7cc6416978ef2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 28 Nov 2020 23:32:21 +0100 Subject: [PATCH 10/42] integration: add the basics of cross-integration concerns --- .changeset/nine-forks-sleep.md | 5 ++ .../integration/src/ScmIntegrationsImpl.ts | 51 ++++++++++++++++ .../src/azure/AzureIntegration.test.ts | 48 +++++++++++++++ .../integration/src/azure/AzureIntegration.ts | 40 +++++++++++++ .../bitbucket/BitbucketIntegration.test.ts | 51 ++++++++++++++++ .../src/bitbucket/BitbucketIntegration.ts | 43 ++++++++++++++ .../src/github/GitHubIntegration.test.ts | 50 ++++++++++++++++ .../src/github/GitHubIntegration.ts | 43 ++++++++++++++ .../src/gitlab/GitLabIntegration.test.ts | 48 +++++++++++++++ .../src/gitlab/GitLabIntegration.ts | 43 ++++++++++++++ packages/integration/src/index.ts | 1 + packages/integration/src/types.ts | 58 +++++++++++++++++++ 12 files changed, 481 insertions(+) create mode 100644 .changeset/nine-forks-sleep.md create mode 100644 packages/integration/src/ScmIntegrationsImpl.ts create mode 100644 packages/integration/src/azure/AzureIntegration.test.ts create mode 100644 packages/integration/src/azure/AzureIntegration.ts create mode 100644 packages/integration/src/bitbucket/BitbucketIntegration.test.ts create mode 100644 packages/integration/src/bitbucket/BitbucketIntegration.ts create mode 100644 packages/integration/src/github/GitHubIntegration.test.ts create mode 100644 packages/integration/src/github/GitHubIntegration.ts create mode 100644 packages/integration/src/gitlab/GitLabIntegration.test.ts create mode 100644 packages/integration/src/gitlab/GitLabIntegration.ts create mode 100644 packages/integration/src/types.ts diff --git a/.changeset/nine-forks-sleep.md b/.changeset/nine-forks-sleep.md new file mode 100644 index 0000000000..7edbca2169 --- /dev/null +++ b/.changeset/nine-forks-sleep.md @@ -0,0 +1,5 @@ +--- +'@backstage/integration': minor +--- + +Add the basics of cross-integration concerns diff --git a/packages/integration/src/ScmIntegrationsImpl.ts b/packages/integration/src/ScmIntegrationsImpl.ts new file mode 100644 index 0000000000..ef6a6835b9 --- /dev/null +++ b/packages/integration/src/ScmIntegrationsImpl.ts @@ -0,0 +1,51 @@ +/* + * 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 { Config } from '@backstage/config'; +import { AzureIntegration } from './azure/AzureIntegration'; +import { BitbucketIntegration } from './bitbucket/BitbucketIntegration'; +import { GitHubIntegration } from './github/GitHubIntegration'; +import { GitLabIntegration } from './gitlab/GitLabIntegration'; +import { + ScmIntegration, + ScmIntegrationPredicateTuple, + ScmIntegrations, +} from './types'; + +export class ScmIntegrationsImpl implements ScmIntegrations { + static fromConfig(config: Config): ScmIntegrationsImpl { + return new ScmIntegrationsImpl([ + ...AzureIntegration.factory({ config }), + ...BitbucketIntegration.factory({ config }), + ...GitHubIntegration.factory({ config }), + ...GitLabIntegration.factory({ config }), + ]); + } + + constructor(private readonly integrations: ScmIntegrationPredicateTuple[]) {} + + list(): ScmIntegration[] { + return this.integrations.map(i => i.integration); + } + + byName(name: string): ScmIntegration | undefined { + return this.integrations.map(i => i.integration).find(i => i.name === name); + } + + byUrl(url: string): ScmIntegration | undefined { + return this.integrations.find(i => i.predicate(new URL(url)))?.integration; + } +} diff --git a/packages/integration/src/azure/AzureIntegration.test.ts b/packages/integration/src/azure/AzureIntegration.test.ts new file mode 100644 index 0000000000..d0d3cf65ad --- /dev/null +++ b/packages/integration/src/azure/AzureIntegration.test.ts @@ -0,0 +1,48 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { AzureIntegration } from './AzureIntegration'; + +describe('AzureIntegration', () => { + it('has a working factory', () => { + const integrations = AzureIntegration.factory({ + config: ConfigReader.fromConfigs([ + { + context: '', + data: { + integrations: { + azure: [ + { + host: 'h.com', + token: 'token', + }, + ], + }, + }, + }, + ]), + }); + expect(integrations.length).toBe(2); // including default + expect(integrations[0].predicate(new URL('https://h.com/a'))).toBe(true); + }); + + it('returns the basics', () => { + const integration = new AzureIntegration({ host: 'h.com' } as any); + expect(integration.type).toBe('azure'); + expect(integration.name).toBe('h.com'); + }); +}); diff --git a/packages/integration/src/azure/AzureIntegration.ts b/packages/integration/src/azure/AzureIntegration.ts new file mode 100644 index 0000000000..2e1e4d9795 --- /dev/null +++ b/packages/integration/src/azure/AzureIntegration.ts @@ -0,0 +1,40 @@ +/* + * 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 { ScmIntegration, ScmIntegrationFactory } from '../types'; +import { AzureIntegrationConfig, readAzureIntegrationConfigs } from './config'; + +export class AzureIntegration implements ScmIntegration { + static factory: ScmIntegrationFactory = ({ config }) => { + const configs = readAzureIntegrationConfigs( + config.getOptionalConfigArray('integrations.azure') ?? [], + ); + return configs.map(integration => ({ + predicate: (url: URL) => url.host === integration.host, + integration: new AzureIntegration(integration), + })); + }; + + constructor(private readonly config: AzureIntegrationConfig) {} + + get type(): string { + return 'azure'; + } + + get name(): string { + return this.config.host; + } +} diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.test.ts b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts new file mode 100644 index 0000000000..9678b35014 --- /dev/null +++ b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts @@ -0,0 +1,51 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { BitbucketIntegration } from './BitbucketIntegration'; + +describe('BitbucketIntegration', () => { + it('has a working factory', () => { + const integrations = BitbucketIntegration.factory({ + config: ConfigReader.fromConfigs([ + { + context: '', + data: { + integrations: { + bitbucket: [ + { + host: 'h.com', + apiBaseUrl: 'a', + token: 't', + username: 'u', + appPassword: 'p', + }, + ], + }, + }, + }, + ]), + }); + expect(integrations.length).toBe(2); // including default + expect(integrations[0].predicate(new URL('https://h.com/a'))).toBe(true); + }); + + it('returns the basics', () => { + const integration = new BitbucketIntegration({ host: 'h.com' } as any); + expect(integration.type).toBe('bitbucket'); + expect(integration.name).toBe('h.com'); + }); +}); diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.ts b/packages/integration/src/bitbucket/BitbucketIntegration.ts new file mode 100644 index 0000000000..e06b539611 --- /dev/null +++ b/packages/integration/src/bitbucket/BitbucketIntegration.ts @@ -0,0 +1,43 @@ +/* + * 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 { ScmIntegration, ScmIntegrationFactory } from '../types'; +import { + BitbucketIntegrationConfig, + readBitbucketIntegrationConfigs, +} from './config'; + +export class BitbucketIntegration implements ScmIntegration { + static factory: ScmIntegrationFactory = ({ config }) => { + const configs = readBitbucketIntegrationConfigs( + config.getOptionalConfigArray('integrations.bitbucket') ?? [], + ); + return configs.map(integration => ({ + predicate: (url: URL) => url.host === integration.host, + integration: new BitbucketIntegration(integration), + })); + }; + + constructor(private readonly config: BitbucketIntegrationConfig) {} + + get type(): string { + return 'bitbucket'; + } + + get name(): string { + return this.config.host; + } +} diff --git a/packages/integration/src/github/GitHubIntegration.test.ts b/packages/integration/src/github/GitHubIntegration.test.ts new file mode 100644 index 0000000000..5a8800200f --- /dev/null +++ b/packages/integration/src/github/GitHubIntegration.test.ts @@ -0,0 +1,50 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { GitHubIntegration } from './GitHubIntegration'; + +describe('GitHubIntegration', () => { + it('has a working factory', () => { + const integrations = GitHubIntegration.factory({ + config: ConfigReader.fromConfigs([ + { + context: '', + data: { + integrations: { + github: [ + { + host: 'h.com', + apiBaseUrl: 'a', + rawBaseUrl: 'r', + token: 't', + }, + ], + }, + }, + }, + ]), + }); + expect(integrations.length).toBe(2); // including default + expect(integrations[0].predicate(new URL('https://h.com/a'))).toBe(true); + }); + + it('returns the basics', () => { + const integration = new GitHubIntegration({ host: 'h.com' } as any); + expect(integration.type).toBe('github'); + expect(integration.name).toBe('h.com'); + }); +}); diff --git a/packages/integration/src/github/GitHubIntegration.ts b/packages/integration/src/github/GitHubIntegration.ts new file mode 100644 index 0000000000..f70684975d --- /dev/null +++ b/packages/integration/src/github/GitHubIntegration.ts @@ -0,0 +1,43 @@ +/* + * 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 { ScmIntegration, ScmIntegrationFactory } from '../types'; +import { + GitHubIntegrationConfig, + readGitHubIntegrationConfigs, +} from './config'; + +export class GitHubIntegration implements ScmIntegration { + static factory: ScmIntegrationFactory = ({ config }) => { + const configs = readGitHubIntegrationConfigs( + config.getOptionalConfigArray('integrations.github') ?? [], + ); + return configs.map(integration => ({ + predicate: (url: URL) => url.host === integration.host, + integration: new GitHubIntegration(integration), + })); + }; + + constructor(private readonly config: GitHubIntegrationConfig) {} + + get type(): string { + return 'github'; + } + + get name(): string { + return this.config.host; + } +} diff --git a/packages/integration/src/gitlab/GitLabIntegration.test.ts b/packages/integration/src/gitlab/GitLabIntegration.test.ts new file mode 100644 index 0000000000..5c46610c53 --- /dev/null +++ b/packages/integration/src/gitlab/GitLabIntegration.test.ts @@ -0,0 +1,48 @@ +/* + * 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 { ConfigReader } from '@backstage/config'; +import { GitLabIntegration } from './GitLabIntegration'; + +describe('GitLabIntegration', () => { + it('has a working factory', () => { + const integrations = GitLabIntegration.factory({ + config: ConfigReader.fromConfigs([ + { + context: '', + data: { + integrations: { + gitlab: [ + { + host: 'h.com', + token: 't', + }, + ], + }, + }, + }, + ]), + }); + expect(integrations.length).toBe(2); // including default + expect(integrations[0].predicate(new URL('https://h.com/a'))).toBe(true); + }); + + it('returns the basics', () => { + const integration = new GitLabIntegration({ host: 'h.com' } as any); + expect(integration.type).toBe('gitlab'); + expect(integration.name).toBe('h.com'); + }); +}); diff --git a/packages/integration/src/gitlab/GitLabIntegration.ts b/packages/integration/src/gitlab/GitLabIntegration.ts new file mode 100644 index 0000000000..6e94e084cc --- /dev/null +++ b/packages/integration/src/gitlab/GitLabIntegration.ts @@ -0,0 +1,43 @@ +/* + * 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 { ScmIntegration, ScmIntegrationFactory } from '../types'; +import { + GitLabIntegrationConfig, + readGitLabIntegrationConfigs, +} from './config'; + +export class GitLabIntegration implements ScmIntegration { + static factory: ScmIntegrationFactory = ({ config }) => { + const configs = readGitLabIntegrationConfigs( + config.getOptionalConfigArray('integrations.gitlab') ?? [], + ); + return configs.map(integration => ({ + predicate: (url: URL) => url.host === integration.host, + integration: new GitLabIntegration(integration), + })); + }; + + constructor(private readonly config: GitLabIntegrationConfig) {} + + get type(): string { + return 'gitlab'; + } + + get name(): string { + return this.config.host; + } +} diff --git a/packages/integration/src/index.ts b/packages/integration/src/index.ts index bfed81824f..9d42914fdb 100644 --- a/packages/integration/src/index.ts +++ b/packages/integration/src/index.ts @@ -18,3 +18,4 @@ export * from './azure'; export * from './bitbucket'; export * from './github'; export * from './gitlab'; +export type { ScmIntegration, ScmIntegrations } from './types'; diff --git a/packages/integration/src/types.ts b/packages/integration/src/types.ts new file mode 100644 index 0000000000..6f9290a948 --- /dev/null +++ b/packages/integration/src/types.ts @@ -0,0 +1,58 @@ +/* + * 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 { Config } from '@backstage/config'; + +/** + * Encapsulates a single SCM integration. + */ +export type ScmIntegration = { + type: string; + name: string; +}; + +/** + * Holds all registered SCM integrations. + */ +export type ScmIntegrations = { + /** + * Lists all registered integrations. + */ + list(): ScmIntegration[]; + + /** + * Fetches a named integration + * + * @param name The name of a registered integration + */ + byName(name: string): ScmIntegration | undefined; + + /** + * Fetches an integration by URL + * + * @param name A URL that matches a registered integration + */ + byUrl(url: string): ScmIntegration | undefined; +}; + +export type ScmIntegrationPredicateTuple = { + predicate: (url: URL) => boolean; + integration: ScmIntegration; +}; + +export type ScmIntegrationFactory = (options: { + config: Config; +}) => ScmIntegrationPredicateTuple[]; From 38e24db009a50428984103562e21ac3848f39c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sun, 29 Nov 2020 14:28:27 +0100 Subject: [PATCH 11/42] integration: move the core url and auth logic to integration for the four major providers --- .changeset/lazy-beans-search.md | 6 + .../src/reading/AzureUrlReader.test.ts | 23 +-- .../src/reading/AzureUrlReader.ts | 108 +----------- .../src/reading/BitbucketUrlReader.test.ts | 87 +--------- .../src/reading/BitbucketUrlReader.ts | 64 +------ .../src/reading/GithubUrlReader.test.ts | 146 +--------------- .../src/reading/GithubUrlReader.ts | 100 +---------- .../src/reading/GitlabUrlReader.ts | 118 +------------ packages/integration/package.json | 4 +- packages/integration/src/azure/core.test.ts | 93 +++++++++++ packages/integration/src/azure/core.ts | 131 +++++++++++++++ packages/integration/src/azure/index.ts | 5 + .../integration/src/bitbucket/core.test.ts | 100 +++++++++++ packages/integration/src/bitbucket/core.ts | 84 ++++++++++ packages/integration/src/bitbucket/index.ts | 1 + packages/integration/src/github/core.test.ts | 115 +++++++++++++ packages/integration/src/github/core.ts | 82 +++++++++ packages/integration/src/github/index.ts | 1 + packages/integration/src/gitlab/core.test.ts | 80 +++++++++ packages/integration/src/gitlab/core.ts | 157 ++++++++++++++++++ packages/integration/src/gitlab/index.ts | 1 + 21 files changed, 884 insertions(+), 622 deletions(-) create mode 100644 .changeset/lazy-beans-search.md create mode 100644 packages/integration/src/azure/core.test.ts create mode 100644 packages/integration/src/azure/core.ts create mode 100644 packages/integration/src/bitbucket/core.test.ts create mode 100644 packages/integration/src/bitbucket/core.ts create mode 100644 packages/integration/src/github/core.test.ts create mode 100644 packages/integration/src/github/core.ts create mode 100644 packages/integration/src/gitlab/core.test.ts create mode 100644 packages/integration/src/gitlab/core.ts diff --git a/.changeset/lazy-beans-search.md b/.changeset/lazy-beans-search.md new file mode 100644 index 0000000000..1ea3c4bb80 --- /dev/null +++ b/.changeset/lazy-beans-search.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-common': patch +'@backstage/integration': patch +--- + +Move the core url and auth logic to integration for the four major providers diff --git a/packages/backend-common/src/reading/AzureUrlReader.test.ts b/packages/backend-common/src/reading/AzureUrlReader.test.ts index ab97d1b073..2c8549f917 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.test.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.test.ts @@ -20,7 +20,7 @@ import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { ConfigReader } from '@backstage/config'; import { getVoidLogger } from '../logging'; -import { AzureUrlReader, getDownloadUrl } from './AzureUrlReader'; +import { AzureUrlReader } from './AzureUrlReader'; import { msw } from '@backstage/test-utils'; import { ReadTreeResponseFactory } from './tree'; @@ -111,13 +111,13 @@ describe('AzureUrlReader', () => { url: 'https://api.com/a/b/blob/master/path/to/c.yaml', config: createConfig(), error: - 'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path', + 'Incorrect URL: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path', }, { url: 'com/a/b/blob/master/path/to/c.yaml', config: createConfig(), error: - 'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml', + 'Incorrect URL: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml', }, { url: '', @@ -178,21 +178,4 @@ describe('AzureUrlReader', () => { expect(indexMarkdownFile.toString()).toBe('# Test\n'); }); }); - - describe('getDownloadUrl', () => { - it('do not add scopePath if no path is specified', async () => { - const result = getDownloadUrl( - 'https://dev.azure.com/organization/project/_git/repository', - ); - - expect(result.searchParams.get('scopePath')).toBeNull(); - }); - - it('add scopePath if a path is specified', async () => { - const result = getDownloadUrl( - 'https://dev.azure.com/organization/project/_git/repository?path=%2Fdocs', - ); - expect(result.searchParams.get('scopePath')).toEqual('docs'); - }); - }); }); diff --git a/packages/backend-common/src/reading/AzureUrlReader.ts b/packages/backend-common/src/reading/AzureUrlReader.ts index ad990d1d5d..ca934ee42c 100644 --- a/packages/backend-common/src/reading/AzureUrlReader.ts +++ b/packages/backend-common/src/reading/AzureUrlReader.ts @@ -17,10 +17,12 @@ import { AzureIntegrationConfig, readAzureIntegrationConfigs, + getAzureFileFetchUrl, + getAzureDownloadUrl, + getAzureRequestOptions, } from '@backstage/integration'; import fetch from 'cross-fetch'; import { Readable } from 'stream'; -import parseGitUri from 'git-url-parse'; import { NotFoundError } from '../errors'; import { ReaderFactory, @@ -30,28 +32,6 @@ import { } from './types'; import { ReadTreeResponseFactory } from './tree'; -export function getDownloadUrl(url: string): URL { - const { - name: repoName, - owner: project, - organization, - protocol, - resource, - filepath, - } = parseGitUri(url); - - // scopePath will limit the downloaded content - // /docs will only download the docs folder and everything below it - // /docs/index.md will only download index.md but put it in the root of the archive - const scopePath = filepath - ? `&scopePath=${encodeURIComponent(filepath)}` - : ''; - - return new URL( - `${protocol}://${resource}/${organization}/${project}/_apis/git/repositories/${repoName}/items?recursionLevel=full&download=true&api-version=6.0${scopePath}`, - ); -} - export class AzureUrlReader implements UrlReader { static factory: ReaderFactory = ({ config, treeResponseFactory }) => { const configs = readAzureIntegrationConfigs( @@ -76,11 +56,11 @@ export class AzureUrlReader implements UrlReader { } async read(url: string): Promise { - const builtUrl = this.buildRawUrl(url); + const builtUrl = getAzureFileFetchUrl(url); let response: Response; try { - response = await fetch(builtUrl.toString(), this.getRequestOptions()); + response = await fetch(builtUrl, getAzureRequestOptions(this.options)); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } @@ -102,8 +82,8 @@ export class AzureUrlReader implements UrlReader { options?: ReadTreeOptions, ): Promise { const response = await fetch( - getDownloadUrl(url).toString(), - this.getRequestOptions({ Accept: 'application/zip' }), + getAzureDownloadUrl(url), + getAzureRequestOptions(this.options, { Accept: 'application/zip' }), ); if (!response.ok) { const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`; @@ -119,80 +99,6 @@ export class AzureUrlReader implements UrlReader { }); } - // Converts - // from: https://dev.azure.com/{organization}/{project}/_git/reponame?path={path}&version=GB{commitOrBranch}&_a=contents - // to: https://dev.azure.com/{organization}/{project}/_apis/git/repositories/reponame/items?path={path}&version={commitOrBranch} - private buildRawUrl(target: string): URL { - try { - const url = new URL(target); - - const [ - empty, - userOrOrg, - project, - srcKeyword, - repoName, - ] = url.pathname.split('/'); - - const path = url.searchParams.get('path') || ''; - const ref = url.searchParams.get('version')?.substr(2); - - if ( - url.hostname !== 'dev.azure.com' || - empty !== '' || - userOrOrg === '' || - project === '' || - srcKeyword !== '_git' || - repoName === '' || - path === '' || - ref === '' - ) { - throw new Error('Wrong Azure Devops URL or Invalid file path'); - } - - // transform to api - url.pathname = [ - empty, - userOrOrg, - project, - '_apis', - 'git', - 'repositories', - repoName, - 'items', - ].join('/'); - - const queryParams = [`path=${path}`]; - - if (ref) { - queryParams.push(`version=${ref}`); - } - - url.search = queryParams.join('&'); - - url.protocol = 'https'; - - return url; - } catch (e) { - throw new Error(`Incorrect url: ${target}, ${e}`); - } - } - - private getRequestOptions(additionalHeaders?: { - [key: string]: string; - }): RequestInit { - const headers: HeadersInit = additionalHeaders ?? {}; - - if (this.options.token) { - headers.Authorization = `Basic ${Buffer.from( - `:${this.options.token}`, - 'utf8', - ).toString('base64')}`; - } - - return { headers }; - } - toString() { const { host, token } = this.options; return `azure{host=${host},authed=${Boolean(token)}}`; diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts index 01744db28a..1c0bd39372 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.test.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.test.ts @@ -14,94 +14,9 @@ * limitations under the License. */ -import { BitbucketIntegrationConfig } from '@backstage/integration'; -import { - BitbucketUrlReader, - getApiRequestOptions, - getApiUrl, -} from './BitbucketUrlReader'; +import { BitbucketUrlReader } from './BitbucketUrlReader'; describe('BitbucketUrlReader', () => { - describe('getApiRequestOptions', () => { - it('inserts a token when needed', () => { - const withToken: BitbucketIntegrationConfig = { - host: '', - apiBaseUrl: '', - token: 'A', - }; - const withoutToken: BitbucketIntegrationConfig = { - host: '', - apiBaseUrl: '', - }; - expect( - (getApiRequestOptions(withToken).headers as any).Authorization, - ).toEqual('Bearer A'); - expect( - (getApiRequestOptions(withoutToken).headers as any).Authorization, - ).toBeUndefined(); - }); - - it('insert basic auth when needed', () => { - const withUsernameAndPassword: BitbucketIntegrationConfig = { - host: '', - apiBaseUrl: '', - username: 'some-user', - appPassword: 'my-secret', - }; - const withoutUsernameAndPassword: BitbucketIntegrationConfig = { - host: '', - apiBaseUrl: '', - }; - expect( - (getApiRequestOptions(withUsernameAndPassword).headers as any) - .Authorization, - ).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA=='); - expect( - (getApiRequestOptions(withoutUsernameAndPassword).headers as any) - .Authorization, - ).toBeUndefined(); - }); - }); - - describe('getApiUrl', () => { - it('rejects targets that do not look like URLs', () => { - const config: BitbucketIntegrationConfig = { host: '', apiBaseUrl: '' }; - expect(() => getApiUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/); - }); - it('happy path for Bitbucket Cloud', () => { - const config: BitbucketIntegrationConfig = { - host: 'bitbucket.org', - apiBaseUrl: 'https://api.bitbucket.org/2.0', - }; - expect( - getApiUrl( - 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml', - config, - ), - ).toEqual( - new URL( - 'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml', - ), - ); - }); - it('happy path for Bitbucket Server', () => { - const config: BitbucketIntegrationConfig = { - host: 'bitbucket.mycompany.net', - apiBaseUrl: 'https://bitbucket.mycompany.net/rest/api/1.0', - }; - expect( - getApiUrl( - 'https://bitbucket.mycompany.net/projects/a/repos/b/browse/path/to/c.yaml', - config, - ), - ).toEqual( - new URL( - 'https://bitbucket.mycompany.net/rest/api/1.0/projects/a/repos/b/raw/path/to/c.yaml', - ), - ); - }); - }); - describe('implementation', () => { it('rejects unknown targets', async () => { const processor = new BitbucketUrlReader({ diff --git a/packages/backend-common/src/reading/BitbucketUrlReader.ts b/packages/backend-common/src/reading/BitbucketUrlReader.ts index 9694c1d987..8c97bf2ee2 100644 --- a/packages/backend-common/src/reading/BitbucketUrlReader.ts +++ b/packages/backend-common/src/reading/BitbucketUrlReader.ts @@ -16,69 +16,14 @@ import { BitbucketIntegrationConfig, + getBitbucketFileFetchUrl, + getBitbucketRequestOptions, readBitbucketIntegrationConfigs, } from '@backstage/integration'; import fetch from 'cross-fetch'; -import parseGitUri from 'git-url-parse'; import { NotFoundError } from '../errors'; import { ReaderFactory, ReadTreeResponse, UrlReader } from './types'; -export function getApiRequestOptions( - provider: BitbucketIntegrationConfig, -): RequestInit { - const headers: HeadersInit = {}; - - if (provider.token) { - headers.Authorization = `Bearer ${provider.token}`; - } else if (provider.username && provider.appPassword) { - headers.Authorization = `Basic ${Buffer.from( - `${provider.username}:${provider.appPassword}`, - 'utf8', - ).toString('base64')}`; - } - - return { - headers, - }; -} - -// Converts for example -// from: https://bitbucket.org/orgname/reponame/src/master/file.yaml -// to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml -export function getApiUrl( - target: string, - provider: BitbucketIntegrationConfig, -): URL { - try { - const { owner, name, ref, filepathtype, filepath } = parseGitUri(target); - if ( - !owner || - !name || - (filepathtype !== 'browse' && - filepathtype !== 'raw' && - filepathtype !== 'src') - ) { - throw new Error('Invalid Bitbucket URL or file path'); - } - - const pathWithoutSlash = filepath.replace(/^\//, ''); - - if (provider.host === 'bitbucket.org') { - if (!ref) { - throw new Error('Invalid Bitbucket URL or file path'); - } - return new URL( - `${provider.apiBaseUrl}/repositories/${owner}/${name}/src/${ref}/${pathWithoutSlash}`, - ); - } - return new URL( - `${provider.apiBaseUrl}/projects/${owner}/repos/${name}/raw/${pathWithoutSlash}?at=${ref}`, - ); - } catch (e) { - throw new Error(`Incorrect URL: ${target}, ${e}`); - } -} - /** * A processor that adds the ability to read files from Bitbucket v1 and v2 APIs, such as * the one exposed by Bitbucket Cloud itself. @@ -116,9 +61,8 @@ export class BitbucketUrlReader implements UrlReader { } async read(url: string): Promise { - const bitbucketUrl = getApiUrl(url, this.config); - - const options = getApiRequestOptions(this.config); + const bitbucketUrl = getBitbucketFileFetchUrl(url, this.config); + const options = getBitbucketRequestOptions(this.config); let response: Response; try { diff --git a/packages/backend-common/src/reading/GithubUrlReader.test.ts b/packages/backend-common/src/reading/GithubUrlReader.test.ts index abe8b4f640..f842adcf90 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.test.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.test.ts @@ -15,19 +15,12 @@ */ import { ConfigReader } from '@backstage/config'; -import { GitHubIntegrationConfig } from '@backstage/integration'; import { msw } from '@backstage/test-utils'; import fs from 'fs'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import path from 'path'; -import { - getApiRequestOptions, - getApiUrl, - getRawRequestOptions, - getRawUrl, - GithubUrlReader, -} from './GithubUrlReader'; +import { GithubUrlReader } from './GithubUrlReader'; import { ReadTreeResponseFactory } from './tree'; const treeResponseFactory = ReadTreeResponseFactory.create({ @@ -35,143 +28,6 @@ const treeResponseFactory = ReadTreeResponseFactory.create({ }); describe('GithubUrlReader', () => { - describe('getApiRequestOptions', () => { - it('sets the correct API version', () => { - const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' }; - expect((getApiRequestOptions(config).headers as any).Accept).toEqual( - 'application/vnd.github.v3.raw', - ); - }); - - it('inserts a token when needed', () => { - const withToken: GitHubIntegrationConfig = { - host: '', - apiBaseUrl: '', - token: 'A', - }; - const withoutToken: GitHubIntegrationConfig = { - host: '', - apiBaseUrl: '', - }; - expect( - (getApiRequestOptions(withToken).headers as any).Authorization, - ).toEqual('token A'); - expect( - (getApiRequestOptions(withoutToken).headers as any).Authorization, - ).toBeUndefined(); - }); - }); - - describe('getRawRequestOptions', () => { - it('inserts a token when needed', () => { - const withToken: GitHubIntegrationConfig = { - host: '', - rawBaseUrl: '', - token: 'A', - }; - const withoutToken: GitHubIntegrationConfig = { - host: '', - rawBaseUrl: '', - }; - expect( - (getRawRequestOptions(withToken).headers as any).Authorization, - ).toEqual('token A'); - expect( - (getRawRequestOptions(withoutToken).headers as any).Authorization, - ).toBeUndefined(); - }); - }); - - describe('getApiUrl', () => { - it('rejects targets that do not look like URLs', () => { - const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' }; - expect(() => getApiUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/); - }); - - it('happy path for github', () => { - const config: GitHubIntegrationConfig = { - host: 'github.com', - apiBaseUrl: 'https://api.github.com', - }; - expect( - getApiUrl( - 'https://github.com/a/b/blob/branchname/path/to/c.yaml', - config, - ), - ).toEqual( - new URL( - 'https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname', - ), - ); - expect( - getApiUrl( - 'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml', - config, - ), - ).toEqual( - new URL( - 'https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname', - ), - ); - }); - - it('happy path for ghe', () => { - const config: GitHubIntegrationConfig = { - host: 'ghe.mycompany.net', - apiBaseUrl: 'https://ghe.mycompany.net/api/v3', - }; - expect( - getApiUrl( - 'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml', - config, - ), - ).toEqual( - new URL( - 'https://ghe.mycompany.net/api/v3/repos/a/b/contents/path/to/c.yaml?ref=branchname', - ), - ); - }); - }); - - describe('getRawUrl', () => { - it('rejects targets that do not look like URLs', () => { - const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' }; - expect(() => getRawUrl('a/b', config)).toThrow(/Incorrect URL: a\/b/); - }); - - it('happy path for github', () => { - const config: GitHubIntegrationConfig = { - host: 'github.com', - rawBaseUrl: 'https://raw.githubusercontent.com', - }; - expect( - getRawUrl( - 'https://github.com/a/b/blob/branchname/path/to/c.yaml', - config, - ), - ).toEqual( - new URL( - 'https://raw.githubusercontent.com/a/b/branchname/path/to/c.yaml', - ), - ); - }); - - it('happy path for ghe', () => { - const config: GitHubIntegrationConfig = { - host: 'ghe.mycompany.net', - rawBaseUrl: 'https://ghe.mycompany.net/raw', - }; - expect( - getRawUrl( - 'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml', - config, - ), - ).toEqual( - new URL('https://ghe.mycompany.net/raw/a/b/branchname/path/to/c.yaml'), - ); - }); - }); - describe('implementation', () => { it('rejects unknown targets', async () => { const processor = new GithubUrlReader( diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index de798a7067..5ca2a99692 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -17,6 +17,8 @@ import { GitHubIntegrationConfig, readGitHubIntegrationConfigs, + getGitHubFileFetchUrl, + getGitHubRequestOptions, } from '@backstage/integration'; import fetch from 'cross-fetch'; import parseGitUri from 'git-url-parse'; @@ -30,92 +32,6 @@ import { UrlReader, } from './types'; -export function getApiRequestOptions( - provider: GitHubIntegrationConfig, -): RequestInit { - const headers: HeadersInit = { - Accept: 'application/vnd.github.v3.raw', - }; - - if (provider.token) { - headers.Authorization = `token ${provider.token}`; - } - - return { - headers, - }; -} - -export function getRawRequestOptions( - provider: GitHubIntegrationConfig, -): RequestInit { - const headers: HeadersInit = {}; - - if (provider.token) { - headers.Authorization = `token ${provider.token}`; - } - - return { - headers, - }; -} - -// Converts for example -// from: https://github.com/a/b/blob/branchname/path/to/c.yaml -// to: https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname -export function getApiUrl( - target: string, - provider: GitHubIntegrationConfig, -): URL { - try { - const { owner, name, ref, filepathtype, filepath } = parseGitUri(target); - - if ( - !owner || - !name || - !ref || - (filepathtype !== 'blob' && filepathtype !== 'raw') - ) { - throw new Error('Invalid GitHub URL or file path'); - } - - const pathWithoutSlash = filepath.replace(/^\//, ''); - return new URL( - `${provider.apiBaseUrl}/repos/${owner}/${name}/contents/${pathWithoutSlash}?ref=${ref}`, - ); - } catch (e) { - throw new Error(`Incorrect URL: ${target}, ${e}`); - } -} - -// Converts for example -// from: https://github.com/a/b/blob/branchname/c.yaml -// to: https://raw.githubusercontent.com/a/b/branchname/c.yaml -export function getRawUrl( - target: string, - provider: GitHubIntegrationConfig, -): URL { - try { - const { owner, name, ref, filepathtype, filepath } = parseGitUri(target); - - if ( - !owner || - !name || - !ref || - (filepathtype !== 'blob' && filepathtype !== 'raw') - ) { - throw new Error('Invalid GitHub URL or file path'); - } - - const pathWithoutSlash = filepath.replace(/^\//, ''); - return new URL( - `${provider.rawBaseUrl}/${owner}/${name}/${ref}/${pathWithoutSlash}`, - ); - } catch (e) { - throw new Error(`Incorrect URL: ${target}, ${e}`); - } -} - /** * A processor that adds the ability to read files from GitHub v3 APIs, such as * the one exposed by GitHub itself. @@ -144,14 +60,8 @@ export class GithubUrlReader implements UrlReader { } async read(url: string): Promise { - const useApi = - this.config.apiBaseUrl && (this.config.token || !this.config.rawBaseUrl); - const ghUrl = useApi - ? getApiUrl(url, this.config) - : getRawUrl(url, this.config); - const options = useApi - ? getApiRequestOptions(this.config) - : getRawRequestOptions(this.config); + const ghUrl = getGitHubFileFetchUrl(url, this.config); + const options = getGitHubRequestOptions(this.config); let response: Response; try { @@ -196,7 +106,7 @@ export class GithubUrlReader implements UrlReader { new URL( `${protocol}://${resource}/${full_name}/archive/${ref}.tar.gz`, ).toString(), - getRawRequestOptions(this.config), + getGitHubRequestOptions(this.config), ); if (!response.ok) { const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`; diff --git a/packages/backend-common/src/reading/GitlabUrlReader.ts b/packages/backend-common/src/reading/GitlabUrlReader.ts index e2d3edfea2..d6d1da5cbb 100644 --- a/packages/backend-common/src/reading/GitlabUrlReader.ts +++ b/packages/backend-common/src/reading/GitlabUrlReader.ts @@ -15,6 +15,8 @@ */ import { + getGitLabFileFetchUrl, + getGitLabRequestOptions, GitLabIntegrationConfig, readGitLabIntegrationConfigs, } from '@backstage/integration'; @@ -37,20 +39,11 @@ export class GitlabUrlReader implements UrlReader { constructor(private readonly options: GitLabIntegrationConfig) {} async read(url: string): Promise { - // TODO(Rugvip): merged the old GitlabReaderProcessor in here and used - // the existence of /~/blob/ to switch the logic. Don't know if this - // makes sense and it might require some more work. - let builtUrl: URL; - if (url.includes('/-/blob/')) { - const projectID = await this.getProjectID(url); - builtUrl = this.buildProjectUrl(url, projectID); - } else { - builtUrl = this.buildRawUrl(url); - } + const builtUrl = await getGitLabFileFetchUrl(url, this.options); let response: Response; try { - response = await fetch(builtUrl.toString(), this.getRequestOptions()); + response = await fetch(builtUrl, getGitLabRequestOptions(this.options)); } catch (e) { throw new Error(`Unable to read ${url}, ${e}`); } @@ -70,109 +63,6 @@ export class GitlabUrlReader implements UrlReader { throw new Error('GitlabUrlReader does not implement readTree'); } - // Converts - // from: https://gitlab.example.com/a/b/blob/master/c.yaml - // to: https://gitlab.example.com/a/b/raw/master/c.yaml - private buildRawUrl(target: string): URL { - try { - const url = new URL(target); - - const [ - empty, - userOrOrg, - repoName, - blobKeyword, - ...restOfPath - ] = url.pathname.split('/'); - - if ( - empty !== '' || - userOrOrg === '' || - repoName === '' || - blobKeyword !== 'blob' || - !restOfPath.join('/').match(/\.yaml$/) - ) { - throw new Error('Wrong GitLab URL'); - } - - // Replace 'blob' with 'raw' - url.pathname = [empty, userOrOrg, repoName, 'raw', ...restOfPath].join( - '/', - ); - - return url; - } catch (e) { - throw new Error(`Incorrect url: ${target}, ${e}`); - } - } - - // convert https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath - // to https://gitlab.com/api/v4/projects//repository/files/filepath?ref=branch - private buildProjectUrl(target: string, projectID: Number): URL { - try { - const url = new URL(target); - - const branchAndFilePath = url.pathname.split('/-/blob/')[1]; - - const [branch, ...filePath] = branchAndFilePath.split('/'); - - url.pathname = [ - '/api/v4/projects', - projectID, - 'repository/files', - encodeURIComponent(filePath.join('/')), - 'raw', - ].join('/'); - url.search = `?ref=${branch}`; - - return url; - } catch (e) { - throw new Error(`Incorrect url: ${target}, ${e}`); - } - } - - private async getProjectID(target: string): Promise { - const url = new URL(target); - - if ( - // absPaths to gitlab files should contain /-/blob - // ex: https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath - !url.pathname.match(/\/\-\/blob\//) - ) { - throw new Error('Please provide full path to yaml file from Gitlab'); - } - try { - const repo = url.pathname.split('/-/blob/')[0]; - - // Find ProjectID from url - // convert 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath' - // to 'https://gitlab.com/api/v4/projects/groupA%2Fteams%2FsubgroupA%2FteamA%2Frepo' - const repoIDLookup = new URL( - `${url.protocol + url.hostname}/api/v4/projects/${encodeURIComponent( - repo.replace(/^\//, ''), - )}`, - ); - const response = await fetch( - repoIDLookup.toString(), - this.getRequestOptions(), - ); - const projectIDJson = await response.json(); - const projectID: Number = projectIDJson.id; - - return projectID; - } catch (e) { - throw new Error(`Could not get GitLab ProjectID for: ${target}, ${e}`); - } - } - - private getRequestOptions(): RequestInit { - return { - headers: { - ['PRIVATE-TOKEN']: this.options.token ?? '', - }, - }; - } - toString() { const { host, token } = this.options; return `gitlab{host=${host},authed=${Boolean(token)}}`; diff --git a/packages/integration/package.json b/packages/integration/package.json index 342476c38f..8af3cb60b8 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -21,11 +21,13 @@ }, "dependencies": { "@backstage/config": "^0.1.1", + "cross-fetch": "^3.0.6", "git-url-parse": "^11.4.0" }, "devDependencies": { "@backstage/cli": "^0.4.0", - "@types/jest": "^26.0.7" + "@types/jest": "^26.0.7", + "msw": "^0.21.2" }, "files": [ "dist", diff --git a/packages/integration/src/azure/core.test.ts b/packages/integration/src/azure/core.test.ts new file mode 100644 index 0000000000..17041a9eb2 --- /dev/null +++ b/packages/integration/src/azure/core.test.ts @@ -0,0 +1,93 @@ +/* + * 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 { + getAzureFileFetchUrl, + getAzureDownloadUrl, + getAzureRequestOptions, +} from './core'; + +describe('azure core', () => { + describe('getAzureRequestOptions', () => { + it('fills in the token if necessary', () => { + expect(getAzureRequestOptions({ host: '', token: '0123456789' })).toEqual( + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Basic OjAxMjM0NTY3ODk=', + }), + }), + ); + expect(getAzureRequestOptions({ host: '' })).toEqual( + expect.objectContaining({ + headers: expect.not.objectContaining({ + Authorization: expect.anything(), + }), + }), + ); + }); + }); + + describe('getAzureFileFetchUrl', () => { + it.each([ + { + url: + 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster', + result: + 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml&version=master', + }, + { + url: + 'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml', + result: + 'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml', + }, + ])('should handle happy path %#', async ({ url, result }) => { + expect(getAzureFileFetchUrl(url)).toBe(result); + }); + + it.each([ + { + url: 'https://api.com/a/b/blob/master/path/to/c.yaml', + error: + 'Incorrect URL: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path', + }, + { + url: 'com/a/b/blob/master/path/to/c.yaml', + error: + 'Incorrect URL: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml', + }, + ])('should handle error path %#', ({ url, error }) => { + expect(() => getAzureFileFetchUrl(url)).toThrow(error); + }); + }); + + describe('getAzureDownloadUrl', () => { + it('do not add scopePath if no path is specified', async () => { + const result = getAzureDownloadUrl( + 'https://dev.azure.com/organization/project/_git/repository', + ); + + expect(new URL(result).searchParams.get('scopePath')).toBeNull(); + }); + + it('add scopePath if a path is specified', async () => { + const result = getAzureDownloadUrl( + 'https://dev.azure.com/organization/project/_git/repository?path=%2Fdocs', + ); + expect(new URL(result).searchParams.get('scopePath')).toEqual('docs'); + }); + }); +}); diff --git a/packages/integration/src/azure/core.ts b/packages/integration/src/azure/core.ts new file mode 100644 index 0000000000..44591f2509 --- /dev/null +++ b/packages/integration/src/azure/core.ts @@ -0,0 +1,131 @@ +/* + * 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 parseGitUrl from 'git-url-parse'; +import { AzureIntegrationConfig } from './config'; + +/** + * Given a URL pointing to a file on a provider, returns a URL that is suitable + * for fetching the contents of the data. + * + * Converts + * from: https://dev.azure.com/{organization}/{project}/_git/reponame?path={path}&version=GB{commitOrBranch}&_a=contents + * to: https://dev.azure.com/{organization}/{project}/_apis/git/repositories/reponame/items?path={path}&version={commitOrBranch} + * + * @param url A URL pointing to a file + */ +export function getAzureFileFetchUrl(url: string): string { + try { + const parsedUrl = new URL(url); + + const [ + empty, + userOrOrg, + project, + srcKeyword, + repoName, + ] = parsedUrl.pathname.split('/'); + + const path = parsedUrl.searchParams.get('path') || ''; + const ref = parsedUrl.searchParams.get('version')?.substr(2); + + if ( + parsedUrl.hostname !== 'dev.azure.com' || + empty !== '' || + userOrOrg === '' || + project === '' || + srcKeyword !== '_git' || + repoName === '' || + path === '' || + ref === '' + ) { + throw new Error('Wrong Azure Devops URL or Invalid file path'); + } + + // transform to api + parsedUrl.pathname = [ + empty, + userOrOrg, + project, + '_apis', + 'git', + 'repositories', + repoName, + 'items', + ].join('/'); + + const queryParams = [`path=${path}`]; + + if (ref) { + queryParams.push(`version=${ref}`); + } + + parsedUrl.search = queryParams.join('&'); + + parsedUrl.protocol = 'https'; + + return parsedUrl.toString(); + } catch (e) { + throw new Error(`Incorrect URL: ${url}, ${e}`); + } +} + +/** + * Given a URL pointing to a path on a provider, returns a URL that is suitable + * for downloading the subtree. + * + * @param url A URL pointing to a path + */ +export function getAzureDownloadUrl(url: string): string { + const { + name: repoName, + owner: project, + organization, + protocol, + resource, + filepath, + } = parseGitUrl(url); + + // scopePath will limit the downloaded content + // /docs will only download the docs folder and everything below it + // /docs/index.md will only download index.md but put it in the root of the archive + const scopePath = filepath + ? `&scopePath=${encodeURIComponent(filepath)}` + : ''; + + return `${protocol}://${resource}/${organization}/${project}/_apis/git/repositories/${repoName}/items?recursionLevel=full&download=true&api-version=6.0${scopePath}`; +} + +/** + * Gets the request options necessary to make requests to a given provider. + * + * @param config The relevant provider config + */ +export function getAzureRequestOptions( + config: AzureIntegrationConfig, + additionalHeaders?: Record, +): RequestInit { + const headers: HeadersInit = additionalHeaders + ? { ...additionalHeaders } + : {}; + + if (config.token) { + const buffer = Buffer.from(`:${config.token}`, 'utf8'); + headers.Authorization = `Basic ${buffer.toString('base64')}`; + } + + return { headers }; +} diff --git a/packages/integration/src/azure/index.ts b/packages/integration/src/azure/index.ts index ede0c88a81..365e4cdcdc 100644 --- a/packages/integration/src/azure/index.ts +++ b/packages/integration/src/azure/index.ts @@ -19,3 +19,8 @@ export { readAzureIntegrationConfigs, } from './config'; export type { AzureIntegrationConfig } from './config'; +export { + getAzureDownloadUrl, + getAzureFileFetchUrl, + getAzureRequestOptions, +} from './core'; diff --git a/packages/integration/src/bitbucket/core.test.ts b/packages/integration/src/bitbucket/core.test.ts new file mode 100644 index 0000000000..8d9f956c6b --- /dev/null +++ b/packages/integration/src/bitbucket/core.test.ts @@ -0,0 +1,100 @@ +/* + * 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 { BitbucketIntegrationConfig } from './config'; +import { getBitbucketFileFetchUrl, getBitbucketRequestOptions } from './core'; + +describe('bitbucket core', () => { + describe('getBitbucketRequestOptions', () => { + it('inserts a token when needed', () => { + const withToken: BitbucketIntegrationConfig = { + host: '', + apiBaseUrl: '', + token: 'A', + }; + const withoutToken: BitbucketIntegrationConfig = { + host: '', + apiBaseUrl: '', + }; + expect( + (getBitbucketRequestOptions(withToken).headers as any).Authorization, + ).toEqual('Bearer A'); + expect( + (getBitbucketRequestOptions(withoutToken).headers as any).Authorization, + ).toBeUndefined(); + }); + + it('insert basic auth when needed', () => { + const withUsernameAndPassword: BitbucketIntegrationConfig = { + host: '', + apiBaseUrl: '', + username: 'some-user', + appPassword: 'my-secret', + }; + const withoutUsernameAndPassword: BitbucketIntegrationConfig = { + host: '', + apiBaseUrl: '', + }; + expect( + (getBitbucketRequestOptions(withUsernameAndPassword).headers as any) + .Authorization, + ).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA=='); + expect( + (getBitbucketRequestOptions(withoutUsernameAndPassword).headers as any) + .Authorization, + ).toBeUndefined(); + }); + }); + + describe('getBitbucketFileFetchUrl', () => { + it('rejects targets that do not look like URLs', () => { + const config: BitbucketIntegrationConfig = { host: '', apiBaseUrl: '' }; + expect(() => getBitbucketFileFetchUrl('a/b', config)).toThrow( + /Incorrect URL: a\/b/, + ); + }); + + it('happy path for Bitbucket Cloud', () => { + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.org', + apiBaseUrl: 'https://api.bitbucket.org/2.0', + }; + expect( + getBitbucketFileFetchUrl( + 'https://bitbucket.org/org-name/repo-name/src/master/templates/my-template.yaml', + config, + ), + ).toEqual( + 'https://api.bitbucket.org/2.0/repositories/org-name/repo-name/src/master/templates/my-template.yaml', + ); + }); + + it('happy path for Bitbucket Server', () => { + const config: BitbucketIntegrationConfig = { + host: 'bitbucket.mycompany.net', + apiBaseUrl: 'https://bitbucket.mycompany.net/rest/api/1.0', + }; + expect( + getBitbucketFileFetchUrl( + 'https://bitbucket.mycompany.net/projects/a/repos/b/browse/path/to/c.yaml', + config, + ), + ).toEqual( + 'https://bitbucket.mycompany.net/rest/api/1.0/projects/a/repos/b/raw/path/to/c.yaml?at=', + ); + }); + }); +}); diff --git a/packages/integration/src/bitbucket/core.ts b/packages/integration/src/bitbucket/core.ts new file mode 100644 index 0000000000..a522e7ce9f --- /dev/null +++ b/packages/integration/src/bitbucket/core.ts @@ -0,0 +1,84 @@ +/* + * 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 parseGitUrl from 'git-url-parse'; +import { BitbucketIntegrationConfig } from './config'; + +/** + * Given a URL pointing to a file on a provider, returns a URL that is suitable + * for fetching the contents of the data. + * + * Converts + * from: https://bitbucket.org/orgname/reponame/src/master/file.yaml + * to: https://api.bitbucket.org/2.0/repositories/orgname/reponame/src/master/file.yaml + * + * @param url A URL pointing to a file + * @param config The relevant provider config + */ +export function getBitbucketFileFetchUrl( + url: string, + config: BitbucketIntegrationConfig, +): string { + try { + const { owner, name, ref, filepathtype, filepath } = parseGitUrl(url); + if ( + !owner || + !name || + (filepathtype !== 'browse' && + filepathtype !== 'raw' && + filepathtype !== 'src') + ) { + throw new Error('Invalid Bitbucket URL or file path'); + } + + const pathWithoutSlash = filepath.replace(/^\//, ''); + + if (config.host === 'bitbucket.org') { + if (!ref) { + throw new Error('Invalid Bitbucket URL or file path'); + } + return `${config.apiBaseUrl}/repositories/${owner}/${name}/src/${ref}/${pathWithoutSlash}`; + } + return `${config.apiBaseUrl}/projects/${owner}/repos/${name}/raw/${pathWithoutSlash}?at=${ref}`; + } catch (e) { + throw new Error(`Incorrect URL: ${url}, ${e}`); + } +} + +/** + * Gets the request options necessary to make requests to a given provider. + * + * @param config The relevant provider config + */ +export function getBitbucketRequestOptions( + config: BitbucketIntegrationConfig, +): RequestInit { + const headers: HeadersInit = {}; + + if (config.token) { + headers.Authorization = `Bearer ${config.token}`; + } else if (config.username && config.appPassword) { + const buffer = Buffer.from( + `${config.username}:${config.appPassword}`, + 'utf8', + ); + headers.Authorization = `Basic ${buffer.toString('base64')}`; + } + + return { + headers, + }; +} diff --git a/packages/integration/src/bitbucket/index.ts b/packages/integration/src/bitbucket/index.ts index 897c00d160..b8d37220db 100644 --- a/packages/integration/src/bitbucket/index.ts +++ b/packages/integration/src/bitbucket/index.ts @@ -19,3 +19,4 @@ export { readBitbucketIntegrationConfigs, } from './config'; export type { BitbucketIntegrationConfig } from './config'; +export { getBitbucketFileFetchUrl, getBitbucketRequestOptions } from './core'; diff --git a/packages/integration/src/github/core.test.ts b/packages/integration/src/github/core.test.ts new file mode 100644 index 0000000000..03235acfbb --- /dev/null +++ b/packages/integration/src/github/core.test.ts @@ -0,0 +1,115 @@ +/* + * 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 { GitHubIntegrationConfig } from './config'; +import { getGitHubFileFetchUrl, getGitHubRequestOptions } from './core'; + +describe('github core', () => { + describe('getGitHubRequestOptions', () => { + it('inserts a token when needed', () => { + const withToken: GitHubIntegrationConfig = { + host: '', + rawBaseUrl: '', + token: 'A', + }; + const withoutToken: GitHubIntegrationConfig = { + host: '', + rawBaseUrl: '', + }; + expect( + (getGitHubRequestOptions(withToken).headers as any).Authorization, + ).toEqual('token A'); + expect( + (getGitHubRequestOptions(withoutToken).headers as any).Authorization, + ).toBeUndefined(); + }); + }); + + describe('getGitHubFileFetchUrl', () => { + it('rejects targets that do not look like URLs', () => { + const config: GitHubIntegrationConfig = { host: '', apiBaseUrl: '' }; + expect(() => getGitHubFileFetchUrl('a/b', config)).toThrow( + /Incorrect URL: a\/b/, + ); + }); + + it('happy path for github api', () => { + const config: GitHubIntegrationConfig = { + host: 'github.com', + apiBaseUrl: 'https://api.github.com', + }; + expect( + getGitHubFileFetchUrl( + 'https://github.com/a/b/blob/branchname/path/to/c.yaml', + config, + ), + ).toEqual( + 'https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname', + ); + expect( + getGitHubFileFetchUrl( + 'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml', + config, + ), + ).toEqual( + 'https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname', + ); + }); + + it('happy path for ghe api', () => { + const config: GitHubIntegrationConfig = { + host: 'ghe.mycompany.net', + apiBaseUrl: 'https://ghe.mycompany.net/api/v3', + }; + expect( + getGitHubFileFetchUrl( + 'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml', + config, + ), + ).toEqual( + 'https://ghe.mycompany.net/api/v3/repos/a/b/contents/path/to/c.yaml?ref=branchname', + ); + }); + + it('happy path for github raw', () => { + const config: GitHubIntegrationConfig = { + host: 'github.com', + rawBaseUrl: 'https://raw.githubusercontent.com', + }; + expect( + getGitHubFileFetchUrl( + 'https://github.com/a/b/blob/branchname/path/to/c.yaml', + config, + ), + ).toEqual( + 'https://raw.githubusercontent.com/a/b/branchname/path/to/c.yaml', + ); + }); + + it('happy path for ghe raw', () => { + const config: GitHubIntegrationConfig = { + host: 'ghe.mycompany.net', + rawBaseUrl: 'https://ghe.mycompany.net/raw', + }; + expect( + getGitHubFileFetchUrl( + 'https://ghe.mycompany.net/a/b/blob/branchname/path/to/c.yaml', + config, + ), + ).toEqual('https://ghe.mycompany.net/raw/a/b/branchname/path/to/c.yaml'); + }); + }); +}); diff --git a/packages/integration/src/github/core.ts b/packages/integration/src/github/core.ts new file mode 100644 index 0000000000..239692e962 --- /dev/null +++ b/packages/integration/src/github/core.ts @@ -0,0 +1,82 @@ +/* + * 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 parseGitUrl from 'git-url-parse'; +import { GitHubIntegrationConfig } from './config'; + +/** + * Given a URL pointing to a file on a provider, returns a URL that is suitable + * for fetching the contents of the data. + * + * Converts + * from: https://github.com/a/b/blob/branchname/path/to/c.yaml + * to: https://api.github.com/repos/a/b/contents/path/to/c.yaml?ref=branchname + * or: https://raw.githubusercontent.com/a/b/branchname/c.yaml + * + * @param url A URL pointing to a file + * @param config The relevant provider config + */ +export function getGitHubFileFetchUrl( + url: string, + config: GitHubIntegrationConfig, +): string { + try { + const { owner, name, ref, filepathtype, filepath } = parseGitUrl(url); + if ( + !owner || + !name || + !ref || + (filepathtype !== 'blob' && filepathtype !== 'raw') + ) { + throw new Error('Invalid GitHub URL or file path'); + } + + const pathWithoutSlash = filepath.replace(/^\//, ''); + if (chooseEndpoint(config) === 'api') { + return `${config.apiBaseUrl}/repos/${owner}/${name}/contents/${pathWithoutSlash}?ref=${ref}`; + } + return `${config.rawBaseUrl}/${owner}/${name}/${ref}/${pathWithoutSlash}`; + } catch (e) { + throw new Error(`Incorrect URL: ${url}, ${e}`); + } +} + +/** + * Gets the request options necessary to make requests to a given provider. + * + * @param config The relevant provider config + */ +export function getGitHubRequestOptions( + config: GitHubIntegrationConfig, +): RequestInit { + const headers: HeadersInit = {}; + + if (chooseEndpoint(config) === 'api') { + headers.Accept = 'application/vnd.github.v3.raw'; + } + if (config.token) { + headers.Authorization = `token ${config.token}`; + } + + return { headers }; +} + +export function chooseEndpoint(config: GitHubIntegrationConfig): 'api' | 'raw' { + if (config.apiBaseUrl && (config.token || !config.rawBaseUrl)) { + return 'api'; + } + return 'raw'; +} diff --git a/packages/integration/src/github/index.ts b/packages/integration/src/github/index.ts index 2099dd42e3..5f97f6980a 100644 --- a/packages/integration/src/github/index.ts +++ b/packages/integration/src/github/index.ts @@ -19,3 +19,4 @@ export { readGitHubIntegrationConfigs, } from './config'; export type { GitHubIntegrationConfig } from './config'; +export { getGitHubFileFetchUrl, getGitHubRequestOptions } from './core'; diff --git a/packages/integration/src/gitlab/core.test.ts b/packages/integration/src/gitlab/core.test.ts new file mode 100644 index 0000000000..43fea72e0b --- /dev/null +++ b/packages/integration/src/gitlab/core.test.ts @@ -0,0 +1,80 @@ +/* + * 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 { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { GitLabIntegrationConfig } from './config'; +import { getGitLabFileFetchUrl } from './core'; + +const worker = setupServer(); + +describe('gitlab core', () => { + beforeAll(() => worker.listen({ onUnhandledRequest: 'error' })); + afterAll(() => worker.close()); + afterEach(() => worker.resetHandlers()); + + beforeEach(() => { + worker.use( + rest.get('*/api/v4/projects/:name', (_, res, ctx) => + res(ctx.status(200), ctx.json({ id: 12345 })), + ), + ); + }); + + const configWithToken: GitLabIntegrationConfig = { + host: 'g.com', + token: '0123456789', + }; + + const configWithNoToken: GitLabIntegrationConfig = { + host: 'g.com', + }; + + describe('getGitLabFileFetchUrl', () => { + it.each([ + // Project URLs + { + config: configWithNoToken, + url: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + result: + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + }, + { + config: configWithToken, + url: + 'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + result: + 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + }, + { + config: configWithNoToken, + url: + 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/to/file.yaml', // Repo not in subgroup + result: + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml/raw?ref=branch', + }, + // Raw URLs + { + config: configWithNoToken, + url: 'https://gitlab.example.com/a/b/blob/master/c.yaml', + result: 'https://gitlab.example.com/a/b/raw/master/c.yaml', + }, + ])('should handle happy path %#', async ({ config, url, result }) => { + await expect(getGitLabFileFetchUrl(url, config)).resolves.toBe(result); + }); + }); +}); diff --git a/packages/integration/src/gitlab/core.ts b/packages/integration/src/gitlab/core.ts new file mode 100644 index 0000000000..63dc3fdb3b --- /dev/null +++ b/packages/integration/src/gitlab/core.ts @@ -0,0 +1,157 @@ +/* + * 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 { GitLabIntegrationConfig } from './config'; +import fetch from 'cross-fetch'; + +/** + * Given a URL pointing to a file on a provider, returns a URL that is suitable + * for fetching the contents of the data. + * + * Converts + * from: https://gitlab.example.com/a/b/blob/master/c.yaml + * to: https://gitlab.example.com/a/b/raw/master/c.yaml + * -or- + * from: https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath + * to: https://gitlab.com/api/v4/projects/projectId/repository/files/filepath?ref=branch + * + * @param url A URL pointing to a file + * @param config The relevant provider config + */ +export async function getGitLabFileFetchUrl( + url: string, + config: GitLabIntegrationConfig, +): Promise { + // TODO(Rugvip): From the old GitlabReaderProcessor; used + // the existence of /-/blob/ to switch the logic. Don't know if this + // makes sense and it might require some more work. + if (url.includes('/-/blob/')) { + const projectID = await getProjectId(url, config); + return buildProjectUrl(url, projectID).toString(); + } + return buildRawUrl(url).toString(); +} + +/** + * Gets the request options necessary to make requests to a given provider. + * + * @param config The relevant provider config + */ +export function getGitLabRequestOptions( + config: GitLabIntegrationConfig, +): RequestInit { + const { token = '' } = config; + return { + headers: { + 'PRIVATE-TOKEN': token, + }, + }; +} + +// Converts +// from: https://gitlab.example.com/a/b/blob/master/c.yaml +// to: https://gitlab.example.com/a/b/raw/master/c.yaml +export function buildRawUrl(target: string): URL { + try { + const url = new URL(target); + + const [ + empty, + userOrOrg, + repoName, + blobKeyword, + ...restOfPath + ] = url.pathname.split('/'); + + if ( + empty !== '' || + userOrOrg === '' || + repoName === '' || + blobKeyword !== 'blob' || + !restOfPath.join('/').match(/\.yaml$/) + ) { + throw new Error('Wrong GitLab URL'); + } + + // Replace 'blob' with 'raw' + url.pathname = [empty, userOrOrg, repoName, 'raw', ...restOfPath].join('/'); + + return url; + } catch (e) { + throw new Error(`Incorrect url: ${target}, ${e}`); + } +} + +// Converts +// from: https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath +// to: https://gitlab.com/api/v4/projects/projectId/repository/files/filepath?ref=branch +export function buildProjectUrl(target: string, projectID: Number): URL { + try { + const url = new URL(target); + + const branchAndFilePath = url.pathname.split('/-/blob/')[1]; + const [branch, ...filePath] = branchAndFilePath.split('/'); + + url.pathname = [ + '/api/v4/projects', + projectID, + 'repository/files', + encodeURIComponent(filePath.join('/')), + 'raw', + ].join('/'); + url.search = `?ref=${branch}`; + + return url; + } catch (e) { + throw new Error(`Incorrect url: ${target}, ${e}`); + } +} + +// Convert +// from: https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath +// to: The project ID that corresponds to the URL +export async function getProjectId( + target: string, + config: GitLabIntegrationConfig, +): Promise { + const url = new URL(target); + + if (!url.pathname.includes('/-/blob/')) { + throw new Error('Please provide full path to yaml file from Gitlab'); + } + + try { + const repo = url.pathname.split('/-/blob/')[0]; + + // Convert + // to: https://gitlab.com/api/v4/projects/groupA%2Fteams%2FsubgroupA%2FteamA%2Frepo + const repoIDLookup = new URL( + `${url.protocol + url.hostname}/api/v4/projects/${encodeURIComponent( + repo.replace(/^\//, ''), + )}`, + ); + const response = await fetch( + repoIDLookup.toString(), + getGitLabRequestOptions(config), + ); + const projectIDJson = await response.json(); + const projectID = Number(projectIDJson.id); + + return projectID; + } catch (e) { + throw new Error(`Could not get GitLab project ID for: ${target}, ${e}`); + } +} diff --git a/packages/integration/src/gitlab/index.ts b/packages/integration/src/gitlab/index.ts index 0801914fd4..8dc4e90764 100644 --- a/packages/integration/src/gitlab/index.ts +++ b/packages/integration/src/gitlab/index.ts @@ -19,3 +19,4 @@ export { readGitLabIntegrationConfigs, } from './config'; export type { GitLabIntegrationConfig } from './config'; +export { getGitLabFileFetchUrl, getGitLabRequestOptions } from './core'; From 075d3dc5aedc0cb610afa003b92d1e54da28ed51 Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Mon, 7 Dec 2020 17:02:54 +0100 Subject: [PATCH 12/42] Refactor the Sentry plugin to use the proxy backend instead of a custom backend --- .changeset/chatty-pens-bathe.md | 65 ++++++++++ app-config.yaml | 7 ++ packages/backend/package.json | 1 - packages/backend/src/index.ts | 7 +- plugins/sentry-backend/README.md | 4 +- plugins/sentry-backend/src/index.ts | 11 +- plugins/sentry-backend/src/service/router.ts | 42 ------- .../sentry-backend/src/service/sentry-api.ts | 47 ------- .../src/service/standaloneApplication.ts | 42 ------- .../src/service/standaloneServer.ts | 42 ------- plugins/sentry/README.md | 119 ++++++++++++++++-- plugins/sentry/docs/sentry-card.png | Bin 0 -> 146025 bytes plugins/sentry/package.json | 12 +- .../sentry/src/api/index.ts | 11 +- .../SentryPluginPage => api/mock}/index.ts | 2 +- .../sentry/src/{data => api/mock}/mock-api.ts | 5 +- .../{data => api/mock}/sentry-issue-mock.json | 0 .../src/{data => api}/production-api.ts | 44 +++---- plugins/sentry/src/api/sentry-api.ts | 27 ++++ .../sentry/src/{data => api}/sentry-issue.ts | 1 + .../components/ErrorCell/ErrorCell.test.tsx | 3 +- .../src/components/ErrorCell/ErrorCell.tsx | 3 +- .../src/components/ErrorGraph/ErrorGraph.tsx | 3 +- plugins/sentry/src/components/Router.tsx | 18 +-- .../SentryIssuesTable.test.tsx | 5 +- .../SentryIssuesTable/SentryIssuesTable.tsx | 2 +- .../SentryIssuesWidget/SentryIssuesWidget.tsx | 84 +++++++++++++ .../SentryIssuesWidget/index.ts} | 5 +- .../SentryPluginPage.test.tsx | 58 --------- .../SentryPluginPage/SentryPluginPage.tsx | 69 ---------- .../SentryPluginWidget/SentryPluginWidget.tsx | 60 --------- .../src/components/index.ts} | 11 +- .../sentry/src/components/useProjectSlug.ts | 23 ++++ plugins/sentry/src/data/api-factory.ts | 28 ----- plugins/sentry/src/index.ts | 3 +- plugins/sentry/src/plugin.ts | 24 +++- 36 files changed, 406 insertions(+), 482 deletions(-) create mode 100644 .changeset/chatty-pens-bathe.md delete mode 100644 plugins/sentry-backend/src/service/router.ts delete mode 100644 plugins/sentry-backend/src/service/sentry-api.ts delete mode 100644 plugins/sentry-backend/src/service/standaloneApplication.ts delete mode 100644 plugins/sentry-backend/src/service/standaloneServer.ts create mode 100644 plugins/sentry/docs/sentry-card.png rename packages/backend/src/plugins/sentry.ts => plugins/sentry/src/api/index.ts (71%) rename plugins/sentry/src/{components/SentryPluginPage => api/mock}/index.ts (92%) rename plugins/sentry/src/{data => api/mock}/mock-api.ts (93%) rename plugins/sentry/src/{data => api/mock}/sentry-issue-mock.json (100%) rename plugins/sentry/src/{data => api}/production-api.ts (53%) create mode 100644 plugins/sentry/src/api/sentry-api.ts rename plugins/sentry/src/{data => api}/sentry-issue.ts (99%) create mode 100644 plugins/sentry/src/components/SentryIssuesWidget/SentryIssuesWidget.tsx rename plugins/sentry/src/{data/sentry-api.ts => components/SentryIssuesWidget/index.ts} (79%) delete mode 100644 plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.test.tsx delete mode 100644 plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.tsx delete mode 100644 plugins/sentry/src/components/SentryPluginWidget/SentryPluginWidget.tsx rename plugins/{sentry-backend/src/service/sentry-api.test.ts => sentry/src/components/index.ts} (66%) create mode 100644 plugins/sentry/src/components/useProjectSlug.ts delete mode 100644 plugins/sentry/src/data/api-factory.ts diff --git a/.changeset/chatty-pens-bathe.md b/.changeset/chatty-pens-bathe.md new file mode 100644 index 0000000000..4ca3378b2e --- /dev/null +++ b/.changeset/chatty-pens-bathe.md @@ -0,0 +1,65 @@ +--- +'@backstage/plugin-sentry': minor +'@backstage/plugin-sentry-backend': minor +--- + +The plugin uses the `proxy-backend` instead of a custom `sentry-backend`. +It requires a proxy configuration: + +`app-config.yaml`: + +```yaml +proxy: + '/sentry/api': + target: https://sentry.io/api/ + allowedMethods: ['GET'] + headers: + Authorization: + $env: SENTRY_TOKEN # export SENTRY_TOKEN="Bearer " +``` + +The `MockApiBackend` is no longer configured by the `NODE_ENV` variable. +Instead, the mock backend can be used with an api-override: + +`packages/app/src/apis.ts`: + +```ts +import { createApiFactory } from '@backstage/core'; +import { MockSentryApi, sentryApiRef } from '@backstage/plugin-sentry'; + +export const apis = [ + // ... + + createApiFactory(sentryApiRef, new MockSentryApi()), +]; +``` + +If you already use the Sentry backend, you must remove it from the backend: + +Delete `packages/backend/src/plugins/sentry.ts`. + +```diff +# packages/backend/package.json + +... + "@backstage/plugin-scaffolder-backend": "^0.3.2", +- "@backstage/plugin-sentry-backend": "^0.1.3", + "@backstage/plugin-techdocs-backend": "^0.3.0", +... +``` + +```diff +// packages/backend/src/index.html + + const apiRouter = Router(); + apiRouter.use('/catalog', await catalog(catalogEnv)); + apiRouter.use('/rollbar', await rollbar(rollbarEnv)); + apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); +- apiRouter.use('/sentry', await sentry(sentryEnv)); + apiRouter.use('/auth', await auth(authEnv)); + apiRouter.use('/techdocs', await techdocs(techdocsEnv)); + apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv)); + apiRouter.use('/proxy', await proxy(proxyEnv)); + apiRouter.use('/graphql', await graphql(graphqlEnv)); + apiRouter.use(notFoundHandler()); +``` diff --git a/app-config.yaml b/app-config.yaml index 54d564de7d..7c26176943 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -52,6 +52,13 @@ proxy: Authorization: $env: BUILDKITE_TOKEN + '/sentry/api': + target: https://sentry.io/api/ + allowedMethods: ['GET'] + headers: + Authorization: + $env: SENTRY_TOKEN + organization: name: My Company diff --git a/packages/backend/package.json b/packages/backend/package.json index 5937d6a6f3..8f328db285 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -29,7 +29,6 @@ "@backstage/plugin-proxy-backend": "^0.2.2", "@backstage/plugin-rollbar-backend": "^0.1.4", "@backstage/plugin-scaffolder-backend": "^0.3.3", - "@backstage/plugin-sentry-backend": "^0.1.3", "@backstage/plugin-techdocs-backend": "^0.3.1", "@gitbeaker/node": "^25.2.0", "@octokit/rest": "^18.0.0", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index b74954ecc5..68cc170901 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -25,13 +25,13 @@ import Router from 'express-promise-router'; import { createServiceBuilder, - loadBackendConfig, getRootLogger, - useHotMemoize, + loadBackendConfig, notFoundHandler, SingleConnectionDatabaseManager, SingleHostDiscovery, UrlReaders, + useHotMemoize, } from '@backstage/backend-common'; import { Config } from '@backstage/config'; import healthcheck from './plugins/healthcheck'; @@ -40,7 +40,6 @@ import catalog from './plugins/catalog'; import kubernetes from './plugins/kubernetes'; import rollbar from './plugins/rollbar'; import scaffolder from './plugins/scaffolder'; -import sentry from './plugins/sentry'; import proxy from './plugins/proxy'; import techdocs from './plugins/techdocs'; import graphql from './plugins/graphql'; @@ -76,7 +75,6 @@ async function main() { const authEnv = useHotMemoize(module, () => createEnv('auth')); const proxyEnv = useHotMemoize(module, () => createEnv('proxy')); const rollbarEnv = useHotMemoize(module, () => createEnv('rollbar')); - const sentryEnv = useHotMemoize(module, () => createEnv('sentry')); const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs')); const kubernetesEnv = useHotMemoize(module, () => createEnv('kubernetes')); const graphqlEnv = useHotMemoize(module, () => createEnv('graphql')); @@ -86,7 +84,6 @@ async function main() { apiRouter.use('/catalog', await catalog(catalogEnv)); apiRouter.use('/rollbar', await rollbar(rollbarEnv)); apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); - apiRouter.use('/sentry', await sentry(sentryEnv)); apiRouter.use('/auth', await auth(authEnv)); apiRouter.use('/techdocs', await techdocs(techdocsEnv)); apiRouter.use('/kubernetes', await kubernetes(kubernetesEnv)); diff --git a/plugins/sentry-backend/README.md b/plugins/sentry-backend/README.md index efd4c1b472..7a559276be 100644 --- a/plugins/sentry-backend/README.md +++ b/plugins/sentry-backend/README.md @@ -1,3 +1,5 @@ # sentry-backend -Simple plugin forwarding requests to [Sentry](https://sentry.io) API. +> DEPRECATED + +Please use the [proxy-backend](../proxy-backend) instead. See [CHANGELOG.md](./CHANGELOG.md). diff --git a/plugins/sentry-backend/src/index.ts b/plugins/sentry-backend/src/index.ts index 7612c392a2..6e1d12359e 100644 --- a/plugins/sentry-backend/src/index.ts +++ b/plugins/sentry-backend/src/index.ts @@ -14,4 +14,13 @@ * limitations under the License. */ -export * from './service/router'; +import { Router } from 'express'; +import { Logger } from 'winston'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const createRouter = async (_: Logger): Promise => Router(); + +throw new Error( + 'The sentry-backend has been deprecated and replaced by the proxy-backend. See the ' + + 'changelog on how to migrate to the proxy backend: https://github.com/backstage/backstage/blob/master/plugins/sentry/CHANGELOG.md.', +); diff --git a/plugins/sentry-backend/src/service/router.ts b/plugins/sentry-backend/src/service/router.ts deleted file mode 100644 index 153a8325b5..0000000000 --- a/plugins/sentry-backend/src/service/router.ts +++ /dev/null @@ -1,42 +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 { Logger } from 'winston'; -import Router from 'express-promise-router'; -import express from 'express'; -import { getSentryApiForwarder } from './sentry-api'; - -export async function createRouter(logger: Logger): Promise { - const router = Router(); - router.use(express.json()); - - const SENTRY_TOKEN = process.env.SENTRY_TOKEN; - if (!SENTRY_TOKEN) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'Sentry token must be provided in SENTRY_TOKEN environment variable to start the API.', - ); - } - logger.warn( - 'Failed to initialize Sentry backend, set SENTRY_TOKEN environment variable to start the API.', - ); - } else { - const sentryForwarder = getSentryApiForwarder(SENTRY_TOKEN, logger); - - router.use(sentryForwarder); - } - - return router; -} diff --git a/plugins/sentry-backend/src/service/sentry-api.ts b/plugins/sentry-backend/src/service/sentry-api.ts deleted file mode 100644 index 8d35038840..0000000000 --- a/plugins/sentry-backend/src/service/sentry-api.ts +++ /dev/null @@ -1,47 +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 express from 'express'; -import axios from 'axios'; -import { Logger } from 'winston'; - -export function getRequestHeaders(token: string) { - return { - headers: { - Authorization: `Bearer ${token}`, - }, - }; -} - -export function getSentryApiForwarder(token: string, logger: Logger) { - return function forwardRequest( - request: express.Request, - response: express.Response, - ) { - const sentryUrl = request.path; - const effectiveUrl = `https://sentry.io/${sentryUrl}`; - logger.info(`Calling Sentry REST API, ${effectiveUrl}`); - axios - .get(effectiveUrl, getRequestHeaders(token)) - .then(res => { - response.send(res.data); - }) - .catch(err => { - return response.status(err.response.status).json({ - detail: err.response.statusText, - }); - }); - }; -} diff --git a/plugins/sentry-backend/src/service/standaloneApplication.ts b/plugins/sentry-backend/src/service/standaloneApplication.ts deleted file mode 100644 index fdaad8bb2e..0000000000 --- a/plugins/sentry-backend/src/service/standaloneApplication.ts +++ /dev/null @@ -1,42 +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 { - errorHandler, - notFoundHandler, - requestLoggingHandler, -} from '@backstage/backend-common'; -import cors from 'cors'; -import express from 'express'; -import helmet from 'helmet'; -import { Logger } from 'winston'; -import { createRouter } from './router'; - -export async function createStandaloneApplication( - logger: Logger, -): Promise { - const app = express(); - - app.use(helmet()); - app.use(cors()); - app.use(express.json()); - app.use(requestLoggingHandler()); - app.use('/', await createRouter(logger)); - app.use(notFoundHandler()); - app.use(errorHandler()); - - return app; -} diff --git a/plugins/sentry-backend/src/service/standaloneServer.ts b/plugins/sentry-backend/src/service/standaloneServer.ts deleted file mode 100644 index 37b87c5c40..0000000000 --- a/plugins/sentry-backend/src/service/standaloneServer.ts +++ /dev/null @@ -1,42 +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 { Server } from 'http'; -import { Logger } from 'winston'; -import { createStandaloneApplication } from './standaloneApplication'; - -export async function startStandaloneServer( - parentLogger: Logger, -): Promise { - const logger = parentLogger.child({ service: 'scaffolder-backend' }); - logger.debug('Creating application...'); - - const app = await createStandaloneApplication(logger); - - logger.debug('Starting application server...'); - const PORT = parseInt(process.env.PORT || '5001', 10); - return await new Promise((resolve, reject) => { - const server = app.listen(PORT, (err?: Error) => { - if (err) { - reject(err); - return; - } - - logger.info(`Listening on port ${PORT}`); - resolve(server); - }); - }); -} diff --git a/plugins/sentry/README.md b/plugins/sentry/README.md index 73cca29d34..5d280de249 100644 --- a/plugins/sentry/README.md +++ b/plugins/sentry/README.md @@ -1,17 +1,116 @@ -# sentry +# Sentry Plugin -Welcome to the sentry plugin! +The Sentry Plugin displays issues from [Sentry](https://sentry.io). -_This plugin was created through the Backstage CLI_ +![Sentry Card](./docs/sentry-card.png) -## Getting started +## Getting Started -Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/sentry](http://localhost:3000/sentry). +1. Install the Sentry Plugin: -You can also serve the plugin in isolation by running `yarn start` in the plugin directory. -This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. -It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. +```bash +# packages/app -Needs SENTRY_TOKEN set in the environment for the backend to startup +yarn add @backstage/plugin-sentry +``` -export SENTRY_TOKEN= +2. Add plugin to the app: + +```js +// packages/app/src/plugins.ts + +export { plugin as Sentry } from '@backstage/plugin-sentry'; +``` + +3. Add the `SentryIssuesWidget` to the EntityPage: + +```jsx +// packages/app/src/components/catalog/EntityPage.tsx + +import { SentryIssuesWidget } from '@backstage/plugin-sentry'; + +const OverviewContent = ({ entity }: { entity: Entity }) => ( + + // ... + + + + // ... + +); +``` + +> You can also import a `Router` if you want to have a dedicated sentry page: +> +> ```tsx +> // packages/app/src/components/catalog/EntityPage.tsx +> +> import { Router as SentryRouter } from '@backstage/plugin-sentry'; +> +> const ServiceEntityPage = ({ entity }: { entity: Entity }) => ( +> +> // ... +> path="/sentry" +> title="Sentry" +> element={} +> /> +> // ... +> +> ); +> ``` + +4. Add the proxy config: + +```yaml +# app-config.yaml + +proxy: + '/sentry/api': + target: https://sentry.io/api/ + allowedMethods: ['GET'] + headers: + Authorization: + # Content: 'Bearer ' + $env: SENTRY_TOKEN + +sentry: + organization: +``` + +5. Create a new internal integration with the permissions `Issues & Events: Read` (https://docs.sentry.io/product/integrations/integration-platform/) and provide it as `SENTRY_TOKEN` as env variable. + +6. Add the `sentry.io/project-slug` annotation to your catalog-info.yaml file: + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage + description: | + Backstage is an open-source developer portal that puts the developer experience first. + annotations: + sentry.io/project-slug: YOUR_PROJECT_SLUG +spec: + type: library + owner: CNCF + lifecycle: experimental +``` + +### Demo Mode + +The plugin provides a MockAPI that always returns dummy data instead of talking to the sentry backend. +You can add it by overriding the `sentryApiRef`: + +```ts +// packages/app/src/apis.ts + +import { createApiFactory } from '@backstage/core'; +import { MockSentryApi, sentryApiRef } from '@backstage/plugin-sentry'; + +export const apis = [ + // ... + + createApiFactory(sentryApiRef, new MockSentryApi()), +]; +``` diff --git a/plugins/sentry/docs/sentry-card.png b/plugins/sentry/docs/sentry-card.png new file mode 100644 index 0000000000000000000000000000000000000000..30aad2a56ecfbb63ca02f58ac0532d3717e5e992 GIT binary patch literal 146025 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 literal 0 HcmV?d00001 diff --git a/plugins/sentry/package.json b/plugins/sentry/package.json index b952a81d51..c9708ee875 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/package.json @@ -27,7 +27,6 @@ "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", - "@types/react": "^16.9", "react": "^16.13.1", "react-dom": "^16.13.1", "react-router": "6.0.0-beta.0", @@ -44,6 +43,7 @@ "@testing-library/user-event": "^12.0.7", "@types/jest": "^26.0.7", "@types/node": "^12.0.0", + "@types/react": "^16.9", "cross-fetch": "^3.0.6", "msw": "^0.21.2" }, @@ -61,9 +61,15 @@ "organization": { "type": "string", "visibility": "frontend" - } + }, + "required": [ + "organization" + ] } } - } + }, + "required": [ + "sentry" + ] } } diff --git a/packages/backend/src/plugins/sentry.ts b/plugins/sentry/src/api/index.ts similarity index 71% rename from packages/backend/src/plugins/sentry.ts rename to plugins/sentry/src/api/index.ts index 5cd0e55761..4cccfdb7a1 100644 --- a/packages/backend/src/plugins/sentry.ts +++ b/plugins/sentry/src/api/index.ts @@ -14,9 +14,8 @@ * limitations under the License. */ -import { createRouter } from '@backstage/plugin-sentry-backend'; -import type { PluginEnvironment } from '../types'; - -export default async function createPlugin({ logger }: PluginEnvironment) { - return await createRouter(logger); -} +export * from './mock'; +export type { SentryApi } from './sentry-api'; +export { sentryApiRef } from './sentry-api'; +export type { SentryIssue } from './sentry-issue'; +export { ProductionSentryApi } from './production-api'; diff --git a/plugins/sentry/src/components/SentryPluginPage/index.ts b/plugins/sentry/src/api/mock/index.ts similarity index 92% rename from plugins/sentry/src/components/SentryPluginPage/index.ts rename to plugins/sentry/src/api/mock/index.ts index 67b34db517..b65fb7a919 100644 --- a/plugins/sentry/src/components/SentryPluginPage/index.ts +++ b/plugins/sentry/src/api/mock/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './SentryPluginPage'; +export { MockSentryApi } from './mock-api'; diff --git a/plugins/sentry/src/data/mock-api.ts b/plugins/sentry/src/api/mock/mock-api.ts similarity index 93% rename from plugins/sentry/src/data/mock-api.ts rename to plugins/sentry/src/api/mock/mock-api.ts index 8026fec4ee..6743cee79f 100644 --- a/plugins/sentry/src/data/mock-api.ts +++ b/plugins/sentry/src/api/mock/mock-api.ts @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SentryIssue } from './sentry-issue'; -import { SentryApi } from './sentry-api'; + +import { SentryIssue } from '../sentry-issue'; +import { SentryApi } from '../sentry-api'; import mockData from './sentry-issue-mock.json'; function getMockIssue(): SentryIssue { diff --git a/plugins/sentry/src/data/sentry-issue-mock.json b/plugins/sentry/src/api/mock/sentry-issue-mock.json similarity index 100% rename from plugins/sentry/src/data/sentry-issue-mock.json rename to plugins/sentry/src/api/mock/sentry-issue-mock.json diff --git a/plugins/sentry/src/data/production-api.ts b/plugins/sentry/src/api/production-api.ts similarity index 53% rename from plugins/sentry/src/data/production-api.ts rename to plugins/sentry/src/api/production-api.ts index 5d21e80604..bf6fa98bce 100644 --- a/plugins/sentry/src/data/production-api.ts +++ b/plugins/sentry/src/api/production-api.ts @@ -13,36 +13,32 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { SentryIssue } from './sentry-issue'; import { SentryApi } from './sentry-api'; +import { DiscoveryApi } from '@backstage/core'; export class ProductionSentryApi implements SentryApi { - private organization: string; - private backendBaseUrl: string; - - constructor(organization: string, backendBaseUrl: string) { - this.organization = organization; - this.backendBaseUrl = backendBaseUrl; - } + constructor( + private readonly discoveryApi: DiscoveryApi, + private readonly organization: string, + ) {} async fetchIssues(project: string, statsFor: string): Promise { - try { - const apiBaseUrl = `${this.backendBaseUrl}/sentry/api/0/projects/`; - - const response = await fetch( - `${apiBaseUrl}/${this.organization}/${project}/issues/?statsFor=${statsFor}`, - ); - - if (response.status >= 400 && response.status < 600) { - throw new Error('Failed fetching Sentry issues'); - } - - return (await response.json()) as SentryIssue[]; - } catch (exception) { - if (exception.detail) { - return exception; - } - throw new Error('Unknown error'); + if (!project) { + return []; } + + const apiUrl = `${await this.discoveryApi.getBaseUrl('proxy')}/sentry/api`; + + const response = await fetch( + `${apiUrl}/0/projects/${this.organization}/${project}/issues/?statsFor=${statsFor}`, + ); + + if (response.status >= 400 && response.status < 600) { + throw new Error('Failed fetching Sentry issues'); + } + + return (await response.json()) as SentryIssue[]; } } diff --git a/plugins/sentry/src/api/sentry-api.ts b/plugins/sentry/src/api/sentry-api.ts new file mode 100644 index 0000000000..1edb550ec4 --- /dev/null +++ b/plugins/sentry/src/api/sentry-api.ts @@ -0,0 +1,27 @@ +/* + * 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 { SentryIssue } from './sentry-issue'; +import { createApiRef } from '@backstage/core'; + +export const sentryApiRef = createApiRef({ + id: 'plugin.sentry.service', + description: 'Used by the Sentry plugin to make requests', +}); + +export interface SentryApi { + fetchIssues(project: string, statsFor: string): Promise; +} diff --git a/plugins/sentry/src/data/sentry-issue.ts b/plugins/sentry/src/api/sentry-issue.ts similarity index 99% rename from plugins/sentry/src/data/sentry-issue.ts rename to plugins/sentry/src/api/sentry-issue.ts index 14621bf629..017396229d 100644 --- a/plugins/sentry/src/data/sentry-issue.ts +++ b/plugins/sentry/src/api/sentry-issue.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + type SentryPlatform = 'javascript' | 'javascript-react' | string; type EventPoint = number[]; diff --git a/plugins/sentry/src/components/ErrorCell/ErrorCell.test.tsx b/plugins/sentry/src/components/ErrorCell/ErrorCell.test.tsx index 74f1501225..b02c2860ad 100644 --- a/plugins/sentry/src/components/ErrorCell/ErrorCell.test.tsx +++ b/plugins/sentry/src/components/ErrorCell/ErrorCell.test.tsx @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { ErrorCell } from './ErrorCell'; import React from 'react'; import { render } from '@testing-library/react'; -import mockIssue from '../../data/sentry-issue-mock.json'; +import mockIssue from '../../api/mock/sentry-issue-mock.json'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; diff --git a/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx b/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx index 617bdb3ce9..022453a7a1 100644 --- a/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx +++ b/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React, { FC } from 'react'; -import { SentryIssue } from '../../data/sentry-issue'; +import { SentryIssue } from '../../api'; import { Link, Typography } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import { BackstageTheme } from '@backstage/theme'; diff --git a/plugins/sentry/src/components/ErrorGraph/ErrorGraph.tsx b/plugins/sentry/src/components/ErrorGraph/ErrorGraph.tsx index 7643c5ac6e..c4226780e4 100644 --- a/plugins/sentry/src/components/ErrorGraph/ErrorGraph.tsx +++ b/plugins/sentry/src/components/ErrorGraph/ErrorGraph.tsx @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React, { FC } from 'react'; -import { SentryIssue } from '../../data/sentry-issue'; +import { SentryIssue } from '../../api'; import { Sparklines, SparklinesBars } from 'react-sparklines'; export const ErrorGraph: FC<{ sentryIssue: SentryIssue }> = ({ diff --git a/plugins/sentry/src/components/Router.tsx b/plugins/sentry/src/components/Router.tsx index 6d15268629..7bed3700ab 100644 --- a/plugins/sentry/src/components/Router.tsx +++ b/plugins/sentry/src/components/Router.tsx @@ -13,28 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React from 'react'; import { Entity } from '@backstage/catalog-model'; -import { Routes, Route } from 'react-router'; -import { MissingAnnotationEmptyState } from '@backstage/core'; -import { SentryPluginWidget } from './SentryPluginWidget/SentryPluginWidget'; - -const SENTRY_ANNOTATION = 'sentry.io/project-slug'; +import { Route, Routes } from 'react-router'; +import { SentryIssuesWidget } from './SentryIssuesWidget'; export const Router = ({ entity }: { entity: Entity }) => { - const projectId = entity.metadata.annotations?.[SENTRY_ANNOTATION]; - - if (!projectId) { - return ; - } - return ( - } + element={} /> ) diff --git a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.test.tsx b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.test.tsx index 6926fdfc2a..441104c0ee 100644 --- a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.test.tsx +++ b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.test.tsx @@ -13,11 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React from 'react'; import { render } from '@testing-library/react'; import SentryIssuesTable from './SentryIssuesTable'; -import { SentryIssue } from '../../data/sentry-issue'; -import mockIssue from '../../data/sentry-issue-mock.json'; +import { SentryIssue } from '../../api'; +import mockIssue from '../../api/mock/sentry-issue-mock.json'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; diff --git a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx index aae8009d98..72081bf2da 100644 --- a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx +++ b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { Table, TableColumn } from '@backstage/core'; -import { SentryIssue } from '../../data/sentry-issue'; +import { SentryIssue } from '../../api'; import { format } from 'timeago.js'; import { ErrorCell } from '../ErrorCell/ErrorCell'; import { ErrorGraph } from '../ErrorGraph/ErrorGraph'; diff --git a/plugins/sentry/src/components/SentryIssuesWidget/SentryIssuesWidget.tsx b/plugins/sentry/src/components/SentryIssuesWidget/SentryIssuesWidget.tsx new file mode 100644 index 0000000000..c52b76c4bc --- /dev/null +++ b/plugins/sentry/src/components/SentryIssuesWidget/SentryIssuesWidget.tsx @@ -0,0 +1,84 @@ +/* + * 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, { useEffect } from 'react'; +import { + EmptyState, + ErrorApi, + errorApiRef, + InfoCard, + MissingAnnotationEmptyState, + Progress, + useApi, +} from '@backstage/core'; +import SentryIssuesTable from '../SentryIssuesTable/SentryIssuesTable'; +import { useAsync } from 'react-use'; +import { sentryApiRef } from '../../api'; +import { + SENTRY_PROJECT_SLUG_ANNOTATION, + useProjectSlug, +} from '../useProjectSlug'; +import { Entity } from '@backstage/catalog-model'; + +export const SentryIssuesWidget = ({ + entity, + statsFor = '24h', + variant = 'gridItem', +}: { + entity: Entity; + statsFor?: '24h' | '12h'; + variant?: string; +}) => { + const errorApi = useApi(errorApiRef); + const sentryApi = useApi(sentryApiRef); + + const projectId = useProjectSlug(entity); + + const { loading, value, error } = useAsync( + () => sentryApi.fetchIssues(projectId, statsFor), + [sentryApi, statsFor, projectId], + ); + + useEffect(() => { + if (error) { + errorApi.post(error); + } + }, [error, errorApi]); + + if (loading || !projectId || error) { + return ( + + {loading && } + + {!loading && !projectId && ( + + )} + + {!loading && error && ( + + )} + + ); + } + + return ; +}; diff --git a/plugins/sentry/src/data/sentry-api.ts b/plugins/sentry/src/components/SentryIssuesWidget/index.ts similarity index 79% rename from plugins/sentry/src/data/sentry-api.ts rename to plugins/sentry/src/components/SentryIssuesWidget/index.ts index 538900b738..fddc1374f2 100644 --- a/plugins/sentry/src/data/sentry-api.ts +++ b/plugins/sentry/src/components/SentryIssuesWidget/index.ts @@ -13,8 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SentryIssue } from './sentry-issue'; -export interface SentryApi { - fetchIssues(project: string, statsFor: string): Promise; -} +export { SentryIssuesWidget } from './SentryIssuesWidget'; diff --git a/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.test.tsx b/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.test.tsx deleted file mode 100644 index 21c21ce385..0000000000 --- a/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.test.tsx +++ /dev/null @@ -1,58 +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 React from 'react'; -import { render } from '@testing-library/react'; -import SentryPluginPage from './SentryPluginPage'; -import { ThemeProvider } from '@material-ui/core'; -import { lightTheme } from '@backstage/theme'; -import { msw } from '@backstage/test-utils'; -import { setupServer } from 'msw/node'; -import { rest } from 'msw'; - -import { - ApiProvider, - ApiRegistry, - errorApiRef, - configApiRef, -} from '@backstage/core'; - -const errorApi = { post: () => {} }; -const ConfigApi = { getString: () => 'test' }; - -describe('SentryPluginPage', () => { - const server = setupServer(); - msw.setupDefaultHandlers(server); - - it('should render header and time switched', () => { - server.use(rest.get('/', (_req, res, ctx) => res(ctx.json({})))); - const rendered = render( - - - - - , - ); - expect(rendered.getByText('Sentry issues')).toBeInTheDocument(); - expect(rendered.getByText('24H')).toBeInTheDocument(); - expect(rendered.getByText('12H')).toBeInTheDocument(); - }); -}); diff --git a/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.tsx b/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.tsx deleted file mode 100644 index 763a8d753f..0000000000 --- a/plugins/sentry/src/components/SentryPluginPage/SentryPluginPage.tsx +++ /dev/null @@ -1,69 +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 React, { FC, useState } from 'react'; -import { Grid } from '@material-ui/core'; -import { - Header, - Page, - Content, - ContentHeader, - SupportButton, -} from '@backstage/core'; -import { SentryPluginWidget } from '../SentryPluginWidget/SentryPluginWidget'; -import { ToggleButton, ToggleButtonGroup } from '@material-ui/lab'; - -const SentryPluginPage: FC<{}> = () => { - const [statsFor, setStatsFor] = useState<'12h' | '24h'>('12h'); - const toggleStatsFor = () => setStatsFor(statsFor === '12h' ? '12h' : '24h'); - const sentryProjectId = 'sample-sentry-project-id'; - - return ( - -
- - - - - 24H - - - 12H - - - - Sentry plugin allows you to preview issues and navigate to sentry. - - - - - - - - - - ); -}; - -export default SentryPluginPage; diff --git a/plugins/sentry/src/components/SentryPluginWidget/SentryPluginWidget.tsx b/plugins/sentry/src/components/SentryPluginWidget/SentryPluginWidget.tsx deleted file mode 100644 index 8d522df4a7..0000000000 --- a/plugins/sentry/src/components/SentryPluginWidget/SentryPluginWidget.tsx +++ /dev/null @@ -1,60 +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 React, { FC, useEffect } from 'react'; -import { - ErrorApi, - errorApiRef, - InfoCard, - Progress, - useApi, - configApiRef, -} from '@backstage/core'; -import SentryIssuesTable from '../SentryIssuesTable/SentryIssuesTable'; -import { useAsync } from 'react-use'; -import { sentryApiFactory } from '../../data/api-factory'; - -export const SentryPluginWidget: FC<{ - sentryProjectId: string; - statsFor: '24h' | '12h'; -}> = ({ sentryProjectId, statsFor }) => { - const errorApi = useApi(errorApiRef); - const configApi = useApi(configApiRef); - const org = configApi.getString('sentry.organization'); - const backendBaseUrl = configApi.getString('backend.baseUrl'); - const api = sentryApiFactory(org, backendBaseUrl); - - const { loading, value, error } = useAsync( - () => api.fetchIssues(sentryProjectId, statsFor), - [statsFor, sentryProjectId], - ); - - useEffect(() => { - if (error) { - errorApi.post(error); - } - }, [error, errorApi]); - - if (loading) { - return ( - - - - ); - } - - return ; -}; diff --git a/plugins/sentry-backend/src/service/sentry-api.test.ts b/plugins/sentry/src/components/index.ts similarity index 66% rename from plugins/sentry-backend/src/service/sentry-api.test.ts rename to plugins/sentry/src/components/index.ts index f15692861f..b1588954c9 100644 --- a/plugins/sentry-backend/src/service/sentry-api.test.ts +++ b/plugins/sentry/src/components/index.ts @@ -13,14 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { getRequestHeaders } from './sentry-api'; -describe('SentryApiForwarder', () => { - it('should generate headers based on token passed in constructor', () => { - expect(getRequestHeaders('testtoken')).toEqual({ - headers: { - Authorization: `Bearer testtoken`, - }, - }); - }); -}); +export * from './SentryIssuesWidget'; diff --git a/plugins/sentry/src/components/useProjectSlug.ts b/plugins/sentry/src/components/useProjectSlug.ts new file mode 100644 index 0000000000..072d517b05 --- /dev/null +++ b/plugins/sentry/src/components/useProjectSlug.ts @@ -0,0 +1,23 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; + +export const SENTRY_PROJECT_SLUG_ANNOTATION = 'sentry.io/project-slug'; + +export const useProjectSlug = (entity: Entity) => { + return entity?.metadata.annotations?.[SENTRY_PROJECT_SLUG_ANNOTATION] ?? ''; +}; diff --git a/plugins/sentry/src/data/api-factory.ts b/plugins/sentry/src/data/api-factory.ts deleted file mode 100644 index 88e1148b72..0000000000 --- a/plugins/sentry/src/data/api-factory.ts +++ /dev/null @@ -1,28 +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 { SentryApi } from './sentry-api'; -import { MockSentryApi } from './mock-api'; -import { ProductionSentryApi } from './production-api'; - -export function sentryApiFactory( - organization: string, - backendBaseUrl: string, -): SentryApi { - if (process.env.NODE_ENV === 'production') { - return new ProductionSentryApi(organization, backendBaseUrl); - } - return new MockSentryApi(); -} diff --git a/plugins/sentry/src/index.ts b/plugins/sentry/src/index.ts index a0d3cab1be..2b9e2186ec 100644 --- a/plugins/sentry/src/index.ts +++ b/plugins/sentry/src/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export * from './api'; +export * from './components'; export { plugin } from './plugin'; export { Router } from './components/Router'; -export { SentryPluginWidget as SentryIssuesWidget } from './components/SentryPluginWidget/SentryPluginWidget'; diff --git a/plugins/sentry/src/plugin.ts b/plugins/sentry/src/plugin.ts index fc4b1af5e0..6a4fe4e5a4 100644 --- a/plugins/sentry/src/plugin.ts +++ b/plugins/sentry/src/plugin.ts @@ -14,8 +14,14 @@ * limitations under the License. */ -import { createPlugin, createRouteRef } from '@backstage/core'; -import SentryPluginPage from './components/SentryPluginPage'; +import { + configApiRef, + createApiFactory, + createPlugin, + createRouteRef, + discoveryApiRef, +} from '@backstage/core'; +import { ProductionSentryApi, sentryApiRef } from './api'; export const rootRouteRef = createRouteRef({ path: '/sentry', @@ -24,7 +30,15 @@ export const rootRouteRef = createRouteRef({ export const plugin = createPlugin({ id: 'sentry', - register({ router }) { - router.addRoute(rootRouteRef, SentryPluginPage); - }, + apis: [ + createApiFactory({ + api: sentryApiRef, + deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, + factory: ({ configApi, discoveryApi }) => + new ProductionSentryApi( + discoveryApi, + configApi.getString('sentry.organization'), + ), + }), + ], }); From 0117081027bea41fe5913432a1780c31978ab84d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 7 Dec 2020 17:29:29 +0100 Subject: [PATCH 13/42] cli: fix for node_modules inside local packages being transformed --- .changeset/fast-frogs-wave.md | 5 +++++ packages/cli/src/lib/bundler/transforms.ts | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 .changeset/fast-frogs-wave.md diff --git a/.changeset/fast-frogs-wave.md b/.changeset/fast-frogs-wave.md new file mode 100644 index 0000000000..7dfe33aa70 --- /dev/null +++ b/.changeset/fast-frogs-wave.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Fixes a big in the bundling logic that caused `node_modules` inside local monorepo packages to be transformed. diff --git a/packages/cli/src/lib/bundler/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts index 6dc32e6563..19f1acff77 100644 --- a/packages/cli/src/lib/bundler/transforms.ts +++ b/packages/cli/src/lib/bundler/transforms.ts @@ -35,7 +35,12 @@ export const transforms = (options: TransformOptions): Transforms => { const extraTransforms = isDev ? ['react-hot-loader'] : []; const transformExcludeCondition = { - and: [/node_modules/, { not: externalTransforms }], + or: [ + // This makes sure we don't transform node_modules inside any of the local monorepo packages + /node_modules.*node_modules/, + // This excludes the local monorepo packages from the excludes, meaning they will be transformed + { and: [/node_modules/, { not: externalTransforms }] }, + ], }; const loaders = [ From e750f086379de5191009fa52a60d60d8fb3be676 Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Mon, 7 Dec 2020 17:38:34 +0100 Subject: [PATCH 14/42] Don't run tests in the deprecated module --- plugins/sentry-backend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/sentry-backend/package.json b/plugins/sentry-backend/package.json index 5f5eafd5c6..7caee23b68 100644 --- a/plugins/sentry-backend/package.json +++ b/plugins/sentry-backend/package.json @@ -14,7 +14,7 @@ "start": "backstage-cli backend:dev", "build": "backstage-cli backend:build", "lint": "backstage-cli lint", - "test": "backstage-cli test", + "test": "backstage-cli test --passWithNoTests", "prepack": "backstage-cli prepack", "postpack": "backstage-cli postpack", "clean": "backstage-cli clean" From 5596e8698e64ed59dbe0237a4e0b41c67e6f524d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 27 Nov 2020 14:47:28 +0100 Subject: [PATCH 15/42] core-api: add basic useRouteRef and RouteResolver without support for params Co-authored-by: blam --- packages/core-api/src/routing/hooks.test.tsx | 87 ++++++++++++++++++++ packages/core-api/src/routing/hooks.tsx | 75 +++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 packages/core-api/src/routing/hooks.test.tsx create mode 100644 packages/core-api/src/routing/hooks.tsx diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx new file mode 100644 index 0000000000..c08891c8cc --- /dev/null +++ b/packages/core-api/src/routing/hooks.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 { render } from '@testing-library/react'; +import React, { PropsWithChildren } from 'react'; +import { MemoryRouter, Routes } from 'react-router-dom'; +import { createRoutableExtension } from '../extensions'; +import { + childDiscoverer, + routeElementDiscoverer, + traverseElementTree, +} from '../extensions/traversal'; +import { createPlugin } from '../plugin'; +import { routeCollector, routeParentCollector } from './collectors'; +import { useRouteRef, RoutingProvider } from './hooks'; +import { createRouteRef } from './RouteRef'; + +const mockConfig = () => ({ path: '/unused', title: 'Unused' }); +const MockComponent = ({ children }: PropsWithChildren<{}>) => <>{children}; + +const plugin = createPlugin({ id: 'my-plugin' }); + +const ref1 = createRouteRef(mockConfig()); +const ref2 = createRouteRef(mockConfig()); +const ref3 = createRouteRef(mockConfig()); + +const MockRouteSource = () => { + const routeFunc = useRouteRef(ref2); + expect(routeFunc?.()).toBe('/foo/bar'); + return null; +}; + +const Extension1 = plugin.provide( + createRoutableExtension({ component: MockComponent, mountPoint: ref1 }), +); +const Extension2 = plugin.provide( + createRoutableExtension({ component: MockRouteSource, mountPoint: ref2 }), +); +const Extension3 = plugin.provide( + createRoutableExtension({ component: MockComponent, mountPoint: ref3 }), +); + +describe('discovery', () => { + it('should handle all react router Route patterns', () => { + const root = ( + + + + + + + + + + ); + + const { routes, routeParents } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routeCollector, + routeParents: routeParentCollector, + }, + }); + + render( + + {root} + , + ); + + expect.assertions(2); + }); +}); diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx new file mode 100644 index 0000000000..8a2aa72c92 --- /dev/null +++ b/packages/core-api/src/routing/hooks.tsx @@ -0,0 +1,75 @@ +/* + * 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, { createContext, ReactNode, useContext } from 'react'; +import { RouteRef } from './types'; + +export type RouteFunc = () => string; + +class RouteResolver { + constructor( + private readonly routes: Map, + private readonly routeParents: Map, + ) {} + + resolve(routeRef: RouteRef): RouteFunc { + let currentRouteRef: RouteRef | undefined = routeRef; + let fullPath = ''; + + while (currentRouteRef) { + const path = this.routes.get(currentRouteRef); + if (!path) { + throw new Error(`No path for ${currentRouteRef}`); + } + fullPath = `${path}${fullPath}`; + currentRouteRef = this.routeParents.get(currentRouteRef); + } + + return () => { + return fullPath; + }; + } +} + +const RoutingContext = createContext(undefined); + +export function useRouteRef(routeRef: RouteRef): RouteFunc | undefined { + const resolver = useContext(RoutingContext); + if (!resolver) { + throw new Error('No route resolver found in context'); + } + + return resolver.resolve(routeRef); +} + +type ProviderProps = { + routes: Map; + routeParents: Map; + children: ReactNode; +}; + +export const RoutingProvider = ({ + routes, + routeParents, + children, +}: ProviderProps) => { + const resolver = new RouteResolver(routes, routeParents); + return ( + + {children} + + ); +}; From 57ce2b2dc5adc04fb5d507e1bef683a7d58fc3fd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 27 Nov 2020 15:49:33 +0100 Subject: [PATCH 16/42] core-api: fix accidental global singletons in collectors Co-authored-by: blam --- .../src/extensions/traversal.test.tsx | 26 ++++++++++++------- packages/core-api/src/extensions/traversal.ts | 4 +-- packages/core-api/src/plugin/collectors.ts | 2 +- packages/core-api/src/routing/collectors.tsx | 4 +-- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/core-api/src/extensions/traversal.test.tsx b/packages/core-api/src/extensions/traversal.test.tsx index a69ee9de77..38571fdcc7 100644 --- a/packages/core-api/src/extensions/traversal.test.tsx +++ b/packages/core-api/src/extensions/traversal.test.tsx @@ -41,11 +41,14 @@ describe('discovery', () => { root, discoverers: [childDiscoverer], collectors: { - names: createCollector(Array(), (acc, el) => { - if (typeof el.type === 'string') { - acc.push(el.type); - } - }), + names: createCollector( + () => Array(), + (acc, el) => { + if (typeof el.type === 'string') { + acc.push(el.type); + } + }, + ), }, }); @@ -85,11 +88,14 @@ describe('discovery', () => { ), ], collectors: { - names: createCollector(Array(), (acc, el) => { - if (typeof el.type === 'string') { - acc.push(el.type); - } - }), + names: createCollector( + () => Array(), + (acc, el) => { + if (typeof el.type === 'string') { + acc.push(el.type); + } + }, + ), }, }); diff --git a/packages/core-api/src/extensions/traversal.ts b/packages/core-api/src/extensions/traversal.ts index a1fbdab25a..054f23daca 100644 --- a/packages/core-api/src/extensions/traversal.ts +++ b/packages/core-api/src/extensions/traversal.ts @@ -120,10 +120,10 @@ export function traverseElementTree(options: { } export function createCollector( - initialResult: Result, + accumulatorFactory: () => Result, visit: ReturnType>['visit'], ): Collector { - return () => ({ accumulator: initialResult, visit }); + return () => ({ accumulator: accumulatorFactory(), visit }); } export function childDiscoverer(element: ReactElement): ReactNode { diff --git a/packages/core-api/src/plugin/collectors.ts b/packages/core-api/src/plugin/collectors.ts index a222746f9e..3eadfc0185 100644 --- a/packages/core-api/src/plugin/collectors.ts +++ b/packages/core-api/src/plugin/collectors.ts @@ -34,7 +34,7 @@ import { getComponentData } from '../extensions'; import { createCollector } from '../extensions/traversal'; export const pluginCollector = createCollector( - new Set(), + () => new Set(), (acc, node) => { const plugin = getComponentData(node, 'core.plugin'); if (plugin) { diff --git a/packages/core-api/src/routing/collectors.tsx b/packages/core-api/src/routing/collectors.tsx index fab0cbe112..34edd8a0d5 100644 --- a/packages/core-api/src/routing/collectors.tsx +++ b/packages/core-api/src/routing/collectors.tsx @@ -20,7 +20,7 @@ import { getComponentData } from '../extensions'; import { createCollector } from '../extensions/traversal'; export const routeCollector = createCollector( - new Map(), + () => new Map(), (acc, node, parent) => { if (parent.props.element === node) { return; @@ -51,7 +51,7 @@ export const routeCollector = createCollector( ); export const routeParentCollector = createCollector( - new Map(), + () => new Map(), (acc, node, parent, parentRouteRef?: RouteRef) => { if (parent.props.element === node) { return parentRouteRef; From cf2855dc9e1155ec2478e2d5d10f6f1b1662c1a6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 27 Nov 2020 15:50:36 +0100 Subject: [PATCH 17/42] core-api: add initial support for route parameters with useRouteRef Co-authored-by: blam --- packages/core-api/src/routing/hooks.test.tsx | 75 +++++++++++++++++--- packages/core-api/src/routing/hooks.tsx | 7 +- 2 files changed, 70 insertions(+), 12 deletions(-) diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx index c08891c8cc..65c7858954 100644 --- a/packages/core-api/src/routing/hooks.test.tsx +++ b/packages/core-api/src/routing/hooks.test.tsx @@ -27,6 +27,7 @@ import { createPlugin } from '../plugin'; import { routeCollector, routeParentCollector } from './collectors'; import { useRouteRef, RoutingProvider } from './hooks'; import { createRouteRef } from './RouteRef'; +import { RouteRef } from './types'; const mockConfig = () => ({ path: '/unused', title: 'Unused' }); const MockComponent = ({ children }: PropsWithChildren<{}>) => <>{children}; @@ -36,11 +37,19 @@ const plugin = createPlugin({ id: 'my-plugin' }); const ref1 = createRouteRef(mockConfig()); const ref2 = createRouteRef(mockConfig()); const ref3 = createRouteRef(mockConfig()); +const ref4 = createRouteRef(mockConfig()); -const MockRouteSource = () => { - const routeFunc = useRouteRef(ref2); - expect(routeFunc?.()).toBe('/foo/bar'); - return null; +const MockRouteSource = (props: { + name: string; + routeRef: RouteRef; + params?: Record; +}) => { + const routeFunc = useRouteRef(props.routeRef); + return ( +
+ Path at {props.name}: {routeFunc?.(props.params)} +
+ ); }; const Extension1 = plugin.provide( @@ -52,18 +61,21 @@ const Extension2 = plugin.provide( const Extension3 = plugin.provide( createRoutableExtension({ component: MockComponent, mountPoint: ref3 }), ); +const Extension4 = plugin.provide( + createRoutableExtension({ component: MockRouteSource, mountPoint: ref4 }), +); describe('discovery', () => { - it('should handle all react router Route patterns', () => { + it('should handle simple routeRef path creation for routeRefs used in other parts of the app', () => { const root = ( - + - + ); @@ -76,12 +88,57 @@ describe('discovery', () => { }, }); - render( + const rendered = render( {root} , ); - expect.assertions(2); + expect(rendered.getByText('Path at inside: /foo/bar')).toBeInTheDocument(); + expect(rendered.getByText('Path at outside: /foo/bar')).toBeInTheDocument(); + }); + + it('should handle routeRefs with parameters', () => { + const root = ( + + + + + + + + + ); + + const { routes, routeParents } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routeCollector, + routeParents: routeParentCollector, + }, + }); + + const rendered = render( + + {root} + , + ); + + expect( + rendered.getByText('Path at inside: /foo/bar/bleb'), + ).toBeInTheDocument(); + expect( + rendered.getByText('Path at outside: /foo/bar/blob'), + ).toBeInTheDocument(); }); }); diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx index 8a2aa72c92..739d74247c 100644 --- a/packages/core-api/src/routing/hooks.tsx +++ b/packages/core-api/src/routing/hooks.tsx @@ -16,8 +16,9 @@ import React, { createContext, ReactNode, useContext } from 'react'; import { RouteRef } from './types'; +import { generatePath } from 'react-router-dom'; -export type RouteFunc = () => string; +export type RouteFunc = (params?: Record) => string; class RouteResolver { constructor( @@ -38,8 +39,8 @@ class RouteResolver { currentRouteRef = this.routeParents.get(currentRouteRef); } - return () => { - return fullPath; + return (params?: Record) => { + return generatePath(fullPath, params); }; } } From 64993c1dd246776bb9c8ab171359d8ca81c0f56b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 27 Nov 2020 16:10:19 +0100 Subject: [PATCH 18/42] core-api: add naive implementation of param-relative routing Co-authored-by: blam --- packages/core-api/src/routing/hooks.test.tsx | 40 +++++++++++++++++++- packages/core-api/src/routing/hooks.tsx | 11 +++--- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx index 65c7858954..6c38a6e18a 100644 --- a/packages/core-api/src/routing/hooks.test.tsx +++ b/packages/core-api/src/routing/hooks.test.tsx @@ -38,6 +38,7 @@ const ref1 = createRouteRef(mockConfig()); const ref2 = createRouteRef(mockConfig()); const ref3 = createRouteRef(mockConfig()); const ref4 = createRouteRef(mockConfig()); +const ref5 = createRouteRef(mockConfig()); const MockRouteSource = (props: { name: string; @@ -47,7 +48,7 @@ const MockRouteSource = (props: { const routeFunc = useRouteRef(props.routeRef); return (
- Path at {props.name}: {routeFunc?.(props.params)} + Path at {props.name}: {routeFunc(props.params)}
); }; @@ -64,6 +65,9 @@ const Extension3 = plugin.provide( const Extension4 = plugin.provide( createRoutableExtension({ component: MockRouteSource, mountPoint: ref4 }), ); +const Extension5 = plugin.provide( + createRoutableExtension({ component: MockComponent, mountPoint: ref5 }), +); describe('discovery', () => { it('should handle simple routeRef path creation for routeRefs used in other parts of the app', () => { @@ -100,7 +104,7 @@ describe('discovery', () => { it('should handle routeRefs with parameters', () => { const root = ( - + { rendered.getByText('Path at outside: /foo/bar/blob'), ).toBeInTheDocument(); }); + + it('should handle relative routing within parameterized routes', () => { + const root = ( + + + + + + + + + ); + + const { routes, routeParents } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routeCollector, + routeParents: routeParentCollector, + }, + }); + + const rendered = render( + + {root} + , + ); + + expect( + rendered.getByText('Path at inside: /foo/blob/baz'), + ).toBeInTheDocument(); + }); }); diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx index 739d74247c..821fd6f9de 100644 --- a/packages/core-api/src/routing/hooks.tsx +++ b/packages/core-api/src/routing/hooks.tsx @@ -16,7 +16,7 @@ import React, { createContext, ReactNode, useContext } from 'react'; import { RouteRef } from './types'; -import { generatePath } from 'react-router-dom'; +import { generatePath, useParams } from 'react-router-dom'; export type RouteFunc = (params?: Record) => string; @@ -26,7 +26,7 @@ class RouteResolver { private readonly routeParents: Map, ) {} - resolve(routeRef: RouteRef): RouteFunc { + resolve(routeRef: RouteRef, parentParams: Record): RouteFunc { let currentRouteRef: RouteRef | undefined = routeRef; let fullPath = ''; @@ -40,20 +40,21 @@ class RouteResolver { } return (params?: Record) => { - return generatePath(fullPath, params); + return generatePath(fullPath, { ...params, ...parentParams }); }; } } const RoutingContext = createContext(undefined); -export function useRouteRef(routeRef: RouteRef): RouteFunc | undefined { +export function useRouteRef(routeRef: RouteRef): RouteFunc { const resolver = useContext(RoutingContext); + const params = useParams(); if (!resolver) { throw new Error('No route resolver found in context'); } - return resolver.resolve(routeRef); + return resolver.resolve(routeRef, params); } type ProviderProps = { From 4ef2a8769b4741358cd80112af43a87feeca9acd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 27 Nov 2020 17:26:38 +0100 Subject: [PATCH 19/42] core-api: add validation of mounted route parameter uniqueness Co-authored-by: blam --- packages/core-api/src/routing/hooks.test.tsx | 27 +++++++++++++- packages/core-api/src/routing/hooks.tsx | 39 ++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx index 6c38a6e18a..c2b8679e5f 100644 --- a/packages/core-api/src/routing/hooks.test.tsx +++ b/packages/core-api/src/routing/hooks.test.tsx @@ -25,7 +25,7 @@ import { } from '../extensions/traversal'; import { createPlugin } from '../plugin'; import { routeCollector, routeParentCollector } from './collectors'; -import { useRouteRef, RoutingProvider } from './hooks'; +import { useRouteRef, RoutingProvider, validateRoutes } from './hooks'; import { createRouteRef } from './RouteRef'; import { RouteRef } from './types'; @@ -177,4 +177,29 @@ describe('discovery', () => { rendered.getByText('Path at inside: /foo/blob/baz'), ).toBeInTheDocument(); }); + + it('should handle relative routing of parameterized routes with duplicate param names', () => { + const root = ( + + + + + + + + ); + + const { routes, routeParents } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routes: routeCollector, + routeParents: routeParentCollector, + }, + }); + + expect(() => validateRoutes(routes, routeParents)).toThrow( + 'Parameter :id is duplicated in path /foo/:id/bar/:id', + ); + }); }); diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx index 821fd6f9de..0c36928f10 100644 --- a/packages/core-api/src/routing/hooks.tsx +++ b/packages/core-api/src/routing/hooks.tsx @@ -75,3 +75,42 @@ export const RoutingProvider = ({ ); }; + +export function validateRoutes( + routes: Map, + routeParents: Map, +) { + const notLeafRoutes = new Set(routeParents.values()); + notLeafRoutes.delete(undefined); + + for (const route of routeParents.keys()) { + if (notLeafRoutes.has(route)) { + continue; + } + + let currentRouteRef: RouteRef | undefined = route; + + let fullPath = ''; + while (currentRouteRef) { + const path = routes.get(currentRouteRef); + if (!path) { + throw new Error(`No path for ${currentRouteRef}`); + } + fullPath = `${path}${fullPath}`; + currentRouteRef = routeParents.get(currentRouteRef); + } + + const params = fullPath.match(/:(\w+)/g); + if (params) { + for (let j = 0; j < params.length; j++) { + for (let i = j + 1; i < params.length; i++) { + if (params[i] === params[j]) { + throw new Error( + `Parameter ${params[i]} is duplicated in path ${fullPath}`, + ); + } + } + } + } + } +} From db28b524c48da657eec00e1deb92affece47a3df Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 30 Nov 2020 13:53:46 +0100 Subject: [PATCH 20/42] core-api: remove RouteRefRegistry and SubRouteRefs Co-authored-by: blam --- packages/core-api/src/routing/RouteRef.ts | 52 +----- .../src/routing/RouteRefRegistry.test.ts | 105 ------------ .../core-api/src/routing/RouteRefRegistry.ts | 155 ------------------ packages/core-api/src/routing/index.ts | 2 +- packages/core-api/src/routing/types.ts | 11 -- 5 files changed, 3 insertions(+), 322 deletions(-) delete mode 100644 packages/core-api/src/routing/RouteRefRegistry.test.ts delete mode 100644 packages/core-api/src/routing/RouteRefRegistry.ts diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index c33335cb38..e7160fdef6 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -14,43 +14,9 @@ * limitations under the License. */ -import { - ConcreteRoute, - routeReference, - ReferencedRoute, - resolveRoute, - RouteRefConfig, -} from './types'; -import { generatePath } from 'react-router-dom'; +import { RouteRefConfig } from './types'; -type SubRouteConfig = { - path: string; -}; - -export class SubRouteRef - implements ReferencedRoute { - constructor( - private readonly parent: ConcreteRoute, - private readonly config: SubRouteConfig, - ) {} - - get [routeReference]() { - return this; - } - - link(...args: Args): ConcreteRoute { - return { - [routeReference]: this, - [resolveRoute]: (path: string) => { - const ownPart = generatePath(this.config.path, args[0] ?? {}); - const parentPart = this.parent[resolveRoute](path); - return parentPart + ownPart; - }, - }; - } -} - -export class AbsoluteRouteRef implements ConcreteRoute { +export class AbsoluteRouteRef { constructor(private readonly config: RouteRefConfig) {} get icon() { @@ -65,20 +31,6 @@ export class AbsoluteRouteRef implements ConcreteRoute { get title() { return this.config.title; } - - createSubRoute( - config: SubRouteConfig, - ) { - return new SubRouteRef(this, config); - } - - get [routeReference]() { - return this; - } - - [resolveRoute](path: string) { - return path; - } } export function createRouteRef(config: RouteRefConfig): AbsoluteRouteRef { diff --git a/packages/core-api/src/routing/RouteRefRegistry.test.ts b/packages/core-api/src/routing/RouteRefRegistry.test.ts deleted file mode 100644 index fa1ef584f1..0000000000 --- a/packages/core-api/src/routing/RouteRefRegistry.test.ts +++ /dev/null @@ -1,105 +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 { RouteRefRegistry } from './RouteRefRegistry'; -import { createRouteRef } from './RouteRef'; - -const dummyConfig = { path: '/', icon: () => null, title: 'my-title' }; -const ref1 = createRouteRef(dummyConfig); -const ref11 = createRouteRef(dummyConfig); -const ref12 = createRouteRef(dummyConfig); -const ref121 = createRouteRef(dummyConfig); -const ref2 = createRouteRef(dummyConfig); -const ref2a = ref2.createSubRoute({ path: '/a' }); -const ref2b = ref2.createSubRoute<{ id: string }>({ path: '/b/:id' }); - -describe('RouteRefRegistry', () => { - it('should be constructed with a root route', () => { - const registry = new RouteRefRegistry(); - expect(registry.resolveRoute([], [])).toBe(''); - }); - - it('should register and resolve some absolute routes', () => { - const registry = new RouteRefRegistry(); - expect(registry.registerRoute([ref1], '1')).toBe(true); - expect(registry.registerRoute([ref1, ref11], '11')).toBe(true); - expect(registry.registerRoute([ref1, ref12], '12')).toBe(true); - expect(registry.registerRoute([ref1, ref12, ref121], '121')).toBe(true); - expect(registry.registerRoute([ref1, ref12, ref121], 'duplicate')).toBe( - false, - ); - expect(registry.registerRoute([ref1, ref12], 'duplicate')).toBe(false); - expect(registry.registerRoute([ref2], '2')).toBe(true); - expect(registry.registerRoute([ref2], 'duplicate')).toBe(false); - expect(registry.registerRoute([ref2], '2')).toBe(true); - - expect(registry.resolveRoute([], [ref1])).toBe('/1'); - expect(registry.resolveRoute([], [ref11])).toBe(undefined); - expect(registry.resolveRoute([], [ref1, ref11])).toBe('/1/11'); - expect(registry.resolveRoute([ref1], [ref11])).toBe('/1/11'); - expect(registry.resolveRoute([ref1], [ref2])).toBe('/2'); - expect(registry.resolveRoute([ref1, ref12, ref121], [])).toBe('/1/12/121'); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref121])).toBe( - '/1/12/121', - ); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref12, ref121])).toBe( - '/1/12/121', - ); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref12])).toBe('/1/12'); - expect(registry.resolveRoute([ref1, ref12, ref121], [ref1])).toBe('/1'); - }); - - it('should register and resolve with sub routes', () => { - const registry = new RouteRefRegistry(); - expect(registry.registerRoute([ref1], '1')).toBe(true); - expect(registry.registerRoute([ref2], '2')).toBe(true); - expect(registry.registerRoute([ref2a], '2')).toBe(true); - expect(registry.registerRoute([ref2a, ref1], '1')).toBe(true); - expect(registry.registerRoute([ref2a, ref2], '2')).toBe(true); - expect(registry.registerRoute([ref2b], '2')).toBe(true); - expect(registry.registerRoute([ref2b, ref1], '1')).toBe(true); - expect(registry.registerRoute([ref2b, ref2], '2')).toBe(true); - - expect(registry.resolveRoute([], [ref1])).toBe('/1'); - expect(registry.resolveRoute([], [ref2])).toBe('/2'); - expect(registry.resolveRoute([], [ref2a.link(), ref1])).toBe('/2/a/1'); - expect(registry.resolveRoute([], [ref2a.link(), ref2])).toBe('/2/a/2'); - expect(registry.resolveRoute([ref2a.link()], [ref2])).toBe('/2/a/2'); - expect(registry.resolveRoute([ref2a.link(), ref1], [ref2])).toBe('/2/a/2'); - expect(registry.resolveRoute([], [ref2b.link({ id: 'abc' }), ref1])).toBe( - '/2/b/abc/1', - ); - expect(registry.resolveRoute([], [ref2b.link({ id: 'xyz' }), ref2])).toBe( - '/2/b/xyz/2', - ); - expect(registry.resolveRoute([ref2b.link({ id: 'abc' })], [ref2])).toBe( - '/2/b/abc/2', - ); - expect( - registry.resolveRoute([ref2b.link({ id: 'abc' }), ref1], [ref2]), - ).toBe('/2/b/abc/2'); - }); - - it('should throw when registering routes incorrectly', () => { - const registry = new RouteRefRegistry(); - expect(() => { - registry.registerRoute([ref1, ref11], '11'); - }).toThrow('Could not find parent for new routing node'); - expect(() => { - registry.registerRoute([], '11'); - }).toThrow('Must provide at least 1 route to add routing node'); - }); -}); diff --git a/packages/core-api/src/routing/RouteRefRegistry.ts b/packages/core-api/src/routing/RouteRefRegistry.ts deleted file mode 100644 index 7e55cbe8f7..0000000000 --- a/packages/core-api/src/routing/RouteRefRegistry.ts +++ /dev/null @@ -1,155 +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 { - ConcreteRoute, - routeReference, - resolveRoute, - ReferencedRoute, -} from './types'; - -const rootRoute: ConcreteRoute = { - get [routeReference]() { - return this; - }, - [resolveRoute]: () => '', -}; - -export type RouteRefResolver = { - resolveRoute(from: ConcreteRoute[], to: ConcreteRoute[]): string; -}; - -class Node { - readonly children = new Map(); - - constructor(readonly path: string, readonly parent: Node | undefined) {} - - /** - * Look up a node in the tree given a path. - */ - findNode(routes: ReferencedRoute[]): Node | undefined { - let node = this as Node | undefined; - - for (let i = 0; i < routes.length; i++) { - node = node?.children.get(routes[i][routeReference]); - } - - return node; - } - - /** - * Assigns a path to a leaf node in the routing tree. All ancestor - * nodes of the new leaf node must already exist, or an error will be thrown. - * - * Returns true if the node was added, or false if the node already existed. - */ - addNode(routes: ReferencedRoute[], path: string): boolean { - if (routes.length === 0) { - throw new Error('Must provide at least 1 route to add routing node'); - } - - const parentNode = this.findNode(routes.slice(0, -1)); - if (!parentNode) { - throw new Error('Could not find parent for new routing node'); - } - - const lastRoute = routes[routes.length - 1]; - const lastRouteRef = lastRoute[routeReference]; - - const existingNode = parentNode.children.get(lastRouteRef); - if (existingNode) { - return existingNode.path === path; - } - - parentNode.children.set(lastRouteRef, new Node(path, parentNode)); - return true; - } - - /** - * Resolve an absolute URL that represents this node in the routing tree, using - * using the supplied concrete routes and ancestors of this node. - * - * The length of the provided routes array must match the depth of - * the routing tree that this node is at, or an error will be thrown. - */ - resolve(routes: ConcreteRoute[]) { - const parts = Array(routes.length); - - let node = this as Node | undefined; - for (let i = routes.length - 1; i >= 0; i--) { - if (!node) { - throw new Error('Route resolve missing required parent'); - } - - const route = routes[i]; - parts[i] = route[resolveRoute](node.path); - - node = node.parent; - } - - if (node) { - throw new Error('Route resolve did not reach root'); - } - - return parts.join('/'); - } -} - -/** - * A registry for resolving route refs into concrete string routes. - */ -export class RouteRefRegistry { - private readonly root = new Node('', undefined); - - /** - * Register a new leaf path for a sequence of routes. All ancestor - * routes must already exist. - */ - registerRoute(routes: ReferencedRoute[], path: string): boolean { - return this.root.addNode(routes, path); - } - - /** - * Resolve an absolute path from a point in the routing tree. - * - * The route referenced by `from` must exist, and is the starting - * point for the search, walking up the tree until a subtree that - * matches the routes reference in `to` are found. - * - * If `from` is empty, the search starts and ends at the root node. - * If `to` is empty, the route referenced by `from` will always be returned. - */ - resolveRoute(from: ConcreteRoute[], to: ConcreteRoute[]): string | undefined { - // Keep track of the `from` routes and pop the last ones as we traverse up - // the routing tree. The list of concrete routes that we're passing to - // `node.resolve()` should only include the ones in the resolve path. - const concreteStack = from.slice(); - - let fromNode = this.root.findNode(from); - while (fromNode) { - const resolvedNode = fromNode.findNode(to); - if (resolvedNode) { - return resolvedNode.resolve([rootRoute].concat(concreteStack, to)); - } - - // Search at this level of the tree failed, move up to parent - concreteStack.pop(); - fromNode = fromNode.parent; - } - - return undefined; - } -} diff --git a/packages/core-api/src/routing/index.ts b/packages/core-api/src/routing/index.ts index 29de34ec42..45bb8975db 100644 --- a/packages/core-api/src/routing/index.ts +++ b/packages/core-api/src/routing/index.ts @@ -14,6 +14,6 @@ * limitations under the License. */ -export type { RouteRef, RouteRefConfig, ConcreteRoute } from './types'; +export type { RouteRef, RouteRefConfig } from './types'; export type { MutableRouteRef, AbsoluteRouteRef } from './RouteRef'; export { createRouteRef } from './RouteRef'; diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index 162ac74bde..edb1bba196 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -16,17 +16,6 @@ import { IconComponent } from '../icons'; -export const resolveRoute = Symbol('resolve-route'); -export const routeReference = Symbol('route-ref'); - -export type ReferencedRoute = { - [routeReference]: unknown; -}; - -export type ConcreteRoute = ReferencedRoute & { - [resolveRoute](path: string): string; -}; - export type RouteRef = { // TODO(Rugvip): Remove path, look up via registry instead path: string; From e4782fd3b92b1f56754ba99298081d07e963004c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 30 Nov 2020 14:20:05 +0100 Subject: [PATCH 21/42] core-api: rename routes to routePaths Co-authored-by: blam --- .../core-api/src/routing/collectors.test.tsx | 8 ++-- packages/core-api/src/routing/collectors.tsx | 2 +- packages/core-api/src/routing/hooks.test.tsx | 38 ++++++++++--------- packages/core-api/src/routing/hooks.tsx | 14 +++---- 4 files changed, 33 insertions(+), 29 deletions(-) diff --git a/packages/core-api/src/routing/collectors.test.tsx b/packages/core-api/src/routing/collectors.test.tsx index c2eefa7e82..2595b8c0a0 100644 --- a/packages/core-api/src/routing/collectors.test.tsx +++ b/packages/core-api/src/routing/collectors.test.tsx @@ -15,7 +15,7 @@ */ import React, { PropsWithChildren } from 'react'; -import { routeCollector, routeParentCollector } from './collectors'; +import { routePathCollector, routeParentCollector } from './collectors'; import { traverseElementTree, @@ -96,7 +96,7 @@ describe('discovery', () => { root, discoverers: [childDiscoverer, routeElementDiscoverer], collectors: { - routes: routeCollector, + routes: routePathCollector, routeParents: routeParentCollector, }, }); @@ -147,7 +147,7 @@ describe('discovery', () => { root, discoverers: [childDiscoverer, routeElementDiscoverer], collectors: { - routes: routeCollector, + routes: routePathCollector, routeParents: routeParentCollector, }, }); @@ -184,7 +184,7 @@ describe('discovery', () => { ), discoverers: [childDiscoverer, routeElementDiscoverer], collectors: { - routes: routeCollector, + routes: routePathCollector, routeParents: routeParentCollector, }, }), diff --git a/packages/core-api/src/routing/collectors.tsx b/packages/core-api/src/routing/collectors.tsx index 34edd8a0d5..37f9252329 100644 --- a/packages/core-api/src/routing/collectors.tsx +++ b/packages/core-api/src/routing/collectors.tsx @@ -19,7 +19,7 @@ import { RouteRef } from '../routing/types'; import { getComponentData } from '../extensions'; import { createCollector } from '../extensions/traversal'; -export const routeCollector = createCollector( +export const routePathCollector = createCollector( () => new Map(), (acc, node, parent) => { if (parent.props.element === node) { diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx index c2b8679e5f..695cd35907 100644 --- a/packages/core-api/src/routing/hooks.test.tsx +++ b/packages/core-api/src/routing/hooks.test.tsx @@ -24,12 +24,16 @@ import { traverseElementTree, } from '../extensions/traversal'; import { createPlugin } from '../plugin'; -import { routeCollector, routeParentCollector } from './collectors'; +import { routePathCollector, routeParentCollector } from './collectors'; import { useRouteRef, RoutingProvider, validateRoutes } from './hooks'; import { createRouteRef } from './RouteRef'; -import { RouteRef } from './types'; +import { RouteRef, RouteRefConfig } from './types'; -const mockConfig = () => ({ path: '/unused', title: 'Unused' }); +const mockConfig = (extra?: Partial) => ({ + path: '/unused', + title: 'Unused', + ...extra, +}); const MockComponent = ({ children }: PropsWithChildren<{}>) => <>{children}; const plugin = createPlugin({ id: 'my-plugin' }); @@ -83,17 +87,17 @@ describe('discovery', () => { ); - const { routes, routeParents } = traverseElementTree({ + const { routePaths, routeParents } = traverseElementTree({ root, discoverers: [childDiscoverer, routeElementDiscoverer], collectors: { - routes: routeCollector, + routePaths: routePathCollector, routeParents: routeParentCollector, }, }); const rendered = render( - + {root} , ); @@ -123,17 +127,17 @@ describe('discovery', () => { ); - const { routes, routeParents } = traverseElementTree({ + const { routePaths, routeParents } = traverseElementTree({ root, discoverers: [childDiscoverer, routeElementDiscoverer], collectors: { - routes: routeCollector, + routePaths: routePathCollector, routeParents: routeParentCollector, }, }); const rendered = render( - + {root} , ); @@ -146,7 +150,7 @@ describe('discovery', () => { ).toBeInTheDocument(); }); - it('should handle relative routing within parameterized routes', () => { + it('should handle relative routing within parameterized routePaths', () => { const root = ( @@ -158,17 +162,17 @@ describe('discovery', () => { ); - const { routes, routeParents } = traverseElementTree({ + const { routePaths, routeParents } = traverseElementTree({ root, discoverers: [childDiscoverer, routeElementDiscoverer], collectors: { - routes: routeCollector, + routePaths: routePathCollector, routeParents: routeParentCollector, }, }); const rendered = render( - + {root} , ); @@ -178,7 +182,7 @@ describe('discovery', () => { ).toBeInTheDocument(); }); - it('should handle relative routing of parameterized routes with duplicate param names', () => { + it('should handle relative routing of parameterized routePaths with duplicate param names', () => { const root = ( @@ -189,16 +193,16 @@ describe('discovery', () => { ); - const { routes, routeParents } = traverseElementTree({ + const { routePaths, routeParents } = traverseElementTree({ root, discoverers: [childDiscoverer, routeElementDiscoverer], collectors: { - routes: routeCollector, + routePaths: routePathCollector, routeParents: routeParentCollector, }, }); - expect(() => validateRoutes(routes, routeParents)).toThrow( + expect(() => validateRoutes(routePaths, routeParents)).toThrow( 'Parameter :id is duplicated in path /foo/:id/bar/:id', ); }); diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx index 0c36928f10..22299e3305 100644 --- a/packages/core-api/src/routing/hooks.tsx +++ b/packages/core-api/src/routing/hooks.tsx @@ -22,7 +22,7 @@ export type RouteFunc = (params?: Record) => string; class RouteResolver { constructor( - private readonly routes: Map, + private readonly routePaths: Map, private readonly routeParents: Map, ) {} @@ -31,7 +31,7 @@ class RouteResolver { let fullPath = ''; while (currentRouteRef) { - const path = this.routes.get(currentRouteRef); + const path = this.routePaths.get(currentRouteRef); if (!path) { throw new Error(`No path for ${currentRouteRef}`); } @@ -58,17 +58,17 @@ export function useRouteRef(routeRef: RouteRef): RouteFunc { } type ProviderProps = { - routes: Map; + routePaths: Map; routeParents: Map; children: ReactNode; }; export const RoutingProvider = ({ - routes, + routePaths, routeParents, children, }: ProviderProps) => { - const resolver = new RouteResolver(routes, routeParents); + const resolver = new RouteResolver(routePaths, routeParents); return ( {children} @@ -77,7 +77,7 @@ export const RoutingProvider = ({ }; export function validateRoutes( - routes: Map, + routePaths: Map, routeParents: Map, ) { const notLeafRoutes = new Set(routeParents.values()); @@ -92,7 +92,7 @@ export function validateRoutes( let fullPath = ''; while (currentRouteRef) { - const path = routes.get(currentRouteRef); + const path = routePaths.get(currentRouteRef); if (!path) { throw new Error(`No path for ${currentRouteRef}`); } From 30a115d39231cf774139d731d8f983281e3e8781 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 30 Nov 2020 17:52:41 +0100 Subject: [PATCH 22/42] core-api: add collector for route objects and use react-router matching to implement relative routing Co-authored-by: blam --- packages/core-api/src/routing/RouteRef.ts | 4 + packages/core-api/src/routing/collectors.tsx | 36 +++++- packages/core-api/src/routing/hooks.test.tsx | 121 ++++++++++++++++--- packages/core-api/src/routing/hooks.tsx | 89 +++++++++++--- packages/core-api/src/routing/types.ts | 9 ++ 5 files changed, 222 insertions(+), 37 deletions(-) diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index e7160fdef6..b9dd7c94fe 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -31,6 +31,10 @@ export class AbsoluteRouteRef { get title() { return this.config.title; } + + toString() { + return `routeRef{path=${this.path}}`; + } } export function createRouteRef(config: RouteRefConfig): AbsoluteRouteRef { diff --git a/packages/core-api/src/routing/collectors.tsx b/packages/core-api/src/routing/collectors.tsx index 37f9252329..38e5376547 100644 --- a/packages/core-api/src/routing/collectors.tsx +++ b/packages/core-api/src/routing/collectors.tsx @@ -15,7 +15,7 @@ */ import { isValidElement, ReactNode } from 'react'; -import { RouteRef } from '../routing/types'; +import { BackstageRouteObject, RouteRef } from '../routing/types'; import { getComponentData } from '../extensions'; import { createCollector } from '../extensions/traversal'; @@ -80,3 +80,37 @@ export const routeParentCollector = createCollector( return nextParent; }, ); + +export const routeObjectCollector = createCollector( + () => Array(), + (acc, node, parent, parentChildArr: BackstageRouteObject[] = acc) => { + if (parent.props.element === node) { + return parentChildArr; + } + + const path: string | undefined = node.props?.path; + const caseSensitive: boolean = Boolean(node.props?.caseSensitive); + const element: ReactNode = node.props?.element; + + let routeRef = getComponentData(node, 'core.mountPoint'); + if (!routeRef && isValidElement(element)) { + routeRef = getComponentData(element, 'core.mountPoint'); + } + if (routeRef) { + const children: BackstageRouteObject[] = []; + if (!path) { + throw new Error(`No path found for mount point ${routeRef}`); + } + parentChildArr.push({ + caseSensitive, + path, + element: null, + routeRef, + children, + }); + return children; + } + + return parentChildArr; + }, +); diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx index 695cd35907..68d7abf38a 100644 --- a/packages/core-api/src/routing/hooks.test.tsx +++ b/packages/core-api/src/routing/hooks.test.tsx @@ -24,7 +24,11 @@ import { traverseElementTree, } from '../extensions/traversal'; import { createPlugin } from '../plugin'; -import { routePathCollector, routeParentCollector } from './collectors'; +import { + routePathCollector, + routeParentCollector, + routeObjectCollector, +} from './collectors'; import { useRouteRef, RoutingProvider, validateRoutes } from './hooks'; import { createRouteRef } from './RouteRef'; import { RouteRef, RouteRefConfig } from './types'; @@ -38,23 +42,31 @@ const MockComponent = ({ children }: PropsWithChildren<{}>) => <>{children}; const plugin = createPlugin({ id: 'my-plugin' }); -const ref1 = createRouteRef(mockConfig()); -const ref2 = createRouteRef(mockConfig()); -const ref3 = createRouteRef(mockConfig()); -const ref4 = createRouteRef(mockConfig()); -const ref5 = createRouteRef(mockConfig()); +const ref1 = createRouteRef(mockConfig({ path: '/wat1' })); +const ref2 = createRouteRef(mockConfig({ path: '/wat2' })); +const ref3 = createRouteRef(mockConfig({ path: '/wat3' })); +const ref4 = createRouteRef(mockConfig({ path: '/wat4' })); +const ref5 = createRouteRef(mockConfig({ path: '/wat5' })); const MockRouteSource = (props: { name: string; routeRef: RouteRef; params?: Record; }) => { - const routeFunc = useRouteRef(props.routeRef); - return ( -
- Path at {props.name}: {routeFunc(props.params)} -
- ); + try { + const routeFunc = useRouteRef(props.routeRef); + return ( +
+ Path at {props.name}: {routeFunc(props.params)} +
+ ); + } catch (ex) { + return ( +
+ Error at {props.name}: {ex.message} +
+ ); + } }; const Extension1 = plugin.provide( @@ -87,17 +99,22 @@ describe('discovery', () => { ); - const { routePaths, routeParents } = traverseElementTree({ + const { routePaths, routeParents, routeObjects } = traverseElementTree({ root, discoverers: [childDiscoverer, routeElementDiscoverer], collectors: { routePaths: routePathCollector, routeParents: routeParentCollector, + routeObjects: routeObjectCollector, }, }); const rendered = render( - + {root} , ); @@ -127,17 +144,22 @@ describe('discovery', () => { ); - const { routePaths, routeParents } = traverseElementTree({ + const { routePaths, routeParents, routeObjects } = traverseElementTree({ root, discoverers: [childDiscoverer, routeElementDiscoverer], collectors: { routePaths: routePathCollector, routeParents: routeParentCollector, + routeObjects: routeObjectCollector, }, }); const rendered = render( - + {root} , ); @@ -152,27 +174,38 @@ describe('discovery', () => { it('should handle relative routing within parameterized routePaths', () => { const root = ( - + + + ); - const { routePaths, routeParents } = traverseElementTree({ + const { routePaths, routeParents, routeObjects } = traverseElementTree({ root, discoverers: [childDiscoverer, routeElementDiscoverer], collectors: { routePaths: routePathCollector, routeParents: routeParentCollector, + routeObjects: routeObjectCollector, }, }); const rendered = render( - + {root} , ); @@ -182,6 +215,56 @@ describe('discovery', () => { ).toBeInTheDocument(); }); + it('should throw errors for routing to other routeRefs with unsupported parameters', () => { + const root = ( + + + + + + + + + + + ); + + const { routePaths, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routePaths: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + + const rendered = render( + + {root} + , + ); + + expect( + rendered.getByText( + `Error at outsideWithParams: Cannot route to ${ref3} with parent ${ref5} as it has parameters`, + ), + ).toBeInTheDocument(); + expect( + rendered.getByText( + `Error at outsideNoParams: Cannot route to ${ref3} with parent ${ref5} as it has parameters`, + ), + ).toBeInTheDocument(); + }); + it('should handle relative routing of parameterized routePaths with duplicate param names', () => { const root = ( diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx index 22299e3305..5dbaed4545 100644 --- a/packages/core-api/src/routing/hooks.tsx +++ b/packages/core-api/src/routing/hooks.tsx @@ -14,9 +14,9 @@ * limitations under the License. */ -import React, { createContext, ReactNode, useContext } from 'react'; -import { RouteRef } from './types'; -import { generatePath, useParams } from 'react-router-dom'; +import React, { createContext, ReactNode, useContext, useMemo } from 'react'; +import { BackstageRouteObject, RouteRef } from './types'; +import { generatePath, matchRoutes, useLocation } from 'react-router-dom'; export type RouteFunc = (params?: Record) => string; @@ -24,23 +24,71 @@ class RouteResolver { constructor( private readonly routePaths: Map, private readonly routeParents: Map, + private readonly routeObjects: BackstageRouteObject[], ) {} - resolve(routeRef: RouteRef, parentParams: Record): RouteFunc { - let currentRouteRef: RouteRef | undefined = routeRef; - let fullPath = ''; + resolve( + routeRef: RouteRef, + sourceLocation: ReturnType, + ): RouteFunc { + const match = matchRoutes(this.routeObjects, sourceLocation) ?? []; - while (currentRouteRef) { - const path = this.routePaths.get(currentRouteRef); - if (!path) { - throw new Error(`No path for ${currentRouteRef}`); + const lastPath = this.routePaths.get(routeRef); + if (!lastPath) { + throw new Error(`No path for ${routeRef}`); + } + const targetRefStack = Array(); + let matchIndex = -1; + + for ( + let currentRouteRef: RouteRef | undefined = routeRef; + currentRouteRef; + currentRouteRef = this.routeParents.get(currentRouteRef) + ) { + matchIndex = match.findIndex( + m => (m.route as BackstageRouteObject).routeRef === currentRouteRef, + ); + if (matchIndex !== -1) { + break; } - fullPath = `${path}${fullPath}`; - currentRouteRef = this.routeParents.get(currentRouteRef); + + targetRefStack.unshift(currentRouteRef); } + // If our target route is present in the initial match we need to construct the final path + // from the parent of the matched route segment. That's to allow the caller of the route + // function to supply their own params. + if (targetRefStack.length === 0) { + matchIndex -= 1; + } + + // This is the part of the route tree that the target and source locations have in common. + // We re-use the existing pathname directly along with all params. + const parentPath = matchIndex === -1 ? '' : match[matchIndex].pathname; + + // This constructs the mid section of the path using paths resolved from all route refs + // we need to traverse to reach our target except for the very last one. None of these + // paths are allowed to require any parameters, as the called would have no way of knowing + // what parameters those are. + const prefixPath = targetRefStack + .slice(0, -1) + .map(ref => { + const path = this.routePaths.get(ref); + if (!path) { + throw new Error(`No path for ${ref}`); + } + if (path.includes(':')) { + throw new Error( + `Cannot route to ${routeRef} with parent ${ref} as it has parameters`, + ); + } + return path; + }) + .join('/') + .replace(/\/\/+/g, '/'); // Normalize path to not contain repeated /'s + return (params?: Record) => { - return generatePath(fullPath, { ...params, ...parentParams }); + return `${parentPath}${prefixPath}${generatePath(lastPath, params)}`; }; } } @@ -48,27 +96,34 @@ class RouteResolver { const RoutingContext = createContext(undefined); export function useRouteRef(routeRef: RouteRef): RouteFunc { + const sourceLocation = useLocation(); const resolver = useContext(RoutingContext); - const params = useParams(); - if (!resolver) { + const routeFunc = useMemo( + () => resolver && resolver.resolve(routeRef, sourceLocation), + [resolver, routeRef, sourceLocation], + ); + + if (!routeFunc) { throw new Error('No route resolver found in context'); } - return resolver.resolve(routeRef, params); + return routeFunc; } type ProviderProps = { routePaths: Map; routeParents: Map; + routeObjects: BackstageRouteObject[]; children: ReactNode; }; export const RoutingProvider = ({ routePaths, routeParents, + routeObjects, children, }: ProviderProps) => { - const resolver = new RouteResolver(routePaths, routeParents); + const resolver = new RouteResolver(routePaths, routeParents, routeObjects); return ( {children} diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index edb1bba196..2f13a97a91 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -28,3 +28,12 @@ export type RouteRefConfig = { icon?: IconComponent; title: string; }; + +// A duplicate of the react-router RouteObject, but with routeRef added +export interface BackstageRouteObject { + caseSensitive: boolean; + children?: BackstageRouteObject[]; + element: React.ReactNode; + path: string; + routeRef: RouteRef; +} From 9a918668948d2092e56420e0bc08e22bb6fdc999 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 30 Nov 2020 17:58:40 +0100 Subject: [PATCH 23/42] core-api: dry up route collectors --- packages/core-api/src/routing/collectors.tsx | 50 +++++++------------- 1 file changed, 16 insertions(+), 34 deletions(-) diff --git a/packages/core-api/src/routing/collectors.tsx b/packages/core-api/src/routing/collectors.tsx index 38e5376547..db6673536d 100644 --- a/packages/core-api/src/routing/collectors.tsx +++ b/packages/core-api/src/routing/collectors.tsx @@ -14,11 +14,22 @@ * limitations under the License. */ -import { isValidElement, ReactNode } from 'react'; +import { isValidElement, ReactElement, ReactNode } from 'react'; import { BackstageRouteObject, RouteRef } from '../routing/types'; import { getComponentData } from '../extensions'; import { createCollector } from '../extensions/traversal'; +function getMountPoint(node: ReactElement): RouteRef | undefined { + const element: ReactNode = node.props?.element; + + let routeRef = getComponentData(node, 'core.mountPoint'); + if (!routeRef && isValidElement(element)) { + routeRef = getComponentData(element, 'core.mountPoint'); + } + + return routeRef; +} + export const routePathCollector = createCollector( () => new Map(), (acc, node, parent) => { @@ -26,26 +37,13 @@ export const routePathCollector = createCollector( return; } - const path: string | undefined = node.props?.path; - const element: ReactNode = node.props?.element; - - const routeRef = getComponentData(node, 'core.mountPoint'); + const routeRef = getMountPoint(node); if (routeRef) { + const path: string | undefined = node.props?.path; if (!path) { throw new Error('Mounted routable extension must have a path'); } acc.set(routeRef, path); - } else if (isValidElement(element)) { - const elementRouteRef = getComponentData( - element, - 'core.mountPoint', - ); - if (elementRouteRef) { - if (!path) { - throw new Error('Route element must have a path'); - } - acc.set(elementRouteRef, path); - } } }, ); @@ -57,24 +55,12 @@ export const routeParentCollector = createCollector( return parentRouteRef; } - const element: ReactNode = node.props?.element; - let nextParent = parentRouteRef; - const routeRef = getComponentData(node, 'core.mountPoint'); + const routeRef = getMountPoint(node); if (routeRef) { acc.set(routeRef, parentRouteRef); nextParent = routeRef; - } else if (isValidElement(element)) { - const elementRouteRef = getComponentData( - element, - 'core.mountPoint', - ); - - if (elementRouteRef) { - acc.set(elementRouteRef, parentRouteRef); - nextParent = elementRouteRef; - } } return nextParent; @@ -90,12 +76,8 @@ export const routeObjectCollector = createCollector( const path: string | undefined = node.props?.path; const caseSensitive: boolean = Boolean(node.props?.caseSensitive); - const element: ReactNode = node.props?.element; - let routeRef = getComponentData(node, 'core.mountPoint'); - if (!routeRef && isValidElement(element)) { - routeRef = getComponentData(element, 'core.mountPoint'); - } + const routeRef = getMountPoint(node); if (routeRef) { const children: BackstageRouteObject[] = []; if (!path) { From ef63f951ea27161ff413ebd56cccbf912b3fe8c8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 30 Nov 2020 18:01:50 +0100 Subject: [PATCH 24/42] core-api: dry up routing hooks tests --- packages/core-api/src/routing/hooks.test.tsx | 104 +++++-------------- 1 file changed, 27 insertions(+), 77 deletions(-) diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx index 68d7abf38a..4f79fea303 100644 --- a/packages/core-api/src/routing/hooks.test.tsx +++ b/packages/core-api/src/routing/hooks.test.tsx @@ -15,7 +15,7 @@ */ import { render } from '@testing-library/react'; -import React, { PropsWithChildren } from 'react'; +import React, { PropsWithChildren, ReactElement } from 'react'; import { MemoryRouter, Routes } from 'react-router-dom'; import { createRoutableExtension } from '../extensions'; import { @@ -85,6 +85,28 @@ const Extension5 = plugin.provide( createRoutableExtension({ component: MockComponent, mountPoint: ref5 }), ); +function withRoutingProvider(root: ReactElement) { + const { routePaths, routeParents, routeObjects } = traverseElementTree({ + root, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + routePaths: routePathCollector, + routeParents: routeParentCollector, + routeObjects: routeObjectCollector, + }, + }); + + return ( + + {root} + + ); +} + describe('discovery', () => { it('should handle simple routeRef path creation for routeRefs used in other parts of the app', () => { const root = ( @@ -99,25 +121,7 @@ describe('discovery', () => { ); - const { routePaths, routeParents, routeObjects } = traverseElementTree({ - root, - discoverers: [childDiscoverer, routeElementDiscoverer], - collectors: { - routePaths: routePathCollector, - routeParents: routeParentCollector, - routeObjects: routeObjectCollector, - }, - }); - - const rendered = render( - - {root} - , - ); + const rendered = render(withRoutingProvider(root)); expect(rendered.getByText('Path at inside: /foo/bar')).toBeInTheDocument(); expect(rendered.getByText('Path at outside: /foo/bar')).toBeInTheDocument(); @@ -144,25 +148,7 @@ describe('discovery', () => { ); - const { routePaths, routeParents, routeObjects } = traverseElementTree({ - root, - discoverers: [childDiscoverer, routeElementDiscoverer], - collectors: { - routePaths: routePathCollector, - routeParents: routeParentCollector, - routeObjects: routeObjectCollector, - }, - }); - - const rendered = render( - - {root} - , - ); + const rendered = render(withRoutingProvider(root)); expect( rendered.getByText('Path at inside: /foo/bar/bleb'), @@ -190,25 +176,7 @@ describe('discovery', () => { ); - const { routePaths, routeParents, routeObjects } = traverseElementTree({ - root, - discoverers: [childDiscoverer, routeElementDiscoverer], - collectors: { - routePaths: routePathCollector, - routeParents: routeParentCollector, - routeObjects: routeObjectCollector, - }, - }); - - const rendered = render( - - {root} - , - ); + const rendered = render(withRoutingProvider(root)); expect( rendered.getByText('Path at inside: /foo/blob/baz'), @@ -233,25 +201,7 @@ describe('discovery', () => { ); - const { routePaths, routeParents, routeObjects } = traverseElementTree({ - root, - discoverers: [childDiscoverer, routeElementDiscoverer], - collectors: { - routePaths: routePathCollector, - routeParents: routeParentCollector, - routeObjects: routeObjectCollector, - }, - }); - - const rendered = render( - - {root} - , - ); + const rendered = render(withRoutingProvider(root)); expect( rendered.getByText( From 3e7378953e1682dd15d94610e519e9d79823f854 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 1 Dec 2020 18:39:23 +0100 Subject: [PATCH 25/42] core-api: add optional typed params for RouteRefs and useRouteRef Co-authored-by: blam --- packages/core-api/src/routing/RouteRef.ts | 22 +++++----- packages/core-api/src/routing/hooks.test.tsx | 17 +++++--- packages/core-api/src/routing/hooks.tsx | 44 +++++++++++++------- packages/core-api/src/routing/index.ts | 1 - packages/core-api/src/routing/types.ts | 10 +++-- 5 files changed, 58 insertions(+), 36 deletions(-) diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index b9dd7c94fe..41384cafe2 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { RouteRefConfig } from './types'; +import { RouteRefConfig, RouteRef } from './types'; -export class AbsoluteRouteRef { - constructor(private readonly config: RouteRefConfig) {} +export class AbsoluteRouteRef { + constructor(private readonly config: RouteRefConfig) {} get icon() { return this.config.icon; @@ -32,16 +32,18 @@ export class AbsoluteRouteRef { return this.config.title; } + get P(): Params { + throw new Error("Don't call me maybe"); + } + toString() { return `routeRef{path=${this.path}}`; } } -export function createRouteRef(config: RouteRefConfig): AbsoluteRouteRef { - return new AbsoluteRouteRef(config); +export function createRouteRef< + ParamKeys extends string, + Params extends { [param in string]: string } = { [name in ParamKeys]: string } +>(config: RouteRefConfig): RouteRef { + return new AbsoluteRouteRef(config); } - -// TODO(Rugvip): Added for backwards compatibility, remove once old usage is gone -// We may want to avoid exporting the AbsoluteRouteRef itself though, and consider -// a different model for how to create sub routes, just avoid this -export type MutableRouteRef = AbsoluteRouteRef; diff --git a/packages/core-api/src/routing/hooks.test.tsx b/packages/core-api/src/routing/hooks.test.tsx index 4f79fea303..ae1053b58d 100644 --- a/packages/core-api/src/routing/hooks.test.tsx +++ b/packages/core-api/src/routing/hooks.test.tsx @@ -29,11 +29,16 @@ import { routeParentCollector, routeObjectCollector, } from './collectors'; -import { useRouteRef, RoutingProvider, validateRoutes } from './hooks'; +import { + useRouteRef, + RoutingProvider, + validateRoutes, + RouteFunc, +} from './hooks'; import { createRouteRef } from './RouteRef'; import { RouteRef, RouteRefConfig } from './types'; -const mockConfig = (extra?: Partial) => ({ +const mockConfig = (extra?: Partial>) => ({ path: '/unused', title: 'Unused', ...extra, @@ -48,13 +53,13 @@ const ref3 = createRouteRef(mockConfig({ path: '/wat3' })); const ref4 = createRouteRef(mockConfig({ path: '/wat4' })); const ref5 = createRouteRef(mockConfig({ path: '/wat5' })); -const MockRouteSource = (props: { +const MockRouteSource = (props: { name: string; - routeRef: RouteRef; - params?: Record; + routeRef: RouteRef; + params?: T; }) => { try { - const routeFunc = useRouteRef(props.routeRef); + const routeFunc = useRouteRef(props.routeRef) as RouteFunc; return (
Path at {props.name}: {routeFunc(props.params)} diff --git a/packages/core-api/src/routing/hooks.tsx b/packages/core-api/src/routing/hooks.tsx index 5dbaed4545..563bc84a76 100644 --- a/packages/core-api/src/routing/hooks.tsx +++ b/packages/core-api/src/routing/hooks.tsx @@ -15,33 +15,42 @@ */ import React, { createContext, ReactNode, useContext, useMemo } from 'react'; -import { BackstageRouteObject, RouteRef } from './types'; +import { AnyRouteRef, BackstageRouteObject, RouteRef } from './types'; import { generatePath, matchRoutes, useLocation } from 'react-router-dom'; -export type RouteFunc = (params?: Record) => string; +// The extra TS magic here is to require a single params argument if the RouteRef +// had at least one param defined, but require 0 arguments if there are no params defined. +// Without this we'd have to pass in empty object to all parameter-less RouteRefs +// just to make TypeScript happy, or we would have to make the argument optional in +// which case you might forget to pass it in when it is actually required. +export type RouteFunc = ( + ...[params]: Params[keyof Params] extends never + ? readonly [] + : readonly [Params] +) => string; class RouteResolver { constructor( - private readonly routePaths: Map, - private readonly routeParents: Map, + private readonly routePaths: Map, + private readonly routeParents: Map, private readonly routeObjects: BackstageRouteObject[], ) {} - resolve( - routeRef: RouteRef, + resolve( + routeRef: RouteRef, sourceLocation: ReturnType, - ): RouteFunc { + ): RouteFunc { const match = matchRoutes(this.routeObjects, sourceLocation) ?? []; const lastPath = this.routePaths.get(routeRef); if (!lastPath) { throw new Error(`No path for ${routeRef}`); } - const targetRefStack = Array(); + const targetRefStack = Array(); let matchIndex = -1; for ( - let currentRouteRef: RouteRef | undefined = routeRef; + let currentRouteRef: AnyRouteRef | undefined = routeRef; currentRouteRef; currentRouteRef = this.routeParents.get(currentRouteRef) ) { @@ -87,15 +96,18 @@ class RouteResolver { .join('/') .replace(/\/\/+/g, '/'); // Normalize path to not contain repeated /'s - return (params?: Record) => { + const routeFunc: RouteFunc = (...[params]) => { return `${parentPath}${prefixPath}${generatePath(lastPath, params)}`; }; + return routeFunc; } } const RoutingContext = createContext(undefined); -export function useRouteRef(routeRef: RouteRef): RouteFunc { +export function useRouteRef( + routeRef: RouteRef, +): RouteFunc { const sourceLocation = useLocation(); const resolver = useContext(RoutingContext); const routeFunc = useMemo( @@ -111,8 +123,8 @@ export function useRouteRef(routeRef: RouteRef): RouteFunc { } type ProviderProps = { - routePaths: Map; - routeParents: Map; + routePaths: Map; + routeParents: Map; routeObjects: BackstageRouteObject[]; children: ReactNode; }; @@ -132,8 +144,8 @@ export const RoutingProvider = ({ }; export function validateRoutes( - routePaths: Map, - routeParents: Map, + routePaths: Map, + routeParents: Map, ) { const notLeafRoutes = new Set(routeParents.values()); notLeafRoutes.delete(undefined); @@ -143,7 +155,7 @@ export function validateRoutes( continue; } - let currentRouteRef: RouteRef | undefined = route; + let currentRouteRef: AnyRouteRef | undefined = route; let fullPath = ''; while (currentRouteRef) { diff --git a/packages/core-api/src/routing/index.ts b/packages/core-api/src/routing/index.ts index 45bb8975db..1493507f4e 100644 --- a/packages/core-api/src/routing/index.ts +++ b/packages/core-api/src/routing/index.ts @@ -15,5 +15,4 @@ */ export type { RouteRef, RouteRefConfig } from './types'; -export type { MutableRouteRef, AbsoluteRouteRef } from './RouteRef'; export { createRouteRef } from './RouteRef'; diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index 2f13a97a91..2726504ad2 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -16,14 +16,18 @@ import { IconComponent } from '../icons'; -export type RouteRef = { +export type RouteRef = { // TODO(Rugvip): Remove path, look up via registry instead path: string; icon?: IconComponent; title: string; + P: Params; }; -export type RouteRefConfig = { +export type AnyRouteRef = RouteRef; + +export type RouteRefConfig = { + params?: Array; path: string; icon?: IconComponent; title: string; @@ -35,5 +39,5 @@ export interface BackstageRouteObject { children?: BackstageRouteObject[]; element: React.ReactNode; path: string; - routeRef: RouteRef; + routeRef: AnyRouteRef; } From 950a3d3828c20156f397cedf6999d5a2355b703b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 7 Dec 2020 14:38:21 +0100 Subject: [PATCH 26/42] core-api: pass ReactNode as parent to collectors --- packages/core-api/src/extensions/traversal.ts | 8 ++++---- packages/core-api/src/routing/collectors.tsx | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/core-api/src/extensions/traversal.ts b/packages/core-api/src/extensions/traversal.ts index 054f23daca..bdf02d16c8 100644 --- a/packages/core-api/src/extensions/traversal.ts +++ b/packages/core-api/src/extensions/traversal.ts @@ -23,7 +23,7 @@ export type Collector = () => { visit( accumulator: Result, element: ReactElement, - parent: ReactElement, + parent: ReactElement | undefined, context: Context, ): Context; }; @@ -33,7 +33,7 @@ export type Collector = () => { * varying methods to discover child nodes and collect data along the way. */ export function traverseElementTree(options: { - root: ReactElement; + root: ReactNode; discoverers: Discoverer[]; collectors: { [name in keyof Results]: Collector }; }): Results { @@ -52,14 +52,14 @@ export function traverseElementTree(options: { // Internal representation of an element in the tree that we're iterating over type QueueItem = { node: ReactNode; - parent: ReactElement; + parent: ReactElement | undefined; contexts: { [name in string]: unknown }; }; const queue = [ { node: Children.toArray(options.root), - parent: options.root, + parent: undefined, contexts: {}, } as QueueItem, ]; diff --git a/packages/core-api/src/routing/collectors.tsx b/packages/core-api/src/routing/collectors.tsx index db6673536d..e47a59aa85 100644 --- a/packages/core-api/src/routing/collectors.tsx +++ b/packages/core-api/src/routing/collectors.tsx @@ -33,7 +33,7 @@ function getMountPoint(node: ReactElement): RouteRef | undefined { export const routePathCollector = createCollector( () => new Map(), (acc, node, parent) => { - if (parent.props.element === node) { + if (parent?.props.element === node) { return; } @@ -51,7 +51,7 @@ export const routePathCollector = createCollector( export const routeParentCollector = createCollector( () => new Map(), (acc, node, parent, parentRouteRef?: RouteRef) => { - if (parent.props.element === node) { + if (parent?.props.element === node) { return parentRouteRef; } @@ -70,7 +70,7 @@ export const routeParentCollector = createCollector( export const routeObjectCollector = createCollector( () => Array(), (acc, node, parent, parentChildArr: BackstageRouteObject[] = acc) => { - if (parent.props.element === node) { + if (parent?.props.element === node) { return parentChildArr; } From d8d5a17dade9cece3479268122a722ccd191e4ee Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 7 Dec 2020 18:35:11 +0100 Subject: [PATCH 27/42] changeset: add core-api changeset for RouteRef changes --- .changeset/red-worms-fold.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/red-worms-fold.md diff --git a/.changeset/red-worms-fold.md b/.changeset/red-worms-fold.md new file mode 100644 index 0000000000..081f686659 --- /dev/null +++ b/.changeset/red-worms-fold.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-api': minor +--- + +Remove exported `ConcreteRoute`, `MutableRouteRef`, `AbsoluteRouteRef` types, and add as of yet unused `params` option to `createRouteRef`. From 0873ed2d06b885c29a445cfdb2f8425ad18b96ec Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 7 Dec 2020 19:06:24 +0100 Subject: [PATCH 28/42] core-api: switch to deprecating routing types rather than removing --- .changeset/red-worms-fold.md | 8 ++++++-- packages/core-api/src/routing/RouteRef.ts | 10 +++++++-- packages/core-api/src/routing/index.ts | 8 +++++++- packages/core-api/src/routing/types.ts | 25 ++++++++++++++++++++++- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/.changeset/red-worms-fold.md b/.changeset/red-worms-fold.md index 081f686659..41dcc75c4c 100644 --- a/.changeset/red-worms-fold.md +++ b/.changeset/red-worms-fold.md @@ -1,5 +1,9 @@ --- -'@backstage/core-api': minor +'@backstage/core-api': patch --- -Remove exported `ConcreteRoute`, `MutableRouteRef`, `AbsoluteRouteRef` types, and add as of yet unused `params` option to `createRouteRef`. +Deprecated the `ConcreteRoute`, `MutableRouteRef`, `AbsoluteRouteRef` types and added a new `RouteRef` type as replacement. + +Deprecated and disabled the `createSubRoute` method of `AbsoluteRouteRef`. + +Add an as of yet unused `params` option to `createRouteRef`. diff --git a/packages/core-api/src/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts index 41384cafe2..0fbc57c6f1 100644 --- a/packages/core-api/src/routing/RouteRef.ts +++ b/packages/core-api/src/routing/RouteRef.ts @@ -32,8 +32,14 @@ export class AbsoluteRouteRef { return this.config.title; } - get P(): Params { - throw new Error("Don't call me maybe"); + /** + * This function should not be used, create a separate RouteRef instead + * @deprecated + */ + createSubRoute(): any { + throw new Error( + 'This method should not be called, create a separate RouteRef instead', + ); } toString() { diff --git a/packages/core-api/src/routing/index.ts b/packages/core-api/src/routing/index.ts index 1493507f4e..d682aa9348 100644 --- a/packages/core-api/src/routing/index.ts +++ b/packages/core-api/src/routing/index.ts @@ -14,5 +14,11 @@ * limitations under the License. */ -export type { RouteRef, RouteRefConfig } from './types'; +export type { + RouteRef, + RouteRefConfig, + AbsoluteRouteRef, + ConcreteRoute, + MutableRouteRef, +} from './types'; export { createRouteRef } from './RouteRef'; diff --git a/packages/core-api/src/routing/types.ts b/packages/core-api/src/routing/types.ts index 2726504ad2..e91ca7ea53 100644 --- a/packages/core-api/src/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -16,16 +16,39 @@ import { IconComponent } from '../icons'; +// @ts-ignore, we're just embedding the Params type for usage in other places export type RouteRef = { // TODO(Rugvip): Remove path, look up via registry instead path: string; icon?: IconComponent; title: string; - P: Params; + /** + * This function should not be used, create a separate RouteRef instead + * @deprecated + */ + createSubRoute(): any; }; export type AnyRouteRef = RouteRef; +/** + * This type should not be used + * @deprecated + */ +export type ConcreteRoute = {}; + +/** + * This type should not be used, use RouteRef instead + * @deprecated + */ +export type AbsoluteRouteRef = RouteRef<{}>; + +/** + * This type should not be used, use RouteRef instead + * @deprecated + */ +export type MutableRouteRef = RouteRef<{}>; + export type RouteRefConfig = { params?: Array; path: string; From b2db892869334f9a4323229b53cc05fa819e6b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 7 Dec 2020 21:43:17 +0100 Subject: [PATCH 29/42] integration: addressed comments --- .changeset/nine-forks-sleep.md | 2 +- ...IntegrationsImpl.ts => ScmIntegrations.ts} | 12 ++++------ .../src/azure/AzureIntegration.test.ts | 2 +- .../integration/src/azure/AzureIntegration.ts | 2 +- .../bitbucket/BitbucketIntegration.test.ts | 2 +- .../src/bitbucket/BitbucketIntegration.ts | 2 +- .../src/github/GitHubIntegration.test.ts | 2 +- .../src/github/GitHubIntegration.ts | 2 +- .../src/gitlab/GitLabIntegration.test.ts | 2 +- .../src/gitlab/GitLabIntegration.ts | 2 +- packages/integration/src/index.ts | 3 ++- packages/integration/src/types.ts | 23 ++++++++++--------- 12 files changed, 27 insertions(+), 29 deletions(-) rename packages/integration/src/{ScmIntegrationsImpl.ts => ScmIntegrations.ts} (82%) diff --git a/.changeset/nine-forks-sleep.md b/.changeset/nine-forks-sleep.md index 7edbca2169..ce8051d35c 100644 --- a/.changeset/nine-forks-sleep.md +++ b/.changeset/nine-forks-sleep.md @@ -1,5 +1,5 @@ --- -'@backstage/integration': minor +'@backstage/integration': patch --- Add the basics of cross-integration concerns diff --git a/packages/integration/src/ScmIntegrationsImpl.ts b/packages/integration/src/ScmIntegrations.ts similarity index 82% rename from packages/integration/src/ScmIntegrationsImpl.ts rename to packages/integration/src/ScmIntegrations.ts index ef6a6835b9..515a32502d 100644 --- a/packages/integration/src/ScmIntegrationsImpl.ts +++ b/packages/integration/src/ScmIntegrations.ts @@ -22,12 +22,12 @@ import { GitLabIntegration } from './gitlab/GitLabIntegration'; import { ScmIntegration, ScmIntegrationPredicateTuple, - ScmIntegrations, + ScmIntegrationRegistry, } from './types'; -export class ScmIntegrationsImpl implements ScmIntegrations { - static fromConfig(config: Config): ScmIntegrationsImpl { - return new ScmIntegrationsImpl([ +export class ScmIntegrations implements ScmIntegrationRegistry { + static fromConfig(config: Config): ScmIntegrations { + return new ScmIntegrations([ ...AzureIntegration.factory({ config }), ...BitbucketIntegration.factory({ config }), ...GitHubIntegration.factory({ config }), @@ -41,10 +41,6 @@ export class ScmIntegrationsImpl implements ScmIntegrations { return this.integrations.map(i => i.integration); } - byName(name: string): ScmIntegration | undefined { - return this.integrations.map(i => i.integration).find(i => i.name === name); - } - byUrl(url: string): ScmIntegration | undefined { return this.integrations.find(i => i.predicate(new URL(url)))?.integration; } diff --git a/packages/integration/src/azure/AzureIntegration.test.ts b/packages/integration/src/azure/AzureIntegration.test.ts index d0d3cf65ad..aec37ce905 100644 --- a/packages/integration/src/azure/AzureIntegration.test.ts +++ b/packages/integration/src/azure/AzureIntegration.test.ts @@ -43,6 +43,6 @@ describe('AzureIntegration', () => { it('returns the basics', () => { const integration = new AzureIntegration({ host: 'h.com' } as any); expect(integration.type).toBe('azure'); - expect(integration.name).toBe('h.com'); + expect(integration.title).toBe('h.com'); }); }); diff --git a/packages/integration/src/azure/AzureIntegration.ts b/packages/integration/src/azure/AzureIntegration.ts index 2e1e4d9795..84dc3800d2 100644 --- a/packages/integration/src/azure/AzureIntegration.ts +++ b/packages/integration/src/azure/AzureIntegration.ts @@ -34,7 +34,7 @@ export class AzureIntegration implements ScmIntegration { return 'azure'; } - get name(): string { + get title(): string { return this.config.host; } } diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.test.ts b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts index 9678b35014..174b6ab232 100644 --- a/packages/integration/src/bitbucket/BitbucketIntegration.test.ts +++ b/packages/integration/src/bitbucket/BitbucketIntegration.test.ts @@ -46,6 +46,6 @@ describe('BitbucketIntegration', () => { it('returns the basics', () => { const integration = new BitbucketIntegration({ host: 'h.com' } as any); expect(integration.type).toBe('bitbucket'); - expect(integration.name).toBe('h.com'); + expect(integration.title).toBe('h.com'); }); }); diff --git a/packages/integration/src/bitbucket/BitbucketIntegration.ts b/packages/integration/src/bitbucket/BitbucketIntegration.ts index e06b539611..b271e2f408 100644 --- a/packages/integration/src/bitbucket/BitbucketIntegration.ts +++ b/packages/integration/src/bitbucket/BitbucketIntegration.ts @@ -37,7 +37,7 @@ export class BitbucketIntegration implements ScmIntegration { return 'bitbucket'; } - get name(): string { + get title(): string { return this.config.host; } } diff --git a/packages/integration/src/github/GitHubIntegration.test.ts b/packages/integration/src/github/GitHubIntegration.test.ts index 5a8800200f..3383c9ebe7 100644 --- a/packages/integration/src/github/GitHubIntegration.test.ts +++ b/packages/integration/src/github/GitHubIntegration.test.ts @@ -45,6 +45,6 @@ describe('GitHubIntegration', () => { it('returns the basics', () => { const integration = new GitHubIntegration({ host: 'h.com' } as any); expect(integration.type).toBe('github'); - expect(integration.name).toBe('h.com'); + expect(integration.title).toBe('h.com'); }); }); diff --git a/packages/integration/src/github/GitHubIntegration.ts b/packages/integration/src/github/GitHubIntegration.ts index f70684975d..92c5951873 100644 --- a/packages/integration/src/github/GitHubIntegration.ts +++ b/packages/integration/src/github/GitHubIntegration.ts @@ -37,7 +37,7 @@ export class GitHubIntegration implements ScmIntegration { return 'github'; } - get name(): string { + get title(): string { return this.config.host; } } diff --git a/packages/integration/src/gitlab/GitLabIntegration.test.ts b/packages/integration/src/gitlab/GitLabIntegration.test.ts index 5c46610c53..4a23f55816 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.test.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.test.ts @@ -43,6 +43,6 @@ describe('GitLabIntegration', () => { it('returns the basics', () => { const integration = new GitLabIntegration({ host: 'h.com' } as any); expect(integration.type).toBe('gitlab'); - expect(integration.name).toBe('h.com'); + expect(integration.title).toBe('h.com'); }); }); diff --git a/packages/integration/src/gitlab/GitLabIntegration.ts b/packages/integration/src/gitlab/GitLabIntegration.ts index 6e94e084cc..4d035cb24e 100644 --- a/packages/integration/src/gitlab/GitLabIntegration.ts +++ b/packages/integration/src/gitlab/GitLabIntegration.ts @@ -37,7 +37,7 @@ export class GitLabIntegration implements ScmIntegration { return 'gitlab'; } - get name(): string { + get title(): string { return this.config.host; } } diff --git a/packages/integration/src/index.ts b/packages/integration/src/index.ts index 9d42914fdb..fdcd7da676 100644 --- a/packages/integration/src/index.ts +++ b/packages/integration/src/index.ts @@ -18,4 +18,5 @@ export * from './azure'; export * from './bitbucket'; export * from './github'; export * from './gitlab'; -export type { ScmIntegration, ScmIntegrations } from './types'; +export { ScmIntegrations } from './ScmIntegrations'; +export type { ScmIntegration, ScmIntegrationRegistry } from './types'; diff --git a/packages/integration/src/types.ts b/packages/integration/src/types.ts index 6f9290a948..ae9c360980 100644 --- a/packages/integration/src/types.ts +++ b/packages/integration/src/types.ts @@ -20,30 +20,31 @@ import { Config } from '@backstage/config'; * Encapsulates a single SCM integration. */ export type ScmIntegration = { + /** + * The type of integration, e.g. "github". + */ type: string; - name: string; + + /** + * A human readable title for the integration, that can be shown to users to + * differentiate between different integrations. + */ + title: string; }; /** * Holds all registered SCM integrations. */ -export type ScmIntegrations = { +export type ScmIntegrationRegistry = { /** * Lists all registered integrations. */ list(): ScmIntegration[]; /** - * Fetches a named integration + * Fetches an integration by URL. * - * @param name The name of a registered integration - */ - byName(name: string): ScmIntegration | undefined; - - /** - * Fetches an integration by URL - * - * @param name A URL that matches a registered integration + * @param url A URL that matches a registered integration */ byUrl(url: string): ScmIntegration | undefined; }; From fd4a2cc5cb6baa85d796ab6567af134bc35ff90d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Dec 2020 09:12:50 +0100 Subject: [PATCH 30/42] build(deps-dev): bump js-yaml from 3.14.0 to 3.14.1 in /microsite (#3610) Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 3.14.0 to 3.14.1. - [Release notes](https://github.com/nodeca/js-yaml/releases) - [Changelog](https://github.com/nodeca/js-yaml/blob/3.14.1/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/3.14.0...3.14.1) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- microsite/package.json | 2 +- microsite/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/microsite/package.json b/microsite/package.json index b0390e8c20..e1860b8314 100644 --- a/microsite/package.json +++ b/microsite/package.json @@ -16,7 +16,7 @@ "devDependencies": { "@spotify/prettier-config": "^9.0.0", "docusaurus": "^2.0.0-alpha.378053ac5", - "js-yaml": "^3.14.0", + "js-yaml": "^3.14.1", "prettier": "^2.2.1" }, "prettier": "@spotify/prettier-config" diff --git a/microsite/yarn.lock b/microsite/yarn.lock index 02e7be47ef..e41db2a665 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -3921,10 +3921,10 @@ js-tokens@^3.0.2: resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= -js-yaml@^3.13.1, js-yaml@^3.14.0, js-yaml@^3.8.1: - version "3.14.0" - resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482" - integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A== +js-yaml@^3.13.1, js-yaml@^3.14.1, js-yaml@^3.8.1: + version "3.14.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" + integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== dependencies: argparse "^1.0.7" esprima "^4.0.0" From 6c1aa706853f95b5ef7cbeb52ece1c8b12a7b18c Mon Sep 17 00:00:00 2001 From: David Tuite Date: Tue, 8 Dec 2020 08:19:08 +0000 Subject: [PATCH 31/42] Improve SEO of microsite title (#3608) Many people will be searching for "service catalog" and "developer platform" when they are looking for something like Backstage. They may not have ever heard of Backstage before so they will not know to search for it. A title which includes the keywords "Service Catalog" and "developer platform" will rank better for those queries. This increases the change of Backstage being found by people who could make good use of it. [Standard advice](https://moz.com/learn/seo/title-tag) is to keep page titles under 60 chars. The proposed title is 49 chars. --- microsite/siteConfig.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/microsite/siteConfig.js b/microsite/siteConfig.js index 517cac2496..3e246172f0 100644 --- a/microsite/siteConfig.js +++ b/microsite/siteConfig.js @@ -12,7 +12,7 @@ const users = []; const siteConfig = { - title: 'Backstage', // Title for your website. + title: 'Backstage Service Catalog and Developer Platform', // Title for your website. tagline: 'An open platform for building developer portals', url: 'https://backstage.io', // Your website URL cname: 'backstage.io', From 9a78b2f09c551ed2723ba5cfb3291e9803730b99 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Dec 2020 09:20:01 +0100 Subject: [PATCH 32/42] build(deps): bump eslint-plugin-react-hooks from 4.0.4 to 4.2.0 (#3611) Bumps [eslint-plugin-react-hooks](https://github.com/facebook/react/tree/HEAD/packages/eslint-plugin-react-hooks) from 4.0.4 to 4.2.0. - [Release notes](https://github.com/facebook/react/releases) - [Changelog](https://github.com/facebook/react/blob/master/packages/eslint-plugin-react-hooks/CHANGELOG.md) - [Commits](https://github.com/facebook/react/commits/HEAD/packages/eslint-plugin-react-hooks) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index fc53712aae..d39c3292f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11342,9 +11342,9 @@ eslint-plugin-notice@^0.9.10: metric-lcs "^0.1.2" eslint-plugin-react-hooks@^4.0.0: - version "4.0.4" - resolved "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.0.4.tgz#aed33b4254a41b045818cacb047b81e6df27fa58" - integrity sha512-equAdEIsUETLFNCmmCkiCGq6rkSK5MoJhXFPFYeUebcjKgBmWWcgVOqZyQC8Bv1BwVCnTq9tBxgJFgAJTWoJtA== + version "4.2.0" + resolved "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.2.0.tgz#8c229c268d468956334c943bb45fc860280f5556" + integrity sha512-623WEiZJqxR7VdxFCKLI6d6LLpwJkGPYKODnkH3D7WpOG5KM8yWueBd8TLsNAetEJNF5iJmolaAKO3F8yzyVBQ== eslint-plugin-react@^7.12.4: version "7.21.5" From 1e50e1f853a1c1dbf65a98c5ff579ae984a2cd82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Ca=C5=82us?= Date: Tue, 8 Dec 2020 12:33:44 +0100 Subject: [PATCH 33/42] Add argo-cd to plugin marketplace (#3604) * Add argo-cd to plugin marketplace * Correct the Argo CD title * Add tags to Argo CD plugin description --- microsite/data/plugins/argo-cd.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 microsite/data/plugins/argo-cd.yaml diff --git a/microsite/data/plugins/argo-cd.yaml b/microsite/data/plugins/argo-cd.yaml new file mode 100644 index 0000000000..a47fa08d82 --- /dev/null +++ b/microsite/data/plugins/argo-cd.yaml @@ -0,0 +1,12 @@ +--- +title: Argo CD +author: roadie.io +authorUrl: https://roadie.io +category: CI +description: View Argo CD status for your projects in Backstage. +documentation: https://roadie.io/backstage/plugins/argo-cd +iconUrl: https://roadie.io/images/logos/argo.png +npmPackageName: '@roadiehq/backstage-plugin-argo-cd' +tags: + - cd + - ci From 90458fed68cb06a2a65e7ba55c95bbe33dbf83f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 8 Dec 2020 13:06:17 +0100 Subject: [PATCH 34/42] cost-insights: fix react-hooks/exhaustive-deps error --- .changeset/friendly-news-know.md | 5 +++++ .../components/ProductInsightsCard/ProductInsightsChart.tsx | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .changeset/friendly-news-know.md diff --git a/.changeset/friendly-news-know.md b/.changeset/friendly-news-know.md new file mode 100644 index 0000000000..521ac5cc28 --- /dev/null +++ b/.changeset/friendly-news-know.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cost-insights': patch +--- + +fix react-hooks/exhaustive-deps error diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx index 08a4c5efa2..ace2766277 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx @@ -70,8 +70,10 @@ export const ProductInsightsChart = ({ const layoutClasses = useLayoutStyles(); // Only a single entities Record for the root product entity is supported - const entityLabel = assertAlways(findAnyKey(entity.entities)); - const entities = entity.entities[entityLabel] ?? []; + const entities = useMemo(() => { + const entityLabel = assertAlways(findAnyKey(entity.entities)); + return entity.entities[entityLabel] ?? []; + }, [entity]); const [activeLabel, setActive] = useState>(); const [selectLabel, setSelected] = useState>(); From e3885c0d4ba8cf26eeb5bab1d497bc1eb8eb5128 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Dec 2020 14:22:57 +0100 Subject: [PATCH 35/42] docs/FAQ: add keeping up to date and dynamic plugin installation Qs (#3615) --- docs/FAQ.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/FAQ.md b/docs/FAQ.md index 9c11b015ea..90f642e591 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -117,6 +117,41 @@ through the proxy. Learn more about [the different components](overview/what-is-backstage.md) that make up Backstage. +### How do I keep my Backstage app up to date? + +In many ways one can view Backstage as a library rather than an application or +service. The `@backstage/create-app` tool that is used to create your own +Backstage app is similar to +[`create-react-app`](https://github.com/facebook/create-react-app) in that it +gives you a starting point. The code you get is meant to be evolved, and most of +the functionality you get out of the box is brought in via NPM dependencies. +Keeping your app up to date generally means keeping your dependencies up to +date. The Backstage CLI provides a command to help you with that. Simply run +`yarn backstage-cli versions:bump` at the root of your repo, and the latest +versions of all Backstage packages will be installed. + +While staying up to date with new releases and changes will keep your app up to +date, it can often be convenient to use the changes done to the +`@backstage/create-app` template as another method to stay up to date. For that +purpose, any changes done to the template are documented along with upgrade +instructions in the +[changelog](https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md) +of the `@backstage/create-app` package. + +### Why can't I dynamically install plugins without modifications the app? + +This decision is part of the core architecture and development flow of +Backstage. Plugins have a lot of freedom in what they provide and how they are +integrated into the app, and it would therefore add a lot of complexity to allow +plugins to be integrated via configuration the same way as they can be +integrated with code. + +By bundling all plugins and their dependencies into one app bundle it is also +possible to do significant optimizations to the app load time by allowing +plugins to share dependencies between each other when possible. This contributes +to Backstage being fast, which is an important part of the user and developer +experience. + ### Do I have to write plugins in TypeScript? No, you can use JavaScript if you prefer. We want to keep the Backstage core From 04f26f88d0eda8c31f123a0219cd1d87d7ab69ee Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Tue, 8 Dec 2020 14:28:21 +0100 Subject: [PATCH 36/42] Export the defaultConfigLoader (#3603) --- .changeset/tall-hairs-switch.md | 5 +++++ packages/core/src/api-wrappers/index.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/tall-hairs-switch.md diff --git a/.changeset/tall-hairs-switch.md b/.changeset/tall-hairs-switch.md new file mode 100644 index 0000000000..2a2f915a6c --- /dev/null +++ b/.changeset/tall-hairs-switch.md @@ -0,0 +1,5 @@ +--- +'@backstage/core': patch +--- + +Export the `defaultConfigLoader` implementation diff --git a/packages/core/src/api-wrappers/index.ts b/packages/core/src/api-wrappers/index.ts index b8136305b0..42c423d868 100644 --- a/packages/core/src/api-wrappers/index.ts +++ b/packages/core/src/api-wrappers/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { createApp } from './createApp'; +export { createApp, defaultConfigLoader } from './createApp'; From e2c3f2864f20fe09c4259583c0f6557ad80c8320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20=C3=96stberg?= Date: Tue, 8 Dec 2020 14:43:48 +0100 Subject: [PATCH 37/42] Update ADOPTERS.md (#3612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update ADOPTERS.md * Update vocab.txt * Ran prettier Co-authored-by: Stefan Ålund --- .github/styles/vocab.txt | 1 + ADOPTERS.md | 31 ++++++++++++++++--------------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 7f889392ac..35251ef502 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -198,6 +198,7 @@ talkdesk Talkdesk tasklist techdocs +Telenor templated templater Templater diff --git a/ADOPTERS.md b/ADOPTERS.md index 0ba3e3196b..b6455e09b0 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -1,15 +1,16 @@ -| Organization | Contact | Description of Use | -| -------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | -| [Spotify](https://www.spotify.com) | [@stefanalund](https://github.com/stefanalund) | Main interface towards all of Spotify's infrastructure and technical documentation. | -| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | -| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | -| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | -| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | -| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | -| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | -| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | -| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | -| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | -| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | -| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling | -| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling | +| Organization | Contact | Description of Use | +| -------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | +| [Spotify](https://www.spotify.com) | [@stefanalund](https://github.com/stefanalund) | Main interface towards all of Spotify's infrastructure and technical documentation. | +| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. | +| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. | +| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up | +| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. | +| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. | +| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. | +| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications | +| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. | +| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. | +| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D | +| [Wealthsimple](https://www.wealthsimple.com) | [@andrewthauer](https://github.com/andrewthauer) | Developer portal, service catalog, documentation and tooling | +| [Grab](https://www.grab.com) | [@althafh](https://github.com/althafh) | Initial work as a unified interface for all of Grab's internal tooling | +| [Telenor Sweden](https://www.telenor.se) | [@O5ten](https://github.com/O5ten) | Building a developer portal for scaffolding projects towards our unified build environment and microservice stacks | From e708679d7706829a56d4d1e68ce442691ef2c876 Mon Sep 17 00:00:00 2001 From: Govindarajan Nagarajan Date: Tue, 8 Dec 2020 14:13:09 +0100 Subject: [PATCH 38/42] `refreshAllLocations` uses a child logger with meta. Refs #3602 When there are a large number of location, `refreshAllLocations` can emit a lot of logs that are noisy and can make debugging hard. To solve this problem, create a child logger with a meta : `component` = `refreshAllLocations`. That can be used to filter out logs if needed --- .changeset/flat-flowers-learn.md | 5 +++++ .../src/ingestion/HigherOrderOperations.ts | 22 ++++++++++++------- 2 files changed, 19 insertions(+), 8 deletions(-) create mode 100644 .changeset/flat-flowers-learn.md diff --git a/.changeset/flat-flowers-learn.md b/.changeset/flat-flowers-learn.md new file mode 100644 index 0000000000..cf93da57cf --- /dev/null +++ b/.changeset/flat-flowers-learn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +refreshAllLocations uses a child logger of the HigherOrderOperation with a meta `component` : `catalog-all-locations-refresh` diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts index ca19a1d1c5..565c3671ee 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts @@ -116,28 +116,34 @@ export class HigherOrderOperations implements HigherOrderOperation { */ async refreshAllLocations(): Promise { const startTimestamp = process.hrtime(); - this.logger.info('Beginning locations refresh'); + const logger = this.logger.child({ + component: 'catalog-all-locations-refresh', + }); + + logger.info('Locations Refresh: Beginning locations refresh'); const locations = await this.locationsCatalog.locations(); - this.logger.info(`Visiting ${locations.length} locations`); + logger.info(`Locations Refresh: Visiting ${locations.length} locations`); for (const { data: location } of locations) { - this.logger.info( - `Refreshing location ${location.type}:${location.target}`, + logger.info( + `Locations Refresh: Refreshing location ${location.type}:${location.target}`, ); try { await this.refreshSingleLocation(location); await this.locationsCatalog.logUpdateSuccess(location.id, undefined); } catch (e) { - this.logger.warn( - `Failed to refresh location ${location.type}:${location.target}, ${e.stack}`, + logger.warn( + `Locations Refresh: Failed to refresh location ${location.type}:${location.target}, ${e.stack}`, ); await this.locationsCatalog.logUpdateFailure(location.id, e); } } - this.logger.info( - `Completed locations refresh in ${durationText(startTimestamp)}`, + logger.info( + `Locations Refresh: Completed locations refresh in ${durationText( + startTimestamp, + )}`, ); } From 1f0f4ed3bcd1983819448f0918039dc6473fdbc2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Dec 2020 16:26:53 +0100 Subject: [PATCH 39/42] Update Footer.js --- microsite/core/Footer.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/microsite/core/Footer.js b/microsite/core/Footer.js index 4a5bca6071..b97d1a5253 100644 --- a/microsite/core/Footer.js +++ b/microsite/core/Footer.js @@ -1,6 +1,4 @@ /* -* Made with <3 at Spotify - * Copyright 2020 Backstage Project Authors. * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -88,6 +86,9 @@ class Footer extends React.Component { )}
+

+ Made with ❤️  at Spotify +

{this.props.config.copyright}

); From cd4eba4afc976f7317a234088ac36f5283c645d6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Dec 2020 16:30:42 +0100 Subject: [PATCH 40/42] Update Footer.js - formatting fix --- microsite/core/Footer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/microsite/core/Footer.js b/microsite/core/Footer.js index b97d1a5253..d2d0f525ee 100644 --- a/microsite/core/Footer.js +++ b/microsite/core/Footer.js @@ -86,7 +86,7 @@ class Footer extends React.Component { )} -

+

Made with ❤️  at Spotify

{this.props.config.copyright}

From 447c9d0a842e08031169f25512cf58d23ef490e5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Dec 2020 16:34:04 +0100 Subject: [PATCH 41/42] Update Footer.js - another formatting fix --- microsite/core/Footer.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/microsite/core/Footer.js b/microsite/core/Footer.js index d2d0f525ee..1471d7b4d1 100644 --- a/microsite/core/Footer.js +++ b/microsite/core/Footer.js @@ -54,14 +54,12 @@ class Footer extends React.Component { Open Source @ {this.props.config.organizationName} - + Spotify Engineering Blog - - Spotify for Developers - - + Spotify for Developers + GitHub Date: Tue, 8 Dec 2020 12:15:12 -0500 Subject: [PATCH 42/42] Add test for quarter end date function --- .../cost-insights/src/utils/duration.test.ts | 17 ++++++++++++++++- plugins/cost-insights/src/utils/duration.ts | 14 ++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/plugins/cost-insights/src/utils/duration.test.ts b/plugins/cost-insights/src/utils/duration.test.ts index 45d8e5b6a3..47769a45f1 100644 --- a/plugins/cost-insights/src/utils/duration.test.ts +++ b/plugins/cost-insights/src/utils/duration.test.ts @@ -15,7 +15,11 @@ */ import { Duration } from '../types'; -import { inclusiveEndDateOf, inclusiveStartDateOf } from './duration'; +import { + inclusiveEndDateOf, + inclusiveStartDateOf, + quarterEndDate, +} from './duration'; const lastCompleteBillingDate = '2020-06-05'; @@ -32,3 +36,14 @@ describe.each` expect(inclusiveEndDateOf(duration, lastCompleteBillingDate)).toBe(endDate); }); }); + +describe.each` + inclusiveEndDate | expectedQuarterEndDate + ${'2020-12-31'} | ${'2020-12-31'} + ${'2020-12-30'} | ${'2020-09-30'} + ${'2021-02-19'} | ${'2020-12-31'} +`('quarterEndDate', ({ inclusiveEndDate, expectedQuarterEndDate }) => { + it(`calculates quarter end date correctly from inclusive end date ${inclusiveEndDate}`, () => { + expect(quarterEndDate(inclusiveEndDate)).toBe(expectedQuarterEndDate); + }); +}); diff --git a/plugins/cost-insights/src/utils/duration.ts b/plugins/cost-insights/src/utils/duration.ts index 0d9d6aa179..7a330b6f91 100644 --- a/plugins/cost-insights/src/utils/duration.ts +++ b/plugins/cost-insights/src/utils/duration.ts @@ -60,7 +60,10 @@ export function exclusiveEndDateOf( .add(1, 'day') .format(DEFAULT_DATE_FORMAT); case Duration.P3M: - return quarterEndDate(inclusiveEndDate); + return moment(quarterEndDate(inclusiveEndDate)) + .utc() + .add(1, 'day') + .format(DEFAULT_DATE_FORMAT); default: return assertNever(duration); } @@ -81,11 +84,14 @@ export function intervalsOf(duration: Duration, inclusiveEndDate: string) { return `R2/${duration}/${exclusiveEndDateOf(duration, inclusiveEndDate)}`; } -function quarterEndDate(inclusiveEndDate: string): string { +export function quarterEndDate(inclusiveEndDate: string): string { const endDate = moment(inclusiveEndDate).utc(); const endOfQuarter = endDate.endOf('quarter').format(DEFAULT_DATE_FORMAT); if (endOfQuarter === inclusiveEndDate) { - return endDate.add(1, 'day').format(DEFAULT_DATE_FORMAT); + return endDate.format(DEFAULT_DATE_FORMAT); } - return endDate.startOf('quarter').format(DEFAULT_DATE_FORMAT); + return endDate + .startOf('quarter') + .subtract(1, 'day') + .format(DEFAULT_DATE_FORMAT); }