From 26ad3398a876d7f683a0855001262359d1d92ce5 Mon Sep 17 00:00:00 2001 From: Kiss Miklos Date: Thu, 19 Jan 2023 10:26:22 +0100 Subject: [PATCH 001/162] add pattern matching Signed-off-by: Kiss Miklos --- .../src/modules/codeowners/CodeOwnersProcessor.ts | 4 +++- plugins/catalog-backend/src/modules/codeowners/lib/read.ts | 3 ++- plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.ts b/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.ts index 186b732271..f50ef6b922 100644 --- a/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.ts +++ b/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.ts @@ -79,11 +79,13 @@ export class CodeOwnersProcessor implements CatalogProcessor { if (!scmIntegration) { return entity; } - + const pattern = + entity.metadata?.annotations['backstage.io/managed-by-location']; const owner = await findCodeOwnerByTarget( this.reader, location.target, scmIntegration, + pattern, ); if (!owner) { diff --git a/plugins/catalog-backend/src/modules/codeowners/lib/read.ts b/plugins/catalog-backend/src/modules/codeowners/lib/read.ts index 2987e162cc..eae4e7ce91 100644 --- a/plugins/catalog-backend/src/modules/codeowners/lib/read.ts +++ b/plugins/catalog-backend/src/modules/codeowners/lib/read.ts @@ -52,6 +52,7 @@ export async function findCodeOwnerByTarget( reader: UrlReader, targetUrl: string, scmIntegration: ScmIntegration, + pattern: string, ): Promise { const codeownersPaths = scmCodeOwnersPaths[scmIntegration?.type ?? '']; @@ -70,7 +71,7 @@ export async function findCodeOwnerByTarget( return undefined; } - const owner = resolveCodeOwner(contents); + const owner = resolveCodeOwner(contents, pattern); return owner; } diff --git a/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts b/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts index 00645f0c9d..f9bc19b1d1 100644 --- a/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts +++ b/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts @@ -29,7 +29,7 @@ export function resolveCodeOwner( const owners = codeowners.parse(contents); return pipe( - filter((e: CodeOwnersEntry) => e.pattern === pattern), + filter((e: CodeOwnersEntry) => pattern.includes(e.pattern)), reverse, head, get('owners'), From 54cffaf07c5cee318eaaea9011171891fb21feef Mon Sep 17 00:00:00 2001 From: Kiss Miklos Date: Mon, 6 Feb 2023 17:53:57 +0100 Subject: [PATCH 002/162] add pattern match Signed-off-by: Kiss Miklos --- .../codeowners/CodeOwnersProcessor.test.ts | 27 ++++++++++++++++--- .../modules/codeowners/CodeOwnersProcessor.ts | 4 ++- .../src/modules/codeowners/lib/read.ts | 2 +- .../modules/codeowners/lib/resolve.test.ts | 10 ++++++- .../src/modules/codeowners/lib/resolve.ts | 18 +++++++++---- 5 files changed, 50 insertions(+), 11 deletions(-) diff --git a/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.test.ts b/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.test.ts index 9cb4d414c0..a33ce84e0d 100644 --- a/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.test.ts +++ b/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.test.ts @@ -21,7 +21,7 @@ import { LocationSpec } from '@backstage/plugin-catalog-node'; const mockCodeOwnersText = () => ` * @acme/team-foo @acme/team-bar -/docs @acme/team-bar +/docs @acme/team-docs `; describe('CodeOwnersProcessor', () => { @@ -34,8 +34,12 @@ describe('CodeOwnersProcessor', () => { }); describe('preProcessEntity', () => { - const setupTest = ({ kind = 'Component', spec = {} } = {}) => { - const entity = { kind, spec }; + const setupTest = ({ + kind = 'Component', + spec = {}, + metadata = {}, + } = {}) => { + const entity = { kind, spec, metadata }; const config = new ConfigReader({}); const reader = { read: jest.fn(), @@ -101,5 +105,22 @@ describe('CodeOwnersProcessor', () => { spec: { owner: 'team-foo' }, }); }); + it('should use the "backstage.io/managed-by-location" annotation as pattern', async () => { + const { entity, processor } = setupTest({ + metadata: { + annotations: { 'backstage.io/managed-by-location': '/docs' }, + }, + }); + + const result = await processor.preProcessEntity( + entity as any, + mockLocation(), + ); + + expect(result).toEqual({ + ...entity, + spec: { owner: 'team-docs' }, + }); + }); }); }); diff --git a/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.ts b/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.ts index f50ef6b922..9a9c58934c 100644 --- a/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.ts +++ b/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.ts @@ -79,8 +79,10 @@ export class CodeOwnersProcessor implements CatalogProcessor { if (!scmIntegration) { return entity; } + const pattern = - entity.metadata?.annotations['backstage.io/managed-by-location']; + entity.metadata?.annotations?.['backstage.io/managed-by-location']; + const owner = await findCodeOwnerByTarget( this.reader, location.target, diff --git a/plugins/catalog-backend/src/modules/codeowners/lib/read.ts b/plugins/catalog-backend/src/modules/codeowners/lib/read.ts index eae4e7ce91..cd15a17997 100644 --- a/plugins/catalog-backend/src/modules/codeowners/lib/read.ts +++ b/plugins/catalog-backend/src/modules/codeowners/lib/read.ts @@ -52,7 +52,7 @@ export async function findCodeOwnerByTarget( reader: UrlReader, targetUrl: string, scmIntegration: ScmIntegration, - pattern: string, + pattern?: string, ): Promise { const codeownersPaths = scmCodeOwnersPaths[scmIntegration?.type ?? '']; diff --git a/plugins/catalog-backend/src/modules/codeowners/lib/resolve.test.ts b/plugins/catalog-backend/src/modules/codeowners/lib/resolve.test.ts index 4e6c646073..ec9321284f 100644 --- a/plugins/catalog-backend/src/modules/codeowners/lib/resolve.test.ts +++ b/plugins/catalog-backend/src/modules/codeowners/lib/resolve.test.ts @@ -18,13 +18,21 @@ import { normalizeCodeOwner, resolveCodeOwner } from './resolve'; const mockCodeOwnersText = () => ` * @acme/team-foo @acme/team-bar -/docs @acme/team-bar +/docs @acme/team-docs `; describe('resolveCodeOwner', () => { it('should parse the codeowners file', () => { expect(resolveCodeOwner(mockCodeOwnersText())).toBe('team-foo'); }); + it('should include the codeowners path into the provided pattern', () => { + expect( + resolveCodeOwner( + mockCodeOwnersText(), + 'url:https://github.com/acem/repo/main/docs/catalog-info.yaml', + ), + ).toBe('team-docs'); + }); }); describe('normalizeCodeOwner', () => { diff --git a/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts b/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts index f9bc19b1d1..69865fde21 100644 --- a/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts +++ b/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts @@ -16,7 +16,7 @@ import * as codeowners from 'codeowners-utils'; import { CodeOwnersEntry } from 'codeowners-utils'; -import { filter, get, head, pipe, reverse } from 'lodash/fp'; +import { get, head, pipe, reverse } from 'lodash/fp'; const USER_PATTERN = /^@.*/; const GROUP_PATTERN = /^@.*\/.*/; @@ -24,18 +24,26 @@ const EMAIL_PATTERN = /^.*@.*\..*$/; export function resolveCodeOwner( contents: string, - pattern = '*', + pattern?: string, ): string | undefined { const owners = codeowners.parse(contents); - return pipe( - filter((e: CodeOwnersEntry) => pattern.includes(e.pattern)), + let relevantOwners = owners.filter((e: CodeOwnersEntry) => + pattern?.includes(e.pattern), + ); + if (relevantOwners.length === 0) { + relevantOwners = owners.filter((e: CodeOwnersEntry) => e.pattern === '*'); + } + + const result = pipe( reverse, head, get('owners'), head, normalizeCodeOwner, - )(owners); + )(relevantOwners); + + return result; } export function normalizeCodeOwner(owner: string) { From 92a4590fc3ab0ee02ae8a0ea93d8dbf4aa6411b2 Mon Sep 17 00:00:00 2001 From: Kiss Miklos Date: Mon, 6 Feb 2023 18:17:50 +0100 Subject: [PATCH 003/162] add changeset Signed-off-by: Kiss Miklos --- .changeset/spicy-carrots-argue.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/spicy-carrots-argue.md diff --git a/.changeset/spicy-carrots-argue.md b/.changeset/spicy-carrots-argue.md new file mode 100644 index 0000000000..559335a646 --- /dev/null +++ b/.changeset/spicy-carrots-argue.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': minor +--- + +Add support to CodeOwnersProcces From f09e37af60f42b24b919585d47199a7785c9d89e Mon Sep 17 00:00:00 2001 From: Kiss Miklos Date: Mon, 6 Feb 2023 18:18:25 +0100 Subject: [PATCH 004/162] add more info to changeset Signed-off-by: Kiss Miklos --- .changeset/spicy-carrots-argue.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/spicy-carrots-argue.md b/.changeset/spicy-carrots-argue.md index 559335a646..b6475b1f7d 100644 --- a/.changeset/spicy-carrots-argue.md +++ b/.changeset/spicy-carrots-argue.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog-backend': minor --- -Add support to CodeOwnersProcces +Add monorepo support to CodeOwnersProccesor. From 76df7bd736dc79b42cf7bdff759904c34c188d4b Mon Sep 17 00:00:00 2001 From: Kiss Miklos Date: Mon, 6 Feb 2023 18:19:54 +0100 Subject: [PATCH 005/162] use more descriptive name Signed-off-by: Kiss Miklos --- .../catalog-backend/src/modules/codeowners/lib/resolve.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts b/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts index 69865fde21..139ea881d8 100644 --- a/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts +++ b/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts @@ -28,11 +28,11 @@ export function resolveCodeOwner( ): string | undefined { const owners = codeowners.parse(contents); - let relevantOwners = owners.filter((e: CodeOwnersEntry) => + let filteredOwners = owners.filter((e: CodeOwnersEntry) => pattern?.includes(e.pattern), ); - if (relevantOwners.length === 0) { - relevantOwners = owners.filter((e: CodeOwnersEntry) => e.pattern === '*'); + if (filteredOwners.length === 0) { + filteredOwners = owners.filter((e: CodeOwnersEntry) => e.pattern === '*'); } const result = pipe( @@ -41,7 +41,7 @@ export function resolveCodeOwner( get('owners'), head, normalizeCodeOwner, - )(relevantOwners); + )(filteredOwners); return result; } From c85b7360161c31487b18ddfad73ae8247a566aeb Mon Sep 17 00:00:00 2001 From: Kiss Miklos Date: Mon, 6 Mar 2023 16:27:48 +0100 Subject: [PATCH 006/162] use more robust matching for CODEOWNERS entries Signed-off-by: Kiss Miklos --- .../codeowners/CodeOwnersProcessor.test.ts | 8 ++--- .../modules/codeowners/CodeOwnersProcessor.ts | 4 --- .../src/modules/codeowners/lib/read.ts | 3 +- .../modules/codeowners/lib/resolve.test.ts | 17 +++++++++-- .../src/modules/codeowners/lib/resolve.ts | 29 +++++++------------ 5 files changed, 29 insertions(+), 32 deletions(-) diff --git a/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.test.ts b/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.test.ts index a33ce84e0d..8e1fbca726 100644 --- a/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.test.ts +++ b/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.test.ts @@ -106,15 +106,11 @@ describe('CodeOwnersProcessor', () => { }); }); it('should use the "backstage.io/managed-by-location" annotation as pattern', async () => { - const { entity, processor } = setupTest({ - metadata: { - annotations: { 'backstage.io/managed-by-location': '/docs' }, - }, - }); + const { entity, processor } = setupTest(); const result = await processor.preProcessEntity( entity as any, - mockLocation(), + mockLocation({ basePath: 'docs/' }), ); expect(result).toEqual({ diff --git a/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.ts b/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.ts index 9a9c58934c..186b732271 100644 --- a/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.ts +++ b/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.ts @@ -80,14 +80,10 @@ export class CodeOwnersProcessor implements CatalogProcessor { return entity; } - const pattern = - entity.metadata?.annotations?.['backstage.io/managed-by-location']; - const owner = await findCodeOwnerByTarget( this.reader, location.target, scmIntegration, - pattern, ); if (!owner) { diff --git a/plugins/catalog-backend/src/modules/codeowners/lib/read.ts b/plugins/catalog-backend/src/modules/codeowners/lib/read.ts index cd15a17997..6a5234c94f 100644 --- a/plugins/catalog-backend/src/modules/codeowners/lib/read.ts +++ b/plugins/catalog-backend/src/modules/codeowners/lib/read.ts @@ -52,7 +52,6 @@ export async function findCodeOwnerByTarget( reader: UrlReader, targetUrl: string, scmIntegration: ScmIntegration, - pattern?: string, ): Promise { const codeownersPaths = scmCodeOwnersPaths[scmIntegration?.type ?? '']; @@ -71,7 +70,7 @@ export async function findCodeOwnerByTarget( return undefined; } - const owner = resolveCodeOwner(contents, pattern); + const owner = resolveCodeOwner(contents, targetUrl); return owner; } diff --git a/plugins/catalog-backend/src/modules/codeowners/lib/resolve.test.ts b/plugins/catalog-backend/src/modules/codeowners/lib/resolve.test.ts index ec9321284f..f9cdf79530 100644 --- a/plugins/catalog-backend/src/modules/codeowners/lib/resolve.test.ts +++ b/plugins/catalog-backend/src/modules/codeowners/lib/resolve.test.ts @@ -23,16 +23,29 @@ const mockCodeOwnersText = () => ` describe('resolveCodeOwner', () => { it('should parse the codeowners file', () => { - expect(resolveCodeOwner(mockCodeOwnersText())).toBe('team-foo'); + expect( + resolveCodeOwner( + mockCodeOwnersText(), + 'https://github.com/can/be/tree/anything/catalog-info.yaml', + ), + ).toBe('team-foo'); }); it('should include the codeowners path into the provided pattern', () => { expect( resolveCodeOwner( mockCodeOwnersText(), - 'url:https://github.com/acem/repo/main/docs/catalog-info.yaml', + 'https://github.com/acme/repo/tree/main/docs/catalog-info.yaml', ), ).toBe('team-docs'); }); + it('should match only in resource path', () => { + expect( + resolveCodeOwner( + mockCodeOwnersText(), + 'https://github.com/acme/repo/tree/docs/catalog-info.yaml', + ), + ).toBe('team-foo'); + }); }); describe('normalizeCodeOwner', () => { diff --git a/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts b/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts index 139ea881d8..92fa4a0ef6 100644 --- a/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts +++ b/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts @@ -15,8 +15,7 @@ */ import * as codeowners from 'codeowners-utils'; -import { CodeOwnersEntry } from 'codeowners-utils'; -import { get, head, pipe, reverse } from 'lodash/fp'; +import parseGitUrl from 'git-url-parse'; const USER_PATTERN = /^@.*/; const GROUP_PATTERN = /^@.*\/.*/; @@ -24,26 +23,20 @@ const EMAIL_PATTERN = /^.*@.*\..*$/; export function resolveCodeOwner( contents: string, - pattern?: string, + catalogInfoFileUrl: string, ): string | undefined { - const owners = codeowners.parse(contents); + const codeOwnerEntries = codeowners.parse(contents); - let filteredOwners = owners.filter((e: CodeOwnersEntry) => - pattern?.includes(e.pattern), - ); - if (filteredOwners.length === 0) { - filteredOwners = owners.filter((e: CodeOwnersEntry) => e.pattern === '*'); + const { filepath } = parseGitUrl(catalogInfoFileUrl); + const match = codeowners.matchFile(filepath, codeOwnerEntries); + + if (!match) { + throw new Error( + `There is no matching entry for path: ${filepath} in the CODEOWNERS file`, + ); } - const result = pipe( - reverse, - head, - get('owners'), - head, - normalizeCodeOwner, - )(filteredOwners); - - return result; + return normalizeCodeOwner(match.owners[0]); } export function normalizeCodeOwner(owner: string) { From 9d6c429801c2ae1bddad6ae1694b9a2caff01f9e Mon Sep 17 00:00:00 2001 From: Kiss Miklos Date: Mon, 6 Mar 2023 18:45:30 +0100 Subject: [PATCH 007/162] add glob and wildcard tests Signed-off-by: Kiss Miklos --- .../codeowners/CodeOwnersProcessor.test.ts | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.test.ts b/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.test.ts index 8e1fbca726..6c2774a6fb 100644 --- a/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.test.ts +++ b/plugins/catalog-backend/src/modules/codeowners/CodeOwnersProcessor.test.ts @@ -17,11 +17,13 @@ import { getVoidLogger } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; import { CodeOwnersProcessor } from './CodeOwnersProcessor'; -import { LocationSpec } from '@backstage/plugin-catalog-node'; +import { LocationSpec } from '@backstage/plugin-catalog-common'; const mockCodeOwnersText = () => ` -* @acme/team-foo @acme/team-bar -/docs @acme/team-docs +* @acme/team-foo @acme/team-bar +/docs @acme/team-docs +/plugins/catalog-* @backstage/maintainers @backstage/catalog-core +**/logs @logs-owner `; describe('CodeOwnersProcessor', () => { @@ -105,7 +107,7 @@ describe('CodeOwnersProcessor', () => { spec: { owner: 'team-foo' }, }); }); - it('should use the "backstage.io/managed-by-location" annotation as pattern', async () => { + it('should match owner based on the targetUrl', async () => { const { entity, processor } = setupTest(); const result = await processor.preProcessEntity( @@ -118,5 +120,31 @@ describe('CodeOwnersProcessor', () => { spec: { owner: 'team-docs' }, }); }); + it('should match wildcard pattern', async () => { + const { entity, processor } = setupTest(); + + const result = await processor.preProcessEntity( + entity as any, + mockLocation({ basePath: 'plugins/catalog-foo' }), + ); + + expect(result).toEqual({ + ...entity, + spec: { owner: 'maintainers' }, + }); + }); + it('should match glob pattern', async () => { + const { entity, processor } = setupTest(); + + const result = await processor.preProcessEntity( + entity as any, + mockLocation({ basePath: 'plugins/catalog-foo/logs/1.txt' }), + ); + + expect(result).toEqual({ + ...entity, + spec: { owner: 'User:logs-owner' }, + }); + }); }); }); From f235e4064fc5358faccafb51b73d5f685a99bc8a Mon Sep 17 00:00:00 2001 From: Kiss Miklos Date: Mon, 13 Mar 2023 10:18:46 +0100 Subject: [PATCH 008/162] return undefined if there is no match Signed-off-by: Kiss Miklos --- .../catalog-backend/src/modules/codeowners/lib/resolve.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts b/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts index 92fa4a0ef6..675f6542aa 100644 --- a/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts +++ b/plugins/catalog-backend/src/modules/codeowners/lib/resolve.ts @@ -30,13 +30,7 @@ export function resolveCodeOwner( const { filepath } = parseGitUrl(catalogInfoFileUrl); const match = codeowners.matchFile(filepath, codeOwnerEntries); - if (!match) { - throw new Error( - `There is no matching entry for path: ${filepath} in the CODEOWNERS file`, - ); - } - - return normalizeCodeOwner(match.owners[0]); + return match ? normalizeCodeOwner(match.owners[0]) : undefined; } export function normalizeCodeOwner(owner: string) { From 42452f005dccd3bf6530ac90d168c5dbf3d98df9 Mon Sep 17 00:00:00 2001 From: headphonejames Date: Thu, 23 Mar 2023 19:14:28 -0700 Subject: [PATCH 009/162] Enable selecting items in the autocomplete picker component when a values in the query string are already provided in the URL. Fixes https://github.com/backstage/backstage/issues/17021 Signed-off-by: headphonejames --- .../EntityAutocompletePicker/EntityAutocompletePicker.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx index b81d681e6d..20294fa7f1 100644 --- a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx @@ -28,7 +28,6 @@ import { useEntityList, } from '../../hooks/useEntityListProvider'; import { EntityFilter } from '../../types'; -import _ from 'lodash'; type KeysMatchingCondition = T extends V ? K : never; type KeysMatching = { @@ -97,13 +96,10 @@ export function EntityAutocompletePicker< // Set selected options on query parameter updates; this happens at initial page load and from // external updates to the page location useEffect(() => { - if ( - queryParameters.length && - !_.isEqual(selectedOptions, queryParameters) - ) { + if (queryParameters.length) { setSelectedOptions(queryParameters); } - }, [selectedOptions, queryParameters]); + }, [queryParameters]); const availableOptions = Object.keys(availableValues ?? {}); const shouldAddFilter = selectedOptions.length && availableOptions.length; From 333f34386d6111a1d3c8853c19e4cbd09e847c05 Mon Sep 17 00:00:00 2001 From: headphonejames Date: Thu, 23 Mar 2023 19:16:56 -0700 Subject: [PATCH 010/162] Remove lodash library Signed-off-by: headphonejames --- plugins/catalog-react/package.json | 1 - yarn.lock | 1 - 2 files changed, 2 deletions(-) diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 166da09118..60212ecd5e 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -62,7 +62,6 @@ "@material-ui/lab": "4.0.0-alpha.61", "classnames": "^2.2.6", "jwt-decode": "^3.1.0", - "lodash": "^4.17.21", "material-ui-popup-state": "^1.9.3", "qs": "^6.9.4", "react-use": "^17.2.4", diff --git a/yarn.lock b/yarn.lock index 4d2b33b122..fc8d9824a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5509,7 +5509,6 @@ __metadata: classnames: ^2.2.6 cross-fetch: ^3.1.5 jwt-decode: ^3.1.0 - lodash: ^4.17.21 material-ui-popup-state: ^1.9.3 qs: ^6.9.4 react-test-renderer: ^16.13.1 From 135b3d9a9c8226d021f7355b275f647fa1053e59 Mon Sep 17 00:00:00 2001 From: headphonejames Date: Fri, 24 Mar 2023 11:19:22 -0700 Subject: [PATCH 011/162] add test for removing tags after query string is provider Signed-off-by: headphonejames --- .../EntityTagPicker/EntityTagPicker.test.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx index c70217f3a4..bfaa689190 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx @@ -202,6 +202,34 @@ describe('', () => { tags: new EntityTagFilter(['tag2']), }); }); + + it('verify that user can select tags after query string has been set', async () => { + const updateFilters = jest.fn(); + render( + + + + + , + ); + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + tags: new EntityTagFilter(['tag1']), + }), + ); + fireEvent.click(screen.getByTestId('tags-picker-expand')); + fireEvent.click(screen.getByLabelText('tag2')); + expect(screen.getByLabelText('tag2')).toBeChecked(); + expect(updateFilters).toHaveBeenLastCalledWith({ + tags: new EntityTagFilter(['tag1', 'tag2']), + }); + }); + it('removes tags from filters if there are none available', async () => { const updateFilters = jest.fn(); const mockCatalogApiRefNoTags = { From d1f5324dff7089ad8325b2c3e8e720b920ff94eb Mon Sep 17 00:00:00 2001 From: headphonejames Date: Fri, 24 Mar 2023 11:29:53 -0700 Subject: [PATCH 012/162] add changeset Signed-off-by: headphonejames --- .changeset/smart-crabs-dream.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/smart-crabs-dream.md diff --git a/.changeset/smart-crabs-dream.md b/.changeset/smart-crabs-dream.md new file mode 100644 index 0000000000..a08747a6e3 --- /dev/null +++ b/.changeset/smart-crabs-dream.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': minor +--- + +Reverted the check if selectedOptions is different than queryParameters before invoking setSelectedOptions. This was preventing updating list items when a query string was already present in the URL when loading the page. From f95a7e2c5d4da5fa69f339da9088432e1c04b68c Mon Sep 17 00:00:00 2001 From: headphonejames Date: Fri, 24 Mar 2023 13:21:17 -0700 Subject: [PATCH 013/162] add changeset Signed-off-by: headphonejames --- .changeset/smart-crabs-dream.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/smart-crabs-dream.md b/.changeset/smart-crabs-dream.md index a08747a6e3..d93c865a23 100644 --- a/.changeset/smart-crabs-dream.md +++ b/.changeset/smart-crabs-dream.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog-react': minor --- -Reverted the check if selectedOptions is different than queryParameters before invoking setSelectedOptions. This was preventing updating list items when a query string was already present in the URL when loading the page. +Reverted the check if the selected options list is different than the query parameters list before invoking setSelectedOptions method. This was preventing updating list items when a query string was already present in the URL when loading the page. From af12902763ca2844b0bc85a5b908ad2944564755 Mon Sep 17 00:00:00 2001 From: headphonejames Date: Fri, 24 Mar 2023 13:55:55 -0700 Subject: [PATCH 014/162] return lodash library Signed-off-by: headphonejames --- plugins/catalog-react/package.json | 1 + yarn.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 60212ecd5e..166da09118 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -62,6 +62,7 @@ "@material-ui/lab": "4.0.0-alpha.61", "classnames": "^2.2.6", "jwt-decode": "^3.1.0", + "lodash": "^4.17.21", "material-ui-popup-state": "^1.9.3", "qs": "^6.9.4", "react-use": "^17.2.4", diff --git a/yarn.lock b/yarn.lock index fc8d9824a1..4d2b33b122 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5509,6 +5509,7 @@ __metadata: classnames: ^2.2.6 cross-fetch: ^3.1.5 jwt-decode: ^3.1.0 + lodash: ^4.17.21 material-ui-popup-state: ^1.9.3 qs: ^6.9.4 react-test-renderer: ^16.13.1 From e4badfd959a9e292505fe8437c17f83c4a5c0d78 Mon Sep 17 00:00:00 2001 From: headphonejames Date: Fri, 24 Mar 2023 14:57:43 -0700 Subject: [PATCH 015/162] re-execute checkin tests Signed-off-by: headphonejames --- .../src/components/EntityTagPicker/EntityTagPicker.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx index bfaa689190..5ee8c481e2 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx @@ -222,6 +222,7 @@ describe('', () => { tags: new EntityTagFilter(['tag1']), }), ); + fireEvent.click(screen.getByTestId('tags-picker-expand')); fireEvent.click(screen.getByLabelText('tag2')); expect(screen.getByLabelText('tag2')).toBeChecked(); From 2190368b52fe9f2b47987255f5c0d60c4764cd06 Mon Sep 17 00:00:00 2001 From: headphonejames Date: Sat, 25 Mar 2023 10:49:07 -0700 Subject: [PATCH 016/162] remove react effect for less potential re-rendering Signed-off-by: headphonejames --- .../EntityAutocompletePicker.tsx | 11 ++++++----- .../EntityTagPicker/EntityTagPicker.test.tsx | 1 - 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx index 20294fa7f1..00114a29fc 100644 --- a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx @@ -95,11 +95,12 @@ export function EntityAutocompletePicker< // Set selected options on query parameter updates; this happens at initial page load and from // external updates to the page location - useEffect(() => { - if (queryParameters.length) { - setSelectedOptions(queryParameters); - } - }, [queryParameters]); + const [prevQueryParameters, setQueryParameter] = useState(queryParameters); + // if the query parameter has changed, update the selected options + if (queryParameters !== prevQueryParameters) { + setSelectedOptions(queryParameters); + setQueryParameter(queryParameters); + } const availableOptions = Object.keys(availableValues ?? {}); const shouldAddFilter = selectedOptions.length && availableOptions.length; diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx index 5ee8c481e2..bfaa689190 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx @@ -222,7 +222,6 @@ describe('', () => { tags: new EntityTagFilter(['tag1']), }), ); - fireEvent.click(screen.getByTestId('tags-picker-expand')); fireEvent.click(screen.getByLabelText('tag2')); expect(screen.getByLabelText('tag2')).toBeChecked(); From 8f0bd33d7a9ac34481fc9bca952634c3a19fc92e Mon Sep 17 00:00:00 2001 From: Phred Date: Mon, 27 Mar 2023 11:59:38 -0500 Subject: [PATCH 017/162] added utility to remove backstage entity refs from entities Signed-off-by: Phred --- .../actions/builtin/helpers.test.ts | 21 ++++++++++++++++++- .../src/scaffolder/actions/builtin/helpers.ts | 4 ++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.test.ts index b5a375d83d..47f2c88b30 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.test.ts @@ -15,7 +15,12 @@ */ import { Git, getVoidLogger } from '@backstage/backend-common'; -import { commitAndPushRepo, initRepoAndPush } from './helpers'; +import { entityNamePickerValidation } from '@backstage/plugin-scaffolder/src/components/fields/EntityNamePicker'; +import { + commitAndPushRepo, + familiarizeEntityName, + initRepoAndPush, +} from './helpers'; jest.mock('@backstage/backend-common', () => ({ Git: { @@ -301,3 +306,17 @@ describe('commitAndPushRepo', () => { }); }); }); + +describe('familiarizeEntityName', () => { + it.each([ + 'user:default/catpants', + 'group:default/catpants', + 'user:catpants', + 'default/catpants', + 'user:custom/catpants', + 'group:custom/catpants', + 'catpants', + ])('should parse: "%s"', (entityName: string) => { + expect(familiarizeEntityName(entityName)).toEqual('catpants'); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts index 8bed279a1b..30b75d9e6f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts @@ -296,3 +296,7 @@ export function getGitCommitMessage( ? gitCommitMessage : config.getOptionalString('scaffolder.defaultCommitMessage'); } + +export function familiarizeEntityName(name: string): string { + return name.replace(/^.*[:/]/g, ''); +} From 57a49da161cfd17dad67c1aa17c7bd346aebc857 Mon Sep 17 00:00:00 2001 From: Phred Date: Mon, 27 Mar 2023 11:59:42 -0500 Subject: [PATCH 018/162] use helper to familiarize entities passed to GitHub Signed-off-by: Phred --- .../actions/builtin/github/helpers.ts | 5 +- .../actions/builtin/publish/github.test.ts | 61 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts index 75c8f25d8b..23f6b804b7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts @@ -29,6 +29,7 @@ import { initRepoAndPush, } from '../helpers'; import { getRepoSourceDirectory, parseRepoUrl } from '../publish/util'; +import { familiarizeEntityName } from '../../builtin/helpers'; const DEFAULT_TIMEOUT_MS = 60_000; @@ -218,13 +219,13 @@ export async function createGithubRepoWithCollaboratorsAndTopics( await client.rest.repos.addCollaborator({ owner, repo, - username: collaborator.user, + username: familiarizeEntityName(collaborator.user), permission: collaborator.access, }); } else if ('team' in collaborator) { await client.rest.teams.addOrUpdateRepoPermissionsInOrg({ org: owner, - team_slug: collaborator.team, + team_slug: familiarizeEntityName(collaborator.team), owner, repo, permission: collaborator.access, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts index 94b51f3e94..4bd8ee1652 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts @@ -29,6 +29,7 @@ import { when } from 'jest-when'; import { PassThrough } from 'stream'; import { enableBranchProtectionOnDefaultRepoBranch, + familiarizeEntityName, initRepoAndPush, } from '../helpers'; import { createPublishGithubAction } from './github'; @@ -68,6 +69,8 @@ describe('publish:github', () => { }, }); + const { familiarizeEntityName: realFamiliarizeEntityName } = + jest.requireActual('../helpers'); const integrations = ScmIntegrations.fromConfig(config); let githubCredentialsProvider: GithubCredentialsProvider; let action: TemplateAction; @@ -95,6 +98,11 @@ describe('publish:github', () => { config, githubCredentialsProvider, }); + + // restore real implmentation + (familiarizeEntityName as jest.MockedFunction).mockImplementation( + realFamiliarizeEntityName, + ); }); it('should fail to create if the team is not found in the org', async () => { @@ -651,6 +659,59 @@ describe('publish:github', () => { }); }); + it('should familiarize entity names while adding collaborators', async () => { + mockOctokit.rest.users.getByUsername.mockResolvedValue({ + data: { type: 'User' }, + }); + + mockOctokit.rest.repos.createForAuthenticatedUser.mockResolvedValue({ + data: { + clone_url: 'https://github.com/clone/url.git', + html_url: 'https://github.com/html/url', + }, + }); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + collaborators: [ + { + access: 'pull', + user: 'user:robot-1', + }, + { + access: 'push', + team: 'group:default/robot-2', + }, + ], + }, + }); + + const commonProperties = { + owner: 'owner', + repo: 'repo', + }; + + expect(mockOctokit.rest.repos.addCollaborator).toHaveBeenCalledWith({ + ...commonProperties, + username: 'robot-1', + permission: 'pull', + }); + + expect( + mockOctokit.rest.teams.addOrUpdateRepoPermissionsInOrg, + ).toHaveBeenCalledWith({ + ...commonProperties, + org: 'owner', + team_slug: 'robot-2', + permission: 'push', + }); + + expect(familiarizeEntityName).toHaveBeenCalledWith('user:robot-1'); + expect(familiarizeEntityName).toHaveBeenCalledWith('group:default/robot-2'); + }); + it('should ignore failures when adding multiple collaborators', async () => { mockOctokit.rest.users.getByUsername.mockResolvedValue({ data: { type: 'User' }, From f37a95adcd8a9a2ad8e5b599706aeba48973a796 Mon Sep 17 00:00:00 2001 From: Phred Date: Mon, 27 Mar 2023 11:59:47 -0500 Subject: [PATCH 019/162] added changeset Signed-off-by: Phred --- .changeset/serious-items-walk.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/serious-items-walk.md diff --git a/.changeset/serious-items-walk.md b/.changeset/serious-items-walk.md new file mode 100644 index 0000000000..6da6d91ac6 --- /dev/null +++ b/.changeset/serious-items-walk.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Removed entity types and namespacing before passing to GitHub API From abbcb55554623639a0425a36c18ba3ad88676db7 Mon Sep 17 00:00:00 2001 From: Phred Date: Mon, 27 Mar 2023 12:25:59 -0500 Subject: [PATCH 020/162] cleaened up tsc issues Signed-off-by: Phred --- .../src/scaffolder/actions/builtin/helpers.test.ts | 1 - .../src/scaffolder/actions/builtin/publish/github.test.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.test.ts index 47f2c88b30..8cde257d64 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.test.ts @@ -15,7 +15,6 @@ */ import { Git, getVoidLogger } from '@backstage/backend-common'; -import { entityNamePickerValidation } from '@backstage/plugin-scaffolder/src/components/fields/EntityNamePicker'; import { commitAndPushRepo, familiarizeEntityName, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts index 4bd8ee1652..82f6948fa8 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts @@ -100,7 +100,7 @@ describe('publish:github', () => { }); // restore real implmentation - (familiarizeEntityName as jest.MockedFunction).mockImplementation( + (familiarizeEntityName as jest.Mock).mockImplementation( realFamiliarizeEntityName, ); }); From 88627d957ea85a35b901e63dbe17be7c57f312dc Mon Sep 17 00:00:00 2001 From: Phred Date: Tue, 28 Mar 2023 08:07:50 -0500 Subject: [PATCH 021/162] mocked helper in github repo create tests Signed-off-by: Phred --- .../actions/builtin/github/githubRepoCreate.test.ts | 5 ++++- .../src/scaffolder/actions/builtin/publish/github.test.ts | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts index a9ef8c3daa..58747d57ef 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts @@ -28,6 +28,7 @@ import { import { when } from 'jest-when'; import { PassThrough } from 'stream'; import { createGithubRepoCreateAction } from './githubRepoCreate'; +import { familiarizeEntityName } from '../helpers'; const mockOctokit = { rest: { @@ -83,15 +84,17 @@ describe('github:repo:create', () => { }; beforeEach(() => { - jest.resetAllMocks(); githubCredentialsProvider = DefaultGithubCredentialsProvider.fromIntegrations(integrations); action = createGithubRepoCreateAction({ integrations, githubCredentialsProvider, }); + (familiarizeEntityName as jest.Mock).mockImplementation((s: string) => s); }); + afterEach(jest.resetAllMocks); + it('should call the githubApis with the correct values for createInOrg', async () => { mockOctokit.rest.users.getByUsername.mockResolvedValue({ data: { type: 'Organization' }, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts index 82f6948fa8..3347ca2e05 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts @@ -90,7 +90,6 @@ describe('publish:github', () => { }; beforeEach(() => { - jest.resetAllMocks(); githubCredentialsProvider = DefaultGithubCredentialsProvider.fromIntegrations(integrations); action = createPublishGithubAction({ @@ -105,6 +104,8 @@ describe('publish:github', () => { ); }); + afterEach(jest.resetAllMocks); + it('should fail to create if the team is not found in the org', async () => { mockOctokit.rest.users.getByUsername.mockResolvedValue({ data: { type: 'Organization' }, From 4933f965d525474a1d61b88b2d43ca33601772d6 Mon Sep 17 00:00:00 2001 From: Phred Date: Tue, 28 Mar 2023 18:03:51 -0400 Subject: [PATCH 022/162] made changeset more explicit Signed-off-by: Phred --- .changeset/serious-items-walk.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/serious-items-walk.md b/.changeset/serious-items-walk.md index 6da6d91ac6..c4cdfe34c4 100644 --- a/.changeset/serious-items-walk.md +++ b/.changeset/serious-items-walk.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder-backend': patch --- -Removed entity types and namespacing before passing to GitHub API +Stripped entity types and namespace before passing to GitHub API From d8cb971216d2c1c22509f387ced6f824192370d2 Mon Sep 17 00:00:00 2001 From: Philipp Hugenroth Date: Fri, 24 Feb 2023 10:20:27 +0100 Subject: [PATCH 023/162] Remove Tabs component Signed-off-by: Philipp Hugenroth --- .../src/components/Tabs/Tab.test.tsx | 26 --- .../src/components/Tabs/Tab.tsx | 72 -------- .../src/components/Tabs/TabBar.tsx | 60 ------- .../src/components/Tabs/TabIcon.tsx | 66 ------- .../src/components/Tabs/TabPanel.tsx | 37 ---- .../src/components/Tabs/Tabs.stories.tsx | 71 -------- .../src/components/Tabs/Tabs.tsx | 170 ------------------ .../src/components/Tabs/index.ts | 22 --- .../src/components/Tabs/utils.ts | 28 --- .../core-components/src/components/index.ts | 1 - 10 files changed, 553 deletions(-) delete mode 100644 packages/core-components/src/components/Tabs/Tab.test.tsx delete mode 100644 packages/core-components/src/components/Tabs/Tab.tsx delete mode 100644 packages/core-components/src/components/Tabs/TabBar.tsx delete mode 100644 packages/core-components/src/components/Tabs/TabIcon.tsx delete mode 100644 packages/core-components/src/components/Tabs/TabPanel.tsx delete mode 100644 packages/core-components/src/components/Tabs/Tabs.stories.tsx delete mode 100644 packages/core-components/src/components/Tabs/Tabs.tsx delete mode 100644 packages/core-components/src/components/Tabs/index.ts delete mode 100644 packages/core-components/src/components/Tabs/utils.ts diff --git a/packages/core-components/src/components/Tabs/Tab.test.tsx b/packages/core-components/src/components/Tabs/Tab.test.tsx deleted file mode 100644 index d30420ebb6..0000000000 --- a/packages/core-components/src/components/Tabs/Tab.test.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; -import { StyledTab } from './Tab'; - -describe('', () => { - it('renders without exploding', async () => { - const rendered = await renderInTestApp(); - expect(rendered.getByText('test')).toBeInTheDocument(); - }); -}); diff --git a/packages/core-components/src/components/Tabs/Tab.tsx b/packages/core-components/src/components/Tabs/Tab.tsx deleted file mode 100644 index 2922bd9651..0000000000 --- a/packages/core-components/src/components/Tabs/Tab.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { makeStyles } from '@material-ui/core/styles'; -import Tab from '@material-ui/core/Tab'; -import { BackstageTheme } from '@backstage/theme'; - -interface StyledTabProps { - label?: string; - icon?: any; // TODO: define type for material-ui icons - isFirstNav?: boolean; - isFirstIndex?: boolean; - value?: any; -} - -const tabMarginLeft = (isFirstNav: boolean, isFirstIndex: boolean) => { - if (isFirstIndex) { - if (isFirstNav) { - return '20px'; - } - return '0'; - } - return '40px'; -}; - -/** @public */ -export type TabClassKey = 'root' | 'selected'; - -const useStyles = makeStyles( - theme => ({ - root: { - textTransform: 'none', - height: '64px', - fontWeight: theme.typography.fontWeightBold, - fontSize: theme.typography.pxToRem(13), - color: theme.palette.textSubtle, - marginLeft: props => - tabMarginLeft( - props.isFirstNav as boolean, - props.isFirstIndex as boolean, - ), - width: '130px', - minWidth: '130px', - '&:hover': { - outline: 'none', - backgroundColor: 'transparent', - color: theme.palette.textSubtle, - }, - }, - }), - { name: 'BackstageTab' }, -); - -export const StyledTab = (props: StyledTabProps) => { - const classes = useStyles(props); - const { isFirstNav, isFirstIndex, ...rest } = props; - return ; -}; diff --git a/packages/core-components/src/components/Tabs/TabBar.tsx b/packages/core-components/src/components/Tabs/TabBar.tsx deleted file mode 100644 index 99be9a7d1d..0000000000 --- a/packages/core-components/src/components/Tabs/TabBar.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { PropsWithChildren } from 'react'; -import { makeStyles } from '@material-ui/core/styles'; -import Tabs from '@material-ui/core/Tabs'; -import Typography from '@material-ui/core/Typography'; -import { BackstageTheme } from '@backstage/theme'; - -interface StyledTabsProps { - value: number | boolean; - selectionFollowsFocus: boolean; - onChange: (event: React.ChangeEvent<{}>, newValue: number) => void; -} - -export type TabBarClassKey = 'indicator' | 'flexContainer' | 'root'; - -const useStyles = makeStyles( - theme => ({ - indicator: { - display: 'flex', - justifyContent: 'center', - backgroundColor: theme.palette.tabbar.indicator, - height: theme.spacing(0.5), - }, - flexContainer: { - alignItems: 'center', - }, - root: { - '&:last-child': { - marginLeft: 'auto', - }, - }, - }), - { name: 'BackstageTabBar' }, -); - -export const StyledTabs = (props: PropsWithChildren) => { - const classes = useStyles(props); - return ( - }} - /> - ); -}; diff --git a/packages/core-components/src/components/Tabs/TabIcon.tsx b/packages/core-components/src/components/Tabs/TabIcon.tsx deleted file mode 100644 index 0e93924de9..0000000000 --- a/packages/core-components/src/components/Tabs/TabIcon.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { makeStyles } from '@material-ui/core/styles'; -import IconButton from '@material-ui/core/IconButton'; -import { BackstageTheme } from '@backstage/theme'; - -interface StyledIconProps { - ariaLabel: string; - children: any; - isNext?: boolean; - onClick: any; -} - -export type TabIconClassKey = 'root'; - -const useStyles = makeStyles( - theme => ({ - root: { - color: '#6E6E6E', - overflow: 'visible', - fontSize: theme.typography.h5.fontSize, - textAlign: 'center', - borderRadius: '50%', - backgroundColor: '#E6E6E6', - marginLeft: props => (props.isNext ? 'auto' : '0'), - marginRight: props => (props.isNext ? '0' : theme.spacing(1.25)), - '&:hover': { - backgroundColor: '#E6E6E6', - opacity: '1', - }, - }, - }), - { name: 'BackstageTabIcon' }, -); - -export const StyledIcon = (props: StyledIconProps) => { - const classes = useStyles(props); - const { ariaLabel, onClick } = props; - return ( - - {props.children} - - ); -}; diff --git a/packages/core-components/src/components/Tabs/TabPanel.tsx b/packages/core-components/src/components/Tabs/TabPanel.tsx deleted file mode 100644 index 3ecc1ba046..0000000000 --- a/packages/core-components/src/components/Tabs/TabPanel.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import Box from '@material-ui/core/Box'; -import React, { PropsWithChildren } from 'react'; - -export interface TabPanelProps { - value?: any; - index?: number; -} - -export const TabPanel = (props: PropsWithChildren) => { - const { children, value, index, ...other } = props; - - return ( - - ); -}; diff --git a/packages/core-components/src/components/Tabs/Tabs.stories.tsx b/packages/core-components/src/components/Tabs/Tabs.stories.tsx deleted file mode 100644 index 4d4cad8757..0000000000 --- a/packages/core-components/src/components/Tabs/Tabs.stories.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; -import { Tabs } from './Tabs'; -import AccessAlarmIcon from '@material-ui/icons/AccessAlarm'; - -export default { - title: 'Navigation/Tabs', - component: Tabs, -}; - -const containerStyle = {}; - -export const Default = () => ( -
- ({ - label: `ANOTHER TAB`, - content:
Content {index}
, - }))} - /> -
-); - -export const Expandable = () => ( -
- ({ - label: `ANOTHER TAB`, - content:
Content {index}
, - }))} - /> -
-); - -export const Icons = () => ( -
- ({ - icon: , - content:
Content {index}
, - }))} - /> -
-); - -export const IconsAndLabels = () => ( -
- ({ - icon: , - label: `ANOTHER TAB`, - content:
Content {index}
, - }))} - /> -
-); diff --git a/packages/core-components/src/components/Tabs/Tabs.tsx b/packages/core-components/src/components/Tabs/Tabs.tsx deleted file mode 100644 index 420263f576..0000000000 --- a/packages/core-components/src/components/Tabs/Tabs.tsx +++ /dev/null @@ -1,170 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { useRef, useEffect, MutableRefObject, useState } from 'react'; -import { BackstageTheme } from '@backstage/theme'; -import AppBar from '@material-ui/core/AppBar'; -import { makeStyles } from '@material-ui/core/styles'; -import NavigateBeforeIcon from '@material-ui/icons/NavigateBefore'; -import NavigateNextIcon from '@material-ui/icons/NavigateNext'; -import { chunkArray } from './utils'; -import useWindowSize from 'react-use/lib/useWindowSize'; - -/* Import Components */ - -import { TabPanel } from './TabPanel'; -import { StyledIcon } from './TabIcon'; -import { StyledTab } from './Tab'; -import { StyledTabs } from './TabBar'; -import Box from '@material-ui/core/Box'; - -/* Props Types */ - -export interface TabProps { - content: any; - label?: string; - icon?: any; // TODO: define type for material-ui icons -} - -export interface TabsProps { - tabs: TabProps[]; -} - -export type TabsClassKey = 'root' | 'styledTabs' | 'appbar'; - -const useStyles = makeStyles( - theme => ({ - root: { - flexGrow: 1, - width: '100%', - }, - styledTabs: { - backgroundColor: theme.palette.background.paper, - }, - appbar: { - boxShadow: 'none', - backgroundColor: theme.palette.background.paper, - paddingLeft: theme.spacing(1.25), - paddingRight: theme.spacing(1.25), - }, - }), - { name: 'BackstageTabs' }, -); - -export function Tabs(props: TabsProps) { - const { tabs } = props; - const classes = useStyles(); - const [value, setValue] = useState([0, 0]); // [selectedChunkedNavIndex, selectedIndex] - const [navIndex, setNavIndex] = useState(0); - const [numberOfChunkedElement, setNumberOfChunkedElement] = useState(0); - const [chunkedTabs, setChunkedTabs] = useState([[]]); - const wrapper = useRef() as MutableRefObject; - - const { width } = useWindowSize(); - - const handleChange = (_: React.ChangeEvent<{}>, newValue: number) => { - setValue([navIndex, newValue]); - }; - - const navigateToPrevChunk = () => { - setNavIndex(navIndex - 1); - }; - - const navigateToNextChunk = () => { - setNavIndex(navIndex + 1); - }; - - const hasNextNavIndex = () => navIndex + 1 < chunkedTabs.length; - - useEffect(() => { - // Each time the window is resized we calculate how many tabs we can render given the window width - const padding = 20; // The AppBar padding - - const numberOfTabIcons = navIndex === 0 ? 1 : 2; - const wrapperWidth = - wrapper.current.offsetWidth - padding - numberOfTabIcons * 30; - const flattenIndex = value[0] * numberOfChunkedElement + value[1]; - const newChunkedElementSize = Math.floor(wrapperWidth / 170); - - setNumberOfChunkedElement(newChunkedElementSize); - setChunkedTabs(chunkArray(tabs, newChunkedElementSize)); - setValue([ - Math.floor(flattenIndex / newChunkedElementSize), - flattenIndex % newChunkedElementSize, - ]); - // eslint-disable-next-line - }, [width, tabs]); - - const currentIndex = navIndex === value[0] ? value[1] : false; - - return ( - - - - - {navIndex !== 0 && ( - - - - )} - {chunkedTabs[navIndex].map((tab, index) => ( - - ))} - {hasNextNavIndex() && ( - - - - )} - - - - {currentIndex !== false ? ( - chunkedTabs[navIndex].map((tab, index) => ( - - {tab.content} - - )) - ) : ( - // Render if the selected tab index is outside the current rendered chunked array - - {chunkedTabs[value[0]][value[1]].content} - - )} - - ); -} diff --git a/packages/core-components/src/components/Tabs/index.ts b/packages/core-components/src/components/Tabs/index.ts deleted file mode 100644 index 9702c39a48..0000000000 --- a/packages/core-components/src/components/Tabs/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export { Tabs } from './Tabs'; -export type { TabsClassKey } from './Tabs'; -export type { TabClassKey } from './Tab'; - -export type { TabBarClassKey } from './TabBar'; -export type { TabIconClassKey } from './TabIcon'; diff --git a/packages/core-components/src/components/Tabs/utils.ts b/packages/core-components/src/components/Tabs/utils.ts deleted file mode 100644 index 80705fdfc0..0000000000 --- a/packages/core-components/src/components/Tabs/utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export function chunkArray(array: T[], chunkSize: number): T[][] { - if (chunkSize <= 0) { - return [array]; - } - - const result: T[][] = []; - for (let i = 0; i < array.length; i += chunkSize) { - result.push(array.slice(i, i + chunkSize)); - } - - return result; -} diff --git a/packages/core-components/src/components/index.ts b/packages/core-components/src/components/index.ts index 0057847754..0222b82aa4 100644 --- a/packages/core-components/src/components/index.ts +++ b/packages/core-components/src/components/index.ts @@ -44,6 +44,5 @@ export * from './StructuredMetadataTable'; export * from './SupportButton'; export * from './TabbedLayout'; export * from './Table'; -export * from './Tabs'; export * from './TrendLine'; export * from './WarningPanel'; From 2466406b15977e5d7b883c1e0bdaaa4af04f275e Mon Sep 17 00:00:00 2001 From: Philipp Hugenroth Date: Fri, 24 Feb 2023 10:37:12 +0100 Subject: [PATCH 024/162] Generate API Report Signed-off-by: Philipp Hugenroth --- packages/core-components/api-report.md | 24 ------------------- .../src/overridableComponents.ts | 8 ------- 2 files changed, 32 deletions(-) diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index 6439be23a7..1e5fa77ed3 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -1318,11 +1318,6 @@ export type Tab = { >; }; -// Warning: (ae-missing-release-tag) "TabBarClassKey" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export type TabBarClassKey = 'indicator' | 'flexContainer' | 'root'; - // Warning: (ae-forgotten-export) The symbol "Props_18" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "TabbedCard" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -1348,14 +1343,6 @@ export namespace TabbedLayout { Route: (props: SubRoute) => null; } -// @public (undocumented) -export type TabClassKey = 'root' | 'selected'; - -// Warning: (ae-missing-release-tag) "TabIconClassKey" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export type TabIconClassKey = 'root'; - // Warning: (ae-missing-release-tag) "Table" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1438,17 +1425,6 @@ export type TableState = { // @public (undocumented) export type TableToolbarClassKey = 'root' | 'title' | 'searchField'; -// Warning: (ae-forgotten-export) The symbol "TabsProps" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "Tabs" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export function Tabs(props: TabsProps): JSX.Element; - -// Warning: (ae-missing-release-tag) "TabsClassKey" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export type TabsClassKey = 'root' | 'styledTabs' | 'appbar'; - // Warning: (ae-missing-release-tag) "TrendLine" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/packages/core-components/src/overridableComponents.ts b/packages/core-components/src/overridableComponents.ts index 49bc162951..c872317049 100644 --- a/packages/core-components/src/overridableComponents.ts +++ b/packages/core-components/src/overridableComponents.ts @@ -60,10 +60,6 @@ import { TableToolbarClassKey, FiltersContainerClassKey, TableClassKey, - TabBarClassKey, - TabIconClassKey, - TabsClassKey, - TabClassKey, WarningPanelClassKey, } from './components'; @@ -141,10 +137,6 @@ type BackstageComponentsNameToClassKey = { BackstageTableToolbar: TableToolbarClassKey; BackstageTableFiltersContainer: FiltersContainerClassKey; BackstageTable: TableClassKey; - BackstageTabBar: TabBarClassKey; - BackstageTabIcon: TabIconClassKey; - BackstageTabs: TabsClassKey; - BackstageTab: TabClassKey; BackstageWarningPanel: WarningPanelClassKey; BackstageBottomLink: BottomLinkClassKey; BackstageBreadcrumbsClickableText: BreadcrumbsClickableTextClassKey; From 01cd4e2575463bf0ab6ae399067e8e91262e9c4a Mon Sep 17 00:00:00 2001 From: Philipp Hugenroth Date: Sat, 1 Apr 2023 18:36:59 +0200 Subject: [PATCH 025/162] Add changeset Signed-off-by: Philipp Hugenroth --- .changeset/yellow-candles-kiss.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/yellow-candles-kiss.md diff --git a/.changeset/yellow-candles-kiss.md b/.changeset/yellow-candles-kiss.md new file mode 100644 index 0000000000..e365be2a0a --- /dev/null +++ b/.changeset/yellow-candles-kiss.md @@ -0,0 +1,21 @@ +--- +'@backstage/core-components': minor +--- + +**BREAKING:** Removing `Tabs` component from `core-components` as it is neither used in the core Backstage app nor in the monorepo plugins. If you are using this component in your instance please consider replacing it with the [Material UI `Tabs`](https://v4.mui.com/components/tabs/#tabs) component like the following: + +```diff +- , +- content:
Label
, +- }]} +- /> + ++ ++ } ++ /> ++ +``` From 2e622932a4f5f3027589fce740969ffa1bb00113 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Wed, 8 Mar 2023 16:22:09 -0500 Subject: [PATCH 026/162] Add proxy method to KubernetesBackendClient Signed-off-by: Ruben Vallejo --- plugins/kubernetes/package.json | 1 + .../src/api/KubernetesBackendClient.ts | 69 +++++++++++++++++++ plugins/kubernetes/src/api/types.ts | 5 ++ .../GoogleKubernetesAuthProvider.ts | 5 ++ .../KubernetesAuthProviders.ts | 23 ++++++- .../OidcKubernetesAuthProvider.ts | 4 ++ .../ServerSideAuthProvider.ts | 4 ++ .../src/kubernetes-auth-provider/types.ts | 7 +- plugins/kubernetes/src/plugin.ts | 9 ++- yarn.lock | 1 + 10 files changed, 124 insertions(+), 4 deletions(-) diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 7b3b3d7b25..f7d7430952 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -37,6 +37,7 @@ "@backstage/config": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/errors": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", "@backstage/plugin-kubernetes-common": "workspace:^", "@backstage/theme": "workspace:^", diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.ts index bffc966333..b54046fbcd 100644 --- a/plugins/kubernetes/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.ts @@ -23,17 +23,22 @@ import { } from '@backstage/plugin-kubernetes-common'; import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; import { stringifyEntityRef } from '@backstage/catalog-model'; +import { KubernetesAuthProvidersApi } from '../kubernetes-auth-provider'; +import { NotFoundError } from '@backstage/errors'; export class KubernetesBackendClient implements KubernetesApi { private readonly discoveryApi: DiscoveryApi; private readonly identityApi: IdentityApi; + private readonly kubernetesAuthProvidersApi: KubernetesAuthProvidersApi; constructor(options: { discoveryApi: DiscoveryApi; identityApi: IdentityApi; + kubernetesAuthProvidersApi: KubernetesAuthProvidersApi; }) { this.discoveryApi = options.discoveryApi; this.identityApi = options.identityApi; + this.kubernetesAuthProvidersApi = options.kubernetesAuthProvidersApi; } private async handleResponse(response: Response): Promise { @@ -110,4 +115,68 @@ export class KubernetesBackendClient implements KubernetesApi { return (await this.handleResponse(response)).items; } + + async proxy( + clusterName: string, + path: string, + init?: RequestInit, + ): Promise { + const { authProvider } = await this.getCluster(clusterName); + const token = await this.getBearerToken(authProvider); + + const url = `${await this.discoveryApi.getBaseUrl( + 'kubernetes', + )}/proxy${path}`; + const headers = { + Authorization: `Bearer ${token}`, + ...init?.headers, + [`X-Kubernetes-Cluster`]: clusterName, + }; + const response = await fetch(url, { ...init, headers }); + return this.handleResponse(response); + } + + private async getCluster( + clusterName: string, + ): Promise<{ name: string; authProvider: string }> { + const cluster = await this.getClusters().then(clusters => + clusters.find(c => c.name === clusterName), + ); + if (!cluster) { + throw new NotFoundError(`Cluster ${clusterName} not found`); + } + return cluster; + } + + private async getBearerToken(authProvider: string): Promise { + // const { auth } = + // await this.kubernetesAuthProvidersApi.decorateRequestBodyForAuth( + // authProvider, + // { + // entity: { + // apiVersion: 'v1', + // kind: 'Pods', + // metadata: { + // name: 'podName', + // annotations: { + // 'backstage.io/kubernetes-label-selector': 'k8s-app=kube-dns', + // }, + // }, + // }, + // }, + // ); + return await this.kubernetesAuthProvidersApi.getBearerToken(authProvider); + + // if (auth) { + // if (authProvider === 'google' && auth.google) { + // return auth.google; + // } else if (authProvider.startsWith('oidc.') && auth.oidc) { + // const oidcTokenProvider = authProvider.replace(/^oidc\./, ''); + // return auth.oidc[oidcTokenProvider]; + // } else { + // throw new Error(`invalid auth provider '${authProvider}'`); + // } + // } + // return ''; + } } diff --git a/plugins/kubernetes/src/api/types.ts b/plugins/kubernetes/src/api/types.ts index 0005a8d069..22e0644461 100644 --- a/plugins/kubernetes/src/api/types.ts +++ b/plugins/kubernetes/src/api/types.ts @@ -43,4 +43,9 @@ export interface KubernetesApi { getCustomObjectsByEntity( request: CustomObjectsByEntityRequest, ): Promise; + proxy( + clusterName: string, + path: string, + init?: RequestInit, + ): Promise; } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts index 058ecf07a3..171ebc9960 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts @@ -38,4 +38,9 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { } return requestBody; } + async getBearerToken(): Promise { + return await this.authProvider.getAccessToken( + 'https://www.googleapis.com/auth/cloud-platform', + ); + } } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts index eb3ea456ea..65dc45e131 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; +import { + KubernetesRequestAuth, + KubernetesRequestBody, +} from '@backstage/plugin-kubernetes-common'; import { KubernetesAuthProvider, KubernetesAuthProvidersApi } from './types'; import { GoogleKubernetesAuthProvider } from './GoogleKubernetesAuthProvider'; import { ServerSideKubernetesAuthProvider } from './ServerSideAuthProvider'; @@ -91,4 +94,22 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { `authProvider "${authProvider}" has no KubernetesAuthProvider defined for it`, ); } + + async getBearerToken(authProvider: string): Promise { + const kubernetesAuthProvider: KubernetesAuthProvider | undefined = + this.kubernetesAuthProviderMap.get(authProvider); + + if (kubernetesAuthProvider) { + return await kubernetesAuthProvider.getBearerToken(); + } + + if (authProvider.startsWith('oidc.')) { + throw new Error( + `KubernetesAuthProviders has no oidcProvider configured for ${authProvider}`, + ); + } + throw new Error( + `authProvider "${authProvider}" has no KubernetesAuthProvider defined for it`, + ); + } } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts index f86df5e73e..b992c71391 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts @@ -40,4 +40,8 @@ export class OidcKubernetesAuthProvider implements KubernetesAuthProvider { requestBody.auth = auth; return requestBody; } + + async getBearerToken(): Promise { + return await this.authProvider.getIdToken(); + } } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/ServerSideAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/ServerSideAuthProvider.ts index 1c475b9568..9290a2eda3 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/ServerSideAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/ServerSideAuthProvider.ts @@ -31,4 +31,8 @@ export class ServerSideKubernetesAuthProvider // No-op, auth will be taken care of on the server-side return requestBody; } + + async getBearerToken(): Promise { + return ''; + } } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts index ffa0fd5961..de9f91c44e 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts @@ -14,13 +14,17 @@ * limitations under the License. */ -import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; +import { + KubernetesRequestAuth, + KubernetesRequestBody, +} from '@backstage/plugin-kubernetes-common'; import { createApiRef } from '@backstage/core-plugin-api'; export interface KubernetesAuthProvider { decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise; + getBearerToken(): Promise; } export const kubernetesAuthProvidersApiRef = @@ -33,4 +37,5 @@ export interface KubernetesAuthProvidersApi { authProvider: string, requestBody: KubernetesRequestBody, ): Promise; + getBearerToken(authProvider: string): Promise; } diff --git a/plugins/kubernetes/src/plugin.ts b/plugins/kubernetes/src/plugin.ts index e2b13f43fe..cb85cf06fb 100644 --- a/plugins/kubernetes/src/plugin.ts +++ b/plugins/kubernetes/src/plugin.ts @@ -43,9 +43,14 @@ export const kubernetesPlugin = createPlugin({ deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef, + kubernetesAuthProvidersApi: kubernetesAuthProvidersApiRef, }, - factory: ({ discoveryApi, identityApi }) => - new KubernetesBackendClient({ discoveryApi, identityApi }), + factory: ({ discoveryApi, identityApi, kubernetesAuthProvidersApi }) => + new KubernetesBackendClient({ + discoveryApi, + identityApi, + kubernetesAuthProvidersApi, + }), }), createApiFactory({ api: kubernetesAuthProvidersApiRef, diff --git a/yarn.lock b/yarn.lock index 75f4a41905..9a4399e5ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7153,6 +7153,7 @@ __metadata: "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/dev-utils": "workspace:^" + "@backstage/errors": "workspace:^" "@backstage/plugin-catalog-react": "workspace:^" "@backstage/plugin-kubernetes-common": "workspace:^" "@backstage/test-utils": "workspace:^" From faac72e5def585d8fe7e96dedab228d483f4ed7c Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Thu, 16 Mar 2023 11:02:28 -0400 Subject: [PATCH 027/162] add proxy method with tests for kubernetesbackendclient Signed-off-by: Ruben Vallejo --- plugins/kubernetes/api-report.md | 21 ++ plugins/kubernetes/dev/index.tsx | 10 + .../src/api/KubernetesBackendClient.test.ts | 266 ++++++++++++++++++ .../src/api/KubernetesBackendClient.ts | 102 +++---- plugins/kubernetes/src/api/types.ts | 10 +- .../GoogleKubernetesAuthProvider.ts | 4 +- .../KubernetesAuthProviders.ts | 5 +- .../OidcKubernetesAuthProvider.ts | 2 +- .../src/kubernetes-auth-provider/types.ts | 5 +- plugins/kubernetes/src/setupTests.ts | 1 + 10 files changed, 348 insertions(+), 78 deletions(-) create mode 100644 plugins/kubernetes/src/api/KubernetesBackendClient.test.ts diff --git a/plugins/kubernetes/api-report.md b/plugins/kubernetes/api-report.md index 2cc2674a9e..7c150689b4 100644 --- a/plugins/kubernetes/api-report.md +++ b/plugins/kubernetes/api-report.md @@ -184,6 +184,8 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise; + // (undocumented) + getBearerToken(): Promise; } // Warning: (ae-missing-release-tag) "GroupedResponses" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -259,6 +261,12 @@ export interface KubernetesApi { getWorkloadsByEntity( request: WorkloadsByEntityRequest, ): Promise; + // (undocumented) + proxy(options: { + clusterName: string; + path: string; + init?: RequestInit; + }): Promise; } // Warning: (ae-missing-release-tag) "kubernetesApiRef" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -281,6 +289,8 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { authProvider: string, requestBody: KubernetesRequestBody, ): Promise; + // (undocumented) + getBearerToken(authProvider: string): Promise; } // Warning: (ae-missing-release-tag) "KubernetesAuthProvidersApi" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -292,6 +302,8 @@ export interface KubernetesAuthProvidersApi { authProvider: string, requestBody: KubernetesRequestBody, ): Promise; + // (undocumented) + getBearerToken(authProvider: string): Promise; } // Warning: (ae-missing-release-tag) "kubernetesAuthProvidersApiRef" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -306,6 +318,7 @@ export class KubernetesBackendClient implements KubernetesApi { constructor(options: { discoveryApi: DiscoveryApi; identityApi: IdentityApi; + kubernetesAuthProvidersApi: KubernetesAuthProvidersApi; }); // (undocumented) getClusters(): Promise< @@ -326,6 +339,12 @@ export class KubernetesBackendClient implements KubernetesApi { getWorkloadsByEntity( request: WorkloadsByEntityRequest, ): Promise; + // (undocumented) + proxy(options: { + clusterName: string; + path: string; + init?: RequestInit; + }): Promise; } // Warning: (ae-forgotten-export) The symbol "KubernetesContentProps" needs to be exported by the entry point index.d.ts @@ -416,6 +435,8 @@ export class ServerSideKubernetesAuthProvider decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise; + // (undocumented) + getBearerToken(): Promise; } // Warning: (ae-forgotten-export) The symbol "ServicesAccordionsProps" needs to be exported by the entry point index.d.ts diff --git a/plugins/kubernetes/dev/index.tsx b/plugins/kubernetes/dev/index.tsx index 77708f1287..5780d649fb 100644 --- a/plugins/kubernetes/dev/index.tsx +++ b/plugins/kubernetes/dev/index.tsx @@ -106,6 +106,16 @@ class MockKubernetesClient implements KubernetesApi { async getClusters(): Promise<{ name: string; authProvider: string }[]> { return [{ name: 'mock-cluster', authProvider: 'serviceAccount' }]; } + + async proxy(_options: { clusterName: String; path: String }): Promise { + return { + kind: 'Namespace', + apiVersion: 'v1', + metadata: { + name: 'mock-ns', + }, + }; + } } createDevApp() diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.test.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.test.ts new file mode 100644 index 0000000000..ec18dd50bb --- /dev/null +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.test.ts @@ -0,0 +1,266 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { KubernetesAuthProvidersApi } from '../kubernetes-auth-provider'; +import { KubernetesBackendClient } from './KubernetesBackendClient'; +import { rest } from 'msw'; +import { UrlPatternDiscovery } from '@backstage/core-app-api'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/test-utils'; +import { + CustomObjectsByEntityRequest, + KubernetesRequestBody, + ObjectsByEntityResponse, + WorkloadsByEntityRequest, +} from '@backstage/plugin-kubernetes-common'; +import { NotFoundError } from '@backstage/errors'; + +describe('KubernetesBackendClient', () => { + let backendClient: KubernetesBackendClient; + const kubernetesAuthProvidersApi: jest.Mocked = { + decorateRequestBodyForAuth: jest.fn(), + getBearerToken: jest.fn(), + }; + let mockResponse: ObjectsByEntityResponse; + const worker = setupServer(); + setupRequestMockHandlers(worker); + + const identityApi = { + getCredentials: jest.fn(), + getProfileInfo: jest.fn(), + getBackstageIdentity: jest.fn(), + signOut: jest.fn(), + }; + + beforeEach(() => { + jest.resetAllMocks(); + backendClient = new KubernetesBackendClient({ + discoveryApi: UrlPatternDiscovery.compile( + 'http://localhost:1234/api/{{ pluginId }}', + ), + identityApi, + kubernetesAuthProvidersApi, + }); + mockResponse = { + items: [ + { + cluster: { + name: 'cluster-a', + }, + resources: [{ type: 'pods', resources: [] }], + podMetrics: [ + { + pod: {}, + cpu: { currentUsage: 8, requestTotal: 2, limitTotal: 1 }, + memory: { currentUsage: 8, requestTotal: 2, limitTotal: 1 }, + containers: [ + { + container: 'test', + cpuUsage: { currentUsage: 8, requestTotal: 2, limitTotal: 1 }, + memoryUsage: { + currentUsage: 8, + requestTotal: 2, + limitTotal: 1, + }, + }, + ], + }, + ], + errors: [], + }, + ], + }; + }); + + it('hits the /clusters API', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => + res(ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] })), + ), + ); + + const clusters = await backendClient.getClusters(); + + expect(clusters).toStrictEqual([ + { name: 'cluster-a', authProvider: 'aws' }, + ]); + }); + + it('hits the /resources/custom/query API', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.post( + 'http://localhost:1234/api/kubernetes/resources/custom/query', + (_, res, ctx) => res(ctx.json(mockResponse)), + ), + ); + + const request: CustomObjectsByEntityRequest = { + auth: {}, + customResources: [ + { + group: 'test-group', + apiVersion: 'v1', + plural: 'none', + }, + ], + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, + }; + + const customObject: ObjectsByEntityResponse = + await backendClient.getCustomObjectsByEntity(request); + + expect(customObject).toStrictEqual(mockResponse); + }); + + it('hits the /services/{entityName} api', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.post( + 'http://localhost:1234/api/kubernetes/services/test-name', + (_, res, ctx) => res(ctx.json(mockResponse)), + ), + ); + + const request: KubernetesRequestBody = { + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, + }; + + const entityObject: ObjectsByEntityResponse = + await backendClient.getObjectsByEntity(request); + + expect(entityObject).toStrictEqual(mockResponse); + }); + + it('hits the /resources/workloads/query API', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.post( + 'http://localhost:1234/api/kubernetes/resources/workloads/query', + (_, res, ctx) => res(ctx.json(mockResponse)), + ), + ); + + const request: WorkloadsByEntityRequest = { + auth: {}, + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, + }; + + const response: ObjectsByEntityResponse = + await backendClient.getWorkloadsByEntity(request); + + expect(response).toStrictEqual(mockResponse); + }); + + it('hits the /proxy API', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + kubernetesAuthProvidersApi.getBearerToken.mockResolvedValue( + 'Bearer k8-token', + ); + const nsResponse = { + kind: 'Namespace', + apiVersion: 'v1', + metadata: { + name: 'new-ns', + }, + }; + worker.use( + rest.get( + 'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces', + (_, res, ctx) => res(ctx.json(nsResponse)), + ), + rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => + res(ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] })), + ), + ); + + const request = { + clusterName: 'cluster-a', + path: '/api/v1/namespaces', + }; + + const response = await backendClient.proxy(request); + + expect(response).toStrictEqual(nsResponse); + }); + + it('/proxy API throws a ERROR_NOT_FOUND if the cluster in the request is not found', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => + res(ctx.json({ items: [{ name: 'cluster-b', authProvider: 'aws' }] })), + ), + ); + + const request = { + clusterName: 'cluster-a', + path: '/api/v1/namespaces', + }; + + await expect(backendClient.proxy(request)).rejects.toThrow(NotFoundError); + }); + + it('hits /proxy api when signed in as a guest', async () => { + // when a user is signed in as a guest the result of the getCredentials() method resolves to the {} value. + identityApi.getCredentials.mockResolvedValue({}); + kubernetesAuthProvidersApi.getBearerToken.mockResolvedValue( + 'Bearer k8-token', + ); + const nsResponse = { + kind: 'Namespace', + apiVersion: 'v1', + metadata: { + name: 'new-ns', + }, + }; + worker.use( + rest.get( + 'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces', + (_, res, ctx) => res(ctx.status(200), ctx.json(nsResponse)), + ), + rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => + res(ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] })), + ), + ); + + const request = { + clusterName: 'cluster-a', + path: '/api/v1/namespaces', + }; + + const response = await backendClient.proxy(request); + expect(response).toStrictEqual(nsResponse); + }); +}); diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.ts index b54046fbcd..dbeb1c3035 100644 --- a/plugins/kubernetes/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.ts @@ -74,6 +74,23 @@ export class KubernetesBackendClient implements KubernetesApi { return this.handleResponse(response); } + private async getCluster( + clusterName: string, + ): Promise<{ name: string; authProvider: string }> { + const cluster = await this.getClusters().then(clusters => + clusters.find(c => c.name === clusterName), + ); + if (!cluster) { + throw new NotFoundError(`Cluster ${clusterName} not found`); + } + + return cluster; + } + + private async getBearerToken(authProvider: string): Promise { + return await this.kubernetesAuthProvidersApi.getBearerToken(authProvider); + } + async getObjectsByEntity( requestBody: KubernetesRequestBody, ): Promise { @@ -105,7 +122,6 @@ export class KubernetesBackendClient implements KubernetesApi { async getClusters(): Promise<{ name: string; authProvider: string }[]> { const { token: idToken } = await this.identityApi.getCredentials(); const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/clusters`; - const response = await fetch(url, { method: 'GET', headers: { @@ -116,67 +132,31 @@ export class KubernetesBackendClient implements KubernetesApi { return (await this.handleResponse(response)).items; } - async proxy( - clusterName: string, - path: string, - init?: RequestInit, - ): Promise { - const { authProvider } = await this.getCluster(clusterName); - const token = await this.getBearerToken(authProvider); + async proxy(options: { + clusterName: string; + path: string; + init?: RequestInit; + }): Promise { + const { authProvider } = await this.getCluster(options.clusterName); + const k8sToken = await this.getBearerToken(authProvider); + const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/proxy${ + options.path + }`; + const identityResponse = await this.identityApi.getCredentials(); + const headers = identityResponse.token + ? { + ...options.init?.headers, + [`Backstage-Kubernetes-Cluster`]: options.clusterName, + [`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`, + Authorization: `Bearer ${identityResponse.token}`, + } + : { + ...options.init?.headers, + [`Backstage-Kubernetes-Cluster`]: options.clusterName, + [`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`, + }; + const response = await fetch(url, { ...options.init, headers }); - const url = `${await this.discoveryApi.getBaseUrl( - 'kubernetes', - )}/proxy${path}`; - const headers = { - Authorization: `Bearer ${token}`, - ...init?.headers, - [`X-Kubernetes-Cluster`]: clusterName, - }; - const response = await fetch(url, { ...init, headers }); return this.handleResponse(response); } - - private async getCluster( - clusterName: string, - ): Promise<{ name: string; authProvider: string }> { - const cluster = await this.getClusters().then(clusters => - clusters.find(c => c.name === clusterName), - ); - if (!cluster) { - throw new NotFoundError(`Cluster ${clusterName} not found`); - } - return cluster; - } - - private async getBearerToken(authProvider: string): Promise { - // const { auth } = - // await this.kubernetesAuthProvidersApi.decorateRequestBodyForAuth( - // authProvider, - // { - // entity: { - // apiVersion: 'v1', - // kind: 'Pods', - // metadata: { - // name: 'podName', - // annotations: { - // 'backstage.io/kubernetes-label-selector': 'k8s-app=kube-dns', - // }, - // }, - // }, - // }, - // ); - return await this.kubernetesAuthProvidersApi.getBearerToken(authProvider); - - // if (auth) { - // if (authProvider === 'google' && auth.google) { - // return auth.google; - // } else if (authProvider.startsWith('oidc.') && auth.oidc) { - // const oidcTokenProvider = authProvider.replace(/^oidc\./, ''); - // return auth.oidc[oidcTokenProvider]; - // } else { - // throw new Error(`invalid auth provider '${authProvider}'`); - // } - // } - // return ''; - } } diff --git a/plugins/kubernetes/src/api/types.ts b/plugins/kubernetes/src/api/types.ts index 22e0644461..02f2fd3521 100644 --- a/plugins/kubernetes/src/api/types.ts +++ b/plugins/kubernetes/src/api/types.ts @@ -43,9 +43,9 @@ export interface KubernetesApi { getCustomObjectsByEntity( request: CustomObjectsByEntityRequest, ): Promise; - proxy( - clusterName: string, - path: string, - init?: RequestInit, - ): Promise; + proxy(options: { + clusterName: string; + path: string; + init?: RequestInit; + }): Promise; } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts index 171ebc9960..4b46884b39 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts @@ -28,9 +28,7 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { async decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise { - const googleAuthToken: string = await this.authProvider.getAccessToken( - 'https://www.googleapis.com/auth/cloud-platform', - ); + const googleAuthToken: string = await this.getBearerToken(); if ('auth' in requestBody) { requestBody.auth!.google = googleAuthToken; } else { diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts index 65dc45e131..45ba243d23 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts @@ -14,10 +14,7 @@ * limitations under the License. */ -import { - KubernetesRequestAuth, - KubernetesRequestBody, -} from '@backstage/plugin-kubernetes-common'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { KubernetesAuthProvider, KubernetesAuthProvidersApi } from './types'; import { GoogleKubernetesAuthProvider } from './GoogleKubernetesAuthProvider'; import { ServerSideKubernetesAuthProvider } from './ServerSideAuthProvider'; diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts index b992c71391..9d9d34fe1d 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts @@ -30,7 +30,7 @@ export class OidcKubernetesAuthProvider implements KubernetesAuthProvider { async decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise { - const authToken: string = await this.authProvider.getIdToken(); + const authToken: string = await this.getBearerToken(); const auth = { ...requestBody.auth }; if (auth.oidc) { auth.oidc[this.providerName] = authToken; diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts index de9f91c44e..395db3c1bd 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts @@ -14,10 +14,7 @@ * limitations under the License. */ -import { - KubernetesRequestAuth, - KubernetesRequestBody, -} from '@backstage/plugin-kubernetes-common'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { createApiRef } from '@backstage/core-plugin-api'; export interface KubernetesAuthProvider { diff --git a/plugins/kubernetes/src/setupTests.ts b/plugins/kubernetes/src/setupTests.ts index a373310a1a..f7752030ed 100644 --- a/plugins/kubernetes/src/setupTests.ts +++ b/plugins/kubernetes/src/setupTests.ts @@ -16,6 +16,7 @@ import '@testing-library/jest-dom'; // eslint-disable-next-line no-restricted-imports import { TextDecoder, TextEncoder } from 'util'; +import 'cross-fetch/polyfill'; // These are missing from jest-node, so not available on global. Object.defineProperty(global, 'TextEncoder', { From 98d06e02aafe88d66407ce3bacc894260097b573 Mon Sep 17 00:00:00 2001 From: Phred Date: Tue, 4 Apr 2023 09:16:18 -0500 Subject: [PATCH 028/162] renamed utility to entityRefToName based on code review Signed-off-by: Phred --- .../actions/builtin/github/githubRepoCreate.test.ts | 4 ++-- .../src/scaffolder/actions/builtin/github/helpers.ts | 6 +++--- .../src/scaffolder/actions/builtin/helpers.test.ts | 10 +++------- .../src/scaffolder/actions/builtin/helpers.ts | 2 +- .../scaffolder/actions/builtin/publish/github.test.ts | 10 +++++----- 5 files changed, 14 insertions(+), 18 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts index 58747d57ef..4f23839fca 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubRepoCreate.test.ts @@ -28,7 +28,7 @@ import { import { when } from 'jest-when'; import { PassThrough } from 'stream'; import { createGithubRepoCreateAction } from './githubRepoCreate'; -import { familiarizeEntityName } from '../helpers'; +import { entityRefToName } from '../helpers'; const mockOctokit = { rest: { @@ -90,7 +90,7 @@ describe('github:repo:create', () => { integrations, githubCredentialsProvider, }); - (familiarizeEntityName as jest.Mock).mockImplementation((s: string) => s); + (entityRefToName as jest.Mock).mockImplementation((s: string) => s); }); afterEach(jest.resetAllMocks); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts index 23f6b804b7..57be72bc73 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/helpers.ts @@ -29,7 +29,7 @@ import { initRepoAndPush, } from '../helpers'; import { getRepoSourceDirectory, parseRepoUrl } from '../publish/util'; -import { familiarizeEntityName } from '../../builtin/helpers'; +import { entityRefToName } from '../../builtin/helpers'; const DEFAULT_TIMEOUT_MS = 60_000; @@ -219,13 +219,13 @@ export async function createGithubRepoWithCollaboratorsAndTopics( await client.rest.repos.addCollaborator({ owner, repo, - username: familiarizeEntityName(collaborator.user), + username: entityRefToName(collaborator.user), permission: collaborator.access, }); } else if ('team' in collaborator) { await client.rest.teams.addOrUpdateRepoPermissionsInOrg({ org: owner, - team_slug: familiarizeEntityName(collaborator.team), + team_slug: entityRefToName(collaborator.team), owner, repo, permission: collaborator.access, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.test.ts index 8cde257d64..239f7c7351 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.test.ts @@ -15,11 +15,7 @@ */ import { Git, getVoidLogger } from '@backstage/backend-common'; -import { - commitAndPushRepo, - familiarizeEntityName, - initRepoAndPush, -} from './helpers'; +import { commitAndPushRepo, entityRefToName, initRepoAndPush } from './helpers'; jest.mock('@backstage/backend-common', () => ({ Git: { @@ -306,7 +302,7 @@ describe('commitAndPushRepo', () => { }); }); -describe('familiarizeEntityName', () => { +describe('entityRefToName', () => { it.each([ 'user:default/catpants', 'group:default/catpants', @@ -316,6 +312,6 @@ describe('familiarizeEntityName', () => { 'group:custom/catpants', 'catpants', ])('should parse: "%s"', (entityName: string) => { - expect(familiarizeEntityName(entityName)).toEqual('catpants'); + expect(entityRefToName(entityName)).toEqual('catpants'); }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts index 30b75d9e6f..c1630d0bf7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/helpers.ts @@ -297,6 +297,6 @@ export function getGitCommitMessage( : config.getOptionalString('scaffolder.defaultCommitMessage'); } -export function familiarizeEntityName(name: string): string { +export function entityRefToName(name: string): string { return name.replace(/^.*[:/]/g, ''); } diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts index 3347ca2e05..8c6b02c1c3 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/github.test.ts @@ -29,7 +29,7 @@ import { when } from 'jest-when'; import { PassThrough } from 'stream'; import { enableBranchProtectionOnDefaultRepoBranch, - familiarizeEntityName, + entityRefToName, initRepoAndPush, } from '../helpers'; import { createPublishGithubAction } from './github'; @@ -69,7 +69,7 @@ describe('publish:github', () => { }, }); - const { familiarizeEntityName: realFamiliarizeEntityName } = + const { entityRefToName: realFamiliarizeEntityName } = jest.requireActual('../helpers'); const integrations = ScmIntegrations.fromConfig(config); let githubCredentialsProvider: GithubCredentialsProvider; @@ -99,7 +99,7 @@ describe('publish:github', () => { }); // restore real implmentation - (familiarizeEntityName as jest.Mock).mockImplementation( + (entityRefToName as jest.Mock).mockImplementation( realFamiliarizeEntityName, ); }); @@ -709,8 +709,8 @@ describe('publish:github', () => { permission: 'push', }); - expect(familiarizeEntityName).toHaveBeenCalledWith('user:robot-1'); - expect(familiarizeEntityName).toHaveBeenCalledWith('group:default/robot-2'); + expect(entityRefToName).toHaveBeenCalledWith('user:robot-1'); + expect(entityRefToName).toHaveBeenCalledWith('group:default/robot-2'); }); it('should ignore failures when adding multiple collaborators', async () => { From c159ab64a609fed99d56eb4b52dcb871e9dc6892 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Mon, 27 Mar 2023 11:30:13 -0400 Subject: [PATCH 029/162] add changeset and docs Signed-off-by: Ruben Vallejo --- .changeset/sweet-eels-hunt.md | 5 +++++ docs/features/kubernetes/proxy.md | 36 +++++-------------------------- 2 files changed, 10 insertions(+), 31 deletions(-) create mode 100644 .changeset/sweet-eels-hunt.md diff --git a/.changeset/sweet-eels-hunt.md b/.changeset/sweet-eels-hunt.md new file mode 100644 index 0000000000..39f124a860 --- /dev/null +++ b/.changeset/sweet-eels-hunt.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-kubernetes': patch +--- + +`KubernetesBackendClient` now requires a `kubernetesAuthProvidersApi` value to be provided. `KubernetesApi` interface now has a proxy method requirement. diff --git a/docs/features/kubernetes/proxy.md b/docs/features/kubernetes/proxy.md index 7b6837dd7d..5811ff8ec4 100644 --- a/docs/features/kubernetes/proxy.md +++ b/docs/features/kubernetes/proxy.md @@ -14,42 +14,16 @@ Kubernetes backend plugin's proxy endpoint to allow them to make arbitrary requests to the [REST API](https://kubernetes.io/docs/reference/using-api/api-concepts/). -Here is a snippet fetching namespaces from a cluster configured with the -`google` [auth provider](https://backstage.io/docs/features/kubernetes/configuration#clustersauthprovider): +Here is a snippet fetching namespaces using the `KubernetesBackendClient` library ```typescript -import { - discoveryApiRef, - googleAuthApiRef, - useApi, - identityApiRef, -} from '@backstage/core-plugin-api'; +import { useApi } from '@backstage/core-plugin-api'; +import { kubernetesApiRef } from '@backstage/plugin-kubernetes'; const CLUSTER_NAME = ''; // use a known cluster name -// get a bearer token from Google -const googleAuthApi = useApi(googleAuthApiRef); -const token = await googleAuthApi.getAccessToken( - 'https://www.googleapis.com/auth/cloud-platform', -); - -// get a backstage ID token -const identityApi = useApi(identityApiRef); -const { token: userToken } = await identityApi.getCredentials(); - -const discoveryApi = useApi(discoveryApiRef); -const kubernetesBaseUrl = await discoveryApi.getBaseUrl('kubernetes'); -const kubernetesProxyEndpoint = `${kubernetesBaseUrl}/proxy`; - -// fetch namespaces -await fetch(`${kubernetesProxyEndpoint}/api/v1/namespaces`, { - method: 'GET', - headers: { - 'Backstage-Kubernetes-Cluster': CLUSTER_NAME, - 'Backstage-Kubernetes-Authorization': `Bearer ${token}`, - Authorization: `Bearer ${userToken}`, - }, -}); +const kubernetesApi = useApi(kubernetesApiRef); +await kubernetesApi.proxy(CLUSTER_NAME, '/api/v1/namespaces'); ``` ## How it works From 6bea150df58a9262724e9d743ac2fe3250652752 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Wed, 29 Mar 2023 17:08:41 -0400 Subject: [PATCH 030/162] fix:addressing PR comments Signed-off-by: Ruben Vallejo --- plugins/kubernetes/api-report.md | 16 +- .../src/api/KubernetesBackendClient.test.ts | 407 +++++++++++++++--- .../src/api/KubernetesBackendClient.ts | 31 +- .../GoogleKubernetesAuthProvider.ts | 12 +- .../KubernetesAuthProviders.ts | 4 +- .../OidcKubernetesAuthProvider.ts | 8 +- .../ServerSideAuthProvider.ts | 4 +- .../src/kubernetes-auth-provider/types.ts | 4 +- 8 files changed, 384 insertions(+), 102 deletions(-) diff --git a/plugins/kubernetes/api-report.md b/plugins/kubernetes/api-report.md index 7c150689b4..a5f72861da 100644 --- a/plugins/kubernetes/api-report.md +++ b/plugins/kubernetes/api-report.md @@ -185,7 +185,9 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { requestBody: KubernetesRequestBody, ): Promise; // (undocumented) - getBearerToken(): Promise; + getCredentials(): Promise<{ + token: string; + }>; } // Warning: (ae-missing-release-tag) "GroupedResponses" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -290,7 +292,9 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { requestBody: KubernetesRequestBody, ): Promise; // (undocumented) - getBearerToken(authProvider: string): Promise; + getCredentials(authProvider: string): Promise<{ + token: string; + }>; } // Warning: (ae-missing-release-tag) "KubernetesAuthProvidersApi" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -303,7 +307,9 @@ export interface KubernetesAuthProvidersApi { requestBody: KubernetesRequestBody, ): Promise; // (undocumented) - getBearerToken(authProvider: string): Promise; + getCredentials(authProvider: string): Promise<{ + token: string; + }>; } // Warning: (ae-missing-release-tag) "kubernetesAuthProvidersApiRef" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -436,7 +442,9 @@ export class ServerSideKubernetesAuthProvider requestBody: KubernetesRequestBody, ): Promise; // (undocumented) - getBearerToken(): Promise; + getCredentials(): Promise<{ + token: string; + }>; } // Warning: (ae-forgotten-export) The symbol "ServicesAccordionsProps" needs to be exported by the entry point index.d.ts diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.test.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.test.ts index ec18dd50bb..2c01f15b18 100644 --- a/plugins/kubernetes/src/api/KubernetesBackendClient.test.ts +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.test.ts @@ -32,7 +32,7 @@ describe('KubernetesBackendClient', () => { let backendClient: KubernetesBackendClient; const kubernetesAuthProvidersApi: jest.Mocked = { decorateRequestBodyForAuth: jest.fn(), - getBearerToken: jest.fn(), + getCredentials: jest.fn(), }; let mockResponse: ObjectsByEntityResponse; const worker = setupServer(); @@ -100,6 +100,32 @@ describe('KubernetesBackendClient', () => { ]); }); + it('/clusters API throws a 404 Error', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => + res(ctx.status(404)), + ), + ); + + await expect(backendClient.getClusters()).rejects.toThrow( + 'Could not find the Kubernetes Backend (HTTP 404). Make sure the plugin has been fully installed.', + ); + }); + + it('/clusters API throws a 500 Error', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => + res(ctx.status(500)), + ), + ); + + await expect(backendClient.getClusters()).rejects.toThrow( + 'Request failed with 500 Internal Server Error, ', + ); + }); + it('hits the /resources/custom/query API', async () => { identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); worker.use( @@ -133,7 +159,75 @@ describe('KubernetesBackendClient', () => { expect(customObject).toStrictEqual(mockResponse); }); - it('hits the /services/{entityName} api', async () => { + it('/resources/custom/query API throws a 404 error', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.post( + 'http://localhost:1234/api/kubernetes/resources/custom/query', + (_, res, ctx) => res(ctx.status(404)), + ), + ); + + const request: CustomObjectsByEntityRequest = { + auth: {}, + customResources: [ + { + group: 'test-group', + apiVersion: 'v1', + plural: 'none', + }, + ], + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, + }; + + const response = backendClient.getCustomObjectsByEntity(request); + + await expect(response).rejects.toThrow( + 'Could not find the Kubernetes Backend (HTTP 404). Make sure the plugin has been fully installed.', + ); + }); + + it('/resources/custom/query API throws a 500 error', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.post( + 'http://localhost:1234/api/kubernetes/resources/custom/query', + (_, res, ctx) => res(ctx.status(500)), + ), + ); + + const request: CustomObjectsByEntityRequest = { + auth: {}, + customResources: [ + { + group: 'test-group', + apiVersion: 'v1', + plural: 'none', + }, + ], + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, + }; + + const response = backendClient.getCustomObjectsByEntity(request); + + await expect(response).rejects.toThrow( + 'Request failed with 500 Internal Server Error, ', + ); + }); + + it('hits the /services/{entityName} API', async () => { identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); worker.use( rest.post( @@ -158,6 +252,58 @@ describe('KubernetesBackendClient', () => { expect(entityObject).toStrictEqual(mockResponse); }); + it('services/{entityName} API throws a 404 error', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.post( + 'http://localhost:1234/api/kubernetes/services/test-name', + (_, res, ctx) => res(ctx.status(404)), + ), + ); + + const request: KubernetesRequestBody = { + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, + }; + + const response = backendClient.getObjectsByEntity(request); + + await expect(response).rejects.toThrow( + 'Could not find the Kubernetes Backend (HTTP 404). Make sure the plugin has been fully installed.', + ); + }); + + it('services/{entityName} API throws a 500 error', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + worker.use( + rest.post( + 'http://localhost:1234/api/kubernetes/services/test-name', + (_, res, ctx) => res(ctx.status(500)), + ), + ); + + const request: KubernetesRequestBody = { + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, + }; + + const response = backendClient.getObjectsByEntity(request); + + await expect(response).rejects.toThrow( + 'Request failed with 500 Internal Server Error, ', + ); + }); + it('hits the /resources/workloads/query API', async () => { identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); worker.use( @@ -184,83 +330,210 @@ describe('KubernetesBackendClient', () => { expect(response).toStrictEqual(mockResponse); }); - it('hits the /proxy API', async () => { - identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); - kubernetesAuthProvidersApi.getBearerToken.mockResolvedValue( - 'Bearer k8-token', - ); - const nsResponse = { - kind: 'Namespace', - apiVersion: 'v1', - metadata: { - name: 'new-ns', - }, - }; - worker.use( - rest.get( - 'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces', - (_, res, ctx) => res(ctx.json(nsResponse)), - ), - rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => - res(ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] })), - ), - ); - - const request = { - clusterName: 'cluster-a', - path: '/api/v1/namespaces', - }; - - const response = await backendClient.proxy(request); - - expect(response).toStrictEqual(nsResponse); - }); - - it('/proxy API throws a ERROR_NOT_FOUND if the cluster in the request is not found', async () => { + it('/resources/workloads/query API throws a 404 error', async () => { identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); worker.use( - rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => - res(ctx.json({ items: [{ name: 'cluster-b', authProvider: 'aws' }] })), + rest.post( + 'http://localhost:1234/api/kubernetes/resources/workloads/query', + (_, res, ctx) => res(ctx.status(404)), ), ); - const request = { - clusterName: 'cluster-a', - path: '/api/v1/namespaces', - }; - - await expect(backendClient.proxy(request)).rejects.toThrow(NotFoundError); - }); - - it('hits /proxy api when signed in as a guest', async () => { - // when a user is signed in as a guest the result of the getCredentials() method resolves to the {} value. - identityApi.getCredentials.mockResolvedValue({}); - kubernetesAuthProvidersApi.getBearerToken.mockResolvedValue( - 'Bearer k8-token', - ); - const nsResponse = { - kind: 'Namespace', - apiVersion: 'v1', - metadata: { - name: 'new-ns', + const request: WorkloadsByEntityRequest = { + auth: {}, + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, }, }; + + const response = backendClient.getWorkloadsByEntity(request); + + await expect(response).rejects.toThrow( + 'Could not find the Kubernetes Backend (HTTP 404). Make sure the plugin has been fully installed.', + ); + }); + + it('/resources/workloads/query API throws a 500 error', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); worker.use( - rest.get( - 'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces', - (_, res, ctx) => res(ctx.status(200), ctx.json(nsResponse)), - ), - rest.get('http://localhost:1234/api/kubernetes/clusters', (_, res, ctx) => - res(ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] })), + rest.post( + 'http://localhost:1234/api/kubernetes/resources/workloads/query', + (_, res, ctx) => res(ctx.status(500)), ), ); - const request = { - clusterName: 'cluster-a', - path: '/api/v1/namespaces', + const request: WorkloadsByEntityRequest = { + auth: {}, + entity: { + apiVersion: 'v1', + kind: 'pod', + metadata: { + name: 'test-name', + }, + }, }; - const response = await backendClient.proxy(request); - expect(response).toStrictEqual(nsResponse); + const response = backendClient.getWorkloadsByEntity(request); + + await expect(response).rejects.toThrow( + 'Request failed with 500 Internal Server Error, ', + ); + }); + + describe('proxy', () => { + beforeEach(() => { + worker.use( + rest.get( + 'http://localhost:1234/api/kubernetes/clusters', + (_, res, ctx) => + res( + ctx.json({ items: [{ name: 'cluster-a', authProvider: 'aws' }] }), + ), + ), + ); + }); + + it('hits the /proxy API', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({ + token: 'k8-token', + }); + const nsResponse = { + kind: 'Namespace', + apiVersion: 'v1', + metadata: { + name: 'new-ns', + }, + }; + worker.use( + rest.get( + 'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces', + (req, res, ctx) => + res( + req.headers.get('Backstage-Kubernetes-Authorization') === + 'Bearer k8-token' + ? ctx.json(nsResponse) + : ctx.status(403), + ), + ), + ); + + const request = { + clusterName: 'cluster-a', + path: '/api/v1/namespaces', + }; + + const response = await backendClient.proxy(request); + + await expect(response.json()).resolves.toStrictEqual(nsResponse); + }); + + it('hits /proxy api when signed in as a guest', async () => { + // when a user is signed in as a guest the result of the getCredentials() method resolves to the {} value. + identityApi.getCredentials.mockResolvedValue({}); + kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({ + token: 'k8-token', + }); + const nsResponse = { + kind: 'Namespace', + apiVersion: 'v1', + metadata: { + name: 'new-ns', + }, + }; + worker.use( + rest.get( + 'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces', + (req, res, ctx) => + res( + req.headers.get('Backstage-Kubernetes-Authorization') === + 'Bearer k8-token' + ? ctx.json(nsResponse) + : ctx.status(403), + ), + ), + ); + + const request = { + clusterName: 'cluster-a', + path: '/api/v1/namespaces', + }; + + const response = await backendClient.proxy(request); + await expect(response.json()).resolves.toStrictEqual(nsResponse); + }); + + it('/proxy API throws a 404 error', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({ + token: 'k8-token', + }); + worker.use( + rest.get( + 'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces', + (_, res, ctx) => res(ctx.status(404)), + ), + ); + + const request = { + clusterName: 'cluster-a', + path: '/api/v1/namespaces', + }; + + const response = await backendClient.proxy(request); + + expect(response.status).toEqual(404); + }); + + it('throws a ERROR_NOT_FOUND if the cluster in the request is not found', async () => { + identityApi.getCredentials.mockResolvedValue({ token: 'idToken' }); + + const request = { + clusterName: 'cluster-b', + path: '/api/v1/namespaces', + }; + + await expect(backendClient.proxy(request)).rejects.toThrow(NotFoundError); + }); + + it('responds with an 403 error when invalid k8 token is provided', async () => { + // when a user is signed in as a guest the result of the getCredentials() method resolves to the {} value. + identityApi.getCredentials.mockResolvedValue({}); + kubernetesAuthProvidersApi.getCredentials.mockResolvedValue({ + token: 'wrong-token', + }); + + const nsResponse = { + kind: 'Namespace', + apiVersion: 'v1', + metadata: { + name: 'new-ns', + }, + }; + worker.use( + rest.get( + 'http://localhost:1234/api/kubernetes/proxy/api/v1/namespaces', + (req, res, ctx) => + res( + req.headers.get('Backstage-Kubernetes-Authorization') === + 'Bearer k8-token' + ? ctx.json(nsResponse) + : ctx.status(403), + ), + ), + ); + + const request = { + clusterName: 'cluster-a', + path: '/api/v1/namespaces', + }; + + const response = await backendClient.proxy(request); + expect(response.status).toEqual(403); + }); }); }); diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.ts index dbeb1c3035..bc510589c8 100644 --- a/plugins/kubernetes/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.ts @@ -87,8 +87,10 @@ export class KubernetesBackendClient implements KubernetesApi { return cluster; } - private async getBearerToken(authProvider: string): Promise { - return await this.kubernetesAuthProvidersApi.getBearerToken(authProvider); + private async getCredentials( + authProvider: string, + ): Promise<{ token: string }> { + return await this.kubernetesAuthProvidersApi.getCredentials(authProvider); } async getObjectsByEntity( @@ -138,25 +140,20 @@ export class KubernetesBackendClient implements KubernetesApi { init?: RequestInit; }): Promise { const { authProvider } = await this.getCluster(options.clusterName); - const k8sToken = await this.getBearerToken(authProvider); + const { token: k8sToken } = await this.getCredentials(authProvider); const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/proxy${ options.path }`; const identityResponse = await this.identityApi.getCredentials(); - const headers = identityResponse.token - ? { - ...options.init?.headers, - [`Backstage-Kubernetes-Cluster`]: options.clusterName, - [`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`, - Authorization: `Bearer ${identityResponse.token}`, - } - : { - ...options.init?.headers, - [`Backstage-Kubernetes-Cluster`]: options.clusterName, - [`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`, - }; - const response = await fetch(url, { ...options.init, headers }); + const headers = { + ...options.init?.headers, + [`Backstage-Kubernetes-Cluster`]: options.clusterName, + [`Backstage-Kubernetes-Authorization`]: `Bearer ${k8sToken}`, + ...(identityResponse.token && { + Authorization: `Bearer ${identityResponse.token}`, + }), + }; - return this.handleResponse(response); + return await fetch(url, { ...options.init, headers }); } } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts index 4b46884b39..5652549b8a 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/GoogleKubernetesAuthProvider.ts @@ -28,7 +28,7 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { async decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise { - const googleAuthToken: string = await this.getBearerToken(); + const googleAuthToken: string = (await this.getCredentials()).token; if ('auth' in requestBody) { requestBody.auth!.google = googleAuthToken; } else { @@ -36,9 +36,11 @@ export class GoogleKubernetesAuthProvider implements KubernetesAuthProvider { } return requestBody; } - async getBearerToken(): Promise { - return await this.authProvider.getAccessToken( - 'https://www.googleapis.com/auth/cloud-platform', - ); + async getCredentials(): Promise<{ token: string }> { + return { + token: await this.authProvider.getAccessToken( + 'https://www.googleapis.com/auth/cloud-platform', + ), + }; } } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts index 45ba243d23..1873376487 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/KubernetesAuthProviders.ts @@ -92,12 +92,12 @@ export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { ); } - async getBearerToken(authProvider: string): Promise { + async getCredentials(authProvider: string): Promise<{ token: string }> { const kubernetesAuthProvider: KubernetesAuthProvider | undefined = this.kubernetesAuthProviderMap.get(authProvider); if (kubernetesAuthProvider) { - return await kubernetesAuthProvider.getBearerToken(); + return await kubernetesAuthProvider.getCredentials(); } if (authProvider.startsWith('oidc.')) { diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts index 9d9d34fe1d..bd5760bde2 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/OidcKubernetesAuthProvider.ts @@ -30,7 +30,7 @@ export class OidcKubernetesAuthProvider implements KubernetesAuthProvider { async decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise { - const authToken: string = await this.getBearerToken(); + const authToken: string = (await this.getCredentials()).token; const auth = { ...requestBody.auth }; if (auth.oidc) { auth.oidc[this.providerName] = authToken; @@ -41,7 +41,9 @@ export class OidcKubernetesAuthProvider implements KubernetesAuthProvider { return requestBody; } - async getBearerToken(): Promise { - return await this.authProvider.getIdToken(); + async getCredentials(): Promise<{ token: string }> { + return { + token: await this.authProvider.getIdToken(), + }; } } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/ServerSideAuthProvider.ts b/plugins/kubernetes/src/kubernetes-auth-provider/ServerSideAuthProvider.ts index 9290a2eda3..8194f1e213 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/ServerSideAuthProvider.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/ServerSideAuthProvider.ts @@ -32,7 +32,7 @@ export class ServerSideKubernetesAuthProvider return requestBody; } - async getBearerToken(): Promise { - return ''; + async getCredentials(): Promise<{ token: string }> { + return { token: '' }; } } diff --git a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts index 395db3c1bd..117c966d36 100644 --- a/plugins/kubernetes/src/kubernetes-auth-provider/types.ts +++ b/plugins/kubernetes/src/kubernetes-auth-provider/types.ts @@ -21,7 +21,7 @@ export interface KubernetesAuthProvider { decorateRequestBodyForAuth( requestBody: KubernetesRequestBody, ): Promise; - getBearerToken(): Promise; + getCredentials(): Promise<{ token: string }>; } export const kubernetesAuthProvidersApiRef = @@ -34,5 +34,5 @@ export interface KubernetesAuthProvidersApi { authProvider: string, requestBody: KubernetesRequestBody, ): Promise; - getBearerToken(authProvider: string): Promise; + getCredentials(authProvider: string): Promise<{ token: string }>; } From 7917cfccfc7fe5e70aeb464e5e490a578290ecf7 Mon Sep 17 00:00:00 2001 From: Axel Hecht Date: Tue, 4 Apr 2023 19:58:50 +0200 Subject: [PATCH 031/162] Use page theme fontColor in scaffolder, episode 2 I missed some white fonts in #16713 Signed-off-by: Axel Hecht --- .changeset/famous-olives-try.md | 5 +++++ .../src/components/TemplateCard/TemplateCard.tsx | 2 +- .../scaffolder/src/next/OngoingTask/ContextMenu.tsx | 10 ++++++---- 3 files changed, 12 insertions(+), 5 deletions(-) create mode 100644 .changeset/famous-olives-try.md diff --git a/.changeset/famous-olives-try.md b/.changeset/famous-olives-try.md new file mode 100644 index 0000000000..3150197203 --- /dev/null +++ b/.changeset/famous-olives-try.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Fix some hard-coded white font colors in scaffolder diff --git a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx index 825317ef73..d35636f507 100644 --- a/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx +++ b/plugins/scaffolder/src/components/TemplateCard/TemplateCard.tsx @@ -107,7 +107,7 @@ const useStyles = makeStyles< top: theme.spacing(0.5), right: theme.spacing(0.5), padding: '0.25rem', - color: theme.palette.common.white, + color: ({ fontColor }) => fontColor, }, })); diff --git a/plugins/scaffolder/src/next/OngoingTask/ContextMenu.tsx b/plugins/scaffolder/src/next/OngoingTask/ContextMenu.tsx index 985dd98903..21db9f5b08 100644 --- a/plugins/scaffolder/src/next/OngoingTask/ContextMenu.tsx +++ b/plugins/scaffolder/src/next/OngoingTask/ContextMenu.tsx @@ -22,6 +22,7 @@ import { MenuItem, MenuList, Popover, + useTheme, } from '@material-ui/core'; import { useAsync } from '@react-hookz/web'; import Cancel from '@material-ui/icons/Cancel'; @@ -40,16 +41,18 @@ type ContextMenuProps = { taskId?: string; }; -const useStyles = makeStyles((theme: BackstageTheme) => ({ +const useStyles = makeStyles(() => ({ button: { - color: theme.palette.common.white, + color: ({ fontColor }) => fontColor, }, })); export const ContextMenu = (props: ContextMenuProps) => { const { cancelEnabled, logsVisible, onStartOver, onToggleLogs, taskId } = props; - const classes = useStyles(); + const { getPageTheme } = useTheme(); + const pageTheme = getPageTheme({ themeId: 'website' }); + const classes = useStyles({ fontColor: pageTheme.fontColor }); const scaffolderApi = useApi(scaffolderApiRef); const [anchorEl, setAnchorEl] = useState(); @@ -69,7 +72,6 @@ export const ContextMenu = (props: ContextMenuProps) => { setAnchorEl(event.currentTarget); }} data-testid="menu-button" - color="inherit" className={classes.button} > From 046e04a7cf4076ff30873dac6202bde98f3db35b Mon Sep 17 00:00:00 2001 From: Aramis Sennyey Date: Tue, 10 Jan 2023 13:07:12 -0500 Subject: [PATCH 032/162] Translated all endpoints to the new format, seeking feedback. Signed-off-by: Aramis Sennyey --- plugins/catalog-backend/package.json | 2 + .../src/service/createRouter.ts | 301 +++++++++--------- yarn.lock | 187 ++++++++++- 3 files changed, 331 insertions(+), 159 deletions(-) diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index db9e1845fe..65a3232e69 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -65,11 +65,13 @@ "codeowners-utils": "^1.0.2", "core-js": "^3.6.5", "express": "^4.17.1", + "express-openapi": "^12.1.0", "express-promise-router": "^4.1.0", "fast-json-stable-stringify": "^2.1.0", "fs-extra": "10.1.0", "git-url-parse": "^13.0.0", "glob": "^7.1.6", + "js-yaml": "^4.1.0", "knex": "^2.0.0", "lodash": "^4.17.21", "luxon": "^3.0.0", diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 849096c601..6b733a0a9a 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -23,7 +23,7 @@ import { stringifyEntityRef, } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; -import { NotFoundError, serializeError } from '@backstage/errors'; +import { InputError, NotFoundError, serializeError } from '@backstage/errors'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; @@ -50,6 +50,16 @@ import { locationInput, validateRequestBody, } from './util'; +import { initialize } from 'express-openapi'; +import yaml from 'js-yaml'; +import fs from 'fs'; +import path from 'path'; + +class ParsingError extends Error { + toString() { + return `ParsingError: ${this.message}`; + } +} /** * Options used by {@link createRouter}. @@ -94,26 +104,41 @@ export async function createRouter( logger.info('Catalog is running in readonly mode'); } - if (refreshService) { - router.post('/refresh', async (req, res) => { - const refreshOptions: RefreshOptions = req.body; - refreshOptions.authorizationToken = getBearerToken( - req.header('authorization'), - ); + const validateDependency = ( + dependency: any, + next: (req: express.Request, res: express.Response) => any, + ) => { + return (req: express.Request, res: express.Response) => { + if (!dependency) { + console.log('no dependency'); + throw new NotFoundError( + 'Dependency Error', + 'Dependency not set up for this endpoint.', + ); + } + return next(req, res); + }; + }; - await refreshService.refresh(refreshOptions); - res.status(200).end(); - }); - } + initialize({ + app: router as any, + // NOTE: If using yaml you can provide a path relative to process.cwd() e.g. + // apiDoc: './api-v1/api-doc.yml', + apiDoc: yaml.load( + fs.readFileSync(path.resolve(__dirname, '../../openapi.yaml'), 'utf-8'), + ) as any, + operations: { + RefreshEntity: validateDependency(refreshService, async (req, res) => { + const refreshOptions: RefreshOptions = req.body; + refreshOptions.authorizationToken = getBearerToken( + req.header('authorization'), + ); - if (permissionIntegrationRouter) { - router.use(permissionIntegrationRouter); - } - - if (entitiesCatalog) { - router - .get('/entities', async (req, res) => { - const { entities, pageInfo } = await entitiesCatalog.entities({ + await refreshService!.refresh(refreshOptions); + res.status(200).end(); + }), + GetEntities: validateDependency(entitiesCatalog, async (req, res) => { + const { entities, pageInfo } = await entitiesCatalog!.entities({ filter: parseEntityFilterParams(req.query), fields: parseEntityTransformParams(req.query), order: parseEntityOrderParams(req.query), @@ -131,30 +156,10 @@ export async function createRouter( // TODO(freben): encode the pageInfo in the response res.json(entities); - }) - .get('/entities/by-query', async (req, res) => { - const { items, pageInfo, totalItems } = - await entitiesCatalog.queryEntities({ - ...parseQueryEntitiesParams(req.query), - authorizationToken: getBearerToken(req.header('authorization')), - }); - - res.json({ - items, - totalItems, - pageInfo: { - ...(pageInfo.nextCursor && { - nextCursor: encodeCursor(pageInfo.nextCursor), - }), - ...(pageInfo.prevCursor && { - prevCursor: encodeCursor(pageInfo.prevCursor), - }), - }, - }); - }) - .get('/entities/by-uid/:uid', async (req, res) => { + }), + GetEntityByUid: validateDependency(entitiesCatalog, async (req, res) => { const { uid } = req.params; - const { entities } = await entitiesCatalog.entities({ + const { entities } = await entitiesCatalog!.entities({ filter: basicEntityFilter({ 'metadata.uid': uid }), authorizationToken: getBearerToken(req.header('authorization')), }); @@ -162,17 +167,10 @@ export async function createRouter( throw new NotFoundError(`No entity with uid ${uid}`); } res.status(200).json(entities[0]); - }) - .delete('/entities/by-uid/:uid', async (req, res) => { - const { uid } = req.params; - await entitiesCatalog.removeEntityByUid(uid, { - authorizationToken: getBearerToken(req.header('authorization')), - }); - res.status(204).end(); - }) - .get('/entities/by-name/:kind/:namespace/:name', async (req, res) => { + }), + GetEntityByName: validateDependency(entitiesCatalog, async (req, res) => { const { kind, namespace, name } = req.params; - const { entities } = await entitiesCatalog.entities({ + const { entities } = await entitiesCatalog!.entities({ filter: basicEntityFilter({ kind: kind, 'metadata.namespace': namespace, @@ -186,41 +184,27 @@ export async function createRouter( ); } res.status(200).json(entities[0]); - }) - .get( - '/entities/by-name/:kind/:namespace/:name/ancestry', + }), + GetEntityAncestryByName: validateDependency( + entitiesCatalog, async (req, res) => { const { kind, namespace, name } = req.params; const entityRef = stringifyEntityRef({ kind, namespace, name }); - const response = await entitiesCatalog.entityAncestry(entityRef, { + const response = await entitiesCatalog!.entityAncestry(entityRef, { authorizationToken: getBearerToken(req.header('authorization')), }); res.status(200).json(response); }, - ) - .post('/entities/by-refs', async (req, res) => { - const request = entitiesBatchRequest(req); - const token = getBearerToken(req.header('authorization')); - const response = await entitiesCatalog.entitiesBatch({ - entityRefs: request.entityRefs, - fields: parseEntityTransformParams(req.query, request.fields), - authorizationToken: token, - }); - res.status(200).json(response); - }) - .get('/entity-facets', async (req, res) => { - const response = await entitiesCatalog.facets({ + ), + GetEntityFacets: validateDependency(entitiesCatalog, async (req, res) => { + const response = await entitiesCatalog!.facets({ filter: parseEntityFilterParams(req.query), facets: parseEntityFacetParams(req.query), authorizationToken: getBearerToken(req.header('authorization')), }); res.status(200).json(response); - }); - } - - if (locationService) { - router - .post('/locations', async (req, res) => { + }), + CreateLocation: validateDependency(locationService, async (req, res) => { const location = await validateRequestBody(req, locationInput); const dryRun = yn(req.query.dryRun, { default: false }); @@ -234,22 +218,21 @@ export async function createRouter( authorizationToken: getBearerToken(req.header('authorization')), }); res.status(201).json(output); - }) - .get('/locations', async (req, res) => { + }), + GetLocations: validateDependency(locationService, async (req, res) => { const locations = await locationService.listLocations({ authorizationToken: getBearerToken(req.header('authorization')), }); res.status(200).json(locations.map(l => ({ data: l }))); - }) - - .get('/locations/:id', async (req, res) => { + }), + GetLocation: validateDependency(locationService, async (req, res) => { const { id } = req.params; const output = await locationService.getLocation(id, { authorizationToken: getBearerToken(req.header('authorization')), }); res.status(200).json(output); - }) - .delete('/locations/:id', async (req, res) => { + }), + DeleteLocation: validateDependency(locationService, async (req, res) => { disallowReadonlyMode(readonlyEnabled); const { id } = req.params; @@ -257,74 +240,108 @@ export async function createRouter( authorizationToken: getBearerToken(req.header('authorization')), }); res.status(204).end(); - }); - } - - if (locationAnalyzer) { - router.post('/analyze-location', async (req, res) => { - const body = await validateRequestBody( - req, - z.object({ + }), + AnalyzeLocation: validateDependency(locationService, async (req, res) => { + const body = await validateRequestBody( + req, + z.object({ + location: locationInput, + catalogFilename: z.string().optional(), + }), + ); + const schema = z.object({ location: locationInput, catalogFilename: z.string().optional(), - }), - ); - const schema = z.object({ - location: locationInput, - catalogFilename: z.string().optional(), - }); - const output = await locationAnalyzer.analyzeLocation(schema.parse(body)); - res.status(200).json(output); - }); - } - - if (orchestrator) { - router.post('/validate-entity', async (req, res) => { - const bodySchema = z.object({ - entity: z.unknown(), - location: z.string(), - }); - - let body: z.infer; - let entity: Entity; - let location: { type: string; target: string }; - try { - body = await validateRequestBody(req, bodySchema); - entity = validateEntityEnvelope(body.entity); - location = parseLocationRef(body.location); - if (location.type !== 'url') - throw new TypeError( - `Invalid location ref ${body.location}, only 'url:' is supported, e.g. url:https://host/path`, - ); - } catch (err) { - return res.status(400).json({ - errors: [serializeError(err)], }); - } + const output = await locationAnalyzer!.analyzeLocation( + schema.parse(body), + ); + res.status(200).json(output); + }), + ValidateEntity: validateDependency(orchestrator, async (req, res) => { + const bodySchema = z.object({ + entity: z.unknown(), + location: z.string(), + }); - const processingResult = await orchestrator.process({ - entity: { - ...entity, - metadata: { - ...entity.metadata, - annotations: { - [ANNOTATION_LOCATION]: body.location, - [ANNOTATION_ORIGIN_LOCATION]: body.location, - ...entity.metadata.annotations, + let body: z.infer; + let entity: Entity; + let location: { type: string; target: string }; + try { + body = await validateRequestBody(req, bodySchema); + entity = validateEntityEnvelope(body.entity); + location = parseLocationRef(body.location); + if (location.type !== 'url') + throw new TypeError( + `Invalid location ref ${body.location}, only 'url:' is supported, e.g. url:https://host/path`, + ); + } catch (err) { + return res.status(400).json({ + errors: [serializeError(err)], + }); + } + + const processingResult = await orchestrator!.process({ + entity: { + ...entity, + metadata: { + ...entity.metadata, + annotations: { + [ANNOTATION_LOCATION]: body.location, + [ANNOTATION_ORIGIN_LOCATION]: body.location, + ...entity.metadata.annotations, + }, }, }, - }, - }); - - if (!processingResult.ok) - res.status(400).json({ - errors: processingResult.errors.map(e => serializeError(e)), }); - return res.status(200).end(); - }); - } - router.use(errorHandler()); + if (!processingResult.ok) + res.status(400).json({ + errors: processingResult.errors.map(e => serializeError(e)), + }); + return res.status(200).end(); + }), + DeleteEntityByUid: validateDependency( + entitiesCatalog, + async (req, res) => { + const { uid } = req.params; + await entitiesCatalog!.removeEntityByUid(uid, { + authorizationToken: getBearerToken(req.header('authorization')), + }); + res.status(204).end(); + }, + ), + GetEntitiesByRefs: validateDependency( + entitiesCatalog, + async (req, res) => { + const request = entitiesBatchRequest(req); + const token = getBearerToken(req.header('authorization')); + const response = await entitiesCatalog!.entitiesBatch({ + entityRefs: request.entityRefs, + fields: parseEntityTransformParams(req.query, request.fields), + authorizationToken: token, + }); + res.status(200).json(response); + }, + ), + }, + enableObjectCoercion: true, + errorMiddleware: errorHandler(), + errorTransformer: (openapiError, ajvError) => { + switch (openapiError.errorCode) { + case 'type.openapi.requestValidation': + throw new InputError( + `Invalid field ${openapiError.path}`, + new ParsingError(openapiError.message), + ); + } + return {}; + }, + }); + + if (permissionIntegrationRouter) { + router.use(permissionIntegrationRouter); + } return router; } diff --git a/yarn.lock b/yarn.lock index 3652a87b61..859a44fda4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5371,11 +5371,13 @@ __metadata: codeowners-utils: ^1.0.2 core-js: ^3.6.5 express: ^4.17.1 + express-openapi: ^12.1.0 express-promise-router: ^4.1.0 fast-json-stable-stringify: ^2.1.0 fs-extra: 10.1.0 git-url-parse: ^13.0.0 glob: ^7.1.6 + js-yaml: ^4.1.0 knex: ^2.0.0 lodash: ^4.17.21 luxon: ^3.0.0 @@ -17590,7 +17592,7 @@ __metadata: languageName: node linkType: hard -"ajv-formats@npm:^2.1.1": +"ajv-formats@npm:^2.0.2, ajv-formats@npm:^2.1.0, ajv-formats@npm:^2.1.1": version: 2.1.1 resolution: "ajv-formats@npm:2.1.1" dependencies: @@ -17636,7 +17638,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.0.0, ajv@npm:^8.10.0, ajv@npm:^8.11.0, ajv@npm:^8.12.0, ajv@npm:^8.8.0": +"ajv@npm:^8.0.0, ajv@npm:^8.1.0, ajv@npm:^8.10.0, ajv@npm:^8.11.0, ajv@npm:^8.12.0, ajv@npm:^8.3.0, ajv@npm:^8.4.0, ajv@npm:^8.8.0": version: 8.12.0 resolution: "ajv@npm:8.12.0" dependencies: @@ -20392,6 +20394,13 @@ __metadata: languageName: node linkType: hard +"content-type@npm:^1.0.4": + version: 1.0.4 + resolution: "content-type@npm:1.0.4" + checksum: 3d93585fda985d1554eca5ebd251994327608d2e200978fdbfba21c0c679914d5faf266d17027de44b34a72c7b0745b18584ecccaa7e1fdfb6a68ac7114f12e0 + languageName: node + linkType: hard + "content-type@npm:~1.0.4, content-type@npm:~1.0.5": version: 1.0.5 resolution: "content-type@npm:1.0.5" @@ -21787,6 +21796,15 @@ __metadata: languageName: node linkType: hard +"difunc@npm:0.0.4": + version: 0.0.4 + resolution: "difunc@npm:0.0.4" + dependencies: + esprima: ^4.0.0 + checksum: 19b850dae20cba3bdf238b85fa90f79526974e7634348a3157eef3da6eb161e7f43142aa531f6be61eed1235d1f4b6a16e07912f313b5afcceee56d998c2e300 + languageName: node + linkType: hard + "dir-glob@npm:^3.0.1": version: 3.0.1 resolution: "dir-glob@npm:3.0.1" @@ -23687,6 +23705,24 @@ __metadata: languageName: node linkType: hard +"express-normalize-query-params-middleware@npm:^0.5.0": + version: 0.5.1 + resolution: "express-normalize-query-params-middleware@npm:0.5.1" + checksum: 20fef3a200c452cbfef25f738e8ed44704ad30f04899cce21d3984b74e4fcbeefdc5dcec3aed1a0e4e54f231c55f3cba2c3d0cc3faebfea0cab437a852bca09b + languageName: node + linkType: hard + +"express-openapi@npm:^12.1.0": + version: 12.1.0 + resolution: "express-openapi@npm:12.1.0" + dependencies: + express-normalize-query-params-middleware: ^0.5.0 + openapi-framework: ^12.1.0 + openapi-types: ^12.1.0 + checksum: 6b87b7990891b1f4d4bd3e071eddd72d7433ae770d5ad0cfbbee234a0d4e712150640ec15d8fe9d1af99b49209700c58f4694045f00bd380a569a86ba5008755 + languageName: node + linkType: hard + "express-prom-bundle@npm:^6.3.6": version: 6.6.0 resolution: "express-prom-bundle@npm:6.6.0" @@ -24584,6 +24620,15 @@ __metadata: languageName: node linkType: hard +"fs-routes@npm:^12.0.0": + version: 12.0.0 + resolution: "fs-routes@npm:12.0.0" + peerDependencies: + glob: ">=7.1.6" + checksum: 4e97b08584c4ee04cf00215ac5175ba6f3aeaa4fb254cd108149d675edffcf90e802ead9965a5252271f26e1a47b31c341b41e209b9d210b2a584ae2a642c466 + languageName: node + linkType: hard + "fs.realpath@npm:^1.0.0": version: 1.0.0 resolution: "fs.realpath@npm:1.0.0" @@ -24905,6 +24950,19 @@ __metadata: languageName: node linkType: hard +"glob@npm:*, glob@npm:8.0.3": + version: 8.0.3 + resolution: "glob@npm:8.0.3" + dependencies: + fs.realpath: ^1.0.0 + inflight: ^1.0.4 + inherits: 2 + minimatch: ^5.0.1 + once: ^1.3.0 + checksum: 50bcdea19d8e79d8de5f460b1939ffc2b3299eac28deb502093fdca22a78efebc03e66bf54f0abc3d3d07d8134d19a32850288b7440d77e072aa55f9d33b18c5 + languageName: node + linkType: hard + "glob@npm:7.1.6": version: 7.1.6 resolution: "glob@npm:7.1.6" @@ -24919,19 +24977,6 @@ __metadata: languageName: node linkType: hard -"glob@npm:8.0.3": - version: 8.0.3 - resolution: "glob@npm:8.0.3" - dependencies: - fs.realpath: ^1.0.0 - inflight: ^1.0.4 - inherits: 2 - minimatch: ^5.0.1 - once: ^1.3.0 - checksum: 50bcdea19d8e79d8de5f460b1939ffc2b3299eac28deb502093fdca22a78efebc03e66bf54f0abc3d3d07d8134d19a32850288b7440d77e072aa55f9d33b18c5 - languageName: node - linkType: hard - "glob@npm:^7.0.0, glob@npm:^7.1.1, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6, glob@npm:^7.1.7, glob@npm:^7.2.0": version: 7.2.3 resolution: "glob@npm:7.2.3" @@ -26591,6 +26636,13 @@ __metadata: languageName: node linkType: hard +"is-dir@npm:^1.0.0": + version: 1.0.0 + resolution: "is-dir@npm:1.0.0" + checksum: b81430f22d03318b144391ec9633abdd6fdcd8fd02509bee1ab5a20b79311505ba4344282d2cab565114779ac36626fd034168b9bd47c3be12d956ae2ca76a5c + languageName: node + linkType: hard + "is-docker@npm:^2.0.0, is-docker@npm:^2.1.1": version: 2.2.1 resolution: "is-docker@npm:2.2.1" @@ -29360,7 +29412,7 @@ __metadata: languageName: node linkType: hard -"lodash.merge@npm:^4.6.2": +"lodash.merge@npm:^4.6.1, lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" checksum: ad580b4bdbb7ca1f7abf7e1bce63a9a0b98e370cf40194b03380a46b4ed799c9573029599caebc1b14e3f24b111aef72b96674a56cfa105e0f5ac70546cdc005 @@ -31924,6 +31976,79 @@ __metadata: languageName: node linkType: hard +"openapi-default-setter@npm:^12.1.0": + version: 12.1.0 + resolution: "openapi-default-setter@npm:12.1.0" + dependencies: + openapi-types: ^12.1.0 + checksum: 3b4543d7c091134690fcfc986258481b6211f17b4322552764b3a4a9725784202bc17419a524302dd1187ecea556dea4f2cb525f4a0258c2ab5ddec884bf00e2 + languageName: node + linkType: hard + +"openapi-framework@npm:^12.1.0": + version: 12.1.0 + resolution: "openapi-framework@npm:12.1.0" + dependencies: + difunc: 0.0.4 + fs-routes: ^12.0.0 + glob: "*" + is-dir: ^1.0.0 + js-yaml: ^3.10.0 + openapi-default-setter: ^12.1.0 + openapi-request-coercer: ^12.1.0 + openapi-request-validator: ^12.1.0 + openapi-response-validator: ^12.1.0 + openapi-schema-validator: ^12.1.0 + openapi-security-handler: ^12.1.0 + openapi-types: ^12.1.0 + ts-log: ^2.1.4 + checksum: 4cbb35e2870604c91c01f397aa2ea8fb072f648acb9a47b830d0d67c1e68bb75eadc3e97bcbe2ea994b7374ba38866b6d91734df6e2481ce42e6625d66ccc5bd + languageName: node + linkType: hard + +"openapi-jsonschema-parameters@npm:^12.1.0": + version: 12.1.0 + resolution: "openapi-jsonschema-parameters@npm:12.1.0" + dependencies: + openapi-types: ^12.1.0 + checksum: 9465549e132d02ad4be67326e22c9a2e3f0cf19ffa0e3c1fe69a542a675707f11c33080836b0eebd75c02501dbcb5f4130fa446d470ede3ba67b7c52e1bc341f + languageName: node + linkType: hard + +"openapi-request-coercer@npm:^12.1.0": + version: 12.1.0 + resolution: "openapi-request-coercer@npm:12.1.0" + dependencies: + openapi-types: ^12.1.0 + ts-log: ^2.1.4 + checksum: 4359d4fd58b032146838aabb9762fc2ac01771e7bea4679af5a241964f3df15ddced7968e6a8d419954d166e93ad32e08f530da0d9a8483d3c4d97be7c7c848c + languageName: node + linkType: hard + +"openapi-request-validator@npm:^12.1.0": + version: 12.1.0 + resolution: "openapi-request-validator@npm:12.1.0" + dependencies: + ajv: ^8.3.0 + ajv-formats: ^2.1.0 + content-type: ^1.0.4 + openapi-jsonschema-parameters: ^12.1.0 + openapi-types: ^12.1.0 + ts-log: ^2.1.4 + checksum: 794b1c15dbf04107ede7b8304e5b7c2fa183ad0c4bd65b3e0ee4d415cf508324932959f661120f1bfb58a23d88b5458dc0db868bc36f9ec44ea03b99ea9d42df + languageName: node + linkType: hard + +"openapi-response-validator@npm:^12.1.0": + version: 12.1.0 + resolution: "openapi-response-validator@npm:12.1.0" + dependencies: + ajv: ^8.4.0 + openapi-types: ^12.1.0 + checksum: f07036661ac846c5d508b423d01039cd5bd33d564e053fdc039046f964fad71b03a7c9254195b1d5a8a1912fcc45dce13616af97c445c6d7b040c825e1f91de1 + languageName: node + linkType: hard + "openapi-sampler@npm:^1.2.1": version: 1.2.1 resolution: "openapi-sampler@npm:1.2.1" @@ -31934,7 +32059,28 @@ __metadata: languageName: node linkType: hard -"openapi-types@npm:^12.0.0": +"openapi-schema-validator@npm:^12.1.0": + version: 12.1.0 + resolution: "openapi-schema-validator@npm:12.1.0" + dependencies: + ajv: ^8.1.0 + ajv-formats: ^2.0.2 + lodash.merge: ^4.6.1 + openapi-types: ^12.1.0 + checksum: 1008ef82910cc30e85132c041b269a4cc2991dad17e2d7c4cddbfb5641a15e934016bc4d1bab32f2dc79f7f3673b82b655c3634838c9ce3a8f497af3be762aa5 + languageName: node + linkType: hard + +"openapi-security-handler@npm:^12.1.0": + version: 12.1.0 + resolution: "openapi-security-handler@npm:12.1.0" + dependencies: + openapi-types: ^12.1.0 + checksum: 07f645300fdc0ca5d01790fb77e01d8e537f6b0439f55ec9af5097d3dd044e3e0fc55ce0acb9551f04256819d7738f8c3d468645e581f7b0487648eed428d519 + languageName: node + linkType: hard + +"openapi-types@npm:^12.0.0, openapi-types@npm:^12.1.0": version: 12.1.0 resolution: "openapi-types@npm:12.1.0" checksum: d8f3e2bae519aa6bcf2012f4a5592b7283fe928c06f3bdc182776ce637c7ed8d5f9f342724f68c95c9572aefe21ed5e89b18a0295a703da61af5f97bd2aea9a7 @@ -38457,6 +38603,13 @@ __metadata: languageName: node linkType: hard +"ts-log@npm:^2.1.4": + version: 2.2.5 + resolution: "ts-log@npm:2.2.5" + checksum: 28f78ab15b8555d56c089dbc243327d8ce4331219956242a29fc4cb3bad6bb0cb8234dd17a292381a1b1dba99a7e4849a2181b2e1a303e8247e9f4ca4e284f2d + languageName: node + linkType: hard + "ts-log@npm:^2.2.3": version: 2.2.3 resolution: "ts-log@npm:2.2.3" From d7585d4003dc93f83f38fc0d266fe79399cd774f Mon Sep 17 00:00:00 2001 From: Aramis Sennyey Date: Thu, 19 Jan 2023 17:17:28 -0500 Subject: [PATCH 033/162] Starting router. Signed-off-by: Aramis Sennyey --- .../src/service/createRouter.ts | 18 + plugins/openapi-router-common/.eslintrc.js | 1 + plugins/openapi-router-common/README.md | 5 + plugins/openapi-router-common/package.json | 37 + plugins/openapi-router-common/src/index.ts | 23 + plugins/openapi-router-common/src/router.ts | 357 ++++++++++ .../openapi-router-common/src/setupTests.ts | 16 + .../openapi-router-common/src/types/fields.ts | 66 ++ .../openapi-router-common/src/types/path.ts | 666 ++++++++++++++++++ .../openapi-router-common/src/types/utils.ts | 144 ++++ yarn.lock | 43 +- 11 files changed, 1374 insertions(+), 2 deletions(-) create mode 100644 plugins/openapi-router-common/.eslintrc.js create mode 100644 plugins/openapi-router-common/README.md create mode 100644 plugins/openapi-router-common/package.json create mode 100644 plugins/openapi-router-common/src/index.ts create mode 100644 plugins/openapi-router-common/src/router.ts create mode 100644 plugins/openapi-router-common/src/setupTests.ts create mode 100644 plugins/openapi-router-common/src/types/fields.ts create mode 100644 plugins/openapi-router-common/src/types/path.ts create mode 100644 plugins/openapi-router-common/src/types/utils.ts diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 6b733a0a9a..6685332002 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -54,6 +54,7 @@ import { initialize } from 'express-openapi'; import yaml from 'js-yaml'; import fs from 'fs'; import path from 'path'; +import OpenAPIBackend from 'openapi-backend'; class ParsingError extends Error { toString() { @@ -104,6 +105,23 @@ export async function createRouter( logger.info('Catalog is running in readonly mode'); } + // create api with your definition file or object + const api = new OpenAPIBackend({ definition: './petstore.yml' }); + + // register your framework specific request handlers here + api.register({ + getPets: (c, req, res) => { + return res.status(200).json({ result: 'ok' }); + }, + getPetById: (c, req, res) => res.status(200).json({ result: 'ok' }), + validationFail: (c, req, res) => + res.status(400).json({ err: c.validation.errors }), + notFound: (c, req, res) => res.status(404).json({ err: 'not found' }), + }); + + // initalize the backend + api.init(); + const validateDependency = ( dependency: any, next: (req: express.Request, res: express.Response) => any, diff --git a/plugins/openapi-router-common/.eslintrc.js b/plugins/openapi-router-common/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/openapi-router-common/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/openapi-router-common/README.md b/plugins/openapi-router-common/README.md new file mode 100644 index 0000000000..cda5d7fc7f --- /dev/null +++ b/plugins/openapi-router-common/README.md @@ -0,0 +1,5 @@ +# @backstage/plugin-openapi-router-common + +Welcome to the common package for the openapi-router plugin! + +_This plugin was created through the Backstage CLI_ diff --git a/plugins/openapi-router-common/package.json b/plugins/openapi-router-common/package.json new file mode 100644 index 0000000000..a852edfcec --- /dev/null +++ b/plugins/openapi-router-common/package.json @@ -0,0 +1,37 @@ +{ + "name": "@backstage/plugin-openapi-router", + "description": "OpenAPI router with type completions.", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "devDependencies": { + "@backstage/cli": "workspace:^" + }, + "files": [ + "dist" + ], + "dependencies": { + "express": "^4.18.2", + "json-schema-to-ts": "^2.6.2", + "openapi-types": "^12.1.0" + } +} diff --git a/plugins/openapi-router-common/src/index.ts b/plugins/openapi-router-common/src/index.ts new file mode 100644 index 0000000000..7edca76f93 --- /dev/null +++ b/plugins/openapi-router-common/src/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Common functionalities for the openapi-router plugin. + * + * @packageDocumentation + */ + +export * from './router'; diff --git a/plugins/openapi-router-common/src/router.ts b/plugins/openapi-router-common/src/router.ts new file mode 100644 index 0000000000..c7940cdbd0 --- /dev/null +++ b/plugins/openapi-router-common/src/router.ts @@ -0,0 +1,357 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { OpenAPIV3_1 } from 'openapi-types'; +import { FieldValues } from './types/fields'; +import { + AppendNonBlankKey, + FieldPath, + FieldPathValue, + Path, +} from './types/path'; +import { IRouter, Router, IRouterMatcher } from 'express'; +import core, { + ParamsDictionary, + RequestHandler, +} from 'express-serve-static-core'; +import { FromSchema } from 'json-schema-to-ts'; + +type RouterFn = < + TFieldName extends FieldPath = FieldPath, +>( + name: TFieldName, +) => null; + +const doc = { + openapi: '3.1.0', + info: { + version: '1.0.0', + title: 'Swagger Petstore', + license: { + name: 'MIT', + url: 'https://opensource.org/licenses/MIT', + }, + }, + servers: [ + { + url: 'http://petstore.swagger.io/v1', + }, + ], + paths: { + '/pets': { + get: { + summary: 'List all pets', + operationId: 'listPets', + tags: ['pets'], + parameters: [ + { + name: 'limit', + in: 'query', + description: 'How many items to return at one time (max 100)', + required: false, + schema: { + type: 'integer', + format: 'int32', + }, + }, + ], + responses: { + '200': { + description: 'A paged array of pets', + headers: { + 'x-next': { + description: 'A link to the next page of responses', + schema: { + type: 'string', + }, + }, + }, + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Pets', + }, + }, + }, + }, + default: { + description: 'unexpected error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + }, + }, + }, + }, + }, + post: { + summary: 'Create a pet', + operationId: 'createPets', + tags: ['pets'], + responses: { + '201': { + description: 'Null response', + }, + default: { + description: 'unexpected error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + }, + }, + }, + }, + }, + }, + '/pets/{petId}': { + get: { + summary: 'Info for a specific pet', + operationId: 'showPetById', + tags: ['pets'], + parameters: [ + { + name: 'petId', + in: 'path', + required: true, + description: 'The id of the pet to retrieve', + schema: { + type: 'string', + }, + }, + ], + responses: { + '200': { + description: 'Expected response to a valid request', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Pet', + }, + }, + }, + }, + default: { + description: 'unexpected error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Pet: { + type: 'object', + required: ['id', 'name'], + properties: { + id: { + type: 'integer', + format: 'int64', + }, + name: { + type: 'string', + }, + tag: { + type: 'string', + }, + }, + }, + Pets: { + type: 'array', + items: { + $ref: '#/components/schemas/Pet', + }, + }, + Error: { + type: 'object', + required: ['code', 'message'], + properties: { + code: { + type: 'integer', + format: 'int32', + }, + message: { + type: 'string', + }, + }, + }, + }, + }, +} as const; + +const isUndefined = (a: any): a is undefined => a === undefined; +const isNull = (a: any): a is null => a === null; + +const isNullOrUndefined = (a: any): a is undefined | null => + isUndefined(a) || isNull(a); + +const isDateObject = (value: unknown): value is Date => value instanceof Date; + +const isObjectType = (value: unknown) => typeof value === 'object'; + +const isObject = (value: unknown): value is T => + !isNullOrUndefined(value) && + !Array.isArray(value) && + isObjectType(value) && + !isDateObject(value); + +const compact = (value: TValue[]) => + Array.isArray(value) ? value.filter(Boolean) : []; + +export const resolve = ( + obj: T, + path: string, + defaultValue?: unknown, +): any => { + if (!path || !isObject(obj)) { + return defaultValue; + } + + const result = compact(path.split(/[,[\].]+?/)).reduce( + (result, key) => + isNullOrUndefined(result) ? result : result[key as keyof {}], + obj, + ); + if (result === undefined || result === obj) { + return obj[path as keyof T] === undefined + ? defaultValue + : obj[path as keyof T]; + } + return result; +}; + +/** + * We want this to input path and have + * @param name + * @returns + */ +const x: RouterFn = name => { + console.log(resolve(doc, name)); + return null; +}; + +x('/pets/{petId}'); + +type RemoveTail< + S extends string, + Tail extends string, +> = S extends `${infer P}${Tail}` ? P : S; +type GetRouteParameter = RemoveTail< + RemoveTail, `-${string}`>, + `.${string}` +>; +export type RouteParameters = string extends Route + ? ParamsDictionary + : Route extends `${string}(${string}` + ? ParamsDictionary + : Route extends `${string}:${infer Rest}` + ? (GetRouteParameter extends never + ? ParamsDictionary + : GetRouteParameter extends `${infer ParamName}?` + ? { [P in ParamName]?: string } + : { [P in GetRouteParameter]: string }) & + (Rest extends `${GetRouteParameter}${infer Next}` + ? RouteParameters + : unknown) + : {}; +interface ParsedQs { + [key: string]: undefined | string | string[] | ParsedQs | ParsedQs[]; +} +interface ApiRouterMatcher< + TFieldValues extends FieldValues, + T, + Method extends + | 'all' + | 'get' + | 'post' + | 'put' + | 'delete' + | 'patch' + | 'options' + | 'head', +> { + < + TFieldName extends Path = Path, + P = RouteParameters, + ResBody = any, + ReqBody = any, + ReqQuery = ParsedQs, + Locals extends Record = Record, + >( + // (it's used as the default type parameter for P) + path: TFieldName, + // (This generic is meant to be passed explicitly.) + ...handlers: Array> + ): T; +} + +type path = ApiRouterMatcher; +const test: path = a => { + console.log(a); +}; +test('/pets/{petId}'); + +export interface IApiRouter extends IRouter { + all: ApiRouterMatcher; + get: ApiRouterMatcher; +} + +export default class ApiRouter< + ApiSpec, + PathSpec extends OpenAPIV3_1.Document, + T, +> implements IApiRouter +{ + private _router = Router(); + + constructor(private spec: OpenAPIV3_1.Document) {} + + static fromSpec(spec: OpenAPIV3_1.Document) { + return new ApiRouter(spec); + } + + get< + TFieldValues extends FieldValues = PathSpec, + TFieldName extends FieldPath = FieldPath, + >( + path: TFieldName, + ...handlers: core.RequestHandler< + core.ParamsDictionary, + any, + FieldPathValue, 'get'>, + ParsedQs, + Record + >[] + ) { + console.log(path); + return this._router.get(path, ...handlers); + } +} + +const router = ApiRouter.fromSpec(doc); + +router.get('/pets', (req, res) => { + req.body.tags; +}); diff --git a/plugins/openapi-router-common/src/setupTests.ts b/plugins/openapi-router-common/src/setupTests.ts new file mode 100644 index 0000000000..4b9026cde5 --- /dev/null +++ b/plugins/openapi-router-common/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export {}; diff --git a/plugins/openapi-router-common/src/types/fields.ts b/plugins/openapi-router-common/src/types/fields.ts new file mode 100644 index 0000000000..e97eeace9d --- /dev/null +++ b/plugins/openapi-router-common/src/types/fields.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { IsFlatObject, Noop } from './utils'; + +export type InternalFieldName = string; + +export type FieldName = + IsFlatObject extends true + ? Extract + : string; + +export type CustomElement = { + name: FieldName; + type?: string; + value?: any; + disabled?: boolean; + checked?: boolean; + options?: HTMLOptionsCollection; + files?: FileList | null; + focus?: Noop; +}; + +export type FieldValue = + TFieldValues[InternalFieldName]; + +export type FieldValues = Record; + +export type NativeFieldValue = + | string + | number + | boolean + | null + | undefined + | unknown[]; + +export type FieldElement = + | HTMLInputElement + | HTMLSelectElement + | HTMLTextAreaElement + | CustomElement; + +export type Ref = FieldElement; + +export type Field = { + _f: { + ref: Ref; + name: InternalFieldName; + refs?: HTMLInputElement[]; + mount?: boolean; + }; +}; + +export type FieldRefs = Partial>; diff --git a/plugins/openapi-router-common/src/types/path.ts b/plugins/openapi-router-common/src/types/path.ts new file mode 100644 index 0000000000..c1ffbd6378 --- /dev/null +++ b/plugins/openapi-router-common/src/types/path.ts @@ -0,0 +1,666 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FieldValues } from './fields'; +import { + BrowserNativeObject, + IsAny, + IsNever, + IsEqual, + Primitive, +} from './utils'; + +/** + * Type alias to `string` which describes a lodash-like path through an object. + * E.g. `'foo.bar.0.baz'` + */ +export type PathString = string; + +/** + * Type which can be traversed through with a {@link PathString}. + * I.e. objects, arrays, and tuples + */ +export type Traversable = object; + +/** + * Type to query whether an array type T is a tuple type. + * @typeParam T - type which may be an array or tuple + * @example + * ``` + * IsTuple<[number]> = true + * IsTuple = false + * ``` + */ +export type IsTuple> = number extends T['length'] + ? false + : true; + +/** + * Type which can be used to index an array or tuple type. + */ +export type ArrayKey = number; + +/** + * Type which can be used to index an object. + */ +export type Key = string; + +/** + * Type to assert that a type is a {@link Key}. + * @typeParam T - type which may be a {@link Key} + */ +export type AsKey = Extract; + +/** + * Type to convert a type to a {@link Key}. + * @typeParam T - type which may be converted to a {@link Key} + */ +export type ToKey = T extends ArrayKey ? `${T}` : AsKey; + +/** + * Type which describes a path through an object + * as a list of individual {@link Key}s. + */ +export type PathTuple = Key[]; + +/** + * Type to assert that a type is a {@link PathTuple}. + * @typeParam T - type which may be a {@link PathTuple} + */ +export type AsPathTuple = Extract; + +/** + * Type to intersect a union type. + * See https://fettblog.eu/typescript-union-to-intersection/ + * @typeParam U - union + * @example + * ``` + * UnionToIntersection<{ foo: string } | { bar: number }> + * = { foo: string; bar: number } + * ``` + */ +export type UnionToIntersection = ( + U extends any ? (_: U) => any : never +) extends (_: infer I) => any + ? I + : never; + +/** + * Type which appends a {@link Key} to the {@link PathTuple} only if it is not + * blank, i.e. not the empty string. + * @typeParam PT - path + * @typeParam K - key + * @example + * ``` + * AppendNonBlankKey<['foo'], 'bar'> = ['foo', 'bar'] + * AppendNonBlankKey<['foo'], ''> = ['foo'] + * ``` + */ +export type AppendNonBlankKey< + PT extends PathTuple, + K extends Key, +> = K extends '' ? PT : [...PT, K]; + +/** + * Type to implement {@link SplitPathString} tail recursively. + * @typeParam PS - remaining {@link PathString} which should be split into its + * individual {@link Key}s + * @typeParam PT - accumulator of the {@link Key}s which have been split from + * the original {@link PathString} already + */ +type SplitPathStringImpl< + PS extends PathString, + PT extends PathTuple, +> = PS extends `${infer K}.${infer R}` + ? SplitPathStringImpl> + : AppendNonBlankKey; + +/** + * Type to split a {@link PathString} into a {@link PathTuple}. + * The individual {@link Key}s may be empty strings. + * @typeParam PS - {@link PathString} which should be split into its + * individual {@link Key}s + * @example + * ``` + * SplitPathString<'foo'> = ['foo'] + * SplitPathString<'foo.bar.0.baz'> = ['foo', 'bar', '0', 'baz'] + * SplitPathString<'.'> = [] + * ``` + */ +export type SplitPathString = SplitPathStringImpl< + PS, + [] +>; + +/** + * Type to implement {@link JoinPathTuple} tail-recursively. + * @typeParam PT - remaining {@link Key}s which needs to be joined + * @typeParam PS - accumulator of the already joined {@link Key}s + */ +type JoinPathTupleImpl< + PT extends PathTuple, + PS extends PathString, +> = PT extends [infer K, ...infer R] + ? JoinPathTupleImpl, `${PS}.${AsKey}`> + : PS; + +/** + * Type to join a {@link PathTuple} to a {@link PathString}. + * @typeParam PT - {@link PathTuple} which should be joined. + * @example + * ``` + * JoinPathTuple<['foo']> = 'foo' + * JoinPathTuple<['foo', 'bar', '0', 'baz']> = 'foo.bar.0.baz' + * JoinPathTuple<[]> = never + * ``` + */ +export type JoinPathTuple = PT extends [ + infer K, + ...infer R, +] + ? JoinPathTupleImpl, AsKey> + : never; + +/** + * Type which converts all keys of an object to {@link Key}s. + * @typeParam T - object type + * @example + * ``` + * MapKeys<{0: string}> = {'0': string} + * ``` + */ +type MapKeys = { [K in keyof T as ToKey]: T[K] }; + +/** + * Type to access a type by a key. + * - Returns undefined if it can't be indexed by that key. + * - Returns null if the type is null. + * - Returns undefined if the type is not traversable. + * @typeParam T - type which is indexed by the key + * @typeParam K - key into the type + * ``` + * TryAccess<{foo: string}, 'foo'> = string + * TryAccess<{foo: string}, 'bar'> = undefined + * TryAccess = null + * TryAccess = undefined + * ``` + */ +type TryAccess = K extends keyof T + ? T[K] + : T extends null + ? null + : undefined; + +/** + * Type to access an array type by a key. + * Returns undefined if the key is non-numeric. + * @typeParam T - type which is indexed by the key + * @typeParam K - key into the type + * ``` + * TryAccessArray = string + * TryAccessArray = undefined + * ``` + */ +type TryAccessArray< + T extends ReadonlyArray, + K extends Key, +> = K extends `${ArrayKey}` ? T[number] : TryAccess; + +/** + * Type to evaluate the type which the given key points to. + * @typeParam T - type which is indexed by the key + * @typeParam K - key into the type + * @example + * ``` + * EvaluateKey<{foo: string}, 'foo'> = string + * EvaluateKey<[number, string], '1'> = string + * EvaluateKey = string + * ``` + */ +export type EvaluateKey = T extends ReadonlyArray + ? IsTuple extends true + ? TryAccess + : TryAccessArray + : TryAccess, K>; + +/** + * Type to evaluate the type which the given path points to. + * @typeParam T - deeply nested type which is indexed by the path + * @typeParam PT - path into the deeply nested type + * @example + * ``` + * EvaluatePath<{foo: {bar: string}}, ['foo', 'bar']> = string + * EvaluatePath<[number, string], ['1']> = string + * EvaluatePath = number + * EvaluatePath = undefined + * ``` + */ +export type EvaluatePath = PT extends [ + infer K, + ...infer R, +] + ? EvaluatePath>, AsPathTuple> + : T; + +/** + * Type which given a tuple type returns its own keys, i.e. only its indices. + * @typeParam T - tuple type + * @example + * ``` + * TupleKeys<[number, string]> = '0' | '1' + * ``` + */ +export type TupleKeys> = Exclude< + keyof T, + keyof any[] +>; + +/** + * Type which extracts all numeric keys from an object. + * @typeParam T - type + * @example + * ``` + * NumericObjectKeys<{0: string, '1': string, foo: string}> = '0' | '1' + * ``` + */ +type NumericObjectKeys = ToKey< + Extract +>; + +/** + * Type which extracts all numeric keys from an object, tuple, or array. + * If a union is passed, it evaluates to the overlapping numeric keys. + * @typeParam T - type + * @example + * ``` + * NumericKeys<{0: string, '1': string, foo: string}> = '0' | '1' + * NumericKeys = `${number}` + * NumericKeys<[string, number]> = '0' | '1' + * NumericKeys<{0: string, '1': string} | [number] | number[]> = '0' + * ``` + */ +export type NumericKeys = UnionToIntersection< + T extends ReadonlyArray + ? IsTuple extends true + ? [TupleKeys] + : [ToKey] + : [NumericObjectKeys] +>[never]; + +/** + * Type which extracts all keys from an object. + * If a union is passed, it evaluates to the overlapping keys. + * @typeParam T - object type + * @example + * ``` + * ObjectKeys<{foo: string, bar: string}, string> = 'foo' | 'bar' + * ObjectKeys<{foo: string, bar: number}, string> = 'foo' + * ``` + */ +export type ObjectKeys = Exclude< + ToKey, + `${string}.${string}` | '' +>; + +/** + * Type to check whether a type's property matches the constraint type + * and return its key. Converts the key to a {@link Key}. + * @typeParam T - type whose property should be checked + * @typeParam K - key of the property + * @typeParam U - constraint type + * @example + * ``` + * CheckKeyConstraint<{foo: string}, 'foo', string> = 'foo' + * CheckKeyConstraint<{foo: string}, 'foo', number> = never + * CheckKeyConstraint = `${number}` + * ``` + */ +export type CheckKeyConstraint = K extends any + ? EvaluateKey extends U + ? K + : never + : never; + +/** + * Type which evaluates to true when the type is an array or tuple or is a union + * which contains an array or tuple. + * @typeParam T - type + * @example + * ``` + * ContainsIndexable<{foo: string}> = false + * ContainsIndexable<{foo: string} | number[]> = true + * ``` + */ +export type ContainsIndexable = IsNever< + Extract> +> extends true + ? false + : true; + +/** + * Type to implement {@link Keys} for non-nullable values. + * @typeParam T - non-nullable type whose property should be checked + */ +type KeysImpl = [T] extends [Traversable] + ? ContainsIndexable extends true + ? NumericKeys + : ObjectKeys + : never; + +/** + * Type to find all properties of a type that match the constraint type + * and return their keys. + * If a union is passed, it evaluates to the overlapping keys. + * @typeParam T - type whose property should be checked + * @typeParam U - constraint type + * @example + * ``` + * Keys<{foo: string, bar: string}, string> = 'foo' | 'bar' + * Keys<{foo?: string, bar?: string}> = 'foo' | 'bar' + * Keys<{foo: string, bar: number}, string> = 'foo' + * Keys<[string, number], string> = '0' + * Keys = `${number}` + * Keys<{0: string, '1': string} | [number] | number[]> = '0' + * ``` + */ +export type Keys = IsAny extends true + ? Key + : IsNever extends true + ? Key + : IsNever> extends true + ? never + : CheckKeyConstraint>, U>; + +/** + * Type to check whether a {@link Key} is present in a type. + * If a union of {@link Key}s is passed, all {@link Key}s have to be present + * in the type. + * @typeParam T - type which is introspected + * @typeParam K - key + * @example + * ``` + * HasKey<{foo: string}, 'foo'> = true + * HasKey<{foo: string}, 'bar'> = false + * HasKey<{foo: string}, 'foo' | 'bar'> = false + * ``` + */ +export type HasKey = IsNever>>; + +/** + * Type to implement {@link ValidPathPrefix} tail recursively. + * @typeParam T - type which the path should be checked against + * @typeParam PT - path which should exist within the given type + * @typeParam VPT - accumulates the prefix of {@link Key}s which have been + * confirmed to exist already + */ +type ValidPathPrefixImpl< + T, + PT extends PathTuple, + VPT extends PathTuple, +> = PT extends [infer K, ...infer R] + ? HasKey> extends true + ? ValidPathPrefixImpl< + EvaluateKey>, + AsPathTuple, + AsPathTuple<[...VPT, K]> + > + : VPT + : VPT; + +/** + * Type to find the longest path prefix which is still valid, + * i.e. exists within the given type. + * @typeParam T - type which the path should be checked against + * @typeParam PT - path which should exist within the given type + * @example + * ``` + * ValidPathPrefix<{foo: {bar: string}}, ['foo', 'bar']> = ['foo', 'bar'] + * ValidPathPrefix<{foo: {bar: string}}, ['foo', 'ba']> = ['foo'] + * ``` + */ +export type ValidPathPrefix = ValidPathPrefixImpl< + T, + PT, + [] +>; + +/** + * Type to check whether a path through a type exists. + * @typeParam T - type which the path should be checked against + * @typeParam PT - path which should exist within the given type + * @example + * ``` + * HasPath<{foo: {bar: string}}, ['foo', 'bar']> = true + * HasPath<{foo: {bar: string}}, ['foo', 'ba']> = false + * ``` + */ +export type HasPath = ValidPathPrefix extends PT + ? true + : false; + +/** + * Helper function to break apart T1 and check if any are equal to T2 + * + * See {@link IsEqual} + */ +type AnyIsEqual = T1 extends T2 + ? IsEqual extends true + ? true + : never + : never; + +type HttpMethod = 'get' | 'post' | 'put' | 'delete' | 'all'; + +/** + * Helper type for recursively constructing paths through a type. + * This actually constructs the strings and recurses into nested + * object types. + * + * See {@link Path} + */ +type PathImpl = V extends + | Primitive + | BrowserNativeObject + ? `${K}` + : // Check so that we don't recurse into the same type + // by ensuring that the types are mutually assignable + // mutually required to avoid false positives of subtypes + true extends AnyIsEqual + ? `${K}` + : true extends AnyIsEqual + ? `` + : `${K}` | `${K}.${PathInternal}`; + +/** + * Helper type for recursively constructing paths through a type. + * This obsucres the internal type param TraversedTypes from exported contract. + * + * See {@link Path} + */ +type PathInternal = T extends ReadonlyArray + ? IsTuple extends true + ? { + [K in TupleKeys]-?: PathImpl; + }[TupleKeys] + : PathImpl + : { + [K in keyof T]-?: PathImpl; + }[keyof T]; + +/** + * Type which eagerly collects all paths through a type + * @typeParam T - type which should be introspected + * @example + * ``` + * Path<{foo: {bar: string}}> = 'foo' | 'foo.bar' + * ``` + */ +// We want to explode the union type and process each individually +// so assignable types don't leak onto the stack from the base. +export type Path = T extends any ? PathInternal : never; + +/** + * See {@link Path} + */ +export type FieldPath = Path; + +/** + * Helper type for recursively constructing paths through a type. + * This actually constructs the strings and recurses into nested + * object types. + * + * See {@link ArrayPath} + */ +type ArrayPathImpl = V extends + | Primitive + | BrowserNativeObject + ? IsAny extends true + ? string + : never + : V extends ReadonlyArray + ? U extends Primitive | BrowserNativeObject + ? IsAny extends true + ? string + : never + : // Check so that we don't recurse into the same type + // by ensuring that the types are mutually assignable + // mutually required to avoid false positives of subtypes + true extends AnyIsEqual + ? never + : `${K}` | `${K}.${ArrayPathInternal}` + : true extends AnyIsEqual + ? never + : `${K}.${ArrayPathInternal}`; + +/** + * Helper type for recursively constructing paths through a type. + * This obsucres the internal type param TraversedTypes from exported contract. + * + * See {@link ArrayPath} + */ +type ArrayPathInternal = T extends ReadonlyArray + ? IsTuple extends true + ? { + [K in TupleKeys]-?: ArrayPathImpl; + }[TupleKeys] + : ArrayPathImpl + : { + [K in keyof T]-?: ArrayPathImpl; + }[keyof T]; + +/** + * Type which eagerly collects all paths through a type which point to an array + * type. + * @typeParam T - type which should be introspected. + * @example + * ``` + * Path<{foo: {bar: string[], baz: number[]}}> = 'foo.bar' | 'foo.baz' + * ``` + */ +// We want to explode the union type and process each individually +// so assignable types don't leak onto the stack from the base. +export type ArrayPath = T extends any ? ArrayPathInternal : never; + +/** + * See {@link ArrayPath} + */ +export type FieldArrayPath = + ArrayPath; + +/** + * Type to evaluate the type which the given path points to. + * @typeParam T - deeply nested type which is indexed by the path + * @typeParam P - path into the deeply nested type + * @example + * ``` + * PathValue<{foo: {bar: string}}, 'foo.bar'> = string + * PathValue<[number, string], '1'> = string + * ``` + */ +export type PathValue | ArrayPath> = T extends any + ? P extends `${infer K}.${infer R}` + ? K extends keyof T + ? R extends Path + ? PathValue + : never + : K extends `${ArrayKey}` + ? T extends ReadonlyArray + ? PathValue> + : never + : never + : P extends keyof T + ? T[P] + : P extends `${ArrayKey}` + ? T extends ReadonlyArray + ? V + : never + : never + : never; + +/** + * See {@link PathValue} + */ +export type FieldPathValue< + TFieldValues extends FieldValues, + TFieldPath extends FieldPath, +> = PathValue; + +/** + * See {@link PathValue} + */ +export type FieldArrayPathValue< + TFieldValues extends FieldValues, + TFieldArrayPath extends FieldArrayPath, +> = PathValue; + +/** + * Type to evaluate the type which the given paths point to. + * @typeParam TFieldValues - field values which are indexed by the paths + * @typeParam TPath - paths into the deeply nested field values + * @example + * ``` + * FieldPathValues<{foo: {bar: string}}, ['foo', 'foo.bar']> + * = [{bar: string}, string] + * ``` + */ +export type FieldPathValues< + TFieldValues extends FieldValues, + TPath extends FieldPath[] | readonly FieldPath[], +> = {} & { + [K in keyof TPath]: FieldPathValue< + TFieldValues, + TPath[K] & FieldPath + >; +}; + +/** + * Type which eagerly collects all paths through a fieldType that matches a give type + * @typeParam TFieldValues - field values which are indexed by the paths + * @typeParam TValue - the value you want to match into each type + * @example + * ```typescript + * FieldPathByValue<{foo: {bar: number}, baz: number, bar: string}, number> + * = 'foo.bar' | 'baz' + * ``` + */ +export type FieldPathByValue = { + [FieldKey in FieldPath]: FieldPathValue< + TFieldValues, + FieldKey + > extends TValue + ? FieldKey + : never; +}[FieldPath]; diff --git a/plugins/openapi-router-common/src/types/utils.ts b/plugins/openapi-router-common/src/types/utils.ts new file mode 100644 index 0000000000..25282e1da2 --- /dev/null +++ b/plugins/openapi-router-common/src/types/utils.ts @@ -0,0 +1,144 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +declare const $NestedValue: unique symbol; + +/** + * @deprecated to be removed in the next major version + */ +export type NestedValue = { + [$NestedValue]: never; +} & TValue; + +/* +Projects that React Hook Form installed don't include the DOM library need these interfaces to compile. +React Native applications is no DOM available. The JavaScript runtime is ES6/ES2015 only. +These definitions allow such projects to compile with only --lib ES6. + +Warning: all of these interfaces are empty. +If you want type definitions for various properties, you need to add `--lib DOM` (via command line or tsconfig.json). +*/ + +export type Noop = () => void; + +interface File extends Blob { + readonly lastModified: number; + readonly name: string; +} + +interface FileList { + readonly length: number; + item(index: number): File | null; + [index: number]: File; +} + +export type Primitive = + | null + | undefined + | string + | number + | boolean + | symbol + | bigint; + +export type BrowserNativeObject = Date | FileList | File; + +export type EmptyObject = { [K in string | number]: never }; + +export type NonUndefined = T extends undefined ? never : T; + +export type LiteralUnion = + | T + | (U & { _?: never }); + +export type DeepPartial = T extends BrowserNativeObject | NestedValue + ? T + : { [K in keyof T]?: DeepPartial }; + +export type DeepPartialSkipArrayKey = T extends + | BrowserNativeObject + | NestedValue + ? T + : T extends ReadonlyArray + ? { [K in keyof T]: DeepPartialSkipArrayKey } + : { [K in keyof T]?: DeepPartialSkipArrayKey }; + +/** + * Checks whether the type is any + * See {@link https://stackoverflow.com/a/49928360/3406963} + * @typeParam T - type which may be any + * ``` + * IsAny = true + * IsAny = false + * ``` + */ +export type IsAny = 0 extends 1 & T ? true : false; + +/** + * Checks whether the type is never + * @typeParam T - type which may be never + * ``` + * IsAny = true + * IsAny = false + * ``` + */ +export type IsNever = [T] extends [never] ? true : false; + +/** + * Checks whether T1 can be exactly (mutually) assigned to T2 + * @typeParam T1 - type to check + * @typeParam T2 - type to check against + * ``` + * IsEqual = true + * IsEqual<'foo', 'foo'> = true + * IsEqual = false + * IsEqual = false + * IsEqual = false + * IsEqual<'foo', string> = false + * IsEqual<'foo' | 'bar', 'foo'> = boolean // 'foo' is assignable, but 'bar' is not (true | false) -> boolean + * ``` + */ +export type IsEqual = T1 extends T2 + ? (() => G extends T1 ? 1 : 2) extends () => G extends T2 ? 1 : 2 + ? true + : false + : false; + +export type DeepMap = IsAny extends true + ? any + : T extends BrowserNativeObject | NestedValue + ? TValue + : T extends object + ? { [K in keyof T]: DeepMap, TValue> } + : TValue; + +export type IsFlatObject = Extract< + Exclude, + any[] | object +> extends never + ? true + : false; + +export type Merge = { + [K in keyof A | keyof B]?: K extends keyof A & keyof B + ? [A[K], B[K]] extends [object, object] + ? Merge + : A[K] | B[K] + : K extends keyof A + ? A[K] + : K extends keyof B + ? B[K] + : never; +}; diff --git a/yarn.lock b/yarn.lock index 859a44fda4..eb39765348 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3342,7 +3342,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.14.6, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.2.0, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.14.6, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.2.0, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": version: 7.20.13 resolution: "@babel/runtime@npm:7.20.13" dependencies: @@ -7446,6 +7446,17 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-openapi-router@workspace:plugins/openapi-router-common": + version: 0.0.0-use.local + resolution: "@backstage/plugin-openapi-router@workspace:plugins/openapi-router-common" + dependencies: + "@backstage/cli": "workspace:^" + express: ^4.18.2 + json-schema-to-ts: ^2.6.2 + openapi-types: ^12.1.0 + languageName: unknown + linkType: soft + "@backstage/plugin-org-react@workspace:plugins/org-react": version: 0.0.0-use.local resolution: "@backstage/plugin-org-react@workspace:plugins/org-react" @@ -23777,7 +23788,7 @@ __metadata: languageName: node linkType: hard -"express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.1": +"express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.1, express@npm:^4.18.2": version: 4.18.2 resolution: "express@npm:4.18.2" dependencies: @@ -28324,6 +28335,18 @@ __metadata: languageName: node linkType: hard +"json-schema-to-ts@npm:^2.6.2": + version: 2.6.2 + resolution: "json-schema-to-ts@npm:2.6.2" + dependencies: + "@babel/runtime": ^7.18.3 + "@types/json-schema": ^7.0.9 + ts-algebra: ^1.1.1 + ts-toolbelt: ^9.6.0 + checksum: e408f8d3d32dda1a001f194cf5514fea7ef0a4dc4ed4f5448b9ea02df3a071119a0517aaffb639de725f4a12102406dfdf7743455e0e30317e231bb551362039 + languageName: node + linkType: hard + "json-schema-traverse@npm:^0.4.1": version: 0.4.1 resolution: "json-schema-traverse@npm:0.4.1" @@ -38580,6 +38603,15 @@ __metadata: languageName: node linkType: hard +"ts-algebra@npm:^1.1.1": + version: 1.1.1 + resolution: "ts-algebra@npm:1.1.1" + dependencies: + ts-toolbelt: ^9.6.0 + checksum: 29fe27215863520377a94a14ccf1e78e921be6a5c514a0a8250dab09e325b006a9571ef1f366875d58e495b59ba880620718a1dd38b6af598cc6ea6de82d4f66 + languageName: node + linkType: hard + "ts-easing@npm:^0.2.0": version: 0.2.0 resolution: "ts-easing@npm:0.2.0" @@ -38672,6 +38704,13 @@ __metadata: languageName: node linkType: hard +"ts-toolbelt@npm:^9.6.0": + version: 9.6.0 + resolution: "ts-toolbelt@npm:9.6.0" + checksum: 9f35fd95d895a5d32ea9fd2e532a695b0bae6cbff6832b77292efa188a0ed1ed6e54f63f74a8920390f3d909a7a3adb20a144686372a8e78b420246a9bd3d58a + languageName: node + linkType: hard + "tsconfig-paths@npm:^3.14.1": version: 3.14.1 resolution: "tsconfig-paths@npm:3.14.1" From ef4d42ec301f586560b2a195d23a091acb8d06e9 Mon Sep 17 00:00:00 2001 From: Aramis Sennyey Date: Thu, 19 Jan 2023 18:51:12 -0500 Subject: [PATCH 034/162] Testing Signed-off-by: Aramis Sennyey --- plugins/openapi-router-common/src/router.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/openapi-router-common/src/router.ts b/plugins/openapi-router-common/src/router.ts index c7940cdbd0..740dc8dc03 100644 --- a/plugins/openapi-router-common/src/router.ts +++ b/plugins/openapi-router-common/src/router.ts @@ -340,7 +340,7 @@ export default class ApiRouter< ...handlers: core.RequestHandler< core.ParamsDictionary, any, - FieldPathValue, 'get'>, + FieldPathValue, ParsedQs, Record >[] From e1962a4f0a0db599e9a5a66307f603d7ebf7753b Mon Sep 17 00:00:00 2001 From: Aramis Sennyey Date: Thu, 16 Feb 2023 16:13:26 -0500 Subject: [PATCH 035/162] Ading server testing and better handlers. Signed-off-by: Aramis Sennyey --- .../src/service/createRouter.ts | 33 +- plugins/openapi-router-common/package.json | 4 +- plugins/openapi-router-common/src/router.ts | 295 ++++---- plugins/openapi-router-common/src/run.ts | 33 + .../src/standaloneServer.ts | 48 ++ .../openapi-router-common/src/types/common.ts | 116 +++ .../openapi-router-common/src/types/fields.ts | 66 -- .../openapi-router-common/src/types/index.ts | 2 + .../openapi-router-common/src/types/path.ts | 666 ------------------ .../src/types/requests.ts | 36 + .../src/types/response.ts | 68 ++ .../openapi-router-common/src/types/utils.ts | 144 ---- yarn.lock | 11 + 13 files changed, 491 insertions(+), 1031 deletions(-) create mode 100644 plugins/openapi-router-common/src/run.ts create mode 100644 plugins/openapi-router-common/src/standaloneServer.ts create mode 100644 plugins/openapi-router-common/src/types/common.ts delete mode 100644 plugins/openapi-router-common/src/types/fields.ts create mode 100644 plugins/openapi-router-common/src/types/index.ts delete mode 100644 plugins/openapi-router-common/src/types/path.ts create mode 100644 plugins/openapi-router-common/src/types/requests.ts create mode 100644 plugins/openapi-router-common/src/types/response.ts delete mode 100644 plugins/openapi-router-common/src/types/utils.ts diff --git a/plugins/catalog-backend/src/service/createRouter.ts b/plugins/catalog-backend/src/service/createRouter.ts index 6685332002..f6c2e441e5 100644 --- a/plugins/catalog-backend/src/service/createRouter.ts +++ b/plugins/catalog-backend/src/service/createRouter.ts @@ -54,7 +54,6 @@ import { initialize } from 'express-openapi'; import yaml from 'js-yaml'; import fs from 'fs'; import path from 'path'; -import OpenAPIBackend from 'openapi-backend'; class ParsingError extends Error { toString() { @@ -105,23 +104,6 @@ export async function createRouter( logger.info('Catalog is running in readonly mode'); } - // create api with your definition file or object - const api = new OpenAPIBackend({ definition: './petstore.yml' }); - - // register your framework specific request handlers here - api.register({ - getPets: (c, req, res) => { - return res.status(200).json({ result: 'ok' }); - }, - getPetById: (c, req, res) => res.status(200).json({ result: 'ok' }), - validationFail: (c, req, res) => - res.status(400).json({ err: c.validation.errors }), - notFound: (c, req, res) => res.status(404).json({ err: 'not found' }), - }); - - // initalize the backend - api.init(); - const validateDependency = ( dependency: any, next: (req: express.Request, res: express.Response) => any, @@ -143,6 +125,7 @@ export async function createRouter( // NOTE: If using yaml you can provide a path relative to process.cwd() e.g. // apiDoc: './api-v1/api-doc.yml', apiDoc: yaml.load( + // eslint-disable-next-line no-restricted-syntax fs.readFileSync(path.resolve(__dirname, '../../openapi.yaml'), 'utf-8'), ) as any, operations: { @@ -345,12 +328,18 @@ export async function createRouter( }, enableObjectCoercion: true, errorMiddleware: errorHandler(), - errorTransformer: (openapiError, ajvError) => { - switch (openapiError.errorCode) { + errorTransformer: openapiError => { + const error = openapiError as { + errorCode: string; + path: string; + message: string; + }; + // eslint-disable-next-line default-case + switch (error.errorCode) { case 'type.openapi.requestValidation': throw new InputError( - `Invalid field ${openapiError.path}`, - new ParsingError(openapiError.message), + `Invalid field ${error.path}`, + new ParsingError(error.message), ); } return {}; diff --git a/plugins/openapi-router-common/package.json b/plugins/openapi-router-common/package.json index a852edfcec..3a40ddecd0 100644 --- a/plugins/openapi-router-common/package.json +++ b/plugins/openapi-router-common/package.json @@ -32,6 +32,8 @@ "dependencies": { "express": "^4.18.2", "json-schema-to-ts": "^2.6.2", - "openapi-types": "^12.1.0" + "openapi-types": "^12.1.0", + "openapi3-ts": "^3.1.2", + "ts-node": "^10.9.1" } } diff --git a/plugins/openapi-router-common/src/router.ts b/plugins/openapi-router-common/src/router.ts index 740dc8dc03..99a45b8e0c 100644 --- a/plugins/openapi-router-common/src/router.ts +++ b/plugins/openapi-router-common/src/router.ts @@ -13,26 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { OpenAPIV3_1 } from 'openapi-types'; -import { FieldValues } from './types/fields'; +import { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'; +import { IRouter, Router } from 'express'; +import core, { ParamsDictionary } from 'express-serve-static-core'; +import { FromSchema, JSONSchema7 } from 'json-schema-to-ts'; import { - AppendNonBlankKey, - FieldPath, - FieldPathValue, - Path, -} from './types/path'; -import { IRouter, Router, IRouterMatcher } from 'express'; -import core, { - ParamsDictionary, - RequestHandler, -} from 'express-serve-static-core'; -import { FromSchema } from 'json-schema-to-ts'; + DocPath, + DocPathMethod, + DocPathTemplate, + MethodAwareDocPath, + PathTemplate, + RequestBodySchema, + RequiredDoc, + ValueOf, +} from './types'; +import { ResponseSchemas } from './types/response'; -type RouterFn = < - TFieldName extends FieldPath = FieldPath, ->( - name: TFieldName, -) => null; +type DeepWriteable = { -readonly [P in keyof T]: DeepWriteable }; const doc = { openapi: '3.1.0', @@ -201,59 +198,6 @@ const doc = { }, } as const; -const isUndefined = (a: any): a is undefined => a === undefined; -const isNull = (a: any): a is null => a === null; - -const isNullOrUndefined = (a: any): a is undefined | null => - isUndefined(a) || isNull(a); - -const isDateObject = (value: unknown): value is Date => value instanceof Date; - -const isObjectType = (value: unknown) => typeof value === 'object'; - -const isObject = (value: unknown): value is T => - !isNullOrUndefined(value) && - !Array.isArray(value) && - isObjectType(value) && - !isDateObject(value); - -const compact = (value: TValue[]) => - Array.isArray(value) ? value.filter(Boolean) : []; - -export const resolve = ( - obj: T, - path: string, - defaultValue?: unknown, -): any => { - if (!path || !isObject(obj)) { - return defaultValue; - } - - const result = compact(path.split(/[,[\].]+?/)).reduce( - (result, key) => - isNullOrUndefined(result) ? result : result[key as keyof {}], - obj, - ); - if (result === undefined || result === obj) { - return obj[path as keyof T] === undefined - ? defaultValue - : obj[path as keyof T]; - } - return result; -}; - -/** - * We want this to input path and have - * @param name - * @returns - */ -const x: RouterFn = name => { - console.log(resolve(doc, name)); - return null; -}; - -x('/pets/{petId}'); - type RemoveTail< S extends string, Tail extends string, @@ -279,79 +223,166 @@ export type RouteParameters = string extends Route interface ParsedQs { [key: string]: undefined | string | string[] | ParsedQs | ParsedQs[]; } -interface ApiRouterMatcher< - TFieldValues extends FieldValues, + +// oh boy don't do this +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never; +type LastOf = UnionToIntersection< + T extends any ? () => T : never +> extends () => infer R + ? R + : never; + +// TS4.0+ +type Push = [...T, V]; + +// TS4.1+ +type TuplifyUnion< T, - Method extends - | 'all' - | 'get' - | 'post' - | 'put' - | 'delete' - | 'patch' - | 'options' - | 'head', -> { - < - TFieldName extends Path = Path, - P = RouteParameters, - ResBody = any, - ReqBody = any, - ReqQuery = ParsedQs, - Locals extends Record = Record, - >( - // (it's used as the default type parameter for P) - path: TFieldName, - // (This generic is meant to be passed explicitly.) - ...handlers: Array> - ): T; -} + L = LastOf, + N = [T] extends [never] ? true : false, +> = true extends N ? [] : Push>, L>; -type path = ApiRouterMatcher; -const test: path = a => { - console.log(a); -}; -test('/pets/{petId}'); +type ConvertAll = []> = T extends [ + infer First extends JSONSchema7, + ...infer Rest, +] + ? ConvertAll]> + : R; -export interface IApiRouter extends IRouter { - all: ApiRouterMatcher; - get: ApiRouterMatcher; -} +type ResponseToJsonSchema< + Doc extends RequiredDoc, + Path extends PathTemplate>, + Method extends DocPathMethod, +> = ConvertAll< + TuplifyUnion>> +>[number]; -export default class ApiRouter< - ApiSpec, - PathSpec extends OpenAPIV3_1.Document, - T, -> implements IApiRouter -{ +type DocRequestHandler< + Doc extends RequiredDoc, + Path extends DocPathTemplate, + Method extends keyof Doc['paths'][Path], +> = core.RequestHandler< + core.ParamsDictionary, + // From https://stackoverflow.com/questions/71393738/typescript-intersection-not-union-type-from-json-schema. + ResponseToJsonSchema, + RequestBodySchema, + ParsedQs, + Record +>; + +export default class ApiRouter { private _router = Router(); - constructor(private spec: OpenAPIV3_1.Document) {} + constructor(private spec: OpenAPIV3_1.Document | OpenAPIV3.Document) {} - static fromSpec(spec: OpenAPIV3_1.Document) { - return new ApiRouter(spec); + static fromSpec( + spec: OpenAPIV3_1.Document | OpenAPIV3.Document, + ) { + return new ApiRouter(spec); } - get< - TFieldValues extends FieldValues = PathSpec, - TFieldName extends FieldPath = FieldPath, - >( - path: TFieldName, - ...handlers: core.RequestHandler< - core.ParamsDictionary, - any, - FieldPathValue, - ParsedQs, - Record - >[] + get, 'get'>>( + path: Path, + ...handlers: DocRequestHandler[] ) { console.log(path); - return this._router.get(path, ...handlers); + this._router.get(path, ...handlers); + return this; + } + + post, 'post'>>( + path: Path, + ...handlers: DocRequestHandler[] + ) { + console.log(path); + this._router.post(path, ...handlers); + return this; + } + + all, 'all'>>( + path: Path, + ...handlers: DocRequestHandler[] + ) { + console.log(path); + this._router.all(path, ...handlers); + return this; + } + + put, 'put'>>( + path: Path, + ...handlers: DocRequestHandler[] + ) { + console.log(path); + this._router.put(path, ...handlers); + return this; + } + delete, 'delete'>>( + path: Path, + ...handlers: DocRequestHandler[] + ) { + console.log(path); + this._router.delete(path, ...handlers); + return this; + } + patch, 'patch'>>( + path: Path, + ...handlers: DocRequestHandler[] + ) { + console.log(path); + this._router.patch(path, ...handlers); + return this; + } + options< + Path extends MethodAwareDocPath, 'options'>, + >(path: Path, ...handlers: DocRequestHandler[]) { + console.log(path); + this._router.options(path, ...handlers); + return this; + } + head, 'head'>>( + path: Path, + ...handlers: DocRequestHandler[] + ) { + console.log(path); + this._router.head(path, ...handlers); + return this; + } + + use(...handlers: core.RequestHandler[]) { + return this._router.use(handlers); + } + + build() { + return this._router; } } -const router = ApiRouter.fromSpec(doc); +interface RouterOptions {} -router.get('/pets', (req, res) => { - req.body.tags; -}); +export async function createRouter(options: RouterOptions) { + const router = ApiRouter.fromSpec>( + // As const forces the doc to readonly which conflicts with imported types. + doc as DeepWriteable, + ); + + router.get('/pets/:uid', (req, res) => { + res.json({ + id: 1, + name: req.params['uid'], + }); + }); + + // router.get('/pet') will complain with a TS error + + router.post('/pets', (req, res) => { + res.json({ + message: req.path, + code: 1, + }); + }); + return router.build(); +} diff --git a/plugins/openapi-router-common/src/run.ts b/plugins/openapi-router-common/src/run.ts new file mode 100644 index 0000000000..9a14d0e57d --- /dev/null +++ b/plugins/openapi-router-common/src/run.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import yn from 'yn'; +import { getRootLogger } from '@backstage/backend-common'; +import { startStandaloneServer } from './standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3004; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/plugins/openapi-router-common/src/standaloneServer.ts b/plugins/openapi-router-common/src/standaloneServer.ts new file mode 100644 index 0000000000..aef8f006ff --- /dev/null +++ b/plugins/openapi-router-common/src/standaloneServer.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Server } from 'http'; +import { Logger } from 'winston'; +import { createServiceBuilder } from '@backstage/backend-common'; +import { createRouter } from './router'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: 'app-backend' }); + logger.debug('Starting application server...'); + const router = await createRouter({ + logger, + appPackageName: 'example-app', + }); + + const service = createServiceBuilder(module) + .setPort(options.port) + .addRouter('', router); + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/plugins/openapi-router-common/src/types/common.ts b/plugins/openapi-router-common/src/types/common.ts new file mode 100644 index 0000000000..2f0d6735a4 --- /dev/null +++ b/plugins/openapi-router-common/src/types/common.ts @@ -0,0 +1,116 @@ +/** + * Pulled from https://github.com/varanauskas/oatx. + */ + +import type { + ContentObject, + OpenAPIObject, + ReferenceObject, +} from 'openapi3-ts'; + +export type RequiredDoc = Pick; +export type PathDoc = { paths: Record }; + +/** + * Get value types of `T` + */ +export type ValueOf = T[keyof T]; + +/** + * Validate a string against OpenAPI path template + * ``` + * const path = PathTemplate<"/posts/{postId}/comments/{commentId}"> = "/posts/1/comments/2"const pathWithParams: PathTemplate<"/posts/{postId}/comments/{commentId}"> = "/posts/1/comments/2"; + * const pathWithoutParams: PathTemplate<"/posts/comments"> = "/posts/comments";``` + * https://spec.openapis.org/oas/v3.1.0#path-templating-matching + */ +export type PathTemplate = + Path extends `${infer Prefix}{${string}}${infer Suffix}` + ? `${Prefix}${string}${PathTemplate}` + : Path; + +/** + * Extract path as specified in OpenAPI `Doc` based on request path + * ``` + * const spec = { + * paths: { + * "/posts/{postId}/comments/{commentId}": {}, + * "/posts/comments": {}, + * } + * }; + * const specPathWithParams: DocPath = "/posts/{postId}/comments/{commentId}"; + * const specPathWithoutParams: DocPath = "/posts/comments"; + * ``` + */ +export type DocPath< + Doc extends PathDoc, + Path extends PathTemplate>, +> = ValueOf<{ + [Template in Extract< + keyof Doc['paths'], + string + >]: Path extends PathTemplate