From b74962217bbdca9e2f4e27d01c66f2126d761317 Mon Sep 17 00:00:00 2001 From: Nick Marinelli Date: Thu, 25 Aug 2022 18:57:57 -0400 Subject: [PATCH 001/192] Local SSL tutorial Signed-off-by: Nick Marinelli --- .../docs/tutorials/enable-ssl-self-signed.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 contrib/docs/tutorials/enable-ssl-self-signed.md diff --git a/contrib/docs/tutorials/enable-ssl-self-signed.md b/contrib/docs/tutorials/enable-ssl-self-signed.md new file mode 100644 index 0000000000..8169e277fb --- /dev/null +++ b/contrib/docs/tutorials/enable-ssl-self-signed.md @@ -0,0 +1,46 @@ +# Enabling SSL for Local Testing + +If you need to use an `https:` URL for local testing (i.e. if an OAuth provider requires a "secure" callback URL), you can use a self-signed certificate by following these steps. + +## Backend + +1. Generate a self-signed certificate and key for localhost. One approach uses OpenSSL: + ```bash + mkdir certs ; + openssl req -x509 -newkey rsa:2048 -nodes -keyout certs/localhost.key -out certs/localhost.crt -sha256 -days 3650 -subj '/CN=localhost' ; + ``` +1. Update `backend.baseUrl` in app-config.local.yaml to use an `https:` address, and copy the contents of the certificate and key files into `backend.https.certificate.cert` and `backend.https.certificate.cert`, respectively. Your app-config.local.yaml should look something like: + ```yaml + backend: + baseUrl: https://localhost:7007 + https: + certificate: + cert: | + -----BEGIN CERTIFICATE----- + MIIDCTCCAfGgAwIBAgIUZ9VhZckcy690L + ... + -----END CERTIFICATE----- + key: | + -----BEGIN PRIVATE KEY----- + MIIEvAIBADANBgkqhkiG9w0BAQ + ... + -----END PRIVATE KEY----- + ``` +1. Convince your browser to trust the certificate. In Windows this might mean adding the certificate to your Trusted Root CAs, or you may use a browser-specific configuration like Chrome's flag `chrome://flags/#allow-insecure-localhost`. +1. Start the backend with `NODE_EXTRA_CA_CERTS=/absolute/path/to/certs/localhost.crt yarn start-backend` + +## Frontend + +Webpack will generate a self signed certificate automatically in development environments when the protocol in the `baseUrl` is `https`. Therefore, simply add this to your local config: + +```yaml +app: + baseUrl: https://localhost:3000 +backend: + cors: + origin: https://localhost:3000 +``` + +and start the app with `yarn start`. As with the backend instructions above, the certificate must be trusted. + +Depending on what plugins are in use, you may need to override additional URLs to use `https` for those endpoints to work. From 928e31bfdc0fcddf2ef4c5004e0873348c9aca69 Mon Sep 17 00:00:00 2001 From: Nick Marinelli Date: Fri, 26 Aug 2022 15:25:38 -0400 Subject: [PATCH 002/192] adapt for mkcert Signed-off-by: Nick Marinelli --- contrib/docs/tutorials/enable-ssl-self-signed.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/contrib/docs/tutorials/enable-ssl-self-signed.md b/contrib/docs/tutorials/enable-ssl-self-signed.md index 8169e277fb..589e96dc80 100644 --- a/contrib/docs/tutorials/enable-ssl-self-signed.md +++ b/contrib/docs/tutorials/enable-ssl-self-signed.md @@ -4,11 +4,7 @@ If you need to use an `https:` URL for local testing (i.e. if an OAuth provider ## Backend -1. Generate a self-signed certificate and key for localhost. One approach uses OpenSSL: - ```bash - mkdir certs ; - openssl req -x509 -newkey rsa:2048 -nodes -keyout certs/localhost.key -out certs/localhost.crt -sha256 -days 3650 -subj '/CN=localhost' ; - ``` +1. Generate a self-signed certificate and key for localhost and configure your system to trust it. [mkcert](https://github.com/FiloSottile/mkcert) is a helpful tool to accomplish this. 1. Update `backend.baseUrl` in app-config.local.yaml to use an `https:` address, and copy the contents of the certificate and key files into `backend.https.certificate.cert` and `backend.https.certificate.cert`, respectively. Your app-config.local.yaml should look something like: ```yaml backend: @@ -26,8 +22,7 @@ If you need to use an `https:` URL for local testing (i.e. if an OAuth provider ... -----END PRIVATE KEY----- ``` -1. Convince your browser to trust the certificate. In Windows this might mean adding the certificate to your Trusted Root CAs, or you may use a browser-specific configuration like Chrome's flag `chrome://flags/#allow-insecure-localhost`. -1. Start the backend with `NODE_EXTRA_CA_CERTS=/absolute/path/to/certs/localhost.crt yarn start-backend` +1. Start the backend with `NODE_EXTRA_CA_CERTS=/absolute/path/to/cert.pem yarn start-backend` ## Frontend From 9acd95709ef1342f6d56788aac94dc613ebd52ab Mon Sep 17 00:00:00 2001 From: Nick Marinelli Date: Fri, 26 Aug 2022 18:25:33 -0400 Subject: [PATCH 003/192] spellcheck workaround? Signed-off-by: Nick Marinelli --- contrib/docs/tutorials/enable-ssl-self-signed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/docs/tutorials/enable-ssl-self-signed.md b/contrib/docs/tutorials/enable-ssl-self-signed.md index 589e96dc80..18d9752ba2 100644 --- a/contrib/docs/tutorials/enable-ssl-self-signed.md +++ b/contrib/docs/tutorials/enable-ssl-self-signed.md @@ -4,7 +4,7 @@ If you need to use an `https:` URL for local testing (i.e. if an OAuth provider ## Backend -1. Generate a self-signed certificate and key for localhost and configure your system to trust it. [mkcert](https://github.com/FiloSottile/mkcert) is a helpful tool to accomplish this. +1. Generate a self-signed certificate and key for localhost and configure your system to trust it. The application ["mkcert"](https://github.com/FiloSottile/mkcert) is a helpful tool to accomplish this. 1. Update `backend.baseUrl` in app-config.local.yaml to use an `https:` address, and copy the contents of the certificate and key files into `backend.https.certificate.cert` and `backend.https.certificate.cert`, respectively. Your app-config.local.yaml should look something like: ```yaml backend: From 07dda0b746a9dd16ea5921fb72f1ef0312d0a999 Mon Sep 17 00:00:00 2001 From: Axel Hecht Date: Wed, 31 Aug 2022 14:06:00 +0200 Subject: [PATCH 004/192] Add optional value to hasAnnotation. Signed-off-by: Axel Hecht --- .changeset/famous-rats-heal.md | 5 ++ plugins/catalog-backend/api-report.md | 4 +- .../permissions/rules/hasAnnotation.test.ts | 62 +++++++++++++++++++ .../src/permissions/rules/hasAnnotation.ts | 21 +++++-- 4 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 .changeset/famous-rats-heal.md diff --git a/.changeset/famous-rats-heal.md b/.changeset/famous-rats-heal.md new file mode 100644 index 0000000000..e233070849 --- /dev/null +++ b/.changeset/famous-rats-heal.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Add optional value to hasAnnotation permission rule diff --git a/plugins/catalog-backend/api-report.md b/plugins/catalog-backend/api-report.md index 0460fbc5e9..8a2b0fc9cc 100644 --- a/plugins/catalog-backend/api-report.md +++ b/plugins/catalog-backend/api-report.md @@ -177,7 +177,7 @@ export const catalogConditions: Conditions<{ Entity, EntitiesSearchFilter, 'catalog-entity', - [annotation: string] + [annotation: string, value?: string | undefined] >; hasLabel: PermissionRule< Entity, @@ -448,7 +448,7 @@ export const permissionRules: { Entity, EntitiesSearchFilter, 'catalog-entity', - [annotation: string] + [annotation: string, value?: string | undefined] >; hasLabel: PermissionRule< Entity, diff --git a/plugins/catalog-backend/src/permissions/rules/hasAnnotation.test.ts b/plugins/catalog-backend/src/permissions/rules/hasAnnotation.test.ts index 1596270907..609114be3d 100644 --- a/plugins/catalog-backend/src/permissions/rules/hasAnnotation.test.ts +++ b/plugins/catalog-backend/src/permissions/rules/hasAnnotation.test.ts @@ -49,6 +49,19 @@ describe('hasAnnotation permission rule', () => { 'backstage.io/test-annotation', ), ).toEqual(false); + expect( + hasAnnotation.apply( + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + }, + }, + 'backstage.io/test-annotation', + 'some value', + ), + ).toEqual(false); }); it('returns true when specified annotation is present', () => { @@ -69,6 +82,46 @@ describe('hasAnnotation permission rule', () => { ), ).toEqual(true); }); + + it('returns false when specified annotation has different than expected value', () => { + expect( + hasAnnotation.apply( + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'other-annotation': 'foo', + 'backstage.io/test-annotation': 'bar', + }, + }, + }, + 'backstage.io/test-annotation', + 'baz', + ), + ).toEqual(false); + }); + + it('returns true when specified annotation has expected value', () => { + expect( + hasAnnotation.apply( + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test-component', + annotations: { + 'other-annotation': 'foo', + 'backstage.io/test-annotation': 'bar', + }, + }, + }, + 'backstage.io/test-annotation', + 'bar', + ), + ).toEqual(true); + }); }); describe('toQuery', () => { @@ -78,4 +131,13 @@ describe('hasAnnotation permission rule', () => { }); }); }); + + it('returns an appropriate catalog-backend filter with values', () => { + expect( + hasAnnotation.toQuery('backstage.io/test-annotation', 'foo'), + ).toEqual({ + key: 'metadata.annotations.backstage.io/test-annotation', + values: ['foo'], + }); + }); }); diff --git a/plugins/catalog-backend/src/permissions/rules/hasAnnotation.ts b/plugins/catalog-backend/src/permissions/rules/hasAnnotation.ts index 68ca65418d..22dbd307a6 100644 --- a/plugins/catalog-backend/src/permissions/rules/hasAnnotation.ts +++ b/plugins/catalog-backend/src/permissions/rules/hasAnnotation.ts @@ -22,6 +22,8 @@ import { createCatalogPermissionRule } from './util'; * A catalog {@link @backstage/plugin-permission-node#PermissionRule} which * filters for the presence of an annotation on a given entity. * + * If a value is given, it filters for the annotation value, too. + * * @alpha */ export const hasAnnotation = createCatalogPermissionRule({ @@ -29,9 +31,18 @@ export const hasAnnotation = createCatalogPermissionRule({ description: 'Allow entities which are annotated with the specified annotation', resourceType: RESOURCE_TYPE_CATALOG_ENTITY, - apply: (resource: Entity, annotation: string) => - !!resource.metadata.annotations?.hasOwnProperty(annotation), - toQuery: (annotation: string) => ({ - key: `metadata.annotations.${annotation}`, - }), + apply: (resource: Entity, annotation: string, value?: string) => + !!resource.metadata.annotations?.hasOwnProperty(annotation) && + (value === undefined + ? true + : resource.metadata.annotations?.[annotation] === value), + toQuery: (annotation: string, value?: string) => + value === undefined + ? { + key: `metadata.annotations.${annotation}`, + } + : { + key: `metadata.annotations.${annotation}`, + values: [value], + }, }); From 5fa831ce551eda0fb14ac4d05a372b8450048e07 Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Tue, 6 Sep 2022 13:12:50 +0200 Subject: [PATCH 005/192] Add optional SameSite attribute to CookieConfigurer and return from defaultCookieConfigurer for secure contexts. Signed-off-by: Marcus Eide --- .changeset/kind-penguins-report.md | 5 ++++ plugins/auth-backend/api-report.md | 5 ++-- .../src/lib/oauth/OAuthAdapter.test.ts | 15 ++++++++--- .../src/lib/oauth/OAuthAdapter.ts | 16 ++++-------- .../src/lib/oauth/helpers.test.ts | 25 +++++++++++++++++++ plugins/auth-backend/src/lib/oauth/helpers.ts | 3 ++- plugins/auth-backend/src/providers/types.ts | 7 +++++- 7 files changed, 57 insertions(+), 19 deletions(-) create mode 100644 .changeset/kind-penguins-report.md diff --git a/.changeset/kind-penguins-report.md b/.changeset/kind-penguins-report.md new file mode 100644 index 0000000000..6596f727d2 --- /dev/null +++ b/.changeset/kind-penguins-report.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Allow CookieConfigurer to optionally return the SameSite cookie attribute. Return `SameSite=None` in `defaultCookieConfigurer` for secure contexts to allow cookies to be included in third-party requests. diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index d47abeee83..ed3561d37c 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -187,6 +187,7 @@ export type CookieConfigurer = (ctx: { domain: string; path: string; secure: boolean; + sameSite?: 'none' | 'lax' | 'strict'; }; // @public @@ -280,11 +281,9 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { // @public (undocumented) export type OAuthAdapterOptions = { providerId: string; - secure: boolean; persistScopes?: boolean; - cookieDomain: string; - cookiePath: string; appOrigin: string; + cookieConfig: ReturnType; isOriginAllowed: (origin: string) => boolean; callbackUrl: string; }; diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts index 9dcd76e92e..a61554b600 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts @@ -18,6 +18,7 @@ import express from 'express'; import { THOUSAND_DAYS_MS, TEN_MINUTES_MS, OAuthAdapter } from './OAuthAdapter'; import { encodeState } from './helpers'; import { OAuthHandlers, OAuthState } from './types'; +import { CookieConfigurer } from '../../providers/types'; const mockResponseData = { providerInfo: { @@ -57,12 +58,17 @@ describe('OAuthAdapter', () => { } } const providerInstance = new MyAuthProvider(); + const cookieConfig = { + domain: 'example.com', + path: '/auth/test-provider', + secure: false, + sameSite: 'lax', + } as ReturnType; + const oAuthProviderOptions = { providerId: 'test-provider', - secure: false, appOrigin: 'http://localhost:3000', - cookieDomain: 'example.com', - cookiePath: '/auth/test-provider', + cookieConfig, tokenIssuer: { issueToken: async () => 'my-id-token', listPublicKeys: async () => ({ keys: [] }), @@ -136,6 +142,8 @@ describe('OAuthAdapter', () => { expect.objectContaining({ path: '/auth/test-provider', maxAge: THOUSAND_DAYS_MS, + secure: false, + sameSite: 'lax', }), ); }); @@ -331,6 +339,7 @@ describe('OAuthAdapter', () => { domain: 'authdomain.org', path: '/auth/test-provider/handler', secure: true, + sameSite: 'none', }), ); }); diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index 6caa9fed12..c021104010 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -24,6 +24,7 @@ import { import { AuthProviderRouteHandlers, AuthProviderConfig, + CookieConfigurer, } from '../../providers/types'; import { AuthenticationError, @@ -47,11 +48,9 @@ export const TEN_MINUTES_MS = 600 * 1000; /** @public */ export type OAuthAdapterOptions = { providerId: string; - secure: boolean; persistScopes?: boolean; - cookieDomain: string; - cookiePath: string; appOrigin: string; + cookieConfig: ReturnType; isOriginAllowed: (origin: string) => boolean; callbackUrl: string; }; @@ -78,9 +77,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { return new OAuthAdapter(handlers, { ...options, appOrigin, - cookieDomain: cookieConfig.domain, - cookiePath: cookieConfig.path, - secure: cookieConfig.secure, + cookieConfig, isOriginAllowed: config.isOriginAllowed, }); } @@ -93,10 +90,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { ) { this.baseCookieOptions = { httpOnly: true, - sameSite: 'lax', - secure: this.options.secure, - path: this.options.cookiePath, - domain: this.options.cookieDomain, + ...this.options.cookieConfig, }; } @@ -265,7 +259,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { res.cookie(`${this.options.providerId}-nonce`, nonce, { maxAge: TEN_MINUTES_MS, ...this.baseCookieOptions, - path: `${this.options.cookiePath}/handler`, + path: `${this.options.cookieConfig.path}/handler`, }); }; diff --git a/plugins/auth-backend/src/lib/oauth/helpers.test.ts b/plugins/auth-backend/src/lib/oauth/helpers.test.ts index b3052c93e1..2202fa60ea 100644 --- a/plugins/auth-backend/src/lib/oauth/helpers.test.ts +++ b/plugins/auth-backend/src/lib/oauth/helpers.test.ts @@ -150,5 +150,30 @@ describe('OAuthProvider Utils', () => { secure: true, }); }); + + it('should set sameSite to none for https', () => { + expect( + defaultCookieConfigurer({ + baseUrl: '', + providerId: 'test-provider', + callbackUrl: 'https://domain.org/auth', + }), + ).toMatchObject({ + sameSite: 'none', + secure: true, + }); + }); + it('should set sameSite to lax for http', () => { + expect( + defaultCookieConfigurer({ + baseUrl: '', + providerId: 'test-provider', + callbackUrl: 'http://domain.org/auth', + }), + ).toMatchObject({ + sameSite: 'lax', + secure: false, + }); + }); }); }); diff --git a/plugins/auth-backend/src/lib/oauth/helpers.ts b/plugins/auth-backend/src/lib/oauth/helpers.ts index 63d5d62ef9..b18db59ca7 100644 --- a/plugins/auth-backend/src/lib/oauth/helpers.ts +++ b/plugins/auth-backend/src/lib/oauth/helpers.ts @@ -68,6 +68,7 @@ export const defaultCookieConfigurer: CookieConfigurer = ({ }) => { const { hostname: domain, pathname, protocol } = new URL(callbackUrl); const secure = protocol === 'https:'; + const sameSite = secure ? 'none' : 'lax'; // If the provider supports callbackUrls, the pathname will // contain the complete path to the frame handler so we need @@ -76,5 +77,5 @@ export const defaultCookieConfigurer: CookieConfigurer = ({ ? pathname.slice(0, -'/handler/frame'.length) : `${pathname}/${providerId}`; - return { domain, path, secure }; + return { domain, path, secure, sameSite }; }; diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 574afade79..f22e6b0492 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -100,7 +100,12 @@ export type CookieConfigurer = (ctx: { baseUrl: string; /** The configured callback URL of the auth provider */ callbackUrl: string; -}) => { domain: string; path: string; secure: boolean }; +}) => { + domain: string; + path: string; + secure: boolean; + sameSite?: 'none' | 'lax' | 'strict'; +}; /** @public */ export type AuthProviderConfig = { From 7531aef58b36c60255ebed1c9273cc6646971ac7 Mon Sep 17 00:00:00 2001 From: Vladimir Masarik Date: Thu, 1 Sep 2022 10:21:05 +0200 Subject: [PATCH 006/192] Fix settings page not displaying sing in properly Signed-off-by: Vladimir Masarik --- .../AuthProviders/ProviderSettingsItem.tsx | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx index e170aa2daa..6de5f9ad73 100644 --- a/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx +++ b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx @@ -26,9 +26,10 @@ import { import { ApiRef, SessionApi, + ProfileInfoApi, + ProfileInfo, useApi, IconComponent, - SessionState, } from '@backstage/core-plugin-api'; /** @public */ @@ -36,23 +37,27 @@ export const ProviderSettingsItem = (props: { title: string; description: string; icon: IconComponent; - apiRef: ApiRef; + apiRef: ApiRef; }) => { const { title, description, icon: Icon, apiRef } = props; const api = useApi(apiRef); const [signedIn, setSignedIn] = useState(false); + const [profileEmail, setProfileEmail] = useState(''); useEffect(() => { let didCancel = false; - const subscription = api - .sessionState$() - .subscribe((sessionState: SessionState) => { - if (!didCancel) { - setSignedIn(sessionState === SessionState.SignedIn); - } - }); + const subscription = api.sessionState$().subscribe(() => { + if (!didCancel) { + api + .getProfile({ optional: false }) + .then((profile: ProfileInfo | undefined) => { + setSignedIn(profile !== undefined); + setProfileEmail(profile?.email ?? ''); + }); + } + }); return () => { didCancel = true; @@ -69,7 +74,9 @@ export const ProviderSettingsItem = (props: { primary={title} secondary={ - {description} + + {description}. Signed in as {profileEmail} + } secondaryTypographyProps={{ noWrap: true, style: { width: '80%' } }} From 4ce0c846cccae75c682643e0ba3f5f71d514a8fa Mon Sep 17 00:00:00 2001 From: Vladimir Masarik Date: Fri, 2 Sep 2022 14:00:12 +0200 Subject: [PATCH 007/192] fix optional to true Signed-off-by: Vladimir Masarik --- .../src/components/AuthProviders/ProviderSettingsItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx index 6de5f9ad73..7ff13a11d4 100644 --- a/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx +++ b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx @@ -51,7 +51,7 @@ export const ProviderSettingsItem = (props: { const subscription = api.sessionState$().subscribe(() => { if (!didCancel) { api - .getProfile({ optional: false }) + .getProfile({ optional: true }) .then((profile: ProfileInfo | undefined) => { setSignedIn(profile !== undefined); setProfileEmail(profile?.email ?? ''); From 5e00eb69c5b79f686f27a248136d6f52e80d61bd Mon Sep 17 00:00:00 2001 From: Vladimir Masarik Date: Fri, 2 Sep 2022 14:00:38 +0200 Subject: [PATCH 008/192] check sessions state as well Signed-off-by: Vladimir Masarik --- .../AuthProviders/ProviderSettingsItem.tsx | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx index 7ff13a11d4..4a3b28b335 100644 --- a/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx +++ b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx @@ -26,6 +26,7 @@ import { import { ApiRef, SessionApi, + SessionState, ProfileInfoApi, ProfileInfo, useApi, @@ -48,16 +49,22 @@ export const ProviderSettingsItem = (props: { useEffect(() => { let didCancel = false; - const subscription = api.sessionState$().subscribe(() => { - if (!didCancel) { - api - .getProfile({ optional: true }) - .then((profile: ProfileInfo | undefined) => { - setSignedIn(profile !== undefined); - setProfileEmail(profile?.email ?? ''); - }); - } - }); + const subscription = api + .sessionState$() + .subscribe((sessionState: SessionState) => { + if (!didCancel) { + api + .getProfile({ optional: true }) + .then((profile: ProfileInfo | undefined) => { + if (sessionState === SessionState.SignedIn) { + setSignedIn(true); + } else { + setSignedIn(profile !== undefined); + } + setProfileEmail(profile?.email ?? ''); + }); + } + }); return () => { didCancel = true; From 0d25d297a33841b8e8e0c24c0d48c60a37fa784a Mon Sep 17 00:00:00 2001 From: Vladimir Masarik Date: Mon, 5 Sep 2022 16:46:08 +0200 Subject: [PATCH 009/192] add more info into providers Signed-off-by: Vladimir Masarik --- .../AuthProviders/ProviderSettingsAvatar.tsx | 38 +++++++++++++++++++ .../AuthProviders/ProviderSettingsItem.tsx | 37 +++++++++++++++--- 2 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 plugins/user-settings/src/components/AuthProviders/ProviderSettingsAvatar.tsx diff --git a/plugins/user-settings/src/components/AuthProviders/ProviderSettingsAvatar.tsx b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsAvatar.tsx new file mode 100644 index 0000000000..e92b0efe15 --- /dev/null +++ b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsAvatar.tsx @@ -0,0 +1,38 @@ +/* + * 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 { BackstageTheme } from '@backstage/theme'; +import { makeStyles, Avatar } from '@material-ui/core'; +import { sidebarConfig } from '@backstage/core-components'; + +const useStyles = makeStyles(theme => ({ + avatar: { + width: ({ size }) => size, + height: ({ size }) => size, + fontSize: ({ size }) => size * 0.7, + border: `1px solid ${theme.palette.textSubtle}`, + }, +})); + +type Props = { size?: number; picture: string | undefined }; + +export const ProviderSettingsAvatar = ({ size, picture }: Props) => { + const { iconSize } = sidebarConfig; + const classes = useStyles(size ? { size } : { size: iconSize }); + + return ; +}; diff --git a/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx index 4a3b28b335..ef31e70d81 100644 --- a/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx +++ b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx @@ -17,11 +17,13 @@ import React, { useEffect, useState } from 'react'; import { Button, + Grid, ListItem, ListItemIcon, ListItemSecondaryAction, ListItemText, Tooltip, + Typography, } from '@material-ui/core'; import { ApiRef, @@ -32,6 +34,7 @@ import { useApi, IconComponent, } from '@backstage/core-plugin-api'; +import { ProviderSettingsAvatar } from './ProviderSettingsAvatar'; /** @public */ export const ProviderSettingsItem = (props: { @@ -44,7 +47,8 @@ export const ProviderSettingsItem = (props: { const api = useApi(apiRef); const [signedIn, setSignedIn] = useState(false); - const [profileEmail, setProfileEmail] = useState(''); + const emptyProfile: ProfileInfo = {}; + const [profile, setProfile] = useState(emptyProfile); useEffect(() => { let didCancel = false; @@ -55,13 +59,13 @@ export const ProviderSettingsItem = (props: { if (!didCancel) { api .getProfile({ optional: true }) - .then((profile: ProfileInfo | undefined) => { + .then((profileResponse: ProfileInfo | undefined) => { if (sessionState === SessionState.SignedIn) { setSignedIn(true); } else { - setSignedIn(profile !== undefined); + setSignedIn(profileResponse !== undefined); } - setProfileEmail(profile?.email ?? ''); + setProfile(profileResponse ?? {}); }); } }); @@ -82,7 +86,30 @@ export const ProviderSettingsItem = (props: { secondary={ - {description}. Signed in as {profileEmail} + + + + + + + + + {profile.displayName} + + + {profile.email} + + + {description} + + + + + } From 5543e866602cd6fd67ee8cf04c2233b7b8d1a8e3 Mon Sep 17 00:00:00 2001 From: Vladimir Masarik Date: Tue, 6 Sep 2022 19:09:59 +0200 Subject: [PATCH 010/192] add change set Signed-off-by: Vladimir Masarik --- .changeset/soft-falcons-love.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/soft-falcons-love.md diff --git a/.changeset/soft-falcons-love.md b/.changeset/soft-falcons-love.md new file mode 100644 index 0000000000..5f2d282158 --- /dev/null +++ b/.changeset/soft-falcons-love.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-user-settings': patch +--- + +Fixed settings page showing providers as logged out when the user is using more than one provider, and displayed some additional login information. From 2ecbd7cbdfca9c11269649501378629e5d5ffef6 Mon Sep 17 00:00:00 2001 From: Nick Marinelli Date: Tue, 6 Sep 2022 16:59:29 -0400 Subject: [PATCH 011/192] update for #13338 changes, and option Signed-off-by: Nick Marinelli --- .../docs/tutorials/enable-ssl-self-signed.md | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/contrib/docs/tutorials/enable-ssl-self-signed.md b/contrib/docs/tutorials/enable-ssl-self-signed.md index 18d9752ba2..67c7091341 100644 --- a/contrib/docs/tutorials/enable-ssl-self-signed.md +++ b/contrib/docs/tutorials/enable-ssl-self-signed.md @@ -5,37 +5,45 @@ If you need to use an `https:` URL for local testing (i.e. if an OAuth provider ## Backend 1. Generate a self-signed certificate and key for localhost and configure your system to trust it. The application ["mkcert"](https://github.com/FiloSottile/mkcert) is a helpful tool to accomplish this. -1. Update `backend.baseUrl` in app-config.local.yaml to use an `https:` address, and copy the contents of the certificate and key files into `backend.https.certificate.cert` and `backend.https.certificate.cert`, respectively. Your app-config.local.yaml should look something like: +1. Update `backend.baseUrl` in app-config.local.yaml to use an `https:` address. +1. Add the certificate and key to `backend.https.certificate.cert` and `backend.https.certificate.cert`, respectively. ```yaml backend: baseUrl: https://localhost:7007 https: certificate: + # You may copy the contents of the file... cert: | -----BEGIN CERTIFICATE----- MIIDCTCCAfGgAwIBAgIUZ9VhZckcy690L ... -----END CERTIFICATE----- - key: | - -----BEGIN PRIVATE KEY----- - MIIEvAIBADANBgkqhkiG9w0BAQ - ... - -----END PRIVATE KEY----- + # ... or use a path + key: + $file: ./certs/localhost-key.pem ``` 1. Start the backend with `NODE_EXTRA_CA_CERTS=/absolute/path/to/cert.pem yarn start-backend` ## Frontend -Webpack will generate a self signed certificate automatically in development environments when the protocol in the `baseUrl` is `https`. Therefore, simply add this to your local config: +1. As with the backend instructions above, a trusted certificate and key are needed. +1. Update `app.baseUrl` and `backend.cors.origin` in app-config.local.yaml to use an `https:` address. +1. Add the certificate and key to `app.https.certificate.cert` and `app.https.certificate.cert`, respectively. -```yaml -app: - baseUrl: https://localhost:3000 -backend: - cors: - origin: https://localhost:3000 -``` + ```yaml + app: + baseUrl: https://localhost:3000 + https: + certificate: + cert: + $file: ./certs/localhost.pem + key: + $file: ./certs/localhost-key.pem + backend: + cors: + origin: https://localhost:3000 + ``` -and start the app with `yarn start`. As with the backend instructions above, the certificate must be trusted. +1. and start the app with `yarn start`. Depending on what plugins are in use, you may need to override additional URLs to use `https` for those endpoints to work. From 8bf6ce022348b9923f9fcc7a01cfe11de89a15ec Mon Sep 17 00:00:00 2001 From: Nick Marinelli Date: Tue, 6 Sep 2022 17:28:33 -0400 Subject: [PATCH 012/192] further vale appeasement Signed-off-by: Nick Marinelli --- contrib/docs/tutorials/enable-ssl-self-signed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contrib/docs/tutorials/enable-ssl-self-signed.md b/contrib/docs/tutorials/enable-ssl-self-signed.md index 67c7091341..27e0c82063 100644 --- a/contrib/docs/tutorials/enable-ssl-self-signed.md +++ b/contrib/docs/tutorials/enable-ssl-self-signed.md @@ -4,7 +4,7 @@ If you need to use an `https:` URL for local testing (i.e. if an OAuth provider ## Backend -1. Generate a self-signed certificate and key for localhost and configure your system to trust it. The application ["mkcert"](https://github.com/FiloSottile/mkcert) is a helpful tool to accomplish this. +1. Generate a self-signed certificate and key for localhost and configure your system to trust it. The application [`mkcert`](https://github.com/FiloSottile/mkcert) is a helpful tool to accomplish this. 1. Update `backend.baseUrl` in app-config.local.yaml to use an `https:` address. 1. Add the certificate and key to `backend.https.certificate.cert` and `backend.https.certificate.cert`, respectively. ```yaml From d419784c8bf7faae959ae35cf72aa1342da5616c Mon Sep 17 00:00:00 2001 From: Nick Marinelli Date: Tue, 6 Sep 2022 17:31:43 -0400 Subject: [PATCH 013/192] whitespace Signed-off-by: Nick Marinelli --- contrib/docs/tutorials/enable-ssl-self-signed.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/contrib/docs/tutorials/enable-ssl-self-signed.md b/contrib/docs/tutorials/enable-ssl-self-signed.md index 27e0c82063..80f5d36a98 100644 --- a/contrib/docs/tutorials/enable-ssl-self-signed.md +++ b/contrib/docs/tutorials/enable-ssl-self-signed.md @@ -29,7 +29,6 @@ If you need to use an `https:` URL for local testing (i.e. if an OAuth provider 1. As with the backend instructions above, a trusted certificate and key are needed. 1. Update `app.baseUrl` and `backend.cors.origin` in app-config.local.yaml to use an `https:` address. 1. Add the certificate and key to `app.https.certificate.cert` and `app.https.certificate.cert`, respectively. - ```yaml app: baseUrl: https://localhost:3000 @@ -43,7 +42,6 @@ If you need to use an `https:` URL for local testing (i.e. if an OAuth provider cors: origin: https://localhost:3000 ``` - 1. and start the app with `yarn start`. Depending on what plugins are in use, you may need to override additional URLs to use `https` for those endpoints to work. From 19ac96e4c2987c31af0c97ecbe01cdca5c0288dc Mon Sep 17 00:00:00 2001 From: David Zemon Date: Thu, 8 Sep 2022 11:36:49 -0500 Subject: [PATCH 014/192] feat: Allow passing additional JWT claims to `TokenIssuer` Signed-off-by: David Zemon --- plugins/auth-backend/src/identity/TokenFactory.test.ts | 8 +++++++- plugins/auth-backend/src/identity/TokenFactory.ts | 5 ++--- plugins/auth-backend/src/identity/types.ts | 10 ++++++++-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/plugins/auth-backend/src/identity/TokenFactory.test.ts b/plugins/auth-backend/src/identity/TokenFactory.test.ts index 9a163b3ff5..4f6df89210 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.test.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.test.ts @@ -48,7 +48,12 @@ describe('TokenFactory', () => { await expect(factory.listPublicKeys()).resolves.toEqual({ keys: [] }); const token = await factory.issueToken({ - claims: { sub: entityRef, ent: [entityRef] }, + claims: { + sub: entityRef, + ent: [entityRef], + 'x-fancy-claim': 'my special claim', + aud: 'this value will be overridden', + }, }); const { keys } = await factory.listPublicKeys(); @@ -60,6 +65,7 @@ describe('TokenFactory', () => { aud: 'backstage', sub: entityRef, ent: [entityRef], + 'x-fancy-claim': 'my special claim', iat: expect.any(Number), exp: expect.any(Number), }); diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index 44cfebfb71..f80b79b6bc 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -77,8 +77,7 @@ export class TokenFactory implements TokenIssuer { const key = await this.getKey(); const iss = this.issuer; - const sub = params.claims.sub; - const ent = params.claims.ent; + const { sub, ent, ...additionalClaims } = params.claims; const aud = 'backstage'; const iat = Math.floor(Date.now() / MS_IN_S); const exp = iat + this.keyDurationSeconds; @@ -98,7 +97,7 @@ export class TokenFactory implements TokenIssuer { throw new AuthenticationError('No algorithm was provided in the key'); } - return new SignJWT({ iss, sub, ent, aud, iat, exp }) + return new SignJWT({ ...additionalClaims, iss, sub, ent, aud, iat, exp }) .setProtectedHeader({ alg: key.alg, kid: key.kid }) .setIssuer(iss) .setAudience(aud) diff --git a/plugins/auth-backend/src/identity/types.ts b/plugins/auth-backend/src/identity/types.ts index 4765b50d7d..49d78b8472 100644 --- a/plugins/auth-backend/src/identity/types.ts +++ b/plugins/auth-backend/src/identity/types.ts @@ -28,13 +28,19 @@ export interface AnyJWK extends Record { * @public */ export type TokenParams = { - /** The claims that will be embedded within the token */ + /** + * The claims that will be embedded within the token. At a minimum, this should include + * the subject claim, `sub`. It is common to also list entity ownership relations in the + * `ent` list. Additional claims may also be added at the developer's discretion except + * for the following list, which will be overwritten by the TokenIssuer: `iss`, `aud`, + * `iat`, and `exp`. + */ claims: { /** The token subject, i.e. User ID */ sub: string; /** A list of entity references that the user claims ownership through */ ent?: string[]; - }; + } & Record; }; /** From 5b011fb2e6aa88964fe737531fe2296e7e3f8dbb Mon Sep 17 00:00:00 2001 From: David Zemon Date: Thu, 8 Sep 2022 11:48:36 -0500 Subject: [PATCH 015/192] chore: Add changeset Signed-off-by: David Zemon --- .changeset/cold-bananas-hang.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cold-bananas-hang.md diff --git a/.changeset/cold-bananas-hang.md b/.changeset/cold-bananas-hang.md new file mode 100644 index 0000000000..8e535a5532 --- /dev/null +++ b/.changeset/cold-bananas-hang.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Allow adding misc claims to JWT From dba3c15c96be917c99461158a16f6a9dfafab3b0 Mon Sep 17 00:00:00 2001 From: David Zemon Date: Thu, 8 Sep 2022 12:06:23 -0500 Subject: [PATCH 016/192] chore: Add API report Signed-off-by: David Zemon --- plugins/auth-backend/api-report.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index d47abeee83..2420a79a79 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -706,7 +706,7 @@ export type TokenParams = { claims: { sub: string; ent?: string[]; - }; + } & Record; }; // @public (undocumented) From 12943d1ade5a7658a05148d7a8a676ce98a43c40 Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Fri, 9 Sep 2022 11:16:04 +0200 Subject: [PATCH 017/192] Calculate SameSite attribute more carefully, defaulting to lax in most cases Signed-off-by: Marcus Eide --- .changeset/kind-penguins-report.md | 7 +- plugins/auth-backend/api-report.md | 3 + .../src/lib/oauth/OAuthAdapter.test.ts | 121 +++++++++++++++++- .../src/lib/oauth/OAuthAdapter.ts | 25 +++- .../src/lib/oauth/helpers.test.ts | 40 +++++- plugins/auth-backend/src/lib/oauth/helpers.ts | 11 +- plugins/auth-backend/src/providers/types.ts | 2 + 7 files changed, 193 insertions(+), 16 deletions(-) diff --git a/.changeset/kind-penguins-report.md b/.changeset/kind-penguins-report.md index 6596f727d2..9f2401491e 100644 --- a/.changeset/kind-penguins-report.md +++ b/.changeset/kind-penguins-report.md @@ -1,5 +1,8 @@ --- -'@backstage/plugin-auth-backend': patch +'@backstage/plugin-auth-backend': minor --- -Allow CookieConfigurer to optionally return the SameSite cookie attribute. Return `SameSite=None` in `defaultCookieConfigurer` for secure contexts to allow cookies to be included in third-party requests. +CookieConfigurer can optionally return the `SameSite` cookie attribute. +CookieConfigurer now requires an additional argument `appOrigin` - the origin URL of the app - which is used to calculate the `SameSite` attribute. +defaultCookieConfigurer returns the `SameSite` attribute which defaults to `Lax`. In cases where an auth-backend is running on a different domain than the App, `SameSite=None` is used - but only for secure contexts. This is so that cookies can be included in third-party requests. +OAuthAdapterOptions has been modified to require additional arguments, `baseUrl`, `cookieConfigurer` and `cookieConfig`. diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index ed3561d37c..8cb7e35e9b 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -183,6 +183,7 @@ export type CookieConfigurer = (ctx: { providerId: string; baseUrl: string; callbackUrl: string; + appOrigin: string; }) => { domain: string; path: string; @@ -283,7 +284,9 @@ export type OAuthAdapterOptions = { providerId: string; persistScopes?: boolean; appOrigin: string; + baseUrl: string; cookieConfig: ReturnType; + cookieConfigurer: CookieConfigurer; isOriginAllowed: (origin: string) => boolean; callbackUrl: string; }; diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts index a61554b600..d59a712205 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts @@ -37,6 +37,10 @@ const mockResponseData = { }; describe('OAuthAdapter', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + class MyAuthProvider implements OAuthHandlers { async start() { return { @@ -58,17 +62,19 @@ describe('OAuthAdapter', () => { } } const providerInstance = new MyAuthProvider(); - const cookieConfig = { + const mockCookieConfig: ReturnType = { domain: 'example.com', path: '/auth/test-provider', secure: false, - sameSite: 'lax', - } as ReturnType; + }; + const mockCookieConfigurer = jest.fn().mockReturnValue(mockCookieConfig); const oAuthProviderOptions = { providerId: 'test-provider', appOrigin: 'http://localhost:3000', - cookieConfig, + cookieConfig: mockCookieConfig, + cookieConfigurer: mockCookieConfigurer, + baseUrl: 'http://example.com:7007', tokenIssuer: { issueToken: async () => 'my-id-token', listPublicKeys: async () => ({ keys: [] }), @@ -135,11 +141,13 @@ describe('OAuthAdapter', () => { } as unknown as express.Response; await oauthProvider.frameHandler(mockRequest, mockResponse); + expect(mockCookieConfigurer).toHaveBeenCalledTimes(1); expect(mockResponse.cookie).toHaveBeenCalledTimes(1); expect(mockResponse.cookie).toHaveBeenCalledWith( expect.stringContaining('test-provider-refresh-token'), expect.stringContaining('token'), expect.objectContaining({ + httpOnly: true, path: '/auth/test-provider', maxAge: THOUSAND_DAYS_MS, secure: false, @@ -210,6 +218,7 @@ describe('OAuthAdapter', () => { } as unknown as express.Response; await oauthProvider.frameHandler(mockHandleReq, mockHandleRes); + expect(mockCookieConfigurer).toHaveBeenCalledTimes(1); expect(mockHandleRes.cookie).toHaveBeenCalledTimes(1); expect(mockHandleRes.cookie).toHaveBeenCalledWith( 'test-provider-granted-scope', @@ -303,7 +312,7 @@ describe('OAuthAdapter', () => { }); }); - it('sets the correct cookie configuration using a callbackUrl', async () => { + it('sets the correct cookie configuration using an unsecure callbackUrl', async () => { const config = { baseUrl: 'http://domain.org/auth', appUrl: 'http://domain.org', @@ -312,7 +321,7 @@ describe('OAuthAdapter', () => { const oauthProvider = OAuthAdapter.fromConfig(config, providerInstance, { ...oAuthProviderOptions, - callbackUrl: 'https://authdomain.org/auth/test-provider/handler/frame', + callbackUrl: 'http://authdomain.org/auth/test-provider/handler/frame', }); const mockRequest = { @@ -330,14 +339,112 @@ describe('OAuthAdapter', () => { } as unknown as express.Response; await oauthProvider.start(mockRequest, mockResponse); - + expect(mockCookieConfigurer).not.toHaveBeenCalled(); expect(mockResponse.cookie).toHaveBeenCalledTimes(1); expect(mockResponse.cookie).toHaveBeenCalledWith( `${oAuthProviderOptions.providerId}-nonce`, expect.any(String), expect.objectContaining({ + httpOnly: true, domain: 'authdomain.org', path: '/auth/test-provider/handler', + secure: false, + sameSite: 'lax', + }), + ); + }); + + it('sets the correct cookie configuration using an secure callbackUrl', async () => { + const config = { + baseUrl: 'https://domain.org/auth', + appUrl: 'http://domain.org', + isOriginAllowed: () => false, + }; + + const oauthProvider = OAuthAdapter.fromConfig(config, providerInstance, { + ...oAuthProviderOptions, + callbackUrl: 'https://authdomain.org/auth/test-provider/handler/frame', + }); + + const state = { + nonce: 'nonce', + env: 'development', + }; + + const mockRequest = { + cookies: { + 'test-provider-nonce': 'nonce', + }, + query: { + state: encodeState(state), + }, + } as unknown as express.Request; + + const mockResponse = { + cookie: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), + } as unknown as express.Response; + + await oauthProvider.frameHandler(mockRequest, mockResponse); + expect(mockCookieConfigurer).not.toHaveBeenCalled(); + expect(mockResponse.cookie).toHaveBeenCalledTimes(1); + expect(mockResponse.cookie).toHaveBeenCalledWith( + expect.stringContaining('test-provider-refresh-token'), + expect.stringContaining('token'), + expect.objectContaining({ + httpOnly: true, + domain: 'authdomain.org', + path: '/auth/test-provider', + secure: true, + sameSite: 'none', + }), + ); + }); + + it('sets the correct cookie configuration using state', async () => { + const config = { + baseUrl: 'https://domain.org/auth', + appUrl: 'http://domain.org', + isOriginAllowed: () => true, + }; + + const oauthProvider = OAuthAdapter.fromConfig(config, providerInstance, { + ...oAuthProviderOptions, + callbackUrl: 'https://domain.org/auth/test-provider/handler/frame', + }); + + const state = { + nonce: 'nonce', + env: 'development', + origin: 'http://other.domain', + }; + + const mockRequest = { + cookies: { + 'test-provider-nonce': 'nonce', + }, + query: { + state: encodeState(state), + }, + } as unknown as express.Request; + + const mockResponse = { + cookie: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), + } as unknown as express.Response; + + await oauthProvider.frameHandler(mockRequest, mockResponse); + expect(mockCookieConfigurer).not.toHaveBeenCalled(); + expect(mockResponse.cookie).toHaveBeenCalledTimes(1); + expect(mockResponse.cookie).toHaveBeenCalledWith( + expect.stringContaining('test-provider-refresh-token'), + expect.stringContaining('token'), + expect.objectContaining({ + httpOnly: true, + domain: 'domain.org', + path: '/auth/test-provider', secure: true, sameSite: 'none', }), diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index c021104010..64dc4f70e3 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -50,7 +50,9 @@ export type OAuthAdapterOptions = { providerId: string; persistScopes?: boolean; appOrigin: string; + baseUrl: string; cookieConfig: ReturnType; + cookieConfigurer: CookieConfigurer; isOriginAllowed: (origin: string) => boolean; callbackUrl: string; }; @@ -65,24 +67,28 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { 'providerId' | 'persistScopes' | 'callbackUrl' >, ): OAuthAdapter { - const { origin: appOrigin } = new URL(config.appUrl); + const { appUrl, baseUrl, isOriginAllowed } = config; + const { origin: appOrigin } = new URL(appUrl); const cookieConfigurer = config.cookieConfigurer ?? defaultCookieConfigurer; const cookieConfig = cookieConfigurer({ providerId: options.providerId, baseUrl: config.baseUrl, callbackUrl: options.callbackUrl, + appOrigin, }); return new OAuthAdapter(handlers, { ...options, appOrigin, + baseUrl, cookieConfig, - isOriginAllowed: config.isOriginAllowed, + cookieConfigurer, + isOriginAllowed, }); } - private readonly baseCookieOptions: CookieOptions; + private baseCookieOptions: CookieOptions; constructor( private readonly handlers: OAuthHandlers, @@ -90,6 +96,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { ) { this.baseCookieOptions = { httpOnly: true, + sameSite: 'lax', ...this.options.cookieConfig, }; } @@ -147,6 +154,18 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { } } + // Update cookie options to reflect any changes for the appOrigin + const updatedCookieOptions = this.options.cookieConfigurer({ + providerId: this.options.providerId, + baseUrl: this.options.baseUrl, + callbackUrl: this.options.callbackUrl, + appOrigin, + }); + this.baseCookieOptions = { + ...this.baseCookieOptions, + ...updatedCookieOptions, + }; + // verify nonce cookie and state cookie on callback verifyNonce(req, this.options.providerId); diff --git a/plugins/auth-backend/src/lib/oauth/helpers.test.ts b/plugins/auth-backend/src/lib/oauth/helpers.test.ts index 2202fa60ea..58a3dcab05 100644 --- a/plugins/auth-backend/src/lib/oauth/helpers.test.ts +++ b/plugins/auth-backend/src/lib/oauth/helpers.test.ts @@ -117,6 +117,7 @@ describe('OAuthProvider Utils', () => { baseUrl: '', providerId: 'test-provider', callbackUrl: 'http://domain.org/auth', + appOrigin: 'http://domain.org', }), ).toMatchObject({ domain: 'domain.org', @@ -131,6 +132,7 @@ describe('OAuthProvider Utils', () => { baseUrl: '', providerId: 'test-provider', callbackUrl: 'http://domain.org/auth/test-provider/handler/frame', + appOrigin: 'http://domain.org', }), ).toMatchObject({ domain: 'domain.org', @@ -145,35 +147,67 @@ describe('OAuthProvider Utils', () => { baseUrl: '', providerId: 'test-provider', callbackUrl: 'https://domain.org/auth', + appOrigin: 'http://domain.org', }), ).toMatchObject({ secure: true, }); }); - it('should set sameSite to none for https', () => { + it('should set sameSite to lax for https on the same domain', () => { expect( defaultCookieConfigurer({ baseUrl: '', providerId: 'test-provider', callbackUrl: 'https://domain.org/auth', + appOrigin: 'http://domain.org', }), ).toMatchObject({ - sameSite: 'none', + sameSite: 'lax', secure: true, }); }); - it('should set sameSite to lax for http', () => { + + it('should set sameSite to lax for http on the same domain', () => { expect( defaultCookieConfigurer({ baseUrl: '', providerId: 'test-provider', callbackUrl: 'http://domain.org/auth', + appOrigin: 'http://domain.org', }), ).toMatchObject({ sameSite: 'lax', secure: false, }); }); + + it('should set sameSite to lax if not secure and on different domains', () => { + expect( + defaultCookieConfigurer({ + baseUrl: '', + providerId: 'test-provider', + callbackUrl: 'http://authdomain.org/auth', + appOrigin: 'http://domain.org', + }), + ).toMatchObject({ + sameSite: 'lax', + secure: false, + }); + }); + + it('should set sameSite to none if secure and on different domains', () => { + expect( + defaultCookieConfigurer({ + baseUrl: '', + providerId: 'test-provider', + callbackUrl: 'https://authdomain.org/auth', + appOrigin: 'http://domain.org', + }), + ).toMatchObject({ + sameSite: 'none', + secure: true, + }); + }); }); }); diff --git a/plugins/auth-backend/src/lib/oauth/helpers.ts b/plugins/auth-backend/src/lib/oauth/helpers.ts index b18db59ca7..2b4e50b477 100644 --- a/plugins/auth-backend/src/lib/oauth/helpers.ts +++ b/plugins/auth-backend/src/lib/oauth/helpers.ts @@ -65,10 +65,19 @@ export const verifyNonce = (req: express.Request, providerId: string) => { export const defaultCookieConfigurer: CookieConfigurer = ({ callbackUrl, providerId, + appOrigin, }) => { const { hostname: domain, pathname, protocol } = new URL(callbackUrl); const secure = protocol === 'https:'; - const sameSite = secure ? 'none' : 'lax'; + + // For situations where the auth-backend is running on a + // different domain than the app, we set the SameSite attribute + // to 'none' to allow third-party access to the cookie, but + // only if it's in a secure context (https). + let sameSite: ReturnType['sameSite'] = 'lax'; + if (new URL(appOrigin).hostname !== domain && secure) { + sameSite = 'none'; + } // If the provider supports callbackUrls, the pathname will // contain the complete path to the frame handler so we need diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index f22e6b0492..1459cff484 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -100,6 +100,8 @@ export type CookieConfigurer = (ctx: { baseUrl: string; /** The configured callback URL of the auth provider */ callbackUrl: string; + /** The origin URL of the app */ + appOrigin: string; }) => { domain: string; path: string; From bc329bab90ebb1e409888069155e4bcde1111275 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 9 Sep 2022 11:17:47 +0000 Subject: [PATCH 018/192] fix(deps): update dependency @codemirror/view to v6.2.3 Signed-off-by: Renovate Bot --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index b1f3cc567f..dc5dd6dfee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7814,13 +7814,13 @@ __metadata: linkType: hard "@codemirror/view@npm:^6.0.0": - version: 6.2.2 - resolution: "@codemirror/view@npm:6.2.2" + version: 6.2.3 + resolution: "@codemirror/view@npm:6.2.3" dependencies: "@codemirror/state": ^6.0.0 style-mod: ^4.0.0 w3c-keyname: ^2.2.4 - checksum: 6983c51362367d3885961fa233d302e75dfb103cd83ec78e6a044c2420e61466b5e94cfb73a9e840b04765577e8bbb269eb1ad45fa21abd28efda4ae780791db + checksum: 93d6f159c49ffd9276e9b6ba28cef2c41934be7eb68aa49acf1971dede97ee2684bde6daeb7494da8e15f2ef937933c3fa076253127b230c6996db0fe7a79ef2 languageName: node linkType: hard From 01345242dd8b47b93b883aee2a9367159ad89b82 Mon Sep 17 00:00:00 2001 From: Axel Hecht Date: Fri, 9 Sep 2022 14:41:47 +0200 Subject: [PATCH 019/192] Update .changeset/famous-rats-heal.md Co-authored-by: Ben Lambert Signed-off-by: Axel Hecht --- .changeset/famous-rats-heal.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/famous-rats-heal.md b/.changeset/famous-rats-heal.md index e233070849..4235a9fa44 100644 --- a/.changeset/famous-rats-heal.md +++ b/.changeset/famous-rats-heal.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog-backend': patch --- -Add optional value to hasAnnotation permission rule +Add optional value to `hasAnnotation` permission rule From 2270997d57f4f39f58bbfadee9dcaf7d101ee774 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Mota?= Date: Fri, 9 Sep 2022 22:44:55 +0200 Subject: [PATCH 020/192] Update docker guide for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As happened to a colleague of mine and might happen to others, one might see a "docker" section and skip straight to that since it looks on the surface to be a quick way to check out backstage yourself. In this case that resulted in trying to make all these builds from the main backstage/backstage repo, before even understanding the concept of a Backstage App, which obviously did not work out. Signed-off-by: Tomás Mota --- docs/deployment/docker.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index d46aff1b06..5455babc6f 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -16,7 +16,8 @@ are stateless, so for a production deployment you will want to set up and connect to an external PostgreSQL instance where the backend plugins can store their state, rather than using SQLite. -By default, in an app created with `@backstage/create-app`, the frontend is +It is in this section assumed that an [app](https://backstage.io/docs/getting-started/create-an-app) +has already been created with`@backstage/create-app`, in which the frontend is bundled and served from the backend. This is done using the `@backstage/plugin-app-backend` plugin, which also injects the frontend configuration into the app. This means that you only need to build and deploy a From 01e9214ab23cb64b1d62b9bee29b630a56f596b8 Mon Sep 17 00:00:00 2001 From: Vladimir Masarik Date: Sun, 11 Sep 2022 15:34:24 +0200 Subject: [PATCH 021/192] Chang changeset to minor Signed-off-by: Vladimir Masarik --- .changeset/soft-falcons-love.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/soft-falcons-love.md b/.changeset/soft-falcons-love.md index 5f2d282158..9fbc8f1419 100644 --- a/.changeset/soft-falcons-love.md +++ b/.changeset/soft-falcons-love.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-user-settings': patch +'@backstage/plugin-user-settings': minor --- -Fixed settings page showing providers as logged out when the user is using more than one provider, and displayed some additional login information. +**BREAKING** Fixed settings page showing providers as logged out when the user is using more than one provider, and displayed some additional login information. From 2282c9936ceed5b34efbd6a0993e1a8a38ca0b16 Mon Sep 17 00:00:00 2001 From: Vladimir Masarik Date: Sun, 11 Sep 2022 15:34:35 +0200 Subject: [PATCH 022/192] Generate API report Signed-off-by: Vladimir Masarik --- plugins/user-settings/api-report.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/user-settings/api-report.md b/plugins/user-settings/api-report.md index bc5c3a9c70..e2f3d46e24 100644 --- a/plugins/user-settings/api-report.md +++ b/plugins/user-settings/api-report.md @@ -10,6 +10,7 @@ import { BackstagePlugin } from '@backstage/core-plugin-api'; import { BackstageUserIdentity } from '@backstage/core-plugin-api'; import { IconComponent } from '@backstage/core-plugin-api'; import { ProfileInfo } from '@backstage/core-plugin-api'; +import { ProfileInfoApi } from '@backstage/core-plugin-api'; import { PropsWithChildren } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; import { SessionApi } from '@backstage/core-plugin-api'; @@ -24,7 +25,7 @@ export const ProviderSettingsItem: (props: { title: string; description: string; icon: IconComponent; - apiRef: ApiRef; + apiRef: ApiRef; }) => JSX.Element; // @public (undocumented) From cc538eccc20eca3102ad0332dda8ef97cafb117c Mon Sep 17 00:00:00 2001 From: Vladimir Masarik Date: Sun, 11 Sep 2022 15:34:56 +0200 Subject: [PATCH 023/192] Dont set to empty profile Signed-off-by: Vladimir Masarik --- .../src/components/AuthProviders/ProviderSettingsItem.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx index ef31e70d81..b5d056eed2 100644 --- a/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx +++ b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx @@ -62,10 +62,10 @@ export const ProviderSettingsItem = (props: { .then((profileResponse: ProfileInfo | undefined) => { if (sessionState === SessionState.SignedIn) { setSignedIn(true); - } else { - setSignedIn(profileResponse !== undefined); } - setProfile(profileResponse ?? {}); + if (profileResponse) { + setProfile(profileResponse); + } }); } }); From f52dfc0c485f7c7cc0763bb89b978828895bc021 Mon Sep 17 00:00:00 2001 From: sblausten Date: Mon, 12 Sep 2022 11:56:23 +0100 Subject: [PATCH 024/192] Fix overflow issue for mic drop image Signed-off-by: sblausten --- packages/core-components/src/layout/ErrorPage/ErrorPage.tsx | 2 +- packages/core-components/src/layout/ErrorPage/MicDrop.tsx | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core-components/src/layout/ErrorPage/ErrorPage.tsx b/packages/core-components/src/layout/ErrorPage/ErrorPage.tsx index 928a3eb0d4..658c06de2a 100644 --- a/packages/core-components/src/layout/ErrorPage/ErrorPage.tsx +++ b/packages/core-components/src/layout/ErrorPage/ErrorPage.tsx @@ -70,7 +70,6 @@ export function ErrorPage(props: IErrorPageProps) { return ( - + ); } diff --git a/packages/core-components/src/layout/ErrorPage/MicDrop.tsx b/packages/core-components/src/layout/ErrorPage/MicDrop.tsx index efa101b23b..aacb44079a 100644 --- a/packages/core-components/src/layout/ErrorPage/MicDrop.tsx +++ b/packages/core-components/src/layout/ErrorPage/MicDrop.tsx @@ -22,12 +22,10 @@ const useStyles = makeStyles( theme => ({ micDrop: { maxWidth: '60%', - position: 'absolute', bottom: theme.spacing(2), right: theme.spacing(2), [theme.breakpoints.down('xs')]: { maxWidth: '96%', - position: 'relative', bottom: 'unset', right: 'unset', margin: `${theme.spacing(10)}px auto ${theme.spacing(4)}px`, From 023d14c52b03d530de1dd257cd199e9caafd37d7 Mon Sep 17 00:00:00 2001 From: sblausten Date: Mon, 12 Sep 2022 13:03:50 +0100 Subject: [PATCH 025/192] changeset Signed-off-by: sblausten --- .changeset/three-dryers-confess.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/three-dryers-confess.md diff --git a/.changeset/three-dryers-confess.md b/.changeset/three-dryers-confess.md new file mode 100644 index 0000000000..e68bc36665 --- /dev/null +++ b/.changeset/three-dryers-confess.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Fix overflow bug on MicDrop image for 404 page by moving the image and making it relative rather than absolute From 05f22193c5a57796079890952624b55ec582ce80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Tunkl?= Date: Tue, 13 Sep 2022 13:46:32 +0200 Subject: [PATCH 026/192] Update of EntityPickers to support flags which control omit of default namespace in result. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomáš Tunkl --- .changeset/selfish-turkeys-exist.md | 5 +++++ plugins/scaffolder/api-report.md | 6 ++++++ .../src/components/fields/EntityPicker/EntityPicker.tsx | 4 +++- .../fields/OwnedEntityPicker/OwnedEntityPicker.tsx | 4 +++- .../src/components/fields/OwnerPicker/OwnerPicker.tsx | 2 ++ 5 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 .changeset/selfish-turkeys-exist.md diff --git a/.changeset/selfish-turkeys-exist.md b/.changeset/selfish-turkeys-exist.md new file mode 100644 index 0000000000..f99b92d976 --- /dev/null +++ b/.changeset/selfish-turkeys-exist.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': minor +--- + +EntityPickers now support flags to control ommit of default namespace diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 9541098934..bc7a277ddb 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -66,6 +66,8 @@ export interface EntityPickerUiOptions { allowedKinds?: string[]; // (undocumented) defaultKind?: string; + // (undocumented) + defaultNamespace?: string | false; } // @public @@ -163,6 +165,8 @@ export interface OwnedEntityPickerUiOptions { allowedKinds?: string[]; // (undocumented) defaultKind?: string; + // (undocumented) + defaultNamespace?: string | false; } // @public @@ -177,6 +181,8 @@ export interface OwnerPickerUiOptions { allowArbitraryValues?: boolean; // (undocumented) allowedKinds?: string[]; + // (undocumented) + defaultNamespace?: string | false; } // @public diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx index 2aa72550c3..e7883a4721 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx @@ -35,6 +35,7 @@ export interface EntityPickerUiOptions { allowedKinds?: string[]; defaultKind?: string; allowArbitraryValues?: boolean; + defaultNamespace?: string | false; } /** @@ -57,6 +58,7 @@ export const EntityPicker = ( } = props; const allowedKinds = uiSchema['ui:options']?.allowedKinds; const defaultKind = uiSchema['ui:options']?.defaultKind; + const defaultNamespace = uiSchema['ui:options']?.defaultNamespace; const catalogApi = useApi(catalogApiRef); @@ -67,7 +69,7 @@ export const EntityPicker = ( ); const entityRefs = entities?.items.map(e => - humanizeEntityRef(e, { defaultKind }), + humanizeEntityRef(e, { defaultKind, defaultNamespace }), ); const onSelect = useCallback( diff --git a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx index a280ebad6a..2446883a86 100644 --- a/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/OwnedEntityPicker/OwnedEntityPicker.tsx @@ -38,6 +38,7 @@ export interface OwnedEntityPickerUiOptions { allowedKinds?: string[]; defaultKind?: string; allowArbitraryValues?: boolean; + defaultNamespace?: string | false; } /** @@ -61,12 +62,13 @@ export const OwnedEntityPicker = ( const allowedKinds = uiSchema['ui:options']?.allowedKinds; const defaultKind = uiSchema['ui:options']?.defaultKind; + const defaultNamespace = uiSchema['ui:options']?.defaultNamespace; const allowArbitraryValues = uiSchema['ui:options']?.allowArbitraryValues ?? true; const { ownedEntities, loading } = useOwnedEntities(allowedKinds); const entityRefs = ownedEntities?.items - .map(e => humanizeEntityRef(e, { defaultKind })) + .map(e => humanizeEntityRef(e, { defaultKind, defaultNamespace })) .filter(n => n); const onSelect = (_: any, value: string | null) => { diff --git a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx index 10425c17ff..1cb95c4d3e 100644 --- a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx +++ b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx @@ -26,6 +26,7 @@ import { FieldExtensionComponentProps } from '../../../extensions'; export interface OwnerPickerUiOptions { allowedKinds?: string[]; allowArbitraryValues?: boolean; + defaultNamespace?: string | false; } /** @@ -53,6 +54,7 @@ export const OwnerPicker = ( defaultKind: 'Group', allowArbitraryValues: uiSchema['ui:options']?.allowArbitraryValues ?? true, + defaultNamespace: uiSchema['ui:options']?.defaultNamespace ?? '', }, }; From aa49202de03e9e5591f9bd5d74ea9e6316aa9698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Tunkl?= Date: Tue, 13 Sep 2022 14:17:49 +0200 Subject: [PATCH 027/192] Update of EntityPickers to support flags which control omit of default namespace in result. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomáš Tunkl --- .changeset/selfish-turkeys-exist.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/selfish-turkeys-exist.md b/.changeset/selfish-turkeys-exist.md index f99b92d976..3530b3a864 100644 --- a/.changeset/selfish-turkeys-exist.md +++ b/.changeset/selfish-turkeys-exist.md @@ -2,4 +2,5 @@ '@backstage/plugin-scaffolder': minor --- -EntityPickers now support flags to control ommit of default namespace +EntityPickers now support flags to control when to include default namespace +in result From b6de0abf85cfc7c1c75626466f1242e665f12cd7 Mon Sep 17 00:00:00 2001 From: Taras Date: Thu, 15 Sep 2022 09:15:57 -0400 Subject: [PATCH 028/192] Add failing test Signed-off-by: Taras --- .../modules/core/PlaceholderProcessor.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.test.ts b/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.test.ts index 8fd8114760..e0f7d81968 100644 --- a/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.test.ts +++ b/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.test.ts @@ -387,6 +387,44 @@ describe('PlaceholderProcessor', () => { key: 'url:http://example.com/path-to-file.json', }); }); + + it('accepts arbitrary object as value', async () => { + read.mockResolvedValue( + Buffer.from(JSON.stringify({ a: ['b', 7] }), 'utf-8'), + ); + + const processor = new PlaceholderProcessor({ + resolvers: { + merge: async ({ value }) => { + console.log({ value }); + if (value instanceof Object) { + return { merged: 'value', ...value }; + } + return value; + }, + }, + reader, + integrations, + }); + + const result = await processor.preProcessEntity( + { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { a: { $merge: { passed: 'value' } } }, + }, + { type: 'fake', target: 'http://example.com' }, + () => {}, + ); + + expect(result).toMatchObject({ + apiVersion: 'a', + kind: 'k', + metadata: { name: 'n' }, + spec: { a: { passed: 'value', merged: 'value' } }, + }); + }); }); describe('yamlPlaceholderResolver', () => { From f2f2df9a731491a55bdf530244f21e706af88866 Mon Sep 17 00:00:00 2001 From: Taras Date: Thu, 15 Sep 2022 09:23:07 -0400 Subject: [PATCH 029/192] Allow value to be non-string Signed-off-by: Taras --- .../modules/core/PlaceholderProcessor.test.ts | 1 - .../src/modules/core/PlaceholderProcessor.ts | 16 ++++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.test.ts b/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.test.ts index e0f7d81968..aa0d6c4766 100644 --- a/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.test.ts +++ b/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.test.ts @@ -396,7 +396,6 @@ describe('PlaceholderProcessor', () => { const processor = new PlaceholderProcessor({ resolvers: { merge: async ({ value }) => { - console.log({ value }); if (value instanceof Object) { return { merged: 'value', ...value }; } diff --git a/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.ts b/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.ts index 39c9dc59ec..cf5679058d 100644 --- a/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.ts +++ b/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.ts @@ -111,15 +111,19 @@ export class PlaceholderProcessor implements CatalogProcessor { const resolverValue = data[keys[0]]; const resolver = this.options.resolvers[resolverKey]; - if (!resolver || typeof resolverValue !== 'string') { - // If there was no such placeholder resolver or if the value was not a - // string, we err on the side of safety and assume that this is - // something that's best left alone. For example, if the input contains - // JSONSchema, there may be "$ref": "#/definitions/node" nodes in the - // document. + if (!resolver) { + // If there was no such placeholder resolver, we err on the side of safety + // and assume that this is something that's best left alone. For example, if + // the input contains JSONSchema, there may be "$ref": "#/definitions/node" + // nodes in the document. return [data, false]; } + // if (typeof resolverValue !== 'string') { + // treat it as an argument to resolver function + // TODO: make this recursive, but it should resolve from bottom up + // } + const read = async (url: string): Promise => { if (this.options.reader.readUrl) { const response = await this.options.reader.readUrl(url); From 63296ebcd4191c58d11fb168c953440c1871bfd5 Mon Sep 17 00:00:00 2001 From: Taras Date: Thu, 15 Sep 2022 09:44:14 -0400 Subject: [PATCH 030/192] Added changeset Signed-off-by: Taras --- .changeset/rude-bulldogs-sleep.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/rude-bulldogs-sleep.md diff --git a/.changeset/rude-bulldogs-sleep.md b/.changeset/rude-bulldogs-sleep.md new file mode 100644 index 0000000000..8a2a0590b3 --- /dev/null +++ b/.changeset/rude-bulldogs-sleep.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Allow Placeholder value to be any value, not only string. From a98f5d4c212283eae991dd287569241e8b9675a5 Mon Sep 17 00:00:00 2001 From: Jake Crews Date: Thu, 15 Sep 2022 08:51:02 -0500 Subject: [PATCH 031/192] Add the ability to pass custom defaults to createGitlabApi constructor Signed-off-by: Jake Crews --- .changeset/wicked-spies-draw.md | 5 +++++ plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 .changeset/wicked-spies-draw.md diff --git a/.changeset/wicked-spies-draw.md b/.changeset/wicked-spies-draw.md new file mode 100644 index 0000000000..3b92e394d0 --- /dev/null +++ b/.changeset/wicked-spies-draw.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cicd-statistics-module-gitlab': patch +--- + +add the ability to add cicd default options to createGitlabApi constructor diff --git a/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts b/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts index 7439d1800c..5e4384fe88 100644 --- a/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts +++ b/plugins/cicd-statistics-module-gitlab/src/api/gitlab.ts @@ -18,6 +18,7 @@ import { CicdStatisticsApi, CicdState, CicdConfiguration, + CicdDefaults, Build, FetchBuildsOptions, Stage, @@ -47,9 +48,14 @@ export type GitlabClient = { */ export class CicdStatisticsApiGitlab implements CicdStatisticsApi { readonly #gitLabAuthApi: OAuthApi; + readonly #cicdDefaults: Partial; - constructor(gitLabAuthApi: OAuthApi) { + constructor( + gitLabAuthApi: OAuthApi, + cicdDefaults: Partial = {}, + ) { this.#gitLabAuthApi = gitLabAuthApi; + this.#cicdDefaults = cicdDefaults; } public async createGitlabApi( @@ -154,6 +160,7 @@ export class CicdStatisticsApiGitlab implements CicdStatisticsApi { 'expired', 'unknown', ] as const, + defaults: this.#cicdDefaults, }; } } From f493a9efcc639107a313db2f74cc5f62cded4a88 Mon Sep 17 00:00:00 2001 From: Jake Crews Date: Thu, 15 Sep 2022 09:47:05 -0500 Subject: [PATCH 032/192] Fix documentation errors Signed-off-by: Jake Crews --- .changeset/wicked-spies-draw.md | 2 +- plugins/cicd-statistics-module-gitlab/README.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/wicked-spies-draw.md b/.changeset/wicked-spies-draw.md index 3b92e394d0..399f70e062 100644 --- a/.changeset/wicked-spies-draw.md +++ b/.changeset/wicked-spies-draw.md @@ -2,4 +2,4 @@ '@backstage/plugin-cicd-statistics-module-gitlab': patch --- -add the ability to add cicd default options to createGitlabApi constructor +add the ability to add CICD default options to createGitlabApi constructor diff --git a/plugins/cicd-statistics-module-gitlab/README.md b/plugins/cicd-statistics-module-gitlab/README.md index 0b11f6950a..392bd0e937 100644 --- a/plugins/cicd-statistics-module-gitlab/README.md +++ b/plugins/cicd-statistics-module-gitlab/README.md @@ -7,6 +7,7 @@ This is an extension module to the `cicd-statistics` plugin, providing a `CicdSt 1. Install the `cicd-statistics` and `cicd-statistics-module-gitlab` plugins in the `app` package. 2. Configure your ApiFactory: + - You can optionally pass in a second argument to `CicdStatisticsApiGitlab` of type [CicdDefaults](https://github.com/backstage/backstage/blob/2881c53cb383bf127c150f837f37fe535d8cf97b/plugins/cicd-statistics/src/apis/types.ts#L179) to alter the default CICD UI configuration ```tsx // packages/app/src/apis.ts From b31ffb0067319258697c6668679979116791e21e Mon Sep 17 00:00:00 2001 From: Jake Crews Date: Thu, 15 Sep 2022 09:58:12 -0500 Subject: [PATCH 033/192] api report generation Signed-off-by: Jake Crews --- plugins/cicd-statistics-module-gitlab/api-report.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/cicd-statistics-module-gitlab/api-report.md b/plugins/cicd-statistics-module-gitlab/api-report.md index 5e07255f54..a0364afa31 100644 --- a/plugins/cicd-statistics-module-gitlab/api-report.md +++ b/plugins/cicd-statistics-module-gitlab/api-report.md @@ -4,6 +4,7 @@ ```ts import { CicdConfiguration } from '@backstage/plugin-cicd-statistics'; +import { CicdDefaults } from '@backstage/plugin-cicd-statistics'; import { CicdState } from '@backstage/plugin-cicd-statistics'; import { CicdStatisticsApi } from '@backstage/plugin-cicd-statistics'; import { Entity } from '@backstage/catalog-model'; @@ -13,7 +14,7 @@ import { OAuthApi } from '@backstage/core-plugin-api'; // @public export class CicdStatisticsApiGitlab implements CicdStatisticsApi { - constructor(gitLabAuthApi: OAuthApi); + constructor(gitLabAuthApi: OAuthApi, cicdDefaults?: Partial); // (undocumented) createGitlabApi(entity: Entity, scopes: string[]): Promise; // (undocumented) From 95376dd679e66df3c2b464e8b62cb018a8d859a5 Mon Sep 17 00:00:00 2001 From: Taras Date: Thu, 15 Sep 2022 14:50:43 -0400 Subject: [PATCH 034/192] Remove unnecessary mock Signed-off-by: Taras --- .../src/modules/core/PlaceholderProcessor.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.test.ts b/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.test.ts index aa0d6c4766..105ab03d90 100644 --- a/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.test.ts +++ b/plugins/catalog-backend/src/modules/core/PlaceholderProcessor.test.ts @@ -389,10 +389,6 @@ describe('PlaceholderProcessor', () => { }); it('accepts arbitrary object as value', async () => { - read.mockResolvedValue( - Buffer.from(JSON.stringify({ a: ['b', 7] }), 'utf-8'), - ); - const processor = new PlaceholderProcessor({ resolvers: { merge: async ({ value }) => { From 06bd9581778ad435e9f39c20ed54af9d9be4a8ec Mon Sep 17 00:00:00 2001 From: Taras Date: Thu, 15 Sep 2022 15:08:56 -0400 Subject: [PATCH 035/192] Add some minimal documentation on how to use the PlaceholderProcessor Signed-off-by: Taras --- .../software-templates/writing-templates.md | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index a1cd50f271..2ce7c27bc1 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -581,3 +581,96 @@ output things. You can grab that output using `steps.$stepId.output.$property`. You can read more about all the `inputs` and `outputs` defined in the actions in code part of the `JSONSchema`, or you can read more about our [built in actions](./builtin-actions.md). + +## Creating reusable templates + +We can use the PlaceholderProcessor to create reusable portions of a template. A placeholder is a property on an entity object that starts with `$`. Backstage has some built in placeholders including `$text`, `$json` and `$yaml`. Each placeholder has a resolver. A resolver is an asyncronous function that receives the value assigned to the placeholder. A resolver is expected to return a promise that resolves to a value. The resolved value will replace the object where the placeholder was defined. + +Let's say we want to reuse a portion of the template that asks user to specify a host name and we want to be able to add other fields to that form step. + +```yaml +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: v1beta3-demo + title: Microservice example + description: scaffolder v1beta3 template demo +spec: + owner: backstage/techdocs-core + type: service + parameters: + - title: Specify a host + parameters: + name: + title: Hostname + type: string + description: Specify host name +``` + +Our placeholder is going to be called `$specifyHostname`. We'd use it like this, + +```yaml +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: v1beta3-demo + title: Microservice example + description: scaffolder v1beta3 template demo +spec: + owner: backstage/techdocs-core + type: service + parameters: + - $specifyHostname: + domainExt: + name: Domain extension + type: string + description: Specify domain extension like .com, .ca, .co.uk or something else. +``` + +The result after the placeholder is applied will look like this, + +```yaml +apiVersion: scaffolder.backstage.io/v1beta3 +kind: Template +metadata: + name: v1beta3-demo + title: Microservice example + description: scaffolder v1beta3 template demo +spec: + owner: backstage/techdocs-core + type: service + parameters: + - title: Specify a host + parameters: + name: + title: Hostname + type: string + description: Specify host name + domainExt: + name: Domain extension + type: string + description: Specify domain extension like .com, .ca, .co.uk or something else. +``` + +To implement this, you have to modify the catalog plugin to create a `specifyHostname` resolver. In `/packages/backend/src/plugins/catalog.ts`. Add the following code, + +```ts +const builder = await CatalogBuilder.create(env); + +builder.setPlaceholderResolver( + 'specifyHostname', + async specifyHostnameResolver({ value }) => { + return { + "title": "Specify a host", + "parameters": { + "name": { + "title": "Hostname", + "type": "string", + "description": "Specify host name" + }, + ...value + } + } + }, + );` +``` From b87a88776d168dc039957ca32adda8ecd9f54b80 Mon Sep 17 00:00:00 2001 From: Matthew Clarke Date: Thu, 15 Sep 2022 21:59:57 -0400 Subject: [PATCH 036/192] (feat): kubernetes add new backend endpoints to api interface Signed-off-by: Matthew Clarke --- .../src/service/KubernetesFanOutHandler.ts | 2 +- plugins/kubernetes-backend/src/types/types.ts | 7 +---- plugins/kubernetes-common/src/types.ts | 7 +++++ .../src/api/KubernetesBackendClient.ts | 30 ++++++++++++++++--- plugins/kubernetes/src/api/types.ts | 12 ++++++++ 5 files changed, 47 insertions(+), 11 deletions(-) diff --git a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts index 7fcc9836a3..23163d1004 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesFanOutHandler.ts @@ -25,7 +25,6 @@ import { FetchResponseWrapper, ObjectToFetch, CustomResource, - CustomResourceMatcher, CustomResourcesByEntity, KubernetesObjectsByEntity, } from '../types/types'; @@ -40,6 +39,7 @@ import { ObjectsByEntityResponse, PodFetchResponse, KubernetesRequestAuth, + CustomResourceMatcher, } from '@backstage/plugin-kubernetes-common'; import { ContainerStatus, diff --git a/plugins/kubernetes-backend/src/types/types.ts b/plugins/kubernetes-backend/src/types/types.ts index 33fde70b24..5ddfe7367d 100644 --- a/plugins/kubernetes-backend/src/types/types.ts +++ b/plugins/kubernetes-backend/src/types/types.ts @@ -18,6 +18,7 @@ import { Entity } from '@backstage/catalog-model'; import { Logger } from 'winston'; import type { JsonObject } from '@backstage/types'; import type { + CustomResourceMatcher, FetchResponse, KubernetesFetchError, KubernetesRequestAuth, @@ -86,12 +87,6 @@ export interface CustomResource extends ObjectToFetch { objectType: 'customresources'; } -/** - * - * @alpha - */ -export type CustomResourceMatcher = Omit; - /** * * @alpha diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index 830c32c32c..07378ae688 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -39,6 +39,13 @@ export interface KubernetesRequestAuth { }; } +/** @public */ +export interface CustomResourceMatcher { + group: string; + apiVersion: string; + plural: string; +} + /** @public */ export interface KubernetesRequestBody { auth?: KubernetesRequestAuth; diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.ts index 209f5f950d..04f4624b24 100644 --- a/plugins/kubernetes/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.ts @@ -16,10 +16,13 @@ import { KubernetesApi } from './types'; import { + KubernetesRequestAuth, KubernetesRequestBody, ObjectsByEntityResponse, + CustomResourceMatcher, } from '@backstage/plugin-kubernetes-common'; import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; export class KubernetesBackendClient implements KubernetesApi { private readonly discoveryApi: DiscoveryApi; @@ -51,10 +54,7 @@ export class KubernetesBackendClient implements KubernetesApi { return await response.json(); } - private async postRequired( - path: string, - requestBody: KubernetesRequestBody, - ): Promise { + private async postRequired(path: string, requestBody: any): Promise { const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}${path}`; const { token: idToken } = await this.identityApi.getCredentials(); const response = await fetch(url, { @@ -78,6 +78,28 @@ export class KubernetesBackendClient implements KubernetesApi { ); } + async getWorkloadsByEntity( + auth: KubernetesRequestAuth, + entity: Entity, + ): Promise { + return await this.postRequired('/resources/workloads/query', { + auth, + entityRef: stringifyEntityRef(entity), + }); + } + + async getCustomObjectsByEntity( + auth: KubernetesRequestAuth, + customResources: CustomResourceMatcher[], + entity: Entity, + ): Promise { + return await this.postRequired(`/resources/custom/query`, { + entityRef: stringifyEntityRef(entity), + auth, + customResources, + }); + } + async getClusters(): Promise<{ name: string; authProvider: string }[]> { const { token: idToken } = await this.identityApi.getCredentials(); const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/clusters`; diff --git a/plugins/kubernetes/src/api/types.ts b/plugins/kubernetes/src/api/types.ts index 7c5943a6eb..b7e3f9fd49 100644 --- a/plugins/kubernetes/src/api/types.ts +++ b/plugins/kubernetes/src/api/types.ts @@ -15,10 +15,13 @@ */ import { + KubernetesRequestAuth, KubernetesRequestBody, ObjectsByEntityResponse, + CustomResourceMatcher, } from '@backstage/plugin-kubernetes-common'; import { createApiRef } from '@backstage/core-plugin-api'; +import { Entity } from '@backstage/catalog-model'; export const kubernetesApiRef = createApiRef({ id: 'plugin.kubernetes.service', @@ -35,4 +38,13 @@ export interface KubernetesApi { oidcTokenProvider?: string | undefined; }[] >; + getWorkloadsByEntity( + auth: KubernetesRequestAuth, + entity: Entity, + ): Promise; + getCustomObjectsByEntity( + auth: KubernetesRequestAuth, + customResources: CustomResourceMatcher[], + entity: Entity, + ): Promise; } From 29dd35cdcb9d504f7c744804e7be5e777e7f1424 Mon Sep 17 00:00:00 2001 From: Matthew Clarke Date: Thu, 15 Sep 2022 22:03:09 -0400 Subject: [PATCH 037/192] update api report Signed-off-by: Matthew Clarke --- plugins/kubernetes-backend/api-report.md | 4 +--- plugins/kubernetes-common/api-report.md | 10 ++++++++++ plugins/kubernetes/api-report.md | 24 ++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md index f97a0fd032..4219387819 100644 --- a/plugins/kubernetes-backend/api-report.md +++ b/plugins/kubernetes-backend/api-report.md @@ -8,6 +8,7 @@ import { Config } from '@backstage/config'; import { CoreV1Api } from '@kubernetes/client-node'; import { Credentials } from 'aws-sdk'; import { CustomObjectsApi } from '@kubernetes/client-node'; +import type { CustomResourceMatcher } from '@backstage/plugin-kubernetes-common'; import { Duration } from 'luxon'; import { Entity } from '@backstage/catalog-model'; import express from 'express'; @@ -101,9 +102,6 @@ export interface CustomResource extends ObjectToFetch { objectType: 'customresources'; } -// @alpha (undocumented) -export type CustomResourceMatcher = Omit; - // @alpha (undocumented) export interface CustomResourcesByEntity extends KubernetesObjectsByEntity { // (undocumented) diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index ef36d5ce28..9485bd0294 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -97,6 +97,16 @@ export interface CustomResourceFetchResponse { type: 'customresources'; } +// @public (undocumented) +export interface CustomResourceMatcher { + // (undocumented) + apiVersion: string; + // (undocumented) + group: string; + // (undocumented) + plural: string; +} + // @public (undocumented) export interface DaemonSetsFetchResponse { // (undocumented) diff --git a/plugins/kubernetes/api-report.md b/plugins/kubernetes/api-report.md index 81ce386200..98ca4e4725 100644 --- a/plugins/kubernetes/api-report.md +++ b/plugins/kubernetes/api-report.md @@ -10,10 +10,12 @@ import { BackstagePlugin } from '@backstage/core-plugin-api'; import { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; import { ClusterAttributes } from '@backstage/plugin-kubernetes-common'; import { ClusterObjects } from '@backstage/plugin-kubernetes-common'; +import { CustomResourceMatcher } from '@backstage/plugin-kubernetes-common'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { IdentityApi } from '@backstage/core-plugin-api'; import type { JsonObject } from '@backstage/types'; +import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common'; import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { OAuthApi } from '@backstage/core-plugin-api'; import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common'; @@ -245,9 +247,20 @@ export interface KubernetesApi { }[] >; // (undocumented) + getCustomObjectsByEntity( + auth: KubernetesRequestAuth, + customResources: CustomResourceMatcher[], + entity: Entity, + ): Promise; + // (undocumented) getObjectsByEntity( requestBody: KubernetesRequestBody, ): Promise; + // (undocumented) + getWorkloadsByEntity( + auth: KubernetesRequestAuth, + entity: Entity, + ): Promise; } // Warning: (ae-missing-release-tag) "kubernetesApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -304,9 +317,20 @@ export class KubernetesBackendClient implements KubernetesApi { }[] >; // (undocumented) + getCustomObjectsByEntity( + auth: KubernetesRequestAuth, + customResources: CustomResourceMatcher[], + entity: Entity, + ): Promise; + // (undocumented) getObjectsByEntity( requestBody: KubernetesRequestBody, ): Promise; + // (undocumented) + getWorkloadsByEntity( + auth: KubernetesRequestAuth, + entity: Entity, + ): Promise; } // Warning: (ae-forgotten-export) The symbol "KubernetesContentProps" needs to be exported by the entry point index.d.ts From 0768d6dece0706b37218a2e08058bf7dd0a8f693 Mon Sep 17 00:00:00 2001 From: Matthew Clarke Date: Thu, 15 Sep 2022 22:04:31 -0400 Subject: [PATCH 038/192] changeset Signed-off-by: Matthew Clarke --- .changeset/poor-lobsters-wonder.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/poor-lobsters-wonder.md diff --git a/.changeset/poor-lobsters-wonder.md b/.changeset/poor-lobsters-wonder.md new file mode 100644 index 0000000000..c0adea235f --- /dev/null +++ b/.changeset/poor-lobsters-wonder.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-kubernetes': patch +'@backstage/plugin-kubernetes-backend': patch +'@backstage/plugin-kubernetes-common': patch +--- + +add new kubernetes backend endpoints to kubernetes backend client From 3da0c9a1812a92e6d0948a88e8c2306097561574 Mon Sep 17 00:00:00 2001 From: Matthew Clarke Date: Thu, 15 Sep 2022 22:10:42 -0400 Subject: [PATCH 039/192] update mock Signed-off-by: Matthew Clarke --- plugins/kubernetes/dev/index.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/plugins/kubernetes/dev/index.tsx b/plugins/kubernetes/dev/index.tsx index 068f26b7f5..6a02540604 100644 --- a/plugins/kubernetes/dev/index.tsx +++ b/plugins/kubernetes/dev/index.tsx @@ -24,7 +24,9 @@ import { KubernetesApi, } from '../src'; import { + CustomResourceMatcher, FetchResponse, + KubernetesRequestAuth, ObjectsByEntityResponse, } from '@backstage/plugin-kubernetes-common'; import fixture1 from '../src/__fixtures__/1-deployments.json'; @@ -59,6 +61,19 @@ class MockKubernetesClient implements KubernetesApi { ({ type: type.toLocaleLowerCase('en-US'), resources } as FetchResponse), ); } + async getWorkloadsByEntity( + _auth: KubernetesRequestAuth, + _entity: Entity, + ): Promise { + throw new Error('Method not implemented.'); + } + async getCustomObjectsByEntity( + _auth: KubernetesRequestAuth, + _customResources: CustomResourceMatcher[], + entity: Entity, + ): Promise { + throw new Error('Method not implemented.'); + } async getObjectsByEntity(): Promise { return { From 5eb54be2ac56af8765e18def149cefe09f0f920e Mon Sep 17 00:00:00 2001 From: Matthew Clarke Date: Thu, 15 Sep 2022 22:15:26 -0400 Subject: [PATCH 040/192] typo Signed-off-by: Matthew Clarke --- plugins/kubernetes/dev/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/kubernetes/dev/index.tsx b/plugins/kubernetes/dev/index.tsx index 6a02540604..3dd0557ae1 100644 --- a/plugins/kubernetes/dev/index.tsx +++ b/plugins/kubernetes/dev/index.tsx @@ -70,7 +70,7 @@ class MockKubernetesClient implements KubernetesApi { async getCustomObjectsByEntity( _auth: KubernetesRequestAuth, _customResources: CustomResourceMatcher[], - entity: Entity, + _entity: Entity, ): Promise { throw new Error('Method not implemented.'); } From 04c50e297d8a0e4fb0ad78fe9f5912ce1794f543 Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Fri, 16 Sep 2022 09:57:44 +0200 Subject: [PATCH 041/192] Resolve cookie configuration per request Signed-off-by: Marcus Eide --- .changeset/kind-penguins-report.md | 4 +- plugins/auth-backend/api-report.md | 1 - .../src/lib/oauth/OAuthAdapter.test.ts | 164 ++++++++++++++++-- .../src/lib/oauth/OAuthAdapter.ts | 74 ++++---- 4 files changed, 200 insertions(+), 43 deletions(-) diff --git a/.changeset/kind-penguins-report.md b/.changeset/kind-penguins-report.md index 9f2401491e..4023ea5976 100644 --- a/.changeset/kind-penguins-report.md +++ b/.changeset/kind-penguins-report.md @@ -5,4 +5,6 @@ CookieConfigurer can optionally return the `SameSite` cookie attribute. CookieConfigurer now requires an additional argument `appOrigin` - the origin URL of the app - which is used to calculate the `SameSite` attribute. defaultCookieConfigurer returns the `SameSite` attribute which defaults to `Lax`. In cases where an auth-backend is running on a different domain than the App, `SameSite=None` is used - but only for secure contexts. This is so that cookies can be included in third-party requests. -OAuthAdapterOptions has been modified to require additional arguments, `baseUrl`, `cookieConfigurer` and `cookieConfig`. + +OAuthAdapterOptions has been modified to require additional arguments, `baseUrl`, and `cookieConfigurer`. +OAuthAdapter now resolves cookie configuration using its supplied CookieConfigurer for each request to make sure that the proper attributes always are set. diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 8cb7e35e9b..f4e8415266 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -285,7 +285,6 @@ export type OAuthAdapterOptions = { persistScopes?: boolean; appOrigin: string; baseUrl: string; - cookieConfig: ReturnType; cookieConfigurer: CookieConfigurer; isOriginAllowed: (origin: string) => boolean; callbackUrl: string; diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts index d59a712205..6521519808 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts @@ -63,7 +63,7 @@ describe('OAuthAdapter', () => { } const providerInstance = new MyAuthProvider(); const mockCookieConfig: ReturnType = { - domain: 'example.com', + domain: 'domain.org', path: '/auth/test-provider', secure: false, }; @@ -72,15 +72,14 @@ describe('OAuthAdapter', () => { const oAuthProviderOptions = { providerId: 'test-provider', appOrigin: 'http://localhost:3000', - cookieConfig: mockCookieConfig, + baseUrl: 'http://domain.org/auth', cookieConfigurer: mockCookieConfigurer, - baseUrl: 'http://example.com:7007', tokenIssuer: { issueToken: async () => 'my-id-token', listPublicKeys: async () => ({ keys: [] }), }, isOriginAllowed: () => false, - callbackUrl: 'http://example.com:7007/auth/test-provider/frame/handler', + callbackUrl: 'http://domain.org/auth/test-provider/handler/frame', }; it('sets the correct headers in start', async () => { @@ -108,7 +107,14 @@ describe('OAuthAdapter', () => { expect(mockResponse.cookie).toHaveBeenCalledWith( `${oAuthProviderOptions.providerId}-nonce`, expect.any(String), - expect.objectContaining({ maxAge: TEN_MINUTES_MS }), + expect.objectContaining({ + httpOnly: true, + path: '/auth/test-provider/handler', + maxAge: TEN_MINUTES_MS, + domain: 'domain.org', + sameSite: 'lax', + secure: false, + }), ); // redirect checks expect(mockResponse.setHeader).toHaveBeenCalledTimes(2); @@ -150,6 +156,7 @@ describe('OAuthAdapter', () => { httpOnly: true, path: '/auth/test-provider', maxAge: THOUSAND_DAYS_MS, + domain: 'domain.org', secure: false, sameSite: 'lax', }), @@ -218,14 +225,17 @@ describe('OAuthAdapter', () => { } as unknown as express.Response; await oauthProvider.frameHandler(mockHandleReq, mockHandleRes); - expect(mockCookieConfigurer).toHaveBeenCalledTimes(1); expect(mockHandleRes.cookie).toHaveBeenCalledTimes(1); expect(mockHandleRes.cookie).toHaveBeenCalledWith( 'test-provider-granted-scope', 'user', expect.objectContaining({ + httpOnly: true, path: '/auth/test-provider', maxAge: THOUSAND_DAYS_MS, + domain: 'domain.org', + secure: false, + sameSite: 'lax', }), ); @@ -260,6 +270,7 @@ describe('OAuthAdapter', () => { const mockRequest = { header: () => 'XMLHttpRequest', + get: jest.fn(), } as unknown as express.Request; const mockResponse = { @@ -269,6 +280,7 @@ describe('OAuthAdapter', () => { } as unknown as express.Response; await oauthProvider.logout(mockRequest, mockResponse); + expect(mockRequest.get).toHaveBeenCalledTimes(1); expect(mockResponse.cookie).toHaveBeenCalledTimes(1); expect(mockResponse.cookie).toHaveBeenCalledWith( expect.stringContaining('test-provider-refresh-token'), @@ -312,7 +324,46 @@ describe('OAuthAdapter', () => { }); }); - it('sets the correct cookie configuration using an unsecure callbackUrl', async () => { + it('sets new access-token when old cookie exists', async () => { + const oauthProvider = new OAuthAdapter(providerInstance, { + ...oAuthProviderOptions, + isOriginAllowed: () => false, + }); + + const mockRequest = { + header: () => 'XMLHttpRequest', + cookies: { + 'test-provider-refresh-token': 'old-token', + }, + query: {}, + get: jest.fn(), + } as unknown as express.Request; + + const mockResponse = { + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + cookie: jest.fn().mockReturnThis(), + } as unknown as express.Response; + + await oauthProvider.refresh(mockRequest, mockResponse); + expect(mockRequest.get).toHaveBeenCalledTimes(1); + expect(mockCookieConfigurer).toHaveBeenCalledTimes(1); + expect(mockResponse.cookie).toHaveBeenCalledTimes(1); + expect(mockResponse.cookie).toHaveBeenCalledWith( + 'test-provider-refresh-token', + 'token', + expect.objectContaining({ + httpOnly: true, + path: '/auth/test-provider', + maxAge: THOUSAND_DAYS_MS, + domain: 'domain.org', + secure: false, + sameSite: 'lax', + }), + ); + }); + + it('sets the correct nonce cookie configuration', async () => { const config = { baseUrl: 'http://domain.org/auth', appUrl: 'http://domain.org', @@ -321,7 +372,6 @@ describe('OAuthAdapter', () => { const oauthProvider = OAuthAdapter.fromConfig(config, providerInstance, { ...oAuthProviderOptions, - callbackUrl: 'http://authdomain.org/auth/test-provider/handler/frame', }); const mockRequest = { @@ -346,7 +396,8 @@ describe('OAuthAdapter', () => { expect.any(String), expect.objectContaining({ httpOnly: true, - domain: 'authdomain.org', + domain: 'domain.org', + maxAge: TEN_MINUTES_MS, path: '/auth/test-provider/handler', secure: false, sameSite: 'lax', @@ -361,6 +412,54 @@ describe('OAuthAdapter', () => { isOriginAllowed: () => false, }; + const oauthProvider = OAuthAdapter.fromConfig(config, providerInstance, { + ...oAuthProviderOptions, + callbackUrl: 'https://domain.org/auth/test-provider/handler/frame', + }); + + const state = { + nonce: 'nonce', + env: 'development', + }; + + const mockRequest = { + cookies: { + 'test-provider-nonce': 'nonce', + }, + query: { + state: encodeState(state), + }, + } as unknown as express.Request; + + const mockResponse = { + cookie: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), + } as unknown as express.Response; + + await oauthProvider.frameHandler(mockRequest, mockResponse); + expect(mockCookieConfigurer).not.toHaveBeenCalled(); + expect(mockResponse.cookie).toHaveBeenCalledTimes(1); + expect(mockResponse.cookie).toHaveBeenCalledWith( + expect.stringContaining('test-provider-refresh-token'), + expect.stringContaining('token'), + expect.objectContaining({ + httpOnly: true, + domain: 'domain.org', + path: '/auth/test-provider', + secure: true, + sameSite: 'lax', + }), + ); + }); + + it('sets the correct cookie configuration when on different domains and secure', async () => { + const config = { + baseUrl: 'https://domain.org/auth', + appUrl: 'http://domain.org', + isOriginAllowed: () => false, + }; + const oauthProvider = OAuthAdapter.fromConfig(config, providerInstance, { ...oAuthProviderOptions, callbackUrl: 'https://authdomain.org/auth/test-provider/handler/frame', @@ -402,7 +501,7 @@ describe('OAuthAdapter', () => { ); }); - it('sets the correct cookie configuration using state', async () => { + it('sets the correct cookie configuration using origin from state', async () => { const config = { baseUrl: 'https://domain.org/auth', appUrl: 'http://domain.org', @@ -450,4 +549,49 @@ describe('OAuthAdapter', () => { }), ); }); + + it('sets the correct cookie configuration using origin from header', async () => { + const config = { + baseUrl: 'https://domain.org/auth', + appUrl: 'http://domain.org', + isOriginAllowed: () => false, + }; + + const oauthProvider = OAuthAdapter.fromConfig(config, providerInstance, { + ...oAuthProviderOptions, + callbackUrl: 'https://domain.org/auth/test-provider/handler/frame', + }); + + const mockRequest = { + header: () => 'XMLHttpRequest', + cookies: { + 'test-provider-refresh-token': 'old-token', + }, + query: {}, + get: jest.fn().mockReturnValue('http://other.domain'), + } as unknown as express.Request; + + const mockResponse = { + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + cookie: jest.fn().mockReturnThis(), + } as unknown as express.Response; + + await oauthProvider.refresh(mockRequest, mockResponse); + expect(mockRequest.get).toHaveBeenCalledTimes(1); + expect(mockCookieConfigurer).not.toHaveBeenCalled(); + expect(mockResponse.cookie).toHaveBeenCalledTimes(1); + expect(mockResponse.cookie).toHaveBeenCalledWith( + 'test-provider-refresh-token', + 'token', + expect.objectContaining({ + httpOnly: true, + path: '/auth/test-provider', + maxAge: THOUSAND_DAYS_MS, + domain: 'domain.org', + secure: true, + sameSite: 'none', + }), + ); + }); }); diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index 64dc4f70e3..43fcc5e20b 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -51,7 +51,6 @@ export type OAuthAdapterOptions = { persistScopes?: boolean; appOrigin: string; baseUrl: string; - cookieConfig: ReturnType; cookieConfigurer: CookieConfigurer; isOriginAllowed: (origin: string) => boolean; callbackUrl: string; @@ -71,24 +70,17 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { const { origin: appOrigin } = new URL(appUrl); const cookieConfigurer = config.cookieConfigurer ?? defaultCookieConfigurer; - const cookieConfig = cookieConfigurer({ - providerId: options.providerId, - baseUrl: config.baseUrl, - callbackUrl: options.callbackUrl, - appOrigin, - }); return new OAuthAdapter(handlers, { ...options, appOrigin, baseUrl, - cookieConfig, cookieConfigurer, isOriginAllowed, }); } - private baseCookieOptions: CookieOptions; + private readonly baseCookieOptions: CookieOptions; constructor( private readonly handlers: OAuthHandlers, @@ -97,7 +89,6 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { this.baseCookieOptions = { httpOnly: true, sameSite: 'lax', - ...this.options.cookieConfig, }; } @@ -111,9 +102,11 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { throw new InputError('No env provided in request query parameters'); } + const cookieConfig = this.getCookieConfig(); + const nonce = crypto.randomBytes(16).toString('base64'); // set a nonce cookie before redirecting to oauth provider - this.setNonceCookie(res, nonce); + this.setNonceCookie(res, nonce, cookieConfig); const state: OAuthState = { nonce, env, origin }; @@ -154,33 +147,23 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { } } - // Update cookie options to reflect any changes for the appOrigin - const updatedCookieOptions = this.options.cookieConfigurer({ - providerId: this.options.providerId, - baseUrl: this.options.baseUrl, - callbackUrl: this.options.callbackUrl, - appOrigin, - }); - this.baseCookieOptions = { - ...this.baseCookieOptions, - ...updatedCookieOptions, - }; - // verify nonce cookie and state cookie on callback verifyNonce(req, this.options.providerId); const { response, refreshToken } = await this.handlers.handler(req); + const cookieConfig = this.getCookieConfig(appOrigin); + // Store the scope that we have been granted for this session. This is useful if // the provider does not return granted scopes on refresh or if they are normalized. if (this.options.persistScopes && state.scope) { - this.setGrantedScopeCookie(res, state.scope); + this.setGrantedScopeCookie(res, state.scope, cookieConfig); response.providerInfo.scope = state.scope; } if (refreshToken) { // set new refresh token - this.setRefreshTokenCookie(res, refreshToken); + this.setRefreshTokenCookie(res, refreshToken, cookieConfig); } const identity = await this.populateIdentity(response.backstageIdentity); @@ -208,7 +191,9 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { } // remove refresh token cookie if it is set - this.removeRefreshTokenCookie(res); + const origin = req.get('origin'); + const cookieConfig = this.getCookieConfig(origin); + this.removeRefreshTokenCookie(res, cookieConfig); res.status(200).end(); } @@ -248,7 +233,9 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { ); if (newRefreshToken && newRefreshToken !== refreshToken) { - this.setRefreshTokenCookie(res, newRefreshToken); + const origin = req.get('origin'); + const cookieConfig = this.getCookieConfig(origin); + this.setRefreshTokenCookie(res, newRefreshToken, cookieConfig); } res.status(200).json({ ...response, backstageIdentity }); @@ -274,18 +261,28 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { return prepareBackstageIdentityResponse(identity); } - private setNonceCookie = (res: express.Response, nonce: string) => { + private setNonceCookie = ( + res: express.Response, + nonce: string, + cookieConfig: ReturnType, + ) => { res.cookie(`${this.options.providerId}-nonce`, nonce, { maxAge: TEN_MINUTES_MS, ...this.baseCookieOptions, - path: `${this.options.cookieConfig.path}/handler`, + ...cookieConfig, + path: `${cookieConfig.path}/handler`, }); }; - private setGrantedScopeCookie = (res: express.Response, scope: string) => { + private setGrantedScopeCookie = ( + res: express.Response, + scope: string, + cookieConfig: ReturnType, + ) => { res.cookie(`${this.options.providerId}-granted-scope`, scope, { maxAge: THOUSAND_DAYS_MS, ...this.baseCookieOptions, + ...cookieConfig, }); }; @@ -296,17 +293,32 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { private setRefreshTokenCookie = ( res: express.Response, refreshToken: string, + cookieConfig: ReturnType, ) => { res.cookie(`${this.options.providerId}-refresh-token`, refreshToken, { maxAge: THOUSAND_DAYS_MS, ...this.baseCookieOptions, + ...cookieConfig, }); }; - private removeRefreshTokenCookie = (res: express.Response) => { + private removeRefreshTokenCookie = ( + res: express.Response, + cookieConfig: ReturnType, + ) => { res.cookie(`${this.options.providerId}-refresh-token`, '', { maxAge: 0, ...this.baseCookieOptions, + ...cookieConfig, + }); + }; + + private getCookieConfig = (origin?: string) => { + return this.options.cookieConfigurer({ + providerId: this.options.providerId, + baseUrl: this.options.baseUrl, + callbackUrl: this.options.callbackUrl, + appOrigin: origin ?? this.options.appOrigin, }); }; } From d3737da3377da376b0b82c9d884cc656f5455292 Mon Sep 17 00:00:00 2001 From: Phil Kuang Date: Sat, 2 Jul 2022 00:00:52 -0400 Subject: [PATCH 042/192] feat(playlists): implement playlist plugin Signed-off-by: Phil Kuang --- .changeset/calm-snakes-tap.md | 5 + .changeset/nice-ducks-smile.md | 7 + .changeset/purple-chicken-kneel.md | 5 + app-config.yaml | 3 + microsite/data/plugins/playlist.yaml | 9 + microsite/static/img/playlist-logo.png | Bin 0 -> 14734 bytes packages/app/package.json | 1 + packages/app/src/App.tsx | 2 + packages/app/src/components/Root/Root.tsx | 2 + .../app/src/components/catalog/EntityPage.tsx | 12 + packages/backend/package.json | 1 + packages/backend/src/index.ts | 3 + packages/backend/src/plugins/permission.ts | 25 +- packages/backend/src/plugins/playlist.ts | 37 ++ packages/backend/src/types.ts | 7 +- packages/core-components/api-report.md | 27 + .../src/components/Table/Table.tsx | 5 + .../HeaderActionMenu/HeaderActionMenu.tsx | 15 +- .../src/layout/HeaderActionMenu/index.ts | 4 + packages/core-components/src/layout/index.ts | 1 + plugins/playlist-backend/.eslintrc.js | 1 + plugins/playlist-backend/README.md | 93 ++++ plugins/playlist-backend/api-report.md | 93 ++++ .../migrations/20220701011329_init.js | 60 ++ plugins/playlist-backend/package.json | 52 ++ plugins/playlist-backend/src/index.ts | 28 + .../DefaultPlaylistPermissionPolicy.test.ts | 135 +++++ .../DefaultPlaylistPermissionPolicy.ts | 90 +++ .../src/permissions/conditions.ts | 44 ++ .../playlist-backend/src/permissions/index.ts | 19 + .../playlist-backend/src/permissions/rules.ts | 54 ++ plugins/playlist-backend/src/run.ts | 33 ++ .../src/service/DatabaseHandler.test.ts | 349 ++++++++++++ .../src/service/DatabaseHandler.ts | 278 ++++++++++ .../src/service/ListPlaylistsFilter.test.ts | 95 ++++ .../src/service/ListPlaylistsFilter.ts | 101 ++++ plugins/playlist-backend/src/service/index.ts | 21 + .../src/service/router.test.ts | 511 ++++++++++++++++++ .../playlist-backend/src/service/router.ts | 232 ++++++++ .../src/service/standaloneServer.ts | 90 +++ plugins/playlist-backend/src/setupTests.ts | 17 + plugins/playlist-common/.eslintrc.js | 1 + plugins/playlist-common/README.md | 3 + plugins/playlist-common/api-report.md | 36 ++ plugins/playlist-common/package.json | 34 ++ plugins/playlist-common/src/index.ts | 24 + plugins/playlist-common/src/permissions.ts | 62 +++ plugins/playlist-common/src/setupTests.ts | 16 + plugins/playlist-common/src/types.ts | 35 ++ plugins/playlist/.eslintrc.js | 1 + plugins/playlist/README.md | 128 +++++ plugins/playlist/api-report.md | 106 ++++ .../playlist/dev/index.tsx | 20 +- plugins/playlist/package.json | 67 +++ plugins/playlist/src/api/PlaylistApi.ts | 75 +++ .../playlist/src/api/PlaylistClient.test.ts | 272 ++++++++++ plugins/playlist/src/api/PlaylistClient.ts | 198 +++++++ plugins/playlist/src/api/index.ts | 18 + .../CreatePlaylistButton.test.tsx | 142 +++++ .../CreatePlaylistButton.tsx | 87 +++ .../components/CreatePlaylistButton/index.ts | 17 + .../EntityPlaylistDialog.test.tsx | 200 +++++++ .../EntityPlaylistDialog.tsx | 256 +++++++++ .../components/EntityPlaylistDialog/index.ts | 17 + .../PersonalListPicker.test.tsx | 286 ++++++++++ .../PersonalListPicker/PersonalListPicker.tsx | 293 ++++++++++ .../components/PersonalListPicker/index.ts | 17 + .../PlaylistCard/PlaylistCard.test.tsx | 57 ++ .../components/PlaylistCard/PlaylistCard.tsx | 131 +++++ .../src/components/PlaylistCard/index.ts | 17 + .../PlaylistEditDialog.test.tsx | 86 +++ .../PlaylistEditDialog/PlaylistEditDialog.tsx | 217 ++++++++ .../components/PlaylistEditDialog/index.ts | 17 + .../PlaylistIndexPage.test.tsx | 52 ++ .../PlaylistIndexPage/PlaylistIndexPage.tsx | 56 ++ .../src/components/PlaylistIndexPage/index.ts | 16 + .../PlaylistList/PlaylistList.test.tsx | 86 +++ .../components/PlaylistList/PlaylistList.tsx | 58 ++ .../src/components/PlaylistList/index.ts | 17 + .../PlaylistOwnerPicker.test.tsx | 215 ++++++++ .../PlaylistOwnerPicker.tsx | 134 +++++ .../components/PlaylistOwnerPicker/index.ts | 17 + .../PlaylistPage/AddEntitiesDrawer.test.tsx | 159 ++++++ .../PlaylistPage/AddEntitiesDrawer.tsx | 239 ++++++++ .../PlaylistEntitiesTable.test.tsx | 211 ++++++++ .../PlaylistPage/PlaylistEntitiesTable.tsx | 218 ++++++++ .../PlaylistPage/PlaylistHeader.test.tsx | 242 +++++++++ .../PlaylistPage/PlaylistHeader.tsx | 197 +++++++ .../PlaylistPage/PlaylistPage.test.tsx | 141 +++++ .../components/PlaylistPage/PlaylistPage.tsx | 133 +++++ .../src/components/PlaylistPage/index.ts | 17 + .../PlaylistSearchBar.test.tsx | 52 ++ .../PlaylistSearchBar/PlaylistSearchBar.tsx | 102 ++++ .../src/components/PlaylistSearchBar/index.ts | 17 + .../PlaylistSortPicker.test.tsx | 68 +++ .../PlaylistSortPicker/PlaylistSortPicker.tsx | 115 ++++ .../components/PlaylistSortPicker/index.ts | 17 + .../src/components/Router/Router.test.tsx | 51 ++ .../playlist/src/components/Router/Router.tsx | 29 + .../playlist/src/components/Router/index.ts | 17 + plugins/playlist/src/components/index.ts | 27 + plugins/playlist/src/hooks/index.ts | 17 + .../src/hooks/usePlaylistList.test.tsx | 225 ++++++++ .../playlist/src/hooks/usePlaylistList.tsx | 284 ++++++++++ plugins/playlist/src/index.ts | 28 + plugins/playlist/src/plugin.test.ts | 22 + plugins/playlist/src/plugin.ts | 74 +++ plugins/playlist/src/routes.ts | 26 + plugins/playlist/src/setupTests.ts | 17 + .../testUtils/MockPlaylistListProvider.tsx | 102 ++++ plugins/playlist/src/testUtils/index.ts | 17 + plugins/playlist/src/types.ts | 27 + .../SearchFilter.Autocomplete.tsx | 3 +- .../SearchFilter/SearchFilter.test.tsx | 16 +- .../components/SearchFilter/SearchFilter.tsx | 6 +- 115 files changed, 9042 insertions(+), 28 deletions(-) create mode 100644 .changeset/calm-snakes-tap.md create mode 100644 .changeset/nice-ducks-smile.md create mode 100644 .changeset/purple-chicken-kneel.md create mode 100644 microsite/data/plugins/playlist.yaml create mode 100644 microsite/static/img/playlist-logo.png create mode 100644 packages/backend/src/plugins/playlist.ts create mode 100644 plugins/playlist-backend/.eslintrc.js create mode 100644 plugins/playlist-backend/README.md create mode 100644 plugins/playlist-backend/api-report.md create mode 100644 plugins/playlist-backend/migrations/20220701011329_init.js create mode 100644 plugins/playlist-backend/package.json create mode 100644 plugins/playlist-backend/src/index.ts create mode 100644 plugins/playlist-backend/src/permissions/DefaultPlaylistPermissionPolicy.test.ts create mode 100644 plugins/playlist-backend/src/permissions/DefaultPlaylistPermissionPolicy.ts create mode 100644 plugins/playlist-backend/src/permissions/conditions.ts create mode 100644 plugins/playlist-backend/src/permissions/index.ts create mode 100644 plugins/playlist-backend/src/permissions/rules.ts create mode 100644 plugins/playlist-backend/src/run.ts create mode 100644 plugins/playlist-backend/src/service/DatabaseHandler.test.ts create mode 100644 plugins/playlist-backend/src/service/DatabaseHandler.ts create mode 100644 plugins/playlist-backend/src/service/ListPlaylistsFilter.test.ts create mode 100644 plugins/playlist-backend/src/service/ListPlaylistsFilter.ts create mode 100644 plugins/playlist-backend/src/service/index.ts create mode 100644 plugins/playlist-backend/src/service/router.test.ts create mode 100644 plugins/playlist-backend/src/service/router.ts create mode 100644 plugins/playlist-backend/src/service/standaloneServer.ts create mode 100644 plugins/playlist-backend/src/setupTests.ts create mode 100644 plugins/playlist-common/.eslintrc.js create mode 100644 plugins/playlist-common/README.md create mode 100644 plugins/playlist-common/api-report.md create mode 100644 plugins/playlist-common/package.json create mode 100644 plugins/playlist-common/src/index.ts create mode 100644 plugins/playlist-common/src/permissions.ts create mode 100644 plugins/playlist-common/src/setupTests.ts create mode 100644 plugins/playlist-common/src/types.ts create mode 100644 plugins/playlist/.eslintrc.js create mode 100644 plugins/playlist/README.md create mode 100644 plugins/playlist/api-report.md rename packages/core-components/src/layout/HeaderActionMenu/VerticalMenuIcon.tsx => plugins/playlist/dev/index.tsx (58%) create mode 100644 plugins/playlist/package.json create mode 100644 plugins/playlist/src/api/PlaylistApi.ts create mode 100644 plugins/playlist/src/api/PlaylistClient.test.ts create mode 100644 plugins/playlist/src/api/PlaylistClient.ts create mode 100644 plugins/playlist/src/api/index.ts create mode 100644 plugins/playlist/src/components/CreatePlaylistButton/CreatePlaylistButton.test.tsx create mode 100644 plugins/playlist/src/components/CreatePlaylistButton/CreatePlaylistButton.tsx create mode 100644 plugins/playlist/src/components/CreatePlaylistButton/index.ts create mode 100644 plugins/playlist/src/components/EntityPlaylistDialog/EntityPlaylistDialog.test.tsx create mode 100644 plugins/playlist/src/components/EntityPlaylistDialog/EntityPlaylistDialog.tsx create mode 100644 plugins/playlist/src/components/EntityPlaylistDialog/index.ts create mode 100644 plugins/playlist/src/components/PersonalListPicker/PersonalListPicker.test.tsx create mode 100644 plugins/playlist/src/components/PersonalListPicker/PersonalListPicker.tsx create mode 100644 plugins/playlist/src/components/PersonalListPicker/index.ts create mode 100644 plugins/playlist/src/components/PlaylistCard/PlaylistCard.test.tsx create mode 100644 plugins/playlist/src/components/PlaylistCard/PlaylistCard.tsx create mode 100644 plugins/playlist/src/components/PlaylistCard/index.ts create mode 100644 plugins/playlist/src/components/PlaylistEditDialog/PlaylistEditDialog.test.tsx create mode 100644 plugins/playlist/src/components/PlaylistEditDialog/PlaylistEditDialog.tsx create mode 100644 plugins/playlist/src/components/PlaylistEditDialog/index.ts create mode 100644 plugins/playlist/src/components/PlaylistIndexPage/PlaylistIndexPage.test.tsx create mode 100644 plugins/playlist/src/components/PlaylistIndexPage/PlaylistIndexPage.tsx create mode 100644 plugins/playlist/src/components/PlaylistIndexPage/index.ts create mode 100644 plugins/playlist/src/components/PlaylistList/PlaylistList.test.tsx create mode 100644 plugins/playlist/src/components/PlaylistList/PlaylistList.tsx create mode 100644 plugins/playlist/src/components/PlaylistList/index.ts create mode 100644 plugins/playlist/src/components/PlaylistOwnerPicker/PlaylistOwnerPicker.test.tsx create mode 100644 plugins/playlist/src/components/PlaylistOwnerPicker/PlaylistOwnerPicker.tsx create mode 100644 plugins/playlist/src/components/PlaylistOwnerPicker/index.ts create mode 100644 plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.test.tsx create mode 100644 plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.tsx create mode 100644 plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.test.tsx create mode 100644 plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx create mode 100644 plugins/playlist/src/components/PlaylistPage/PlaylistHeader.test.tsx create mode 100644 plugins/playlist/src/components/PlaylistPage/PlaylistHeader.tsx create mode 100644 plugins/playlist/src/components/PlaylistPage/PlaylistPage.test.tsx create mode 100644 plugins/playlist/src/components/PlaylistPage/PlaylistPage.tsx create mode 100644 plugins/playlist/src/components/PlaylistPage/index.ts create mode 100644 plugins/playlist/src/components/PlaylistSearchBar/PlaylistSearchBar.test.tsx create mode 100644 plugins/playlist/src/components/PlaylistSearchBar/PlaylistSearchBar.tsx create mode 100644 plugins/playlist/src/components/PlaylistSearchBar/index.ts create mode 100644 plugins/playlist/src/components/PlaylistSortPicker/PlaylistSortPicker.test.tsx create mode 100644 plugins/playlist/src/components/PlaylistSortPicker/PlaylistSortPicker.tsx create mode 100644 plugins/playlist/src/components/PlaylistSortPicker/index.ts create mode 100644 plugins/playlist/src/components/Router/Router.test.tsx create mode 100644 plugins/playlist/src/components/Router/Router.tsx create mode 100644 plugins/playlist/src/components/Router/index.ts create mode 100644 plugins/playlist/src/components/index.ts create mode 100644 plugins/playlist/src/hooks/index.ts create mode 100644 plugins/playlist/src/hooks/usePlaylistList.test.tsx create mode 100644 plugins/playlist/src/hooks/usePlaylistList.tsx create mode 100644 plugins/playlist/src/index.ts create mode 100644 plugins/playlist/src/plugin.test.ts create mode 100644 plugins/playlist/src/plugin.ts create mode 100644 plugins/playlist/src/routes.ts create mode 100644 plugins/playlist/src/setupTests.ts create mode 100644 plugins/playlist/src/testUtils/MockPlaylistListProvider.tsx create mode 100644 plugins/playlist/src/testUtils/index.ts create mode 100644 plugins/playlist/src/types.ts diff --git a/.changeset/calm-snakes-tap.md b/.changeset/calm-snakes-tap.md new file mode 100644 index 0000000000..6b853da53d --- /dev/null +++ b/.changeset/calm-snakes-tap.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Export `HeaderActionMenu` and expose default `Table` icons via `Table.tableIcons` diff --git a/.changeset/nice-ducks-smile.md b/.changeset/nice-ducks-smile.md new file mode 100644 index 0000000000..14153c6350 --- /dev/null +++ b/.changeset/nice-ducks-smile.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-playlist': minor +'@backstage/plugin-playlist-backend': minor +'@backstage/plugin-playlist-common': minor +--- + +Implement playlist plugin, check out the `README.md` for more details! diff --git a/.changeset/purple-chicken-kneel.md b/.changeset/purple-chicken-kneel.md new file mode 100644 index 0000000000..d6a81c7f17 --- /dev/null +++ b/.changeset/purple-chicken-kneel.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-react': patch +--- + +Reset page cursor on search filter change diff --git a/app-config.yaml b/app-config.yaml index 7c93d9cbd2..22ca1fcaff 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -444,3 +444,6 @@ apacheAirflow: gocd: baseUrl: https://your.gocd.instance.com + +permission: + enabled: true diff --git a/microsite/data/plugins/playlist.yaml b/microsite/data/plugins/playlist.yaml new file mode 100644 index 0000000000..3d69103d69 --- /dev/null +++ b/microsite/data/plugins/playlist.yaml @@ -0,0 +1,9 @@ +--- +title: Playlist +author: Phil Kuang +authorUrl: https://github.com/kuangp +category: Discovery +description: Create, share, and follow custom collections of entities available in the Backstage catalog. +documentation: https://github.com/backstage/backstage/tree/master/plugins/playlist +iconUrl: img/playlist-logo.png +npmPackageName: '@backstage/plugin-playlist' diff --git a/microsite/static/img/playlist-logo.png b/microsite/static/img/playlist-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b5c8d9940175d6221c1bf1cdf9ab473e5793a5fb GIT binary patch literal 14734 zcmd_RWmH_zwkAq|1PBhn2`<6i-QBHlcc*X(?(PnO;O-LK-Jx)I3ob!l<(zZRmDfG` ze|L?ly=SfcO<8lvUN*xO#KfIyUz6jcV_&%nQ3*iYbVNbqA1`1S#)EFlb0 zHHm)={$pVZkTR2#gP;b}un^E6K0!eJg+M@pFKGXwKY;0P|Ct_Y2?6~N3s`{~&asDIPDe}UHo^)X<$gQY6K86YRiZESB#Z)jp~WJ>RD>+lxVpD~pQ%iyi!rkHo^+*@2sZ!OhK$-i?Lc-pQPSiHnPifsvVknVAlZpaXi?IUBmu z*#SxaDda!oh?)Y8oh%)kE$!`y{>n8pvUhRjBO&>#=-;1z+Uaa*_Me*Ufd9%CID-s- zpD-}dGcx?I*ua@pwlueJ2L9X1|HkWX=cxg3()j$(m%3kV`9bt z*6>g5|8UPgt>*n}6Ss)Ht%H*(5D3m4KNIgi?EG(O|C!5w;+0K-_BJkmouOuD>CDgk zFP8t2{6D!AoGeYj-u=t=Z!P~d?SJAGE$p4`!6ofvX)I;uZ0ZCy@n78kq40l?_%E{l z?O1LNQzI3y1XEkE8T@~%0`OmT!Pw9a=xlFn?eJG4{okUpu{1Wd1DY~$F|ji-ae?ax z?_c-*vw;4)od2TfueZ5n|G{bRq^2zUf6!s;0<<)ybFeY=u(1R>)47@&JKHaaZ>H~)MGoLE-ofJ#sc%Wev3S|0F#^JA<$2S+d>^=2;^+M|8vR`dh zww$dlQ+$2g?;G!$*3}*ND=~7J?E86QsEW{0g58!uNz?@-KVSi{-wYdxzd?Q?{t?7h zQm3jaCJ0wYdD7i478d4fy3l-rf1O_6O(PF5fD)w68DMBL%3&L3|nwFMC z_@Sw<`jHr*fPsP3GGc1bNi{2dl(;M^Iz@^c0S$;4pP2I!-aVR52FM>gx90 zqtfxikKTRJG`cyl8_vUTEt;Y*k$-qMBhg$toBcFBzjJt`QD`FHiO1_JMn*5bKw079 z)}EG9);yGk*8U=a@#T*+8H5WRgCVd|;#EjEl+=0Skx7{PZJS(EANk(i(Kfa}*jdHT ziCNhbD)69&u)t25>|!v1b7=gYZod25_Z98rD^-Qwe8AfV|KqD3JdIMF`ny|>mG)Oq zdUI0xI?R+g^YF)B&i51RX_ZEl>hsfqQ<$eqVRQU9MEk+EuInzxlDdy=UNB0UI86;@ zl4nKfXWM38>E;hqW^ccxm(qv4H#U`Ub?HT78QH8Ru3b3O9EkQ2dI+rqdvOL&`K0zz zvqECMk5{oP?0RKjR^we-q(G3)Mrc!u`9ay$Yyx9b*$U=y3h^yegmspL*Ow%Tu0%K! zJ?Ec)HVTCIM2NMVMQ#e)oiB6FFCce~1+wBhJigw5J`@IJ?}zYQ*ymzB#6!t958(zP z{P_br>rng6|$}6hmzq4td1qJhVsHKZKZ}b)gWjCt{v?0q|V|!4B+GeTN z$I#Zbet~@B75cm-pd4LYUaM&3M{T})5MDDP>>HT>%p8)?vA+PsqFa7t< zn*;luL;W@!9)0gG#q?H!`m~moM&_Ux9#=p{rhjxO-&o-us|)@jpqHCmIK(75q`~Le z6#)@3xh$3Gfy!_wsAvxvZIQ+>@b2!eq_mW4o1vX{U4Bvr_roWd$7{zkkLwR>iSj$W zr*^`Ik@E3&1RYU8LnHV<#|>VKj)znxYd9UlwX+S(pN#RBa^GI&Ic zo-~Vvis7v>>AK4mc*Bab?AmuTPOofyk(e|fVA{M;Y|46J-jCZ_UJ0uuhVpCydg#JY z=fq>Fk2)8txH15b-G{q7!+{8lme>1L;)5%b2sBq_iy0I0_!oS6uERB;!*(#1e0WXD zZhwz% z_R6C0v1(_=gHO5-v7&-c70h?GyMxhyHdi{M+NEKrqDQ%L+fww$|df)R)29v{=wDEFFW z^2A^ZL&r+6LOR;k1iFSUQSO?2QpOdASeoQd=z31gtO+S{lyf0R3x{6qMXke(G(ulg?Cb^ferCeM#l2iI zj@<@n+YhK|=$fLcAE=F*B{sFxMZ6Qljt>kSZ+?W{+h1B)iO96?Tz0ybgP|wor1*el zc_L!9QN!V<;v@8OywSFOv$!4b;Da#bhCp%-zr9dJoaNFZwsiTM&i(G8OL%xn391uf z$63!br1${Cgs(!oSmYL`tq5Eyz2PIPYim*V-)}M>C*3-Y(^XwlAc@c2MUcx3DHjK! z7bVF83>YmuA}HPN_hOMkdnLuuh80&wdB1Xq;AntUcS`kNu;n5#f9_YvEn&!A!VtbS zR3rMn^>m5z1XADgK9hxdVb<7w`Avn(HrBGwYrBVv{z;lU6S4T>?ZB1P+E>2LWt96VSkUhD$k7l@Kmo`Ps-C@z~ z?W6C5eX((THQIV90$LV!P4rB}#p7P|I+?d>E=Wes%WDMc7*@{t&MWmzQ`^kDQk111I84TWt;q7=* z8J+7=NH8Ad4$GAGy$YR=mO=IMs#i>e4z%GYF-V%nLlW<8FN!NzGz)W5xj zyPJ}-$f{($tM3m`th_L~o}Lgbsd8Vxzc z)K=rw<{P+D{mWchdJ%o5#)}Wirl?_IEyd=3t<%e+I2g$RF+RE4_9VK!!lVShN+LP+ z3cBzi4Q6rPoTnNO;BAC`&Grc!Oq47{{|MZ`J!8UCAg@@yuUME8^*@)LE`(?lqdH6w?1)tBn+-@B5`9!m0nzJ zmm3ua;!1>f1TijRzumgA00Q3eWxn+L^O8Fgl*s#^sm3kYInwRh9U6vMjTMx#%yUfS z9R_oeXrNo%tk7#7@+}e(NT;l(7$J-*3p|4N?dBTV&7s?pD5`rH2MZKV+quFLO&46( z+gswkFJ+jPg~-+t7T96gXrOLsmG}?2L)O6z6e>y;+Kt%R416Jcv74d6&dg#z9S?0CP1`qo{I%o^bw&aqVE1dIAkM>@K;p7MQ zBXsbwJThn$>9%XO(aTjf2s>{)zOZiIDA5R+w+_E_tshWM6u6<}tf%@FeF;yzS;K)& zKIeUtg-r!hM$nL4WB&bQ3gaqgq9Udry^pa8+MWew&URV(Vg@K%yQa2>P7n`C(V|H( z@Qmia?4|(mi#b@+0BjYg(JH~bxF47t)1O#R58bU%vif4oKD{;bzcLE*c`2a|lb*X8 z_zhKf^--8<-D>6~>(RsS6he(kQvq+q#=gg*HJz19exwcerhlqsw^-`G8BSC*d(gUERp4262Gfx< z4fsGIVsNexhU!3XNXFip>yni4zCzI>H6L9wtBqE|KjsAF>Ntd66VR1>1U zrh}y&W>G9a_eT5Oj*H@SM;2eRFR;LcWmo_k_D6j^x~=UwXgW5|J#6llI<-Qus*HdM z>8v3AOs>k(h$*GUH7z6jN9H$R>o%Q9a;3qFQ&KXED;d@o%De7?DM<~OvZVo+_~8V9 zlA0el%Lc)X^AaJi02O$`k#Ul_!?P?ov@d#gh@-NqGBUPP{>fSW$|eML9o4rul;1DH zMh^Q@Q}b}N6+RoL*FzAPMj4hTngB!`gx&0F#d9N8h zqHFSt%v5HE;|wXZC5)8?;NbbyN0r;Zh>~ce~dg(sYd}(IK5y@)PCzLqRosNzU(3sM-g&d8h1YbxRc&P3? zKLygzCXl&F+zJOIp~`8};2B3)Uz)T!`3Ybfg?vuPwUt22%UDWeOQ~t%0xc{AGx+`) zbANx{w7;_q9-eJ(Y!ogXUGPbi5QL=}%kzG_$sGzr<_}9rNg2^MVK$dE2x1%L21ylq z<#{Bq3^IyZLd}Yk@kJT8K+$g2FC)xN4C4tp@uq7;kyi+=&&>EYcbwaTn7?}6pVdlg z*;Fmt>)wQwAj8cNTgzo;zH=f}VLqezV$WRE%Y{u*BxA(E=Jo&XsV zHXS)Uvy$?IU-7hf#LFL381(H>)j(eHZxwQYi41q6)m((=un@l8D3%!O#%^>PxoxX_ z&dScp!stkrSC&WpItnKW+h5by=;4KV+I2>mO`lVXMe2_KzB3!M<&k+W zA?(_(#BDgOp`NLN$##q|%cZ=H@S@xC%gf7aT~5`**jUafu0#h2*|RI9j-6FoD{D2; zSOI+lwUGF!(LI#U%j~lWLh(g4B4IWqt0REkh)>C;$g8WXjw9r$TPNk!ITjeFjzj)_ z00uOn+d#sXZj#@QIz|b_igZZU@?5n5!Wf;^y~bycCBZ(^ATF*4;RUrwJvf?@av&!FeFE&LOanp(8u zAcX5Mm9cgsGQ)jD>P%K#;!4&6^bWmJ4>hh1H26W`m7boSK7ys=4&&qU!|Z%(a$2Q2 zr8wt4iW)MU)bL|I*+f9stw~TBHP<^^2m)Q8!E|U~Y*!eB20AP|h$NfCYc!F06(608YdYH`xSzoj!NRFClu_V0vib zE<;`bKK+-O)hg?d{*48ZkXy$kZ%2O?%jrQFZFVw6*H$$78ColRx<`|dRbI%b-R3sTtg=`XM;8X ziF>fcW+(uckf7uB&_Y}Q5+NT&WohirXh6=?is?bkn0kl$0ZtG+ydP&Irm$m=)wplo zUK^_p30z@IaOxfNsFjoL1$EwWoq7K@Wc`FwH|nSrYGHctof>JF^F#0W5qVM#%jf0| zn_gJJiW=+LOeSaM8h)%x4a+J{q~Y?iefO_s{Se)7+D~;-Aw*5a=^O#(7p+i#9w9_f zOI2`Ww`&q&So#6<1{O-axLS@kx;Bq>Y z&f@w8gwmtY@w)k%eX(SuHazLss73}&`M%1VH(#Tj)=cWy^FDc-1`FoAF7nn#@1@}m zqu&Ch3loj2a8#N0YW)4*d>@z3FB$Dx5?Fv)XkaTetD}oiNNR^mf z0Yj1vYDefH-QCkfWt@{MzfN7e-{Et6r0hLuesNiszita`%^z@E>B=LG9B?Mf8|8Rt zBdv@EeMrBwtC<=Z-+D(^KlRLG7Wea6B$4AJfy=}Y85heQX-^rYmd`%zW7&MB-wg@0 z*b_w&P^*?zuc=dFtfVTHuDhNiImI?^GmR3hwJ0-;oSE*L5cCtJ|0Y+3D#OezwKj)) zl75w|M~Sp88K0NcF3QFP$X7g@+O7`y4qgPTnB`DF}Aw{e79qnlB=@3W?Duk;{X zI&-7j#YU?#rRg7hW-RqOPt~x8))nxY2PntRtnCjYQD;dtKLRE_%nLH{axMEa@Gd zT8El97x(F5c|LkqwMHOKn(PUWKD93cqc2hKM5l# zY|Szuh9nayN6xieQjJQ8BEt{ZaePSd!d;)%g|jefn#~*?d*C0pnYbA^4C@RAGaEwJ zvX-To;!-_{sa$&KK@M$r>;4#t6{7&8F$=F4MDlXpfp+pUXEv4&I~-C2MkQO-AuqAz zp`&8(s&^6`STb`dEQSbb8Z4w0ngTRApIHhDwoGo)QSM|y5R1=hElRp2*X{QRKN7|d z(5ij%5M+ENO(-UvYz6?93W6!Hdj^GiIJXxK#K`Yh%#J(la&0}O?PIhDa)BD^W!d) z2o#mZOyvHzarkios|TIUyrAqpHd?I-ki$>HIjP<-^(12*dJb>Bgz=sRp7Es3DHAi@ zuRad$KEqBkrGxv$2FJ6>4w4CW^o$q|UfXUhD1`XJ_7@OFBB*C8tx)0qA6kHp^C5L5 z)m46rSUYub{A5N-3CU%FM(f z2K@AZ!3m1#6+$tFmKE)F;VjREeL%x+)*xKW9u>{NFR7UcYFmOg%Ofvc1XUk3de*w+ zs(!FdIPytx&98@i)z0bb#Ci`4thN;*#VWD7#C~%D-rK>M5@vnSF%A7Waq+Lh;lsA57+4;1)jI98)R5Zp<2^YGb2@+fI88k+5_{26Iy{)VBy2!1p0 zDREM;ojHg-qrc4o*j=GN=Y{8yu6FNdIng962qk2X)XUy`#N5#)Y=|lMjTR^RD9u#hbYkn-UWhv^ctDIDMuFDsyKRQ)6T24bR@_d9y?@VS+Dydd>L%Qls{n(Kh|GMV_$;naz*(v?Y zS9y~Zlw7jJRCt_EFwdp0IyzjVg>tKd1Ttt+lx1aBimQr|g1UV2{O=PbRsaN6IuY{~ z|G*jAk)u8$0b~;$3(jXFowYj5hmEJHrM7pMs+iyVk?JY)KMoFMgd^G&3sx&V7Xp6n z{dIGW__*Q7RbBO;bLDK1Cw5&(+^7 zu3(Qw=R$MUli3-f?|X`Q=Ig?;Grwl%neQ_{UE9Z%Ur?_6eLXqd4%v8ps@8I@$nd9S zg~%wdD=jub()(&>pygr1Yq>gs!v(}lX`AT8J3?tK9NP&}9bcGPOt?s>Qyb;kxI#Kx zZAOO2q)jstXqr9(_KUUG87A0zT@WFGD!V1#j`6}iX4X_&hs|H+M`SdnZ>;^gg=kuT z0P+Gib+@`{sRQ)pgZp5A;isg_W-cX+crMpgQ$;E+_n=CBCzLLnL6T8DO}&h?gt}N)D)$lvehmNkA&BKgGHqY!6-K6^3Xmf*s9)NQrYz=C{-oR^$(24nvyar?+ zb{hbIBZ}T=Zt{%zawhQ}u{U|>aJ|D#HWxrDcO*M|jU-c@@nV9C+~j>tSgWtuzlSuN zgXpeX;`SUDg4{;XinXfe{mIvHVsp0vp1#*V23yBp+IavcY5QjPd{HkJi+ktD?47NF zD9YUk1<5nsbT_{`_!mBV3=l1RK-o?`o_rqR-I&&J06(RWdI z@>O9jB{L-Xt>ueH&Xpc3x-DV8esG1J)d2L6f;Nl1_a%$I;13USS62jLGQCZ21Q}f_ zeD4=j-rGKVLqnp_UoPdSIA?{WC#KUPc^sDL18=nCfiDycOg)=@dHE`-)j}{D;=b1) z29l;5)2y4kt|8)4!DnnfA5{Db+io6#J2BA?w)p~KaV-h_=44+@AimQMg?-XC^G3(H z@j+kl-=%amcJ`gO=Zm4(@3*8nK6eYD6NZjk_~o){BJ#P*MP z-+4YlWxFY3otTdzciN!&*rjqdiiD*8~hSZDgyuuJmNeXv$Okvx{i`eMT2$is)s z;2s9k;BwOLiICwQqM%!j<}ycFjytq#;O|f7=QWpt77HL~cLTxeB?i>EstMY@cjfv6 zw$1CV*k+cT_pa`TB?3h&5JjGE-yADUj*Je*WF+-CReXrUQe2f+5v6ly_5I=BPoUgA zhynpI#vM`U{B4CFwBI(f-5-ZG2XP{;E&7W}mZ{X3qZzZMJq)KD(ueC;3ix-{53uQX z!*0B5n^@Tm9(Es$fSi87_XD%`d0dfc6){BWe%TqwgUEp82qzGBMJ}j4)gI0=L&Aj9 z##fse%lyf zbr^V_Zu9j^zacUOVHXkbw=8%JkElfrvfEN-aV1Sv@WLw}ERt?1&C#b#)se|Ey+%uz z_#U%kOmWi;eHV&-$kTtN`0$Gbh4%)seGOC0kHuc63Odw$_Hp9t<{-5%E@E<{B-t#2 zk%0?yQF!8W*zp<8=7`H&IW(LpC)p;iQ~H%LLt`mV~Ahd57Z$*Byc^lNGq zq$c0SQ0v{O0r(FrNyp)X=<2MA7y}}Hw)rc7$OByK`dF$^g?R0!%BFMywe%%T!3P7h z?LkahoeqL*d~T^(ZG6Emjq7c-`@i4215R+sj-s6ATFG1;Hl*?$Xi&tCtj}|5zL7d$ z0zn(JDvPEQc`h|wzZ&vT+j6`Jpm<#KQ!?24P^3KUJVu7i(r1N1`4gS$SDkS1>_bSb zDK)@9l{5@Q#NAgZw1MQGu;R%UuVe`0G`|MUBf=5M*CIn&pPWh4x)1&x8GFM%EHu(sebfvQ0cS%endAbim^IVpKDqdf3W@EwX?U!J`)DY~JXf&FTA&OE?HK+sK3|UZZO1$2Rm<8~ zAqzTHZZF<(B|vA6baOt}!K~^>P&Q7M`7OO>0@Om|Js^AcEm)}X#xY(7DraANXetKmuGS=Qj8 zERZNiQ!2P6VX6nC1N|}Y@@y?%Qq{{O=&o7&;kqBc7daezLMEd5VciB_zC_& z19RFZ$&d{Zm@VTLgV7Pqp0Hv{81C|?T)7xtftWZzR*05af zD~I|(DvHp-HC`<^9<0;kR{{H)fQ&Kw^iBfDPP5&k3+uo|g=X4`-DP?L2IH)sB9cIY z3bs!)MRlfliZ@d=_qn#T6hL2N(rk*GxH-qOnT>_xma!{cmEO-CS%OHDz`J_=qhhzE z9t1Rc4)oI2qf*l-$Cc4yGN{~Ok`)gMnVg3)r<|(u*W^6%m2reOb4D?xcP$6{Tl3gq zt+*Qi#p_vDQW4Zt>mSuQ|0Ki3D2g3cFH1dTYyX%c-sdR07%ww2HBp=|Jd*tbFA5&% z0Ez2}DXAj4Um^Mg#)*vx1O_RhioKI)mvbK4;p8zAo~5OdT1Dt72(p(BXUQ=oHd*}> zk|=%7(u{A7m0raxm$S?LTo;lm<;>23^?=dGFU%cukz~&mI0p(&Ei@IfZ}Nj*ro;zU*I^yq$+^4}%@OK3N0cqix(k9c%_4F+ zpBoXfYx48Yb`K3OzCMsnh3eqFU+YOikTwG+m*&XIND>%JPcDwpk=M6Y{)BRFw^(oLKXnh};RA-eI zgUE|*w|JMKZ{%AQh5kIPYR$~GVhJ6ji<9o+7p22u;=@NEF>GUru*gGxJi!V?cy{591Sn{~fdID<|5F{U23p#TI4C#KSplQLS?EBR z6{EV(?|Z6AssTte-1_&)$zb6{X9nD`h$IZ~vcS0H!Q}Fi6nR#Oq9+_J)wD}=(DTjs zl^^6`cEOQSBP?z?4}*l?eYdC|i|lhGlG0Q|86X2iZ`Z%CCzp^aT_^DU^;ExEk|v!p zrg>QO^67;7Y&)_7xXR92-36gb#j)b~NpZCS-~4_5`SLpZJ)|C!gGescDFUuLn;^&e z@s&a~o13^|8*eOYKL~1a`ihD0%KF*Wct8Enc=_0TdwG&orN+rcrfN6QYBSiMPrEkY zSb>GCrXC@`HU{hcn0E@$D@|Yg`uq?`gSDlS`;=5@DkhUI4H$WO5AS-02Jdc!KmTr< z(wfHkpG@O>ro{j;sYTBJpL=*ohEoOEn! zGJG1rzF~e8s7jqZ3=US+J1hHoz)*~poGETHe+;izU+)UlnTb?91~b8gM6a%fVD;h= z&U+n3v9%~z27vjS|Lwf!O&I6>Y5z|-cr1L56FE@hdaebpscAgK9=^}fCZLg1Lr9lQ zd(h~(D_)`BwV~y?cSK528!AFvYJvoBeNwx>aOmi8BLbW2M36eYh06g4ldas?@>u;2 z^R`RFVW8QV*_!eq87FJhJId+^GnKl`jMl=~*em-+R-S|p?mbp~QGD;?Pq(!@kzjtc zW|8-irn0ILFLz>1tv~Qol)wWq9{5`<@{x1d8&{`#>Z5hFB_x-@Tbc(4X1tGEf$Ea0 zCMU&fPtDi7`KAmVx5_eOpvA>lcl<`e|7OC`_~ zIlXT4qMVIdas!RiG0rl@Lw;TD*uS9{>v&__D+Zf&Brk0&#@^Yt;^+17^FG!44vmbZ zYSo+8olQvtwp1_UQzcTA)HMfb2e{C;8$n34#W-)E*f&1cf_C*;2TIZ!EvsJ6+aZ{m z_fnXetkW0iI<|Ca4wngj+}PHebpRrbkJ>8f8u;we+uNJe!l)|lUV$BJPvHJXvKUIu zI*}~PpZheRABFT>*KQekWaT=fQ%sXzO5H7pqG{|NKkry`Y$yNbgq$x?dqcon+^c=f zYrDEd?urhMVzja;sXzEpbj-)U0U>@O5fnh*0Hk#{*M}VA=s?GMTQ{$Do9=P(G65|3 zuMnkHjXUpRap*^80I;aj7e`2y6ueze%-=N97Oe%!t(G9s#L?8L)rP5aR2iV4_Ux&4 zoHHzNB&1NDI9bL8wMQpf|7){!yN^&G_YgS(Q-sg zTKj3eC`f%@3kYRZTot^qNH&Njy%wKqudb1I|Gcq2CE9rgiRf%20ltiGHQ@=wJpKO4 zS;`*d4`^ph5(DxJ*nGx9MqPg3A(?o28I$atoGQb8FO7twkvQNSq#h7DjPKv|To$lB zB^0FA3G8~!-Ry(^^XUwG&jH@p-RBRqmK9V9aPYEwsw))hq62=SRH#AKfsk+Fn)!;+ zmnoSZtrtIL``57Y2=!<}lwd4O$LW5Li~fzU}On?ah= z@aJZv$|+>aJ5Tdg_kqAxL6PSqQ3OE5;b(4-C&a5cG)gkTK)Y+KOR%qC*R<<86 z+s>k=&F&BXuRi#zr#Av<>yxxb#=eo?a9PSzYLF`rTgz?VBlxEHqDAcqg`Ek4kEL80 z>A5ZnVRC|!{V>YA#$JZb=t+ecfN?V4t9XHYpsWY>AoUK^z)qt3Tk%pRE{CK&c)yH> z^s%6q-Y;(#0NkpII6E&ib6~fz(IFCBfE@fMF;A=XI2?+B393!g}<~RI!$p@xsh94xQZ{TzX6X4LAPg3k6%VaPr)QMz5b6K4dvsa$X(F|ir z40`mB&;~Mn{UyHe_^UW zG*;@JjMpNpt{o$dnQpOp-C1aa#9ns8n0VB3!yTJ!gDuwe#=+I7+`S+`5KRQBa;EE& z;sn+UQ*)=2DdD#CW@Y+l5!N%-Mal>y-Nx6$PM4}mT^D&h)&6t-LpCro@8a)eTzZ*#Ac$@So5(s z=RuuT#|yVCwNfxdMh&T;Rh~PwGD$4$n37~0JFN#*4>?;>DCWNkHN>CcR)od4AaP-VXi)(nTMM+%p>5^$z#|72#=dm8A78fBcO~(nHGvFhcNqr zElkoEYc{ID%InT_&x;%N00re$1{Ma}O6o9qY=ECBkRwZnkvnJ0Nu!~-BMM_udEoHK zf?FM2_M(z(XOg^8udcMc1g{3{niklJ18A6lqxFdU!cThz52}+RY$nSRrUFgoaeXYT zk>^Lny|Val8F5Le7e1D zzMtXR(>3=j`1}<|vBJI`7DHK&b4!37F8{X*{{Hw+x%|?D){MtthH&2+F@W_@D!kYc$mLAg;{2iqn8oe1>z`1YDD3eGx zN#T1b_Y_@&%!cHbLc@AQC#;`~Qj4O0ilKh<7mO*YO6=nl1_h|orq$o)4S;O?OUjsR zZHjJG;CQl+aJ_A@>Mvw!8xPzuD+>LO@4i-l%N`Oj-|3<Nc;=c*GFe0GHQN4zCk z=D7u8qH^wdq=^yVg8pvtG5y`*Ln!{^r5az{>@A3G&spqUkcA|bR5Ry)LaQ_P+gkn= z)O2j^o6?!jC#&+b2zQokLhEqMfBC|Ka=3}E@PqD{I6|-$9P2{2n4#4A#UBkE)gt4U z-}c4d2^> } /> } /> + } /> ); diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 30da01841c..8b5f0932fb 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -22,6 +22,7 @@ import RuleIcon from '@material-ui/icons/AssignmentTurnedIn'; import MapIcon from '@material-ui/icons/MyLocation'; import LayersIcon from '@material-ui/icons/Layers'; import LibraryBooks from '@material-ui/icons/LibraryBooks'; +import PlaylistPlayIcon from '@material-ui/icons/PlaylistPlay'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; import SearchIcon from '@material-ui/icons/Search'; import MenuIcon from '@material-ui/icons/Menu'; @@ -105,6 +106,7 @@ export const Root = ({ children }: PropsWithChildren<{}>) => ( /> + {/* End global nav */} diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index e15df4bc6c..c2f297a2d7 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -104,6 +104,7 @@ import { EntityPagerDutyCard, isPagerDutyAvailable, } from '@backstage/plugin-pagerduty'; +import { EntityPlaylistDialog } from '@backstage/plugin-playlist'; import { EntityRollbarContent, isRollbarAvailable, @@ -114,6 +115,7 @@ import { EntityTechInsightsScorecardCard } from '@backstage/plugin-tech-insights import { EntityTodoContent } from '@backstage/plugin-todo'; import { Button, Grid } from '@material-ui/core'; import BadgeIcon from '@material-ui/icons/CallToAction'; +import PlaylistAddIcon from '@material-ui/icons/PlaylistAdd'; import { EntityGithubInsightsContent, @@ -155,6 +157,7 @@ const customEntityFilterKind = ['Component', 'API', 'System']; const EntityLayoutWrapper = (props: { children?: ReactNode }) => { const [badgesDialogOpen, setBadgesDialogOpen] = useState(false); + const [playlistDialogOpen, setPlaylistDialogOpen] = useState(false); const extraMenuItems = useMemo(() => { return [ @@ -163,6 +166,11 @@ const EntityLayoutWrapper = (props: { children?: ReactNode }) => { Icon: BadgeIcon, onClick: () => setBadgesDialogOpen(true), }, + { + title: 'Add to playlist', + Icon: PlaylistAddIcon, + onClick: () => setPlaylistDialogOpen(true), + }, ]; }, []); @@ -180,6 +188,10 @@ const EntityLayoutWrapper = (props: { children?: ReactNode }) => { open={badgesDialogOpen} onClose={() => setBadgesDialogOpen(false)} /> + setPlaylistDialogOpen(false)} + /> ); }; diff --git a/packages/backend/package.json b/packages/backend/package.json index a8ae29ee18..eb0e0d58e5 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -46,6 +46,7 @@ "@backstage/plugin-permission-backend": "^0.5.11-next.2", "@backstage/plugin-permission-common": "^0.6.4-next.2", "@backstage/plugin-permission-node": "^0.6.5-next.3", + "@backstage/plugin-playlist-backend": "^0.0.0", "@backstage/plugin-proxy-backend": "^0.2.30-next.2", "@backstage/plugin-rollbar-backend": "^0.1.33-next.3", "@backstage/plugin-scaffolder-backend": "^1.6.0-next.3", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index de3f435508..cb9c9bf580 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -57,6 +57,7 @@ import app from './plugins/app'; import badges from './plugins/badges'; import jenkins from './plugins/jenkins'; import permission from './plugins/permission'; +import playlist from './plugins/playlist'; import { PluginEnvironment } from './types'; import { ServerPermissionClient } from '@backstage/plugin-permission-node'; import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; @@ -139,6 +140,7 @@ async function main() { createEnv('tech-insights'), ); const permissionEnv = useHotMemoize(module, () => createEnv('permission')); + const playlistEnv = useHotMemoize(module, () => createEnv('playlist')); const apiRouter = Router(); apiRouter.use('/catalog', await catalog(catalogEnv)); @@ -158,6 +160,7 @@ async function main() { apiRouter.use('/badges', await badges(badgesEnv)); apiRouter.use('/jenkins', await jenkins(jenkinsEnv)); apiRouter.use('/permission', await permission(permissionEnv)); + apiRouter.use('/playlist', await playlist(playlistEnv)); apiRouter.use(notFoundHandler()); const service = createServiceBuilder(module) diff --git a/packages/backend/src/plugins/permission.ts b/packages/backend/src/plugins/permission.ts index 0d3dcc588d..7192a1ddec 100644 --- a/packages/backend/src/plugins/permission.ts +++ b/packages/backend/src/plugins/permission.ts @@ -14,17 +14,34 @@ * limitations under the License. */ +import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; import { createRouter } from '@backstage/plugin-permission-backend'; import { AuthorizeResult, PolicyDecision, } from '@backstage/plugin-permission-common'; -import { PermissionPolicy } from '@backstage/plugin-permission-node'; +import { + PermissionPolicy, + PolicyQuery, +} from '@backstage/plugin-permission-node'; +import { + DefaultPlaylistPermissionPolicy, + isPlaylistPermission, +} from '@backstage/plugin-playlist-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; -class AllowAllPermissionPolicy implements PermissionPolicy { - async handle(): Promise { +class ExamplePermissionPolicy implements PermissionPolicy { + private playlistPermissionPolicy = new DefaultPlaylistPermissionPolicy(); + + async handle( + request: PolicyQuery, + user?: BackstageIdentityResponse, + ): Promise { + if (isPlaylistPermission(request.permission)) { + return this.playlistPermissionPolicy.handle(request, user); + } + return { result: AuthorizeResult.ALLOW, }; @@ -38,7 +55,7 @@ export default async function createPlugin( config: env.config, logger: env.logger, discovery: env.discovery, - policy: new AllowAllPermissionPolicy(), + policy: new ExamplePermissionPolicy(), identity: env.identity, }); } diff --git a/packages/backend/src/plugins/playlist.ts b/packages/backend/src/plugins/playlist.ts new file mode 100644 index 0000000000..0d31158842 --- /dev/null +++ b/packages/backend/src/plugins/playlist.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IdentityClient } from '@backstage/plugin-auth-node'; +import { createRouter } from '@backstage/plugin-playlist-backend'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + database, + discovery, + logger, + permissions, +}: PluginEnvironment): Promise { + return await createRouter({ + database, + identity: IdentityClient.create({ + discovery, + issuer: await discovery.getExternalBaseUrl('auth'), + }), + logger, + permissions, + }); +} diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 831eaebb66..895581c702 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -24,11 +24,8 @@ import { UrlReader, } from '@backstage/backend-common'; import { PluginTaskScheduler } from '@backstage/backend-tasks'; -import { - PermissionAuthorizer, - PermissionEvaluator, -} from '@backstage/plugin-permission-common'; import { IdentityApi } from '@backstage/plugin-auth-node'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; export type PluginEnvironment = { logger: Logger; @@ -38,7 +35,7 @@ export type PluginEnvironment = { reader: UrlReader; discovery: PluginEndpointDiscovery; tokenManager: TokenManager; - permissions: PermissionEvaluator | PermissionAuthorizer; + permissions: PermissionEvaluator; scheduler: PluginTaskScheduler; identity: IdentityApi; }; diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index 3dc4e2848e..20e14312bb 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -16,15 +16,18 @@ import { CardHeaderProps } from '@material-ui/core/CardHeader'; import { Column } from '@material-table/core'; import { ComponentClass } from 'react'; import { ComponentProps } from 'react'; +import { ComponentType } from 'react'; import { default as CSS_2 } from 'csstype'; import { CSSProperties } from 'react'; import { ElementType } from 'react'; import { ErrorInfo } from 'react'; import { IconComponent } from '@backstage/core-plugin-api'; +import { Icons } from '@material-table/core'; import { IdentityApi } from '@backstage/core-plugin-api'; import { LinearProgressProps } from '@material-ui/core/LinearProgress'; import { LinkProps as LinkProps_2 } from '@material-ui/core/Link'; import { LinkProps as LinkProps_3 } from 'react-router-dom'; +import { ListItemTextProps } from '@material-ui/core/ListItemText'; import MaterialBreadcrumbs from '@material-ui/core/Breadcrumbs'; import { MaterialTableProps } from '@material-table/core'; import { NavLinkProps } from 'react-router-dom'; @@ -50,6 +53,16 @@ import { Theme } from '@material-ui/core/styles'; import { TooltipProps } from '@material-ui/core/Tooltip'; import { WithStyles } from '@material-ui/core/styles'; +// @public (undocumented) +export type ActionItemProps = { + label?: ListItemTextProps['primary']; + secondaryLabel?: ListItemTextProps['secondary']; + icon?: ReactElement; + disabled?: boolean; + onClick?: (event: React_2.MouseEvent) => void; + WrapperComponent?: ComponentType; +}; + // @public (undocumented) export function AlertDisplay(props: AlertDisplayProps): JSX.Element | null; @@ -432,6 +445,14 @@ export function GroupIcon(props: IconComponentProps): JSX.Element; // @public export function Header(props: PropsWithChildren): JSX.Element; +// @public (undocumented) +export function HeaderActionMenu(props: HeaderActionMenuProps): JSX.Element; + +// @public (undocumented) +export type HeaderActionMenuProps = { + actionItems: ActionItemProps[]; +}; + // @public (undocumented) export type HeaderClassKey = | 'header' @@ -1333,6 +1354,12 @@ export type TabIconClassKey = 'root'; // @public (undocumented) export function Table(props: TableProps): JSX.Element; +// @public (undocumented) +export namespace Table { + var // (undocumented) + tableIcons: Readonly; +} + // Warning: (ae-missing-release-tag) "TableClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/packages/core-components/src/components/Table/Table.tsx b/packages/core-components/src/components/Table/Table.tsx index 65edb5b69b..b04c247b2f 100644 --- a/packages/core-components/src/components/Table/Table.tsx +++ b/packages/core-components/src/components/Table/Table.tsx @@ -292,6 +292,9 @@ export function TableToolbar(toolbarProps: { ); } +/** + * @public + */ export function Table(props: TableProps) { const { data, @@ -519,3 +522,5 @@ export function Table(props: TableProps) { ); } + +Table.tableIcons = Object.freeze(tableIcons); diff --git a/packages/core-components/src/layout/HeaderActionMenu/HeaderActionMenu.tsx b/packages/core-components/src/layout/HeaderActionMenu/HeaderActionMenu.tsx index d8dc398ce3..125363fe0a 100644 --- a/packages/core-components/src/layout/HeaderActionMenu/HeaderActionMenu.tsx +++ b/packages/core-components/src/layout/HeaderActionMenu/HeaderActionMenu.tsx @@ -23,9 +23,12 @@ import ListItemText, { ListItemTextProps, } from '@material-ui/core/ListItemText'; import Popover from '@material-ui/core/Popover'; -import { VerticalMenuIcon } from './VerticalMenuIcon'; +import MoreVert from '@material-ui/icons/MoreVert'; -type ActionItemProps = { +/** + * @public + */ +export type ActionItemProps = { label?: ListItemTextProps['primary']; secondaryLabel?: ListItemTextProps['secondary']; icon?: ReactElement; @@ -61,10 +64,16 @@ const ActionItem = ({ ); }; +/** + * @public + */ export type HeaderActionMenuProps = { actionItems: ActionItemProps[]; }; +/** + * @public + */ export function HeaderActionMenu(props: HeaderActionMenuProps) { const { actionItems } = props; const [open, setOpen] = React.useState(false); @@ -84,7 +93,7 @@ export function HeaderActionMenu(props: HeaderActionMenuProps) { padding: 0, }} > - + { + return await createRouter({ + database, + identity: IdentityClient.create({ + discovery, + issuer: await discovery.getExternalBaseUrl('auth'), + }), + logger, + permissions, + }); +} +``` + +With the `playlist.ts` router setup in place, add the router to `packages/backend/src/index.ts`: + +```diff ++import playlist from './plugins/playlist'; + +async function main() { + ... + const createEnv = makeCreateEnv(config); + + const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); ++ const playlistEnv = useHotMemoize(module, () => createEnv('playlist')); + + const apiRouter = Router(); ++ apiRouter.use('/playlist', await playlist(playlistEnv)); + ... + apiRouter.use(notFoundHandler()); + +``` + +## Setting up plugin permissions + +You configure permissions for specific playlist actions by importing the supported set of permissions from the [playlist-common](../playlist-common/README.md) package along with the custom rules/conditions provided here to incorporate into your [permission policy](https://backstage.io/docs/permissions/writing-a-policy). + +This package also exports a `DefaultPlaylistPermissionPolicy` which contains a recommended default permissions policy you can apply as a "sub-policy" in your app: + +```diff +# packages/backend/src/plugins/permission.ts + ++import { DefaultPlaylistPermissionPolicy, isPlaylistPermission } from '@backstage/plugin-playlist-backend'; +... +class BackstagePermissionPolicy implements PermissionPolicy { ++ private playlistPermissionPolicy = new DefaultPlaylistPermissionPolicy(); + + async handle( + request: PolicyQuery, + user?: BackstageIdentityResponse, + ): Promise { ++ if (isPlaylistPermission(request.permission)) { ++ return this.playlistPermissionPolicy.handle(request, user); ++ } + ... + } +} + +export default async function createPlugin(env: PluginEnvironment): Promise { + return await createRouter({ + config: env.config, + logger: env.logger, + discovery: env.discovery, + policy: new BackstagePermissionPolicy(), + ... +``` diff --git a/plugins/playlist-backend/api-report.md b/plugins/playlist-backend/api-report.md new file mode 100644 index 0000000000..5c86b6ae45 --- /dev/null +++ b/plugins/playlist-backend/api-report.md @@ -0,0 +1,93 @@ +## API Report File for "@backstage/plugin-playlist-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; +import { ConditionalPolicyDecision } from '@backstage/plugin-permission-common'; +import { Conditions } from '@backstage/plugin-permission-node'; +import express from 'express'; +import { IdentityClient } from '@backstage/plugin-auth-node'; +import { Logger } from 'winston'; +import { Permission } from '@backstage/plugin-permission-common'; +import { PermissionCondition } from '@backstage/plugin-permission-common'; +import { PermissionCriteria } from '@backstage/plugin-permission-common'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; +import { PermissionPolicy } from '@backstage/plugin-permission-node'; +import { PermissionRule } from '@backstage/plugin-permission-node'; +import { PlaylistMetadata } from '@backstage/plugin-playlist-common'; +import { PluginDatabaseManager } from '@backstage/backend-common'; +import { PolicyDecision } from '@backstage/plugin-permission-common'; +import { PolicyQuery } from '@backstage/plugin-permission-node'; +import { ResourcePermission } from '@backstage/plugin-permission-common'; + +// @public (undocumented) +export const createPlaylistConditionalDecision: ( + permission: ResourcePermission<'playlist-list'>, + conditions: PermissionCriteria< + PermissionCondition<'playlist-list', unknown[]> + >, +) => ConditionalPolicyDecision; + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public +export class DefaultPlaylistPermissionPolicy implements PermissionPolicy { + // (undocumented) + handle( + request: PolicyQuery, + user?: BackstageIdentityResponse, + ): Promise; +} + +// @public (undocumented) +export const isPlaylistPermission: (permission: Permission) => boolean; + +// @public (undocumented) +export type ListPlaylistsFilter = + | { + allOf: ListPlaylistsFilter[]; + } + | { + anyOf: ListPlaylistsFilter[]; + } + | { + not: ListPlaylistsFilter; + } + | ListPlaylistsMatchFilter; + +// @public (undocumented) +export type ListPlaylistsMatchFilter = { + key: string; + values: any[]; +}; + +// @public (undocumented) +export const playlistConditions: Conditions<{ + isOwner: PermissionRule< + PlaylistMetadata, + ListPlaylistsFilter, + 'playlist-list', + [userOwnershipRefs: string[]] + >; + isPublic: PermissionRule< + PlaylistMetadata, + ListPlaylistsFilter, + 'playlist-list', + [] + >; +}>; + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + database: PluginDatabaseManager; + // (undocumented) + identity: IdentityClient; + // (undocumented) + logger: Logger; + // (undocumented) + permissions: PermissionEvaluator; +} +``` diff --git a/plugins/playlist-backend/migrations/20220701011329_init.js b/plugins/playlist-backend/migrations/20220701011329_init.js new file mode 100644 index 0000000000..17a71e8438 --- /dev/null +++ b/plugins/playlist-backend/migrations/20220701011329_init.js @@ -0,0 +1,60 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +exports.up = async function up(knex) { + await knex.schema.createTable('playlists', table => { + table.comment('Playlists table'); + table.uuid('id').primary().comment('Automatically generated unique ID'); + table.text('name').notNullable().comment('The name of the playlist'); + table.text('description').comment('The description of the playlist'); + table.string('owner').notNullable().comment('The owner entity ref'); + table + .boolean('public') + .defaultTo(false) + .notNullable() + .comment('Whether the playlist is public'); + }); + + await knex.schema.createTable('entities', table => { + table.comment('The table of playlist entities'); + table + .uuid('playlist_id') + .notNullable() + .references('playlists.id') + .onDelete('CASCADE') + .comment('The id of the playlist this entity belongs to'); + table.string('entity_ref').notNullable().comment('A entity ref'); + table.unique(['playlist_id', 'entity_ref']); + }); + + await knex.schema.createTable('followers', table => { + table.comment('The table of playlist followers'); + table + .uuid('playlist_id') + .notNullable() + .references('playlists.id') + .onDelete('CASCADE') + .comment('The id of the playlist being followed'); + table.string('user_ref').notNullable().comment('A user entity ref'); + table.unique(['playlist_id', 'user_ref']); + }); +}; + +exports.down = async function down(knex) { + await knex.schema.dropTable('playlists'); + await knex.schema.dropTable('entities'); + await knex.schema.dropTable('followers'); +}; diff --git a/plugins/playlist-backend/package.json b/plugins/playlist-backend/package.json new file mode 100644 index 0000000000..bbea8163fb --- /dev/null +++ b/plugins/playlist-backend/package.json @@ -0,0 +1,52 @@ +{ + "name": "@backstage/plugin-playlist-backend", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.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" + }, + "dependencies": { + "@backstage/backend-common": "^0.15.1-next.2", + "@backstage/backend-test-utils": "^0.1.28-next.2", + "@backstage/config": "^1.0.1", + "@backstage/errors": "^1.1.0", + "@backstage/plugin-auth-node": "^0.2.5-next.2", + "@backstage/plugin-permission-common": "^0.6.4-next.1", + "@backstage/plugin-permission-node": "^0.6.5-next.2", + "@backstage/plugin-playlist-common": "^0.0.0", + "@types/express": "*", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "knex": "^2.0.0", + "node-fetch": "^2.6.7", + "uuid": "^8.2.0", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.19.0-next.2", + "@types/supertest": "^2.0.8", + "msw": "^0.47.0", + "supertest": "^6.1.3" + }, + "files": [ + "dist", + "migrations/**/*.{js,d.ts}" + ] +} diff --git a/plugins/playlist-backend/src/index.ts b/plugins/playlist-backend/src/index.ts new file mode 100644 index 0000000000..eeccd8913f --- /dev/null +++ b/plugins/playlist-backend/src/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Playlist backend plugin + * + * @packageDocumentation + */ +export * from './service'; +export { + createPlaylistConditionalDecision, + DefaultPlaylistPermissionPolicy, + isPlaylistPermission, + playlistConditions, +} from './permissions'; diff --git a/plugins/playlist-backend/src/permissions/DefaultPlaylistPermissionPolicy.test.ts b/plugins/playlist-backend/src/permissions/DefaultPlaylistPermissionPolicy.test.ts new file mode 100644 index 0000000000..179b0d7f9b --- /dev/null +++ b/plugins/playlist-backend/src/permissions/DefaultPlaylistPermissionPolicy.test.ts @@ -0,0 +1,135 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; +import { + AuthorizeResult, + createPermission, +} from '@backstage/plugin-permission-common'; +import { + permissions, + PLAYLIST_LIST_RESOURCE_TYPE, +} from '@backstage/plugin-playlist-common'; + +import { playlistConditions } from './conditions'; +import { DefaultPlaylistPermissionPolicy } from './DefaultPlaylistPermissionPolicy'; + +describe('DefaultPlaylistPermissionPolicy', () => { + const policy = new DefaultPlaylistPermissionPolicy(); + const mockUser: BackstageIdentityResponse = { + token: 'token', + identity: { + type: 'user', + ownershipEntityRefs: ['user:default/me', 'group:default/owner'], + userEntityRef: 'user:default/me', + }, + }; + + it('should deny non-playlist permissions', async () => { + const mockPermission = createPermission({ + name: 'test.permission', + attributes: { action: 'read' }, + }); + + expect(await policy.handle({ permission: mockPermission })).toEqual({ + result: AuthorizeResult.DENY, + }); + }); + + it('should allow create permissions', async () => { + expect( + await policy.handle({ permission: permissions.playlistListCreate }), + ).toEqual({ result: AuthorizeResult.ALLOW }); + }); + + it('should return a conditional decision for read permissions', async () => { + expect( + await policy.handle( + { permission: permissions.playlistListRead }, + mockUser, + ), + ).toEqual({ + result: AuthorizeResult.CONDITIONAL, + pluginId: 'playlist', + resourceType: PLAYLIST_LIST_RESOURCE_TYPE, + conditions: { + anyOf: [ + playlistConditions.isOwner([ + 'user:default/me', + 'group:default/owner', + ]), + playlistConditions.isPublic(), + ], + }, + }); + }); + + it('should return a conditional decision for followers update permissions', async () => { + expect( + await policy.handle( + { permission: permissions.playlistFollowersUpdate }, + mockUser, + ), + ).toEqual({ + result: AuthorizeResult.CONDITIONAL, + pluginId: 'playlist', + resourceType: PLAYLIST_LIST_RESOURCE_TYPE, + conditions: { + anyOf: [ + playlistConditions.isOwner([ + 'user:default/me', + 'group:default/owner', + ]), + playlistConditions.isPublic(), + ], + }, + }); + }); + + it('should return a conditional decision for update permissions', async () => { + expect( + await policy.handle( + { permission: permissions.playlistListUpdate }, + mockUser, + ), + ).toEqual({ + result: AuthorizeResult.CONDITIONAL, + pluginId: 'playlist', + resourceType: PLAYLIST_LIST_RESOURCE_TYPE, + conditions: playlistConditions.isOwner([ + 'user:default/me', + 'group:default/owner', + ]), + }); + }); + + it('should return a conditional decision for delete permissions', async () => { + expect( + await policy.handle( + { permission: permissions.playlistListDelete }, + mockUser, + ), + ).toEqual({ + result: AuthorizeResult.CONDITIONAL, + pluginId: 'playlist', + resourceType: PLAYLIST_LIST_RESOURCE_TYPE, + conditions: playlistConditions.isOwner([ + 'user:default/me', + 'group:default/owner', + ]), + }); + }); +}); diff --git a/plugins/playlist-backend/src/permissions/DefaultPlaylistPermissionPolicy.ts b/plugins/playlist-backend/src/permissions/DefaultPlaylistPermissionPolicy.ts new file mode 100644 index 0000000000..86d103e789 --- /dev/null +++ b/plugins/playlist-backend/src/permissions/DefaultPlaylistPermissionPolicy.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; +import { + AuthorizeResult, + isPermission, + Permission, + PolicyDecision, +} from '@backstage/plugin-permission-common'; +import { + PermissionPolicy, + PolicyQuery, +} from '@backstage/plugin-permission-node'; +import { permissions } from '@backstage/plugin-playlist-common'; + +import { + createPlaylistConditionalDecision, + playlistConditions, +} from './conditions'; + +/** + * @public + */ +export const isPlaylistPermission = (permission: Permission) => + Object.values(permissions).some(playlistPermission => + isPermission(permission, playlistPermission), + ); + +/** + * Default policy for the Playlist plugin. This should be applied as a "sub-policy" + * of your master permission policy for Playlist permission requests only. + * + * @public + */ +export class DefaultPlaylistPermissionPolicy implements PermissionPolicy { + async handle( + request: PolicyQuery, + user?: BackstageIdentityResponse, + ): Promise { + // Reject permissions we don't know how to evaluate in case this policy was incorrectly applied + if (!isPlaylistPermission(request.permission)) { + return { result: AuthorizeResult.DENY }; + } + + // Anyone should be allowed to create a new playlist + if (isPermission(request.permission, permissions.playlistListCreate)) { + return { result: AuthorizeResult.ALLOW }; + } + + // Reading and following/unfollowing playlists is allowed if it is public or owned + if ( + isPermission(request.permission, permissions.playlistListRead) || + isPermission(request.permission, permissions.playlistFollowersUpdate) + ) { + return createPlaylistConditionalDecision(request.permission, { + anyOf: [ + playlistConditions.isOwner(user?.identity.ownershipEntityRefs ?? []), + playlistConditions.isPublic(), + ], + }); + } + + // Updating or deleting playlists is only allowed for owners + if ( + isPermission(request.permission, permissions.playlistListUpdate) || + isPermission(request.permission, permissions.playlistListDelete) + ) { + return createPlaylistConditionalDecision( + request.permission, + playlistConditions.isOwner(user?.identity.ownershipEntityRefs ?? []), + ); + } + + return { result: AuthorizeResult.ALLOW }; + } +} diff --git a/plugins/playlist-backend/src/permissions/conditions.ts b/plugins/playlist-backend/src/permissions/conditions.ts new file mode 100644 index 0000000000..8162c1046f --- /dev/null +++ b/plugins/playlist-backend/src/permissions/conditions.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PLAYLIST_LIST_RESOURCE_TYPE } from '@backstage/plugin-playlist-common'; +import { + ConditionTransformer, + createConditionExports, + createConditionTransformer, +} from '@backstage/plugin-permission-node'; + +import { ListPlaylistsFilter } from '../service'; +import { rules } from './rules'; + +const { conditions, createConditionalDecision } = createConditionExports({ + pluginId: 'playlist', + resourceType: PLAYLIST_LIST_RESOURCE_TYPE, + rules, +}); + +/** + * @public + */ +export const playlistConditions = conditions; + +/** + * @public + */ +export const createPlaylistConditionalDecision = createConditionalDecision; + +export const transformConditions: ConditionTransformer = + createConditionTransformer(Object.values(rules)); diff --git a/plugins/playlist-backend/src/permissions/index.ts b/plugins/playlist-backend/src/permissions/index.ts new file mode 100644 index 0000000000..a6bb633a1e --- /dev/null +++ b/plugins/playlist-backend/src/permissions/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './conditions'; +export * from './rules'; +export * from './DefaultPlaylistPermissionPolicy'; diff --git a/plugins/playlist-backend/src/permissions/rules.ts b/plugins/playlist-backend/src/permissions/rules.ts new file mode 100644 index 0000000000..cbf81bee65 --- /dev/null +++ b/plugins/playlist-backend/src/permissions/rules.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { makeCreatePermissionRule } from '@backstage/plugin-permission-node'; +import { + PLAYLIST_LIST_RESOURCE_TYPE, + PlaylistMetadata, +} from '@backstage/plugin-playlist-common'; + +import { ListPlaylistsFilter } from '../service'; + +const createPlaylistPermissionRule = makeCreatePermissionRule< + PlaylistMetadata, + ListPlaylistsFilter, + typeof PLAYLIST_LIST_RESOURCE_TYPE +>(); + +const isOwner = createPlaylistPermissionRule({ + name: 'IS_OWNER', + description: 'Should allow only if the playlist belongs to the user', + resourceType: PLAYLIST_LIST_RESOURCE_TYPE, + apply: (list: PlaylistMetadata, userOwnershipRefs: string[]) => + userOwnershipRefs.includes(list.owner), + toQuery: (userOwnershipRefs: string[]) => ({ + key: 'owner', + values: userOwnershipRefs, + }), +}); + +const isPublic = createPlaylistPermissionRule({ + name: 'IS_PUBLIC', + description: 'Should allow only if the playlist is public', + resourceType: PLAYLIST_LIST_RESOURCE_TYPE, + apply: (list: PlaylistMetadata) => list.public, + toQuery: () => ({ key: 'public', values: [true] }), +}); + +/** + * @public + */ +export const rules = { isOwner, isPublic }; diff --git a/plugins/playlist-backend/src/run.ts b/plugins/playlist-backend/src/run.ts new file mode 100644 index 0000000000..d945aa13f0 --- /dev/null +++ b/plugins/playlist-backend/src/run.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getRootLogger } from '@backstage/backend-common'; +import yn from 'yn'; +import { startStandaloneServer } from './service/standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; +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/playlist-backend/src/service/DatabaseHandler.test.ts b/plugins/playlist-backend/src/service/DatabaseHandler.test.ts new file mode 100644 index 0000000000..5d6b4aa0ca --- /dev/null +++ b/plugins/playlist-backend/src/service/DatabaseHandler.test.ts @@ -0,0 +1,349 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DatabaseHandler } from './DatabaseHandler'; +import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { BackstageUserIdentity } from '@backstage/plugin-auth-node'; +import { Knex } from 'knex'; +import { v4 as uuid } from 'uuid'; + +describe('DatabaseHandler', () => { + const databases = TestDatabases.create(); + + async function createDatabaseHandler(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + return { + knex, + dbHandler: await DatabaseHandler.create({ database: knex }), + }; + } + + describe.each(databases.eachSupportedId())( + '%p', + databaseId => { + let knex: Knex; + let dbHandler: DatabaseHandler; + const playlist1Id = uuid(); + const playlist2Id = uuid(); + const playlist3Id = uuid(); + const user: BackstageUserIdentity = { + type: 'user', + userEntityRef: 'user:default/foo', + ownershipEntityRefs: ['user:default/foo', 'group:default/foo-group'], + }; + + beforeEach(async () => { + ({ knex, dbHandler } = await createDatabaseHandler(databaseId)); + await knex('playlists').insert([ + { + id: playlist1Id, + name: 'test', + description: 'test description', + owner: 'user:default/foo', + public: true, + }, + { + id: playlist2Id, + name: 'test 2', + description: 'test description 2', + owner: 'group:default/foo-group', + public: false, + }, + { + id: playlist3Id, + name: 'test 3', + description: 'test description 3', + owner: 'user:default/bar', + public: false, + }, + ]); + + await knex('entities').insert([ + { + playlist_id: playlist1Id, + entity_ref: 'component:default/ent0', + }, + { + playlist_id: playlist1Id, + entity_ref: 'component:default/ent1', + }, + { + playlist_id: playlist1Id, + entity_ref: 'component:default/ent2', + }, + { + playlist_id: playlist2Id, + entity_ref: 'component:default/ent1', + }, + ]); + + await knex('followers').insert([ + { + playlist_id: playlist2Id, + user_ref: 'user:default/some-user', + }, + { + playlist_id: playlist2Id, + user_ref: 'user:default/bar', + }, + { + playlist_id: playlist2Id, + user_ref: 'user:default/foo', + }, + { + playlist_id: playlist3Id, + user_ref: 'user:default/foo', + }, + ]); + }, 30000); + + it('listPlaylists', async () => { + const allPlaylists = [ + { + id: playlist1Id, + name: 'test', + description: 'test description', + owner: 'user:default/foo', + public: true, + entities: 3, + followers: 0, + isFollowing: false, + }, + { + id: playlist2Id, + name: 'test 2', + description: 'test description 2', + owner: 'group:default/foo-group', + public: false, + entities: 1, + followers: 3, + isFollowing: true, + }, + { + id: playlist3Id, + name: 'test 3', + description: 'test description 3', + owner: 'user:default/bar', + public: false, + entities: 0, + followers: 1, + isFollowing: true, + }, + ]; + + const playlists = await dbHandler.listPlaylists(user); + expect(playlists.length).toEqual(3); + expect(playlists).toEqual(expect.arrayContaining(allPlaylists)); + + const followedPlaylists = await dbHandler.listPlaylists(user, { + key: 'public', + values: [true], + }); + expect(followedPlaylists).toEqual([allPlaylists[0]]); + + const ownedPlaylists = await dbHandler.listPlaylists(user, { + key: 'owner', + values: user.ownershipEntityRefs, + }); + expect(ownedPlaylists.length).toEqual(2); + expect(ownedPlaylists).toEqual( + expect.arrayContaining([allPlaylists[0], allPlaylists[1]]), + ); + }); + + it('createPlaylist', async () => { + const newList = { + name: 'new list', + description: 'new description', + owner: 'user:default/new', + public: true, + }; + + const newPlaylistId = await dbHandler.createPlaylist(newList); + + const newPlaylist = await knex('playlists').where('id', newPlaylistId); + expect( + newPlaylist.map(list => ({ ...list, public: Boolean(list.public) })), + ).toEqual([ + { + ...newList, + id: newPlaylistId, + }, + ]); + }); + + it('getPlaylist', async () => { + const playlist = await dbHandler.getPlaylist(playlist1Id, user); + expect(playlist).toEqual({ + id: playlist1Id, + name: 'test', + description: 'test description', + owner: 'user:default/foo', + public: true, + entities: 3, + followers: 0, + isFollowing: false, + }); + }); + + it('updatePlaylist', async () => { + const update = { + id: playlist1Id, + name: 'test rename', + description: 'test new description', + owner: 'user:default/new-foo', + public: false, + }; + await dbHandler.updatePlaylist(update); + + const updatedPlaylist = await knex('playlists').where( + 'id', + playlist1Id, + ); + expect( + updatedPlaylist.map(list => ({ + ...list, + public: Boolean(list.public), + })), + ).toEqual([update]); + }); + + it('deletePlaylist', async () => { + await dbHandler.deletePlaylist(playlist1Id); + const deleted = await knex('playlists').where('id', playlist1Id); + expect(deleted.length).toEqual(0); + }); + + it('addPlaylistEntities', async () => { + await dbHandler.addPlaylistEntities(playlist1Id, [ + 'component:default/ent1', + 'component:default/ent3', + 'component:default/ent4', + ]); + + const entities = await knex('entities').where( + 'playlist_id', + playlist1Id, + ); + expect(entities.length).toEqual(5); + expect(entities).toEqual( + expect.arrayContaining([ + { + playlist_id: playlist1Id, + entity_ref: 'component:default/ent0', + }, + { + playlist_id: playlist1Id, + entity_ref: 'component:default/ent1', + }, + { + playlist_id: playlist1Id, + entity_ref: 'component:default/ent2', + }, + { + playlist_id: playlist1Id, + entity_ref: 'component:default/ent3', + }, + { + playlist_id: playlist1Id, + entity_ref: 'component:default/ent4', + }, + ]), + ); + }); + + it('getPlaylistEntities', async () => { + const entities = await dbHandler.getPlaylistEntities(playlist1Id); + expect(entities.length).toEqual(3); + expect(entities).toEqual( + expect.arrayContaining([ + 'component:default/ent0', + 'component:default/ent1', + 'component:default/ent2', + ]), + ); + }); + + it('removePlaylistEntities', async () => { + await dbHandler.removePlaylistEntities(playlist1Id, [ + 'component:default/ent1', + 'component:default/ent2', + ]); + + const entities = await knex('entities').where( + 'playlist_id', + playlist1Id, + ); + expect(entities).toEqual([ + { + playlist_id: playlist1Id, + entity_ref: 'component:default/ent0', + }, + ]); + }); + + it('followPlaylist', async () => { + await dbHandler.followPlaylist(playlist1Id, user); + + const playlist1Followers = await knex('followers').where( + 'playlist_id', + playlist1Id, + ); + expect(playlist1Followers).toEqual([ + { + playlist_id: playlist1Id, + user_ref: 'user:default/foo', + }, + ]); + + await dbHandler.followPlaylist(playlist3Id, user); + + const playlist3Followers = await knex('followers').where( + 'playlist_id', + playlist3Id, + ); + expect(playlist3Followers).toEqual([ + { + playlist_id: playlist3Id, + user_ref: 'user:default/foo', + }, + ]); + }); + + it('unfollowPlaylist', async () => { + await dbHandler.unfollowPlaylist(playlist2Id, user); + + const followers = await knex('followers').where( + 'playlist_id', + playlist2Id, + ); + expect(followers).toEqual( + expect.arrayContaining([ + { + playlist_id: playlist2Id, + user_ref: 'user:default/some-user', + }, + { + playlist_id: playlist2Id, + user_ref: 'user:default/bar', + }, + ]), + ); + }); + }, + 60000, + ); +}); diff --git a/plugins/playlist-backend/src/service/DatabaseHandler.ts b/plugins/playlist-backend/src/service/DatabaseHandler.ts new file mode 100644 index 0000000000..6118f22ff9 --- /dev/null +++ b/plugins/playlist-backend/src/service/DatabaseHandler.ts @@ -0,0 +1,278 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { resolvePackagePath } from '@backstage/backend-common'; +import { BackstageUserIdentity } from '@backstage/plugin-auth-node'; +import { Playlist, PlaylistMetadata } from '@backstage/plugin-playlist-common'; +import { Knex } from 'knex'; +import { v4 as uuid } from 'uuid'; + +import { + ListPlaylistsFilter, + ListPlaylistsMatchFilter, +} from './ListPlaylistsFilter'; + +const migrationsDir = resolvePackagePath( + '@backstage/plugin-playlist-backend', + 'migrations', +); + +function isMatchFilter( + filter: ListPlaylistsMatchFilter | ListPlaylistsFilter, +): filter is ListPlaylistsMatchFilter { + return filter.hasOwnProperty('key'); +} + +function isOrFilter( + filter: { anyOf: ListPlaylistsFilter[] } | ListPlaylistsFilter, +): filter is { anyOf: ListPlaylistsFilter[] } { + return filter.hasOwnProperty('anyOf'); +} + +function isNegationFilter( + filter: { not: ListPlaylistsFilter } | ListPlaylistsFilter, +): filter is { not: ListPlaylistsFilter } { + return filter.hasOwnProperty('not'); +} + +function parseFilter( + filter: ListPlaylistsFilter, + query: Knex.QueryBuilder, + db: Knex, + negate: boolean = false, +): Knex.QueryBuilder { + if (isMatchFilter(filter)) { + return query.andWhere(function filterFunction() { + if (filter.values.length === 1) { + this.where(filter.key, negate ? '!=' : '=', filter.values[0]); + } else { + this.andWhere(filter.key, negate ? 'not in' : 'in', filter.values); + } + }); + } + + if (isNegationFilter(filter)) { + return parseFilter(filter.not, query, db, !negate); + } + + return query[negate ? 'andWhereNot' : 'andWhere'](function filterFunction() { + if (isOrFilter(filter)) { + for (const subFilter of filter.anyOf ?? []) { + this.orWhere(subQuery => parseFilter(subFilter, subQuery, db)); + } + } else { + for (const subFilter of filter.allOf ?? []) { + this.andWhere(subQuery => parseFilter(subFilter, subQuery, db)); + } + } + }); +} + +type Options = { + database: Knex; +}; + +/** + * @public + */ +export class DatabaseHandler { + static async create(options: Options): Promise { + const { database } = options; + + await database.migrate.latest({ + directory: migrationsDir, + }); + + return new DatabaseHandler(options); + } + + private readonly database: Knex; + + private constructor(options: Options) { + this.database = options.database; + } + + private playlistColumns = ['id', 'name', 'description', 'owner', 'public']; + + async listPlaylists( + user: BackstageUserIdentity, + filter?: ListPlaylistsFilter, + ): Promise { + let playlistQuery = this.database>( + 'playlists', + ) + .select( + ...this.playlistColumns, + this.database.raw('COALESCE(entities, 0) AS entities'), + this.database.raw('COALESCE(followers, 0) AS followers'), + ) + .leftOuterJoin( + this.database('entities') + .as('e') + .select('playlist_id') + .count('entity_ref', { as: 'entities' }) + .groupBy('playlist_id'), + 'playlists.id', + 'e.playlist_id', + ) + .leftOuterJoin( + this.database('followers') + .as('f') + .select('playlist_id') + .count('user_ref', { as: 'followers' }) + .groupBy('playlist_id'), + 'playlists.id', + 'f.playlist_id', + ); + + if (filter) { + playlistQuery = parseFilter(filter, playlistQuery, this.database); + } + + const playlists = await playlistQuery; + + const followedPlaylists = ( + await this.database('followers') + .select('playlist_id') + .where('user_ref', user.userEntityRef) + ).map(follows => follows.playlist_id); + + return playlists.map(list => ({ + ...list, + entities: Number(list.entities), + followers: Number(list.followers), + public: Boolean(list.public), + isFollowing: followedPlaylists.includes(list.id), + })); + } + + async createPlaylist( + playlist: Omit, + ): Promise { + const id = uuid(); + const newPlaylist = await this.database('playlists').insert( + { + ...playlist, + id, + }, + ['id'], + ); + + // MySQL does not support returning from inserts so return generated uuid if undefined + return newPlaylist[0].id ?? id; + } + + async getPlaylist( + id: string, + user?: BackstageUserIdentity, + ): Promise { + const playlist = await this.database>( + 'playlists', + ) + .select( + ...this.playlistColumns, + this.database.raw('COALESCE(entities, 0) AS entities'), + this.database.raw('COALESCE(followers, 0) AS followers'), + ) + .where('id', id) + .leftOuterJoin( + this.database('entities') + .as('e') + .select('playlist_id') + .count('entity_ref', { as: 'entities' }) + .groupBy('playlist_id'), + 'playlists.id', + 'e.playlist_id', + ) + .leftOuterJoin( + this.database('followers') + .as('f') + .select('playlist_id') + .count('user_ref', { as: 'followers' }) + .groupBy('playlist_id'), + 'playlists.id', + 'f.playlist_id', + ); + + if (!playlist.length) { + return undefined; + } + + const followedPlaylists = user + ? ( + await this.database('followers') + .select('playlist_id') + .where('user_ref', user.userEntityRef) + ).map(follows => follows.playlist_id) + : []; + + return { + ...playlist[0], + entities: Number(playlist[0].entities), + followers: Number(playlist[0].followers), + public: Boolean(playlist[0].public), + isFollowing: followedPlaylists.includes(playlist[0].id), + }; + } + + async updatePlaylist(playlist: PlaylistMetadata) { + await this.database('playlists').where('id', playlist.id).update(playlist); + } + + async deletePlaylist(id: string) { + await this.database('playlists').where('id', id).del(); + } + + async addPlaylistEntities(playlistId: string, entityRefs: string[]) { + await this.database('entities') + .insert( + entityRefs.map(ref => ({ playlist_id: playlistId, entity_ref: ref })), + ) + .onConflict(['playlist_id', 'entity_ref']) + .ignore(); + } + + async getPlaylistEntities(playlistId: string): Promise { + return ( + await this.database('entities') + .select('entity_ref') + .where('playlist_id', playlistId) + ).map(entity => entity.entity_ref); + } + + async removePlaylistEntities(playlistId: string, entityRefs: string[]) { + await this.database('entities') + .where(builder => + entityRefs.forEach(ref => + builder.orWhere({ playlist_id: playlistId, entity_ref: ref }), + ), + ) + .del(); + } + + async followPlaylist(playlistId: string, user: BackstageUserIdentity) { + await this.database('followers') + .insert({ playlist_id: playlistId, user_ref: user.userEntityRef }) + .onConflict(['playlist_id', 'user_ref']) + .ignore(); + } + + async unfollowPlaylist(playlistId: string, user: BackstageUserIdentity) { + await this.database('followers') + .where({ playlist_id: playlistId, user_ref: user.userEntityRef }) + .del(); + } +} diff --git a/plugins/playlist-backend/src/service/ListPlaylistsFilter.test.ts b/plugins/playlist-backend/src/service/ListPlaylistsFilter.test.ts new file mode 100644 index 0000000000..64405fa6d0 --- /dev/null +++ b/plugins/playlist-backend/src/service/ListPlaylistsFilter.test.ts @@ -0,0 +1,95 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + parseListPlaylistsFilterParams, + parseListPlaylistsFilterString, +} from './ListPlaylistsFilter'; + +describe('parseListPlaylistsFilterParams', () => { + it('translates empty query to empty list', () => { + const result = parseListPlaylistsFilterParams({}); + expect(result).toEqual(undefined); + }); + + it('supports single-string format', () => { + const result = parseListPlaylistsFilterParams({ filter: 'a=1' })!; + expect(result).toEqual({ + anyOf: [{ allOf: [{ key: 'a', values: ['1'] }] }], + }); + }); + + it('supports array-of-strings format', () => { + const result = parseListPlaylistsFilterParams({ + filter: ['a=1', 'b=2'], + }); + expect(result).toEqual({ + anyOf: [ + { allOf: [{ key: 'a', values: ['1'] }] }, + { allOf: [{ key: 'b', values: ['2'] }] }, + ], + }); + }); + + it('merges values within each filter', () => { + const result = parseListPlaylistsFilterParams({ + filter: ['a=1', 'b=2,b=3,c=4'], + }); + expect(result).toEqual({ + anyOf: [ + { allOf: [{ key: 'a', values: ['1'] }] }, + { + allOf: [ + { key: 'b', values: ['2', '3'] }, + { key: 'c', values: ['4'] }, + ], + }, + ], + }); + }); + + it('throws for non-strings', () => { + expect(() => parseListPlaylistsFilterParams({ filter: [3] })).toThrow( + 'Invalid filter', + ); + }); +}); + +describe('parseListPlaylistsFilterString', () => { + it('works for the happy path', () => { + expect(parseListPlaylistsFilterString('')).toBeUndefined(); + expect(parseListPlaylistsFilterString('a=1,b=2,a=3')).toEqual([ + { key: 'a', values: ['1', '3'] }, + { key: 'b', values: ['2'] }, + ]); + }); + + it('trims values', () => { + expect(parseListPlaylistsFilterString(' a = 1 , b = 2 , a = 3 ')).toEqual([ + { key: 'a', values: ['1', '3'] }, + { key: 'b', values: ['2'] }, + ]); + }); + + it('rejects malformed strings', () => { + expect(() => parseListPlaylistsFilterString('x=2,=a')).toThrow( + "Invalid filter, '=a' is not a valid statement (expected a string of the form a=b)", + ); + expect(() => parseListPlaylistsFilterString('x=2,a=')).toThrow( + "Invalid filter, 'a=' is not a valid statement (expected a string of the form a=b)", + ); + }); +}); diff --git a/plugins/playlist-backend/src/service/ListPlaylistsFilter.ts b/plugins/playlist-backend/src/service/ListPlaylistsFilter.ts new file mode 100644 index 0000000000..bff14b2a58 --- /dev/null +++ b/plugins/playlist-backend/src/service/ListPlaylistsFilter.ts @@ -0,0 +1,101 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InputError } from '@backstage/errors'; + +// TODO(kuangp): this filter shape was basically plagiarized from catalog-backend, +// it should probably be abstracted into a common plugin module to allow others to reuse the same pattern + +/** + * @public + */ +export type ListPlaylistsMatchFilter = { + key: string; + values: any[]; +}; + +/** + * @public + */ +export type ListPlaylistsFilter = + | { allOf: ListPlaylistsFilter[] } + | { anyOf: ListPlaylistsFilter[] } + | { not: ListPlaylistsFilter } + | ListPlaylistsMatchFilter; + +export function parseListPlaylistsFilterParams( + params: Record, +): ListPlaylistsFilter | undefined { + if (!params.filter) { + return undefined; + } + + // Each filter string is on the form a=b,c=d + const filterStrings = [params.filter].flat(); + if (filterStrings.some(p => typeof p !== 'string')) { + throw new InputError('Invalid filter'); + } + + // Outer array: "any of the inner ones" + // Inner arrays: "all of these must match" + const filters = (filterStrings as string[]) + .map(parseListPlaylistsFilterString) + .filter(Boolean); + if (!filters.length) { + return undefined; + } + + return { anyOf: filters.map(f => ({ allOf: f! })) }; +} + +export function parseListPlaylistsFilterString( + filterString: string, +): ListPlaylistsMatchFilter[] | undefined { + const statements = filterString + .split(',') + .map(s => s.trim()) + .filter(Boolean); + + if (!statements.length) { + return undefined; + } + + const filtersByKey: Record = {}; + + for (const statement of statements) { + const equalsIndex = statement.indexOf('='); + + const key = + equalsIndex === -1 ? statement : statement.substr(0, equalsIndex).trim(); + const value = + equalsIndex === -1 ? undefined : statement.substr(equalsIndex + 1).trim(); + + if (!key || !value) { + throw new InputError( + `Invalid filter, '${statement}' is not a valid statement (expected a string of the form a=b)`, + ); + } + + const f = + key in filtersByKey + ? filtersByKey[key] + : (filtersByKey[key] = { key, values: [] }); + + f.values.push(value); + } + + return Object.values(filtersByKey); +} diff --git a/plugins/playlist-backend/src/service/index.ts b/plugins/playlist-backend/src/service/index.ts new file mode 100644 index 0000000000..3a1499f495 --- /dev/null +++ b/plugins/playlist-backend/src/service/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './router'; +export type { + ListPlaylistsFilter, + ListPlaylistsMatchFilter, +} from './ListPlaylistsFilter'; diff --git a/plugins/playlist-backend/src/service/router.test.ts b/plugins/playlist-backend/src/service/router.test.ts new file mode 100644 index 0000000000..06185acae8 --- /dev/null +++ b/plugins/playlist-backend/src/service/router.test.ts @@ -0,0 +1,511 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DatabaseManager, getVoidLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { IdentityClient } from '@backstage/plugin-auth-node'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { permissions } from '@backstage/plugin-playlist-common'; +import express from 'express'; +import request from 'supertest'; + +import { createRouter } from './router'; + +jest.mock('@backstage/plugin-auth-node', () => ({ + ...jest.requireActual('@backstage/plugin-auth-node'), + getBearerTokenFromAuthorizationHeader: () => 'token', +})); + +const mockConditionFilter = { key: 'test', values: ['test-val'] }; +jest.mock('../permissions', () => ({ + ...jest.requireActual('../permissions'), + transformConditions: () => mockConditionFilter, +})); + +const mockPlaylist = { + id: 'playlist-id', + name: 'test-playlist', + owner: 'group:default/owner', + public: true, + entities: 2, + followers: 4, + isFollowing: false, +}; + +const mockEntities = [ + 'component:default/test-ent', + 'system:default/test-ent-system', +]; + +const mockDbHandler = { + listPlaylists: jest.fn().mockImplementation(async () => [mockPlaylist]), + createPlaylist: jest.fn().mockImplementation(async () => 'playlist-id'), + getPlaylist: jest.fn().mockImplementation(async () => mockPlaylist), + updatePlaylist: jest.fn().mockImplementation(async () => {}), + deletePlaylist: jest.fn().mockImplementation(async () => {}), + addPlaylistEntities: jest.fn().mockImplementation(async () => {}), + getPlaylistEntities: jest.fn().mockImplementation(async () => mockEntities), + removePlaylistEntities: jest.fn().mockImplementation(async () => {}), + followPlaylist: jest.fn().mockImplementation(async () => {}), + unfollowPlaylist: jest.fn().mockImplementation(async () => {}), +}; + +jest.mock('./DatabaseHandler', () => ({ + DatabaseHandler: { create: async () => mockDbHandler }, +})); + +describe('createRouter', () => { + let app: express.Express; + + const createDatabase = () => + DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, + }), + ).forPlugin('playlist'); + + const mockedAuthorize = jest + .fn() + .mockImplementation(async () => [{ result: AuthorizeResult.ALLOW }]); + const mockedAuthorizeConditional = jest + .fn() + .mockImplementation(async () => [{ result: AuthorizeResult.ALLOW }]); + const mockPermissionEvaluator = { + authorize: mockedAuthorize, + authorizeConditional: mockedAuthorizeConditional, + }; + + const mockUser = { + type: 'user', + ownershipEntityRefs: ['user:default/me', 'group:default/owner'], + userEntityRef: 'user:default/me', + }; + const mockIdentityClient = { + authenticate: jest + .fn() + .mockImplementation(async () => ({ identity: mockUser })), + } as unknown as IdentityClient; + + beforeEach(async () => { + const router = await createRouter({ + database: createDatabase(), + identity: mockIdentityClient, + logger: getVoidLogger(), + permissions: mockPermissionEvaluator, + }); + + app = express().use(router); + jest.clearAllMocks(); + }); + + describe('GET /', () => { + const mockRequestFilter = { + anyOf: [ + { allOf: [{ key: 'mock', values: ['test'] }] }, + { allOf: [{ key: 'foo', values: ['bar'] }] }, + ], + }; + + it('should respond correctly if unauthorized', async () => { + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const response = await request(app).get('/').send(); + + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [{ permission: permissions.playlistListRead }], + { token: 'token' }, + ); + expect(mockDbHandler.listPlaylists).not.toHaveBeenCalled(); + expect(response.status).toEqual(403); + }); + + it('should get playlists correctly', async () => { + let response = await request(app).get('/').send(); + expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith( + mockUser, + undefined, + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual([mockPlaylist]); + + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.CONDITIONAL }, + ]); + response = await request(app).get('/').send(); + expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith( + mockUser, + mockConditionFilter, + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual([mockPlaylist]); + }); + + it('should get filtered playlists correctly', async () => { + let response = await request(app) + .get('/?filter=mock=test&filter=foo=bar') + .send(); + expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith( + mockUser, + mockRequestFilter, + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual([mockPlaylist]); + + mockedAuthorizeConditional.mockImplementationOnce(async () => [ + { result: AuthorizeResult.CONDITIONAL }, + ]); + response = await request(app) + .get('/?filter=mock=test&filter=foo=bar') + .send(); + expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith(mockUser, { + allOf: [mockRequestFilter, mockConditionFilter], + }); + expect(response.status).toEqual(200); + expect(response.body).toEqual([mockPlaylist]); + }); + + it('should get editable playlists correctly', async () => { + let response = await request(app).get('/?editable=true').send(); + expect(mockedAuthorizeConditional).toHaveBeenCalledWith( + [{ permission: permissions.playlistListUpdate }], + { token: 'token' }, + ); + expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith( + mockUser, + undefined, + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual([mockPlaylist]); + + response = await request(app) + .get('/?editable=true&filter=mock=test&filter=foo=bar') + .send(); + expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith( + mockUser, + mockRequestFilter, + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual([mockPlaylist]); + + mockedAuthorizeConditional.mockImplementation(async () => [ + { result: AuthorizeResult.CONDITIONAL }, + ]); + response = await request(app).get('/?editable=true').send(); + expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith(mockUser, { + allOf: [mockConditionFilter, mockConditionFilter], + }); + expect(response.status).toEqual(200); + expect(response.body).toEqual([mockPlaylist]); + + mockedAuthorizeConditional.mockImplementation(async () => [ + { result: AuthorizeResult.CONDITIONAL }, + ]); + response = await request(app) + .get('/?editable=true&filter=mock=test&filter=foo=bar') + .send(); + expect(mockDbHandler.listPlaylists).toHaveBeenLastCalledWith(mockUser, { + allOf: [ + { allOf: [mockRequestFilter, mockConditionFilter] }, + mockConditionFilter, + ], + }); + expect(response.status).toEqual(200); + expect(response.body).toEqual([mockPlaylist]); + }); + }); + + describe('POST /', () => { + const body = { name: 'new-playlist' }; + + it('should respond correctly if unauthorized', async () => { + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const response = await request(app).post('/').send(body); + + expect(mockedAuthorize).toHaveBeenCalledWith( + [{ permission: permissions.playlistListCreate }], + { token: 'token' }, + ); + expect(mockDbHandler.createPlaylist).not.toHaveBeenCalled(); + expect(response.status).toEqual(403); + }); + + it('should create a playlist correctly', async () => { + const response = await request(app).post('/').send(body); + expect(mockDbHandler.createPlaylist).toHaveBeenCalledWith(body); + expect(response.status).toEqual(201); + expect(response.body).toEqual('playlist-id'); + }); + }); + + describe('GET /:playlistId', () => { + it('should respond correctly if unauthorized', async () => { + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const response = await request(app).get('/playlist-id').send(); + + expect(mockedAuthorize).toHaveBeenCalledWith( + [ + { + permission: permissions.playlistListRead, + resourceRef: 'playlist-id', + }, + ], + { token: 'token' }, + ); + expect(mockDbHandler.getPlaylist).not.toHaveBeenCalled(); + expect(response.status).toEqual(403); + }); + + it('should get a playlist correctly', async () => { + const response = await request(app).get('/playlist-id').send(); + expect(mockDbHandler.getPlaylist).toHaveBeenCalledWith( + 'playlist-id', + mockUser, + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual(mockPlaylist); + }); + }); + + describe('PUT /:playlistId', () => { + it('should respond correctly if unauthorized', async () => { + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const response = await request(app) + .put('/playlist-id') + .send(mockPlaylist); + + expect(mockedAuthorize).toHaveBeenCalledWith( + [ + { + permission: permissions.playlistListUpdate, + resourceRef: 'playlist-id', + }, + ], + { token: 'token' }, + ); + expect(mockDbHandler.updatePlaylist).not.toHaveBeenCalled(); + expect(response.status).toEqual(403); + }); + + it('should update a playlist correctly', async () => { + const response = await request(app) + .put('/playlist-id') + .send(mockPlaylist); + expect(mockDbHandler.updatePlaylist).toHaveBeenCalledWith(mockPlaylist); + expect(response.status).toEqual(200); + }); + }); + + describe('DELETE /:playlistId', () => { + it('should respond correctly if unauthorized', async () => { + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const response = await request(app).delete('/playlist-id').send(); + + expect(mockedAuthorize).toHaveBeenCalledWith( + [ + { + permission: permissions.playlistListDelete, + resourceRef: 'playlist-id', + }, + ], + { token: 'token' }, + ); + expect(mockDbHandler.deletePlaylist).not.toHaveBeenCalled(); + expect(response.status).toEqual(403); + }); + + it('should delete a playlist correctly', async () => { + const response = await request(app).delete('/playlist-id').send(); + expect(mockDbHandler.deletePlaylist).toHaveBeenCalledWith('playlist-id'); + expect(response.status).toEqual(200); + }); + }); + + describe('POST /:playlistId/entities', () => { + it('should respond correctly if unauthorized', async () => { + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const response = await request(app) + .post('/playlist-id/entities') + .send(['component:default/test-ent']); + + expect(mockedAuthorize).toHaveBeenCalledWith( + [ + { + permission: permissions.playlistListUpdate, + resourceRef: 'playlist-id', + }, + ], + { token: 'token' }, + ); + expect(mockDbHandler.addPlaylistEntities).not.toHaveBeenCalled(); + expect(response.status).toEqual(403); + }); + + it('should add entities to a playlist correctly', async () => { + const response = await request(app) + .post('/playlist-id/entities') + .send(mockEntities); + expect(mockDbHandler.addPlaylistEntities).toHaveBeenCalledWith( + 'playlist-id', + mockEntities, + ); + expect(response.status).toEqual(200); + }); + }); + + describe('GET /:playlistId/entities', () => { + it('should respond correctly if unauthorized', async () => { + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const response = await request(app).get('/playlist-id/entities').send(); + + expect(mockedAuthorize).toHaveBeenCalledWith( + [ + { + permission: permissions.playlistListRead, + resourceRef: 'playlist-id', + }, + ], + { token: 'token' }, + ); + expect(mockDbHandler.getPlaylistEntities).not.toHaveBeenCalled(); + expect(response.status).toEqual(403); + }); + + it('should get entities from a playlist correctly', async () => { + const response = await request(app).get('/playlist-id/entities').send(); + expect(mockDbHandler.getPlaylistEntities).toHaveBeenCalledWith( + 'playlist-id', + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual(mockEntities); + }); + }); + + describe('DELETE /:playlistId/entities', () => { + it('should respond correctly if unauthorized', async () => { + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const response = await request(app) + .delete('/playlist-id/entities') + .send(mockEntities); + + expect(mockedAuthorize).toHaveBeenCalledWith( + [ + { + permission: permissions.playlistListUpdate, + resourceRef: 'playlist-id', + }, + ], + { token: 'token' }, + ); + expect(mockDbHandler.removePlaylistEntities).not.toHaveBeenCalled(); + expect(response.status).toEqual(403); + }); + + it('should delete entities from a playlist correctly', async () => { + const response = await request(app) + .delete('/playlist-id/entities') + .send(mockEntities); + expect(mockDbHandler.removePlaylistEntities).toHaveBeenCalledWith( + 'playlist-id', + mockEntities, + ); + expect(response.status).toEqual(200); + }); + }); + + describe('POST /:playlistId/followers', () => { + it('should respond correctly if unauthorized', async () => { + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const response = await request(app).post('/playlist-id/followers').send(); + + expect(mockedAuthorize).toHaveBeenCalledWith( + [ + { + permission: permissions.playlistFollowersUpdate, + resourceRef: 'playlist-id', + }, + ], + { token: 'token' }, + ); + expect(mockDbHandler.followPlaylist).not.toHaveBeenCalled(); + expect(response.status).toEqual(403); + }); + + it('should follow a playlist correctly', async () => { + const response = await request(app).post('/playlist-id/followers').send(); + expect(mockDbHandler.followPlaylist).toHaveBeenCalledWith( + 'playlist-id', + mockUser, + ); + expect(response.status).toEqual(200); + }); + }); + + describe('DELETE /:playlistId/followers', () => { + it('should respond correctly if unauthorized', async () => { + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + const response = await request(app) + .delete('/playlist-id/followers') + .send(); + + expect(mockedAuthorize).toHaveBeenCalledWith( + [ + { + permission: permissions.playlistFollowersUpdate, + resourceRef: 'playlist-id', + }, + ], + { token: 'token' }, + ); + expect(mockDbHandler.unfollowPlaylist).not.toHaveBeenCalled(); + expect(response.status).toEqual(403); + }); + + it('should unfollow a playlist correctly', async () => { + const response = await request(app) + .delete('/playlist-id/followers') + .send(); + expect(mockDbHandler.unfollowPlaylist).toHaveBeenCalledWith( + 'playlist-id', + mockUser, + ); + expect(response.status).toEqual(200); + }); + }); +}); diff --git a/plugins/playlist-backend/src/service/router.ts b/plugins/playlist-backend/src/service/router.ts new file mode 100644 index 0000000000..7ca41c0ac1 --- /dev/null +++ b/plugins/playlist-backend/src/service/router.ts @@ -0,0 +1,232 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { errorHandler, PluginDatabaseManager } from '@backstage/backend-common'; +import { NotAllowedError } from '@backstage/errors'; +import { + getBearerTokenFromAuthorizationHeader, + IdentityClient, +} from '@backstage/plugin-auth-node'; +import { + AuthorizePermissionRequest, + AuthorizeResult, + PermissionEvaluator, + QueryPermissionRequest, +} from '@backstage/plugin-permission-common'; +import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; +import { + PLAYLIST_LIST_RESOURCE_TYPE, + permissions, +} from '@backstage/plugin-playlist-common'; +import express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; + +import { rules, transformConditions } from '../permissions'; +import { DatabaseHandler } from './DatabaseHandler'; +import { parseListPlaylistsFilterParams } from './ListPlaylistsFilter'; + +/** + * @public + */ +export interface RouterOptions { + database: PluginDatabaseManager; + identity: IdentityClient; + logger: Logger; + permissions: PermissionEvaluator; +} + +/** + * @public + */ +export async function createRouter( + options: RouterOptions, +): Promise { + const { + database, + identity, + logger, + permissions: permissionEvaluator, + } = options; + + logger.info('Initializing Playlist backend'); + + const db = await database.getClient(); + const dbHandler = await DatabaseHandler.create({ database: db }); + + const evaluateRequestPermission = async ( + req: express.Request, + permission: AuthorizePermissionRequest | QueryPermissionRequest, + conditional: boolean = false, + ) => { + const token = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ); + + const user = await identity.authenticate(token); + + const decision = conditional + ? ( + await permissionEvaluator.authorizeConditional( + [permission as QueryPermissionRequest], + { token }, + ) + )[0] + : ( + await permissionEvaluator.authorize( + [permission as AuthorizePermissionRequest], + { token }, + ) + )[0]; + + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError('Unauthorized'); + } + + return { decision, user: user.identity }; + }; + + const permissionIntegrationRouter = createPermissionIntegrationRouter({ + getResources: resourceRefs => + Promise.all(resourceRefs.map(ref => dbHandler.getPlaylist(ref))), + resourceType: PLAYLIST_LIST_RESOURCE_TYPE, + rules: Object.values(rules), + }); + + const router = Router(); + router.use(express.json()); + router.use(permissionIntegrationRouter); + + router.get('/', async (req, res) => { + const { decision, user } = await evaluateRequestPermission( + req, + { permission: permissions.playlistListRead }, + true, + ); + + let filter = parseListPlaylistsFilterParams(req.query); + if (decision.result === AuthorizeResult.CONDITIONAL) { + const conditionsFilter = transformConditions(decision.conditions); + filter = filter + ? { allOf: [filter, conditionsFilter] } + : conditionsFilter; + } + + if (req.query.editable) { + const { decision: updatePermissionDecision } = + await evaluateRequestPermission( + req, + { permission: permissions.playlistListUpdate }, + true, + ); + + if (updatePermissionDecision.result === AuthorizeResult.CONDITIONAL) { + const updateConditionsFilter = transformConditions( + updatePermissionDecision.conditions, + ); + filter = filter + ? { allOf: [filter, updateConditionsFilter] } + : updateConditionsFilter; + } + } + + const playlists = await dbHandler.listPlaylists(user, filter); + res.json(playlists); + }); + + router.post('/', async (req, res) => { + await evaluateRequestPermission(req, { + permission: permissions.playlistListCreate, + }); + const playlistId = await dbHandler.createPlaylist(req.body); + res.status(201).json(playlistId); + }); + + router.get('/:playlistId', async (req, res) => { + const { user } = await evaluateRequestPermission(req, { + permission: permissions.playlistListRead, + resourceRef: req.params.playlistId, + }); + const playlist = await dbHandler.getPlaylist(req.params.playlistId, user); + res.json(playlist); + }); + + router.put('/:playlistId', async (req, res) => { + await evaluateRequestPermission(req, { + permission: permissions.playlistListUpdate, + resourceRef: req.params.playlistId, + }); + await dbHandler.updatePlaylist({ ...req.body, id: req.params.playlistId }); + res.status(200).end(); + }); + + router.delete('/:playlistId', async (req, res) => { + await evaluateRequestPermission(req, { + permission: permissions.playlistListDelete, + resourceRef: req.params.playlistId, + }); + await dbHandler.deletePlaylist(req.params.playlistId); + res.status(200).end(); + }); + + router.post('/:playlistId/entities', async (req, res) => { + await evaluateRequestPermission(req, { + permission: permissions.playlistListUpdate, + resourceRef: req.params.playlistId, + }); + await dbHandler.addPlaylistEntities(req.params.playlistId, req.body); + res.status(200).end(); + }); + + router.get('/:playlistId/entities', async (req, res) => { + await evaluateRequestPermission(req, { + permission: permissions.playlistListRead, + resourceRef: req.params.playlistId, + }); + const entities = await dbHandler.getPlaylistEntities(req.params.playlistId); + res.json(entities); + }); + + router.delete('/:playlistId/entities', async (req, res) => { + await evaluateRequestPermission(req, { + permission: permissions.playlistListUpdate, + resourceRef: req.params.playlistId, + }); + await dbHandler.removePlaylistEntities(req.params.playlistId, req.body); + res.status(200).end(); + }); + + router.post('/:playlistId/followers', async (req, res) => { + const { user } = await evaluateRequestPermission(req, { + permission: permissions.playlistFollowersUpdate, + resourceRef: req.params.playlistId, + }); + await dbHandler.followPlaylist(req.params.playlistId, user); + res.status(200).end(); + }); + + router.delete('/:playlistId/followers', async (req, res) => { + const { user } = await evaluateRequestPermission(req, { + permission: permissions.playlistFollowersUpdate, + resourceRef: req.params.playlistId, + }); + await dbHandler.unfollowPlaylist(req.params.playlistId, user); + res.status(200).end(); + }); + + router.use(errorHandler()); + return router; +} diff --git a/plugins/playlist-backend/src/service/standaloneServer.ts b/plugins/playlist-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..46d6f7ad8d --- /dev/null +++ b/plugins/playlist-backend/src/service/standaloneServer.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createServiceBuilder, + DatabaseManager, + loadBackendConfig, + ServerTokenManager, + SingleHostDiscovery, + useHotMemoize, +} from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { IdentityClient } from '@backstage/plugin-auth-node'; +import { ServerPermissionClient } from '@backstage/plugin-permission-node'; +import { Server } from 'http'; +import { Logger } from 'winston'; +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: 'playlist-backend' }); + const config = await loadBackendConfig({ logger, argv: process.argv }); + const discovery = SingleHostDiscovery.fromConfig(config); + + const database = useHotMemoize(module, () => { + const manager = DatabaseManager.fromConfig( + new ConfigReader({ + backend: { + database: { client: 'better-sqlite3', connection: ':memory:' }, + }, + }), + ); + return manager.forPlugin('playlist'); + }); + + const identity = IdentityClient.create({ + discovery, + issuer: await discovery.getExternalBaseUrl('auth'), + }); + + const tokenManager = ServerTokenManager.fromConfig(config, { + logger, + }); + const permissions = ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager, + }); + + logger.debug('Starting application server...'); + const router = await createRouter({ + database, + identity, + logger, + permissions, + }); + + let service = createServiceBuilder(module) + .setPort(options.port) + .addRouter('/playlist', router); + if (options.enableCors) { + service = service.enableCors({ origin: 'http://localhost:3000' }); + } + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/plugins/playlist-backend/src/setupTests.ts b/plugins/playlist-backend/src/setupTests.ts new file mode 100644 index 0000000000..813cdeaae3 --- /dev/null +++ b/plugins/playlist-backend/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export {}; diff --git a/plugins/playlist-common/.eslintrc.js b/plugins/playlist-common/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/playlist-common/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/playlist-common/README.md b/plugins/playlist-common/README.md new file mode 100644 index 0000000000..72f502386c --- /dev/null +++ b/plugins/playlist-common/README.md @@ -0,0 +1,3 @@ +# Playlist Common + +Common functionalities, types, and permissions for the playlist plugin. diff --git a/plugins/playlist-common/api-report.md b/plugins/playlist-common/api-report.md new file mode 100644 index 0000000000..e97cb3dc64 --- /dev/null +++ b/plugins/playlist-common/api-report.md @@ -0,0 +1,36 @@ +## API Report File for "@backstage/plugin-playlist-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BasicPermission } from '@backstage/plugin-permission-common'; +import { ResourcePermission } from '@backstage/plugin-permission-common'; + +// @public (undocumented) +export const permissions: { + playlistListCreate: BasicPermission; + playlistListRead: ResourcePermission<'playlist-list'>; + playlistListUpdate: ResourcePermission<'playlist-list'>; + playlistListDelete: ResourcePermission<'playlist-list'>; + playlistFollowersUpdate: ResourcePermission<'playlist-list'>; +}; + +// @public (undocumented) +export type Playlist = PlaylistMetadata & { + entities: number; + followers: number; + isFollowing: boolean; +}; + +// @public (undocumented) +export const PLAYLIST_LIST_RESOURCE_TYPE = 'playlist-list'; + +// @public (undocumented) +export type PlaylistMetadata = { + id: string; + name: string; + description?: string; + owner: string; + public: boolean; +}; +``` diff --git a/plugins/playlist-common/package.json b/plugins/playlist-common/package.json new file mode 100644 index 0000000000..67a97b1237 --- /dev/null +++ b/plugins/playlist-common/package.json @@ -0,0 +1,34 @@ +{ + "name": "@backstage/plugin-playlist-common", + "description": "Common functionalities for the playlist plugin", + "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": "common-library" + }, + "scripts": { + "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" + }, + "dependencies": { + "@backstage/plugin-permission-common": "^0.6.4-next.1" + }, + "devDependencies": { + "@backstage/cli": "^0.19.0-next.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/playlist-common/src/index.ts b/plugins/playlist-common/src/index.ts new file mode 100644 index 0000000000..92cf1eb461 --- /dev/null +++ b/plugins/playlist-common/src/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Common functionalities for the playlist plugin. + * + * @packageDocumentation + */ + +export * from './types'; +export * from './permissions'; diff --git a/plugins/playlist-common/src/permissions.ts b/plugins/playlist-common/src/permissions.ts new file mode 100644 index 0000000000..89d526127e --- /dev/null +++ b/plugins/playlist-common/src/permissions.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createPermission } from '@backstage/plugin-permission-common'; + +/** + * @public + */ +export const PLAYLIST_LIST_RESOURCE_TYPE = 'playlist-list'; + +const playlistListCreate = createPermission({ + name: 'playlist.list.create', + attributes: { action: 'create' }, +}); + +const playlistListRead = createPermission({ + name: 'playlist.list.read', + attributes: { action: 'read' }, + resourceType: PLAYLIST_LIST_RESOURCE_TYPE, +}); + +const playlistListUpdate = createPermission({ + name: 'playlist.list.update', + attributes: { action: 'update' }, + resourceType: PLAYLIST_LIST_RESOURCE_TYPE, +}); + +const playlistListDelete = createPermission({ + name: 'playlist.list.delete', + attributes: { action: 'delete' }, + resourceType: PLAYLIST_LIST_RESOURCE_TYPE, +}); + +const playlistFollowersUpdate = createPermission({ + name: 'playlist.followers.update', + attributes: { action: 'update' }, + resourceType: PLAYLIST_LIST_RESOURCE_TYPE, +}); + +/** + * @public + */ +export const permissions = { + playlistListCreate, + playlistListRead, + playlistListUpdate, + playlistListDelete, + playlistFollowersUpdate, +}; diff --git a/plugins/playlist-common/src/setupTests.ts b/plugins/playlist-common/src/setupTests.ts new file mode 100644 index 0000000000..8b9b6bd586 --- /dev/null +++ b/plugins/playlist-common/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export {}; diff --git a/plugins/playlist-common/src/types.ts b/plugins/playlist-common/src/types.ts new file mode 100644 index 0000000000..667cd35ece --- /dev/null +++ b/plugins/playlist-common/src/types.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @public + */ +export type PlaylistMetadata = { + id: string; + name: string; + description?: string; + owner: string; + public: boolean; +}; + +/** + * @public + */ +export type Playlist = PlaylistMetadata & { + entities: number; + followers: number; + isFollowing: boolean; +}; diff --git a/plugins/playlist/.eslintrc.js b/plugins/playlist/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/playlist/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/playlist/README.md b/plugins/playlist/README.md new file mode 100644 index 0000000000..7c814d302b --- /dev/null +++ b/plugins/playlist/README.md @@ -0,0 +1,128 @@ +# Playlist Plugin + +Welcome to the playlist plugin! + +This plugin allows you to create, share, and follow custom collections of entities available in the Backstage catalog. + +## Setup + +Install this plugin: + +```bash +# From your Backstage root directory +yarn --cwd packages/app add @backstage/plugin-playlist +``` + +### Add the plugin to your `packages/app` + +Add the root page that the playlist plugin provides to your app. You can +choose any path for the route, but we recommend the following: + +```diff +// packages/app/src/App.tsx ++import { PlaylistIndexPage } from '@backstage/plugin-playlist'; + + + + } /> + }> + {entityPage} + ++ } /> + ... + +``` + +You may also want to add a link to the playlist page to your application sidebar: + +```diff +// packages/app/src/components/Root/Root.tsx ++import PlaylistPlayIcon from '@material-ui/icons/PlaylistPlay'; + +export const Root = ({ children }: PropsWithChildren<{}>) => ( + + ++ + ... + +``` + +### Entity Pages + +You can also make the following changes to add the playlist context menu to your `EntityPage.tsx` +to be able to add entities to playlists directly from your entity pages: + +First we need to add the following imports: + +```ts +import { EntityPlaylistDialog } from '@backstage/plugin-playlist'; +import PlaylistAddIcon from '@material-ui/icons/PlaylistAdd'; +``` + +Next we'll update the React import that looks like this: + +```ts +import React from 'react'; +``` + +To look like this: + +```ts +import React, { ReactNode, useMemo, useState } from 'react'; +``` + +Then we have to add this chunk of code after all the imports but before any of the other code: + +```ts +const EntityLayoutWrapper = (props: { children?: ReactNode }) => { + const [playlistDialogOpen, setPlaylistDialogOpen] = useState(false); + + const extraMenuItems = useMemo(() => { + return [ + { + title: 'Add to playlist', + Icon: PlaylistAddIcon, + onClick: () => setPlaylistDialogOpen(true), + }, + ]; + }, []); + + return ( + <> + + {props.children} + + setPlaylistDialogOpen(false)} + /> + + ); +}; +``` + +The last step is to wrap all the entity pages in the `EntityLayoutWrapper` like this: + +```diff +const defaultEntityPage = ( ++ + + {overviewContent} + + + + + + + + + ++ +); +``` + +Note: the above only shows an example for the `defaultEntityPage` for a full example of this you can look at [this EntityPage](../../packages/app/src/components/catalog/EntityPage.tsx) + +## Links + +- [playlist-backend](../playlist-backend) provides the backend API for this frontend. diff --git a/plugins/playlist/api-report.md b/plugins/playlist/api-report.md new file mode 100644 index 0000000000..ddfec2948d --- /dev/null +++ b/plugins/playlist/api-report.md @@ -0,0 +1,106 @@ +## API Report File for "@backstage/plugin-playlist" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +/// + +import { ApiRef } from '@backstage/core-plugin-api'; +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { FetchApi } from '@backstage/core-plugin-api'; +import { Playlist } from '@backstage/plugin-playlist-common'; +import { PlaylistMetadata } from '@backstage/plugin-playlist-common'; +import { RouteRef } from '@backstage/core-plugin-api'; + +// @public (undocumented) +export const EntityPlaylistDialog: ({ + open, + onClose, +}: EntityPlaylistDialogProps) => JSX.Element; + +// @public (undocumented) +export type EntityPlaylistDialogProps = { + open: boolean; + onClose: () => void; +}; + +// @public (undocumented) +export interface GetAllPlaylistsRequest { + // (undocumented) + editable?: boolean; + filter?: + | Record[] + | Record; +} + +// @public (undocumented) +export interface PlaylistApi { + // (undocumented) + addPlaylistEntities(playlistId: string, entityRefs: string[]): Promise; + // (undocumented) + createPlaylist(playlist: Omit): Promise; + // (undocumented) + deletePlaylist(playlistId: string): Promise; + // (undocumented) + followPlaylist(playlistId: string): Promise; + // (undocumented) + getAllPlaylists(req: GetAllPlaylistsRequest): Promise; + // (undocumented) + getPlaylist(playlistId: string): Promise; + // (undocumented) + getPlaylistEntities(playlistId: string): Promise; + // (undocumented) + removePlaylistEntities( + playlistId: string, + entityRefs: string[], + ): Promise; + // (undocumented) + unfollowPlaylist(playlistId: string): Promise; + // (undocumented) + updatePlaylist(playlist: PlaylistMetadata): Promise; +} + +// @public (undocumented) +export const playlistApiRef: ApiRef; + +// @public (undocumented) +export class PlaylistClient implements PlaylistApi { + constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi }); + // (undocumented) + addPlaylistEntities(playlistId: string, entityRefs: string[]): Promise; + // (undocumented) + createPlaylist(playlist: Omit): Promise; + // (undocumented) + deletePlaylist(playlistId: string): Promise; + // (undocumented) + followPlaylist(playlistId: string): Promise; + // (undocumented) + getAllPlaylists(req?: GetAllPlaylistsRequest): Promise; + // (undocumented) + getPlaylist(playlistId: string): Promise; + // (undocumented) + getPlaylistEntities(playlistId: string): Promise; + // (undocumented) + removePlaylistEntities( + playlistId: string, + entityRefs: string[], + ): Promise; + // (undocumented) + unfollowPlaylist(playlistId: string): Promise; + // (undocumented) + updatePlaylist(playlist: PlaylistMetadata): Promise; +} + +// @public (undocumented) +export const PlaylistIndexPage: () => JSX.Element; + +// @public (undocumented) +export const playlistPlugin: BackstagePlugin< + { + root: RouteRef; + }, + {}, + {} +>; +``` diff --git a/packages/core-components/src/layout/HeaderActionMenu/VerticalMenuIcon.tsx b/plugins/playlist/dev/index.tsx similarity index 58% rename from packages/core-components/src/layout/HeaderActionMenu/VerticalMenuIcon.tsx rename to plugins/playlist/dev/index.tsx index a6a1d3a3d7..c9c626ab38 100644 --- a/packages/core-components/src/layout/HeaderActionMenu/VerticalMenuIcon.tsx +++ b/plugins/playlist/dev/index.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Backstage Authors + * Copyright 2022 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,13 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import React from 'react'; -import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon'; +import { createDevApp } from '@backstage/dev-utils'; +import { playlistPlugin, PlaylistIndexPage } from '../src/plugin'; -export const VerticalMenuIcon = (props: SvgIconProps) => - React.createElement( - SvgIcon, - props, - , - ); +createDevApp() + .registerPlugin(playlistPlugin) + .addPage({ + element: , + title: 'Root Page', + path: '/playlist', + }) + .render(); diff --git a/plugins/playlist/package.json b/plugins/playlist/package.json new file mode 100644 index 0000000000..51cffcca9f --- /dev/null +++ b/plugins/playlist/package.json @@ -0,0 +1,67 @@ +{ + "name": "@backstage/plugin-playlist", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-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" + }, + "dependencies": { + "@backstage/catalog-model": "^1.1.0", + "@backstage/core-components": "^0.11.1-next.2", + "@backstage/core-plugin-api": "^1.0.6-next.2", + "@backstage/errors": "^1.1.0", + "@backstage/plugin-catalog-common": "^1.0.6-next.0", + "@backstage/plugin-catalog-react": "^1.1.4-next.1", + "@backstage/plugin-permission-common": "^0.6.4-next.1", + "@backstage/plugin-permission-react": "^0.4.5-next.1", + "@backstage/plugin-playlist-common": "^0.0.0", + "@backstage/plugin-search-react": "^1.1.0-next.2", + "@backstage/theme": "^0.2.16", + "@material-ui/core": "^4.9.13", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "^4.0.0-alpha.57", + "lodash": "^4.17.21", + "qs": "^6.9.4", + "react-hook-form": "^7.13.0", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0", + "react-router": "6.0.0-beta.0 || ^6.3.0", + "react-router-dom": "6.0.0-beta.0 || ^6.3.0" + }, + "devDependencies": { + "@backstage/cli": "^0.19.0-next.2", + "@backstage/core-app-api": "^1.1.0-next.2", + "@backstage/dev-utils": "^1.0.6-next.1", + "@backstage/test-utils": "^1.2.0-next.2", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^12.1.3", + "@testing-library/react-hooks": "^8.0.0", + "@testing-library/user-event": "^14.0.0", + "@types/jest": "*", + "@types/node": "*", + "cross-fetch": "^3.1.5", + "msw": "^0.47.0", + "swr": "^1.1.2" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/playlist/src/api/PlaylistApi.ts b/plugins/playlist/src/api/PlaylistApi.ts new file mode 100644 index 0000000000..d0b87ec28a --- /dev/null +++ b/plugins/playlist/src/api/PlaylistApi.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createApiRef } from '@backstage/core-plugin-api'; +import { Playlist, PlaylistMetadata } from '@backstage/plugin-playlist-common'; + +/** + * @public + */ +export const playlistApiRef = createApiRef({ + id: 'plugin.playlist.service', +}); + +/** + * @public + */ +export interface GetAllPlaylistsRequest { + /** + * If multiple filter sets are given as an array, then there is effectively an + * OR between each filter set. + * + * Within one filter set, there is effectively an AND between the various + * keys. + * + * Within one key, if there are more than one value, then there is effectively + * an OR between them. + */ + filter?: + | Record[] + | Record; + + // If true, will filter results that satisfies the playlist.list.update permission + editable?: boolean; +} + +/** + * @public + */ +export interface PlaylistApi { + getAllPlaylists(req: GetAllPlaylistsRequest): Promise; + + createPlaylist(playlist: Omit): Promise; + + getPlaylist(playlistId: string): Promise; + + updatePlaylist(playlist: PlaylistMetadata): Promise; + + deletePlaylist(playlistId: string): Promise; + + addPlaylistEntities(playlistId: string, entityRefs: string[]): Promise; + + getPlaylistEntities(playlistId: string): Promise; + + removePlaylistEntities( + playlistId: string, + entityRefs: string[], + ): Promise; + + followPlaylist(playlistId: string): Promise; + + unfollowPlaylist(playlistId: string): Promise; +} diff --git a/plugins/playlist/src/api/PlaylistClient.test.ts b/plugins/playlist/src/api/PlaylistClient.test.ts new file mode 100644 index 0000000000..f3b62c19ac --- /dev/null +++ b/plugins/playlist/src/api/PlaylistClient.test.ts @@ -0,0 +1,272 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; + +import { PlaylistClient } from './PlaylistClient'; + +const server = setupServer(); + +describe('PlaylistClient', () => { + setupRequestMockHandlers(server); + const mockBaseUrl = 'http://backstage/api/playlist'; + const discoveryApi = { getBaseUrl: async () => mockBaseUrl }; + const fetchApi = new MockFetchApi(); + + let client: PlaylistClient; + beforeEach(() => { + client = new PlaylistClient({ discoveryApi, fetchApi }); + }); + + describe('getAllPlaylists', () => { + const expectedResp = [ + { + id: 'id', + name: 'name', + description: 'description', + owner: 'owner', + public: true, + entities: 1, + followers: 2, + isFollowing: true, + }, + ]; + + it('should fetch playlists from correct endpoint', async () => { + server.use( + rest.get(`${mockBaseUrl}/`, (_, res, ctx) => + res(ctx.json(expectedResp)), + ), + ); + const response = await client.getAllPlaylists(); + expect(response).toEqual(expectedResp); + }); + + it('should fetch editable playlists correctly', async () => { + expect.assertions(2); + + server.use( + rest.get(`${mockBaseUrl}/`, (req, res, ctx) => { + expect(req.url.search).toBe('?editable=true'); + return res(ctx.json(expectedResp)); + }), + ); + + const response = await client.getAllPlaylists({ editable: true }); + expect(response).toEqual(expectedResp); + }); + + it('builds multiple search filters properly', async () => { + expect.assertions(2); + + server.use( + rest.get(`${mockBaseUrl}/`, (req, res, ctx) => { + expect(req.url.search).toBe( + '?filter=a=1,b=2,b=3,%C3%B6=%3D&filter=a=2&editable=true', + ); + return res(ctx.json(expectedResp)); + }), + ); + + const response = await client.getAllPlaylists({ + editable: true, + filter: [ + { + a: '1', + b: ['2', '3'], + ö: '=', + }, + { + a: '2', + }, + ], + }); + + expect(response).toEqual(expectedResp); + }); + + it('builds single search filter properly', async () => { + expect.assertions(2); + + server.use( + rest.get(`${mockBaseUrl}/`, (req, res, ctx) => { + expect(req.url.search).toBe('?filter=a=1,b=2,b=3,%C3%B6=%3D'); + return res(ctx.json(expectedResp)); + }), + ); + + const response = await client.getAllPlaylists({ + filter: { + a: '1', + b: ['2', '3'], + ö: '=', + }, + }); + + expect(response).toEqual(expectedResp); + }); + }); + + it('createPlaylist', async () => { + expect.assertions(2); + + const newPlaylist = { + name: 'name', + description: 'description', + owner: 'owner', + public: true, + }; + + server.use( + rest.post(`${mockBaseUrl}/`, (req, res, ctx) => { + expect(req.body).toEqual(newPlaylist); + return res(ctx.json('123')); + }), + ); + + const response = await client.createPlaylist(newPlaylist); + + expect(response).toEqual('123'); + }); + + it('getPlaylist', async () => { + const playlist = { + id: '123', + name: 'name', + description: 'description', + owner: 'owner', + public: true, + entities: 1, + followers: 2, + isFollowing: true, + }; + + server.use( + rest.get(`${mockBaseUrl}/123`, (_, res, ctx) => res(ctx.json(playlist))), + ); + + const response = await client.getPlaylist('123'); + expect(response).toEqual(playlist); + }); + + it('updatePlaylist', async () => { + expect.assertions(1); + + const playlist = { + id: 'id', + name: 'name', + description: 'description', + owner: 'owner', + public: true, + }; + + server.use( + rest.put(`${mockBaseUrl}/id`, (req, res) => { + expect(req.body).toEqual(playlist); + return res(); + }), + ); + + await client.updatePlaylist(playlist); + }); + + it('deletePlaylist', async () => { + expect.assertions(1); + + server.use( + rest.delete(`${mockBaseUrl}/id`, (_, res) => { + // eslint requires at least 1 assertion so this is here is verify this handler is called + expect(true).toBe(true); + return res(); + }), + ); + + await client.deletePlaylist('id'); + }); + + it('addPlaylistEntities', async () => { + expect.assertions(1); + + const entities = ['component:default/ent1', 'component:default/ent2']; + + server.use( + rest.post(`${mockBaseUrl}/id/entities`, (req, res) => { + expect(req.body).toEqual(entities); + return res(); + }), + ); + + await client.addPlaylistEntities('id', entities); + }); + + it('getPlaylistEntities', async () => { + const entities = ['component:default/ent1', 'component:default/ent2']; + + server.use( + rest.get(`${mockBaseUrl}/id/entities`, (_, res, ctx) => + res(ctx.json(entities)), + ), + ); + + const response = await client.getPlaylistEntities('id'); + expect(response).toEqual(entities); + }); + + it('removePlaylistEntities', async () => { + expect.assertions(1); + + const entities = ['component:default/ent1', 'component:default/ent2']; + + server.use( + rest.delete(`${mockBaseUrl}/id/entities`, (req, res) => { + expect(req.body).toEqual(entities); + return res(); + }), + ); + + await client.removePlaylistEntities('id', entities); + }); + + it('followPlaylist', async () => { + expect.assertions(1); + + server.use( + rest.post(`${mockBaseUrl}/id/followers`, (_, res) => { + // eslint requires at least 1 assertion so this is here is verify this handler is called + expect(true).toBe(true); + return res(); + }), + ); + + await client.followPlaylist('id'); + }); + + it('unfollowPlaylist', async () => { + expect.assertions(1); + + server.use( + rest.delete(`${mockBaseUrl}/id/followers`, (_, res) => { + // eslint requires at least 1 assertion so this is here is verify this handler is called + expect(true).toBe(true); + return res(); + }), + ); + + await client.unfollowPlaylist('id'); + }); +}); diff --git a/plugins/playlist/src/api/PlaylistClient.ts b/plugins/playlist/src/api/PlaylistClient.ts new file mode 100644 index 0000000000..b77aa79733 --- /dev/null +++ b/plugins/playlist/src/api/PlaylistClient.ts @@ -0,0 +1,198 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; +import { ResponseError } from '@backstage/errors'; +import { Playlist, PlaylistMetadata } from '@backstage/plugin-playlist-common'; + +import { GetAllPlaylistsRequest, PlaylistApi } from './PlaylistApi'; + +/** + * @public + */ +export class PlaylistClient implements PlaylistApi { + private readonly discoveryApi: DiscoveryApi; + private readonly fetchApi: FetchApi; + + constructor(options: { discoveryApi: DiscoveryApi; fetchApi: FetchApi }) { + this.discoveryApi = options.discoveryApi; + this.fetchApi = options.fetchApi; + } + + async getAllPlaylists(req: GetAllPlaylistsRequest = {}): Promise { + const { filter = [], editable } = req; + const params: string[] = []; + + // the "outer array" defined by `filter` occurrences corresponds to "anyOf" filters + // the "inner array" defined within a `filter` param corresponds to "allOf" filters + for (const filterItem of [filter].flat()) { + const filterParts: string[] = []; + for (const [key, value] of Object.entries(filterItem)) { + for (const v of [value].flat()) { + if (typeof v === 'string') { + filterParts.push( + `${encodeURIComponent(key)}=${encodeURIComponent(v)}`, + ); + } + } + } + + if (filterParts.length) { + params.push(`filter=${filterParts.join(',')}`); + } + } + + if (editable) { + params.push('editable=true'); + } + + const query = params.length ? `?${params.join('&')}` : ''; + const baseUrl = await this.discoveryApi.getBaseUrl('playlist'); + const resp = await this.fetchApi.fetch(`${baseUrl}/${query}`, { + method: 'GET', + }); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + + return await resp.json(); + } + + async createPlaylist( + playlist: Omit, + ): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('playlist'); + const resp = await this.fetchApi.fetch(`${baseUrl}/`, { + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + body: JSON.stringify(playlist), + }); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + + return await resp.json(); + } + + async getPlaylist(playlistId: string): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('playlist'); + const resp = await this.fetchApi.fetch(`${baseUrl}/${playlistId}`, { + method: 'GET', + }); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + + return await resp.json(); + } + + async updatePlaylist(playlist: PlaylistMetadata) { + const baseUrl = await this.discoveryApi.getBaseUrl('playlist'); + const resp = await this.fetchApi.fetch(`${baseUrl}/${playlist.id}`, { + headers: { 'Content-Type': 'application/json' }, + method: 'PUT', + body: JSON.stringify(playlist), + }); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + } + + async deletePlaylist(playlistId: string) { + const baseUrl = await this.discoveryApi.getBaseUrl('playlist'); + const resp = await this.fetchApi.fetch(`${baseUrl}/${playlistId}`, { + method: 'DELETE', + }); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + } + + async addPlaylistEntities(playlistId: string, entityRefs: string[]) { + const baseUrl = await this.discoveryApi.getBaseUrl('playlist'); + const resp = await this.fetchApi.fetch( + `${baseUrl}/${playlistId}/entities`, + { + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + body: JSON.stringify(entityRefs), + }, + ); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + } + + async getPlaylistEntities(playlistId: string): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('playlist'); + const resp = await this.fetchApi.fetch( + `${baseUrl}/${playlistId}/entities`, + { method: 'GET' }, + ); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + + return await resp.json(); + } + + async removePlaylistEntities(playlistId: string, entityRefs: string[]) { + const baseUrl = await this.discoveryApi.getBaseUrl('playlist'); + const resp = await this.fetchApi.fetch( + `${baseUrl}/${playlistId}/entities`, + { + headers: { 'Content-Type': 'application/json' }, + method: 'DELETE', + body: JSON.stringify(entityRefs), + }, + ); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + } + + async followPlaylist(playlistId: string) { + const baseUrl = await this.discoveryApi.getBaseUrl('playlist'); + const resp = await this.fetchApi.fetch( + `${baseUrl}/${playlistId}/followers`, + { method: 'POST' }, + ); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + } + + async unfollowPlaylist(playlistId: string) { + const baseUrl = await this.discoveryApi.getBaseUrl('playlist'); + const resp = await this.fetchApi.fetch( + `${baseUrl}/${playlistId}/followers`, + { method: 'DELETE' }, + ); + + if (!resp.ok) { + throw await ResponseError.fromResponse(resp); + } + } +} diff --git a/plugins/playlist/src/api/index.ts b/plugins/playlist/src/api/index.ts new file mode 100644 index 0000000000..b53cbd99ef --- /dev/null +++ b/plugins/playlist/src/api/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './PlaylistClient'; +export * from './PlaylistApi'; diff --git a/plugins/playlist/src/components/CreatePlaylistButton/CreatePlaylistButton.test.tsx b/plugins/playlist/src/components/CreatePlaylistButton/CreatePlaylistButton.test.tsx new file mode 100644 index 0000000000..e00f7dc582 --- /dev/null +++ b/plugins/playlist/src/components/CreatePlaylistButton/CreatePlaylistButton.test.tsx @@ -0,0 +1,142 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ErrorApi, errorApiRef } from '@backstage/core-plugin-api'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { + PermissionApi, + permissionApiRef, +} from '@backstage/plugin-permission-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { Button } from '@material-ui/core'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { act } from '@testing-library/react-hooks'; +import React from 'react'; +import { SWRConfig } from 'swr'; + +import { PlaylistApi, playlistApiRef } from '../../api'; +import { rootRouteRef } from '../../routes'; +import { CreatePlaylistButton } from './CreatePlaylistButton'; + +jest.mock('../PlaylistEditDialog', () => ({ + PlaylistEditDialog: ({ onSave, open }: { onSave: Function; open: boolean }) => + open ? ( + + )} + setOpenDialog(false)} + onSave={savePlaylist} + /> + + ); +}; diff --git a/plugins/playlist/src/components/CreatePlaylistButton/index.ts b/plugins/playlist/src/components/CreatePlaylistButton/index.ts new file mode 100644 index 0000000000..ac28bbc509 --- /dev/null +++ b/plugins/playlist/src/components/CreatePlaylistButton/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './CreatePlaylistButton'; diff --git a/plugins/playlist/src/components/EntityPlaylistDialog/EntityPlaylistDialog.test.tsx b/plugins/playlist/src/components/EntityPlaylistDialog/EntityPlaylistDialog.test.tsx new file mode 100644 index 0000000000..9c61105ef9 --- /dev/null +++ b/plugins/playlist/src/components/EntityPlaylistDialog/EntityPlaylistDialog.test.tsx @@ -0,0 +1,200 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Entity } from '@backstage/catalog-model'; +import { AlertApi, alertApiRef } from '@backstage/core-plugin-api'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { + PermissionApi, + permissionApiRef, +} from '@backstage/plugin-permission-react'; +import { Button } from '@material-ui/core'; +import { fireEvent, getByRole, waitFor } from '@testing-library/react'; +import { act } from '@testing-library/react-hooks'; +import React from 'react'; +import { SWRConfig } from 'swr'; + +import { PlaylistApi, playlistApiRef } from '../../api'; +import { rootRouteRef } from '../../routes'; +import { EntityPlaylistDialog } from './EntityPlaylistDialog'; + +const navigateMock = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => navigateMock, +})); + +jest.mock('../PlaylistEditDialog', () => ({ + PlaylistEditDialog: ({ onSave, open }: { onSave: Function; open: boolean }) => + open ? ( + + + + setOpenEditDialog(false)} + onSave={createNewPlaylist} + /> + + ); +}; diff --git a/plugins/playlist/src/components/EntityPlaylistDialog/index.ts b/plugins/playlist/src/components/EntityPlaylistDialog/index.ts new file mode 100644 index 0000000000..77d7e73ae0 --- /dev/null +++ b/plugins/playlist/src/components/EntityPlaylistDialog/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './EntityPlaylistDialog'; diff --git a/plugins/playlist/src/components/PersonalListPicker/PersonalListPicker.test.tsx b/plugins/playlist/src/components/PersonalListPicker/PersonalListPicker.test.tsx new file mode 100644 index 0000000000..9255ee1bdb --- /dev/null +++ b/plugins/playlist/src/components/PersonalListPicker/PersonalListPicker.test.tsx @@ -0,0 +1,286 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiProvider } from '@backstage/core-app-api'; +import { + ConfigApi, + configApiRef, + IdentityApi, + identityApiRef, + storageApiRef, +} from '@backstage/core-plugin-api'; +import { Playlist } from '@backstage/plugin-playlist-common'; +import { MockStorageApi, TestApiRegistry } from '@backstage/test-utils'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { MockPlaylistListProvider } from '../../testUtils'; +import { PlaylistOwnerFilter } from '../PlaylistOwnerPicker'; +import { PersonalListPicker } from './PersonalListPicker'; + +const mockConfigApi = { + getOptionalString: () => 'Test Company', +} as Partial; + +const mockIdentityApi = { + getBackstageIdentity: async () => ({ + ownershipEntityRefs: ['user:default/owner', 'group:default/some-owner'], + }), +} as Partial; + +const apis = TestApiRegistry.from( + [configApiRef, mockConfigApi], + [identityApiRef, mockIdentityApi], + [storageApiRef, MockStorageApi.create()], +); + +const backendPlaylists: Playlist[] = [ + { + id: 'id1', + name: 'playlist-1', + owner: 'group:default/some-owner', + public: true, + entities: 1, + followers: 2, + isFollowing: false, + }, + { + id: 'id2', + name: 'playlist-2', + owner: 'group:default/another-owner', + public: true, + entities: 2, + followers: 1, + isFollowing: true, + }, + { + id: 'id3', + name: 'playlist-3', + owner: 'group:default/another-owner', + public: true, + entities: 2, + followers: 1, + isFollowing: true, + }, + { + id: 'id4', + name: 'playlist-4', + owner: 'user:default/owner', + public: true, + entities: 2, + followers: 1, + isFollowing: true, + }, +]; + +describe('', () => { + const updateFilters = jest.fn() as any; + + beforeEach(() => { + updateFilters.mockClear(); + }); + + it('renders filter groups', async () => { + const { queryByText } = render( + + + + + , + ); + + await waitFor(() => { + expect(queryByText('Personal')).toBeInTheDocument(); + expect(queryByText('Test Company')).toBeInTheDocument(); + }); + }); + + it('renders filters', async () => { + const { getAllByRole } = render( + + + + + , + ); + + await waitFor(() => { + expect( + getAllByRole('menuitem').map(({ textContent }) => textContent), + ).toEqual(['Owned 2', 'Following 3', 'All 4']); + }); + }); + + it('respects other frontend filters in counts', async () => { + const { getAllByRole } = render( + + + + + , + ); + + await waitFor(() => { + expect( + getAllByRole('menuitem').map(({ textContent }) => textContent), + ).toEqual(['Owned -', 'Following 2', 'All 2']); + }); + }); + + it('respects the query parameter filter value', async () => { + const queryParameters = { personal: 'owned' }; + render( + + + + + , + ); + + await waitFor(() => { + expect(updateFilters.mock.lastCall[0].personal.value).toBe('owned'); + }); + }); + + it('updates personal filter when a menuitem is selected', async () => { + const { getByText } = render( + + + + + , + ); + + fireEvent.click(getByText('Following')); + + await waitFor(() => { + expect(updateFilters.mock.lastCall[0].personal.value).toBe('following'); + }); + }); + + it('responds to external queryParameters changes', async () => { + const rendered = render( + + + + + , + ); + + await waitFor(() => { + expect(updateFilters.mock.lastCall[0].personal.value).toBe('all'); + }); + + rendered.rerender( + + + + + , + ); + + await waitFor(() => { + expect(updateFilters.mock.lastCall[0].personal.value).toBe('owned'); + }); + }); + + describe.each(['owned', 'following'])( + 'filter resetting for %s playlists', + type => { + const picker = (props: { loading: boolean; mockData: any }) => ( + + ({ + ...p, + ...props.mockData, + })), + queryParameters: { personal: type }, + updateFilters, + loading: props.loading, + }} + > + + + + ); + + describe(`when there are no ${type} playlists`, () => { + const mockData = + type === 'owned' + ? { owner: 'group:default/no-owner' } + : { isFollowing: false }; + + it('does not reset the filter while playlists are loading', async () => { + render(picker({ loading: true, mockData })); + await waitFor(() => { + expect(updateFilters.mock.lastCall[0].personal.value).not.toBe( + 'all', + ); + }); + }); + + it('resets the filter to "all" when playlists are loaded', async () => { + render(picker({ loading: false, mockData })); + await waitFor(() => { + expect(updateFilters.mock.lastCall[0].personal.value).toBe('all'); + }); + }); + }); + + describe(`when there are some ${type} playlists present`, () => { + const mockData = {}; + + it('does not reset the filter while playlists are loading', async () => { + render(picker({ loading: true, mockData })); + await waitFor(() => { + expect(updateFilters.mock.lastCall[0].personal.value).not.toBe( + 'all', + ); + }); + }); + + it('does not reset the filter when playlists are loaded', async () => { + render(picker({ loading: false, mockData })); + await waitFor(() => { + expect(updateFilters.mock.lastCall[0].personal.value).toBe(type); + }); + }); + }); + }, + ); +}); diff --git a/plugins/playlist/src/components/PersonalListPicker/PersonalListPicker.tsx b/plugins/playlist/src/components/PersonalListPicker/PersonalListPicker.tsx new file mode 100644 index 0000000000..1172f2f6fa --- /dev/null +++ b/plugins/playlist/src/components/PersonalListPicker/PersonalListPicker.tsx @@ -0,0 +1,293 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + configApiRef, + IconComponent, + identityApiRef, + useApi, +} from '@backstage/core-plugin-api'; +import { Playlist } from '@backstage/plugin-playlist-common'; +import { + Card, + List, + ListItemIcon, + ListItemSecondaryAction, + ListItemText, + makeStyles, + MenuItem, + Theme, + Typography, +} from '@material-ui/core'; +import PlaylistPlayIcon from '@material-ui/icons/PlaylistPlay'; +import SettingsIcon from '@material-ui/icons/Settings'; +import { compact } from 'lodash'; +import React, { Fragment, useEffect, useMemo, useState } from 'react'; +import useAsync from 'react-use/lib/useAsync'; + +import { usePlaylistList } from '../../hooks'; +import { PlaylistFilter } from '../../types'; + +export const enum PersonalListFilterValue { + owned = 'owned', + following = 'following', + all = 'all', +} + +export class PersonalListFilter implements PlaylistFilter { + constructor( + readonly value: PersonalListFilterValue, + readonly isOwnedPlaylist: (playlist: Playlist) => boolean, + ) {} + + filterPlaylist(playlist: Playlist): boolean { + switch (this.value) { + case PersonalListFilterValue.owned: + return this.isOwnedPlaylist(playlist); + case PersonalListFilterValue.following: + return playlist.isFollowing; + default: + return true; + } + } + + toQueryValue(): string { + return this.value; + } +} + +const useStyles = makeStyles(theme => ({ + root: { + backgroundColor: 'rgba(0, 0, 0, .11)', + boxShadow: 'none', + margin: theme.spacing(1, 0, 1, 0), + }, + title: { + margin: theme.spacing(1, 0, 0, 1), + textTransform: 'uppercase', + fontSize: 12, + fontWeight: 'bold', + }, + listIcon: { + minWidth: 30, + color: theme.palette.text.primary, + }, + menuItem: { + minHeight: theme.spacing(6), + }, + groupWrapper: { + margin: theme.spacing(1, 1, 2, 1), + }, +})); + +type ButtonGroup = { + name: string; + items: { + id: PersonalListFilterValue; + label: string; + icon?: IconComponent; + }[]; +}; + +function getFilterGroups(orgName: string | undefined): ButtonGroup[] { + return [ + { + name: 'Personal', + items: [ + { + id: PersonalListFilterValue.owned, + label: 'Owned', + icon: SettingsIcon, + }, + { + id: PersonalListFilterValue.following, + label: 'Following', + icon: PlaylistPlayIcon, + }, + ], + }, + { + name: orgName ?? 'Company', + items: [ + { + id: PersonalListFilterValue.all, + label: 'All', + }, + ], + }, + ]; +} + +export const PersonalListPicker = () => { + const classes = useStyles(); + const configApi = useApi(configApiRef); + const identityApi = useApi(identityApiRef); + const orgName = configApi.getOptionalString('organization.name') ?? 'Company'; + const filterGroups = getFilterGroups(orgName); + + const { loading: loadingOwnership, value: ownershipRefs } = + useAsync(async () => { + const { ownershipEntityRefs } = await identityApi.getBackstageIdentity(); + return ownershipEntityRefs; + }, []); + + const isOwnedPlaylist = useMemo(() => { + const myOwnerRefs = new Set(ownershipRefs ?? []); + return (playlist: Playlist) => myOwnerRefs.has(playlist.owner); + }, [ownershipRefs]); + + const { + filters, + updateFilters, + backendPlaylists, + queryParameters: { personal: personalParameter }, + loading: loadingBackendPlaylists, + } = usePlaylistList(); + + const loading = loadingBackendPlaylists || loadingOwnership; + + // Static filters; used for generating counts of potentially unselected options + const ownedFilter = useMemo( + () => + new PersonalListFilter(PersonalListFilterValue.owned, isOwnedPlaylist), + [isOwnedPlaylist], + ); + const followingFilter = useMemo( + () => + new PersonalListFilter( + PersonalListFilterValue.following, + isOwnedPlaylist, + ), + [isOwnedPlaylist], + ); + + const queryParamPersonalFilter = useMemo( + () => [personalParameter].flat()[0], + [personalParameter], + ); + + const [selectedPersonalFilter, setSelectedPersonalFilter] = useState( + queryParamPersonalFilter ?? PersonalListFilterValue.all, + ); + + // To show proper counts for each section, apply all other frontend filters _except_ the personal + // filter that's controlled by this picker. + const playlistsWithoutPersonalFilter = useMemo( + () => + backendPlaylists.filter(playlist => + compact(Object.values({ ...filters, personal: undefined })).every( + (filter: PlaylistFilter) => + !filter.filterPlaylist || filter.filterPlaylist(playlist), + ), + ), + [filters, backendPlaylists], + ); + + const filterCounts = useMemo>( + () => ({ + all: playlistsWithoutPersonalFilter.length, + following: playlistsWithoutPersonalFilter.filter(playlist => + followingFilter.filterPlaylist(playlist), + ).length, + owned: playlistsWithoutPersonalFilter.filter(playlist => + ownedFilter.filterPlaylist(playlist), + ).length, + }), + [playlistsWithoutPersonalFilter, followingFilter, ownedFilter], + ); + + // Set selected personal filter on query parameter updates; this happens at initial page load and from + // external updates to the page location. + useEffect(() => { + if (queryParamPersonalFilter) { + setSelectedPersonalFilter(queryParamPersonalFilter); + } + }, [queryParamPersonalFilter]); + + useEffect(() => { + if ( + !loading && + !!selectedPersonalFilter && + selectedPersonalFilter !== PersonalListFilterValue.all && + filterCounts[selectedPersonalFilter] === 0 + ) { + setSelectedPersonalFilter(PersonalListFilterValue.all); + } + }, [ + loading, + filterCounts, + selectedPersonalFilter, + setSelectedPersonalFilter, + ]); + + useEffect(() => { + updateFilters({ + personal: selectedPersonalFilter + ? new PersonalListFilter( + selectedPersonalFilter as PersonalListFilterValue, + isOwnedPlaylist, + ) + : undefined, + }); + }, [selectedPersonalFilter, isOwnedPlaylist, updateFilters]); + + return ( + + {filterGroups.map(group => ( + + + {group.name} + + + + {group.items.map(item => ( + setSelectedPersonalFilter(item.id)} + selected={item.id === selectedPersonalFilter} + className={classes.menuItem} + disabled={filterCounts[item.id] === 0} + data-testid={`personal-picker-${item.id}`} + tabIndex={0} + ContainerProps={{ role: 'menuitem' }} + > + {item.icon && ( + + + + )} + + {item.label} + + + {filterCounts[item.id] || '-'} + + + ))} + + + + ))} + + ); +}; diff --git a/plugins/playlist/src/components/PersonalListPicker/index.ts b/plugins/playlist/src/components/PersonalListPicker/index.ts new file mode 100644 index 0000000000..f67f18ab39 --- /dev/null +++ b/plugins/playlist/src/components/PersonalListPicker/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './PersonalListPicker'; diff --git a/plugins/playlist/src/components/PlaylistCard/PlaylistCard.test.tsx b/plugins/playlist/src/components/PlaylistCard/PlaylistCard.test.tsx new file mode 100644 index 0000000000..177cc23b81 --- /dev/null +++ b/plugins/playlist/src/components/PlaylistCard/PlaylistCard.test.tsx @@ -0,0 +1,57 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { entityRouteRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; +import React from 'react'; + +import { rootRouteRef } from '../../routes'; +import { PlaylistCard } from './PlaylistCard'; + +describe('', () => { + it('renders playlist info', async () => { + const rendered = await renderInTestApp( + + + , + { + mountedRoutes: { + '/playlists': rootRouteRef, + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, + ); + + expect(rendered.getByText('playlist-1')).toBeInTheDocument(); + expect(rendered.getByText('test description')).toBeInTheDocument(); + expect(rendered.getByText('some-owner')).toBeInTheDocument(); + expect(rendered.getByText('3 entities')).toBeInTheDocument(); + expect(rendered.getByText('2 followers')).toBeInTheDocument(); + }); +}); diff --git a/plugins/playlist/src/components/PlaylistCard/PlaylistCard.tsx b/plugins/playlist/src/components/PlaylistCard/PlaylistCard.tsx new file mode 100644 index 0000000000..2db7e85f7e --- /dev/null +++ b/plugins/playlist/src/components/PlaylistCard/PlaylistCard.tsx @@ -0,0 +1,131 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Button, + ItemCardHeader, + MarkdownContent, +} from '@backstage/core-components'; +import { useRouteRef } from '@backstage/core-plugin-api'; +import { EntityRefLinks } from '@backstage/plugin-catalog-react'; +import { Playlist } from '@backstage/plugin-playlist-common'; +import { BackstageTheme } from '@backstage/theme'; +import { + Box, + Card, + CardActions, + CardContent, + CardMedia, + Chip, + makeStyles, + Tooltip, + Typography, +} from '@material-ui/core'; +import LockIcon from '@material-ui/icons/Lock'; +import React from 'react'; + +import { playlistRouteRef } from '../../routes'; + +const useStyles = makeStyles((theme: BackstageTheme) => ({ + cardHeader: { + position: 'relative', + }, + title: { + backgroundImage: theme.getPageTheme({ themeId: 'home' }).backgroundImage, + }, + box: { + overflow: 'hidden', + textOverflow: 'ellipsis', + display: '-webkit-box', + '-webkit-line-clamp': 10, + '-webkit-box-orient': 'vertical', + paddingBottom: '0.8em', + }, + label: { + color: theme.palette.text.secondary, + textTransform: 'uppercase', + fontSize: '0.65rem', + fontWeight: 'bold', + letterSpacing: 0.5, + lineHeight: 1, + paddingBottom: '0.2rem', + }, + chip: { + marginRight: 'auto', + }, + privateIcon: { + position: 'absolute', + top: theme.spacing(0.5), + right: theme.spacing(0.5), + padding: '0.25rem', + }, +})); + +export type PlaylistCardProps = { + playlist: Playlist; +}; + +export const PlaylistCard = ({ playlist }: PlaylistCardProps) => { + const classes = useStyles(); + const playlistRoute = useRouteRef(playlistRouteRef); + + return ( + + + {!playlist.public && ( + + + + )} + + + + + + + + + Description + + + + + + Owner + + + + + + + + + ); +}; diff --git a/plugins/playlist/src/components/PlaylistCard/index.ts b/plugins/playlist/src/components/PlaylistCard/index.ts new file mode 100644 index 0000000000..d043c8b6d0 --- /dev/null +++ b/plugins/playlist/src/components/PlaylistCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './PlaylistCard'; diff --git a/plugins/playlist/src/components/PlaylistEditDialog/PlaylistEditDialog.test.tsx b/plugins/playlist/src/components/PlaylistEditDialog/PlaylistEditDialog.test.tsx new file mode 100644 index 0000000000..9f30d7608c --- /dev/null +++ b/plugins/playlist/src/components/PlaylistEditDialog/PlaylistEditDialog.test.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IdentityApi, identityApiRef } from '@backstage/core-plugin-api'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { fireEvent, getByRole, waitFor } from '@testing-library/react'; +import { act } from '@testing-library/react-hooks'; +import React from 'react'; + +import { PlaylistEditDialog } from './PlaylistEditDialog'; + +describe('', () => { + it('handle saving with an edited playlist', async () => { + const identityApi: Partial = { + getBackstageIdentity: async () => ({ + type: 'user', + userEntityRef: 'user:default/me', + ownershipEntityRefs: ['group:default/test-owner', 'user:default/me'], + }), + }; + + const mockOnSave = jest.fn().mockImplementation(async () => {}); + const rendered = await renderInTestApp( + + + , + ); + + act(() => { + fireEvent.input( + getByRole(rendered.getByTestId('edit-dialog-name-input'), 'textbox'), + { + target: { + value: 'test playlist', + }, + }, + ); + + fireEvent.input( + getByRole( + rendered.getByTestId('edit-dialog-description-input'), + 'textbox', + ), + { + target: { + value: 'test description', + }, + }, + ); + + fireEvent.mouseDown( + getByRole(rendered.getByTestId('edit-dialog-owner-select'), 'button'), + ); + + fireEvent.click(rendered.getByText('test-owner')); + + fireEvent.click( + getByRole(rendered.getByTestId('edit-dialog-public-option'), 'radio'), + ); + + fireEvent.click(rendered.getByTestId('edit-dialog-save-button')); + }); + + await waitFor(() => { + expect(mockOnSave).toHaveBeenCalledWith({ + name: 'test playlist', + description: 'test description', + owner: 'group:default/test-owner', + public: true, + }); + }); + }); +}); diff --git a/plugins/playlist/src/components/PlaylistEditDialog/PlaylistEditDialog.tsx b/plugins/playlist/src/components/PlaylistEditDialog/PlaylistEditDialog.tsx new file mode 100644 index 0000000000..27cc13fede --- /dev/null +++ b/plugins/playlist/src/components/PlaylistEditDialog/PlaylistEditDialog.tsx @@ -0,0 +1,217 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { parseEntityRef } from '@backstage/catalog-model'; +import { identityApiRef, useApi } from '@backstage/core-plugin-api'; +import { humanizeEntityRef } from '@backstage/plugin-catalog-react'; +import { PlaylistMetadata } from '@backstage/plugin-playlist-common'; +import { + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + FormControl, + FormControlLabel, + InputLabel, + makeStyles, + MenuItem, + LinearProgress, + Radio, + RadioGroup, + Select, + TextField, +} from '@material-ui/core'; +import React from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import useAsync from 'react-use/lib/useAsync'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; + +const useStyles = makeStyles({ + buttonWrapper: { + position: 'relative', + }, + buttonProgress: { + position: 'absolute', + top: '50%', + left: '50%', + marginTop: -12, + marginLeft: -12, + }, +}); + +export type PlaylistEditDialogProps = { + open: boolean; + onClose: () => void; + onSave: (playlist: Omit) => Promise; + playlist?: Omit; +}; + +export const PlaylistEditDialog = ({ + open, + onClose, + onSave, + playlist = { + name: '', + description: '', + owner: '', + public: false, + }, +}: PlaylistEditDialogProps) => { + const classes = useStyles(); + const identityApi = useApi(identityApiRef); + + const { loading: loadingOwnership, value: ownershipRefs } = + useAsync(async () => { + const { ownershipEntityRefs } = await identityApi.getBackstageIdentity(); + return ownershipEntityRefs; + }, []); + + const defaultValues = { + ...playlist, + public: playlist.public.toString(), + }; + + const { + control, + formState: { errors }, + handleSubmit, + reset, + } = useForm({ defaultValues }); + + const [saving, savePlaylist] = useAsyncFn( + formValues => + onSave({ ...formValues, public: JSON.parse(formValues.public) }), + [onSave], + ); + + const closeDialog = () => { + if (!saving.loading) { + onClose(); + reset(defaultValues); + } + }; + + return ( + + + ( + + )} + /> + ( + + )} + /> + {loadingOwnership ? ( + + ) : ( + ( + + Owner + + + )} + /> + )} + ( + + + } + /> + } + /> + + + )} + /> + + + +
+ + {saving.loading && ( + + )} +
+
+
+ ); +}; diff --git a/plugins/playlist/src/components/PlaylistEditDialog/index.ts b/plugins/playlist/src/components/PlaylistEditDialog/index.ts new file mode 100644 index 0000000000..3355443b82 --- /dev/null +++ b/plugins/playlist/src/components/PlaylistEditDialog/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './PlaylistEditDialog'; diff --git a/plugins/playlist/src/components/PlaylistIndexPage/PlaylistIndexPage.test.tsx b/plugins/playlist/src/components/PlaylistIndexPage/PlaylistIndexPage.test.tsx new file mode 100644 index 0000000000..6dae17d3bc --- /dev/null +++ b/plugins/playlist/src/components/PlaylistIndexPage/PlaylistIndexPage.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { + PermissionApi, + permissionApiRef, +} from '@backstage/plugin-permission-react'; +import React from 'react'; + +import { PlaylistApi, playlistApiRef } from '../../api'; +import { rootRouteRef } from '../../routes'; +import { PlaylistIndexPage } from './PlaylistIndexPage'; + +const playlistApi: Partial = { + getAllPlaylists: async () => [], +}; + +const permissionApi: Partial = { + authorize: async () => ({ result: AuthorizeResult.ALLOW }), +}; + +describe('PlaylistIndexPage', () => { + it('should render', async () => { + const rendered = await renderInTestApp( + + + , + { mountedRoutes: { '/playlists': rootRouteRef } }, + ); + expect(rendered.getByText('Playlists')).toBeInTheDocument(); + }); +}); diff --git a/plugins/playlist/src/components/PlaylistIndexPage/PlaylistIndexPage.tsx b/plugins/playlist/src/components/PlaylistIndexPage/PlaylistIndexPage.tsx new file mode 100644 index 0000000000..0ee829018a --- /dev/null +++ b/plugins/playlist/src/components/PlaylistIndexPage/PlaylistIndexPage.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + PageWithHeader, + Content, + ContentHeader, + SupportButton, +} from '@backstage/core-components'; +import { CatalogFilterLayout } from '@backstage/plugin-catalog-react'; + +import { PlaylistListProvider } from '../../hooks'; +import { CreatePlaylistButton } from '../CreatePlaylistButton'; +import { PersonalListPicker } from '../PersonalListPicker'; +import { PlaylistList } from '../PlaylistList'; +import { PlaylistOwnerPicker } from '../PlaylistOwnerPicker'; +import { PlaylistSearchBar } from '../PlaylistSearchBar'; +import { PlaylistSortPicker } from '../PlaylistSortPicker'; + +export const PlaylistIndexPage = () => ( + + + + + + + + + + + + + + + + + + + + + +); diff --git a/plugins/playlist/src/components/PlaylistIndexPage/index.ts b/plugins/playlist/src/components/PlaylistIndexPage/index.ts new file mode 100644 index 0000000000..06858a8262 --- /dev/null +++ b/plugins/playlist/src/components/PlaylistIndexPage/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { PlaylistIndexPage } from './PlaylistIndexPage'; diff --git a/plugins/playlist/src/components/PlaylistList/PlaylistList.test.tsx b/plugins/playlist/src/components/PlaylistList/PlaylistList.test.tsx new file mode 100644 index 0000000000..27030ec1c7 --- /dev/null +++ b/plugins/playlist/src/components/PlaylistList/PlaylistList.test.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Playlist } from '@backstage/plugin-playlist-common'; +import { render } from '@testing-library/react'; +import React from 'react'; + +import { MockPlaylistListProvider } from '../../testUtils'; +import { PlaylistList } from './PlaylistList'; + +jest.mock('../PlaylistCard', () => ({ + PlaylistCard: ({ playlist }: { playlist: Playlist }) => ( +
{playlist.name}
+ ), +})); + +describe('', () => { + it('renders error on error', () => { + const rendered = render( + + + , + ); + + expect(rendered.getByText('Test Error')).toBeInTheDocument(); + }); + + it('handles no playlists', () => { + const rendered = render( + + + , + ); + + expect( + rendered.getByText('No playlists found that match your filter.'), + ).toBeInTheDocument(); + }); + + it('renders playlists', () => { + const rendered = render( + + + , + ); + + expect(rendered.getByText('playlist-1')).toBeInTheDocument(); + expect(rendered.getByText('playlist-2')).toBeInTheDocument(); + }); +}); diff --git a/plugins/playlist/src/components/PlaylistList/PlaylistList.tsx b/plugins/playlist/src/components/PlaylistList/PlaylistList.tsx new file mode 100644 index 0000000000..fcc3971542 --- /dev/null +++ b/plugins/playlist/src/components/PlaylistList/PlaylistList.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + Content, + ItemCardGrid, + Progress, + WarningPanel, +} from '@backstage/core-components'; +import { Typography } from '@material-ui/core'; + +import { usePlaylistList } from '../../hooks'; +import { PlaylistCard } from '../PlaylistCard'; + +export const PlaylistList = () => { + const { loading, error, playlists } = usePlaylistList(); + + return ( + <> + {loading && } + + {error && ( + + {error.message} + + )} + + {!error && !loading && !playlists.length && ( + + No playlists found that match your filter. + + )} + + + + {playlists?.length > 0 && + playlists.map(playlist => ( + + ))} + + + + ); +}; diff --git a/plugins/playlist/src/components/PlaylistList/index.ts b/plugins/playlist/src/components/PlaylistList/index.ts new file mode 100644 index 0000000000..59fa5cf0fb --- /dev/null +++ b/plugins/playlist/src/components/PlaylistList/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './PlaylistList'; diff --git a/plugins/playlist/src/components/PlaylistOwnerPicker/PlaylistOwnerPicker.test.tsx b/plugins/playlist/src/components/PlaylistOwnerPicker/PlaylistOwnerPicker.test.tsx new file mode 100644 index 0000000000..c74e6f6853 --- /dev/null +++ b/plugins/playlist/src/components/PlaylistOwnerPicker/PlaylistOwnerPicker.test.tsx @@ -0,0 +1,215 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { parseEntityRef } from '@backstage/catalog-model'; +import { humanizeEntityRef } from '@backstage/plugin-catalog-react'; +import { Playlist } from '@backstage/plugin-playlist-common'; +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { MockPlaylistListProvider } from '../../testUtils'; +import { + PlaylistOwnerFilter, + PlaylistOwnerPicker, +} from './PlaylistOwnerPicker'; + +const samplePlaylists: Playlist[] = [ + { + id: 'id1', + name: 'playlist-1', + owner: 'group:default/some-owner', + public: true, + entities: 1, + followers: 2, + isFollowing: false, + }, + { + id: 'id2', + name: 'playlist-2', + owner: 'group:default/another-owner', + public: true, + entities: 2, + followers: 1, + isFollowing: true, + }, + { + id: 'id3', + name: 'playlist-3', + owner: 'group:default/another-owner', + public: true, + entities: 2, + followers: 1, + isFollowing: true, + }, + { + id: 'id4', + name: 'playlist-4', + owner: 'user:default/owner', + public: true, + entities: 2, + followers: 1, + isFollowing: true, + }, +]; + +describe('', () => { + it('renders all owners', () => { + const rendered = render( + + + , + ); + expect(rendered.getByText('Owner')).toBeInTheDocument(); + + fireEvent.click(rendered.getByTestId('owner-picker-expand')); + samplePlaylists + .map(p => + humanizeEntityRef(parseEntityRef(p.owner), { defaultKind: 'group' }), + ) + .forEach(owner => { + expect(rendered.getByText(owner as string)).toBeInTheDocument(); + }); + }); + + it('renders unique owners in alphabetical order', () => { + const rendered = render( + + + , + ); + expect(rendered.getByText('Owner')).toBeInTheDocument(); + + fireEvent.click(rendered.getByTestId('owner-picker-expand')); + + expect(rendered.getAllByRole('option').map(o => o.textContent)).toEqual([ + 'another-owner', + 'some-owner', + 'user:owner', + ]); + }); + + it('respects the query parameter filter value', () => { + const updateFilters = jest.fn(); + const queryParameters = { owners: ['group:default/another-owner'] }; + render( + + + , + ); + + expect(updateFilters).toHaveBeenLastCalledWith({ + owners: new PlaylistOwnerFilter(['group:default/another-owner']), + }); + }); + + it('adds owners to filters', () => { + const updateFilters = jest.fn(); + const rendered = render( + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + owners: undefined, + }); + + fireEvent.click(rendered.getByTestId('owner-picker-expand')); + fireEvent.click(rendered.getByText('some-owner')); + expect(updateFilters).toHaveBeenLastCalledWith({ + owners: new PlaylistOwnerFilter(['group:default/some-owner']), + }); + }); + + it('removes owners from filters', () => { + const updateFilters = jest.fn(); + const rendered = render( + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + owners: new PlaylistOwnerFilter(['group:default/some-owner']), + }); + fireEvent.click(rendered.getByTestId('owner-picker-expand')); + expect(rendered.getByLabelText('some-owner')).toBeChecked(); + + fireEvent.click(rendered.getByLabelText('some-owner')); + expect(updateFilters).toHaveBeenLastCalledWith({ + owner: undefined, + }); + }); + + it('responds to external queryParameters changes', () => { + const updateFilters = jest.fn(); + const rendered = render( + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + owners: new PlaylistOwnerFilter(['group:default/team-a']), + }); + rendered.rerender( + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + owners: new PlaylistOwnerFilter(['group:default/team-b']), + }); + }); +}); diff --git a/plugins/playlist/src/components/PlaylistOwnerPicker/PlaylistOwnerPicker.tsx b/plugins/playlist/src/components/PlaylistOwnerPicker/PlaylistOwnerPicker.tsx new file mode 100644 index 0000000000..7def794c6b --- /dev/null +++ b/plugins/playlist/src/components/PlaylistOwnerPicker/PlaylistOwnerPicker.tsx @@ -0,0 +1,134 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { parseEntityRef } from '@backstage/catalog-model'; +import { humanizeEntityRef } from '@backstage/plugin-catalog-react'; +import { Playlist } from '@backstage/plugin-playlist-common'; +import { + Box, + Checkbox, + Chip, + FormControlLabel, + TextField, + Typography, +} from '@material-ui/core'; +import CheckBoxIcon from '@material-ui/icons/CheckBox'; +import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { Autocomplete } from '@material-ui/lab'; +import React, { useEffect, useMemo, useState } from 'react'; + +import { usePlaylistList } from '../../hooks'; +import { PlaylistFilter } from '../../types'; + +export class PlaylistOwnerFilter implements PlaylistFilter { + constructor(readonly values: string[]) {} + + filterPlaylist(playlist: Playlist): boolean { + return this.values.some(v => playlist.owner === v); + } + + toQueryValue(): string[] { + return this.values; + } +} + +const icon = ; +const checkedIcon = ; + +export const PlaylistOwnerPicker = () => { + const { + updateFilters, + backendPlaylists, + filters, + queryParameters: { owners: ownersParameter }, + } = usePlaylistList(); + + const queryParamOwners = useMemo( + () => [ownersParameter].flat().filter(Boolean) as string[], + [ownersParameter], + ); + + const [selectedOwners, setSelectedOwners] = useState( + queryParamOwners.length ? queryParamOwners : filters.owners?.values ?? [], + ); + + // Set selected owners on query parameter updates; this happens at initial page load and from + // external updates to the page location. + useEffect(() => { + if (queryParamOwners.length) { + setSelectedOwners(queryParamOwners); + } + }, [queryParamOwners]); + + useEffect(() => { + updateFilters({ + owners: selectedOwners.length + ? new PlaylistOwnerFilter(selectedOwners) + : undefined, + }); + }, [selectedOwners, updateFilters]); + + const availableOwners = useMemo( + () => [...new Set(backendPlaylists.map(p => p.owner))].sort(), + [backendPlaylists], + ); + + if (!availableOwners.length) return null; + + return ( + + + Owner + setSelectedOwners(value)} + renderTags={(values: string[], getTagProps: Function) => + values.map((val, index) => ( + + )) + } + renderOption={(option, { selected }) => ( + + } + label={humanizeEntityRef(parseEntityRef(option), { + defaultKind: 'group', + })} + /> + )} + size="small" + popupIcon={} + renderInput={params => } + /> + + + ); +}; diff --git a/plugins/playlist/src/components/PlaylistOwnerPicker/index.ts b/plugins/playlist/src/components/PlaylistOwnerPicker/index.ts new file mode 100644 index 0000000000..fcde27e956 --- /dev/null +++ b/plugins/playlist/src/components/PlaylistOwnerPicker/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './PlaylistOwnerPicker'; diff --git a/plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.test.tsx b/plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.test.tsx new file mode 100644 index 0000000000..d1bdf1d46a --- /dev/null +++ b/plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.test.tsx @@ -0,0 +1,159 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + CatalogApi, + catalogApiRef, + entityRouteRef, +} from '@backstage/plugin-catalog-react'; +import { SearchApi, searchApiRef } from '@backstage/plugin-search-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { fireEvent, getByText } from '@testing-library/react'; +import { act } from '@testing-library/react-hooks'; +import React from 'react'; + +import { AddEntitiesDrawer } from './AddEntitiesDrawer'; + +describe('AddEntitiesDrawer', () => { + const catalogApi: Partial = { + getEntityFacets: jest.fn().mockImplementation(async () => ({ + facets: { + kind: [{ value: 'component' }], + }, + })), + }; + + const mockOnAdd = jest.fn(); + const sampleCurrentEntities = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'system', + metadata: { namespace: 'default', name: 'test-ent' }, + }, + ]; + + const mockSearchResults = [ + { + type: 'software-catalog', + document: { + title: 'Test Ent', + text: 'This is test ent', + location: '/catalog/default/system/test-ent', + kind: 'system', + }, + }, + { + type: 'software-catalog', + document: { + title: 'Test Ent 2', + text: 'This is test ent 2', + location: '/catalog/foo/component/test-ent2', + kind: 'component', + type: 'library', + }, + }, + { + type: 'software-catalog', + document: { + title: 'Test Ent 3', + text: 'This is test ent 3', + location: '/catalog/bar/api/test-ent3', + kind: 'api', + type: 'openapi', + }, + }, + ]; + + const searchApi: Partial = { + query: jest + .fn() + .mockImplementation(async () => ({ results: mockSearchResults })), + }; + + const element = ( + + + + ); + + const render = async () => + renderInTestApp(element, { + mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef }, + }); + + beforeEach(() => { + mockOnAdd.mockClear(); + }); + + it('should render available entities correctly', async () => { + const rendered = await render(); + expect(searchApi.query).toHaveBeenLastCalledWith({ + filters: {}, + term: '', + types: ['software-catalog'], + }); + + expect(rendered.getByText('Test Ent')).toBeInTheDocument(); + expect(rendered.getByText('This is test ent')).toBeInTheDocument(); + expect(rendered.getByText('Kind: system')).toBeInTheDocument(); + + expect(rendered.getByText('Test Ent 2')).toBeInTheDocument(); + expect(rendered.getByText('This is test ent 2')).toBeInTheDocument(); + expect(rendered.getByText('Kind: component')).toBeInTheDocument(); + expect(rendered.getByText('Type: library')).toBeInTheDocument(); + + expect(rendered.getByText('Test Ent 3')).toBeInTheDocument(); + expect(rendered.getByText('This is test ent 3')).toBeInTheDocument(); + expect(rendered.getByText('Kind: api')).toBeInTheDocument(); + expect(rendered.getByText('Type: openapi')).toBeInTheDocument(); + }); + + it('should disable options that are already added', async () => { + const rendered = await render(); + + const addButtons = rendered.getAllByTestId('entity-drawer-add-button'); + expect(addButtons.length).toEqual(3); + + expect(getByText(addButtons[0], 'Added')).toBeInTheDocument(); + expect(addButtons[0]).toBeDisabled(); + + expect(getByText(addButtons[1], 'Add')).toBeInTheDocument(); + expect(addButtons[1]).not.toBeDisabled(); + + expect(getByText(addButtons[2], 'Add')).toBeInTheDocument(); + expect(addButtons[2]).not.toBeDisabled(); + }); + + it('should add entities correctly', async () => { + const rendered = await render(); + + act(() => { + fireEvent.click(rendered.getAllByTestId('entity-drawer-add-button')[1]); + }); + + expect(mockOnAdd).toHaveBeenCalledWith('component:foo/test-ent2'); + }); +}); diff --git a/plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.tsx b/plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.tsx new file mode 100644 index 0000000000..05dd4da231 --- /dev/null +++ b/plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.tsx @@ -0,0 +1,239 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Entity, + getCompoundEntityRef, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { useApi, useRouteRef } from '@backstage/core-plugin-api'; +import { CatalogEntityDocument } from '@backstage/plugin-catalog-common'; +import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; +import { + SearchBar, + SearchContextProvider, + SearchFilter, + SearchResult, + SearchResultPager, + useSearch, +} from '@backstage/plugin-search-react'; +import { + Box, + Button, + Chip, + createStyles, + Divider, + Drawer, + Grid, + List, + ListItem, + ListItemSecondaryAction, + ListItemText, + makeStyles, + Paper, + Theme, + Typography, +} from '@material-ui/core'; +import React, { useCallback, useEffect, useMemo } from 'react'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + paper: { + width: '50%', + padding: theme.spacing(2.5), + }, + searchBarContainer: { + borderRadius: 30, + display: 'flex', + height: '2.4em', + }, + gridContainer: { + height: '100%', + }, + searchResults: { + overflow: 'auto', + }, + itemContainer: { + flexWrap: 'wrap', + paddingRight: '75px', + }, + itemText: { + width: '100%', + wordBreak: 'break-word', + marginBottom: '1rem', + }, + }), +); + +const RestrictCatalogIndexResults = () => { + const { setTypes } = useSearch(); + useEffect(() => setTypes(['software-catalog']), [setTypes]); + return null; +}; + +export type AddEntitiesDrawerProps = { + currentEntities: Entity[]; + open: boolean; + onAdd: (entityRef: string) => void; + onClose: () => void; +}; + +export const AddEntitiesDrawer = ({ + currentEntities, + open, + onAdd, + onClose, +}: AddEntitiesDrawerProps) => { + const classes = useStyles(); + const catalogApi = useApi(catalogApiRef); + const entityRoute = useRouteRef(entityRouteRef); + const entityLocationRegex = useMemo(() => { + const forwardSlashRegex = new RegExp('/', 'g'); + const locationRegex = entityRoute({ + namespace: '(?.+?)', + kind: '(?.+?)', + name: '(?.+?)', + }).replace(forwardSlashRegex, '\\/'); + + return new RegExp(`${locationRegex}$`); + }, [entityRoute]); + + const currentEntityLocations = useMemo( + () => + currentEntities.map(entity => + entityRoute(getCompoundEntityRef(entity)).toLocaleLowerCase('en-US'), + ), + [currentEntities, entityRoute], + ); + + const getEntityKinds = async () => { + return ( + await catalogApi.getEntityFacets({ facets: ['kind'] }) + ).facets.kind.map(f => f.value); + }; + + const addEntity = useCallback( + entityResult => { + // TODO (kuangp): this parsing of the location is not great. Ideally `CatalogEntityDocument` + // contains the `metadata.name` field so we can derive the full ref and we only fall back to + // parsing location if it's missing (ie. for older versions) + const { groups } = entityResult.location.match(entityLocationRegex); + if (groups) { + onAdd(stringifyEntityRef(groups)); + } else { + // eslint-disable-next-line no-console + console.error( + `Failed to parse entity ref from entity location: ${entityResult.location}`, + ); + } + }, + [entityLocationRegex, onAdd], + ); + + return ( + + + + + + + Let's find something for your playlist + + + + + + + + + + + + + {({ results }) => ( + + {results.map(({ document }) => ( + + +
+ + + + + + {(document as CatalogEntityDocument).kind && ( + + )} + {(document as CatalogEntityDocument).type && ( + + )} + +
+
+ +
+ ))} +
+ )} +
+ +
+
+
+
+ ); +}; diff --git a/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.test.tsx b/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.test.tsx new file mode 100644 index 0000000000..3dfd89fda9 --- /dev/null +++ b/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.test.tsx @@ -0,0 +1,211 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ErrorApi, errorApiRef } from '@backstage/core-plugin-api'; +import { + CatalogApi, + catalogApiRef, + entityRouteRef, +} from '@backstage/plugin-catalog-react'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { + PermissionApi, + permissionApiRef, +} from '@backstage/plugin-permission-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { Button } from '@material-ui/core'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { act } from '@testing-library/react-hooks'; +import React from 'react'; +import { SWRConfig } from 'swr'; + +import { PlaylistApi, playlistApiRef } from '../../api'; +import { PlaylistEntitiesTable } from './PlaylistEntitiesTable'; + +jest.mock('./AddEntitiesDrawer', () => ({ + AddEntitiesDrawer: ({ onAdd, open }: { onAdd: Function; open: boolean }) => + open ? ( + +
+ + {deleting.loading && ( + + )} +
+ + + + ); +}; diff --git a/plugins/playlist/src/components/PlaylistPage/PlaylistPage.test.tsx b/plugins/playlist/src/components/PlaylistPage/PlaylistPage.test.tsx new file mode 100644 index 0000000000..65d3d79be6 --- /dev/null +++ b/plugins/playlist/src/components/PlaylistPage/PlaylistPage.test.tsx @@ -0,0 +1,141 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ErrorApi, errorApiRef } from '@backstage/core-plugin-api'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { + PermissionApi, + permissionApiRef, +} from '@backstage/plugin-permission-react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { fireEvent, getByText, waitFor } from '@testing-library/react'; +import { act } from '@testing-library/react-hooks'; +import React from 'react'; +import { SWRConfig } from 'swr'; + +import { PlaylistApi, playlistApiRef } from '../../api'; +import { PlaylistPage } from './PlaylistPage'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ playlistId: 'id1' }), +})); + +// Mock out nested components to simplify tests +jest.mock('./PlaylistEntitiesTable', () => ({ + PlaylistEntitiesTable: () => null, +})); + +jest.mock('./PlaylistHeader', () => ({ + PlaylistHeader: () => null, +})); + +describe('PlaylistPage', () => { + const testPlaylist = { + id: 'id1', + name: 'playlist-1', + description: 'test description', + owner: 'group:default/some-owner', + public: true, + entities: 1, + followers: 2, + isFollowing: false, + }; + + const errorApi: Partial = { post: jest.fn() }; + const playlistApi: Partial = { + getPlaylist: jest.fn().mockImplementation(async () => testPlaylist), + followPlaylist: jest.fn().mockImplementation(async () => {}), + unfollowPlaylist: jest.fn().mockImplementation(async () => {}), + }; + const mockAuthorize = jest + .fn() + .mockImplementation(async () => ({ result: AuthorizeResult.ALLOW })); + const permissionApi: Partial = { authorize: mockAuthorize }; + + // SWR used by the usePermission hook needs cache to be reset for each test + const element = ( + new Map() }}> + + + + + ); + + beforeEach(() => { + mockAuthorize.mockClear(); + }); + + it('show render the playlist info', async () => { + const rendered = await renderInTestApp(element); + expect(playlistApi.getPlaylist).toHaveBeenCalledWith('id1'); + expect(rendered.getByText('test description')).toBeInTheDocument(); + expect( + rendered.getByTestId('playlist-page-follow-button'), + ).toBeInTheDocument(); + expect( + getByText(rendered.getByTestId('playlist-page-follow-button'), 'Follow'), + ).toBeInTheDocument(); + }); + + it('should not render the follow button if unauthorized', async () => { + mockAuthorize.mockImplementationOnce(async () => ({ + result: AuthorizeResult.DENY, + })); + const rendered = await renderInTestApp(element); + expect(rendered.queryByTestId('playlist-page-follow-button')).toBeNull(); + }); + + it('should reflect and toggle the following state of the playlist', async () => { + const rendered = await renderInTestApp(element); + + act(() => { + fireEvent.click(rendered.getByTestId('playlist-page-follow-button')); + testPlaylist.isFollowing = true; + }); + + await waitFor(() => { + expect(playlistApi.followPlaylist).toHaveBeenCalledWith('id1'); + expect( + getByText( + rendered.getByTestId('playlist-page-follow-button'), + 'Following', + ), + ).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(rendered.getByTestId('playlist-page-follow-button')); + testPlaylist.isFollowing = false; + }); + + await waitFor(() => { + expect(playlistApi.unfollowPlaylist).toHaveBeenCalledWith('id1'); + expect( + getByText( + rendered.getByTestId('playlist-page-follow-button'), + 'Follow', + ), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/playlist/src/components/PlaylistPage/PlaylistPage.tsx b/plugins/playlist/src/components/PlaylistPage/PlaylistPage.tsx new file mode 100644 index 0000000000..4c9a0edbb7 --- /dev/null +++ b/plugins/playlist/src/components/PlaylistPage/PlaylistPage.tsx @@ -0,0 +1,133 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Content, ErrorPage, Page } from '@backstage/core-components'; +import { errorApiRef, useApi } from '@backstage/core-plugin-api'; +import { ResponseError } from '@backstage/errors'; +import { usePermission } from '@backstage/plugin-permission-react'; +import { permissions } from '@backstage/plugin-playlist-common'; +import { + Button, + Card, + CardContent, + CardHeader, + Divider, + LinearProgress, + makeStyles, + Typography, +} from '@material-ui/core'; +import React, { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; + +import { playlistApiRef } from '../../api'; +import { PlaylistEntitiesTable } from './PlaylistEntitiesTable'; +import { PlaylistHeader } from './PlaylistHeader'; + +const useStyles = makeStyles({ + followButton: { + top: '6px', + right: '8px', + }, +}); + +export const PlaylistPage = () => { + const classes = useStyles(); + const errorApi = useApi(errorApiRef); + const playlistApi = useApi(playlistApiRef); + const { playlistId } = useParams(); + + const [{ value: playlist, loading, error }, loadPlaylist] = useAsyncFn( + () => playlistApi.getPlaylist(playlistId!), + [playlistApi], + ); + + useEffect(() => { + loadPlaylist(); + }, [loadPlaylist]); + + const { allowed: followAllowed } = usePermission({ + permission: permissions.playlistFollowersUpdate, + resourceRef: playlist?.id, + }); + + const [{ loading: loadingFollowRequest }, followPlaylist] = + useAsyncFn(async () => { + try { + if (playlist!.isFollowing) { + await playlistApi.unfollowPlaylist(playlist!.id); + } else { + await playlistApi.followPlaylist(playlist!.id); + } + + loadPlaylist(); + } catch (e) { + errorApi.post(e); + } + }, [errorApi, loadPlaylist, playlist, playlistApi]); + + if (error) { + return ( + + ); + } + + return ( + <> + {loading && } + + {playlist && ( + <> + + + + + {playlist.isFollowing ? 'Following' : 'Follow'} + + ) + } + /> + + + + {playlist.description} + + + +
+ +
+ + )} +
+ + ); +}; diff --git a/plugins/playlist/src/components/PlaylistPage/index.ts b/plugins/playlist/src/components/PlaylistPage/index.ts new file mode 100644 index 0000000000..695b243cd0 --- /dev/null +++ b/plugins/playlist/src/components/PlaylistPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './PlaylistPage'; diff --git a/plugins/playlist/src/components/PlaylistSearchBar/PlaylistSearchBar.test.tsx b/plugins/playlist/src/components/PlaylistSearchBar/PlaylistSearchBar.test.tsx new file mode 100644 index 0000000000..84650e4f41 --- /dev/null +++ b/plugins/playlist/src/components/PlaylistSearchBar/PlaylistSearchBar.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { DefaultPlaylistFilters } from '../../hooks'; +import { MockPlaylistListProvider } from '../../testUtils'; +import { PlaylistSearchBar, PlaylistTextFilter } from './PlaylistSearchBar'; + +describe('PlaylistSearchBar', () => { + it('should display search value and execute set callback', async () => { + const updateFilters = jest.fn(); + + const filters: DefaultPlaylistFilters = { + text: new PlaylistTextFilter('hello'), + }; + + const { getByDisplayValue } = render( + + + , + ); + + const searchInput = getByDisplayValue('hello'); + expect(searchInput).toBeInTheDocument(); + + fireEvent.change(searchInput, { target: { value: 'world' } }); + await waitFor(() => expect(updateFilters.mock.calls.length).toBe(1)); + expect(updateFilters).toHaveBeenCalledWith({ + text: new PlaylistTextFilter('world'), + }); + + fireEvent.change(searchInput, { target: { value: '' } }); + await waitFor(() => expect(updateFilters.mock.calls.length).toBe(2)); + expect(updateFilters).toHaveBeenCalledWith({ + text: undefined, + }); + }); +}); diff --git a/plugins/playlist/src/components/PlaylistSearchBar/PlaylistSearchBar.tsx b/plugins/playlist/src/components/PlaylistSearchBar/PlaylistSearchBar.tsx new file mode 100644 index 0000000000..94a22a23f5 --- /dev/null +++ b/plugins/playlist/src/components/PlaylistSearchBar/PlaylistSearchBar.tsx @@ -0,0 +1,102 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Playlist } from '@backstage/plugin-playlist-common'; +import { + FormControl, + IconButton, + Input, + InputAdornment, + makeStyles, + Toolbar, +} from '@material-ui/core'; +import Clear from '@material-ui/icons/Clear'; +import Search from '@material-ui/icons/Search'; +import React, { useState } from 'react'; +import useDebounce from 'react-use/lib/useDebounce'; + +import { usePlaylistList } from '../../hooks'; +import { PlaylistFilter } from '../../types'; + +export class PlaylistTextFilter implements PlaylistFilter { + constructor(readonly value: string) {} + + filterPlaylist(playlist: Playlist): boolean { + const upperCaseValue = this.value.toLocaleUpperCase('en-US'); + return ( + playlist.name.toLocaleUpperCase('en-US').includes(upperCaseValue) || + !!playlist.description + ?.toLocaleUpperCase('en-US') + .includes(upperCaseValue) + ); + } +} + +const useStyles = makeStyles(_theme => ({ + searchToolbar: { + paddingLeft: 0, + paddingRight: 0, + }, +})); + +export const PlaylistSearchBar = () => { + const classes = useStyles(); + + const { filters, updateFilters } = usePlaylistList(); + const [search, setSearch] = useState(filters.text?.value ?? ''); + + useDebounce( + () => { + updateFilters({ + text: search.length ? new PlaylistTextFilter(search) : undefined, + }); + }, + 250, + [search, updateFilters], + ); + + return ( + + + setSearch(event.target.value)} + value={search} + startAdornment={ + + + + } + endAdornment={ + + setSearch('')} + edge="end" + disabled={search.length === 0} + > + + + + } + /> + + + ); +}; diff --git a/plugins/playlist/src/components/PlaylistSearchBar/index.ts b/plugins/playlist/src/components/PlaylistSearchBar/index.ts new file mode 100644 index 0000000000..65b7e181cf --- /dev/null +++ b/plugins/playlist/src/components/PlaylistSearchBar/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './PlaylistSearchBar'; diff --git a/plugins/playlist/src/components/PlaylistSortPicker/PlaylistSortPicker.test.tsx b/plugins/playlist/src/components/PlaylistSortPicker/PlaylistSortPicker.test.tsx new file mode 100644 index 0000000000..9317bfd4c1 --- /dev/null +++ b/plugins/playlist/src/components/PlaylistSortPicker/PlaylistSortPicker.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { fireEvent, getByRole, render, waitFor } from '@testing-library/react'; +import { act } from '@testing-library/react-hooks'; +import React from 'react'; + +import { MockPlaylistListProvider } from '../../testUtils'; +import { + DefaultPlaylistSortTypes, + DefaultSortCompareFunctions, + PlaylistSortPicker, +} from './PlaylistSortPicker'; + +describe('PlaylistSortPicker', () => { + it('should update sort based on selection', async () => { + const updateSort = jest.fn(); + + const { getByText, getByTestId } = render( + + + , + ); + + act(() => { + fireEvent.mouseDown( + getByRole(getByTestId('sort-picker-select'), 'button'), + ); + }); + const abcSort = getByText('Alphabetical'); + expect(abcSort).toBeInTheDocument(); + + fireEvent.click(abcSort); + await waitFor(() => { + expect(updateSort).toHaveBeenLastCalledWith( + DefaultSortCompareFunctions[DefaultPlaylistSortTypes.alphabetical], + ); + }); + + act(() => { + fireEvent.mouseDown( + getByRole(getByTestId('sort-picker-select'), 'button'), + ); + }); + const entitiesSort = getByText('# Entities'); + expect(entitiesSort).toBeInTheDocument(); + + fireEvent.click(entitiesSort); + await waitFor(() => { + expect(updateSort).toHaveBeenLastCalledWith( + DefaultSortCompareFunctions[DefaultPlaylistSortTypes.numEntities], + ); + }); + }); +}); diff --git a/plugins/playlist/src/components/PlaylistSortPicker/PlaylistSortPicker.tsx b/plugins/playlist/src/components/PlaylistSortPicker/PlaylistSortPicker.tsx new file mode 100644 index 0000000000..21c961eb91 --- /dev/null +++ b/plugins/playlist/src/components/PlaylistSortPicker/PlaylistSortPicker.tsx @@ -0,0 +1,115 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Playlist } from '@backstage/plugin-playlist-common'; +import { + ListSubheader, + makeStyles, + MenuItem, + Select, + Typography, +} from '@material-ui/core'; +import SwapVertIcon from '@material-ui/icons/SwapVert'; +import React from 'react'; +import useEffectOnce from 'react-use/lib/useEffectOnce'; + +import { usePlaylistList } from '../../hooks'; +import { PlaylistSortCompareFunction } from '../../types'; + +export const enum DefaultPlaylistSortTypes { + popular = 'popular', + alphabetical = 'alphabetical', + numEntities = 'numEntities', +} + +export const DefaultSortCompareFunctions: { + [type in DefaultPlaylistSortTypes]: PlaylistSortCompareFunction; +} = { + [DefaultPlaylistSortTypes.popular]: (a: Playlist, b: Playlist) => { + if (a.followers < b.followers) { + return 1; + } else if (a.followers > b.followers) { + return -1; + } + return 0; + }, + [DefaultPlaylistSortTypes.alphabetical]: (a: Playlist, b: Playlist) => + a.name.localeCompare(b.name), + [DefaultPlaylistSortTypes.numEntities]: (a: Playlist, b: Playlist) => { + if (a.entities < b.entities) { + return 1; + } else if (a.entities > b.entities) { + return -1; + } + return 0; + }, +}; + +const sortTypeLabels = { + [DefaultPlaylistSortTypes.popular]: 'Popular', + [DefaultPlaylistSortTypes.alphabetical]: 'Alphabetical', + [DefaultPlaylistSortTypes.numEntities]: '# Entities', +}; + +const useStyles = makeStyles({ + select: { + width: '10rem', + marginRight: '1rem', + }, + icon: { + verticalAlign: 'bottom', + }, +}); + +export const PlaylistSortPicker = () => { + const classes = useStyles(); + const { updateSort } = usePlaylistList(); + + useEffectOnce(() => + updateSort(DefaultSortCompareFunctions[DefaultPlaylistSortTypes.popular]), + ); + + return ( + + ); +}; diff --git a/plugins/playlist/src/components/PlaylistSortPicker/index.ts b/plugins/playlist/src/components/PlaylistSortPicker/index.ts new file mode 100644 index 0000000000..04ea5268ef --- /dev/null +++ b/plugins/playlist/src/components/PlaylistSortPicker/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './PlaylistSortPicker'; diff --git a/plugins/playlist/src/components/Router/Router.test.tsx b/plugins/playlist/src/components/Router/Router.test.tsx new file mode 100644 index 0000000000..ea8620ec42 --- /dev/null +++ b/plugins/playlist/src/components/Router/Router.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { PlaylistIndexPage } from '../PlaylistIndexPage'; +import { PlaylistPage } from '../PlaylistPage'; +import { Router } from './Router'; +import { renderInTestApp } from '@backstage/test-utils'; + +jest.mock('../PlaylistIndexPage', () => ({ + PlaylistIndexPage: jest.fn(() => null), +})); + +jest.mock('../PlaylistPage', () => ({ + PlaylistPage: jest.fn(() => null), +})); + +describe('Router', () => { + beforeEach(() => { + (PlaylistPage as jest.Mock).mockClear(); + (PlaylistIndexPage as jest.Mock).mockClear(); + }); + describe('/', () => { + it('should render the PlaylistIndexPage', async () => { + await renderInTestApp(); + expect(PlaylistIndexPage).toHaveBeenCalled(); + }); + }); + + describe('/:playlistId', () => { + it('should render the PlaylistPage page', async () => { + await renderInTestApp(, { + routeEntries: ['/my-playlist'], + }); + expect(PlaylistPage).toHaveBeenCalled(); + }); + }); +}); diff --git a/plugins/playlist/src/components/Router/Router.tsx b/plugins/playlist/src/components/Router/Router.tsx new file mode 100644 index 0000000000..5791a0817c --- /dev/null +++ b/plugins/playlist/src/components/Router/Router.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { Routes, Route } from 'react-router'; + +import { playlistRouteRef } from '../../routes'; +import { PlaylistIndexPage } from '../PlaylistIndexPage'; +import { PlaylistPage } from '../PlaylistPage'; + +export const Router = () => ( + + } /> + } /> + +); diff --git a/plugins/playlist/src/components/Router/index.ts b/plugins/playlist/src/components/Router/index.ts new file mode 100644 index 0000000000..6606629fee --- /dev/null +++ b/plugins/playlist/src/components/Router/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './Router'; diff --git a/plugins/playlist/src/components/index.ts b/plugins/playlist/src/components/index.ts new file mode 100644 index 0000000000..2be300ac51 --- /dev/null +++ b/plugins/playlist/src/components/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './CreatePlaylistButton'; +export * from './EntityPlaylistDialog'; +export * from './PersonalListPicker'; +export * from './PlaylistCard'; +export * from './PlaylistEditDialog'; +export * from './PlaylistIndexPage'; +export * from './PlaylistList'; +export * from './PlaylistOwnerPicker'; +export * from './PlaylistPage'; +export * from './PlaylistSearchBar'; +export * from './PlaylistSortPicker'; diff --git a/plugins/playlist/src/hooks/index.ts b/plugins/playlist/src/hooks/index.ts new file mode 100644 index 0000000000..316121b515 --- /dev/null +++ b/plugins/playlist/src/hooks/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './usePlaylistList'; diff --git a/plugins/playlist/src/hooks/usePlaylistList.test.tsx b/plugins/playlist/src/hooks/usePlaylistList.test.tsx new file mode 100644 index 0000000000..d04996513e --- /dev/null +++ b/plugins/playlist/src/hooks/usePlaylistList.test.tsx @@ -0,0 +1,225 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ConfigApi, + configApiRef, + IdentityApi, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { Playlist } from '@backstage/plugin-playlist-common'; +import { TestApiProvider } from '@backstage/test-utils'; +import { act, renderHook } from '@testing-library/react-hooks'; +import qs from 'qs'; +import React, { PropsWithChildren } from 'react'; +import { MemoryRouter } from 'react-router'; +import { PlaylistApi, playlistApiRef } from '../api'; +import { + DefaultSortCompareFunctions, + DefaultPlaylistSortTypes, + PersonalListFilter, + PersonalListFilterValue, + PersonalListPicker, + PlaylistOwnerPicker, +} from '../components'; +import { PlaylistListProvider, usePlaylistList } from './usePlaylistList'; + +const playlists: Playlist[] = [ + { + id: 'id1', + name: 'list-1', + owner: 'user:default/guest', + public: true, + entities: 2, + followers: 5, + isFollowing: false, + }, + { + id: 'id2', + name: 'list-2', + owner: 'group:default/foo', + public: true, + entities: 3, + followers: 2, + isFollowing: true, + }, +]; + +const mockConfigApi = { + getOptionalString: () => '', +} as Partial; + +const mockIdentityApi: Partial = { + getBackstageIdentity: async () => ({ + type: 'user', + userEntityRef: 'user:default/guest', + ownershipEntityRefs: [], + }), +}; + +const mockPlaylistApi: Partial = { + getAllPlaylists: jest.fn().mockImplementation(async () => playlists), +}; + +const wrapper = ({ + location, + children, +}: PropsWithChildren<{ + location?: string; +}>) => { + return ( + + + + + + {children} + + + + ); +}; + +describe('', () => { + const origReplaceState = window.history.replaceState; + beforeEach(() => { + window.history.replaceState = jest.fn(); + }); + afterEach(() => { + window.history.replaceState = origReplaceState; + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('resolves backend filters', async () => { + const { result, waitForValueToChange } = renderHook( + () => usePlaylistList(), + { + wrapper, + }, + ); + await waitForValueToChange(() => result.current.backendPlaylists); + expect(result.current.backendPlaylists.length).toBe(2); + expect(mockPlaylistApi.getAllPlaylists).toHaveBeenCalled(); + }); + + it('resolves frontend filters', async () => { + const { result, waitFor } = renderHook(() => usePlaylistList(), { + wrapper, + }); + await waitFor(() => !!result.current.playlists.length); + expect(result.current.backendPlaylists.length).toBe(2); + + act(() => + result.current.updateFilters({ + personal: new PersonalListFilter( + PersonalListFilterValue.owned, + playlist => playlist.name === 'list-1', + ), + }), + ); + + await waitFor(() => { + expect(result.current.backendPlaylists.length).toBe(2); + expect(result.current.playlists.length).toBe(1); + expect(mockPlaylistApi.getAllPlaylists).toHaveBeenCalledTimes(1); + }); + }); + + it('resolves query param filter values', async () => { + const query = qs.stringify({ + filters: { personal: 'all', owners: ['user:default/guest'] }, + }); + const { result, waitFor } = renderHook(() => usePlaylistList(), { + wrapper, + initialProps: { + location: `/playlist?${query}`, + }, + }); + await waitFor(() => !!result.current.queryParameters); + expect(result.current.queryParameters).toEqual({ + personal: 'all', + owners: ['user:default/guest'], + }); + }); + + it('does not fetch when only frontend filters change', async () => { + const { result, waitFor } = renderHook(() => usePlaylistList(), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.playlists.length).toBe(2); + expect(mockPlaylistApi.getAllPlaylists).toHaveBeenCalledTimes(1); + }); + + act(() => + result.current.updateFilters({ + personal: new PersonalListFilter( + PersonalListFilterValue.owned, + playlist => playlist.name === 'list-1', + ), + }), + ); + + await waitFor(() => { + expect(result.current.playlists.length).toBe(1); + expect(mockPlaylistApi.getAllPlaylists).toHaveBeenCalledTimes(1); + }); + }); + + it('applies custom sorting', async () => { + const { result, waitFor } = renderHook(() => usePlaylistList(), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.playlists.length).toBe(2); + expect(result.current.playlists[0].name).toEqual('list-1'); + expect(result.current.playlists[1].name).toEqual('list-2'); + }); + + act(() => + result.current.updateSort( + DefaultSortCompareFunctions[DefaultPlaylistSortTypes.numEntities], + ), + ); + + await waitFor(() => { + expect(result.current.playlists.length).toBe(2); + expect(result.current.playlists[0].name).toEqual('list-2'); + expect(result.current.playlists[1].name).toEqual('list-1'); + }); + }); + + it('returns an error on playlistApi failure', async () => { + mockPlaylistApi.getAllPlaylists = jest.fn().mockRejectedValue('error'); + const { result, waitFor } = renderHook(() => usePlaylistList(), { + wrapper, + }); + await waitFor(() => { + expect(result.current.error).toBeDefined(); + }); + }); +}); diff --git a/plugins/playlist/src/hooks/usePlaylistList.tsx b/plugins/playlist/src/hooks/usePlaylistList.tsx new file mode 100644 index 0000000000..93c0becbfd --- /dev/null +++ b/plugins/playlist/src/hooks/usePlaylistList.tsx @@ -0,0 +1,284 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useApi } from '@backstage/core-plugin-api'; +import { Playlist } from '@backstage/plugin-playlist-common'; +import { compact, isEqual } from 'lodash'; +import qs from 'qs'; +import React, { + createContext, + PropsWithChildren, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import { useLocation } from 'react-router'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; +import useDebounce from 'react-use/lib/useDebounce'; +import useMountedState from 'react-use/lib/useMountedState'; + +import { playlistApiRef } from '../api'; +import { PersonalListFilter } from '../components/PersonalListPicker'; +import { PlaylistOwnerFilter } from '../components/PlaylistOwnerPicker'; +import { PlaylistTextFilter } from '../components/PlaylistSearchBar'; +import { + DefaultPlaylistSortTypes, + DefaultSortCompareFunctions, +} from '../components/PlaylistSortPicker'; +import { PlaylistFilter, PlaylistSortCompareFunction } from '../types'; + +class NoopFilter implements PlaylistFilter { + getBackendFilters() { + return { '': null }; + } +} + +export type DefaultPlaylistFilters = { + noop?: NoopFilter; + owners?: PlaylistOwnerFilter; + personal?: PersonalListFilter; + text?: PlaylistTextFilter; +}; + +export type PlaylistListContextProps< + PlaylistFilters extends DefaultPlaylistFilters = DefaultPlaylistFilters, +> = { + /** + * The currently registered filters, adhering to the shape of DefaultPlaylistFilters + */ + filters: PlaylistFilters; + + /** + * The resolved list of playlists, after all filters are applied. + */ + playlists: Playlist[]; + + /** + * The resolved list of playlists, after _only playlist-backend_ filters are applied. + */ + backendPlaylists: Playlist[]; + + /** + * Update one or more of the registered filters. Optional filters can be set to `undefined` to + * reset the filter. + */ + updateFilters: ( + filters: + | Partial + | ((prevFilters: PlaylistFilters) => Partial), + ) => void; + + /** + * Update the compare function used to sort playlists. + */ + updateSort: (compareFn: PlaylistSortCompareFunction) => void; + + /** + * Filter values from query parameters. + */ + queryParameters: Partial>; + + loading: boolean; + error?: Error; +}; + +export const PlaylistListContext = createContext< + PlaylistListContextProps | undefined +>(undefined); + +const reduceBackendFilters = ( + filters: PlaylistFilter[], +): Record => { + return filters.reduce((compoundFilter, filter) => { + return { + ...compoundFilter, + ...(filter.getBackendFilters ? filter.getBackendFilters() : {}), + }; + }, {} as Record); +}; + +type OutputState = { + appliedFilters: PlaylistFilters; + playlists: Playlist[]; + backendPlaylists: Playlist[]; +}; + +export const PlaylistListProvider = < + PlaylistFilters extends DefaultPlaylistFilters, +>({ + children, +}: PropsWithChildren<{}>) => { + const isMounted = useMountedState(); + const playlistApi = useApi(playlistApiRef); + const [sortCompareFn, setSortCompareFn] = + useState( + () => DefaultSortCompareFunctions[DefaultPlaylistSortTypes.popular], + ); + const [requestedFilters, setRequestedFilters] = useState( + {} as PlaylistFilters, + ); + + // We use react-router's useLocation hook so updates from external sources trigger an update to + // the queryParameters in outputState. Updates from this hook use replaceState below and won't + // trigger a useLocation change; this would instead come from an external source, such as a manual + // update of the URL or two sidebar links with different filters. + const location = useLocation(); + const queryParameters = useMemo( + () => + (qs.parse(location.search, { + ignoreQueryPrefix: true, + }).filters ?? {}) as Record, + [location], + ); + + const [outputState, setOutputState] = useState>({ + appliedFilters: { + noop: new NoopFilter(), // Init with a noop filter to trigger intial request + } as PlaylistFilters, + playlists: [], + backendPlaylists: [], + }); + + // The main async filter worker. Note that while it has a lot of dependencies + // in terms of its implementation, the triggering only happens (debounced) + // based on the requested filters/sortCompareFn changing. + const [{ loading, error }, refresh] = useAsyncFn( + async () => { + const compacted: PlaylistFilter[] = compact( + Object.values(requestedFilters), + ); + const playlistFilter = (p: Playlist) => + compacted.every( + filter => !filter.filterPlaylist || filter.filterPlaylist(p), + ); + const backendFilter = reduceBackendFilters(compacted); + const previousBackendFilter = reduceBackendFilters( + compact(Object.values(outputState.appliedFilters)), + ); + + const queryParams = Object.keys(requestedFilters).reduce( + (params, key) => { + const filter: PlaylistFilter | undefined = + requestedFilters[key as keyof PlaylistFilters]; + if (filter?.toQueryValue) { + params[key] = filter.toQueryValue(); + } + return params; + }, + {} as Record, + ); + + if (!isEqual(previousBackendFilter, backendFilter)) { + const response = await playlistApi.getAllPlaylists({ + filter: backendFilter, + }); + setOutputState({ + appliedFilters: requestedFilters, + backendPlaylists: response, + playlists: response.filter(playlistFilter).sort(sortCompareFn), + }); + } else { + setOutputState({ + appliedFilters: requestedFilters, + backendPlaylists: outputState.backendPlaylists, + playlists: outputState.backendPlaylists + .filter(playlistFilter) + .sort(sortCompareFn), + }); + } + + if (isMounted()) { + const oldParams = qs.parse(location.search, { + ignoreQueryPrefix: true, + }); + const newParams = qs.stringify( + { ...oldParams, filters: queryParams }, + { addQueryPrefix: true, arrayFormat: 'repeat' }, + ); + const newUrl = `${window.location.pathname}${newParams}`; + // We use direct history manipulation since useSearchParams and + // useNavigate in react-router-dom cause unnecessary extra rerenders. + // Also make sure to replace the state rather than pushing, since we + // don't want there to be back/forward slots for every single filter + // change. + window.history?.replaceState(null, document.title, newUrl); + } + }, + [ + playlistApi, + queryParameters, + requestedFilters, + sortCompareFn, + outputState, + ], + { loading: true }, + ); + + // Slight debounce on the refresh, since (especially on page load) several + // filters will be calling this in rapid succession. + useDebounce(refresh, 10, [requestedFilters, sortCompareFn]); + + const updateFilters = useCallback( + ( + update: + | Partial + | ((prevFilters: PlaylistFilters) => Partial), + ) => { + setRequestedFilters(prevFilters => { + const newFilters = + typeof update === 'function' ? update(prevFilters) : update; + return { ...prevFilters, ...newFilters }; + }); + }, + [], + ); + + const updateSort = useCallback( + (compareFn: PlaylistSortCompareFunction) => + setSortCompareFn(() => compareFn), + [], + ); + + const value = useMemo( + () => ({ + filters: outputState.appliedFilters, + playlists: outputState.playlists, + backendPlaylists: outputState.backendPlaylists, + updateFilters, + updateSort, + queryParameters, + loading, + error, + }), + [outputState, updateFilters, updateSort, queryParameters, loading, error], + ); + + return ( + + {children} + + ); +}; + +export function usePlaylistList< + PlaylistFilters extends DefaultPlaylistFilters = DefaultPlaylistFilters, +>(): PlaylistListContextProps { + const context = useContext(PlaylistListContext); + if (!context) + throw new Error('usePlaylistList must be used within PlaylistListProvider'); + return context; +} diff --git a/plugins/playlist/src/index.ts b/plugins/playlist/src/index.ts new file mode 100644 index 0000000000..3b7004ce22 --- /dev/null +++ b/plugins/playlist/src/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Playlist frontend plugin + * + * @packageDocumentation + */ +export type { EntityPlaylistDialogProps } from './components'; +export { + EntityPlaylistDialog, + playlistPlugin, + PlaylistIndexPage, +} from './plugin'; +export * from './api'; diff --git a/plugins/playlist/src/plugin.test.ts b/plugins/playlist/src/plugin.test.ts new file mode 100644 index 0000000000..a2dfc7fcd7 --- /dev/null +++ b/plugins/playlist/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { playlistPlugin } from './plugin'; + +describe('playlist', () => { + it('should export plugin', () => { + expect(playlistPlugin).toBeDefined(); + }); +}); diff --git a/plugins/playlist/src/plugin.ts b/plugins/playlist/src/plugin.ts new file mode 100644 index 0000000000..8e73fffe37 --- /dev/null +++ b/plugins/playlist/src/plugin.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createApiFactory, + createComponentExtension, + createPlugin, + createRoutableExtension, + discoveryApiRef, + fetchApiRef, +} from '@backstage/core-plugin-api'; + +import { playlistApiRef, PlaylistClient } from './api'; +import { rootRouteRef } from './routes'; + +/** + * @public + */ +export const playlistPlugin = createPlugin({ + id: 'playlist', + routes: { + root: rootRouteRef, + }, + apis: [ + createApiFactory({ + api: playlistApiRef, + deps: { + discoveryApi: discoveryApiRef, + fetchApi: fetchApiRef, + }, + factory: ({ discoveryApi, fetchApi }) => + new PlaylistClient({ discoveryApi, fetchApi }), + }), + ], +}); + +/** + * @public + */ +export const PlaylistIndexPage = playlistPlugin.provide( + createRoutableExtension({ + name: 'PlaylistIndexPage', + component: () => import('./components/Router').then(m => m.Router), + mountPoint: rootRouteRef, + }), +); + +/** + * @public + */ +export const EntityPlaylistDialog = playlistPlugin.provide( + createComponentExtension({ + name: 'EntityPlaylistDialog', + component: { + lazy: () => + import('./components/EntityPlaylistDialog').then( + m => m.EntityPlaylistDialog, + ), + }, + }), +); diff --git a/plugins/playlist/src/routes.ts b/plugins/playlist/src/routes.ts new file mode 100644 index 0000000000..632dc06439 --- /dev/null +++ b/plugins/playlist/src/routes.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createRouteRef, createSubRouteRef } from '@backstage/core-plugin-api'; + +export const rootRouteRef = createRouteRef({ + id: 'playlist:index-page', +}); + +export const playlistRouteRef = createSubRouteRef({ + id: 'playlist:playlist-page', + parent: rootRouteRef, + path: '/:playlistId', +}); diff --git a/plugins/playlist/src/setupTests.ts b/plugins/playlist/src/setupTests.ts new file mode 100644 index 0000000000..9bb3e72355 --- /dev/null +++ b/plugins/playlist/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/plugins/playlist/src/testUtils/MockPlaylistListProvider.tsx b/plugins/playlist/src/testUtils/MockPlaylistListProvider.tsx new file mode 100644 index 0000000000..2acef15e14 --- /dev/null +++ b/plugins/playlist/src/testUtils/MockPlaylistListProvider.tsx @@ -0,0 +1,102 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { + PropsWithChildren, + useCallback, + useMemo, + useState, +} from 'react'; +import { + DefaultPlaylistSortTypes, + DefaultSortCompareFunctions, +} from '../components'; +import { + DefaultPlaylistFilters, + PlaylistListContext, + PlaylistListContextProps, +} from '../hooks'; +import { PlaylistSortCompareFunction } from '../types'; + +export const MockPlaylistListProvider = ({ + children, + value, +}: PropsWithChildren<{ + value?: Partial; +}>) => { + const [filters, setFilters] = useState( + value?.filters ?? {}, + ); + + const [_, setSortCompareFn] = useState( + () => DefaultSortCompareFunctions[DefaultPlaylistSortTypes.popular], + ); + + const updateFilters = useCallback( + ( + update: + | Partial + | (( + prevFilters: DefaultPlaylistFilters, + ) => Partial), + ) => { + setFilters(prevFilters => { + const newFilters = + typeof update === 'function' ? update(prevFilters) : update; + return { ...prevFilters, ...newFilters }; + }); + }, + [], + ); + + const updateSort = useCallback( + (compareFn: PlaylistSortCompareFunction) => + setSortCompareFn(() => compareFn), + [], + ); + + // Memoize the default values since pickers have useEffect triggers on these; naively defaulting + // below with `?? ` breaks referential equality on subsequent updates. + const defaultValues = useMemo( + () => ({ + playlists: [], + backendPlaylists: [], + queryParameters: {}, + }), + [], + ); + + const resolvedValue: PlaylistListContextProps = useMemo( + () => ({ + playlists: value?.playlists ?? defaultValues.playlists, + backendPlaylists: + value?.backendPlaylists ?? defaultValues.backendPlaylists, + updateFilters: value?.updateFilters ?? updateFilters, + filters, + updateSort: value?.updateSort ?? updateSort, + loading: value?.loading ?? false, + queryParameters: value?.queryParameters ?? defaultValues.queryParameters, + error: value?.error, + }), + [value, defaultValues, filters, updateFilters, updateSort], + ); + + return ( + + {children} + + ); +}; diff --git a/plugins/playlist/src/testUtils/index.ts b/plugins/playlist/src/testUtils/index.ts new file mode 100644 index 0000000000..9b6f333a59 --- /dev/null +++ b/plugins/playlist/src/testUtils/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './MockPlaylistListProvider'; diff --git a/plugins/playlist/src/types.ts b/plugins/playlist/src/types.ts new file mode 100644 index 0000000000..fbd348f98a --- /dev/null +++ b/plugins/playlist/src/types.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Playlist } from '@backstage/plugin-playlist-common'; + +export type PlaylistFilter = { + getBackendFilters?: () => Record; + + filterPlaylist?: (playlist: Playlist) => boolean; + + toQueryValue?: () => string | string[]; +}; + +export type PlaylistSortCompareFunction = (a: Playlist, b: Playlist) => number; diff --git a/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.tsx index 35d1f376e5..c14dfdfa7e 100644 --- a/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.tsx +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.tsx @@ -62,7 +62,7 @@ export const AutocompleteFilter = (props: SearchAutocompleteFilterProps) => { defaultValues, valuesDebounceMs, ); - const { filters, setFilters } = useSearch(); + const { filters, setFilters, setPageCursor } = useSearch(); const filterValue = (filters[name] as string | string[] | undefined) || (multiple ? [] : null); @@ -79,6 +79,7 @@ export const AutocompleteFilter = (props: SearchAutocompleteFilterProps) => { } return { ...others }; }); + setPageCursor(undefined); }; // Provide the input field. diff --git a/plugins/search-react/src/components/SearchFilter/SearchFilter.test.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.test.tsx index 89a1818e8d..27fbae237c 100644 --- a/plugins/search-react/src/components/SearchFilter/SearchFilter.test.tsx +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.test.tsx @@ -33,6 +33,7 @@ describe('SearchFilter', () => { term: '', filters: {}, types: [], + pageCursor: 'abc123', }; const label = 'Field'; @@ -138,7 +139,10 @@ describe('SearchFilter', () => { await userEvent.click(checkBox); await waitFor(() => { expect(query).toHaveBeenLastCalledWith( - expect.objectContaining({ filters: { field: [values[0]] } }), + expect.objectContaining({ + filters: { field: [values[0]] }, + pageCursor: undefined, + }), ); }); @@ -146,7 +150,7 @@ describe('SearchFilter', () => { await userEvent.click(checkBox); await waitFor(() => { expect(query).toHaveBeenLastCalledWith( - expect.objectContaining({ filters: {} }), + expect.objectContaining({ filters: {}, pageCursor: undefined }), ); }); }); @@ -170,6 +174,7 @@ describe('SearchFilter', () => { expect(query).toHaveBeenLastCalledWith( expect.objectContaining({ filters: { ...filters, field: [values[0]] }, + pageCursor: undefined, }), ); }); @@ -178,7 +183,7 @@ describe('SearchFilter', () => { await userEvent.click(checkBox); await waitFor(() => { expect(query).toHaveBeenLastCalledWith( - expect.objectContaining({ filters }), + expect.objectContaining({ filters, pageCursor: undefined }), ); }); }); @@ -337,6 +342,7 @@ describe('SearchFilter', () => { expect(query).toHaveBeenLastCalledWith( expect.objectContaining({ filters: { [name]: values[0] }, + pageCursor: undefined, }), ); }); @@ -353,6 +359,7 @@ describe('SearchFilter', () => { expect(query).toHaveBeenLastCalledWith( expect.objectContaining({ filters: {}, + pageCursor: undefined, }), ); }); @@ -388,6 +395,7 @@ describe('SearchFilter', () => { expect(query).toHaveBeenLastCalledWith( expect.objectContaining({ filters: { ...filters, [name]: values[0] }, + pageCursor: undefined, }), ); }); @@ -402,7 +410,7 @@ describe('SearchFilter', () => { await waitFor(() => { expect(query).toHaveBeenLastCalledWith( - expect.objectContaining({ filters }), + expect.objectContaining({ filters, pageCursor: undefined }), ); }); }); diff --git a/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx index 2a6d7baafa..958caf0927 100644 --- a/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx @@ -82,7 +82,7 @@ export const CheckboxFilter = (props: SearchFilterComponentProps) => { valuesDebounceMs, } = props; const classes = useStyles(); - const { filters, setFilters } = useSearch(); + const { filters, setFilters, setPageCursor } = useSearch(); useDefaultFilterValue(name, defaultValue); const asyncValues = typeof givenValues === 'function' ? givenValues : undefined; @@ -106,6 +106,7 @@ export const CheckboxFilter = (props: SearchFilterComponentProps) => { const items = checked ? [...rest, value] : rest; return items.length ? { ...others, [name]: items } : others; }); + setPageCursor(undefined); }; return ( @@ -161,7 +162,7 @@ export const SelectFilter = (props: SearchFilterComponentProps) => { defaultValues, valuesDebounceMs, ); - const { filters, setFilters } = useSearch(); + const { filters, setFilters, setPageCursor } = useSearch(); const handleChange = (e: ChangeEvent<{ value: unknown }>) => { const { @@ -172,6 +173,7 @@ export const SelectFilter = (props: SearchFilterComponentProps) => { const { [name]: filter, ...others } = prevFilters; return value ? { ...others, [name]: value as string } : others; }); + setPageCursor(undefined); }; return ( From 2028a73c9661b1589e2e4f15faab6427200791f9 Mon Sep 17 00:00:00 2001 From: Phil Kuang Date: Tue, 6 Sep 2022 17:36:17 -0400 Subject: [PATCH 043/192] refactor(playlists): address review comments Signed-off-by: Phil Kuang --- .changeset/calm-snakes-tap.md | 2 +- .github/CODEOWNERS | 4 + microsite/static/img/playlist-logo.png | Bin 14734 -> 14844 bytes packages/backend/src/plugins/playlist.ts | 22 ++--- packages/core-components/api-report.md | 24 +++-- .../src/components/Table/Table.tsx | 2 +- .../HeaderActionMenu.test.tsx | 6 +- .../HeaderActionMenu/HeaderActionMenu.tsx | 16 ++-- .../src/layout/HeaderActionMenu/index.ts | 2 +- plugins/playlist-backend/README.md | 21 ++--- plugins/playlist-backend/api-report.md | 7 +- .../migrations/20220701011329_init.js | 8 +- plugins/playlist-backend/package.json | 18 ++-- .../src/service/ListPlaylistsFilter.ts | 13 ++- .../src/service/router.test.ts | 71 +++++++++++++-- .../playlist-backend/src/service/router.ts | 55 ++++++++++-- .../src/service/standaloneServer.ts | 5 +- plugins/playlist-common/package.json | 4 +- plugins/playlist/api-report.md | 12 +-- plugins/playlist/package.json | 23 +++-- plugins/playlist/src/api/PlaylistApi.ts | 3 +- .../playlist/src/api/PlaylistClient.test.ts | 24 ++++- plugins/playlist/src/api/PlaylistClient.ts | 3 +- .../EntityPlaylistDialog.tsx | 7 +- .../PlaylistEntitiesTable.test.tsx | 48 ++-------- .../PlaylistPage/PlaylistEntitiesTable.tsx | 48 ++-------- yarn.lock | 83 +++++++++++++++++- 27 files changed, 330 insertions(+), 201 deletions(-) diff --git a/.changeset/calm-snakes-tap.md b/.changeset/calm-snakes-tap.md index 6b853da53d..6fdf092ede 100644 --- a/.changeset/calm-snakes-tap.md +++ b/.changeset/calm-snakes-tap.md @@ -2,4 +2,4 @@ '@backstage/core-components': patch --- -Export `HeaderActionMenu` and expose default `Table` icons via `Table.tableIcons` +Export `HeaderActionMenu` and expose default `Table` icons via `Table.icons` diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f00771d29d..b0f1e828cd 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -16,6 +16,8 @@ yarn.lock @backstage/reviewers @backst /docs/plugins/integrating-search-into-plugins.md @backstage/reviewers @backstage/techdocs-core /packages/techdocs-cli @backstage/reviewers @backstage/techdocs-core /packages/techdocs-cli-embedded-app @backstage/reviewers @backstage/techdocs-core +/plugins/adr @backstage/reviewers @kuangp +/plugins/adr-* @backstage/reviewers @kuangp /plugins/allure @backstage/reviewers @deepak-bhardwaj-ps /plugins/apache-airflow @backstage/reviewers @cmpadden /plugins/api-docs @backstage/reviewers @backstage/sda-se-reviewers @@ -47,6 +49,8 @@ yarn.lock @backstage/reviewers @backst /plugins/kubernetes @backstage/reviewers @backstage/warpspeed /plugins/kubernetes-* @backstage/reviewers @backstage/warpspeed /plugins/newrelic-dashboard @backstage/reviewers @mufaddal7 +/plugins/playlist @backstage/reviewers @kuangp +/plugins/playlist-* @backstage/reviewers @kuangp /plugins/scaffolder-backend-module-rails @backstage/reviewers @angeliski /plugins/scaffolder-backend-module-yeoman @backstage/reviewers @pawelmitka /plugins/search @backstage/reviewers @backstage/techdocs-core diff --git a/microsite/static/img/playlist-logo.png b/microsite/static/img/playlist-logo.png index b5c8d9940175d6221c1bf1cdf9ab473e5793a5fb..333a9f832a48f430f415c94cc9e1346d5a04ce20 100644 GIT binary patch literal 14844 zcmeHudpuNY`?nb*L&dDxItj~6B}o)fIn0uYP_%iTgdEyQwR1j%oMyJlv~!pqHrYaL zp_4;|ipEs-CPar_D5q^3D#sbyDPz3Xa(JHi^V{#=@B4W_k3ag%$Gz@#-S>T6-|KrF z?$xD3*5=D)*U3sqNG!M5&#;w{Aad}3-^i_eA^D9Aze}(Q_U6(qcp6acYXp8n?;+NgGLN&;1+-{y;X7Y=fuH?aQt1V>? z+S--BVmq$PIZxUoD_J9Ba`(!%oHZNWPo$`4dwySgDb;l(W?VaMWpCTixv_H@C!LMM zdg@IgGDh!>PHK)CO!XHB%h1Ub6k|#dh$Qkq$Voq*G_sug14dEOO{@7w6!g$W6utY< z@W8@T6m7%bnSGhHg+Orlm49sEZvx@7+<&CXkZZPVlV7>83VDCvGnylE{i5S0)xGu$*Mi9- z6N&!k)m#3whF`Vj)5!{)x3O0$u&rhdx8O^ zRq1ZI4u>&o9ZP9d?D~Bs({tJPkFJnN-TU9}IbJ{%E(_||fVq)i#b-(Verg25qm})- zb+R?5%|4?fvhFGhC(cAu|6fez`qPdl`~Qh$+!#zFkz-L<@#?>f24UskAJRP>2)kcy zRyr@i`VI22?YH=5kb2P$_QgJtt#Hh5}Xe2;m}BS~&A6FW{Iw5<8vp9ZS9k`wfs z%c2n$jj-qm|4%(ZPsf%1^4R+Gz?#dYEY*YckLIUk?-v+5TuV%%`fb1l7 z&AR4*`(d*Mqi;N~o3MMJ@mhL-Q_s{L<~5%jYX)8-N(0qNaw2|l0yiHx% z0iGh629oe;yIe3#*=O+bG%OYAaZC*_HnlmxPp6KQemDyJrB;ICb>uxDMftyzB6YWp1U>GPBHdKnJ6o;m!6}!Ya!EDA=Hm@aqrtQ*AGq(* zn9P1J>X<1$(G=#X{^p)I7oIe&@UZ~k^+&yZflW)v`}J{TkjS1u>58u^cEmB~xg18# zWyv+Pl8C$dVD68M{uq4XMVMzA`(o1^^*bixV?AKgfX1fJ`Ku`$Z-|s&vt}@l{`Jrk z>p-ou!3sv>0Tt%oPaXDwuvK=cSstIbOch0A)+8RBi)|%a@v(YM6z$C@c(x;-;rFq;c4#2@th}#+Ph7JX!{oMaN}CJbXB_act40C=XjH0FOT@buQ5@ZfS^+a?2WdH*10Cza>!dG+oXsXNv#C?^fj zlV;i0^XP9`8`1yHnJ?Nu5$(iULD?pW)l%*&%`StsZ-BP_Zw05=4zBg@)r(+5{#3OU zRWxzDD8c#+rsB{PIHvIf4-YP_F?&xeLT;V_>so`wciR$ncQ?m>e8BW{*IayIQA>-^ zSoDYgzgfeu;X}%7sG_p!D>SGs^w(FsjecZa3oOiw--tR{IL=1nWGKL_;yxZ5f~3M( zW-(Oa8e7cK7IU=!)*Q_}OVNKcYX7=CkF`Y7pw0CpfBEzvsU0KJv@ZR9RnpGW)7+U! zA$_J}e6nxmsJPFzmS5S?AH(!CJAH!y{jIDUCp`2VYj=>yt(80@$ma+zHGLH>Wv)Ro z14o4YC8sXISxuonUB<^Yzs;QIeh@U+p z+=CJB&oCUmd7P_G-4->S#h%E`PKpX>GbVLg*a=&BMJ5 zaO-ZR@twD(l{=~J;R;Cwn%Ncn_Ytx+*0~-|F;LQ&)z7U(u2CPtJX|8r3hpC(`}eFn?1lvhc#`L3m-WU`1R?W#o*mblA8_yq~v zlUb3Pm`iTG!Hl}pBS#oNXQUH#l3_UTrJ&<}Q}Dg8t5AZ*o~xIasWA&!$%#WXO?i!& zT#)pR5uwvSt75#0-tRqLV|$?Rj6A9VO-W}8p@sZr2VMpSqdF>Qt#*lfRQ0?PcS7A{ zcIN3xDKHacz_aQq=SrqdUT23i{L|1y{QGKp57bxz4J9qNN$xbuc`@!~Af|FFHA4}J zj5}Ut_kHPhLQAZF!v)a$BiRK(!3b#ZiSd7>gh=F6+LnGtl1O~^`ZG? z`7tAah}(h{-h$bThqfr&bX;D34a#YoER{c>gOqCvQgWf+8y>-v zSOh+n2PxVMQtZH%V!_=I(JMQ>&aAoG?M+K0oQ$=ADETLFGN*!P0%7ip36oA2(DSzc zq32_8uBc#!=q3B1s7;<&CQIHwZh|SN+}(mnNUXhse+%FzP3lH!xhCkKdUor}KN`TP zgg*8&>FkLK1J{?bE1}qtiTNPhAGxIHO3oZ=BA57&18g#t5ufI1Q)BHSC~rc~N%uQr zdV5#R;s83#=Ku!9w62}ykyTwffYY@(5C2>XDmc1;0dLqF_@7qGfaIzSXv8+?zEobEEoI;&Ucd9<#g7e^%ou3$J*Qv!v{b?^V=X6O)60mURu~6IfUhF^9^=oF)Ji1 zoLm*=x1fjX=L8~XJGi=}Sr)KQ@cr zjlKm?`>xJcfPVEhb|!1qN8sQul!mqHb&nklMF@|CfBE>HC4@@eOf#gSKEHIYB?fQp zJc;^XS5q?)guV_@36HKe+S?f+1G;|Jh$$~9n3u^2C^g%>0Iw-?h0?;9J z)mu&M=#*up0a)+sZuQlxbW za~md1+~lES7W0L126poNn|VC6kAY^FQoCBpB3?<7N}*LWaMr3q9aW5T!1R82unan2 zdEmNRsFAh%R15JI-rA7A_&`cB3yAbt%_(5Bs~>JAodwS~kWAoK0E?I`AbMKXy{Ff_ zDxrJ>NeoTHB@%ti#Hl=D0Sw8td6uh3Od#e+8Ui%%MZ>%B$f03yrdh{{qEWfO|BuJD_2ug{Pe8=aM>c1-784J7 zL>^&?zGH|=6&m_t+n}K}X!fS*BFWu#q)-uxgbFKEN2}7SkzY0>h~`0f6D}KNan(`j zb;)@v(874kgKJ1tE^oM${y+!9dJA^$WBhzWktZakPHsm8Ln$#5zB#Z(yC|m5(i*}= z_sh_e-N;F`iDjqU3;I5H-~fi5)#J36(&ihbg%y}bWEdlyFQiB`K$JT9MSj?eu$#y6 zyduFW0j&znSG$PGrgZW(_^_$*UnLV$ z4?@Fp3V)K%Py?(dn*8;6IiaP`z5lWVYa?Lf0wRdrC_nVd2+p68^ZE}Ll6#El{f$J@ zdxe_%vVB$61sa{*=|H*GOJ4Wsk=zRs?`siB$t!E>=VnC$(MkqrjN0XArS#!g7!c|c>zu?08Q9Y? zMd&ux)MqBJV>sw57VN~0Y~*8tjTtJ1(J(i4R=uba5s3ef@7>d!CC{Ado99 z&#KNN*C^+HXG0=yRfCwMfE3K05PI_cK|9j>t>7%=kI)&#PZ-V+)qIe(?zjREPpvWX z-iM9tQV64BZo3JU(%t^Fp=lnmXj~?}f=&n(N`z-X4|p|f#|{xF>5>DfC~B~lQScEz z%WqsV<%lJKdOKCzZyR&AIRlc+#>YEr|iU?@&(dv(%4#DGH-#CGajX?iqj0)c;i3WbTM3rwXCrhly zJ)D#Pr*aqFmd;{A&Adwf3i|32&qqC5)cE%eWGW&Bjzitnt+#p7Nw)1RpW8j}N`fQ* zTXV126RS6n1JU6#*+~hX+*C?m!CiHK$gWZ|CT|aDmW2ou`>r)x7_6H3Y+Fk+XwS?G zf{;3|!cHnD)hB^1{R;Ih;*F=+a=W{YCc=BcLL-D0WdgB}?B2s~!dxOXZ>o?QzM1%7 zTU&PR5uszy6^ucr8Nd}d0&uBA1pLeEo3>9WgoLZ}heped4EmtWKc+1ehWFIVXOJ;| z!j1~jm5Y&-U3NT`;~_a>v||+~dReWW4CSgTq@o5?7o8c4^g_r`F4*)+emNFKEQ(QY zmIPR}PwQ}06p~tw}Kf}G5LOux&4Pa`PoU; zK@YW@zRc1V<>S1sTQCg$z}M;Dc4}CucZ7D0N1UU;jYiFNPq=2^3D{kW2%hb_BMzJ)Gpn}PG9_4(B+;IlNsm3eOOf}J{3l1t zA}J5St1FPi7=W_xJ@ByYROQfq`v|XNtf9@>qR$r^hd%uTrd(0~Ctz*YUhv&5RB;#5 zAzV&`YEGT6!a;rQAhg-3HzLq%!N8JL?1^95A(fK!KD#rrqUW}?xp|?1x$Rp}w5&EX z0esb9DNb_FR5Z$pi}`<^0aZR@VQ|xQ@3j!W@sazcrd}(at+H!4(YPds7j31Uk~uII z&4Vv4y5nve?gs`v4uoz>?Q%7%GFP5D;p()%b121Dx%gG6QVE=AHUTA(`&BuqDfHD- z!rtRkklV0wQ*2!xR6l!vZTe}#{|7l zkkrXP5y7N3e3CKIV_U1Q>+;tBGI-#=B?DLCdkhT-b8L1>d>GMS=uoE;A{e%<)e3&7 z@3aowTuys~(+1qlx!b792YR@Sv=ct;N@w%%i@a;tw!+Az;-cc8DOvpzKdM zDRI-gMq_Tt^CvBDP{_aMf*ybivRCp7(%_-aa!YkrznC*25q^-bO-T8M6G^`zHTAh> zRpW8R#|;J!b*9_XrzQfF245yG0lt@9UUwL7H94vDFd2yzBt6-R!X#mR+|ao=UKk|G zb$Co-!2*}5UDDDYit@&2zLOCu^sFSI)wJgeYcq&LS=J3)8Oo8vz?ZwI84tEHqkR-Y z4y*ISO1b?;a}^TK!qE`EU>khbTa(PErQYSeG8IKhLg~h-3av&c>Q1KJ8iCup^AP{E zs2Sx|$CQNDQrl*_>iH_pGxh1)x+93=P;3R`q9h?Lj5#{JwL4n6m8+?uG29C{3F%jh z@Hz`FH+4QeL4tK!N>pn6HolbqR4f3UiOuTvRRJLVT zeAi)fBw`=vtn2smRCpVXOh5!**c0bZ%kj}Xl6bE=zt2(AL+O7z5J2@f^;zSN<;K;& zeT+J&&X>onPin_ItVFS8(#j( z>`bBG{w<+ID#O7t^p6iD>q=-!wK_F_#ocN@kk^+lO{T_wUAOsqAd0B9CEM^xrB};q zPmof2X%F0uTERy6O;4P~+3V=@)tO;1MDNn+ z$0xa@c4Z_%EBh!;1?7;ow_S?BFMF#&&p?mDNFT~4&7FHBHl$Yy&8dE z|Kd|F)4J5FqgqzWuX-rj-9!%>){pT158QfI0m~dQ{C?1GTg2l!pQe!y*;4Dp-vAHD zmM7q6-UW_Th6wzsBVMP-tq8FNMpynzGgEaK^z!BcPUu8pYU#yM z=o#amApjB#(J4L-Gg_}eT#aCazg5!g;_Hwy94#kVI>!BJq(P9;hIhudZsw#m{>UD3 z9R5U8o@}CZSxqn1p~ZW*8!fE~J=y0D>U;=juiQly#UAb$@6Lnt(Y99cYkN*x9w6C1 z?coT1&Q+|tbb~3aeO0DU1J#CqWQUwp=X8AX;p;mSfv5 zStC&a>U_mL9}@B?AzDx!?w!3dE(a_=LIj5Eb7e)A48z@Myd>e{Hm=(-q5*(F>5!%c5P}2j z2q;#lb3CQe*ey9A$Tz;O(jj8fp}MmDsf#BID}iM-kdq|RPo_uYoQOgVd+M~u5 z37J-g;@%z&C(c|=b}AFU5ne`24V}!3%uqt)GygD@&iuo?K41KSxGfp23P@(?=y+Xbc)(Z!RPkpD#4BP?+B_dGhjPj}{?{CsT9^Y< zJsIJ)jHBZF&^yu7=& zM4zDlwUsa3(bz%XOpTmQt`wUyMB`JU@yJJzJrv)XhPhA^wUT>xf=A7s_zn=(yB7xf ya3*|U@$sTs7U8hy28-;l$cq2hqrr^4=*S(lcUyh(;J#KfIyUz6jcV_&%nQ3*iYbVNbqA1`1S#)EFlb0 zHHm)={$pVZkTR2#gP;b}un^E6K0!eJg+M@pFKGXwKY;0P|Ct_Y2?6~N3s`{~&asDIPDe}UHo^)X<$gQY6K86YRiZESB#Z)jp~WJ>RD>+lxVpD~pQ%iyi!rkHo^+*@2sZ!OhK$-i?Lc-pQPSiHnPifsvVknVAlZpaXi?IUBmu z*#SxaDda!oh?)Y8oh%)kE$!`y{>n8pvUhRjBO&>#=-;1z+Uaa*_Me*Ufd9%CID-s- zpD-}dGcx?I*ua@pwlueJ2L9X1|HkWX=cxg3()j$(m%3kV`9bt z*6>g5|8UPgt>*n}6Ss)Ht%H*(5D3m4KNIgi?EG(O|C!5w;+0K-_BJkmouOuD>CDgk zFP8t2{6D!AoGeYj-u=t=Z!P~d?SJAGE$p4`!6ofvX)I;uZ0ZCy@n78kq40l?_%E{l z?O1LNQzI3y1XEkE8T@~%0`OmT!Pw9a=xlFn?eJG4{okUpu{1Wd1DY~$F|ji-ae?ax z?_c-*vw;4)od2TfueZ5n|G{bRq^2zUf6!s;0<<)ybFeY=u(1R>)47@&JKHaaZ>H~)MGoLE-ofJ#sc%Wev3S|0F#^JA<$2S+d>^=2;^+M|8vR`dh zww$dlQ+$2g?;G!$*3}*ND=~7J?E86QsEW{0g58!uNz?@-KVSi{-wYdxzd?Q?{t?7h zQm3jaCJ0wYdD7i478d4fy3l-rf1O_6O(PF5fD)w68DMBL%3&L3|nwFMC z_@Sw<`jHr*fPsP3GGc1bNi{2dl(;M^Iz@^c0S$;4pP2I!-aVR52FM>gx90 zqtfxikKTRJG`cyl8_vUTEt;Y*k$-qMBhg$toBcFBzjJt`QD`FHiO1_JMn*5bKw079 z)}EG9);yGk*8U=a@#T*+8H5WRgCVd|;#EjEl+=0Skx7{PZJS(EANk(i(Kfa}*jdHT ziCNhbD)69&u)t25>|!v1b7=gYZod25_Z98rD^-Qwe8AfV|KqD3JdIMF`ny|>mG)Oq zdUI0xI?R+g^YF)B&i51RX_ZEl>hsfqQ<$eqVRQU9MEk+EuInzxlDdy=UNB0UI86;@ zl4nKfXWM38>E;hqW^ccxm(qv4H#U`Ub?HT78QH8Ru3b3O9EkQ2dI+rqdvOL&`K0zz zvqECMk5{oP?0RKjR^we-q(G3)Mrc!u`9ay$Yyx9b*$U=y3h^yegmspL*Ow%Tu0%K! zJ?Ec)HVTCIM2NMVMQ#e)oiB6FFCce~1+wBhJigw5J`@IJ?}zYQ*ymzB#6!t958(zP z{P_br>rng6|$}6hmzq4td1qJhVsHKZKZ}b)gWjCt{v?0q|V|!4B+GeTN z$I#Zbet~@B75cm-pd4LYUaM&3M{T})5MDDP>>HT>%p8)?vA+PsqFa7t< zn*;luL;W@!9)0gG#q?H!`m~moM&_Ux9#=p{rhjxO-&o-us|)@jpqHCmIK(75q`~Le z6#)@3xh$3Gfy!_wsAvxvZIQ+>@b2!eq_mW4o1vX{U4Bvr_roWd$7{zkkLwR>iSj$W zr*^`Ik@E3&1RYU8LnHV<#|>VKj)znxYd9UlwX+S(pN#RBa^GI&Ic zo-~Vvis7v>>AK4mc*Bab?AmuTPOofyk(e|fVA{M;Y|46J-jCZ_UJ0uuhVpCydg#JY z=fq>Fk2)8txH15b-G{q7!+{8lme>1L;)5%b2sBq_iy0I0_!oS6uERB;!*(#1e0WXD zZhwz% z_R6C0v1(_=gHO5-v7&-c70h?GyMxhyHdi{M+NEKrqDQ%L+fww$|df)R)29v{=wDEFFW z^2A^ZL&r+6LOR;k1iFSUQSO?2QpOdASeoQd=z31gtO+S{lyf0R3x{6qMXke(G(ulg?Cb^ferCeM#l2iI zj@<@n+YhK|=$fLcAE=F*B{sFxMZ6Qljt>kSZ+?W{+h1B)iO96?Tz0ybgP|wor1*el zc_L!9QN!V<;v@8OywSFOv$!4b;Da#bhCp%-zr9dJoaNFZwsiTM&i(G8OL%xn391uf z$63!br1${Cgs(!oSmYL`tq5Eyz2PIPYim*V-)}M>C*3-Y(^XwlAc@c2MUcx3DHjK! z7bVF83>YmuA}HPN_hOMkdnLuuh80&wdB1Xq;AntUcS`kNu;n5#f9_YvEn&!A!VtbS zR3rMn^>m5z1XADgK9hxdVb<7w`Avn(HrBGwYrBVv{z;lU6S4T>?ZB1P+E>2LWt96VSkUhD$k7l@Kmo`Ps-C@z~ z?W6C5eX((THQIV90$LV!P4rB}#p7P|I+?d>E=Wes%WDMc7*@{t&MWmzQ`^kDQk111I84TWt;q7=* z8J+7=NH8Ad4$GAGy$YR=mO=IMs#i>e4z%GYF-V%nLlW<8FN!NzGz)W5xj zyPJ}-$f{($tM3m`th_L~o}Lgbsd8Vxzc z)K=rw<{P+D{mWchdJ%o5#)}Wirl?_IEyd=3t<%e+I2g$RF+RE4_9VK!!lVShN+LP+ z3cBzi4Q6rPoTnNO;BAC`&Grc!Oq47{{|MZ`J!8UCAg@@yuUME8^*@)LE`(?lqdH6w?1)tBn+-@B5`9!m0nzJ zmm3ua;!1>f1TijRzumgA00Q3eWxn+L^O8Fgl*s#^sm3kYInwRh9U6vMjTMx#%yUfS z9R_oeXrNo%tk7#7@+}e(NT;l(7$J-*3p|4N?dBTV&7s?pD5`rH2MZKV+quFLO&46( z+gswkFJ+jPg~-+t7T96gXrOLsmG}?2L)O6z6e>y;+Kt%R416Jcv74d6&dg#z9S?0CP1`qo{I%o^bw&aqVE1dIAkM>@K;p7MQ zBXsbwJThn$>9%XO(aTjf2s>{)zOZiIDA5R+w+_E_tshWM6u6<}tf%@FeF;yzS;K)& zKIeUtg-r!hM$nL4WB&bQ3gaqgq9Udry^pa8+MWew&URV(Vg@K%yQa2>P7n`C(V|H( z@Qmia?4|(mi#b@+0BjYg(JH~bxF47t)1O#R58bU%vif4oKD{;bzcLE*c`2a|lb*X8 z_zhKf^--8<-D>6~>(RsS6he(kQvq+q#=gg*HJz19exwcerhlqsw^-`G8BSC*d(gUERp4262Gfx< z4fsGIVsNexhU!3XNXFip>yni4zCzI>H6L9wtBqE|KjsAF>Ntd66VR1>1U zrh}y&W>G9a_eT5Oj*H@SM;2eRFR;LcWmo_k_D6j^x~=UwXgW5|J#6llI<-Qus*HdM z>8v3AOs>k(h$*GUH7z6jN9H$R>o%Q9a;3qFQ&KXED;d@o%De7?DM<~OvZVo+_~8V9 zlA0el%Lc)X^AaJi02O$`k#Ul_!?P?ov@d#gh@-NqGBUPP{>fSW$|eML9o4rul;1DH zMh^Q@Q}b}N6+RoL*FzAPMj4hTngB!`gx&0F#d9N8h zqHFSt%v5HE;|wXZC5)8?;NbbyN0r;Zh>~ce~dg(sYd}(IK5y@)PCzLqRosNzU(3sM-g&d8h1YbxRc&P3? zKLygzCXl&F+zJOIp~`8};2B3)Uz)T!`3Ybfg?vuPwUt22%UDWeOQ~t%0xc{AGx+`) zbANx{w7;_q9-eJ(Y!ogXUGPbi5QL=}%kzG_$sGzr<_}9rNg2^MVK$dE2x1%L21ylq z<#{Bq3^IyZLd}Yk@kJT8K+$g2FC)xN4C4tp@uq7;kyi+=&&>EYcbwaTn7?}6pVdlg z*;Fmt>)wQwAj8cNTgzo;zH=f}VLqezV$WRE%Y{u*BxA(E=Jo&XsV zHXS)Uvy$?IU-7hf#LFL381(H>)j(eHZxwQYi41q6)m((=un@l8D3%!O#%^>PxoxX_ z&dScp!stkrSC&WpItnKW+h5by=;4KV+I2>mO`lVXMe2_KzB3!M<&k+W zA?(_(#BDgOp`NLN$##q|%cZ=H@S@xC%gf7aT~5`**jUafu0#h2*|RI9j-6FoD{D2; zSOI+lwUGF!(LI#U%j~lWLh(g4B4IWqt0REkh)>C;$g8WXjw9r$TPNk!ITjeFjzj)_ z00uOn+d#sXZj#@QIz|b_igZZU@?5n5!Wf;^y~bycCBZ(^ATF*4;RUrwJvf?@av&!FeFE&LOanp(8u zAcX5Mm9cgsGQ)jD>P%K#;!4&6^bWmJ4>hh1H26W`m7boSK7ys=4&&qU!|Z%(a$2Q2 zr8wt4iW)MU)bL|I*+f9stw~TBHP<^^2m)Q8!E|U~Y*!eB20AP|h$NfCYc!F06(608YdYH`xSzoj!NRFClu_V0vib zE<;`bKK+-O)hg?d{*48ZkXy$kZ%2O?%jrQFZFVw6*H$$78ColRx<`|dRbI%b-R3sTtg=`XM;8X ziF>fcW+(uckf7uB&_Y}Q5+NT&WohirXh6=?is?bkn0kl$0ZtG+ydP&Irm$m=)wplo zUK^_p30z@IaOxfNsFjoL1$EwWoq7K@Wc`FwH|nSrYGHctof>JF^F#0W5qVM#%jf0| zn_gJJiW=+LOeSaM8h)%x4a+J{q~Y?iefO_s{Se)7+D~;-Aw*5a=^O#(7p+i#9w9_f zOI2`Ww`&q&So#6<1{O-axLS@kx;Bq>Y z&f@w8gwmtY@w)k%eX(SuHazLss73}&`M%1VH(#Tj)=cWy^FDc-1`FoAF7nn#@1@}m zqu&Ch3loj2a8#N0YW)4*d>@z3FB$Dx5?Fv)XkaTetD}oiNNR^mf z0Yj1vYDefH-QCkfWt@{MzfN7e-{Et6r0hLuesNiszita`%^z@E>B=LG9B?Mf8|8Rt zBdv@EeMrBwtC<=Z-+D(^KlRLG7Wea6B$4AJfy=}Y85heQX-^rYmd`%zW7&MB-wg@0 z*b_w&P^*?zuc=dFtfVTHuDhNiImI?^GmR3hwJ0-;oSE*L5cCtJ|0Y+3D#OezwKj)) zl75w|M~Sp88K0NcF3QFP$X7g@+O7`y4qgPTnB`DF}Aw{e79qnlB=@3W?Duk;{X zI&-7j#YU?#rRg7hW-RqOPt~x8))nxY2PntRtnCjYQD;dtKLRE_%nLH{axMEa@Gd zT8El97x(F5c|LkqwMHOKn(PUWKD93cqc2hKM5l# zY|Szuh9nayN6xieQjJQ8BEt{ZaePSd!d;)%g|jefn#~*?d*C0pnYbA^4C@RAGaEwJ zvX-To;!-_{sa$&KK@M$r>;4#t6{7&8F$=F4MDlXpfp+pUXEv4&I~-C2MkQO-AuqAz zp`&8(s&^6`STb`dEQSbb8Z4w0ngTRApIHhDwoGo)QSM|y5R1=hElRp2*X{QRKN7|d z(5ij%5M+ENO(-UvYz6?93W6!Hdj^GiIJXxK#K`Yh%#J(la&0}O?PIhDa)BD^W!d) z2o#mZOyvHzarkios|TIUyrAqpHd?I-ki$>HIjP<-^(12*dJb>Bgz=sRp7Es3DHAi@ zuRad$KEqBkrGxv$2FJ6>4w4CW^o$q|UfXUhD1`XJ_7@OFBB*C8tx)0qA6kHp^C5L5 z)m46rSUYub{A5N-3CU%FM(f z2K@AZ!3m1#6+$tFmKE)F;VjREeL%x+)*xKW9u>{NFR7UcYFmOg%Ofvc1XUk3de*w+ zs(!FdIPytx&98@i)z0bb#Ci`4thN;*#VWD7#C~%D-rK>M5@vnSF%A7Waq+Lh;lsA57+4;1)jI98)R5Zp<2^YGb2@+fI88k+5_{26Iy{)VBy2!1p0 zDREM;ojHg-qrc4o*j=GN=Y{8yu6FNdIng962qk2X)XUy`#N5#)Y=|lMjTR^RD9u#hbYkn-UWhv^ctDIDMuFDsyKRQ)6T24bR@_d9y?@VS+Dydd>L%Qls{n(Kh|GMV_$;naz*(v?Y zS9y~Zlw7jJRCt_EFwdp0IyzjVg>tKd1Ttt+lx1aBimQr|g1UV2{O=PbRsaN6IuY{~ z|G*jAk)u8$0b~;$3(jXFowYj5hmEJHrM7pMs+iyVk?JY)KMoFMgd^G&3sx&V7Xp6n z{dIGW__*Q7RbBO;bLDK1Cw5&(+^7 zu3(Qw=R$MUli3-f?|X`Q=Ig?;Grwl%neQ_{UE9Z%Ur?_6eLXqd4%v8ps@8I@$nd9S zg~%wdD=jub()(&>pygr1Yq>gs!v(}lX`AT8J3?tK9NP&}9bcGPOt?s>Qyb;kxI#Kx zZAOO2q)jstXqr9(_KUUG87A0zT@WFGD!V1#j`6}iX4X_&hs|H+M`SdnZ>;^gg=kuT z0P+Gib+@`{sRQ)pgZp5A;isg_W-cX+crMpgQ$;E+_n=CBCzLLnL6T8DO}&h?gt}N)D)$lvehmNkA&BKgGHqY!6-K6^3Xmf*s9)NQrYz=C{-oR^$(24nvyar?+ zb{hbIBZ}T=Zt{%zawhQ}u{U|>aJ|D#HWxrDcO*M|jU-c@@nV9C+~j>tSgWtuzlSuN zgXpeX;`SUDg4{;XinXfe{mIvHVsp0vp1#*V23yBp+IavcY5QjPd{HkJi+ktD?47NF zD9YUk1<5nsbT_{`_!mBV3=l1RK-o?`o_rqR-I&&J06(RWdI z@>O9jB{L-Xt>ueH&Xpc3x-DV8esG1J)d2L6f;Nl1_a%$I;13USS62jLGQCZ21Q}f_ zeD4=j-rGKVLqnp_UoPdSIA?{WC#KUPc^sDL18=nCfiDycOg)=@dHE`-)j}{D;=b1) z29l;5)2y4kt|8)4!DnnfA5{Db+io6#J2BA?w)p~KaV-h_=44+@AimQMg?-XC^G3(H z@j+kl-=%amcJ`gO=Zm4(@3*8nK6eYD6NZjk_~o){BJ#P*MP z-+4YlWxFY3otTdzciN!&*rjqdiiD*8~hSZDgyuuJmNeXv$Okvx{i`eMT2$is)s z;2s9k;BwOLiICwQqM%!j<}ycFjytq#;O|f7=QWpt77HL~cLTxeB?i>EstMY@cjfv6 zw$1CV*k+cT_pa`TB?3h&5JjGE-yADUj*Je*WF+-CReXrUQe2f+5v6ly_5I=BPoUgA zhynpI#vM`U{B4CFwBI(f-5-ZG2XP{;E&7W}mZ{X3qZzZMJq)KD(ueC;3ix-{53uQX z!*0B5n^@Tm9(Es$fSi87_XD%`d0dfc6){BWe%TqwgUEp82qzGBMJ}j4)gI0=L&Aj9 z##fse%lyf zbr^V_Zu9j^zacUOVHXkbw=8%JkElfrvfEN-aV1Sv@WLw}ERt?1&C#b#)se|Ey+%uz z_#U%kOmWi;eHV&-$kTtN`0$Gbh4%)seGOC0kHuc63Odw$_Hp9t<{-5%E@E<{B-t#2 zk%0?yQF!8W*zp<8=7`H&IW(LpC)p;iQ~H%LLt`mV~Ahd57Z$*Byc^lNGq zq$c0SQ0v{O0r(FrNyp)X=<2MA7y}}Hw)rc7$OByK`dF$^g?R0!%BFMywe%%T!3P7h z?LkahoeqL*d~T^(ZG6Emjq7c-`@i4215R+sj-s6ATFG1;Hl*?$Xi&tCtj}|5zL7d$ z0zn(JDvPEQc`h|wzZ&vT+j6`Jpm<#KQ!?24P^3KUJVu7i(r1N1`4gS$SDkS1>_bSb zDK)@9l{5@Q#NAgZw1MQGu;R%UuVe`0G`|MUBf=5M*CIn&pPWh4x)1&x8GFM%EHu(sebfvQ0cS%endAbim^IVpKDqdf3W@EwX?U!J`)DY~JXf&FTA&OE?HK+sK3|UZZO1$2Rm<8~ zAqzTHZZF<(B|vA6baOt}!K~^>P&Q7M`7OO>0@Om|Js^AcEm)}X#xY(7DraANXetKmuGS=Qj8 zERZNiQ!2P6VX6nC1N|}Y@@y?%Qq{{O=&o7&;kqBc7daezLMEd5VciB_zC_& z19RFZ$&d{Zm@VTLgV7Pqp0Hv{81C|?T)7xtftWZzR*05af zD~I|(DvHp-HC`<^9<0;kR{{H)fQ&Kw^iBfDPP5&k3+uo|g=X4`-DP?L2IH)sB9cIY z3bs!)MRlfliZ@d=_qn#T6hL2N(rk*GxH-qOnT>_xma!{cmEO-CS%OHDz`J_=qhhzE z9t1Rc4)oI2qf*l-$Cc4yGN{~Ok`)gMnVg3)r<|(u*W^6%m2reOb4D?xcP$6{Tl3gq zt+*Qi#p_vDQW4Zt>mSuQ|0Ki3D2g3cFH1dTYyX%c-sdR07%ww2HBp=|Jd*tbFA5&% z0Ez2}DXAj4Um^Mg#)*vx1O_RhioKI)mvbK4;p8zAo~5OdT1Dt72(p(BXUQ=oHd*}> zk|=%7(u{A7m0raxm$S?LTo;lm<;>23^?=dGFU%cukz~&mI0p(&Ei@IfZ}Nj*ro;zU*I^yq$+^4}%@OK3N0cqix(k9c%_4F+ zpBoXfYx48Yb`K3OzCMsnh3eqFU+YOikTwG+m*&XIND>%JPcDwpk=M6Y{)BRFw^(oLKXnh};RA-eI zgUE|*w|JMKZ{%AQh5kIPYR$~GVhJ6ji<9o+7p22u;=@NEF>GUru*gGxJi!V?cy{591Sn{~fdID<|5F{U23p#TI4C#KSplQLS?EBR z6{EV(?|Z6AssTte-1_&)$zb6{X9nD`h$IZ~vcS0H!Q}Fi6nR#Oq9+_J)wD}=(DTjs zl^^6`cEOQSBP?z?4}*l?eYdC|i|lhGlG0Q|86X2iZ`Z%CCzp^aT_^DU^;ExEk|v!p zrg>QO^67;7Y&)_7xXR92-36gb#j)b~NpZCS-~4_5`SLpZJ)|C!gGescDFUuLn;^&e z@s&a~o13^|8*eOYKL~1a`ihD0%KF*Wct8Enc=_0TdwG&orN+rcrfN6QYBSiMPrEkY zSb>GCrXC@`HU{hcn0E@$D@|Yg`uq?`gSDlS`;=5@DkhUI4H$WO5AS-02Jdc!KmTr< z(wfHkpG@O>ro{j;sYTBJpL=*ohEoOEn! zGJG1rzF~e8s7jqZ3=US+J1hHoz)*~poGETHe+;izU+)UlnTb?91~b8gM6a%fVD;h= z&U+n3v9%~z27vjS|Lwf!O&I6>Y5z|-cr1L56FE@hdaebpscAgK9=^}fCZLg1Lr9lQ zd(h~(D_)`BwV~y?cSK528!AFvYJvoBeNwx>aOmi8BLbW2M36eYh06g4ldas?@>u;2 z^R`RFVW8QV*_!eq87FJhJId+^GnKl`jMl=~*em-+R-S|p?mbp~QGD;?Pq(!@kzjtc zW|8-irn0ILFLz>1tv~Qol)wWq9{5`<@{x1d8&{`#>Z5hFB_x-@Tbc(4X1tGEf$Ea0 zCMU&fPtDi7`KAmVx5_eOpvA>lcl<`e|7OC`_~ zIlXT4qMVIdas!RiG0rl@Lw;TD*uS9{>v&__D+Zf&Brk0&#@^Yt;^+17^FG!44vmbZ zYSo+8olQvtwp1_UQzcTA)HMfb2e{C;8$n34#W-)E*f&1cf_C*;2TIZ!EvsJ6+aZ{m z_fnXetkW0iI<|Ca4wngj+}PHebpRrbkJ>8f8u;we+uNJe!l)|lUV$BJPvHJXvKUIu zI*}~PpZheRABFT>*KQekWaT=fQ%sXzO5H7pqG{|NKkry`Y$yNbgq$x?dqcon+^c=f zYrDEd?urhMVzja;sXzEpbj-)U0U>@O5fnh*0Hk#{*M}VA=s?GMTQ{$Do9=P(G65|3 zuMnkHjXUpRap*^80I;aj7e`2y6ueze%-=N97Oe%!t(G9s#L?8L)rP5aR2iV4_Ux&4 zoHHzNB&1NDI9bL8wMQpf|7){!yN^&G_YgS(Q-sg zTKj3eC`f%@3kYRZTot^qNH&Njy%wKqudb1I|Gcq2CE9rgiRf%20ltiGHQ@=wJpKO4 zS;`*d4`^ph5(DxJ*nGx9MqPg3A(?o28I$atoGQb8FO7twkvQNSq#h7DjPKv|To$lB zB^0FA3G8~!-Ry(^^XUwG&jH@p-RBRqmK9V9aPYEwsw))hq62=SRH#AKfsk+Fn)!;+ zmnoSZtrtIL``57Y2=!<}lwd4O$LW5Li~fzU}On?ah= z@aJZv$|+>aJ5Tdg_kqAxL6PSqQ3OE5;b(4-C&a5cG)gkTK)Y+KOR%qC*R<<86 z+s>k=&F&BXuRi#zr#Av<>yxxb#=eo?a9PSzYLF`rTgz?VBlxEHqDAcqg`Ek4kEL80 z>A5ZnVRC|!{V>YA#$JZb=t+ecfN?V4t9XHYpsWY>AoUK^z)qt3Tk%pRE{CK&c)yH> z^s%6q-Y;(#0NkpII6E&ib6~fz(IFCBfE@fMF;A=XI2?+B393!g}<~RI!$p@xsh94xQZ{TzX6X4LAPg3k6%VaPr)QMz5b6K4dvsa$X(F|ir z40`mB&;~Mn{UyHe_^UW zG*;@JjMpNpt{o$dnQpOp-C1aa#9ns8n0VB3!yTJ!gDuwe#=+I7+`S+`5KRQBa;EE& z;sn+UQ*)=2DdD#CW@Y+l5!N%-Mal>y-Nx6$PM4}mT^D&h)&6t-LpCro@8a)eTzZ*#Ac$@So5(s z=RuuT#|yVCwNfxdMh&T;Rh~PwGD$4$n37~0JFN#*4>?;>DCWNkHN>CcR)od4AaP-VXi)(nTMM+%p>5^$z#|72#=dm8A78fBcO~(nHGvFhcNqr zElkoEYc{ID%InT_&x;%N00re$1{Ma}O6o9qY=ECBkRwZnkvnJ0Nu!~-BMM_udEoHK zf?FM2_M(z(XOg^8udcMc1g{3{niklJ18A6lqxFdU!cThz52}+RY$nSRrUFgoaeXYT zk>^Lny|Val8F5Le7e1D zzMtXR(>3=j`1}<|vBJI`7DHK&b4!37F8{X*{{Hw+x%|?D){MtthH&2+F@W_@D!kYc$mLAg;{2iqn8oe1>z`1YDD3eGx zN#T1b_Y_@&%!cHbLc@AQC#;`~Qj4O0ilKh<7mO*YO6=nl1_h|orq$o)4S;O?OUjsR zZHjJG;CQl+aJ_A@>Mvw!8xPzuD+>LO@4i-l%N`Oj-|3<Nc;=c*GFe0GHQN4zCk z=D7u8qH^wdq=^yVg8pvtG5y`*Ln!{^r5az{>@A3G&spqUkcA|bR5Ry)LaQ_P+gkn= z)O2j^o6?!jC#&+b2zQokLhEqMfBC|Ka=3}E@PqD{I6|-$9P2{2n4#4A#UBkE)gt4U z-}c4d2^> { +export default async function createPlugin( + env: PluginEnvironment, +): Promise { return await createRouter({ - database, - identity: IdentityClient.create({ - discovery, - issuer: await discovery.getExternalBaseUrl('auth'), - }), - logger, - permissions, + database: env.database, + discovery: env.discovery, + identity: env.identity, + logger: env.logger, + permissions: env.permissions, }); } diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index 20e14312bb..99d18b9b12 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -16,7 +16,6 @@ import { CardHeaderProps } from '@material-ui/core/CardHeader'; import { Column } from '@material-table/core'; import { ComponentClass } from 'react'; import { ComponentProps } from 'react'; -import { ComponentType } from 'react'; import { default as CSS_2 } from 'csstype'; import { CSSProperties } from 'react'; import { ElementType } from 'react'; @@ -53,16 +52,6 @@ import { Theme } from '@material-ui/core/styles'; import { TooltipProps } from '@material-ui/core/Tooltip'; import { WithStyles } from '@material-ui/core/styles'; -// @public (undocumented) -export type ActionItemProps = { - label?: ListItemTextProps['primary']; - secondaryLabel?: ListItemTextProps['secondary']; - icon?: ReactElement; - disabled?: boolean; - onClick?: (event: React_2.MouseEvent) => void; - WrapperComponent?: ComponentType; -}; - // @public (undocumented) export function AlertDisplay(props: AlertDisplayProps): JSX.Element | null; @@ -448,9 +437,18 @@ export function Header(props: PropsWithChildren): JSX.Element; // @public (undocumented) export function HeaderActionMenu(props: HeaderActionMenuProps): JSX.Element; +// @public (undocumented) +export type HeaderActionMenuItem = { + label?: ListItemTextProps['primary']; + secondaryLabel?: ListItemTextProps['secondary']; + icon?: ReactElement; + disabled?: boolean; + onClick?: (event: React_2.MouseEvent) => void; +}; + // @public (undocumented) export type HeaderActionMenuProps = { - actionItems: ActionItemProps[]; + actionItems: HeaderActionMenuItem[]; }; // @public (undocumented) @@ -1357,7 +1355,7 @@ export function Table(props: TableProps): JSX.Element; // @public (undocumented) export namespace Table { var // (undocumented) - tableIcons: Readonly; + icons: Readonly; } // Warning: (ae-missing-release-tag) "TableClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/packages/core-components/src/components/Table/Table.tsx b/packages/core-components/src/components/Table/Table.tsx index b04c247b2f..49538b680a 100644 --- a/packages/core-components/src/components/Table/Table.tsx +++ b/packages/core-components/src/components/Table/Table.tsx @@ -523,4 +523,4 @@ export function Table(props: TableProps) { ); } -Table.tableIcons = Object.freeze(tableIcons); +Table.icons = Object.freeze(tableIcons); diff --git a/packages/core-components/src/layout/HeaderActionMenu/HeaderActionMenu.test.tsx b/packages/core-components/src/layout/HeaderActionMenu/HeaderActionMenu.test.tsx index 3337571d4e..92ce241aa8 100644 --- a/packages/core-components/src/layout/HeaderActionMenu/HeaderActionMenu.test.tsx +++ b/packages/core-components/src/layout/HeaderActionMenu/HeaderActionMenu.test.tsx @@ -60,7 +60,7 @@ describe('', () => { ); }); - it('Test wrapper, and secondary label', async () => { + it('Secondary label', async () => { const onClickFunction = jest.fn(); const rendered = await renderInTestApp( ', () => { { label: 'Some label', secondaryLabel: 'Secondary label', - WrapperComponent: ({ children }) => ( - - ), + onClick: onClickFunction, }, ]} />, diff --git a/packages/core-components/src/layout/HeaderActionMenu/HeaderActionMenu.tsx b/packages/core-components/src/layout/HeaderActionMenu/HeaderActionMenu.tsx index 125363fe0a..e164244ea7 100644 --- a/packages/core-components/src/layout/HeaderActionMenu/HeaderActionMenu.tsx +++ b/packages/core-components/src/layout/HeaderActionMenu/HeaderActionMenu.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { Fragment, ReactElement, ComponentType } from 'react'; +import React, { Fragment, ReactElement } from 'react'; import IconButton from '@material-ui/core/IconButton'; import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; @@ -28,13 +28,12 @@ import MoreVert from '@material-ui/icons/MoreVert'; /** * @public */ -export type ActionItemProps = { +export type HeaderActionMenuItem = { label?: ListItemTextProps['primary']; secondaryLabel?: ListItemTextProps['secondary']; icon?: ReactElement; disabled?: boolean; - onClick?: (event: React.MouseEvent) => void; - WrapperComponent?: ComponentType; + onClick?: (event: React.MouseEvent) => void; }; const ActionItem = ({ @@ -43,10 +42,9 @@ const ActionItem = ({ icon, disabled = false, onClick, - WrapperComponent = React.Fragment, -}: ActionItemProps) => { +}: HeaderActionMenuItem) => { return ( - + {icon}} - + ); }; @@ -68,7 +66,7 @@ const ActionItem = ({ * @public */ export type HeaderActionMenuProps = { - actionItems: ActionItemProps[]; + actionItems: HeaderActionMenuItem[]; }; /** diff --git a/packages/core-components/src/layout/HeaderActionMenu/index.ts b/packages/core-components/src/layout/HeaderActionMenu/index.ts index 6fa74d9d09..4fd9552fa7 100644 --- a/packages/core-components/src/layout/HeaderActionMenu/index.ts +++ b/packages/core-components/src/layout/HeaderActionMenu/index.ts @@ -16,6 +16,6 @@ export { HeaderActionMenu } from './HeaderActionMenu'; export type { - ActionItemProps, + HeaderActionMenuItem, HeaderActionMenuProps, } from './HeaderActionMenu'; diff --git a/plugins/playlist-backend/README.md b/plugins/playlist-backend/README.md index 75e0281a08..14a39ca741 100644 --- a/plugins/playlist-backend/README.md +++ b/plugins/playlist-backend/README.md @@ -21,20 +21,15 @@ import { createRouter } from '@backstage/plugin-playlist-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; -export default async function createPlugin({ - database, - discovery, - logger, - permissions, -}: PluginEnvironment): Promise { +export default async function createPlugin( + env: PluginEnvironment, +): Promise { return await createRouter({ - database, - identity: IdentityClient.create({ - discovery, - issuer: await discovery.getExternalBaseUrl('auth'), - }), - logger, - permissions, + database: env.database, + discovery: env.discovery, + identity: env.identity, + logger: env.logger, + permissions: env.permissions, }); } ``` diff --git a/plugins/playlist-backend/api-report.md b/plugins/playlist-backend/api-report.md index 5c86b6ae45..c7109efbfb 100644 --- a/plugins/playlist-backend/api-report.md +++ b/plugins/playlist-backend/api-report.md @@ -7,7 +7,7 @@ import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; import { ConditionalPolicyDecision } from '@backstage/plugin-permission-common'; import { Conditions } from '@backstage/plugin-permission-node'; import express from 'express'; -import { IdentityClient } from '@backstage/plugin-auth-node'; +import { IdentityApi } from '@backstage/plugin-auth-node'; import { Logger } from 'winston'; import { Permission } from '@backstage/plugin-permission-common'; import { PermissionCondition } from '@backstage/plugin-permission-common'; @@ -17,6 +17,7 @@ import { PermissionPolicy } from '@backstage/plugin-permission-node'; import { PermissionRule } from '@backstage/plugin-permission-node'; import { PlaylistMetadata } from '@backstage/plugin-playlist-common'; import { PluginDatabaseManager } from '@backstage/backend-common'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { PolicyDecision } from '@backstage/plugin-permission-common'; import { PolicyQuery } from '@backstage/plugin-permission-node'; import { ResourcePermission } from '@backstage/plugin-permission-common'; @@ -84,7 +85,9 @@ export interface RouterOptions { // (undocumented) database: PluginDatabaseManager; // (undocumented) - identity: IdentityClient; + discovery: PluginEndpointDiscovery; + // (undocumented) + identity: IdentityApi; // (undocumented) logger: Logger; // (undocumented) diff --git a/plugins/playlist-backend/migrations/20220701011329_init.js b/plugins/playlist-backend/migrations/20220701011329_init.js index 17a71e8438..be08363dec 100644 --- a/plugins/playlist-backend/migrations/20220701011329_init.js +++ b/plugins/playlist-backend/migrations/20220701011329_init.js @@ -37,7 +37,9 @@ exports.up = async function up(knex) { .onDelete('CASCADE') .comment('The id of the playlist this entity belongs to'); table.string('entity_ref').notNullable().comment('A entity ref'); - table.unique(['playlist_id', 'entity_ref']); + table.unique(['playlist_id', 'entity_ref'], { + indexName: 'playlist_entity_composite_index', + }); }); await knex.schema.createTable('followers', table => { @@ -49,7 +51,9 @@ exports.up = async function up(knex) { .onDelete('CASCADE') .comment('The id of the playlist being followed'); table.string('user_ref').notNullable().comment('A user entity ref'); - table.unique(['playlist_id', 'user_ref']); + table.unique(['playlist_id', 'user_ref'], { + indexName: 'playlist_follower_composite_index', + }); }); }; diff --git a/plugins/playlist-backend/package.json b/plugins/playlist-backend/package.json index bbea8163fb..b760611548 100644 --- a/plugins/playlist-backend/package.json +++ b/plugins/playlist-backend/package.json @@ -22,13 +22,15 @@ "postpack": "backstage-cli package postpack" }, "dependencies": { - "@backstage/backend-common": "^0.15.1-next.2", - "@backstage/backend-test-utils": "^0.1.28-next.2", - "@backstage/config": "^1.0.1", - "@backstage/errors": "^1.1.0", - "@backstage/plugin-auth-node": "^0.2.5-next.2", - "@backstage/plugin-permission-common": "^0.6.4-next.1", - "@backstage/plugin-permission-node": "^0.6.5-next.2", + "@backstage/backend-common": "^0.15.1-next.3", + "@backstage/backend-test-utils": "^0.1.28-next.3", + "@backstage/catalog-client": "1.1.0-next.2", + "@backstage/catalog-model": "^1.1.1-next.0", + "@backstage/config": "^1.0.2-next.0", + "@backstage/errors": "^1.1.1-next.0", + "@backstage/plugin-auth-node": "^0.2.5-next.3", + "@backstage/plugin-permission-common": "^0.6.4-next.2", + "@backstage/plugin-permission-node": "^0.6.5-next.3", "@backstage/plugin-playlist-common": "^0.0.0", "@types/express": "*", "express": "^4.17.1", @@ -40,7 +42,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/cli": "^0.19.0-next.2", + "@backstage/cli": "^0.19.0-next.3", "@types/supertest": "^2.0.8", "msw": "^0.47.0", "supertest": "^6.1.3" diff --git a/plugins/playlist-backend/src/service/ListPlaylistsFilter.ts b/plugins/playlist-backend/src/service/ListPlaylistsFilter.ts index bff14b2a58..a746286136 100644 --- a/plugins/playlist-backend/src/service/ListPlaylistsFilter.ts +++ b/plugins/playlist-backend/src/service/ListPlaylistsFilter.ts @@ -73,7 +73,7 @@ export function parseListPlaylistsFilterString( return undefined; } - const filtersByKey: Record = {}; + const filtersByKey = new Map(); for (const statement of statements) { const equalsIndex = statement.indexOf('='); @@ -89,13 +89,12 @@ export function parseListPlaylistsFilterString( ); } - const f = - key in filtersByKey - ? filtersByKey[key] - : (filtersByKey[key] = { key, values: [] }); + const f = filtersByKey.has(key) + ? filtersByKey.get(key) + : filtersByKey.set(key, { key, values: [] }).get(key); - f.values.push(value); + f!.values.push(value); } - return Object.values(filtersByKey); + return [...filtersByKey.values()]; } diff --git a/plugins/playlist-backend/src/service/router.test.ts b/plugins/playlist-backend/src/service/router.test.ts index 06185acae8..3f90a8b8af 100644 --- a/plugins/playlist-backend/src/service/router.test.ts +++ b/plugins/playlist-backend/src/service/router.test.ts @@ -14,9 +14,13 @@ * limitations under the License. */ -import { DatabaseManager, getVoidLogger } from '@backstage/backend-common'; +import { + DatabaseManager, + getVoidLogger, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; -import { IdentityClient } from '@backstage/plugin-auth-node'; +import { IdentityApi } from '@backstage/plugin-auth-node'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { permissions } from '@backstage/plugin-playlist-common'; import express from 'express'; @@ -24,8 +28,39 @@ import request from 'supertest'; import { createRouter } from './router'; +const sampleEntities = [ + { + kind: 'system', + metadata: { + namespace: 'default', + name: 'test-ent-system', + title: 'Test Ent', + description: 'test ent description', + }, + }, + { + kind: 'component', + metadata: { + namespace: 'default', + name: 'test-ent', + title: 'Test Ent 2', + description: 'test ent description 2', + }, + spec: { + type: 'library', + }, + }, +]; +const mockGetEntties = jest + .fn() + .mockImplementation(async () => ({ items: sampleEntities })); +jest.mock('@backstage/catalog-client', () => ({ + CatalogClient: jest + .fn() + .mockImplementation(() => ({ getEntities: mockGetEntties })), +})); + jest.mock('@backstage/plugin-auth-node', () => ({ - ...jest.requireActual('@backstage/plugin-auth-node'), getBearerTokenFromAuthorizationHeader: () => 'token', })); @@ -99,14 +134,20 @@ describe('createRouter', () => { userEntityRef: 'user:default/me', }; const mockIdentityClient = { - authenticate: jest + getIdentity: jest .fn() .mockImplementation(async () => ({ identity: mockUser })), - } as unknown as IdentityClient; + } as unknown as IdentityApi; + + const discovery: jest.Mocked = { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), + }; beforeEach(async () => { const router = await createRouter({ database: createDatabase(), + discovery, identity: mockIdentityClient, logger: getVoidLogger(), permissions: mockPermissionEvaluator, @@ -398,6 +439,7 @@ describe('createRouter', () => { { token: 'token' }, ); expect(mockDbHandler.getPlaylistEntities).not.toHaveBeenCalled(); + expect(mockGetEntties).not.toHaveBeenCalled(); expect(response.status).toEqual(403); }); @@ -406,8 +448,25 @@ describe('createRouter', () => { expect(mockDbHandler.getPlaylistEntities).toHaveBeenCalledWith( 'playlist-id', ); + expect(mockGetEntties).toHaveBeenCalledWith( + { + filter: [ + { + kind: 'component', + 'metadata.namespace': 'default', + 'metadata.name': 'test-ent', + }, + { + kind: 'system', + 'metadata.namespace': 'default', + 'metadata.name': 'test-ent-system', + }, + ], + }, + { token: 'token' }, + ); expect(response.status).toEqual(200); - expect(response.body).toEqual(mockEntities); + expect(response.body).toEqual(sampleEntities); }); }); diff --git a/plugins/playlist-backend/src/service/router.ts b/plugins/playlist-backend/src/service/router.ts index 7ca41c0ac1..bf0b789b7a 100644 --- a/plugins/playlist-backend/src/service/router.ts +++ b/plugins/playlist-backend/src/service/router.ts @@ -14,11 +14,17 @@ * limitations under the License. */ -import { errorHandler, PluginDatabaseManager } from '@backstage/backend-common'; +import { + errorHandler, + PluginDatabaseManager, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; +import { CatalogClient } from '@backstage/catalog-client'; +import { parseEntityRef } from '@backstage/catalog-model'; import { NotAllowedError } from '@backstage/errors'; import { getBearerTokenFromAuthorizationHeader, - IdentityClient, + IdentityApi, } from '@backstage/plugin-auth-node'; import { AuthorizePermissionRequest, @@ -44,7 +50,8 @@ import { parseListPlaylistsFilterParams } from './ListPlaylistsFilter'; */ export interface RouterOptions { database: PluginDatabaseManager; - identity: IdentityClient; + discovery: PluginEndpointDiscovery; + identity: IdentityApi; logger: Logger; permissions: PermissionEvaluator; } @@ -57,6 +64,7 @@ export async function createRouter( ): Promise { const { database, + discovery, identity, logger, permissions: permissionEvaluator, @@ -64,19 +72,23 @@ export async function createRouter( logger.info('Initializing Playlist backend'); + const catalogClient = new CatalogClient({ discoveryApi: discovery }); const db = await database.getClient(); const dbHandler = await DatabaseHandler.create({ database: db }); const evaluateRequestPermission = async ( - req: express.Request, + request: express.Request, permission: AuthorizePermissionRequest | QueryPermissionRequest, conditional: boolean = false, ) => { const token = getBearerTokenFromAuthorizationHeader( - req.header('authorization'), + request.header('authorization'), ); - const user = await identity.authenticate(token); + const user = await identity.getIdentity({ request }); + if (!user) { + throw new NotAllowedError('Unauthorized'); + } const decision = conditional ? ( @@ -196,7 +208,36 @@ export async function createRouter( permission: permissions.playlistListRead, resourceRef: req.params.playlistId, }); - const entities = await dbHandler.getPlaylistEntities(req.params.playlistId); + + const entityRefs = await dbHandler.getPlaylistEntities( + req.params.playlistId, + ); + if (!entityRefs.length) { + res.json([]); + return; + } + + const filter = entityRefs.map(ref => { + const compoundRef = parseEntityRef(ref); + return { + kind: compoundRef.kind, + 'metadata.namespace': compoundRef.namespace, + 'metadata.name': compoundRef.name, + }; + }); + + const token = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ); + + // TODO(kuanpg): entities in this playlist that no longer exist in the catalog will be + // excluded from this response, we need a way to clean up these orphaned refs potentially + // via catalog events (https://github.com/backstage/backstage/issues/8219) + // + // Note: This will also enforce catalog permissions and will only return entities for which the current user has access to + const entities = (await catalogClient.getEntities({ filter }, { token })) + .items; + res.json(entities); }); diff --git a/plugins/playlist-backend/src/service/standaloneServer.ts b/plugins/playlist-backend/src/service/standaloneServer.ts index 46d6f7ad8d..3b6acfd09b 100644 --- a/plugins/playlist-backend/src/service/standaloneServer.ts +++ b/plugins/playlist-backend/src/service/standaloneServer.ts @@ -23,7 +23,7 @@ import { useHotMemoize, } from '@backstage/backend-common'; import { ConfigReader } from '@backstage/config'; -import { IdentityClient } from '@backstage/plugin-auth-node'; +import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; import { ServerPermissionClient } from '@backstage/plugin-permission-node'; import { Server } from 'http'; import { Logger } from 'winston'; @@ -53,7 +53,7 @@ export async function startStandaloneServer( return manager.forPlugin('playlist'); }); - const identity = IdentityClient.create({ + const identity = DefaultIdentityClient.create({ discovery, issuer: await discovery.getExternalBaseUrl('auth'), }); @@ -69,6 +69,7 @@ export async function startStandaloneServer( logger.debug('Starting application server...'); const router = await createRouter({ database, + discovery, identity, logger, permissions, diff --git a/plugins/playlist-common/package.json b/plugins/playlist-common/package.json index 67a97b1237..b9e8637f17 100644 --- a/plugins/playlist-common/package.json +++ b/plugins/playlist-common/package.json @@ -23,10 +23,10 @@ "postpack": "backstage-cli package postpack" }, "dependencies": { - "@backstage/plugin-permission-common": "^0.6.4-next.1" + "@backstage/plugin-permission-common": "^0.6.4-next.2" }, "devDependencies": { - "@backstage/cli": "^0.19.0-next.2" + "@backstage/cli": "^0.19.0-next.3" }, "files": [ "dist" diff --git a/plugins/playlist/api-report.md b/plugins/playlist/api-report.md index ddfec2948d..7a732a4a53 100644 --- a/plugins/playlist/api-report.md +++ b/plugins/playlist/api-report.md @@ -8,16 +8,16 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { Entity } from '@backstage/catalog-model'; import { FetchApi } from '@backstage/core-plugin-api'; import { Playlist } from '@backstage/plugin-playlist-common'; import { PlaylistMetadata } from '@backstage/plugin-playlist-common'; import { RouteRef } from '@backstage/core-plugin-api'; // @public (undocumented) -export const EntityPlaylistDialog: ({ - open, - onClose, -}: EntityPlaylistDialogProps) => JSX.Element; +export const EntityPlaylistDialog: ( + props: EntityPlaylistDialogProps, +) => JSX.Element; // @public (undocumented) export type EntityPlaylistDialogProps = { @@ -49,7 +49,7 @@ export interface PlaylistApi { // (undocumented) getPlaylist(playlistId: string): Promise; // (undocumented) - getPlaylistEntities(playlistId: string): Promise; + getPlaylistEntities(playlistId: string): Promise; // (undocumented) removePlaylistEntities( playlistId: string, @@ -80,7 +80,7 @@ export class PlaylistClient implements PlaylistApi { // (undocumented) getPlaylist(playlistId: string): Promise; // (undocumented) - getPlaylistEntities(playlistId: string): Promise; + getPlaylistEntities(playlistId: string): Promise; // (undocumented) removePlaylistEntities( playlistId: string, diff --git a/plugins/playlist/package.json b/plugins/playlist/package.json index 51cffcca9f..f59fce18db 100644 --- a/plugins/playlist/package.json +++ b/plugins/playlist/package.json @@ -22,14 +22,14 @@ "postpack": "backstage-cli package postpack" }, "dependencies": { - "@backstage/catalog-model": "^1.1.0", - "@backstage/core-components": "^0.11.1-next.2", - "@backstage/core-plugin-api": "^1.0.6-next.2", - "@backstage/errors": "^1.1.0", + "@backstage/catalog-model": "^1.1.1-next.0", + "@backstage/core-components": "^0.11.1-next.3", + "@backstage/core-plugin-api": "^1.0.6-next.3", + "@backstage/errors": "^1.1.1-next.0", "@backstage/plugin-catalog-common": "^1.0.6-next.0", - "@backstage/plugin-catalog-react": "^1.1.4-next.1", - "@backstage/plugin-permission-common": "^0.6.4-next.1", - "@backstage/plugin-permission-react": "^0.4.5-next.1", + "@backstage/plugin-catalog-react": "^1.1.4-next.2", + "@backstage/plugin-permission-common": "^0.6.4-next.2", + "@backstage/plugin-permission-react": "^0.4.5-next.2", "@backstage/plugin-playlist-common": "^0.0.0", "@backstage/plugin-search-react": "^1.1.0-next.2", "@backstage/theme": "^0.2.16", @@ -47,15 +47,14 @@ "react-router-dom": "6.0.0-beta.0 || ^6.3.0" }, "devDependencies": { - "@backstage/cli": "^0.19.0-next.2", - "@backstage/core-app-api": "^1.1.0-next.2", - "@backstage/dev-utils": "^1.0.6-next.1", - "@backstage/test-utils": "^1.2.0-next.2", + "@backstage/cli": "^0.19.0-next.3", + "@backstage/core-app-api": "^1.1.0-next.3", + "@backstage/dev-utils": "^1.0.6-next.2", + "@backstage/test-utils": "^1.2.0-next.3", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^12.1.3", "@testing-library/react-hooks": "^8.0.0", "@testing-library/user-event": "^14.0.0", - "@types/jest": "*", "@types/node": "*", "cross-fetch": "^3.1.5", "msw": "^0.47.0", diff --git a/plugins/playlist/src/api/PlaylistApi.ts b/plugins/playlist/src/api/PlaylistApi.ts index d0b87ec28a..9a9da1badb 100644 --- a/plugins/playlist/src/api/PlaylistApi.ts +++ b/plugins/playlist/src/api/PlaylistApi.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; import { createApiRef } from '@backstage/core-plugin-api'; import { Playlist, PlaylistMetadata } from '@backstage/plugin-playlist-common'; @@ -62,7 +63,7 @@ export interface PlaylistApi { addPlaylistEntities(playlistId: string, entityRefs: string[]): Promise; - getPlaylistEntities(playlistId: string): Promise; + getPlaylistEntities(playlistId: string): Promise; removePlaylistEntities( playlistId: string, diff --git a/plugins/playlist/src/api/PlaylistClient.test.ts b/plugins/playlist/src/api/PlaylistClient.test.ts index f3b62c19ac..20d4a0bcc8 100644 --- a/plugins/playlist/src/api/PlaylistClient.test.ts +++ b/plugins/playlist/src/api/PlaylistClient.test.ts @@ -215,7 +215,29 @@ describe('PlaylistClient', () => { }); it('getPlaylistEntities', async () => { - const entities = ['component:default/ent1', 'component:default/ent2']; + const entities = [ + { + kind: 'system', + metadata: { + namespace: 'default', + name: 'test-ent', + title: 'Test Ent', + description: 'test ent description', + }, + }, + { + kind: 'component', + metadata: { + namespace: 'foo', + name: 'test-ent2', + title: 'Test Ent 2', + description: 'test ent description 2', + }, + spec: { + type: 'library', + }, + }, + ]; server.use( rest.get(`${mockBaseUrl}/id/entities`, (_, res, ctx) => diff --git a/plugins/playlist/src/api/PlaylistClient.ts b/plugins/playlist/src/api/PlaylistClient.ts index b77aa79733..425b185de8 100644 --- a/plugins/playlist/src/api/PlaylistClient.ts +++ b/plugins/playlist/src/api/PlaylistClient.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; import { Playlist, PlaylistMetadata } from '@backstage/plugin-playlist-common'; @@ -142,7 +143,7 @@ export class PlaylistClient implements PlaylistApi { } } - async getPlaylistEntities(playlistId: string): Promise { + async getPlaylistEntities(playlistId: string): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('playlist'); const resp = await this.fetchApi.fetch( `${baseUrl}/${playlistId}/entities`, diff --git a/plugins/playlist/src/components/EntityPlaylistDialog/EntityPlaylistDialog.tsx b/plugins/playlist/src/components/EntityPlaylistDialog/EntityPlaylistDialog.tsx index 474a9abb1f..247f737f3f 100644 --- a/plugins/playlist/src/components/EntityPlaylistDialog/EntityPlaylistDialog.tsx +++ b/plugins/playlist/src/components/EntityPlaylistDialog/EntityPlaylistDialog.tsx @@ -78,10 +78,9 @@ export type EntityPlaylistDialogProps = { onClose: () => void; }; -export const EntityPlaylistDialog = ({ - open, - onClose, -}: EntityPlaylistDialogProps) => { +export const EntityPlaylistDialog = (props: EntityPlaylistDialogProps) => { + const { open, onClose } = props; + const classes = useStyles(); const navigate = useNavigate(); const { entity } = useAsyncEntity(); diff --git a/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.test.tsx b/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.test.tsx index 3dfd89fda9..f7720b25c1 100644 --- a/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.test.tsx +++ b/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.test.tsx @@ -15,11 +15,7 @@ */ import { ErrorApi, errorApiRef } from '@backstage/core-plugin-api'; -import { - CatalogApi, - catalogApiRef, - entityRouteRef, -} from '@backstage/plugin-catalog-react'; +import { entityRouteRef } from '@backstage/plugin-catalog-react'; import { AuthorizeResult } from '@backstage/plugin-permission-common'; import { PermissionApi, @@ -47,17 +43,6 @@ jest.mock('./AddEntitiesDrawer', () => ({ describe('PlaylistEntitiesTable', () => { const errorApi: Partial = { post: jest.fn() }; - const playlistApi: Partial = { - getPlaylistEntities: jest - .fn() - .mockImplementation(async () => [ - 'system:default/test-ent', - 'component:foo/test-ent2', - ]), - addPlaylistEntities: jest.fn().mockImplementation(async () => {}), - removePlaylistEntities: jest.fn().mockImplementation(async () => {}), - }; - const sampleEntities = [ { kind: 'system', @@ -81,10 +66,12 @@ describe('PlaylistEntitiesTable', () => { }, }, ]; - const catalogApi: Partial = { - getEntities: jest + const playlistApi: Partial = { + getPlaylistEntities: jest .fn() - .mockImplementation(async () => ({ items: sampleEntities })), + .mockImplementation(async () => sampleEntities), + addPlaylistEntities: jest.fn().mockImplementation(async () => {}), + removePlaylistEntities: jest.fn().mockImplementation(async () => {}), }; const mockAuthorize = jest @@ -97,7 +84,6 @@ describe('PlaylistEntitiesTable', () => { new Map() }}> { const rendered = await render(); expect(playlistApi.getPlaylistEntities).toHaveBeenCalledWith('playlist-id'); - expect(catalogApi.getEntities).toHaveBeenCalledWith({ - filter: [ - { - kind: 'system', - 'metadata.namespace': 'default', - 'metadata.name': 'test-ent', - }, - { - kind: 'component', - 'metadata.namespace': 'foo', - 'metadata.name': 'test-ent2', - }, - ], - fields: [ - 'kind', - 'metadata.namespace', - 'metadata.name', - 'metadata.title', - 'metadata.description', - 'spec.type', - ], - }); expect(rendered.getByText('Test Ent')).toBeInTheDocument(); expect(rendered.getByText('system')).toBeInTheDocument(); diff --git a/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx b/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx index 0fa78b19bd..f499f59a61 100644 --- a/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx +++ b/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx @@ -14,11 +14,7 @@ * limitations under the License. */ -import { - Entity, - parseEntityRef, - stringifyEntityRef, -} from '@backstage/catalog-model'; +import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { ErrorPanel, SubvalueCell, @@ -26,7 +22,7 @@ import { TableFilter, } from '@backstage/core-components'; import { errorApiRef, useApi } from '@backstage/core-plugin-api'; -import { catalogApiRef, EntityRefLink } from '@backstage/plugin-catalog-react'; +import { EntityRefLink } from '@backstage/plugin-catalog-react'; import { usePermission } from '@backstage/plugin-permission-react'; import { permissions } from '@backstage/plugin-playlist-common'; import AddBoxIcon from '@material-ui/icons/AddBox'; @@ -43,7 +39,6 @@ export const PlaylistEntitiesTable = ({ }: { playlistId: string; }) => { - const catalogApi = useApi(catalogApiRef); const errorApi = useApi(errorApiRef); const playlistApi = useApi(playlistApiRef); const [openAddEntitiesDrawer, setOpenAddEntitiesDrawer] = useState(false); @@ -53,39 +48,10 @@ export const PlaylistEntitiesTable = ({ resourceRef: playlistId, }); - const [{ value: entities, loading, error }, loadEntities] = - useAsyncFn(async () => { - const entityRefs = await playlistApi.getPlaylistEntities(playlistId); - if (!entityRefs.length) { - return []; - } - - const filter = entityRefs.map(ref => { - const compoundRef = parseEntityRef(ref); - return { - kind: compoundRef.kind, - 'metadata.namespace': compoundRef.namespace, - 'metadata.name': compoundRef.name, - }; - }); - - // TODO(kuanpg): entities in this playlist that no longer exist in the catalog will be - // excluded from this response, we need a way to clean up these orphaned refs potentially - // via catalog events (https://github.com/backstage/backstage/issues/8219) - return ( - await catalogApi.getEntities({ - filter, - fields: [ - 'kind', - 'metadata.namespace', - 'metadata.name', - 'metadata.title', - 'metadata.description', - 'spec.type', - ], - }) - ).items; - }, [catalogApi, playlistApi]); + const [{ value: entities, loading, error }, loadEntities] = useAsyncFn( + () => playlistApi.getPlaylistEntities(playlistId), + [playlistApi], + ); useEffect(() => { loadEntities(); @@ -190,7 +156,7 @@ export const PlaylistEntitiesTable = ({ data={entities ?? []} filters={filters} icons={{ - ...Table.tableIcons, + ...Table.icons, Search: forwardRef((props, ref) => ( )), diff --git a/yarn.lock b/yarn.lock index d55434a22a..02659263e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3010,7 +3010,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/catalog-client@^1.1.0-next.2, @backstage/catalog-client@workspace:packages/catalog-client": +"@backstage/catalog-client@1.1.0-next.2, @backstage/catalog-client@^1.1.0-next.2, @backstage/catalog-client@workspace:packages/catalog-client": version: 0.0.0-use.local resolution: "@backstage/catalog-client@workspace:packages/catalog-client" dependencies: @@ -6222,6 +6222,85 @@ __metadata: languageName: node linkType: hard +"@backstage/plugin-playlist-backend@^0.0.0, @backstage/plugin-playlist-backend@workspace:plugins/playlist-backend": + version: 0.0.0-use.local + resolution: "@backstage/plugin-playlist-backend@workspace:plugins/playlist-backend" + dependencies: + "@backstage/backend-common": ^0.15.1-next.3 + "@backstage/backend-test-utils": ^0.1.28-next.3 + "@backstage/catalog-client": 1.1.0-next.2 + "@backstage/catalog-model": ^1.1.1-next.0 + "@backstage/cli": ^0.19.0-next.3 + "@backstage/config": ^1.0.2-next.0 + "@backstage/errors": ^1.1.1-next.0 + "@backstage/plugin-auth-node": ^0.2.5-next.3 + "@backstage/plugin-permission-common": ^0.6.4-next.2 + "@backstage/plugin-permission-node": ^0.6.5-next.3 + "@backstage/plugin-playlist-common": ^0.0.0 + "@types/express": "*" + "@types/supertest": ^2.0.8 + express: ^4.17.1 + express-promise-router: ^4.1.0 + knex: ^2.0.0 + msw: ^0.47.0 + node-fetch: ^2.6.7 + supertest: ^6.1.3 + uuid: ^8.2.0 + winston: ^3.2.1 + yn: ^4.0.0 + languageName: unknown + linkType: soft + +"@backstage/plugin-playlist-common@^0.0.0, @backstage/plugin-playlist-common@workspace:plugins/playlist-common": + version: 0.0.0-use.local + resolution: "@backstage/plugin-playlist-common@workspace:plugins/playlist-common" + dependencies: + "@backstage/cli": ^0.19.0-next.3 + "@backstage/plugin-permission-common": ^0.6.4-next.2 + languageName: unknown + linkType: soft + +"@backstage/plugin-playlist@^0.0.0, @backstage/plugin-playlist@workspace:plugins/playlist": + version: 0.0.0-use.local + resolution: "@backstage/plugin-playlist@workspace:plugins/playlist" + dependencies: + "@backstage/catalog-model": ^1.1.1-next.0 + "@backstage/cli": ^0.19.0-next.3 + "@backstage/core-app-api": ^1.1.0-next.3 + "@backstage/core-components": ^0.11.1-next.3 + "@backstage/core-plugin-api": ^1.0.6-next.3 + "@backstage/dev-utils": ^1.0.6-next.2 + "@backstage/errors": ^1.1.1-next.0 + "@backstage/plugin-catalog-common": ^1.0.6-next.0 + "@backstage/plugin-catalog-react": ^1.1.4-next.2 + "@backstage/plugin-permission-common": ^0.6.4-next.2 + "@backstage/plugin-permission-react": ^0.4.5-next.2 + "@backstage/plugin-playlist-common": ^0.0.0 + "@backstage/plugin-search-react": ^1.1.0-next.2 + "@backstage/test-utils": ^1.2.0-next.3 + "@backstage/theme": ^0.2.16 + "@material-ui/core": ^4.9.13 + "@material-ui/icons": ^4.9.1 + "@material-ui/lab": ^4.0.0-alpha.57 + "@testing-library/jest-dom": ^5.10.1 + "@testing-library/react": ^12.1.3 + "@testing-library/react-hooks": ^8.0.0 + "@testing-library/user-event": ^14.0.0 + "@types/node": "*" + cross-fetch: ^3.1.5 + lodash: ^4.17.21 + msw: ^0.47.0 + qs: ^6.9.4 + react-hook-form: ^7.13.0 + react-use: ^17.2.4 + swr: ^1.1.2 + peerDependencies: + react: ^16.13.1 || ^17.0.0 + react-router: 6.0.0-beta.0 || ^6.3.0 + react-router-dom: 6.0.0-beta.0 || ^6.3.0 + languageName: unknown + linkType: soft + "@backstage/plugin-proxy-backend@^0.2.30-next.2, @backstage/plugin-proxy-backend@workspace:plugins/proxy-backend": version: 0.0.0-use.local resolution: "@backstage/plugin-proxy-backend@workspace:plugins/proxy-backend" @@ -22470,6 +22549,7 @@ __metadata: "@backstage/plugin-org": ^0.5.9-next.3 "@backstage/plugin-pagerduty": 0.5.2-next.3 "@backstage/plugin-permission-react": ^0.4.5-next.2 + "@backstage/plugin-playlist": ^0.0.0 "@backstage/plugin-rollbar": ^0.4.9-next.3 "@backstage/plugin-scaffolder": ^1.6.0-next.3 "@backstage/plugin-search": ^1.0.2-next.3 @@ -22557,6 +22637,7 @@ __metadata: "@backstage/plugin-permission-backend": ^0.5.11-next.2 "@backstage/plugin-permission-common": ^0.6.4-next.2 "@backstage/plugin-permission-node": ^0.6.5-next.3 + "@backstage/plugin-playlist-backend": ^0.0.0 "@backstage/plugin-proxy-backend": ^0.2.30-next.2 "@backstage/plugin-rollbar-backend": ^0.1.33-next.3 "@backstage/plugin-scaffolder-backend": ^1.6.0-next.3 From 02a5296f859aa605a53b32d4b4c5a922740867a7 Mon Sep 17 00:00:00 2001 From: Phil Kuang Date: Fri, 16 Sep 2022 14:34:33 -0400 Subject: [PATCH 044/192] refactor(search-react): address review comment Signed-off-by: Phil Kuang --- plugins/search-react/package.json | 1 + .../SearchFilter.Autocomplete.tsx | 3 +- .../SearchFilter/SearchFilter.test.tsx | 16 ++---- .../components/SearchFilter/SearchFilter.tsx | 6 +-- .../src/context/SearchContext.test.tsx | 54 +++++++++++++++++++ .../src/context/SearchContext.tsx | 10 ++++ yarn.lock | 1 + 7 files changed, 73 insertions(+), 18 deletions(-) diff --git a/plugins/search-react/package.json b/plugins/search-react/package.json index 826d751353..8de637d46c 100644 --- a/plugins/search-react/package.json +++ b/plugins/search-react/package.json @@ -40,6 +40,7 @@ "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", + "lodash": "^4.17.21", "qs": "^6.9.4", "react-use": "^17.3.2" }, diff --git a/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.tsx index c14dfdfa7e..35d1f376e5 100644 --- a/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.tsx +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.tsx @@ -62,7 +62,7 @@ export const AutocompleteFilter = (props: SearchAutocompleteFilterProps) => { defaultValues, valuesDebounceMs, ); - const { filters, setFilters, setPageCursor } = useSearch(); + const { filters, setFilters } = useSearch(); const filterValue = (filters[name] as string | string[] | undefined) || (multiple ? [] : null); @@ -79,7 +79,6 @@ export const AutocompleteFilter = (props: SearchAutocompleteFilterProps) => { } return { ...others }; }); - setPageCursor(undefined); }; // Provide the input field. diff --git a/plugins/search-react/src/components/SearchFilter/SearchFilter.test.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.test.tsx index 27fbae237c..89a1818e8d 100644 --- a/plugins/search-react/src/components/SearchFilter/SearchFilter.test.tsx +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.test.tsx @@ -33,7 +33,6 @@ describe('SearchFilter', () => { term: '', filters: {}, types: [], - pageCursor: 'abc123', }; const label = 'Field'; @@ -139,10 +138,7 @@ describe('SearchFilter', () => { await userEvent.click(checkBox); await waitFor(() => { expect(query).toHaveBeenLastCalledWith( - expect.objectContaining({ - filters: { field: [values[0]] }, - pageCursor: undefined, - }), + expect.objectContaining({ filters: { field: [values[0]] } }), ); }); @@ -150,7 +146,7 @@ describe('SearchFilter', () => { await userEvent.click(checkBox); await waitFor(() => { expect(query).toHaveBeenLastCalledWith( - expect.objectContaining({ filters: {}, pageCursor: undefined }), + expect.objectContaining({ filters: {} }), ); }); }); @@ -174,7 +170,6 @@ describe('SearchFilter', () => { expect(query).toHaveBeenLastCalledWith( expect.objectContaining({ filters: { ...filters, field: [values[0]] }, - pageCursor: undefined, }), ); }); @@ -183,7 +178,7 @@ describe('SearchFilter', () => { await userEvent.click(checkBox); await waitFor(() => { expect(query).toHaveBeenLastCalledWith( - expect.objectContaining({ filters, pageCursor: undefined }), + expect.objectContaining({ filters }), ); }); }); @@ -342,7 +337,6 @@ describe('SearchFilter', () => { expect(query).toHaveBeenLastCalledWith( expect.objectContaining({ filters: { [name]: values[0] }, - pageCursor: undefined, }), ); }); @@ -359,7 +353,6 @@ describe('SearchFilter', () => { expect(query).toHaveBeenLastCalledWith( expect.objectContaining({ filters: {}, - pageCursor: undefined, }), ); }); @@ -395,7 +388,6 @@ describe('SearchFilter', () => { expect(query).toHaveBeenLastCalledWith( expect.objectContaining({ filters: { ...filters, [name]: values[0] }, - pageCursor: undefined, }), ); }); @@ -410,7 +402,7 @@ describe('SearchFilter', () => { await waitFor(() => { expect(query).toHaveBeenLastCalledWith( - expect.objectContaining({ filters, pageCursor: undefined }), + expect.objectContaining({ filters }), ); }); }); diff --git a/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx index 958caf0927..2a6d7baafa 100644 --- a/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx @@ -82,7 +82,7 @@ export const CheckboxFilter = (props: SearchFilterComponentProps) => { valuesDebounceMs, } = props; const classes = useStyles(); - const { filters, setFilters, setPageCursor } = useSearch(); + const { filters, setFilters } = useSearch(); useDefaultFilterValue(name, defaultValue); const asyncValues = typeof givenValues === 'function' ? givenValues : undefined; @@ -106,7 +106,6 @@ export const CheckboxFilter = (props: SearchFilterComponentProps) => { const items = checked ? [...rest, value] : rest; return items.length ? { ...others, [name]: items } : others; }); - setPageCursor(undefined); }; return ( @@ -162,7 +161,7 @@ export const SelectFilter = (props: SearchFilterComponentProps) => { defaultValues, valuesDebounceMs, ); - const { filters, setFilters, setPageCursor } = useSearch(); + const { filters, setFilters } = useSearch(); const handleChange = (e: ChangeEvent<{ value: unknown }>) => { const { @@ -173,7 +172,6 @@ export const SelectFilter = (props: SearchFilterComponentProps) => { const { [name]: filter, ...others } = prevFilters; return value ? { ...others, [name]: value as string } : others; }); - setPageCursor(undefined); }; return ( diff --git a/plugins/search-react/src/context/SearchContext.test.tsx b/plugins/search-react/src/context/SearchContext.test.tsx index 68c79d3a5e..b5888fa8a9 100644 --- a/plugins/search-react/src/context/SearchContext.test.tsx +++ b/plugins/search-react/src/context/SearchContext.test.tsx @@ -160,6 +160,60 @@ describe('SearchContext', () => { expect(result.current.pageCursor).toBeUndefined(); }); + + it('When filters are cleared', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSearch(), { + wrapper, + initialProps: { + initialState: { + ...initialState, + filters: { foo: 'bar' }, + term: 'first term', + pageCursor: 'SOMEPAGE', + }, + }, + }); + + await waitForNextUpdate(); + + expect(result.current.filters).toEqual({ foo: 'bar' }); + expect(result.current.pageCursor).toEqual('SOMEPAGE'); + + act(() => { + result.current.setFilters({}); + }); + + await waitForNextUpdate(); + + expect(result.current.pageCursor).toBeUndefined(); + }); + + it('When filters are set (and different from previous)', async () => { + const { result, waitForNextUpdate } = renderHook(() => useSearch(), { + wrapper, + initialProps: { + initialState: { + ...initialState, + filters: { foo: 'bar' }, + term: 'first term', + pageCursor: 'SOMEPAGE', + }, + }, + }); + + await waitForNextUpdate(); + + expect(result.current.filters).toEqual({ foo: 'bar' }); + expect(result.current.pageCursor).toEqual('SOMEPAGE'); + + act(() => { + result.current.setFilters({ foo: 'test' }); + }); + + await waitForNextUpdate(); + + expect(result.current.pageCursor).toBeUndefined(); + }); }); describe('Performs search (and sets results)', () => { diff --git a/plugins/search-react/src/context/SearchContext.tsx b/plugins/search-react/src/context/SearchContext.tsx index 7faa743c9d..6557f04c15 100644 --- a/plugins/search-react/src/context/SearchContext.tsx +++ b/plugins/search-react/src/context/SearchContext.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { isEqual } from 'lodash'; import React, { PropsWithChildren, useCallback, @@ -115,6 +116,7 @@ const useSearchContextValue = ( ); const prevTerm = usePrevious(term); + const prevFilters = usePrevious(filters); const result = useAsync( () => @@ -146,6 +148,14 @@ const useSearchContextValue = ( } }, [term, prevTerm, setPageCursor]); + useEffect(() => { + // Any time filters is reset, we want to start from page 0. + // Only reset the page if it has been modified by the user at least once, the initial state must not reset the page. + if (prevFilters !== undefined && !isEqual(filters, prevFilters)) { + setPageCursor(undefined); + } + }, [filters, prevFilters, setPageCursor]); + const value: SearchContextValue = { result, filters, diff --git a/yarn.lock b/yarn.lock index 02659263e8..659cc76d23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6712,6 +6712,7 @@ __metadata: "@testing-library/react": ^12.1.3 "@testing-library/react-hooks": ^8.0.0 "@testing-library/user-event": ^14.0.0 + lodash: ^4.17.21 qs: ^6.9.4 react-use: ^17.3.2 peerDependencies: From cc63eb8611d86d12b7a28a08b4ec33a658e4277e Mon Sep 17 00:00:00 2001 From: Tomasz Szuba Date: Tue, 13 Sep 2022 13:43:40 +0200 Subject: [PATCH 045/192] Sort entries in skeleton.tar.gz Signed-off-by: Tomasz Szuba --- .changeset/clever-rocks-train.md | 5 +++++ .../cli/src/lib/packager/createDistWorkspace.ts | 14 ++++++++------ 2 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 .changeset/clever-rocks-train.md diff --git a/.changeset/clever-rocks-train.md b/.changeset/clever-rocks-train.md new file mode 100644 index 0000000000..91d3964cdd --- /dev/null +++ b/.changeset/clever-rocks-train.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Sort entries in skeleton.tar.gz for better docker layer caching diff --git a/packages/cli/src/lib/packager/createDistWorkspace.ts b/packages/cli/src/lib/packager/createDistWorkspace.ts index 53e00ab6d9..5451a873f7 100644 --- a/packages/cli/src/lib/packager/createDistWorkspace.ts +++ b/packages/cli/src/lib/packager/createDistWorkspace.ts @@ -22,7 +22,7 @@ import { relative as relativePath, } from 'path'; import { tmpdir } from 'os'; -import tar, { CreateOptions } from 'tar'; +import tar, { CreateOptions, FileOptions } from 'tar'; import partition from 'lodash/partition'; import { paths } from '../paths'; import { run } from '../run'; @@ -214,10 +214,12 @@ export async function createDistWorkspace( } if (options.skeleton) { - const skeletonFiles = targets.map(target => { - const dir = relativePath(paths.targetRoot, target.dir); - return joinPath(dir, 'package.json'); - }); + const skeletonFiles = targets + .map(target => { + const dir = relativePath(paths.targetRoot, target.dir); + return joinPath(dir, 'package.json'); + }) + .sort(); await tar.create( { @@ -226,7 +228,7 @@ export async function createDistWorkspace( portable: true, noMtime: true, gzip: options.skeleton.endsWith('.gz'), - } as CreateOptions & { noMtime: boolean }, + } as CreateOptions & FileOptions & { noMtime: boolean }, skeletonFiles, ); } From 108cdc39124bdb8e011bc614cd513cff695e88a0 Mon Sep 17 00:00:00 2001 From: Dominik Schwank Date: Fri, 26 Aug 2022 10:46:16 +0200 Subject: [PATCH 046/192] feat: add new user settings backend Adding a new user settings backend and PersistentStorage to store user related settings in the database. Signed-off-by: Dominik Schwank --- .changeset/healthy-oranges-fly.md | 8 + .github/CODEOWNERS | 1 + packages/core-app-api/api-report.md | 29 ++ packages/core-app-api/package.json | 2 + .../StorageApi/PersistentStorage.test.ts | 324 +++++++++++++++ .../StorageApi/PersistentStorage.ts | 193 +++++++++ .../apis/implementations/StorageApi/index.ts | 1 + plugins/user-settings-backend/.eslintrc.js | 1 + plugins/user-settings-backend/README.md | 104 +++++ plugins/user-settings-backend/api-report.md | 177 +++++++++ .../migrations/20220824183000_init.js | 41 ++ plugins/user-settings-backend/package.json | 52 +++ .../DatabaseUserSettingsStore.test.ts | 373 ++++++++++++++++++ .../src/database/DatabaseUserSettingsStore.ts | 143 +++++++ .../src/database/UserSettingsStore.ts | 65 +++ .../src/database/index.ts | 20 + plugins/user-settings-backend/src/index.ts | 17 + plugins/user-settings-backend/src/run.ts | 33 ++ .../src/service/index.ts | 20 + .../src/service/router.test.ts | 319 +++++++++++++++ .../src/service/router.ts | 168 ++++++++ .../src/service/standaloneServer.ts | 82 ++++ .../user-settings-backend/src/setupTests.ts | 16 + yarn.lock | 29 +- 24 files changed, 2215 insertions(+), 3 deletions(-) create mode 100644 .changeset/healthy-oranges-fly.md create mode 100644 packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.test.ts create mode 100644 packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.ts create mode 100644 plugins/user-settings-backend/.eslintrc.js create mode 100644 plugins/user-settings-backend/README.md create mode 100644 plugins/user-settings-backend/api-report.md create mode 100644 plugins/user-settings-backend/migrations/20220824183000_init.js create mode 100644 plugins/user-settings-backend/package.json create mode 100644 plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts create mode 100644 plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts create mode 100644 plugins/user-settings-backend/src/database/UserSettingsStore.ts create mode 100644 plugins/user-settings-backend/src/database/index.ts create mode 100644 plugins/user-settings-backend/src/index.ts create mode 100644 plugins/user-settings-backend/src/run.ts create mode 100644 plugins/user-settings-backend/src/service/index.ts create mode 100644 plugins/user-settings-backend/src/service/router.test.ts create mode 100644 plugins/user-settings-backend/src/service/router.ts create mode 100644 plugins/user-settings-backend/src/service/standaloneServer.ts create mode 100644 plugins/user-settings-backend/src/setupTests.ts diff --git a/.changeset/healthy-oranges-fly.md b/.changeset/healthy-oranges-fly.md new file mode 100644 index 0000000000..da6530b903 --- /dev/null +++ b/.changeset/healthy-oranges-fly.md @@ -0,0 +1,8 @@ +--- +'@backstage/core-app-api': minor +'@backstage/plugin-user-settings-backend': minor +--- + +Add new plugin @backstage/user-settings-backend to store user related settings +in the database. Additionally adding a PersistentStorage implementation to use +easily use the new plugin as drop-in replacement for the WebStorage. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f00771d29d..599a83cedf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -56,6 +56,7 @@ yarn.lock @backstage/reviewers @backst /plugins/stack-overflow-backend @backstage/reviewers @backstage/techdocs-core /plugins/techdocs @backstage/reviewers @backstage/techdocs-core /plugins/techdocs-* @backstage/reviewers @backstage/techdocs-core +/plugins/user-settings-backend @backstage/reviewers @backstage/sda-se-reviewers /tech-insights-backend @backstage/reviewers @xantier @iain-b /tech-insights-backend-module-jsonfc @backstage/reviewers @xantier @iain-b /tech-insights-tech-insights-common @backstage/reviewers @xantier @iain-b diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index f2bc4b6665..de3afdcbd4 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -506,6 +506,35 @@ export type OneLoginAuthCreateOptions = { provider?: AuthProviderInfo; }; +// @public +export class PersistentStorage implements StorageApi { + constructor( + namespace: string, + fetchApi: FetchApi, + discoveryApi: DiscoveryApi, + errorApi: ErrorApi, + ); + // (undocumented) + static create(options: { + fetchApi: FetchApi; + discoveryApi: DiscoveryApi; + errorApi: ErrorApi; + namespace?: string; + }): PersistentStorage; + // (undocumented) + forBucket(name: string): StorageApi; + // (undocumented) + observe$( + key: string, + ): Observable>; + // (undocumented) + remove(key: string): Promise; + // (undocumented) + set(key: string, data: T): Promise; + // (undocumented) + snapshot(key: string): StorageValueSnapshot; +} + // @public export class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index 8532997477..632c429361 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -34,6 +34,8 @@ "dependencies": { "@backstage/config": "^1.0.2-next.0", "@backstage/core-plugin-api": "^1.0.6-next.3", + "@backstage/errors": "^1.1.0", + "@backstage/plugin-permission-common": "^0.6.4-next.0", "@backstage/types": "^1.0.0", "@backstage/version-bridge": "^1.0.1", "@types/prop-types": "^15.7.3", diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.test.ts b/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.test.ts new file mode 100644 index 0000000000..6cea8dde5b --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.test.ts @@ -0,0 +1,324 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PersistentStorage } from './PersistentStorage'; +import { ErrorApi, FetchApi, StorageApi } from '@backstage/core-plugin-api'; +import { DiscoveryApi } from '@backstage/plugin-permission-common'; +import { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils'; + +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; + +const server = setupServer(); + +describe('Persistent Storage API', () => { + setupRequestMockHandlers(server); + const mockBaseUrl = 'http://backstage:9191/api'; + const mockErrorApi = { post: jest.fn(), error$: jest.fn() }; + const mockDiscoveryApi = { getBaseUrl: async () => mockBaseUrl }; + + const createPersistentStorage = ( + args?: Partial<{ + fetchApi: FetchApi; + discoveryApi: DiscoveryApi; + errorApi: ErrorApi; + namespace?: string; + }>, + ): StorageApi => { + return PersistentStorage.create({ + errorApi: mockErrorApi, + fetchApi: new MockFetchApi(), + discoveryApi: mockDiscoveryApi, + ...args, + }); + }; + + beforeEach(() => { + server.use( + rest.get(`${mockBaseUrl}/`, async (_req, res, ctx) => { + return res(ctx.json([])); + }), + ); + }); + + afterEach(() => jest.resetAllMocks()); + + it('should return undefined for values which are unset', async () => { + const storage = createPersistentStorage(); + + server.use( + rest.get(`${mockBaseUrl}/:bucket/:key`, async (_req, res, ctx) => { + return res(ctx.json({ value: 'a' })); + }), + ); + + expect(storage.snapshot('myfakekey').value).toBeUndefined(); + expect(storage.snapshot('myfakekey')).toEqual({ + key: 'myfakekey', + presence: 'unknown', + value: undefined, + }); + }); + + it('should allow setting of a simple data structure', async () => { + const storage = createPersistentStorage(); + const dummyValue = 'a'; + + server.use( + rest.put(`${mockBaseUrl}/:bucket/:key`, async (req, res, ctx) => { + const body = await req.json(); + const data = { value: JSON.stringify(dummyValue) }; + expect(body).toEqual(data); + + return res(ctx.json(data)); + }), + ); + + await storage.set('my-key', dummyValue); + }); + + it('should allow setting of a complex data structure', async () => { + const storage = createPersistentStorage(); + const dummyValue = { + some: 'nice data', + with: { nested: 'values', nice: true }, + }; + + server.use( + rest.put(`${mockBaseUrl}/:bucket/:key`, async (req, res, ctx) => { + const body = await req.json(); + const data = { value: JSON.stringify(dummyValue) }; + expect(body).toEqual(data); + + return res(ctx.json(data)); + }), + ); + + await storage.set('my-key', dummyValue); + }); + + it('should subscribe to key changes when setting a new value', async () => { + const storage = createPersistentStorage(); + + const wrongKeyNextHandler = jest.fn(); + const selectedKeyNextHandler = jest.fn(); + const mockData = { hello: 'im a great new value' }; + + server.use( + rest.put(`${mockBaseUrl}/:bucket/:key`, async (req, res, ctx) => { + const body = await req.json(); + const data = { value: JSON.stringify(mockData) }; + expect(body).toEqual(data); + + return res(ctx.json(data)); + }), + ); + + await new Promise(resolve => { + storage.observe$('correctKey').subscribe({ + next: snapshot => { + selectedKeyNextHandler(snapshot); + if (snapshot.presence === 'present') { + resolve(); + } + }, + }); + + storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler }); + + storage.set('correctKey', mockData); + }); + + expect(wrongKeyNextHandler).toHaveBeenCalledTimes(0); + expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1); + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'correctKey', + presence: 'present', + value: mockData, + }); + }); + + it('should subscribe to key changes when deleting a value', async () => { + const storage = createPersistentStorage(); + + const wrongKeyNextHandler = jest.fn(); + const selectedKeyNextHandler = jest.fn(); + + server.use( + rest.delete(`${mockBaseUrl}/:bucket/:key`, async (_req, res, ctx) => { + return res(ctx.status(204)); + }), + ); + + await new Promise(resolve => { + storage.observe$('correctKey').subscribe({ + next: snapshot => { + selectedKeyNextHandler(snapshot); + if (snapshot.presence === 'absent') { + resolve(); + } + }, + }); + + storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler }); + + storage.remove('correctKey'); + }); + + expect(wrongKeyNextHandler).toHaveBeenCalledTimes(0); + expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1); + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'correctKey', + presence: 'absent', + value: undefined, + }); + }); + + it('should not clash with other namespaces when creating buckets', async () => { + const rootStorage = createPersistentStorage(); + const selectedKeyNextHandler = jest.fn(); + + server.use( + rest.put(`${mockBaseUrl}/:bucket/:key`, async (req, res, ctx) => { + const { bucket, key } = req.params; + const { value } = await req.json(); + + expect(bucket).toEqual('default.profile.something.deep'); + expect(key).toEqual('test2'); + + return res(ctx.json({ value })); + }), + rest.get(`${mockBaseUrl}/:bucket/:key`, async (req, res, ctx) => { + const { bucket, key } = req.params; + + expect(bucket).toEqual('default.profile/something'); + expect(key).toEqual('deep/test2'); + + return res(ctx.status(404)); + }), + ); + + // when getting key test2 it will translate to default.profile.something.deep/test2 + const firstStorage = rootStorage + .forBucket('profile') + .forBucket('something') + .forBucket('deep'); + // when getting key deep/test2 it will translate to default.profile.something/deep/test2 + const secondStorage = rootStorage.forBucket('profile/something'); + + await firstStorage.set('test2', { error: true }); + + await new Promise(resolve => { + secondStorage.observe$('deep/test2').subscribe({ + next: snapshot => { + selectedKeyNextHandler(snapshot); + if (snapshot.presence === 'absent') { + resolve(); + } + }, + }); + + secondStorage.snapshot('deep/test2'); + }); + + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'deep/test2', + presence: 'absent', + value: undefined, + }); + expect(mockErrorApi.post).not.toHaveBeenCalled(); + }); + + it('should call the error api when the json can not be parsed in local storage', async () => { + const selectedKeyNextHandler = jest.fn(); + const rootStorage = createPersistentStorage({ + namespace: 'Test.Mock.Thing', + }); + + server.use( + rest.get(`${mockBaseUrl}/:bucket/:key`, async (req, res, ctx) => { + const { bucket, key } = req.params; + + expect(bucket).toEqual('Test.Mock.Thing'); + expect(key).toEqual('key'); + + return res(ctx.text('{ invalid: json string }')); + }), + ); + + await new Promise(resolve => { + rootStorage.observe$('key').subscribe({ + next: snapshot => { + selectedKeyNextHandler(snapshot); + if (snapshot.presence === 'absent') { + resolve(); + } + }, + }); + + rootStorage.snapshot('key'); + }); + + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'key', + presence: 'absent', + value: undefined, + }); + expect(mockErrorApi.post).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('should freeze the snapshot value', async () => { + const storage = createPersistentStorage(); + const selectedKeyNextHandler = jest.fn(); + const data = { foo: 'bar', baz: [{ foo: 'bar' }] }; + + server.use( + rest.get(`${mockBaseUrl}/:bucket/:key`, async (_req, res, ctx) => { + return res(ctx.text(JSON.stringify({ value: JSON.stringify(data) }))); + }), + ); + + await new Promise(resolve => { + storage.observe$('key').subscribe({ + next: snapshot => { + selectedKeyNextHandler(snapshot); + if (snapshot.presence === 'present') { + resolve(); + } + }, + }); + + storage.snapshot('key'); + }); + + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'key', + presence: 'present', + value: { baz: [{ foo: 'bar' }], foo: 'bar' }, + }); + + const snapshot = selectedKeyNextHandler.mock.calls[0][0]; + expect(() => { + snapshot.value.foo = 'buzz'; + }).toThrow(/Cannot assign to read only property/); + expect(() => { + snapshot.value.baz[0].foo = 'buzz'; + }).toThrow(/Cannot assign to read only property/); + expect(() => { + snapshot.value.baz.push({ foo: 'buzz' }); + }).toThrow(/Cannot add property 1, object is not extensible/); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.ts b/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.ts new file mode 100644 index 0000000000..4ebf635cd9 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.ts @@ -0,0 +1,193 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + DiscoveryApi, + ErrorApi, + FetchApi, + StorageApi, + StorageValueSnapshot, +} from '@backstage/core-plugin-api'; +import { NotFoundError } from '@backstage/errors'; +import { JsonValue, Observable } from '@backstage/types'; +import ObservableImpl from 'zen-observable'; + +const buckets = new Map(); + +/** + * An implementation of the storage API, that uses the user-settings backend to + * persist the data in the DB. + * + * @public + */ +export class PersistentStorage implements StorageApi { + private subscribers = new Set< + ZenObservable.SubscriptionObserver> + >(); + + private readonly observable = new ObservableImpl< + StorageValueSnapshot + >(subscriber => { + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }); + + constructor( + private readonly namespace: string, + private readonly fetchApi: FetchApi, + private readonly discoveryApi: DiscoveryApi, + private readonly errorApi: ErrorApi, + ) {} + + static create(options: { + fetchApi: FetchApi; + discoveryApi: DiscoveryApi; + errorApi: ErrorApi; + namespace?: string; + }): PersistentStorage { + return new PersistentStorage( + options.namespace ?? 'default', + options.fetchApi, + options.discoveryApi, + options.errorApi, + ); + } + + forBucket(name: string): StorageApi { + // use dot instead of slash separator to have nicer URLs + const bucketPath = `${this.namespace}.${name}`; + + if (!buckets.has(bucketPath)) { + buckets.set( + bucketPath, + new PersistentStorage( + bucketPath, + this.fetchApi, + this.discoveryApi, + this.errorApi, + ), + ); + } + + return buckets.get(bucketPath)!; + } + + async remove(key: string): Promise { + const fetchUrl = await this.getFetchUrl(key); + + await this.fetchApi.fetch(fetchUrl, { + method: 'DELETE', + }); + + this.notifyChanges({ + key, + presence: 'absent', + }); + } + + async set(key: string, data: T): Promise { + const fetchUrl = await this.getFetchUrl(key); + const body = JSON.stringify({ value: JSON.stringify(data) }); + + const response = await this.fetchApi.fetch(fetchUrl, { + method: 'PUT', + body, + }); + + const { value } = await response.json(); + + this.notifyChanges({ + key, + value: JSON.parse(value), + presence: 'present', + }); + } + + observe$( + key: string, + ): Observable> { + return this.observable.filter(({ key: messageKey }) => messageKey === key); + } + + snapshot(key: string): StorageValueSnapshot { + // trigger a reload + this.get(key).then(snapshot => this.notifyChanges(snapshot)); + + return { + key, + presence: 'unknown', + }; + } + + private async get( + key: string, + ): Promise> { + try { + const fetchUrl = await this.getFetchUrl(key); + const response = await this.fetchApi.fetch(fetchUrl); + + if (response.status === 404) { + throw new NotFoundError( + `Setting '${key}' is not set in bucket '${this.namespace}'`, + ); + } else if (!response.ok) { + throw new Error( + `Unable to fetch '${key}' from bucket '${this.namespace}'`, + ); + } + + const { value: rawValue } = await response.json(); + const value = JSON.parse(rawValue, (_key, val) => { + if (typeof val === 'object' && val !== null) { + Object.freeze(val); + } + return val; + }); + + return { + key, + presence: 'present', + value, + }; + } catch (error) { + // NotFoundError shouldn't be recorded + if (error && !(error instanceof NotFoundError)) { + this.errorApi.post(error); + } + + return { + key, + presence: 'absent', + value: undefined, + }; + } + } + + private async getFetchUrl(key: string) { + const baseUrl = await this.discoveryApi.getBaseUrl('user-settings'); + const encodedNamespace = encodeURIComponent(this.namespace); + const encodedKey = encodeURIComponent(key); + return `${baseUrl}/${encodedNamespace}/${encodedKey}`; + } + + private async notifyChanges(snapshot: StorageValueSnapshot) { + for (const subscription of this.subscribers) { + subscription.next(snapshot); + } + } +} diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/index.ts b/packages/core-app-api/src/apis/implementations/StorageApi/index.ts index 2f941381fd..70a8c9cbe1 100644 --- a/packages/core-app-api/src/apis/implementations/StorageApi/index.ts +++ b/packages/core-app-api/src/apis/implementations/StorageApi/index.ts @@ -15,3 +15,4 @@ */ export { WebStorage } from './WebStorage'; +export { PersistentStorage } from './PersistentStorage'; diff --git a/plugins/user-settings-backend/.eslintrc.js b/plugins/user-settings-backend/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/user-settings-backend/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/user-settings-backend/README.md b/plugins/user-settings-backend/README.md new file mode 100644 index 0000000000..05a8c15c6d --- /dev/null +++ b/plugins/user-settings-backend/README.md @@ -0,0 +1,104 @@ +# User settings backend + +This backend allows to save user specific settings. All requests need to be +authorized, as the user identifier (`userEntityRef`) is resolved using the +authorization token. + +## Setup backend + +1. Install the backend plugin: + +```bash +# From your Backstage root directory +yarn --cwd packages/backend add @backstage/plugin-user-settings-backend +``` + +1. Configure the routes by adding a new `userSettings.ts` file in + `packages/backend/src/plugins/`: + +```ts +// packages/backend/src/plugins/userSettings.ts +import { IdentityClient } from '@backstage/plugin-auth-node'; +import { + createRouter, + createUserSettingsStore, +} from '@backstage/plugin-user-settings-backend'; +import { Router } from 'express'; + +import { PluginEnvironment } from '../types'; + +export default async function createPlugin({ + database, + discovery, +}: PluginEnvironment): Promise { + const identity = IdentityClient.create({ + discovery, + issuer: await discovery.getExternalBaseUrl('auth'), + }); + + return await createRouter({ + userSettingsStore: await createUserSettingsStore(database), + identity, + }); +} +``` + +3. Add the new routes to your backend by modifying the + `packages/backend/src/index.ts`: + +```diff +// packages/backend/src/index.ts ++ import userSettings from './plugins/userSettings'; +async function main() { ++ const userSettingsEnv = useHotMemoize(module, () => ++ createEnv('userSettings'), ++ ); + const apiRouter = Router(); ++ apiRouter.use('/user-settings', await userSettings(userSettingsEnv)); +} +``` + +## Setup app + +To make use of the user settings backend, replace the `WebStorage` with the +`PersistentStorage` by using the `storageApiRef`. + +```diff +// packages/app/src/apis.ts +import { + AnyApiFactory, + createApiFactory, ++ discoveryApiRef, ++ fetchApiRef + errorApiRef, ++ storageApiRef, +} from '@backstage/core-plugin-api'; ++ import { PersistentStorage } from '@backstage/core-app-api'; + +export const apis: AnyApiFactory[] = [ ++ createApiFactory({ ++ api: storageApiRef, ++ deps: { ++ discoveryApi: discoveryApiRef, ++ errorApi: errorApiRef, ++ fetchApi: fetchApiRef, ++ }, ++ factory: ({ discoveryApi, errorApi, fetchApi }) => ++ PersistentStorage.create({ discoveryApi, errorApi, fetchApi }), ++ }), +]; +``` + +## Development + +Use `yarn start` to start the local dev environment. To simplify the access to +the API, the token will automatically be set to `user:default/john_doe`. + +You can change the user by setting a custom `Authorization` header. To keep +things simple, the raw `Bearer` value will directly be used as user. + +_Example:_ + +```bash +curl --request GET 'http://localhost:7007/user-settings' --header 'Authorization: Bearer user:default/custom-user' +``` diff --git a/plugins/user-settings-backend/api-report.md b/plugins/user-settings-backend/api-report.md new file mode 100644 index 0000000000..b3b40970c9 --- /dev/null +++ b/plugins/user-settings-backend/api-report.md @@ -0,0 +1,177 @@ +## API Report File for "@backstage/plugin-user-settings-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import express from 'express'; +import { IdentityClient } from '@backstage/plugin-auth-node'; +import { Knex } from 'knex'; +import { PluginDatabaseManager } from '@backstage/backend-common'; + +// @public +export function createRouter( + options: RouterOptions, +): Promise; + +// @public (undocumented) +export function createUserSettingsStore( + database: PluginDatabaseManager, +): Promise; + +// @public +export class DatabaseUserSettingsStore + implements UserSettingsStore +{ + // (undocumented) + static create(knex: Knex): Promise; + // (undocumented) + delete( + tx: Knex.Transaction, + opts: { + userEntityRef: string; + bucket: string; + key: string; + }, + ): Promise; + // (undocumented) + deleteAll( + tx: Knex.Transaction, + opts: { + userEntityRef: string; + }, + ): Promise; + // (undocumented) + deleteBucket( + tx: Knex.Transaction, + opts: { + userEntityRef: string; + bucket: string; + }, + ): Promise; + // (undocumented) + get( + tx: Knex.Transaction, + opts: { + userEntityRef: string; + bucket: string; + key: string; + }, + ): Promise; + // (undocumented) + getAll( + tx: Knex.Transaction, + opts: { + userEntityRef: string; + }, + ): Promise[]>; + // (undocumented) + getBucket( + tx: Knex.Transaction, + opts: { + userEntityRef: string; + bucket: string; + }, + ): Promise; + // (undocumented) + set( + tx: Knex.Transaction, + opts: { + userEntityRef: string; + bucket: string; + key: string; + value: string; + }, + ): Promise; + // (undocumented) + transaction(fn: (tx: Knex.Transaction) => Promise): Promise; +} + +// @public (undocumented) +export type RawDbUserSettingsRow = { + user_entity_ref: string; + bucket: string; + key: string; + value: string; +}; + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + identity: IdentityClient; + // (undocumented) + userSettingsStore: UserSettingsStore; +} + +// @public (undocumented) +export type UserSetting = { + bucket: string; + key: string; + value: string; +}; + +// @public +export interface UserSettingsStore { + // (undocumented) + delete( + tx: Transaction, + opts: { + userEntityRef: string; + bucket: string; + key: string; + }, + ): Promise; + // (undocumented) + deleteAll( + tx: Transaction, + opts: { + userEntityRef: string; + }, + ): Promise; + // (undocumented) + deleteBucket( + tx: Transaction, + opts: { + userEntityRef: string; + bucket: string; + }, + ): Promise; + // (undocumented) + get( + tx: Transaction, + opts: { + userEntityRef: string; + bucket: string; + key: string; + }, + ): Promise; + // (undocumented) + getAll( + tx: Transaction, + opts: { + userEntityRef: string; + }, + ): Promise; + // (undocumented) + getBucket( + tx: Transaction, + opts: { + userEntityRef: string; + bucket: string; + }, + ): Promise; + // (undocumented) + set( + tx: Transaction, + opts: { + userEntityRef: string; + bucket: string; + key: string; + value: string; + }, + ): Promise; + // (undocumented) + transaction(fn: (tx: Transaction) => Promise): Promise; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/user-settings-backend/migrations/20220824183000_init.js b/plugins/user-settings-backend/migrations/20220824183000_init.js new file mode 100644 index 0000000000..688d8495b5 --- /dev/null +++ b/plugins/user-settings-backend/migrations/20220824183000_init.js @@ -0,0 +1,41 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @param {import('knex').Knex} knex + */ +exports.up = async function up(knex) { + await knex.schema.createTable('user_settings', table => { + table.comment('The table of user related settings'); + + table + .text('user_entity_ref') + .notNullable() + .comment('The entityRef of the user'); + table.text('bucket').notNullable().comment('Name of the bucket'); + table.text('key').notNullable().comment('Key of a bucket value'); + table.text('value').notNullable().comment('The value'); + + table.primary(['user_entity_ref', 'bucket', 'key']); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.dropTable('user_settings'); +}; diff --git a/plugins/user-settings-backend/package.json b/plugins/user-settings-backend/package.json new file mode 100644 index 0000000000..f20180cac3 --- /dev/null +++ b/plugins/user-settings-backend/package.json @@ -0,0 +1,52 @@ +{ + "name": "@backstage/plugin-user-settings-backend", + "description": "The Backstage backend plugin to manage user settings", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "backstage": { + "role": "backend-plugin" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/user-settings-backend" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "clean": "backstage-cli package clean" + }, + "dependencies": { + "@backstage/backend-common": "^0.15.1-next.1", + "@backstage/catalog-model": "^1.1.0", + "@backstage/errors": "^1.1.0", + "@backstage/plugin-auth-node": "^0.2.5-next.1", + "@types/express": "^4.17.6", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "knex": "^2.0.0", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/backend-test-utils": "^0.1.28-next.1", + "@backstage/cli": "^0.19.0-next.1", + "@types/supertest": "^2.0.8", + "supertest": "^6.1.3" + }, + "files": [ + "dist", + "migrations" + ] +} diff --git a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts new file mode 100644 index 0000000000..717707f034 --- /dev/null +++ b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts @@ -0,0 +1,373 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; +import { Knex } from 'knex'; + +import { + DatabaseUserSettingsStore, + RawDbUserSettingsRow, +} from './DatabaseUserSettingsStore'; + +jest.setTimeout(60_000); + +const databases = TestDatabases.create({ + ids: ['POSTGRES_13', 'SQLITE_3'], +}); + +async function createStore(databaseId: TestDatabaseId) { + const knex = await databases.init(databaseId); + return { + knex, + storage: await DatabaseUserSettingsStore.create(knex), + }; +} + +describe.each(databases.eachSupportedId())( + 'DatabaseUserSettingsStore (%s)', + databaseId => { + let storage: DatabaseUserSettingsStore; + let knex: Knex; + + beforeAll(async () => { + ({ storage, knex } = await createStore(databaseId)); + }); + + afterEach(async () => { + jest.resetAllMocks(); + + await knex('user_settings').del(); + }); + + const insert = (data: RawDbUserSettingsRow[]) => + knex('user_settings').insert(data); + const query = () => + knex('user_settings') + .orderBy('user_entity_ref') + .select(); + + describe('getAll', () => { + it('should return empty user settings', async () => { + expect( + await storage.transaction(tx => + storage.getAll(tx, { userEntityRef: 'user-1' }), + ), + ).toEqual([]); + }); + + it('should return all user settings', async () => { + await insert([ + { + user_entity_ref: 'user-1', + bucket: 'bucket-a', + key: 'key-a', + value: 'value-a', + }, + { + user_entity_ref: 'user-1', + bucket: 'bucket-a', + key: 'key-b', + value: 'value-b', + }, + { + user_entity_ref: 'user-1', + bucket: 'bucket-c', + key: 'key-c', + value: 'value-c', + }, + ]); + + expect( + await storage.transaction(tx => + storage.getAll(tx, { userEntityRef: 'user-1' }), + ), + ).toEqual([ + { bucket: 'bucket-a', key: 'key-a', value: 'value-a' }, + { bucket: 'bucket-a', key: 'key-b', value: 'value-b' }, + { bucket: 'bucket-c', key: 'key-c', value: 'value-c' }, + ]); + }); + }); + + describe('deleteAll', () => { + it('should delete all user settings', async () => { + await insert([ + { + user_entity_ref: 'user-1', + bucket: 'bucket-a', + key: 'key-a', + value: 'value-a', + }, + { + user_entity_ref: 'user-1', + bucket: 'bucket-c', + key: 'key-c', + value: 'value-c', + }, + { + user_entity_ref: 'user-2', + bucket: 'bucket-c', + key: 'key-c', + value: 'value-c', + }, + ]); + + await storage.transaction(tx => + storage.deleteAll(tx, { userEntityRef: 'user-1' }), + ); + + expect(await query()).toEqual([ + { + user_entity_ref: 'user-2', + bucket: 'bucket-c', + key: 'key-c', + value: 'value-c', + }, + ]); + }); + }); + + describe('getBucket', () => { + it('should return an empty bucket', async () => { + expect( + await storage.transaction(tx => + storage.getBucket(tx, { + userEntityRef: 'user-1', + bucket: 'bucket-c', + }), + ), + ).toEqual([]); + }); + + it('should return the settings of the bucket', async () => { + await insert([ + { + user_entity_ref: 'user-1', + bucket: 'bucket-a', + key: 'key-a', + value: 'value-a', + }, + { + user_entity_ref: 'user-1', + bucket: 'bucket-c', + key: 'key-c', + value: 'value-c', + }, + { + user_entity_ref: 'user-2', + bucket: 'bucket-c', + key: 'key-c', + value: 'value-c', + }, + ]); + + expect( + await storage.transaction(tx => + storage.getBucket(tx, { + userEntityRef: 'user-1', + bucket: 'bucket-a', + }), + ), + ).toEqual([{ bucket: 'bucket-a', key: 'key-a', value: 'value-a' }]); + }); + }); + + describe('deleteBucket', () => { + it('should delete a bucket', async () => { + await insert([ + { + user_entity_ref: 'user-1', + bucket: 'bucket-a', + key: 'key-a', + value: 'value-a', + }, + { + user_entity_ref: 'user-1', + bucket: 'bucket-c', + key: 'key-c', + value: 'value-c', + }, + { + user_entity_ref: 'user-2', + bucket: 'bucket-c', + key: 'key-c', + value: 'value-c', + }, + ]); + + await storage.transaction(tx => + storage.deleteBucket(tx, { + userEntityRef: 'user-1', + bucket: 'bucket-a', + }), + ); + + expect(await query()).toEqual([ + { + user_entity_ref: 'user-1', + bucket: 'bucket-c', + key: 'key-c', + value: 'value-c', + }, + { + user_entity_ref: 'user-2', + bucket: 'bucket-c', + key: 'key-c', + value: 'value-c', + }, + ]); + }); + }); + + describe('get', () => { + it('should throw an error', async () => { + await expect(() => + storage.transaction(tx => + storage.get(tx, { + userEntityRef: 'user-1', + bucket: 'bucket-c', + key: 'key-c', + }), + ), + ).rejects.toThrow(`Unable to find 'key-c' in bucket 'bucket-c'`); + }); + + it('should return the setting', async () => { + await insert([ + { + user_entity_ref: 'user-1', + bucket: 'bucket-a', + key: 'key-a', + value: 'value-a', + }, + { + user_entity_ref: 'user-2', + bucket: 'bucket-c', + key: 'key-c', + value: 'value-c', + }, + ]); + + expect( + await storage.transaction(tx => + storage.get(tx, { + userEntityRef: 'user-1', + bucket: 'bucket-a', + key: 'key-a', + }), + ), + ).toEqual({ bucket: 'bucket-a', key: 'key-a', value: 'value-a' }); + }); + }); + + describe('set', () => { + it('should insert a new setting', async () => { + await storage.transaction(tx => + storage.set(tx, { + userEntityRef: 'user-1', + bucket: 'bucket-a', + key: 'key-a', + value: 'value-a', + }), + ); + + expect(await query()).toEqual([ + { + user_entity_ref: 'user-1', + bucket: 'bucket-a', + key: 'key-a', + value: 'value-a', + }, + ]); + }); + + it('should overwrite an existing setting', async () => { + await storage.transaction(tx => + storage.set(tx, { + userEntityRef: 'user-1', + bucket: 'bucket-a', + key: 'key-a', + value: 'value-a', + }), + ); + + await storage.transaction(tx => + storage.set(tx, { + userEntityRef: 'user-1', + bucket: 'bucket-a', + key: 'key-a', + value: 'value-b', + }), + ); + + expect(await query()).toEqual([ + { + user_entity_ref: 'user-1', + bucket: 'bucket-a', + key: 'key-a', + value: 'value-b', + }, + ]); + }); + }); + + describe('delete', () => { + it('should not throw an error if the entry does not exist', async () => { + await expect(() => + storage.transaction(tx => + storage.delete(tx, { + userEntityRef: 'user-1', + bucket: 'bucket-c', + key: 'key-c', + }), + ), + ).not.toThrow(); + }); + + it('should return the setting', async () => { + await insert([ + { + user_entity_ref: 'user-1', + bucket: 'bucket-a', + key: 'key-a', + value: 'value-a', + }, + { + user_entity_ref: 'user-2', + bucket: 'bucket-c', + key: 'key-c', + value: 'value-c', + }, + ]); + + await storage.transaction(tx => + storage.delete(tx, { + userEntityRef: 'user-1', + bucket: 'bucket-a', + key: 'key-a', + }), + ); + expect(await query()).toEqual([ + { + user_entity_ref: 'user-2', + bucket: 'bucket-c', + key: 'key-c', + value: 'value-c', + }, + ]); + }); + }); + }, +); diff --git a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts new file mode 100644 index 0000000000..fd24a6f3e5 --- /dev/null +++ b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts @@ -0,0 +1,143 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { resolvePackagePath } from '@backstage/backend-common'; +import { NotFoundError } from '@backstage/errors'; +import { Knex } from 'knex'; + +import { type UserSetting, UserSettingsStore } from './UserSettingsStore'; + +const migrationsDir = resolvePackagePath( + '@backstage/plugin-user-settings-backend', + 'migrations', +); + +/** + * @public + */ +export type RawDbUserSettingsRow = { + user_entity_ref: string; + bucket: string; + key: string; + value: string; +}; + +/** + * Store to manage the user settings. + * + * @public + */ +export class DatabaseUserSettingsStore + implements UserSettingsStore +{ + static async create(knex: Knex): Promise { + await knex.migrate.latest({ + directory: migrationsDir, + }); + return new DatabaseUserSettingsStore(knex); + } + + private constructor(private readonly db: Knex) {} + + async getAll(tx: Knex.Transaction, opts: { userEntityRef: string }) { + const settings = await tx('user_settings') + .where({ user_entity_ref: opts.userEntityRef }) + .select('bucket', 'key', 'value'); + + return settings; + } + + async deleteAll( + tx: Knex.Transaction, + opts: { userEntityRef: string }, + ): Promise { + await tx('user_settings') + .where({ user_entity_ref: opts.userEntityRef }) + .delete(); + } + + async getBucket( + tx: Knex.Transaction, + opts: { userEntityRef: string; bucket: string }, + ): Promise { + const settings = await tx('user_settings') + .where({ user_entity_ref: opts.userEntityRef, bucket: opts.bucket }) + .select(['bucket', 'key', 'value']); + + return settings; + } + + async deleteBucket( + tx: Knex.Transaction, + opts: { userEntityRef: string; bucket: string }, + ): Promise { + await tx('user_settings') + .where({ user_entity_ref: opts.userEntityRef, bucket: opts.bucket }) + .delete(); + } + + async get( + tx: Knex.Transaction, + opts: { userEntityRef: string; bucket: string; key: string }, + ): Promise { + const setting = await tx('user_settings') + .where({ + user_entity_ref: opts.userEntityRef, + bucket: opts.bucket, + key: opts.key, + }) + .select(['bucket', 'key', 'value']); + + if (!setting.length) { + throw new NotFoundError( + `Unable to find '${opts.key}' in bucket '${opts.bucket}'`, + ); + } + + return setting[0]; + } + + async set( + tx: Knex.Transaction, + opts: { userEntityRef: string; bucket: string; key: string; value: string }, + ): Promise { + await tx('user_settings') + .insert({ + user_entity_ref: opts.userEntityRef, + bucket: opts.bucket, + key: opts.key, + value: opts.value, + }) + .onConflict(['user_entity_ref', 'bucket', 'key']) + .merge({ value: opts.value }); + } + + async delete( + tx: Knex.Transaction, + opts: { userEntityRef: string; bucket: string; key: string }, + ): Promise { + await tx('user_settings') + .where({ + user_entity_ref: opts.userEntityRef, + bucket: opts.bucket, + key: opts.key, + }) + .delete(); + } + + async transaction(fn: (tx: Knex.Transaction) => Promise) { + return await this.db.transaction(fn); + } +} diff --git a/plugins/user-settings-backend/src/database/UserSettingsStore.ts b/plugins/user-settings-backend/src/database/UserSettingsStore.ts new file mode 100644 index 0000000000..b86673270f --- /dev/null +++ b/plugins/user-settings-backend/src/database/UserSettingsStore.ts @@ -0,0 +1,65 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @public + */ +export type UserSetting = { + bucket: string; + key: string; + value: string; +}; + +/** + * Store definition for the user settings. + * + * @public + */ +export interface UserSettingsStore { + transaction(fn: (tx: Transaction) => Promise): Promise; + + get( + tx: Transaction, + opts: { userEntityRef: string; bucket: string; key: string }, + ): Promise; + + set( + tx: Transaction, + opts: { userEntityRef: string; bucket: string; key: string; value: string }, + ): Promise; + + delete( + tx: Transaction, + opts: { userEntityRef: string; bucket: string; key: string }, + ): Promise; + + getBucket( + tx: Transaction, + opts: { userEntityRef: string; bucket: string }, + ): Promise; + + deleteBucket( + tx: Transaction, + opts: { userEntityRef: string; bucket: string }, + ): Promise; + + getAll( + tx: Transaction, + opts: { userEntityRef: string }, + ): Promise; + + deleteAll(tx: Transaction, opts: { userEntityRef: string }): Promise; +} diff --git a/plugins/user-settings-backend/src/database/index.ts b/plugins/user-settings-backend/src/database/index.ts new file mode 100644 index 0000000000..23023ceb2d --- /dev/null +++ b/plugins/user-settings-backend/src/database/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { + DatabaseUserSettingsStore, + type RawDbUserSettingsRow, +} from './DatabaseUserSettingsStore'; +export type { UserSettingsStore, UserSetting } from './UserSettingsStore'; diff --git a/plugins/user-settings-backend/src/index.ts b/plugins/user-settings-backend/src/index.ts new file mode 100644 index 0000000000..9d9fe923e4 --- /dev/null +++ b/plugins/user-settings-backend/src/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './service'; +export * from './database'; diff --git a/plugins/user-settings-backend/src/run.ts b/plugins/user-settings-backend/src/run.ts new file mode 100644 index 0000000000..a1ba29a339 --- /dev/null +++ b/plugins/user-settings-backend/src/run.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { getRootLogger } from '@backstage/backend-common'; +import yn from 'yn'; + +import { startStandaloneServer } from './service/standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; +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/user-settings-backend/src/service/index.ts b/plugins/user-settings-backend/src/service/index.ts new file mode 100644 index 0000000000..5f4020dc68 --- /dev/null +++ b/plugins/user-settings-backend/src/service/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { + createRouter, + createUserSettingsStore, + type RouterOptions, +} from './router'; diff --git a/plugins/user-settings-backend/src/service/router.test.ts b/plugins/user-settings-backend/src/service/router.test.ts new file mode 100644 index 0000000000..ba23ad11c0 --- /dev/null +++ b/plugins/user-settings-backend/src/service/router.test.ts @@ -0,0 +1,319 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { AuthenticationError } from '@backstage/errors'; +import { IdentityClient } from '@backstage/plugin-auth-node'; +import express from 'express'; +import request from 'supertest'; + +import { UserSettingsStore } from '../database'; +import { createRouter } from './router'; + +describe('createRouter', () => { + const userSettingsStore: jest.Mocked> = { + transaction: jest.fn(), + deleteAll: jest.fn(), + getAll: jest.fn(), + getBucket: jest.fn(), + deleteBucket: jest.fn(), + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }; + const authenticateMock = jest.fn(); + const identityClient: jest.Mocked> = { + authenticate: authenticateMock, + }; + + let app: express.Express; + + beforeEach(async () => { + userSettingsStore.transaction.mockImplementation(fn => fn('tx')); + + const router = await createRouter({ + userSettingsStore, + identity: identityClient as IdentityClient, + }); + + app = express().use(router); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('GET /', () => { + it('returns ok', async () => { + const settings = [ + { bucket: 'a', key: 'a', value: 'a' }, + { bucket: 'b', key: 'b', value: 'b' }, + ]; + authenticateMock.mockResolvedValue({ + identity: { userEntityRef: 'user-1' }, + }); + + userSettingsStore.getAll.mockResolvedValue(settings); + + const responses = await request(app) + .get('/') + .set('Authorization', 'Bearer foo'); + + expect(responses.status).toEqual(200); + expect(responses.body).toEqual(settings); + + expect(authenticateMock).toHaveBeenCalledWith('foo'); + expect(userSettingsStore.getAll).toBeCalledTimes(1); + expect(userSettingsStore.getAll).toBeCalledWith('tx', { + userEntityRef: 'user-1', + }); + }); + + it('returns an error if the Authorization header is missing', async () => { + const responses = await request(app).get('/'); + + expect(responses.status).toEqual(401); + expect(userSettingsStore.getAll).not.toHaveBeenCalled(); + }); + + it('returns an error if the token is not valid', async () => { + authenticateMock.mockRejectedValue( + new AuthenticationError('Invalid token'), + ); + + const responses = await request(app) + .get('/') + .set('Authorization', 'Bearer foo'); + + expect(responses.status).toEqual(401); + expect(userSettingsStore.getAll).not.toHaveBeenCalled(); + }); + }); + + describe('DELETE /', () => { + it('returns ok', async () => { + authenticateMock.mockResolvedValue({ + identity: { userEntityRef: 'user-1' }, + }); + + userSettingsStore.deleteAll.mockResolvedValue(); + + const responses = await request(app) + .delete('/') + .set('Authorization', 'Bearer foo'); + + expect(responses.status).toEqual(204); + + expect(authenticateMock).toHaveBeenCalledWith('foo'); + expect(userSettingsStore.deleteAll).toBeCalledTimes(1); + expect(userSettingsStore.deleteAll).toBeCalledWith('tx', { + userEntityRef: 'user-1', + }); + }); + + it('returns an error if the Authorization header is missing', async () => { + const responses = await request(app).delete('/'); + + expect(responses.status).toEqual(401); + expect(userSettingsStore.getAll).not.toHaveBeenCalled(); + }); + }); + + describe('GET /:bucket', () => { + it('returns ok', async () => { + const settings = [ + { bucket: 'my-bucket', key: 'a', value: 'a' }, + { bucket: 'my-bucket', key: 'b', value: 'b' }, + ]; + authenticateMock.mockResolvedValue({ + identity: { userEntityRef: 'user-1' }, + }); + + userSettingsStore.getBucket.mockResolvedValue(settings); + + const responses = await request(app) + .get('/my-bucket') + .set('Authorization', 'Bearer foo'); + + expect(responses.status).toEqual(200); + expect(responses.body).toEqual(settings); + + expect(authenticateMock).toHaveBeenCalledWith('foo'); + expect(userSettingsStore.getBucket).toBeCalledTimes(1); + expect(userSettingsStore.getBucket).toBeCalledWith('tx', { + userEntityRef: 'user-1', + bucket: 'my-bucket', + }); + }); + + it('returns an error if the Authorization header is missing', async () => { + const responses = await request(app).get('/my-bucket'); + + expect(responses.status).toEqual(401); + expect(userSettingsStore.getBucket).not.toHaveBeenCalled(); + }); + }); + + describe('DELETE /:bucket', () => { + it('returns ok', async () => { + authenticateMock.mockResolvedValue({ + identity: { userEntityRef: 'user-1' }, + }); + + userSettingsStore.deleteBucket.mockResolvedValue(); + + const responses = await request(app) + .delete('/my-bucket') + .set('Authorization', 'Bearer foo'); + + expect(responses.status).toEqual(204); + + expect(authenticateMock).toHaveBeenCalledWith('foo'); + expect(userSettingsStore.deleteBucket).toBeCalledTimes(1); + expect(userSettingsStore.deleteBucket).toBeCalledWith('tx', { + userEntityRef: 'user-1', + bucket: 'my-bucket', + }); + }); + + it('returns an error if the Authorization header is missing', async () => { + const responses = await request(app).delete('/my-bucket'); + + expect(responses.status).toEqual(401); + expect(userSettingsStore.deleteBucket).not.toHaveBeenCalled(); + }); + }); + + describe('GET /:bucket/:key', () => { + it('returns ok', async () => { + const setting = { bucket: 'my-bucket', key: 'my-key', value: 'a' }; + authenticateMock.mockResolvedValue({ + identity: { userEntityRef: 'user-1' }, + }); + + userSettingsStore.get.mockResolvedValue(setting); + + const responses = await request(app) + .get('/my-bucket/my-key') + .set('Authorization', 'Bearer foo'); + + expect(responses.status).toEqual(200); + expect(responses.body).toEqual(setting); + + expect(authenticateMock).toHaveBeenCalledWith('foo'); + expect(userSettingsStore.get).toBeCalledTimes(1); + expect(userSettingsStore.get).toBeCalledWith('tx', { + userEntityRef: 'user-1', + bucket: 'my-bucket', + key: 'my-key', + }); + }); + + it('returns an error if the Authorization header is missing', async () => { + const responses = await request(app).get('/my-bucket/my-key'); + + expect(responses.status).toEqual(401); + expect(userSettingsStore.get).not.toHaveBeenCalled(); + }); + }); + + describe('DELETE /:bucket/:key', () => { + it('returns ok', async () => { + authenticateMock.mockResolvedValue({ + identity: { userEntityRef: 'user-1' }, + }); + + userSettingsStore.delete.mockResolvedValue(); + + const responses = await request(app) + .delete('/my-bucket/my-key') + .set('Authorization', 'Bearer foo'); + + expect(responses.status).toEqual(204); + + expect(authenticateMock).toHaveBeenCalledWith('foo'); + expect(userSettingsStore.delete).toBeCalledTimes(1); + expect(userSettingsStore.delete).toBeCalledWith('tx', { + userEntityRef: 'user-1', + bucket: 'my-bucket', + key: 'my-key', + }); + }); + + it('returns an error if the Authorization header is missing', async () => { + const responses = await request(app).delete('/my-bucket/my-key'); + + expect(responses.status).toEqual(401); + expect(userSettingsStore.delete).not.toHaveBeenCalled(); + }); + }); + + describe('PUT /:bucket/:key', () => { + it('returns ok', async () => { + const setting = { bucket: 'my-bucket', key: 'my-key', value: 'a' }; + authenticateMock.mockResolvedValue({ + identity: { userEntityRef: 'user-1' }, + }); + + userSettingsStore.set.mockResolvedValue(); + userSettingsStore.get.mockResolvedValue(setting); + + const responses = await request(app) + .put('/my-bucket/my-key') + .set('Authorization', 'Bearer foo') + .send({ value: 'a' }); + + expect(responses.status).toEqual(200); + expect(responses.body).toEqual(setting); + + expect(authenticateMock).toHaveBeenCalledWith('foo'); + expect(userSettingsStore.set).toBeCalledTimes(1); + expect(userSettingsStore.set).toHaveBeenCalledWith('tx', { + userEntityRef: 'user-1', + bucket: 'my-bucket', + key: 'my-key', + value: 'a', + }); + expect(userSettingsStore.get).toBeCalledTimes(1); + expect(userSettingsStore.get).toBeCalledWith('tx', { + userEntityRef: 'user-1', + bucket: 'my-bucket', + key: 'my-key', + }); + }); + + it('returns an error if the value is not a string', async () => { + authenticateMock.mockResolvedValue({ + identity: { userEntityRef: 'user-1' }, + }); + + const responses = await request(app) + .put('/my-bucket/my-key') + .set('Authorization', 'Bearer foo') + .send({ value: { invalid: 'because not a string' } }); + + expect(responses.status).toEqual(400); + + expect(authenticateMock).toHaveBeenCalledWith('foo'); + expect(userSettingsStore.set).not.toHaveBeenCalled(); + }); + + it('returns an error if the Authorization header is missing', async () => { + const responses = await request(app).get('/my-bucket/my-key'); + + expect(responses.status).toEqual(401); + expect(userSettingsStore.get).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/plugins/user-settings-backend/src/service/router.ts b/plugins/user-settings-backend/src/service/router.ts new file mode 100644 index 0000000000..a715433dcb --- /dev/null +++ b/plugins/user-settings-backend/src/service/router.ts @@ -0,0 +1,168 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { PluginDatabaseManager, errorHandler } from '@backstage/backend-common'; +import { AuthenticationError, InputError } from '@backstage/errors'; +import { + getBearerTokenFromAuthorizationHeader, + IdentityClient, +} from '@backstage/plugin-auth-node'; +import express, { Request } from 'express'; +import Router from 'express-promise-router'; + +import { DatabaseUserSettingsStore, UserSettingsStore } from '../database'; + +/** + * @public + */ +export async function createUserSettingsStore(database: PluginDatabaseManager) { + return await DatabaseUserSettingsStore.create(await database.getClient()); +} + +/** + * @public + */ +export interface RouterOptions { + userSettingsStore: UserSettingsStore; + identity: IdentityClient; +} + +/** + * Create the user settings backend routes. + * + * @public + */ +export async function createRouter( + options: RouterOptions, +): Promise { + const { userSettingsStore, identity } = options; + const router = Router(); + router.use(express.json()); + + /** + * Helper method to extract the userEntityRef from the request. + */ + const getUserEntityRef = async (req: Request): Promise => { + const token = getBearerTokenFromAuthorizationHeader( + req.header('authorization'), + ); + + if (!token) { + throw new AuthenticationError(`Missing token in 'authorization' header`); + } + + // throws an AuthenticationError in case the token is invalid + const user = await identity.authenticate(token); + + return user.identity.userEntityRef; + }; + + // get all user related settings + router.get('/', async (req, res) => { + const userEntityRef = await getUserEntityRef(req); + + const settings = await userSettingsStore.transaction(tx => + userSettingsStore.getAll(tx, { userEntityRef }), + ); + + res.json(settings); + }); + + // remove all user related settings + router.delete('/', async (req, res) => { + const userEntityRef = await getUserEntityRef(req); + + await userSettingsStore.transaction(tx => + userSettingsStore.deleteAll(tx, { userEntityRef }), + ); + + res.send(204).end(); + }); + + // get a single bucket + router.get('/:bucket', async (req, res) => { + const userEntityRef = await getUserEntityRef(req); + const { bucket } = req.params; + + const settings = await userSettingsStore.transaction(tx => + userSettingsStore.getBucket(tx, { userEntityRef, bucket }), + ); + + res.json(settings); + }); + + // delete a whole bucket + router.delete('/:bucket', async (req, res) => { + const userEntityRef = await getUserEntityRef(req); + const { bucket } = req.params; + + await userSettingsStore.transaction(tx => + userSettingsStore.deleteBucket(tx, { userEntityRef, bucket }), + ); + + res.status(204).end(); + }); + + // get a single value + router.get('/:bucket/:key', async (req, res) => { + const userEntityRef = await getUserEntityRef(req); + const { bucket, key } = req.params; + + const setting = await userSettingsStore.transaction(tx => + userSettingsStore.get(tx, { userEntityRef, bucket, key }), + ); + + res.json(setting); + }); + + // set a single value + router.put('/:bucket/:key', async (req, res) => { + const userEntityRef = await getUserEntityRef(req); + const { bucket, key } = req.params; + const { value } = req.body; + + if (typeof value !== 'string') { + throw new InputError('Value must be a string'); + } + + const setting = await userSettingsStore.transaction(async tx => { + await userSettingsStore.set(tx, { + userEntityRef, + bucket, + key, + value, + }); + return userSettingsStore.get(tx, { userEntityRef, bucket, key }); + }); + + res.json(setting); + }); + + // get a single value + router.delete('/:bucket/:key', async (req, res) => { + const userEntityRef = await getUserEntityRef(req); + const { bucket, key } = req.params; + + await userSettingsStore.transaction(tx => + userSettingsStore.delete(tx, { userEntityRef, bucket, key }), + ); + + res.send(204).end(); + }); + + router.use(errorHandler()); + + return router; +} diff --git a/plugins/user-settings-backend/src/service/standaloneServer.ts b/plugins/user-settings-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..39ef0ba098 --- /dev/null +++ b/plugins/user-settings-backend/src/service/standaloneServer.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Server } from 'http'; + +import { createServiceBuilder, useHotMemoize } from '@backstage/backend-common'; +import Knex from 'knex'; +import { Logger } from 'winston'; + +import { DatabaseUserSettingsStore } from '../database'; +import { createRouter } from './router'; +import { IdentityClient } from '@backstage/plugin-auth-node'; +import { Router } from 'express'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: 'storage-backend' }); + + const database = useHotMemoize(module, () => { + return Knex({ + client: 'better-sqlite3', + connection: ':memory:', + useNullAsDefault: true, + }); + }); + + logger.debug('Starting application server...'); + + const identityMock = { + authenticate: async token => ({ + identity: { userEntityRef: token ?? 'user:default/john_doe' }, + }), + } as IdentityClient; + + const router = await createRouter({ + userSettingsStore: await DatabaseUserSettingsStore.create(database), + identity: identityMock, + }); + + // set a custom authorization header to simplify the development + const authWrapper = Router(); + authWrapper.use((req, _res, next) => { + req.headers.authorization = + req.headers.authorization ?? 'Bearer user:default/john_doe'; + next(); + }); + authWrapper.use(router); + + let service = createServiceBuilder(module) + .setPort(options.port) + .addRouter('/user-settings', authWrapper); + + if (options.enableCors) { + service = service.enableCors({ origin: 'http://localhost:3000' }); + } + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/plugins/user-settings-backend/src/setupTests.ts b/plugins/user-settings-backend/src/setupTests.ts new file mode 100644 index 0000000000..8b9b6bd586 --- /dev/null +++ b/plugins/user-settings-backend/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export {}; diff --git a/yarn.lock b/yarn.lock index afe89bf424..a51757dcde 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2855,7 +2855,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/backend-common@^0.15.1-next.3, @backstage/backend-common@workspace:packages/backend-common": +"@backstage/backend-common@^0.15.1-next.1, @backstage/backend-common@^0.15.1-next.3, @backstage/backend-common@workspace:packages/backend-common": version: 0.0.0-use.local resolution: "@backstage/backend-common@workspace:packages/backend-common" dependencies: @@ -2991,7 +2991,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/backend-test-utils@^0.1.28-next.3, @backstage/backend-test-utils@workspace:packages/backend-test-utils": +"@backstage/backend-test-utils@^0.1.28-next.1, @backstage/backend-test-utils@^0.1.28-next.3, @backstage/backend-test-utils@workspace:packages/backend-test-utils": version: 0.0.0-use.local resolution: "@backstage/backend-test-utils@workspace:packages/backend-test-utils" dependencies: @@ -3291,6 +3291,8 @@ __metadata: "@backstage/cli": ^0.19.0-next.3 "@backstage/config": ^1.0.2-next.0 "@backstage/core-plugin-api": ^1.0.6-next.3 + "@backstage/errors": ^1.1.0 + "@backstage/plugin-permission-common": ^0.6.4-next.0 "@backstage/test-utils": ^1.2.0-next.3 "@backstage/types": ^1.0.0 "@backstage/version-bridge": ^1.0.1 @@ -4027,7 +4029,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-auth-node@^0.2.5-next.3, @backstage/plugin-auth-node@workspace:plugins/auth-node": +"@backstage/plugin-auth-node@^0.2.5-next.1, @backstage/plugin-auth-node@^0.2.5-next.3, @backstage/plugin-auth-node@workspace:plugins/auth-node": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-node@workspace:plugins/auth-node" dependencies: @@ -7310,6 +7312,27 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-user-settings-backend@workspace:plugins/user-settings-backend": + version: 0.0.0-use.local + resolution: "@backstage/plugin-user-settings-backend@workspace:plugins/user-settings-backend" + dependencies: + "@backstage/backend-common": ^0.15.1-next.1 + "@backstage/backend-test-utils": ^0.1.28-next.1 + "@backstage/catalog-model": ^1.1.0 + "@backstage/cli": ^0.19.0-next.1 + "@backstage/errors": ^1.1.0 + "@backstage/plugin-auth-node": ^0.2.5-next.1 + "@types/express": ^4.17.6 + "@types/supertest": ^2.0.8 + express: ^4.17.1 + express-promise-router: ^4.1.0 + knex: ^2.0.0 + supertest: ^6.1.3 + winston: ^3.2.1 + yn: ^4.0.0 + languageName: unknown + linkType: soft + "@backstage/plugin-user-settings@^0.4.8-next.3, @backstage/plugin-user-settings@workspace:plugins/user-settings": version: 0.0.0-use.local resolution: "@backstage/plugin-user-settings@workspace:plugins/user-settings" From 32fa6ca2d8a4912cd6d1b7a228fc20bad0f5ce4d Mon Sep 17 00:00:00 2001 From: Dominik Schwank Date: Thu, 1 Sep 2022 10:30:02 +0200 Subject: [PATCH 047/192] fix: change imports Signed-off-by: Dominik Schwank --- packages/core-app-api/package.json | 1 - .../implementations/StorageApi/PersistentStorage.test.ts | 8 ++++++-- yarn.lock | 1 - 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index 632c429361..e1c24997ac 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -35,7 +35,6 @@ "@backstage/config": "^1.0.2-next.0", "@backstage/core-plugin-api": "^1.0.6-next.3", "@backstage/errors": "^1.1.0", - "@backstage/plugin-permission-common": "^0.6.4-next.0", "@backstage/types": "^1.0.0", "@backstage/version-bridge": "^1.0.1", "@types/prop-types": "^15.7.3", diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.test.ts b/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.test.ts index 6cea8dde5b..dfa0008e32 100644 --- a/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.test.ts +++ b/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.test.ts @@ -15,8 +15,12 @@ */ import { PersistentStorage } from './PersistentStorage'; -import { ErrorApi, FetchApi, StorageApi } from '@backstage/core-plugin-api'; -import { DiscoveryApi } from '@backstage/plugin-permission-common'; +import { + ErrorApi, + FetchApi, + StorageApi, + DiscoveryApi, +} from '@backstage/core-plugin-api'; import { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils'; import { rest } from 'msw'; diff --git a/yarn.lock b/yarn.lock index a51757dcde..9df599d106 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3292,7 +3292,6 @@ __metadata: "@backstage/config": ^1.0.2-next.0 "@backstage/core-plugin-api": ^1.0.6-next.3 "@backstage/errors": ^1.1.0 - "@backstage/plugin-permission-common": ^0.6.4-next.0 "@backstage/test-utils": ^1.2.0-next.3 "@backstage/types": ^1.0.0 "@backstage/version-bridge": ^1.0.1 From 3250830e6ec3b8006bbd86e252634cf0c9e6fde9 Mon Sep 17 00:00:00 2001 From: Dominik Schwank Date: Thu, 1 Sep 2022 10:31:22 +0200 Subject: [PATCH 048/192] feat: use string in DB instead of text Signed-off-by: Dominik Schwank --- .../user-settings-backend/migrations/20220824183000_init.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/user-settings-backend/migrations/20220824183000_init.js b/plugins/user-settings-backend/migrations/20220824183000_init.js index 688d8495b5..a519969a98 100644 --- a/plugins/user-settings-backend/migrations/20220824183000_init.js +++ b/plugins/user-settings-backend/migrations/20220824183000_init.js @@ -22,11 +22,11 @@ exports.up = async function up(knex) { table.comment('The table of user related settings'); table - .text('user_entity_ref') + .string('user_entity_ref') .notNullable() .comment('The entityRef of the user'); - table.text('bucket').notNullable().comment('Name of the bucket'); - table.text('key').notNullable().comment('Key of a bucket value'); + table.string('bucket').notNullable().comment('Name of the bucket'); + table.string('key').notNullable().comment('Key of a bucket value'); table.text('value').notNullable().comment('The value'); table.primary(['user_entity_ref', 'bucket', 'key']); From 81f0945f6e724427abd522f6f74544d26e471a3a Mon Sep 17 00:00:00 2001 From: Dominik Schwank Date: Thu, 1 Sep 2022 10:31:45 +0200 Subject: [PATCH 049/192] fix: change initial version to 0.0.0 Signed-off-by: Dominik Schwank --- plugins/user-settings-backend/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/user-settings-backend/package.json b/plugins/user-settings-backend/package.json index f20180cac3..41178d49b8 100644 --- a/plugins/user-settings-backend/package.json +++ b/plugins/user-settings-backend/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/plugin-user-settings-backend", "description": "The Backstage backend plugin to manage user settings", - "version": "0.1.0", + "version": "0.0.0", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", From 1e12c7f5f188cdf221d876e2b6e4faf6908d9b83 Mon Sep 17 00:00:00 2001 From: Dominik Schwank Date: Thu, 1 Sep 2022 11:03:32 +0200 Subject: [PATCH 050/192] feat: support skip migration Signed-off-by: Dominik Schwank --- plugins/user-settings-backend/api-report.md | 9 +++----- .../DatabaseUserSettingsStore.test.ts | 11 ++++++++- .../src/database/DatabaseUserSettingsStore.ts | 23 ++++++++++++++----- .../src/service/index.ts | 6 +---- .../src/service/router.ts | 11 ++------- .../src/service/standaloneServer.ts | 4 +++- 6 files changed, 36 insertions(+), 28 deletions(-) diff --git a/plugins/user-settings-backend/api-report.md b/plugins/user-settings-backend/api-report.md index b3b40970c9..47e59637d6 100644 --- a/plugins/user-settings-backend/api-report.md +++ b/plugins/user-settings-backend/api-report.md @@ -13,17 +13,14 @@ export function createRouter( options: RouterOptions, ): Promise; -// @public (undocumented) -export function createUserSettingsStore( - database: PluginDatabaseManager, -): Promise; - // @public export class DatabaseUserSettingsStore implements UserSettingsStore { // (undocumented) - static create(knex: Knex): Promise; + static create(options: { + database: PluginDatabaseManager; + }): Promise; // (undocumented) delete( tx: Knex.Transaction, diff --git a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts index 717707f034..547beaa6b6 100644 --- a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts +++ b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts @@ -29,9 +29,18 @@ const databases = TestDatabases.create({ async function createStore(databaseId: TestDatabaseId) { const knex = await databases.init(databaseId); + const databaseManager = { + getClient: async () => knex, + migrations: { + skip: false, + }, + }; + return { knex, - storage: await DatabaseUserSettingsStore.create(knex), + storage: await DatabaseUserSettingsStore.create({ + database: databaseManager, + }), }; } diff --git a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts index fd24a6f3e5..2e2eb6318a 100644 --- a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts +++ b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts @@ -13,7 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { resolvePackagePath } from '@backstage/backend-common'; +import { + PluginDatabaseManager, + resolvePackagePath, +} from '@backstage/backend-common'; import { NotFoundError } from '@backstage/errors'; import { Knex } from 'knex'; @@ -42,11 +45,19 @@ export type RawDbUserSettingsRow = { export class DatabaseUserSettingsStore implements UserSettingsStore { - static async create(knex: Knex): Promise { - await knex.migrate.latest({ - directory: migrationsDir, - }); - return new DatabaseUserSettingsStore(knex); + static async create(options: { + database: PluginDatabaseManager; + }): Promise { + const { database } = options; + const client = await database.getClient(); + + if (!database.migrations?.skip) { + await client.migrate.latest({ + directory: migrationsDir, + }); + } + + return new DatabaseUserSettingsStore(client); } private constructor(private readonly db: Knex) {} diff --git a/plugins/user-settings-backend/src/service/index.ts b/plugins/user-settings-backend/src/service/index.ts index 5f4020dc68..8f3e084d24 100644 --- a/plugins/user-settings-backend/src/service/index.ts +++ b/plugins/user-settings-backend/src/service/index.ts @@ -13,8 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { - createRouter, - createUserSettingsStore, - type RouterOptions, -} from './router'; +export { createRouter, type RouterOptions } from './router'; diff --git a/plugins/user-settings-backend/src/service/router.ts b/plugins/user-settings-backend/src/service/router.ts index a715433dcb..0a8c52b156 100644 --- a/plugins/user-settings-backend/src/service/router.ts +++ b/plugins/user-settings-backend/src/service/router.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { PluginDatabaseManager, errorHandler } from '@backstage/backend-common'; +import { errorHandler } from '@backstage/backend-common'; import { AuthenticationError, InputError } from '@backstage/errors'; import { getBearerTokenFromAuthorizationHeader, @@ -22,14 +22,7 @@ import { import express, { Request } from 'express'; import Router from 'express-promise-router'; -import { DatabaseUserSettingsStore, UserSettingsStore } from '../database'; - -/** - * @public - */ -export async function createUserSettingsStore(database: PluginDatabaseManager) { - return await DatabaseUserSettingsStore.create(await database.getClient()); -} +import { UserSettingsStore } from '../database'; /** * @public diff --git a/plugins/user-settings-backend/src/service/standaloneServer.ts b/plugins/user-settings-backend/src/service/standaloneServer.ts index 39ef0ba098..1e079f02c0 100644 --- a/plugins/user-settings-backend/src/service/standaloneServer.ts +++ b/plugins/user-settings-backend/src/service/standaloneServer.ts @@ -52,7 +52,9 @@ export async function startStandaloneServer( } as IdentityClient; const router = await createRouter({ - userSettingsStore: await DatabaseUserSettingsStore.create(database), + userSettingsStore: await DatabaseUserSettingsStore.create({ + database: { getClient: async () => database }, + }), identity: identityMock, }); From 31a240304444d11e18e3d64f3bef24d877685681 Mon Sep 17 00:00:00 2001 From: Dominik Schwank Date: Thu, 1 Sep 2022 11:12:44 +0200 Subject: [PATCH 051/192] feat: use buckets prefix for routes Signed-off-by: Dominik Schwank --- .../StorageApi/PersistentStorage.test.ts | 41 ++++++---- .../StorageApi/PersistentStorage.ts | 2 +- .../src/service/router.test.ts | 76 +++++++++---------- .../src/service/router.ts | 14 ++-- 4 files changed, 71 insertions(+), 62 deletions(-) diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.test.ts b/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.test.ts index dfa0008e32..3157395bcf 100644 --- a/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.test.ts +++ b/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.test.ts @@ -52,7 +52,7 @@ describe('Persistent Storage API', () => { beforeEach(() => { server.use( - rest.get(`${mockBaseUrl}/`, async (_req, res, ctx) => { + rest.get(`${mockBaseUrl}/buckets/`, async (_req, res, ctx) => { return res(ctx.json([])); }), ); @@ -64,9 +64,12 @@ describe('Persistent Storage API', () => { const storage = createPersistentStorage(); server.use( - rest.get(`${mockBaseUrl}/:bucket/:key`, async (_req, res, ctx) => { - return res(ctx.json({ value: 'a' })); - }), + rest.get( + `${mockBaseUrl}/buckets/:bucket/:key`, + async (_req, res, ctx) => { + return res(ctx.json({ value: 'a' })); + }, + ), ); expect(storage.snapshot('myfakekey').value).toBeUndefined(); @@ -82,7 +85,7 @@ describe('Persistent Storage API', () => { const dummyValue = 'a'; server.use( - rest.put(`${mockBaseUrl}/:bucket/:key`, async (req, res, ctx) => { + rest.put(`${mockBaseUrl}/buckets/:bucket/:key`, async (req, res, ctx) => { const body = await req.json(); const data = { value: JSON.stringify(dummyValue) }; expect(body).toEqual(data); @@ -102,7 +105,7 @@ describe('Persistent Storage API', () => { }; server.use( - rest.put(`${mockBaseUrl}/:bucket/:key`, async (req, res, ctx) => { + rest.put(`${mockBaseUrl}/buckets/:bucket/:key`, async (req, res, ctx) => { const body = await req.json(); const data = { value: JSON.stringify(dummyValue) }; expect(body).toEqual(data); @@ -122,7 +125,7 @@ describe('Persistent Storage API', () => { const mockData = { hello: 'im a great new value' }; server.use( - rest.put(`${mockBaseUrl}/:bucket/:key`, async (req, res, ctx) => { + rest.put(`${mockBaseUrl}/buckets/:bucket/:key`, async (req, res, ctx) => { const body = await req.json(); const data = { value: JSON.stringify(mockData) }; expect(body).toEqual(data); @@ -162,9 +165,12 @@ describe('Persistent Storage API', () => { const selectedKeyNextHandler = jest.fn(); server.use( - rest.delete(`${mockBaseUrl}/:bucket/:key`, async (_req, res, ctx) => { - return res(ctx.status(204)); - }), + rest.delete( + `${mockBaseUrl}/buckets/:bucket/:key`, + async (_req, res, ctx) => { + return res(ctx.status(204)); + }, + ), ); await new Promise(resolve => { @@ -196,7 +202,7 @@ describe('Persistent Storage API', () => { const selectedKeyNextHandler = jest.fn(); server.use( - rest.put(`${mockBaseUrl}/:bucket/:key`, async (req, res, ctx) => { + rest.put(`${mockBaseUrl}/buckets/:bucket/:key`, async (req, res, ctx) => { const { bucket, key } = req.params; const { value } = await req.json(); @@ -205,7 +211,7 @@ describe('Persistent Storage API', () => { return res(ctx.json({ value })); }), - rest.get(`${mockBaseUrl}/:bucket/:key`, async (req, res, ctx) => { + rest.get(`${mockBaseUrl}/buckets/:bucket/:key`, async (req, res, ctx) => { const { bucket, key } = req.params; expect(bucket).toEqual('default.profile/something'); @@ -253,7 +259,7 @@ describe('Persistent Storage API', () => { }); server.use( - rest.get(`${mockBaseUrl}/:bucket/:key`, async (req, res, ctx) => { + rest.get(`${mockBaseUrl}/buckets/:bucket/:key`, async (req, res, ctx) => { const { bucket, key } = req.params; expect(bucket).toEqual('Test.Mock.Thing'); @@ -290,9 +296,12 @@ describe('Persistent Storage API', () => { const data = { foo: 'bar', baz: [{ foo: 'bar' }] }; server.use( - rest.get(`${mockBaseUrl}/:bucket/:key`, async (_req, res, ctx) => { - return res(ctx.text(JSON.stringify({ value: JSON.stringify(data) }))); - }), + rest.get( + `${mockBaseUrl}/buckets/:bucket/:key`, + async (_req, res, ctx) => { + return res(ctx.text(JSON.stringify({ value: JSON.stringify(data) }))); + }, + ), ); await new Promise(resolve => { diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.ts b/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.ts index 4ebf635cd9..38a986863e 100644 --- a/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.ts +++ b/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.ts @@ -182,7 +182,7 @@ export class PersistentStorage implements StorageApi { const baseUrl = await this.discoveryApi.getBaseUrl('user-settings'); const encodedNamespace = encodeURIComponent(this.namespace); const encodedKey = encodeURIComponent(key); - return `${baseUrl}/${encodedNamespace}/${encodedKey}`; + return `${baseUrl}/buckets/${encodedNamespace}/${encodedKey}`; } private async notifyChanges(snapshot: StorageValueSnapshot) { diff --git a/plugins/user-settings-backend/src/service/router.test.ts b/plugins/user-settings-backend/src/service/router.test.ts index ba23ad11c0..21aff9f164 100644 --- a/plugins/user-settings-backend/src/service/router.test.ts +++ b/plugins/user-settings-backend/src/service/router.test.ts @@ -54,7 +54,7 @@ describe('createRouter', () => { jest.resetAllMocks(); }); - describe('GET /', () => { + describe('GET /buckets/', () => { it('returns ok', async () => { const settings = [ { bucket: 'a', key: 'a', value: 'a' }, @@ -67,21 +67,21 @@ describe('createRouter', () => { userSettingsStore.getAll.mockResolvedValue(settings); const responses = await request(app) - .get('/') + .get('/buckets/') .set('Authorization', 'Bearer foo'); expect(responses.status).toEqual(200); expect(responses.body).toEqual(settings); expect(authenticateMock).toHaveBeenCalledWith('foo'); - expect(userSettingsStore.getAll).toBeCalledTimes(1); - expect(userSettingsStore.getAll).toBeCalledWith('tx', { + expect(userSettingsStore.getAll).toHaveBeenCalledTimes(1); + expect(userSettingsStore.getAll).toHaveBeenCalledWith('tx', { userEntityRef: 'user-1', }); }); it('returns an error if the Authorization header is missing', async () => { - const responses = await request(app).get('/'); + const responses = await request(app).get('/buckets/'); expect(responses.status).toEqual(401); expect(userSettingsStore.getAll).not.toHaveBeenCalled(); @@ -93,7 +93,7 @@ describe('createRouter', () => { ); const responses = await request(app) - .get('/') + .get('/buckets/') .set('Authorization', 'Bearer foo'); expect(responses.status).toEqual(401); @@ -101,7 +101,7 @@ describe('createRouter', () => { }); }); - describe('DELETE /', () => { + describe('DELETE /buckets/', () => { it('returns ok', async () => { authenticateMock.mockResolvedValue({ identity: { userEntityRef: 'user-1' }, @@ -110,27 +110,27 @@ describe('createRouter', () => { userSettingsStore.deleteAll.mockResolvedValue(); const responses = await request(app) - .delete('/') + .delete('/buckets/') .set('Authorization', 'Bearer foo'); expect(responses.status).toEqual(204); expect(authenticateMock).toHaveBeenCalledWith('foo'); - expect(userSettingsStore.deleteAll).toBeCalledTimes(1); - expect(userSettingsStore.deleteAll).toBeCalledWith('tx', { + expect(userSettingsStore.deleteAll).toHaveBeenCalledTimes(1); + expect(userSettingsStore.deleteAll).toHaveBeenCalledWith('tx', { userEntityRef: 'user-1', }); }); it('returns an error if the Authorization header is missing', async () => { - const responses = await request(app).delete('/'); + const responses = await request(app).delete('/buckets/'); expect(responses.status).toEqual(401); expect(userSettingsStore.getAll).not.toHaveBeenCalled(); }); }); - describe('GET /:bucket', () => { + describe('GET /buckets/:bucket', () => { it('returns ok', async () => { const settings = [ { bucket: 'my-bucket', key: 'a', value: 'a' }, @@ -143,29 +143,29 @@ describe('createRouter', () => { userSettingsStore.getBucket.mockResolvedValue(settings); const responses = await request(app) - .get('/my-bucket') + .get('/buckets/my-bucket') .set('Authorization', 'Bearer foo'); expect(responses.status).toEqual(200); expect(responses.body).toEqual(settings); expect(authenticateMock).toHaveBeenCalledWith('foo'); - expect(userSettingsStore.getBucket).toBeCalledTimes(1); - expect(userSettingsStore.getBucket).toBeCalledWith('tx', { + expect(userSettingsStore.getBucket).toHaveBeenCalledTimes(1); + expect(userSettingsStore.getBucket).toHaveBeenCalledWith('tx', { userEntityRef: 'user-1', bucket: 'my-bucket', }); }); it('returns an error if the Authorization header is missing', async () => { - const responses = await request(app).get('/my-bucket'); + const responses = await request(app).get('/buckets/my-bucket'); expect(responses.status).toEqual(401); expect(userSettingsStore.getBucket).not.toHaveBeenCalled(); }); }); - describe('DELETE /:bucket', () => { + describe('DELETE /buckets/:bucket', () => { it('returns ok', async () => { authenticateMock.mockResolvedValue({ identity: { userEntityRef: 'user-1' }, @@ -174,28 +174,28 @@ describe('createRouter', () => { userSettingsStore.deleteBucket.mockResolvedValue(); const responses = await request(app) - .delete('/my-bucket') + .delete('/buckets/my-bucket') .set('Authorization', 'Bearer foo'); expect(responses.status).toEqual(204); expect(authenticateMock).toHaveBeenCalledWith('foo'); - expect(userSettingsStore.deleteBucket).toBeCalledTimes(1); - expect(userSettingsStore.deleteBucket).toBeCalledWith('tx', { + expect(userSettingsStore.deleteBucket).toHaveBeenCalledTimes(1); + expect(userSettingsStore.deleteBucket).toHaveBeenCalledWith('tx', { userEntityRef: 'user-1', bucket: 'my-bucket', }); }); it('returns an error if the Authorization header is missing', async () => { - const responses = await request(app).delete('/my-bucket'); + const responses = await request(app).delete('/buckets/my-bucket'); expect(responses.status).toEqual(401); expect(userSettingsStore.deleteBucket).not.toHaveBeenCalled(); }); }); - describe('GET /:bucket/:key', () => { + describe('GET /buckets/:bucket/:key', () => { it('returns ok', async () => { const setting = { bucket: 'my-bucket', key: 'my-key', value: 'a' }; authenticateMock.mockResolvedValue({ @@ -205,15 +205,15 @@ describe('createRouter', () => { userSettingsStore.get.mockResolvedValue(setting); const responses = await request(app) - .get('/my-bucket/my-key') + .get('/buckets/my-bucket/my-key') .set('Authorization', 'Bearer foo'); expect(responses.status).toEqual(200); expect(responses.body).toEqual(setting); expect(authenticateMock).toHaveBeenCalledWith('foo'); - expect(userSettingsStore.get).toBeCalledTimes(1); - expect(userSettingsStore.get).toBeCalledWith('tx', { + expect(userSettingsStore.get).toHaveBeenCalledTimes(1); + expect(userSettingsStore.get).toHaveBeenCalledWith('tx', { userEntityRef: 'user-1', bucket: 'my-bucket', key: 'my-key', @@ -221,14 +221,14 @@ describe('createRouter', () => { }); it('returns an error if the Authorization header is missing', async () => { - const responses = await request(app).get('/my-bucket/my-key'); + const responses = await request(app).get('/buckets/my-bucket/my-key'); expect(responses.status).toEqual(401); expect(userSettingsStore.get).not.toHaveBeenCalled(); }); }); - describe('DELETE /:bucket/:key', () => { + describe('DELETE /buckets/:bucket/:key', () => { it('returns ok', async () => { authenticateMock.mockResolvedValue({ identity: { userEntityRef: 'user-1' }, @@ -237,14 +237,14 @@ describe('createRouter', () => { userSettingsStore.delete.mockResolvedValue(); const responses = await request(app) - .delete('/my-bucket/my-key') + .delete('/buckets/my-bucket/my-key') .set('Authorization', 'Bearer foo'); expect(responses.status).toEqual(204); expect(authenticateMock).toHaveBeenCalledWith('foo'); - expect(userSettingsStore.delete).toBeCalledTimes(1); - expect(userSettingsStore.delete).toBeCalledWith('tx', { + expect(userSettingsStore.delete).toHaveBeenCalledTimes(1); + expect(userSettingsStore.delete).toHaveBeenCalledWith('tx', { userEntityRef: 'user-1', bucket: 'my-bucket', key: 'my-key', @@ -252,14 +252,14 @@ describe('createRouter', () => { }); it('returns an error if the Authorization header is missing', async () => { - const responses = await request(app).delete('/my-bucket/my-key'); + const responses = await request(app).delete('/buckets/my-bucket/my-key'); expect(responses.status).toEqual(401); expect(userSettingsStore.delete).not.toHaveBeenCalled(); }); }); - describe('PUT /:bucket/:key', () => { + describe('PUT /buckets/:bucket/:key', () => { it('returns ok', async () => { const setting = { bucket: 'my-bucket', key: 'my-key', value: 'a' }; authenticateMock.mockResolvedValue({ @@ -270,7 +270,7 @@ describe('createRouter', () => { userSettingsStore.get.mockResolvedValue(setting); const responses = await request(app) - .put('/my-bucket/my-key') + .put('/buckets/my-bucket/my-key') .set('Authorization', 'Bearer foo') .send({ value: 'a' }); @@ -278,15 +278,15 @@ describe('createRouter', () => { expect(responses.body).toEqual(setting); expect(authenticateMock).toHaveBeenCalledWith('foo'); - expect(userSettingsStore.set).toBeCalledTimes(1); + expect(userSettingsStore.set).toHaveBeenCalledTimes(1); expect(userSettingsStore.set).toHaveBeenCalledWith('tx', { userEntityRef: 'user-1', bucket: 'my-bucket', key: 'my-key', value: 'a', }); - expect(userSettingsStore.get).toBeCalledTimes(1); - expect(userSettingsStore.get).toBeCalledWith('tx', { + expect(userSettingsStore.get).toHaveBeenCalledTimes(1); + expect(userSettingsStore.get).toHaveBeenCalledWith('tx', { userEntityRef: 'user-1', bucket: 'my-bucket', key: 'my-key', @@ -299,7 +299,7 @@ describe('createRouter', () => { }); const responses = await request(app) - .put('/my-bucket/my-key') + .put('/buckets/my-bucket/my-key') .set('Authorization', 'Bearer foo') .send({ value: { invalid: 'because not a string' } }); @@ -310,7 +310,7 @@ describe('createRouter', () => { }); it('returns an error if the Authorization header is missing', async () => { - const responses = await request(app).get('/my-bucket/my-key'); + const responses = await request(app).get('/buckets/my-bucket/my-key'); expect(responses.status).toEqual(401); expect(userSettingsStore.get).not.toHaveBeenCalled(); diff --git a/plugins/user-settings-backend/src/service/router.ts b/plugins/user-settings-backend/src/service/router.ts index 0a8c52b156..588759c929 100644 --- a/plugins/user-settings-backend/src/service/router.ts +++ b/plugins/user-settings-backend/src/service/router.ts @@ -63,7 +63,7 @@ export async function createRouter( }; // get all user related settings - router.get('/', async (req, res) => { + router.get('/buckets', async (req, res) => { const userEntityRef = await getUserEntityRef(req); const settings = await userSettingsStore.transaction(tx => @@ -74,7 +74,7 @@ export async function createRouter( }); // remove all user related settings - router.delete('/', async (req, res) => { + router.delete('/buckets', async (req, res) => { const userEntityRef = await getUserEntityRef(req); await userSettingsStore.transaction(tx => @@ -85,7 +85,7 @@ export async function createRouter( }); // get a single bucket - router.get('/:bucket', async (req, res) => { + router.get('/buckets/:bucket', async (req, res) => { const userEntityRef = await getUserEntityRef(req); const { bucket } = req.params; @@ -97,7 +97,7 @@ export async function createRouter( }); // delete a whole bucket - router.delete('/:bucket', async (req, res) => { + router.delete('/buckets/:bucket', async (req, res) => { const userEntityRef = await getUserEntityRef(req); const { bucket } = req.params; @@ -109,7 +109,7 @@ export async function createRouter( }); // get a single value - router.get('/:bucket/:key', async (req, res) => { + router.get('/buckets/:bucket/:key', async (req, res) => { const userEntityRef = await getUserEntityRef(req); const { bucket, key } = req.params; @@ -121,7 +121,7 @@ export async function createRouter( }); // set a single value - router.put('/:bucket/:key', async (req, res) => { + router.put('/buckets/:bucket/:key', async (req, res) => { const userEntityRef = await getUserEntityRef(req); const { bucket, key } = req.params; const { value } = req.body; @@ -144,7 +144,7 @@ export async function createRouter( }); // get a single value - router.delete('/:bucket/:key', async (req, res) => { + router.delete('/buckets/:bucket/:key', async (req, res) => { const userEntityRef = await getUserEntityRef(req); const { bucket, key } = req.params; From 8f7e5a7517ed25d9ff34cb1a37d367698c321885 Mon Sep 17 00:00:00 2001 From: Dominik Schwank Date: Thu, 1 Sep 2022 11:22:42 +0200 Subject: [PATCH 052/192] feat: add keys path support Signed-off-by: Dominik Schwank --- .../StorageApi/PersistentStorage.test.ts | 98 +++++++++++-------- .../StorageApi/PersistentStorage.ts | 2 +- .../src/service/router.test.ts | 26 +++-- .../src/service/router.ts | 6 +- 4 files changed, 78 insertions(+), 54 deletions(-) diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.test.ts b/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.test.ts index 3157395bcf..79da01ca41 100644 --- a/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.test.ts +++ b/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.test.ts @@ -65,7 +65,7 @@ describe('Persistent Storage API', () => { server.use( rest.get( - `${mockBaseUrl}/buckets/:bucket/:key`, + `${mockBaseUrl}/buckets/:bucket/keys/:key`, async (_req, res, ctx) => { return res(ctx.json({ value: 'a' })); }, @@ -85,13 +85,16 @@ describe('Persistent Storage API', () => { const dummyValue = 'a'; server.use( - rest.put(`${mockBaseUrl}/buckets/:bucket/:key`, async (req, res, ctx) => { - const body = await req.json(); - const data = { value: JSON.stringify(dummyValue) }; - expect(body).toEqual(data); + rest.put( + `${mockBaseUrl}/buckets/:bucket/keys/:key`, + async (req, res, ctx) => { + const body = await req.json(); + const data = { value: JSON.stringify(dummyValue) }; + expect(body).toEqual(data); - return res(ctx.json(data)); - }), + return res(ctx.json(data)); + }, + ), ); await storage.set('my-key', dummyValue); @@ -105,13 +108,16 @@ describe('Persistent Storage API', () => { }; server.use( - rest.put(`${mockBaseUrl}/buckets/:bucket/:key`, async (req, res, ctx) => { - const body = await req.json(); - const data = { value: JSON.stringify(dummyValue) }; - expect(body).toEqual(data); + rest.put( + `${mockBaseUrl}/buckets/:bucket/keys/:key`, + async (req, res, ctx) => { + const body = await req.json(); + const data = { value: JSON.stringify(dummyValue) }; + expect(body).toEqual(data); - return res(ctx.json(data)); - }), + return res(ctx.json(data)); + }, + ), ); await storage.set('my-key', dummyValue); @@ -125,13 +131,16 @@ describe('Persistent Storage API', () => { const mockData = { hello: 'im a great new value' }; server.use( - rest.put(`${mockBaseUrl}/buckets/:bucket/:key`, async (req, res, ctx) => { - const body = await req.json(); - const data = { value: JSON.stringify(mockData) }; - expect(body).toEqual(data); + rest.put( + `${mockBaseUrl}/buckets/:bucket/keys/:key`, + async (req, res, ctx) => { + const body = await req.json(); + const data = { value: JSON.stringify(mockData) }; + expect(body).toEqual(data); - return res(ctx.json(data)); - }), + return res(ctx.json(data)); + }, + ), ); await new Promise(resolve => { @@ -166,7 +175,7 @@ describe('Persistent Storage API', () => { server.use( rest.delete( - `${mockBaseUrl}/buckets/:bucket/:key`, + `${mockBaseUrl}/buckets/:bucket/keys/:key`, async (_req, res, ctx) => { return res(ctx.status(204)); }, @@ -202,23 +211,29 @@ describe('Persistent Storage API', () => { const selectedKeyNextHandler = jest.fn(); server.use( - rest.put(`${mockBaseUrl}/buckets/:bucket/:key`, async (req, res, ctx) => { - const { bucket, key } = req.params; - const { value } = await req.json(); + rest.put( + `${mockBaseUrl}/buckets/:bucket/keys/:key`, + async (req, res, ctx) => { + const { bucket, key } = req.params; + const { value } = await req.json(); - expect(bucket).toEqual('default.profile.something.deep'); - expect(key).toEqual('test2'); + expect(bucket).toEqual('default.profile.something.deep'); + expect(key).toEqual('test2'); - return res(ctx.json({ value })); - }), - rest.get(`${mockBaseUrl}/buckets/:bucket/:key`, async (req, res, ctx) => { - const { bucket, key } = req.params; + return res(ctx.json({ value })); + }, + ), + rest.get( + `${mockBaseUrl}/buckets/:bucket/keys/:key`, + async (req, res, ctx) => { + const { bucket, key } = req.params; - expect(bucket).toEqual('default.profile/something'); - expect(key).toEqual('deep/test2'); + expect(bucket).toEqual('default.profile/something'); + expect(key).toEqual('deep/test2'); - return res(ctx.status(404)); - }), + return res(ctx.status(404)); + }, + ), ); // when getting key test2 it will translate to default.profile.something.deep/test2 @@ -259,14 +274,17 @@ describe('Persistent Storage API', () => { }); server.use( - rest.get(`${mockBaseUrl}/buckets/:bucket/:key`, async (req, res, ctx) => { - const { bucket, key } = req.params; + rest.get( + `${mockBaseUrl}/buckets/:bucket/keys/:key`, + async (req, res, ctx) => { + const { bucket, key } = req.params; - expect(bucket).toEqual('Test.Mock.Thing'); - expect(key).toEqual('key'); + expect(bucket).toEqual('Test.Mock.Thing'); + expect(key).toEqual('key'); - return res(ctx.text('{ invalid: json string }')); - }), + return res(ctx.text('{ invalid: json string }')); + }, + ), ); await new Promise(resolve => { @@ -297,7 +315,7 @@ describe('Persistent Storage API', () => { server.use( rest.get( - `${mockBaseUrl}/buckets/:bucket/:key`, + `${mockBaseUrl}/buckets/:bucket/keys/:key`, async (_req, res, ctx) => { return res(ctx.text(JSON.stringify({ value: JSON.stringify(data) }))); }, diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.ts b/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.ts index 38a986863e..b23ecd2b3f 100644 --- a/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.ts +++ b/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.ts @@ -182,7 +182,7 @@ export class PersistentStorage implements StorageApi { const baseUrl = await this.discoveryApi.getBaseUrl('user-settings'); const encodedNamespace = encodeURIComponent(this.namespace); const encodedKey = encodeURIComponent(key); - return `${baseUrl}/buckets/${encodedNamespace}/${encodedKey}`; + return `${baseUrl}/buckets/${encodedNamespace}/keys/${encodedKey}`; } private async notifyChanges(snapshot: StorageValueSnapshot) { diff --git a/plugins/user-settings-backend/src/service/router.test.ts b/plugins/user-settings-backend/src/service/router.test.ts index 21aff9f164..a0e5403612 100644 --- a/plugins/user-settings-backend/src/service/router.test.ts +++ b/plugins/user-settings-backend/src/service/router.test.ts @@ -195,7 +195,7 @@ describe('createRouter', () => { }); }); - describe('GET /buckets/:bucket/:key', () => { + describe('GET /buckets/:bucket/keys/:key', () => { it('returns ok', async () => { const setting = { bucket: 'my-bucket', key: 'my-key', value: 'a' }; authenticateMock.mockResolvedValue({ @@ -205,7 +205,7 @@ describe('createRouter', () => { userSettingsStore.get.mockResolvedValue(setting); const responses = await request(app) - .get('/buckets/my-bucket/my-key') + .get('/buckets/my-bucket/keys/my-key') .set('Authorization', 'Bearer foo'); expect(responses.status).toEqual(200); @@ -221,14 +221,16 @@ describe('createRouter', () => { }); it('returns an error if the Authorization header is missing', async () => { - const responses = await request(app).get('/buckets/my-bucket/my-key'); + const responses = await request(app).get( + '/buckets/my-bucket/keys/my-key', + ); expect(responses.status).toEqual(401); expect(userSettingsStore.get).not.toHaveBeenCalled(); }); }); - describe('DELETE /buckets/:bucket/:key', () => { + describe('DELETE /buckets/:bucket/keys/:key', () => { it('returns ok', async () => { authenticateMock.mockResolvedValue({ identity: { userEntityRef: 'user-1' }, @@ -237,7 +239,7 @@ describe('createRouter', () => { userSettingsStore.delete.mockResolvedValue(); const responses = await request(app) - .delete('/buckets/my-bucket/my-key') + .delete('/buckets/my-bucket/keys/my-key') .set('Authorization', 'Bearer foo'); expect(responses.status).toEqual(204); @@ -252,14 +254,16 @@ describe('createRouter', () => { }); it('returns an error if the Authorization header is missing', async () => { - const responses = await request(app).delete('/buckets/my-bucket/my-key'); + const responses = await request(app).delete( + '/buckets/my-bucket/keys/my-key', + ); expect(responses.status).toEqual(401); expect(userSettingsStore.delete).not.toHaveBeenCalled(); }); }); - describe('PUT /buckets/:bucket/:key', () => { + describe('PUT /buckets/:bucket/keys/:key', () => { it('returns ok', async () => { const setting = { bucket: 'my-bucket', key: 'my-key', value: 'a' }; authenticateMock.mockResolvedValue({ @@ -270,7 +274,7 @@ describe('createRouter', () => { userSettingsStore.get.mockResolvedValue(setting); const responses = await request(app) - .put('/buckets/my-bucket/my-key') + .put('/buckets/my-bucket/keys/my-key') .set('Authorization', 'Bearer foo') .send({ value: 'a' }); @@ -299,7 +303,7 @@ describe('createRouter', () => { }); const responses = await request(app) - .put('/buckets/my-bucket/my-key') + .put('/buckets/my-bucket/keys/my-key') .set('Authorization', 'Bearer foo') .send({ value: { invalid: 'because not a string' } }); @@ -310,7 +314,9 @@ describe('createRouter', () => { }); it('returns an error if the Authorization header is missing', async () => { - const responses = await request(app).get('/buckets/my-bucket/my-key'); + const responses = await request(app).get( + '/buckets/my-bucket/keys/my-key', + ); expect(responses.status).toEqual(401); expect(userSettingsStore.get).not.toHaveBeenCalled(); diff --git a/plugins/user-settings-backend/src/service/router.ts b/plugins/user-settings-backend/src/service/router.ts index 588759c929..f51e38c10a 100644 --- a/plugins/user-settings-backend/src/service/router.ts +++ b/plugins/user-settings-backend/src/service/router.ts @@ -109,7 +109,7 @@ export async function createRouter( }); // get a single value - router.get('/buckets/:bucket/:key', async (req, res) => { + router.get('/buckets/:bucket/keys/:key', async (req, res) => { const userEntityRef = await getUserEntityRef(req); const { bucket, key } = req.params; @@ -121,7 +121,7 @@ export async function createRouter( }); // set a single value - router.put('/buckets/:bucket/:key', async (req, res) => { + router.put('/buckets/:bucket/keys/:key', async (req, res) => { const userEntityRef = await getUserEntityRef(req); const { bucket, key } = req.params; const { value } = req.body; @@ -144,7 +144,7 @@ export async function createRouter( }); // get a single value - router.delete('/buckets/:bucket/:key', async (req, res) => { + router.delete('/buckets/:bucket/keys/:key', async (req, res) => { const userEntityRef = await getUserEntityRef(req); const { bucket, key } = req.params; From 9895e6bfb2707eaee73c198d36db9268ed1afa6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 6 Sep 2022 10:50:49 +0200 Subject: [PATCH 053/192] rename to UserSettingsStorage and adjust error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/healthy-oranges-fly.md | 7 +- packages/core-app-api/api-report.md | 52 ++++++------- ...ge.test.ts => UserSettingsStorage.test.ts} | 20 +++-- ...stentStorage.ts => UserSettingsStorage.ts} | 78 +++++++++++-------- .../apis/implementations/StorageApi/index.ts | 2 +- .../DatabaseUserSettingsStore.test.ts | 2 +- .../src/database/DatabaseUserSettingsStore.ts | 2 +- .../src/database/index.ts | 1 + plugins/user-settings-backend/src/index.ts | 1 + plugins/user-settings-backend/src/run.ts | 1 + .../src/service/index.ts | 1 + .../src/service/router.test.ts | 2 +- .../src/service/router.ts | 2 +- .../src/service/standaloneServer.ts | 11 ++- .../user-settings-backend/src/setupTests.ts | 1 + 15 files changed, 97 insertions(+), 86 deletions(-) rename packages/core-app-api/src/apis/implementations/StorageApi/{PersistentStorage.test.ts => UserSettingsStorage.test.ts} (96%) rename packages/core-app-api/src/apis/implementations/StorageApi/{PersistentStorage.ts => UserSettingsStorage.ts} (73%) diff --git a/.changeset/healthy-oranges-fly.md b/.changeset/healthy-oranges-fly.md index da6530b903..c6b6f6ff93 100644 --- a/.changeset/healthy-oranges-fly.md +++ b/.changeset/healthy-oranges-fly.md @@ -3,6 +3,7 @@ '@backstage/plugin-user-settings-backend': minor --- -Add new plugin @backstage/user-settings-backend to store user related settings -in the database. Additionally adding a PersistentStorage implementation to use -easily use the new plugin as drop-in replacement for the WebStorage. +Add new plugin `@backstage/user-settings-backend` to store user related settings +in the database. Additionally adding a `UserSettingsStorage` implementation of +the `StorageApi` to easily use the new plugin as drop-in replacement for the +`WebStorage`. diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index de3afdcbd4..90e18df723 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -506,35 +506,6 @@ export type OneLoginAuthCreateOptions = { provider?: AuthProviderInfo; }; -// @public -export class PersistentStorage implements StorageApi { - constructor( - namespace: string, - fetchApi: FetchApi, - discoveryApi: DiscoveryApi, - errorApi: ErrorApi, - ); - // (undocumented) - static create(options: { - fetchApi: FetchApi; - discoveryApi: DiscoveryApi; - errorApi: ErrorApi; - namespace?: string; - }): PersistentStorage; - // (undocumented) - forBucket(name: string): StorageApi; - // (undocumented) - observe$( - key: string, - ): Observable>; - // (undocumented) - remove(key: string): Promise; - // (undocumented) - set(key: string, data: T): Promise; - // (undocumented) - snapshot(key: string): StorageValueSnapshot; -} - // @public export class SamlAuth implements ProfileInfoApi, BackstageIdentityApi, SessionApi @@ -572,6 +543,29 @@ export class UrlPatternDiscovery implements DiscoveryApi { getBaseUrl(pluginId: string): Promise; } +// @public +export class UserSettingsStorage implements StorageApi { + // (undocumented) + static create(options: { + fetchApi: FetchApi; + discoveryApi: DiscoveryApi; + errorApi: ErrorApi; + namespace?: string; + }): UserSettingsStorage; + // (undocumented) + forBucket(name: string): StorageApi; + // (undocumented) + observe$( + key: string, + ): Observable>; + // (undocumented) + remove(key: string): Promise; + // (undocumented) + set(key: string, data: T): Promise; + // (undocumented) + snapshot(key: string): StorageValueSnapshot; +} + // @public export class WebStorage implements StorageApi { constructor(namespace: string, errorApi: ErrorApi); diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.test.ts b/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.test.ts similarity index 96% rename from packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.test.ts rename to packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.test.ts index 79da01ca41..cc48fc925c 100644 --- a/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.test.ts +++ b/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.test.ts @@ -14,22 +14,21 @@ * limitations under the License. */ -import { PersistentStorage } from './PersistentStorage'; import { + DiscoveryApi, ErrorApi, FetchApi, StorageApi, - DiscoveryApi, } from '@backstage/core-plugin-api'; import { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils'; - import { rest } from 'msw'; import { setupServer } from 'msw/node'; - -const server = setupServer(); +import { UserSettingsStorage } from './UserSettingsStorage'; describe('Persistent Storage API', () => { + const server = setupServer(); setupRequestMockHandlers(server); + const mockBaseUrl = 'http://backstage:9191/api'; const mockErrorApi = { post: jest.fn(), error$: jest.fn() }; const mockDiscoveryApi = { getBaseUrl: async () => mockBaseUrl }; @@ -42,7 +41,7 @@ describe('Persistent Storage API', () => { namespace?: string; }>, ): StorageApi => { - return PersistentStorage.create({ + return UserSettingsStorage.create({ errorApi: mockErrorApi, fetchApi: new MockFetchApi(), discoveryApi: mockDiscoveryApi, @@ -58,7 +57,9 @@ describe('Persistent Storage API', () => { ); }); - afterEach(() => jest.resetAllMocks()); + afterEach(() => { + jest.resetAllMocks(); + }); it('should return undefined for values which are unset', async () => { const storage = createPersistentStorage(); @@ -267,7 +268,7 @@ describe('Persistent Storage API', () => { expect(mockErrorApi.post).not.toHaveBeenCalled(); }); - it('should call the error api when the json can not be parsed in local storage', async () => { + it('should silently treat the value as absent when the json can not be parsed', async () => { const selectedKeyNextHandler = jest.fn(); const rootStorage = createPersistentStorage({ namespace: 'Test.Mock.Thing', @@ -278,10 +279,8 @@ describe('Persistent Storage API', () => { `${mockBaseUrl}/buckets/:bucket/keys/:key`, async (req, res, ctx) => { const { bucket, key } = req.params; - expect(bucket).toEqual('Test.Mock.Thing'); expect(key).toEqual('key'); - return res(ctx.text('{ invalid: json string }')); }, ), @@ -305,7 +304,6 @@ describe('Persistent Storage API', () => { presence: 'absent', value: undefined, }); - expect(mockErrorApi.post).toHaveBeenCalledWith(expect.any(Error)); }); it('should freeze the snapshot value', async () => { diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.ts b/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.ts similarity index 73% rename from packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.ts rename to packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.ts index b23ecd2b3f..0763ad8187 100644 --- a/packages/core-app-api/src/apis/implementations/StorageApi/PersistentStorage.ts +++ b/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.ts @@ -21,11 +21,11 @@ import { StorageApi, StorageValueSnapshot, } from '@backstage/core-plugin-api'; -import { NotFoundError } from '@backstage/errors'; +import { ResponseError } from '@backstage/errors'; import { JsonValue, Observable } from '@backstage/types'; import ObservableImpl from 'zen-observable'; -const buckets = new Map(); +const buckets = new Map(); /** * An implementation of the storage API, that uses the user-settings backend to @@ -33,7 +33,7 @@ const buckets = new Map(); * * @public */ -export class PersistentStorage implements StorageApi { +export class UserSettingsStorage implements StorageApi { private subscribers = new Set< ZenObservable.SubscriptionObserver> >(); @@ -47,7 +47,7 @@ export class PersistentStorage implements StorageApi { }; }); - constructor( + private constructor( private readonly namespace: string, private readonly fetchApi: FetchApi, private readonly discoveryApi: DiscoveryApi, @@ -59,8 +59,8 @@ export class PersistentStorage implements StorageApi { discoveryApi: DiscoveryApi; errorApi: ErrorApi; namespace?: string; - }): PersistentStorage { - return new PersistentStorage( + }): UserSettingsStorage { + return new UserSettingsStorage( options.namespace ?? 'default', options.fetchApi, options.discoveryApi, @@ -75,7 +75,7 @@ export class PersistentStorage implements StorageApi { if (!buckets.has(bucketPath)) { buckets.set( bucketPath, - new PersistentStorage( + new UserSettingsStorage( bucketPath, this.fetchApi, this.discoveryApi, @@ -90,10 +90,14 @@ export class PersistentStorage implements StorageApi { async remove(key: string): Promise { const fetchUrl = await this.getFetchUrl(key); - await this.fetchApi.fetch(fetchUrl, { + const response = await this.fetchApi.fetch(fetchUrl, { method: 'DELETE', }); + if (!response.ok && response.status !== 404) { + throw ResponseError.fromResponse(response); + } + this.notifyChanges({ key, presence: 'absent', @@ -109,6 +113,10 @@ export class PersistentStorage implements StorageApi { body, }); + if (!response.ok) { + throw ResponseError.fromResponse(response); + } + const { value } = await response.json(); this.notifyChanges({ @@ -121,12 +129,16 @@ export class PersistentStorage implements StorageApi { observe$( key: string, ): Observable> { + // TODO(freben): Introduce server polling or similar, to ensure that different devices update when values change return this.observable.filter(({ key: messageKey }) => messageKey === key); } snapshot(key: string): StorageValueSnapshot { - // trigger a reload - this.get(key).then(snapshot => this.notifyChanges(snapshot)); + // trigger a reload, ensure it happens on the next tick (after returning) + Promise.resolve() + .then(() => this.get(key)) + .then(snapshot => this.notifyChanges(snapshot)) + .catch(error => this.errorApi.post(error)); return { key, @@ -137,20 +149,21 @@ export class PersistentStorage implements StorageApi { private async get( key: string, ): Promise> { + const fetchUrl = await this.getFetchUrl(key); + const response = await this.fetchApi.fetch(fetchUrl); + + if (response.status === 404) { + return { + key, + presence: 'absent', + }; + } + + if (!response.ok) { + throw ResponseError.fromResponse(response); + } + try { - const fetchUrl = await this.getFetchUrl(key); - const response = await this.fetchApi.fetch(fetchUrl); - - if (response.status === 404) { - throw new NotFoundError( - `Setting '${key}' is not set in bucket '${this.namespace}'`, - ); - } else if (!response.ok) { - throw new Error( - `Unable to fetch '${key}' from bucket '${this.namespace}'`, - ); - } - const { value: rawValue } = await response.json(); const value = JSON.parse(rawValue, (_key, val) => { if (typeof val === 'object' && val !== null) { @@ -164,16 +177,11 @@ export class PersistentStorage implements StorageApi { presence: 'present', value, }; - } catch (error) { - // NotFoundError shouldn't be recorded - if (error && !(error instanceof NotFoundError)) { - this.errorApi.post(error); - } - + } catch { + // If the value is not valid JSON, we return an unknown presence. This should never happen return { key, presence: 'absent', - value: undefined, }; } } @@ -185,9 +193,15 @@ export class PersistentStorage implements StorageApi { return `${baseUrl}/buckets/${encodedNamespace}/keys/${encodedKey}`; } - private async notifyChanges(snapshot: StorageValueSnapshot) { + private async notifyChanges( + snapshot: StorageValueSnapshot, + ) { for (const subscription of this.subscribers) { - subscription.next(snapshot); + try { + subscription.next(snapshot); + } catch { + // ignore + } } } } diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/index.ts b/packages/core-app-api/src/apis/implementations/StorageApi/index.ts index 70a8c9cbe1..e4162d120d 100644 --- a/packages/core-app-api/src/apis/implementations/StorageApi/index.ts +++ b/packages/core-app-api/src/apis/implementations/StorageApi/index.ts @@ -15,4 +15,4 @@ */ export { WebStorage } from './WebStorage'; -export { PersistentStorage } from './PersistentStorage'; +export { UserSettingsStorage } from './UserSettingsStorage'; diff --git a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts index 547beaa6b6..ed69bdcb59 100644 --- a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts +++ b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts @@ -13,9 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils'; import { Knex } from 'knex'; - import { DatabaseUserSettingsStore, RawDbUserSettingsRow, diff --git a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts index 2e2eb6318a..961149c9ea 100644 --- a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts +++ b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { PluginDatabaseManager, resolvePackagePath, } from '@backstage/backend-common'; import { NotFoundError } from '@backstage/errors'; import { Knex } from 'knex'; - import { type UserSetting, UserSettingsStore } from './UserSettingsStore'; const migrationsDir = resolvePackagePath( diff --git a/plugins/user-settings-backend/src/database/index.ts b/plugins/user-settings-backend/src/database/index.ts index 23023ceb2d..2552b7db13 100644 --- a/plugins/user-settings-backend/src/database/index.ts +++ b/plugins/user-settings-backend/src/database/index.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export { DatabaseUserSettingsStore, type RawDbUserSettingsRow, diff --git a/plugins/user-settings-backend/src/index.ts b/plugins/user-settings-backend/src/index.ts index 9d9fe923e4..04d8accdaf 100644 --- a/plugins/user-settings-backend/src/index.ts +++ b/plugins/user-settings-backend/src/index.ts @@ -13,5 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export * from './service'; export * from './database'; diff --git a/plugins/user-settings-backend/src/run.ts b/plugins/user-settings-backend/src/run.ts index a1ba29a339..178de0f786 100644 --- a/plugins/user-settings-backend/src/run.ts +++ b/plugins/user-settings-backend/src/run.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { getRootLogger } from '@backstage/backend-common'; import yn from 'yn'; diff --git a/plugins/user-settings-backend/src/service/index.ts b/plugins/user-settings-backend/src/service/index.ts index 8f3e084d24..8ec00d13c8 100644 --- a/plugins/user-settings-backend/src/service/index.ts +++ b/plugins/user-settings-backend/src/service/index.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export { createRouter, type RouterOptions } from './router'; diff --git a/plugins/user-settings-backend/src/service/router.test.ts b/plugins/user-settings-backend/src/service/router.test.ts index a0e5403612..c1feb284a0 100644 --- a/plugins/user-settings-backend/src/service/router.test.ts +++ b/plugins/user-settings-backend/src/service/router.test.ts @@ -13,11 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { AuthenticationError } from '@backstage/errors'; import { IdentityClient } from '@backstage/plugin-auth-node'; import express from 'express'; import request from 'supertest'; - import { UserSettingsStore } from '../database'; import { createRouter } from './router'; diff --git a/plugins/user-settings-backend/src/service/router.ts b/plugins/user-settings-backend/src/service/router.ts index f51e38c10a..58580eddda 100644 --- a/plugins/user-settings-backend/src/service/router.ts +++ b/plugins/user-settings-backend/src/service/router.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { errorHandler } from '@backstage/backend-common'; import { AuthenticationError, InputError } from '@backstage/errors'; import { @@ -21,7 +22,6 @@ import { } from '@backstage/plugin-auth-node'; import express, { Request } from 'express'; import Router from 'express-promise-router'; - import { UserSettingsStore } from '../database'; /** diff --git a/plugins/user-settings-backend/src/service/standaloneServer.ts b/plugins/user-settings-backend/src/service/standaloneServer.ts index 1e079f02c0..3cd3704ca8 100644 --- a/plugins/user-settings-backend/src/service/standaloneServer.ts +++ b/plugins/user-settings-backend/src/service/standaloneServer.ts @@ -13,16 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Server } from 'http'; import { createServiceBuilder, useHotMemoize } from '@backstage/backend-common'; -import Knex from 'knex'; -import { Logger } from 'winston'; - -import { DatabaseUserSettingsStore } from '../database'; -import { createRouter } from './router'; import { IdentityClient } from '@backstage/plugin-auth-node'; import { Router } from 'express'; +import { Server } from 'http'; +import Knex from 'knex'; +import { Logger } from 'winston'; +import { DatabaseUserSettingsStore } from '../database'; +import { createRouter } from './router'; export interface ServerOptions { port: number; diff --git a/plugins/user-settings-backend/src/setupTests.ts b/plugins/user-settings-backend/src/setupTests.ts index 8b9b6bd586..813cdeaae3 100644 --- a/plugins/user-settings-backend/src/setupTests.ts +++ b/plugins/user-settings-backend/src/setupTests.ts @@ -13,4 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export {}; From 46cda8068d8ae75a6eb40827e851e9572ccc2477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 6 Sep 2022 17:11:01 +0200 Subject: [PATCH 054/192] simplify router and db interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/user-settings-backend/api-report.md | 160 +--------- .../DatabaseUserSettingsStore.test.ts | 281 +++--------------- .../src/database/DatabaseUserSettingsStore.ts | 107 +++---- .../src/database/UserSettingsStore.ts | 53 ++-- .../src/database/index.ts | 6 +- .../src/service/router.test.ts | 165 +--------- .../src/service/router.ts | 106 +++---- .../src/service/standaloneServer.ts | 6 +- 8 files changed, 155 insertions(+), 729 deletions(-) diff --git a/plugins/user-settings-backend/api-report.md b/plugins/user-settings-backend/api-report.md index 47e59637d6..15e8b5eb5b 100644 --- a/plugins/user-settings-backend/api-report.md +++ b/plugins/user-settings-backend/api-report.md @@ -5,169 +5,17 @@ ```ts import express from 'express'; import { IdentityClient } from '@backstage/plugin-auth-node'; -import { Knex } from 'knex'; import { PluginDatabaseManager } from '@backstage/backend-common'; // @public -export function createRouter( - options: RouterOptions, -): Promise; - -// @public -export class DatabaseUserSettingsStore - implements UserSettingsStore -{ - // (undocumented) - static create(options: { - database: PluginDatabaseManager; - }): Promise; - // (undocumented) - delete( - tx: Knex.Transaction, - opts: { - userEntityRef: string; - bucket: string; - key: string; - }, - ): Promise; - // (undocumented) - deleteAll( - tx: Knex.Transaction, - opts: { - userEntityRef: string; - }, - ): Promise; - // (undocumented) - deleteBucket( - tx: Knex.Transaction, - opts: { - userEntityRef: string; - bucket: string; - }, - ): Promise; - // (undocumented) - get( - tx: Knex.Transaction, - opts: { - userEntityRef: string; - bucket: string; - key: string; - }, - ): Promise; - // (undocumented) - getAll( - tx: Knex.Transaction, - opts: { - userEntityRef: string; - }, - ): Promise[]>; - // (undocumented) - getBucket( - tx: Knex.Transaction, - opts: { - userEntityRef: string; - bucket: string; - }, - ): Promise; - // (undocumented) - set( - tx: Knex.Transaction, - opts: { - userEntityRef: string; - bucket: string; - key: string; - value: string; - }, - ): Promise; - // (undocumented) - transaction(fn: (tx: Knex.Transaction) => Promise): Promise; -} +export function createRouter(options: RouterOptions): Promise; // @public (undocumented) -export type RawDbUserSettingsRow = { - user_entity_ref: string; - bucket: string; - key: string; - value: string; -}; - -// @public (undocumented) -export interface RouterOptions { +export interface RouterOptions { + // (undocumented) + database: PluginDatabaseManager; // (undocumented) identity: IdentityClient; - // (undocumented) - userSettingsStore: UserSettingsStore; -} - -// @public (undocumented) -export type UserSetting = { - bucket: string; - key: string; - value: string; -}; - -// @public -export interface UserSettingsStore { - // (undocumented) - delete( - tx: Transaction, - opts: { - userEntityRef: string; - bucket: string; - key: string; - }, - ): Promise; - // (undocumented) - deleteAll( - tx: Transaction, - opts: { - userEntityRef: string; - }, - ): Promise; - // (undocumented) - deleteBucket( - tx: Transaction, - opts: { - userEntityRef: string; - bucket: string; - }, - ): Promise; - // (undocumented) - get( - tx: Transaction, - opts: { - userEntityRef: string; - bucket: string; - key: string; - }, - ): Promise; - // (undocumented) - getAll( - tx: Transaction, - opts: { - userEntityRef: string; - }, - ): Promise; - // (undocumented) - getBucket( - tx: Transaction, - opts: { - userEntityRef: string; - bucket: string; - }, - ): Promise; - // (undocumented) - set( - tx: Transaction, - opts: { - userEntityRef: string; - bucket: string; - key: string; - value: string; - }, - ): Promise; - // (undocumented) - transaction(fn: (tx: Transaction) => Promise): Promise; } // (No @packageDocumentation comment for this package) diff --git a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts index ed69bdcb59..e98ee0010e 100644 --- a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts +++ b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts @@ -67,189 +67,14 @@ describe.each(databases.eachSupportedId())( .orderBy('user_entity_ref') .select(); - describe('getAll', () => { - it('should return empty user settings', async () => { - expect( - await storage.transaction(tx => - storage.getAll(tx, { userEntityRef: 'user-1' }), - ), - ).toEqual([]); - }); - - it('should return all user settings', async () => { - await insert([ - { - user_entity_ref: 'user-1', - bucket: 'bucket-a', - key: 'key-a', - value: 'value-a', - }, - { - user_entity_ref: 'user-1', - bucket: 'bucket-a', - key: 'key-b', - value: 'value-b', - }, - { - user_entity_ref: 'user-1', - bucket: 'bucket-c', - key: 'key-c', - value: 'value-c', - }, - ]); - - expect( - await storage.transaction(tx => - storage.getAll(tx, { userEntityRef: 'user-1' }), - ), - ).toEqual([ - { bucket: 'bucket-a', key: 'key-a', value: 'value-a' }, - { bucket: 'bucket-a', key: 'key-b', value: 'value-b' }, - { bucket: 'bucket-c', key: 'key-c', value: 'value-c' }, - ]); - }); - }); - - describe('deleteAll', () => { - it('should delete all user settings', async () => { - await insert([ - { - user_entity_ref: 'user-1', - bucket: 'bucket-a', - key: 'key-a', - value: 'value-a', - }, - { - user_entity_ref: 'user-1', - bucket: 'bucket-c', - key: 'key-c', - value: 'value-c', - }, - { - user_entity_ref: 'user-2', - bucket: 'bucket-c', - key: 'key-c', - value: 'value-c', - }, - ]); - - await storage.transaction(tx => - storage.deleteAll(tx, { userEntityRef: 'user-1' }), - ); - - expect(await query()).toEqual([ - { - user_entity_ref: 'user-2', - bucket: 'bucket-c', - key: 'key-c', - value: 'value-c', - }, - ]); - }); - }); - - describe('getBucket', () => { - it('should return an empty bucket', async () => { - expect( - await storage.transaction(tx => - storage.getBucket(tx, { - userEntityRef: 'user-1', - bucket: 'bucket-c', - }), - ), - ).toEqual([]); - }); - - it('should return the settings of the bucket', async () => { - await insert([ - { - user_entity_ref: 'user-1', - bucket: 'bucket-a', - key: 'key-a', - value: 'value-a', - }, - { - user_entity_ref: 'user-1', - bucket: 'bucket-c', - key: 'key-c', - value: 'value-c', - }, - { - user_entity_ref: 'user-2', - bucket: 'bucket-c', - key: 'key-c', - value: 'value-c', - }, - ]); - - expect( - await storage.transaction(tx => - storage.getBucket(tx, { - userEntityRef: 'user-1', - bucket: 'bucket-a', - }), - ), - ).toEqual([{ bucket: 'bucket-a', key: 'key-a', value: 'value-a' }]); - }); - }); - - describe('deleteBucket', () => { - it('should delete a bucket', async () => { - await insert([ - { - user_entity_ref: 'user-1', - bucket: 'bucket-a', - key: 'key-a', - value: 'value-a', - }, - { - user_entity_ref: 'user-1', - bucket: 'bucket-c', - key: 'key-c', - value: 'value-c', - }, - { - user_entity_ref: 'user-2', - bucket: 'bucket-c', - key: 'key-c', - value: 'value-c', - }, - ]); - - await storage.transaction(tx => - storage.deleteBucket(tx, { - userEntityRef: 'user-1', - bucket: 'bucket-a', - }), - ); - - expect(await query()).toEqual([ - { - user_entity_ref: 'user-1', - bucket: 'bucket-c', - key: 'key-c', - value: 'value-c', - }, - { - user_entity_ref: 'user-2', - bucket: 'bucket-c', - key: 'key-c', - value: 'value-c', - }, - ]); - }); - }); - describe('get', () => { it('should throw an error', async () => { await expect(() => - storage.transaction(tx => - storage.get(tx, { - userEntityRef: 'user-1', - bucket: 'bucket-c', - key: 'key-c', - }), - ), + storage.get({ + userEntityRef: 'user-1', + bucket: 'bucket-c', + key: 'key-c', + }), ).rejects.toThrow(`Unable to find 'key-c' in bucket 'bucket-c'`); }); @@ -269,30 +94,30 @@ describe.each(databases.eachSupportedId())( }, ]); - expect( - await storage.transaction(tx => - storage.get(tx, { - userEntityRef: 'user-1', - bucket: 'bucket-a', - key: 'key-a', - }), - ), - ).toEqual({ bucket: 'bucket-a', key: 'key-a', value: 'value-a' }); + await expect( + storage.get({ + userEntityRef: 'user-1', + bucket: 'bucket-a', + key: 'key-a', + }), + ).resolves.toEqual({ + bucket: 'bucket-a', + key: 'key-a', + value: 'value-a', + }); }); }); describe('set', () => { it('should insert a new setting', async () => { - await storage.transaction(tx => - storage.set(tx, { - userEntityRef: 'user-1', - bucket: 'bucket-a', - key: 'key-a', - value: 'value-a', - }), - ); + await storage.set({ + userEntityRef: 'user-1', + bucket: 'bucket-a', + key: 'key-a', + value: 'value-a', + }); - expect(await query()).toEqual([ + await expect(query()).resolves.toEqual([ { user_entity_ref: 'user-1', bucket: 'bucket-a', @@ -303,25 +128,21 @@ describe.each(databases.eachSupportedId())( }); it('should overwrite an existing setting', async () => { - await storage.transaction(tx => - storage.set(tx, { - userEntityRef: 'user-1', - bucket: 'bucket-a', - key: 'key-a', - value: 'value-a', - }), - ); + await storage.set({ + userEntityRef: 'user-1', + bucket: 'bucket-a', + key: 'key-a', + value: 'value-a', + }); - await storage.transaction(tx => - storage.set(tx, { - userEntityRef: 'user-1', - bucket: 'bucket-a', - key: 'key-a', - value: 'value-b', - }), - ); + await storage.set({ + userEntityRef: 'user-1', + bucket: 'bucket-a', + key: 'key-a', + value: 'value-b', + }); - expect(await query()).toEqual([ + await expect(query()).resolves.toEqual([ { user_entity_ref: 'user-1', bucket: 'bucket-a', @@ -334,15 +155,13 @@ describe.each(databases.eachSupportedId())( describe('delete', () => { it('should not throw an error if the entry does not exist', async () => { - await expect(() => - storage.transaction(tx => - storage.delete(tx, { - userEntityRef: 'user-1', - bucket: 'bucket-c', - key: 'key-c', - }), - ), - ).not.toThrow(); + await expect( + storage.delete({ + userEntityRef: 'user-1', + bucket: 'bucket-c', + key: 'key-c', + }), + ).resolves.toBeUndefined(); }); it('should return the setting', async () => { @@ -361,14 +180,12 @@ describe.each(databases.eachSupportedId())( }, ]); - await storage.transaction(tx => - storage.delete(tx, { - userEntityRef: 'user-1', - bucket: 'bucket-a', - key: 'key-a', - }), - ); - expect(await query()).toEqual([ + await storage.delete({ + userEntityRef: 'user-1', + bucket: 'bucket-a', + key: 'key-a', + }); + await expect(query()).resolves.toEqual([ { user_entity_ref: 'user-2', bucket: 'bucket-c', diff --git a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts index 961149c9ea..4b46b6383f 100644 --- a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts +++ b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts @@ -42,9 +42,7 @@ export type RawDbUserSettingsRow = { * * @public */ -export class DatabaseUserSettingsStore - implements UserSettingsStore -{ +export class DatabaseUserSettingsStore implements UserSettingsStore { static async create(options: { database: PluginDatabaseManager; }): Promise { @@ -62,93 +60,56 @@ export class DatabaseUserSettingsStore private constructor(private readonly db: Knex) {} - async getAll(tx: Knex.Transaction, opts: { userEntityRef: string }) { - const settings = await tx('user_settings') - .where({ user_entity_ref: opts.userEntityRef }) - .select('bucket', 'key', 'value'); - - return settings; - } - - async deleteAll( - tx: Knex.Transaction, - opts: { userEntityRef: string }, - ): Promise { - await tx('user_settings') - .where({ user_entity_ref: opts.userEntityRef }) - .delete(); - } - - async getBucket( - tx: Knex.Transaction, - opts: { userEntityRef: string; bucket: string }, - ): Promise { - const settings = await tx('user_settings') - .where({ user_entity_ref: opts.userEntityRef, bucket: opts.bucket }) - .select(['bucket', 'key', 'value']); - - return settings; - } - - async deleteBucket( - tx: Knex.Transaction, - opts: { userEntityRef: string; bucket: string }, - ): Promise { - await tx('user_settings') - .where({ user_entity_ref: opts.userEntityRef, bucket: opts.bucket }) - .delete(); - } - - async get( - tx: Knex.Transaction, - opts: { userEntityRef: string; bucket: string; key: string }, - ): Promise { - const setting = await tx('user_settings') + async get(options: { + userEntityRef: string; + bucket: string; + key: string; + }): Promise { + const rows = await this.db('user_settings') .where({ - user_entity_ref: opts.userEntityRef, - bucket: opts.bucket, - key: opts.key, + user_entity_ref: options.userEntityRef, + bucket: options.bucket, + key: options.key, }) .select(['bucket', 'key', 'value']); - if (!setting.length) { + if (!rows.length) { throw new NotFoundError( - `Unable to find '${opts.key}' in bucket '${opts.bucket}'`, + `Unable to find '${options.key}' in bucket '${options.bucket}'`, ); } - return setting[0]; + return rows[0]; } - async set( - tx: Knex.Transaction, - opts: { userEntityRef: string; bucket: string; key: string; value: string }, - ): Promise { - await tx('user_settings') + async set(options: { + userEntityRef: string; + bucket: string; + key: string; + value: string; + }): Promise { + await this.db('user_settings') .insert({ - user_entity_ref: opts.userEntityRef, - bucket: opts.bucket, - key: opts.key, - value: opts.value, + user_entity_ref: options.userEntityRef, + bucket: options.bucket, + key: options.key, + value: options.value, }) .onConflict(['user_entity_ref', 'bucket', 'key']) - .merge({ value: opts.value }); + .merge(['value']); } - async delete( - tx: Knex.Transaction, - opts: { userEntityRef: string; bucket: string; key: string }, - ): Promise { - await tx('user_settings') + async delete(options: { + userEntityRef: string; + bucket: string; + key: string; + }): Promise { + await this.db('user_settings') .where({ - user_entity_ref: opts.userEntityRef, - bucket: opts.bucket, - key: opts.key, + user_entity_ref: options.userEntityRef, + bucket: options.bucket, + key: options.key, }) .delete(); } - - async transaction(fn: (tx: Knex.Transaction) => Promise) { - return await this.db.transaction(fn); - } } diff --git a/plugins/user-settings-backend/src/database/UserSettingsStore.ts b/plugins/user-settings-backend/src/database/UserSettingsStore.ts index b86673270f..d14af091ef 100644 --- a/plugins/user-settings-backend/src/database/UserSettingsStore.ts +++ b/plugins/user-settings-backend/src/database/UserSettingsStore.ts @@ -15,7 +15,7 @@ */ /** - * @public + * A single setting in a bucket */ export type UserSetting = { bucket: string; @@ -25,41 +25,24 @@ export type UserSetting = { /** * Store definition for the user settings. - * - * @public */ -export interface UserSettingsStore { - transaction(fn: (tx: Transaction) => Promise): Promise; +export interface UserSettingsStore { + get(options: { + userEntityRef: string; + bucket: string; + key: string; + }): Promise; - get( - tx: Transaction, - opts: { userEntityRef: string; bucket: string; key: string }, - ): Promise; + set(options: { + userEntityRef: string; + bucket: string; + key: string; + value: string; + }): Promise; - set( - tx: Transaction, - opts: { userEntityRef: string; bucket: string; key: string; value: string }, - ): Promise; - - delete( - tx: Transaction, - opts: { userEntityRef: string; bucket: string; key: string }, - ): Promise; - - getBucket( - tx: Transaction, - opts: { userEntityRef: string; bucket: string }, - ): Promise; - - deleteBucket( - tx: Transaction, - opts: { userEntityRef: string; bucket: string }, - ): Promise; - - getAll( - tx: Transaction, - opts: { userEntityRef: string }, - ): Promise; - - deleteAll(tx: Transaction, opts: { userEntityRef: string }): Promise; + delete(options: { + userEntityRef: string; + bucket: string; + key: string; + }): Promise; } diff --git a/plugins/user-settings-backend/src/database/index.ts b/plugins/user-settings-backend/src/database/index.ts index 2552b7db13..813cdeaae3 100644 --- a/plugins/user-settings-backend/src/database/index.ts +++ b/plugins/user-settings-backend/src/database/index.ts @@ -14,8 +14,4 @@ * limitations under the License. */ -export { - DatabaseUserSettingsStore, - type RawDbUserSettingsRow, -} from './DatabaseUserSettingsStore'; -export type { UserSettingsStore, UserSetting } from './UserSettingsStore'; +export {}; diff --git a/plugins/user-settings-backend/src/service/router.test.ts b/plugins/user-settings-backend/src/service/router.test.ts index c1feb284a0..7640714477 100644 --- a/plugins/user-settings-backend/src/service/router.test.ts +++ b/plugins/user-settings-backend/src/service/router.test.ts @@ -14,20 +14,14 @@ * limitations under the License. */ -import { AuthenticationError } from '@backstage/errors'; import { IdentityClient } from '@backstage/plugin-auth-node'; import express from 'express'; import request from 'supertest'; -import { UserSettingsStore } from '../database'; -import { createRouter } from './router'; +import { UserSettingsStore } from '../database/UserSettingsStore'; +import { createRouterInternal } from './router'; describe('createRouter', () => { - const userSettingsStore: jest.Mocked> = { - transaction: jest.fn(), - deleteAll: jest.fn(), - getAll: jest.fn(), - getBucket: jest.fn(), - deleteBucket: jest.fn(), + const userSettingsStore: jest.Mocked = { get: jest.fn(), set: jest.fn(), delete: jest.fn(), @@ -40,9 +34,7 @@ describe('createRouter', () => { let app: express.Express; beforeEach(async () => { - userSettingsStore.transaction.mockImplementation(fn => fn('tx')); - - const router = await createRouter({ + const router = await createRouterInternal({ userSettingsStore, identity: identityClient as IdentityClient, }); @@ -54,147 +46,6 @@ describe('createRouter', () => { jest.resetAllMocks(); }); - describe('GET /buckets/', () => { - it('returns ok', async () => { - const settings = [ - { bucket: 'a', key: 'a', value: 'a' }, - { bucket: 'b', key: 'b', value: 'b' }, - ]; - authenticateMock.mockResolvedValue({ - identity: { userEntityRef: 'user-1' }, - }); - - userSettingsStore.getAll.mockResolvedValue(settings); - - const responses = await request(app) - .get('/buckets/') - .set('Authorization', 'Bearer foo'); - - expect(responses.status).toEqual(200); - expect(responses.body).toEqual(settings); - - expect(authenticateMock).toHaveBeenCalledWith('foo'); - expect(userSettingsStore.getAll).toHaveBeenCalledTimes(1); - expect(userSettingsStore.getAll).toHaveBeenCalledWith('tx', { - userEntityRef: 'user-1', - }); - }); - - it('returns an error if the Authorization header is missing', async () => { - const responses = await request(app).get('/buckets/'); - - expect(responses.status).toEqual(401); - expect(userSettingsStore.getAll).not.toHaveBeenCalled(); - }); - - it('returns an error if the token is not valid', async () => { - authenticateMock.mockRejectedValue( - new AuthenticationError('Invalid token'), - ); - - const responses = await request(app) - .get('/buckets/') - .set('Authorization', 'Bearer foo'); - - expect(responses.status).toEqual(401); - expect(userSettingsStore.getAll).not.toHaveBeenCalled(); - }); - }); - - describe('DELETE /buckets/', () => { - it('returns ok', async () => { - authenticateMock.mockResolvedValue({ - identity: { userEntityRef: 'user-1' }, - }); - - userSettingsStore.deleteAll.mockResolvedValue(); - - const responses = await request(app) - .delete('/buckets/') - .set('Authorization', 'Bearer foo'); - - expect(responses.status).toEqual(204); - - expect(authenticateMock).toHaveBeenCalledWith('foo'); - expect(userSettingsStore.deleteAll).toHaveBeenCalledTimes(1); - expect(userSettingsStore.deleteAll).toHaveBeenCalledWith('tx', { - userEntityRef: 'user-1', - }); - }); - - it('returns an error if the Authorization header is missing', async () => { - const responses = await request(app).delete('/buckets/'); - - expect(responses.status).toEqual(401); - expect(userSettingsStore.getAll).not.toHaveBeenCalled(); - }); - }); - - describe('GET /buckets/:bucket', () => { - it('returns ok', async () => { - const settings = [ - { bucket: 'my-bucket', key: 'a', value: 'a' }, - { bucket: 'my-bucket', key: 'b', value: 'b' }, - ]; - authenticateMock.mockResolvedValue({ - identity: { userEntityRef: 'user-1' }, - }); - - userSettingsStore.getBucket.mockResolvedValue(settings); - - const responses = await request(app) - .get('/buckets/my-bucket') - .set('Authorization', 'Bearer foo'); - - expect(responses.status).toEqual(200); - expect(responses.body).toEqual(settings); - - expect(authenticateMock).toHaveBeenCalledWith('foo'); - expect(userSettingsStore.getBucket).toHaveBeenCalledTimes(1); - expect(userSettingsStore.getBucket).toHaveBeenCalledWith('tx', { - userEntityRef: 'user-1', - bucket: 'my-bucket', - }); - }); - - it('returns an error if the Authorization header is missing', async () => { - const responses = await request(app).get('/buckets/my-bucket'); - - expect(responses.status).toEqual(401); - expect(userSettingsStore.getBucket).not.toHaveBeenCalled(); - }); - }); - - describe('DELETE /buckets/:bucket', () => { - it('returns ok', async () => { - authenticateMock.mockResolvedValue({ - identity: { userEntityRef: 'user-1' }, - }); - - userSettingsStore.deleteBucket.mockResolvedValue(); - - const responses = await request(app) - .delete('/buckets/my-bucket') - .set('Authorization', 'Bearer foo'); - - expect(responses.status).toEqual(204); - - expect(authenticateMock).toHaveBeenCalledWith('foo'); - expect(userSettingsStore.deleteBucket).toHaveBeenCalledTimes(1); - expect(userSettingsStore.deleteBucket).toHaveBeenCalledWith('tx', { - userEntityRef: 'user-1', - bucket: 'my-bucket', - }); - }); - - it('returns an error if the Authorization header is missing', async () => { - const responses = await request(app).delete('/buckets/my-bucket'); - - expect(responses.status).toEqual(401); - expect(userSettingsStore.deleteBucket).not.toHaveBeenCalled(); - }); - }); - describe('GET /buckets/:bucket/keys/:key', () => { it('returns ok', async () => { const setting = { bucket: 'my-bucket', key: 'my-key', value: 'a' }; @@ -213,7 +64,7 @@ describe('createRouter', () => { expect(authenticateMock).toHaveBeenCalledWith('foo'); expect(userSettingsStore.get).toHaveBeenCalledTimes(1); - expect(userSettingsStore.get).toHaveBeenCalledWith('tx', { + expect(userSettingsStore.get).toHaveBeenCalledWith({ userEntityRef: 'user-1', bucket: 'my-bucket', key: 'my-key', @@ -246,7 +97,7 @@ describe('createRouter', () => { expect(authenticateMock).toHaveBeenCalledWith('foo'); expect(userSettingsStore.delete).toHaveBeenCalledTimes(1); - expect(userSettingsStore.delete).toHaveBeenCalledWith('tx', { + expect(userSettingsStore.delete).toHaveBeenCalledWith({ userEntityRef: 'user-1', bucket: 'my-bucket', key: 'my-key', @@ -283,14 +134,14 @@ describe('createRouter', () => { expect(authenticateMock).toHaveBeenCalledWith('foo'); expect(userSettingsStore.set).toHaveBeenCalledTimes(1); - expect(userSettingsStore.set).toHaveBeenCalledWith('tx', { + expect(userSettingsStore.set).toHaveBeenCalledWith({ userEntityRef: 'user-1', bucket: 'my-bucket', key: 'my-key', value: 'a', }); expect(userSettingsStore.get).toHaveBeenCalledTimes(1); - expect(userSettingsStore.get).toHaveBeenCalledWith('tx', { + expect(userSettingsStore.get).toHaveBeenCalledWith({ userEntityRef: 'user-1', bucket: 'my-bucket', key: 'my-key', diff --git a/plugins/user-settings-backend/src/service/router.ts b/plugins/user-settings-backend/src/service/router.ts index 58580eddda..d677ee4bfb 100644 --- a/plugins/user-settings-backend/src/service/router.ts +++ b/plugins/user-settings-backend/src/service/router.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { errorHandler } from '@backstage/backend-common'; +import { errorHandler, PluginDatabaseManager } from '@backstage/backend-common'; import { AuthenticationError, InputError } from '@backstage/errors'; import { getBearerTokenFromAuthorizationHeader, @@ -22,13 +22,14 @@ import { } from '@backstage/plugin-auth-node'; import express, { Request } from 'express'; import Router from 'express-promise-router'; -import { UserSettingsStore } from '../database'; +import { DatabaseUserSettingsStore } from '../database/DatabaseUserSettingsStore'; +import { UserSettingsStore } from '../database/UserSettingsStore'; /** * @public */ -export interface RouterOptions { - userSettingsStore: UserSettingsStore; +export interface RouterOptions { + database: PluginDatabaseManager; identity: IdentityClient; } @@ -37,10 +38,23 @@ export interface RouterOptions { * * @public */ -export async function createRouter( - options: RouterOptions, +export async function createRouter( + options: RouterOptions, ): Promise { - const { userSettingsStore, identity } = options; + const userSettingsStore = await DatabaseUserSettingsStore.create({ + database: options.database, + }); + + return await createRouterInternal({ + userSettingsStore, + identity: options.identity, + }); +} + +export async function createRouterInternal(options: { + identity: IdentityClient; + userSettingsStore: UserSettingsStore; +}): Promise { const router = Router(); router.use(express.json()); @@ -57,65 +71,21 @@ export async function createRouter( } // throws an AuthenticationError in case the token is invalid - const user = await identity.authenticate(token); + const user = await options.identity.authenticate(token); return user.identity.userEntityRef; }; - // get all user related settings - router.get('/buckets', async (req, res) => { - const userEntityRef = await getUserEntityRef(req); - - const settings = await userSettingsStore.transaction(tx => - userSettingsStore.getAll(tx, { userEntityRef }), - ); - - res.json(settings); - }); - - // remove all user related settings - router.delete('/buckets', async (req, res) => { - const userEntityRef = await getUserEntityRef(req); - - await userSettingsStore.transaction(tx => - userSettingsStore.deleteAll(tx, { userEntityRef }), - ); - - res.send(204).end(); - }); - - // get a single bucket - router.get('/buckets/:bucket', async (req, res) => { - const userEntityRef = await getUserEntityRef(req); - const { bucket } = req.params; - - const settings = await userSettingsStore.transaction(tx => - userSettingsStore.getBucket(tx, { userEntityRef, bucket }), - ); - - res.json(settings); - }); - - // delete a whole bucket - router.delete('/buckets/:bucket', async (req, res) => { - const userEntityRef = await getUserEntityRef(req); - const { bucket } = req.params; - - await userSettingsStore.transaction(tx => - userSettingsStore.deleteBucket(tx, { userEntityRef, bucket }), - ); - - res.status(204).end(); - }); - // get a single value router.get('/buckets/:bucket/keys/:key', async (req, res) => { const userEntityRef = await getUserEntityRef(req); const { bucket, key } = req.params; - const setting = await userSettingsStore.transaction(tx => - userSettingsStore.get(tx, { userEntityRef, bucket, key }), - ); + const setting = await options.userSettingsStore.get({ + userEntityRef, + bucket, + key, + }); res.json(setting); }); @@ -130,14 +100,16 @@ export async function createRouter( throw new InputError('Value must be a string'); } - const setting = await userSettingsStore.transaction(async tx => { - await userSettingsStore.set(tx, { - userEntityRef, - bucket, - key, - value, - }); - return userSettingsStore.get(tx, { userEntityRef, bucket, key }); + await options.userSettingsStore.set({ + userEntityRef, + bucket, + key, + value, + }); + const setting = await options.userSettingsStore.get({ + userEntityRef, + bucket, + key, }); res.json(setting); @@ -148,9 +120,7 @@ export async function createRouter( const userEntityRef = await getUserEntityRef(req); const { bucket, key } = req.params; - await userSettingsStore.transaction(tx => - userSettingsStore.delete(tx, { userEntityRef, bucket, key }), - ); + await options.userSettingsStore.delete({ userEntityRef, bucket, key }); res.send(204).end(); }); diff --git a/plugins/user-settings-backend/src/service/standaloneServer.ts b/plugins/user-settings-backend/src/service/standaloneServer.ts index 3cd3704ca8..28af3a163f 100644 --- a/plugins/user-settings-backend/src/service/standaloneServer.ts +++ b/plugins/user-settings-backend/src/service/standaloneServer.ts @@ -20,8 +20,8 @@ import { Router } from 'express'; import { Server } from 'http'; import Knex from 'knex'; import { Logger } from 'winston'; -import { DatabaseUserSettingsStore } from '../database'; -import { createRouter } from './router'; +import { DatabaseUserSettingsStore } from '../database/DatabaseUserSettingsStore'; +import { createRouterInternal } from './router'; export interface ServerOptions { port: number; @@ -50,7 +50,7 @@ export async function startStandaloneServer( }), } as IdentityClient; - const router = await createRouter({ + const router = await createRouterInternal({ userSettingsStore: await DatabaseUserSettingsStore.create({ database: { getClient: async () => database }, }), From 9018da59a91126cc07666bb4b77170f962d8425d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 7 Sep 2022 09:45:43 +0200 Subject: [PATCH 055/192] do not double encode the value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../StorageApi/UserSettingsStorage.test.ts | 17 +++++------------ .../StorageApi/UserSettingsStorage.ts | 6 +++--- plugins/user-settings-backend/package.json | 1 + .../database/DatabaseUserSettingsStore.test.ts | 14 +++++++------- .../src/database/DatabaseUserSettingsStore.ts | 13 +++++++++---- .../src/database/UserSettingsStore.ts | 6 ++++-- .../src/service/router.test.ts | 4 ++-- .../user-settings-backend/src/service/router.ts | 4 ++-- yarn.lock | 1 + 9 files changed, 34 insertions(+), 32 deletions(-) diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.test.ts b/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.test.ts index cc48fc925c..d8ea87b38d 100644 --- a/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.test.ts +++ b/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.test.ts @@ -49,14 +49,6 @@ describe('Persistent Storage API', () => { }); }; - beforeEach(() => { - server.use( - rest.get(`${mockBaseUrl}/buckets/`, async (_req, res, ctx) => { - return res(ctx.json([])); - }), - ); - }); - afterEach(() => { jest.resetAllMocks(); }); @@ -90,7 +82,8 @@ describe('Persistent Storage API', () => { `${mockBaseUrl}/buckets/:bucket/keys/:key`, async (req, res, ctx) => { const body = await req.json(); - const data = { value: JSON.stringify(dummyValue) }; + const data = { value: dummyValue }; + expect(body).toEqual(data); return res(ctx.json(data)); @@ -113,7 +106,7 @@ describe('Persistent Storage API', () => { `${mockBaseUrl}/buckets/:bucket/keys/:key`, async (req, res, ctx) => { const body = await req.json(); - const data = { value: JSON.stringify(dummyValue) }; + const data = { value: dummyValue }; expect(body).toEqual(data); return res(ctx.json(data)); @@ -136,7 +129,7 @@ describe('Persistent Storage API', () => { `${mockBaseUrl}/buckets/:bucket/keys/:key`, async (req, res, ctx) => { const body = await req.json(); - const data = { value: JSON.stringify(mockData) }; + const data = { value: mockData }; expect(body).toEqual(data); return res(ctx.json(data)); @@ -315,7 +308,7 @@ describe('Persistent Storage API', () => { rest.get( `${mockBaseUrl}/buckets/:bucket/keys/:key`, async (_req, res, ctx) => { - return res(ctx.text(JSON.stringify({ value: JSON.stringify(data) }))); + return res(ctx.json({ value: data })); }, ), ); diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.ts b/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.ts index 0763ad8187..d01d5021ee 100644 --- a/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.ts +++ b/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.ts @@ -106,7 +106,7 @@ export class UserSettingsStorage implements StorageApi { async set(key: string, data: T): Promise { const fetchUrl = await this.getFetchUrl(key); - const body = JSON.stringify({ value: JSON.stringify(data) }); + const body = JSON.stringify({ value: data }); const response = await this.fetchApi.fetch(fetchUrl, { method: 'PUT', @@ -121,7 +121,7 @@ export class UserSettingsStorage implements StorageApi { this.notifyChanges({ key, - value: JSON.parse(value), + value, presence: 'present', }); } @@ -165,7 +165,7 @@ export class UserSettingsStorage implements StorageApi { try { const { value: rawValue } = await response.json(); - const value = JSON.parse(rawValue, (_key, val) => { + const value = JSON.parse(JSON.stringify(rawValue), (_key, val) => { if (typeof val === 'object' && val !== null) { Object.freeze(val); } diff --git a/plugins/user-settings-backend/package.json b/plugins/user-settings-backend/package.json index 41178d49b8..690d1d13c9 100644 --- a/plugins/user-settings-backend/package.json +++ b/plugins/user-settings-backend/package.json @@ -32,6 +32,7 @@ "@backstage/catalog-model": "^1.1.0", "@backstage/errors": "^1.1.0", "@backstage/plugin-auth-node": "^0.2.5-next.1", + "@backstage/types": "^1.0.0", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", diff --git a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts index e98ee0010e..eb50e26179 100644 --- a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts +++ b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.test.ts @@ -84,13 +84,13 @@ describe.each(databases.eachSupportedId())( user_entity_ref: 'user-1', bucket: 'bucket-a', key: 'key-a', - value: 'value-a', + value: JSON.stringify('value-a'), }, { user_entity_ref: 'user-2', bucket: 'bucket-c', key: 'key-c', - value: 'value-c', + value: JSON.stringify('value-c'), }, ]); @@ -122,7 +122,7 @@ describe.each(databases.eachSupportedId())( user_entity_ref: 'user-1', bucket: 'bucket-a', key: 'key-a', - value: 'value-a', + value: JSON.stringify('value-a'), }, ]); }); @@ -147,7 +147,7 @@ describe.each(databases.eachSupportedId())( user_entity_ref: 'user-1', bucket: 'bucket-a', key: 'key-a', - value: 'value-b', + value: JSON.stringify('value-b'), }, ]); }); @@ -170,13 +170,13 @@ describe.each(databases.eachSupportedId())( user_entity_ref: 'user-1', bucket: 'bucket-a', key: 'key-a', - value: 'value-a', + value: JSON.stringify('value-a'), }, { user_entity_ref: 'user-2', bucket: 'bucket-c', key: 'key-c', - value: 'value-c', + value: JSON.stringify('value-c'), }, ]); @@ -190,7 +190,7 @@ describe.each(databases.eachSupportedId())( user_entity_ref: 'user-2', bucket: 'bucket-c', key: 'key-c', - value: 'value-c', + value: JSON.stringify('value-c'), }, ]); }); diff --git a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts index 4b46b6383f..b0fe8fdecc 100644 --- a/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts +++ b/plugins/user-settings-backend/src/database/DatabaseUserSettingsStore.ts @@ -19,8 +19,9 @@ import { resolvePackagePath, } from '@backstage/backend-common'; import { NotFoundError } from '@backstage/errors'; +import { JsonValue } from '@backstage/types'; import { Knex } from 'knex'; -import { type UserSetting, UserSettingsStore } from './UserSettingsStore'; +import { UserSettingsStore, type UserSetting } from './UserSettingsStore'; const migrationsDir = resolvePackagePath( '@backstage/plugin-user-settings-backend', @@ -79,21 +80,25 @@ export class DatabaseUserSettingsStore implements UserSettingsStore { ); } - return rows[0]; + return { + bucket: rows[0].bucket, + key: rows[0].key, + value: JSON.parse(rows[0].value), + }; } async set(options: { userEntityRef: string; bucket: string; key: string; - value: string; + value: JsonValue; }): Promise { await this.db('user_settings') .insert({ user_entity_ref: options.userEntityRef, bucket: options.bucket, key: options.key, - value: options.value, + value: JSON.stringify(options.value), }) .onConflict(['user_entity_ref', 'bucket', 'key']) .merge(['value']); diff --git a/plugins/user-settings-backend/src/database/UserSettingsStore.ts b/plugins/user-settings-backend/src/database/UserSettingsStore.ts index d14af091ef..289c37523e 100644 --- a/plugins/user-settings-backend/src/database/UserSettingsStore.ts +++ b/plugins/user-settings-backend/src/database/UserSettingsStore.ts @@ -14,13 +14,15 @@ * limitations under the License. */ +import { JsonValue } from '@backstage/types'; + /** * A single setting in a bucket */ export type UserSetting = { bucket: string; key: string; - value: string; + value: JsonValue; }; /** @@ -37,7 +39,7 @@ export interface UserSettingsStore { userEntityRef: string; bucket: string; key: string; - value: string; + value: JsonValue; }): Promise; delete(options: { diff --git a/plugins/user-settings-backend/src/service/router.test.ts b/plugins/user-settings-backend/src/service/router.test.ts index 7640714477..c8df77a845 100644 --- a/plugins/user-settings-backend/src/service/router.test.ts +++ b/plugins/user-settings-backend/src/service/router.test.ts @@ -148,7 +148,7 @@ describe('createRouter', () => { }); }); - it('returns an error if the value is not a string', async () => { + it('returns an error if the value is not given', async () => { authenticateMock.mockResolvedValue({ identity: { userEntityRef: 'user-1' }, }); @@ -156,7 +156,7 @@ describe('createRouter', () => { const responses = await request(app) .put('/buckets/my-bucket/keys/my-key') .set('Authorization', 'Bearer foo') - .send({ value: { invalid: 'because not a string' } }); + .send({}); expect(responses.status).toEqual(400); diff --git a/plugins/user-settings-backend/src/service/router.ts b/plugins/user-settings-backend/src/service/router.ts index d677ee4bfb..487a6b4fee 100644 --- a/plugins/user-settings-backend/src/service/router.ts +++ b/plugins/user-settings-backend/src/service/router.ts @@ -96,8 +96,8 @@ export async function createRouterInternal(options: { const { bucket, key } = req.params; const { value } = req.body; - if (typeof value !== 'string') { - throw new InputError('Value must be a string'); + if (value === undefined) { + throw new InputError('Missing required field "value"'); } await options.userSettingsStore.set({ diff --git a/yarn.lock b/yarn.lock index 9df599d106..4d821c0e64 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7321,6 +7321,7 @@ __metadata: "@backstage/cli": ^0.19.0-next.1 "@backstage/errors": ^1.1.0 "@backstage/plugin-auth-node": ^0.2.5-next.1 + "@backstage/types": ^1.0.0 "@types/express": ^4.17.6 "@types/supertest": ^2.0.8 express: ^4.17.1 From 0f88eab1b7bca8dc5bc2f3c546aa29da23890b0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 7 Sep 2022 11:53:53 +0200 Subject: [PATCH 056/192] use the IdentityApi properly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- plugins/user-settings-backend/README.md | 61 +++++++------------ plugins/user-settings-backend/api-report.md | 4 +- .../src/service/router.test.ts | 60 ++++++++++++------ .../src/service/router.ts | 22 +++---- .../src/service/standaloneServer.ts | 32 +++++----- 5 files changed, 89 insertions(+), 90 deletions(-) diff --git a/plugins/user-settings-backend/README.md b/plugins/user-settings-backend/README.md index 05a8c15c6d..5a98c86326 100644 --- a/plugins/user-settings-backend/README.md +++ b/plugins/user-settings-backend/README.md @@ -18,64 +18,47 @@ yarn --cwd packages/backend add @backstage/plugin-user-settings-backend ```ts // packages/backend/src/plugins/userSettings.ts -import { IdentityClient } from '@backstage/plugin-auth-node'; -import { - createRouter, - createUserSettingsStore, -} from '@backstage/plugin-user-settings-backend'; -import { Router } from 'express'; - +import { createRouter } from '@backstage/plugin-user-settings-backend'; import { PluginEnvironment } from '../types'; -export default async function createPlugin({ - database, - discovery, -}: PluginEnvironment): Promise { - const identity = IdentityClient.create({ - discovery, - issuer: await discovery.getExternalBaseUrl('auth'), - }); - +export default async function createPlugin(env: PluginEnvironment) { return await createRouter({ - userSettingsStore: await createUserSettingsStore(database), - identity, + database: env.database, + identity: env.identity, }); } ``` -3. Add the new routes to your backend by modifying the - `packages/backend/src/index.ts`: +3. Add the new routes to your backend by modifying `packages/backend/src/index.ts`: ```diff -// packages/backend/src/index.ts -+ import userSettings from './plugins/userSettings'; -async function main() { -+ const userSettingsEnv = useHotMemoize(module, () => -+ createEnv('userSettings'), -+ ); - const apiRouter = Router(); -+ apiRouter.use('/user-settings', await userSettings(userSettingsEnv)); + // packages/backend/src/index.ts ++import userSettings from './plugins/userSettings'; + async function main() { ++ const userSettingsEnv = useHotMemoize(module, () => createEnv('userSettings')); + const apiRouter = Router(); ++ apiRouter.use('/user-settings', await userSettings(userSettingsEnv)); } ``` ## Setup app To make use of the user settings backend, replace the `WebStorage` with the -`PersistentStorage` by using the `storageApiRef`. +`UserSettingsStorage` by using the `storageApiRef`. ```diff -// packages/app/src/apis.ts -import { - AnyApiFactory, - createApiFactory, + // packages/app/src/apis.ts + import { + AnyApiFactory, + createApiFactory, + discoveryApiRef, + fetchApiRef - errorApiRef, + errorApiRef, + storageApiRef, -} from '@backstage/core-plugin-api'; -+ import { PersistentStorage } from '@backstage/core-app-api'; + } from '@backstage/core-plugin-api'; ++import { UserSettingsStorage } from '@backstage/core-app-api'; -export const apis: AnyApiFactory[] = [ + export const apis: AnyApiFactory[] = [ + createApiFactory({ + api: storageApiRef, + deps: { @@ -84,9 +67,9 @@ export const apis: AnyApiFactory[] = [ + fetchApi: fetchApiRef, + }, + factory: ({ discoveryApi, errorApi, fetchApi }) => -+ PersistentStorage.create({ discoveryApi, errorApi, fetchApi }), ++ UserSettingsStorage.create({ discoveryApi, errorApi, fetchApi }), + }), -]; + ]; ``` ## Development diff --git a/plugins/user-settings-backend/api-report.md b/plugins/user-settings-backend/api-report.md index 15e8b5eb5b..1bc37bbec7 100644 --- a/plugins/user-settings-backend/api-report.md +++ b/plugins/user-settings-backend/api-report.md @@ -4,7 +4,7 @@ ```ts import express from 'express'; -import { IdentityClient } from '@backstage/plugin-auth-node'; +import { IdentityApi } from '@backstage/plugin-auth-node'; import { PluginDatabaseManager } from '@backstage/backend-common'; // @public @@ -15,7 +15,7 @@ export interface RouterOptions { // (undocumented) database: PluginDatabaseManager; // (undocumented) - identity: IdentityClient; + identity: IdentityApi; } // (No @packageDocumentation comment for this package) diff --git a/plugins/user-settings-backend/src/service/router.test.ts b/plugins/user-settings-backend/src/service/router.test.ts index c8df77a845..64cdfcc465 100644 --- a/plugins/user-settings-backend/src/service/router.test.ts +++ b/plugins/user-settings-backend/src/service/router.test.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { IdentityClient } from '@backstage/plugin-auth-node'; +import { + BackstageIdentityResponse, + IdentityApi, +} from '@backstage/plugin-auth-node'; import express from 'express'; import request from 'supertest'; import { UserSettingsStore } from '../database/UserSettingsStore'; @@ -26,9 +29,12 @@ describe('createRouter', () => { set: jest.fn(), delete: jest.fn(), }; - const authenticateMock = jest.fn(); - const identityClient: jest.Mocked> = { - authenticate: authenticateMock, + const getIdentityMock = jest.fn< + Promise, + any + >(); + const identityApi: jest.Mocked> = { + getIdentity: getIdentityMock, }; let app: express.Express; @@ -36,7 +42,7 @@ describe('createRouter', () => { beforeEach(async () => { const router = await createRouterInternal({ userSettingsStore, - identity: identityClient as IdentityClient, + identity: identityApi as IdentityApi, }); app = express().use(router); @@ -49,8 +55,13 @@ describe('createRouter', () => { describe('GET /buckets/:bucket/keys/:key', () => { it('returns ok', async () => { const setting = { bucket: 'my-bucket', key: 'my-key', value: 'a' }; - authenticateMock.mockResolvedValue({ - identity: { userEntityRef: 'user-1' }, + getIdentityMock.mockResolvedValue({ + token: 'a', + identity: { + type: 'user', + ownershipEntityRefs: [], + userEntityRef: 'user-1', + }, }); userSettingsStore.get.mockResolvedValue(setting); @@ -62,7 +73,7 @@ describe('createRouter', () => { expect(responses.status).toEqual(200); expect(responses.body).toEqual(setting); - expect(authenticateMock).toHaveBeenCalledWith('foo'); + expect(getIdentityMock).toHaveBeenCalledTimes(1); expect(userSettingsStore.get).toHaveBeenCalledTimes(1); expect(userSettingsStore.get).toHaveBeenCalledWith({ userEntityRef: 'user-1', @@ -83,8 +94,13 @@ describe('createRouter', () => { describe('DELETE /buckets/:bucket/keys/:key', () => { it('returns ok', async () => { - authenticateMock.mockResolvedValue({ - identity: { userEntityRef: 'user-1' }, + getIdentityMock.mockResolvedValue({ + token: 'a', + identity: { + type: 'user', + ownershipEntityRefs: [], + userEntityRef: 'user-1', + }, }); userSettingsStore.delete.mockResolvedValue(); @@ -95,7 +111,7 @@ describe('createRouter', () => { expect(responses.status).toEqual(204); - expect(authenticateMock).toHaveBeenCalledWith('foo'); + expect(getIdentityMock).toHaveBeenCalledTimes(1); expect(userSettingsStore.delete).toHaveBeenCalledTimes(1); expect(userSettingsStore.delete).toHaveBeenCalledWith({ userEntityRef: 'user-1', @@ -117,8 +133,13 @@ describe('createRouter', () => { describe('PUT /buckets/:bucket/keys/:key', () => { it('returns ok', async () => { const setting = { bucket: 'my-bucket', key: 'my-key', value: 'a' }; - authenticateMock.mockResolvedValue({ - identity: { userEntityRef: 'user-1' }, + getIdentityMock.mockResolvedValue({ + token: 'a', + identity: { + type: 'user', + ownershipEntityRefs: [], + userEntityRef: 'user-1', + }, }); userSettingsStore.set.mockResolvedValue(); @@ -132,7 +153,7 @@ describe('createRouter', () => { expect(responses.status).toEqual(200); expect(responses.body).toEqual(setting); - expect(authenticateMock).toHaveBeenCalledWith('foo'); + expect(getIdentityMock).toHaveBeenCalledTimes(1); expect(userSettingsStore.set).toHaveBeenCalledTimes(1); expect(userSettingsStore.set).toHaveBeenCalledWith({ userEntityRef: 'user-1', @@ -149,8 +170,13 @@ describe('createRouter', () => { }); it('returns an error if the value is not given', async () => { - authenticateMock.mockResolvedValue({ - identity: { userEntityRef: 'user-1' }, + getIdentityMock.mockResolvedValue({ + token: 'a', + identity: { + type: 'user', + ownershipEntityRefs: [], + userEntityRef: 'user-1', + }, }); const responses = await request(app) @@ -160,7 +186,7 @@ describe('createRouter', () => { expect(responses.status).toEqual(400); - expect(authenticateMock).toHaveBeenCalledWith('foo'); + expect(getIdentityMock).toHaveBeenCalledTimes(1); expect(userSettingsStore.set).not.toHaveBeenCalled(); }); diff --git a/plugins/user-settings-backend/src/service/router.ts b/plugins/user-settings-backend/src/service/router.ts index 487a6b4fee..6792042585 100644 --- a/plugins/user-settings-backend/src/service/router.ts +++ b/plugins/user-settings-backend/src/service/router.ts @@ -16,10 +16,7 @@ import { errorHandler, PluginDatabaseManager } from '@backstage/backend-common'; import { AuthenticationError, InputError } from '@backstage/errors'; -import { - getBearerTokenFromAuthorizationHeader, - IdentityClient, -} from '@backstage/plugin-auth-node'; +import { IdentityApi } from '@backstage/plugin-auth-node'; import express, { Request } from 'express'; import Router from 'express-promise-router'; import { DatabaseUserSettingsStore } from '../database/DatabaseUserSettingsStore'; @@ -30,7 +27,7 @@ import { UserSettingsStore } from '../database/UserSettingsStore'; */ export interface RouterOptions { database: PluginDatabaseManager; - identity: IdentityClient; + identity: IdentityApi; } /** @@ -52,7 +49,7 @@ export async function createRouter( } export async function createRouterInternal(options: { - identity: IdentityClient; + identity: IdentityApi; userSettingsStore: UserSettingsStore; }): Promise { const router = Router(); @@ -62,18 +59,13 @@ export async function createRouterInternal(options: { * Helper method to extract the userEntityRef from the request. */ const getUserEntityRef = async (req: Request): Promise => { - const token = getBearerTokenFromAuthorizationHeader( - req.header('authorization'), - ); - - if (!token) { + // throws an AuthenticationError in case the token exists but is invalid + const identity = await options.identity.getIdentity({ request: req }); + if (!identity) { throw new AuthenticationError(`Missing token in 'authorization' header`); } - // throws an AuthenticationError in case the token is invalid - const user = await options.identity.authenticate(token); - - return user.identity.userEntityRef; + return identity.identity.userEntityRef; }; // get a single value diff --git a/plugins/user-settings-backend/src/service/standaloneServer.ts b/plugins/user-settings-backend/src/service/standaloneServer.ts index 28af3a163f..6dd8c7f72d 100644 --- a/plugins/user-settings-backend/src/service/standaloneServer.ts +++ b/plugins/user-settings-backend/src/service/standaloneServer.ts @@ -15,8 +15,7 @@ */ import { createServiceBuilder, useHotMemoize } from '@backstage/backend-common'; -import { IdentityClient } from '@backstage/plugin-auth-node'; -import { Router } from 'express'; +import { IdentityApi } from '@backstage/plugin-auth-node'; import { Server } from 'http'; import Knex from 'knex'; import { Logger } from 'winston'; @@ -44,11 +43,19 @@ export async function startStandaloneServer( logger.debug('Starting application server...'); - const identityMock = { - authenticate: async token => ({ - identity: { userEntityRef: token ?? 'user:default/john_doe' }, - }), - } as IdentityClient; + const identityMock: IdentityApi = { + async getIdentity({ request }) { + const token = request.headers.authorization?.split(' ')[1]; + return { + identity: { + type: 'user', + ownershipEntityRefs: [], + userEntityRef: token || 'user:default/john_doe', + }, + token: token || 'no-token', + }; + }, + }; const router = await createRouterInternal({ userSettingsStore: await DatabaseUserSettingsStore.create({ @@ -57,18 +64,9 @@ export async function startStandaloneServer( identity: identityMock, }); - // set a custom authorization header to simplify the development - const authWrapper = Router(); - authWrapper.use((req, _res, next) => { - req.headers.authorization = - req.headers.authorization ?? 'Bearer user:default/john_doe'; - next(); - }); - authWrapper.use(router); - let service = createServiceBuilder(module) .setPort(options.port) - .addRouter('/user-settings', authWrapper); + .addRouter('/user-settings', router); if (options.enableCors) { service = service.enableCors({ origin: 'http://localhost:3000' }); From 8a57ffc0fa9c1c57d21439d8c28d4abc98b2db54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 8 Sep 2022 09:20:22 +0200 Subject: [PATCH 057/192] Ensure that we return stable observer references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/sixty-apes-wait.md | 5 + packages/core-app-api/api-report.md | 5 +- .../StorageApi/UserSettingsStorage.test.ts | 9 +- .../StorageApi/UserSettingsStorage.ts | 118 +++++++++++------- .../implementations/StorageApi/WebStorage.ts | 4 +- .../shortcuts/src/api/LocalStoredShortcuts.ts | 12 +- plugins/user-settings-backend/README.md | 9 +- 7 files changed, 104 insertions(+), 58 deletions(-) create mode 100644 .changeset/sixty-apes-wait.md diff --git a/.changeset/sixty-apes-wait.md b/.changeset/sixty-apes-wait.md new file mode 100644 index 0000000000..1273307519 --- /dev/null +++ b/.changeset/sixty-apes-wait.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-shortcuts': patch +--- + +Ensure that a stable observable is used in the shortcuts API diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 90e18df723..39e5388d68 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -550,6 +550,7 @@ export class UserSettingsStorage implements StorageApi { fetchApi: FetchApi; discoveryApi: DiscoveryApi; errorApi: ErrorApi; + identityApi: IdentityApi; namespace?: string; }): UserSettingsStorage; // (undocumented) @@ -579,7 +580,9 @@ export class WebStorage implements StorageApi { // (undocumented) get(key: string): T | undefined; // (undocumented) - observe$(key: string): Observable>; + observe$( + key: string, + ): Observable>; // (undocumented) remove(key: string): Promise; // (undocumented) diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.test.ts b/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.test.ts index d8ea87b38d..0ebd96d19c 100644 --- a/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.test.ts +++ b/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.test.ts @@ -18,6 +18,7 @@ import { DiscoveryApi, ErrorApi, FetchApi, + IdentityApi, StorageApi, } from '@backstage/core-plugin-api'; import { MockFetchApi, setupRequestMockHandlers } from '@backstage/test-utils'; @@ -31,7 +32,12 @@ describe('Persistent Storage API', () => { const mockBaseUrl = 'http://backstage:9191/api'; const mockErrorApi = { post: jest.fn(), error$: jest.fn() }; - const mockDiscoveryApi = { getBaseUrl: async () => mockBaseUrl }; + const mockDiscoveryApi = { + getBaseUrl: async () => mockBaseUrl, + }; + const mockIdentityApi: Partial = { + getCredentials: async () => ({ token: 'a-token' }), + }; const createPersistentStorage = ( args?: Partial<{ @@ -45,6 +51,7 @@ describe('Persistent Storage API', () => { errorApi: mockErrorApi, fetchApi: new MockFetchApi(), discoveryApi: mockDiscoveryApi, + identityApi: mockIdentityApi as IdentityApi, ...args, }); }; diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.ts b/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.ts index d01d5021ee..b595c524cf 100644 --- a/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.ts +++ b/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.ts @@ -18,12 +18,19 @@ import { DiscoveryApi, ErrorApi, FetchApi, + IdentityApi, StorageApi, StorageValueSnapshot, } from '@backstage/core-plugin-api'; import { ResponseError } from '@backstage/errors'; import { JsonValue, Observable } from '@backstage/types'; import ObservableImpl from 'zen-observable'; +import { WebStorage } from './WebStorage'; + +const JSON_HEADERS = { + 'Content-Type': 'application/json; charset=utf-8', + Accept: 'application/json', +}; const buckets = new Map(); @@ -38,26 +45,25 @@ export class UserSettingsStorage implements StorageApi { ZenObservable.SubscriptionObserver> >(); - private readonly observable = new ObservableImpl< - StorageValueSnapshot - >(subscriber => { - this.subscribers.add(subscriber); - return () => { - this.subscribers.delete(subscriber); - }; - }); + private readonly observables = new Map< + string, + Observable> + >(); private constructor( private readonly namespace: string, private readonly fetchApi: FetchApi, private readonly discoveryApi: DiscoveryApi, private readonly errorApi: ErrorApi, + private readonly identityApi: IdentityApi, + private readonly fallback: WebStorage, ) {} static create(options: { fetchApi: FetchApi; discoveryApi: DiscoveryApi; errorApi: ErrorApi; + identityApi: IdentityApi; namespace?: string; }): UserSettingsStorage { return new UserSettingsStorage( @@ -65,6 +71,11 @@ export class UserSettingsStorage implements StorageApi { options.fetchApi, options.discoveryApi, options.errorApi, + options.identityApi, + WebStorage.create({ + namespace: options.namespace, + errorApi: options.errorApi, + }), ); } @@ -80,6 +91,8 @@ export class UserSettingsStorage implements StorageApi { this.fetchApi, this.discoveryApi, this.errorApi, + this.identityApi, + this.fallback, ), ); } @@ -95,72 +108,81 @@ export class UserSettingsStorage implements StorageApi { }); if (!response.ok && response.status !== 404) { - throw ResponseError.fromResponse(response); + throw await ResponseError.fromResponse(response); } - this.notifyChanges({ - key, - presence: 'absent', - }); + this.notifyChanges({ key, presence: 'absent' }); } async set(key: string, data: T): Promise { + if (!(await this.isSignedIn())) { + await this.fallback.set(key, data); + this.notifyChanges({ key, presence: 'present', value: data }); + } + const fetchUrl = await this.getFetchUrl(key); - const body = JSON.stringify({ value: data }); const response = await this.fetchApi.fetch(fetchUrl, { method: 'PUT', - body, + headers: JSON_HEADERS, + body: JSON.stringify({ value: data }), }); if (!response.ok) { - throw ResponseError.fromResponse(response); + throw await ResponseError.fromResponse(response); } const { value } = await response.json(); - this.notifyChanges({ - key, - value, - presence: 'present', - }); + this.notifyChanges({ key, value, presence: 'present' }); } observe$( key: string, ): Observable> { - // TODO(freben): Introduce server polling or similar, to ensure that different devices update when values change - return this.observable.filter(({ key: messageKey }) => messageKey === key); + if (!this.observables.has(key)) { + this.observables.set( + key, + new ObservableImpl>(subscriber => { + this.subscribers.add(subscriber); + + // TODO(freben): Introduce server polling or similar, to ensure that different devices update when values change + Promise.resolve() + .then(() => this.get(key)) + .then(snapshot => subscriber.next(snapshot)) + .catch(error => this.errorApi.post(error)); + + return () => { + this.subscribers.delete(subscriber); + }; + }).filter(({ key: messageKey }) => messageKey === key), + ); + } + + return this.observables.get(key) as Observable>; } snapshot(key: string): StorageValueSnapshot { - // trigger a reload, ensure it happens on the next tick (after returning) - Promise.resolve() - .then(() => this.get(key)) - .then(snapshot => this.notifyChanges(snapshot)) - .catch(error => this.errorApi.post(error)); - - return { - key, - presence: 'unknown', - }; + return { key, presence: 'unknown' }; } private async get( key: string, ): Promise> { + if (!(await this.isSignedIn())) { + // This explicitly uses WebStorage, which we know is synchronous and doesn't return presence: unknown + return this.fallback.snapshot(key); + } + const fetchUrl = await this.getFetchUrl(key); const response = await this.fetchApi.fetch(fetchUrl); if (response.status === 404) { - return { - key, - presence: 'absent', - }; + return { key, presence: 'absent' }; } if (!response.ok) { - throw ResponseError.fromResponse(response); + throw await ResponseError.fromResponse(response); } try { @@ -172,17 +194,10 @@ export class UserSettingsStorage implements StorageApi { return val; }); - return { - key, - presence: 'present', - value, - }; + return { key, presence: 'present', value }; } catch { // If the value is not valid JSON, we return an unknown presence. This should never happen - return { - key, - presence: 'absent', - }; + return { key, presence: 'absent' }; } } @@ -204,4 +219,13 @@ export class UserSettingsStorage implements StorageApi { } } } + + private async isSignedIn(): Promise { + try { + const credentials = await this.identityApi.getCredentials(); + return credentials?.token ? true : false; + } catch { + return false; + } + } } diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.ts b/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.ts index ac3d3f20f2..b0cbfbd883 100644 --- a/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.ts +++ b/packages/core-app-api/src/apis/implementations/StorageApi/WebStorage.ts @@ -86,7 +86,9 @@ export class WebStorage implements StorageApi { this.notifyChanges(key); } - observe$(key: string): Observable> { + observe$( + key: string, + ): Observable> { return this.observable.filter(({ key: messageKey }) => messageKey === key); } diff --git a/plugins/shortcuts/src/api/LocalStoredShortcuts.ts b/plugins/shortcuts/src/api/LocalStoredShortcuts.ts index 9f3561be67..65aeb86e3f 100644 --- a/plugins/shortcuts/src/api/LocalStoredShortcuts.ts +++ b/plugins/shortcuts/src/api/LocalStoredShortcuts.ts @@ -27,12 +27,16 @@ import Observable from 'zen-observable'; * @public */ export class LocalStoredShortcuts implements ShortcutApi { - constructor(private readonly storageApi: StorageApi) {} + private readonly shortcuts: Observable; + + constructor(private readonly storageApi: StorageApi) { + this.shortcuts = Observable.from( + this.storageApi.observe$('items'), + ).map(snapshot => snapshot.value ?? []); + } shortcut$() { - return Observable.from(this.storageApi.observe$('items')).map( - snapshot => snapshot.value ?? [], - ); + return this.shortcuts; } get() { diff --git a/plugins/user-settings-backend/README.md b/plugins/user-settings-backend/README.md index 5a98c86326..45e06731c9 100644 --- a/plugins/user-settings-backend/README.md +++ b/plugins/user-settings-backend/README.md @@ -35,7 +35,7 @@ export default async function createPlugin(env: PluginEnvironment) { // packages/backend/src/index.ts +import userSettings from './plugins/userSettings'; async function main() { -+ const userSettingsEnv = useHotMemoize(module, () => createEnv('userSettings')); ++ const userSettingsEnv = useHotMemoize(module, () => createEnv('user-settings')); const apiRouter = Router(); + apiRouter.use('/user-settings', await userSettings(userSettingsEnv)); } @@ -52,8 +52,9 @@ To make use of the user settings backend, replace the `WebStorage` with the AnyApiFactory, createApiFactory, + discoveryApiRef, -+ fetchApiRef ++ fetchApiRef, errorApiRef, ++ identityApi, + storageApiRef, } from '@backstage/core-plugin-api'; +import { UserSettingsStorage } from '@backstage/core-app-api'; @@ -65,9 +66,9 @@ To make use of the user settings backend, replace the `WebStorage` with the + discoveryApi: discoveryApiRef, + errorApi: errorApiRef, + fetchApi: fetchApiRef, ++ identityApi: identityApiRef + }, -+ factory: ({ discoveryApi, errorApi, fetchApi }) => -+ UserSettingsStorage.create({ discoveryApi, errorApi, fetchApi }), ++ factory: deps => UserSettingsStorage.create(deps), + }), ]; ``` From 294805ed598df10f2e1b090f8bbc1f2f4c0a6956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 19 Sep 2022 13:23:48 +0200 Subject: [PATCH 058/192] refresh internal version deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- packages/core-app-api/package.json | 2 +- plugins/user-settings-backend/package.json | 10 +++++----- yarn.lock | 18 +++++++++--------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index e1c24997ac..b6e2f64889 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -34,7 +34,7 @@ "dependencies": { "@backstage/config": "^1.0.2-next.0", "@backstage/core-plugin-api": "^1.0.6-next.3", - "@backstage/errors": "^1.1.0", + "@backstage/errors": "^1.1.1-next.0", "@backstage/types": "^1.0.0", "@backstage/version-bridge": "^1.0.1", "@types/prop-types": "^15.7.3", diff --git a/plugins/user-settings-backend/package.json b/plugins/user-settings-backend/package.json index 690d1d13c9..f1b4b503e1 100644 --- a/plugins/user-settings-backend/package.json +++ b/plugins/user-settings-backend/package.json @@ -28,10 +28,10 @@ "clean": "backstage-cli package clean" }, "dependencies": { - "@backstage/backend-common": "^0.15.1-next.1", - "@backstage/catalog-model": "^1.1.0", - "@backstage/errors": "^1.1.0", - "@backstage/plugin-auth-node": "^0.2.5-next.1", + "@backstage/backend-common": "^0.15.1-next.3", + "@backstage/catalog-model": "^1.1.1-next.0", + "@backstage/errors": "^1.1.1-next.0", + "@backstage/plugin-auth-node": "^0.2.5-next.3", "@backstage/types": "^1.0.0", "@types/express": "^4.17.6", "express": "^4.17.1", @@ -41,7 +41,7 @@ "yn": "^4.0.0" }, "devDependencies": { - "@backstage/backend-test-utils": "^0.1.28-next.1", + "@backstage/backend-test-utils": "^0.1.28-next.3", "@backstage/cli": "^0.19.0-next.1", "@types/supertest": "^2.0.8", "supertest": "^6.1.3" diff --git a/yarn.lock b/yarn.lock index 4d821c0e64..a9f5a553b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2855,7 +2855,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/backend-common@^0.15.1-next.1, @backstage/backend-common@^0.15.1-next.3, @backstage/backend-common@workspace:packages/backend-common": +"@backstage/backend-common@^0.15.1-next.3, @backstage/backend-common@workspace:packages/backend-common": version: 0.0.0-use.local resolution: "@backstage/backend-common@workspace:packages/backend-common" dependencies: @@ -2991,7 +2991,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/backend-test-utils@^0.1.28-next.1, @backstage/backend-test-utils@^0.1.28-next.3, @backstage/backend-test-utils@workspace:packages/backend-test-utils": +"@backstage/backend-test-utils@^0.1.28-next.3, @backstage/backend-test-utils@workspace:packages/backend-test-utils": version: 0.0.0-use.local resolution: "@backstage/backend-test-utils@workspace:packages/backend-test-utils" dependencies: @@ -3291,7 +3291,7 @@ __metadata: "@backstage/cli": ^0.19.0-next.3 "@backstage/config": ^1.0.2-next.0 "@backstage/core-plugin-api": ^1.0.6-next.3 - "@backstage/errors": ^1.1.0 + "@backstage/errors": ^1.1.1-next.0 "@backstage/test-utils": ^1.2.0-next.3 "@backstage/types": ^1.0.0 "@backstage/version-bridge": ^1.0.1 @@ -4028,7 +4028,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-auth-node@^0.2.5-next.1, @backstage/plugin-auth-node@^0.2.5-next.3, @backstage/plugin-auth-node@workspace:plugins/auth-node": +"@backstage/plugin-auth-node@^0.2.5-next.3, @backstage/plugin-auth-node@workspace:plugins/auth-node": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-node@workspace:plugins/auth-node" dependencies: @@ -7315,12 +7315,12 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/plugin-user-settings-backend@workspace:plugins/user-settings-backend" dependencies: - "@backstage/backend-common": ^0.15.1-next.1 - "@backstage/backend-test-utils": ^0.1.28-next.1 - "@backstage/catalog-model": ^1.1.0 + "@backstage/backend-common": ^0.15.1-next.3 + "@backstage/backend-test-utils": ^0.1.28-next.3 + "@backstage/catalog-model": ^1.1.1-next.0 "@backstage/cli": ^0.19.0-next.1 - "@backstage/errors": ^1.1.0 - "@backstage/plugin-auth-node": ^0.2.5-next.1 + "@backstage/errors": ^1.1.1-next.0 + "@backstage/plugin-auth-node": ^0.2.5-next.3 "@backstage/types": ^1.0.0 "@types/express": ^4.17.6 "@types/supertest": ^2.0.8 From d37a0ea6b6bc041d8c6ff16972fa920af77094b0 Mon Sep 17 00:00:00 2001 From: Nishkarsh Raj Date: Mon, 19 Sep 2022 17:06:30 +0530 Subject: [PATCH 059/192] Fixing document for CI/CD Statistics Plugin - GitLab Module Signed-off-by: Nishkarsh Raj --- plugins/cicd-statistics-module-gitlab/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/cicd-statistics-module-gitlab/README.md b/plugins/cicd-statistics-module-gitlab/README.md index 0b11f6950a..7e1ae2016c 100644 --- a/plugins/cicd-statistics-module-gitlab/README.md +++ b/plugins/cicd-statistics-module-gitlab/README.md @@ -13,7 +13,7 @@ This is an extension module to the `cicd-statistics` plugin, providing a `CicdSt import { gitlabAuthApiRef } from '@backstage/core-plugin-api'; import { cicdStatisticsApiRef } from '@backstage/plugin-cicd-statistics'; -import { CicdStatisticsApiGitlab } from '@backstage plugin-cicd-statistics-module-gitlab'; +import { CicdStatisticsApiGitlab } from '@backstage/plugin-cicd-statistics-module-gitlab'; export const apis: AnyApiFactory[] = [ createApiFactory({ From 0f9b0750028f0fa80d555803868954b77ee7052e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 19 Sep 2022 12:00:45 +0000 Subject: [PATCH 060/192] fix(deps): update dependency @types/passport to v1.0.11 Signed-off-by: Renovate Bot --- yarn.lock | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index afe89bf424..76250936ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14777,7 +14777,7 @@ __metadata: languageName: node linkType: hard -"@types/passport@npm:*, @types/passport@npm:^1.0.3": +"@types/passport@npm:*": version: 1.0.10 resolution: "@types/passport@npm:1.0.10" dependencies: @@ -14786,6 +14786,15 @@ __metadata: languageName: node linkType: hard +"@types/passport@npm:^1.0.3": + version: 1.0.11 + resolution: "@types/passport@npm:1.0.11" + dependencies: + "@types/express": "*" + checksum: 7fea92aeb0d05bd872c17deadc1f1219dbdb23bae1b8f50e1c432ef755bce2b5f3fd60af76c38815f1fbaa047960d3638a4144221c4330457091be1f6e492168 + languageName: node + linkType: hard + "@types/pluralize@npm:^0.0.29": version: 0.0.29 resolution: "@types/pluralize@npm:0.0.29" From 0c780278e0d18924359e44c5add20cc2fb8e9fbc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 19 Sep 2022 14:21:27 +0200 Subject: [PATCH 061/192] backend-common: fix for zip entries being skipped Signed-off-by: Patrik Oldsberg --- .changeset/twelve-items-jump.md | 5 +++ .../reading/tree/ZipArchiveResponse.test.ts | 44 +++++++++++++++++++ .../src/reading/tree/ZipArchiveResponse.ts | 4 +- 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 .changeset/twelve-items-jump.md diff --git a/.changeset/twelve-items-jump.md b/.changeset/twelve-items-jump.md new file mode 100644 index 0000000000..50bc8a4391 --- /dev/null +++ b/.changeset/twelve-items-jump.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Fix for entries being skipped or incomplete when reading large zip archives. diff --git a/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts b/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts index 427c237802..e45a25db66 100644 --- a/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts +++ b/packages/backend-common/src/reading/tree/ZipArchiveResponse.test.ts @@ -16,6 +16,8 @@ import fs from 'fs-extra'; import mockFs from 'mock-fs'; +import { Readable } from 'stream'; +import { create as createArchive } from 'archiver'; import { resolve as resolvePath } from 'path'; import { ZipArchiveResponse } from './ZipArchiveResponse'; @@ -161,6 +163,48 @@ describe('ZipArchiveResponse', () => { ).resolves.toBe(false); }); + it('should extract a large archive', async () => { + const fileCount = 10; + const fileSize = 1000 * 1000; + const filePath = await new Promise((resolve, reject) => { + const outFile = '/large-archive.zip'; + const archive = createArchive('zip'); + + archive.on('error', reject); + archive.on('end', () => resolve(outFile)); + archive.pipe(fs.createWriteStream(outFile)); + archive.on('warning', w => console.warn('WARN', w)); + + for (let i = 0; i < fileCount; i++) { + // Workaround for https://github.com/archiverjs/node-archiver/issues/542 + // TODO(Rugvip): Without this workaround the archive entries end up with an uncompressed size of 0. + // That in turn causes yauzl to hang on extraction, because the internal transform error is ignored. + const stream = new Readable(); + stream.push(Buffer.alloc(fileSize, i)); + stream.push(null); + archive.append(stream, { name: `file-${i}.data` }); + } + + archive.finalize(); + }); + + const stream = fs.createReadStream(filePath); + + const res = new ZipArchiveResponse(stream, '', '/tmp', 'etag'); + const dir = await res.dir({ + targetDir: '/out', + }); + + expect(dir).toBe('/out'); + const files = await fs.readdir(dir); + expect(files).toHaveLength(fileCount); + + for (const file of files) { + const stat = await fs.stat(resolvePath(dir, file)); + expect(stat.size).toBe(fileSize); + } + }); + it('should throw on invalid archive', async () => { const stream = fs.createReadStream('/test-archive-corrupted.zip'); diff --git a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts index 4100af3d57..5a1c5f9688 100644 --- a/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts +++ b/packages/backend-common/src/reading/tree/ZipArchiveResponse.ts @@ -124,9 +124,11 @@ export class ZipArchiveResponse implements ReadTreeResponse { } await callback(entry, readStream); + zipfile.readEntry(); }); + } else { + zipfile.readEntry(); } - zipfile.readEntry(); }); zipfile.once('end', () => resolve()); zipfile.on('error', e => reject(e)); From c8e837da5b9427cacb1db5a3b562f11466226699 Mon Sep 17 00:00:00 2001 From: David Zemon Date: Mon, 19 Sep 2022 08:00:18 -0500 Subject: [PATCH 062/192] feat: Use JsonValue for JWT claim values Signed-off-by: David Zemon --- plugins/auth-backend/api-report.md | 2 +- plugins/auth-backend/src/identity/types.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 2420a79a79..6e9de12cdb 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -706,7 +706,7 @@ export type TokenParams = { claims: { sub: string; ent?: string[]; - } & Record; + } & Record; }; // @public (undocumented) diff --git a/plugins/auth-backend/src/identity/types.ts b/plugins/auth-backend/src/identity/types.ts index 49d78b8472..e325f74c81 100644 --- a/plugins/auth-backend/src/identity/types.ts +++ b/plugins/auth-backend/src/identity/types.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { JsonValue } from '@backstage/types'; + /** Represents any form of serializable JWK */ export interface AnyJWK extends Record { use: 'sig'; @@ -33,14 +35,15 @@ export type TokenParams = { * the subject claim, `sub`. It is common to also list entity ownership relations in the * `ent` list. Additional claims may also be added at the developer's discretion except * for the following list, which will be overwritten by the TokenIssuer: `iss`, `aud`, - * `iat`, and `exp`. + * `iat`, and `exp`. The Backstage team also maintains the right add new claims in the future + * without listing the change as a "breaking change". */ claims: { /** The token subject, i.e. User ID */ sub: string; /** A list of entity references that the user claims ownership through */ ent?: string[]; - } & Record; + } & Record; }; /** From 0ecc9a678482929c4fb9037103518d2943e5458b Mon Sep 17 00:00:00 2001 From: Phil Kuang Date: Mon, 12 Sep 2022 15:59:44 -0400 Subject: [PATCH 063/192] fix(scaffolder): properly set isDryRun flag in dry-run mode Signed-off-by: Phil Kuang --- .changeset/spicy-lizards-pay.md | 5 ++ .../tasks/NunjucksWorkflowRunner.test.ts | 31 ++++++++++ .../tasks/NunjucksWorkflowRunner.ts | 61 ++++++++++++++----- 3 files changed, 81 insertions(+), 16 deletions(-) create mode 100644 .changeset/spicy-lizards-pay.md diff --git a/.changeset/spicy-lizards-pay.md b/.changeset/spicy-lizards-pay.md new file mode 100644 index 0000000000..285875560c --- /dev/null +++ b/.changeset/spicy-lizards-pay.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Properly set `ctx.isDryRun` when running actions in dry run mode. Also always log action inputs for debugging purposes when running in dry run mode. diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts index 8745b7afdf..24a35078e2 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts @@ -56,9 +56,11 @@ describe('DefaultWorkflowRunner', () => { const createMockTaskWithSpec = ( spec: TaskSpec, secrets?: TaskSecrets, + isDryRun?: boolean, ): TaskContext => ({ spec, secrets, + isDryRun, complete: async () => {}, done: false, emitLog: async () => {}, @@ -85,6 +87,7 @@ describe('DefaultWorkflowRunner', () => { actionRegistry.register({ id: 'jest-validated-action', description: 'Mock action for testing', + supportsDryRun: true, handler: fakeActionHandler, schema: { input: { @@ -640,4 +643,32 @@ describe('DefaultWorkflowRunner', () => { }); }); }); + + describe('dry run', () => { + it('sets isDryRun flag correctly', async () => { + const task = createMockTaskWithSpec( + { + apiVersion: 'scaffolder.backstage.io/v1beta3', + parameters: {}, + output: {}, + steps: [ + { + id: 'test', + name: 'name', + action: 'jest-validated-action', + input: { foo: 1 }, + }, + ], + }, + { + backstageToken: 'secret', + }, + true, + ); + + await runner.execute(task); + + expect(fakeActionHandler.mock.calls[0][0].isDryRun).toEqual(true); + }); + }); }); diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 6e35f7c69b..009ea1357d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -236,25 +236,53 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { const action = this.options.actionRegistry.get(step.action); const { taskLogger, streamLogger } = createStepLogger({ task, step }); - if (task.isDryRun && !action.supportsDryRun) { - task.emitLog( - `Skipping because ${action.id} does not support dry-run`, - { - stepId: step.id, - status: 'skipped', - }, + if (task.isDryRun) { + const redactedSecrets = Object.fromEntries( + Object.entries(task.secrets ?? {}).map(secret => [ + secret[0], + '[REDACTED]', + ]), ); - const outputSchema = action.schema?.output; - if (outputSchema) { - context.steps[step.id] = { - output: generateExampleOutput(outputSchema) as { - [name in string]: JsonValue; + const debugInput = + (step.input && + this.render( + step.input, + { + ...context, + secrets: redactedSecrets, + }, + renderTemplate, + )) ?? + {}; + taskLogger.info( + `Running ${ + action.id + } in dry-run mode with inputs (secrets redacted): ${JSON.stringify( + debugInput, + undefined, + 2, + )}`, + ); + if (!action.supportsDryRun) { + task.emitLog( + `Skipping because ${action.id} does not support dry-run`, + { + stepId: step.id, + status: 'skipped', }, - }; - } else { - context.steps[step.id] = { output: {} }; + ); + const outputSchema = action.schema?.output; + if (outputSchema) { + context.steps[step.id] = { + output: generateExampleOutput(outputSchema) as { + [name in string]: JsonValue; + }, + }; + } else { + context.steps[step.id] = { output: {} }; + } + continue; } - continue; } // Secrets are only passed when templating the input to actions for security reasons @@ -301,6 +329,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { }, templateInfo: task.spec.templateInfo, user: task.spec.user, + isDryRun: task.isDryRun, }); // Remove all temporary directories that were created when executing the action From 8448b53dd6f5366ce27337e3a473b08b1d23b89d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 19 Sep 2022 15:21:10 +0200 Subject: [PATCH 064/192] move the settings storage to the user settings frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/healthy-oranges-fly.md | 7 ++-- .changeset/six-apricots-add.md | 7 ++++ .changeset/strange-geckos-know.md | 5 +++ packages/core-app-api/api-report.md | 24 -------------- packages/core-app-api/package.json | 1 - .../apis/implementations/StorageApi/index.ts | 1 - plugins/user-settings-backend/README.md | 2 +- plugins/user-settings/README.md | 12 ++++--- plugins/user-settings/api-report.md | 32 +++++++++++++++++++ plugins/user-settings/package.json | 7 ++-- .../StorageApi/UserSettingsStorage.test.ts | 0 .../apis}/StorageApi/UserSettingsStorage.ts | 2 +- .../src/apis/StorageApi/index.ts | 17 ++++++++++ plugins/user-settings/src/apis/index.ts | 17 ++++++++++ plugins/user-settings/src/index.ts | 3 +- yarn.lock | 4 ++- 16 files changed, 99 insertions(+), 42 deletions(-) create mode 100644 .changeset/six-apricots-add.md create mode 100644 .changeset/strange-geckos-know.md rename {packages/core-app-api/src/apis/implementations => plugins/user-settings/src/apis}/StorageApi/UserSettingsStorage.test.ts (100%) rename {packages/core-app-api/src/apis/implementations => plugins/user-settings/src/apis}/StorageApi/UserSettingsStorage.ts (99%) create mode 100644 plugins/user-settings/src/apis/StorageApi/index.ts create mode 100644 plugins/user-settings/src/apis/index.ts diff --git a/.changeset/healthy-oranges-fly.md b/.changeset/healthy-oranges-fly.md index c6b6f6ff93..a10c56f4d0 100644 --- a/.changeset/healthy-oranges-fly.md +++ b/.changeset/healthy-oranges-fly.md @@ -1,9 +1,6 @@ --- -'@backstage/core-app-api': minor '@backstage/plugin-user-settings-backend': minor --- -Add new plugin `@backstage/user-settings-backend` to store user related settings -in the database. Additionally adding a `UserSettingsStorage` implementation of -the `StorageApi` to easily use the new plugin as drop-in replacement for the -`WebStorage`. +Added new plugin `@backstage/plugin-user-settings-backend` to store user related +settings in the database. diff --git a/.changeset/six-apricots-add.md b/.changeset/six-apricots-add.md new file mode 100644 index 0000000000..95ec68b1d5 --- /dev/null +++ b/.changeset/six-apricots-add.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-user-settings': patch +--- + +Added a `UserSettingsStorage` implementation of the `StorageApi` for use as +drop-in replacement for the `WebStorage`, in conjunction with the newly created +`@backstage/plugin-user-settings-backend`. diff --git a/.changeset/strange-geckos-know.md b/.changeset/strange-geckos-know.md new file mode 100644 index 0000000000..b14762d3e5 --- /dev/null +++ b/.changeset/strange-geckos-know.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Clarify that the `WebStorage` observable returns `JsonValue` items. diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 39e5388d68..4d1db6fde1 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -543,30 +543,6 @@ export class UrlPatternDiscovery implements DiscoveryApi { getBaseUrl(pluginId: string): Promise; } -// @public -export class UserSettingsStorage implements StorageApi { - // (undocumented) - static create(options: { - fetchApi: FetchApi; - discoveryApi: DiscoveryApi; - errorApi: ErrorApi; - identityApi: IdentityApi; - namespace?: string; - }): UserSettingsStorage; - // (undocumented) - forBucket(name: string): StorageApi; - // (undocumented) - observe$( - key: string, - ): Observable>; - // (undocumented) - remove(key: string): Promise; - // (undocumented) - set(key: string, data: T): Promise; - // (undocumented) - snapshot(key: string): StorageValueSnapshot; -} - // @public export class WebStorage implements StorageApi { constructor(namespace: string, errorApi: ErrorApi); diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index b6e2f64889..8532997477 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -34,7 +34,6 @@ "dependencies": { "@backstage/config": "^1.0.2-next.0", "@backstage/core-plugin-api": "^1.0.6-next.3", - "@backstage/errors": "^1.1.1-next.0", "@backstage/types": "^1.0.0", "@backstage/version-bridge": "^1.0.1", "@types/prop-types": "^15.7.3", diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/index.ts b/packages/core-app-api/src/apis/implementations/StorageApi/index.ts index e4162d120d..2f941381fd 100644 --- a/packages/core-app-api/src/apis/implementations/StorageApi/index.ts +++ b/packages/core-app-api/src/apis/implementations/StorageApi/index.ts @@ -15,4 +15,3 @@ */ export { WebStorage } from './WebStorage'; -export { UserSettingsStorage } from './UserSettingsStorage'; diff --git a/plugins/user-settings-backend/README.md b/plugins/user-settings-backend/README.md index 45e06731c9..d12ee0aa89 100644 --- a/plugins/user-settings-backend/README.md +++ b/plugins/user-settings-backend/README.md @@ -57,7 +57,7 @@ To make use of the user settings backend, replace the `WebStorage` with the + identityApi, + storageApiRef, } from '@backstage/core-plugin-api'; -+import { UserSettingsStorage } from '@backstage/core-app-api'; ++import { UserSettingsStorage } from '@backstage/plugin-user-settings'; export const apis: AnyApiFactory[] = [ + createApiFactory({ diff --git a/plugins/user-settings/README.md b/plugins/user-settings/README.md index 13cfe720c8..abe66120fe 100644 --- a/plugins/user-settings/README.md +++ b/plugins/user-settings/README.md @@ -2,15 +2,17 @@ Welcome to the user-settings plugin! -_This plugin was created through the Backstage CLI_ - ## About the plugin -This plugin provides two components, `` is intended to be used within the [``](https://backstage.io/storybook/?path=/story/sidebar--sample-sidebar) and displays the signed-in users profile picture and name. +This plugin provides two components, `` is intended to be used within the [``](https://backstage.io/storybook/?path=/story/sidebar--sample-sidebar) and displays the signed-in users profile picture and name. The second component is a settings page where the user can control different settings across the App. -The second component is a settings page where the user can control different settings across the App. +It also provides a `UserSettingsStorage` implementation of the `StorageApi`, to +be used in the frontend as a persistent alternative to the builtin `WebStorage`. +Please see [the backend +README](https://github.com/backstage/backstage/tree/master/plugins/user-settings-backend) +for installation instructions. -## Usage +## Components Usage Add the item to the Sidebar: diff --git a/plugins/user-settings/api-report.md b/plugins/user-settings/api-report.md index bc5c3a9c70..76eaed940e 100644 --- a/plugins/user-settings/api-report.md +++ b/plugins/user-settings/api-report.md @@ -8,11 +8,19 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { BackstageUserIdentity } from '@backstage/core-plugin-api'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { ErrorApi } from '@backstage/core-plugin-api'; +import { FetchApi } from '@backstage/core-plugin-api'; import { IconComponent } from '@backstage/core-plugin-api'; +import { IdentityApi } from '@backstage/core-plugin-api'; +import { JsonValue } from '@backstage/types'; +import { Observable } from '@backstage/types'; import { ProfileInfo } from '@backstage/core-plugin-api'; import { PropsWithChildren } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; import { SessionApi } from '@backstage/core-plugin-api'; +import { StorageApi } from '@backstage/core-plugin-api'; +import { StorageValueSnapshot } from '@backstage/core-plugin-api'; // @public (undocumented) export const DefaultProviderSettings: (props: { @@ -83,6 +91,30 @@ export const UserSettingsSignInAvatar: (props: { size?: number; }) => JSX.Element; +// @public +export class UserSettingsStorage implements StorageApi { + // (undocumented) + static create(options: { + fetchApi: FetchApi; + discoveryApi: DiscoveryApi; + errorApi: ErrorApi; + identityApi: IdentityApi; + namespace?: string; + }): UserSettingsStorage; + // (undocumented) + forBucket(name: string): StorageApi; + // (undocumented) + observe$( + key: string, + ): Observable>; + // (undocumented) + remove(key: string): Promise; + // (undocumented) + set(key: string, data: T): Promise; + // (undocumented) + snapshot(key: string): StorageValueSnapshot; +} + // @public export const UserSettingsTab: (props: UserSettingsTabProps) => JSX.Element; diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index f99c0d206e..b4afc6766b 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -32,14 +32,18 @@ "clean": "backstage-cli package clean" }, "dependencies": { + "@backstage/core-app-api": "^1.1.0-next.3", "@backstage/core-components": "^0.11.1-next.3", "@backstage/core-plugin-api": "^1.0.6-next.3", + "@backstage/errors": "^1.1.1-next.0", "@backstage/theme": "^0.2.16", + "@backstage/types": "^1.0.0", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "@types/react": "^16.13.1 || ^17.0.0", - "react-use": "^17.2.4" + "react-use": "^17.2.4", + "zen-observable": "^0.8.15" }, "peerDependencies": { "react": "^16.13.1 || ^17.0.0", @@ -47,7 +51,6 @@ }, "devDependencies": { "@backstage/cli": "^0.19.0-next.3", - "@backstage/core-app-api": "^1.1.0-next.3", "@backstage/dev-utils": "^1.0.6-next.2", "@backstage/test-utils": "^1.2.0-next.3", "@testing-library/jest-dom": "^5.10.1", diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.test.ts b/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.test.ts similarity index 100% rename from packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.test.ts rename to plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.test.ts diff --git a/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.ts b/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.ts similarity index 99% rename from packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.ts rename to plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.ts index b595c524cf..8905b4aeea 100644 --- a/packages/core-app-api/src/apis/implementations/StorageApi/UserSettingsStorage.ts +++ b/plugins/user-settings/src/apis/StorageApi/UserSettingsStorage.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { WebStorage } from '@backstage/core-app-api'; import { DiscoveryApi, ErrorApi, @@ -25,7 +26,6 @@ import { import { ResponseError } from '@backstage/errors'; import { JsonValue, Observable } from '@backstage/types'; import ObservableImpl from 'zen-observable'; -import { WebStorage } from './WebStorage'; const JSON_HEADERS = { 'Content-Type': 'application/json; charset=utf-8', diff --git a/plugins/user-settings/src/apis/StorageApi/index.ts b/plugins/user-settings/src/apis/StorageApi/index.ts new file mode 100644 index 0000000000..9127cdfef4 --- /dev/null +++ b/plugins/user-settings/src/apis/StorageApi/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { UserSettingsStorage } from './UserSettingsStorage'; diff --git a/plugins/user-settings/src/apis/index.ts b/plugins/user-settings/src/apis/index.ts new file mode 100644 index 0000000000..1598430773 --- /dev/null +++ b/plugins/user-settings/src/apis/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './StorageApi'; diff --git a/plugins/user-settings/src/index.ts b/plugins/user-settings/src/index.ts index 0654b7fced..bc878ecbb3 100644 --- a/plugins/user-settings/src/index.ts +++ b/plugins/user-settings/src/index.ts @@ -15,11 +15,12 @@ */ /** - * A Backstage plugin that provides a settings page + * A Backstage plugin that provides various per-user settings functionality. * * @packageDocumentation */ +export * from './apis'; export { userSettingsPlugin, userSettingsPlugin as plugin, diff --git a/yarn.lock b/yarn.lock index a9f5a553b9..69e6cb4267 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3291,7 +3291,6 @@ __metadata: "@backstage/cli": ^0.19.0-next.3 "@backstage/config": ^1.0.2-next.0 "@backstage/core-plugin-api": ^1.0.6-next.3 - "@backstage/errors": ^1.1.1-next.0 "@backstage/test-utils": ^1.2.0-next.3 "@backstage/types": ^1.0.0 "@backstage/version-bridge": ^1.0.1 @@ -7342,8 +7341,10 @@ __metadata: "@backstage/core-components": ^0.11.1-next.3 "@backstage/core-plugin-api": ^1.0.6-next.3 "@backstage/dev-utils": ^1.0.6-next.2 + "@backstage/errors": ^1.1.1-next.0 "@backstage/test-utils": ^1.2.0-next.3 "@backstage/theme": ^0.2.16 + "@backstage/types": ^1.0.0 "@material-ui/core": ^4.12.2 "@material-ui/icons": ^4.9.1 "@material-ui/lab": 4.0.0-alpha.57 @@ -7355,6 +7356,7 @@ __metadata: cross-fetch: ^3.1.5 msw: ^0.47.0 react-use: ^17.2.4 + zen-observable: ^0.8.15 peerDependencies: react: ^16.13.1 || ^17.0.0 react-router: 6.0.0-beta.0 || ^6.3.0 From 471abf7585324ed7f97367ed2bac3faa621258a1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 19 Sep 2022 13:42:56 +0000 Subject: [PATCH 065/192] fix(deps): update dependency @yarnpkg/parsers to v3.0.0-rc.20 Signed-off-by: Renovate Bot --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 76250936ef..11b75e3583 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15933,12 +15933,12 @@ __metadata: linkType: hard "@yarnpkg/parsers@npm:^3.0.0-rc.4": - version: 3.0.0-rc.18 - resolution: "@yarnpkg/parsers@npm:3.0.0-rc.18" + version: 3.0.0-rc.20 + resolution: "@yarnpkg/parsers@npm:3.0.0-rc.20" dependencies: js-yaml: ^3.10.0 tslib: ^2.4.0 - checksum: 73cc59cb2349ce78a376d6ad7953117360de9aff866f42329a3e957ebabbbd4cbd91bfe79e30aa3bf07f126af66b72c00a7fc733724ac93d7f46d420ba81beec + checksum: a0dfc2d4d824322ae779363870ad7705b42161ad5e90c992404bf5dc0454a046a6f04144303c9bb047239e480b882b4105272127f2ba080b865180276c46deda languageName: node linkType: hard From 8117b58ae1b702086f046cd20762582b6ab6df62 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 19 Sep 2022 15:00:14 +0000 Subject: [PATCH 066/192] chore(deps): update dependency msw to v0.47.3 Signed-off-by: Renovate Bot --- yarn.lock | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/yarn.lock b/yarn.lock index e0bbd5f107..ac596f0b61 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11376,18 +11376,19 @@ __metadata: languageName: node linkType: hard -"@mswjs/interceptors@npm:^0.17.2": - version: 0.17.2 - resolution: "@mswjs/interceptors@npm:0.17.2" +"@mswjs/interceptors@npm:^0.17.5": + version: 0.17.5 + resolution: "@mswjs/interceptors@npm:0.17.5" dependencies: "@open-draft/until": ^1.0.3 + "@types/debug": ^4.1.7 "@xmldom/xmldom": ^0.7.5 debug: ^4.3.3 - headers-polyfill: ^3.0.4 + headers-polyfill: ^3.1.0 outvariant: ^1.2.1 strict-event-emitter: ^0.2.4 web-encoding: ^1.1.5 - checksum: aa40a0389b6d1dac67ac488cc989f73122e94c1e2c765c59b85223e4c659c516dc0f8cef38b15ff8915d4f29c49e73681580f9fd24b656b9dc7078e5690457da + checksum: 0293ccc56c1c85fb7334cd5902574f7df20c26be74d633c83fde64ffd7620f81e08253fe7985c6b5ad3b64c04ad53c3610e9b9c07621518aabd977343026bb2b languageName: node linkType: hard @@ -14034,7 +14035,7 @@ __metadata: languageName: node linkType: hard -"@types/debug@npm:^4.0.0": +"@types/debug@npm:^4.0.0, @types/debug@npm:^4.1.7": version: 4.1.7 resolution: "@types/debug@npm:4.1.7" dependencies: @@ -25032,6 +25033,13 @@ __metadata: languageName: node linkType: hard +"headers-polyfill@npm:^3.1.0": + version: 3.1.0 + resolution: "headers-polyfill@npm:3.1.0" + checksum: a95257065684653b7185efbb9a380c547ea832002991b5adf0d90cd222073da2701be9dc2849d1970ecf15e8c35b383984358566afe6e76ca8ff1dbd7cdce3af + languageName: node + linkType: hard + "helmet@npm:^6.0.0": version: 6.0.0 resolution: "helmet@npm:6.0.0" @@ -30766,11 +30774,11 @@ __metadata: linkType: hard "msw@npm:^0.47.0": - version: 0.47.0 - resolution: "msw@npm:0.47.0" + version: 0.47.3 + resolution: "msw@npm:0.47.3" dependencies: "@mswjs/cookies": ^0.2.2 - "@mswjs/interceptors": ^0.17.2 + "@mswjs/interceptors": ^0.17.5 "@open-draft/until": ^1.0.3 "@types/cookie": ^0.4.1 "@types/js-levenshtein": ^1.1.1 @@ -30778,7 +30786,7 @@ __metadata: chokidar: ^3.4.2 cookie: ^0.4.2 graphql: ^15.0.0 || ^16.0.0 - headers-polyfill: ^3.0.4 + headers-polyfill: ^3.1.0 inquirer: ^8.2.0 is-node-process: ^1.0.1 js-levenshtein: ^1.1.6 @@ -30796,7 +30804,7 @@ __metadata: optional: true bin: msw: cli/index.js - checksum: d48cd73327da0e44700595ebaaf0b8ee5f1b6c56c081d65ceed52dd8f49b64ce7a6734c84f8ab1093594e0c87b2554b6c35f7f92894c93148540dbfac826c441 + checksum: 1be018c7b2eff982409967cccb5c604e45f65710ee9698bab57fbe794f8426d1a4d33e52b75ef395c6d226948c799241c7c2c7748ec4f5b741e7f25bcbafbd1e languageName: node linkType: hard From 71b7464a7c041ce4bf0491dfc49e9433089c048f Mon Sep 17 00:00:00 2001 From: NishkarshRaj Date: Mon, 19 Sep 2022 20:48:40 +0530 Subject: [PATCH 067/192] chore: adding changesets to the PR Signed-off-by: NishkarshRaj --- .changeset/chatty-mangos-look.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/chatty-mangos-look.md diff --git a/.changeset/chatty-mangos-look.md b/.changeset/chatty-mangos-look.md new file mode 100644 index 0000000000..e692085f11 --- /dev/null +++ b/.changeset/chatty-mangos-look.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-cicd-statistics-module-gitlab': patch +--- + +Fixing typo in the Readme file: `'@backstage plugin-cicd-statistics-module-gitlab';` -> `'@backstage/plugin-cicd-statistics-module-gitlab';` From 9853a167b459e80a398d753c7299637653c5e783 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 19 Sep 2022 17:26:43 +0200 Subject: [PATCH 068/192] Update docker.md Signed-off-by: Patrik Oldsberg --- docs/deployment/docker.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md index 5455babc6f..d35e7dd593 100644 --- a/docs/deployment/docker.md +++ b/docs/deployment/docker.md @@ -16,8 +16,8 @@ are stateless, so for a production deployment you will want to set up and connect to an external PostgreSQL instance where the backend plugins can store their state, rather than using SQLite. -It is in this section assumed that an [app](https://backstage.io/docs/getting-started/create-an-app) -has already been created with`@backstage/create-app`, in which the frontend is +This section assumes that an [app](https://backstage.io/docs/getting-started/create-an-app) +has already been created with `@backstage/create-app`, in which the frontend is bundled and served from the backend. This is done using the `@backstage/plugin-app-backend` plugin, which also injects the frontend configuration into the app. This means that you only need to build and deploy a From 4d0c66c16fe10889ed2a8663bc20801189c83ae7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 19 Sep 2022 15:49:03 +0000 Subject: [PATCH 069/192] fix(deps): update dependency core-js to v3.25.2 Signed-off-by: Renovate Bot --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index ac596f0b61..4718a3e0e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19602,9 +19602,9 @@ __metadata: linkType: hard "core-js@npm:^3.6.5": - version: 3.25.1 - resolution: "core-js@npm:3.25.1" - checksum: bfacb078e790913841e2d5008b9b6705ae56ed23f83c7f0ae08d330d874561012611089d53b71520105234ed0abba36f28d91b391514a16541a8c8d98c464239 + version: 3.25.2 + resolution: "core-js@npm:3.25.2" + checksum: e93c6c645d44d98973efb07750975552ad405f080f5a563a99972ff6b2c5c6ee25705f55accd363f5dccd51e9e5f56be25e2be6c14a7294da65763e0e5659c02 languageName: node linkType: hard From 03fcff9a99811257286a124c52eba2b246420ec6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 19 Sep 2022 16:47:18 +0000 Subject: [PATCH 070/192] fix(deps): update dependency eslint to v8.23.1 Signed-off-by: Renovate Bot --- yarn.lock | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/yarn.lock b/yarn.lock index 4718a3e0e1..8c539bdbd7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8118,9 +8118,9 @@ __metadata: languageName: node linkType: hard -"@eslint/eslintrc@npm:^1.3.1": - version: 1.3.1 - resolution: "@eslint/eslintrc@npm:1.3.1" +"@eslint/eslintrc@npm:^1.3.2": + version: 1.3.2 + resolution: "@eslint/eslintrc@npm:1.3.2" dependencies: ajv: ^6.12.4 debug: ^4.3.2 @@ -8131,7 +8131,7 @@ __metadata: js-yaml: ^4.1.0 minimatch: ^3.1.2 strip-json-comments: ^3.1.1 - checksum: 9844dcc58a44399649926d5a17a2d53d529b80d3e8c3e9d0964ae198bac77ee6bb1cf44940f30cd9c2e300f7568ec82500be42ace6cacefb08aebf9905fe208e + checksum: 2074dca47d7e1c5c6323ff353f690f4b25d3ab53fe7d27337e2592d37a894cf60ca0e85ca66b50ff2db0bc7e630cc1e9c7347d65bb185b61416565584c38999c languageName: node linkType: hard @@ -22305,10 +22305,10 @@ __metadata: linkType: hard "eslint@npm:^8.6.0": - version: 8.23.0 - resolution: "eslint@npm:8.23.0" + version: 8.23.1 + resolution: "eslint@npm:8.23.1" dependencies: - "@eslint/eslintrc": ^1.3.1 + "@eslint/eslintrc": ^1.3.2 "@humanwhocodes/config-array": ^0.10.4 "@humanwhocodes/gitignore-to-minimatch": ^1.0.2 "@humanwhocodes/module-importer": ^1.0.1 @@ -22327,7 +22327,6 @@ __metadata: fast-deep-equal: ^3.1.3 file-entry-cache: ^6.0.1 find-up: ^5.0.0 - functional-red-black-tree: ^1.0.1 glob-parent: ^6.0.1 globals: ^13.15.0 globby: ^11.1.0 @@ -22336,6 +22335,7 @@ __metadata: import-fresh: ^3.0.0 imurmurhash: ^0.1.4 is-glob: ^4.0.0 + js-sdsl: ^4.1.4 js-yaml: ^4.1.0 json-stable-stringify-without-jsonify: ^1.0.1 levn: ^0.4.1 @@ -22349,7 +22349,7 @@ __metadata: text-table: ^0.2.0 bin: eslint: bin/eslint.js - checksum: ff6075daa28d817a7ac4508f31bc108a04d9ab5056608c8651b5bf9cfea5d708ca16dea6cdab2c3c0ae99b0bf0e726af8504eaa8e17c8e12e242cb68237ead64 + checksum: a727e15492786a03b438bcf021db49f715680679846a7b8d79b98ad34576f2a570404ffe882d3c3e26f6359bff7277ef11fae5614bfe8629adb653f20d018c71 languageName: node linkType: hard @@ -27583,6 +27583,13 @@ __metadata: languageName: node linkType: hard +"js-sdsl@npm:^4.1.4": + version: 4.1.4 + resolution: "js-sdsl@npm:4.1.4" + checksum: 1977cea4ab18e0e03e28bdf0371d8b443fad65ca0988e0faa216406faf6bb943714fe8f7cc7a5bfe5f35ba3d94ddae399f4d10200f547f2c3320688b0670d726 + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" From 72e610f0e49122e3ee5fcce2ad8d5da5669d9d39 Mon Sep 17 00:00:00 2001 From: Matthew Clarke Date: Mon, 19 Sep 2022 15:57:13 -0400 Subject: [PATCH 071/192] pr feedback Signed-off-by: Matthew Clarke --- plugins/kubernetes-common/api-report.md | 18 +++++++++++++++ plugins/kubernetes-common/src/types.ts | 13 +++++++++++ plugins/kubernetes/api-report.md | 18 +++++---------- .../src/api/KubernetesBackendClient.ts | 23 ++++++++----------- plugins/kubernetes/src/api/types.ts | 12 ++++------ 5 files changed, 51 insertions(+), 33 deletions(-) diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md index 9485bd0294..85015662c6 100644 --- a/plugins/kubernetes-common/api-report.md +++ b/plugins/kubernetes-common/api-report.md @@ -89,6 +89,16 @@ export interface CronJobsFetchResponse { type: 'cronjobs'; } +// @public (undocumented) +export interface CustomObjectsByEntityRequest { + // (undocumented) + auth: KubernetesRequestAuth; + // (undocumented) + customResources: CustomResourceMatcher[]; + // (undocumented) + entity: Entity; +} + // @public (undocumented) export interface CustomResourceFetchResponse { // (undocumented) @@ -243,4 +253,12 @@ export interface StatefulSetsFetchResponse { // (undocumented) type: 'statefulsets'; } + +// @public (undocumented) +export interface WorkloadsByEntityRequest { + // (undocumented) + auth: KubernetesRequestAuth; + // (undocumented) + entity: Entity; +} ``` diff --git a/plugins/kubernetes-common/src/types.ts b/plugins/kubernetes-common/src/types.ts index 07378ae688..149caadb15 100644 --- a/plugins/kubernetes-common/src/types.ts +++ b/plugins/kubernetes-common/src/types.ts @@ -46,6 +46,19 @@ export interface CustomResourceMatcher { plural: string; } +/** @public */ +export interface WorkloadsByEntityRequest { + auth: KubernetesRequestAuth; + entity: Entity; +} + +/** @public */ +export interface CustomObjectsByEntityRequest { + auth: KubernetesRequestAuth; + customResources: CustomResourceMatcher[]; + entity: Entity; +} + /** @public */ export interface KubernetesRequestBody { auth?: KubernetesRequestAuth; diff --git a/plugins/kubernetes/api-report.md b/plugins/kubernetes/api-report.md index 98ca4e4725..b7ec80aee3 100644 --- a/plugins/kubernetes/api-report.md +++ b/plugins/kubernetes/api-report.md @@ -10,12 +10,11 @@ import { BackstagePlugin } from '@backstage/core-plugin-api'; import { ClientPodStatus } from '@backstage/plugin-kubernetes-common'; import { ClusterAttributes } from '@backstage/plugin-kubernetes-common'; import { ClusterObjects } from '@backstage/plugin-kubernetes-common'; -import { CustomResourceMatcher } from '@backstage/plugin-kubernetes-common'; +import { CustomObjectsByEntityRequest } from '@backstage/plugin-kubernetes-common'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { IdentityApi } from '@backstage/core-plugin-api'; import type { JsonObject } from '@backstage/types'; -import { KubernetesRequestAuth } from '@backstage/plugin-kubernetes-common'; import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; import { OAuthApi } from '@backstage/core-plugin-api'; import { ObjectsByEntityResponse } from '@backstage/plugin-kubernetes-common'; @@ -33,6 +32,7 @@ import { V1Pod } from '@kubernetes/client-node'; import { V1ReplicaSet } from '@kubernetes/client-node'; import { V1Service } from '@kubernetes/client-node'; import { V1StatefulSet } from '@kubernetes/client-node'; +import { WorkloadsByEntityRequest } from '@backstage/plugin-kubernetes-common'; // Warning: (ae-forgotten-export) The symbol "ClusterProps" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "Cluster" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -248,9 +248,7 @@ export interface KubernetesApi { >; // (undocumented) getCustomObjectsByEntity( - auth: KubernetesRequestAuth, - customResources: CustomResourceMatcher[], - entity: Entity, + request: CustomObjectsByEntityRequest, ): Promise; // (undocumented) getObjectsByEntity( @@ -258,8 +256,7 @@ export interface KubernetesApi { ): Promise; // (undocumented) getWorkloadsByEntity( - auth: KubernetesRequestAuth, - entity: Entity, + request: WorkloadsByEntityRequest, ): Promise; } @@ -318,9 +315,7 @@ export class KubernetesBackendClient implements KubernetesApi { >; // (undocumented) getCustomObjectsByEntity( - auth: KubernetesRequestAuth, - customResources: CustomResourceMatcher[], - entity: Entity, + request: CustomObjectsByEntityRequest, ): Promise; // (undocumented) getObjectsByEntity( @@ -328,8 +323,7 @@ export class KubernetesBackendClient implements KubernetesApi { ): Promise; // (undocumented) getWorkloadsByEntity( - auth: KubernetesRequestAuth, - entity: Entity, + request: WorkloadsByEntityRequest, ): Promise; } diff --git a/plugins/kubernetes/src/api/KubernetesBackendClient.ts b/plugins/kubernetes/src/api/KubernetesBackendClient.ts index 04f4624b24..bffc966333 100644 --- a/plugins/kubernetes/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes/src/api/KubernetesBackendClient.ts @@ -16,13 +16,13 @@ import { KubernetesApi } from './types'; import { - KubernetesRequestAuth, KubernetesRequestBody, ObjectsByEntityResponse, - CustomResourceMatcher, + WorkloadsByEntityRequest, + CustomObjectsByEntityRequest, } from '@backstage/plugin-kubernetes-common'; import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; -import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; +import { stringifyEntityRef } from '@backstage/catalog-model'; export class KubernetesBackendClient implements KubernetesApi { private readonly discoveryApi: DiscoveryApi; @@ -79,24 +79,21 @@ export class KubernetesBackendClient implements KubernetesApi { } async getWorkloadsByEntity( - auth: KubernetesRequestAuth, - entity: Entity, + request: WorkloadsByEntityRequest, ): Promise { return await this.postRequired('/resources/workloads/query', { - auth, - entityRef: stringifyEntityRef(entity), + auth: request.auth, + entityRef: stringifyEntityRef(request.entity), }); } async getCustomObjectsByEntity( - auth: KubernetesRequestAuth, - customResources: CustomResourceMatcher[], - entity: Entity, + request: CustomObjectsByEntityRequest, ): Promise { return await this.postRequired(`/resources/custom/query`, { - entityRef: stringifyEntityRef(entity), - auth, - customResources, + entityRef: stringifyEntityRef(request.entity), + auth: request.auth, + customResources: request.customResources, }); } diff --git a/plugins/kubernetes/src/api/types.ts b/plugins/kubernetes/src/api/types.ts index b7e3f9fd49..0005a8d069 100644 --- a/plugins/kubernetes/src/api/types.ts +++ b/plugins/kubernetes/src/api/types.ts @@ -15,13 +15,12 @@ */ import { - KubernetesRequestAuth, KubernetesRequestBody, ObjectsByEntityResponse, - CustomResourceMatcher, + WorkloadsByEntityRequest, + CustomObjectsByEntityRequest, } from '@backstage/plugin-kubernetes-common'; import { createApiRef } from '@backstage/core-plugin-api'; -import { Entity } from '@backstage/catalog-model'; export const kubernetesApiRef = createApiRef({ id: 'plugin.kubernetes.service', @@ -39,12 +38,9 @@ export interface KubernetesApi { }[] >; getWorkloadsByEntity( - auth: KubernetesRequestAuth, - entity: Entity, + request: WorkloadsByEntityRequest, ): Promise; getCustomObjectsByEntity( - auth: KubernetesRequestAuth, - customResources: CustomResourceMatcher[], - entity: Entity, + request: CustomObjectsByEntityRequest, ): Promise; } From 7a89391d176828e295998b03c1751fe89562b21b Mon Sep 17 00:00:00 2001 From: Matthew Clarke Date: Mon, 19 Sep 2022 16:07:23 -0400 Subject: [PATCH 072/192] fix dev Signed-off-by: Matthew Clarke --- plugins/kubernetes/dev/index.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/plugins/kubernetes/dev/index.tsx b/plugins/kubernetes/dev/index.tsx index 3dd0557ae1..05fcdefc8a 100644 --- a/plugins/kubernetes/dev/index.tsx +++ b/plugins/kubernetes/dev/index.tsx @@ -24,10 +24,10 @@ import { KubernetesApi, } from '../src'; import { - CustomResourceMatcher, + CustomObjectsByEntityRequest, FetchResponse, - KubernetesRequestAuth, ObjectsByEntityResponse, + WorkloadsByEntityRequest, } from '@backstage/plugin-kubernetes-common'; import fixture1 from '../src/__fixtures__/1-deployments.json'; import fixture2 from '../src/__fixtures__/2-deployments.json'; @@ -62,15 +62,12 @@ class MockKubernetesClient implements KubernetesApi { ); } async getWorkloadsByEntity( - _auth: KubernetesRequestAuth, - _entity: Entity, + _request: WorkloadsByEntityRequest, ): Promise { throw new Error('Method not implemented.'); } async getCustomObjectsByEntity( - _auth: KubernetesRequestAuth, - _customResources: CustomResourceMatcher[], - _entity: Entity, + _request: CustomObjectsByEntityRequest, ): Promise { throw new Error('Method not implemented.'); } From a246d5a9b8f284be374907f3d2c0e872284611ab Mon Sep 17 00:00:00 2001 From: Niels Henrik Hagen Date: Mon, 19 Sep 2022 22:13:20 +0200 Subject: [PATCH 073/192] Read queryMode from the microsoftGraphOrg config Closes: backstage/backstage#13638 Signed-off-by: Niels Henrik Hagen --- .changeset/lemon-chefs-grab.md | 5 +++++ .../catalog-backend-module-msgraph/config.d.ts | 18 ++++++++++++++++++ .../src/microsoftGraph/config.test.ts | 2 ++ .../src/microsoftGraph/config.ts | 10 ++++++++++ 4 files changed, 35 insertions(+) create mode 100644 .changeset/lemon-chefs-grab.md diff --git a/.changeset/lemon-chefs-grab.md b/.changeset/lemon-chefs-grab.md new file mode 100644 index 0000000000..6c020b7863 --- /dev/null +++ b/.changeset/lemon-chefs-grab.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-msgraph': patch +--- + +Read `queryMode` from the `microsoftGraphOrg` config diff --git a/plugins/catalog-backend-module-msgraph/config.d.ts b/plugins/catalog-backend-module-msgraph/config.d.ts index df41b581f9..9fe1cb81c9 100644 --- a/plugins/catalog-backend-module-msgraph/config.d.ts +++ b/plugins/catalog-backend-module-msgraph/config.d.ts @@ -144,6 +144,15 @@ export interface Config { */ clientSecret?: string; + /** + * By default, the Microsoft Graph API only provides the basic feature set + * for querying. Certain features are limited to advanced query capabilities + * (see https://docs.microsoft.com/en-us/graph/aad-advanced-queries) + * and need to be enabled. + * + * Some features like `$expand` are not available for advanced queries, though. + */ + queryMode?: string; user?: { /** * The "expand" argument to apply to users. @@ -230,6 +239,15 @@ export interface Config { */ clientSecret: string; + /** + * By default, the Microsoft Graph API only provides the basic feature set + * for querying. Certain features are limited to advanced query capabilities + * (see https://docs.microsoft.com/en-us/graph/aad-advanced-queries) + * and need to be enabled. + * + * Some features like `$expand` are not available for advanced queries, though. + */ + queryMode?: string; user?: { /** * The filter to apply to extract users. diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts index 8e2866df65..3331e3609d 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts @@ -162,6 +162,7 @@ describe('readProviderConfigs', () => { clientId: 'clientId', clientSecret: 'clientSecret', authority: 'https://login.example.com/', + queryMode: 'advanced', user: { expand: 'manager', filter: 'accountEnabled eq true', @@ -185,6 +186,7 @@ describe('readProviderConfigs', () => { clientId: 'clientId', clientSecret: 'clientSecret', authority: 'https://login.example.com/', + queryMode: 'advanced', userExpand: 'manager', userFilter: 'accountEnabled eq true', groupExpand: 'member', diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts index 2429b67c89..b8565e0d72 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts @@ -261,6 +261,15 @@ export function readProviderConfig( const groupSearch = config.getOptionalString('group.search'); const groupSelect = config.getOptionalStringArray('group.select'); + const queryMode = config.getOptionalString('queryMode'); + if ( + queryMode !== undefined && + queryMode !== 'basic' && + queryMode !== 'advanced' + ) { + throw new Error(`queryMode must be one of: basic, advanced`); + } + const userGroupMemberFilter = config.getOptionalString( 'userGroupMember.filter', ); @@ -300,6 +309,7 @@ export function readProviderConfig( groupFilter, groupSearch, groupSelect, + queryMode, userGroupMemberFilter, userGroupMemberSearch, }; From 11a03929552837210712eedcc298018cba806537 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 19 Sep 2022 21:32:33 +0000 Subject: [PATCH 074/192] fix(deps): update dependency eslint-plugin-jest to v27.0.4 Signed-off-by: Renovate Bot --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 8c539bdbd7..460e28fa4a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22143,8 +22143,8 @@ __metadata: linkType: hard "eslint-plugin-jest@npm:^27.0.0": - version: 27.0.2 - resolution: "eslint-plugin-jest@npm:27.0.2" + version: 27.0.4 + resolution: "eslint-plugin-jest@npm:27.0.4" dependencies: "@typescript-eslint/utils": ^5.10.0 peerDependencies: @@ -22155,7 +22155,7 @@ __metadata: optional: true jest: optional: true - checksum: 0545568a1ddd14138f08015f5f1a99e08c0d5ef5d2f14a8e66c28c1b8296a541c1075e9660f637fcb71be01f2a709991937878fc96abc760c00efc768f4598c8 + checksum: 8408d8a53bae946527ac4120865c29b3468cf58d8e5ff3b9c75c5303bb5aa451ac7e04329fc004cf6302f84431e6c6c1f2ba9009b0150d1718df58ea490ed3f5 languageName: node linkType: hard From 743bfd516afa5aaee385ce9387fa447f16da75d0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 19 Sep 2022 22:13:27 +0000 Subject: [PATCH 075/192] fix(deps): update dependency jose to v4.9.3 Signed-off-by: Renovate Bot --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 460e28fa4a..20976f1797 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27528,9 +27528,9 @@ __metadata: linkType: hard "jose@npm:^4.6.0": - version: 4.9.2 - resolution: "jose@npm:4.9.2" - checksum: d3950385a6417d988c50bd8ba5407f5960624060aa8e4662c2109f1ebcc40c418e64b721a87065d8197b4aa0ddd7fb4dad5064618956dba8e74dc630916bf40f + version: 4.9.3 + resolution: "jose@npm:4.9.3" + checksum: 95865830768dcf82774d19e92dc854c5bc9dc5d9c9626a65a2974272e3aca5d2f56678611943f85802431d2d6d6f8bff9548b7cdb7578e6fe61529bd9c82e1d3 languageName: node linkType: hard From bd7c0edbfd8d0297543bfdd2dafa3f0b6e38cdb1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 19 Sep 2022 23:04:13 +0000 Subject: [PATCH 076/192] fix(deps): update dependency react-virtualized-auto-sizer to v1.0.7 Signed-off-by: Renovate Bot --- yarn.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index 20976f1797..10e0f5e4f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34928,12 +34928,12 @@ __metadata: linkType: hard "react-virtualized-auto-sizer@npm:^1.0.6": - version: 1.0.6 - resolution: "react-virtualized-auto-sizer@npm:1.0.6" + version: 1.0.7 + resolution: "react-virtualized-auto-sizer@npm:1.0.7" peerDependencies: - react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 - react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 - checksum: 81270e9d328e9b779515f42b6aa8dea2f21097f69fc776906d6b7d31ada03706d34408a6318baf59531f180402fbf74fdb9928ab65d53442a134b7ee5ba01265 + react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc + react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc + checksum: 7f2f013c422771828c6613c7890f792aa90a033ea2b41c489c67ca2c6793eefa94d25232c1050fdf69cdd626fad9e3821c5003d45fa6fc831184cd09232023c2 languageName: node linkType: hard From beb51e25395a90a08b951f8ce4191af4244e453e Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 20 Sep 2022 10:00:37 +0200 Subject: [PATCH 077/192] chore: exit pre-release Signed-off-by: blam --- .changeset/pre.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index 028a1a665f..5644249456 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -1,5 +1,5 @@ { - "mode": "pre", + "mode": "exit", "tag": "next", "initialVersions": { "example-app": "0.2.74", From 6ba51aae68834dfcaa4bf187d26871db7321694b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 20 Sep 2022 08:27:50 +0000 Subject: [PATCH 078/192] fix(deps): update dependency winston to v3.8.2 Signed-off-by: Renovate Bot --- yarn.lock | 108 +++++++++++++++++++++++++++--------------------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/yarn.lock b/yarn.lock index 5ff7d3e304..6d22f7a0cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2851,7 +2851,7 @@ __metadata: "@backstage/plugin-permission-node": ^0.6.5-next.3 express: ^4.17.1 express-promise-router: ^4.1.0 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -2930,7 +2930,7 @@ __metadata: supertest: ^6.1.3 tar: ^6.1.2 uuid: ^8.3.2 - winston: ^3.2.1 + winston: ^3.8.1 yauzl: ^2.10.0 yn: ^4.0.0 peerDependencies: @@ -2962,7 +2962,7 @@ __metadata: "@backstage/plugin-permission-common": ^0.6.4-next.2 "@types/express": ^4.17.6 express: ^4.17.1 - winston: ^3.2.1 + winston: ^3.8.1 winston-transport: ^4.5.0 languageName: unknown linkType: soft @@ -2986,7 +2986,7 @@ __metadata: node-abort-controller: ^3.0.1 uuid: ^8.0.0 wait-for-expect: ^3.0.2 - winston: ^3.2.1 + winston: ^3.8.1 zod: ^3.9.5 languageName: unknown linkType: soft @@ -3667,7 +3667,7 @@ __metadata: msw: ^0.47.0 node-fetch: ^2.6.5 supertest: ^6.1.3 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 languageName: unknown linkType: soft @@ -3736,7 +3736,7 @@ __metadata: http-proxy-middleware: ^2.0.0 msw: ^0.47.0 supertest: ^6.1.6 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 languageName: unknown linkType: soft @@ -3958,7 +3958,7 @@ __metadata: mock-fs: ^5.1.0 msw: ^0.47.0 supertest: ^6.1.3 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 languageName: unknown linkType: soft @@ -4022,7 +4022,7 @@ __metadata: passport-saml: ^3.1.2 supertest: ^6.1.3 uuid: ^8.0.0 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 languageName: unknown linkType: soft @@ -4043,7 +4043,7 @@ __metadata: msw: ^0.47.0 node-fetch: ^2.6.7 uuid: ^8.0.0 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -4064,7 +4064,7 @@ __metadata: msw: ^0.47.0 p-limit: ^3.1.0 supertest: ^6.1.6 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 languageName: unknown linkType: soft @@ -4127,7 +4127,7 @@ __metadata: express: ^4.17.1 express-promise-router: ^4.1.0 supertest: ^6.1.3 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 languageName: unknown linkType: soft @@ -4174,7 +4174,7 @@ __metadata: express: ^4.17.1 express-promise-router: ^4.1.0 knex: ^2.0.0 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 languageName: unknown linkType: soft @@ -4272,7 +4272,7 @@ __metadata: lodash: ^4.17.21 p-limit: ^3.0.2 uuid: ^8.0.0 - winston: ^3.2.1 + winston: ^3.8.1 yaml: ^2.0.0 languageName: unknown linkType: soft @@ -4296,7 +4296,7 @@ __metadata: msw: ^0.47.0 node-fetch: ^2.6.7 uuid: ^8.0.0 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -4314,7 +4314,7 @@ __metadata: "@backstage/plugin-catalog-backend": ^1.4.0-next.3 msw: ^0.47.0 uuid: ^8.0.0 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -4335,7 +4335,7 @@ __metadata: msw: ^0.47.0 node-fetch: ^2.6.7 uuid: ^8.0.0 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -4357,7 +4357,7 @@ __metadata: lodash: ^4.17.21 msw: ^0.47.0 node-fetch: ^2.6.7 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -4379,7 +4379,7 @@ __metadata: msw: ^0.47.0 node-fetch: ^2.6.7 uuid: ^8.0.0 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -4405,7 +4405,7 @@ __metadata: msw: ^0.47.0 node-fetch: ^2.6.7 uuid: ^8.0.0 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -4429,7 +4429,7 @@ __metadata: msw: ^0.47.0 node-fetch: ^2.6.7 uuid: ^8.0.0 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -4449,7 +4449,7 @@ __metadata: ldapjs: ^2.2.0 lodash: ^4.17.21 uuid: ^8.0.0 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -4474,7 +4474,7 @@ __metadata: p-limit: ^3.0.2 qs: ^6.9.4 uuid: ^8.0.0 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -4493,7 +4493,7 @@ __metadata: "@backstage/plugin-catalog-node": ^1.1.0-next.2 "@backstage/types": ^1.0.0 openapi-types: ^12.0.0 - winston: ^3.2.1 + winston: ^3.8.1 yaml: ^2.1.1 languageName: unknown linkType: soft @@ -4544,7 +4544,7 @@ __metadata: supertest: ^6.1.3 uuid: ^8.0.0 wait-for-expect: ^3.0.2 - winston: ^3.2.1 + winston: ^3.8.1 yaml: ^2.0.0 yn: ^4.0.0 zod: ^3.11.6 @@ -4627,7 +4627,7 @@ __metadata: graphql-type-json: ^0.3.2 msw: ^0.47.0 node-fetch: ^2.6.7 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -4966,7 +4966,7 @@ __metadata: msw: ^0.47.0 supertest: ^6.1.6 uuid: ^8.3.2 - winston: ^3.2.1 + winston: ^3.8.1 xml2js: ^0.4.23 yn: ^4.0.0 languageName: unknown @@ -5604,7 +5604,7 @@ __metadata: msw: ^0.47.0 reflect-metadata: ^0.1.13 supertest: ^6.1.3 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 languageName: unknown linkType: soft @@ -5721,7 +5721,7 @@ __metadata: msw: ^0.47.0 promise-any-polyfill: ^1.0.1 supertest: ^6.1.6 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 languageName: unknown linkType: soft @@ -5788,7 +5788,7 @@ __metadata: kafkajs: ^2.0.0 lodash: ^4.17.21 supertest: ^6.1.3 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -5856,7 +5856,7 @@ __metadata: morgan: ^1.10.0 stream-buffers: ^3.0.2 supertest: ^6.1.3 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 languageName: unknown linkType: soft @@ -6071,7 +6071,7 @@ __metadata: msw: ^0.47.0 node-fetch: ^2.6.7 supertest: ^6.1.6 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 languageName: unknown linkType: soft @@ -6129,7 +6129,7 @@ __metadata: msw: ^0.47.0 node-fetch: ^2.6.7 supertest: ^6.1.6 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 zod: ^3.11.6 languageName: unknown @@ -6246,7 +6246,7 @@ __metadata: node-fetch: ^2.6.7 supertest: ^6.1.3 uuid: ^8.2.0 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 languageName: unknown linkType: soft @@ -6320,7 +6320,7 @@ __metadata: msw: ^0.47.0 supertest: ^6.1.3 uuid: ^8.0.0 - winston: ^3.2.1 + winston: ^3.8.1 yaml: ^2.0.0 yn: ^4.0.0 yup: ^0.32.9 @@ -6348,7 +6348,7 @@ __metadata: msw: ^0.47.0 node-fetch: ^2.6.7 supertest: ^6.1.3 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 languageName: unknown linkType: soft @@ -6405,7 +6405,7 @@ __metadata: fs-extra: 10.1.0 mock-fs: ^5.1.0 msw: ^0.47.0 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 languageName: unknown linkType: soft @@ -6441,7 +6441,7 @@ __metadata: "@backstage/config": ^1.0.2-next.0 "@backstage/plugin-scaffolder-backend": ^1.6.0-next.3 "@backstage/types": ^1.0.0 - winston: ^3.2.1 + winston: ^3.8.1 yeoman-environment: ^3.9.1 languageName: unknown linkType: soft @@ -6503,7 +6503,7 @@ __metadata: supertest: ^6.1.3 uuid: ^8.2.0 vm2: ^3.9.11 - winston: ^3.2.1 + winston: ^3.8.1 yaml: ^2.0.0 zen-observable: ^0.8.15 zod: ^3.11.6 @@ -6602,7 +6602,7 @@ __metadata: elastic-builder: ^2.16.0 lodash: ^4.17.21 uuid: ^8.3.2 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -6640,7 +6640,7 @@ __metadata: ndjson: ^2.0.0 node-abort-controller: ^3.0.1 uuid: ^8.3.2 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -6666,7 +6666,7 @@ __metadata: lodash: ^4.17.21 qs: ^6.10.1 supertest: ^6.1.3 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 zod: ^3.11.6 languageName: unknown @@ -6919,7 +6919,7 @@ __metadata: "@backstage/plugin-search-common": ^1.0.1-next.0 node-fetch: ^2.6.7 qs: ^6.9.4 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -6992,7 +6992,7 @@ __metadata: json-rules-engine: ^6.1.2 lodash: ^4.17.21 luxon: ^3.0.0 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -7023,7 +7023,7 @@ __metadata: supertest: ^6.1.3 uuid: ^8.3.2 wait-for-expect: ^3.0.2 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 languageName: unknown linkType: soft @@ -7051,7 +7051,7 @@ __metadata: "@backstage/types": ^1.0.0 "@types/luxon": ^3.0.0 luxon: ^3.0.0 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -7184,7 +7184,7 @@ __metadata: node-fetch: ^2.6.7 p-limit: ^3.1.0 supertest: ^6.1.3 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -7254,7 +7254,7 @@ __metadata: p-limit: ^3.1.0 recursive-readdir: ^2.2.2 supertest: ^6.1.3 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -7355,7 +7355,7 @@ __metadata: leasot: ^12.0.0 msw: ^0.47.0 supertest: ^6.1.3 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 languageName: unknown linkType: soft @@ -7407,7 +7407,7 @@ __metadata: express-promise-router: ^4.1.0 knex: ^2.0.0 supertest: ^6.1.3 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 languageName: unknown linkType: soft @@ -7465,7 +7465,7 @@ __metadata: node-fetch: ^2.6.7 p-limit: ^3.1.0 supertest: ^6.1.6 - winston: ^3.7.2 + winston: ^3.8.1 yn: ^5.0.0 languageName: unknown linkType: soft @@ -9260,7 +9260,7 @@ __metadata: msw: ^0.47.0 supertest: ^6.1.6 uuid: ^8.3.2 - winston: ^3.2.1 + winston: ^3.8.1 yn: ^4.0.0 languageName: unknown linkType: soft @@ -13449,7 +13449,7 @@ __metadata: serve-handler: ^6.1.3 techdocs-cli-embedded-app: "link:../techdocs-cli-embedded-app" ts-node: ^10.0.0 - winston: ^3.2.1 + winston: ^3.8.1 bin: techdocs-cli: bin/techdocs-cli languageName: unknown @@ -22723,7 +22723,7 @@ __metadata: pg: ^8.3.0 pg-connection-string: ^2.3.0 prom-client: ^14.0.1 - winston: ^3.2.1 + winston: ^3.8.1 languageName: unknown linkType: soft @@ -40591,7 +40591,7 @@ __metadata: languageName: node linkType: hard -"winston@npm:^3.2.1, winston@npm:^3.7.2, winston@npm:^3.8.1": +"winston@npm:^3.8.1": version: 3.8.1 resolution: "winston@npm:3.8.1" dependencies: From d325409a71dba9a1ed84f324a6b1c7b1ecda1153 Mon Sep 17 00:00:00 2001 From: Tim Jacomb <21194782+timja@users.noreply.github.com> Date: Tue, 20 Sep 2022 09:56:58 +0100 Subject: [PATCH 079/192] Re-order dockerfile in example backend slightly Signed-off-by: Tim Jacomb <21194782+timja@users.noreply.github.com> --- packages/backend/Dockerfile | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile index 21ff07deb4..990b4abc84 100644 --- a/packages/backend/Dockerfile +++ b/packages/backend/Dockerfile @@ -19,10 +19,8 @@ RUN apt-get update && \ yarn config set python /usr/bin/python3 # From here on we use the least-privileged `node` user to run the backend. -WORKDIR /app -RUN chown node:node /app USER node - +WORKDIR /app # This switches many Node.js dependencies to production mode. ENV NODE_ENV production From ff28494249e2748b1e27acf4020584ad71d6c543 Mon Sep 17 00:00:00 2001 From: Paul Cowan Date: Tue, 9 Aug 2022 19:54:57 +0100 Subject: [PATCH 080/192] allow a custom ObjectFieldTemplate to be supplied through the ui:ObjectFieldTemplate field Signed-off-by: Paul Cowan --- .../MultistepJsonForm/MultistepJsonForm.tsx | 3 + plugins/scaffolder/src/components/Router.tsx | 25 +++++- .../TemplateEditorPage/TemplateEditor.tsx | 3 + .../TemplateEditorPage/TemplateEditorForm.tsx | 9 ++- .../TemplateEditorPage/TemplateEditorPage.tsx | 4 + .../TemplateFormPreviewer.tsx | 4 + .../components/TemplatePage/TemplatePage.tsx | 4 + .../DefaultStepFormLayout.tsx | 79 +++++++++++++++++++ .../layouts/DefaultStepFormLayout/index.tsx | 16 ++++ plugins/scaffolder/src/layouts/default.ts | 21 +++++ plugins/scaffolder/src/layouts/index.tsx | 46 +++++++++++ plugins/scaffolder/src/layouts/types.ts | 22 ++++++ .../src/next/Router/Router.test.tsx | 45 +++++++++++ plugins/scaffolder/src/next/Router/Router.tsx | 23 +++++- .../Stepper/Stepper.test.tsx | 8 +- .../TemplateWizardPage/Stepper/Stepper.tsx | 2 + .../Stepper/useTemplateSchema.test.tsx | 2 + .../TemplateWizardPage/TemplateWizardPage.tsx | 3 + 18 files changed, 315 insertions(+), 4 deletions(-) create mode 100644 plugins/scaffolder/src/components/layouts/DefaultStepFormLayout/DefaultStepFormLayout.tsx create mode 100644 plugins/scaffolder/src/components/layouts/DefaultStepFormLayout/index.tsx create mode 100644 plugins/scaffolder/src/layouts/default.ts create mode 100644 plugins/scaffolder/src/layouts/index.tsx create mode 100644 plugins/scaffolder/src/layouts/types.ts diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index 43e12ae85f..97c10c34ec 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -55,6 +55,7 @@ type Props = { widgets?: FormProps['widgets']; fields?: FormProps['fields']; finishButtonLabel?: string; + layout?: FormProps['ObjectFieldTemplate']; }; export function getUiSchemasFromSteps(steps: Step[]): UiSchema[] { @@ -119,6 +120,7 @@ export const MultistepJsonForm = (props: Props) => { fields, widgets, finishButtonLabel, + layout, } = props; const [activeStep, setActiveStep] = useState(0); const [disableButtons, setDisableButtons] = useState(false); @@ -203,6 +205,7 @@ export const MultistepJsonForm = (props: Props) => {
{ ), ), ]; + + const customLayouts = useElementFilter(outlet, elements => + elements + .selectByComponentData({ + key: LAYOUTS_WRAPPER_KEY, + }) + .findComponentData({ + key: LAYOUTS_KEY, + }), + ); + + const layout = customLayouts?.[0] ?? DEFAULT_SCAFFOLDER_LAYOUT; + /** * This component can be deleted once the older routes have been deprecated. */ @@ -142,7 +161,10 @@ export const Router = (props: RouterProps) => { path={selectedTemplateRouteRef.path} element={ - + } /> @@ -159,6 +181,7 @@ export const Router = (props: RouterProps) => { } diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx index cc4dd8c9a1..68ad355b61 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditor.tsx @@ -16,6 +16,7 @@ import { makeStyles } from '@material-ui/core'; import React, { useState } from 'react'; import { FieldExtensionOptions } from '../../extensions'; +import { LayoutOptions } from '../../layouts'; import { TemplateDirectoryAccess } from '../../lib/filesystem'; import { DirectoryEditorProvider } from './DirectoryEditorContext'; import { DryRunProvider } from './DryRunContext'; @@ -57,6 +58,7 @@ const useStyles = makeStyles({ export const TemplateEditor = (props: { directory: TemplateDirectoryAccess; fieldExtensions?: FieldExtensionOptions[]; + layout?: LayoutOptions; onClose?: () => void; }) => { const classes = useStyles(); @@ -77,6 +79,7 @@ export const TemplateEditor = (props: {
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx index d802ab07dc..8b9fea6b30 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx @@ -20,6 +20,7 @@ import React, { Component, ReactNode, useMemo, useState } from 'react'; import useDebounce from 'react-use/lib/useDebounce'; import yaml from 'yaml'; import { FieldExtensionOptions } from '../../extensions'; +import { DEFAULT_SCAFFOLDER_LAYOUT, LayoutOptions } from '../../layouts'; import { TemplateParameterSchema } from '../../types'; import { MultistepJsonForm } from '../MultistepJsonForm'; import { createValidator } from '../TemplatePage'; @@ -83,6 +84,7 @@ interface TemplateEditorFormProps { onDryRun?: (data: JsonObject) => Promise; fieldExtensions?: FieldExtensionOptions[]; + layout?: LayoutOptions; } function isJsonObject(value: JsonValue | undefined): value is JsonObject { @@ -99,6 +101,7 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) { onDryRun, setErrorText, fieldExtensions = [], + layout = DEFAULT_SCAFFOLDER_LAYOUT, } = props; const classes = useStyles(); const apiHolder = useApiHolder(); @@ -188,6 +191,7 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) { onReset={() => onUpdate({})} finishButtonLabel={onDryRun && 'Try It'} onFinish={onDryRun && (() => onDryRun(data))} + layout={layout.component} /> @@ -197,7 +201,10 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) { /** A version of the TemplateEditorForm that is connected to the DirectoryEditor and DryRun contexts */ export function TemplateEditorFormDirectoryEditorDryRun( - props: Pick, + props: Pick< + TemplateEditorFormProps, + 'setErrorText' | 'fieldExtensions' | 'layout' + >, ) { const { setErrorText, fieldExtensions = [] } = props; const dryRun = useDryRun(); diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx index fce2881190..2995678248 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx @@ -23,6 +23,7 @@ import { TemplateEditorIntro } from './TemplateEditorIntro'; import { TemplateEditor } from './TemplateEditor'; import { TemplateFormPreviewer } from './TemplateFormPreviewer'; import { FieldExtensionOptions } from '../../extensions'; +import { LayoutOptions } from '../../layouts'; type Selection = | { @@ -36,6 +37,7 @@ type Selection = interface TemplateEditorPageProps { defaultPreviewTemplate?: string; customFieldExtensions?: FieldExtensionOptions[]; + layout?: LayoutOptions; } export function TemplateEditorPage(props: TemplateEditorPageProps) { @@ -48,6 +50,7 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) { directory={selection.directory} fieldExtensions={props.customFieldExtensions} onClose={() => setSelection(undefined)} + layout={props.layout} /> ); } else if (selection?.type === 'form') { @@ -56,6 +59,7 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) { defaultPreviewTemplate={props.defaultPreviewTemplate} customFieldExtensions={props.customFieldExtensions} onClose={() => setSelection(undefined)} + layout={props.layout} /> ); } else { diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx index bfa7071d48..1e3758ebba 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx @@ -33,6 +33,7 @@ import React, { useCallback, useState } from 'react'; import useAsync from 'react-use/lib/useAsync'; import yaml from 'yaml'; import { FieldExtensionOptions } from '../../extensions'; +import { DEFAULT_SCAFFOLDER_LAYOUT, LayoutOptions } from '../../layouts'; import { TemplateEditorForm } from './TemplateEditorForm'; import { TemplateEditorTextArea } from './TemplateEditorTextArea'; @@ -110,10 +111,12 @@ export const TemplateFormPreviewer = ({ defaultPreviewTemplate = EXAMPLE_TEMPLATE_PARAMS_YAML, customFieldExtensions = [], onClose, + layout = DEFAULT_SCAFFOLDER_LAYOUT, }: { defaultPreviewTemplate?: string; customFieldExtensions?: FieldExtensionOptions[]; onClose?: () => void; + layout?: LayoutOptions; }) => { const classes = useStyles(); const alertApi = useApi(alertApiRef); @@ -208,6 +211,7 @@ export const TemplateFormPreviewer = ({ data={formState} onUpdate={setFormState} setErrorText={setErrorText} + layout={layout} /> diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 03ce57136f..2d2ab3989e 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -39,6 +39,7 @@ import { useRouteRefParams, } from '@backstage/core-plugin-api'; import { stringifyEntityRef } from '@backstage/catalog-model'; +import { DEFAULT_SCAFFOLDER_LAYOUT, LayoutOptions } from '../../layouts'; const useTemplateParameterSchema = (templateRef: string) => { const scaffolderApi = useApi(scaffolderApiRef); @@ -51,8 +52,10 @@ const useTemplateParameterSchema = (templateRef: string) => { export const TemplatePage = ({ customFieldExtensions = [], + layout = DEFAULT_SCAFFOLDER_LAYOUT, }: { customFieldExtensions?: FieldExtensionOptions[]; + layout?: LayoutOptions; }) => { const apiHolder = useApiHolder(); const secretsContext = useContext(SecretsContext); @@ -146,6 +149,7 @@ export const TemplatePage = ({ onChange={handleChange} onReset={handleFormReset} onFinish={handleCreate} + layout={layout.component} steps={schema.steps.map(step => { return { ...step, diff --git a/plugins/scaffolder/src/components/layouts/DefaultStepFormLayout/DefaultStepFormLayout.tsx b/plugins/scaffolder/src/components/layouts/DefaultStepFormLayout/DefaultStepFormLayout.tsx new file mode 100644 index 0000000000..871f568383 --- /dev/null +++ b/plugins/scaffolder/src/components/layouts/DefaultStepFormLayout/DefaultStepFormLayout.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { ObjectFieldTemplateProps, utils } from '@rjsf/core'; +import { Button, Grid } from '@material-ui/core'; + +const { canExpand } = utils; + +export const DefaultStepFormLayout = ({ + DescriptionField, + description, + TitleField, + title, + properties, + required, + disabled, + readonly, + uiSchema, + idSchema, + schema, + formData, + onAddClick, +}: ObjectFieldTemplateProps) => { + return ( + <> +

THIS IS OUR OBJECTFIELDTEMPLATE!!!!!!

+ {(uiSchema['ui:title'] || title) && ( + + )} + {description && ( + + )} + + {properties.map((element, index) => + // Remove the if the inner element is hidden as the + // itself would otherwise still take up space. + element.hidden ? ( + element.content + ) : ( + + {element.content} + + ), + )} + {canExpand(schema, uiSchema, formData) && ( + + +
diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx index 8b9fea6b30..4395f06206 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorForm.tsx @@ -84,7 +84,7 @@ interface TemplateEditorFormProps { onDryRun?: (data: JsonObject) => Promise; fieldExtensions?: FieldExtensionOptions[]; - layout?: LayoutOptions; + layouts?: LayoutOptions[]; } function isJsonObject(value: JsonValue | undefined): value is JsonObject { @@ -101,7 +101,7 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) { onDryRun, setErrorText, fieldExtensions = [], - layout = DEFAULT_SCAFFOLDER_LAYOUT, + layouts = [DEFAULT_SCAFFOLDER_LAYOUT], } = props; const classes = useStyles(); const apiHolder = useApiHolder(); @@ -141,6 +141,7 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) { } const { parameters } = rootObj; + if (!Array.isArray(parameters)) { setErrorText('Template parameters must be an array'); setSteps(undefined); @@ -191,7 +192,7 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) { onReset={() => onUpdate({})} finishButtonLabel={onDryRun && 'Try It'} onFinish={onDryRun && (() => onDryRun(data))} - layout={layout.component} + layouts={layouts} /> @@ -203,7 +204,7 @@ export function TemplateEditorForm(props: TemplateEditorFormProps) { export function TemplateEditorFormDirectoryEditorDryRun( props: Pick< TemplateEditorFormProps, - 'setErrorText' | 'fieldExtensions' | 'layout' + 'setErrorText' | 'fieldExtensions' | 'layouts' >, ) { const { setErrorText, fieldExtensions = [] } = props; diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx index 2995678248..b539b6403b 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateEditorPage.tsx @@ -23,7 +23,7 @@ import { TemplateEditorIntro } from './TemplateEditorIntro'; import { TemplateEditor } from './TemplateEditor'; import { TemplateFormPreviewer } from './TemplateFormPreviewer'; import { FieldExtensionOptions } from '../../extensions'; -import { LayoutOptions } from '../../layouts'; +import type { LayoutOptions } from '../../layouts'; type Selection = | { @@ -37,7 +37,7 @@ type Selection = interface TemplateEditorPageProps { defaultPreviewTemplate?: string; customFieldExtensions?: FieldExtensionOptions[]; - layout?: LayoutOptions; + layouts?: LayoutOptions[]; } export function TemplateEditorPage(props: TemplateEditorPageProps) { @@ -50,7 +50,7 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) { directory={selection.directory} fieldExtensions={props.customFieldExtensions} onClose={() => setSelection(undefined)} - layout={props.layout} + layouts={props.layouts} /> ); } else if (selection?.type === 'form') { @@ -59,7 +59,7 @@ export function TemplateEditorPage(props: TemplateEditorPageProps) { defaultPreviewTemplate={props.defaultPreviewTemplate} customFieldExtensions={props.customFieldExtensions} onClose={() => setSelection(undefined)} - layout={props.layout} + layouts={props.layouts} /> ); } else { diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx index 1e3758ebba..1cdc211865 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/TemplateFormPreviewer.tsx @@ -111,12 +111,12 @@ export const TemplateFormPreviewer = ({ defaultPreviewTemplate = EXAMPLE_TEMPLATE_PARAMS_YAML, customFieldExtensions = [], onClose, - layout = DEFAULT_SCAFFOLDER_LAYOUT, + layouts = [DEFAULT_SCAFFOLDER_LAYOUT], }: { defaultPreviewTemplate?: string; customFieldExtensions?: FieldExtensionOptions[]; onClose?: () => void; - layout?: LayoutOptions; + layouts?: LayoutOptions[]; }) => { const classes = useStyles(); const alertApi = useApi(alertApiRef); @@ -211,7 +211,7 @@ export const TemplateFormPreviewer = ({ data={formState} onUpdate={setFormState} setErrorText={setErrorText} - layout={layout} + layouts={layouts} /> diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 2d2ab3989e..4cf3cf1841 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -52,10 +52,10 @@ const useTemplateParameterSchema = (templateRef: string) => { export const TemplatePage = ({ customFieldExtensions = [], - layout = DEFAULT_SCAFFOLDER_LAYOUT, + layouts = [DEFAULT_SCAFFOLDER_LAYOUT], }: { customFieldExtensions?: FieldExtensionOptions[]; - layout?: LayoutOptions; + layouts?: LayoutOptions[]; }) => { const apiHolder = useApiHolder(); const secretsContext = useContext(SecretsContext); @@ -149,7 +149,7 @@ export const TemplatePage = ({ onChange={handleChange} onReset={handleFormReset} onFinish={handleCreate} - layout={layout.component} + layouts={layouts} steps={schema.steps.map(step => { return { ...step, diff --git a/plugins/scaffolder/src/components/layouts/DefaultStepFormLayout/DefaultStepFormLayout.tsx b/plugins/scaffolder/src/components/layouts/DefaultStepFormLayout/DefaultStepFormLayout.tsx index 871f568383..b7711e59fb 100644 --- a/plugins/scaffolder/src/components/layouts/DefaultStepFormLayout/DefaultStepFormLayout.tsx +++ b/plugins/scaffolder/src/components/layouts/DefaultStepFormLayout/DefaultStepFormLayout.tsx @@ -36,7 +36,6 @@ export const DefaultStepFormLayout = ({ }: ObjectFieldTemplateProps) => { return ( <> -

THIS IS OUR OBJECTFIELDTEMPLATE!!!!!!

{(uiSchema['ui:title'] || title) && ( = () => null; + +export function createScaffolderLayout< + TFieldReturnValue = unknown, + TInputProps = unknown, +>( options: LayoutOptions, -): Extension> { +): Extension> { return { expose() { const LayoutDataHolder: any = () => null; @@ -41,6 +44,6 @@ export const ScaffolderLayouts: React.ComponentType = (): JSX.Element | null => attachComponentData(ScaffolderLayouts, LAYOUTS_WRAPPER_KEY, true); -export type { LayoutOptions } from './types'; +export type { LayoutOptions, ObjectFieldTemplate } from './types'; export { DEFAULT_SCAFFOLDER_LAYOUT } from './default'; diff --git a/plugins/scaffolder/src/layouts/types.ts b/plugins/scaffolder/src/layouts/types.ts index 66106bda21..fb0e766a43 100644 --- a/plugins/scaffolder/src/layouts/types.ts +++ b/plugins/scaffolder/src/layouts/types.ts @@ -13,10 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ObjectFieldTemplateProps } from '@rjsf/core'; -import type { FunctionComponent } from 'react'; +import type { FormProps } from '@rjsf/core'; -export type LayoutOptions = { +export type ObjectFieldTemplate = Required< + FormProps +>['ObjectFieldTemplate']; + +export interface LayoutOptions { name: string; - component: FunctionComponent; -}; + component: ObjectFieldTemplate; +} diff --git a/plugins/scaffolder/src/next/Router/Router.test.tsx b/plugins/scaffolder/src/next/Router/Router.test.tsx index 5a47127885..d1c627de51 100644 --- a/plugins/scaffolder/src/next/Router/Router.test.tsx +++ b/plugins/scaffolder/src/next/Router/Router.test.tsx @@ -102,19 +102,17 @@ describe('Router', () => { it('should extract the custom layout and pass it through', async () => { const mockLayout = () => null; - const CustomLayout = scaffolderPlugin.provide( + const Customlayout = scaffolderPlugin.provide( createScaffolderLayout({ - name: 'customLayout', + name: 'CustomLayout', component: mockLayout, }), ); - const props = {} as ObjectFieldTemplateProps; - await renderInTestApp( - + , { routeEntries: ['/templates/default/foo'] }, @@ -124,7 +122,7 @@ describe('Router', () => { // eslint-disable-next-line no-console const [{ layout }] = mock.mock.calls[0]; - expect(layout).toEqual({ name: 'customLayout', component: mockLayout }); + expect(layout).toEqual({ name: 'CustomLayout', component: mockLayout }); }); }); }); diff --git a/plugins/scaffolder/src/next/Router/Router.tsx b/plugins/scaffolder/src/next/Router/Router.tsx index 8a64c9fde9..5934206564 100644 --- a/plugins/scaffolder/src/next/Router/Router.tsx +++ b/plugins/scaffolder/src/next/Router/Router.tsx @@ -91,7 +91,13 @@ export const Router = (props: PropsWithChildren) => { }), ); - const layout = customLayouts?.[0] ?? DEFAULT_SCAFFOLDER_LAYOUT; + if ( + !customLayouts.find( + layout => layout.name === DEFAULT_SCAFFOLDER_LAYOUT.name, + ) + ) { + customLayouts.push(DEFAULT_SCAFFOLDER_LAYOUT); + } return ( @@ -111,7 +117,7 @@ export const Router = (props: PropsWithChildren) => { } diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx index c87bc2ed0a..1802f8bea2 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx @@ -26,7 +26,6 @@ import { FieldValidation, withTheme } from '@rjsf/core'; import { Theme as MuiTheme } from '@rjsf/material-ui'; import React, { useMemo, useState } from 'react'; import { FieldExtensionOptions } from '../../../extensions'; -import type { LayoutOptions } from '../../../layouts'; import { TemplateParameterSchema } from '../../../types'; import { createAsyncValidators } from './createAsyncValidators'; import { useTemplateSchema } from './useTemplateSchema'; diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx index 2c403f4999..e29c903e25 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx +++ b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx @@ -41,7 +41,7 @@ import type { LayoutOptions } from '../../layouts'; export interface TemplateWizardPageProps { customFieldExtensions: FieldExtensionOptions[]; - layout?: LayoutOptions; + layouts: LayoutOptions[]; } const useStyles = makeStyles(() => ({ @@ -115,7 +115,7 @@ export const TemplateWizardPage = (props: TemplateWizardPageProps) => { )} From 7fdb1fe283fa55db5ca5589d5f4977b42445a268 Mon Sep 17 00:00:00 2001 From: Paul Cowan Date: Wed, 10 Aug 2022 20:16:50 +0100 Subject: [PATCH 082/192] extract ObjectFieldTemplate from uiSchema Signed-off-by: Paul Cowan --- package.json | 5 ++- packages/app/src/App.tsx | 8 +++-- .../scaffolder/customScaffolderLayouts.tsx | 27 ++++++++++++++- .../scaffolder/defaultPreviewTemplate.ts | 1 + .../MultistepJsonForm/MultistepJsonForm.tsx | 33 ++++++++++--------- 5 files changed, 55 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index a0de99bfad..da8f193d3a 100644 --- a/package.json +++ b/package.json @@ -93,5 +93,8 @@ "node ./scripts/check-docs-quality" ] }, - "packageManager": "yarn@3.2.3" + "packageManager": "yarn@3.2.3", + "volta": { + "node": "14.19.3" + } } diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 9969a0fcdc..7e49ad5087 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -105,7 +105,10 @@ import { ApacheAirflowPage } from '@backstage/plugin-apache-airflow'; import { RequirePermission } from '@backstage/plugin-permission-react'; import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common'; import { PlaylistIndexPage } from '@backstage/plugin-playlist'; -import { Customlayout } from './components/scaffolder/customScaffolderLayouts'; +import { + AnotherCustomlayout, + CustomLayout, +} from './components/scaffolder/customScaffolderLayouts'; const app = createApp({ apis, @@ -222,7 +225,8 @@ const routes = ( - + + diff --git a/packages/app/src/components/scaffolder/customScaffolderLayouts.tsx b/packages/app/src/components/scaffolder/customScaffolderLayouts.tsx index 0fd4bed2d0..065a1ca780 100644 --- a/packages/app/src/components/scaffolder/customScaffolderLayouts.tsx +++ b/packages/app/src/components/scaffolder/customScaffolderLayouts.tsx @@ -35,9 +35,34 @@ const ALayout: ObjectFieldTemplate = ({ properties, description }) => { ); }; -export const Customlayout = scaffolderPlugin.provide( +const AnotherCustomLayout: ObjectFieldTemplate = ({ + properties, + description, +}) => { + // eslint-disable-next-line no-console + return ( +
+

ANOTHER CUSTOM LAYOUT!!!!!

+
+ {properties.map(prop => ( +
{prop.content}
+ ))} +
+ {description} +
+ ); +}; + +export const CustomLayout = scaffolderPlugin.provide( createScaffolderLayout({ name: 'CustomLayout', component: ALayout, }), ); + +export const AnotherCustomlayout = scaffolderPlugin.provide( + createScaffolderLayout({ + name: 'AnotherCustomLayout', + component: AnotherCustomLayout, + }), +); diff --git a/packages/app/src/components/scaffolder/defaultPreviewTemplate.ts b/packages/app/src/components/scaffolder/defaultPreviewTemplate.ts index db0725591c..53805265ed 100644 --- a/packages/app/src/components/scaffolder/defaultPreviewTemplate.ts +++ b/packages/app/src/components/scaffolder/defaultPreviewTemplate.ts @@ -34,6 +34,7 @@ parameters: allowedKinds: - Group - title: Choose a location + ui:ObjectFieldTemplate: 'AnotherCustomLayout' required: - repoUrl properties: diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index c4384c2d04..c2050fe703 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -155,20 +155,6 @@ export const MultistepJsonForm = (props: Props) => { : filteredStep.schema.required; } - const layoutName = - filteredStep.schema['ui:ObjectFieldTemplate'] ?? - DEFAULT_SCAFFOLDER_LAYOUT.name; - - const LayoutComponent = layouts.find( - layout => layout.name === layoutName, - )?.component; - - if (!LayoutComponent) { - throw new Error(`no step layout found for ${layoutName}`); - } - - filteredStep.schema['ui:ObjectFieldTemplate'] = LayoutComponent as any; - return filteredStep; }; @@ -208,6 +194,22 @@ export const MultistepJsonForm = (props: Props) => { <> {steps.map(({ title, schema, ...formProps }, index) => { + const schemaProps = transformSchemaToProps(schema); + + const layoutName = + schemaProps.uiSchema?.['ui:ObjectFieldTemplate'] ?? + DEFAULT_SCAFFOLDER_LAYOUT.name; + + delete schemaProps.uiSchema?.['ui:ObjectFieldTemplate']; + + const LayoutComponent = layouts.find( + layout => layout.name === layoutName, + )?.component; + + if (!LayoutComponent) { + throw new Error(`no step layout found for ${layoutName}`); + } + return ( { { if (e.errors.length === 0) handleNext(); }} {...formProps} - {...transformSchemaToProps(schema)} + {...schemaProps} > ++ {!loadingPermission && ( ++ ++ )} + + + ); + } + +... +``` + +Here we are using the [`usePermission` hook](https://backstage.io/docs/reference/plugin-permission-react.usepermission) to communicate with the permission policy and receive a decision on whether this user is authorized to create a todo list item. + +It's really that simple! Let's change our policy to test the disabled button: + +```diff +// packages/backend/src/plugins/permission.ts + +... + + if (isPermission(request.permission, todoListCreatePermission)) { + return { +- result: AuthorizeResult.ALLOW, ++ result: AuthorizeResult.DENY, + }; + } + +... +``` + +And now you should see that you are not able to create a todo item from the frontend! + +## Using `RequirePermission` + +Providing a disabled state can be a helpful signal to users, but there may be cases where hiding the element is preferred. For such cases, you can use the provided [`RequirePermission` component](https://backstage.io/docs/reference/plugin-permission-react.requirepermission): + +```diff +// plugins/todo-list/src/components/TodoListPage/TodoListPage.tsx + +... + + import { + alertApiRef, + discoveryApiRef, + fetchApiRef, + useApi, + } from '@backstage/core-plugin-api'; +- import { usePermission } from '@backstage/plugin-permission-react'; ++ import { RequirePermission } from '@backstage/plugin-permission-react'; + import { todoListCreatePermission } from '@internal/plugin-todo-list-common'; + +... + + export const TodoListPage = () => { + +... + + +- +- +- ++ ++ ++ ++ ++ + + + + + +... + + + function AddTodo({ onAdd }: { onAdd: (title: string) => any }) { + const title = useRef(''); +- const { loading: loadingPermission, allowed: canAddTodo } = usePermission({ permission: todoListCreatePermission }); + + return ( + <> + Add todo + + (title.current = e.target.value)} + /> +- {!loadingPermission && ( +- +- )} ++ + + + ); + } + +... +``` + +Now you should find that the component for adding a todo list item does not render at all. Success! diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 8e77594376..41e54ccb29 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -295,7 +295,8 @@ "permissions/plugin-authors/01-setup", "permissions/plugin-authors/02-adding-a-basic-permission-check", "permissions/plugin-authors/03-adding-a-resource-permission-check", - "permissions/plugin-authors/04-authorizing-access-to-paginated-data" + "permissions/plugin-authors/04-authorizing-access-to-paginated-data", + "permissions/plugin-authors/05-frontend-authorization" ] } ], diff --git a/plugins/example-todo-list-common/api-report.md b/plugins/example-todo-list-common/api-report.md index 15ffb7b139..1e51e560c8 100644 --- a/plugins/example-todo-list-common/api-report.md +++ b/plugins/example-todo-list-common/api-report.md @@ -8,5 +8,8 @@ import { BasicPermission } from '@backstage/plugin-permission-common'; // @public export const tempExamplePermission: BasicPermission; +// @public +export const todoListPermissions: BasicPermission[]; + // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/example-todo-list-common/src/permissions.ts b/plugins/example-todo-list-common/src/permissions.ts index 439bf5e8b9..ea0b097376 100644 --- a/plugins/example-todo-list-common/src/permissions.ts +++ b/plugins/example-todo-list-common/src/permissions.ts @@ -25,3 +25,10 @@ export const tempExamplePermission = createPermission({ name: 'temp.example.noop', attributes: {}, }); + +/** + * List of all todo list permissions. + * + * @public + */ +export const todoListPermissions = [tempExamplePermission]; diff --git a/plugins/example-todo-list/src/components/TodoListPage/TodoListPage.tsx b/plugins/example-todo-list/src/components/TodoListPage/TodoListPage.tsx index 7ebb517532..6071502fdb 100644 --- a/plugins/example-todo-list/src/components/TodoListPage/TodoListPage.tsx +++ b/plugins/example-todo-list/src/components/TodoListPage/TodoListPage.tsx @@ -45,17 +45,16 @@ export const TodoListPage = () => { const discoveryApi = useApi(discoveryApiRef); const { fetch } = useApi(fetchApiRef); const alertApi = useApi(alertApiRef); - const title = useRef(''); const [key, refetchTodos] = useReducer(i => i + 1, 0); const [editElement, setEdit] = useState(); - const handleAdd = async () => { + const handleAdd = async (title: string) => { try { const response = await fetch( `${await discoveryApi.getBaseUrl('todolist')}/todos`, { method: 'POST', - body: JSON.stringify({ title: title.current }), + body: JSON.stringify({ title }), headers: { 'Content-Type': 'application/json', }, @@ -117,21 +116,7 @@ export const TodoListPage = () => { - Add todo - - (title.current = e.target.value)} - /> - - + @@ -149,6 +134,30 @@ export const TodoListPage = () => { ); }; +function AddTodo({ onAdd }: { onAdd: (title: string) => any }) { + const title = useRef(''); + + return ( + <> + Add todo + + (title.current = e.target.value)} + /> + + + + ); +} + function EditModal({ todo, onCancel, From ed0ef64fd36385e2111940b71548b2dbdd7afef5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 22 Sep 2022 15:18:59 +0200 Subject: [PATCH 185/192] backend-tests: increase timeout for setting up test databases Signed-off-by: Patrik Oldsberg --- .../backend-tasks/src/tasks/PluginTaskSchedulerImpl.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.test.ts b/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.test.ts index f6177b57f7..d61948a929 100644 --- a/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.test.ts +++ b/packages/backend-tasks/src/tasks/PluginTaskSchedulerImpl.test.ts @@ -45,7 +45,7 @@ describe('PluginTaskManagerImpl', () => { ); jest.useFakeTimers(); - }, 30_000); + }, 60_000); async function init(databaseId: TestDatabaseId) { const knex = await databases.init(databaseId); From a91bd8e268c40ae94d5b1fd37165ecb51d74f988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 22 Sep 2022 17:03:20 +0200 Subject: [PATCH 186/192] nerf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/swift-phones-cheat.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/swift-phones-cheat.md b/.changeset/swift-phones-cheat.md index 97697ad916..6d1653caa3 100644 --- a/.changeset/swift-phones-cheat.md +++ b/.changeset/swift-phones-cheat.md @@ -1,5 +1,5 @@ --- -'@backstage/cli': minor +'@backstage/cli': patch --- Removed `tsx` and `jsx` as supported extensions in backend packages. For most From 7506c95e94e94ba8b34e6ef4144cafc59fc15258 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Fri, 23 Sep 2022 13:05:55 +0100 Subject: [PATCH 187/192] docs: fix incorrect import in frontend permission integration docs (#13816) Signed-off-by: MT Lewis Signed-off-by: MT Lewis --- docs/permissions/frontend-integration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/permissions/frontend-integration.md b/docs/permissions/frontend-integration.md index 2a429ea120..d7302f9083 100644 --- a/docs/permissions/frontend-integration.md +++ b/docs/permissions/frontend-integration.md @@ -17,7 +17,7 @@ If your Backstage permission policy may return a `DENY` for users requesting the ... -+ import { PermissionedRoute } from '@backstage/plugin-permission-react'; ++ import { RequirePermission } from '@backstage/plugin-permission-react'; + import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common'; ... From 74022e01639e58e9612ff3abf3cb8b5e2d610195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 23 Sep 2022 14:00:00 +0200 Subject: [PATCH 188/192] stitch relations after deleting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/thick-kings-destroy.md | 5 ++ .../src/service/CatalogBuilder.ts | 7 +- .../service/DefaultEntitiesCatalog.test.ts | 64 ++++++++++++++----- .../src/service/DefaultEntitiesCatalog.ts | 34 +++++++++- 4 files changed, 90 insertions(+), 20 deletions(-) create mode 100644 .changeset/thick-kings-destroy.md diff --git a/.changeset/thick-kings-destroy.md b/.changeset/thick-kings-destroy.md new file mode 100644 index 0000000000..77c6be0d3c --- /dev/null +++ b/.changeset/thick-kings-destroy.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Make sure to stitch entities correctly after deletion, to ensure that their relations are updated. diff --git a/plugins/catalog-backend/src/service/CatalogBuilder.ts b/plugins/catalog-backend/src/service/CatalogBuilder.ts index 25f9fde313..d47ace1454 100644 --- a/plugins/catalog-backend/src/service/CatalogBuilder.ts +++ b/plugins/catalog-backend/src/service/CatalogBuilder.ts @@ -409,7 +409,11 @@ export class CatalogBuilder { parser, policy, }); - const unauthorizedEntitiesCatalog = new DefaultEntitiesCatalog(dbClient); + const stitcher = new Stitcher(dbClient, logger); + const unauthorizedEntitiesCatalog = new DefaultEntitiesCatalog( + dbClient, + stitcher, + ); let permissionEvaluator: PermissionEvaluator; if ('authorizeConditional' in permissions) { @@ -453,7 +457,6 @@ export class CatalogBuilder { permissions: catalogPermissions, rules: this.permissionRules, }); - const stitcher = new Stitcher(dbClient, logger); const locationStore = new DefaultLocationStore(dbClient); const configLocationProvider = new ConfigLocationEntityProvider(config); diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts index 43e208197f..49d4fe9e19 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.test.ts @@ -25,12 +25,15 @@ import { DbRefreshStateRow, DbSearchRow, } from '../database/tables'; +import { Stitcher } from '../stitching/Stitcher'; import { DefaultEntitiesCatalog } from './DefaultEntitiesCatalog'; describe('DefaultEntitiesCatalog', () => { const databases = TestDatabases.create({ ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'], }); + const stitch = jest.fn(); + const stitcher: Stitcher = { stitch } as any; async function createDatabase(databaseId: TestDatabaseId) { const knex = await databases.init(databaseId); @@ -122,6 +125,10 @@ describe('DefaultEntitiesCatalog', () => { ); } + afterEach(() => { + jest.resetAllMocks(); + }); + describe('entityAncestry', () => { it.each(databases.eachSupportedId())( 'should return the ancestry with one parent, %p', @@ -151,7 +158,7 @@ describe('DefaultEntitiesCatalog', () => { await addEntity(knex, parent, [{ entity: grandparent }]); await addEntity(knex, root, [{ entity: parent }]); - const catalog = new DefaultEntitiesCatalog(knex); + const catalog = new DefaultEntitiesCatalog(knex, stitcher); const result = await catalog.entityAncestry('k:default/root'); expect(result.rootEntityRef).toEqual('k:default/root'); @@ -181,7 +188,7 @@ describe('DefaultEntitiesCatalog', () => { 'should throw error if the entity does not exist, %p', async databaseId => { const { knex } = await createDatabase(databaseId); - const catalog = new DefaultEntitiesCatalog(knex); + const catalog = new DefaultEntitiesCatalog(knex, stitcher); await expect(() => catalog.entityAncestry('k:default/root'), ).rejects.toThrow('No such entity k:default/root'); @@ -224,7 +231,7 @@ describe('DefaultEntitiesCatalog', () => { await addEntity(knex, parent2, [{ entity: grandparent }]); await addEntity(knex, root, [{ entity: parent1 }, { entity: parent2 }]); - const catalog = new DefaultEntitiesCatalog(knex); + const catalog = new DefaultEntitiesCatalog(knex, stitcher); const result = await catalog.entityAncestry('k:default/root'); expect(result.rootEntityRef).toEqual('k:default/root'); @@ -280,7 +287,7 @@ describe('DefaultEntitiesCatalog', () => { }; await addEntityToSearch(knex, entity1); await addEntityToSearch(knex, entity2); - const catalog = new DefaultEntitiesCatalog(knex); + const catalog = new DefaultEntitiesCatalog(knex, stitcher); const testFilter = { key: 'spec.test', @@ -313,7 +320,7 @@ describe('DefaultEntitiesCatalog', () => { }; await addEntityToSearch(knex, entity1); await addEntityToSearch(knex, entity2); - const catalog = new DefaultEntitiesCatalog(knex); + const catalog = new DefaultEntitiesCatalog(knex, stitcher); const testFilter = { not: { @@ -360,7 +367,7 @@ describe('DefaultEntitiesCatalog', () => { await addEntityToSearch(knex, entity2); await addEntityToSearch(knex, entity3); await addEntityToSearch(knex, entity4); - const catalog = new DefaultEntitiesCatalog(knex); + const catalog = new DefaultEntitiesCatalog(knex, stitcher); const testFilter1 = { key: 'metadata.org', @@ -415,7 +422,7 @@ describe('DefaultEntitiesCatalog', () => { }; await addEntityToSearch(knex, entity1); await addEntityToSearch(knex, entity2); - const catalog = new DefaultEntitiesCatalog(knex); + const catalog = new DefaultEntitiesCatalog(knex, stitcher); const testFilter1 = { key: 'metadata.org', @@ -457,7 +464,7 @@ describe('DefaultEntitiesCatalog', () => { }; await addEntityToSearch(knex, entity1); await addEntityToSearch(knex, entity2); - const catalog = new DefaultEntitiesCatalog(knex); + const catalog = new DefaultEntitiesCatalog(knex, stitcher); const testFilter = { key: 'kind', @@ -501,7 +508,7 @@ describe('DefaultEntitiesCatalog', () => { }, [], ); - const catalog = new DefaultEntitiesCatalog(knex); + const catalog = new DefaultEntitiesCatalog(knex, stitcher); const { entities } = await catalog.entities(); @@ -557,10 +564,16 @@ describe('DefaultEntitiesCatalog', () => { metadata: { name: 'root' }, spec: {}, }; - const unrelated: Entity = { + const unrelated1: Entity = { apiVersion: 'a', kind: 'k', - metadata: { name: 'unrelated' }, + metadata: { name: 'unrelated1' }, + spec: {}, + }; + const unrelated2: Entity = { + apiVersion: 'a', + kind: 'k', + metadata: { name: 'unrelated2' }, spec: {}, }; @@ -571,10 +584,23 @@ describe('DefaultEntitiesCatalog', () => { { entity: parent1 }, { entity: parent2 }, ]); - await addEntity(knex, unrelated, []); + await addEntity(knex, unrelated1, []); + await addEntity(knex, unrelated2, []); await knex('refresh_state').update({ result_hash: 'not-changed' }); + await knex('relations').insert({ + originating_entity_id: uid, + type: 't', + source_entity_ref: 'k:default/root', + target_entity_ref: 'k:default/unrelated1', + }); + await knex('relations').insert({ + originating_entity_id: uid, + type: 't', + source_entity_ref: 'k:default/unrelated2', + target_entity_ref: 'k:default/root', + }); - const catalog = new DefaultEntitiesCatalog(knex); + const catalog = new DefaultEntitiesCatalog(knex, stitcher); await catalog.removeEntityByUid(uid); await expect( @@ -586,8 +612,12 @@ describe('DefaultEntitiesCatalog', () => { { entity_ref: 'k:default/grandparent', result_hash: 'not-changed' }, { entity_ref: 'k:default/parent1', result_hash: 'child-was-deleted' }, { entity_ref: 'k:default/parent2', result_hash: 'child-was-deleted' }, - { entity_ref: 'k:default/unrelated', result_hash: 'not-changed' }, + { entity_ref: 'k:default/unrelated1', result_hash: 'not-changed' }, + { entity_ref: 'k:default/unrelated2', result_hash: 'not-changed' }, ]); + expect(stitch).toHaveBeenCalledWith( + new Set(['k:default/unrelated1', 'k:default/unrelated2']), + ); }, ); }); @@ -616,7 +646,7 @@ describe('DefaultEntitiesCatalog', () => { metadata: { name: 'two' }, spec: {}, }); - const catalog = new DefaultEntitiesCatalog(knex); + const catalog = new DefaultEntitiesCatalog(knex, stitcher); await expect(catalog.facets({ facets: ['kind'] })).resolves.toEqual({ facets: { @@ -654,7 +684,7 @@ describe('DefaultEntitiesCatalog', () => { }, spec: {}, }); - const catalog = new DefaultEntitiesCatalog(knex); + const catalog = new DefaultEntitiesCatalog(knex, stitcher); await expect( catalog.facets({ @@ -698,7 +728,7 @@ describe('DefaultEntitiesCatalog', () => { }, spec: {}, }); - const catalog = new DefaultEntitiesCatalog(knex); + const catalog = new DefaultEntitiesCatalog(knex, stitcher); await expect( catalog.facets({ diff --git a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts index fa11903447..dc84b12484 100644 --- a/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/service/DefaultEntitiesCatalog.ts @@ -38,8 +38,10 @@ import { DbPageInfo, DbRefreshStateReferencesRow, DbRefreshStateRow, + DbRelationsRow, DbSearchRow, } from '../database/tables'; +import { Stitcher } from '../stitching/Stitcher'; function parsePagination(input?: EntityPagination): { limit?: number; @@ -158,7 +160,10 @@ function parseFilter( } export class DefaultEntitiesCatalog implements EntitiesCatalog { - constructor(private readonly database: Knex) {} + constructor( + private readonly database: Knex, + private readonly stitcher: Stitcher, + ) {} async entities(request?: EntitiesRequest): Promise { const db = this.database; @@ -244,6 +249,7 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { await this.database('refresh_state') .update({ result_hash: 'child-was-deleted', + next_update_at: this.database.fn.now(), }) .whereIn('entity_ref', function parents(builder) { return builder @@ -256,9 +262,35 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog { .select('refresh_state_references.source_entity_ref'); }); + // Stitch the entities that the deleted one had relations to. If we do not + // do this, the entities in the other end of the relations will still look + // like they have a relation to the entity that was deleted, despite not + // having any corresponding rows in the relations table. + const relationPeers = await this.database + .from('relations') + .innerJoin('refresh_state', { + 'refresh_state.entity_ref': 'relations.target_entity_ref', + }) + .where('relations.originating_entity_id', '=', uid) + .andWhere('refresh_state.entity_id', '!=', uid) + .select({ ref: 'relations.target_entity_ref' }) + .union(other => + other + .from('relations') + .innerJoin('refresh_state', { + 'refresh_state.entity_ref': 'relations.source_entity_ref', + }) + .where('relations.originating_entity_id', '=', uid) + .andWhere('refresh_state.entity_id', '!=', uid) + .select({ ref: 'relations.source_entity_ref' }), + ); + + // Perform the actual deletion await this.database('refresh_state') .where('entity_id', uid) .delete(); + + await this.stitcher.stitch(new Set(relationPeers.map(p => p.ref))); } async entityAncestry(rootRef: string): Promise { From 6103298d0589893700cb3a9e4c94ae94432358c3 Mon Sep 17 00:00:00 2001 From: Brian Fletcher Date: Fri, 23 Sep 2022 15:22:19 +0100 Subject: [PATCH 189/192] Fix command to create a backend plugin Signed-off-by: Brian Fletcher --- docs/plugins/backend-plugin.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/backend-plugin.md b/docs/plugins/backend-plugin.md index c6f4a50e60..24213c57a9 100644 --- a/docs/plugins/backend-plugin.md +++ b/docs/plugins/backend-plugin.md @@ -13,7 +13,7 @@ A new, bare-bones backend plugin package can be created by issuing the following command in your Backstage repository root: ```sh -yarn create-plugin --backend +yarn new --select backend-plugin ``` Please also see the `--help` flag for the `create-plugin` command for some From 81b063782404917be2df7113ea6b9add10d092c8 Mon Sep 17 00:00:00 2001 From: Brian Fletcher Date: Fri, 23 Sep 2022 15:24:39 +0100 Subject: [PATCH 190/192] Update backend-plugin.md Signed-off-by: Brian Fletcher --- docs/plugins/backend-plugin.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins/backend-plugin.md b/docs/plugins/backend-plugin.md index 24213c57a9..3eddb9e1c2 100644 --- a/docs/plugins/backend-plugin.md +++ b/docs/plugins/backend-plugin.md @@ -16,7 +16,7 @@ command in your Backstage repository root: yarn new --select backend-plugin ``` -Please also see the `--help` flag for the `create-plugin` command for some +Please also see the `--help` flag for the `new` command for some further options that are available, notably the `--scope` and `--no-private` flags that control naming and publishing of the newly created package. Your repo root `package.json` will probably also have some default values already set up From c73e2780429991ba316a13bf18f839463db9e41d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 24 Sep 2022 11:19:12 +0200 Subject: [PATCH 191/192] Update .changeset/quiet-hats-kick.md Signed-off-by: Patrik Oldsberg --- .changeset/quiet-hats-kick.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/quiet-hats-kick.md b/.changeset/quiet-hats-kick.md index 155d8f232c..a0bbab4cb1 100644 --- a/.changeset/quiet-hats-kick.md +++ b/.changeset/quiet-hats-kick.md @@ -2,4 +2,4 @@ '@backstage/plugin-jenkins-backend': patch --- -fix incorrect jenkins backend config initialization. named config and non named config use extraRequestHeaders +Fixed a bug where `extraRequestHeaders` configuration was ignored. From a0189412855a0a94a4553377118af27eb33d9e97 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 26 Sep 2022 12:43:24 +0200 Subject: [PATCH 192/192] workflows: allow republish Signed-off-by: Patrik Oldsberg --- .github/workflows/deploy_nightly.yml | 2 +- .github/workflows/deploy_packages.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy_nightly.yml b/.github/workflows/deploy_nightly.yml index b82e1a30d4..a4664288da 100644 --- a/.github/workflows/deploy_nightly.yml +++ b/.github/workflows/deploy_nightly.yml @@ -57,7 +57,7 @@ jobs: - name: publish nightly release run: | yarn config set -H 'npmAuthToken' "${{secrets.NPM_TOKEN}}" - yarn workspaces foreach -v --no-private npm publish --access public --tag nightly + yarn workspaces foreach -v --no-private npm publish --access public --tolerate-republish --tag nightly env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/deploy_packages.yml b/.github/workflows/deploy_packages.yml index 06ed3bc45e..64e5fe813a 100644 --- a/.github/workflows/deploy_packages.yml +++ b/.github/workflows/deploy_packages.yml @@ -164,9 +164,9 @@ jobs: run: | yarn config set -H 'npmAuthToken' "${{secrets.NPM_TOKEN}}" if [ -f ".changeset/pre.json" ]; then - yarn workspaces foreach -v --no-private npm publish --access public --tag next + yarn workspaces foreach -v --no-private npm publish --access public --tolerate-republish --tag next else - yarn workspaces foreach -v --no-private npm publish --access public + yarn workspaces foreach -v --no-private npm publish --access public --tolerate-republish fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}