From 91767ddc5442226896d52e83b01824b988ac15fe Mon Sep 17 00:00:00 2001 From: Julien Date: Wed, 26 Jun 2024 17:11:16 +0200 Subject: [PATCH 001/291] feat(visitsApi): make use of limit query param Signed-off-by: Julien --- plugins/home/src/api/VisitsStorageApi.test.ts | 48 +++++++++++++++---- plugins/home/src/api/VisitsStorageApi.ts | 4 +- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/plugins/home/src/api/VisitsStorageApi.test.ts b/plugins/home/src/api/VisitsStorageApi.test.ts index e361c624b9..5e8bc2dbfc 100644 --- a/plugins/home/src/api/VisitsStorageApi.test.ts +++ b/plugins/home/src/api/VisitsStorageApi.test.ts @@ -134,11 +134,25 @@ describe('VisitsStorageApi.create', () => { let visitsToSave: Array>; let baseDate: number; + const fillVisitsApi = () => { + baseDate = Date.now(); + // Chaining items to ensure the right setSystemTime + return visitsToSave.reduce( + (acc, visit, index) => + acc.then(() => { + jest.setSystemTime(baseDate + 360_000 * index); + return api.save({ visit }); + }), + Promise.resolve({}), + ); + }; + beforeEach(() => { api = VisitsStorageApi.create({ storageApi: MockStorageApi.create(), identityApi: mockIdentityApi, }); + visitsToSave = [ { pathname: '/catalog/default/component/playback-order-1', @@ -156,16 +170,8 @@ describe('VisitsStorageApi.create', () => { name: 'Playback Order Odd', }, ]; - baseDate = Date.now(); - // Chaining items to ensure the right setSystemTime - return visitsToSave.reduce( - (acc, visit, index) => - acc.then(() => { - jest.setSystemTime(baseDate + 360_000 * index); - return api.save({ visit }); - }), - Promise.resolve({}), - ); + + return fillVisitsApi(); }); it('retrieves visits', async () => { @@ -335,5 +341,27 @@ describe('VisitsStorageApi.create', () => { expect(visits).toHaveLength(1); expect(visits).toEqual([expect.objectContaining(visitsToSave[0])]); }); + + it('retrieves a limited set of visits according to given limit', async () => { + const visits = await api.list({ + limit: 2, + }); + expect(visitsToSave.length).toBeGreaterThan(2); + expect(visits.length).toEqual(2); + }); + + it('retrieves a default set of 8 visits', async () => { + visitsToSave = Array.from({ length: 9 }, (_, index) => ({ + pathname: `/catalog/default/component/playback-order-${index}`, + entityRef: `component:default/playback-order-${index}`, + name: `Playback Order ${index}`, + })); + + await fillVisitsApi(); + + const visits = await api.list(); + expect(visitsToSave.length).toBeGreaterThan(8); + expect(visits.length).toEqual(8); + }); }); }); diff --git a/plugins/home/src/api/VisitsStorageApi.ts b/plugins/home/src/api/VisitsStorageApi.ts index a7cd063455..ca19dcbdf1 100644 --- a/plugins/home/src/api/VisitsStorageApi.ts +++ b/plugins/home/src/api/VisitsStorageApi.ts @@ -30,6 +30,8 @@ export type VisitsStorageApiOptions = { type ArrayElement = A extends readonly (infer T)[] ? T : never; +const DEFAULT_LIST_LIMIT = 8; + /** * @public * This is an implementation of VisitsApi that relies on a StorageApi. @@ -83,7 +85,7 @@ export class VisitsStorageApi implements VisitsApi { }); }); - return visits; + return visits.slice(0, queryParams?.limit ?? DEFAULT_LIST_LIMIT); } /** From 9893bb52a48caffb0ebd188d587c8de238855dc7 Mon Sep 17 00:00:00 2001 From: Julien Date: Wed, 26 Jun 2024 17:11:40 +0200 Subject: [PATCH 002/291] chore: add changeset Signed-off-by: Julien --- .changeset/olive-walls-wave.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/olive-walls-wave.md diff --git a/.changeset/olive-walls-wave.md b/.changeset/olive-walls-wave.md new file mode 100644 index 0000000000..bc6264bbcb --- /dev/null +++ b/.changeset/olive-walls-wave.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-home': minor +--- + +**BREAKING** Implement usage of unused `limit` query param in visits API `.list()` function From 8542af998a323480b80805b70aead82572c8cc69 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Sun, 28 Jul 2024 03:15:00 -0400 Subject: [PATCH 003/291] fix errors with auth redirect flow Signed-off-by: Stephen Glass --- packages/core-app-api/package.json | 57 ++++++++++--------- .../auth/microsoft/MicrosoftAuth.ts | 4 ++ .../implementations/auth/oauth2/OAuth2.ts | 11 +++- .../implementations/auth/saml/SamlAuth.ts | 9 ++- packages/core-app-api/src/lib/index.ts | 1 + .../core-app-api/src/lib/signInAuthError.ts | 29 ++++++++++ .../src/layout/SignInPage/SignInPage.tsx | 19 ++++++- .../src/apis/definitions/auth.ts | 5 ++ .../createCookieAuthErrorMiddleware.ts | 44 ++++++++++++++ plugins/auth-backend/src/service/router.ts | 8 ++- .../src/oauth/createAuthErrorCookie.ts | 56 ++++++++++++++++++ .../src/oauth/createOAuthRouteHandlers.ts | 28 +++++++-- 12 files changed, 232 insertions(+), 39 deletions(-) create mode 100644 packages/core-app-api/src/lib/signInAuthError.ts create mode 100644 plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts create mode 100644 plugins/auth-node/src/oauth/createAuthErrorCookie.ts diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index 5f297b16d5..31b3cd22fc 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -1,14 +1,30 @@ { "name": "@backstage/core-app-api", - "description": "Core app API used by Backstage apps", "version": "1.14.0", + "description": "Core app API used by Backstage apps", + "backstage": { + "role": "web-library" + }, "publishConfig": { "access": "public" }, + "keywords": [ + "backstage" + ], + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/core-app-api" + }, + "license": "Apache-2.0", + "sideEffects": false, "exports": { ".": "./src/index.ts", "./package.json": "./package.json" }, + "main": "src/index.ts", + "types": "src/index.ts", "typesVersions": { "*": { "package.json": [ @@ -16,34 +32,23 @@ ] } }, - "backstage": { - "role": "web-library" - }, - "homepage": "https://backstage.io", - "repository": { - "type": "git", - "url": "https://github.com/backstage/backstage", - "directory": "packages/core-app-api" - }, - "keywords": [ - "backstage" + "files": [ + "dist", + "config.d.ts" ], - "license": "Apache-2.0", - "main": "src/index.ts", - "types": "src/index.ts", - "sideEffects": false, "scripts": { "build": "backstage-cli package build", + "clean": "backstage-cli package clean", "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", - "start": "backstage-cli package start" + "start": "backstage-cli package start", + "test": "backstage-cli package test" }, "dependencies": { "@backstage/config": "workspace:^", "@backstage/core-plugin-api": "workspace:^", + "@backstage/errors": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", "@types/prop-types": "^15.7.3", @@ -56,11 +61,6 @@ "zen-observable": "^0.10.0", "zod": "^3.22.4" }, - "peerDependencies": { - "react": "^16.13.1 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0", - "react-router-dom": "6.0.0-beta.0 || ^6.3.0" - }, "devDependencies": { "@backstage/cli": "workspace:^", "@backstage/test-utils": "workspace:^", @@ -76,9 +76,10 @@ "react-router-dom-stable": "npm:react-router-dom@^6.3.0", "react-router-stable": "npm:react-router@^6.3.0" }, - "files": [ - "dist", - "config.d.ts" - ], + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react-router-dom": "6.0.0-beta.0 || ^6.3.0" + }, "configSchema": "config.d.ts" } diff --git a/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts index c57c65d830..049022702e 100644 --- a/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts @@ -175,6 +175,10 @@ export default class MicrosoftAuth { return this.microsoftGraph().signOut(); } + getSignInAuthError() { + return this.microsoftGraph().getSignInAuthError(); + } + sessionState$() { return this.microsoftGraph().sessionState$(); } diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts index dbad031652..1ac5199b8f 100644 --- a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts @@ -31,10 +31,12 @@ import { SessionApi, BackstageIdentityApi, BackstageUserIdentity, + DiscoveryApi, } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; import { OAuth2Session } from './types'; import { OAuthApiCreateOptions } from '../types'; +import { getSignInAuthError } from '../../../../lib'; /** * OAuth2 create options. @@ -152,18 +154,21 @@ export default class OAuth2 }, }); - return new OAuth2({ sessionManager, scopeTransform }); + return new OAuth2({ sessionManager, scopeTransform, discoveryApi }); } private readonly sessionManager: SessionManager; private readonly scopeTransform: (scopes: string[]) => string[]; + private readonly discoveryApi: DiscoveryApi; private constructor(options: { sessionManager: SessionManager; scopeTransform: (scopes: string[]) => string[]; + discoveryApi: DiscoveryApi; }) { this.sessionManager = options.sessionManager; this.scopeTransform = options.scopeTransform; + this.discoveryApi = options.discoveryApi; } async signIn() { @@ -224,4 +229,8 @@ export default class OAuth2 return new Set(scopeTransform(scopeList)); } + + async getSignInAuthError() { + return getSignInAuthError(this.discoveryApi); + } } diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts index dad62f1d06..325be93c4c 100644 --- a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -22,6 +22,7 @@ import { SessionApi, SessionState, BackstageIdentityResponse, + DiscoveryApi, } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; import { DirectAuthConnector } from '../../../../lib/AuthConnector'; @@ -32,6 +33,7 @@ import { import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { AuthApiCreateOptions } from '../types'; import { SamlSession, samlSessionSchema } from './types'; +import { getSignInAuthError } from '../../../../lib'; export type SamlAuthResponse = { profile: ProfileInfo; @@ -75,7 +77,7 @@ export default class SamlAuth schema: samlSessionSchema, }); - return new SamlAuth(authSessionStore); + return new SamlAuth(authSessionStore, discoveryApi); } sessionState$(): Observable { @@ -84,6 +86,7 @@ export default class SamlAuth private constructor( private readonly sessionManager: SessionManager, + private readonly discoveryApi: DiscoveryApi, ) {} async signIn() { @@ -104,4 +107,8 @@ export default class SamlAuth const session = await this.sessionManager.getSession(options); return session?.profile; } + + async getSignInAuthError() { + return getSignInAuthError(this.discoveryApi); + } } diff --git a/packages/core-app-api/src/lib/index.ts b/packages/core-app-api/src/lib/index.ts index 1327aab4c2..d5aa2b9e3b 100644 --- a/packages/core-app-api/src/lib/index.ts +++ b/packages/core-app-api/src/lib/index.ts @@ -16,5 +16,6 @@ export * from './subjects'; export * from './loginPopup'; +export * from './signInAuthError'; export * from './AuthConnector'; export * from './AuthSessionManager'; diff --git a/packages/core-app-api/src/lib/signInAuthError.ts b/packages/core-app-api/src/lib/signInAuthError.ts new file mode 100644 index 0000000000..a86a55ee4c --- /dev/null +++ b/packages/core-app-api/src/lib/signInAuthError.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { deserializeError } from '@backstage/errors'; + +export async function getSignInAuthError( + discoveryApi: DiscoveryApi, +): Promise { + const baseUrl = await discoveryApi.getBaseUrl('auth'); + const response = await fetch(`${baseUrl}/.backstage/error`, { + credentials: 'include', + }); + const data = await response.json(); + + return data ? deserializeError(data) : undefined; +} diff --git a/packages/core-components/src/layout/SignInPage/SignInPage.tsx b/packages/core-components/src/layout/SignInPage/SignInPage.tsx index 95d96b701a..207d7e664e 100644 --- a/packages/core-components/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core-components/src/layout/SignInPage/SignInPage.tsx @@ -25,7 +25,7 @@ import Button from '@material-ui/core/Button'; import Grid from '@material-ui/core/Grid'; import Typography from '@material-ui/core/Typography'; import React, { useState } from 'react'; -import { useMountEffect } from '@react-hookz/web'; +import { useAsync, useMountEffect } from '@react-hookz/web'; import { Progress } from '../../components/Progress'; import { Content } from '../Content/Content'; import { ContentHeader } from '../ContentHeader/ContentHeader'; @@ -37,6 +37,7 @@ import { GridItem, useStyles } from './styles'; import { IdentityProviders, SignInProviderConfig } from './types'; import { coreComponentsTranslationRef } from '../../translation'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { useSearchParams } from 'react-router-dom'; type MultiSignInPageProps = SignInPageProps & { providers: IdentityProviders; @@ -106,6 +107,8 @@ export const SingleSignInPage = ({ // displayed for a split second when the user is already logged-in. const [showLoginPage, setShowLoginPage] = useState(false); + const [searchParams, _setSearchParams] = useSearchParams(); + type LoginOpts = { checkExisting?: boolean; showPopup?: boolean }; const login = async ({ checkExisting, showPopup }: LoginOpts) => { try { @@ -152,7 +155,19 @@ export const SingleSignInPage = ({ } }; - useMountEffect(() => login({ checkExisting: true })); + const [_state, actions] = useAsync(async () => { + if (searchParams.get('error') !== 'false') { + const errorResponse = await authApi.getSignInAuthError(); + if (errorResponse) { + setError(errorResponse); + } + } + }); + + useMountEffect(() => { + actions.execute(); + login({ checkExisting: true }); + }); return showLoginPage ? ( diff --git a/packages/core-plugin-api/src/apis/definitions/auth.ts b/packages/core-plugin-api/src/apis/definitions/auth.ts index d89544cf68..c29d965890 100644 --- a/packages/core-plugin-api/src/apis/definitions/auth.ts +++ b/packages/core-plugin-api/src/apis/definitions/auth.ts @@ -291,6 +291,11 @@ export type SessionApi = { */ signOut(): Promise; + /** + * Get any caught errors during the auth redirect flow + */ + getSignInAuthError(): Promise; + /** * Observe the current state of the auth session. Emits the current state on subscription. */ diff --git a/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts b/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts new file mode 100644 index 0000000000..9b1d20431d --- /dev/null +++ b/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2024 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 Router from 'express-promise-router'; + +const AUTH_ERROR_COOKIE = 'auth-error'; + +/** + * @public + * Creates a middleware that can be used to get auth errors in redirect flow + */ +export function createCookieAuthErrorMiddleware(authUrl: string) { + const router = Router(); + + router.get('/.backstage/error', async (req, res) => { + const error = req.cookies[AUTH_ERROR_COOKIE]; + if (error) { + const { hostname: domain } = new URL(authUrl); + + res.clearCookie('auth-error', { + path: '/api/auth/.backstage/error', + domain, + }); + res.status(200).json(error); + } else { + res.status(404); + } + }); + + return router; +} diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 4bb42c3abd..0f6e7d201a 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -48,6 +48,7 @@ import { StaticTokenIssuer } from '../identity/StaticTokenIssuer'; import { StaticKeyStore } from '../identity/StaticKeyStore'; import { Config } from '@backstage/config'; import { bindProviderRouters, ProviderFactories } from '../providers/router'; +import { createCookieAuthErrorMiddleware } from './createCookieAuthErrorMiddleware'; /** @public */ export interface RouterOptions { @@ -170,10 +171,15 @@ export async function createRouter( }); // Gives a more helpful error message than a plain 404 - router.use('/:provider/', req => { + router.use('/:provider/', (req, _, next) => { const { provider } = req.params; + if (provider.startsWith('.backstage')) { + return next('route'); + } throw new NotFoundError(`Unknown auth provider '${provider}'`); }); + router.use(createCookieAuthErrorMiddleware(authUrl)); + return router; } diff --git a/plugins/auth-node/src/oauth/createAuthErrorCookie.ts b/plugins/auth-node/src/oauth/createAuthErrorCookie.ts new file mode 100644 index 0000000000..05b1c99cee --- /dev/null +++ b/plugins/auth-node/src/oauth/createAuthErrorCookie.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2024 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 { Response } from 'express'; +import { CookieConfigurer } from '../types'; +import { serializeError } from '@backstage/errors'; + +const ONE_MINUTE_MS = 60 * 1000; + +const AUTH_ERROR_COOKIE = 'auth-error'; + +function configureAuthErrorCookie(redirectUrl: string, appOrigin: string) { + const { hostname: domain, pathname: path, protocol } = new URL(redirectUrl); + const secure = protocol === 'https:'; + + // 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'; + } + + return { domain, path, secure, sameSite }; +} + +export function createAuthErrorCookie( + res: Response, + origin: string, + options: { + error: Error; + redirectUrl: string; + }, +) { + const { error, redirectUrl } = options; + const jsonData = serializeError(error); + + res.cookie(AUTH_ERROR_COOKIE, jsonData, { + maxAge: ONE_MINUTE_MS, + httpOnly: true, + ...configureAuthErrorCookie(redirectUrl, origin), + }); +} diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts index 82bfa2f02c..649c094dba 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts @@ -42,6 +42,7 @@ import { import { OAuthAuthenticator, OAuthAuthenticatorResult } from './types'; import { Config } from '@backstage/config'; import { CookieScopeManager } from './CookieScopeManager'; +import { createAuthErrorCookie } from './createAuthErrorCookie'; /** @public */ export interface OAuthRouteHandlersOptions { @@ -164,9 +165,10 @@ export function createOAuthRouteHandlers( res: express.Response, ): Promise { let origin = defaultAppOrigin; + let state; try { - const state = decodeOAuthState(req.query.state?.toString() ?? ''); + state = decodeOAuthState(req.query.state?.toString() ?? ''); if (state.origin) { try { @@ -248,11 +250,25 @@ export function createOAuthRouteHandlers( const { name, message } = isError(error) ? error : new Error('Encountered invalid error'); // Being a bit safe and not forwarding the bad value - // post error message back to popup if failure - sendWebMessageResponse(res, origin, { - type: 'authorization_response', - error: { name, message }, - }); + + if (state?.flow === 'redirect' && state?.redirectUrl) { + createAuthErrorCookie(res, state?.redirectUrl, { + error: { name, message }, + redirectUrl: `${baseUrl}/.backstage/error`, + }); + + const redirectUrl = new URL(state.redirectUrl); + redirectUrl.searchParams.set('error', 'true'); + + // set the error in a cookie and redirect user back to sign in where the error can be rendered + res.redirect(redirectUrl.toString()); + } else { + // post error message back to popup if failure + sendWebMessageResponse(res, origin, { + type: 'authorization_response', + error: { name, message }, + }); + } } }, From 5c11d3f97039785cc52bb8c68b54483b8d4aba85 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Sun, 28 Jul 2024 03:21:05 -0400 Subject: [PATCH 004/291] fix formatting Signed-off-by: Stephen Glass --- packages/core-app-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index 31b3cd22fc..547dd47654 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/core-app-api", - "version": "1.14.0", + "version": "1.14.1-next.0", "description": "Core app API used by Backstage apps", "backstage": { "role": "web-library" From 0625085c2fe946cf7091f7633b15c29fde435ee6 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Sun, 28 Jul 2024 03:29:31 -0400 Subject: [PATCH 005/291] update clear cookie to use var name Signed-off-by: Stephen Glass --- .../auth-backend/src/service/createCookieAuthErrorMiddleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts b/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts index 9b1d20431d..cf7645b081 100644 --- a/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts +++ b/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts @@ -30,7 +30,7 @@ export function createCookieAuthErrorMiddleware(authUrl: string) { if (error) { const { hostname: domain } = new URL(authUrl); - res.clearCookie('auth-error', { + res.clearCookie(AUTH_ERROR_COOKIE, { path: '/api/auth/.backstage/error', domain, }); From 31edacd9b153209670a5e97a998c434e19d07a41 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Sun, 28 Jul 2024 03:29:58 -0400 Subject: [PATCH 006/291] yarn lock Signed-off-by: Stephen Glass --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index c6a1d2139a..c3d1f83de5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4123,6 +4123,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/core-plugin-api": "workspace:^" + "@backstage/errors": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@backstage/version-bridge": "workspace:^" From e1caa7bc85b886f6e585ed9b66747ff199d751b6 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Sun, 28 Jul 2024 14:12:12 -0400 Subject: [PATCH 007/291] create auth error api ref Signed-off-by: Stephen Glass --- packages/app-defaults/src/defaults/apis.ts | 9 ++++ .../AuthErrorApi/SignInAuthErrorApi.ts | 41 +++++++++++++++++ .../implementations/AuthErrorApi/index.ts} | 14 +----- .../auth/microsoft/MicrosoftAuth.ts | 4 -- .../implementations/auth/oauth2/OAuth2.ts | 11 +---- .../implementations/auth/saml/SamlAuth.ts | 9 +--- .../src/apis/implementations/index.ts | 1 + packages/core-app-api/src/lib/index.ts | 1 - .../src/layout/SignInPage/SignInPage.tsx | 8 ++-- .../src/layout/SignInPage/providers.tsx | 17 +++++++ .../src/apis/definitions/AuthErrorApi.ts | 45 +++++++++++++++++++ .../src/apis/definitions/auth.ts | 5 --- .../src/apis/definitions/index.ts | 1 + 13 files changed, 122 insertions(+), 44 deletions(-) create mode 100644 packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.ts rename packages/core-app-api/src/{lib/signInAuthError.ts => apis/implementations/AuthErrorApi/index.ts} (56%) create mode 100644 packages/core-plugin-api/src/apis/definitions/AuthErrorApi.ts diff --git a/packages/app-defaults/src/defaults/apis.ts b/packages/app-defaults/src/defaults/apis.ts index 4e9e1a492c..84902b9f52 100644 --- a/packages/app-defaults/src/defaults/apis.ts +++ b/packages/app-defaults/src/defaults/apis.ts @@ -35,6 +35,7 @@ import { createFetchApi, FetchMiddlewares, VMwareCloudAuth, + SignInAuthErrorApi, } from '@backstage/core-app-api'; import { @@ -58,6 +59,7 @@ import { bitbucketServerAuthApiRef, atlassianAuthApiRef, vmwareCloudAuthApiRef, + authErrorApiRef, } from '@backstage/core-plugin-api'; import { permissionApiRef, @@ -287,4 +289,11 @@ export const apis = [ factory: ({ config, discovery, identity }) => IdentityPermissionApi.create({ config, discovery, identity }), }), + createApiFactory({ + api: authErrorApiRef, + deps: { + discovery: discoveryApiRef, + }, + factory: ({ discovery }) => SignInAuthErrorApi.create({ discovery }), + }), ]; diff --git a/packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.ts b/packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.ts new file mode 100644 index 0000000000..0197f253ab --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2021 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, AuthErrorApi } from '@backstage/core-plugin-api'; +import { deserializeError } from '@backstage/errors'; + +/** + * The default implementation of the AuthErrorApi, which simply calls auth error endpoint. + * @public + */ +export class SignInAuthErrorApi implements AuthErrorApi { + private constructor(private readonly discoveryApi: DiscoveryApi) {} + + static create(options: { discovery: DiscoveryApi }) { + const { discovery } = options; + return new SignInAuthErrorApi(discovery); + } + + async getSignInAuthError(): Promise { + const baseUrl = await this.discoveryApi.getBaseUrl('auth'); + const response = await fetch(`${baseUrl}/.backstage/error`, { + credentials: 'include', + }); + const data = await response.json(); + + return data ? deserializeError(data) : undefined; + } +} diff --git a/packages/core-app-api/src/lib/signInAuthError.ts b/packages/core-app-api/src/apis/implementations/AuthErrorApi/index.ts similarity index 56% rename from packages/core-app-api/src/lib/signInAuthError.ts rename to packages/core-app-api/src/apis/implementations/AuthErrorApi/index.ts index a86a55ee4c..6f3a3ae269 100644 --- a/packages/core-app-api/src/lib/signInAuthError.ts +++ b/packages/core-app-api/src/apis/implementations/AuthErrorApi/index.ts @@ -13,17 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { DiscoveryApi } from '@backstage/core-plugin-api'; -import { deserializeError } from '@backstage/errors'; -export async function getSignInAuthError( - discoveryApi: DiscoveryApi, -): Promise { - const baseUrl = await discoveryApi.getBaseUrl('auth'); - const response = await fetch(`${baseUrl}/.backstage/error`, { - credentials: 'include', - }); - const data = await response.json(); - - return data ? deserializeError(data) : undefined; -} +export { SignInAuthErrorApi } from './SignInAuthErrorApi'; diff --git a/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts index 049022702e..c57c65d830 100644 --- a/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts @@ -175,10 +175,6 @@ export default class MicrosoftAuth { return this.microsoftGraph().signOut(); } - getSignInAuthError() { - return this.microsoftGraph().getSignInAuthError(); - } - sessionState$() { return this.microsoftGraph().sessionState$(); } diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts index 1ac5199b8f..dbad031652 100644 --- a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts @@ -31,12 +31,10 @@ import { SessionApi, BackstageIdentityApi, BackstageUserIdentity, - DiscoveryApi, } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; import { OAuth2Session } from './types'; import { OAuthApiCreateOptions } from '../types'; -import { getSignInAuthError } from '../../../../lib'; /** * OAuth2 create options. @@ -154,21 +152,18 @@ export default class OAuth2 }, }); - return new OAuth2({ sessionManager, scopeTransform, discoveryApi }); + return new OAuth2({ sessionManager, scopeTransform }); } private readonly sessionManager: SessionManager; private readonly scopeTransform: (scopes: string[]) => string[]; - private readonly discoveryApi: DiscoveryApi; private constructor(options: { sessionManager: SessionManager; scopeTransform: (scopes: string[]) => string[]; - discoveryApi: DiscoveryApi; }) { this.sessionManager = options.sessionManager; this.scopeTransform = options.scopeTransform; - this.discoveryApi = options.discoveryApi; } async signIn() { @@ -229,8 +224,4 @@ export default class OAuth2 return new Set(scopeTransform(scopeList)); } - - async getSignInAuthError() { - return getSignInAuthError(this.discoveryApi); - } } diff --git a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts index 325be93c4c..dad62f1d06 100644 --- a/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts +++ b/packages/core-app-api/src/apis/implementations/auth/saml/SamlAuth.ts @@ -22,7 +22,6 @@ import { SessionApi, SessionState, BackstageIdentityResponse, - DiscoveryApi, } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; import { DirectAuthConnector } from '../../../../lib/AuthConnector'; @@ -33,7 +32,6 @@ import { import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { AuthApiCreateOptions } from '../types'; import { SamlSession, samlSessionSchema } from './types'; -import { getSignInAuthError } from '../../../../lib'; export type SamlAuthResponse = { profile: ProfileInfo; @@ -77,7 +75,7 @@ export default class SamlAuth schema: samlSessionSchema, }); - return new SamlAuth(authSessionStore, discoveryApi); + return new SamlAuth(authSessionStore); } sessionState$(): Observable { @@ -86,7 +84,6 @@ export default class SamlAuth private constructor( private readonly sessionManager: SessionManager, - private readonly discoveryApi: DiscoveryApi, ) {} async signIn() { @@ -107,8 +104,4 @@ export default class SamlAuth const session = await this.sessionManager.getSession(options); return session?.profile; } - - async getSignInAuthError() { - return getSignInAuthError(this.discoveryApi); - } } diff --git a/packages/core-app-api/src/apis/implementations/index.ts b/packages/core-app-api/src/apis/implementations/index.ts index 1c79d3d164..7643dfcd82 100644 --- a/packages/core-app-api/src/apis/implementations/index.ts +++ b/packages/core-app-api/src/apis/implementations/index.ts @@ -30,3 +30,4 @@ export * from './FeatureFlagsApi'; export * from './FetchApi'; export * from './OAuthRequestApi'; export * from './StorageApi'; +export * from './AuthErrorApi'; diff --git a/packages/core-app-api/src/lib/index.ts b/packages/core-app-api/src/lib/index.ts index d5aa2b9e3b..1327aab4c2 100644 --- a/packages/core-app-api/src/lib/index.ts +++ b/packages/core-app-api/src/lib/index.ts @@ -16,6 +16,5 @@ export * from './subjects'; export * from './loginPopup'; -export * from './signInAuthError'; export * from './AuthConnector'; export * from './AuthSessionManager'; diff --git a/packages/core-components/src/layout/SignInPage/SignInPage.tsx b/packages/core-components/src/layout/SignInPage/SignInPage.tsx index 207d7e664e..f0af0c5d35 100644 --- a/packages/core-components/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core-components/src/layout/SignInPage/SignInPage.tsx @@ -19,6 +19,7 @@ import { configApiRef, SignInPageProps, useApi, + authErrorApiRef, } from '@backstage/core-plugin-api'; import { UserIdentity } from './UserIdentity'; import Button from '@material-ui/core/Button'; @@ -98,6 +99,7 @@ export const SingleSignInPage = ({ const classes = useStyles(); const authApi = useApi(provider.apiRef); const configApi = useApi(configApiRef); + const authErrorApi = useApi(authErrorApiRef); const { t } = useTranslationRef(coreComponentsTranslationRef); const [error, setError] = useState(); @@ -155,9 +157,9 @@ export const SingleSignInPage = ({ } }; - const [_state, actions] = useAsync(async () => { + const [_, { execute }] = useAsync(async () => { if (searchParams.get('error') !== 'false') { - const errorResponse = await authApi.getSignInAuthError(); + const errorResponse = await authErrorApi.getSignInAuthError(); if (errorResponse) { setError(errorResponse); } @@ -165,7 +167,7 @@ export const SingleSignInPage = ({ }); useMountEffect(() => { - actions.execute(); + execute(); login({ checkExisting: true }); }); diff --git a/packages/core-components/src/layout/SignInPage/providers.tsx b/packages/core-components/src/layout/SignInPage/providers.tsx index e456a6948b..9b73cfc561 100644 --- a/packages/core-components/src/layout/SignInPage/providers.tsx +++ b/packages/core-components/src/layout/SignInPage/providers.tsx @@ -21,6 +21,7 @@ import { useApiHolder, errorApiRef, IdentityApi, + authErrorApiRef, } from '@backstage/core-plugin-api'; import { IdentityProviders, @@ -31,6 +32,8 @@ import { commonProvider } from './commonProvider'; import { guestProvider } from './guestProvider'; import { customProvider } from './customProvider'; import { IdentityApiSignOutProxy } from './IdentityApiSignOutProxy'; +import { useSearchParams } from 'react-router-dom'; +import { useMountEffect, useAsync } from '@react-hookz/web'; const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider'; @@ -86,8 +89,22 @@ export const useSignInProviders = ( ) => { const errorApi = useApi(errorApiRef); const apiHolder = useApiHolder(); + const authErrorApi = useApi(authErrorApiRef); const [loading, setLoading] = useState(true); + const [searchParams, _setSearchParams] = useSearchParams(); + + const [_, { execute }] = useAsync(async () => { + if (searchParams.get('error') !== 'false') { + const errorResponse = await authErrorApi.getSignInAuthError(); + if (errorResponse) { + errorApi.post(errorResponse); + } + } + }); + + useMountEffect(execute); + // This decorates the result with sign out logic from this hook const handleWrappedResult = useCallback( (identityApi: IdentityApi) => { diff --git a/packages/core-plugin-api/src/apis/definitions/AuthErrorApi.ts b/packages/core-plugin-api/src/apis/definitions/AuthErrorApi.ts new file mode 100644 index 0000000000..57c6b1088f --- /dev/null +++ b/packages/core-plugin-api/src/apis/definitions/AuthErrorApi.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2024 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 { ApiRef, createApiRef } from '../system'; + +/** + * A wrapper for retrieving any errors caught in authentication redirect flow + * + * @public + */ +export type AuthErrorApi = { + /** + * Get any caught errors during the auth redirect flow + */ + getSignInAuthError(): Promise; +}; + +/** + * The {@link ApiRef} of {@link AuthErrorApi}. + * + * @remarks + * + * This is a wrapper that uses fetch to retrieve any caught errors + * in the authentication redirect flow process. This API calls a + * Backstage endpoint to read any error stored in authentication error + * cookie and returns it to the user. + * + * @public + */ +export const authErrorApiRef: ApiRef = createApiRef({ + id: 'core.authError', +}); diff --git a/packages/core-plugin-api/src/apis/definitions/auth.ts b/packages/core-plugin-api/src/apis/definitions/auth.ts index c29d965890..d89544cf68 100644 --- a/packages/core-plugin-api/src/apis/definitions/auth.ts +++ b/packages/core-plugin-api/src/apis/definitions/auth.ts @@ -291,11 +291,6 @@ export type SessionApi = { */ signOut(): Promise; - /** - * Get any caught errors during the auth redirect flow - */ - getSignInAuthError(): Promise; - /** * Observe the current state of the auth session. Emits the current state on subscription. */ diff --git a/packages/core-plugin-api/src/apis/definitions/index.ts b/packages/core-plugin-api/src/apis/definitions/index.ts index 67d442587d..406dc1980e 100644 --- a/packages/core-plugin-api/src/apis/definitions/index.ts +++ b/packages/core-plugin-api/src/apis/definitions/index.ts @@ -33,3 +33,4 @@ export * from './FetchApi'; export * from './IdentityApi'; export * from './OAuthRequestApi'; export * from './StorageApi'; +export * from './AuthErrorApi'; From 40064678e0e5b4f24ff9558ba3efd700c0e9cd07 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Sun, 28 Jul 2024 14:15:08 -0400 Subject: [PATCH 008/291] rename async execute Signed-off-by: Stephen Glass --- packages/core-components/src/layout/SignInPage/SignInPage.tsx | 4 ++-- packages/core-components/src/layout/SignInPage/providers.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core-components/src/layout/SignInPage/SignInPage.tsx b/packages/core-components/src/layout/SignInPage/SignInPage.tsx index f0af0c5d35..a0d5ec232e 100644 --- a/packages/core-components/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core-components/src/layout/SignInPage/SignInPage.tsx @@ -157,7 +157,7 @@ export const SingleSignInPage = ({ } }; - const [_, { execute }] = useAsync(async () => { + const [_, { execute: checkAuthErrors }] = useAsync(async () => { if (searchParams.get('error') !== 'false') { const errorResponse = await authErrorApi.getSignInAuthError(); if (errorResponse) { @@ -167,7 +167,7 @@ export const SingleSignInPage = ({ }); useMountEffect(() => { - execute(); + checkAuthErrors(); login({ checkExisting: true }); }); diff --git a/packages/core-components/src/layout/SignInPage/providers.tsx b/packages/core-components/src/layout/SignInPage/providers.tsx index 9b73cfc561..4b6c251e7b 100644 --- a/packages/core-components/src/layout/SignInPage/providers.tsx +++ b/packages/core-components/src/layout/SignInPage/providers.tsx @@ -94,7 +94,7 @@ export const useSignInProviders = ( const [searchParams, _setSearchParams] = useSearchParams(); - const [_, { execute }] = useAsync(async () => { + const [_, { execute: checkAuthErrors }] = useAsync(async () => { if (searchParams.get('error') !== 'false') { const errorResponse = await authErrorApi.getSignInAuthError(); if (errorResponse) { @@ -103,7 +103,7 @@ export const useSignInProviders = ( } }); - useMountEffect(execute); + useMountEffect(checkAuthErrors); // This decorates the result with sign out logic from this hook const handleWrappedResult = useCallback( From 5d8649d7757c665ca76b9fc687d3b0e28c98f4da Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Sun, 28 Jul 2024 23:28:13 -0400 Subject: [PATCH 009/291] update param name Signed-off-by: Stephen Glass --- .../implementations/AuthErrorApi/SignInAuthErrorApi.ts | 3 +++ plugins/auth-node/src/oauth/createAuthErrorCookie.ts | 10 +++++----- .../auth-node/src/oauth/createOAuthRouteHandlers.ts | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.ts b/packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.ts index 0197f253ab..2f2ba9cb09 100644 --- a/packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.ts +++ b/packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.ts @@ -31,6 +31,9 @@ export class SignInAuthErrorApi implements AuthErrorApi { async getSignInAuthError(): Promise { const baseUrl = await this.discoveryApi.getBaseUrl('auth'); + + // use native fetch instead of depending on fetchApi because + // we are not signed in and are calling an unauthenticated endpoint const response = await fetch(`${baseUrl}/.backstage/error`, { credentials: 'include', }); diff --git a/plugins/auth-node/src/oauth/createAuthErrorCookie.ts b/plugins/auth-node/src/oauth/createAuthErrorCookie.ts index 05b1c99cee..61914e7c05 100644 --- a/plugins/auth-node/src/oauth/createAuthErrorCookie.ts +++ b/plugins/auth-node/src/oauth/createAuthErrorCookie.ts @@ -21,8 +21,8 @@ const ONE_MINUTE_MS = 60 * 1000; const AUTH_ERROR_COOKIE = 'auth-error'; -function configureAuthErrorCookie(redirectUrl: string, appOrigin: string) { - const { hostname: domain, pathname: path, protocol } = new URL(redirectUrl); +function configureAuthErrorCookie(apiUrl: string, appOrigin: string) { + const { hostname: domain, pathname: path, protocol } = new URL(apiUrl); const secure = protocol === 'https:'; // For situations where the auth-backend is running on a @@ -42,15 +42,15 @@ export function createAuthErrorCookie( origin: string, options: { error: Error; - redirectUrl: string; + apiUrl: string; }, ) { - const { error, redirectUrl } = options; + const { error, apiUrl } = options; const jsonData = serializeError(error); res.cookie(AUTH_ERROR_COOKIE, jsonData, { maxAge: ONE_MINUTE_MS, httpOnly: true, - ...configureAuthErrorCookie(redirectUrl, origin), + ...configureAuthErrorCookie(apiUrl, origin), }); } diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts index 649c094dba..847dc4844f 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts @@ -254,7 +254,7 @@ export function createOAuthRouteHandlers( if (state?.flow === 'redirect' && state?.redirectUrl) { createAuthErrorCookie(res, state?.redirectUrl, { error: { name, message }, - redirectUrl: `${baseUrl}/.backstage/error`, + apiUrl: `${baseUrl}/.backstage/error`, }); const redirectUrl = new URL(state.redirectUrl); From 41b0d71313f5804b890f39d5f258325ab838a718 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Sun, 28 Jul 2024 23:54:30 -0400 Subject: [PATCH 010/291] fix warning on cookie clear Signed-off-by: Stephen Glass --- .../src/apis/definitions/AuthErrorApi.ts | 2 +- .../src/service/createCookieAuthErrorMiddleware.ts | 12 ++++++++++-- plugins/auth-backend/src/service/router.ts | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/core-plugin-api/src/apis/definitions/AuthErrorApi.ts b/packages/core-plugin-api/src/apis/definitions/AuthErrorApi.ts index 57c6b1088f..558c1202a8 100644 --- a/packages/core-plugin-api/src/apis/definitions/AuthErrorApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/AuthErrorApi.ts @@ -41,5 +41,5 @@ export type AuthErrorApi = { * @public */ export const authErrorApiRef: ApiRef = createApiRef({ - id: 'core.authError', + id: 'core.auth-error', }); diff --git a/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts b/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts index cf7645b081..325126d831 100644 --- a/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts +++ b/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts @@ -22,17 +22,25 @@ const AUTH_ERROR_COOKIE = 'auth-error'; * @public * Creates a middleware that can be used to get auth errors in redirect flow */ -export function createCookieAuthErrorMiddleware(authUrl: string) { +export function createCookieAuthErrorMiddleware( + appUrl: string, + authUrl: string, +) { const router = Router(); router.get('/.backstage/error', async (req, res) => { const error = req.cookies[AUTH_ERROR_COOKIE]; if (error) { - const { hostname: domain } = new URL(authUrl); + const { hostname: domain, protocol } = new URL(authUrl); + const secure = protocol === 'https:'; + const sameSite = + new URL(appUrl).hostname !== domain && secure ? 'none' : 'lax'; res.clearCookie(AUTH_ERROR_COOKIE, { path: '/api/auth/.backstage/error', domain, + sameSite, + secure, }); res.status(200).json(error); } else { diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 0f6e7d201a..b1952755ca 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -179,7 +179,7 @@ export async function createRouter( throw new NotFoundError(`Unknown auth provider '${provider}'`); }); - router.use(createCookieAuthErrorMiddleware(authUrl)); + router.use(createCookieAuthErrorMiddleware(appUrl, authUrl)); return router; } From 672a4b4876db0eb3ba8756503b1252e42f49cf01 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Mon, 29 Jul 2024 00:06:51 -0400 Subject: [PATCH 011/291] update middleware order Signed-off-by: Stephen Glass --- .../implementations/AuthErrorApi/SignInAuthErrorApi.ts | 2 +- plugins/auth-backend/src/service/router.ts | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.ts b/packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.ts index 2f2ba9cb09..77b3c53f14 100644 --- a/packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.ts +++ b/packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.ts @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * Copyright 2024 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. diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index b1952755ca..57f01cfba1 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -170,16 +170,13 @@ export async function createRouter( userInfoDatabaseHandler, }); + router.use(createCookieAuthErrorMiddleware(appUrl, authUrl)); + // Gives a more helpful error message than a plain 404 - router.use('/:provider/', (req, _, next) => { + router.use('/:provider/', req => { const { provider } = req.params; - if (provider.startsWith('.backstage')) { - return next('route'); - } throw new NotFoundError(`Unknown auth provider '${provider}'`); }); - router.use(createCookieAuthErrorMiddleware(appUrl, authUrl)); - return router; } From 17c9a1a330b509817d749f1944d91d58483a6efe Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Mon, 29 Jul 2024 00:45:41 -0400 Subject: [PATCH 012/291] add test Signed-off-by: Stephen Glass --- .../src/oauth/createAuthErrorCookie.ts | 3 +-- .../oauth/createOAuthRouteHandlers.test.ts | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/plugins/auth-node/src/oauth/createAuthErrorCookie.ts b/plugins/auth-node/src/oauth/createAuthErrorCookie.ts index 61914e7c05..46ef4c4a1c 100644 --- a/plugins/auth-node/src/oauth/createAuthErrorCookie.ts +++ b/plugins/auth-node/src/oauth/createAuthErrorCookie.ts @@ -14,7 +14,6 @@ * limitations under the License. */ import { Response } from 'express'; -import { CookieConfigurer } from '../types'; import { serializeError } from '@backstage/errors'; const ONE_MINUTE_MS = 60 * 1000; @@ -29,7 +28,7 @@ function configureAuthErrorCookie(apiUrl: string, appOrigin: string) { // 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'; + let sameSite: 'lax' | 'none' = 'lax'; if (new URL(appOrigin).hostname !== domain && secure) { sameSite = 'none'; } diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts index 7da5519655..2f3eb360cc 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts @@ -742,5 +742,29 @@ describe('createOAuthRouteHandlers', () => { }, }); }); + + it('should set cookie on caught error redirect', async () => { + const app = wrapInApp(createOAuthRouteHandlers(baseConfig)); + const res = await request(app) + .get('/my-provider/handler/frame') + .query({ + state: encodeOAuthState({ + env: 'development', + nonce: '123', + flow: 'redirect', + redirectUrl: 'http://localhost:3000', + }), + }); + + // redirects on error with auth error cookie + expect(res.status).toBe(302); + const setCookieHeader = res.header['set-cookie']; + expect(setCookieHeader).toBeDefined(); + + const authErrorCookie = setCookieHeader.find((cookie: string) => + cookie.startsWith('auth-error='), + ); + expect(authErrorCookie).toBeDefined(); + }); }); }); From 155b9018980f71a3d88cd83bde430000656f6fd2 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Mon, 29 Jul 2024 00:47:32 -0400 Subject: [PATCH 013/291] update test name Signed-off-by: Stephen Glass --- plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts index 2f3eb360cc..b54de50881 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts @@ -743,7 +743,7 @@ describe('createOAuthRouteHandlers', () => { }); }); - it('should set cookie on caught error redirect', async () => { + it('should set cookie and redirect on caught error', async () => { const app = wrapInApp(createOAuthRouteHandlers(baseConfig)); const res = await request(app) .get('/my-provider/handler/frame') From cc2642d69633e20561280bd048fe71b0a5cd1840 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Mon, 29 Jul 2024 14:23:13 -0400 Subject: [PATCH 014/291] add tests Signed-off-by: Stephen Glass --- .../AuthErrorApi/SignInAuthErrorApi.test.ts | 94 +++++++++++++++++++ .../createCookieAuthErrorMiddleware.test.ts | 67 +++++++++++++ .../createCookieAuthErrorMiddleware.ts | 2 +- 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.test.ts create mode 100644 plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.test.ts diff --git a/packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.test.ts b/packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.test.ts new file mode 100644 index 0000000000..c4d6fe83de --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.test.ts @@ -0,0 +1,94 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { serializeError } from '@backstage/errors'; +import { SignInAuthErrorApi } from './SignInAuthErrorApi'; + +describe('SignInAuthErrorApi', () => { + const mockDiscoveryApi = { + getBaseUrl: jest.fn(), + } as unknown as jest.Mocked; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should create an instance of SignInAuthErrorApi', async () => { + const api = SignInAuthErrorApi.create({ + discovery: mockDiscoveryApi, + }); + + mockDiscoveryApi.getBaseUrl.mockResolvedValue( + 'http://localhost:7007/api/auth', + ); + + expect(api).toBeInstanceOf(SignInAuthErrorApi); + }); + + it('should return an error when the cookie returns an error object', async () => { + const errorObject = { + name: 'TestError', + message: 'This is a test error', + }; + const serializedError = serializeError(errorObject); + mockDiscoveryApi.getBaseUrl.mockResolvedValue( + 'http://localhost:7000/api/auth', + ); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(serializedError), + } as unknown as Response); + + const api = SignInAuthErrorApi.create({ discovery: mockDiscoveryApi }); + const error = await api.getSignInAuthError(); + + expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('auth'); + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:7000/api/auth/.backstage/error', + { + credentials: 'include', + }, + ); + expect(error).toBeInstanceOf(Error); + expect((error as Error).name).toEqual(errorObject.name); + expect((error as Error).message).toEqual(errorObject.message); + }); + + it('should return undefined when the backend does not return an error object', async () => { + mockDiscoveryApi.getBaseUrl.mockResolvedValue( + 'http://localhost:7000/api/auth', + ); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(undefined), + } as unknown as Response); + + const api = SignInAuthErrorApi.create({ discovery: mockDiscoveryApi }); + const error = await api.getSignInAuthError(); + + expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('auth'); + expect(fetch).toHaveBeenCalledWith( + 'http://localhost:7000/api/auth/.backstage/error', + { + credentials: 'include', + }, + ); + expect(error).toBeUndefined(); + }); +}); diff --git a/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.test.ts b/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.test.ts new file mode 100644 index 0000000000..1d270a953f --- /dev/null +++ b/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2024 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 express from 'express'; +import request from 'supertest'; +import { createCookieAuthErrorMiddleware } from './createCookieAuthErrorMiddleware'; + +const AUTH_ERROR_COOKIE = 'auth-error'; + +describe('createCookieAuthErrorMiddleware', () => { + let app: express.Express; + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + // Mock cookie parser middleware + app.use((req, _, next) => { + req.cookies = {}; + const cookieHeader = req.headers.cookie; + if (cookieHeader) { + const cookies = cookieHeader.split(';'); + cookies.forEach(cookie => { + const [name, ...rest] = cookie.split('='); + req.cookies[name.trim()] = decodeURIComponent(rest.join('=')); + }); + } + next(); + }); + + app.use( + createCookieAuthErrorMiddleware( + 'http://localhost:3000', + 'http://localhost:7000', + ), + ); + }); + + it('should return cookie content if error cookie exists', async () => { + const error = 'test'; + const res = await request(app) + .get('/.backstage/error') + .set('Cookie', `${AUTH_ERROR_COOKIE}=${encodeURIComponent(error)}`); + + expect(res.status).toBe(200); + expect(res.body).toEqual('test'); + }); + + it('should return 404 if error cookie does not exist', async () => { + const res = await request(app).get('/.backstage/error'); + expect(res.status).toBe(404); + }); +}); diff --git a/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts b/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts index 325126d831..30724fc3cd 100644 --- a/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts +++ b/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts @@ -44,7 +44,7 @@ export function createCookieAuthErrorMiddleware( }); res.status(200).json(error); } else { - res.status(404); + res.status(404).end(); } }); From b73387a1118bd7b3cce712753a7820073b6d0a97 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Mon, 29 Jul 2024 14:38:48 -0400 Subject: [PATCH 015/291] fix formatting Signed-off-by: Stephen Glass --- packages/core-app-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index e68faa0b6b..6025fd5677 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -82,4 +82,4 @@ "config.d.ts" ], "configSchema": "config.d.ts" -} \ No newline at end of file +} From e4ad29ad8b4d84590b81519f5382ad7e15383e1c Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Mon, 29 Jul 2024 15:16:31 -0400 Subject: [PATCH 016/291] add changeset Signed-off-by: Stephen Glass --- .changeset/violet-beds-promise.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/violet-beds-promise.md diff --git a/.changeset/violet-beds-promise.md b/.changeset/violet-beds-promise.md new file mode 100644 index 0000000000..9fa03646c4 --- /dev/null +++ b/.changeset/violet-beds-promise.md @@ -0,0 +1,17 @@ +--- +'@backstage/core-components': patch +'@backstage/core-plugin-api': patch +'@backstage/app-defaults': patch +'@backstage/core-app-api': patch +'@backstage/plugin-auth-backend': patch +'@backstage/plugin-auth-node': patch +--- + +Fix error handling using authentication redirect flow via `enableExperimentalRedirectFlow` config. If an error is caught during authentication, the user is redirected back to app origin with `?error=true` query parameter. A cookie is also set in the redirect which contains the error message. The error can be fetched from any custom sign in page using the new `AuthErrorApi` and `getSignInError()` implementation. Example: + +```ts +import { useApi, authErrorApiRef } from '@backstage/core-plugin-api'; + +const authErrorApi = useApi(authErrorApiRef); +const errorResponse = await authErrorApi.getSignInAuthError(); +``` From b6591fa45a0fd23a1d713b21d11cacef64f516f8 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Mon, 29 Jul 2024 15:39:29 -0400 Subject: [PATCH 017/291] update changeset Signed-off-by: Stephen Glass --- .changeset/violet-beds-promise.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/violet-beds-promise.md b/.changeset/violet-beds-promise.md index 9fa03646c4..ea5f478543 100644 --- a/.changeset/violet-beds-promise.md +++ b/.changeset/violet-beds-promise.md @@ -7,7 +7,7 @@ '@backstage/plugin-auth-node': patch --- -Fix error handling using authentication redirect flow via `enableExperimentalRedirectFlow` config. If an error is caught during authentication, the user is redirected back to app origin with `?error=true` query parameter. A cookie is also set in the redirect which contains the error message. The error can be fetched from any custom sign in page using the new `AuthErrorApi` and `getSignInError()` implementation. Example: +Fix error handling using authentication redirect flow via `enableExperimentalRedirectFlow` config. If an error is caught during authentication, the user is redirected back to app origin with `?error=true` query parameter. A cookie is also set in the redirect which contains the error message. The error can be fetched from any custom sign in page using the new `AuthErrorApi`. Example: ```ts import { useApi, authErrorApiRef } from '@backstage/core-plugin-api'; From d576de7f9e2313270d0f9750c6169ffa9663e3d1 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Mon, 29 Jul 2024 16:11:58 -0400 Subject: [PATCH 018/291] build api reports Signed-off-by: Stephen Glass --- packages/core-app-api/api-report.md | 9 +++++++++ packages/core-plugin-api/api-report.md | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 8b8cc991c8..ae9d709c88 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -16,6 +16,7 @@ import { AppConfig } from '@backstage/config'; import { AppTheme } from '@backstage/core-plugin-api'; import { AppThemeApi } from '@backstage/core-plugin-api'; import { atlassianAuthApiRef } from '@backstage/core-plugin-api'; +import { AuthErrorApi } from '@backstage/core-plugin-api'; import { AuthProviderInfo } from '@backstage/core-plugin-api'; import { AuthRequestOptions } from '@backstage/core-plugin-api'; import { BackstageIdentityApi } from '@backstage/core-plugin-api'; @@ -635,6 +636,14 @@ export class SamlAuth signOut(): Promise; } +// @public +export class SignInAuthErrorApi implements AuthErrorApi { + // (undocumented) + static create(options: { discovery: DiscoveryApi }): SignInAuthErrorApi; + // (undocumented) + getSignInAuthError(): Promise; +} + // @public export type SignInPageProps = PropsWithChildren<{ onSignInSuccess(identityApi: IdentityApi): void; diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 134b338c92..22e254cc29 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -194,6 +194,14 @@ export function attachComponentData

( data: unknown, ): void; +// @public +export type AuthErrorApi = { + getSignInAuthError(): Promise; +}; + +// @public +export const authErrorApiRef: ApiRef; + // @public export type AuthProviderInfo = { id: string; From 7fd44657053a75b337f79c7fb41349e7e7fca9cc Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Mon, 29 Jul 2024 16:39:23 -0400 Subject: [PATCH 019/291] fix createApp test case Signed-off-by: Stephen Glass --- packages/frontend-app-api/src/wiring/createApp.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx index 94254a2e01..6bff2d6d10 100644 --- a/packages/frontend-app-api/src/wiring/createApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx @@ -293,6 +293,7 @@ describe('createApp', () => { + ] " `); From cf15b1f53d894e8960495cbe8265b3453e22c15d Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Mon, 29 Jul 2024 17:12:28 -0400 Subject: [PATCH 020/291] use cookie parser in test instead of mock Signed-off-by: Stephen Glass --- .../createCookieAuthErrorMiddleware.test.ts | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.test.ts b/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.test.ts index 1d270a953f..f0c547842a 100644 --- a/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.test.ts +++ b/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.test.ts @@ -16,6 +16,7 @@ import express from 'express'; import request from 'supertest'; +import cookieParser from 'cookie-parser'; import { createCookieAuthErrorMiddleware } from './createCookieAuthErrorMiddleware'; const AUTH_ERROR_COOKIE = 'auth-error'; @@ -28,19 +29,7 @@ describe('createCookieAuthErrorMiddleware', () => { app.use(express.json()); app.use(express.urlencoded({ extended: true })); - // Mock cookie parser middleware - app.use((req, _, next) => { - req.cookies = {}; - const cookieHeader = req.headers.cookie; - if (cookieHeader) { - const cookies = cookieHeader.split(';'); - cookies.forEach(cookie => { - const [name, ...rest] = cookie.split('='); - req.cookies[name.trim()] = decodeURIComponent(rest.join('=')); - }); - } - next(); - }); + app.use(cookieParser()); app.use( createCookieAuthErrorMiddleware( From 32fe26a57ff6ad31f2543275967b630dd37113f3 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Thu, 1 Aug 2024 09:51:30 -0400 Subject: [PATCH 021/291] fix redirect loop using auto prop in signin component Signed-off-by: Stephen Glass --- .../core-components/src/layout/SignInPage/SignInPage.tsx | 7 +++++-- .../core-components/src/layout/SignInPage/providers.tsx | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/core-components/src/layout/SignInPage/SignInPage.tsx b/packages/core-components/src/layout/SignInPage/SignInPage.tsx index a0d5ec232e..932266cdc7 100644 --- a/packages/core-components/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core-components/src/layout/SignInPage/SignInPage.tsx @@ -109,7 +109,10 @@ export const SingleSignInPage = ({ // displayed for a split second when the user is already logged-in. const [showLoginPage, setShowLoginPage] = useState(false); + // User was redirected back to sign in page with error from auth redirect flow const [searchParams, _setSearchParams] = useSearchParams(); + const errorParam = searchParams.get('error'); + const hasErrorSearchParam = errorParam !== 'false' && errorParam !== null; type LoginOpts = { checkExisting?: boolean; showPopup?: boolean }; const login = async ({ checkExisting, showPopup }: LoginOpts) => { @@ -123,7 +126,7 @@ export const SingleSignInPage = ({ } // If no session exists, show the sign-in page - if (!identityResponse && (showPopup || auto)) { + if (!identityResponse && (showPopup || auto) && !hasErrorSearchParam) { // Unless auto is set to true, this step should not happen. // When user intentionally clicks the Sign In button, autoShowPopup is set to true setShowLoginPage(true); @@ -158,7 +161,7 @@ export const SingleSignInPage = ({ }; const [_, { execute: checkAuthErrors }] = useAsync(async () => { - if (searchParams.get('error') !== 'false') { + if (hasErrorSearchParam) { const errorResponse = await authErrorApi.getSignInAuthError(); if (errorResponse) { setError(errorResponse); diff --git a/packages/core-components/src/layout/SignInPage/providers.tsx b/packages/core-components/src/layout/SignInPage/providers.tsx index 4b6c251e7b..1cf4ad54bf 100644 --- a/packages/core-components/src/layout/SignInPage/providers.tsx +++ b/packages/core-components/src/layout/SignInPage/providers.tsx @@ -92,10 +92,13 @@ export const useSignInProviders = ( const authErrorApi = useApi(authErrorApiRef); const [loading, setLoading] = useState(true); + // User was redirected back to sign in page with error from auth redirect flow const [searchParams, _setSearchParams] = useSearchParams(); + const errorParam = searchParams.get('error'); + const hasErrorSearchParam = errorParam !== 'false' && errorParam !== null; const [_, { execute: checkAuthErrors }] = useAsync(async () => { - if (searchParams.get('error') !== 'false') { + if (hasErrorSearchParam) { const errorResponse = await authErrorApi.getSignInAuthError(); if (errorResponse) { errorApi.post(errorResponse); From d8e6b69baeb028abb87ce1d86013d7cea12f0a4d Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Tue, 13 Aug 2024 23:43:41 -0400 Subject: [PATCH 022/291] change api auth utility to hook Signed-off-by: Stephen Glass --- packages/app-defaults/src/defaults/apis.ts | 9 --- packages/core-app-api/api-report.md | 9 --- packages/core-app-api/package.json | 1 - .../src/apis/implementations/index.ts | 1 - packages/core-components/package.json | 1 + .../src/layout/SignInPage/SignInPage.tsx | 27 ++++--- .../src/layout/SignInPage/providers.tsx | 29 +++---- packages/core-plugin-api/api-report.md | 8 -- .../src/apis/definitions/AuthErrorApi.ts | 45 ----------- .../src/apis/definitions/index.ts | 1 - plugins/auth-react/src/hooks/index.ts | 1 + .../src/hooks/useSignInAuthError/index.tsx | 2 +- .../useSignInAuthError.test.tsx | 76 ++++++++++--------- .../useSignInAuthError/useSignInAuthError.tsx | 29 ++++--- yarn.lock | 2 +- 15 files changed, 86 insertions(+), 155 deletions(-) delete mode 100644 packages/core-plugin-api/src/apis/definitions/AuthErrorApi.ts rename packages/core-app-api/src/apis/implementations/AuthErrorApi/index.ts => plugins/auth-react/src/hooks/useSignInAuthError/index.tsx (91%) rename packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.test.ts => plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.test.tsx (53%) rename packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.ts => plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.tsx (59%) diff --git a/packages/app-defaults/src/defaults/apis.ts b/packages/app-defaults/src/defaults/apis.ts index 84902b9f52..4e9e1a492c 100644 --- a/packages/app-defaults/src/defaults/apis.ts +++ b/packages/app-defaults/src/defaults/apis.ts @@ -35,7 +35,6 @@ import { createFetchApi, FetchMiddlewares, VMwareCloudAuth, - SignInAuthErrorApi, } from '@backstage/core-app-api'; import { @@ -59,7 +58,6 @@ import { bitbucketServerAuthApiRef, atlassianAuthApiRef, vmwareCloudAuthApiRef, - authErrorApiRef, } from '@backstage/core-plugin-api'; import { permissionApiRef, @@ -289,11 +287,4 @@ export const apis = [ factory: ({ config, discovery, identity }) => IdentityPermissionApi.create({ config, discovery, identity }), }), - createApiFactory({ - api: authErrorApiRef, - deps: { - discovery: discoveryApiRef, - }, - factory: ({ discovery }) => SignInAuthErrorApi.create({ discovery }), - }), ]; diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index ae9d709c88..8b8cc991c8 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -16,7 +16,6 @@ import { AppConfig } from '@backstage/config'; import { AppTheme } from '@backstage/core-plugin-api'; import { AppThemeApi } from '@backstage/core-plugin-api'; import { atlassianAuthApiRef } from '@backstage/core-plugin-api'; -import { AuthErrorApi } from '@backstage/core-plugin-api'; import { AuthProviderInfo } from '@backstage/core-plugin-api'; import { AuthRequestOptions } from '@backstage/core-plugin-api'; import { BackstageIdentityApi } from '@backstage/core-plugin-api'; @@ -636,14 +635,6 @@ export class SamlAuth signOut(): Promise; } -// @public -export class SignInAuthErrorApi implements AuthErrorApi { - // (undocumented) - static create(options: { discovery: DiscoveryApi }): SignInAuthErrorApi; - // (undocumented) - getSignInAuthError(): Promise; -} - // @public export type SignInPageProps = PropsWithChildren<{ onSignInSuccess(identityApi: IdentityApi): void; diff --git a/packages/core-app-api/package.json b/packages/core-app-api/package.json index 6025fd5677..0f78b11f98 100644 --- a/packages/core-app-api/package.json +++ b/packages/core-app-api/package.json @@ -44,7 +44,6 @@ "dependencies": { "@backstage/config": "workspace:^", "@backstage/core-plugin-api": "workspace:^", - "@backstage/errors": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", "@types/prop-types": "^15.7.3", diff --git a/packages/core-app-api/src/apis/implementations/index.ts b/packages/core-app-api/src/apis/implementations/index.ts index 7643dfcd82..1c79d3d164 100644 --- a/packages/core-app-api/src/apis/implementations/index.ts +++ b/packages/core-app-api/src/apis/implementations/index.ts @@ -30,4 +30,3 @@ export * from './FeatureFlagsApi'; export * from './FetchApi'; export * from './OAuthRequestApi'; export * from './StorageApi'; -export * from './AuthErrorApi'; diff --git a/packages/core-components/package.json b/packages/core-components/package.json index a2688a0037..49cecfbb21 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -57,6 +57,7 @@ "@backstage/config": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/plugin-auth-react": "workspace:^", "@backstage/theme": "workspace:^", "@backstage/version-bridge": "workspace:^", "@date-io/core": "^1.3.13", diff --git a/packages/core-components/src/layout/SignInPage/SignInPage.tsx b/packages/core-components/src/layout/SignInPage/SignInPage.tsx index 932266cdc7..d7e8d1fcf6 100644 --- a/packages/core-components/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core-components/src/layout/SignInPage/SignInPage.tsx @@ -19,14 +19,13 @@ import { configApiRef, SignInPageProps, useApi, - authErrorApiRef, } from '@backstage/core-plugin-api'; import { UserIdentity } from './UserIdentity'; import Button from '@material-ui/core/Button'; import Grid from '@material-ui/core/Grid'; import Typography from '@material-ui/core/Typography'; -import React, { useState } from 'react'; -import { useAsync, useMountEffect } from '@react-hookz/web'; +import React, { useEffect, useState } from 'react'; +import { useMountEffect } from '@react-hookz/web'; import { Progress } from '../../components/Progress'; import { Content } from '../Content/Content'; import { ContentHeader } from '../ContentHeader/ContentHeader'; @@ -39,6 +38,7 @@ import { IdentityProviders, SignInProviderConfig } from './types'; import { coreComponentsTranslationRef } from '../../translation'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { useSearchParams } from 'react-router-dom'; +import { useSignInAuthError } from '@backstage/plugin-auth-react'; type MultiSignInPageProps = SignInPageProps & { providers: IdentityProviders; @@ -99,7 +99,7 @@ export const SingleSignInPage = ({ const classes = useStyles(); const authApi = useApi(provider.apiRef); const configApi = useApi(configApiRef); - const authErrorApi = useApi(authErrorApiRef); + const { error: signInError, checkAuthError } = useSignInAuthError(); const { t } = useTranslationRef(coreComponentsTranslationRef); const [error, setError] = useState(); @@ -160,20 +160,19 @@ export const SingleSignInPage = ({ } }; - const [_, { execute: checkAuthErrors }] = useAsync(async () => { - if (hasErrorSearchParam) { - const errorResponse = await authErrorApi.getSignInAuthError(); - if (errorResponse) { - setError(errorResponse); - } - } - }); - useMountEffect(() => { - checkAuthErrors(); + if (hasErrorSearchParam) { + checkAuthError(); + } login({ checkExisting: true }); }); + useEffect(() => { + if (signInError) { + setError(signInError); + } + }, [signInError]); + return showLoginPage ? (

diff --git a/packages/core-components/src/layout/SignInPage/providers.tsx b/packages/core-components/src/layout/SignInPage/providers.tsx index 1cf4ad54bf..8d34d8d9de 100644 --- a/packages/core-components/src/layout/SignInPage/providers.tsx +++ b/packages/core-components/src/layout/SignInPage/providers.tsx @@ -14,14 +14,19 @@ * limitations under the License. */ -import React, { useLayoutEffect, useState, useMemo, useCallback } from 'react'; +import React, { + useLayoutEffect, + useState, + useMemo, + useCallback, + useEffect, +} from 'react'; import { SignInPageProps, useApi, useApiHolder, errorApiRef, IdentityApi, - authErrorApiRef, } from '@backstage/core-plugin-api'; import { IdentityProviders, @@ -33,7 +38,8 @@ import { guestProvider } from './guestProvider'; import { customProvider } from './customProvider'; import { IdentityApiSignOutProxy } from './IdentityApiSignOutProxy'; import { useSearchParams } from 'react-router-dom'; -import { useMountEffect, useAsync } from '@react-hookz/web'; +import { useMountEffect } from '@react-hookz/web'; +import { useSignInAuthError } from '@backstage/plugin-auth-react'; const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider'; @@ -89,7 +95,7 @@ export const useSignInProviders = ( ) => { const errorApi = useApi(errorApiRef); const apiHolder = useApiHolder(); - const authErrorApi = useApi(authErrorApiRef); + const { error: signInError, checkAuthError } = useSignInAuthError(); const [loading, setLoading] = useState(true); // User was redirected back to sign in page with error from auth redirect flow @@ -97,16 +103,13 @@ export const useSignInProviders = ( const errorParam = searchParams.get('error'); const hasErrorSearchParam = errorParam !== 'false' && errorParam !== null; - const [_, { execute: checkAuthErrors }] = useAsync(async () => { - if (hasErrorSearchParam) { - const errorResponse = await authErrorApi.getSignInAuthError(); - if (errorResponse) { - errorApi.post(errorResponse); - } - } - }); + useMountEffect(() => hasErrorSearchParam && checkAuthError()); - useMountEffect(checkAuthErrors); + useEffect(() => { + if (signInError) { + errorApi.post(signInError); + } + }, [errorApi, signInError]); // This decorates the result with sign out logic from this hook const handleWrappedResult = useCallback( diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 22e254cc29..134b338c92 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -194,14 +194,6 @@ export function attachComponentData

( data: unknown, ): void; -// @public -export type AuthErrorApi = { - getSignInAuthError(): Promise; -}; - -// @public -export const authErrorApiRef: ApiRef; - // @public export type AuthProviderInfo = { id: string; diff --git a/packages/core-plugin-api/src/apis/definitions/AuthErrorApi.ts b/packages/core-plugin-api/src/apis/definitions/AuthErrorApi.ts deleted file mode 100644 index 558c1202a8..0000000000 --- a/packages/core-plugin-api/src/apis/definitions/AuthErrorApi.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2024 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 { ApiRef, createApiRef } from '../system'; - -/** - * A wrapper for retrieving any errors caught in authentication redirect flow - * - * @public - */ -export type AuthErrorApi = { - /** - * Get any caught errors during the auth redirect flow - */ - getSignInAuthError(): Promise; -}; - -/** - * The {@link ApiRef} of {@link AuthErrorApi}. - * - * @remarks - * - * This is a wrapper that uses fetch to retrieve any caught errors - * in the authentication redirect flow process. This API calls a - * Backstage endpoint to read any error stored in authentication error - * cookie and returns it to the user. - * - * @public - */ -export const authErrorApiRef: ApiRef = createApiRef({ - id: 'core.auth-error', -}); diff --git a/packages/core-plugin-api/src/apis/definitions/index.ts b/packages/core-plugin-api/src/apis/definitions/index.ts index 406dc1980e..67d442587d 100644 --- a/packages/core-plugin-api/src/apis/definitions/index.ts +++ b/packages/core-plugin-api/src/apis/definitions/index.ts @@ -33,4 +33,3 @@ export * from './FetchApi'; export * from './IdentityApi'; export * from './OAuthRequestApi'; export * from './StorageApi'; -export * from './AuthErrorApi'; diff --git a/plugins/auth-react/src/hooks/index.ts b/plugins/auth-react/src/hooks/index.ts index 1257334498..0e87bab67f 100644 --- a/plugins/auth-react/src/hooks/index.ts +++ b/plugins/auth-react/src/hooks/index.ts @@ -18,3 +18,4 @@ // which hooks are public API and should be exported from the package. export * from './useCookieAuthRefresh'; +export * from './useSignInAuthError'; diff --git a/packages/core-app-api/src/apis/implementations/AuthErrorApi/index.ts b/plugins/auth-react/src/hooks/useSignInAuthError/index.tsx similarity index 91% rename from packages/core-app-api/src/apis/implementations/AuthErrorApi/index.ts rename to plugins/auth-react/src/hooks/useSignInAuthError/index.tsx index 6f3a3ae269..549b5b9f1f 100644 --- a/packages/core-app-api/src/apis/implementations/AuthErrorApi/index.ts +++ b/plugins/auth-react/src/hooks/useSignInAuthError/index.tsx @@ -14,4 +14,4 @@ * limitations under the License. */ -export { SignInAuthErrorApi } from './SignInAuthErrorApi'; +export { useSignInAuthError } from './useSignInAuthError'; diff --git a/packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.test.ts b/plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.test.tsx similarity index 53% rename from packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.test.ts rename to plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.test.tsx index c4d6fe83de..a5ac57ec83 100644 --- a/packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.test.ts +++ b/plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.test.tsx @@ -14,29 +14,20 @@ * limitations under the License. */ -import { DiscoveryApi } from '@backstage/core-plugin-api'; +import React from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { discoveryApiRef } from '@backstage/core-plugin-api'; +import { TestApiProvider } from '@backstage/test-utils'; +import { useSignInAuthError } from './useSignInAuthError'; import { serializeError } from '@backstage/errors'; -import { SignInAuthErrorApi } from './SignInAuthErrorApi'; -describe('SignInAuthErrorApi', () => { - const mockDiscoveryApi = { - getBaseUrl: jest.fn(), - } as unknown as jest.Mocked; +describe('useCookieAuthRefresh', () => { + const discoveryApiMock = { + getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000/api/auth'), + }; beforeEach(() => { - jest.resetAllMocks(); - }); - - it('should create an instance of SignInAuthErrorApi', async () => { - const api = SignInAuthErrorApi.create({ - discovery: mockDiscoveryApi, - }); - - mockDiscoveryApi.getBaseUrl.mockResolvedValue( - 'http://localhost:7007/api/auth', - ); - - expect(api).toBeInstanceOf(SignInAuthErrorApi); + jest.clearAllMocks(); }); it('should return an error when the cookie returns an error object', async () => { @@ -45,50 +36,63 @@ describe('SignInAuthErrorApi', () => { message: 'This is a test error', }; const serializedError = serializeError(errorObject); - mockDiscoveryApi.getBaseUrl.mockResolvedValue( - 'http://localhost:7000/api/auth', - ); global.fetch = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue(serializedError), } as unknown as Response); - const api = SignInAuthErrorApi.create({ discovery: mockDiscoveryApi }); - const error = await api.getSignInAuthError(); + const { result } = renderHook(() => useSignInAuthError(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); - expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('auth'); + await act(async () => { + result.current.checkAuthError(); + }); + + expect(discoveryApiMock.getBaseUrl).toHaveBeenCalledWith('auth'); expect(fetch).toHaveBeenCalledWith( 'http://localhost:7000/api/auth/.backstage/error', { credentials: 'include', }, ); - expect(error).toBeInstanceOf(Error); - expect((error as Error).name).toEqual(errorObject.name); - expect((error as Error).message).toEqual(errorObject.message); + expect(result.current.error).toBeInstanceOf(Error); + expect((result.current.error as Error).name).toEqual(errorObject.name); + expect((result.current.error as Error).message).toEqual( + errorObject.message, + ); }); it('should return undefined when the backend does not return an error object', async () => { - mockDiscoveryApi.getBaseUrl.mockResolvedValue( - 'http://localhost:7000/api/auth', - ); - global.fetch = jest.fn().mockResolvedValue({ ok: true, json: jest.fn().mockResolvedValue(undefined), } as unknown as Response); - const api = SignInAuthErrorApi.create({ discovery: mockDiscoveryApi }); - const error = await api.getSignInAuthError(); + const { result } = renderHook(() => useSignInAuthError(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); - expect(mockDiscoveryApi.getBaseUrl).toHaveBeenCalledWith('auth'); + await act(async () => { + result.current.checkAuthError(); + }); + + expect(discoveryApiMock.getBaseUrl).toHaveBeenCalledWith('auth'); expect(fetch).toHaveBeenCalledWith( 'http://localhost:7000/api/auth/.backstage/error', { credentials: 'include', }, ); - expect(error).toBeUndefined(); + expect(result.current.error).toBeUndefined(); }); }); diff --git a/packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.ts b/plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.tsx similarity index 59% rename from packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.ts rename to plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.tsx index 77b3c53f14..8d4af1f555 100644 --- a/packages/core-app-api/src/apis/implementations/AuthErrorApi/SignInAuthErrorApi.ts +++ b/plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.tsx @@ -14,25 +14,20 @@ * limitations under the License. */ -import { DiscoveryApi, AuthErrorApi } from '@backstage/core-plugin-api'; +import { discoveryApiRef, useApi } from '@backstage/core-plugin-api'; +import { useAsync } from '@react-hookz/web'; import { deserializeError } from '@backstage/errors'; -/** - * The default implementation of the AuthErrorApi, which simply calls auth error endpoint. - * @public - */ -export class SignInAuthErrorApi implements AuthErrorApi { - private constructor(private readonly discoveryApi: DiscoveryApi) {} +export function useSignInAuthError(): { + error: Error | undefined; + checkAuthError: () => void; +} { + const discoveryApi = useApi(discoveryApiRef); - static create(options: { discovery: DiscoveryApi }) { - const { discovery } = options; - return new SignInAuthErrorApi(discovery); - } + const [state, { execute: checkAuthError }] = useAsync(async () => { + const baseUrl = await discoveryApi.getBaseUrl('auth'); - async getSignInAuthError(): Promise { - const baseUrl = await this.discoveryApi.getBaseUrl('auth'); - - // use native fetch instead of depending on fetchApi because + // use native fetch instead of fetchApi because // we are not signed in and are calling an unauthenticated endpoint const response = await fetch(`${baseUrl}/.backstage/error`, { credentials: 'include', @@ -40,5 +35,7 @@ export class SignInAuthErrorApi implements AuthErrorApi { const data = await response.json(); return data ? deserializeError(data) : undefined; - } + }); + + return { error: state.result, checkAuthError }; } diff --git a/yarn.lock b/yarn.lock index cb41faa20f..dfd1cffd9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4123,7 +4123,6 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/core-plugin-api": "workspace:^" - "@backstage/errors": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@backstage/version-bridge": "workspace:^" @@ -4288,6 +4287,7 @@ __metadata: "@backstage/core-app-api": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/plugin-auth-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" "@backstage/version-bridge": "workspace:^" From d2757e9ca02152d1506d27c012019b7c9edb0651 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Tue, 13 Aug 2024 23:44:14 -0400 Subject: [PATCH 023/291] update test Signed-off-by: Stephen Glass --- packages/frontend-app-api/src/wiring/createApp.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx index 6bff2d6d10..94254a2e01 100644 --- a/packages/frontend-app-api/src/wiring/createApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx @@ -293,7 +293,6 @@ describe('createApp', () => { - ] " `); From f82952433fdb8e98eaa943a6ccc173a92e8f39e8 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Tue, 13 Aug 2024 23:52:51 -0400 Subject: [PATCH 024/291] update changeset Signed-off-by: Stephen Glass --- .changeset/violet-beds-promise.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.changeset/violet-beds-promise.md b/.changeset/violet-beds-promise.md index ea5f478543..5fd4890ffa 100644 --- a/.changeset/violet-beds-promise.md +++ b/.changeset/violet-beds-promise.md @@ -1,17 +1,14 @@ --- '@backstage/core-components': patch -'@backstage/core-plugin-api': patch -'@backstage/app-defaults': patch -'@backstage/core-app-api': patch '@backstage/plugin-auth-backend': patch '@backstage/plugin-auth-node': patch +'@backstage/plugin-auth-react': patch --- -Fix error handling using authentication redirect flow via `enableExperimentalRedirectFlow` config. If an error is caught during authentication, the user is redirected back to app origin with `?error=true` query parameter. A cookie is also set in the redirect which contains the error message. The error can be fetched from any custom sign in page using the new `AuthErrorApi`. Example: +Fix error handling using authentication redirect flow via `enableExperimentalRedirectFlow` config. If an error is caught during authentication, the user is redirected back to app origin with `?error=true` query parameter. A cookie is also set in the redirect which contains the error message. The error can be fetched from any custom sign in page using the new `useSignInAuthError` hook.: ```ts -import { useApi, authErrorApiRef } from '@backstage/core-plugin-api'; +import { useSignInAuthError } from '@backstage/plugin-auth-react'; -const authErrorApi = useApi(authErrorApiRef); -const errorResponse = await authErrorApi.getSignInAuthError(); +const { error, checkAuthError } = useSignInAuthError(); ``` From 2ae5f4b2f72a729b61b51698c9693dc810f93da4 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Wed, 14 Aug 2024 00:11:46 -0400 Subject: [PATCH 025/291] api report Signed-off-by: Stephen Glass --- plugins/auth-react/api-report.md | 6 ++++++ .../src/hooks/useSignInAuthError/useSignInAuthError.tsx | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/plugins/auth-react/api-report.md b/plugins/auth-react/api-report.md index d5206261d2..52e6c6bc3a 100644 --- a/plugins/auth-react/api-report.md +++ b/plugins/auth-react/api-report.md @@ -36,4 +36,10 @@ export function useCookieAuthRefresh(options: { pluginId: string }): expiresAt: string; }; }; + +// @public +export function useSignInAuthError(): { + error: Error | undefined; + checkAuthError: () => void; +}; ``` diff --git a/plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.tsx b/plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.tsx index 8d4af1f555..fe146fedca 100644 --- a/plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.tsx +++ b/plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.tsx @@ -18,6 +18,10 @@ import { discoveryApiRef, useApi } from '@backstage/core-plugin-api'; import { useAsync } from '@react-hookz/web'; import { deserializeError } from '@backstage/errors'; +/** + * @public + * A hook that will fetch any sign in auth error in redirect auth flow + */ export function useSignInAuthError(): { error: Error | undefined; checkAuthError: () => void; From 823484ba62297a701bc0eb769003c5e492da91c4 Mon Sep 17 00:00:00 2001 From: JounQin Date: Fri, 9 Aug 2024 01:07:39 +0800 Subject: [PATCH 026/291] feat: experimentally support using rspack instead close #21682 Signed-off-by: JounQin --- packages/cli/package.json | 15 + .../cli/src/commands/build/buildFrontend.ts | 4 +- packages/cli/src/commands/build/command.ts | 4 + packages/cli/src/lib/bundler/bundle.ts | 16 +- packages/cli/src/lib/bundler/config.ts | 160 ++++--- packages/cli/src/lib/bundler/optimization.ts | 24 +- packages/cli/src/lib/bundler/server.ts | 14 +- packages/cli/src/lib/bundler/transforms.ts | 23 +- packages/cli/src/lib/bundler/types.ts | 3 + yarn.lock | 447 +++++++++++++++--- 10 files changed, 565 insertions(+), 145 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index de38b0c656..e2726f358a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -166,6 +166,9 @@ "@backstage/dev-utils": "workspace:^", "@backstage/test-utils": "workspace:^", "@backstage/theme": "workspace:^", + "@rspack/core": "^0.7.5", + "@rspack/dev-server": "^0.7.5", + "@rspack/plugin-react-refresh": "^0.7.5", "@types/cross-spawn": "^6.0.2", "@types/diff": "^5.0.0", "@types/ejs": "^3.1.3", @@ -191,12 +194,24 @@ "vite-plugin-node-polyfills": "^0.22.0" }, "peerDependencies": { + "@rspack/core": "^0.7.5", + "@rspack/dev-server": "^0.7.5", + "@rspack/plugin-react-refresh": "^0.7.5", "@vitejs/plugin-react": "^4.0.4", "vite": "^4.4.9", "vite-plugin-html": "^3.2.0", "vite-plugin-node-polyfills": "^0.22.0" }, "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "@rspack/dev-server": { + "optional": true + }, + "@rspack/plugin-react-refresh": { + "optional": true + }, "@vitejs/plugin-react": { "optional": true }, diff --git a/packages/cli/src/commands/build/buildFrontend.ts b/packages/cli/src/commands/build/buildFrontend.ts index f29e0bdd38..5cb6d3b526 100644 --- a/packages/cli/src/commands/build/buildFrontend.ts +++ b/packages/cli/src/commands/build/buildFrontend.ts @@ -25,10 +25,11 @@ interface BuildAppOptions { writeStats: boolean; configPaths: string[]; isModuleFederationRemote?: true; + useRspack?: boolean; } export async function buildFrontend(options: BuildAppOptions) { - const { targetDir, writeStats, configPaths } = options; + const { targetDir, writeStats, configPaths, useRspack } = options; const { name } = await fs.readJson(resolvePath(targetDir, 'package.json')); await buildBundle({ @@ -44,5 +45,6 @@ export async function buildFrontend(options: BuildAppOptions) { args: configPaths, fromPackage: name, })), + useRspack, }); } diff --git a/packages/cli/src/commands/build/command.ts b/packages/cli/src/commands/build/command.ts index c0ad3c912b..4f99ec6a00 100644 --- a/packages/cli/src/commands/build/command.ts +++ b/packages/cli/src/commands/build/command.ts @@ -25,6 +25,8 @@ import { isValidUrl } from '../../lib/urls'; import chalk from 'chalk'; export async function command(opts: OptionValues): Promise { + const useRspack = !!process.env.EXPERIMENTAL_RSPACK; + const role = await findRoleFromCommand(opts); if (role === 'frontend' || role === 'backend') { @@ -40,6 +42,7 @@ export async function command(opts: OptionValues): Promise { targetDir: paths.targetDir, configPaths, writeStats: Boolean(opts.stats), + useRspack, }); } return buildBackend({ @@ -62,6 +65,7 @@ export async function command(opts: OptionValues): Promise { configPaths: [], writeStats: Boolean(opts.stats), isModuleFederationRemote: true, + useRspack, }); } diff --git a/packages/cli/src/lib/bundler/bundle.ts b/packages/cli/src/lib/bundler/bundle.ts index 0aa91a90a6..0a36f54053 100644 --- a/packages/cli/src/lib/bundler/bundle.ts +++ b/packages/cli/src/lib/bundler/bundle.ts @@ -38,7 +38,7 @@ function applyContextToError(error: string, moduleName: string): string { } export async function buildBundle(options: BuildOptions) { - const { statsJsonEnabled, schema: configSchema } = options; + const { statsJsonEnabled, schema: configSchema, useRspack } = options; const paths = resolveBundlingPaths(options); const publicPaths = await resolveOptionalBundlingPaths({ @@ -54,7 +54,7 @@ export async function buildBundle(options: BuildOptions) { getFrontendAppConfigs: () => options.frontendAppConfigs, }; - const configs = []; + const configs: webpack.Configuration[] = []; if (options.moduleFederation?.mode === 'remote') { // Package detection is disabled for remote bundles @@ -119,7 +119,7 @@ export async function buildBundle(options: BuildOptions) { ); } - const { stats } = await build(configs, isCi); + const { stats } = await build(configs, isCi, useRspack); if (!stats) { throw new Error('No stats returned'); @@ -152,10 +152,16 @@ export async function buildBundle(options: BuildOptions) { } } -async function build(configs: webpack.Configuration[], isCi: boolean) { +async function build( + configs: webpack.Configuration[], + isCi: boolean, + useRspack?: boolean, +) { + const bundler: typeof webpack = useRspack ? require('@rspack/core') : webpack; + const stats = await new Promise( (resolve, reject) => { - webpack(configs, (err, buildStats) => { + bundler(configs, (err, buildStats) => { if (err) { if (err.message) { const { errors } = formatWebpackMessages({ diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index da7b378fbd..69c673b833 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -21,7 +21,7 @@ import { } from './types'; import { posix as posixPath, resolve as resolvePath, dirname } from 'path'; import chalk from 'chalk'; -import webpack, { ProvidePlugin } from 'webpack'; +import webpack from 'webpack'; import { BackstagePackage } from '@backstage/cli-node'; import { BundlingPaths } from './paths'; @@ -132,6 +132,7 @@ export async function createConfig( frontendConfig, moduleFederation, publicSubPath = '', + useRspack, } = options; const { plugins, loaders } = transforms(options); @@ -152,15 +153,20 @@ export async function createConfig( options.moduleFederation, ); - plugins.push( - new ReactRefreshPlugin({ - overlay: { - sockProtocol: 'ws', - sockHost: host, - sockPort: port, - }, - }), - ); + if (useRspack) { + const RspackReactRefreshPlugin = require('@rspack/plugin-react-refresh'); + plugins.push(new RspackReactRefreshPlugin()); + } else { + plugins.push( + new ReactRefreshPlugin({ + overlay: { + sockProtocol: 'ws', + sockHost: host, + sockPort: port, + }, + }), + ); + } } if (checksEnabled) { @@ -175,18 +181,24 @@ export async function createConfig( ); } + const rspack = useRspack + ? (require('@rspack/core') as typeof import('@rspack/core').rspack) + : undefined; + const bundler = useRspack ? (rspack as unknown as typeof webpack) : webpack; + // TODO(blam): process is no longer auto polyfilled by webpack in v5. // we use the provide plugin to provide this polyfill, but lets look // to remove this eventually! plugins.push( - new ProvidePlugin({ - process: require.resolve('process/browser'), + new bundler.ProvidePlugin({ + process: require.resolve('process/browser'),, Buffer: ['buffer', 'Buffer'], }), ); if (options.moduleFederation?.mode !== 'remote') { plugins.push( + // `rspack.HtmlRspackPlugin` does not support object type `templateParameters` value, `frontendConfig` in this case new HtmlWebpackPlugin({ meta: { 'backstage-app-mode': options?.appMode ?? 'public', @@ -203,8 +215,13 @@ export async function createConfig( if (options.moduleFederation) { const isRemote = options.moduleFederation?.mode === 'remote'; + const AdaptedModuleFederationPlugin = useRspack + ? (rspack!.container + .ModuleFederationPlugin as unknown as typeof ModuleFederationPlugin) + : ModuleFederationPlugin; + plugins.push( - new ModuleFederationPlugin({ + new AdaptedModuleFederationPlugin({ ...(isRemote && { filename: 'remoteEntry.js', exposes: { @@ -264,13 +281,17 @@ export async function createConfig( } const buildInfo = await readBuildInfo(); + plugins.push( - new webpack.DefinePlugin({ + new bundler.DefinePlugin({ 'process.env.BUILD_INFO': JSON.stringify(buildInfo), - 'process.env.APP_CONFIG': webpack.DefinePlugin.runtimeValue( - () => JSON.stringify(options.getFrontendAppConfigs()), - true, - ), + 'process.env.APP_CONFIG': useRspack + ? // FIXME: see also https://github.com/web-infra-dev/rspack/issues/5606 + JSON.stringify(options.getFrontendAppConfigs()) + : bundler.DefinePlugin.runtimeValue( + () => JSON.stringify(options.getFrontendAppConfigs()), + true, + ), // This allows for conditional imports of react-dom/client, since there's no way // to check for presence of it in source code without module resolution errors. 'process.env.HAS_REACT_DOM_CLIENT': JSON.stringify(hasReactDomClient()), @@ -280,13 +301,17 @@ export async function createConfig( // These files are required by the transpiled code when using React Refresh. // They need to be excluded to the module scope plugin which ensures that files // that exist in the package are required. - const reactRefreshFiles = [ - require.resolve( - '@pmmmwh/react-refresh-webpack-plugin/lib/runtime/RefreshUtils.js', - ), - require.resolve('@pmmmwh/react-refresh-webpack-plugin/overlay/index.js'), - require.resolve('react-refresh'), - ]; + const reactRefreshFiles = useRspack + ? [] + : [ + require.resolve( + '@pmmmwh/react-refresh-webpack-plugin/lib/runtime/RefreshUtils.js', + ), + require.resolve( + '@pmmmwh/react-refresh-webpack-plugin/overlay/index.js', + ), + require.resolve('react-refresh'), + ]; const mode = isDev ? 'development' : 'production'; const optimization = optimizationConfig(options); @@ -315,16 +340,19 @@ export async function createConfig( // Instead, provide a custom definition which always uses "development" if // the module is part of `react` or `react-dom`, and `config.mode` otherwise. plugins.push( - new webpack.DefinePlugin({ - 'process.env.NODE_ENV': webpack.DefinePlugin.runtimeValue( - ({ module }) => { - if (reactPackageDirs.some(val => module.resource.startsWith(val))) { - return '"development"'; - } + new bundler.DefinePlugin({ + 'process.env.NODE_ENV': useRspack + ? // FIXME: see also https://github.com/web-infra-dev/rspack/issues/5606 + JSON.stringify(mode) + : webpack.DefinePlugin.runtimeValue(({ module }) => { + if ( + reactPackageDirs.some(val => module.resource.startsWith(val)) + ) { + return '"development"'; + } - return `"${mode}"`; - }, - ), + return `"${mode}"`; + }), }), ); } @@ -362,13 +390,16 @@ export async function createConfig( http: false, util: require.resolve('util/'), }, - plugins: [ - new LinkedPackageResolvePlugin(paths.rootNodeModules, externalPkgs), - new ModuleScopePlugin( - [paths.targetSrc, paths.targetDev], - [paths.targetPackageJson, ...reactRefreshFiles], - ), - ], + // FIXME: see also https://github.com/web-infra-dev/rspack/issues/3408 + ...(!useRspack && { + plugins: [ + new LinkedPackageResolvePlugin(paths.rootNodeModules, externalPkgs), + new ModuleScopePlugin( + [paths.targetSrc, paths.targetDev], + [paths.targetPackageJson, ...reactRefreshFiles], + ), + ], + }), }, module: { rules: loaders, @@ -396,16 +427,20 @@ export async function createConfig( lazyCompilation: yn(process.env.EXPERIMENTAL_LAZY_COMPILATION), }, plugins, - ...(withCache - ? { - cache: { - type: 'filesystem', - buildDependencies: { - config: [__filename], - }, - }, - } - : {}), + ...(withCache && { + cache: { + type: 'filesystem', + buildDependencies: { + config: [__filename], + }, + }, + }), + ...(useRspack && { + // We're still using `style-loader` for custom `insert` option + experiments: { + css: false, + }, + }), }; } @@ -413,7 +448,7 @@ export async function createBackendConfig( paths: BundlingPaths, options: BackendBundlingOptions, ): Promise { - const { checksEnabled, isDev } = options; + const { checksEnabled, isDev, useRspack } = options; // Find all local monorepo packages and their node_modules, and mark them as external. const { packages } = await getPackages(cliPaths.targetDir); @@ -484,13 +519,16 @@ export async function createBackendConfig( extensions: ['.ts', '.mjs', '.js', '.json'], mainFields: ['main'], modules: [paths.rootNodeModules, ...moduleDirs], - plugins: [ - new LinkedPackageResolvePlugin(paths.rootNodeModules, externalPkgs), - new ModuleScopePlugin( - [paths.targetSrc, paths.targetDev], - [paths.targetPackageJson], - ), - ], + // FIXME: see also https://github.com/web-infra-dev/rspack/issues/3408 + ...(!useRspack && { + plugins: [ + new LinkedPackageResolvePlugin(paths.rootNodeModules, externalPkgs), + new ModuleScopePlugin( + [paths.targetSrc, paths.targetDev], + [paths.targetPackageJson], + ), + ], + }), }, module: { rules: loaders, @@ -517,7 +555,9 @@ export async function createBackendConfig( nodeArgs: runScriptNodeArgs.length > 0 ? runScriptNodeArgs : undefined, args: process.argv.slice(3), // drop `node backstage-cli backend:dev` }), - new webpack.HotModuleReplacementPlugin(), + new (useRspack + ? require('@rspack/core').rspack.HotModuleReplacementPlugin + : webpack.HotModuleReplacementPlugin)(), ...(checksEnabled ? [ new ForkTsCheckerWebpackPlugin({ diff --git a/packages/cli/src/lib/bundler/optimization.ts b/packages/cli/src/lib/bundler/optimization.ts index cf240b4791..1cef6b6205 100644 --- a/packages/cli/src/lib/bundler/optimization.ts +++ b/packages/cli/src/lib/bundler/optimization.ts @@ -22,22 +22,35 @@ const { EsbuildPlugin } = require('esbuild-loader'); export const optimization = ( options: BundlingOptions, ): WebpackOptionsNormalized['optimization'] => { - const { isDev } = options; + const { isDev, useRspack } = options; + + const extralOptions = useRspack + ? {} + : { maxAsyncRequests: Infinity, maxInitialRequests: Infinity }; + + const rspack = useRspack + ? (require('@rspack/core') as typeof import('@rspack/core').rspack) + : undefined; + + const MinifyPlugin = useRspack + ? rspack!.SwcJsMinimizerRspackPlugin + : EsbuildPlugin; return { minimize: !isDev, minimizer: [ - new EsbuildPlugin({ + new MinifyPlugin({ target: 'ES2022', format: 'iife', exclude: 'remoteEntry.js', }), // Avoid iife wrapping of module federation remote entry as it breaks the variable assignment - new EsbuildPlugin({ + new MinifyPlugin({ target: 'ES2022', format: undefined, include: 'remoteEntry.js', }), + useRspack && new rspack!.LightningCssMinimizerRspackPlugin(), ], runtimeChunk: 'single', splitChunks: { @@ -69,9 +82,8 @@ export const optimization = ( priority: 10, minSize: 100000, minChunks: 1, - maxAsyncRequests: Infinity, - maxInitialRequests: Infinity, - } as any, // filename is not included in type, but we need it + ...extralOptions, + }, // filename is not included in type, but we need it // Group together the smallest modules vendor: { chunks: 'initial', diff --git a/packages/cli/src/lib/bundler/server.ts b/packages/cli/src/lib/bundler/server.ts index 259f3263eb..25d3cbe321 100644 --- a/packages/cli/src/lib/bundler/server.ts +++ b/packages/cli/src/lib/bundler/server.ts @@ -106,12 +106,15 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be }, }); + const useRspack = !!process.env.EXPERIMENTAL_RSPACK; + const commonConfigOptions = { ...options, checksEnabled: options.checksEnabled, isDev: true, baseUrl: url, frontendConfig, + useRspack, getFrontendAppConfigs: () => { return latestFrontendAppConfigs; }, @@ -163,6 +166,11 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be root: paths.targetPath, }); } else { + const bundler = useRspack ? require('@rspack/core') : webpack; + const DevServer: typeof WebpackDevServer = useRspack + ? require('@rspack/dev-server').RspackDevServer + : WebpackDevServer; + const publicPaths = await resolveOptionalBundlingPaths({ entry: 'src/index-public-experimental', dist: 'dist/public', @@ -175,10 +183,10 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be ); } const compiler = publicPaths - ? webpack([config, await createConfig(publicPaths, commonConfigOptions)]) - : webpack(config); + ? bundler([config, await createConfig(publicPaths, commonConfigOptions)]) + : bundler(config); - webpackServer = new WebpackDevServer( + webpackServer = new DevServer( { hot: !process.env.CI, devMiddleware: { diff --git a/packages/cli/src/lib/bundler/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts index 302b2fbe68..a6a49895ef 100644 --- a/packages/cli/src/lib/bundler/transforms.ts +++ b/packages/cli/src/lib/bundler/transforms.ts @@ -26,10 +26,15 @@ type Transforms = { type TransformOptions = { isDev: boolean; isBackend?: boolean; + useRspack?: boolean; }; export const transforms = (options: TransformOptions): Transforms => { - const { isDev, isBackend } = options; + const { isDev, isBackend, useRspack } = options; + + const CssExtractRspackPlugin: typeof MiniCssExtractPlugin = useRspack + ? require('@rspack/core').CssExtractRspackPlugin + : MiniCssExtractPlugin; // This ensures that styles inserted from the style-loader and any // async style chunks are always given lower priority than JSS styles. @@ -54,7 +59,9 @@ export const transforms = (options: TransformOptions): Transforms => { exclude: /node_modules/, use: [ { - loader: require.resolve('swc-loader'), + loader: useRspack + ? 'builtin:swc-loader' + : require.resolve('swc-loader'), options: { jsc: { target: 'es2022', @@ -82,7 +89,9 @@ export const transforms = (options: TransformOptions): Transforms => { exclude: /node_modules/, use: [ { - loader: require.resolve('swc-loader'), + loader: useRspack + ? 'builtin:swc-loader' + : require.resolve('swc-loader'), options: { jsc: { target: 'es2022', @@ -115,7 +124,9 @@ export const transforms = (options: TransformOptions): Transforms => { test: [/\.icon\.svg$/], use: [ { - loader: require.resolve('swc-loader'), + loader: useRspack + ? 'builtin:swc-loader' + : require.resolve('swc-loader'), options: { jsc: { target: 'es2022', @@ -179,7 +190,7 @@ export const transforms = (options: TransformOptions): Transforms => { insert: insertBeforeJssStyles, }, } - : MiniCssExtractPlugin.loader, + : CssExtractRspackPlugin.loader, { loader: require.resolve('css-loader'), options: { @@ -194,7 +205,7 @@ export const transforms = (options: TransformOptions): Transforms => { if (!isDev) { plugins.push( - new MiniCssExtractPlugin({ + new CssExtractRspackPlugin({ filename: 'static/[name].[contenthash:8].css', chunkFilename: 'static/[name].[id].[contenthash:8].css', insert: insertBeforeJssStyles, // Only applies to async chunks diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts index f8e228c5c4..8b48976f9b 100644 --- a/packages/cli/src/lib/bundler/types.ts +++ b/packages/cli/src/lib/bundler/types.ts @@ -37,6 +37,7 @@ export type BundlingOptions = { // Mode that the app is running in, 'protected' or 'public', default is 'public' appMode?: string; moduleFederation?: ModuleFederationOptions; + useRspack?: boolean; }; export type ServeOptions = BundlingPathsOptions & { @@ -57,6 +58,7 @@ export type BuildOptions = BundlingPathsOptions & { frontendAppConfigs: AppConfig[]; fullConfig: Config; moduleFederation?: ModuleFederationOptions; + useRspack?: boolean; }; export type BackendBundlingOptions = { @@ -66,6 +68,7 @@ export type BackendBundlingOptions = { inspectEnabled: boolean; inspectBrkEnabled: boolean; require?: string; + useRspack?: boolean; }; export type BackendServeOptions = BundlingPathsOptions & { diff --git a/yarn.lock b/yarn.lock index 9c292f5903..7222777537 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3958,6 +3958,9 @@ __metadata: "@rollup/plugin-json": ^6.0.0 "@rollup/plugin-node-resolve": ^15.0.0 "@rollup/plugin-yaml": ^4.0.0 + "@rspack/core": ^0.7.5 + "@rspack/dev-server": ^0.7.5 + "@rspack/plugin-react-refresh": ^0.7.5 "@spotify/eslint-config-base": ^15.0.0 "@spotify/eslint-config-react": ^15.0.0 "@spotify/eslint-config-typescript": ^15.0.0 @@ -4073,11 +4076,20 @@ __metadata: yn: ^4.0.0 zod: ^3.22.4 peerDependencies: + "@rspack/core": ^0.7.5 + "@rspack/dev-server": ^0.7.5 + "@rspack/plugin-react-refresh": ^0.7.5 "@vitejs/plugin-react": ^4.0.4 vite: ^4.4.9 vite-plugin-html: ^3.2.0 vite-plugin-node-polyfills: ^0.22.0 peerDependenciesMeta: + "@rspack/core": + optional: true + "@rspack/dev-server": + optional: true + "@rspack/plugin-react-refresh": + optional: true "@vitejs/plugin-react": optional: true vite: @@ -11270,6 +11282,16 @@ __metadata: languageName: node linkType: hard +"@module-federation/runtime-tools@npm:0.1.6": + version: 0.1.6 + resolution: "@module-federation/runtime-tools@npm:0.1.6" + dependencies: + "@module-federation/runtime": 0.1.6 + "@module-federation/webpack-bundler-runtime": 0.1.6 + checksum: a902fe7fd07707be566fec6620c71d597311cee02cc2c2605b9b796f9aad07fcc3c60939efb2e139b01b28ac599d610f5dbc054c555915cd9e5fcc9324598413 + languageName: node + linkType: hard + "@module-federation/runtime-tools@npm:0.3.5": version: 0.3.5 resolution: "@module-federation/runtime-tools@npm:0.3.5" @@ -11280,6 +11302,15 @@ __metadata: languageName: node linkType: hard +"@module-federation/runtime@npm:0.1.6": + version: 0.1.6 + resolution: "@module-federation/runtime@npm:0.1.6" + dependencies: + "@module-federation/sdk": 0.1.6 + checksum: c564636edd5c1abf5ddf54a6d0dde8fcad1a72d2561163a841f08c354fb1a6e2c69e89d0ec3e5412d55556ea19057ad1980962fbd571aca5a0fb7945e60c0822 + languageName: node + linkType: hard + "@module-federation/runtime@npm:0.3.5": version: 0.3.5 resolution: "@module-federation/runtime@npm:0.3.5" @@ -11289,6 +11320,13 @@ __metadata: languageName: node linkType: hard +"@module-federation/sdk@npm:0.1.6": + version: 0.1.6 + resolution: "@module-federation/sdk@npm:0.1.6" + checksum: 99442f2269e916af78f9f77f7fc71a40e83e4dec5408d6ea8b1f053662005e5717340f91c666cce178e0d9ab92b13826f7cba5e00417bce2a3ee67c0face6ac5 + languageName: node + linkType: hard + "@module-federation/sdk@npm:0.3.5": version: 0.3.5 resolution: "@module-federation/sdk@npm:0.3.5" @@ -11307,6 +11345,16 @@ __metadata: languageName: node linkType: hard +"@module-federation/webpack-bundler-runtime@npm:0.1.6": + version: 0.1.6 + resolution: "@module-federation/webpack-bundler-runtime@npm:0.1.6" + dependencies: + "@module-federation/runtime": 0.1.6 + "@module-federation/sdk": 0.1.6 + checksum: 7dd6478cdd34881e974f67c8fe3cf668dbd881722416e214981eb423a67a7a1743564d60e652c6819445b1ab8d13fef823ea309c9c75c189001e75e81bd34fb3 + languageName: node + linkType: hard + "@module-federation/webpack-bundler-runtime@npm:0.3.5": version: 0.3.5 resolution: "@module-federation/webpack-bundler-runtime@npm:0.3.5" @@ -15068,6 +15116,153 @@ __metadata: languageName: node linkType: hard +"@rspack/binding-darwin-arm64@npm:0.7.5": + version: 0.7.5 + resolution: "@rspack/binding-darwin-arm64@npm:0.7.5" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rspack/binding-darwin-x64@npm:0.7.5": + version: 0.7.5 + resolution: "@rspack/binding-darwin-x64@npm:0.7.5" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rspack/binding-linux-arm64-gnu@npm:0.7.5": + version: 0.7.5 + resolution: "@rspack/binding-linux-arm64-gnu@npm:0.7.5" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rspack/binding-linux-arm64-musl@npm:0.7.5": + version: 0.7.5 + resolution: "@rspack/binding-linux-arm64-musl@npm:0.7.5" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rspack/binding-linux-x64-gnu@npm:0.7.5": + version: 0.7.5 + resolution: "@rspack/binding-linux-x64-gnu@npm:0.7.5" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rspack/binding-linux-x64-musl@npm:0.7.5": + version: 0.7.5 + resolution: "@rspack/binding-linux-x64-musl@npm:0.7.5" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rspack/binding-win32-arm64-msvc@npm:0.7.5": + version: 0.7.5 + resolution: "@rspack/binding-win32-arm64-msvc@npm:0.7.5" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rspack/binding-win32-ia32-msvc@npm:0.7.5": + version: 0.7.5 + resolution: "@rspack/binding-win32-ia32-msvc@npm:0.7.5" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@rspack/binding-win32-x64-msvc@npm:0.7.5": + version: 0.7.5 + resolution: "@rspack/binding-win32-x64-msvc@npm:0.7.5" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rspack/binding@npm:0.7.5": + version: 0.7.5 + resolution: "@rspack/binding@npm:0.7.5" + dependencies: + "@rspack/binding-darwin-arm64": 0.7.5 + "@rspack/binding-darwin-x64": 0.7.5 + "@rspack/binding-linux-arm64-gnu": 0.7.5 + "@rspack/binding-linux-arm64-musl": 0.7.5 + "@rspack/binding-linux-x64-gnu": 0.7.5 + "@rspack/binding-linux-x64-musl": 0.7.5 + "@rspack/binding-win32-arm64-msvc": 0.7.5 + "@rspack/binding-win32-ia32-msvc": 0.7.5 + "@rspack/binding-win32-x64-msvc": 0.7.5 + dependenciesMeta: + "@rspack/binding-darwin-arm64": + optional: true + "@rspack/binding-darwin-x64": + optional: true + "@rspack/binding-linux-arm64-gnu": + optional: true + "@rspack/binding-linux-arm64-musl": + optional: true + "@rspack/binding-linux-x64-gnu": + optional: true + "@rspack/binding-linux-x64-musl": + optional: true + "@rspack/binding-win32-arm64-msvc": + optional: true + "@rspack/binding-win32-ia32-msvc": + optional: true + "@rspack/binding-win32-x64-msvc": + optional: true + checksum: de25c44cc9c3a240c6f59a657d19d7170c3af1c99097fe2aa5118e2af7b0d606935ad45640102888ce1806bc67fb207189f562cfbcfe4c1628116f5b06f7c6b6 + languageName: node + linkType: hard + +"@rspack/core@npm:^0.7.5": + version: 0.7.5 + resolution: "@rspack/core@npm:0.7.5" + dependencies: + "@module-federation/runtime-tools": 0.1.6 + "@rspack/binding": 0.7.5 + caniuse-lite: ^1.0.30001616 + tapable: 2.2.1 + webpack-sources: 3.2.3 + peerDependencies: + "@swc/helpers": ">=0.5.1" + peerDependenciesMeta: + "@swc/helpers": + optional: true + checksum: 9e41005231d7a58888cb349ee26737752087f13847f6178308f04e99169187996e40cdaf8987e656a81fe1fb91aeb2e8ca2f1fa32fed46b04fe2709325e5a387 + languageName: node + linkType: hard + +"@rspack/dev-server@npm:^0.7.5": + version: 0.7.5 + resolution: "@rspack/dev-server@npm:0.7.5" + dependencies: + chokidar: 3.5.3 + connect-history-api-fallback: 2.0.0 + express: 4.19.2 + http-proxy-middleware: 2.0.6 + mime-types: 2.1.35 + webpack-dev-middleware: 6.1.2 + webpack-dev-server: 4.13.1 + ws: 8.8.1 + peerDependencies: + "@rspack/core": "*" + checksum: d5c767726b1083797cdcc852cb0603fc81ae0225341b5cf52d15e847ced0f1672b83a42bd3237fd047ba63ebe5a9032f5de9ca8c47f318e6ac68425c91ff46d7 + languageName: node + linkType: hard + +"@rspack/plugin-react-refresh@npm:^0.7.5": + version: 0.7.5 + resolution: "@rspack/plugin-react-refresh@npm:0.7.5" + peerDependencies: + react-refresh: ">=0.10.0 <1.0.0" + peerDependenciesMeta: + react-refresh: + optional: true + checksum: ab5f480c44563799bd5d837a608223e57ac41d535adcd22fb8f40625f6bda83e2ece28d40dce4c290249e7c9a8657781b5dfa864b14fae86017e41925d026655 + languageName: node + linkType: hard + "@rushstack/node-core-library@npm:3.59.7": version: 3.59.7 resolution: "@rushstack/node-core-library@npm:3.59.7" @@ -17476,7 +17671,7 @@ __metadata: languageName: node linkType: hard -"@types/bonjour@npm:^3.5.13": +"@types/bonjour@npm:^3.5.13, @types/bonjour@npm:^3.5.9": version: 3.5.13 resolution: "@types/bonjour@npm:3.5.13" dependencies: @@ -17581,7 +17776,7 @@ __metadata: languageName: node linkType: hard -"@types/connect-history-api-fallback@npm:^1.5.4": +"@types/connect-history-api-fallback@npm:^1.3.5, @types/connect-history-api-fallback@npm:^1.5.4": version: 1.5.4 resolution: "@types/connect-history-api-fallback@npm:1.5.4" dependencies: @@ -17870,7 +18065,7 @@ __metadata: languageName: node linkType: hard -"@types/express@npm:*, @types/express@npm:^4.17.14, @types/express@npm:^4.17.21, @types/express@npm:^4.17.6": +"@types/express@npm:*, @types/express@npm:^4.17.13, @types/express@npm:^4.17.14, @types/express@npm:^4.17.21, @types/express@npm:^4.17.6": version: 4.17.21 resolution: "@types/express@npm:4.17.21" dependencies: @@ -18365,13 +18560,6 @@ __metadata: languageName: node linkType: hard -"@types/mime@npm:*": - version: 3.0.4 - resolution: "@types/mime@npm:3.0.4" - checksum: a6139c8e1f705ef2b064d072f6edc01f3c099023ad7c4fce2afc6c2bf0231888202adadbdb48643e8e20da0ce409481a49922e737eca52871b3dc08017455843 - languageName: node - linkType: hard - "@types/mime@npm:^1": version: 1.3.2 resolution: "@types/mime@npm:1.3.2" @@ -18895,6 +19083,13 @@ __metadata: languageName: node linkType: hard +"@types/retry@npm:0.12.0": + version: 0.12.0 + resolution: "@types/retry@npm:0.12.0" + checksum: 61a072c7639f6e8126588bf1eb1ce8835f2cb9c2aba795c4491cf6310e013267b0c8488039857c261c387e9728c1b43205099223f160bb6a76b4374f741b5603 + languageName: node + linkType: hard + "@types/retry@npm:0.12.2": version: 0.12.2 resolution: "@types/retry@npm:0.12.2" @@ -18960,7 +19155,7 @@ __metadata: languageName: node linkType: hard -"@types/serve-index@npm:^1.9.4": +"@types/serve-index@npm:^1.9.1, @types/serve-index@npm:^1.9.4": version: 1.9.4 resolution: "@types/serve-index@npm:1.9.4" dependencies: @@ -18969,14 +19164,14 @@ __metadata: languageName: node linkType: hard -"@types/serve-static@npm:*, @types/serve-static@npm:^1.15.5": - version: 1.15.5 - resolution: "@types/serve-static@npm:1.15.5" +"@types/serve-static@npm:*, @types/serve-static@npm:^1.13.10, @types/serve-static@npm:^1.15.5": + version: 1.15.7 + resolution: "@types/serve-static@npm:1.15.7" dependencies: "@types/http-errors": "*" - "@types/mime": "*" "@types/node": "*" - checksum: 0ff4b3703cf20ba89c9f9e345bc38417860a88e85863c8d6fe274a543220ab7f5f647d307c60a71bb57dc9559f0890a661e8dc771a6ec5ef195d91c8afc4a893 + "@types/send": "*" + checksum: bbbf00dbd84719da2250a462270dc68964006e8d62f41fe3741abd94504ba3688f420a49afb2b7478921a1544d3793183ffa097c5724167da777f4e0c7f1a7d6 languageName: node linkType: hard @@ -19019,7 +19214,7 @@ __metadata: languageName: node linkType: hard -"@types/sockjs@npm:^0.3.36": +"@types/sockjs@npm:^0.3.33, @types/sockjs@npm:^0.3.36": version: 0.3.36 resolution: "@types/sockjs@npm:0.3.36" dependencies: @@ -19253,12 +19448,12 @@ __metadata: languageName: node linkType: hard -"@types/ws@npm:*, @types/ws@npm:^8.0.0, @types/ws@npm:^8.5.10, @types/ws@npm:^8.5.3": - version: 8.5.10 - resolution: "@types/ws@npm:8.5.10" +"@types/ws@npm:*, @types/ws@npm:^8.0.0, @types/ws@npm:^8.5.1, @types/ws@npm:^8.5.10, @types/ws@npm:^8.5.3": + version: 8.5.12 + resolution: "@types/ws@npm:8.5.12" dependencies: "@types/node": "*" - checksum: 3ec416ea2be24042ebd677932a462cf16d2080393d8d7d0b1b3f5d6eaa4a7387aaf0eefb99193c0bfd29444857cf2e0c3ac89899e130550dc6c14ada8a46d25e + checksum: ddefb6ad1671f70ce73b38a5f47f471d4d493864fca7c51f002a86e5993d031294201c5dced6d5018fb8905ad46888d65c7f20dd54fc165910b69f42fba9a6d0 languageName: node linkType: hard @@ -22203,7 +22398,7 @@ __metadata: languageName: node linkType: hard -"bonjour-service@npm:^1.2.1": +"bonjour-service@npm:^1.0.11, bonjour-service@npm:^1.2.1": version: 1.2.1 resolution: "bonjour-service@npm:1.2.1" dependencies: @@ -22773,10 +22968,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001629": - version: 1.0.30001632 - resolution: "caniuse-lite@npm:1.0.30001632" - checksum: 95be155501650ac36a8c3bdf60886bc8f7c419e7715cdaf1c04941f8676c0bd75355aeda62563092585fbe6f9d50d2eb6dea6bd063d7f6a58004ec62d8f8fe49 +"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001616, caniuse-lite@npm:^1.0.30001629": + version: 1.0.30001651 + resolution: "caniuse-lite@npm:1.0.30001651" + checksum: c31a5a01288e70cdbbfb5cd94af3df02f295791673173b8ce6d6a16db4394a6999197d44190be5a6ff06b8c2c7d2047e94dfd5e5eb4c103ab000fca2d370afc7 languageName: node linkType: hard @@ -22921,6 +23116,25 @@ __metadata: languageName: node linkType: hard +"chokidar@npm:3.5.3": + version: 3.5.3 + resolution: "chokidar@npm:3.5.3" + dependencies: + anymatch: ~3.1.2 + braces: ~3.0.2 + fsevents: ~2.3.2 + glob-parent: ~5.1.2 + is-binary-path: ~2.1.0 + is-glob: ~4.0.1 + normalize-path: ~3.0.0 + readdirp: ~3.6.0 + dependenciesMeta: + fsevents: + optional: true + checksum: b49fcde40176ba007ff361b198a2d35df60d9bb2a5aab228279eb810feae9294a6b4649ab15981304447afe1e6ffbf4788ad5db77235dc770ab777c6e771980c + languageName: node + linkType: hard + "chokidar@npm:^3.3.1, chokidar@npm:^3.4.2, chokidar@npm:^3.5.2, chokidar@npm:^3.5.3, chokidar@npm:^3.6.0": version: 3.6.0 resolution: "chokidar@npm:3.6.0" @@ -23758,6 +23972,13 @@ __metadata: languageName: node linkType: hard +"connect-history-api-fallback@npm:2.0.0, connect-history-api-fallback@npm:^2.0.0": + version: 2.0.0 + resolution: "connect-history-api-fallback@npm:2.0.0" + checksum: dc5368690f4a5c413889792f8df70d5941ca9da44523cde3f87af0745faee5ee16afb8195434550f0504726642734f2683d6c07f8b460f828a12c45fbd4c9a68 + languageName: node + linkType: hard + "connect-history-api-fallback@npm:^1.6.0": version: 1.6.0 resolution: "connect-history-api-fallback@npm:1.6.0" @@ -23765,13 +23986,6 @@ __metadata: languageName: node linkType: hard -"connect-history-api-fallback@npm:^2.0.0": - version: 2.0.0 - resolution: "connect-history-api-fallback@npm:2.0.0" - checksum: dc5368690f4a5c413889792f8df70d5941ca9da44523cde3f87af0745faee5ee16afb8195434550f0504726642734f2683d6c07f8b460f828a12c45fbd4c9a68 - languageName: node - linkType: hard - "connect-session-knex@npm:^4.0.0": version: 4.0.0 resolution: "connect-session-knex@npm:4.0.0" @@ -27383,7 +27597,7 @@ __metadata: languageName: node linkType: hard -"express@npm:^4.14.0, express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.1, express@npm:^4.18.2, express@npm:^4.19.2": +"express@npm:4.19.2, express@npm:^4.14.0, express@npm:^4.17.1, express@npm:^4.17.3, express@npm:^4.18.1, express@npm:^4.18.2, express@npm:^4.19.2": version: 4.19.2 resolution: "express@npm:4.19.2" dependencies: @@ -29422,7 +29636,7 @@ __metadata: languageName: node linkType: hard -"html-entities@npm:^2.1.0, html-entities@npm:^2.4.0, html-entities@npm:^2.5.2": +"html-entities@npm:^2.1.0, html-entities@npm:^2.3.2, html-entities@npm:^2.4.0, html-entities@npm:^2.5.2": version: 2.5.2 resolution: "html-entities@npm:2.5.2" checksum: b23f4a07d33d49ade1994069af4e13d31650e3fb62621e92ae10ecdf01d1a98065c78fd20fdc92b4c7881612210b37c275f2c9fba9777650ab0d6f2ceb3b99b6 @@ -29612,7 +29826,7 @@ __metadata: languageName: node linkType: hard -"http-proxy-middleware@npm:^2.0.0, http-proxy-middleware@npm:^2.0.3, http-proxy-middleware@npm:^2.0.6": +"http-proxy-middleware@npm:2.0.6, http-proxy-middleware@npm:^2.0.0, http-proxy-middleware@npm:^2.0.3, http-proxy-middleware@npm:^2.0.6": version: 2.0.6 resolution: "http-proxy-middleware@npm:2.0.6" dependencies: @@ -30189,10 +30403,10 @@ __metadata: languageName: node linkType: hard -"ipaddr.js@npm:^2.1.0": - version: 2.1.0 - resolution: "ipaddr.js@npm:2.1.0" - checksum: 807a054f2bd720c4d97ee479d6c9e865c233bea21f139fb8dabd5a35c4226d2621c42e07b4ad94ff3f82add926a607d8d9d37c625ad0319f0e08f9f2bd1968e2 +"ipaddr.js@npm:^2.0.1, ipaddr.js@npm:^2.1.0": + version: 2.2.0 + resolution: "ipaddr.js@npm:2.2.0" + checksum: 770ba8451fd9bf78015e8edac0d5abd7a708cbf75f9429ca9147a9d2f3a2d60767cd5de2aab2b1e13ca6e4445bdeff42bf12ef6f151c07a5c6cf8a44328e2859 languageName: node linkType: hard @@ -32700,13 +32914,13 @@ __metadata: languageName: node linkType: hard -"launch-editor@npm:^2.6.1": - version: 2.6.1 - resolution: "launch-editor@npm:2.6.1" +"launch-editor@npm:^2.6.0, launch-editor@npm:^2.6.1": + version: 2.8.1 + resolution: "launch-editor@npm:2.8.1" dependencies: picocolors: ^1.0.0 shell-quote: ^1.8.1 - checksum: e06d193075ac09f7f8109f10cabe464a211bf7ed4cbe75f83348d6f67bf4d9f162f06e7a1ab3e1cd7fc250b5342c3b57080618aff2e646dc34248fe499227601 + checksum: 69adfc913c066b0bcd685103907525789db6af3585cdc5f8c1172f0fcebe2c4ea1cff1108f76e9c591c00134329a5fb29e5911e9c0c347618a5300978b6bb767 languageName: node linkType: hard @@ -33947,7 +34161,7 @@ __metadata: languageName: node linkType: hard -"memfs@npm:^3.1.2, memfs@npm:^3.4.1": +"memfs@npm:^3.1.2, memfs@npm:^3.4.1, memfs@npm:^3.4.12, memfs@npm:^3.4.3": version: 3.5.3 resolution: "memfs@npm:3.5.3" dependencies: @@ -36105,14 +36319,14 @@ __metadata: languageName: node linkType: hard -"open@npm:^8.0.0, open@npm:^8.4.0": - version: 8.4.0 - resolution: "open@npm:8.4.0" +"open@npm:^8.0.0, open@npm:^8.0.9, open@npm:^8.4.0": + version: 8.4.2 + resolution: "open@npm:8.4.2" dependencies: define-lazy-prop: ^2.0.0 is-docker: ^2.1.1 is-wsl: ^2.2.0 - checksum: e9545bec64cdbf30a0c35c1bdc310344adf8428a117f7d8df3c0af0a0a24c513b304916a6d9b11db0190ff7225c2d578885080b761ed46a3d5f6f1eebb98b63c + checksum: 6388bfff21b40cb9bd8f913f9130d107f2ed4724ea81a8fd29798ee322b361ca31fa2cdfb491a5c31e43a3996cfe9566741238c7a741ada8d7af1cb78d85cf26 languageName: node linkType: hard @@ -36389,6 +36603,16 @@ __metadata: languageName: node linkType: hard +"p-retry@npm:^4.5.0": + version: 4.6.2 + resolution: "p-retry@npm:4.6.2" + dependencies: + "@types/retry": 0.12.0 + retry: ^0.13.1 + checksum: 45c270bfddaffb4a895cea16cb760dcc72bdecb6cb45fef1971fa6ea2e91ddeafddefe01e444ac73e33b1b3d5d29fb0dd18a7effb294262437221ddc03ce0f2e + languageName: node + linkType: hard + "p-retry@npm:^6.2.0": version: 6.2.0 resolution: "p-retry@npm:6.2.0" @@ -40622,7 +40846,7 @@ __metadata: languageName: node linkType: hard -"selfsigned@npm:^2.0.0, selfsigned@npm:^2.4.1": +"selfsigned@npm:^2.0.0, selfsigned@npm:^2.1.1, selfsigned@npm:^2.4.1": version: 2.4.1 resolution: "selfsigned@npm:2.4.1" dependencies: @@ -42397,6 +42621,13 @@ __metadata: languageName: node linkType: hard +"tapable@npm:2.2.1, tapable@npm:^2.0.0, tapable@npm:^2.1.1, tapable@npm:^2.2.0, tapable@npm:^2.2.1": + version: 2.2.1 + resolution: "tapable@npm:2.2.1" + checksum: 3b7a1b4d86fa940aad46d9e73d1e8739335efd4c48322cb37d073eb6f80f5281889bf0320c6d8ffcfa1a0dd5bfdbd0f9d037e252ef972aca595330538aac4d51 + languageName: node + linkType: hard + "tapable@npm:^1.0.0": version: 1.1.3 resolution: "tapable@npm:1.1.3" @@ -42404,13 +42635,6 @@ __metadata: languageName: node linkType: hard -"tapable@npm:^2.0.0, tapable@npm:^2.1.1, tapable@npm:^2.2.0, tapable@npm:^2.2.1": - version: 2.2.1 - resolution: "tapable@npm:2.2.1" - checksum: 3b7a1b4d86fa940aad46d9e73d1e8739335efd4c48322cb37d073eb6f80f5281889bf0320c6d8ffcfa1a0dd5bfdbd0f9d037e252ef972aca595330538aac4d51 - languageName: node - linkType: hard - "tar-fs@npm:^2.0.0": version: 2.1.1 resolution: "tar-fs@npm:2.1.1" @@ -44642,6 +44866,39 @@ __metadata: languageName: node linkType: hard +"webpack-dev-middleware@npm:6.1.2": + version: 6.1.2 + resolution: "webpack-dev-middleware@npm:6.1.2" + dependencies: + colorette: ^2.0.10 + memfs: ^3.4.12 + mime-types: ^2.1.31 + range-parser: ^1.2.1 + schema-utils: ^4.0.0 + peerDependencies: + webpack: ^5.0.0 + peerDependenciesMeta: + webpack: + optional: true + checksum: 6e962341db5b3ac8526cd678fc6b128adcb92c288aab767948ab7d01591d7837d2d97cf9329d307831b1d51c831fcea4d8f2eb514efe8c8654ae2a4ae9d9f1fb + languageName: node + linkType: hard + +"webpack-dev-middleware@npm:^5.3.1": + version: 5.3.4 + resolution: "webpack-dev-middleware@npm:5.3.4" + dependencies: + colorette: ^2.0.10 + memfs: ^3.4.3 + mime-types: ^2.1.31 + range-parser: ^1.2.1 + schema-utils: ^4.0.0 + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + checksum: 90cf3e27d0714c1a745454a1794f491b7076434939340605b9ee8718ba2b85385b120939754e9fdbd6569811e749dee53eec319e0d600e70e0b0baffd8e3fb13 + languageName: node + linkType: hard + "webpack-dev-middleware@npm:^7.1.0": version: 7.2.1 resolution: "webpack-dev-middleware@npm:7.2.1" @@ -44661,6 +44918,53 @@ __metadata: languageName: node linkType: hard +"webpack-dev-server@npm:4.13.1": + version: 4.13.1 + resolution: "webpack-dev-server@npm:4.13.1" + dependencies: + "@types/bonjour": ^3.5.9 + "@types/connect-history-api-fallback": ^1.3.5 + "@types/express": ^4.17.13 + "@types/serve-index": ^1.9.1 + "@types/serve-static": ^1.13.10 + "@types/sockjs": ^0.3.33 + "@types/ws": ^8.5.1 + ansi-html-community: ^0.0.8 + bonjour-service: ^1.0.11 + chokidar: ^3.5.3 + colorette: ^2.0.10 + compression: ^1.7.4 + connect-history-api-fallback: ^2.0.0 + default-gateway: ^6.0.3 + express: ^4.17.3 + graceful-fs: ^4.2.6 + html-entities: ^2.3.2 + http-proxy-middleware: ^2.0.3 + ipaddr.js: ^2.0.1 + launch-editor: ^2.6.0 + open: ^8.0.9 + p-retry: ^4.5.0 + rimraf: ^3.0.2 + schema-utils: ^4.0.0 + selfsigned: ^2.1.1 + serve-index: ^1.9.1 + sockjs: ^0.3.24 + spdy: ^4.0.2 + webpack-dev-middleware: ^5.3.1 + ws: ^8.13.0 + peerDependencies: + webpack: ^4.37.0 || ^5.0.0 + peerDependenciesMeta: + webpack: + optional: true + webpack-cli: + optional: true + bin: + webpack-dev-server: bin/webpack-dev-server.js + checksum: f70611544b7d964a31eb3d934d7c2b376b97e6927a89e03b2e21cfa5812bb639625cd18fd350de1604ba6c455b324135523a894032f28c69d90d90682e4f3b7d + languageName: node + linkType: hard + "webpack-dev-server@npm:^5.0.0": version: 5.0.4 resolution: "webpack-dev-server@npm:5.0.4" @@ -44715,6 +45019,13 @@ __metadata: languageName: node linkType: hard +"webpack-sources@npm:3.2.3, webpack-sources@npm:^3.2.3": + version: 3.2.3 + resolution: "webpack-sources@npm:3.2.3" + checksum: 989e401b9fe3536529e2a99dac8c1bdc50e3a0a2c8669cbafad31271eadd994bc9405f88a3039cd2e29db5e6d9d0926ceb7a1a4e7409ece021fe79c37d9c4607 + languageName: node + linkType: hard + "webpack-sources@npm:^1.4.3": version: 1.4.3 resolution: "webpack-sources@npm:1.4.3" @@ -44725,13 +45036,6 @@ __metadata: languageName: node linkType: hard -"webpack-sources@npm:^3.2.3": - version: 3.2.3 - resolution: "webpack-sources@npm:3.2.3" - checksum: 989e401b9fe3536529e2a99dac8c1bdc50e3a0a2c8669cbafad31271eadd994bc9405f88a3039cd2e29db5e6d9d0926ceb7a1a4e7409ece021fe79c37d9c4607 - languageName: node - linkType: hard - "webpack@npm:^5, webpack@npm:^5.70.0": version: 5.91.0 resolution: "webpack@npm:5.91.0" @@ -45142,6 +45446,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:8.8.1": + version: 8.8.1 + resolution: "ws@npm:8.8.1" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 2152cf862cae0693f3775bc688a6afb2e989d19d626d215e70f5fcd8eb55b1c3b0d3a6a4052905ec320e2d7734e20aeedbf9744496d62f15a26ad79cf4cf7dae + languageName: node + linkType: hard + "ws@npm:^7, ws@npm:^7.4.6, ws@npm:^7.5.5": version: 7.5.10 resolution: "ws@npm:7.5.10" From b676cc944f9891236c9e29ee01b7f10fbee548ed Mon Sep 17 00:00:00 2001 From: JounQin Date: Fri, 9 Aug 2024 01:11:21 +0800 Subject: [PATCH 027/291] chore: add related changeset Signed-off-by: JounQin --- .changeset/green-berries-wave.md | 5 +++++ packages/cli/src/lib/bundler/optimization.ts | 9 ++++----- 2 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 .changeset/green-berries-wave.md diff --git a/.changeset/green-berries-wave.md b/.changeset/green-berries-wave.md new file mode 100644 index 0000000000..574d329488 --- /dev/null +++ b/.changeset/green-berries-wave.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +feat: experimentally support using rspack instead under `EXPERIMENTAL_RSPACK` env flag diff --git a/packages/cli/src/lib/bundler/optimization.ts b/packages/cli/src/lib/bundler/optimization.ts index 1cef6b6205..4c569e5ccf 100644 --- a/packages/cli/src/lib/bundler/optimization.ts +++ b/packages/cli/src/lib/bundler/optimization.ts @@ -24,10 +24,6 @@ export const optimization = ( ): WebpackOptionsNormalized['optimization'] => { const { isDev, useRspack } = options; - const extralOptions = useRspack - ? {} - : { maxAsyncRequests: Infinity, maxInitialRequests: Infinity }; - const rspack = useRspack ? (require('@rspack/core') as typeof import('@rspack/core').rspack) : undefined; @@ -82,7 +78,10 @@ export const optimization = ( priority: 10, minSize: 100000, minChunks: 1, - ...extralOptions, + ...(!useRspack && { + maxAsyncRequests: Infinity, + maxInitialRequests: Infinity, + }), }, // filename is not included in type, but we need it // Group together the smallest modules vendor: { From 28a0779d6e34dc4b90d515db5618129eaa4245fc Mon Sep 17 00:00:00 2001 From: JounQin Date: Fri, 9 Aug 2024 11:35:09 +0800 Subject: [PATCH 028/291] chore: add @types/webpack-sources dev dep Signed-off-by: JounQin --- packages/cli/package.json | 1 + packages/cli/src/lib/bundler/backend.ts | 9 ++++++++- yarn.lock | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index e2726f358a..5c5ca64692 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -184,6 +184,7 @@ "@types/svgo": "^2.6.2", "@types/tar": "^6.1.1", "@types/terser-webpack-plugin": "^5.0.4", + "@types/webpack-sources": "^3.2.3", "@types/yarnpkg__lockfile": "^1.1.4", "@vitejs/plugin-react": "^4.0.4", "del": "^7.0.0", diff --git a/packages/cli/src/lib/bundler/backend.ts b/packages/cli/src/lib/bundler/backend.ts index 598b77bc51..e089a9fd72 100644 --- a/packages/cli/src/lib/bundler/backend.ts +++ b/packages/cli/src/lib/bundler/backend.ts @@ -20,17 +20,24 @@ import { resolveBundlingPaths } from './paths'; import { BackendServeOptions } from './types'; export async function serveBackend(options: BackendServeOptions) { + const useRspack = !!process.env.EXPERIMENTAL_RSPACK; + const paths = resolveBundlingPaths(options); const config = await createBackendConfig(paths, { ...options, isDev: true, + useRspack, }); // Webpack only replaces occurrences of this in code it touches, which does // not include dependencies in node_modules. So we set it here at runtime as well. (process.env as { NODE_ENV: string }).NODE_ENV = 'development'; - const compiler = webpack(config, (err: Error | null) => { + const bundler: typeof webpack = useRspack + ? require('@rspack/core').rspack + : webpack; + + const compiler = bundler(config, (err: Error | null) => { if (err) { console.error(err); } else console.log('Build succeeded'); diff --git a/yarn.lock b/yarn.lock index 7222777537..d63d839c08 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3990,6 +3990,7 @@ __metadata: "@types/tar": ^6.1.1 "@types/terser-webpack-plugin": ^5.0.4 "@types/webpack-env": ^1.15.2 + "@types/webpack-sources": ^3.2.3 "@types/yarnpkg__lockfile": ^1.1.4 "@typescript-eslint/eslint-plugin": ^6.12.0 "@typescript-eslint/parser": ^6.7.2 @@ -19223,6 +19224,13 @@ __metadata: languageName: node linkType: hard +"@types/source-list-map@npm:*": + version: 0.1.6 + resolution: "@types/source-list-map@npm:0.1.6" + checksum: 9cd294c121f1562062de5d241fe4d10780b1131b01c57434845fe50968e9dcf67ede444591c2b1ad6d3f9b6bc646ac02cc8f51a3577c795f9c64cf4573dcc6b1 + languageName: node + linkType: hard + "@types/ssh2-streams@npm:*": version: 0.1.8 resolution: "@types/ssh2-streams@npm:0.1.8" @@ -19430,6 +19438,17 @@ __metadata: languageName: node linkType: hard +"@types/webpack-sources@npm:^3.2.3": + version: 3.2.3 + resolution: "@types/webpack-sources@npm:3.2.3" + dependencies: + "@types/node": "*" + "@types/source-list-map": "*" + source-map: ^0.7.3 + checksum: 7b557f242efaa10e4e3e18cc4171a0c98e22898570caefdd4f7b076fe8534b5abfac92c953c6604658dcb7218507f970230352511840fe9fdea31a9af3b9a906 + languageName: node + linkType: hard + "@types/webpack@npm:^5.28.0": version: 5.28.5 resolution: "@types/webpack@npm:5.28.5" From 4e640197902f1612543834de4af68250c69f91c2 Mon Sep 17 00:00:00 2001 From: JounQin Date: Wed, 14 Aug 2024 18:56:35 +0800 Subject: [PATCH 029/291] chore: rebase issue Signed-off-by: JounQin --- packages/cli/src/lib/bundler/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 69c673b833..f31acc3a1b 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -191,7 +191,7 @@ export async function createConfig( // to remove this eventually! plugins.push( new bundler.ProvidePlugin({ - process: require.resolve('process/browser'),, + process: require.resolve('process/browser'), Buffer: ['buffer', 'Buffer'], }), ); From 551abf6d55e3b62f24375ef48ee7ea4d7cb791af Mon Sep 17 00:00:00 2001 From: JounQin Date: Wed, 14 Aug 2024 19:00:43 +0800 Subject: [PATCH 030/291] chore: override experiments unexpectedly Signed-off-by: JounQin --- packages/cli/src/lib/bundler/config.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index f31acc3a1b..59ef2655cc 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -424,7 +424,12 @@ export async function createConfig( : {}), }, experiments: { - lazyCompilation: yn(process.env.EXPERIMENTAL_LAZY_COMPILATION), + lazyCompilation: + !useRspack && yn(process.env.EXPERIMENTAL_LAZY_COMPILATION), + ...(useRspack && { + // We're still using `style-loader` for custom `insert` option + css: false, + }), }, plugins, ...(withCache && { @@ -435,12 +440,6 @@ export async function createConfig( }, }, }), - ...(useRspack && { - // We're still using `style-loader` for custom `insert` option - experiments: { - css: false, - }, - }), }; } From cd7a503bbc8360297db2f10443851fa237524cac Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Wed, 14 Aug 2024 20:58:32 -0400 Subject: [PATCH 031/291] trigger ci Signed-off-by: Stephen Glass --- .changeset/violet-beds-promise.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/violet-beds-promise.md b/.changeset/violet-beds-promise.md index 5fd4890ffa..7632ea68c7 100644 --- a/.changeset/violet-beds-promise.md +++ b/.changeset/violet-beds-promise.md @@ -5,7 +5,7 @@ '@backstage/plugin-auth-react': patch --- -Fix error handling using authentication redirect flow via `enableExperimentalRedirectFlow` config. If an error is caught during authentication, the user is redirected back to app origin with `?error=true` query parameter. A cookie is also set in the redirect which contains the error message. The error can be fetched from any custom sign in page using the new `useSignInAuthError` hook.: +Fix authentication error handling using redirect flow via `enableExperimentalRedirectFlow` config. If an error is caught during authentication, the user is redirected back to app origin with `?error=true` query parameter. A cookie is also set in the redirect which contains the error message. The error can be fetched from any custom sign in page using the new `useSignInAuthError` hook.: ```ts import { useSignInAuthError } from '@backstage/plugin-auth-react'; From 65a5939be6b82d5b75626a4862f5461f81ad4a52 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Wed, 14 Aug 2024 20:59:23 -0400 Subject: [PATCH 032/291] fix typo Signed-off-by: Stephen Glass --- .changeset/violet-beds-promise.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/violet-beds-promise.md b/.changeset/violet-beds-promise.md index 7632ea68c7..ee002d8319 100644 --- a/.changeset/violet-beds-promise.md +++ b/.changeset/violet-beds-promise.md @@ -5,7 +5,7 @@ '@backstage/plugin-auth-react': patch --- -Fix authentication error handling using redirect flow via `enableExperimentalRedirectFlow` config. If an error is caught during authentication, the user is redirected back to app origin with `?error=true` query parameter. A cookie is also set in the redirect which contains the error message. The error can be fetched from any custom sign in page using the new `useSignInAuthError` hook.: +Fix authentication error handling using redirect flow via `enableExperimentalRedirectFlow` config. If an error is caught during authentication, the user is redirected back to app origin with `?error=true` query parameter. A cookie is also set in the redirect which contains the error message. The error can be fetched from any custom sign in page using the new `useSignInAuthError` hook. ```ts import { useSignInAuthError } from '@backstage/plugin-auth-react'; From d964eb1a030d5271b848d644d79692c77ef8df41 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Fri, 23 Aug 2024 10:22:14 -0400 Subject: [PATCH 033/291] fix test suite name for auth error hook Signed-off-by: Stephen Glass --- .../src/hooks/useSignInAuthError/useSignInAuthError.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.test.tsx b/plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.test.tsx index a5ac57ec83..0c67c419f8 100644 --- a/plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.test.tsx +++ b/plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.test.tsx @@ -21,7 +21,7 @@ import { TestApiProvider } from '@backstage/test-utils'; import { useSignInAuthError } from './useSignInAuthError'; import { serializeError } from '@backstage/errors'; -describe('useCookieAuthRefresh', () => { +describe('useSignInAuthError', () => { const discoveryApiMock = { getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000/api/auth'), }; From 311efd906c03f3d2ee4e6ed1a2ee3a8e337ea28d Mon Sep 17 00:00:00 2001 From: Alex Eftimie Date: Tue, 2 Jul 2024 15:12:31 +0200 Subject: [PATCH 034/291] Techdocs: allow custom collation of mkdocs search document Signed-off-by: Alex Eftimie --- .../api-report.md | 30 +++++++++++++++ .../DefaultTechDocsCollatorFactory.ts | 20 +++++----- .../TechDocsCollatorDocumentTransformer.ts | 38 +++++++++++++++++++ ...aultTechDocsCollatorDocumentTransformer.ts | 30 +++++++++++++++ .../src/collators/index.ts | 7 ++++ 5 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 plugins/search-backend-module-techdocs/src/collators/TechDocsCollatorDocumentTransformer.ts create mode 100644 plugins/search-backend-module-techdocs/src/collators/defaultTechDocsCollatorDocumentTransformer.ts diff --git a/plugins/search-backend-module-techdocs/api-report.md b/plugins/search-backend-module-techdocs/api-report.md index 9c9fae1a38..3f4ece1cea 100644 --- a/plugins/search-backend-module-techdocs/api-report.md +++ b/plugins/search-backend-module-techdocs/api-report.md @@ -18,6 +18,9 @@ import { Readable } from 'stream'; import { TechDocsDocument } from '@backstage/plugin-techdocs-node'; import { TokenManager } from '@backstage/backend-common'; +// @public (undocumented) +export const defaultTechDocsCollatorDocumentTransformer: TechDocsCollatorDocumentTransformer; + // @public (undocumented) export const defaultTechDocsCollatorEntityTransformer: TechDocsCollatorEntityTransformer; @@ -36,6 +39,32 @@ export class DefaultTechDocsCollatorFactory implements DocumentCollatorFactory { readonly visibilityPermission: Permission; } +// @public (undocumented) +export interface MkSearchIndexDoc { + // (undocumented) + location: string; + // (undocumented) + tags?: string[]; + // (undocumented) + text: string; + // (undocumented) + title: string; +} + +// @public (undocumented) +export type TechDocsCollatorDocumentTransformer = ( + doc: MkSearchIndexDoc, +) => Omit< + TechDocsDocument, + | 'location' + | 'authorization' + | 'kind' + | 'namespace' + | 'name' + | 'lifecycle' + | 'owner' +>; + // @public (undocumented) export type TechDocsCollatorEntityTransformer = ( entity: Entity, @@ -53,5 +82,6 @@ export type TechDocsCollatorFactoryOptions = { parallelismLimit?: number; legacyPathCasing?: boolean; entityTransformer?: TechDocsCollatorEntityTransformer; + documentTransformer?: TechDocsCollatorDocumentTransformer; }; ``` diff --git a/plugins/search-backend-module-techdocs/src/collators/DefaultTechDocsCollatorFactory.ts b/plugins/search-backend-module-techdocs/src/collators/DefaultTechDocsCollatorFactory.ts index 748cb515d9..6b0eb6255b 100644 --- a/plugins/search-backend-module-techdocs/src/collators/DefaultTechDocsCollatorFactory.ts +++ b/plugins/search-backend-module-techdocs/src/collators/DefaultTechDocsCollatorFactory.ts @@ -34,12 +34,16 @@ import { catalogEntityReadPermission } from '@backstage/plugin-catalog-common/al import { Permission } from '@backstage/plugin-permission-common'; import { DocumentCollatorFactory } from '@backstage/plugin-search-common'; import { TechDocsDocument } from '@backstage/plugin-techdocs-node'; -import unescape from 'lodash/unescape'; import fetch from 'node-fetch'; import pLimit from 'p-limit'; import { Readable } from 'stream'; import { TechDocsCollatorEntityTransformer } from './TechDocsCollatorEntityTransformer'; +import { + MkSearchIndexDoc, + TechDocsCollatorDocumentTransformer, +} from './TechDocsCollatorDocumentTransformer'; import { defaultTechDocsCollatorEntityTransformer } from './defaultTechDocsCollatorEntityTransformer'; +import { defaultTechDocsCollatorDocumentTransformer } from './defaultTechDocsCollatorDocumentTransformer'; import { AuthService, DiscoveryService, @@ -47,12 +51,6 @@ import { LoggerService, } from '@backstage/backend-plugin-api'; -interface MkSearchIndexDoc { - title: string; - text: string; - location: string; -} - /** * Options to configure the TechDocs collator factory * @@ -70,6 +68,7 @@ export type TechDocsCollatorFactoryOptions = { parallelismLimit?: number; legacyPathCasing?: boolean; entityTransformer?: TechDocsCollatorEntityTransformer; + documentTransformer?: TechDocsCollatorDocumentTransformer; }; type EntityInfo = { @@ -98,6 +97,7 @@ export class DefaultTechDocsCollatorFactory implements DocumentCollatorFactory { private readonly parallelismLimit: number; private readonly legacyPathCasing: boolean; private entityTransformer: TechDocsCollatorEntityTransformer; + private documentTransformer: TechDocsCollatorDocumentTransformer; private constructor(options: TechDocsCollatorFactoryOptions) { this.discovery = options.discovery; @@ -111,6 +111,8 @@ export class DefaultTechDocsCollatorFactory implements DocumentCollatorFactory { this.legacyPathCasing = options.legacyPathCasing ?? false; this.entityTransformer = options.entityTransformer ?? defaultTechDocsCollatorEntityTransformer; + this.documentTransformer = + options.documentTransformer ?? defaultTechDocsCollatorDocumentTransformer; this.auth = createLegacyAuthAdapters({ auth: options.auth, @@ -225,8 +227,7 @@ export class DefaultTechDocsCollatorFactory implements DocumentCollatorFactory { return searchIndex.docs.map((doc: MkSearchIndexDoc) => ({ ...this.entityTransformer(entity), - title: unescape(doc.title), - text: unescape(doc.text || ''), + ...this.documentTransformer(doc), location: this.applyArgsToFormat( this.locationTemplate || '/docs/:namespace/:kind/:name/:path', { @@ -234,7 +235,6 @@ export class DefaultTechDocsCollatorFactory implements DocumentCollatorFactory { path: doc.location, }, ), - path: doc.location, ...entityInfo, entityTitle: entity.metadata.title, componentType: entity.spec?.type?.toString() || 'other', diff --git a/plugins/search-backend-module-techdocs/src/collators/TechDocsCollatorDocumentTransformer.ts b/plugins/search-backend-module-techdocs/src/collators/TechDocsCollatorDocumentTransformer.ts new file mode 100644 index 0000000000..b95187bde4 --- /dev/null +++ b/plugins/search-backend-module-techdocs/src/collators/TechDocsCollatorDocumentTransformer.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2024 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 { TechDocsDocument } from '@backstage/plugin-techdocs-node'; + +/** @public */ +export interface MkSearchIndexDoc { + title: string; + text: string; + location: string; + tags?: string[]; +} + +/** @public */ +export type TechDocsCollatorDocumentTransformer = ( + doc: MkSearchIndexDoc, +) => Omit< + TechDocsDocument, + | 'location' + | 'authorization' + | 'kind' + | 'namespace' + | 'name' + | 'lifecycle' + | 'owner' +>; diff --git a/plugins/search-backend-module-techdocs/src/collators/defaultTechDocsCollatorDocumentTransformer.ts b/plugins/search-backend-module-techdocs/src/collators/defaultTechDocsCollatorDocumentTransformer.ts new file mode 100644 index 0000000000..36492de6bd --- /dev/null +++ b/plugins/search-backend-module-techdocs/src/collators/defaultTechDocsCollatorDocumentTransformer.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2024 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 unescape from 'lodash/unescape'; +import { + TechDocsCollatorDocumentTransformer, + MkSearchIndexDoc, +} from './TechDocsCollatorDocumentTransformer'; + +/** @public */ +export const defaultTechDocsCollatorDocumentTransformer: TechDocsCollatorDocumentTransformer = + (doc: MkSearchIndexDoc) => { + return { + title: unescape(doc.title), + text: unescape(doc.text || ''), + path: doc.location, + }; + }; diff --git a/plugins/search-backend-module-techdocs/src/collators/index.ts b/plugins/search-backend-module-techdocs/src/collators/index.ts index 94be458e33..1f0795368f 100644 --- a/plugins/search-backend-module-techdocs/src/collators/index.ts +++ b/plugins/search-backend-module-techdocs/src/collators/index.ts @@ -21,3 +21,10 @@ export type { TechDocsCollatorFactoryOptions } from './DefaultTechDocsCollatorFa export { defaultTechDocsCollatorEntityTransformer } from './defaultTechDocsCollatorEntityTransformer'; export type { TechDocsCollatorEntityTransformer } from './TechDocsCollatorEntityTransformer'; + +export { defaultTechDocsCollatorDocumentTransformer } from './defaultTechDocsCollatorDocumentTransformer'; + +export type { + TechDocsCollatorDocumentTransformer, + MkSearchIndexDoc, +} from './TechDocsCollatorDocumentTransformer'; From 07a7fc2cf59275d55558b14d2468b643dc7a5dbe Mon Sep 17 00:00:00 2001 From: Alex Eftimie Date: Tue, 2 Jul 2024 16:06:39 +0200 Subject: [PATCH 035/291] Add changeset Signed-off-by: Alex Eftimie --- .changeset/funny-dancers-drum.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/funny-dancers-drum.md diff --git a/.changeset/funny-dancers-drum.md b/.changeset/funny-dancers-drum.md new file mode 100644 index 0000000000..3f57244a1a --- /dev/null +++ b/.changeset/funny-dancers-drum.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-backend-module-techdocs': minor +--- + +Refactor TechDocs collator, enable clients to override the mkdocs search index transformer, so that per document properties (like tags) can be added to Backstage search index. From 98be1a602a95140d798398917b9a9782017b03ca Mon Sep 17 00:00:00 2001 From: Alex Eftimie Date: Wed, 4 Sep 2024 17:45:01 +0200 Subject: [PATCH 036/291] refactor to use partials Signed-off-by: Alex Eftimie --- docs/features/search/how-to-guides.md | 12 ++++- .../DefaultTechDocsCollatorFactory.test.ts | 44 ++++++++++++++++++- .../DefaultTechDocsCollatorFactory.ts | 14 +++--- .../TechDocsCollatorDocumentTransformer.ts | 20 +++++---- .../TechDocsCollatorEntityTransformer.ts | 2 +- .../src/collators/index.ts | 2 - 6 files changed, 73 insertions(+), 21 deletions(-) diff --git a/docs/features/search/how-to-guides.md b/docs/features/search/how-to-guides.md index b0df9440f3..2c51e6ded5 100644 --- a/docs/features/search/how-to-guides.md +++ b/docs/features/search/how-to-guides.md @@ -86,17 +86,27 @@ const techDocsEntityTransformer: TechDocsCollatorEntityTransformer = ( ) => { return { // add more fields to the index - ...defaultTechDocsCollatorEntityTransformer(entity), tags: entity.metadata.tags, }; }; +const techDocsDocumentTransformer: TechDocsCollatorDocumentTransformer = ( + doc: MkSearchIndexDoc, +) => { + return { + // add more fields to the index + bost: doc.boost, + }; +}; + indexBuilder.addCollator({ collator: DefaultTechDocsCollatorFactory.fromConfig(env.config, { discovery: env.discovery, tokenManager: env.tokenManager, /* highlight-add-next-line */ entityTransformer: techDocsEntityTransformer, + /* highlight-add-next-line */ + documentTransformer: techDocsDocumentTransformer, }), }); ``` diff --git a/plugins/search-backend-module-techdocs/src/collators/DefaultTechDocsCollatorFactory.test.ts b/plugins/search-backend-module-techdocs/src/collators/DefaultTechDocsCollatorFactory.test.ts index 1fc23b915e..4adb42cf1e 100644 --- a/plugins/search-backend-module-techdocs/src/collators/DefaultTechDocsCollatorFactory.test.ts +++ b/plugins/search-backend-module-techdocs/src/collators/DefaultTechDocsCollatorFactory.test.ts @@ -24,9 +24,12 @@ import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { Readable } from 'stream'; import { DefaultTechDocsCollatorFactory } from './DefaultTechDocsCollatorFactory'; -import { defaultTechDocsCollatorEntityTransformer } from './defaultTechDocsCollatorEntityTransformer'; import { TechDocsCollatorEntityTransformer } from './TechDocsCollatorEntityTransformer'; import { DiscoveryService } from '@backstage/backend-plugin-api'; +import { + MkSearchIndexDoc, + TechDocsCollatorDocumentTransformer, +} from './TechDocsCollatorDocumentTransformer'; const logger = mockServices.logger.mock(); @@ -254,11 +257,11 @@ describe('DefaultTechDocsCollatorFactory', () => { }); it('should transform the entity using the entityTransformer function', async () => { + // @ts-ignore const entityTransformer: TechDocsCollatorEntityTransformer = ( entity: Entity, ) => { return { - ...defaultTechDocsCollatorEntityTransformer(entity), tags: entity.metadata.tags, }; }; @@ -289,5 +292,42 @@ describe('DefaultTechDocsCollatorFactory', () => { }); }); }); + + it('should transform the doc using the documentTransformer function', async () => { + // @ts-ignore + const documentTransformer: TechDocsCollatorDocumentTransformer = ( + _: MkSearchIndexDoc, + ) => { + return { + tags: ['static-tag'], + }; + }; + + factory = DefaultTechDocsCollatorFactory.fromConfig(config, { + ...options, + documentTransformer, + }); + + collator = await factory.getCollator(); + + const pipeline = TestPipeline.fromCollator(collator); + const { documents } = await pipeline.execute(); + const entity = expectedEntities[0]; + documents.forEach((document, idx) => { + expect(document).toMatchObject({ + title: mockSearchDocIndex.docs[idx].title, + location: `/docs/default/component/${entity.metadata.name}/${mockSearchDocIndex.docs[idx].location}`, + text: mockSearchDocIndex.docs[idx].text, + namespace: 'default', + entityTitle: entity!.metadata.title, + componentType: entity!.spec!.type, + lifecycle: entity!.spec!.lifecycle, + owner: '', + kind: entity.kind.toLocaleLowerCase('en-US'), + name: entity.metadata.name, + tags: ['static-tag'], + }); + }); + }); }); }); diff --git a/plugins/search-backend-module-techdocs/src/collators/DefaultTechDocsCollatorFactory.ts b/plugins/search-backend-module-techdocs/src/collators/DefaultTechDocsCollatorFactory.ts index 6b0eb6255b..4e4b1417cd 100644 --- a/plugins/search-backend-module-techdocs/src/collators/DefaultTechDocsCollatorFactory.ts +++ b/plugins/search-backend-module-techdocs/src/collators/DefaultTechDocsCollatorFactory.ts @@ -109,10 +109,10 @@ export class DefaultTechDocsCollatorFactory implements DocumentCollatorFactory { new CatalogClient({ discoveryApi: options.discovery }); this.parallelismLimit = options.parallelismLimit ?? 10; this.legacyPathCasing = options.legacyPathCasing ?? false; - this.entityTransformer = - options.entityTransformer ?? defaultTechDocsCollatorEntityTransformer; - this.documentTransformer = - options.documentTransformer ?? defaultTechDocsCollatorDocumentTransformer; + // @ts-ignore + this.entityTransformer = options.entityTransformer ?? (() => {}); + // @ts-ignore + this.documentTransformer = options.documentTransformer ?? (() => {}); this.auth = createLegacyAuthAdapters({ auth: options.auth, @@ -226,8 +226,10 @@ export class DefaultTechDocsCollatorFactory implements DocumentCollatorFactory { ]); return searchIndex.docs.map((doc: MkSearchIndexDoc) => ({ - ...this.entityTransformer(entity), - ...this.documentTransformer(doc), + ...defaultTechDocsCollatorEntityTransformer(entity), + ...defaultTechDocsCollatorDocumentTransformer(doc), + ...(this.entityTransformer(entity) ?? {}), + ...(this.documentTransformer(doc) ?? {}), location: this.applyArgsToFormat( this.locationTemplate || '/docs/:namespace/:kind/:name/:path', { diff --git a/plugins/search-backend-module-techdocs/src/collators/TechDocsCollatorDocumentTransformer.ts b/plugins/search-backend-module-techdocs/src/collators/TechDocsCollatorDocumentTransformer.ts index b95187bde4..5c21c6085f 100644 --- a/plugins/search-backend-module-techdocs/src/collators/TechDocsCollatorDocumentTransformer.ts +++ b/plugins/search-backend-module-techdocs/src/collators/TechDocsCollatorDocumentTransformer.ts @@ -26,13 +26,15 @@ export interface MkSearchIndexDoc { /** @public */ export type TechDocsCollatorDocumentTransformer = ( doc: MkSearchIndexDoc, -) => Omit< - TechDocsDocument, - | 'location' - | 'authorization' - | 'kind' - | 'namespace' - | 'name' - | 'lifecycle' - | 'owner' +) => Partial< + Omit< + TechDocsDocument, + | 'location' + | 'authorization' + | 'kind' + | 'namespace' + | 'name' + | 'lifecycle' + | 'owner' + > >; diff --git a/plugins/search-backend-module-techdocs/src/collators/TechDocsCollatorEntityTransformer.ts b/plugins/search-backend-module-techdocs/src/collators/TechDocsCollatorEntityTransformer.ts index 142199e3dc..ff1c543c1e 100644 --- a/plugins/search-backend-module-techdocs/src/collators/TechDocsCollatorEntityTransformer.ts +++ b/plugins/search-backend-module-techdocs/src/collators/TechDocsCollatorEntityTransformer.ts @@ -20,4 +20,4 @@ import { TechDocsDocument } from '@backstage/plugin-techdocs-node'; /** @public */ export type TechDocsCollatorEntityTransformer = ( entity: Entity, -) => Omit; +) => Partial>; diff --git a/plugins/search-backend-module-techdocs/src/collators/index.ts b/plugins/search-backend-module-techdocs/src/collators/index.ts index 1f0795368f..289ea39c83 100644 --- a/plugins/search-backend-module-techdocs/src/collators/index.ts +++ b/plugins/search-backend-module-techdocs/src/collators/index.ts @@ -22,8 +22,6 @@ export { defaultTechDocsCollatorEntityTransformer } from './defaultTechDocsColla export type { TechDocsCollatorEntityTransformer } from './TechDocsCollatorEntityTransformer'; -export { defaultTechDocsCollatorDocumentTransformer } from './defaultTechDocsCollatorDocumentTransformer'; - export type { TechDocsCollatorDocumentTransformer, MkSearchIndexDoc, From adf89f46454c94e64e898d1114aa317f373d4be6 Mon Sep 17 00:00:00 2001 From: Alex Eftimie Date: Wed, 4 Sep 2024 17:55:42 +0200 Subject: [PATCH 037/291] update api report Signed-off-by: Alex Eftimie --- .../api-report.md | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/plugins/search-backend-module-techdocs/api-report.md b/plugins/search-backend-module-techdocs/api-report.md index 3f4ece1cea..ff4d269eb3 100644 --- a/plugins/search-backend-module-techdocs/api-report.md +++ b/plugins/search-backend-module-techdocs/api-report.md @@ -18,9 +18,6 @@ import { Readable } from 'stream'; import { TechDocsDocument } from '@backstage/plugin-techdocs-node'; import { TokenManager } from '@backstage/backend-common'; -// @public (undocumented) -export const defaultTechDocsCollatorDocumentTransformer: TechDocsCollatorDocumentTransformer; - // @public (undocumented) export const defaultTechDocsCollatorEntityTransformer: TechDocsCollatorEntityTransformer; @@ -54,21 +51,23 @@ export interface MkSearchIndexDoc { // @public (undocumented) export type TechDocsCollatorDocumentTransformer = ( doc: MkSearchIndexDoc, -) => Omit< - TechDocsDocument, - | 'location' - | 'authorization' - | 'kind' - | 'namespace' - | 'name' - | 'lifecycle' - | 'owner' +) => Partial< + Omit< + TechDocsDocument, + | 'location' + | 'authorization' + | 'kind' + | 'namespace' + | 'name' + | 'lifecycle' + | 'owner' + > >; // @public (undocumented) export type TechDocsCollatorEntityTransformer = ( entity: Entity, -) => Omit; +) => Partial>; // @public @deprecated export type TechDocsCollatorFactoryOptions = { From 2001ff8aee8f37d80436feac89ca0500759424de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 20 Sep 2024 12:56:06 +0200 Subject: [PATCH 038/291] Update .changeset/olive-walls-wave.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/olive-walls-wave.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/olive-walls-wave.md b/.changeset/olive-walls-wave.md index bc6264bbcb..f7dc5434a6 100644 --- a/.changeset/olive-walls-wave.md +++ b/.changeset/olive-walls-wave.md @@ -2,4 +2,4 @@ '@backstage/plugin-home': minor --- -**BREAKING** Implement usage of unused `limit` query param in visits API `.list()` function +**BREAKING** Implement usage of unused `limit` query parameter in visits API `.list()` function From 5b3d4b884f3c91768a70dcbd73864c300351cf5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veith=20M=2E=20B=C3=BCrgerhoff?= Date: Fri, 20 Sep 2024 22:17:12 +0200 Subject: [PATCH 039/291] rework the additional templates documentation to include additionalTemplateGlobals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Veith M. Bürgerhoff --- .../software-templates/writing-templates.md | 72 ++++++++++--------- 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index 71ca0c56ae..0c1f7338cb 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -801,34 +801,33 @@ The `projectSlug` filter generates a project slug from a repository URL - **Input**: `github.com?repo=backstage&org=backstage` - **Output**: `backstage/backstage` -## Custom Filters +## Custom Filters and Globals -Whenever it is needed to extend the built-in filters with yours `${{ parameters.name | my-filter1 | my-filter2 | etc }}`, then you can add them -using the property `additionalTemplateFilters`. +You may with to extend the filters and globals with your own custom ones. For example `${{ myGlobal | myFilter | myOtherFilter }}` or `${{ myFunctionGlobal(1,2) | myFilter }}`. +This can be achieved using the `additionalTemplateFilters` and `additionalTemplateGlobals` properties respectively. -The `additionalTemplateFilters` property accepts as type a `Record` +These properties accept a `Record` -```ts title="plugins/scaffolder-backend/src/service/Router.ts" +```ts title="plugins/scaffolder-backend/src/service/router.ts" additionalTemplateFilters?: Record; + additionalTemplateGlobals?: Record; ``` -where the first parameter is the name of the filter and the second receives a list of `JSON value` arguments. The `templateFilter()` function must return a JsonValue which is either a Json array, object or primitive. +where the first parameter is the identifier of the filter or global and the second is a `TemplateFilter` or a `TemplateGlobal` respectively. +A `TemplateFilter` is a function which will be called using the previous `JsonValue` objets and may return a `JsonValue` object. +A `TemplateGlobal` can either be a function which will be called using the passed `JsonValue` objects and may return a `JsonValue` object or it can be a `JsonValue` object itself. ```ts title="plugins/scaffolder-node/src/types.ts" export type TemplateFilter = (...args: JsonValue[]) => JsonValue | undefined; + +export type TemplateGlobal = + | ((...args: JsonValue[]) => JsonValue | undefined) + | JsonValue; ``` -From a practical coding point of view, you will translate that into the following snippet code handling 2 filters: +**Usage Example** -```ts" -... -additionalTemplateFilters: { - base64: (...args: JsonValue[]) => btoa(args.join("")), - betterFilter: (...args: JsonValue[]) => { return `This is a much better string than "${args}", don't you think?` } -} -``` - -And within your template, you will be able to use the filters using a parameter and the filter passed using the pipe symbol +Given you want to have the following filters and globals available in you template: ```yaml apiVersion: scaffolder.backstage.io/v1beta3 @@ -840,22 +839,21 @@ spec: owner: user:guest type: service - parameters: - - title: Test custom filters - properties: - userName: - title: Name of the user - type: string - steps: - - id: debug - name: debug + - id: debug1 + name: debug1 action: debug:log input: - message: ${{ parameters.userName | betterFilter | base64 }} + message: ${{ myGlobal | myFilter | myOtherFilter }} + + - id: debug2 + name: debug2 + action: debug:log + input: + message: ${{ myFunctionGlobal(1,2) | myFilter }} ``` -Next, you will have to register the property `addTemplateFilters` using the `scaffolderTemplatingExtensionPoint` of a new `BackendModule` [created](../../backend-system/architecture/06-modules.md). +You will have to create a new [`BackendModule`](../../backend-system/architecture/06-modules.md) using the `scaffolderTemplatingExtensionPoint`. Here is a very simplified example of how to do that: @@ -876,11 +874,13 @@ const scaffolderModuleCustomFilters = createBackendModule({ // ... and other dependencies as needed }, async init({ scaffolder /* ..., other dependencies */ }) { - scaffolder.addTemplateFilters({ - base64: (...args: JsonValue[]) => btoa(args.join('')), - betterFilter: (...args: JsonValue[]) => { - return `This is a much better string than "${args}", don't you think?`; - }, + scaffolder.addTemplateGlobals({ + myGlobal: () => 'myGlobal', + myFunctionGlobal: (...args: JsonValue[]) => args[0] + args[1], + }); + scaffolder.additionalTemplateFilters({ + myFilter: () => 'the value is this now', + myOtherFilter: (...args: JsonValue[]) => args.join(''), }); }, }); @@ -908,10 +908,16 @@ export default async function createPlugin({ additionalTemplateFilters: { - } + }, + additionalTemplateGlobals: { + + }, }); +} ``` +Note, that addtional template global functions are currently not supported in `fetch:template` (see #25445). + ## Template Editor Writing template is most of the times an iterative process. You will need to test your template to make sure it has a good user experience and that it works as expected. To help on this process the scaffolder comes with a build in template editor that allows you to test your template in a real environment for querying data and execute the actions on dry-run mode to see the results of those one. From 2ce049cce2a25aef486926f07e12f80f41e08e18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veith=20M=2E=20B=C3=BCrgerhoff?= Date: Mon, 23 Sep 2024 10:10:40 +0200 Subject: [PATCH 040/291] fix typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Veith M. Bürgerhoff --- docs/features/software-templates/writing-templates.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index 0c1f7338cb..2cc14f1000 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -803,7 +803,7 @@ The `projectSlug` filter generates a project slug from a repository URL ## Custom Filters and Globals -You may with to extend the filters and globals with your own custom ones. For example `${{ myGlobal | myFilter | myOtherFilter }}` or `${{ myFunctionGlobal(1,2) | myFilter }}`. +You may wish to extend the filters and globals with your own custom ones. For example `${{ myGlobal | myFilter | myOtherFilter }}` or `${{ myFunctionGlobal(1,2) | myFilter }}`. This can be achieved using the `additionalTemplateFilters` and `additionalTemplateGlobals` properties respectively. These properties accept a `Record` From faacf235255936d3f4dfd813c523545a67eb5a3d Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Wed, 25 Sep 2024 10:44:28 +0530 Subject: [PATCH 041/291] Fixed grammar mistakes in the backstage accessibility docs Signed-off-by: AmbrishRamachandiran --- docs/accessibility/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/accessibility/index.md b/docs/accessibility/index.md index 021c8309be..810fc80e73 100644 --- a/docs/accessibility/index.md +++ b/docs/accessibility/index.md @@ -8,7 +8,7 @@ In an effort to bake accessibility practices further into our process of buildin ## How to contribute -There are multiple ways to contribute to making Backstage more accessible, you'll find below a list of examples to help you get started. +There are multiple ways to contribute to making Backstage more accessible; you'll find below a list of examples to help you get started. ### Run Lighthouse in CI on your plugin @@ -58,6 +58,6 @@ If your Backstage plugin lives outside of the [Backstage main repository](https: ### Report identified issues -It’s important to remember that automated checks can only catch a small number of accessibility issues, therefore we also encourage you to conduct manual testing of your plugins using Assistive technology (screen readers, alternative navigation, and screen magnifiers are a few examples). +It’s important to remember that automated checks can only catch a small number of accessibility issues; therefore, we also encourage you to conduct manual testing of your plugins using Assistive technology (screen readers, alternative navigation, and screen magnifiers are a few examples). If you have identified accessibility issues and don’t have time to contribute a fix, please open an issue over at [Backstage Issues](https://github.com/backstage/backstage/issues) to let us know. From bef41425475452302a004fe4b28c7dfba21dfe0e Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Wed, 25 Sep 2024 18:42:44 +0530 Subject: [PATCH 042/291] updated changes as per review Signed-off-by: AmbrishRamachandiran --- docs/accessibility/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/accessibility/index.md b/docs/accessibility/index.md index 810fc80e73..4073afcf4c 100644 --- a/docs/accessibility/index.md +++ b/docs/accessibility/index.md @@ -8,7 +8,7 @@ In an effort to bake accessibility practices further into our process of buildin ## How to contribute -There are multiple ways to contribute to making Backstage more accessible; you'll find below a list of examples to help you get started. +There are multiple ways to contribute to making Backstage more accessible, you'll find below a list of examples to help you get started. ### Run Lighthouse in CI on your plugin @@ -58,6 +58,6 @@ If your Backstage plugin lives outside of the [Backstage main repository](https: ### Report identified issues -It’s important to remember that automated checks can only catch a small number of accessibility issues; therefore, we also encourage you to conduct manual testing of your plugins using Assistive technology (screen readers, alternative navigation, and screen magnifiers are a few examples). +It’s important to remember that automated checks can only catch a small number of accessibility issues, therefore, we also encourage you to conduct manual testing of your plugins using Assistive technology (screen readers, alternative navigation, and screen magnifiers are a few examples). If you have identified accessibility issues and don’t have time to contribute a fix, please open an issue over at [Backstage Issues](https://github.com/backstage/backstage/issues) to let us know. From 0040632c8ce6acf9f06489d5e3c694ed61cea476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerna=C5=9B?= Date: Thu, 26 Sep 2024 22:00:49 +0200 Subject: [PATCH 043/291] feat(user-settings): Search for user avatar also in the entities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the identity system doesn't provide an avatar, but the existing user entity does have the correct annotation use that as the profile avatar. Fixes #26838 Signed-off-by: Łukasz Jernaś --- .changeset/silly-geckos-learn.md | 5 ++ .changeset/ten-rings-look.md | 5 ++ packages/catalog-client/src/types/api.ts | 1 + plugins/user-settings/dev/index.tsx | 29 +++++++- plugins/user-settings/package.json | 1 + .../DefaultSettingsPage.test.tsx | 21 +++++- .../General/UserSettingsIdentityCard.test.tsx | 32 +++++--- .../General/UserSettingsProfileCard.test.tsx | 74 +++++++++++++++++++ .../SettingsPage/SettingsPage.test.tsx | 56 ++++++++++---- .../src/components/useUserProfileInfo.ts | 18 ++++- yarn.lock | 1 + 11 files changed, 210 insertions(+), 33 deletions(-) create mode 100644 .changeset/silly-geckos-learn.md create mode 100644 .changeset/ten-rings-look.md create mode 100644 plugins/user-settings/src/components/General/UserSettingsProfileCard.test.tsx diff --git a/.changeset/silly-geckos-learn.md b/.changeset/silly-geckos-learn.md new file mode 100644 index 0000000000..e0b525fd4e --- /dev/null +++ b/.changeset/silly-geckos-learn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-user-settings': minor +--- + +Search for profile image also in User Entities diff --git a/.changeset/ten-rings-look.md b/.changeset/ten-rings-look.md new file mode 100644 index 0000000000..b5275253f9 --- /dev/null +++ b/.changeset/ten-rings-look.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': minor +--- + +Add missing doc string to API diff --git a/packages/catalog-client/src/types/api.ts b/packages/catalog-client/src/types/api.ts index c8fbc7e7ac..85cea17660 100644 --- a/packages/catalog-client/src/types/api.ts +++ b/packages/catalog-client/src/types/api.ts @@ -650,6 +650,7 @@ export interface CatalogApi { * * @param entity - Entity to validate * @param locationRef - Location ref in format `url:http://example.com/file` + * @param options - Additional options */ validateEntity( entity: Entity, diff --git a/plugins/user-settings/dev/index.tsx b/plugins/user-settings/dev/index.tsx index 90be1e3bad..d7041e96a8 100644 --- a/plugins/user-settings/dev/index.tsx +++ b/plugins/user-settings/dev/index.tsx @@ -17,10 +17,37 @@ import React from 'react'; import { CatalogEntityPage } from '@backstage/plugin-catalog'; import { createDevApp } from '@backstage/dev-utils'; -import { userSettingsPlugin, UserSettingsPage } from '../src/plugin'; +import { UserSettingsPage, userSettingsPlugin } from '../src'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { + CompoundEntityRef, + Entity, + UserEntity, +} from '@backstage/catalog-model'; +const userEntity: UserEntity = { + apiVersion: 'backstage.io/v1beta1', + kind: 'User', + metadata: { + name: 'Guest', + }, + spec: {}, +}; createDevApp() .registerPlugin(userSettingsPlugin) + .registerApi({ + api: catalogApiRef, + deps: {}, + factory() { + return { + async getEntityByRef( + _: string | CompoundEntityRef, + ): Promise { + return userEntity; + }, + } as Partial as unknown as CatalogApi; + }, + }) .addPage({ title: 'Settings', path: '/settings', diff --git a/plugins/user-settings/package.json b/plugins/user-settings/package.json index 4c20c2f0f3..8c73defa89 100644 --- a/plugins/user-settings/package.json +++ b/plugins/user-settings/package.json @@ -55,6 +55,7 @@ "test": "backstage-cli package test" }, "dependencies": { + "@backstage/catalog-model": "workspace:^", "@backstage/core-app-api": "workspace:^", "@backstage/core-compat-api": "workspace:^", "@backstage/core-components": "workspace:^", diff --git a/plugins/user-settings/src/components/DefaultSettingsPage/DefaultSettingsPage.test.tsx b/plugins/user-settings/src/components/DefaultSettingsPage/DefaultSettingsPage.test.tsx index e31c9f26fd..14b7b7b48b 100644 --- a/plugins/user-settings/src/components/DefaultSettingsPage/DefaultSettingsPage.test.tsx +++ b/plugins/user-settings/src/components/DefaultSettingsPage/DefaultSettingsPage.test.tsx @@ -15,24 +15,33 @@ */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { DefaultSettingsPage } from './DefaultSettingsPage'; import { UserSettingsTab } from '../UserSettingsTab'; import { useOutlet } from 'react-router-dom'; import { SettingsLayout } from '../SettingsLayout'; +import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useOutlet: jest.fn().mockReturnValue(undefined), })); +const catalogApiMock: jest.Mocked = { + getEntityByRef: jest.fn(), +} as any; + describe('', () => { beforeEach(() => { (useOutlet as jest.Mock).mockReset(); }); it('should render the settings page with 3 tabs', async () => { - const { container } = await renderInTestApp(); + const { container } = await renderInTestApp( + + + , + ); const tabs = container.querySelectorAll('[class*=MuiTabs-root] a'); expect(tabs).toHaveLength(3); @@ -45,7 +54,9 @@ describe('', () => { ); const { container } = await renderInTestApp( - , + + + , ); const tabs = container.querySelectorAll('[class*=MuiTabs-root] a'); @@ -60,7 +71,9 @@ describe('', () => { ); const { container } = await renderInTestApp( - , + + + , ); const tabs = container.querySelectorAll('[class*=MuiTabs-root] a'); diff --git a/plugins/user-settings/src/components/General/UserSettingsIdentityCard.test.tsx b/plugins/user-settings/src/components/General/UserSettingsIdentityCard.test.tsx index 1be797980b..b1a811d7fa 100644 --- a/plugins/user-settings/src/components/General/UserSettingsIdentityCard.test.tsx +++ b/plugins/user-settings/src/components/General/UserSettingsIdentityCard.test.tsx @@ -20,19 +20,27 @@ import React from 'react'; import { UserSettingsIdentityCard } from './UserSettingsIdentityCard'; import { ApiProvider } from '@backstage/core-app-api'; import { identityApiRef } from '@backstage/core-plugin-api'; -import { entityRouteRef } from '@backstage/plugin-catalog-react'; +import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; -const apiRegistry = TestApiRegistry.from([ - identityApiRef, - { - getProfileInfo: jest.fn(async () => ({})), - getBackstageIdentity: jest.fn(async () => ({ - type: 'user' as const, - userEntityRef: 'foo:bar/foobar', - ownershipEntityRefs: ['user:default/test-ownership'], - })), - }, -]); +const apiRegistry = TestApiRegistry.from( + [ + identityApiRef, + { + getProfileInfo: jest.fn(async () => ({})), + getBackstageIdentity: jest.fn(async () => ({ + type: 'user' as const, + userEntityRef: 'foo:bar/foobar', + ownershipEntityRefs: ['user:default/test-ownership'], + })), + }, + ], + [ + catalogApiRef, + { + getEntityByRef: jest.fn(), + }, + ], +); describe('', () => { it('displays an identity card', async () => { diff --git a/plugins/user-settings/src/components/General/UserSettingsProfileCard.test.tsx b/plugins/user-settings/src/components/General/UserSettingsProfileCard.test.tsx new file mode 100644 index 0000000000..582daadad7 --- /dev/null +++ b/plugins/user-settings/src/components/General/UserSettingsProfileCard.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2024 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, TestApiRegistry } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; +import React from 'react'; +import { identityApiRef } from '@backstage/core-plugin-api'; +import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; +import { ApiProvider } from '@backstage/core-app-api'; +import { UserSettingsProfileCard } from './UserSettingsProfileCard'; + +const apiRegistry = TestApiRegistry.from( + [ + identityApiRef, + { + getProfileInfo: jest.fn(async () => ({})), + getBackstageIdentity: jest.fn(async () => ({ + type: 'user' as const, + userEntityRef: 'foo:bar/foobar', + ownershipEntityRefs: ['user:default/test-ownership'], + })), + }, + ], + [ + catalogApiRef, + { + getEntityByRef: jest.fn(async () => { + return { + apiVersion: 'backstage.io/v1beta1', + kind: 'User', + metadata: { + name: 'Guest', + annotations: {}, + }, + spec: { + profile: { + picture: 'https://example.com/avatar.png', + }, + }, + }; + }), + }, + ], +); + +describe('', () => { + it('displays avatar if it exists in user entity', async () => { + await renderInTestApp( + + + , + { + mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef }, + }, + ); + expect(screen.getByAltText('Profile picture')).toHaveAttribute( + 'src', + 'https://example.com/avatar.png', + ); + }); +}); diff --git a/plugins/user-settings/src/components/SettingsPage/SettingsPage.test.tsx b/plugins/user-settings/src/components/SettingsPage/SettingsPage.test.tsx index d4b941c850..acdb6c3f87 100644 --- a/plugins/user-settings/src/components/SettingsPage/SettingsPage.test.tsx +++ b/plugins/user-settings/src/components/SettingsPage/SettingsPage.test.tsx @@ -15,29 +15,42 @@ */ import React from 'react'; -import { renderInTestApp } from '@backstage/test-utils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { SettingsPage } from './SettingsPage'; import { UserSettingsTab } from '../UserSettingsTab'; import { useOutlet } from 'react-router-dom'; import { SettingsLayout } from '../SettingsLayout'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { entityRouteRef } from '@backstage/plugin-catalog-react'; +import { + CatalogApi, + catalogApiRef, + entityRouteRef, +} from '@backstage/plugin-catalog-react'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useOutlet: jest.fn().mockReturnValue(undefined), })); +const catalogApiMock: jest.Mocked = { + getEntityByRef: jest.fn(), +} as any; + describe('', () => { beforeEach(() => { (useOutlet as jest.Mock).mockReset(); }); it('should render the default settings page with 3 tabs', async () => { - const { container } = await renderInTestApp(, { - mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef }, - }); + const { container } = await renderInTestApp( + + + , + { + mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef }, + }, + ); const tabs = container.querySelectorAll('[class*=MuiTabs-root] a'); expect(tabs).toHaveLength(3); @@ -50,9 +63,14 @@ describe('', () => { ); (useOutlet as jest.Mock).mockReturnValue(advancedTabRoute); - const { container } = await renderInTestApp(, { - mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef }, - }); + const { container } = await renderInTestApp( + + + , + { + mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef }, + }, + ); const tabs = container.querySelectorAll('[class*=MuiTabs-root] a'); expect(tabs).toHaveLength(4); @@ -66,9 +84,14 @@ describe('', () => { ); (useOutlet as jest.Mock).mockReturnValue(advancedTabRoute); - const { container } = await renderInTestApp(, { - mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef }, - }); + const { container } = await renderInTestApp( + + + , + { + mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef }, + }, + ); const tabs = container.querySelectorAll('[class*=MuiTabs-root] a'); expect(tabs).toHaveLength(4); @@ -91,9 +114,14 @@ describe('', () => { ); (useOutlet as jest.Mock).mockReturnValue(customLayout); - const { container } = await renderInTestApp(, { - mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef }, - }); + const { container } = await renderInTestApp( + + + , + { + mountedRoutes: { '/catalog/:namespace/:kind/:name': entityRouteRef }, + }, + ); const tabs = container.querySelectorAll('[class*=MuiTabs-root] a'); expect(tabs).toHaveLength(2); diff --git a/plugins/user-settings/src/components/useUserProfileInfo.ts b/plugins/user-settings/src/components/useUserProfileInfo.ts index ee4578cd6b..f777c3d75b 100644 --- a/plugins/user-settings/src/components/useUserProfileInfo.ts +++ b/plugins/user-settings/src/components/useUserProfileInfo.ts @@ -22,16 +22,30 @@ import { } from '@backstage/core-plugin-api'; import { useEffect } from 'react'; import useAsync from 'react-use/esm/useAsync'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { UserEntity } from '@backstage/catalog-model'; /** @public */ export const useUserProfile = () => { const identityApi = useApi(identityApiRef); const alertApi = useApi(alertApiRef); + const catalogApi = useApi(catalogApiRef); const { value, loading, error } = useAsync(async () => { + const identityProfile = await identityApi.getProfileInfo(); + const backStageIdentity = await identityApi.getBackstageIdentity(); + const catalogProfile = (await catalogApi.getEntityByRef( + backStageIdentity.userEntityRef, + )) as unknown as UserEntity; + if ( + identityProfile.picture === undefined && + catalogProfile?.spec?.profile?.picture + ) { + identityProfile.picture = catalogProfile.spec.profile.picture; + } return { - profile: await identityApi.getProfileInfo(), - identity: await identityApi.getBackstageIdentity(), + profile: identityProfile, + identity: backStageIdentity, }; }, []); diff --git a/yarn.lock b/yarn.lock index 9db800de86..231e84a976 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8140,6 +8140,7 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/plugin-user-settings@workspace:plugins/user-settings" dependencies: + "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/core-app-api": "workspace:^" "@backstage/core-compat-api": "workspace:^" From 276cd3d1bd88f4386f6a4c5fdbe392aa48749976 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 27 Sep 2024 12:38:26 +0000 Subject: [PATCH 044/291] fix(deps): update dependency @kubernetes-models/apimachinery to v2 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .changeset/renovate-f88b005.md | 7 +++++ plugins/kubernetes-cluster/package.json | 2 +- plugins/kubernetes-react/package.json | 2 +- plugins/kubernetes/package.json | 2 +- yarn.lock | 40 ++----------------------- 5 files changed, 13 insertions(+), 40 deletions(-) create mode 100644 .changeset/renovate-f88b005.md diff --git a/.changeset/renovate-f88b005.md b/.changeset/renovate-f88b005.md new file mode 100644 index 0000000000..b62080e8da --- /dev/null +++ b/.changeset/renovate-f88b005.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-kubernetes-cluster': patch +'@backstage/plugin-kubernetes-react': patch +'@backstage/plugin-kubernetes': patch +--- + +Updated dependency `@kubernetes-models/apimachinery` to `^2.0.0`. diff --git a/plugins/kubernetes-cluster/package.json b/plugins/kubernetes-cluster/package.json index 6523292373..65bf617b91 100644 --- a/plugins/kubernetes-cluster/package.json +++ b/plugins/kubernetes-cluster/package.json @@ -47,7 +47,7 @@ "@backstage/plugin-catalog-react": "workspace:^", "@backstage/plugin-kubernetes-common": "workspace:^", "@backstage/plugin-kubernetes-react": "workspace:^", - "@kubernetes-models/apimachinery": "^1.1.0", + "@kubernetes-models/apimachinery": "^2.0.0", "@kubernetes-models/base": "^5.0.0", "@material-ui/core": "^4.12.2", "@material-ui/lab": "4.0.0-alpha.61", diff --git a/plugins/kubernetes-react/package.json b/plugins/kubernetes-react/package.json index a568016925..582ad9196b 100644 --- a/plugins/kubernetes-react/package.json +++ b/plugins/kubernetes-react/package.json @@ -47,7 +47,7 @@ "@backstage/errors": "workspace:^", "@backstage/plugin-kubernetes-common": "workspace:^", "@backstage/types": "workspace:^", - "@kubernetes-models/apimachinery": "^1.1.0", + "@kubernetes-models/apimachinery": "^2.0.0", "@kubernetes-models/base": "^5.0.0", "@kubernetes/client-node": "^0.20.0", "@material-ui/core": "^4.9.13", diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index 8625fc9167..6d3ac3eddb 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -66,7 +66,7 @@ "@backstage/plugin-catalog-react": "workspace:^", "@backstage/plugin-kubernetes-common": "workspace:^", "@backstage/plugin-kubernetes-react": "workspace:^", - "@kubernetes-models/apimachinery": "^1.1.0", + "@kubernetes-models/apimachinery": "^2.0.0", "@kubernetes-models/base": "^5.0.0", "@kubernetes/client-node": "0.20.0", "@material-ui/core": "^4.12.2", diff --git a/yarn.lock b/yarn.lock index b8a663a95e..fbc5cf1be5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6510,7 +6510,7 @@ __metadata: "@backstage/plugin-kubernetes-common": "workspace:^" "@backstage/plugin-kubernetes-react": "workspace:^" "@backstage/test-utils": "workspace:^" - "@kubernetes-models/apimachinery": ^1.1.0 + "@kubernetes-models/apimachinery": ^2.0.0 "@kubernetes-models/base": ^5.0.0 "@material-ui/core": ^4.12.2 "@material-ui/lab": 4.0.0-alpha.61 @@ -6582,7 +6582,7 @@ __metadata: "@backstage/plugin-kubernetes-common": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" - "@kubernetes-models/apimachinery": ^1.1.0 + "@kubernetes-models/apimachinery": ^2.0.0 "@kubernetes-models/base": ^5.0.0 "@kubernetes/client-node": ^0.20.0 "@material-ui/core": ^4.9.13 @@ -6622,7 +6622,7 @@ __metadata: "@backstage/plugin-kubernetes-common": "workspace:^" "@backstage/plugin-kubernetes-react": "workspace:^" "@backstage/test-utils": "workspace:^" - "@kubernetes-models/apimachinery": ^1.1.0 + "@kubernetes-models/apimachinery": ^2.0.0 "@kubernetes-models/base": ^5.0.0 "@kubernetes/client-node": 0.20.0 "@material-ui/core": ^4.12.2 @@ -10592,17 +10592,6 @@ __metadata: languageName: node linkType: hard -"@kubernetes-models/apimachinery@npm:^1.1.0": - version: 1.2.2 - resolution: "@kubernetes-models/apimachinery@npm:1.2.2" - dependencies: - "@kubernetes-models/base": ^4.0.4 - "@kubernetes-models/validate": ^3.1.2 - tslib: ^2.4.0 - checksum: 57387bb8a1f43b740506877d2557a188122512dd2f05c54e3076e0d6cf9a2112f7c5cbbba762c8f4fee22845fcf1302b97537a9e24cfd18b6a99d51a1360136a - languageName: node - linkType: hard - "@kubernetes-models/apimachinery@npm:^2.0.0": version: 2.0.0 resolution: "@kubernetes-models/apimachinery@npm:2.0.0" @@ -10614,17 +10603,6 @@ __metadata: languageName: node linkType: hard -"@kubernetes-models/base@npm:^4.0.4": - version: 4.0.4 - resolution: "@kubernetes-models/base@npm:4.0.4" - dependencies: - "@kubernetes-models/validate": ^3.1.2 - is-plain-object: ^5.0.0 - tslib: ^2.4.0 - checksum: 15d531118e8a85eda8fd9602de4ba0e61e794d52218df7acb9842fe3c2fad5e788f3468768e8287405c6228625cce5cb1d91d923499e401a1a0f55287b2e6b5a - languageName: node - linkType: hard - "@kubernetes-models/base@npm:^5.0.0": version: 5.0.0 resolution: "@kubernetes-models/base@npm:5.0.0" @@ -10636,18 +10614,6 @@ __metadata: languageName: node linkType: hard -"@kubernetes-models/validate@npm:^3.1.2": - version: 3.1.2 - resolution: "@kubernetes-models/validate@npm:3.1.2" - dependencies: - ajv: ^8.12.0 - ajv-formats: ^2.1.1 - ajv-formats-draft2019: ^1.6.1 - tslib: ^2.4.0 - checksum: 44bd7e45aa9716448532f5afc6510398a7eed77c23df67acd94acde4e940c3284a879cc62372fb3dcb67c12d2d10e43900bdd98bc39d1177492c9f2d48b63d4e - languageName: node - linkType: hard - "@kubernetes-models/validate@npm:^4.0.0": version: 4.0.0 resolution: "@kubernetes-models/validate@npm:4.0.0" From 25d00302eb106efc662eee975648577225f61c9a Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Mon, 30 Sep 2024 13:40:23 +0530 Subject: [PATCH 045/291] Fixing grammer mistakes in openapi-docs Signed-off-by: AmbrishRamachandiran --- docs/openapi/01-getting-started.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/openapi/01-getting-started.md b/docs/openapi/01-getting-started.md index a9c70c61f8..df0f72194b 100644 --- a/docs/openapi/01-getting-started.md +++ b/docs/openapi/01-getting-started.md @@ -14,8 +14,8 @@ Difficulty: Medium The goal of this tutorial is to give you exposure to tools that more tightly couple your OpenAPI specification and plugin lifecycle. The tools we'll be presenting were created by the OpenAPI tooling project area and allow you to create, -1. A typed `express` router that provides strong guardrails during development for input and output values. Support for query, path parameters and request body, as well as experimental support for headers and cookies. -2. An auto-generated client to interact with your plugin's backend. Support for all request types, parameters and body, as well as return types. Provides a low-level interface to allow more customization by higher level libraries. +1. A typed `express` router that provides strong guardrails during development for input and output values. Support for query, parameters, and request body, as well as experimental support for headers and cookies. +2. An auto-generated client to interact with your plugin's backend. Support for all request types, parameters, and body, as well as return types. Provides a low-level interface to allow more customization by higher-level libraries. 3. Validation and verification tooling to ensure your API and specification stay in sync. Includes testing against your unit tests. ## Prerequisites @@ -32,8 +32,8 @@ This tutorial assumes that you're already familiar with the following, There are two required npm packages before we start, -1. `@backstage/repo-tools`, this package contains all OpenAPI related commands for your plugins. We will be using this throughout the tutorial. -2. `@useoptic/optic`, this package is a dependency of `@backstage/repo-tools` but is only required for OpenAPI related commands. +1. `@backstage/repo-tools`, this package contains all OpenAPI-related commands for your plugins. We will be using this throughout the tutorial. +2. `@useoptic/optic`, this package is a dependency of `@backstage/repo-tools` but is only required for OpenAPI-related commands. Further, for generating the client a `java` binary has to be available on your PATH. @@ -47,7 +47,7 @@ You should create a new folder, `src/schema` in your backend plugin to store you ## Generating a typed express router from a spec -Run `yarn backstage-repo-tools package schema openapi generate --server` from the directory with your plugin. This will create an `openapi.generated.ts` file in the `src/schema` directory that contains the OpenAPI schema as well as a generated express router with types. You should add this command to your `package.json` for future use and you can combine both the server generation and the client generation below like so, `yarn backstage-repo-tools package schema openapi generate --server --client-package ` +Run `yarn backstage-repo-tools package schema openapi generate --server` from the directory with your plugin. This will create an `openapi.generated.ts` file in the `src/schema` directory that contains the OpenAPI schema as well as a generated express router with types. You should add this command to your `package.json` for future use, and you can combine both the server generation and the client generation below, like so, `yarn backstage-repo-tools package schema openapi generate --server --client-package ` Use it like so, update your `router.ts` or `createRouter.ts` file with the following content, @@ -86,7 +86,7 @@ export class CatalogClient implements CatalogApi { usage of the types will depend on your type names. -You should be able to use the generated `DefaultApi.client.ts` file out of the box for your API needs. For full customization, you can use a wrapper around the generated client to adjust the flavor of your clients. +You should be able to use the generated `DefaultApi.client.ts` file out of the box for your API needs. For full customization, you can use a wrapper around the generated client to adjust the flavour of your clients. For more information, see [the docs](./generate-client.md). From 8bbb568d46404f937d1f300ff36befd16c0cd5b8 Mon Sep 17 00:00:00 2001 From: AmbrishRamachandiran Date: Mon, 30 Sep 2024 13:44:41 +0530 Subject: [PATCH 046/291] fixing grammer mistakes in openapi-docs Signed-off-by: AmbrishRamachandiran --- docs/openapi/01-getting-started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/openapi/01-getting-started.md b/docs/openapi/01-getting-started.md index df0f72194b..156df9bd17 100644 --- a/docs/openapi/01-getting-started.md +++ b/docs/openapi/01-getting-started.md @@ -14,7 +14,7 @@ Difficulty: Medium The goal of this tutorial is to give you exposure to tools that more tightly couple your OpenAPI specification and plugin lifecycle. The tools we'll be presenting were created by the OpenAPI tooling project area and allow you to create, -1. A typed `express` router that provides strong guardrails during development for input and output values. Support for query, parameters, and request body, as well as experimental support for headers and cookies. +1. A typed `express` router that provides strong guardrails during development for input and output values. Support for query, path parameters, and request body, as well as experimental support for headers and cookies. 2. An auto-generated client to interact with your plugin's backend. Support for all request types, parameters, and body, as well as return types. Provides a low-level interface to allow more customization by higher-level libraries. 3. Validation and verification tooling to ensure your API and specification stay in sync. Includes testing against your unit tests. From 66141bbd21dd25943a539f828561ea2b42edeab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerna=C5=9B?= Date: Mon, 30 Sep 2024 13:00:49 +0200 Subject: [PATCH 047/291] Update .changeset/silly-geckos-learn.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Vincenzo Scamporlino Signed-off-by: Łukasz Jernaś --- .changeset/silly-geckos-learn.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/silly-geckos-learn.md b/.changeset/silly-geckos-learn.md index e0b525fd4e..53d4301b5c 100644 --- a/.changeset/silly-geckos-learn.md +++ b/.changeset/silly-geckos-learn.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-user-settings': minor +'@backstage/plugin-user-settings': patch --- -Search for profile image also in User Entities +`useUserProfile` will now use the user's picture stored in the catalog as a fallback if the identity provider doesn't return a picture. From 5c7d9a3808adf5bb6ed11bcdf7f6016ef9955979 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerna=C5=9B?= Date: Mon, 30 Sep 2024 13:01:01 +0200 Subject: [PATCH 048/291] Update .changeset/ten-rings-look.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Vincenzo Scamporlino Signed-off-by: Łukasz Jernaś --- .changeset/ten-rings-look.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/ten-rings-look.md b/.changeset/ten-rings-look.md index b5275253f9..d3f6e059bd 100644 --- a/.changeset/ten-rings-look.md +++ b/.changeset/ten-rings-look.md @@ -1,5 +1,5 @@ --- -'@backstage/catalog-client': minor +'@backstage/catalog-client': patch --- Add missing doc string to API From 3e3aa8094946bf6933cb58125ada2fa1d1076b41 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 1 Oct 2024 07:27:10 +0000 Subject: [PATCH 049/291] chore(deps): update chromaui/action digest to 30b6228 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/verify_storybook.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/verify_storybook.yml b/.github/workflows/verify_storybook.yml index 458ef69690..77a01f9ffd 100644 --- a/.github/workflows/verify_storybook.yml +++ b/.github/workflows/verify_storybook.yml @@ -51,7 +51,7 @@ jobs: - run: yarn build-storybook - - uses: chromaui/action@f4e60a7072abcac4203f4ca50613f28e199a52ba # v11 + - uses: chromaui/action@30b6228aa809059d46219e0f556752e8672a7e26 # v11 with: token: ${{ secrets.GITHUB_TOKEN }} # projectToken intentionally shared to allow collaborators to run Chromatic on forks From 720a2f943a7d06634b5e3d4f39c36407c48bce30 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 1 Oct 2024 09:08:04 +0000 Subject: [PATCH 050/291] fix(deps): update dependency git-url-parse to v15 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .changeset/renovate-85a184e.md | 15 +++++++++ packages/backend-defaults/package.json | 2 +- packages/cli/package.json | 2 +- packages/integration/package.json | 2 +- .../package.json | 2 +- plugins/catalog-backend/package.json | 2 +- plugins/catalog-import/package.json | 2 +- .../package.json | 2 +- plugins/scaffolder/package.json | 2 +- .../package.json | 2 +- plugins/techdocs-node/package.json | 2 +- plugins/techdocs/package.json | 2 +- yarn.lock | 31 ++++++++++++------- 13 files changed, 46 insertions(+), 22 deletions(-) create mode 100644 .changeset/renovate-85a184e.md diff --git a/.changeset/renovate-85a184e.md b/.changeset/renovate-85a184e.md new file mode 100644 index 0000000000..91b575e6db --- /dev/null +++ b/.changeset/renovate-85a184e.md @@ -0,0 +1,15 @@ +--- +'@backstage/backend-defaults': patch +'@backstage/cli': patch +'@backstage/integration': patch +'@backstage/plugin-catalog-backend-module-github': patch +'@backstage/plugin-catalog-backend': patch +'@backstage/plugin-catalog-import': patch +'@backstage/plugin-scaffolder-backend-module-confluence-to-markdown': patch +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-techdocs-module-addons-contrib': patch +'@backstage/plugin-techdocs-node': patch +'@backstage/plugin-techdocs': patch +--- + +Updated dependency `git-url-parse` to `^15.0.0`. diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index 4437a4ff1f..8e3525989d 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -153,7 +153,7 @@ "express": "^4.17.1", "express-promise-router": "^4.1.0", "fs-extra": "^11.2.0", - "git-url-parse": "^14.0.0", + "git-url-parse": "^15.0.0", "helmet": "^6.0.0", "isomorphic-git": "^1.23.0", "jose": "^5.0.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 2cbf7b8484..f032d37c1f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -106,7 +106,7 @@ "express": "^4.17.1", "fork-ts-checker-webpack-plugin": "^9.0.0", "fs-extra": "^11.2.0", - "git-url-parse": "^14.0.0", + "git-url-parse": "^15.0.0", "glob": "^7.1.7", "global-agent": "^3.0.0", "handlebars": "^4.7.3", diff --git a/packages/integration/package.json b/packages/integration/package.json index 4566c8efe2..6fc8f8cadb 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -43,7 +43,7 @@ "@octokit/auth-app": "^4.0.0", "@octokit/rest": "^19.0.3", "cross-fetch": "^4.0.0", - "git-url-parse": "^14.0.0", + "git-url-parse": "^15.0.0", "lodash": "^4.17.21", "luxon": "^3.0.0" }, diff --git a/plugins/catalog-backend-module-github/package.json b/plugins/catalog-backend-module-github/package.json index 876deb2db8..3b916c4a52 100644 --- a/plugins/catalog-backend-module-github/package.json +++ b/plugins/catalog-backend-module-github/package.json @@ -63,7 +63,7 @@ "@backstage/plugin-events-node": "workspace:^", "@octokit/graphql": "^5.0.0", "@octokit/rest": "^19.0.3", - "git-url-parse": "^14.0.0", + "git-url-parse": "^15.0.0", "lodash": "^4.17.21", "minimatch": "^9.0.0", "node-fetch": "^2.7.0", diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 1a18bfdebc..0df7ad33c5 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -83,7 +83,7 @@ "express": "^4.17.1", "fast-json-stable-stringify": "^2.1.0", "fs-extra": "^11.2.0", - "git-url-parse": "^14.0.0", + "git-url-parse": "^15.0.0", "glob": "^7.1.6", "knex": "^3.0.0", "lodash": "^4.17.21", diff --git a/plugins/catalog-import/package.json b/plugins/catalog-import/package.json index 277e259458..6245a38a03 100644 --- a/plugins/catalog-import/package.json +++ b/plugins/catalog-import/package.json @@ -70,7 +70,7 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", "@octokit/rest": "^19.0.3", - "git-url-parse": "^14.0.0", + "git-url-parse": "^15.0.0", "js-base64": "^3.6.0", "lodash": "^4.17.21", "react-hook-form": "^7.12.2", diff --git a/plugins/scaffolder-backend-module-confluence-to-markdown/package.json b/plugins/scaffolder-backend-module-confluence-to-markdown/package.json index 17a4b22d70..7d15c30e80 100644 --- a/plugins/scaffolder-backend-module-confluence-to-markdown/package.json +++ b/plugins/scaffolder-backend-module-confluence-to-markdown/package.json @@ -52,7 +52,7 @@ "@backstage/integration": "workspace:^", "@backstage/plugin-scaffolder-node": "workspace:^", "fs-extra": "^11.2.0", - "git-url-parse": "^14.0.0", + "git-url-parse": "^15.0.0", "node-fetch": "^2.7.0", "node-html-markdown": "^1.3.0", "yaml": "^2.0.0" diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 01c7dacf66..af5d8a33a2 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -87,7 +87,7 @@ "@rjsf/validator-ajv8": "5.21.1", "@uiw/react-codemirror": "^4.9.3", "classnames": "^2.2.6", - "git-url-parse": "^14.0.0", + "git-url-parse": "^15.0.0", "humanize-duration": "^3.25.1", "idb-keyval": "5.0.2", "json-schema": "^0.4.0", diff --git a/plugins/techdocs-module-addons-contrib/package.json b/plugins/techdocs-module-addons-contrib/package.json index 951bd8f432..a7866cd2bb 100644 --- a/plugins/techdocs-module-addons-contrib/package.json +++ b/plugins/techdocs-module-addons-contrib/package.json @@ -47,7 +47,7 @@ "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@react-hookz/web": "^24.0.0", - "git-url-parse": "^14.0.0", + "git-url-parse": "^15.0.0", "photoswipe": "^5.3.7" }, "devDependencies": { diff --git a/plugins/techdocs-node/package.json b/plugins/techdocs-node/package.json index e807b7abb0..2d9f0be516 100644 --- a/plugins/techdocs-node/package.json +++ b/plugins/techdocs-node/package.json @@ -68,7 +68,7 @@ "dockerode": "^4.0.0", "express": "^4.17.1", "fs-extra": "^11.2.0", - "git-url-parse": "^14.0.0", + "git-url-parse": "^15.0.0", "hpagent": "^1.2.0", "js-yaml": "^4.0.0", "json5": "^2.1.3", diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 8fe9b71c1c..e11ba0618d 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -81,7 +81,7 @@ "@material-ui/styles": "^4.10.0", "@microsoft/fetch-event-source": "^2.0.1", "dompurify": "^3.0.0", - "git-url-parse": "^14.0.0", + "git-url-parse": "^15.0.0", "jss": "~10.10.0", "lodash": "^4.17.21", "react-helmet": "6.1.0", diff --git a/yarn.lock b/yarn.lock index 11bbcb2240..c190460f9a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3651,7 +3651,7 @@ __metadata: express: ^4.17.1 express-promise-router: ^4.1.0 fs-extra: ^11.2.0 - git-url-parse: ^14.0.0 + git-url-parse: ^15.0.0 helmet: ^6.0.0 http-errors: ^2.0.0 isomorphic-git: ^1.23.0 @@ -3981,7 +3981,7 @@ __metadata: express: ^4.17.1 fork-ts-checker-webpack-plugin: ^9.0.0 fs-extra: ^11.2.0 - git-url-parse: ^14.0.0 + git-url-parse: ^15.0.0 glob: ^7.1.7 global-agent: ^3.0.0 handlebars: ^4.7.3 @@ -4765,7 +4765,7 @@ __metadata: "@octokit/rest": ^19.0.3 "@types/luxon": ^3.0.0 cross-fetch: ^4.0.0 - git-url-parse: ^14.0.0 + git-url-parse: ^15.0.0 lodash: ^4.17.21 luxon: ^3.0.0 msw: ^1.0.0 @@ -5672,7 +5672,7 @@ __metadata: "@octokit/graphql": ^5.0.0 "@octokit/rest": ^19.0.3 "@types/lodash": ^4.14.151 - git-url-parse: ^14.0.0 + git-url-parse: ^15.0.0 lodash: ^4.17.21 luxon: ^3.0.0 minimatch: ^9.0.0 @@ -5921,7 +5921,7 @@ __metadata: express: ^4.17.1 fast-json-stable-stringify: ^2.1.0 fs-extra: ^11.2.0 - git-url-parse: ^14.0.0 + git-url-parse: ^15.0.0 glob: ^7.1.6 knex: ^3.0.0 lodash: ^4.17.21 @@ -6026,7 +6026,7 @@ __metadata: "@testing-library/react": ^16.0.0 "@testing-library/user-event": ^14.0.0 "@types/react": ^18.0.0 - git-url-parse: ^14.0.0 + git-url-parse: ^15.0.0 js-base64: ^3.6.0 lodash: ^4.17.21 msw: ^1.0.0 @@ -7263,7 +7263,7 @@ __metadata: "@backstage/plugin-scaffolder-node": "workspace:^" "@backstage/plugin-scaffolder-node-test-utils": "workspace:^" fs-extra: ^11.2.0 - git-url-parse: ^14.0.0 + git-url-parse: ^15.0.0 msw: ^1.0.0 node-fetch: ^2.7.0 node-html-markdown: ^1.3.0 @@ -7718,7 +7718,7 @@ __metadata: "@types/react-window": ^1.8.8 "@uiw/react-codemirror": ^4.9.3 classnames: ^2.2.6 - git-url-parse: ^14.0.0 + git-url-parse: ^15.0.0 humanize-duration: ^3.25.1 idb-keyval: 5.0.2 json-schema: ^0.4.0 @@ -8222,7 +8222,7 @@ __metadata: "@testing-library/jest-dom": ^6.0.0 "@testing-library/react": ^16.0.0 "@types/react": ^18.0.0 - git-url-parse: ^14.0.0 + git-url-parse: ^15.0.0 photoswipe: ^5.3.7 react: ^18.0.2 react-dom: ^18.0.2 @@ -8271,7 +8271,7 @@ __metadata: dockerode: ^4.0.0 express: ^4.17.1 fs-extra: ^11.2.0 - git-url-parse: ^14.0.0 + git-url-parse: ^15.0.0 hpagent: ^1.2.0 js-yaml: ^4.0.0 json5: ^2.1.3 @@ -8356,7 +8356,7 @@ __metadata: "@types/react": ^18.0.0 canvas: ^2.10.2 dompurify: ^3.0.0 - git-url-parse: ^14.0.0 + git-url-parse: ^15.0.0 jss: ~10.10.0 lodash: ^4.17.21 react: ^18.0.2 @@ -28405,6 +28405,15 @@ __metadata: languageName: node linkType: hard +"git-url-parse@npm:^15.0.0": + version: 15.0.0 + resolution: "git-url-parse@npm:15.0.0" + dependencies: + git-up: ^7.0.0 + checksum: 4379e9b0a1297b62d603630341a7ce1a6e17901114ee7bb70a611f62ea0fd3b3649ac754891d2746fd42031a21e97ddc0092287a04cc028cbf37df8f6be65a9e + languageName: node + linkType: hard + "github-from-package@npm:0.0.0": version: 0.0.0 resolution: "github-from-package@npm:0.0.0" From b1254b40a8b78782acc4ecace2e118b8362f3b75 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 1 Oct 2024 12:57:15 +0200 Subject: [PATCH 051/291] Add root lifecycle shutdown hook to clean up DB connections Signed-off-by: Eric Peterson --- .../entrypoints/database/DatabaseManager.ts | 28 +++++++++++++++++++ .../database/databaseServiceFactory.ts | 12 ++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts b/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts index 46d4ee8cf7..6ed5545d35 100644 --- a/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts +++ b/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts @@ -89,6 +89,27 @@ export class DatabaseManagerImpl { return { getClient, migrations: { skip } }; } + /** + * Method to be called during shutdown to destroy all known connections. + */ + async shutdown(deps: { logger: LoggerService }): Promise { + const pluginIds = Array.from(this.databaseCache.keys()); + await Promise.all( + pluginIds.map(async pluginId => { + const connection = await this.databaseCache.get(pluginId); + if (connection) { + await connection.destroy().catch((error: unknown) => { + deps.logger.error( + `Problem closing database connection for ${pluginId}: ${stringifyError( + error, + )}`, + ); + }); + } + }), + ); + } + /** * Provides the client type which should be used for a given plugin. * @@ -236,4 +257,11 @@ export class DatabaseManager { ): PluginDatabaseManager { return this.impl.forPlugin(pluginId, deps); } + + /** + * Method to be called during shutdown to destroy all known connections. + */ + async shutdown(deps: { logger: LoggerService }): Promise { + return this.impl.shutdown(deps); + } } diff --git a/packages/backend-defaults/src/entrypoints/database/databaseServiceFactory.ts b/packages/backend-defaults/src/entrypoints/database/databaseServiceFactory.ts index 167779e807..5e23e6ac4c 100644 --- a/packages/backend-defaults/src/entrypoints/database/databaseServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/database/databaseServiceFactory.ts @@ -37,9 +37,11 @@ export const databaseServiceFactory = createServiceFactory({ lifecycle: coreServices.lifecycle, logger: coreServices.logger, pluginMetadata: coreServices.pluginMetadata, + rootLifecycle: coreServices.rootLifecycle, + rootLogger: coreServices.rootLogger, }, - async createRootContext({ config }) { - return config.getOptional('backend.database') + async createRootContext({ config, rootLifecycle, rootLogger }) { + const databaseManager = config.getOptional('backend.database') ? DatabaseManager.fromConfig(config) : DatabaseManager.fromConfig( new ConfigReader({ @@ -48,6 +50,12 @@ export const databaseServiceFactory = createServiceFactory({ }, }), ); + + rootLifecycle.addShutdownHook(async () => { + await databaseManager.shutdown({ logger: rootLogger }); + }); + + return databaseManager; }, async factory({ pluginMetadata, lifecycle, logger }, databaseManager) { return databaseManager.forPlugin(pluginMetadata.getId(), { From 8bfe9f0f61aad548b6d129610b05b56aa9f93f2b Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 1 Oct 2024 12:57:43 +0200 Subject: [PATCH 052/291] Ensure root lifecycle shutdown hooks happen after plugins' Signed-off-by: Eric Peterson --- .../src/wiring/BackendInitializer.ts | 24 ++++++++- .../lifecycle/lifecycleServiceFactory.ts | 51 +++++++++++++++---- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.ts b/packages/backend-app-api/src/wiring/BackendInitializer.ts index 86771fbb74..e580109987 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -407,6 +407,26 @@ export class BackendInitializer { // The startup failed, but we may still want to do cleanup so we continue silently } + // Get all plugins. + const pluginMap = new Map(); + for (const feature of this.#registrations) { + for (const r of feature.getRegistrations()) { + if (r.type === 'plugin') { + pluginMap.set(r.pluginId, true); + } + } + } + const allPluginIds = Array.from(pluginMap.keys()); + + // Iterate through all plugins and run their shutdown hooks. + await Promise.allSettled( + allPluginIds.map(async pluginId => { + const lifecycleService = await this.#getPluginLifecycleImpl(pluginId); + await lifecycleService.shutdown(); + }), + ); + + // Once all plugin shutdown hooks are done, run root shutdown hooks. const lifecycleService = await this.#getRootLifecycleImpl(); await lifecycleService.shutdown(); } @@ -437,7 +457,9 @@ export class BackendInitializer { async #getPluginLifecycleImpl( pluginId: string, - ): Promise }> { + ): Promise< + LifecycleService & { startup(): Promise; shutdown(): Promise } + > { const lifecycleService = await this.#serviceRegistry.get( coreServices.lifecycle, pluginId, diff --git a/packages/backend-defaults/src/entrypoints/lifecycle/lifecycleServiceFactory.ts b/packages/backend-defaults/src/entrypoints/lifecycle/lifecycleServiceFactory.ts index 192886ca61..02195d0cd0 100644 --- a/packages/backend-defaults/src/entrypoints/lifecycle/lifecycleServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/lifecycle/lifecycleServiceFactory.ts @@ -22,7 +22,6 @@ import { LifecycleServiceStartupOptions, LoggerService, PluginMetadataService, - RootLifecycleService, coreServices, createServiceFactory, } from '@backstage/backend-plugin-api'; @@ -31,15 +30,19 @@ import { export class BackendPluginLifecycleImpl implements LifecycleService { constructor( private readonly logger: LoggerService, - private readonly rootLifecycle: RootLifecycleService, private readonly pluginMetadata: PluginMetadataService, ) {} #hasStarted = false; + #hasShutdown = false; #startupTasks: Array<{ hook: LifecycleServiceStartupHook; options?: LifecycleServiceStartupOptions; }> = []; + #shutdownTasks: Array<{ + hook: LifecycleServiceShutdownHook; + options?: LifecycleServiceShutdownOptions; + }> = []; addStartupHook( hook: LifecycleServiceStartupHook, @@ -77,11 +80,42 @@ export class BackendPluginLifecycleImpl implements LifecycleService { hook: LifecycleServiceShutdownHook, options?: LifecycleServiceShutdownOptions, ): void { + if (this.#hasShutdown) { + throw new Error('Attempted to add shutdown hook after shutdown'); + } const plugin = this.pluginMetadata.getId(); - this.rootLifecycle.addShutdownHook(hook, { - logger: options?.logger?.child({ plugin }) ?? this.logger, + const logger = options?.logger?.child({ plugin }) ?? this.logger; + this.#shutdownTasks.push({ + hook, + options: { + ...options, + logger, + }, }); } + + async shutdown(): Promise { + if (this.#hasShutdown) { + return; + } + this.#hasShutdown = true; + + this.logger.debug( + `Running ${this.#shutdownTasks.length} plugin shutdown tasks...`, + ); + + await Promise.all( + this.#shutdownTasks.map(async ({ hook, options }) => { + const logger = options?.logger ?? this.logger; + try { + await hook(); + logger.debug(`Plugin shutdown hook succeeded`); + } catch (error) { + logger.error(`Plugin shutdown hook failed, ${error}`); + } + }), + ); + } } /** @@ -97,14 +131,9 @@ export const lifecycleServiceFactory = createServiceFactory({ service: coreServices.lifecycle, deps: { logger: coreServices.logger, - rootLifecycle: coreServices.rootLifecycle, pluginMetadata: coreServices.pluginMetadata, }, - async factory({ rootLifecycle, logger, pluginMetadata }) { - return new BackendPluginLifecycleImpl( - logger, - rootLifecycle, - pluginMetadata, - ); + async factory({ logger, pluginMetadata }) { + return new BackendPluginLifecycleImpl(logger, pluginMetadata); }, }); From 1aaaf7033705b1199be90f3b3c9586a412171e49 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 1 Oct 2024 14:10:16 +0200 Subject: [PATCH 053/291] Minor cleanup based on feedback. Signed-off-by: Eric Peterson --- .../src/wiring/BackendInitializer.ts | 13 ++++++++----- .../src/entrypoints/database/DatabaseManager.ts | 2 +- .../lifecycle/lifecycleServiceFactory.ts | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/backend-app-api/src/wiring/BackendInitializer.ts b/packages/backend-app-api/src/wiring/BackendInitializer.ts index e580109987..3ea1200898 100644 --- a/packages/backend-app-api/src/wiring/BackendInitializer.ts +++ b/packages/backend-app-api/src/wiring/BackendInitializer.ts @@ -408,19 +408,18 @@ export class BackendInitializer { } // Get all plugins. - const pluginMap = new Map(); + const allPlugins = new Set(); for (const feature of this.#registrations) { for (const r of feature.getRegistrations()) { if (r.type === 'plugin') { - pluginMap.set(r.pluginId, true); + allPlugins.add(r.pluginId); } } } - const allPluginIds = Array.from(pluginMap.keys()); // Iterate through all plugins and run their shutdown hooks. await Promise.allSettled( - allPluginIds.map(async pluginId => { + [...allPlugins].map(async pluginId => { const lifecycleService = await this.#getPluginLifecycleImpl(pluginId); await lifecycleService.shutdown(); }), @@ -466,7 +465,11 @@ export class BackendInitializer { ); const service = lifecycleService as any; - if (service && typeof service.startup === 'function') { + if ( + service && + typeof service.startup === 'function' && + typeof service.shutdown === 'function' + ) { return service; } diff --git a/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts b/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts index 6ed5545d35..ff95c0562a 100644 --- a/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts +++ b/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts @@ -94,7 +94,7 @@ export class DatabaseManagerImpl { */ async shutdown(deps: { logger: LoggerService }): Promise { const pluginIds = Array.from(this.databaseCache.keys()); - await Promise.all( + await Promise.allSettled( pluginIds.map(async pluginId => { const connection = await this.databaseCache.get(pluginId); if (connection) { diff --git a/packages/backend-defaults/src/entrypoints/lifecycle/lifecycleServiceFactory.ts b/packages/backend-defaults/src/entrypoints/lifecycle/lifecycleServiceFactory.ts index 02195d0cd0..0ba812f5b4 100644 --- a/packages/backend-defaults/src/entrypoints/lifecycle/lifecycleServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/lifecycle/lifecycleServiceFactory.ts @@ -111,7 +111,7 @@ export class BackendPluginLifecycleImpl implements LifecycleService { await hook(); logger.debug(`Plugin shutdown hook succeeded`); } catch (error) { - logger.error(`Plugin shutdown hook failed, ${error}`); + logger.error('Plugin shutdown hook failed', error); } }), ); From 8fa3ec895fd6d6d6c8bb5eca73b9f4a87791caaf Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 1 Oct 2024 14:23:22 +0200 Subject: [PATCH 054/291] Refactor DB Manager shutdown to be private Signed-off-by: Eric Peterson --- .../entrypoints/database/DatabaseManager.ts | 27 +++++++++++-------- .../database/databaseServiceFactory.ts | 11 +++----- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts b/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts index ff95c0562a..9441c8bca9 100644 --- a/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts +++ b/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts @@ -19,6 +19,8 @@ import { LifecycleService, LoggerService, RootConfigService, + RootLifecycleService, + RootLoggerService, } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; import { stringifyError } from '@backstage/errors'; @@ -42,6 +44,8 @@ function pluginPath(pluginId: string): string { */ export type DatabaseManagerOptions = { migrations?: DatabaseService['migrations']; + rootLogger?: RootLoggerService; + rootLifecycle?: RootLifecycleService; }; /** @@ -53,7 +57,15 @@ export class DatabaseManagerImpl { private readonly connectors: Record, private readonly options?: DatabaseManagerOptions, private readonly databaseCache: Map> = new Map(), - ) {} + ) { + // If a rootLifecycle service was provided, register a shutdown hook to + // clean up any database connections. + if (options?.rootLifecycle !== undefined) { + options.rootLifecycle.addShutdownHook(async () => { + await this.shutdown({ logger: options.rootLogger }); + }); + } + } /** * Generates a PluginDatabaseManager for consumption by plugins. @@ -90,16 +102,16 @@ export class DatabaseManagerImpl { } /** - * Method to be called during shutdown to destroy all known connections. + * Destroys all known connections. */ - async shutdown(deps: { logger: LoggerService }): Promise { + private async shutdown(deps?: { logger?: LoggerService }): Promise { const pluginIds = Array.from(this.databaseCache.keys()); await Promise.allSettled( pluginIds.map(async pluginId => { const connection = await this.databaseCache.get(pluginId); if (connection) { await connection.destroy().catch((error: unknown) => { - deps.logger.error( + deps?.logger?.error( `Problem closing database connection for ${pluginId}: ${stringifyError( error, )}`, @@ -257,11 +269,4 @@ export class DatabaseManager { ): PluginDatabaseManager { return this.impl.forPlugin(pluginId, deps); } - - /** - * Method to be called during shutdown to destroy all known connections. - */ - async shutdown(deps: { logger: LoggerService }): Promise { - return this.impl.shutdown(deps); - } } diff --git a/packages/backend-defaults/src/entrypoints/database/databaseServiceFactory.ts b/packages/backend-defaults/src/entrypoints/database/databaseServiceFactory.ts index 5e23e6ac4c..525830471b 100644 --- a/packages/backend-defaults/src/entrypoints/database/databaseServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/database/databaseServiceFactory.ts @@ -41,21 +41,16 @@ export const databaseServiceFactory = createServiceFactory({ rootLogger: coreServices.rootLogger, }, async createRootContext({ config, rootLifecycle, rootLogger }) { - const databaseManager = config.getOptional('backend.database') - ? DatabaseManager.fromConfig(config) + return config.getOptional('backend.database') + ? DatabaseManager.fromConfig(config, { rootLifecycle, rootLogger }) : DatabaseManager.fromConfig( new ConfigReader({ backend: { database: { client: 'better-sqlite3', connection: ':memory:' }, }, }), + { rootLifecycle, rootLogger }, ); - - rootLifecycle.addShutdownHook(async () => { - await databaseManager.shutdown({ logger: rootLogger }); - }); - - return databaseManager; }, async factory({ pluginMetadata, lifecycle, logger }, databaseManager) { return databaseManager.forPlugin(pluginMetadata.getId(), { From 2bc52ff8bb9ba032971baa0bdbf054b909b30415 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 1 Oct 2024 16:13:22 +0200 Subject: [PATCH 055/291] Update API report for backend defaults Signed-off-by: Eric Peterson --- packages/backend-defaults/report-database.api.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/backend-defaults/report-database.api.md b/packages/backend-defaults/report-database.api.md index d9e9e9e287..9c07338264 100644 --- a/packages/backend-defaults/report-database.api.md +++ b/packages/backend-defaults/report-database.api.md @@ -7,6 +7,8 @@ import { DatabaseService } from '@backstage/backend-plugin-api'; import { LifecycleService } from '@backstage/backend-plugin-api'; import { LoggerService } from '@backstage/backend-plugin-api'; import { RootConfigService } from '@backstage/backend-plugin-api'; +import { RootLifecycleService } from '@backstage/backend-plugin-api'; +import { RootLoggerService } from '@backstage/backend-plugin-api'; import { ServiceFactory } from '@backstage/backend-plugin-api'; // @public @@ -27,6 +29,8 @@ export class DatabaseManager { // @public export type DatabaseManagerOptions = { migrations?: DatabaseService['migrations']; + rootLogger?: RootLoggerService; + rootLifecycle?: RootLifecycleService; }; // @public From ffd1f4ab74707b3c76177bae2781dbd2beb29759 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 1 Oct 2024 16:20:18 +0200 Subject: [PATCH 056/291] Add changesets Signed-off-by: Eric Peterson --- .changeset/crash-loop-baby.md | 6 ++++++ .changeset/crash-loop-honey.md | 5 +++++ 2 files changed, 11 insertions(+) create mode 100644 .changeset/crash-loop-baby.md create mode 100644 .changeset/crash-loop-honey.md diff --git a/.changeset/crash-loop-baby.md b/.changeset/crash-loop-baby.md new file mode 100644 index 0000000000..4f629c7f92 --- /dev/null +++ b/.changeset/crash-loop-baby.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-app-api': patch +'@backstage/backend-defaults': patch +--- + +Plugin lifecycle shutdown hooks are now performed before root lifecycle shutdown hooks. diff --git a/.changeset/crash-loop-honey.md b/.changeset/crash-loop-honey.md new file mode 100644 index 0000000000..60f9f04600 --- /dev/null +++ b/.changeset/crash-loop-honey.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +The database manager now attempts to close any database connections in a root lifecycle shutdown hook. From 8374f0ca17e00d93115b55db0f09947624aac294 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 1 Oct 2024 16:43:03 +0200 Subject: [PATCH 057/291] Test the database manager shutdown hook Signed-off-by: Eric Peterson --- .../database/DatabaseManager.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/backend-defaults/src/entrypoints/database/DatabaseManager.test.ts b/packages/backend-defaults/src/entrypoints/database/DatabaseManager.test.ts index 26c2d54231..c7e739c7c7 100644 --- a/packages/backend-defaults/src/entrypoints/database/DatabaseManager.test.ts +++ b/packages/backend-defaults/src/entrypoints/database/DatabaseManager.test.ts @@ -176,4 +176,35 @@ describe('DatabaseManagerImpl', () => { skip: true, }); }); + + it('registers a shutdown hook if root lifecycle service is provided', async () => { + // Given a database manager that is provided a rootLifecycle service + const rootLifecycle = { addShutdownHook: jest.fn() } as unknown as any; + const destroy = jest.fn(); + const connector1 = { + getClient: jest.fn().mockResolvedValue({ destroy }), + } satisfies Connector; + const impl = new DatabaseManagerImpl( + new ConfigReader({ + client: 'pg', + }), + { + pg: connector1, + }, + { rootLifecycle }, + ); + + // Then a shutdown hook should have been added + expect(rootLifecycle.addShutdownHook).toHaveBeenCalled(); + const shutdownHook = rootLifecycle.addShutdownHook.mock.calls[0][0]; + + // When a database client for a plugin is retrieved + await impl.forPlugin('plugin1', deps).getClient(); + + // And the shutdownhook is called + await shutdownHook(); + + // Then the destroy method should have been called on the resolved client + expect(destroy).toHaveBeenCalled(); + }); }); From 4935d29d151f335b1c1cb8a50e76fac4f0b8f9e9 Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Tue, 1 Oct 2024 23:12:10 -0400 Subject: [PATCH 058/291] change code to use search params instead of cookie Signed-off-by: Stephen Glass --- .changeset/violet-beds-promise.md | 10 +- packages/core-components/package.json | 1 - .../src/layout/SignInPage/SignInPage.tsx | 17 +--- .../src/layout/SignInPage/providers.tsx | 32 +++--- .../createCookieAuthErrorMiddleware.test.ts | 56 ----------- .../createCookieAuthErrorMiddleware.ts | 52 ---------- plugins/auth-backend/src/service/router.ts | 3 - .../src/oauth/createAuthErrorCookie.ts | 55 ----------- .../oauth/createOAuthRouteHandlers.test.ts | 20 ++-- .../src/oauth/createOAuthRouteHandlers.ts | 8 +- plugins/auth-react/api-report.md | 6 -- plugins/auth-react/src/hooks/index.ts | 1 - .../src/hooks/useSignInAuthError/index.tsx | 17 ---- .../useSignInAuthError.test.tsx | 98 ------------------- .../useSignInAuthError/useSignInAuthError.tsx | 45 --------- yarn.lock | 1 - 16 files changed, 34 insertions(+), 388 deletions(-) delete mode 100644 plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.test.ts delete mode 100644 plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts delete mode 100644 plugins/auth-node/src/oauth/createAuthErrorCookie.ts delete mode 100644 plugins/auth-react/src/hooks/useSignInAuthError/index.tsx delete mode 100644 plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.test.tsx delete mode 100644 plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.tsx diff --git a/.changeset/violet-beds-promise.md b/.changeset/violet-beds-promise.md index ee002d8319..b5369d4c56 100644 --- a/.changeset/violet-beds-promise.md +++ b/.changeset/violet-beds-promise.md @@ -1,14 +1,6 @@ --- '@backstage/core-components': patch -'@backstage/plugin-auth-backend': patch '@backstage/plugin-auth-node': patch -'@backstage/plugin-auth-react': patch --- -Fix authentication error handling using redirect flow via `enableExperimentalRedirectFlow` config. If an error is caught during authentication, the user is redirected back to app origin with `?error=true` query parameter. A cookie is also set in the redirect which contains the error message. The error can be fetched from any custom sign in page using the new `useSignInAuthError` hook. - -```ts -import { useSignInAuthError } from '@backstage/plugin-auth-react'; - -const { error, checkAuthError } = useSignInAuthError(); -``` +Fix authentication error handling using redirect flow via `enableExperimentalRedirectFlow` config. If an error is caught during authentication, the user is redirected back to app origin with `error` query parameter containing the error message. diff --git a/packages/core-components/package.json b/packages/core-components/package.json index 49cecfbb21..a2688a0037 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -57,7 +57,6 @@ "@backstage/config": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", - "@backstage/plugin-auth-react": "workspace:^", "@backstage/theme": "workspace:^", "@backstage/version-bridge": "workspace:^", "@date-io/core": "^1.3.13", diff --git a/packages/core-components/src/layout/SignInPage/SignInPage.tsx b/packages/core-components/src/layout/SignInPage/SignInPage.tsx index d7e8d1fcf6..51e8b62199 100644 --- a/packages/core-components/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core-components/src/layout/SignInPage/SignInPage.tsx @@ -24,7 +24,7 @@ import { UserIdentity } from './UserIdentity'; import Button from '@material-ui/core/Button'; import Grid from '@material-ui/core/Grid'; import Typography from '@material-ui/core/Typography'; -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { useMountEffect } from '@react-hookz/web'; import { Progress } from '../../components/Progress'; import { Content } from '../Content/Content'; @@ -38,7 +38,6 @@ import { IdentityProviders, SignInProviderConfig } from './types'; import { coreComponentsTranslationRef } from '../../translation'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { useSearchParams } from 'react-router-dom'; -import { useSignInAuthError } from '@backstage/plugin-auth-react'; type MultiSignInPageProps = SignInPageProps & { providers: IdentityProviders; @@ -99,7 +98,6 @@ export const SingleSignInPage = ({ const classes = useStyles(); const authApi = useApi(provider.apiRef); const configApi = useApi(configApiRef); - const { error: signInError, checkAuthError } = useSignInAuthError(); const { t } = useTranslationRef(coreComponentsTranslationRef); const [error, setError] = useState(); @@ -112,7 +110,6 @@ export const SingleSignInPage = ({ // User was redirected back to sign in page with error from auth redirect flow const [searchParams, _setSearchParams] = useSearchParams(); const errorParam = searchParams.get('error'); - const hasErrorSearchParam = errorParam !== 'false' && errorParam !== null; type LoginOpts = { checkExisting?: boolean; showPopup?: boolean }; const login = async ({ checkExisting, showPopup }: LoginOpts) => { @@ -126,7 +123,7 @@ export const SingleSignInPage = ({ } // If no session exists, show the sign-in page - if (!identityResponse && (showPopup || auto) && !hasErrorSearchParam) { + if (!identityResponse && (showPopup || auto) && !errorParam) { // Unless auto is set to true, this step should not happen. // When user intentionally clicks the Sign In button, autoShowPopup is set to true setShowLoginPage(true); @@ -161,18 +158,12 @@ export const SingleSignInPage = ({ }; useMountEffect(() => { - if (hasErrorSearchParam) { - checkAuthError(); + if (errorParam) { + setError(new Error(decodeURIComponent(errorParam))); } login({ checkExisting: true }); }); - useEffect(() => { - if (signInError) { - setError(signInError); - } - }, [signInError]); - return showLoginPage ? (

diff --git a/packages/core-components/src/layout/SignInPage/providers.tsx b/packages/core-components/src/layout/SignInPage/providers.tsx index 8d34d8d9de..9cb2fa3c9f 100644 --- a/packages/core-components/src/layout/SignInPage/providers.tsx +++ b/packages/core-components/src/layout/SignInPage/providers.tsx @@ -14,13 +14,7 @@ * limitations under the License. */ -import React, { - useLayoutEffect, - useState, - useMemo, - useCallback, - useEffect, -} from 'react'; +import React, { useLayoutEffect, useState, useMemo, useCallback } from 'react'; import { SignInPageProps, useApi, @@ -39,7 +33,9 @@ import { customProvider } from './customProvider'; import { IdentityApiSignOutProxy } from './IdentityApiSignOutProxy'; import { useSearchParams } from 'react-router-dom'; import { useMountEffect } from '@react-hookz/web'; -import { useSignInAuthError } from '@backstage/plugin-auth-react'; +import { ForwardedError } from '@backstage/errors'; +import { coreComponentsTranslationRef } from '../../translation'; +import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider'; @@ -95,21 +91,23 @@ export const useSignInProviders = ( ) => { const errorApi = useApi(errorApiRef); const apiHolder = useApiHolder(); - const { error: signInError, checkAuthError } = useSignInAuthError(); const [loading, setLoading] = useState(true); + const { t } = useTranslationRef(coreComponentsTranslationRef); // User was redirected back to sign in page with error from auth redirect flow const [searchParams, _setSearchParams] = useSearchParams(); - const errorParam = searchParams.get('error'); - const hasErrorSearchParam = errorParam !== 'false' && errorParam !== null; - useMountEffect(() => hasErrorSearchParam && checkAuthError()); - - useEffect(() => { - if (signInError) { - errorApi.post(signInError); + useMountEffect(() => { + const errorParam = searchParams.get('error'); + if (errorParam) { + errorApi.post( + new ForwardedError(t('signIn.loginFailed'), { + name: 'Error', + message: decodeURIComponent(errorParam), + }), + ); } - }, [errorApi, signInError]); + }); // This decorates the result with sign out logic from this hook const handleWrappedResult = useCallback( diff --git a/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.test.ts b/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.test.ts deleted file mode 100644 index f0c547842a..0000000000 --- a/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright 2024 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 express from 'express'; -import request from 'supertest'; -import cookieParser from 'cookie-parser'; -import { createCookieAuthErrorMiddleware } from './createCookieAuthErrorMiddleware'; - -const AUTH_ERROR_COOKIE = 'auth-error'; - -describe('createCookieAuthErrorMiddleware', () => { - let app: express.Express; - - beforeEach(() => { - app = express(); - app.use(express.json()); - app.use(express.urlencoded({ extended: true })); - - app.use(cookieParser()); - - app.use( - createCookieAuthErrorMiddleware( - 'http://localhost:3000', - 'http://localhost:7000', - ), - ); - }); - - it('should return cookie content if error cookie exists', async () => { - const error = 'test'; - const res = await request(app) - .get('/.backstage/error') - .set('Cookie', `${AUTH_ERROR_COOKIE}=${encodeURIComponent(error)}`); - - expect(res.status).toBe(200); - expect(res.body).toEqual('test'); - }); - - it('should return 404 if error cookie does not exist', async () => { - const res = await request(app).get('/.backstage/error'); - expect(res.status).toBe(404); - }); -}); diff --git a/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts b/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts deleted file mode 100644 index 30724fc3cd..0000000000 --- a/plugins/auth-backend/src/service/createCookieAuthErrorMiddleware.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2024 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 Router from 'express-promise-router'; - -const AUTH_ERROR_COOKIE = 'auth-error'; - -/** - * @public - * Creates a middleware that can be used to get auth errors in redirect flow - */ -export function createCookieAuthErrorMiddleware( - appUrl: string, - authUrl: string, -) { - const router = Router(); - - router.get('/.backstage/error', async (req, res) => { - const error = req.cookies[AUTH_ERROR_COOKIE]; - if (error) { - const { hostname: domain, protocol } = new URL(authUrl); - const secure = protocol === 'https:'; - const sameSite = - new URL(appUrl).hostname !== domain && secure ? 'none' : 'lax'; - - res.clearCookie(AUTH_ERROR_COOKIE, { - path: '/api/auth/.backstage/error', - domain, - sameSite, - secure, - }); - res.status(200).json(error); - } else { - res.status(404).end(); - } - }); - - return router; -} diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 57f01cfba1..4bb42c3abd 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -48,7 +48,6 @@ import { StaticTokenIssuer } from '../identity/StaticTokenIssuer'; import { StaticKeyStore } from '../identity/StaticKeyStore'; import { Config } from '@backstage/config'; import { bindProviderRouters, ProviderFactories } from '../providers/router'; -import { createCookieAuthErrorMiddleware } from './createCookieAuthErrorMiddleware'; /** @public */ export interface RouterOptions { @@ -170,8 +169,6 @@ export async function createRouter( userInfoDatabaseHandler, }); - router.use(createCookieAuthErrorMiddleware(appUrl, authUrl)); - // Gives a more helpful error message than a plain 404 router.use('/:provider/', req => { const { provider } = req.params; diff --git a/plugins/auth-node/src/oauth/createAuthErrorCookie.ts b/plugins/auth-node/src/oauth/createAuthErrorCookie.ts deleted file mode 100644 index 46ef4c4a1c..0000000000 --- a/plugins/auth-node/src/oauth/createAuthErrorCookie.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2024 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 { Response } from 'express'; -import { serializeError } from '@backstage/errors'; - -const ONE_MINUTE_MS = 60 * 1000; - -const AUTH_ERROR_COOKIE = 'auth-error'; - -function configureAuthErrorCookie(apiUrl: string, appOrigin: string) { - const { hostname: domain, pathname: path, protocol } = new URL(apiUrl); - const secure = protocol === 'https:'; - - // 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: 'lax' | 'none' = 'lax'; - if (new URL(appOrigin).hostname !== domain && secure) { - sameSite = 'none'; - } - - return { domain, path, secure, sameSite }; -} - -export function createAuthErrorCookie( - res: Response, - origin: string, - options: { - error: Error; - apiUrl: string; - }, -) { - const { error, apiUrl } = options; - const jsonData = serializeError(error); - - res.cookie(AUTH_ERROR_COOKIE, jsonData, { - maxAge: ONE_MINUTE_MS, - httpOnly: true, - ...configureAuthErrorCookie(apiUrl, origin), - }); -} diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts index b54de50881..e2155df444 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts @@ -743,7 +743,7 @@ describe('createOAuthRouteHandlers', () => { }); }); - it('should set cookie and redirect on caught error', async () => { + it('should set error search param and redirect on caught error', async () => { const app = wrapInApp(createOAuthRouteHandlers(baseConfig)); const res = await request(app) .get('/my-provider/handler/frame') @@ -756,15 +756,21 @@ describe('createOAuthRouteHandlers', () => { }), }); - // redirects on error with auth error cookie + // Check if it redirects on error expect(res.status).toBe(302); - const setCookieHeader = res.header['set-cookie']; - expect(setCookieHeader).toBeDefined(); - const authErrorCookie = setCookieHeader.find((cookie: string) => - cookie.startsWith('auth-error='), + // Extract the redirect URL from the location header + const redirectLocation = res.header.location; + expect(redirectLocation).toBeDefined(); + + // Create a URL object from the redirect location + const redirectUrl = new URL(redirectLocation); + + // Verify that the 'error' search param is set with the encoded error message + const errorMessage = redirectUrl.searchParams.get('error'); + expect(errorMessage).toBe( + encodeURIComponent('Auth response is missing cookie nonce'), ); - expect(authErrorCookie).toBeDefined(); }); }); }); diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts index 847dc4844f..46f8fc3eca 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.ts @@ -42,7 +42,6 @@ import { import { OAuthAuthenticator, OAuthAuthenticatorResult } from './types'; import { Config } from '@backstage/config'; import { CookieScopeManager } from './CookieScopeManager'; -import { createAuthErrorCookie } from './createAuthErrorCookie'; /** @public */ export interface OAuthRouteHandlersOptions { @@ -252,13 +251,8 @@ export function createOAuthRouteHandlers( : new Error('Encountered invalid error'); // Being a bit safe and not forwarding the bad value if (state?.flow === 'redirect' && state?.redirectUrl) { - createAuthErrorCookie(res, state?.redirectUrl, { - error: { name, message }, - apiUrl: `${baseUrl}/.backstage/error`, - }); - const redirectUrl = new URL(state.redirectUrl); - redirectUrl.searchParams.set('error', 'true'); + redirectUrl.searchParams.set('error', encodeURIComponent(message)); // set the error in a cookie and redirect user back to sign in where the error can be rendered res.redirect(redirectUrl.toString()); diff --git a/plugins/auth-react/api-report.md b/plugins/auth-react/api-report.md index 52e6c6bc3a..d5206261d2 100644 --- a/plugins/auth-react/api-report.md +++ b/plugins/auth-react/api-report.md @@ -36,10 +36,4 @@ export function useCookieAuthRefresh(options: { pluginId: string }): expiresAt: string; }; }; - -// @public -export function useSignInAuthError(): { - error: Error | undefined; - checkAuthError: () => void; -}; ``` diff --git a/plugins/auth-react/src/hooks/index.ts b/plugins/auth-react/src/hooks/index.ts index 0e87bab67f..1257334498 100644 --- a/plugins/auth-react/src/hooks/index.ts +++ b/plugins/auth-react/src/hooks/index.ts @@ -18,4 +18,3 @@ // which hooks are public API and should be exported from the package. export * from './useCookieAuthRefresh'; -export * from './useSignInAuthError'; diff --git a/plugins/auth-react/src/hooks/useSignInAuthError/index.tsx b/plugins/auth-react/src/hooks/useSignInAuthError/index.tsx deleted file mode 100644 index 549b5b9f1f..0000000000 --- a/plugins/auth-react/src/hooks/useSignInAuthError/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2024 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 { useSignInAuthError } from './useSignInAuthError'; diff --git a/plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.test.tsx b/plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.test.tsx deleted file mode 100644 index 0c67c419f8..0000000000 --- a/plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright 2024 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 { act, renderHook } from '@testing-library/react'; -import { discoveryApiRef } from '@backstage/core-plugin-api'; -import { TestApiProvider } from '@backstage/test-utils'; -import { useSignInAuthError } from './useSignInAuthError'; -import { serializeError } from '@backstage/errors'; - -describe('useSignInAuthError', () => { - const discoveryApiMock = { - getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7000/api/auth'), - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should return an error when the cookie returns an error object', async () => { - const errorObject = { - name: 'TestError', - message: 'This is a test error', - }; - const serializedError = serializeError(errorObject); - - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(serializedError), - } as unknown as Response); - - const { result } = renderHook(() => useSignInAuthError(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - await act(async () => { - result.current.checkAuthError(); - }); - - expect(discoveryApiMock.getBaseUrl).toHaveBeenCalledWith('auth'); - expect(fetch).toHaveBeenCalledWith( - 'http://localhost:7000/api/auth/.backstage/error', - { - credentials: 'include', - }, - ); - expect(result.current.error).toBeInstanceOf(Error); - expect((result.current.error as Error).name).toEqual(errorObject.name); - expect((result.current.error as Error).message).toEqual( - errorObject.message, - ); - }); - - it('should return undefined when the backend does not return an error object', async () => { - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - json: jest.fn().mockResolvedValue(undefined), - } as unknown as Response); - - const { result } = renderHook(() => useSignInAuthError(), { - wrapper: ({ children }) => ( - - {children} - - ), - }); - - await act(async () => { - result.current.checkAuthError(); - }); - - expect(discoveryApiMock.getBaseUrl).toHaveBeenCalledWith('auth'); - expect(fetch).toHaveBeenCalledWith( - 'http://localhost:7000/api/auth/.backstage/error', - { - credentials: 'include', - }, - ); - expect(result.current.error).toBeUndefined(); - }); -}); diff --git a/plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.tsx b/plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.tsx deleted file mode 100644 index fe146fedca..0000000000 --- a/plugins/auth-react/src/hooks/useSignInAuthError/useSignInAuthError.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2024 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { discoveryApiRef, useApi } from '@backstage/core-plugin-api'; -import { useAsync } from '@react-hookz/web'; -import { deserializeError } from '@backstage/errors'; - -/** - * @public - * A hook that will fetch any sign in auth error in redirect auth flow - */ -export function useSignInAuthError(): { - error: Error | undefined; - checkAuthError: () => void; -} { - const discoveryApi = useApi(discoveryApiRef); - - const [state, { execute: checkAuthError }] = useAsync(async () => { - const baseUrl = await discoveryApi.getBaseUrl('auth'); - - // use native fetch instead of fetchApi because - // we are not signed in and are calling an unauthenticated endpoint - const response = await fetch(`${baseUrl}/.backstage/error`, { - credentials: 'include', - }); - const data = await response.json(); - - return data ? deserializeError(data) : undefined; - }); - - return { error: state.result, checkAuthError }; -} diff --git a/yarn.lock b/yarn.lock index dfd1cffd9e..155c0eeb9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4287,7 +4287,6 @@ __metadata: "@backstage/core-app-api": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/errors": "workspace:^" - "@backstage/plugin-auth-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" "@backstage/version-bridge": "workspace:^" From 8b49cc2be94ddd05d7fff1aa938a30c322556a58 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 2 Oct 2024 13:43:58 +0200 Subject: [PATCH 059/291] Avoid unnecessary keepalive failed warnings during shutdown Signed-off-by: Eric Peterson --- .../entrypoints/database/DatabaseManager.ts | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts b/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts index 9441c8bca9..0f6207ef07 100644 --- a/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts +++ b/packages/backend-defaults/src/entrypoints/database/DatabaseManager.ts @@ -57,6 +57,10 @@ export class DatabaseManagerImpl { private readonly connectors: Record, private readonly options?: DatabaseManagerOptions, private readonly databaseCache: Map> = new Map(), + private readonly keepaliveIntervals: Map< + string, + NodeJS.Timeout + > = new Map(), ) { // If a rootLifecycle service was provided, register a shutdown hook to // clean up any database connections. @@ -108,6 +112,9 @@ export class DatabaseManagerImpl { const pluginIds = Array.from(this.databaseCache.keys()); await Promise.allSettled( pluginIds.map(async pluginId => { + // We no longer need to keep connections alive. + clearInterval(this.keepaliveIntervals.get(pluginId)); + const connection = await this.databaseCache.get(pluginId); if (connection) { await connection.destroy().catch((error: unknown) => { @@ -187,25 +194,28 @@ export class DatabaseManagerImpl { ): void { let lastKeepaliveFailed = false; - setInterval(() => { - // During testing it can happen that the environment is torn down and - // this client is `undefined`, but this interval is still run. - client?.raw('select 1').then( - () => { - lastKeepaliveFailed = false; - }, - (error: unknown) => { - if (!lastKeepaliveFailed) { - lastKeepaliveFailed = true; - logger.warn( - `Database keepalive failed for plugin ${pluginId}, ${stringifyError( - error, - )}`, - ); - } - }, - ); - }, 60 * 1000); + this.keepaliveIntervals.set( + pluginId, + setInterval(() => { + // During testing it can happen that the environment is torn down and + // this client is `undefined`, but this interval is still run. + client?.raw('select 1').then( + () => { + lastKeepaliveFailed = false; + }, + (error: unknown) => { + if (!lastKeepaliveFailed) { + lastKeepaliveFailed = true; + logger.warn( + `Database keepalive failed for plugin ${pluginId}, ${stringifyError( + error, + )}`, + ); + } + }, + ); + }, 60 * 1000), + ); } } From e36d12f5badc0ec7277a7009e92dde739e3c8a29 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Wed, 2 Oct 2024 14:55:50 +0200 Subject: [PATCH 060/291] Attempt to abort tasks when root lifecycle shutdown hook is invoked Signed-off-by: Eric Peterson --- .changeset/crash-loop-yeah.md | 5 ++ .../backend-defaults/report-database.api.md | 2 + .../backend-defaults/report-scheduler.api.md | 2 + .../scheduler/lib/DefaultSchedulerService.ts | 13 ++++- .../scheduler/lib/LocalTaskWorker.ts | 18 +++--- .../lib/PluginTaskSchedulerImpl.test.ts | 57 +++++++++++++++++++ .../scheduler/lib/PluginTaskSchedulerImpl.ts | 17 +++++- .../entrypoints/scheduler/lib/TaskWorker.ts | 12 ++-- .../scheduler/schedulerServiceFactory.ts | 5 +- 9 files changed, 109 insertions(+), 22 deletions(-) create mode 100644 .changeset/crash-loop-yeah.md diff --git a/.changeset/crash-loop-yeah.md b/.changeset/crash-loop-yeah.md new file mode 100644 index 0000000000..098bc01bae --- /dev/null +++ b/.changeset/crash-loop-yeah.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +The task scheduler now attempts to abort any tasks if it detects that Backstage is being shut down. diff --git a/packages/backend-defaults/report-database.api.md b/packages/backend-defaults/report-database.api.md index 9c07338264..05c5ab251c 100644 --- a/packages/backend-defaults/report-database.api.md +++ b/packages/backend-defaults/report-database.api.md @@ -3,6 +3,8 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +/// + import { DatabaseService } from '@backstage/backend-plugin-api'; import { LifecycleService } from '@backstage/backend-plugin-api'; import { LoggerService } from '@backstage/backend-plugin-api'; diff --git a/packages/backend-defaults/report-scheduler.api.md b/packages/backend-defaults/report-scheduler.api.md index 351873ad31..7d886e539c 100644 --- a/packages/backend-defaults/report-scheduler.api.md +++ b/packages/backend-defaults/report-scheduler.api.md @@ -5,6 +5,7 @@ ```ts import { DatabaseService } from '@backstage/backend-plugin-api'; import { LoggerService } from '@backstage/backend-plugin-api'; +import { RootLifecycleService } from '@backstage/backend-plugin-api'; import { SchedulerService } from '@backstage/backend-plugin-api'; import { ServiceFactory } from '@backstage/backend-plugin-api'; @@ -14,6 +15,7 @@ export class DefaultSchedulerService { static create(options: { database: DatabaseService; logger: LoggerService; + rootLifecycle?: RootLifecycleService; }): SchedulerService; } diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/DefaultSchedulerService.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/DefaultSchedulerService.ts index 8e2ac5cd07..dcaf673753 100644 --- a/packages/backend-defaults/src/entrypoints/scheduler/lib/DefaultSchedulerService.ts +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/DefaultSchedulerService.ts @@ -17,6 +17,7 @@ import { DatabaseService, LoggerService, + RootLifecycleService, SchedulerService, } from '@backstage/backend-plugin-api'; import { once } from 'lodash'; @@ -34,6 +35,7 @@ export class DefaultSchedulerService { static create(options: { database: DatabaseService; logger: LoggerService; + rootLifecycle?: RootLifecycleService; }): SchedulerService { const databaseFactory = once(async () => { const knex = await options.database.getClient(); @@ -43,17 +45,24 @@ export class DefaultSchedulerService { } if (process.env.NODE_ENV !== 'test') { + const abortController = new AbortController(); const janitor = new PluginTaskSchedulerJanitor({ knex, waitBetweenRuns: Duration.fromObject({ minutes: 1 }), logger: options.logger, }); - janitor.start(); + + options.rootLifecycle?.addShutdownHook(() => abortController.abort()); + janitor.start(abortController.signal); } return knex; }); - return new PluginTaskSchedulerImpl(databaseFactory, options.logger); + return new PluginTaskSchedulerImpl( + databaseFactory, + options.logger, + options.rootLifecycle, + ); } } diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/LocalTaskWorker.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/LocalTaskWorker.ts index 3510a855e9..278afae224 100644 --- a/packages/backend-defaults/src/entrypoints/scheduler/lib/LocalTaskWorker.ts +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/LocalTaskWorker.ts @@ -36,7 +36,7 @@ export class LocalTaskWorker { private readonly logger: LoggerService, ) {} - start(settings: TaskSettingsV2, options?: { signal?: AbortSignal }) { + start(settings: TaskSettingsV2, options: { signal: AbortSignal }) { this.logger.info( `Task worker starting: ${this.taskId}, ${JSON.stringify(settings)}`, ); @@ -48,18 +48,18 @@ export class LocalTaskWorker { if (settings.initialDelayDuration) { await this.sleep( Duration.fromISO(settings.initialDelayDuration), - options?.signal, + options.signal, ); } - while (!options?.signal?.aborted) { + while (!options.signal.aborted) { const startTime = process.hrtime(); - await this.runOnce(settings, options?.signal); + await this.runOnce(settings, options.signal); const timeTaken = process.hrtime(startTime); await this.waitUntilNext( settings, (timeTaken[0] + timeTaken[1] / 1e9) * 1000, - options?.signal, + options.signal, ); } @@ -89,7 +89,7 @@ export class LocalTaskWorker { */ private async runOnce( settings: TaskSettingsV2, - signal?: AbortSignal, + signal: AbortSignal, ): Promise { // Abort the task execution either if the worker is stopped, or if the // task timeout is hit @@ -115,9 +115,9 @@ export class LocalTaskWorker { private async waitUntilNext( settings: TaskSettingsV2, lastRunMillis: number, - signal?: AbortSignal, + signal: AbortSignal, ) { - if (signal?.aborted) { + if (signal.aborted) { return; } @@ -145,7 +145,7 @@ export class LocalTaskWorker { private async sleep( duration: Duration, - abortSignal?: AbortSignal, + abortSignal: AbortSignal, ): Promise { this.abortWait = delegateAbortController(abortSignal); await sleep(duration, this.abortWait.signal); diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.test.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.test.ts index 3e0782d4a3..3cb41a0b48 100644 --- a/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.test.ts +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.test.ts @@ -38,6 +38,7 @@ function defer() { jest.setTimeout(60_000); describe('PluginTaskManagerImpl', () => { + const addShutdownHook = jest.fn(); const databases = TestDatabases.create({ ids: ['POSTGRES_16', 'POSTGRES_12', 'SQLITE_3'], }); @@ -51,12 +52,17 @@ describe('PluginTaskManagerImpl', () => { jest.useFakeTimers(); }, 60_000); + beforeEach(() => { + jest.clearAllMocks(); + }); + async function init(databaseId: TestDatabaseId) { const knex = await databases.init(databaseId); await migrateBackendTasks(knex); const manager = new PluginTaskSchedulerImpl( async () => knex, mockServices.logger.mock(), + { addShutdownHook, addStartupHook: jest.fn() }, ); return { knex, manager }; } @@ -103,6 +109,33 @@ describe('PluginTaskManagerImpl', () => { expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal)); }, ); + + it.each(databases.eachSupportedId())( + 'aborts the task if shutdown hook is invoked, %p', + async databaseId => { + const { manager } = await init(databaseId); + + const fn = jest.fn(); + const promise = new Promise(resolve => + fn.mockImplementation(resolve), + ); + await manager.scheduleTask({ + id: 'task3', + timeout: Duration.fromMillis(5000), + frequency: { cron: '* * * * * *' }, + fn, + scope: 'global', + }); + + const shutdownHook = addShutdownHook.mock.calls[0][0]; + const abortSignal = await promise; + expect(abortSignal.aborted).toBe(false); + + // Should be aborted after the shutdown hook is invoked + await shutdownHook(); + expect(abortSignal.aborted).toBe(true); + }, + ); }); describe('triggerTask with global scope', () => { @@ -212,6 +245,30 @@ describe('PluginTaskManagerImpl', () => { await promise; expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal)); }, 60_000); + + it('aborts the task if shutdown hook is invoked', async () => { + const { manager } = await init('SQLITE_3'); + + const fn = jest.fn(); + const promise = new Promise(resolve => + fn.mockImplementation(resolve), + ); + await manager.scheduleTask({ + id: 'task3', + timeout: Duration.fromMillis(5000), + frequency: { cron: '* * * * * *' }, + fn, + scope: 'local', + }); + + const shutdownHook = addShutdownHook.mock.calls[0][0]; + const abortSignal = await promise; + expect(abortSignal.aborted).toBe(false); + + // Should be aborted after the shutdown hook is invoked + await shutdownHook(); + expect(abortSignal.aborted).toBe(true); + }, 60_000); }); describe('triggerTask with local scope', () => { diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.ts index 5c3af24ab1..5c04521149 100644 --- a/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.ts +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.ts @@ -16,6 +16,7 @@ import { LoggerService, + RootLifecycleService, SchedulerService, SchedulerServiceTaskDescriptor, SchedulerServiceTaskFunction, @@ -29,7 +30,7 @@ import { Duration } from 'luxon'; import { LocalTaskWorker } from './LocalTaskWorker'; import { TaskWorker } from './TaskWorker'; import { TaskSettingsV2 } from './types'; -import { TRACER_ID, validateId } from './util'; +import { delegateAbortController, TRACER_ID, validateId } from './util'; const tracer = trace.getTracer(TRACER_ID); @@ -39,6 +40,7 @@ const tracer = trace.getTracer(TRACER_ID); export class PluginTaskSchedulerImpl implements SchedulerService { private readonly localTasksById = new Map(); private readonly allScheduledTasks: SchedulerServiceTaskDescriptor[] = []; + private readonly shutdownInitiated: Promise; private readonly counter: Counter; private readonly duration: Histogram; @@ -46,6 +48,7 @@ export class PluginTaskSchedulerImpl implements SchedulerService { constructor( private readonly databaseFactory: () => Promise, private readonly logger: LoggerService, + rootLifecycle?: RootLifecycleService, ) { const meter = metrics.getMeter('default'); this.counter = meter.createCounter('backend_tasks.task.runs.count', { @@ -55,6 +58,9 @@ export class PluginTaskSchedulerImpl implements SchedulerService { description: 'Histogram of task run durations', unit: 'seconds', }); + this.shutdownInitiated = new Promise(shutdownInitiated => { + rootLifecycle?.addShutdownHook(() => shutdownInitiated(true)); + }); } async triggerTask(id: string): Promise { @@ -83,6 +89,11 @@ export class PluginTaskSchedulerImpl implements SchedulerService { timeoutAfterDuration: parseDuration(task.timeout), }; + // Delegated abort controller that will abort either when the provided + // controller aborts, or when a root lifecycle shutdown happens + const abortController = delegateAbortController(task.signal); + this.shutdownInitiated.then(() => abortController.abort()); + if (scope === 'global') { const knex = await this.databaseFactory(); const worker = new TaskWorker( @@ -91,14 +102,14 @@ export class PluginTaskSchedulerImpl implements SchedulerService { knex, this.logger.child({ task: task.id }), ); - await worker.start(settings, { signal: task.signal }); + await worker.start(settings, { signal: abortController.signal }); } else { const worker = new LocalTaskWorker( task.id, this.instrumentedFunction(task, scope), this.logger.child({ task: task.id }), ); - worker.start(settings, { signal: task.signal }); + worker.start(settings, { signal: abortController.signal }); this.localTasksById.set(task.id, worker); } diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/TaskWorker.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/TaskWorker.ts index c5570a8e30..48bc81efbf 100644 --- a/packages/backend-defaults/src/entrypoints/scheduler/lib/TaskWorker.ts +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/TaskWorker.ts @@ -41,7 +41,7 @@ export class TaskWorker { private readonly workCheckFrequency: Duration = DEFAULT_WORK_CHECK_FREQUENCY, ) {} - async start(settings: TaskSettingsV2, options?: { signal?: AbortSignal }) { + async start(settings: TaskSettingsV2, options: { signal: AbortSignal }) { try { await this.persistTask(settings); } catch (e) { @@ -68,18 +68,18 @@ export class TaskWorker { if (settings.initialDelayDuration) { await sleep( Duration.fromISO(settings.initialDelayDuration), - options?.signal, + options.signal, ); } - while (!options?.signal?.aborted) { - const runResult = await this.runOnce(options?.signal); + while (!options.signal.aborted) { + const runResult = await this.runOnce(options.signal); if (runResult.result === 'abort') { break; } - await sleep(workCheckFrequency, options?.signal); + await sleep(workCheckFrequency, options.signal); } this.logger.info(`Task worker finished: ${this.taskId}`); @@ -122,7 +122,7 @@ export class TaskWorker { * @returns The outcome of the attempt */ private async runOnce( - signal?: AbortSignal, + signal: AbortSignal, ): Promise< | { result: 'not-ready-yet' } | { result: 'abort' } diff --git a/packages/backend-defaults/src/entrypoints/scheduler/schedulerServiceFactory.ts b/packages/backend-defaults/src/entrypoints/scheduler/schedulerServiceFactory.ts index effa5c5925..3668dbd17a 100644 --- a/packages/backend-defaults/src/entrypoints/scheduler/schedulerServiceFactory.ts +++ b/packages/backend-defaults/src/entrypoints/scheduler/schedulerServiceFactory.ts @@ -34,8 +34,9 @@ export const schedulerServiceFactory = createServiceFactory({ deps: { database: coreServices.database, logger: coreServices.logger, + rootLifecycle: coreServices.rootLifecycle, }, - async factory({ database, logger }) { - return DefaultSchedulerService.create({ database, logger }); + async factory({ database, logger, rootLifecycle }) { + return DefaultSchedulerService.create({ database, logger, rootLifecycle }); }, }); From c05293198fe86e6f0b950f99df84e5feee2b5797 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 2 Oct 2024 13:48:32 +0000 Subject: [PATCH 061/291] fix(deps): update dependency @backstage-community/plugin-explore-common to ^0.0.6 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .changeset/renovate-9f7136b.md | 5 +++++ plugins/search-backend-module-explore/package.json | 2 +- yarn.lock | 10 +++++----- 3 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 .changeset/renovate-9f7136b.md diff --git a/.changeset/renovate-9f7136b.md b/.changeset/renovate-9f7136b.md new file mode 100644 index 0000000000..a1efc81de7 --- /dev/null +++ b/.changeset/renovate-9f7136b.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-backend-module-explore': patch +--- + +Updated dependency `@backstage-community/plugin-explore-common` to `^0.0.6`. diff --git a/plugins/search-backend-module-explore/package.json b/plugins/search-backend-module-explore/package.json index 937d8db489..1c27c48a7b 100644 --- a/plugins/search-backend-module-explore/package.json +++ b/plugins/search-backend-module-explore/package.json @@ -48,7 +48,7 @@ "test": "backstage-cli package test" }, "dependencies": { - "@backstage-community/plugin-explore-common": "^0.0.5", + "@backstage-community/plugin-explore-common": "^0.0.6", "@backstage/backend-common": "^0.25.0", "@backstage/backend-plugin-api": "workspace:^", "@backstage/config": "workspace:^", diff --git a/yarn.lock b/yarn.lock index f550e34f21..0e8e3bd965 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3390,10 +3390,10 @@ __metadata: languageName: node linkType: hard -"@backstage-community/plugin-explore-common@npm:^0.0.5": - version: 0.0.5 - resolution: "@backstage-community/plugin-explore-common@npm:0.0.5" - checksum: b849c1b2cb4cd2221dc5a7f6e3ea2d00cea529d27f2de074159b9eef5aa5d538e28f32e57878a97bdda7f0242d82a56cbfc42f52c2bdb02f455c7a0d232ecfeb +"@backstage-community/plugin-explore-common@npm:^0.0.6": + version: 0.0.6 + resolution: "@backstage-community/plugin-explore-common@npm:0.0.6" + checksum: f476264bec3b5471619f19fe16e0af13bd86be933f4922461056dff01de9d6efdb17246856cee649046299c763a2b10ceb9652215558c2d7c077385407449b31 languageName: node linkType: hard @@ -7813,7 +7813,7 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/plugin-search-backend-module-explore@workspace:plugins/search-backend-module-explore" dependencies: - "@backstage-community/plugin-explore-common": ^0.0.5 + "@backstage-community/plugin-explore-common": ^0.0.6 "@backstage/backend-common": ^0.25.0 "@backstage/backend-plugin-api": "workspace:^" "@backstage/backend-test-utils": "workspace:^" From a1ba8d8d2f934a59f22bb8a33c7a219a724c5b20 Mon Sep 17 00:00:00 2001 From: Alex Eftimie Date: Wed, 2 Oct 2024 16:22:56 +0200 Subject: [PATCH 062/291] update api reports Signed-off-by: Alex Eftimie --- .../search-backend-module-techdocs/report.api.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/plugins/search-backend-module-techdocs/report.api.md b/plugins/search-backend-module-techdocs/report.api.md index 8100aa852d..ce9500a5f6 100644 --- a/plugins/search-backend-module-techdocs/report.api.md +++ b/plugins/search-backend-module-techdocs/report.api.md @@ -86,10 +86,16 @@ export type TechDocsCollatorFactoryOptions = { // Warnings were encountered during analysis: // -// src/collators/DefaultTechDocsCollatorFactory.d.ts:36:5 - (ae-undocumented) Missing documentation for "type". -// src/collators/DefaultTechDocsCollatorFactory.d.ts:37:5 - (ae-undocumented) Missing documentation for "visibilityPermission". -// src/collators/DefaultTechDocsCollatorFactory.d.ts:47:5 - (ae-undocumented) Missing documentation for "fromConfig". -// src/collators/DefaultTechDocsCollatorFactory.d.ts:48:5 - (ae-undocumented) Missing documentation for "getCollator". +// src/collators/DefaultTechDocsCollatorFactory.d.ts:38:5 - (ae-undocumented) Missing documentation for "type". +// src/collators/DefaultTechDocsCollatorFactory.d.ts:39:5 - (ae-undocumented) Missing documentation for "visibilityPermission". +// src/collators/DefaultTechDocsCollatorFactory.d.ts:50:5 - (ae-undocumented) Missing documentation for "fromConfig". +// src/collators/DefaultTechDocsCollatorFactory.d.ts:51:5 - (ae-undocumented) Missing documentation for "getCollator". +// src/collators/TechDocsCollatorDocumentTransformer.d.ts:3:1 - (ae-undocumented) Missing documentation for "MkSearchIndexDoc". +// src/collators/TechDocsCollatorDocumentTransformer.d.ts:4:5 - (ae-undocumented) Missing documentation for "title". +// src/collators/TechDocsCollatorDocumentTransformer.d.ts:5:5 - (ae-undocumented) Missing documentation for "text". +// src/collators/TechDocsCollatorDocumentTransformer.d.ts:6:5 - (ae-undocumented) Missing documentation for "location". +// src/collators/TechDocsCollatorDocumentTransformer.d.ts:7:5 - (ae-undocumented) Missing documentation for "tags". +// src/collators/TechDocsCollatorDocumentTransformer.d.ts:10:1 - (ae-undocumented) Missing documentation for "TechDocsCollatorDocumentTransformer". // src/collators/TechDocsCollatorEntityTransformer.d.ts:4:1 - (ae-undocumented) Missing documentation for "TechDocsCollatorEntityTransformer". // src/collators/defaultTechDocsCollatorEntityTransformer.d.ts:3:22 - (ae-undocumented) Missing documentation for "defaultTechDocsCollatorEntityTransformer". ``` From 065f5a5624a35ce1156c4c35df78cc2f63b72391 Mon Sep 17 00:00:00 2001 From: JeevaRamanathan <64531160+JeevaRamanathan@users.noreply.github.com> Date: Wed, 2 Oct 2024 21:59:26 +0530 Subject: [PATCH 063/291] Update contribute docs Signed-off-by: JeevaRamanathan <64531160+JeevaRamanathan@users.noreply.github.com> --- docs/contribute/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/contribute/index.md b/docs/contribute/index.md index 34ed1561cb..627a2ff0d0 100644 --- a/docs/contribute/index.md +++ b/docs/contribute/index.md @@ -5,9 +5,9 @@ title: Contributors description: Documentation on how to get set up for doing development on the Backstage repository --- -Our vision for Backstage is for it to become the trusted standard toolbox (read: UX layer) for the open source infrastructure landscape. Think of it like Kubernetes for developer experience. We realize this is an ambitious goal. We can’t do it alone. +Our vision for Backstage is for it to become the trusted standard toolbox (read: UX layer) for the open source infrastructure landscape. Think of it like Kubernetes for developer experience. We realize this is an ambitious goal and we can’t do it alone. -Therefore we want to create a strong community of contributors -- all working together to create the kind of delightful experience that our developers deserve. +Therefore, we want to create a strong community of contributors — all working together to create the kind of delightful experience that our developers deserve. Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. ❤️ From 26b00cb9b4da97947da007d04f49003cb953eb9a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 2 Oct 2024 19:58:26 +0000 Subject: [PATCH 064/291] chore(deps): update dependency @types/react to v18.3.11 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index a5bdef9da1..89f8bccb74 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18622,12 +18622,12 @@ __metadata: linkType: hard "@types/react@npm:^18": - version: 18.3.10 - resolution: "@types/react@npm:18.3.10" + version: 18.3.11 + resolution: "@types/react@npm:18.3.11" dependencies: "@types/prop-types": "*" csstype: ^3.0.2 - checksum: 04261654b5f4bc9584e9d882c7dfd5b36dc58963f958f8c3efd24cb68c9d205bc2d57558a1479b86d7827f0e5116d5bd111791d1253583d1e1c165f0aeb48c48 + checksum: 6cbf36673b64e758dd61b16c24139d015f58530e0d476777de26ba83f24b55e142fbf64e3b8f6b3c7b05ed9ba548551b2a62d9ffb0f95743d0a368646a619163 languageName: node linkType: hard From b1de959466b25344a53350bd467049262f2f14ae Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Wed, 2 Oct 2024 23:42:31 -0400 Subject: [PATCH 065/291] disable task list routes without permission Signed-off-by: Stephen Glass --- .changeset/quiet-lions-lie.md | 6 ++++++ .../ScaffolderPageContextMenu.tsx | 8 +++++++- .../scaffolder/src/components/Router/Router.tsx | 16 ++++++++++++---- 3 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 .changeset/quiet-lions-lie.md diff --git a/.changeset/quiet-lions-lie.md b/.changeset/quiet-lions-lie.md new file mode 100644 index 0000000000..4aa6567005 --- /dev/null +++ b/.changeset/quiet-lions-lie.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder-react': patch +'@backstage/plugin-scaffolder': patch +--- + +Scaffolder task routes require read permission to access. The tasks list option in the scaffolder page context menu only shows with permission. diff --git a/plugins/scaffolder-react/src/next/components/ScaffolderPageContextMenu/ScaffolderPageContextMenu.tsx b/plugins/scaffolder-react/src/next/components/ScaffolderPageContextMenu/ScaffolderPageContextMenu.tsx index 12ca4135f0..23b34123d1 100644 --- a/plugins/scaffolder-react/src/next/components/ScaffolderPageContextMenu/ScaffolderPageContextMenu.tsx +++ b/plugins/scaffolder-react/src/next/components/ScaffolderPageContextMenu/ScaffolderPageContextMenu.tsx @@ -27,6 +27,8 @@ import Edit from '@material-ui/icons/Edit'; import List from '@material-ui/icons/List'; import MoreVert from '@material-ui/icons/MoreVert'; import React, { useState } from 'react'; +import { usePermission } from '@backstage/plugin-permission-react'; +import { taskReadPermission } from '@backstage/plugin-scaffolder-common/alpha'; const useStyles = makeStyles(theme => ({ button: { @@ -55,6 +57,10 @@ export function ScaffolderPageContextMenu( const classes = useStyles(); const [anchorEl, setAnchorEl] = useState(); + const { allowed: canReadTasks } = usePermission({ + permission: taskReadPermission, + }); + if (!onEditorClicked && !onActionsClicked) { return null; } @@ -116,7 +122,7 @@ export function ScaffolderPageContextMenu( )} - {onTasksClicked && ( + {onTasksClicked && canReadTasks && ( diff --git a/plugins/scaffolder/src/components/Router/Router.tsx b/plugins/scaffolder/src/components/Router/Router.tsx index 5766e3d043..1121fd2766 100644 --- a/plugins/scaffolder/src/components/Router/Router.tsx +++ b/plugins/scaffolder/src/components/Router/Router.tsx @@ -59,6 +59,8 @@ import { TemplateEditorPage, CustomFieldsPage, } from '../../alpha/components/TemplateEditorPage'; +import { RequirePermission } from '@backstage/plugin-permission-react'; +import { taskReadPermission } from '@backstage/plugin-scaffolder-common/alpha'; /** * The Props for the Scaffolder Router @@ -162,9 +164,11 @@ export const Router = (props: PropsWithChildren) => { + + + } /> ) => { } /> } + element={ + + + + } /> Date: Wed, 2 Oct 2024 23:59:36 -0400 Subject: [PATCH 066/291] add tests Signed-off-by: Stephen Glass --- .../src/components/ActionsPage/ActionsPage.test.tsx | 7 ++++++- .../src/components/ListTasksPage/ListTaskPage.test.tsx | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx index bf056dd3b3..acfdabac5b 100644 --- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx +++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.test.tsx @@ -23,6 +23,7 @@ import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { ApiProvider } from '@backstage/core-app-api'; import { rootRouteRef } from '../../routes'; import { userEvent } from '@testing-library/user-event'; +import { permissionApiRef } from '@backstage/plugin-permission-react'; const scaffolderApiMock: jest.Mocked = { scaffold: jest.fn(), @@ -36,7 +37,11 @@ const scaffolderApiMock: jest.Mocked = { autocomplete: jest.fn(), }; -const apis = TestApiRegistry.from([scaffolderApiRef, scaffolderApiMock]); +const mockPermissionApi = { authorize: jest.fn() }; +const apis = TestApiRegistry.from( + [scaffolderApiRef, scaffolderApiMock], + [permissionApiRef, mockPermissionApi], +); describe('TemplatePage', () => { beforeEach(() => jest.resetAllMocks()); diff --git a/plugins/scaffolder/src/components/ListTasksPage/ListTaskPage.test.tsx b/plugins/scaffolder/src/components/ListTasksPage/ListTaskPage.test.tsx index f7f75bc3e8..ad3ca7fbde 100644 --- a/plugins/scaffolder/src/components/ListTasksPage/ListTaskPage.test.tsx +++ b/plugins/scaffolder/src/components/ListTasksPage/ListTaskPage.test.tsx @@ -30,6 +30,7 @@ import { } from '@backstage/plugin-scaffolder-react'; import { act, fireEvent } from '@testing-library/react'; import { rootRouteRef } from '../../routes'; +import { permissionApiRef } from '@backstage/plugin-permission-react'; describe('', () => { const catalogApi: jest.Mocked = { @@ -49,6 +50,8 @@ describe('', () => { listTasks: jest.fn(), } as any; + const mockPermissionApi = { authorize: jest.fn() }; + it('should render the page', async () => { const entity: Entity = { apiVersion: 'v1', @@ -72,6 +75,7 @@ describe('', () => { [catalogApiRef, catalogApi], [identityApiRef, identityApi], [scaffolderApiRef, scaffolderApiMock], + [permissionApiRef, mockPermissionApi], ]} > @@ -132,6 +136,7 @@ describe('', () => { [catalogApiRef, catalogApi], [identityApiRef, identityApi], [scaffolderApiRef, scaffolderApiMock], + [permissionApiRef, mockPermissionApi], ]} > @@ -230,6 +235,7 @@ describe('', () => { [catalogApiRef, catalogApi], [identityApiRef, identityApi], [scaffolderApiRef, scaffolderApiMock], + [permissionApiRef, mockPermissionApi], ]} > From 1a81c44162ce99bc88a315edd95060ffa3c21291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Jerna=C5=9B?= Date: Thu, 3 Oct 2024 12:45:05 +0200 Subject: [PATCH 067/291] Don't mutate retrieved identityProfile, replace it instead. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Łukasz Jernaś --- plugins/user-settings/src/components/useUserProfileInfo.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/user-settings/src/components/useUserProfileInfo.ts b/plugins/user-settings/src/components/useUserProfileInfo.ts index f777c3d75b..e368b9e688 100644 --- a/plugins/user-settings/src/components/useUserProfileInfo.ts +++ b/plugins/user-settings/src/components/useUserProfileInfo.ts @@ -32,7 +32,7 @@ export const useUserProfile = () => { const catalogApi = useApi(catalogApiRef); const { value, loading, error } = useAsync(async () => { - const identityProfile = await identityApi.getProfileInfo(); + let identityProfile = await identityApi.getProfileInfo(); const backStageIdentity = await identityApi.getBackstageIdentity(); const catalogProfile = (await catalogApi.getEntityByRef( backStageIdentity.userEntityRef, @@ -41,7 +41,10 @@ export const useUserProfile = () => { identityProfile.picture === undefined && catalogProfile?.spec?.profile?.picture ) { - identityProfile.picture = catalogProfile.spec.profile.picture; + identityProfile = { + ...identityProfile, + picture: catalogProfile.spec.profile.picture, + }; } return { profile: identityProfile, From 08eca61e5216cc7d94b189bdfda7287e827ddcf4 Mon Sep 17 00:00:00 2001 From: Jonathan Roebuck Date: Thu, 3 Oct 2024 15:39:41 +0100 Subject: [PATCH 068/291] add customisable techdocs code bg color that defaults to background.paper Signed-off-by: Jonathan Roebuck --- packages/theme/src/base/palettes.ts | 2 ++ packages/theme/src/base/types.ts | 3 +++ .../src/reader/transformers/styles/rules/variables.ts | 4 +++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/theme/src/base/palettes.ts b/packages/theme/src/base/palettes.ts index 5e70afd7b8..db33106094 100644 --- a/packages/theme/src/base/palettes.ts +++ b/packages/theme/src/base/palettes.ts @@ -89,6 +89,7 @@ export const palettes = { tabbar: { indicator: '#9BF0E1', }, + code: {}, }, dark: { type: 'dark' as const, @@ -163,5 +164,6 @@ export const palettes = { tabbar: { indicator: '#9BF0E1', }, + code: {}, }, }; diff --git a/packages/theme/src/base/types.ts b/packages/theme/src/base/types.ts index d62d944741..72a7632dd9 100644 --- a/packages/theme/src/base/types.ts +++ b/packages/theme/src/base/types.ts @@ -82,6 +82,9 @@ export type BackstagePaletteAdditions = { closeButtonColor?: string; warning?: string; }; + code: { + background?: string; + }; }; /** diff --git a/plugins/techdocs/src/reader/transformers/styles/rules/variables.ts b/plugins/techdocs/src/reader/transformers/styles/rules/variables.ts index e7e47c6fc7..e87e61f9b3 100644 --- a/plugins/techdocs/src/reader/transformers/styles/rules/variables.ts +++ b/plugins/techdocs/src/reader/transformers/styles/rules/variables.ts @@ -108,7 +108,9 @@ export default ({ theme }: RuleOptions) => ` :host > * { /* CODE */ --md-code-fg-color: ${theme.palette.text.primary}; - --md-code-bg-color: ${theme.palette.background.paper}; + --md-code-bg-color: ${ + theme.palette.code.background || theme.palette.background.paper + }; --md-code-hl-color: ${alpha(theme.palette.warning.main, 0.5)}; --md-code-hl-color--light: var(--md-code-hl-color); --md-code-hl-keyword-color: ${ From e77ff3d2ffb53594f3ec62b3f47e8bcf27057da0 Mon Sep 17 00:00:00 2001 From: Jonathan Roebuck Date: Thu, 3 Oct 2024 16:04:06 +0100 Subject: [PATCH 069/291] Add changeset Signed-off-by: Jonathan Roebuck --- .changeset/slimy-ravens-end.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/slimy-ravens-end.md diff --git a/.changeset/slimy-ravens-end.md b/.changeset/slimy-ravens-end.md new file mode 100644 index 0000000000..65e02079b1 --- /dev/null +++ b/.changeset/slimy-ravens-end.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-techdocs': minor +'@backstage/theme': minor +--- + +Adds support for custom background colors in code blocks and inline code within TechDocs. From b672693280bb1718aaff31f6d7db50a8dbed523a Mon Sep 17 00:00:00 2001 From: Jonathan Roebuck Date: Thu, 3 Oct 2024 16:10:18 +0100 Subject: [PATCH 070/291] build api report Signed-off-by: Jonathan Roebuck --- packages/theme/report.api.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/theme/report.api.md b/packages/theme/report.api.md index b380ce9fd1..a0c41ca45c 100644 --- a/packages/theme/report.api.md +++ b/packages/theme/report.api.md @@ -81,6 +81,9 @@ export type BackstagePaletteAdditions = { closeButtonColor?: string; warning?: string; }; + code: { + background?: string; + }; }; // @public @deprecated @@ -310,6 +313,7 @@ export const palettes: { tabbar: { indicator: string; }; + code: {}; }; dark: { type: 'dark'; @@ -384,6 +388,7 @@ export const palettes: { tabbar: { indicator: string; }; + code: {}; }; }; From f1994b501075e84e83ca8dc9c6935e2389ac43b5 Mon Sep 17 00:00:00 2001 From: nikolar Date: Thu, 3 Oct 2024 15:05:41 -0700 Subject: [PATCH 071/291] move template buttons Signed-off-by: nikolar --- .../scaffolder-react/src/next/components/Stepper/Stepper.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index a1b31d0089..3809132707 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -63,7 +63,7 @@ const useStyles = makeStyles(theme => ({ footer: { display: 'flex', flexDirection: 'row', - justifyContent: 'right', + justifyContent: 'left', marginTop: theme.spacing(2), }, formWrapper: { From 341e5db3c1635ad6d90e6581dba80423b56b2d80 Mon Sep 17 00:00:00 2001 From: nikolar Date: Thu, 3 Oct 2024 15:18:47 -0700 Subject: [PATCH 072/291] add changeset Signed-off-by: nikolar --- .changeset/breezy-berries-yawn.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/breezy-berries-yawn.md diff --git a/.changeset/breezy-berries-yawn.md b/.changeset/breezy-berries-yawn.md new file mode 100644 index 0000000000..beb1053034 --- /dev/null +++ b/.changeset/breezy-berries-yawn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': patch +--- + +Update template form buttons from `justifyContent` 'right' to `justifyContent` 'left' From 9b57739ce91e49424f379478b843fa124f65b091 Mon Sep 17 00:00:00 2001 From: Jonathan Roebuck Date: Thu, 3 Oct 2024 16:47:43 +0100 Subject: [PATCH 073/291] Check for code in palette before accessing background Signed-off-by: Jonathan Roebuck --- packages/theme/report.api.md | 2 -- packages/theme/src/base/palettes.ts | 2 -- .../techdocs/src/reader/transformers/styles/rules/variables.ts | 2 +- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/theme/report.api.md b/packages/theme/report.api.md index a0c41ca45c..0c9e87e958 100644 --- a/packages/theme/report.api.md +++ b/packages/theme/report.api.md @@ -313,7 +313,6 @@ export const palettes: { tabbar: { indicator: string; }; - code: {}; }; dark: { type: 'dark'; @@ -388,7 +387,6 @@ export const palettes: { tabbar: { indicator: string; }; - code: {}; }; }; diff --git a/packages/theme/src/base/palettes.ts b/packages/theme/src/base/palettes.ts index db33106094..5e70afd7b8 100644 --- a/packages/theme/src/base/palettes.ts +++ b/packages/theme/src/base/palettes.ts @@ -89,7 +89,6 @@ export const palettes = { tabbar: { indicator: '#9BF0E1', }, - code: {}, }, dark: { type: 'dark' as const, @@ -164,6 +163,5 @@ export const palettes = { tabbar: { indicator: '#9BF0E1', }, - code: {}, }, }; diff --git a/plugins/techdocs/src/reader/transformers/styles/rules/variables.ts b/plugins/techdocs/src/reader/transformers/styles/rules/variables.ts index e87e61f9b3..c419d5f71b 100644 --- a/plugins/techdocs/src/reader/transformers/styles/rules/variables.ts +++ b/plugins/techdocs/src/reader/transformers/styles/rules/variables.ts @@ -109,7 +109,7 @@ export default ({ theme }: RuleOptions) => ` /* CODE */ --md-code-fg-color: ${theme.palette.text.primary}; --md-code-bg-color: ${ - theme.palette.code.background || theme.palette.background.paper + theme.palette.code?.background ?? theme.palette.background.paper }; --md-code-hl-color: ${alpha(theme.palette.warning.main, 0.5)}; --md-code-hl-color--light: var(--md-code-hl-color); From e6bbfcedb126ae0055ee73bd17e08806e3c05a98 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 4 Oct 2024 10:05:53 +0200 Subject: [PATCH 074/291] test(scaffolder): editor toolbar templates menu Signed-off-by: Camila Belo --- .changeset/eighty-mice-turn.md | 5 + ...emplateEditorToolbarTemplatesMenu.test.tsx | 318 ++++++++++++++++++ .../TemplateEditorToolbarTemplatesMenu.tsx | 11 +- 3 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 .changeset/eighty-mice-turn.md create mode 100644 plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbarTemplatesMenu.test.tsx diff --git a/.changeset/eighty-mice-turn.md b/.changeset/eighty-mice-turn.md new file mode 100644 index 0000000000..cec3835641 --- /dev/null +++ b/.changeset/eighty-mice-turn.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Add tests for the `TemplateEditorToolbarTemplatesMenu` component. diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbarTemplatesMenu.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbarTemplatesMenu.test.tsx new file mode 100644 index 0000000000..5e6acc1712 --- /dev/null +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbarTemplatesMenu.test.tsx @@ -0,0 +1,318 @@ +/* + * Copyright 2024 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 { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderInTestApp } from '@backstage/test-utils'; +import { + TemplateEditorToolbarTemplatesMenu, + TemplateOption, +} from './TemplateEditorToolbarTemplatesMenu'; + +describe('TemplateEditorToolbarTemplatesMenu', () => { + const options: TemplateOption[] = [ + { + label: 'Create React App Template', + value: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { + namespace: 'default', + name: 'create-react-app-template', + title: 'Create React App Template', + }, + spec: { + parameters: [ + { + title: 'Provide some simple information', + required: ['component_id', 'owner'], + properties: { + component_id: { + title: 'Name', + type: 'string', + description: 'Unique name of the component', + 'ui:field': 'EntityNamePicker', + }, + description: { + title: 'Description', + type: 'string', + description: + 'Help others understand what this website is for.', + }, + owner: { + title: 'Owner', + type: 'string', + description: 'Owner of the component', + 'ui:field': 'OwnerPicker', + 'ui:options': { + allowedKinds: ['Group'], + }, + }, + }, + }, + { + title: 'Choose a location', + required: ['repoUrl'], + properties: { + repoUrl: { + title: 'Repository Location', + type: 'string', + 'ui:field': 'RepoUrlPicker', + 'ui:options': { + allowedHosts: ['github.com'], + }, + }, + }, + }, + ], + steps: [ + { + id: 'template', + name: 'Fetch Skeleton + Template', + action: 'fetch:template', + input: { + url: './skeleton', + copyWithoutRender: ['.github/workflows/*'], + values: { + component_id: '${{ parameters.component_id }}', + description: '${{ parameters.description }}', + destination: '${{ parameters.repoUrl | parseRepoUrl }}', + owner: '${{ parameters.owner }}', + }, + }, + }, + { + id: 'publish', + name: 'Publish', + action: 'publish:github', + input: { + allowedHosts: ['github.com'], + description: 'This is ${{ parameters.component_id }}', + repoUrl: '${{ parameters.repoUrl }}', + }, + }, + { + id: 'register', + name: 'Register', + action: 'catalog:register', + input: { + repoContentsUrl: '${{ steps.publish.output.repoContentsUrl }}', + catalogInfoPath: '/catalog-info.yaml', + }, + }, + ], + output: { + links: [ + { + title: 'Repository', + url: '${{ steps.publish.output.remoteUrl }}', + }, + { + title: 'Open in catalog', + icon: 'catalog', + entityRef: '${{ steps.register.output.entityRef }}', + }, + ], + }, + }, + }, + }, + { + label: 'React SSR Template', + value: { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Template', + metadata: { + namespace: 'default', + name: 'react-ssr-template', + title: 'React SSR Template', + }, + spec: { + parameters: [ + { + title: 'Provide some simple information', + required: ['component_id', 'owner'], + properties: { + component_id: { + title: 'Name', + type: 'string', + description: 'Unique name of the component', + 'ui:field': 'EntityNamePicker', + }, + description: { + title: 'Description', + type: 'string', + description: + 'Help others understand what this website is for.', + }, + owner: { + title: 'Owner', + type: 'string', + description: 'Owner of the component', + 'ui:field': 'OwnerPicker', + 'ui:options': { + allowedKinds: ['Group'], + }, + }, + }, + }, + { + title: 'Choose a location', + required: ['repoUrl'], + properties: { + repoUrl: { + title: 'Repository Location', + type: 'string', + 'ui:field': 'RepoUrlPicker', + 'ui:options': { + allowedHosts: ['github.com'], + }, + }, + }, + }, + ], + steps: [ + { + id: 'template', + name: 'Fetch Skeleton + Template', + action: 'fetch:template', + input: { + url: './skeleton', + copyWithoutRender: ['.github/workflows/*'], + values: { + component_id: '${{ parameters.component_id }}', + description: '${{ parameters.description }}', + destination: '${{ parameters.repoUrl | parseRepoUrl }}', + owner: '${{ parameters.owner }}', + }, + }, + }, + { + id: 'publish', + name: 'Publish', + action: 'publish:github', + input: { + allowedHosts: ['github.com'], + description: 'This is ${{ parameters.component_id }}', + repoUrl: '${{ parameters.repoUrl }}', + }, + }, + { + id: 'register', + name: 'Register', + action: 'catalog:register', + input: { + repoContentsUrl: '${{ steps.publish.output.repoContentsUrl }}', + catalogInfoPath: '/catalog-info.yaml', + }, + }, + ], + output: { + links: [ + { + title: 'Repository', + url: '${{ steps.publish.output.remoteUrl }}', + }, + { + title: 'Open in catalog', + icon: 'catalog', + entityRef: '${{ steps.register.output.entityRef }}', + }, + ], + }, + }, + }, + }, + ]; + + const onSelectOption = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render an item for each option', async () => { + await renderInTestApp( + , + ); + + for (const option of options) { + expect( + screen.queryByRole('menuitem', { name: option.label }), + ).not.toBeInTheDocument(); + } + + await userEvent.click(screen.getByRole('button', { name: 'Templates' })); + + for (const option of options) { + expect( + screen.getByRole('menuitem', { name: option.label }), + ).toBeInTheDocument(); + } + }); + + it('should call "onSelectOption" when a option is selected', async () => { + await renderInTestApp( + , + ); + + for (const option of options) { + expect( + screen.queryByRole('menuitem', { name: option.label }), + ).not.toBeInTheDocument(); + } + + await userEvent.click(screen.getByRole('button', { name: 'Templates' })); + + await userEvent.click( + screen.getByRole('menuitem', { name: options[0].label }), + ); + + expect(onSelectOption).toHaveBeenCalledTimes(1); + expect(onSelectOption).toHaveBeenCalledWith(options[0]); + }); + + it('should highlight the passed selected option', async () => { + const selectedOption = options[0]; + + await renderInTestApp( + , + ); + + await userEvent.click(screen.getByRole('button', { name: 'Templates' })); + + expect( + screen.getByRole('menuitem', { name: selectedOption.label }), + ).toHaveAttribute('aria-selected', 'true'); + + for (const option of options.splice(1)) { + expect( + screen.getByRole('menuitem', { name: option.label }), + ).toHaveAttribute('aria-selected', 'false'); + } + }); +}); diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbarTemplatesMenu.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbarTemplatesMenu.tsx index 81ffac2c04..4a71aba5e5 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbarTemplatesMenu.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbarTemplatesMenu.tsx @@ -48,6 +48,13 @@ export function TemplateEditorToolbarTemplatesMenu(props: { const [anchorEl, setAnchorEl] = useState(null); const { t } = useTranslationRef(scaffolderTranslationRef); + const isSelectedOption = useCallback( + (option: TemplateOption) => { + return !!selectedOption && selectedOption.value === option.value; + }, + [selectedOption], + ); + const handleOpenMenu = useCallback( (event: MouseEvent) => { setAnchorEl(event.currentTarget); @@ -93,12 +100,12 @@ export function TemplateEditorToolbarTemplatesMenu(props: { PaperProps={{ className: classes.menu, }} - keepMounted > {options.map((option, index) => ( handleSelectOption(option)} > {option.label} From f4b7b72705c1f4e0016c20648831f9e7db104bca Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 4 Oct 2024 14:09:53 +0200 Subject: [PATCH 075/291] docs/threat-model: update sign-in resolver section Signed-off-by: Patrik Oldsberg --- docs/overview/threat-model.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/overview/threat-model.md b/docs/overview/threat-model.md index 2b78d022f9..2461f002c8 100644 --- a/docs/overview/threat-model.md +++ b/docs/overview/threat-model.md @@ -53,11 +53,11 @@ Note that the `UrlReaderService` system operates with a service context and is n Backstage provides authentication of users through the `auth` plugin, which primarily acts as an authorization server for different OAuth 2.0 provider integrations. These integrations can both serve the purpose of signing users into Backstage, as well as providing delegated access to external resources, and are all subject to the common concerns of implementing secure OAuth 2.0 authorization servers. All auth provider integrations are disabled by default, and need to be enabled through configuration in order to be used. For each Backstage installation it is recommended to only enable the minimal set of providers that are in use by that instance. -In order to use an auth provider to sign in users into Backstage, it needs to be configured with an [Identity resolver](https://backstage.io/docs/auth/identity-resolver), which is a custom callback implemented in code. The identity resolver is a sensitive part of configuring Backstage and it is important that it always resolves user identities correctly, based on information provided by the authentication provider. There are a number of built-in identity resolvers that can simplify configuration, and it is important that these all resolve users in a secure way, regardless of how they are used. +In order to use an auth provider to sign in users into Backstage, it needs to be configured with a [sign-in resolver](https://backstage.io/docs/auth/identity-resolver). The sign-in resolver is a sensitive part of configuring Backstage and it is important that it always resolves user identities correctly, and rejects unauthorized users. There are a number of built-in sign-in resolvers that can simplify configuration, or you can implement your own custom sign-in resolver in code, either way it is very important that these resolvers map user identities correctly. You should **always use the minimum number of sign-in resolvers necessary** to avoid risk of account hijacking. Backstage also supports authentication through an authenticating reverse proxy such as [AWS ALB](https://aws.amazon.com/elasticloadbalancing/application-load-balancer/), where the user identity is read from the incoming proxied decorated request. The following proxy auth providers verify the signature of incoming requests, and are therefore safe to deploy with direct access by users: `awsAlb`, `cfAccess`, and `gcpIap`. Providers like `oauth2Proxy` do not verify the incoming request and can therefore be spoofed by a malicious internal user to supply the `auth` backend with forged identity information. It’s therefore highly recommended to restrict access to the `oauth2Proxy` endpoints, or use a different provider. -As part of signing in with an identity resolver, a Backstage Token is issued containing the resolved user identity. The tokens are asymmetrically signed JSON Web Tokens, with the public keys available to any service that wishes to verify a token. The signing keys are rotated continuously and are unique to each installation of Backstage, meaning that Backstage Tokens are not shared across installations. The token contains claims for the user identity and ownership information, which can be used to determine what Backstage resources are owned by that user or group. It is important that this token can not be forged outside of the `auth` plugin, with the exception of other plugins deployed in the same backend service or sharing the same database. For a high-security deployment, the `auth` backend should therefore be deployed in a separate service with its own database. +As part of signing in with a sign-in resolver, a Backstage Token is issued containing the resolved user identity. The tokens are asymmetrically signed JSON Web Tokens, with the public keys available to any service that wishes to verify a token. The signing keys are rotated continuously and are unique to each installation of Backstage, meaning that Backstage Tokens are not shared across installations. The token contains claims for the user identity and ownership information, which can be used to determine what Backstage resources are owned by that user or group. It is important that this token can not be forged outside of the `auth` plugin, with the exception of other plugins deployed in the same backend service or sharing the same database. For a high-security deployment, the `auth` backend should therefore be deployed in a separate service with its own database. The token is used to prove the identity of the user within the Backstage system, and is used throughout Backstage plugins to control access. It is important that the ownership resolution logic is consistent across the entire Backstage ecosystem, with no possibility of misinterpreting the ownership information. From ad336b576b4155ab8fb193b5782c7c95c4f81f96 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 4 Oct 2024 14:27:17 +0200 Subject: [PATCH 076/291] docs/auth/identity-resolver: discourage use of multiple sign-in resolvers Signed-off-by: Patrik Oldsberg --- docs/auth/identity-resolver.md | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md index d6b377d990..b8bcd95a5e 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -70,6 +70,14 @@ always be full entity references, as opposed to shorthands like just `jane` or ## Sign-in Resolvers +:::warning +Be careful when configuring Sign-in resolvers, as they are part of determining who +has access to your Backstage instance, and with what identity. Always only configure +**a single sign-in resolver for one of your auth providers**. The only reason to have +more sign-in resolvers is if you want to allow your users to sign in to Backstage in +multiple ways, but it increases the risk of account hijacking. +::: + Signing in a user into Backstage requires a mapping of the user identity from the third-party auth provider to a Backstage user identity. This mapping can vary quite a lot between different organizations and auth providers, and because of that there's @@ -112,19 +120,23 @@ auth: signIn: resolvers: - resolver: usernameMatchingUserEntityName - - resolver: emailMatchingUserEntityProfileEmail - - resolver: emailLocalPartMatchingUserEntityName ``` -Note that in this instance it lists several resolvers, which means that the -framework will try them one by one until one succeeds. If none of them do, the -sign in attempt is rejected. - The list of available resolvers is different for each provider, since they often depend on the information model returned from the upstream provider service. Consult the documentation of the respective provider to find the list. -In the example above `emailMatchingUserEntityProfileEmail` and `emailLocalPartMatchingUserEntityName` are common to all auth providers and `usernameMatchingUserEntityName` is specific to GitHub. +In the example above, the `usernameMatchingUserEntityName` is specific to the +GitHub provider, but you could also choose to use the +`emailMatchingUserEntityProfileEmail` or `emailLocalPartMatchingUserEntityName` +resolvers, which are common to all auth providers. + +:::warning +When using the `emailLocalPartMatchingUserEntityName` resolver it is important +to only allow users to sign in with email addresses from expected domains. This +is typically controlled as part of the OAuth configuration in the provider +itself. +::: ### Building Custom Resolvers @@ -160,8 +172,6 @@ auth: signIn: resolvers: - resolver: usernameMatchingUserEntityName - - resolver: emailMatchingUserEntityProfileEmail - - resolver: emailLocalPartMatchingUserEntityName /* highlight-remove-end */ ``` @@ -318,6 +328,12 @@ async signInResolver({ profile: { email} }, ctx) { ### Sign-In without Users in the Catalog +:::warning +Signing in users without verifying that they exist in the catalog can be +dangerous. Take care to ensure that your custom resolvers only allow expected +users to sign in, for example by checking email domains. +::: + While populating the catalog with organizational data unlocks more powerful ways to browse your software ecosystem, it might not always be a viable or prioritized option. However, even if you do not have user entities populated in your catalog, you From 6edb7ba4095c03f2c6eaf7964bb42c9b675adcb5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 4 Oct 2024 14:35:43 +0200 Subject: [PATCH 077/291] docs/auth: remove insecure sign-in resolver recommendations Signed-off-by: Patrik Oldsberg --- docs/auth/atlassian/provider.md | 4 +--- docs/auth/aws-alb/provider.md | 3 +-- docs/auth/bitbucket/provider.md | 5 +---- docs/auth/cloudflare/provider.md | 2 +- docs/auth/github/provider.md | 3 +-- docs/auth/gitlab/provider.md | 4 +--- docs/auth/google/gcp-iap-auth.md | 4 +--- docs/auth/google/provider.md | 4 +--- docs/auth/microsoft/azure-easyauth.md | 4 +--- docs/auth/microsoft/provider.md | 5 +---- docs/auth/oauth2-proxy/provider.md | 4 +--- docs/auth/oidc.md | 2 -- docs/auth/okta/provider.md | 4 +--- docs/auth/onelogin/provider.md | 4 +--- docs/auth/vmware-cloud/provider.md | 4 +--- docs/backend-system/building-backends/08-migrating.md | 2 -- 16 files changed, 14 insertions(+), 44 deletions(-) diff --git a/docs/auth/atlassian/provider.md b/docs/auth/atlassian/provider.md index 96271f04a0..f4793d4f51 100644 --- a/docs/auth/atlassian/provider.md +++ b/docs/auth/atlassian/provider.md @@ -49,9 +49,7 @@ auth: scope: ${AUTH_ATLASSIAN_SCOPES} signIn: resolvers: - # typically you would pick one of these - - resolver: emailMatchingUserEntityProfileEmail - - resolver: emailLocalPartMatchingUserEntityName + # See https://backstage.io/docs/auth/atlassian/provider#resolvers for more resolvers - resolver: usernameMatchingUserEntityName ``` diff --git a/docs/auth/aws-alb/provider.md b/docs/auth/aws-alb/provider.md index a3974c895e..8ce1eba5ab 100644 --- a/docs/auth/aws-alb/provider.md +++ b/docs/auth/aws-alb/provider.md @@ -25,9 +25,8 @@ auth: region: 'us-west-2' signIn: resolvers: - # typically you would pick one of these + # See https://backstage.io/docs/auth/aws-alb/provider#resolvers for more resolvers - resolver: emailMatchingUserEntityProfileEmail - - resolver: emailLocalPartMatchingUserEntityName ``` Ensure that you have set the signer correctly. It is also recommended that you restrict your target groups' security policy to only accept connections from that ALB. diff --git a/docs/auth/bitbucket/provider.md b/docs/auth/bitbucket/provider.md index 1ca35437ba..e58ad622fc 100644 --- a/docs/auth/bitbucket/provider.md +++ b/docs/auth/bitbucket/provider.md @@ -39,11 +39,8 @@ auth: clientSecret: ${AUTH_BITBUCKET_CLIENT_SECRET} signIn: resolvers: - # typically you would pick one of these - - resolver: emailMatchingUserEntityProfileEmail - - resolver: emailLocalPartMatchingUserEntityName + # See https://backstage.io/docs/auth/bitbucket/provider#resolvers for more resolvers - resolver: userIdMatchingUserEntityAnnotation - - resolver: usernameMatchingUserEntityAnnotation ``` The Bitbucket provider is a structure with two configuration keys: diff --git a/docs/auth/cloudflare/provider.md b/docs/auth/cloudflare/provider.md index a1946580a7..224a64f056 100644 --- a/docs/auth/cloudflare/provider.md +++ b/docs/auth/cloudflare/provider.md @@ -41,8 +41,8 @@ auth: # This picks what sign in resolver(s) you want to use. signIn: resolvers: + # See https://backstage.io/docs/auth/cloudflare/provider#resolvers for more resolvers - resolver: emailMatchingUserEntityProfileEmail - - resolver: emailLocalPartMatchingUserEntityName ``` This config section must be in place for the provider to load at all. diff --git a/docs/auth/github/provider.md b/docs/auth/github/provider.md index 76b63c6617..3b61815017 100644 --- a/docs/auth/github/provider.md +++ b/docs/auth/github/provider.md @@ -51,8 +51,7 @@ auth: # enterpriseInstanceUrl: ${AUTH_GITHUB_ENTERPRISE_INSTANCE_URL} signIn: resolvers: - # Matches the GitHub username with the Backstage user entity name. - # See https://backstage.io/docs/auth/github/provider#resolvers for more resolvers. + # See https://backstage.io/docs/auth/github/provider#resolvers for more resolvers - resolver: usernameMatchingUserEntityName ``` diff --git a/docs/auth/gitlab/provider.md b/docs/auth/gitlab/provider.md index 4f5339f5b9..5241481463 100644 --- a/docs/auth/gitlab/provider.md +++ b/docs/auth/gitlab/provider.md @@ -46,9 +46,7 @@ auth: # callbackUrl: https://${BASE_URL}/api/auth/gitlab/handler/frame signIn: resolvers: - # typically you would pick one of these - - resolver: emailMatchingUserEntityProfileEmail - - resolver: emailLocalPartMatchingUserEntityName + # See https://backstage.io/docs/auth/gitlab/provider#resolvers for more resolvers - resolver: usernameMatchingUserEntityName ``` diff --git a/docs/auth/google/gcp-iap-auth.md b/docs/auth/google/gcp-iap-auth.md index 298f212399..6fe8b56d9e 100644 --- a/docs/auth/google/gcp-iap-auth.md +++ b/docs/auth/google/gcp-iap-auth.md @@ -29,9 +29,7 @@ auth: jwtHeader: x-custom-header # Optional: Only if you are using a custom header for the IAP JWT signIn: resolvers: - # typically you would pick one of these - - resolver: emailMatchingUserEntityProfileEmail - - resolver: emailLocalPartMatchingUserEntityName + # See https://backstage.io/docs/auth/google/gcp-iap-auth#resolvers for more resolvers - resolver: emailMatchingUserEntityAnnotation ``` diff --git a/docs/auth/google/provider.md b/docs/auth/google/provider.md index b19d21d63f..109c2ddd9f 100644 --- a/docs/auth/google/provider.md +++ b/docs/auth/google/provider.md @@ -44,9 +44,7 @@ auth: clientSecret: ${AUTH_GOOGLE_CLIENT_SECRET} signIn: resolvers: - # typically you would pick one of these - - resolver: emailMatchingUserEntityProfileEmail - - resolver: emailLocalPartMatchingUserEntityName + # See https://backstage.io/docs/auth/google/provider#resolvers for more resolvers - resolver: emailMatchingUserEntityAnnotation ``` diff --git a/docs/auth/microsoft/azure-easyauth.md b/docs/auth/microsoft/azure-easyauth.md index c2a6eab218..52c61a0d86 100644 --- a/docs/auth/microsoft/azure-easyauth.md +++ b/docs/auth/microsoft/azure-easyauth.md @@ -66,9 +66,7 @@ auth: azureEasyAuth: signIn: resolvers: - # typically you would pick one of these - - resolver: emailMatchingUserEntityProfileEmail - - resolver: emailLocalPartMatchingUserEntityName + # See https://backstage.io/docs/auth/microsoft/easy-auth#resolvers for more resolvers - resolver: idMatchingUserEntityAnnotation ``` diff --git a/docs/auth/microsoft/provider.md b/docs/auth/microsoft/provider.md index eb4fc833fd..a786083f5f 100644 --- a/docs/auth/microsoft/provider.md +++ b/docs/auth/microsoft/provider.md @@ -69,10 +69,7 @@ auth: domainHint: ${AZURE_TENANT_ID} signIn: resolvers: - # typically you would pick one of these - - resolver: emailMatchingUserEntityProfileEmail - - resolver: emailLocalPartMatchingUserEntityName - - resolver: emailMatchingUserEntityAnnotation + # See https://backstage.io/docs/auth/microsoft/provider#resolvers for more resolvers - resolver: userIdMatchingUserEntityAnnotation ``` diff --git a/docs/auth/oauth2-proxy/provider.md b/docs/auth/oauth2-proxy/provider.md index 84e2b307e6..2fa027e0ad 100644 --- a/docs/auth/oauth2-proxy/provider.md +++ b/docs/auth/oauth2-proxy/provider.md @@ -31,9 +31,7 @@ auth: oauth2Proxy: signIn: resolvers: - # typically you would pick one of these - - resolver: emailMatchingUserEntityProfileEmail - - resolver: emailLocalPartMatchingUserEntityName + # See https://backstage.io/docs/auth/oauth2-proxy/provider#resolvers for more resolvers - resolver: forwardedUserMatchingUserEntityName ``` diff --git a/docs/auth/oidc.md b/docs/auth/oidc.md index 4040a272b4..6d2a937320 100644 --- a/docs/auth/oidc.md +++ b/docs/auth/oidc.md @@ -156,8 +156,6 @@ auth: # ... signIn: resolvers: - # typically you would pick one of these - - resolver: emailLocalPartMatchingUserEntityName - resolver: emailMatchingUserEntityProfileEmail ``` diff --git a/docs/auth/okta/provider.md b/docs/auth/okta/provider.md index 83352aaa0d..abc6231839 100644 --- a/docs/auth/okta/provider.md +++ b/docs/auth/okta/provider.md @@ -49,9 +49,7 @@ auth: additionalScopes: ${AUTH_OKTA_ADDITIONAL_SCOPES} # Optional signIn: resolvers: - # typically you would pick one of these - - resolver: emailMatchingUserEntityProfileEmail - - resolver: emailLocalPartMatchingUserEntityName + # See https://backstage.io/docs/auth/okta/provider#resolvers for more resolvers - resolver: emailMatchingUserEntityAnnotation ``` diff --git a/docs/auth/onelogin/provider.md b/docs/auth/onelogin/provider.md index 002445a9d1..edceb1de62 100644 --- a/docs/auth/onelogin/provider.md +++ b/docs/auth/onelogin/provider.md @@ -40,9 +40,7 @@ auth: issuer: https://.onelogin.com/oidc/2 signIn: resolvers: - # typically you would pick one of these - - resolver: emailMatchingUserEntityProfileEmail - - resolver: emailLocalPartMatchingUserEntityName + # See https://backstage.io/docs/auth/onelogin/provider#resolvers for more resolvers - resolver: usernameMatchingUserEntityName ``` diff --git a/docs/auth/vmware-cloud/provider.md b/docs/auth/vmware-cloud/provider.md index ac2cf3ee92..a52ee98df2 100644 --- a/docs/auth/vmware-cloud/provider.md +++ b/docs/auth/vmware-cloud/provider.md @@ -49,10 +49,8 @@ auth: organizationId: ${ORG_ID} signIn: resolvers: - # typically you would pick one of these + # See https://backstage.io/docs/auth/vmware-cloud/provider#resolvers for more resolvers - resolver: emailMatchingUserEntityProfileEmail - - resolver: emailLocalPartMatchingUserEntityName - - resolver: vmwareCloudSignInResolvers ``` Where `APP_ID` refers to the ID retrieved when creating the OAuth App, and diff --git a/docs/backend-system/building-backends/08-migrating.md b/docs/backend-system/building-backends/08-migrating.md index 188cd4f26f..2bc111b316 100644 --- a/docs/backend-system/building-backends/08-migrating.md +++ b/docs/backend-system/building-backends/08-migrating.md @@ -884,8 +884,6 @@ auth: signIn: resolvers: - resolver: emailMatchingUserEntityProfileEmail - - resolver: emailLocalPartMatchingUserEntityName - - resolver: emailMatchingUserEntityAnnotation ``` :::note Note From d0edfec4541565e7b0cbfd2d699933107559d29d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 4 Oct 2024 14:38:13 +0200 Subject: [PATCH 078/291] auth-backend-module-vmware-cloud-provider: remove insecure sign-in resolver Signed-off-by: Patrik Oldsberg --- .changeset/purple-toys-heal.md | 5 ++ docs/auth/vmware-cloud/provider.md | 1 - .../config.d.ts | 1 - .../src/index.ts | 1 - .../src/module.ts | 2 - .../src/resolvers.test.ts | 90 ------------------- .../src/resolvers.ts | 75 ---------------- 7 files changed, 5 insertions(+), 170 deletions(-) create mode 100644 .changeset/purple-toys-heal.md delete mode 100644 plugins/auth-backend-module-vmware-cloud-provider/src/resolvers.test.ts delete mode 100644 plugins/auth-backend-module-vmware-cloud-provider/src/resolvers.ts diff --git a/.changeset/purple-toys-heal.md b/.changeset/purple-toys-heal.md new file mode 100644 index 0000000000..4a57dca64f --- /dev/null +++ b/.changeset/purple-toys-heal.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-vmware-cloud-provider': minor +--- + +**BREAKING**: The `profileEmailMatchingUserEntityEmail` sign-in resolver has been removed as it was using an insecure fallback for resolving user identities. See https://backstage.io/docs/auth/identity-resolver#sign-in-without-users-in-the-catalog for how to create a custom sign-in resolver if needed as a replacement. diff --git a/docs/auth/vmware-cloud/provider.md b/docs/auth/vmware-cloud/provider.md index a52ee98df2..d654331178 100644 --- a/docs/auth/vmware-cloud/provider.md +++ b/docs/auth/vmware-cloud/provider.md @@ -74,7 +74,6 @@ This provider includes several resolvers out of the box that you can use: - `emailMatchingUserEntityProfileEmail`: Matches the email address from the auth provider with the User entity that has a matching `spec.profile.email`. If no match is found it will throw a `NotFoundError`. - `emailLocalPartMatchingUserEntityName`: Matches the [local part](https://en.wikipedia.org/wiki/Email_address#Local-part) of the email address from the auth provider with the User entity that has a matching `name`. If no match is found it will throw a `NotFoundError`. -- `vmwareCloudSignInResolvers`: Matches the email address from the auth provider with the User entity that has a matching `spec.profile.email`. If no match is found it will sign in the user without associating with a catalog user. :::note Note diff --git a/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts b/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts index a08451cff3..8bb8320c0c 100644 --- a/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts +++ b/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts @@ -27,7 +27,6 @@ export interface Config { additionalScopes?: string | string[]; signIn?: { resolvers: Array< - | { resolver: 'profileEmailMatchingUserEntityEmail' } | { resolver: 'emailLocalPartMatchingUserEntityName' } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; diff --git a/plugins/auth-backend-module-vmware-cloud-provider/src/index.ts b/plugins/auth-backend-module-vmware-cloud-provider/src/index.ts index 1d7bb14b63..4f42092c21 100644 --- a/plugins/auth-backend-module-vmware-cloud-provider/src/index.ts +++ b/plugins/auth-backend-module-vmware-cloud-provider/src/index.ts @@ -26,4 +26,3 @@ export { type VMwarePassportProfile, } from './authenticator'; export { authModuleVmwareCloudProvider as default } from './module'; -export { vmwareCloudSignInResolvers } from './resolvers'; diff --git a/plugins/auth-backend-module-vmware-cloud-provider/src/module.ts b/plugins/auth-backend-module-vmware-cloud-provider/src/module.ts index 5ef79654a3..4d9ca700a3 100644 --- a/plugins/auth-backend-module-vmware-cloud-provider/src/module.ts +++ b/plugins/auth-backend-module-vmware-cloud-provider/src/module.ts @@ -21,7 +21,6 @@ import { } from '@backstage/plugin-auth-node'; import { vmwareCloudAuthenticator } from './authenticator'; -import { vmwareCloudSignInResolvers } from './resolvers'; /** * VMware Cloud Provider backend module for the auth plugin @@ -40,7 +39,6 @@ export const authModuleVmwareCloudProvider = createBackendModule({ factory: createOAuthProviderFactory({ authenticator: vmwareCloudAuthenticator, signInResolverFactories: { - ...vmwareCloudSignInResolvers, ...commonSignInResolvers, }, }), diff --git a/plugins/auth-backend-module-vmware-cloud-provider/src/resolvers.test.ts b/plugins/auth-backend-module-vmware-cloud-provider/src/resolvers.test.ts deleted file mode 100644 index 250fd430c7..0000000000 --- a/plugins/auth-backend-module-vmware-cloud-provider/src/resolvers.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2023 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { NotFoundError } from '@backstage/errors'; -import { - AuthResolverContext, - OAuthAuthenticatorResult, - PassportProfile, - SignInInfo, - SignInResolver, -} from '@backstage/plugin-auth-node'; - -import { vmwareCloudSignInResolvers } from './resolvers'; - -describe('vmwareCloudResolver', () => { - let resolverContext: jest.Mocked; - let signInInfo: SignInInfo>; - let signInResolver: SignInResolver>; - - beforeEach(() => { - resolverContext = { - issueToken: jest.fn().mockResolvedValue({ - token: 'defaultBackstageToken', - }), - findCatalogUser: jest.fn(), - signInWithCatalogUser: jest.fn().mockResolvedValue({ - token: 'backstageToken', - }), - }; - - signInInfo = { - result: {} as any, // Resolver doesn't care about the result object - profile: { - displayName: 'TestName', - email: 'user@example.com', - }, - }; - - signInResolver = - vmwareCloudSignInResolvers.profileEmailMatchingUserEntityEmail(); - }); - - it('looks up backstage identity by email', async () => { - const backstageIdentity = await signInResolver(signInInfo, resolverContext); - - expect(backstageIdentity.token).toBe('backstageToken'); - expect(resolverContext.signInWithCatalogUser).toHaveBeenCalledWith({ - filter: { - 'spec.profile.email': 'user@example.com', - }, - }); - }); - - it('returns "fake" backstage identity when no entity matches', async () => { - resolverContext.signInWithCatalogUser.mockRejectedValue( - new NotFoundError('User not found'), - ); - - const backstageIdentity = await signInResolver(signInInfo, resolverContext); - - expect(backstageIdentity.token).toBe('defaultBackstageToken'); - expect(resolverContext.issueToken).toHaveBeenCalledWith({ - claims: { - sub: 'user:default/user@example.com', - ent: ['user:default/user@example.com'], - }, - }); - }); - - it('fails when resolver context throws other error', () => { - const error = new Error('bizarre'); - resolverContext.signInWithCatalogUser.mockRejectedValue(error); - - return expect(signInResolver(signInInfo, resolverContext)).rejects.toThrow( - error, - ); - }); -}); diff --git a/plugins/auth-backend-module-vmware-cloud-provider/src/resolvers.ts b/plugins/auth-backend-module-vmware-cloud-provider/src/resolvers.ts deleted file mode 100644 index 2b3a78842b..0000000000 --- a/plugins/auth-backend-module-vmware-cloud-provider/src/resolvers.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2023 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { stringifyEntityRef } from '@backstage/catalog-model'; -import { - createSignInResolverFactory, - OAuthAuthenticatorResult, - PassportProfile, - SignInInfo, -} from '@backstage/plugin-auth-node'; - -/** - * Available sign-in resolvers for the VMware Cloud auth provider. - * - * @public - */ -export namespace vmwareCloudSignInResolvers { - /** - * Looks up the user by matching their profile email to the entity's profile email. - * If that fails, sign in the user without associating with a catalog user. - */ - export const profileEmailMatchingUserEntityEmail = - createSignInResolverFactory({ - create() { - return async ( - info: SignInInfo>, - ctx, - ) => { - const email = info.profile.email; - - if (!email) { - throw new Error( - 'VMware login failed, user profile does not contain an email', - ); - } - - const userEntityRef = stringifyEntityRef({ - kind: 'User', - name: email, - }); - - try { - // we await here so that signInWithCatalogUser throws in the current `try` - return await ctx.signInWithCatalogUser({ - filter: { - 'spec.profile.email': email, - }, - }); - } catch (e) { - if (e.name !== 'NotFoundError') { - throw e; - } - return ctx.issueToken({ - claims: { - sub: userEntityRef, - ent: [userEntityRef], - }, - }); - } - }; - }, - }); -} From cfa2b0e4f93c898165177be592c33ec2e1d25c54 Mon Sep 17 00:00:00 2001 From: nikolar Date: Fri, 4 Oct 2024 11:09:20 -0700 Subject: [PATCH 079/291] update to just add overridable class Signed-off-by: nikolar --- .changeset/breezy-berries-yawn.md | 2 +- .../src/next/components/Stepper/Stepper.tsx | 36 +++++++++++-------- .../src/next/overridableComponents.ts | 2 ++ 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/.changeset/breezy-berries-yawn.md b/.changeset/breezy-berries-yawn.md index beb1053034..0ef4fa68e7 100644 --- a/.changeset/breezy-berries-yawn.md +++ b/.changeset/breezy-berries-yawn.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder-react': patch --- -Update template form buttons from `justifyContent` 'right' to `justifyContent` 'left' +Add `overridableComponent` `BackstageTemplateStepperClassKey` to template stepper to enable custom styling diff --git a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx index 3809132707..41e1fd7967 100644 --- a/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx +++ b/plugins/scaffolder-react/src/next/components/Stepper/Stepper.tsx @@ -56,20 +56,28 @@ import { merge } from 'lodash'; const validator = customizeValidator(); ajvErrors(validator.ajv); -const useStyles = makeStyles(theme => ({ - backButton: { - marginRight: theme.spacing(1), - }, - footer: { - display: 'flex', - flexDirection: 'row', - justifyContent: 'left', - marginTop: theme.spacing(2), - }, - formWrapper: { - padding: theme.spacing(2), - }, -})); +export type BackstageTemplateStepperClassKey = + | 'backButton' + | 'footer' + | 'formWrapper'; + +const useStyles = makeStyles( + theme => ({ + backButton: { + marginRight: theme.spacing(1), + }, + footer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'right', + marginTop: theme.spacing(2), + }, + formWrapper: { + padding: theme.spacing(2), + }, + }), + { name: 'BackstageTemplateStepper' }, +); /** * The Props for {@link Stepper} component diff --git a/plugins/scaffolder-react/src/next/overridableComponents.ts b/plugins/scaffolder-react/src/next/overridableComponents.ts index 56986d4f80..2045496166 100644 --- a/plugins/scaffolder-react/src/next/overridableComponents.ts +++ b/plugins/scaffolder-react/src/next/overridableComponents.ts @@ -17,10 +17,12 @@ import { Overrides } from '@material-ui/core/styles/overrides'; import { StyleRules } from '@material-ui/core/styles/withStyles'; import { ScaffolderReactTemplateCategoryPickerClassKey } from './components/TemplateCategoryPicker/TemplateCategoryPicker'; +import { BackstageTemplateStepperClassKey } from './components/Stepper/Stepper'; /** @alpha */ export type ScaffolderReactComponentsNameToClassKey = { ScaffolderReactTemplateCategoryPicker: ScaffolderReactTemplateCategoryPickerClassKey; + BackstageTemplateStepper: BackstageTemplateStepperClassKey; }; /** @alpha */ From 20950e6e2d10f6531c8987fa47fb2e65f93c4190 Mon Sep 17 00:00:00 2001 From: nikolar Date: Fri, 4 Oct 2024 11:41:57 -0700 Subject: [PATCH 080/291] add api report Signed-off-by: nikolar --- plugins/scaffolder-react/report-alpha.api.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder-react/report-alpha.api.md b/plugins/scaffolder-react/report-alpha.api.md index 75c6d08e32..7f420f353c 100644 --- a/plugins/scaffolder-react/report-alpha.api.md +++ b/plugins/scaffolder-react/report-alpha.api.md @@ -200,11 +200,18 @@ export type ScaffolderPageContextMenuProps = { // @alpha (undocumented) export type ScaffolderReactComponentsNameToClassKey = { ScaffolderReactTemplateCategoryPicker: ScaffolderReactTemplateCategoryPickerClassKey; + BackstageTemplateStepper: BackstageTemplateStepperClassKey; }; // @alpha (undocumented) export type ScaffolderReactTemplateCategoryPickerClassKey = 'root' | 'label'; +// @alpha (undocumented) +export type BackstageTemplateStepperClassKey = + | 'backButton' + | 'footer' + | 'formWrapper'; + // @alpha export const SecretWidget: ( props: Pick< @@ -416,8 +423,9 @@ export type WorkflowProps = { // src/next/hooks/useTemplateSchema.d.ts:12:5 - (ae-undocumented) Missing documentation for "schema". // src/next/hooks/useTemplateSchema.d.ts:13:5 - (ae-undocumented) Missing documentation for "title". // src/next/hooks/useTemplateSchema.d.ts:14:5 - (ae-undocumented) Missing documentation for "description". -// src/next/overridableComponents.d.ts:5:1 - (ae-undocumented) Missing documentation for "ScaffolderReactComponentsNameToClassKey". -// src/next/overridableComponents.d.ts:9:1 - (ae-undocumented) Missing documentation for "BackstageOverrides". +// src/next/overridableComponents.d.ts:6:1 - (ae-undocumented) Missing documentation for "ScaffolderReactComponentsNameToClassKey". +// src/next/overridableComponents.d.ts:8:5 - (ae-forgotten-export) The symbol "BackstageTemplateStepperClassKey" needs to be exported by the entry point alpha.d.ts +// src/next/overridableComponents.d.ts:11:1 - (ae-undocumented) Missing documentation for "BackstageOverrides". // (No @packageDocumentation comment for this package) ``` From 73f2ccf2187b6182e9cc6b012ad929a0d2d3cac6 Mon Sep 17 00:00:00 2001 From: Matt Benson Date: Fri, 4 Oct 2024 15:37:57 -0500 Subject: [PATCH 081/291] declare correct type (number) for publish:gitlab output.projectId Signed-off-by: Matt Benson --- .changeset/quiet-needles-impress.md | 5 +++++ .../scaffolder-backend-module-gitlab/src/actions/gitlab.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/quiet-needles-impress.md diff --git a/.changeset/quiet-needles-impress.md b/.changeset/quiet-needles-impress.md new file mode 100644 index 0000000000..a79fe97b91 --- /dev/null +++ b/.changeset/quiet-needles-impress.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': minor +--- + +declare correct type (number) for publish:gitlab output.projectId diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.ts index 020e8a9c18..1c888cff09 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlab.ts @@ -294,7 +294,7 @@ export function createPublishGitlabAction(options: { }, projectId: { title: 'The ID of the project', - type: 'string', + type: 'number', }, commitHash: { title: 'The git commit hash of the initial commit', From e118355dfddfbd5372e5d3fbc5ee87cd3e8233fd Mon Sep 17 00:00:00 2001 From: nikolar Date: Fri, 4 Oct 2024 13:55:29 -0700 Subject: [PATCH 082/291] fix api report Signed-off-by: nikolar --- plugins/scaffolder-react/report-alpha.api.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/plugins/scaffolder-react/report-alpha.api.md b/plugins/scaffolder-react/report-alpha.api.md index 7f420f353c..e15ee181d2 100644 --- a/plugins/scaffolder-react/report-alpha.api.md +++ b/plugins/scaffolder-react/report-alpha.api.md @@ -206,12 +206,6 @@ export type ScaffolderReactComponentsNameToClassKey = { // @alpha (undocumented) export type ScaffolderReactTemplateCategoryPickerClassKey = 'root' | 'label'; -// @alpha (undocumented) -export type BackstageTemplateStepperClassKey = - | 'backButton' - | 'footer' - | 'formWrapper'; - // @alpha export const SecretWidget: ( props: Pick< From f1f0076b061de3b4497d8e1ce3bcb3b10fc14934 Mon Sep 17 00:00:00 2001 From: Matt Benson Date: Fri, 4 Oct 2024 15:57:41 -0500 Subject: [PATCH 083/291] handle step.if: false Signed-off-by: Matt Benson --- .changeset/curly-tomatoes-reply.md | 5 +++ .../tasks/NunjucksWorkflowRunner.test.ts | 42 +++++++++++++++++++ .../tasks/NunjucksWorkflowRunner.ts | 14 +++---- 3 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 .changeset/curly-tomatoes-reply.md diff --git a/.changeset/curly-tomatoes-reply.md b/.changeset/curly-tomatoes-reply.md new file mode 100644 index 0000000000..386e2b6109 --- /dev/null +++ b/.changeset/curly-tomatoes-reply.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +handle step.if: false diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts index 44f8836fd1..0bcbd2f917 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts @@ -407,6 +407,48 @@ describe('NunjucksWorkflowRunner', () => { expect(output.result).toBeUndefined(); }); + describe('should apply boolean step conditions', () => { + it('executes when true', async () => { + const task = createMockTaskWithSpec({ + apiVersion: 'scaffolder.backstage.io/v1beta3', + steps: [ + { + id: 'conditional', + name: 'conditional', + action: 'output-action', + if: true, + }, + ], + output: { + result: '${{ steps.conditional.output.mock }}', + }, + parameters: {}, + }); + + const { output } = await runner.execute(task); + expect(output.result).toBe('backstage'); + }); + it('skips when false', async () => { + const task = createMockTaskWithSpec({ + apiVersion: 'scaffolder.backstage.io/v1beta3', + steps: [ + { + id: 'conditional', + name: 'conditional', + action: 'output-action', + if: false, + }, + ], + output: { + result: '${{ steps.conditional.output.mock }}', + }, + parameters: {}, + }); + + const { output } = await runner.execute(task); + expect(output.result).toBeUndefined(); + }); + }); }); describe('templating', () => { diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 6de51ea3b6..6f7551568f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -244,14 +244,14 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { } try { - if (step.if) { - const ifResult = this.render(step.if, context, renderTemplate); - if (!isTruthy(ifResult)) { - await stepTrack.skipFalsy(); - return; - } + if ( + step.if === false || + (typeof step.if === 'string' && + !isTruthy(this.render(step.if, context, renderTemplate))) + ) { + await stepTrack.skipFalsy(); + return; } - const action: TemplateAction = this.options.actionRegistry.get(step.action); const { taskLogger, streamLogger } = createStepLogger({ From fb5eaf18c3da16e62a312861cf76868b53b5be35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veith=20M=2E=20B=C3=BCrgerhoff?= <62615322+VeithBuergerhoff@users.noreply.github.com> Date: Sun, 6 Oct 2024 23:43:39 +0200 Subject: [PATCH 084/291] Update docs/features/software-templates/writing-templates.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Camila Belo Signed-off-by: Veith M. Bürgerhoff <62615322+VeithBuergerhoff@users.noreply.github.com> --- docs/features/software-templates/writing-templates.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/software-templates/writing-templates.md b/docs/features/software-templates/writing-templates.md index 2cc14f1000..7349ff94e0 100644 --- a/docs/features/software-templates/writing-templates.md +++ b/docs/features/software-templates/writing-templates.md @@ -814,7 +814,7 @@ These properties accept a `Record` ``` where the first parameter is the identifier of the filter or global and the second is a `TemplateFilter` or a `TemplateGlobal` respectively. -A `TemplateFilter` is a function which will be called using the previous `JsonValue` objets and may return a `JsonValue` object. +A `TemplateFilter` is a function which will be called using the previous `JsonValue` objects and may return a `JsonValue` object. A `TemplateGlobal` can either be a function which will be called using the passed `JsonValue` objects and may return a `JsonValue` object or it can be a `JsonValue` object itself. ```ts title="plugins/scaffolder-node/src/types.ts" From fd6e6f45ca689b09d290dc32b1fced87d1c71032 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 07:01:21 +0000 Subject: [PATCH 085/291] build(deps): bump cookie from 0.6.0 to 0.7.0 Bumps [cookie](https://github.com/jshttp/cookie) from 0.6.0 to 0.7.0. - [Release notes](https://github.com/jshttp/cookie/releases) - [Commits](https://github.com/jshttp/cookie/compare/v0.6.0...v0.7.0) --- updated-dependencies: - dependency-name: cookie dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .changeset/dependabot-a3fd85a.md | 7 +++++++ packages/backend-app-api/package.json | 2 +- packages/backend-defaults/package.json | 2 +- packages/backend-test-utils/package.json | 2 +- yarn.lock | 15 +++++++++++---- 5 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 .changeset/dependabot-a3fd85a.md diff --git a/.changeset/dependabot-a3fd85a.md b/.changeset/dependabot-a3fd85a.md new file mode 100644 index 0000000000..8ff0c9dfa2 --- /dev/null +++ b/.changeset/dependabot-a3fd85a.md @@ -0,0 +1,7 @@ +--- +'@backstage/backend-app-api': patch +'@backstage/backend-defaults': patch +'@backstage/backend-test-utils': patch +--- + +build(deps): bump `cookie` from 0.6.0 to 0.7.0 diff --git a/packages/backend-app-api/package.json b/packages/backend-app-api/package.json index 018eea139f..f4e3f6a9f2 100644 --- a/packages/backend-app-api/package.json +++ b/packages/backend-app-api/package.json @@ -61,7 +61,7 @@ "@backstage/types": "workspace:^", "@manypkg/get-packages": "^1.1.3", "compression": "^1.7.4", - "cookie": "^0.6.0", + "cookie": "^0.7.0", "cors": "^2.8.5", "express": "^4.17.1", "express-promise-router": "^4.1.0", diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index b3375de9a5..0760ea412c 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -147,7 +147,7 @@ "better-sqlite3": "^11.0.0", "compression": "^1.7.4", "concat-stream": "^2.0.0", - "cookie": "^0.6.0", + "cookie": "^0.7.0", "cors": "^2.8.5", "cron": "^3.0.0", "express": "^4.17.1", diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index 427eb7c7a2..88f01fbc10 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -60,7 +60,7 @@ "@types/keyv": "^4.2.0", "@types/qs": "^6.9.6", "better-sqlite3": "^11.0.0", - "cookie": "^0.6.0", + "cookie": "^0.7.0", "express": "^4.17.1", "fs-extra": "^11.0.0", "keyv": "^4.5.2", diff --git a/yarn.lock b/yarn.lock index f5184e9149..44a6205744 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3490,7 +3490,7 @@ __metadata: "@types/node-forge": ^1.3.0 "@types/stoppable": ^1.1.0 compression: ^1.7.4 - cookie: ^0.6.0 + cookie: ^0.7.0 cors: ^2.8.5 express: ^4.17.1 express-promise-router: ^4.1.0 @@ -3645,7 +3645,7 @@ __metadata: better-sqlite3: ^11.0.0 compression: ^1.7.4 concat-stream: ^2.0.0 - cookie: ^0.6.0 + cookie: ^0.7.0 cors: ^2.8.5 cron: ^3.0.0 express: ^4.17.1 @@ -3814,7 +3814,7 @@ __metadata: "@types/qs": ^6.9.6 "@types/supertest": ^2.0.8 better-sqlite3: ^11.0.0 - cookie: ^0.6.0 + cookie: ^0.7.0 express: ^4.17.1 fs-extra: ^11.0.0 keyv: ^4.5.2 @@ -23714,7 +23714,7 @@ __metadata: languageName: node linkType: hard -"cookie@npm:0.6.0, cookie@npm:^0.6.0, cookie@npm:~0.6.0": +"cookie@npm:0.6.0, cookie@npm:~0.6.0": version: 0.6.0 resolution: "cookie@npm:0.6.0" checksum: f56a7d32a07db5458e79c726b77e3c2eff655c36792f2b6c58d351fb5f61531e5b1ab7f46987150136e366c65213cbe31729e02a3eaed630c3bf7334635fb410 @@ -23735,6 +23735,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:^0.7.0": + version: 0.7.2 + resolution: "cookie@npm:0.7.2" + checksum: 9bf8555e33530affd571ea37b615ccad9b9a34febbf2c950c86787088eb00a8973690833b0f8ebd6b69b753c62669ea60cec89178c1fb007bf0749abed74f93e + languageName: node + linkType: hard + "cookiejar@npm:^2.1.4": version: 2.1.4 resolution: "cookiejar@npm:2.1.4" From 4e9702e8cccdc1f753df4b9e45bf338ce943b18c Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 4 Oct 2024 16:04:27 +0200 Subject: [PATCH 086/291] test(scaffolder): pages header navigation Signed-off-by: Camila Belo --- .changeset/fluffy-dolphins-battle.md | 5 + .../CustomFieldExplorer.tsx | 39 ++++---- .../CustomFieldsPage.test.tsx | 71 +++++++++++++++ .../TemplateEditorPage.test.tsx | 71 +++++++++++++++ .../TemplateFormPage.test.tsx | 59 ++++++++++++ .../TemplateIntroPage.test.tsx | 91 +++++++++++++++++++ .../TemplateEditorPage/TemplateIntroPage.tsx | 58 ++++++------ 7 files changed, 347 insertions(+), 47 deletions(-) create mode 100644 .changeset/fluffy-dolphins-battle.md create mode 100644 plugins/scaffolder/src/alpha/components/TemplateEditorPage/CustomFieldsPage.test.tsx create mode 100644 plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorPage.test.tsx create mode 100644 plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPage.test.tsx create mode 100644 plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateIntroPage.test.tsx diff --git a/.changeset/fluffy-dolphins-battle.md b/.changeset/fluffy-dolphins-battle.md new file mode 100644 index 0000000000..b247d549e5 --- /dev/null +++ b/.changeset/fluffy-dolphins-battle.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Add tests for the new pages header navigation. diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/CustomFieldExplorer.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/CustomFieldExplorer.tsx index 0dacb00271..5ae44ba868 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/CustomFieldExplorer.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/CustomFieldExplorer.tsx @@ -88,27 +88,28 @@ export const CustomFieldExplorer = ({ const classes = useStyles(); const { t } = useTranslationRef(scaffolderTranslationRef); const fieldOptions = customFieldExtensions.filter(field => !!field.schema); - const [selectedField, setSelectedField] = useState(fieldOptions[0]); + const [selectedField, setSelectedField] = useState(fieldOptions?.[0]); const [fieldFormState, setFieldFormState] = useState({}); const [refreshKey, setRefreshKey] = useState(Date.now()); - const sampleFieldTemplate = useMemo( - () => - yaml.stringify({ - parameters: [ - { - title: `${selectedField.name} Example`, - properties: { - [selectedField.name]: { - type: selectedField.schema?.returnValue?.type, - 'ui:field': selectedField.name, - 'ui:options': fieldFormState, - }, + const sampleFieldTemplate = useMemo(() => { + if (!selectedField) { + return ''; + } + return yaml.stringify({ + parameters: [ + { + title: `${selectedField.name} Example`, + properties: { + [selectedField.name]: { + type: selectedField.schema?.returnValue?.type, + 'ui:field': selectedField.name, + 'ui:options': fieldFormState, }, }, - ], - }), - [fieldFormState, selectedField], - ); + }, + ], + }); + }, [fieldFormState, selectedField]); const fieldComponents = useMemo(() => { return Object.fromEntries( @@ -185,7 +186,7 @@ export const CustomFieldExplorer = ({ formContext={{ fieldFormState }} onSubmit={e => handleFieldConfigChange(e.formData)} validator={validator} - schema={selectedField.schema?.uiOptions || {}} + schema={selectedField?.schema?.uiOptions || {}} experimental_defaultFormStateBehavior={{ allOf: 'populateDefaults', }} @@ -194,7 +195,7 @@ export const CustomFieldExplorer = ({ variant="contained" color="primary" type="submit" - disabled={!selectedField.schema?.uiOptions} + disabled={!selectedField?.schema?.uiOptions} > {t( 'templateEditorPage.customFieldExplorer.fieldForm.applyButtonTitle', diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/CustomFieldsPage.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/CustomFieldsPage.test.tsx new file mode 100644 index 0000000000..a6c5ff89d3 --- /dev/null +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/CustomFieldsPage.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright 2024 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 { screen } from '@testing-library/react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; +import { rootRouteRef } from '../../../routes'; +import { CustomFieldsPage } from './CustomFieldsPage'; + +describe('CustomFieldsPage', () => { + const catalogApiMock = { getEntities: jest.fn().mockResolvedValue([]) }; + const scaffolderApiMock = {}; + + it('Should render without exploding', async () => { + await renderInTestApp( + + + , + { + mountedRoutes: { + '/': rootRouteRef, + }, + }, + ); + expect( + screen.getByRole('heading', { name: 'Custom Field Explorer' }), + ).toBeInTheDocument(); + }); + + it('Should have an link back to the edit page', async () => { + await renderInTestApp( + + + , + { + mountedRoutes: { + '/': rootRouteRef, + }, + routeEntries: ['/edit'], + }, + ); + expect( + screen.getByRole('link', { name: /Manage Templates/ }), + ).toHaveAttribute('href', '/edit'); + }); +}); diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorPage.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorPage.test.tsx new file mode 100644 index 0000000000..7f7c66bc5d --- /dev/null +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorPage.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright 2024 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 { screen } from '@testing-library/react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; +import { TemplateEditorPage } from './TemplateEditorPage'; +import { rootRouteRef } from '../../../routes'; + +describe('TemplateEditorPage', () => { + const catalogApiMock = { getEntities: jest.fn().mockResolvedValue([]) }; + const scaffolderApiMock = {}; + + it('Should render without exploding', async () => { + await renderInTestApp( + + + , + { + mountedRoutes: { + '/': rootRouteRef, + }, + }, + ); + expect( + screen.getByRole('heading', { name: 'Template Editor' }), + ).toBeInTheDocument(); + }); + + it('Should have an link back to the edit page', async () => { + await renderInTestApp( + + + , + { + mountedRoutes: { + '/': rootRouteRef, + }, + routeEntries: ['/edit'], + }, + ); + expect( + screen.getByRole('link', { name: /Manage Templates/ }), + ).toHaveAttribute('href', '/edit'); + }); +}); diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPage.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPage.test.tsx new file mode 100644 index 0000000000..35e2772873 --- /dev/null +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateFormPage.test.tsx @@ -0,0 +1,59 @@ +/* + * Copyright 2024 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 { screen } from '@testing-library/react'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { TemplateFormPage } from './TemplateFormPage'; +import { rootRouteRef } from '../../../routes'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; + +describe('TemplateFormPage', () => { + const catalogApiMock = { getEntities: jest.fn().mockResolvedValue([]) }; + + it('Should render without exploding', async () => { + await renderInTestApp( + + + , + { + mountedRoutes: { + '/': rootRouteRef, + }, + }, + ); + expect( + screen.getByRole('heading', { name: 'Template Editor' }), + ).toBeInTheDocument(); + }); + + it('Should have an link back to the edit page', async () => { + await renderInTestApp( + + + , + { + mountedRoutes: { + '/': rootRouteRef, + }, + routeEntries: ['/edit'], + }, + ); + expect( + screen.getByRole('link', { name: /Manage Templates/ }), + ).toHaveAttribute('href', '/edit'); + }); +}); diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateIntroPage.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateIntroPage.test.tsx new file mode 100644 index 0000000000..f85c37233e --- /dev/null +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateIntroPage.test.tsx @@ -0,0 +1,91 @@ +/* + * Copyright 2024 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 { screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { TemplateIntroPage } from './TemplateIntroPage'; +import { rootRouteRef } from '../../../routes'; + +describe('TemplateIntroPage', () => { + it('Should render without exploding', async () => { + await renderInTestApp(, { + mountedRoutes: { + '/': rootRouteRef, + }, + }); + expect( + screen.getByRole('heading', { name: 'Manage Templates' }), + ).toBeInTheDocument(); + }); + + it('Should have an link back to the create page', async () => { + await renderInTestApp(, { + mountedRoutes: { + '/': rootRouteRef, + }, + }); + expect(screen.getByRole('link', { name: /Scaffolder/ })).toHaveAttribute( + 'href', + '/', + ); + }); + + it('Should have an action to load a template directory', async () => { + await renderInTestApp(, { + mountedRoutes: { + '/': rootRouteRef, + }, + }); + expect( + screen.getByRole('button', { name: /Load Template Directory/ }), + ).toBeInTheDocument(); + }); + + it('Should have an action to create a template directory', async () => { + await renderInTestApp(, { + mountedRoutes: { + '/': rootRouteRef, + }, + }); + expect( + screen.getByRole('button', { name: /Create New Template/ }), + ).toBeInTheDocument(); + }); + + it('Should have an action to open the template playground', async () => { + await renderInTestApp(, { + mountedRoutes: { + '/': rootRouteRef, + }, + }); + expect( + screen.getByRole('button', { name: /Template Form Playground/ }), + ).toBeInTheDocument(); + }); + + it('Should have an action to open the custom fields explorer', async () => { + await renderInTestApp(, { + mountedRoutes: { + '/': rootRouteRef, + }, + }); + + expect( + screen.getByRole('button', { name: /Custom Field Explorer/ }), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateIntroPage.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateIntroPage.tsx index 69fcfd37bf..28e4969718 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateIntroPage.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateIntroPage.tsx @@ -13,11 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { Content, Header, Page } from '@backstage/core-components'; -import { WebFileSystemAccess } from '../../../lib/filesystem'; - import { TemplateEditorIntro } from './TemplateEditorIntro'; import { useNavigate } from 'react-router-dom'; import { useRouteRef } from '@backstage/core-plugin-api'; @@ -29,8 +27,7 @@ import { } from '../../../routes'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { scaffolderTranslationRef } from '../../../translation'; -import { WebFileSystemStore } from '../../../lib/filesystem/WebFileSystemStore'; -import { createExampleTemplate } from '../../../lib/filesystem/createExampleTemplate'; +import { useTemplateDirectory } from './useTemplateDirectory'; export function TemplateIntroPage() { const navigate = useNavigate(); @@ -39,6 +36,33 @@ export function TemplateIntroPage() { const templateFormLink = useRouteRef(templateFormRouteRef); const customFieldsLink = useRouteRef(customFieldsRouteRef); const { t } = useTranslationRef(scaffolderTranslationRef); + const { openDirectory, createDirectory } = useTemplateDirectory(); + + const handleSelect = useCallback( + (option: 'create-template' | 'local' | 'form' | 'field-explorer') => { + if (option === 'local') { + openDirectory() + .then(() => navigate(editorLink())) + .catch(() => {}); + } else if (option === 'create-template') { + createDirectory() + .then(() => navigate(editorLink())) + .catch(() => {}); + } else if (option === 'form') { + navigate(templateFormLink()); + } else if (option === 'field-explorer') { + navigate(customFieldsLink()); + } + }, + [ + openDirectory, + createDirectory, + navigate, + editorLink, + templateFormLink, + customFieldsLink, + ], + ); return ( @@ -49,29 +73,7 @@ export function TemplateIntroPage() { subtitle={t('templateIntroPage.subtitle')} /> - { - if (option === 'local') { - WebFileSystemAccess.requestDirectoryAccess() - .then(directory => WebFileSystemStore.setDirectory(directory)) - .then(() => navigate(editorLink())) - .catch(() => {}); - } else if (option === 'create-template') { - WebFileSystemAccess.requestDirectoryAccess() - .then(directory => { - createExampleTemplate(directory).then(() => { - WebFileSystemStore.setDirectory(directory); - navigate(editorLink()); - }); - }) - .catch(() => {}); - } else if (option === 'form') { - navigate(templateFormLink()); - } else if (option === 'field-explorer') { - navigate(customFieldsLink()); - } - }} - /> + ); From d43774902b0c3b274ba052aaefaed63c18165e86 Mon Sep 17 00:00:00 2001 From: Vincenzo Scamporlino Date: Mon, 7 Oct 2024 12:49:48 +0200 Subject: [PATCH 087/291] backend-defaults: fix GithubUrlReader mocked urls Signed-off-by: Vincenzo Scamporlino --- .../urlReader/lib/GithubUrlReader.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/urlReader/lib/GithubUrlReader.test.ts b/packages/backend-defaults/src/entrypoints/urlReader/lib/GithubUrlReader.test.ts index f1dd7c0dc6..e8d81046ab 100644 --- a/packages/backend-defaults/src/entrypoints/urlReader/lib/GithubUrlReader.test.ts +++ b/packages/backend-defaults/src/entrypoints/urlReader/lib/GithubUrlReader.test.ts @@ -111,7 +111,7 @@ describe('GithubUrlReader', () => { worker.use( rest.get( - 'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/', + 'https://ghe.github.com/api/v3/repos/backstage/mock/contents/main', (req, res, ctx) => { expect(req.headers.get('authorization')).toBe( mockHeaders.Authorization, @@ -152,7 +152,7 @@ describe('GithubUrlReader', () => { worker.use( rest.get( - 'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/', + 'https://ghe.github.com/api/v3/repos/backstage/mock/contents/main', (req, res, ctx) => { expect(req.headers.get('authorization')).toBe( mockHeaders.Authorization, @@ -188,7 +188,7 @@ describe('GithubUrlReader', () => { worker.use( rest.get( - 'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/', + 'https://ghe.github.com/api/v3/repos/backstage/mock/contents/main', (req, res, ctx) => { expect(req.headers.get('authorization')).toBe( mockHeaders.Authorization, @@ -219,7 +219,7 @@ describe('GithubUrlReader', () => { worker.use( rest.get( - 'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/', + 'https://ghe.github.com/api/v3/repos/backstage/mock/contents/main', (_req, res, ctx) => { return res( ctx.status(403), @@ -249,7 +249,7 @@ describe('GithubUrlReader', () => { worker.use( rest.get( - 'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/', + 'https://ghe.github.com/api/v3/repos/backstage/mock/contents/main', (_req, res, ctx) => { return res( ctx.status(200), @@ -283,7 +283,7 @@ describe('GithubUrlReader', () => { worker.use( rest.get( - 'https://ghe.github.com/api/v3/repos/backstage/mock/tree/contents/', + 'https://ghe.github.com/api/v3/repos/backstage/mock/contents/main', (req, res, ctx) => { expect(req.headers.get('authorization')).toBe( 'Bearer overridentoken', From f1cab41be1fa14ab47681a67aaaceb0a4c722e80 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Mon, 7 Oct 2024 14:18:06 +0300 Subject: [PATCH 088/291] fix(catalog): update search table in transaction - run the delete and insert in transaction for entity search - mark the deferred stitching done only after search is also updated Signed-off-by: Heikki Hellgren --- .changeset/stale-ravens-clap.md | 5 +++++ .../operations/stitcher/performStitching.ts | 22 +++++++++---------- 2 files changed, 16 insertions(+), 11 deletions(-) create mode 100644 .changeset/stale-ravens-clap.md diff --git a/.changeset/stale-ravens-clap.md b/.changeset/stale-ravens-clap.md new file mode 100644 index 0000000000..3abb16a04d --- /dev/null +++ b/.changeset/stale-ravens-clap.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Update catalog search table in transaction diff --git a/plugins/catalog-backend/src/database/operations/stitcher/performStitching.ts b/plugins/catalog-backend/src/database/operations/stitcher/performStitching.ts index bf9a1307bd..0832ba5db8 100644 --- a/plugins/catalog-backend/src/database/operations/stitcher/performStitching.ts +++ b/plugins/catalog-backend/src/database/operations/stitcher/performStitching.ts @@ -218,28 +218,28 @@ export async function performStitching(options: { .onConflict('entity_id') .merge(['final_entity', 'hash', 'last_updated_at']); - if (options.strategy.mode === 'deferred') { + const markDeferred = async () => { + if (options.strategy.mode !== 'deferred') { + return; + } await markDeferredStitchCompleted({ knex: knex, entityRef, stitchTicket, }); - } + }; if (amountOfRowsChanged === 0) { logger.debug(`Entity ${entityRef} is already stitched, skipping write.`); + await markDeferred(); return 'abandoned'; } - // TODO(freben): Search will probably need a similar safeguard against - // race conditions like the final_entities ticket handling above. - // Otherwise, it can be the case that: - // A writes the entity -> - // B writes the entity -> - // B writes search -> - // A writes search - await knex('search').where({ entity_id: entityId }).delete(); - await knex.batchInsert('search', searchEntries, BATCH_SIZE); + await knex.transaction(async trx => { + await trx('search').where({ entity_id: entityId }).delete(); + await trx.batchInsert('search', searchEntries, BATCH_SIZE); + }); + await markDeferred(); return 'changed'; } From e6c05502d558bfbe96ac5028963dd58cb3171fea Mon Sep 17 00:00:00 2001 From: David Festal Date: Mon, 23 Sep 2024 16:23:06 +0200 Subject: [PATCH 089/291] refactor(backend-dynamic-feature-service): better load failure management... ... and other small enhancements (e.g. ability to get the `ScannedPluginPackage` of a loaded plugin). Signed-off-by: David Festal --- .changeset/kind-avocados-speak.md | 8 + package.json | 1 + .../report.api.md | 154 ++++---- .../src/manager/plugin-manager.test.ts | 341 ++++++++++++++++-- .../src/manager/plugin-manager.ts | 135 ++++--- .../src/manager/types.ts | 11 +- yarn.lock | 1 + 7 files changed, 498 insertions(+), 153 deletions(-) create mode 100644 .changeset/kind-avocados-speak.md diff --git a/.changeset/kind-avocados-speak.md b/.changeset/kind-avocados-speak.md new file mode 100644 index 0000000000..b57d271907 --- /dev/null +++ b/.changeset/kind-avocados-speak.md @@ -0,0 +1,8 @@ +--- +'@backstage/backend-dynamic-feature-service': patch +--- + +Enhance the API of the `DynamicPluginProvider` (available as a service) to: + +- expose the new `getScannedPackage()` method that returns the `ScannedPluginPackage` from which a given plugin has been loaded, +- add an optional `includeFailed` argument in the plugins list retrieval methods, to include the plugins that could be successfully loaded (`false` by default). diff --git a/package.json b/package.json index 425b1c1a38..4826bcd56b 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "jest-haste-map@^29.7.0": "patch:jest-haste-map@npm%3A29.7.0#./.yarn/patches/jest-haste-map-npm-29.7.0-e3be419eff.patch" }, "dependencies": { + "@backstage/cli-node": "workspace:^", "@backstage/errors": "workspace:^", "@manypkg/get-packages": "^1.1.3", "@types/global-agent": "^2.1.3", diff --git a/packages/backend-dynamic-feature-service/report.api.md b/packages/backend-dynamic-feature-service/report.api.md index 9170fedf43..615b053869 100644 --- a/packages/backend-dynamic-feature-service/report.api.md +++ b/packages/backend-dynamic-feature-service/report.api.md @@ -33,11 +33,12 @@ import { ServiceRef } from '@backstage/backend-plugin-api'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { TokenManager } from '@backstage/backend-common'; import { UrlReaderService } from '@backstage/backend-plugin-api'; +import { WinstonLoggerOptions } from '@backstage/backend-defaults/rootLogger'; // @public (undocumented) export interface BackendDynamicPlugin extends BaseDynamicPlugin { // (undocumented) - installer: BackendDynamicPluginInstaller; + installer?: BackendDynamicPluginInstaller; // (undocumented) platform: 'node'; } @@ -50,11 +51,13 @@ export type BackendDynamicPluginInstaller = // @public (undocumented) export interface BackendPluginProvider { // (undocumented) - backendPlugins(): BackendDynamicPlugin[]; + backendPlugins(includeFailed?: boolean): BackendDynamicPlugin[]; } // @public (undocumented) export interface BaseDynamicPlugin { + // (undocumented) + failure?: string; // (undocumented) name: string; // (undocumented) @@ -75,15 +78,17 @@ export class DynamicPluginManager implements DynamicPluginProvider { // (undocumented) get availablePackages(): ScannedPluginPackage[]; // (undocumented) - backendPlugins(): BackendDynamicPlugin[]; + backendPlugins(includeFailed?: boolean): BackendDynamicPlugin[]; // (undocumented) static create( options: DynamicPluginManagerOptions, ): Promise; // (undocumented) - frontendPlugins(): FrontendDynamicPlugin[]; + frontendPlugins(includeFailed?: boolean): FrontendDynamicPlugin[]; // (undocumented) - plugins(): DynamicPlugin[]; + getScannedPackage(plugin: DynamicPlugin): ScannedPluginPackage; + // (undocumented) + plugins(includeFailed?: boolean): DynamicPlugin[]; } // @public (undocumented) @@ -103,20 +108,19 @@ export interface DynamicPluginProvider extends FrontendPluginProvider, BackendPluginProvider { // (undocumented) - plugins(): DynamicPlugin[]; + getScannedPackage(plugin: DynamicPlugin): ScannedPluginPackage; + // (undocumented) + plugins(includeFailed?: boolean): DynamicPlugin[]; } // @public (undocumented) export interface DynamicPluginsFactoryOptions { // (undocumented) - moduleLoader?(logger: LoggerService): ModuleLoader; + moduleLoader?(logger: LoggerService): ModuleLoader | Promise; } -// @public -export const dynamicPluginsFeatureDiscoveryLoader: (( - options?: DynamicPluginsFactoryOptions, -) => BackendFeature) & - BackendFeature; +// @public @deprecated (undocumented) +export const dynamicPluginsFeatureDiscoveryLoader: BackendFeature; // @public @deprecated (undocumented) export const dynamicPluginsFeatureDiscoveryServiceFactory: ServiceFactory< @@ -125,16 +129,32 @@ export const dynamicPluginsFeatureDiscoveryServiceFactory: ServiceFactory< 'singleton' >; +// @public +export const dynamicPluginsFeatureLoader: (( + options?: DynamicPluginsFeatureLoaderOptions, +) => BackendFeature) & + BackendFeature; + // @public (undocumented) +export type DynamicPluginsFeatureLoaderOptions = DynamicPluginsFactoryOptions & + DynamicPluginsSchemasOptions & + DynamicPluginsRootLoggerFactoryOptions; + +// @public @deprecated (undocumented) export const dynamicPluginsFrontendSchemas: BackendFeature; // @public (undocumented) -export const dynamicPluginsRootLoggerServiceFactory: ServiceFactory< - RootLoggerService, - 'root', - 'singleton' +export type DynamicPluginsRootLoggerFactoryOptions = Omit< + WinstonLoggerOptions, + 'meta' >; +// @public @deprecated (undocumented) +export const dynamicPluginsRootLoggerServiceFactory: (( + options?: DynamicPluginsRootLoggerFactoryOptions, +) => ServiceFactory) & + ServiceFactory; + // @public (undocumented) export interface DynamicPluginsSchemasOptions { schemaLocator?: (pluginPackage: ScannedPluginPackage) => string; @@ -148,31 +168,24 @@ export interface DynamicPluginsSchemasService { }>; } -// @public (undocumented) -export const dynamicPluginsSchemasServiceFactory: ServiceFactory< - DynamicPluginsSchemasService, - 'root', - 'singleton' ->; - -// @public (undocumented) -export const dynamicPluginsSchemasServiceFactoryWithOptions: ( +// @public @deprecated (undocumented) +export const dynamicPluginsSchemasServiceFactory: (( options?: DynamicPluginsSchemasOptions, -) => ServiceFactory; +) => ServiceFactory) & + ServiceFactory; // @public @deprecated (undocumented) -export const dynamicPluginsServiceFactory: ServiceFactory< - DynamicPluginProvider, - 'root', - 'singleton' ->; +export const dynamicPluginsServiceFactory: (( + options?: DynamicPluginsFactoryOptions, +) => ServiceFactory) & + ServiceFactory; // @public @deprecated (undocumented) export const dynamicPluginsServiceFactoryWithOptions: ( options?: DynamicPluginsFactoryOptions, ) => ServiceFactory; -// @public @deprecated (undocumented) +// @public (undocumented) export const dynamicPluginsServiceRef: ServiceRef< DynamicPluginProvider, 'root', @@ -188,7 +201,7 @@ export interface FrontendDynamicPlugin extends BaseDynamicPlugin { // @public (undocumented) export interface FrontendPluginProvider { // (undocumented) - frontendPlugins(): FrontendDynamicPlugin[]; + frontendPlugins(includeFailed?: boolean): FrontendDynamicPlugin[]; } // @public (undocumented) @@ -278,6 +291,7 @@ export interface ScannedPluginPackage { // Warnings were encountered during analysis: // +// src/features/features.d.ts:7:1 - (ae-undocumented) Missing documentation for "DynamicPluginsFeatureLoaderOptions". // src/loader/types.d.ts:4:1 - (ae-undocumented) Missing documentation for "ModuleLoader". // src/loader/types.d.ts:5:5 - (ae-undocumented) Missing documentation for "bootstrap". // src/loader/types.d.ts:6:5 - (ae-undocumented) Missing documentation for "load". @@ -293,54 +307,58 @@ export interface ScannedPluginPackage { // src/manager/plugin-manager.d.ts:31:5 - (ae-undocumented) Missing documentation for "backendPlugins". // src/manager/plugin-manager.d.ts:32:5 - (ae-undocumented) Missing documentation for "frontendPlugins". // src/manager/plugin-manager.d.ts:33:5 - (ae-undocumented) Missing documentation for "plugins". +// src/manager/plugin-manager.d.ts:34:5 - (ae-undocumented) Missing documentation for "getScannedPackage". // src/manager/plugin-manager.d.ts:39:22 - (ae-undocumented) Missing documentation for "dynamicPluginsServiceRef". // src/manager/plugin-manager.d.ts:43:1 - (ae-undocumented) Missing documentation for "DynamicPluginsFactoryOptions". // src/manager/plugin-manager.d.ts:44:5 - (ae-undocumented) Missing documentation for "moduleLoader". // src/manager/plugin-manager.d.ts:50:22 - (ae-undocumented) Missing documentation for "dynamicPluginsServiceFactoryWithOptions". // src/manager/plugin-manager.d.ts:55:22 - (ae-undocumented) Missing documentation for "dynamicPluginsServiceFactory". // src/manager/plugin-manager.d.ts:60:22 - (ae-undocumented) Missing documentation for "dynamicPluginsFeatureDiscoveryServiceFactory". -// src/manager/types.d.ts:27:1 - (ae-undocumented) Missing documentation for "LegacyPluginEnvironment". -// src/manager/types.d.ts:45:1 - (ae-undocumented) Missing documentation for "DynamicPluginProvider". -// src/manager/types.d.ts:46:5 - (ae-undocumented) Missing documentation for "plugins". -// src/manager/types.d.ts:51:1 - (ae-undocumented) Missing documentation for "BackendPluginProvider". -// src/manager/types.d.ts:52:5 - (ae-undocumented) Missing documentation for "backendPlugins". -// src/manager/types.d.ts:57:1 - (ae-undocumented) Missing documentation for "FrontendPluginProvider". -// src/manager/types.d.ts:58:5 - (ae-undocumented) Missing documentation for "frontendPlugins". -// src/manager/types.d.ts:63:1 - (ae-undocumented) Missing documentation for "BaseDynamicPlugin". -// src/manager/types.d.ts:64:5 - (ae-undocumented) Missing documentation for "name". -// src/manager/types.d.ts:65:5 - (ae-undocumented) Missing documentation for "version". -// src/manager/types.d.ts:66:5 - (ae-undocumented) Missing documentation for "role". -// src/manager/types.d.ts:67:5 - (ae-undocumented) Missing documentation for "platform". -// src/manager/types.d.ts:72:1 - (ae-undocumented) Missing documentation for "DynamicPlugin". -// src/manager/types.d.ts:76:1 - (ae-undocumented) Missing documentation for "FrontendDynamicPlugin". -// src/manager/types.d.ts:77:5 - (ae-undocumented) Missing documentation for "platform". -// src/manager/types.d.ts:82:1 - (ae-undocumented) Missing documentation for "BackendDynamicPlugin". -// src/manager/types.d.ts:83:5 - (ae-undocumented) Missing documentation for "platform". -// src/manager/types.d.ts:84:5 - (ae-undocumented) Missing documentation for "installer". -// src/manager/types.d.ts:89:1 - (ae-undocumented) Missing documentation for "BackendDynamicPluginInstaller". -// src/manager/types.d.ts:93:1 - (ae-undocumented) Missing documentation for "NewBackendPluginInstaller". -// src/manager/types.d.ts:94:5 - (ae-undocumented) Missing documentation for "kind". -// src/manager/types.d.ts:95:5 - (ae-undocumented) Missing documentation for "install". -// src/manager/types.d.ts:108:1 - (ae-undocumented) Missing documentation for "LegacyBackendPluginInstaller". -// src/manager/types.d.ts:109:5 - (ae-undocumented) Missing documentation for "kind". -// src/manager/types.d.ts:110:5 - (ae-undocumented) Missing documentation for "router". -// src/manager/types.d.ts:114:5 - (ae-undocumented) Missing documentation for "catalog". -// src/manager/types.d.ts:115:5 - (ae-undocumented) Missing documentation for "scaffolder". -// src/manager/types.d.ts:116:5 - (ae-undocumented) Missing documentation for "search". -// src/manager/types.d.ts:117:5 - (ae-undocumented) Missing documentation for "events". -// src/manager/types.d.ts:118:5 - (ae-undocumented) Missing documentation for "permissions". -// src/manager/types.d.ts:125:1 - (ae-undocumented) Missing documentation for "isBackendDynamicPluginInstaller". +// src/manager/plugin-manager.d.ts:65:22 - (ae-undocumented) Missing documentation for "dynamicPluginsFeatureDiscoveryLoader". +// src/manager/types.d.ts:28:1 - (ae-undocumented) Missing documentation for "LegacyPluginEnvironment". +// src/manager/types.d.ts:46:1 - (ae-undocumented) Missing documentation for "DynamicPluginProvider". +// src/manager/types.d.ts:47:5 - (ae-undocumented) Missing documentation for "plugins". +// src/manager/types.d.ts:48:5 - (ae-undocumented) Missing documentation for "getScannedPackage". +// src/manager/types.d.ts:53:1 - (ae-undocumented) Missing documentation for "BackendPluginProvider". +// src/manager/types.d.ts:54:5 - (ae-undocumented) Missing documentation for "backendPlugins". +// src/manager/types.d.ts:59:1 - (ae-undocumented) Missing documentation for "FrontendPluginProvider". +// src/manager/types.d.ts:60:5 - (ae-undocumented) Missing documentation for "frontendPlugins". +// src/manager/types.d.ts:65:1 - (ae-undocumented) Missing documentation for "BaseDynamicPlugin". +// src/manager/types.d.ts:66:5 - (ae-undocumented) Missing documentation for "name". +// src/manager/types.d.ts:67:5 - (ae-undocumented) Missing documentation for "version". +// src/manager/types.d.ts:68:5 - (ae-undocumented) Missing documentation for "role". +// src/manager/types.d.ts:69:5 - (ae-undocumented) Missing documentation for "platform". +// src/manager/types.d.ts:70:5 - (ae-undocumented) Missing documentation for "failure". +// src/manager/types.d.ts:75:1 - (ae-undocumented) Missing documentation for "DynamicPlugin". +// src/manager/types.d.ts:79:1 - (ae-undocumented) Missing documentation for "FrontendDynamicPlugin". +// src/manager/types.d.ts:80:5 - (ae-undocumented) Missing documentation for "platform". +// src/manager/types.d.ts:85:1 - (ae-undocumented) Missing documentation for "BackendDynamicPlugin". +// src/manager/types.d.ts:86:5 - (ae-undocumented) Missing documentation for "platform". +// src/manager/types.d.ts:87:5 - (ae-undocumented) Missing documentation for "installer". +// src/manager/types.d.ts:92:1 - (ae-undocumented) Missing documentation for "BackendDynamicPluginInstaller". +// src/manager/types.d.ts:96:1 - (ae-undocumented) Missing documentation for "NewBackendPluginInstaller". +// src/manager/types.d.ts:97:5 - (ae-undocumented) Missing documentation for "kind". +// src/manager/types.d.ts:98:5 - (ae-undocumented) Missing documentation for "install". +// src/manager/types.d.ts:111:1 - (ae-undocumented) Missing documentation for "LegacyBackendPluginInstaller". +// src/manager/types.d.ts:112:5 - (ae-undocumented) Missing documentation for "kind". +// src/manager/types.d.ts:113:5 - (ae-undocumented) Missing documentation for "router". +// src/manager/types.d.ts:117:5 - (ae-undocumented) Missing documentation for "catalog". +// src/manager/types.d.ts:118:5 - (ae-undocumented) Missing documentation for "scaffolder". +// src/manager/types.d.ts:119:5 - (ae-undocumented) Missing documentation for "search". +// src/manager/types.d.ts:120:5 - (ae-undocumented) Missing documentation for "events". +// src/manager/types.d.ts:121:5 - (ae-undocumented) Missing documentation for "permissions". +// src/manager/types.d.ts:128:1 - (ae-undocumented) Missing documentation for "isBackendDynamicPluginInstaller". // src/scanner/types.d.ts:5:1 - (ae-undocumented) Missing documentation for "ScannedPluginPackage". // src/scanner/types.d.ts:6:5 - (ae-undocumented) Missing documentation for "location". // src/scanner/types.d.ts:7:5 - (ae-undocumented) Missing documentation for "manifest". // src/scanner/types.d.ts:12:1 - (ae-undocumented) Missing documentation for "ScannedPluginManifest". -// src/schemas/appBackendModule.d.ts:2:22 - (ae-undocumented) Missing documentation for "dynamicPluginsFrontendSchemas". -// src/schemas/rootLoggerServiceFactory.d.ts:2:22 - (ae-undocumented) Missing documentation for "dynamicPluginsRootLoggerServiceFactory". +// src/schemas/frontend.d.ts:5:22 - (ae-undocumented) Missing documentation for "dynamicPluginsFrontendSchemas". +// src/schemas/rootLogger.d.ts:5:1 - (ae-undocumented) Missing documentation for "DynamicPluginsRootLoggerFactoryOptions". +// src/schemas/rootLogger.d.ts:10:22 - (ae-undocumented) Missing documentation for "dynamicPluginsRootLoggerServiceFactory". // src/schemas/schemas.d.ts:7:1 - (ae-undocumented) Missing documentation for "DynamicPluginsSchemasService". // src/schemas/schemas.d.ts:8:5 - (ae-undocumented) Missing documentation for "addDynamicPluginsSchemas". // src/schemas/schemas.d.ts:21:1 - (ae-undocumented) Missing documentation for "DynamicPluginsSchemasOptions". -// src/schemas/schemas.d.ts:36:22 - (ae-undocumented) Missing documentation for "dynamicPluginsSchemasServiceFactoryWithOptions". -// src/schemas/schemas.d.ts:40:22 - (ae-undocumented) Missing documentation for "dynamicPluginsSchemasServiceFactory". +// src/schemas/schemas.d.ts:37:22 - (ae-undocumented) Missing documentation for "dynamicPluginsSchemasServiceFactory". // (No @packageDocumentation comment for this package) ``` diff --git a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.test.ts b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.test.ts index 5c01a3dfe5..6c129c07e2 100644 --- a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.test.ts +++ b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.test.ts @@ -44,6 +44,7 @@ import { PluginScanner } from '../scanner/plugin-scanner'; import { findPaths } from '@backstage/cli-common'; import { createMockDirectory } from '@backstage/backend-test-utils'; import { rootLifecycleServiceFactory } from '@backstage/backend-defaults/rootLifecycle'; +import { PackageRole } from '@backstage/cli-node'; describe('backend-dynamic-feature-service', () => { const mockDir = createMockDirectory(); @@ -299,6 +300,29 @@ describe('backend-dynamic-feature-service', () => { ); }, }, + { + name: 'should ignore plugin package with incompatible role', + packageManifest: { + name: 'backend-dynamic-plugin-test', + version: '0.0.0', + backstage: { + role: 'node-library', + }, + main: 'dist/index.cjs.js', + }, + expectedLogs(location) { + return { + infos: [ + { + message: `skipping dynamic plugin package 'backend-dynamic-plugin-test' from '${location}': incompatible role 'node-library'`, + }, + ], + }; + }, + checkLoadedPlugins(plugins) { + expect(plugins).toMatchObject([]); + }, + }, { name: 'should fail when no index file', packageManifest: { @@ -342,7 +366,17 @@ describe('backend-dynamic-feature-service', () => { }; }, checkLoadedPlugins(plugins) { - expect(plugins).toMatchObject([]); + expect(plugins).toMatchObject([ + { + name: 'backend-dynamic-plugin-test', + version: '0.0.0', + role: 'backend-plugin', + platform: 'node', + failure: expect.stringMatching( + `^Error: Cannot find module '[^']*' from .*`, + ), + }, + ]); }, }, { @@ -369,7 +403,15 @@ describe('backend-dynamic-feature-service', () => { }; }, checkLoadedPlugins(plugins) { - expect(plugins).toMatchObject([]); + expect(plugins).toMatchObject([ + { + name: 'backend-dynamic-plugin-test', + version: '0.0.0', + role: 'backend-plugin', + platform: 'node', + failure: `the module should either export a 'BackendFeature' or 'BackendFeatureFactory' as default export, or export a 'const dynamicPluginInstaller: BackendDynamicPluginInstaller' field as dynamic loading entrypoint.`, + }, + ]); }, }, { @@ -397,7 +439,15 @@ describe('backend-dynamic-feature-service', () => { }; }, checkLoadedPlugins(plugins) { - expect(plugins).toMatchObject([]); + expect(plugins).toMatchObject([ + { + name: 'backend-dynamic-plugin-test', + version: '0.0.0', + role: 'backend-plugin', + platform: 'node', + failure: `the module should either export a 'BackendFeature' or 'BackendFeatureFactory' as default export, or export a 'const dynamicPluginInstaller: BackendDynamicPluginInstaller' field as dynamic loading entrypoint.`, + }, + ]); }, }, { @@ -428,7 +478,17 @@ describe('backend-dynamic-feature-service', () => { }; }, checkLoadedPlugins(plugins) { - expect(plugins).toMatchObject([]); + expect(plugins).toMatchObject([ + { + name: 'backend-dynamic-plugin-test', + version: '0.0.0', + role: 'backend-plugin', + platform: 'node', + failure: expect.stringMatching( + `^SyntaxError: Unexpected identifier.*`, + ), + }, + ]); }, }, { @@ -495,6 +555,27 @@ describe('backend-dynamic-feature-service', () => { ]); }, }, + { + name: 'should successfully load a frontend plugin (experimental dynamic container)', + packageManifest: { + name: 'frontend-dynamic-plugin-test', + version: '0.0.0', + backstage: { + role: 'frontend-dynamic-container' as PackageRole, + }, + main: 'dist/index.esm.js', + }, + checkLoadedPlugins(plugins) { + expect(plugins).toMatchObject([ + { + name: 'frontend-dynamic-plugin-test', + version: '0.0.0', + role: 'frontend-dynamic-container', + platform: 'web', + }, + ]); + }, + }, ])('$name', async (tc: TestCase): Promise => { const plugin: ScannedPluginPackage = { location: url.pathToFileURL(mockDir.resolve(randomUUID())), @@ -538,33 +619,54 @@ describe('backend-dynamic-feature-service', () => { }); }); - describe('backendPlugins', () => { + describe('plugin getters', () => { + const plugins: BaseDynamicPlugin[] = [ + { + name: 'a-frontend-plugin', + platform: 'web', + role: 'frontend-plugin', + version: '0.0.0', + }, + { + name: 'a-frontend-module', + platform: 'web', + role: 'frontend-plugin-module', + version: '0.0.0', + }, + { + name: 'a-failing-frontend-plugin', + platform: 'web', + role: 'frontend-plugin', + version: '0.0.0', + failure: 'Some frontend failure', + }, + { + name: 'a-backend-plugin', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + }, + { + name: 'a-backend-module', + platform: 'node', + role: 'backend-plugin-module', + version: '0.0.0', + }, + { + name: 'a-failing-backend-plugin', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + failure: 'Some backend failure', + }, + ]; + it('should return only backend plugins and modules', async () => { const logger = new MockedLogger(); const pluginManager = new (DynamicPluginManager as any)( logger, [], ) as DynamicPluginManager; - const plugins: BaseDynamicPlugin[] = [ - { - name: 'a-frontend-plugin', - platform: 'web', - role: 'frontend-plugin', - version: '0.0.0', - }, - { - name: 'a-backend-plugin', - platform: 'node', - role: 'backend-plugin', - version: '0.0.0', - }, - { - name: 'a-backend-module', - platform: 'node', - role: 'backend-plugin-module', - version: '0.0.0', - }, - ]; (pluginManager as any)._plugins = plugins; expect(pluginManager.backendPlugins()).toEqual([ { @@ -580,17 +682,109 @@ describe('backend-dynamic-feature-service', () => { version: '0.0.0', }, ]); + expect(pluginManager.backendPlugins(false)).toEqual([ + { + name: 'a-backend-plugin', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + }, + { + name: 'a-backend-module', + platform: 'node', + role: 'backend-plugin-module', + version: '0.0.0', + }, + ]); + expect(pluginManager.backendPlugins(true)).toEqual([ + { + name: 'a-backend-plugin', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + }, + { + name: 'a-backend-module', + platform: 'node', + role: 'backend-plugin-module', + version: '0.0.0', + }, + { + name: 'a-failing-backend-plugin', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + failure: 'Some backend failure', + }, + ]); }); - }); - describe('frontendPlugins', () => { it('should return only frontend plugins', async () => { const logger = new MockedLogger(); const pluginManager = new (DynamicPluginManager as any)( logger, [], ) as DynamicPluginManager; - const plugins: BaseDynamicPlugin[] = [ + (pluginManager as any)._plugins = plugins; + expect(pluginManager.frontendPlugins()).toEqual([ + { + name: 'a-frontend-plugin', + platform: 'web', + role: 'frontend-plugin', + version: '0.0.0', + }, + { + name: 'a-frontend-module', + platform: 'web', + role: 'frontend-plugin-module', + version: '0.0.0', + }, + ]); + expect(pluginManager.frontendPlugins(false)).toEqual([ + { + name: 'a-frontend-plugin', + platform: 'web', + role: 'frontend-plugin', + version: '0.0.0', + }, + { + name: 'a-frontend-module', + platform: 'web', + role: 'frontend-plugin-module', + version: '0.0.0', + }, + ]); + expect(pluginManager.frontendPlugins(true)).toEqual([ + { + name: 'a-frontend-plugin', + platform: 'web', + role: 'frontend-plugin', + version: '0.0.0', + }, + { + name: 'a-frontend-module', + platform: 'web', + role: 'frontend-plugin-module', + version: '0.0.0', + }, + { + name: 'a-failing-frontend-plugin', + platform: 'web', + role: 'frontend-plugin', + version: '0.0.0', + failure: 'Some frontend failure', + }, + ]); + }); + + it('should return all plugins', async () => { + const logger = new MockedLogger(); + const pluginManager = new (DynamicPluginManager as any)( + logger, + [], + ) as DynamicPluginManager; + (pluginManager as any)._plugins = plugins; + expect(pluginManager.plugins()).toEqual([ { name: 'a-frontend-plugin', platform: 'web', @@ -615,9 +809,8 @@ describe('backend-dynamic-feature-service', () => { role: 'backend-plugin-module', version: '0.0.0', }, - ]; - (pluginManager as any)._plugins = plugins; - expect(pluginManager.frontendPlugins()).toEqual([ + ]); + expect(pluginManager.plugins(false)).toEqual([ { name: 'a-frontend-plugin', platform: 'web', @@ -630,7 +823,93 @@ describe('backend-dynamic-feature-service', () => { role: 'frontend-plugin-module', version: '0.0.0', }, + { + name: 'a-backend-plugin', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + }, + { + name: 'a-backend-module', + platform: 'node', + role: 'backend-plugin-module', + version: '0.0.0', + }, ]); + expect(pluginManager.plugins(true)).toEqual([ + { + name: 'a-frontend-plugin', + platform: 'web', + role: 'frontend-plugin', + version: '0.0.0', + }, + { + name: 'a-frontend-module', + platform: 'web', + role: 'frontend-plugin-module', + version: '0.0.0', + }, + { + name: 'a-failing-frontend-plugin', + platform: 'web', + role: 'frontend-plugin', + version: '0.0.0', + failure: 'Some frontend failure', + }, + { + name: 'a-backend-plugin', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + }, + { + name: 'a-backend-module', + platform: 'node', + role: 'backend-plugin-module', + version: '0.0.0', + }, + { + name: 'a-failing-backend-plugin', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + failure: 'Some backend failure', + }, + ]); + }); + }); + + describe('get scanned package', () => { + it('should return the scanned package of the plugin', async () => { + const logger = new MockedLogger(); + const packageFolder = mockDir.resolve(randomUUID()); + const scannedPackage = { + manifest: { + name: 'backend-dynamic-plugin-test', + version: '0.0.0', + backstage: { + role: 'backend-plugin', + }, + main: 'dist/index.cjs.js', + }, + location: url.pathToFileURL(packageFolder), + }; + const plugin = { + name: 'backend-dynamic-plugin-test', + version: '0.0.0', + role: 'backend-plugin', + platform: 'node', + installer: { + kind: 'new', + }, + } as BackendDynamicPlugin; + + const pluginManager = new (DynamicPluginManager as any)(logger, [ + scannedPackage, + ]) as DynamicPluginManager; + (pluginManager as any)._plugins = [plugin]; + + expect(pluginManager.getScannedPackage(plugin)).toEqual(scannedPackage); }); }); diff --git a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts index fef0b6000d..5b88c0619b 100644 --- a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts +++ b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts @@ -34,7 +34,7 @@ import { createServiceFactory, createServiceRef, } from '@backstage/backend-plugin-api'; -import { PackageRoles } from '@backstage/cli-node'; +import { PackageRole, PackageRoles } from '@backstage/cli-node'; import { findPaths } from '@backstage/cli-common'; import path from 'path'; import * as fs from 'fs'; @@ -104,7 +104,7 @@ export class DynamicPluginManager implements DynamicPluginProvider { private constructor( private readonly logger: LoggerService, - private packages: ScannedPluginPackage[], + private readonly packages: ScannedPluginPackage[], private readonly moduleLoader: ModuleLoader, ) { this._plugins = []; @@ -123,26 +123,39 @@ export class DynamicPluginManager implements DynamicPluginProvider { const loadedPlugins: DynamicPlugin[] = []; for (const scannedPlugin of this.packages) { - const platform = PackageRoles.getRoleInfo( - scannedPlugin.manifest.backstage.role, - ).platform; + const role = scannedPlugin.manifest.backstage.role; + const platform = PackageRoles.getRoleInfo(role).platform; + const isPlugin = + role.endsWith('-plugin') || + role.endsWith('-plugin-module') || + role === ('frontend-dynamic-container' as PackageRole); - if ( - platform === 'node' && - scannedPlugin.manifest.backstage.role.includes('-plugin') - ) { - const plugin = await this.loadBackendPlugin(scannedPlugin); - if (plugin !== undefined) { - loadedPlugins.push(plugin); - } - } else { - loadedPlugins.push({ - name: scannedPlugin.manifest.name, - version: scannedPlugin.manifest.version, - role: scannedPlugin.manifest.backstage.role, - platform: 'web', - // TODO(davidfestal): add required front-end plugin information here. - }); + if (!isPlugin) { + this.logger.info( + `skipping dynamic plugin package '${scannedPlugin.manifest.name}' from '${scannedPlugin.location}': incompatible role '${role}'`, + ); + continue; + } + + switch (platform) { + case 'node': + loadedPlugins.push(await this.loadBackendPlugin(scannedPlugin)); + break; + + case 'web': + loadedPlugins.push({ + name: scannedPlugin.manifest.name, + version: scannedPlugin.manifest.version, + role: scannedPlugin.manifest.backstage.role, + platform: 'web', + // TODO(davidfestal): add required front-end plugin information here. + }); + break; + + default: + this.logger.info( + `skipping dynamic plugin package '${scannedPlugin.manifest.name}' from '${scannedPlugin.location}': unrelated platform '${platform}'`, + ); } } return loadedPlugins; @@ -150,66 +163,88 @@ export class DynamicPluginManager implements DynamicPluginProvider { private async loadBackendPlugin( plugin: ScannedPluginPackage, - ): Promise { + ): Promise { const packagePath = url.fileURLToPath( `${plugin.location}/${plugin.manifest.main}`, ); + const dynamicPlugin: BackendDynamicPlugin = { + name: plugin.manifest.name, + version: plugin.manifest.version, + platform: 'node', + role: plugin.manifest.backstage.role, + }; + try { const pluginModule = await this.moduleLoader.load(packagePath); - let dynamicPluginInstaller; if (isBackendFeature(pluginModule.default)) { - dynamicPluginInstaller = { + dynamicPlugin.installer = { kind: 'new', install: () => pluginModule.default, }; } else if (isBackendFeatureFactory(pluginModule.default)) { - dynamicPluginInstaller = { + dynamicPlugin.installer = { kind: 'new', install: pluginModule.default, }; - } else { - dynamicPluginInstaller = pluginModule.dynamicPluginInstaller; + } else if ( + isBackendDynamicPluginInstaller(pluginModule.dynamicPluginInstaller) + ) { + dynamicPlugin.installer = pluginModule.dynamicPluginInstaller; } - if (!isBackendDynamicPluginInstaller(dynamicPluginInstaller)) { - this.logger.error( - `dynamic backend plugin '${plugin.manifest.name}' could not be loaded from '${plugin.location}': the module should either export a 'BackendFeature' or 'BackendFeatureFactory' as default export, or export a 'const dynamicPluginInstaller: BackendDynamicPluginInstaller' field as dynamic loading entrypoint.`, + if (dynamicPlugin.installer) { + this.logger.info( + `loaded dynamic backend plugin '${plugin.manifest.name}' from '${plugin.location}'`, + ); + } else { + dynamicPlugin.failure = `the module should either export a 'BackendFeature' or 'BackendFeatureFactory' as default export, or export a 'const dynamicPluginInstaller: BackendDynamicPluginInstaller' field as dynamic loading entrypoint.`; + this.logger.error( + `dynamic backend plugin '${plugin.manifest.name}' could not be loaded from '${plugin.location}': ${dynamicPlugin.failure}`, ); - return undefined; } - this.logger.info( - `loaded dynamic backend plugin '${plugin.manifest.name}' from '${plugin.location}'`, - ); - return { - name: plugin.manifest.name, - version: plugin.manifest.version, - platform: 'node', - role: plugin.manifest.backstage.role, - installer: dynamicPluginInstaller, - }; + return dynamicPlugin; } catch (error) { + const typedError = + typeof error === 'object' && 'message' in error && 'name' in error + ? error + : new Error(error); + dynamicPlugin.failure = `${typedError.name}: ${typedError.message}`; this.logger.error( `an error occurred while loading dynamic backend plugin '${plugin.manifest.name}' from '${plugin.location}'`, - error, + typedError, ); - return undefined; + return dynamicPlugin; } } - backendPlugins(): BackendDynamicPlugin[] { - return this._plugins.filter( + backendPlugins(includeFailed?: boolean): BackendDynamicPlugin[] { + return this.plugins(includeFailed).filter( (p): p is BackendDynamicPlugin => p.platform === 'node', ); } - frontendPlugins(): FrontendDynamicPlugin[] { - return this._plugins.filter( + frontendPlugins(includeFailed?: boolean): FrontendDynamicPlugin[] { + return this.plugins(includeFailed).filter( (p): p is FrontendDynamicPlugin => p.platform === 'web', ); } - plugins(): DynamicPlugin[] { - return this._plugins; + plugins(includeFailed?: boolean): DynamicPlugin[] { + return this._plugins.filter(p => includeFailed || !p.failure); + } + + getScannedPackage(plugin: DynamicPlugin): ScannedPluginPackage { + const pkg = this.packages.find( + p => + p.manifest.name === plugin.name && + p.manifest.version === plugin.version, + ); + if (pkg === undefined) { + throw new Error( + `The scanned package of a dynamic plugin should always be available: ${plugin.name}/${plugin.version}`, + ); + } + return pkg; } } @@ -279,7 +314,7 @@ class DynamicPluginsEnabledFeatureDiscoveryService ...this.dynamicPlugins .backendPlugins() .flatMap((plugin): BackendFeature[] => { - if (plugin.installer.kind === 'new') { + if (plugin.installer?.kind === 'new') { const installed = plugin.installer.install(); if (Array.isArray(installed)) { return installed; diff --git a/packages/backend-dynamic-feature-service/src/manager/types.ts b/packages/backend-dynamic-feature-service/src/manager/types.ts index a4f2937fd5..d3989b5ff9 100644 --- a/packages/backend-dynamic-feature-service/src/manager/types.ts +++ b/packages/backend-dynamic-feature-service/src/manager/types.ts @@ -43,6 +43,7 @@ import { TemplateAction } from '@backstage/plugin-scaffolder-node'; import { IndexBuilder } from '@backstage/plugin-search-backend-node'; import { EventsBackend } from '@backstage/plugin-events-backend'; import { PermissionPolicy } from '@backstage/plugin-permission-node'; +import { ScannedPluginPackage } from '../scanner'; /** * @public @@ -78,21 +79,22 @@ export type LegacyPluginEnvironment = { export interface DynamicPluginProvider extends FrontendPluginProvider, BackendPluginProvider { - plugins(): DynamicPlugin[]; + plugins(includeFailed?: boolean): DynamicPlugin[]; + getScannedPackage(plugin: DynamicPlugin): ScannedPluginPackage; } /** * @public */ export interface BackendPluginProvider { - backendPlugins(): BackendDynamicPlugin[]; + backendPlugins(includeFailed?: boolean): BackendDynamicPlugin[]; } /** * @public */ export interface FrontendPluginProvider { - frontendPlugins(): FrontendDynamicPlugin[]; + frontendPlugins(includeFailed?: boolean): FrontendDynamicPlugin[]; } /** @@ -103,6 +105,7 @@ export interface BaseDynamicPlugin { version: string; role: PackageRole; platform: PackagePlatform; + failure?: string; } /** @@ -122,7 +125,7 @@ export interface FrontendDynamicPlugin extends BaseDynamicPlugin { */ export interface BackendDynamicPlugin extends BaseDynamicPlugin { platform: 'node'; - installer: BackendDynamicPluginInstaller; + installer?: BackendDynamicPluginInstaller; } /** diff --git a/yarn.lock b/yarn.lock index f5184e9149..7285cc8a19 100644 --- a/yarn.lock +++ b/yarn.lock @@ -40001,6 +40001,7 @@ __metadata: resolution: "root@workspace:." dependencies: "@backstage/cli": "workspace:*" + "@backstage/cli-node": "workspace:^" "@backstage/codemods": "workspace:*" "@backstage/create-app": "workspace:*" "@backstage/e2e-test-utils": "workspace:*" From d18d4942f9159e9b57f21e1fbd007e7a416f03fd Mon Sep 17 00:00:00 2001 From: David Festal Date: Wed, 25 Sep 2024 15:40:10 +0200 Subject: [PATCH 090/291] refactor(backend-dynamic-feature-service): single line activation. - DynamicPlugins service is restored, since it is required for plugins to depend on it in order to get the details of loaded dynamic plugins - An all-in-one feature loader is provided that allows 1-liner installation of both the dynamic features and additional services or plugins required to have the dynamic plugins work correctly with dynamic plugins config schemas. Signed-off-by: David Festal --- .changeset/fluffy-dogs-mate.md | 8 ++ .../backend-dynamic-feature-service/README.md | 3 +- .../src/features/features.ts | 111 ++++++++++++++++++ .../src/features/index.ts | 17 +++ .../src/index.ts | 1 + .../src/manager/plugin-manager.ts | 82 ++++--------- .../src/scanner/plugin-scanner.ts | 4 +- .../{appBackendModule.ts => frontend.ts} | 5 +- .../src/schemas/index.ts | 8 +- .../src/schemas/rootLogger.ts | 86 ++++++++++++++ .../src/schemas/rootLoggerServiceFactory.ts | 63 ---------- .../src/schemas/schemas.ts | 13 +- 12 files changed, 263 insertions(+), 138 deletions(-) create mode 100644 .changeset/fluffy-dogs-mate.md create mode 100644 packages/backend-dynamic-feature-service/src/features/features.ts create mode 100644 packages/backend-dynamic-feature-service/src/features/index.ts rename packages/backend-dynamic-feature-service/src/schemas/{appBackendModule.ts => frontend.ts} (92%) create mode 100644 packages/backend-dynamic-feature-service/src/schemas/rootLogger.ts delete mode 100644 packages/backend-dynamic-feature-service/src/schemas/rootLoggerServiceFactory.ts diff --git a/.changeset/fluffy-dogs-mate.md b/.changeset/fluffy-dogs-mate.md new file mode 100644 index 0000000000..cb7a78d0fb --- /dev/null +++ b/.changeset/fluffy-dogs-mate.md @@ -0,0 +1,8 @@ +--- +'@backstage/backend-dynamic-feature-service': patch +--- + +Enhance and simplify the activation of the dynamic plugins feature: + +- The dynamic plugins service (which implements the `DynamicPluginsProvider`) is restored, since it is required for plugins to depend on it in order to get the details of loaded dynamic plugins (possibly with loading errors to be surfaced in some UI). +- A new all-in-one feature loader (`dynamicPluginsFeatureLoader`) is provided that allows a 1-liner activation of both the dynamic features and additional services or plugins required to have the dynamic plugins work correctly with dynamic plugins config schemas. Previous service factories or feature loaders are deprecated. diff --git a/packages/backend-dynamic-feature-service/README.md b/packages/backend-dynamic-feature-service/README.md index 33033cf884..f4ca11bb1f 100644 --- a/packages/backend-dynamic-feature-service/README.md +++ b/packages/backend-dynamic-feature-service/README.md @@ -15,8 +15,7 @@ In the `backend` application, it can be enabled by adding the `backend-dynamic-f ```ts const backend = createBackend(); + -+ backend.add(dynamicPluginsFeatureDiscoveryServiceFactory) // overridden version of the FeatureDiscoveryService which provides features loaded by dynamic plugins -+ backend.add(dynamicPluginsServiceFactory) ++ backend.add(dynamicPluginsFeatureLoader) which provides features loaded by dynamic plugins + ``` diff --git a/packages/backend-dynamic-feature-service/src/features/features.ts b/packages/backend-dynamic-feature-service/src/features/features.ts new file mode 100644 index 0000000000..4788f1da75 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/features.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2024 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 { + coreServices, + createBackendFeatureLoader, +} from '@backstage/backend-plugin-api'; +import { + DynamicPluginsSchemasOptions, + dynamicPluginsFrontendSchemas, + dynamicPluginsRootLoggerServiceFactory, + dynamicPluginsSchemasServiceFactory, +} from '../schemas'; +import { + DynamicPluginsFactoryOptions, + dynamicPluginsFeatureDiscoveryLoader, + dynamicPluginsServiceFactory, +} from '../manager'; +import { DynamicPluginsRootLoggerFactoryOptions } from '../schemas'; +import { configKey } from '../scanner/plugin-scanner'; + +/** + * @public + */ +export type DynamicPluginsFeatureLoaderOptions = DynamicPluginsFactoryOptions & + DynamicPluginsSchemasOptions & + DynamicPluginsRootLoggerFactoryOptions; + +const dynamicPluginsFeatureLoaderWithOptions = ( + options?: DynamicPluginsFeatureLoaderOptions, +) => + createBackendFeatureLoader({ + deps: { + config: coreServices.rootConfig, + }, + *loader({ config }) { + const dynamicPluginsEnabled = config.has(configKey); + + yield* [ + dynamicPluginsSchemasServiceFactory(options), + dynamicPluginsServiceFactory(options), + ]; + if (dynamicPluginsEnabled) { + yield* [ + dynamicPluginsRootLoggerServiceFactory(options), + dynamicPluginsFrontendSchemas, + dynamicPluginsFeatureDiscoveryLoader, + ]; + } + }, + }); + +/** + * A backend feature loader that fully enable backend dynamic plugins. + * More precisely it: + * - adds the dynamic plugins root service (typically depended upon by plugins), + * - adds additional required features to allow supporting dynamic plugins config schemas + * in the frontend application and the backend root logger, + * - uses the dynamic plugins service to discover and expose dynamic plugins as features. + * + * @public + * + * @example + * Using the `dynamicPluginsFeatureLoader` loader in a backend instance: + * ```ts + * //... + * import { createBackend } from '@backstage/backend-defaults'; + * import { dynamicPluginsFeatureLoader } from '@backstage/backend-dynamic-feature-service'; + * + * const backend = createBackend(); + * backend.add(dynamicPluginsFeatureLoader); + * //... + * backend.start(); + * ``` + * + * @example + * Passing options to the `dynamicPluginsFeatureLoader` loader in a backend instance: + * ```ts + * //... + * import { createBackend } from '@backstage/backend-defaults'; + * import { dynamicPluginsFeatureLoader } from '@backstage/backend-dynamic-feature-service'; + * import { myCustomModuleLoader } from './myCustomModuleLoader'; + * import { myCustomSchemaLocator } from './myCustomSchemaLocator'; + * + * const backend = createBackend(); + * backend.add(dynamicPluginsFeatureLoader({ + * moduleLoader: myCustomModuleLoader, + * schemaLocator: myCustomSchemaLocator, + * + * })); + * //... + * backend.start(); + * ``` + */ +export const dynamicPluginsFeatureLoader = Object.assign( + dynamicPluginsFeatureLoaderWithOptions, + dynamicPluginsFeatureLoaderWithOptions(), +); diff --git a/packages/backend-dynamic-feature-service/src/features/index.ts b/packages/backend-dynamic-feature-service/src/features/index.ts new file mode 100644 index 0000000000..c877359d42 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './features'; diff --git a/packages/backend-dynamic-feature-service/src/index.ts b/packages/backend-dynamic-feature-service/src/index.ts index 3768146124..abdbff677b 100644 --- a/packages/backend-dynamic-feature-service/src/index.ts +++ b/packages/backend-dynamic-feature-service/src/index.ts @@ -18,3 +18,4 @@ export * from './loader'; export * from './scanner'; export * from './manager'; export * from './schemas'; +export * from './features'; diff --git a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts index 5b88c0619b..87ff4334ed 100644 --- a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts +++ b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts @@ -250,7 +250,6 @@ export class DynamicPluginManager implements DynamicPluginProvider { /** * @public - * @deprecated The `featureDiscoveryService` is deprecated in favor of using {@link dynamicPluginsFeatureDiscoveryLoader} instead. */ export const dynamicPluginsServiceRef = createServiceRef( { @@ -268,7 +267,7 @@ export interface DynamicPluginsFactoryOptions { /** * @public - * @deprecated Use {@link dynamicPluginsFeatureDiscoveryLoader} instead. + * @deprecated Use {@link dynamicPluginsFeatureLoader} instead, which gathers all services and features required for dynamic plugins. */ export const dynamicPluginsServiceFactoryWithOptions = ( options?: DynamicPluginsFactoryOptions, @@ -291,10 +290,12 @@ export const dynamicPluginsServiceFactoryWithOptions = ( /** * @public - * @deprecated Use {@link dynamicPluginsFeatureDiscoveryLoader} instead. + * @deprecated Use {@link dynamicPluginsFeatureLoader} instead, which gathers all services and features required for dynamic plugins. */ -export const dynamicPluginsServiceFactory = - dynamicPluginsServiceFactoryWithOptions(); +export const dynamicPluginsServiceFactory = Object.assign( + dynamicPluginsServiceFactoryWithOptions, + dynamicPluginsServiceFactoryWithOptions(), +); class DynamicPluginsEnabledFeatureDiscoveryService implements FeatureDiscoveryService @@ -331,7 +332,7 @@ class DynamicPluginsEnabledFeatureDiscoveryService /** * @public - * @deprecated The `featureDiscoveryService` is deprecated in favor of using {@link dynamicPluginsFeatureDiscoveryLoader} instead. + * @deprecated Use {@link dynamicPluginsFeatureLoader} instead, which gathers all services and features required for dynamic plugins. */ export const dynamicPluginsFeatureDiscoveryServiceFactory = createServiceFactory({ @@ -345,65 +346,22 @@ export const dynamicPluginsFeatureDiscoveryServiceFactory = }, }); -const dynamicPluginsFeatureDiscoveryLoaderWithOptions = ( - options?: DynamicPluginsFactoryOptions, -) => - createBackendFeatureLoader({ - deps: { - config: coreServices.rootConfig, - logger: coreServices.rootLogger, - }, - async loader({ config, logger }) { - const manager = await DynamicPluginManager.create({ - config, - logger, - preferAlpha: true, - moduleLoader: options?.moduleLoader?.(logger), - }); - const service = new DynamicPluginsEnabledFeatureDiscoveryService(manager); - const { features } = await service.getBackendFeatures(); - return features; - }, - }); - /** - * A backend feature loader that uses the dynamic plugins system to discover features. - * * @public - * - * @example - * Using the `dynamicPluginsFeatureDiscoveryLoader` loader in a backend instance: - * ```ts - * //... - * import { createBackend } from '@backstage/backend-defaults'; - * import { dynamicPluginsFeatureDiscoveryLoader } from '@backstage/backend-dynamic-feature-service'; - * - * const backend = createBackend(); - * backend.add(dynamicPluginsFeatureDiscoveryLoader); - * //... - * backend.start(); - * ``` - * - * @example - * Passing options to the `dynamicPluginsFeatureDiscoveryLoader` loader in a backend instance: - * ```ts - * //... - * import { createBackend } from '@backstage/backend-defaults'; - * import { dynamicPluginsFeatureDiscoveryLoader } from '@backstage/backend-dynamic-feature-service'; - * import { myCustomModuleLoader } from './myCustomModuleLoader'; - * - * const backend = createBackend(); - * backend.add(dynamicPluginsFeatureDiscoveryLoader({ - * moduleLoader: myCustomModuleLoader - * })); - * //... - * backend.start(); - * ``` + * @deprecated Use {@link dynamicPluginsFeatureLoader} instead, which gathers all services and features required for dynamic plugins. */ -export const dynamicPluginsFeatureDiscoveryLoader = Object.assign( - dynamicPluginsFeatureDiscoveryLoaderWithOptions, - dynamicPluginsFeatureDiscoveryLoaderWithOptions(), -); +export const dynamicPluginsFeatureDiscoveryLoader = createBackendFeatureLoader({ + deps: { + dynamicPlugins: dynamicPluginsServiceRef, + }, + async loader({ dynamicPlugins }) { + const service = new DynamicPluginsEnabledFeatureDiscoveryService( + dynamicPlugins, + ); + const { features } = await service.getBackendFeatures(); + return features; + }, +}); function isBackendFeature(value: unknown): value is BackendFeature { return ( diff --git a/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts b/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts index 9229f5d78a..80ae2ed876 100644 --- a/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts +++ b/packages/backend-dynamic-feature-service/src/scanner/plugin-scanner.ts @@ -35,6 +35,8 @@ export interface ScanRootResponse { packages: ScannedPluginPackage[]; } +export const configKey = 'dynamicPlugins'; + export class PluginScanner { private _rootDirectory?: string; private configUnsubscribe?: () => void; @@ -68,7 +70,7 @@ export class PluginScanner { } private applyConfig(): void | never { - const dynamicPlugins = this.config.getOptional('dynamicPlugins'); + const dynamicPlugins = this.config.getOptional(configKey); if (!dynamicPlugins) { this.logger.info("'dynamicPlugins' config entry not found."); this._rootDirectory = undefined; diff --git a/packages/backend-dynamic-feature-service/src/schemas/appBackendModule.ts b/packages/backend-dynamic-feature-service/src/schemas/frontend.ts similarity index 92% rename from packages/backend-dynamic-feature-service/src/schemas/appBackendModule.ts rename to packages/backend-dynamic-feature-service/src/schemas/frontend.ts index 6e54379e90..306ecc9255 100644 --- a/packages/backend-dynamic-feature-service/src/schemas/appBackendModule.ts +++ b/packages/backend-dynamic-feature-service/src/schemas/frontend.ts @@ -25,7 +25,10 @@ import { loadCompiledConfigSchema, } from '@backstage/plugin-app-node'; -/** @public */ +/** + * @public + * @deprecated Use {@link dynamicPluginsFeatureLoader} instead, which gathers all services and features required for dynamic plugins. + */ export const dynamicPluginsFrontendSchemas = createBackendModule({ pluginId: 'app', moduleId: 'core.dynamicplugins.frontendSchemas', diff --git a/packages/backend-dynamic-feature-service/src/schemas/index.ts b/packages/backend-dynamic-feature-service/src/schemas/index.ts index 14c6734b6b..d67e77232b 100644 --- a/packages/backend-dynamic-feature-service/src/schemas/index.ts +++ b/packages/backend-dynamic-feature-service/src/schemas/index.ts @@ -16,10 +16,12 @@ export { dynamicPluginsSchemasServiceFactory, - dynamicPluginsSchemasServiceFactoryWithOptions, type DynamicPluginsSchemasService, type DynamicPluginsSchemasOptions, } from './schemas'; -export { dynamicPluginsFrontendSchemas } from './appBackendModule'; -export { dynamicPluginsRootLoggerServiceFactory } from './rootLoggerServiceFactory'; +export { dynamicPluginsFrontendSchemas } from './frontend'; +export { + dynamicPluginsRootLoggerServiceFactory, + type DynamicPluginsRootLoggerFactoryOptions, +} from './rootLogger'; diff --git a/packages/backend-dynamic-feature-service/src/schemas/rootLogger.ts b/packages/backend-dynamic-feature-service/src/schemas/rootLogger.ts new file mode 100644 index 0000000000..ac4bf3fa5e --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/schemas/rootLogger.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2024 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 { + createServiceFactory, + coreServices, +} from '@backstage/backend-plugin-api'; +import { + WinstonLogger, + WinstonLoggerOptions, +} from '@backstage/backend-defaults/rootLogger'; +import { createConfigSecretEnumerator } from '@backstage/backend-defaults/rootConfig'; +import { transports, format } from 'winston'; +import { loadConfigSchema } from '@backstage/config-loader'; +import { getPackages } from '@manypkg/get-packages'; +import { dynamicPluginsSchemasServiceRef } from './schemas'; + +/** + * @public + */ +export type DynamicPluginsRootLoggerFactoryOptions = Omit< + WinstonLoggerOptions, + 'meta' +>; + +const dynamicPluginsRootLoggerServiceFactoryWithOptions = ( + options?: DynamicPluginsRootLoggerFactoryOptions, +) => + createServiceFactory({ + service: coreServices.rootLogger, + deps: { + config: coreServices.rootConfig, + schemas: dynamicPluginsSchemasServiceRef, + }, + async factory({ config, schemas }) { + const logger = WinstonLogger.create({ + level: process.env.LOG_LEVEL || 'info', + format: + process.env.NODE_ENV === 'production' + ? format.json() + : WinstonLogger.colorFormat(), + transports: [new transports.Console()], + ...options, + meta: { + service: 'backstage', + }, + }); + + const configSchema = await loadConfigSchema({ + dependencies: ( + await getPackages(process.cwd()) + ).packages.map(p => p.packageJson.name), + }); + + const secretEnumerator = await createConfigSecretEnumerator({ + logger, + schema: (await schemas.addDynamicPluginsSchemas(configSchema)).schema, + }); + logger.addRedactions(secretEnumerator(config)); + config.subscribe?.(() => logger.addRedactions(secretEnumerator(config))); + + return logger; + }, + }); + +/** + * @public + * @deprecated Use {@link dynamicPluginsFeatureLoader} instead, which gathers all services and features required for dynamic plugins. + */ +export const dynamicPluginsRootLoggerServiceFactory = Object.assign( + dynamicPluginsRootLoggerServiceFactoryWithOptions, + dynamicPluginsRootLoggerServiceFactoryWithOptions(), +); diff --git a/packages/backend-dynamic-feature-service/src/schemas/rootLoggerServiceFactory.ts b/packages/backend-dynamic-feature-service/src/schemas/rootLoggerServiceFactory.ts deleted file mode 100644 index 00d5c85e6e..0000000000 --- a/packages/backend-dynamic-feature-service/src/schemas/rootLoggerServiceFactory.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2024 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 { - createServiceFactory, - coreServices, -} from '@backstage/backend-plugin-api'; -import { WinstonLogger } from '@backstage/backend-defaults/rootLogger'; -import { transports, format } from 'winston'; -import { createConfigSecretEnumerator } from '@backstage/backend-common'; -import { loadConfigSchema } from '@backstage/config-loader'; -import { getPackages } from '@manypkg/get-packages'; -import { dynamicPluginsSchemasServiceRef } from './schemas'; - -/** @public */ -export const dynamicPluginsRootLoggerServiceFactory = createServiceFactory({ - service: coreServices.rootLogger, - deps: { - config: coreServices.rootConfig, - schemas: dynamicPluginsSchemasServiceRef, - }, - async factory({ config, schemas }) { - const logger = WinstonLogger.create({ - meta: { - service: 'backstage', - }, - level: process.env.LOG_LEVEL || 'info', - format: - process.env.NODE_ENV === 'production' - ? format.json() - : WinstonLogger.colorFormat(), - transports: [new transports.Console()], - }); - - const configSchema = await loadConfigSchema({ - dependencies: ( - await getPackages(process.cwd()) - ).packages.map(p => p.packageJson.name), - }); - - const secretEnumerator = await createConfigSecretEnumerator({ - logger, - schema: (await schemas.addDynamicPluginsSchemas(configSchema)).schema, - }); - logger.addRedactions(secretEnumerator(config)); - config.subscribe?.(() => logger.addRedactions(secretEnumerator(config))); - - return logger; - }, -}); diff --git a/packages/backend-dynamic-feature-service/src/schemas/schemas.ts b/packages/backend-dynamic-feature-service/src/schemas/schemas.ts index 1cfb42a093..52fb5eab6d 100644 --- a/packages/backend-dynamic-feature-service/src/schemas/schemas.ts +++ b/packages/backend-dynamic-feature-service/src/schemas/schemas.ts @@ -30,6 +30,7 @@ import { LoggerService } from '@backstage/backend-plugin-api'; import { JsonObject } from '@backstage/types'; import { PluginScanner } from '../scanner/plugin-scanner'; import { ConfigSchema, loadConfigSchema } from '@backstage/config-loader'; +import { dynamicPluginsFeatureLoader } from '../features'; /** * @@ -68,10 +69,7 @@ export interface DynamicPluginsSchemasOptions { schemaLocator?: (pluginPackage: ScannedPluginPackage) => string; } -/** - * @public - */ -export const dynamicPluginsSchemasServiceFactoryWithOptions = ( +const dynamicPluginsSchemasServiceFactoryWithOptions = ( options?: DynamicPluginsSchemasOptions, ) => createServiceFactory({ @@ -143,9 +141,12 @@ export const dynamicPluginsSchemasServiceFactoryWithOptions = ( /** * @public + * @deprecated Use {@link dynamicPluginsFeatureLoader} instead, which gathers all services and features required for dynamic plugins. */ -export const dynamicPluginsSchemasServiceFactory = - dynamicPluginsSchemasServiceFactoryWithOptions(); +export const dynamicPluginsSchemasServiceFactory = Object.assign( + dynamicPluginsSchemasServiceFactoryWithOptions, + dynamicPluginsSchemasServiceFactoryWithOptions(), +); /** @internal */ async function gatherDynamicPluginsSchemas( From 4c89e4759d183aeb15964f30ae6cc7362681ea48 Mon Sep 17 00:00:00 2001 From: David Festal Date: Mon, 7 Oct 2024 13:33:44 +0200 Subject: [PATCH 091/291] refactor(backend-dynamic-feature-service): allow passing an async module loader in the `DynamicPluginsFeatureLoaderOptions`. Signed-off-by: David Festal --- .changeset/lemon-badgers-share.md | 5 +++++ .../src/loader/CommonJSModuleLoader.ts | 13 ++++++++----- .../src/manager/plugin-manager.ts | 6 +++--- 3 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 .changeset/lemon-badgers-share.md diff --git a/.changeset/lemon-badgers-share.md b/.changeset/lemon-badgers-share.md new file mode 100644 index 0000000000..c00b43c403 --- /dev/null +++ b/.changeset/lemon-badgers-share.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-dynamic-feature-service': patch +--- + +Allow passing an async module loader in the `DynamicPluginsFeatureLoaderOptions`. diff --git a/packages/backend-dynamic-feature-service/src/loader/CommonJSModuleLoader.ts b/packages/backend-dynamic-feature-service/src/loader/CommonJSModuleLoader.ts index 66af367cea..f9d4a48a44 100644 --- a/packages/backend-dynamic-feature-service/src/loader/CommonJSModuleLoader.ts +++ b/packages/backend-dynamic-feature-service/src/loader/CommonJSModuleLoader.ts @@ -18,7 +18,11 @@ import { LoggerService } from '@backstage/backend-plugin-api'; import path from 'path'; export class CommonJSModuleLoader implements ModuleLoader { - constructor(public readonly logger: LoggerService) {} + private module: any; + + constructor(public readonly logger: LoggerService) { + this.module = require('node:module'); + } async bootstrap( backstageRoot: string, @@ -28,9 +32,8 @@ export class CommonJSModuleLoader implements ModuleLoader { const dynamicNodeModulesPaths = [ ...dynamicPluginsPaths.map(p => path.resolve(p, 'node_modules')), ]; - const Module = require('module'); - const oldNodeModulePaths = Module._nodeModulePaths; - Module._nodeModulePaths = (from: string): string[] => { + const oldNodeModulePaths = this.module._nodeModulePaths; + this.module._nodeModulePaths = (from: string): string[] => { const result: string[] = oldNodeModulePaths(from); if (!dynamicPluginsPaths.some(p => from.startsWith(p))) { return result; @@ -49,6 +52,6 @@ export class CommonJSModuleLoader implements ModuleLoader { } async load(packagePath: string): Promise { - return await require(/* webpackIgnore: true */ packagePath); + return await this.module.prototype.require(packagePath); } } diff --git a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts index 87ff4334ed..a3b4bfbf98 100644 --- a/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts +++ b/packages/backend-dynamic-feature-service/src/manager/plugin-manager.ts @@ -88,7 +88,7 @@ export class DynamicPluginManager implements DynamicPluginProvider { ), ); - moduleLoader.bootstrap(backstageRoot, dynamicPluginsPaths); + await moduleLoader.bootstrap(backstageRoot, dynamicPluginsPaths); scanner.subscribeToRootDirectoryChange(async () => { manager._availablePackages = (await scanner.scanRoot()).packages; @@ -262,7 +262,7 @@ export const dynamicPluginsServiceRef = createServiceRef( * @public */ export interface DynamicPluginsFactoryOptions { - moduleLoader?(logger: LoggerService): ModuleLoader; + moduleLoader?(logger: LoggerService): ModuleLoader | Promise; } /** @@ -283,7 +283,7 @@ export const dynamicPluginsServiceFactoryWithOptions = ( config, logger, preferAlpha: true, - moduleLoader: options?.moduleLoader?.(logger), + moduleLoader: await options?.moduleLoader?.(logger), }); }, }); From bb5b95f61309834ac5e98a65fa673f6fa6198998 Mon Sep 17 00:00:00 2001 From: David Festal Date: Mon, 7 Oct 2024 13:35:36 +0200 Subject: [PATCH 092/291] refactor(backend-dynamic-feature-service): all-in-one feature integration tests Signed-off-by: David Festal --- .../package.json | 2 + .../src/features/__fixtures__/.gitignore | 2 + .../dist/configSchema.json | 18 ++ .../test-backend-dynamic/dist/index.cjs.js | 59 ++++ .../index.js | 7 + .../package.json | 7 + .../test-backend-dynamic/package.json | 35 ++ .../test-dynamic/dist/configSchema.json | 18 ++ .../test-dynamic/dist/mf-manifest.json | 29 ++ .../test-dynamic/dist/remoteEntry.js | 17 + .../test-dynamic/package.json | 20 ++ .../node_modules/.shouldNotBeUsed | 0 .../@backstage/backend-plugin-api/index.js | 1 + .../backend-plugin-api/package.json | 7 + .../src/features/features.test.ts | 306 ++++++++++++++++++ .../src/schemas/frontend.ts | 1 + yarn.lock | 2 + 17 files changed, 531 insertions(+) create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/.gitignore create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/configSchema.json create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/index.cjs.js create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/node_modules/private-dep-with-frontend-plugin-index-path/index.js create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/node_modules/private-dep-with-frontend-plugin-index-path/package.json create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/package.json create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/configSchema.json create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/mf-manifest.json create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/remoteEntry.js create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/package.json create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/node_modules/.shouldNotBeUsed create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/node_modules/@backstage/backend-plugin-api/index.js create mode 100644 packages/backend-dynamic-feature-service/src/features/__fixtures__/node_modules/@backstage/backend-plugin-api/package.json create mode 100644 packages/backend-dynamic-feature-service/src/features/features.test.ts diff --git a/packages/backend-dynamic-feature-service/package.json b/packages/backend-dynamic-feature-service/package.json index b1f6cf79e7..567f8b75ca 100644 --- a/packages/backend-dynamic-feature-service/package.json +++ b/packages/backend-dynamic-feature-service/package.json @@ -76,6 +76,8 @@ "devDependencies": { "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@backstage/plugin-app-backend": "workspace:^", + "triple-beam": "^1.4.1", "wait-for-expect": "^3.0.2" } } diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/.gitignore b/packages/backend-dynamic-feature-service/src/features/__fixtures__/.gitignore new file mode 100644 index 0000000000..8f91d44d47 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/.gitignore @@ -0,0 +1,2 @@ +!dist +!node_modules diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/configSchema.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/configSchema.json new file mode 100644 index 0000000000..445c79a413 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/configSchema.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "test-backend": { + "type": "object", + "required": [ + "secretValue" + ], + "properties": { + "secretValue": { + "type": "string", + "visibility": "secret" + } + } + } + } +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/index.cjs.js b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/index.cjs.js new file mode 100644 index 0000000000..6cc4a4f125 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/dist/index.cjs.js @@ -0,0 +1,59 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +const { dynamicPluginsServiceRef } = require('../../../../../manager'); +var backendPluginApi = require('@backstage/backend-plugin-api'); +const express = require('express'); +const path = require('path'); +const url = require('url'); + +const privateDep = require('private-dep-with-frontend-plugin-index-path'); + +const testPlugin = backendPluginApi.createBackendPlugin({ + pluginId: "test", + register(env) { + env.registerInit({ + deps: { + http: backendPluginApi.coreServices.httpRouter, + logger: backendPluginApi.coreServices.rootLogger, + discovery: backendPluginApi.coreServices.discovery, + dynamicPlugins: dynamicPluginsServiceRef, + metadata: backendPluginApi.coreServices.pluginMetadata, + }, + async init({ + http, + logger, + discovery, + dynamicPlugins, + metadata, + }) { + logger.info("This secret value should be hidden by the dynamic-plugin-aware logger: AVerySecretValue"); + const externalBaseUrl = await discovery.getExternalBaseUrl(metadata.getId()); + const router = express.Router(); + const frontendPluginsIndexPath = privateDep.frontendPluginsIndexPath; + const frontendPluginManifests = Object.fromEntries(dynamicPlugins.frontendPlugins().map(fp => { + const pluginScannedPackage = dynamicPlugins.getScannedPackage(fp); + const pkgDistLocation = path.resolve( + url.fileURLToPath(pluginScannedPackage.location), + 'dist', + ); + router.use(`/${frontendPluginsIndexPath}/${fp.name}`, express.static(pkgDistLocation)) + return [fp.name, `${externalBaseUrl}/${frontendPluginsIndexPath}/${fp.name}/mf-manifest.json`] + })); + router.get(`/${frontendPluginsIndexPath}`, (req, res) => { + res.status(200).json(frontendPluginManifests); + }); + http.use(router); + http.addAuthPolicy({ + path: `/`, + allow: 'unauthenticated', + }); + + } + }); + } +}); + +exports.default = testPlugin; +//# sourceMappingURL=alpha.cjs.js.map diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/node_modules/private-dep-with-frontend-plugin-index-path/index.js b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/node_modules/private-dep-with-frontend-plugin-index-path/index.js new file mode 100644 index 0000000000..e0fb6a55de --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/node_modules/private-dep-with-frontend-plugin-index-path/index.js @@ -0,0 +1,7 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +const frontendPluginsIndexPath = 'frontend-plugins'; + +exports.frontendPluginsIndexPath = frontendPluginsIndexPath; diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/node_modules/private-dep-with-frontend-plugin-index-path/package.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/node_modules/private-dep-with-frontend-plugin-index-path/package.json new file mode 100644 index 0000000000..4717963f57 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/node_modules/private-dep-with-frontend-plugin-index-path/package.json @@ -0,0 +1,7 @@ +{ + "name": "private-dep-with-frontend-plugin-index-path", + "version": "0.0.0", + "description": "private dependency of the test backend plugin", + "main": "index.js", + "dependencies": {} +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/package.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/package.json new file mode 100644 index 0000000000..4fb0100125 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic/package.json @@ -0,0 +1,35 @@ +{ + "name": "plugin-test-backend-dynamic", + "version": "0.0.0", + "description": "A test dynamic backend module that exposes the dynamic frontend plugins to an endpoint.", + "backstage": { + "role": "backend-plugin", + "pluginId": "test", + "pluginPackages": [ + "plugin-test", + "plugin-test-backend" + ] + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "backstage", + "dynamic" + ], + "exports": { + ".": { + "require": "./dist/index.cjs.js", + "default": "./dist/index.cjs.js" + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs.js", + "files": [ + "dist" + ], + "dependencies": { + "private-dep-with-frontend-plugin-index-path": "0.0.0" + }, + "bundleDependencies": true +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/configSchema.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/configSchema.json new file mode 100644 index 0000000000..a1f22b0374 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/configSchema.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "test-frontend": { + "type": "object", + "required": [ + "frontendValue" + ], + "properties": { + "frontendValue": { + "type": "string", + "visibility": "frontend" + } + } + } + } +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/mf-manifest.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/mf-manifest.json new file mode 100644 index 0000000000..99a2949671 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/mf-manifest.json @@ -0,0 +1,29 @@ +{ + "id": "backstage__plugin_test", + "name": "backstage__plugin_test", + "metaData": { + "name": "backstage__plugin_test", + "type": "app", + "buildInfo": { + "buildVersion": "0.0.0", + "buildName": "@backstage/plugin-test" + }, + "remoteEntry": { + "name": "remoteEntry.js", + "path": "", + "type": "global" + }, + "types": { + "path": "", + "name": "", + "zip": "", + "api": "" + }, + "globalName": "backstage__plugin_test", + "pluginVersion": "0.0.0", + "publicPath": "auto" + }, + "shared": [], + "remotes": [], + "exposes": [] +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/remoteEntry.js b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/remoteEntry.js new file mode 100644 index 0000000000..b4554e2a8c --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/dist/remoteEntry.js @@ -0,0 +1,17 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +(function doNothing(){})(); diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/package.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/package.json new file mode 100644 index 0000000000..06531a2363 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-dynamic/package.json @@ -0,0 +1,20 @@ +{ + "name": "plugin-test-dynamic", + "version": "0.0.0", + "description": "A test dynamic, module-federation-based, Backstage frontend plugin that does nothing", + "backstage": { + "role": "frontend-dynamic-container", + "pluginId": "test", + "pluginPackages": [ + "plugin-test" + ] + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "backstage", + "dynamic" + ], + "main": "remote-entry.js" +} diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/node_modules/.shouldNotBeUsed b/packages/backend-dynamic-feature-service/src/features/__fixtures__/node_modules/.shouldNotBeUsed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/node_modules/@backstage/backend-plugin-api/index.js b/packages/backend-dynamic-feature-service/src/features/__fixtures__/node_modules/@backstage/backend-plugin-api/index.js new file mode 100644 index 0000000000..05b446cbe6 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/node_modules/@backstage/backend-plugin-api/index.js @@ -0,0 +1 @@ +throw new Error("False @backstage/backend-plugin-api package which should be skipped by the CommonJSModuleLoader"); \ No newline at end of file diff --git a/packages/backend-dynamic-feature-service/src/features/__fixtures__/node_modules/@backstage/backend-plugin-api/package.json b/packages/backend-dynamic-feature-service/src/features/__fixtures__/node_modules/@backstage/backend-plugin-api/package.json new file mode 100644 index 0000000000..7b2e68e264 --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/__fixtures__/node_modules/@backstage/backend-plugin-api/package.json @@ -0,0 +1,7 @@ +{ + "name": "@backstage/backend-plugin-api", + "version": "0.0.0", + "description": "dummy backstage package that should be skipped by the ComonJSLoduleLoader", + "main": "index.js", + "dependencies": {} +} diff --git a/packages/backend-dynamic-feature-service/src/features/features.test.ts b/packages/backend-dynamic-feature-service/src/features/features.test.ts new file mode 100644 index 0000000000..daee127edc --- /dev/null +++ b/packages/backend-dynamic-feature-service/src/features/features.test.ts @@ -0,0 +1,306 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + startTestBackend, + mockServices, + createMockDirectory, +} from '@backstage/backend-test-utils'; +import { dynamicPluginsFeatureLoader } from './features'; +import { DynamicPlugin, dynamicPluginsServiceRef } from '../manager'; +import path, { resolve as resolvePath } from 'path'; +import { + BackendFeature, + createBackendPlugin, + LoggerService, +} from '@backstage/backend-plugin-api'; +import { CommonJSModuleLoader } from '../loader/CommonJSModuleLoader'; +import * as winston from 'winston'; +import { MESSAGE } from 'triple-beam'; +import { overridePackagePathResolution } from '@backstage/backend-plugin-api/testUtils'; + +async function jestFreeTypescriptAwareModuleLoader( + logger: LoggerService, + dontBootstrap: boolean = false, +) { + const loader = new CommonJSModuleLoader(logger); + (loader as any).module = await loader.load('node:module'); + loader.load(path.resolve(__dirname, '../../../cli/config/nodeTransform.cjs')); + if (dontBootstrap) { + loader.bootstrap = async () => {}; + } + return loader; +} + +class MockedTransport extends winston.transports.Console { + readonly logs: string[] = []; + + public log(info: any, callback: () => void) { + if (!info[MESSAGE]?.includes('info: Plugin initialization ')) { + this.logs.push(info[MESSAGE]); + } + super.log!(info, callback); + } + public logv(info: any, callback: () => void) { + if (!info[MESSAGE]?.includes('info: Plugin initialization ')) { + this.logs.push(info[MESSAGE]); + } + super.log!(info, callback); + } +} + +class DynamicPluginLister { + readonly loadedPlugins: DynamicPlugin[] = []; + feature(): BackendFeature { + // eslint-disable-next-line consistent-this + const that = this; + return createBackendPlugin({ + pluginId: 'dynamicPluginsLister', + register(reg) { + reg.registerInit({ + deps: { + dynamicPlugins: dynamicPluginsServiceRef, + }, + async init({ dynamicPlugins }) { + that.loadedPlugins.push(...dynamicPlugins.plugins(true)); + }, + }); + }, + }); + } +} + +describe('dynamicPluginsFeatureLoader', () => { + const dynamicPluginsRootDirectory = resolvePath( + __dirname, + '__fixtures__/dynamic-plugins-root', + ); + + // A dummy `@backstage/backend-plugin-api` package which throws an error is available inside the test fixtures, + // in a `node_modules` folder which is a sibling of the `dynamic-plugins-root`. + // This test demonstrates how, without the skipping logic implemented in the {@link CommonJSModelLoader}, + // this dummy package would be loaded by the backend dynamic plugins instead of the one of the backstage root. + it('should fail because the model loader is not skipping modules living in unexpected locations.', async () => { + const dynamicPLuginsLister = new DynamicPluginLister(); + const mockedTransport = new MockedTransport(); + await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootDirectory, + }, + }, + }), + dynamicPluginsFeatureLoader({ + moduleLoader: logger => + jestFreeTypescriptAwareModuleLoader(logger, true), + transports: [mockedTransport], + format: winston.format.simple(), + }), + dynamicPLuginsLister.feature(), + ], + }); + expect(mockedTransport.logs).toContainEqual( + expect.stringMatching( + "error: an error occurred while loading dynamic backend plugin 'plugin-test-backend-dynamic' from '.*/packages/backend-dynamic-feature-service/src/features/__fixtures__/dynamic-plugins-root/test-backend-dynamic", + ), + ); + expect(dynamicPLuginsLister.loadedPlugins).toMatchObject([ + { + name: 'plugin-test-backend-dynamic', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + failure: + 'Error: False @backstage/backend-plugin-api package which should be skipped by the CommonJSModuleLoader', + }, + expect.anything(), + ]); + }); + + it('should load and show the 2 dynamic plugins in a list of dynamic plugins returned by a static backend plugin', async () => { + const dynamicPLuginsLister = new DynamicPluginLister(); + await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootDirectory, + }, + }, + }), + dynamicPluginsFeatureLoader({ + moduleLoader: jestFreeTypescriptAwareModuleLoader, + }), + dynamicPLuginsLister.feature(), + ], + }); + + expect(dynamicPLuginsLister.loadedPlugins).toMatchObject([ + { + installer: { + kind: 'new', + }, + name: 'plugin-test-backend-dynamic', + platform: 'node', + role: 'backend-plugin', + version: '0.0.0', + }, + { + name: 'plugin-test-dynamic', + platform: 'web', + role: 'frontend-dynamic-container', + version: '0.0.0', + }, + ]); + }); + + it('should redact the secret config values of dynamic plugin config schemas in logs', async () => { + const mockedTransport = new MockedTransport(); + await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootDirectory, + }, + 'test-backend': { + secretValue: 'AVerySecretValue', + }, + }, + }), + dynamicPluginsFeatureLoader({ + moduleLoader: jestFreeTypescriptAwareModuleLoader, + transports: [mockedTransport], + format: winston.format.simple(), + }), + ], + }); + + expect(mockedTransport.logs).toContainEqual( + 'info: Found 1 new secrets in config that will be redacted {"service":"backstage"}', + ); + + expect(mockedTransport.logs).toContainEqual( + 'info: This secret value should be hidden by the dynamic-plugin-aware logger: *** {"service":"backstage"}', + ); + }); + + const mockAppDir = createMockDirectory(); + overridePackagePathResolution({ + packageName: 'app', + path: mockAppDir.path, + }); + + it('should inject frontend config values of dynamic frontend plugin config schemas to the frontend application', async () => { + mockAppDir.setContent({ + 'package.json': '{}', + dist: { + static: {}, + 'index.html.tmpl': '', + '.config-schema.json': ` +{ + "backstageConfigSchemaVersion": 1, + "schemas": [] +} +`, + }, + }); + + const { server } = await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootDirectory, + }, + 'test-frontend': { + frontendValue: 'AFrontendValue', + }, + }, + }), + dynamicPluginsFeatureLoader({ + moduleLoader: jestFreeTypescriptAwareModuleLoader, + }), + import('@backstage/plugin-app-backend/alpha'), + ], + }); + + await expect( + fetch(`http://localhost:${server.port()}`).then(res => res.text()), + ).resolves.toBe(` + +`); + }); + + it('should access the module federation assets of the frontend plugin through the backend plugin', async () => { + const { server } = await startTestBackend({ + features: [ + mockServices.rootConfig.factory({ + data: { + dynamicPlugins: { + rootDirectory: dynamicPluginsRootDirectory, + }, + }, + }), + dynamicPluginsFeatureLoader({ + moduleLoader: jestFreeTypescriptAwareModuleLoader, + }), + ], + }); + + const list = await fetch( + `http://localhost:${server.port()}/api/test/frontend-plugins`, + ); + expect(list.ok).toBe(true); + expect(await list.json()).toEqual({ + 'plugin-test-dynamic': `http://localhost:${server.port()}/api/test/frontend-plugins/plugin-test-dynamic/mf-manifest.json`, + }); + + const manifest = await fetch( + `http://localhost:${server.port()}/api/test/frontend-plugins/plugin-test-dynamic/mf-manifest.json`, + ); + expect(manifest.ok).toBe(true); + expect(await manifest.json()).toMatchObject({ + exposes: [], + id: 'backstage__plugin_test', + name: 'backstage__plugin_test', + metaData: { + buildInfo: { + buildName: '@backstage/plugin-test', + buildVersion: '0.0.0', + }, + globalName: 'backstage__plugin_test', + name: 'backstage__plugin_test', + pluginVersion: '0.0.0', + publicPath: 'auto', + }, + }); + }); +}); diff --git a/packages/backend-dynamic-feature-service/src/schemas/frontend.ts b/packages/backend-dynamic-feature-service/src/schemas/frontend.ts index 306ecc9255..d101487318 100644 --- a/packages/backend-dynamic-feature-service/src/schemas/frontend.ts +++ b/packages/backend-dynamic-feature-service/src/schemas/frontend.ts @@ -44,6 +44,7 @@ export const dynamicPluginsFrontendSchemas = createBackendModule({ config.getOptionalString('app.packageName') ?? 'app'; const appDistDir = resolvePackagePath(appPackageName, 'dist'); const compiledConfigSchema = await loadCompiledConfigSchema(appDistDir); + // TODO(davidfestal): Add dynamic pliugin config schemas even if the compiled schemas are empty. if (compiledConfigSchema) { configSchemaExtension.setConfigSchema( (await schemas.addDynamicPluginsSchemas(compiledConfigSchema)) diff --git a/yarn.lock b/yarn.lock index 7285cc8a19..c360f2247d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3712,6 +3712,7 @@ __metadata: "@backstage/config": "workspace:^" "@backstage/config-loader": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/plugin-app-backend": "workspace:^" "@backstage/plugin-app-node": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-catalog-backend": "workspace:^" @@ -3729,6 +3730,7 @@ __metadata: express: ^4.17.1 fs-extra: ^11.2.0 lodash: ^4.17.21 + triple-beam: ^1.4.1 wait-for-expect: ^3.0.2 winston: ^3.2.1 languageName: unknown From 5d8114c1d66dbbc8c4fac44e2e3d37fe1c57b089 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Fri, 5 Jul 2024 17:13:56 -0400 Subject: [PATCH 093/291] feat(openapi-tooling): custom validation server for test cases Signed-off-by: aramissennyeydd --- packages/backend-openapi-utils/package.json | 3 + packages/backend-openapi-utils/src/index.ts | 2 +- .../backend-openapi-utils/src/proxy/setup.ts | 71 +++ .../src/schema/validation.ts | 587 ++++++++++++++++++ .../backend-openapi-utils/src/testUtils.ts | 17 + .../src/service/createRouter.test.ts | 4 +- .../src/schema/openapi.generated.ts | 2 +- .../search-backend/src/schema/openapi.yaml | 2 +- .../search-backend/src/service/router.test.ts | 6 +- yarn.lock | 126 +++- 10 files changed, 810 insertions(+), 10 deletions(-) create mode 100644 packages/backend-openapi-utils/src/proxy/setup.ts create mode 100644 packages/backend-openapi-utils/src/schema/validation.ts diff --git a/packages/backend-openapi-utils/package.json b/packages/backend-openapi-utils/package.json index 8f6fd1b6eb..a173cbf17b 100644 --- a/packages/backend-openapi-utils/package.json +++ b/packages/backend-openapi-utils/package.json @@ -33,15 +33,18 @@ "test": "backstage-cli package test" }, "dependencies": { + "@apidevtools/swagger-parser": "^10.1.0", "@backstage/backend-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", + "ajv": "^8.16.0", "express": "^4.17.1", "express-openapi-validator": "^5.0.4", "express-promise-router": "^4.1.0", "json-schema-to-ts": "^3.0.0", "lodash": "^4.17.21", + "mockttp": "^3.13.0", "openapi-merge": "^1.3.2", "openapi3-ts": "^3.1.2" }, diff --git a/packages/backend-openapi-utils/src/index.ts b/packages/backend-openapi-utils/src/index.ts index 9f5ccdc03b..57d9f755c2 100644 --- a/packages/backend-openapi-utils/src/index.ts +++ b/packages/backend-openapi-utils/src/index.ts @@ -32,4 +32,4 @@ export type { } from './utility'; export type { ApiRouter } from './router'; export { createValidatedOpenApiRouter, getOpenApiSpecRoute } from './stub'; -export { wrapInOpenApiTestServer } from './testUtils'; +export { wrapInOpenApiTestServer, wrapServer } from './testUtils'; diff --git a/packages/backend-openapi-utils/src/proxy/setup.ts b/packages/backend-openapi-utils/src/proxy/setup.ts new file mode 100644 index 0000000000..a7205a883a --- /dev/null +++ b/packages/backend-openapi-utils/src/proxy/setup.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2024 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 * as mockttp from 'mockttp'; +import { OpenApiProxyValidator } from '../schema/validation'; + +export class Proxy { + server: mockttp.Mockttp; + #openRequests: Record = {}; + requestResponsePairs = new Map< + mockttp.CompletedRequest, + mockttp.CompletedResponse + >(); + validator: OpenApiProxyValidator; + constructor() { + this.server = mockttp.getLocal(); + this.validator = new OpenApiProxyValidator(); + } + + async setup() { + await this.server.start(); + this.server + .forAnyRequest() + .thenForwardTo(`http://localhost:${process.env.PORT}`); + await this.server.on('request', request => { + this.#openRequests[request.id] = request; + }); + await this.server.on('response', response => { + const request = this.#openRequests[response.id]; + if (request) { + this.requestResponsePairs.set(request, response); + } + delete this.#openRequests[response.id]; + try { + this.validator.validate(request, response); + } catch (err) { + console.error(err); + } + }); + } + + async initialize() { + await this.validator.initialize( + `http://localhost:${process.env.PORT}/openapi.json`, + ); + } + + stop() { + if (Object.keys(this.#openRequests).length > 0) { + throw new Error('There are still open requests'); + } + this.server.stop(); + } + + get url() { + return this.server.proxyEnv.HTTP_PROXY; + } +} diff --git a/packages/backend-openapi-utils/src/schema/validation.ts b/packages/backend-openapi-utils/src/schema/validation.ts new file mode 100644 index 0000000000..104482a485 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/validation.ts @@ -0,0 +1,587 @@ +/* + * Copyright 2024 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 { CompletedRequest, CompletedResponse } from 'mockttp'; +import { + OpenAPIObject, + OperationObject, + ParameterObject, + ResponseObject, + SchemaObject, +} from 'openapi3-ts'; +import Ajv from 'ajv'; +import Parser from '@apidevtools/swagger-parser'; + +const ajv = new Ajv({ allErrors: true }); // options can be passed, e.g. {allErrors: true} + +interface RequestResponsePair { + request: CompletedRequest; + response: CompletedResponse; +} + +interface ValidatorParams { + pair: RequestResponsePair; + operationSchema: OperationObject; + path: string; +} + +interface Validator { + validate(pair: ValidatorParams): Promise; +} + +class RequestErrorFactory { + static createRequestError(request: CompletedRequest, message: string): Error { + return new Error(`[${request.url} (${request.method})]: ${message}`); + } +} + +export class ParameterValidator implements Validator { + schema: OpenAPIObject; + cache: Record = {}; + constructor(schema: OpenAPIObject) { + this.schema = schema; + } + + async validate({ + pair: { request, response }, + operationSchema, + path, + }: ValidatorParams) { + if (response.statusCode === 400) { + // If the response is a 400, then the request is invalid and we shouldn't validate the parameters + return; + } + const parameters = operationSchema.parameters; + const queryParameters: Record = {}; + const headerParameters: Record = {}; + const pathParameters: Record = {}; + for (const parameter of parameters || []) { + if ('$ref' in parameter) { + throw RequestErrorFactory.createRequestError( + request, + 'Reference objects are not supported', + ); + } + if (parameter.in === 'query') { + queryParameters[parameter.name] = parameter; + } + if (parameter.in === 'header') { + headerParameters[parameter.name] = parameter; + } + if (parameter.in === 'path') { + pathParameters[parameter.name] = parameter; + } + } + this.validateQueryParameters(queryParameters, request); + this.validateHeaderParameters(headerParameters, request); + this.validatePathParameters(pathParameters, request, path); + } + + validateQueryParameters( + queryParameters: Record, + request: CompletedRequest, + ) { + const { searchParams } = new URL(request.url); + for (const [name, parameter] of Object.entries(queryParameters)) { + if (!parameter.schema) { + throw RequestErrorFactory.createRequestError( + request, + 'Schema not found for query parameter', + ); + } + if ('$ref' in parameter.schema) { + throw RequestErrorFactory.createRequestError( + request, + 'Reference objects are not supported for parameters', + ); + } + let param: any | null = this.#findQueryParameters( + request, + queryParameters, + searchParams, + name, + ); + if (parameter.schema.type !== 'array' && Array.isArray(param)) { + param = param.length > 0 ? param[0] : undefined; + } + + if (!param && parameter.required) { + throw RequestErrorFactory.createRequestError( + request, + `Required query parameter ${name} not found`, + ); + } else if (!param && !parameter.required) { + continue; + } + if (parameter.schema.type === 'integer') { + // Try to parse the integer as AJV won't do it for us. + param = parseInt(param, 10); + } + const validate = ajv.compile(parameter.schema); + const valid = validate(param); + if (!valid) { + console.log(param); + console.error(validate.errors); + throw RequestErrorFactory.createRequestError( + request, + 'Query parameter validation failed', + ); + } + } + } + + #findQueryParameters( + request: CompletedRequest, + parameters: Record, + searchParams: URLSearchParams, + name: string, + ) { + const parameter = parameters[name]; + const schema = parameter.schema as SchemaObject; + if (schema.type === 'array') { + if (parameter.style === 'form' || !parameter.style) { + if (parameter.explode || typeof parameter.explode === 'undefined') { + if (!searchParams.has(name) && searchParams.has(`${name}[0]`)) { + const values: string[] = []; + let index = 0; + while (searchParams.has(`${name}[${index}]`)) { + values.push(searchParams.get(`${name}[${index}]`)!); + index++; + } + return values; + } + return searchParams.getAll(name); + } + if (!searchParams.has(name) && searchParams.has(`${name}[]`)) { + return searchParams.getAll(`${name}[]`); + } + return searchParams.get(name)?.split(','); + } else if (parameter.style === 'spaceDelimited') { + return searchParams.get(name)?.split(' '); + } else if (parameter.style === 'pipeDelimited') { + return searchParams.get(name)?.split('|'); + } + throw RequestErrorFactory.createRequestError( + request, + 'Unsupported style for array parameter', + ); + } + if (schema.type === 'object') { + if (parameter.style === 'form' || !parameter.style) { + if (parameter.explode) { + const obj: Record = {}; + for (const [key, value] of searchParams.entries()) { + if (this.#matchesOtherQueryParameters(parameters, key)) { + continue; + } + obj[key] = value; + } + console.log(obj); + return obj; + } + const obj: Record = {}; + const value = searchParams.get(name); + if (value) { + const parts = value.split(','); + if (parts.length % 2 !== 0) { + throw RequestErrorFactory.createRequestError( + request, + 'Invalid object parameter', + ); + } + for (let i = 0; i < parts.length; i += 2) { + obj[parts[i]] = parts[i + 1]; + } + } + return obj; + } else if (parameter.style === 'deepObject') { + const obj: Record = {}; + for (const [key, value] of searchParams.entries()) { + if (key.startsWith(`${name}[`)) { + const parts = key.split('['); + let currentLayer = obj; + for (let partIndex = 0; partIndex < parts.length - 1; partIndex++) { + const part = parts[partIndex]; + const objKey = part.split(']')[0]; + if (!currentLayer[objKey]) { + currentLayer[objKey] = {}; + } + currentLayer = currentLayer[objKey]; + } + currentLayer[parts[parts.length - 1].split(']')[0]] = value; + } + } + return obj; + } + throw RequestErrorFactory.createRequestError( + request, + 'Unsupported style for object parameter', + ); + } + // For everything else, just return the value. + return searchParams.getAll(name); + } + + #matchesOtherQueryParameters( + parameters: Record, + nameToMatch: string, + ) { + for (const [name] of Object.entries(parameters)) { + if (name === nameToMatch) { + return true; + } + } + return false; + } + + validateHeaderParameters( + headerParameters: Record, + request: CompletedRequest, + ) { + for (const [name, parameter] of Object.entries(headerParameters)) { + if (!request.headers[name]) { + throw RequestErrorFactory.createRequestError( + request, + `Header parameter ${name} not found`, + ); + } + if (!parameter.schema) { + throw RequestErrorFactory.createRequestError( + request, + 'Schema not found for path parameter', + ); + } + if ('$ref' in parameter.schema) { + throw RequestErrorFactory.createRequestError( + request, + 'Reference objects are not supported for parameters', + ); + } + const validate = ajv.compile(parameter.schema); + const valid = validate(request.headers[name]); + + if (!valid) { + console.log(request.headers[name]); + console.error(validate.errors); + throw RequestErrorFactory.createRequestError( + request, + 'Header parameter validation failed', + ); + } + } + } + + validatePathParameters( + pathParameters: Record, + request: CompletedRequest, + path: string, + ) { + const { pathname } = new URL(request.url); + const params = parsePath({ request, path: pathname, schema: path }); + for (const [name, parameter] of Object.entries(pathParameters)) { + if (!params[name] && parameter.required) { + throw RequestErrorFactory.createRequestError( + request, + `Path parameter ${name} not found`, + ); + } + if (!parameter.schema) { + throw RequestErrorFactory.createRequestError( + request, + 'Schema not found for path parameter', + ); + } + if ('$ref' in parameter.schema) { + throw RequestErrorFactory.createRequestError( + request, + 'Reference objects are not supported for parameters', + ); + } + + const validate = ajv.compile(parameter.schema); + const valid = validate(params[name]); + + if (!valid) { + console.log(params); + console.error(validate.errors); + throw RequestErrorFactory.createRequestError( + request, + 'Path parameter validation failed', + ); + } + } + } +} + +function parsePath({ + request, + schema, + path, +}: { + request: CompletedRequest; + schema: string; + path: string; +}) { + const parts = path.split('/'); + const pathParts = schema.split('/'); + if (parts.length !== pathParts.length) { + throw RequestErrorFactory.createRequestError( + request, + 'Path parts do not match', + ); + } + const params: Record = {}; + for (let i = 0; i < parts.length; i++) { + if (pathParts[i] === parts[i]) { + continue; + } + if (pathParts[i].startsWith('{') && pathParts[i].endsWith('}')) { + params[pathParts[i].slice(1, -1)] = parts[i]; + continue; + } + break; + } + return params; +} + +export class RequestBodyValidator implements Validator { + schema: OpenAPIObject; + constructor(schema: OpenAPIObject) { + this.schema = schema; + } + + async validate({ + pair: { request, response }, + operationSchema, + }: ValidatorParams) { + if (response.statusCode === 400) { + // If the response is a 400, then the request is invalid and we shouldn't validate the request body + return; + } + const requestBody = operationSchema.requestBody; + const bodyText = await request.body.getText(); + if (!requestBody && bodyText?.length) { + throw RequestErrorFactory.createRequestError( + request, + `No request body found for ${request.url}`, + ); + } else if (!requestBody && !bodyText?.length) { + // If there is no request body in the schema and no body in the request, then the request is valid + return; + } + if ('$ref' in requestBody!) { + throw RequestErrorFactory.createRequestError( + request, + 'Reference objects are not supported', + ); + } + if (!requestBody!.content) { + throw RequestErrorFactory.createRequestError( + request, + 'No content found in request body', + ); + } + if (!requestBody!.content['application/json']) { + throw RequestErrorFactory.createRequestError( + request, + 'No application/json content type found in request body', + ); + } + const contentType = request.headers['content-type']; + if (!contentType) { + throw RequestErrorFactory.createRequestError( + request, + 'Content type not found in request', + ); + } + if (contentType !== 'application/json') { + throw RequestErrorFactory.createRequestError( + request, + 'Content type is not application/json', + ); + } + const schema = requestBody!.content['application/json'].schema; + if (!schema) { + throw RequestErrorFactory.createRequestError( + request, + 'No schema found in request body', + ); + } + if ('$ref' in schema) { + throw RequestErrorFactory.createRequestError( + request, + 'Reference objects are not supported', + ); + } + + const validate = ajv.compile(schema); + const body = await request.body.getJson(); + const valid = validate(body); + if (!valid) { + console.log(body); + console.error(validate.errors); + throw RequestErrorFactory.createRequestError( + request, + `Request body validation failed.`, + ); + } + } +} + +export class ResponseBodyValidator implements Validator { + schema: OpenAPIObject; + constructor(schema: OpenAPIObject) { + this.schema = schema; + } + + async validate(pair: ValidatorParams) { + const { + pair: { response, request }, + operationSchema, + } = pair; + const responseSchema = this.findResponseSchema(operationSchema, response); + if (!responseSchema) { + throw RequestErrorFactory.createRequestError( + request, + `No response schema found for ${response.statusCode}`, + ); + } + const body = await response.body.getText(); + if (!responseSchema.content && body?.length) { + throw RequestErrorFactory.createRequestError( + request, + 'No content found in response', + ); + } else if (!responseSchema.content && !body?.length) { + // If there is no content in the response schema and no body in the response, then the response is valid + return; + } + if (!responseSchema.content!['application/json']) { + throw RequestErrorFactory.createRequestError( + request, + 'No application/json content type found in response', + ); + } + const schema = responseSchema.content!['application/json'].schema; + if (!schema) { + throw RequestErrorFactory.createRequestError( + request, + 'No schema found in response', + ); + } + if ('$ref' in schema) { + throw RequestErrorFactory.createRequestError( + request, + 'Reference objects are not supported', + ); + } + + const validate = ajv.compile(schema); + const valid = validate(await response.body.getJson()); + if (!valid) { + console.log(await response.body.getJson()); + console.error(validate.errors); + throw RequestErrorFactory.createRequestError( + request, + 'Response body validation failed', + ); + } + } + + private findResponseSchema( + operationSchema: OperationObject, + response: CompletedResponse, + ): ResponseObject | undefined { + const { statusCode } = response; + return operationSchema.responses?.[statusCode]; + } +} + +export class OpenApiProxyValidator { + schema: OpenAPIObject | undefined; + validators: Validator[] | undefined; + + async initialize(url: string) { + this.schema = (await Parser.dereference(url)) as unknown as OpenAPIObject; + this.validators = [ + new ParameterValidator(this.schema), + new RequestBodyValidator(this.schema), + // new ResponseBodyValidator(this.schema), + ]; + } + + validate(request: CompletedRequest, response: CompletedResponse) { + const operation = this.findOperation(request); + if (!operation) { + throw RequestErrorFactory.createRequestError( + request, + `No operation schema found for ${request.url}`, + ); + } + + const [path, operationSchema] = operation; + + const validators = this.validators!; + for (const validator of validators) { + validator.validate({ + pair: { request, response }, + operationSchema, + path, + }); + } + } + + private findOperation( + request: CompletedRequest, + ): [string, OperationObject] | undefined { + const { url } = request; + const { pathname } = new URL(url); + + const parts = pathname.split('/'); + for (const [path, schema] of Object.entries(this.schema!.paths)) { + const pathParts = path.split('/'); + if (parts.length !== pathParts.length) { + continue; + } + let found = true; + for (let i = 0; i < parts.length; i++) { + if (pathParts[i] === parts[i]) { + continue; + } + if (pathParts[i].startsWith('{') && pathParts[i].endsWith('}')) { + continue; + } + found = false; + break; + } + if (!found) { + continue; + } + let matchingOperationType: OperationObject | undefined = undefined; + for (const [operationType, operation] of Object.entries(schema)) { + if (operationType === request.method.toLowerCase()) { + matchingOperationType = operation as OperationObject; + break; + } + } + if (!matchingOperationType) { + continue; + } + return [path, matchingOperationType]; + } + + return undefined; + } +} diff --git a/packages/backend-openapi-utils/src/testUtils.ts b/packages/backend-openapi-utils/src/testUtils.ts index 2ac5575fc4..f96a0b1b76 100644 --- a/packages/backend-openapi-utils/src/testUtils.ts +++ b/packages/backend-openapi-utils/src/testUtils.ts @@ -15,6 +15,23 @@ */ import { Express } from 'express'; import { Server } from 'http'; +import { Proxy } from './proxy/setup'; + +const proxy = new Proxy(); + +beforeAll(async () => { + await proxy.setup(); +}); + +afterAll(() => { + proxy.stop(); +}); + +export async function wrapServer(app: Express): Promise { + const server = app.listen(+process.env.PORT!); + await proxy.initialize(); + return { ...server, address: () => new URL(proxy.url) } as any; +} /** * !!! THIS CURRENTLY ONLY SUPPORTS SUPERTEST !!! diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 9cc0717807..6654187526 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -38,7 +38,7 @@ import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common/a import { CatalogProcessingOrchestrator } from '../processing/types'; import { z } from 'zod'; import { decodeCursor, encodeCursor } from './util'; -import { wrapInOpenApiTestServer } from '@backstage/backend-openapi-utils'; +import { wrapServer } from '@backstage/backend-openapi-utils'; import { Server } from 'http'; import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; import { LocationAnalyzer } from '@backstage/plugin-catalog-node'; @@ -93,7 +93,7 @@ describe('createRouter readonly disabled', () => { locationAnalyzer, permissionsService: permissionsService, }); - app = wrapInOpenApiTestServer(express().use(router)); + app = await wrapServer(express().use(router)); }); beforeEach(() => { diff --git a/plugins/search-backend/src/schema/openapi.generated.ts b/plugins/search-backend/src/schema/openapi.generated.ts index 8ad27546d6..56aaeb2bbe 100644 --- a/plugins/search-backend/src/schema/openapi.generated.ts +++ b/plugins/search-backend/src/schema/openapi.generated.ts @@ -207,7 +207,7 @@ export const spec = { name: 'filters', in: 'query', required: false, - style: 'deepObject', + style: 'form', explode: true, allowReserved: true, schema: { diff --git a/plugins/search-backend/src/schema/openapi.yaml b/plugins/search-backend/src/schema/openapi.yaml index 358f94fcf6..a49b783b64 100644 --- a/plugins/search-backend/src/schema/openapi.yaml +++ b/plugins/search-backend/src/schema/openapi.yaml @@ -139,7 +139,7 @@ paths: - name: filters in: query required: false - style: deepObject + style: form explode: true allowReserved: true schema: diff --git a/plugins/search-backend/src/service/router.test.ts b/plugins/search-backend/src/service/router.test.ts index d22fc17e54..5b6900ede1 100644 --- a/plugins/search-backend/src/service/router.test.ts +++ b/plugins/search-backend/src/service/router.test.ts @@ -23,7 +23,7 @@ import { import express from 'express'; import request from 'supertest'; import { createRouter } from './router'; -import { wrapInOpenApiTestServer } from '@backstage/backend-openapi-utils'; +import { wrapServer } from '@backstage/backend-openapi-utils'; import { Server } from 'http'; import { mockCredentials, @@ -87,9 +87,7 @@ describe('createRouter', () => { auth: mockServices.auth(), httpAuth: mockServices.httpAuth(), }); - app = wrapInOpenApiTestServer( - express().use(router).use(mockErrorHandler()), - ); + app = await wrapServer(express().use(router).use(mockErrorHandler())); }); beforeEach(() => { diff --git a/yarn.lock b/yarn.lock index f5184e9149..036ac5c852 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3738,16 +3738,19 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/backend-openapi-utils@workspace:packages/backend-openapi-utils" dependencies: + "@apidevtools/swagger-parser": ^10.1.0 "@backstage/backend-plugin-api": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/errors": "workspace:^" "@types/express": ^4.17.6 "@types/express-serve-static-core": ^4.17.5 + ajv: ^8.16.0 express: ^4.17.1 express-openapi-validator: ^5.0.4 express-promise-router: ^4.1.0 json-schema-to-ts: ^3.0.0 lodash: ^4.17.21 + mockttp: ^3.13.0 openapi-merge: ^1.3.2 openapi3-ts: ^3.1.2 supertest: ^7.0.0 @@ -10313,6 +10316,15 @@ __metadata: languageName: node linkType: hard +"@httptoolkit/httpolyglot@npm:^2.2.1": + version: 2.2.1 + resolution: "@httptoolkit/httpolyglot@npm:2.2.1" + dependencies: + "@types/node": "*" + checksum: 5b3882657e37953bd7089d91ac6cd24cec36480deab114e6b69a4b3d9e4ab09db568500e5e96713869fb4a8fe40b5ecc1661cc39ee621ef40ed0e38b55e0257e + languageName: node + linkType: hard + "@httptoolkit/subscriptions-transport-ws@npm:^0.11.2": version: 0.11.2 resolution: "@httptoolkit/subscriptions-transport-ws@npm:0.11.2" @@ -20715,7 +20727,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.0.0, ajv@npm:^8.10.0, ajv@npm:^8.11.0, ajv@npm:^8.12.0, ajv@npm:^8.17.1, ajv@npm:^8.6.0, ajv@npm:^8.6.3, ajv@npm:^8.9.0": +"ajv@npm:^8.0.0, ajv@npm:^8.10.0, ajv@npm:^8.11.0, ajv@npm:^8.12.0, ajv@npm:^8.16.0, ajv@npm:^8.17.1, ajv@npm:^8.6.0, ajv@npm:^8.6.3, ajv@npm:^8.9.0": version: 8.17.1 resolution: "ajv@npm:8.17.1" dependencies: @@ -21339,6 +21351,15 @@ __metadata: languageName: node linkType: hard +"async-mutex@npm:^0.5.0": + version: 0.5.0 + resolution: "async-mutex@npm:0.5.0" + dependencies: + tslib: ^2.4.0 + checksum: be1587f4875f3bb15e34e9fcce82eac2966daef4432c8d0046e61947fb9a1b95405284601bc7ce4869319249bc07c75100880191db6af11d1498931ac2a2f9ea + languageName: node + linkType: hard + "async-retry@npm:^1.3.3": version: 1.3.3 resolution: "async-retry@npm:1.3.3" @@ -22111,6 +22132,13 @@ __metadata: languageName: node linkType: hard +"brotli-wasm@npm:^3.0.0": + version: 3.0.1 + resolution: "brotli-wasm@npm:3.0.1" + checksum: 48191b27265de8ffc59c940f9efef3a931448b6a15c26a4e360192fc3f0968e073c11fe0926510d019c305cc1d9c6d65df4d3e5752648a91cb0bbcccff7a8460 + languageName: node + linkType: hard + "browser-headers@npm:^0.4.1": version: 0.4.1 resolution: "browser-headers@npm:0.4.1" @@ -24995,6 +25023,15 @@ __metadata: languageName: node linkType: hard +"destroyable-server@npm:^1.0.2": + version: 1.0.2 + resolution: "destroyable-server@npm:1.0.2" + dependencies: + "@types/node": "*" + checksum: 81fd70b9132d43c3633a7a819adfe1fc68b52a55154ff8a36f42f4655e7b71b8468559888caadfd324c1aa824f0d236796a8f356e8a00e7438649e647ea654b2 + languageName: node + linkType: hard + "detect-indent@npm:^6.0.0": version: 6.1.0 resolution: "detect-indent@npm:6.1.0" @@ -29301,6 +29338,17 @@ __metadata: languageName: node linkType: hard +"http-encoding@npm:^2.0.1": + version: 2.0.1 + resolution: "http-encoding@npm:2.0.1" + dependencies: + brotli-wasm: ^3.0.0 + pify: ^5.0.0 + zstd-codec: ^0.1.5 + checksum: c34a1cd81ad1c08e6c6aba5aef3f4d4bc4a6c84f8b3511776eb62006beeee48a104ce1630e3c8497f66d5c0913195dea596e776336dd5a598bd7fe06d27e1395 + languageName: node + linkType: hard + "http-errors@npm:2.0.0, http-errors@npm:^2.0.0": version: 2.0.0 resolution: "http-errors@npm:2.0.0" @@ -29459,6 +29507,16 @@ __metadata: languageName: node linkType: hard +"http2-wrapper@npm:^2.2.1": + version: 2.2.1 + resolution: "http2-wrapper@npm:2.2.1" + dependencies: + quick-lru: ^5.1.1 + resolve-alpn: ^1.2.0 + checksum: e95e55e22c6fd61182ce81fecb9b7da3af680d479febe8ad870d05f7ebbc9f076e455193766f4e7934e50913bf1d8da3ba121fb5cd2928892390b58cf9d5c509 + languageName: node + linkType: hard + "https-browserify@npm:^1.0.0": version: 1.0.0 resolution: "https-browserify@npm:1.0.0" @@ -34535,6 +34593,58 @@ __metadata: languageName: node linkType: hard +"mockttp@npm:^3.13.0": + version: 3.15.2 + resolution: "mockttp@npm:3.15.2" + dependencies: + "@graphql-tools/schema": ^8.5.0 + "@graphql-tools/utils": ^8.8.0 + "@httptoolkit/httpolyglot": ^2.2.1 + "@httptoolkit/subscriptions-transport-ws": ^0.11.2 + "@httptoolkit/websocket-stream": ^6.0.1 + "@types/cors": ^2.8.6 + "@types/node": "*" + async-mutex: ^0.5.0 + base64-arraybuffer: ^0.1.5 + body-parser: ^1.15.2 + cacheable-lookup: ^6.0.0 + common-tags: ^1.8.0 + connect: ^3.7.0 + cors: ^2.8.4 + cors-gate: ^1.1.3 + cross-fetch: ^3.1.5 + destroyable-server: ^1.0.2 + express: ^4.14.0 + fast-json-patch: ^3.1.1 + graphql: ^14.0.2 || ^15.5 + graphql-http: ^1.22.0 + graphql-subscriptions: ^1.1.0 + graphql-tag: ^2.12.6 + http-encoding: ^2.0.1 + http2-wrapper: ^2.2.1 + https-proxy-agent: ^5.0.1 + isomorphic-ws: ^4.0.1 + lodash: ^4.16.4 + lru-cache: ^7.14.0 + native-duplexpair: ^1.0.0 + node-forge: ^1.2.1 + pac-proxy-agent: ^7.0.0 + parse-multipart-data: ^1.4.0 + performance-now: ^2.1.0 + portfinder: ^1.0.32 + read-tls-client-hello: ^1.0.0 + semver: ^7.5.3 + socks-proxy-agent: ^7.0.0 + typed-error: ^3.0.2 + urlpattern-polyfill: ^8.0.0 + uuid: ^8.3.2 + ws: ^8.8.0 + bin: + mockttp: dist/admin/admin-bin.js + checksum: 96b90e0515e7ac1b73954e9e01010424d51d9563f8e850e620b06ba864bf064401e1a1af89e103724b956bfa3bee790cb452366df100bf01122f333e04c3aee8 + languageName: node + linkType: hard + "mockttp@npm:^3.9.1": version: 3.9.4 resolution: "mockttp@npm:3.9.4" @@ -43704,6 +43814,13 @@ __metadata: languageName: node linkType: hard +"urlpattern-polyfill@npm:^8.0.0": + version: 8.0.2 + resolution: "urlpattern-polyfill@npm:8.0.2" + checksum: d2cc0905a613c77e330c426e8697ee522dd9640eda79ac51160a0f6350e103f09b8c327623880989f8ba7325e8d95267b745aa280fdcc2aead80b023e16bd09d + languageName: node + linkType: hard + "urlpattern-polyfill@npm:^9.0.0": version: 9.0.0 resolution: "urlpattern-polyfill@npm:9.0.0" @@ -45259,6 +45376,13 @@ __metadata: languageName: node linkType: hard +"zstd-codec@npm:^0.1.5": + version: 0.1.5 + resolution: "zstd-codec@npm:0.1.5" + checksum: ba62bf643c3ca9759fedc090b73a0c3b1e506364fcae902a70b112c1f5b30bc6aabff3184808cc4430f2ab6644cabae979368152ae908c1d8ef39cd8c3223c85 + languageName: node + linkType: hard + "zwitch@npm:^2.0.0": version: 2.0.2 resolution: "zwitch@npm:2.0.2" From 5555a58d62324237a7eab4dd2478ad0477d892f5 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Fri, 5 Jul 2024 17:19:05 -0400 Subject: [PATCH 094/291] throw on failure correctly Signed-off-by: aramissennyeydd --- .../backend-openapi-utils/src/proxy/setup.ts | 9 +++++---- .../src/schema/validation.ts | 18 ++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/backend-openapi-utils/src/proxy/setup.ts b/packages/backend-openapi-utils/src/proxy/setup.ts index a7205a883a..8f24aa2512 100644 --- a/packages/backend-openapi-utils/src/proxy/setup.ts +++ b/packages/backend-openapi-utils/src/proxy/setup.ts @@ -44,11 +44,12 @@ export class Proxy { this.requestResponsePairs.set(request, response); } delete this.#openRequests[response.id]; - try { - this.validator.validate(request, response); - } catch (err) { + this.validator.validate(request, response).catch(err => { + if (process.env.THROW) { + throw err; + } console.error(err); - } + }); }); } diff --git a/packages/backend-openapi-utils/src/schema/validation.ts b/packages/backend-openapi-utils/src/schema/validation.ts index 104482a485..9f2f22b9ac 100644 --- a/packages/backend-openapi-utils/src/schema/validation.ts +++ b/packages/backend-openapi-utils/src/schema/validation.ts @@ -522,7 +522,7 @@ export class OpenApiProxyValidator { ]; } - validate(request: CompletedRequest, response: CompletedResponse) { + async validate(request: CompletedRequest, response: CompletedResponse) { const operation = this.findOperation(request); if (!operation) { throw RequestErrorFactory.createRequestError( @@ -534,13 +534,15 @@ export class OpenApiProxyValidator { const [path, operationSchema] = operation; const validators = this.validators!; - for (const validator of validators) { - validator.validate({ - pair: { request, response }, - operationSchema, - path, - }); - } + await Promise.all( + validators.map(validator => + validator.validate({ + pair: { request, response }, + operationSchema, + path, + }), + ), + ); } private findOperation( From 3bf5285bd63e8c444e19015e7752034b4a061521 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 7 Jul 2024 13:04:16 -0400 Subject: [PATCH 095/291] adjusting validation library format and adding test cases Signed-off-by: aramissennyeydd --- packages/backend-openapi-utils/package.json | 3 + .../schemas/withJsonResponseBody.json | 35 + .../schemas/withQueryParameter.json | 25 + .../src/schema/errors.ts | 23 + .../src/schema/parameter-validation.test.ts | 458 +++++++++++ .../src/schema/parameter-validation.ts | 420 +++++++++++ .../src/schema/request-body-validation.ts | 115 +++ .../src/schema/response-body-validation.ts | 102 +++ .../backend-openapi-utils/src/schema/types.ts | 50 ++ .../backend-openapi-utils/src/schema/utils.ts | 36 + .../src/schema/validation.test.ts | 711 ++++++++++++++++++ .../src/schema/validation.ts | 501 +----------- yarn.lock | 3 + 13 files changed, 2007 insertions(+), 475 deletions(-) create mode 100644 packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonResponseBody.json create mode 100644 packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withQueryParameter.json create mode 100644 packages/backend-openapi-utils/src/schema/errors.ts create mode 100644 packages/backend-openapi-utils/src/schema/parameter-validation.test.ts create mode 100644 packages/backend-openapi-utils/src/schema/parameter-validation.ts create mode 100644 packages/backend-openapi-utils/src/schema/request-body-validation.ts create mode 100644 packages/backend-openapi-utils/src/schema/response-body-validation.ts create mode 100644 packages/backend-openapi-utils/src/schema/types.ts create mode 100644 packages/backend-openapi-utils/src/schema/utils.ts create mode 100644 packages/backend-openapi-utils/src/schema/validation.test.ts diff --git a/packages/backend-openapi-utils/package.json b/packages/backend-openapi-utils/package.json index a173cbf17b..5fb1f98e1b 100644 --- a/packages/backend-openapi-utils/package.json +++ b/packages/backend-openapi-utils/package.json @@ -35,7 +35,9 @@ "dependencies": { "@apidevtools/swagger-parser": "^10.1.0", "@backstage/backend-plugin-api": "workspace:^", + "@backstage/backend-test-utils": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/types": "workspace:^", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", "ajv": "^8.16.0", @@ -45,6 +47,7 @@ "json-schema-to-ts": "^3.0.0", "lodash": "^4.17.21", "mockttp": "^3.13.0", + "msw": "^1.0.0", "openapi-merge": "^1.3.2", "openapi3-ts": "^3.1.2" }, diff --git a/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonResponseBody.json b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonResponseBody.json new file mode 100644 index 0000000000..378ea0a168 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonResponseBody.json @@ -0,0 +1,35 @@ +{ + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": { + "/api/search": { + "get": { + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withQueryParameter.json b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withQueryParameter.json new file mode 100644 index 0000000000..6f0db99697 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withQueryParameter.json @@ -0,0 +1,25 @@ +{ + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": { + "/api/search": { + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "parameters": [ + { + "name": "param", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + } + } +} diff --git a/packages/backend-openapi-utils/src/schema/errors.ts b/packages/backend-openapi-utils/src/schema/errors.ts new file mode 100644 index 0000000000..aab5210f46 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/errors.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2024 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 { Operation } from './types'; + +export class OperationError extends Error { + constructor(operation: Operation, message: string) { + super(`[${operation.path} (${operation.method})]: ${message}`); + } +} diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts new file mode 100644 index 0000000000..2fb8f371b1 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts @@ -0,0 +1,458 @@ +/* + * Copyright 2024 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 _ from 'lodash'; +import withQueryParameter from './__fixtures__/schemas/withQueryParameter.json'; +import { QueryParameterParser } from './parameter-validation'; +import { OperationObject, ParameterObject } from 'openapi3-ts'; +import Ajv from 'ajv'; +import { Operation } from './types'; + +const ajv = new Ajv(); + +describe('query parameters', () => { + let operation: Operation; + let parser: QueryParameterParser; + let schema: (typeof withQueryParameter)['paths']['/api/search']['get']; + + beforeEach(() => { + schema = _.cloneDeep(withQueryParameter.paths['/api/search'].get); + operation = { + schema: schema as OperationObject, + path: '/api/search', + method: 'get', + }; + parser = new QueryParameterParser(operation, { ajv }); + }); + describe('primitives', () => { + describe('string', () => { + it('should parse a string', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello', + } as Request; + const result = await parser.parse(request); + expect(result.param).toBe('hello'); + }); + + it('should throw an error if there are extra parameters', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello&extra=world', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Unexpected query parameters: extra"`, + ); + }); + + it('should throw an error if the parameter is required but missing', async () => { + (schema.parameters![0] as ParameterObject).required = true; + const request = { + url: 'http://localhost:8080/api/search', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Required query parameter param not found"`, + ); + }); + }); + + describe('number', () => { + beforeEach(() => { + schema.parameters![0].schema.type = 'number'; + }); + it('should parse a number', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=42', + } as Request; + const result = await parser.parse(request); + expect(result.param).toBe(42); + }); + + it('should throw an error if the parameter is not a number', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Query parameter validation failed"`, + ); + }); + }); + }); + + describe('arrays', () => { + beforeEach(() => { + schema.parameters![0].schema.type = 'array'; + }); + describe('form', () => { + beforeEach(() => { + (schema.parameters![0] as ParameterObject).style = 'form'; + }); + describe('explode=true', () => { + beforeEach(() => { + (schema.parameters![0] as ParameterObject).explode = true; + }); + it('should parse a form array with a single element', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual(['hello']); + }); + + it('should parse a form array with multiple elements', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello¶m=world', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual(['hello', 'world']); + }); + + it('should throw for missing required parameters', async () => { + (schema.parameters![0] as ParameterObject).required = true; + const request = { + url: 'http://localhost:8080/api/search', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Required query parameter param not found"`, + ); + }); + + it('should throw for extra parameters', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello&extra=world', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Unexpected query parameters: extra"`, + ); + }); + }); + + describe('explode=false', () => { + beforeEach(() => { + (schema.parameters![0] as ParameterObject).explode = false; + }); + + it('should parse a form array with a single element', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual(['hello']); + }); + + it('should parse a form array with multiple elements', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello,world', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual(['hello', 'world']); + }); + + it('should throw for missing required parameters', async () => { + (schema.parameters![0] as ParameterObject).required = true; + const request = { + url: 'http://localhost:8080/api/search', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Required query parameter param not found"`, + ); + }); + + it('should throw for extra parameters', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello&extra=world', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Unexpected query parameters: extra"`, + ); + }); + }); + }); + + describe('spaceDelimited', () => { + beforeEach(() => { + (schema.parameters![0] as ParameterObject).style = 'spaceDelimited'; + }); + + it('should parse a space separated array', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello%20world', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual(['hello', 'world']); + }); + + it('should throw for missing required parameters', async () => { + (schema.parameters![0] as ParameterObject).required = true; + const request = { + url: 'http://localhost:8080/api/search', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Required query parameter param not found"`, + ); + }); + + it('should throw for extra parameters', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello%20world&extra=world', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Unexpected query parameters: extra"`, + ); + }); + }); + + describe('pipeDelimited', () => { + beforeEach(() => { + (schema.parameters![0] as ParameterObject).style = 'pipeDelimited'; + }); + + it('should parse a pipe separated array', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello|world', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual(['hello', 'world']); + }); + + it('should throw for missing required parameters', async () => { + (schema.parameters![0] as ParameterObject).required = true; + const request = { + url: 'http://localhost:8080/api/search', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Required query parameter param not found"`, + ); + }); + + it('should throw for extra parameters', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=hello|world&extra=world', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Unexpected query parameters: extra"`, + ); + }); + }); + }); + + describe('objects', () => { + describe('form', () => { + beforeEach(() => { + schema.parameters![0].schema.type = 'object'; + }); + + describe('explode=true', () => { + beforeEach(() => { + (schema.parameters![0] as ParameterObject).explode = true; + }); + + it('should parse a form object with a single key', async () => { + const request = { + url: 'http://localhost:8080/api/search?key=value', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual({ key: 'value' }); + }); + + it('should parse a form object with multiple keys', async () => { + const request = { + url: 'http://localhost:8080/api/search?key1=value1&key2=value2', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual({ key1: 'value1', key2: 'value2' }); + }); + + it('should throw for missing required parameters', async () => { + (schema.parameters![0] as ParameterObject).required = true; + const request = { + url: 'http://localhost:8080/api/search', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Required query parameter param not found"`, + ); + }); + + it('should respect other parameter encodings', async () => { + const parameter = { + name: 'extra', + in: 'query', + style: 'form', + explode: false, + schema: { type: 'array' }, + required: false, + } as ParameterObject; + schema.parameters!.push(parameter as any); + parser = new QueryParameterParser(operation, { ajv }); + const request = { + url: 'http://localhost:8080/api/search?key=value&otherkey=value2&extra=hello,world', + } as Request; + + const result = await parser.parse(request); + expect(result.param).toEqual({ + key: 'value', + otherkey: 'value2', + }); + expect(result.extra).toEqual(['hello', 'world']); + }); + }); + + describe('explode=false', () => { + beforeEach(() => { + (schema.parameters![0] as ParameterObject).explode = false; + }); + + it('should parse a form object with a single key', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=key,value', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual({ key: 'value' }); + }); + + it('should parse a form object with multiple keys', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=key1,value1,key2,value2', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual({ key1: 'value1', key2: 'value2' }); + }); + + it('should throw for missing required parameters', async () => { + (schema.parameters![0] as ParameterObject).required = true; + const request = { + url: 'http://localhost:8080/api/search', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Required query parameter param not found"`, + ); + }); + + it('should throw for extra parameters', async () => { + const request = { + url: 'http://localhost:8080/api/search?param=key,value&extra=world', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Unexpected query parameters: extra"`, + ); + }); + }); + }); + + describe('deepObject', () => { + beforeEach(() => { + (schema.parameters![0] as ParameterObject).style = 'deepObject'; + (schema.parameters![0] as ParameterObject).explode = true; + (schema.parameters![0] as ParameterObject).schema = { + type: 'object', + properties: { + key: { + type: 'string', + }, + }, + }; + }); + + it('should parse a deep object', async () => { + const request = { + url: 'http://localhost:8080/api/search?param[key]=value', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual({ key: 'value' }); + }); + + it('should parse a deep object with multiple keys', async () => { + const request = { + url: 'http://localhost:8080/api/search?param[key1]=value1¶m[key2]=value2', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual({ key1: 'value1', key2: 'value2' }); + }); + + it('should throw for missing required parameters', async () => { + (schema.parameters![0] as ParameterObject).required = true; + const request = { + url: 'http://localhost:8080/api/search', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Required query parameter param not found"`, + ); + }); + + it('should throw for extra parameters', async () => { + const request = { + url: 'http://localhost:8080/api/search?param[key]=value&extra=world', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (get)]: Unexpected query parameters: extra"`, + ); + }); + + it('should handle nested objects', async () => { + (schema.parameters![0] as ParameterObject).schema = { + type: 'object', + properties: { + key: { + type: 'object', + properties: { + subkey: { + type: 'string', + }, + }, + required: ['subkey'], + }, + }, + }; + parser = new QueryParameterParser(operation, { ajv }); + const request = { + url: 'http://localhost:8080/api/search?param[key][subkey]=value', + } as Request; + const result = await parser.parse(request); + expect(result.param).toEqual({ key: { subkey: 'value' } }); + }); + }); + }); +}); diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.ts new file mode 100644 index 0000000000..15570e120c --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.ts @@ -0,0 +1,420 @@ +/* + * Copyright 2024 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 { OpenAPIObject, ParameterObject, SchemaObject } from 'openapi3-ts'; +import { + Operation, + ParserOptions, + RequestParser, + Validator, + ValidatorParams, +} from './types'; +import Ajv from 'ajv'; +import { OperationError } from './errors'; +import { mockttpToFetchRequest } from './utils'; + +type ReferencelessSchemaObject = SchemaObject & { $ref?: never }; + +type ReferencelessParameterObject = Omit & { + schema: ReferencelessSchemaObject; +}; + +class BaseParameterParser { + ajv: Ajv; + operation: Operation; + parameters: Record = {}; + constructor(operation: Operation, options: ParserOptions) { + this.ajv = options.ajv; + this.operation = operation; + const { schema, path, method } = operation; + const parameters = schema.parameters || []; + for (const parameter of parameters) { + if ('$ref' in parameter) { + throw new Error( + `[(${method}) ${path}] Reference objects are not supported`, + ); + } + + if (!parameter.schema) { + throw new OperationError( + operation, + 'Schema not found for path parameter', + ); + } + if ('$ref' in parameter.schema) { + throw new OperationError( + this.operation, + 'Reference objects are not supported for parameters', + ); + } + if (parameter.in === 'query') { + this.parameters[parameter.name] = + parameter as ReferencelessParameterObject; + } + } + } +} + +export class QueryParameterParser + extends BaseParameterParser + implements RequestParser> +{ + async parse(request: Request) { + const { searchParams } = new URL(request.url); + const remainingQueryParameters = new Set(searchParams.keys()); + const queryParameters: Record = {}; + const parameterIterator = Object.entries(this.parameters).toSorted( + ([_, parameter]) => { + if (parameter.schema.type !== 'object') { + return -1; + } + if (parameter.style === 'form' || !parameter.style) { + if (parameter.explode || typeof parameter.explode === 'undefined') { + return 1; + } + return 0; + } + return 0; + }, + ); + for (const [name, parameter] of parameterIterator) { + if (!parameter.schema) { + throw new OperationError( + this.operation, + 'Schema not found for query parameter', + ); + } + if ('$ref' in parameter.schema) { + throw new OperationError( + this.operation, + 'Reference objects are not supported for parameters', + ); + } + // eslint-disable-next-line prefer-const + let [param, indices]: [any | null, string[]] = this.#findQueryParameters( + this.parameters, + queryParameters, + searchParams, + name, + ); + if (!!param) { + indices.forEach(index => remainingQueryParameters.delete(index)); + } + if (parameter.schema.type !== 'array' && Array.isArray(param)) { + param = param.length > 0 ? param[0] : undefined; + } + if ( + parameter.required && + !indices.some(index => searchParams.has(index)) + ) { + throw new OperationError( + this.operation, + `Required query parameter ${name} not found`, + ); + } else if (!param && !parameter.required) { + continue; + } + if (parameter.schema.type === 'integer') { + // Try to parse the integer as AJV won't do it for us. + param = parseInt(param, 10); + } + if (parameter.schema.type === 'number') { + // Try to parse the number as AJV won't do it for us. + param = parseFloat(param); + } + const validate = this.ajv.compile(parameter.schema); + const valid = validate(param); + if (!valid) { + throw new OperationError( + this.operation, + 'Query parameter validation failed', + ); + } + queryParameters[name] = param; + } + if (remainingQueryParameters.size > 0) { + throw new OperationError( + this.operation, + `Unexpected query parameters: ${Array.from( + remainingQueryParameters, + ).join(', ')}`, + ); + } + return queryParameters; + } + + #findQueryParameters( + parameters: Record, + currentQueryParameters: Record, + searchParams: URLSearchParams, + name: string, + ): [any | null, string[]] { + const parameter = parameters[name]; + const schema = parameter.schema as SchemaObject; + + const getIfExists = (key: string) => + searchParams.has(key) ? searchParams.getAll(key) : null; + + if (schema.type === 'array') { + if (parameter.style === 'form' || !parameter.style) { + if (parameter.explode || typeof parameter.explode === 'undefined') { + if (!searchParams.has(name) && searchParams.has(`${name}[0]`)) { + const values: string[] = []; + const indices: string[] = []; + let index = 0; + while (searchParams.has(`${name}[${index}]`)) { + values.push(searchParams.get(`${name}[${index}]`)!); + indices.push(`${name}[${index}]`); + index++; + } + return [values, indices]; + } + return [getIfExists(name), [name]]; + } + if (!searchParams.has(name) && searchParams.has(`${name}[]`)) { + return [searchParams.get(`${name}[]`)?.split(','), [`${name}[]`]]; + } + if (searchParams.has(name) && searchParams.getAll(name).length > 1) { + throw new OperationError( + this.operation, + 'Array parameter should not have multiple values', + ); + } + return [searchParams.get(name)?.split(','), [name]]; + } else if (parameter.style === 'spaceDelimited') { + return [searchParams.get(name)?.split(' '), [name]]; + } else if (parameter.style === 'pipeDelimited') { + return [searchParams.get(name)?.split('|'), [name]]; + } + throw new OperationError( + this.operation, + 'Unsupported style for array parameter', + ); + } + if (schema.type === 'object') { + if (parameter.style === 'form' || !parameter.style) { + if (parameter.explode) { + const obj: Record = {}; + const indices: string[] = []; + for (const [key, value] of searchParams.entries()) { + if ( + this.#matchesOtherQueryParameters(currentQueryParameters, key) + ) { + continue; + } + indices.push(key); + obj[key] = value; + } + return [obj, indices]; + } + const obj: Record = {}; + const value = searchParams.get(name); + if (value) { + const parts = value.split(','); + if (parts.length % 2 !== 0) { + throw new OperationError( + this.operation, + 'Invalid object parameter', + ); + } + for (let i = 0; i < parts.length; i += 2) { + obj[parts[i]] = parts[i + 1]; + } + } + return [obj, [name]]; + } else if (parameter.style === 'deepObject') { + const obj: Record = {}; + const indices: string[] = []; + for (const [key, value] of searchParams.entries()) { + if (key.startsWith(`${name}[`)) { + indices.push(key); + const parts = key.split('['); + let currentLayer = obj; + for (let partIndex = 1; partIndex < parts.length - 1; partIndex++) { + const part = parts[partIndex]; + if (!part.includes(']')) { + throw new OperationError( + this.operation, + 'Invalid object parameter', + ); + } + const objKey = part.split(']')[0]; + if (!currentLayer[objKey]) { + currentLayer[objKey] = {}; + } + currentLayer = currentLayer[objKey]; + } + const lastPart = parts[parts.length - 1]; + if (!lastPart.includes(']')) { + throw new OperationError( + this.operation, + 'Invalid object parameter', + ); + } + currentLayer[lastPart.split(']')[0]] = value; + } + } + return [obj, indices]; + } + throw new OperationError( + this.operation, + 'Unsupported style for object parameter', + ); + } + // For everything else, just return the value. + return [getIfExists(name), [name]]; + } + + #matchesOtherQueryParameters( + parameters: Record, + nameToMatch: string, + ) { + for (const [name] of Object.entries(parameters)) { + if (name === nameToMatch) { + return true; + } + } + return false; + } +} + +export class HeaderParameterParser + extends BaseParameterParser + implements RequestParser> +{ + async parse(request: Request) { + const headerParameters: Record = {}; + for (const [name, parameter] of Object.entries(this.parameters)) { + const header = request.headers.get(name); + if (!header) { + if (parameter.required) { + throw new OperationError( + this.operation, + `Header parameter ${name} not found`, + ); + } + continue; + } + if (!parameter.schema) { + throw new OperationError( + this.operation, + 'Schema not found for header parameter', + ); + } + if ('$ref' in parameter.schema) { + throw new OperationError( + this.operation, + 'Reference objects are not supported for parameters', + ); + } + const validate = this.ajv.compile(parameter.schema); + const valid = validate(header); + + if (!valid) { + throw new OperationError( + this.operation, + 'Header parameter validation failed', + ); + } + headerParameters[name] = header; + } + return headerParameters; + } +} + +export class PathParameterParser + extends BaseParameterParser + implements RequestParser> +{ + async parse(request: Request) { + const { pathname } = new URL(request.url); + const params = this.parsePath({ + path: pathname, + schema: this.operation.path, + }); + const pathParameters: Record = {}; + for (const [name, parameter] of Object.entries(this.parameters)) { + if (!params[name] && parameter.required) { + throw new OperationError( + this.operation, + `Path parameter ${name} not found`, + ); + } else if (!params[name] && !parameter.required) { + continue; + } + + const validate = this.ajv.compile(parameter.schema); + const valid = validate(params[name]); + + if (!valid) { + throw new OperationError( + this.operation, + 'Path parameter validation failed', + ); + } + pathParameters[name] = params[name]; + } + return pathParameters; + } + + parsePath({ schema, path }: { schema: string; path: string }) { + const parts = path.split('/'); + const pathParts = schema.split('/'); + if (parts.length !== pathParts.length) { + throw new OperationError(this.operation, 'Path parts do not match'); + } + const params: Record = {}; + for (let i = 0; i < parts.length; i++) { + if (pathParts[i] === parts[i]) { + continue; + } + if (pathParts[i].startsWith('{') && pathParts[i].endsWith('}')) { + params[pathParts[i].slice(1, -1)] = parts[i]; + continue; + } + break; + } + return params; + } +} + +export class ParameterValidator implements Validator { + schema: OpenAPIObject; + cache: Record = {}; + constructor(schema: OpenAPIObject) { + this.schema = schema; + } + + async validate({ pair: { request, response }, operation }: ValidatorParams) { + if (response.statusCode === 400) { + // If the response is a 400, then the request is invalid and we shouldn't validate the parameters + return; + } + + const ajv = new Ajv(); + const queryParser = new QueryParameterParser(operation, { ajv }); + const headerParser = new HeaderParameterParser(operation, { ajv }); + const pathParser = new PathParameterParser(operation, { ajv }); + + const fetchRequest = mockttpToFetchRequest(request); + + await Promise.all([ + queryParser.parse(fetchRequest), + headerParser.parse(fetchRequest), + pathParser.parse(fetchRequest), + ]); + } +} diff --git a/packages/backend-openapi-utils/src/schema/request-body-validation.ts b/packages/backend-openapi-utils/src/schema/request-body-validation.ts new file mode 100644 index 0000000000..f35b8f07c7 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/request-body-validation.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2024 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 { JsonObject } from '@backstage/types'; +import { Operation, ParserOptions, RequestParser } from './types'; +import { ValidateFunction } from 'ajv'; +import { OperationError } from './errors'; + +export class RequestBodyParser + implements RequestParser +{ + operation: Operation; + validate: + | { fn: ValidateFunction; disabled: false } + | { + fn: undefined; + disabled: true; + }; + constructor(operation: Operation, options: ParserOptions) { + this.operation = operation; + const { schema: operationSchema } = this.operation; + const requestBody = operationSchema.requestBody; + if (!requestBody) { + this.validate = { disabled: true, fn: undefined }; + return; + } + + if ('$ref' in requestBody!) { + throw new OperationError( + this.operation, + 'Reference objects are not supported', + ); + } + if (!requestBody!.content) { + throw new OperationError( + this.operation, + 'No content found in request body', + ); + } + if (!requestBody!.content['application/json']) { + throw new OperationError( + this.operation, + 'No application/json content type found in request body', + ); + } + const schema = requestBody!.content['application/json'].schema; + if (!schema) { + throw new OperationError( + this.operation, + 'No JSON schema found in request body', + ); + } + if ('$ref' in schema) { + throw new OperationError( + this.operation, + 'Reference objects are not supported', + ); + } + this.validate = { + disabled: false, + fn: options.ajv.compile(operation.schema), + }; + } + async parse(request: Request): Promise { + const { disabled, fn } = this.validate; + const bodyText = await request.text(); + if (!disabled && bodyText?.length) { + throw new OperationError( + this.operation, + `No request body found for ${request.url}`, + ); + } else if (disabled && !bodyText?.length) { + // If there is no request body in the schema and no body in the request, then the request is valid + return undefined; + } else if (disabled && bodyText?.length) { + throw new OperationError( + this.operation, + 'Received a body but no schema was found', + ); + } + + const contentType = + request.headers.get('content-type') || 'application/json'; + if (contentType !== 'application/json') { + throw new OperationError( + this.operation, + 'Content type is not application/json', + ); + } + const body = (await request.json()) as JsonObject; + const valid = fn!(body); + if (!valid) { + console.log(body); + console.error(fn!.errors); + throw new OperationError( + this.operation, + `Request body validation failed.`, + ); + } + return body; + } +} diff --git a/packages/backend-openapi-utils/src/schema/response-body-validation.ts b/packages/backend-openapi-utils/src/schema/response-body-validation.ts new file mode 100644 index 0000000000..d947a21970 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/response-body-validation.ts @@ -0,0 +1,102 @@ +/* + * Copyright 2024 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 { JsonObject } from '@backstage/types'; +import { Operation, ParserOptions, ResponseParser } from './types'; +import { OperationError } from './errors'; +import Ajv from 'ajv'; +import { OperationObject, ResponseObject } from 'openapi3-ts'; + +export class ResponseBodyParser + implements ResponseParser +{ + operation: Operation; + ajv: Ajv; + constructor(operation: Operation, options: ParserOptions) { + this.operation = operation; + this.ajv = options.ajv; + const responseSchemas = operation.schema.responses; + if (!Object.keys(responseSchemas).length) { + throw new OperationError(this.operation, `No response schemas found`); + } + for (const [statusCode, schema] of Object.entries(responseSchemas)) { + if (!schema.content) { + continue; + } else if (!schema.content['application/json']) { + throw new OperationError( + this.operation, + `No application/json content type found in response for status code ${statusCode}`, + ); + } else if ('$ref' in schema.content['application/json'].schema) { + throw new OperationError( + this.operation, + 'Reference objects are not supported', + ); + } + } + } + + async parse(response: Response): Promise { + const body = await response.text(); + const responseSchema = this.findResponseSchema( + this.operation.schema, + response, + ); + if (!responseSchema?.content && body?.length) { + throw new OperationError(this.operation, 'No content found in response'); + } else if (!responseSchema?.content && !body?.length) { + // If there is no content in the response schema and no body in the response, then the response is valid + return undefined; + } + if (!responseSchema?.content!['application/json']) { + throw new OperationError( + this.operation, + 'No application/json content type found in response', + ); + } + const schema = responseSchema.content!['application/json'].schema; + if (!schema) { + throw new OperationError(this.operation, 'No schema found in response'); + } + if ('$ref' in schema) { + throw new OperationError( + this.operation, + 'Reference objects are not supported', + ); + } + + const validate = this.ajv.compile(schema); + const jsonBody = (await response.json()) as JsonObject; + const valid = validate(jsonBody); + if (!valid) { + throw new OperationError( + this.operation, + 'Response body validation failed', + ); + } + return jsonBody; + } + + private findResponseSchema( + operationSchema: OperationObject, + response: Response, + ): ResponseObject | undefined { + const { status } = response; + return ( + operationSchema.responses?.[status] ?? operationSchema.responses?.default + ); + } +} diff --git a/packages/backend-openapi-utils/src/schema/types.ts b/packages/backend-openapi-utils/src/schema/types.ts new file mode 100644 index 0000000000..7de1e1c57e --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/types.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2024 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 { CompletedRequest, CompletedResponse } from 'mockttp'; +import { OperationObject } from 'openapi3-ts'; +import Ajv from 'ajv'; + +export interface RequestParser { + parse(request: Request): Promise; +} +export interface ResponseParser { + parse(response: Response): Promise; +} + +export interface ParserOptions { + ajv: Ajv; +} + +export interface Operation { + schema: OperationObject; + path: string; + method: string; +} + +export interface RequestResponsePair { + request: CompletedRequest; + response: CompletedResponse; +} + +export interface ValidatorParams { + pair: RequestResponsePair; + operation: Operation; +} + +export interface Validator { + validate(pair: ValidatorParams): Promise; +} diff --git a/packages/backend-openapi-utils/src/schema/utils.ts b/packages/backend-openapi-utils/src/schema/utils.ts new file mode 100644 index 0000000000..cc00d00095 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/utils.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2024 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 { CompletedRequest, CompletedResponse } from 'mockttp'; + +export function mockttpToFetchRequest(request: CompletedRequest) { + const headers = new Headers(request.rawHeaders); + return { + url: request.url, + method: request.method, + headers, + json: () => request.body.getJson(), + text: () => request.body.getText(), + } as Request; +} +export function mockttpToFetchResponse(response: CompletedResponse) { + const headers = new Headers(response.rawHeaders); + return { + status: response.statusCode, + headers, + json: () => response.body?.getJson(), + text: () => response.body?.getText(), + } as Response; +} diff --git a/packages/backend-openapi-utils/src/schema/validation.test.ts b/packages/backend-openapi-utils/src/schema/validation.test.ts new file mode 100644 index 0000000000..1c440fa7b5 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/validation.test.ts @@ -0,0 +1,711 @@ +/* + * Copyright 2024 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 { OpenApiProxyValidator } from './validation'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { CompletedBody, CompletedRequest, CompletedResponse } from 'mockttp'; +import withResponseBody from './__fixtures__/schemas/withJsonResponseBody.json'; +import withQueryParameter from './__fixtures__/schemas/withQueryParameter.json'; +import _ from 'lodash'; +import { ParameterObject } from 'openapi3-ts'; + +const server = setupServer(); + +function createMockttpRequest(request: { + method: string; + url: string; + headers?: Record; + body?: object; +}): CompletedRequest { + return { + method: request.method, + url: `http://localhost:8080${request.url}`, + headers: { 'content-type': 'application/json', ...request.headers }, + body: { + getText: async () => JSON.stringify(request.body), + getJson: async () => request.body, + } as CompletedBody, + } as CompletedRequest; +} + +function createMockttpResponse(response: { + statusCode: number; + headers?: Record; + body?: object; +}): CompletedResponse { + return { + statusCode: response.statusCode, + headers: response.headers, + body: response.body + ? ({ + getText: async () => JSON.stringify(response.body), + getJson: async () => response.body, + } as CompletedBody) + : undefined, + } as CompletedResponse; +} + +describe('OpenApiProxyValidator', () => { + setupRequestMockHandlers(server); + let validator: OpenApiProxyValidator; + + async function mockSchema(schema: any) { + server.use( + rest.get('http://localhost:7000/openapi.json', (_req, res, ctx) => + res(ctx.json(schema)), + ), + ); + await validator.initialize('http://localhost:7000/openapi.json'); + } + + beforeEach(async () => { + validator = new OpenApiProxyValidator(); + }); + + describe('request body', () => { + it('validates a JSON request body', async () => { + await mockSchema(withResponseBody); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + }); + const response = createMockttpResponse({ + statusCode: 200, + headers: { + 'content-type': 'application/json', + }, + body: { results: [] }, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('throws for missing request body per schema', async () => { + await mockSchema(withResponseBody); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + body: { id: '123' }, + }); + const response = createMockttpResponse({ + statusCode: 200, + headers: { + 'content-type': 'application/json', + }, + body: { results: [] }, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Received a body but no schema was found]`, + ); + }); + }); + + describe('query parameters', () => { + describe('primitives', () => { + describe('string', () => { + it('accepts valid parameter', async () => { + await mockSchema(withQueryParameter); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=abc', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + }); + describe('number', () => { + const schema = _.cloneDeep(withQueryParameter); + schema.paths['/api/search'].get.parameters[0].schema.type = 'number'; + it('throws for a missing required parameter', async () => { + await mockSchema(schema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?test=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Unexpected query parameters: test]`, + ); + }); + + it('throws for invalid parameter', async () => { + await mockSchema(schema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=abc', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Query parameter validation failed]`, + ); + }); + + it('accepts valid parameter', async () => { + await mockSchema(schema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + }); + }); + + describe('object', () => { + describe('deepObject', () => { + const schema = _.cloneDeep(withQueryParameter); + schema.paths['/api/search'].get.parameters[0].schema.type = 'object'; + ( + schema.paths['/api/search'].get.parameters[0] as ParameterObject + ).style = 'deepObject'; + it('throws for invalid parameter (not an object)', async () => { + await mockSchema(schema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Unexpected query parameters: param]`, + ); + }); + + it('throws for missing required property', async () => { + const requiredSchema = _.cloneDeep(schema); + requiredSchema.paths['/api/search'].get.parameters[0].required = true; + await mockSchema(requiredSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ); + }); + + it('throws for invalid format', async () => { + await mockSchema(schema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param[t=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Invalid object parameter]`, + ); + }); + }); + + describe('form', () => { + const schema = _.cloneDeep(withQueryParameter); + schema.paths['/api/search'].get.parameters[0].schema.type = 'object'; + ( + schema.paths['/api/search'].get.parameters[0] as ParameterObject + ).style = 'form'; + it('throws for invalid parameter (not an object)', async () => { + await mockSchema(schema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param[t=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Unexpected query parameters: param[t]`, + ); + }); + + it('throws for missing required property', async () => { + const requiredSchema = _.cloneDeep(schema); + requiredSchema.paths['/api/search'].get.parameters[0].required = true; + await mockSchema(requiredSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ); + }); + + it('throws for invalid format', async () => { + await mockSchema(schema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Invalid object parameter]`, + ); + }); + describe('explode', () => { + const explodeSchema = _.cloneDeep(schema); + ( + explodeSchema.paths['/api/search'].get + .parameters[0] as ParameterObject + ).explode = true; + it('accepts valid parameter', async () => { + await mockSchema(explodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123,test,456', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('accepts multiple parameters', async () => { + await mockSchema(explodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?test=123&myparam=test&otherparam=456', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + }); + + describe('no explode', () => { + const noExplodeSchema = _.cloneDeep(schema); + ( + noExplodeSchema.paths['/api/search'].get + .parameters[0] as ParameterObject + ).explode = false; + it('accepts valid parameter', async () => { + await mockSchema(schema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123,test,456,param', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('throws for invalid parameter', async () => { + await mockSchema(schema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123,test,456', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[/api/search (GET)]: Invalid object parameter"`, + ); + }); + }); + }); + }); + + describe('array', () => { + const arraySchema = _.cloneDeep(withQueryParameter); + arraySchema.paths['/api/search'].get.parameters[0].schema.type = 'array'; + describe('form', () => { + describe('explode', () => { + const explodeSchema = _.cloneDeep(arraySchema); + ( + explodeSchema.paths['/api/search'].get + .parameters[0] as ParameterObject + ).explode = true; + + it('accepts single parameter', async () => { + await mockSchema(explodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('accepts multiple parameters', async () => { + await mockSchema(explodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123¶m=test¶m=456', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('throws for missing required parameter', async () => { + const requiredSchema = _.cloneDeep(explodeSchema); + requiredSchema.paths['/api/search'].get.parameters[0].required = + true; + await mockSchema(requiredSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ); + }); + + it('throws for invalid parameter', async () => { + await mockSchema(explodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param[]=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Unexpected query parameters: param[]]`, + ); + }); + }); + + describe('no explode', () => { + const noExplodeSchema = _.cloneDeep(arraySchema); + ( + noExplodeSchema.paths['/api/search'].get + .parameters[0] as ParameterObject + ).explode = false; + + it('accepts single parameter', async () => { + await mockSchema(noExplodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('accepts multiple parameters', async () => { + await mockSchema(noExplodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123,456,789', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('throws for missing required parameter', async () => { + const requiredSchema = _.cloneDeep(noExplodeSchema); + requiredSchema.paths['/api/search'].get.parameters[0].required = + true; + await mockSchema(requiredSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ); + }); + + it('throws for invalid parameter', async () => { + await mockSchema(noExplodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123¶m=456', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Array parameter should not have multiple values]`, + ); + }); + }); + + describe('compatible with qs', () => { + const noExplodeSchema = _.cloneDeep(arraySchema); + ( + noExplodeSchema.paths['/api/search'].get + .parameters[0] as ParameterObject + ).explode = false; + + const explodeSchema = _.cloneDeep(arraySchema); + ( + explodeSchema.paths['/api/search'].get + .parameters[0] as ParameterObject + ).explode = true; + it('accepts the [] syntax', async () => { + await mockSchema(noExplodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param[]=123,456,789', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('accepts the array index syntax', async () => { + await mockSchema(explodeSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param[0]=123¶m[1]=456¶m[2]=789', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + }); + }); + + describe('spaceDelimited', () => { + const spaceDelimitedSchema = _.cloneDeep(arraySchema); + ( + spaceDelimitedSchema.paths['/api/search'].get + .parameters[0] as ParameterObject + ).style = 'spaceDelimited'; + + it('accepts single parameter', async () => { + await mockSchema(spaceDelimitedSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('accepts multiple parameters', async () => { + await mockSchema(spaceDelimitedSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123 test 456', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('throws for missing required parameter', async () => { + const requiredSchema = _.cloneDeep(spaceDelimitedSchema); + requiredSchema.paths['/api/search'].get.parameters[0].required = true; + await mockSchema(requiredSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ); + }); + }); + + describe('pipeDelimited', () => { + const pipeDelimitedSchema = _.cloneDeep(arraySchema); + ( + pipeDelimitedSchema.paths['/api/search'].get + .parameters[0] as ParameterObject + ).style = 'pipeDelimited'; + + it('accepts single parameter', async () => { + await mockSchema(pipeDelimitedSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('accepts multiple parameters', async () => { + await mockSchema(pipeDelimitedSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search?param=123|test|456', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('throws for missing required parameter', async () => { + const requiredSchema = _.cloneDeep(pipeDelimitedSchema); + requiredSchema.paths['/api/search'].get.parameters[0].required = true; + await mockSchema(requiredSchema); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + }); + const response = createMockttpResponse({ + statusCode: 200, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ); + }); + }); + }); + }); + + describe('response body', () => { + it('validates a JSON response body', async () => { + await mockSchema(withResponseBody); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + }); + const response = createMockttpResponse({ + statusCode: 200, + headers: { + 'content-type': 'application/json', + }, + body: { results: [] }, + }); + + expect(await validator.validate(request, response)).toBeUndefined(); + }); + + it('throws for missing response body per schema', async () => { + await mockSchema(withResponseBody); + const request = createMockttpRequest({ + method: 'GET', + url: '/api/search', + }); + const response = createMockttpResponse({ + statusCode: 200, + headers: { + 'content-type': 'application/json', + }, + }); + + await expect( + async () => await validator.validate(request, response), + ).rejects.toMatchInlineSnapshot( + `[Error: [/api/search (GET)]: Response body validation failed]`, + ); + }); + }); +}); diff --git a/packages/backend-openapi-utils/src/schema/validation.ts b/packages/backend-openapi-utils/src/schema/validation.ts index 9f2f22b9ac..b5a96e050a 100644 --- a/packages/backend-openapi-utils/src/schema/validation.ts +++ b/packages/backend-openapi-utils/src/schema/validation.ts @@ -23,489 +23,40 @@ import { } from 'openapi3-ts'; import Ajv from 'ajv'; import Parser from '@apidevtools/swagger-parser'; +import { Operation, Validator, ValidatorParams } from './types'; +import { ParameterValidator } from './parameter-validation'; +import { OperationError } from './errors'; +import { RequestBodyParser } from './request-body-validation'; +import { mockttpToFetchRequest, mockttpToFetchResponse } from './utils'; +import { ResponseBodyParser } from './response-body-validation'; const ajv = new Ajv({ allErrors: true }); // options can be passed, e.g. {allErrors: true} -interface RequestResponsePair { - request: CompletedRequest; - response: CompletedResponse; -} - -interface ValidatorParams { - pair: RequestResponsePair; - operationSchema: OperationObject; - path: string; -} - -interface Validator { - validate(pair: ValidatorParams): Promise; -} - -class RequestErrorFactory { - static createRequestError(request: CompletedRequest, message: string): Error { - return new Error(`[${request.url} (${request.method})]: ${message}`); - } -} - -export class ParameterValidator implements Validator { - schema: OpenAPIObject; - cache: Record = {}; - constructor(schema: OpenAPIObject) { - this.schema = schema; - } - - async validate({ - pair: { request, response }, - operationSchema, - path, - }: ValidatorParams) { - if (response.statusCode === 400) { - // If the response is a 400, then the request is invalid and we shouldn't validate the parameters - return; - } - const parameters = operationSchema.parameters; - const queryParameters: Record = {}; - const headerParameters: Record = {}; - const pathParameters: Record = {}; - for (const parameter of parameters || []) { - if ('$ref' in parameter) { - throw RequestErrorFactory.createRequestError( - request, - 'Reference objects are not supported', - ); - } - if (parameter.in === 'query') { - queryParameters[parameter.name] = parameter; - } - if (parameter.in === 'header') { - headerParameters[parameter.name] = parameter; - } - if (parameter.in === 'path') { - pathParameters[parameter.name] = parameter; - } - } - this.validateQueryParameters(queryParameters, request); - this.validateHeaderParameters(headerParameters, request); - this.validatePathParameters(pathParameters, request, path); - } - - validateQueryParameters( - queryParameters: Record, - request: CompletedRequest, - ) { - const { searchParams } = new URL(request.url); - for (const [name, parameter] of Object.entries(queryParameters)) { - if (!parameter.schema) { - throw RequestErrorFactory.createRequestError( - request, - 'Schema not found for query parameter', - ); - } - if ('$ref' in parameter.schema) { - throw RequestErrorFactory.createRequestError( - request, - 'Reference objects are not supported for parameters', - ); - } - let param: any | null = this.#findQueryParameters( - request, - queryParameters, - searchParams, - name, - ); - if (parameter.schema.type !== 'array' && Array.isArray(param)) { - param = param.length > 0 ? param[0] : undefined; - } - - if (!param && parameter.required) { - throw RequestErrorFactory.createRequestError( - request, - `Required query parameter ${name} not found`, - ); - } else if (!param && !parameter.required) { - continue; - } - if (parameter.schema.type === 'integer') { - // Try to parse the integer as AJV won't do it for us. - param = parseInt(param, 10); - } - const validate = ajv.compile(parameter.schema); - const valid = validate(param); - if (!valid) { - console.log(param); - console.error(validate.errors); - throw RequestErrorFactory.createRequestError( - request, - 'Query parameter validation failed', - ); - } - } - } - - #findQueryParameters( - request: CompletedRequest, - parameters: Record, - searchParams: URLSearchParams, - name: string, - ) { - const parameter = parameters[name]; - const schema = parameter.schema as SchemaObject; - if (schema.type === 'array') { - if (parameter.style === 'form' || !parameter.style) { - if (parameter.explode || typeof parameter.explode === 'undefined') { - if (!searchParams.has(name) && searchParams.has(`${name}[0]`)) { - const values: string[] = []; - let index = 0; - while (searchParams.has(`${name}[${index}]`)) { - values.push(searchParams.get(`${name}[${index}]`)!); - index++; - } - return values; - } - return searchParams.getAll(name); - } - if (!searchParams.has(name) && searchParams.has(`${name}[]`)) { - return searchParams.getAll(`${name}[]`); - } - return searchParams.get(name)?.split(','); - } else if (parameter.style === 'spaceDelimited') { - return searchParams.get(name)?.split(' '); - } else if (parameter.style === 'pipeDelimited') { - return searchParams.get(name)?.split('|'); - } - throw RequestErrorFactory.createRequestError( - request, - 'Unsupported style for array parameter', - ); - } - if (schema.type === 'object') { - if (parameter.style === 'form' || !parameter.style) { - if (parameter.explode) { - const obj: Record = {}; - for (const [key, value] of searchParams.entries()) { - if (this.#matchesOtherQueryParameters(parameters, key)) { - continue; - } - obj[key] = value; - } - console.log(obj); - return obj; - } - const obj: Record = {}; - const value = searchParams.get(name); - if (value) { - const parts = value.split(','); - if (parts.length % 2 !== 0) { - throw RequestErrorFactory.createRequestError( - request, - 'Invalid object parameter', - ); - } - for (let i = 0; i < parts.length; i += 2) { - obj[parts[i]] = parts[i + 1]; - } - } - return obj; - } else if (parameter.style === 'deepObject') { - const obj: Record = {}; - for (const [key, value] of searchParams.entries()) { - if (key.startsWith(`${name}[`)) { - const parts = key.split('['); - let currentLayer = obj; - for (let partIndex = 0; partIndex < parts.length - 1; partIndex++) { - const part = parts[partIndex]; - const objKey = part.split(']')[0]; - if (!currentLayer[objKey]) { - currentLayer[objKey] = {}; - } - currentLayer = currentLayer[objKey]; - } - currentLayer[parts[parts.length - 1].split(']')[0]] = value; - } - } - return obj; - } - throw RequestErrorFactory.createRequestError( - request, - 'Unsupported style for object parameter', - ); - } - // For everything else, just return the value. - return searchParams.getAll(name); - } - - #matchesOtherQueryParameters( - parameters: Record, - nameToMatch: string, - ) { - for (const [name] of Object.entries(parameters)) { - if (name === nameToMatch) { - return true; - } - } - return false; - } - - validateHeaderParameters( - headerParameters: Record, - request: CompletedRequest, - ) { - for (const [name, parameter] of Object.entries(headerParameters)) { - if (!request.headers[name]) { - throw RequestErrorFactory.createRequestError( - request, - `Header parameter ${name} not found`, - ); - } - if (!parameter.schema) { - throw RequestErrorFactory.createRequestError( - request, - 'Schema not found for path parameter', - ); - } - if ('$ref' in parameter.schema) { - throw RequestErrorFactory.createRequestError( - request, - 'Reference objects are not supported for parameters', - ); - } - const validate = ajv.compile(parameter.schema); - const valid = validate(request.headers[name]); - - if (!valid) { - console.log(request.headers[name]); - console.error(validate.errors); - throw RequestErrorFactory.createRequestError( - request, - 'Header parameter validation failed', - ); - } - } - } - - validatePathParameters( - pathParameters: Record, - request: CompletedRequest, - path: string, - ) { - const { pathname } = new URL(request.url); - const params = parsePath({ request, path: pathname, schema: path }); - for (const [name, parameter] of Object.entries(pathParameters)) { - if (!params[name] && parameter.required) { - throw RequestErrorFactory.createRequestError( - request, - `Path parameter ${name} not found`, - ); - } - if (!parameter.schema) { - throw RequestErrorFactory.createRequestError( - request, - 'Schema not found for path parameter', - ); - } - if ('$ref' in parameter.schema) { - throw RequestErrorFactory.createRequestError( - request, - 'Reference objects are not supported for parameters', - ); - } - - const validate = ajv.compile(parameter.schema); - const valid = validate(params[name]); - - if (!valid) { - console.log(params); - console.error(validate.errors); - throw RequestErrorFactory.createRequestError( - request, - 'Path parameter validation failed', - ); - } - } - } -} - -function parsePath({ - request, - schema, - path, -}: { - request: CompletedRequest; - schema: string; - path: string; -}) { - const parts = path.split('/'); - const pathParts = schema.split('/'); - if (parts.length !== pathParts.length) { - throw RequestErrorFactory.createRequestError( - request, - 'Path parts do not match', - ); - } - const params: Record = {}; - for (let i = 0; i < parts.length; i++) { - if (pathParts[i] === parts[i]) { - continue; - } - if (pathParts[i].startsWith('{') && pathParts[i].endsWith('}')) { - params[pathParts[i].slice(1, -1)] = parts[i]; - continue; - } - break; - } - return params; -} - -export class RequestBodyValidator implements Validator { +class RequestBodyValidator implements Validator { schema: OpenAPIObject; constructor(schema: OpenAPIObject) { this.schema = schema; } - async validate({ - pair: { request, response }, - operationSchema, - }: ValidatorParams) { - if (response.statusCode === 400) { - // If the response is a 400, then the request is invalid and we shouldn't validate the request body - return; - } - const requestBody = operationSchema.requestBody; - const bodyText = await request.body.getText(); - if (!requestBody && bodyText?.length) { - throw RequestErrorFactory.createRequestError( - request, - `No request body found for ${request.url}`, - ); - } else if (!requestBody && !bodyText?.length) { - // If there is no request body in the schema and no body in the request, then the request is valid - return; - } - if ('$ref' in requestBody!) { - throw RequestErrorFactory.createRequestError( - request, - 'Reference objects are not supported', - ); - } - if (!requestBody!.content) { - throw RequestErrorFactory.createRequestError( - request, - 'No content found in request body', - ); - } - if (!requestBody!.content['application/json']) { - throw RequestErrorFactory.createRequestError( - request, - 'No application/json content type found in request body', - ); - } - const contentType = request.headers['content-type']; - if (!contentType) { - throw RequestErrorFactory.createRequestError( - request, - 'Content type not found in request', - ); - } - if (contentType !== 'application/json') { - throw RequestErrorFactory.createRequestError( - request, - 'Content type is not application/json', - ); - } - const schema = requestBody!.content['application/json'].schema; - if (!schema) { - throw RequestErrorFactory.createRequestError( - request, - 'No schema found in request body', - ); - } - if ('$ref' in schema) { - throw RequestErrorFactory.createRequestError( - request, - 'Reference objects are not supported', - ); - } - - const validate = ajv.compile(schema); - const body = await request.body.getJson(); - const valid = validate(body); - if (!valid) { - console.log(body); - console.error(validate.errors); - throw RequestErrorFactory.createRequestError( - request, - `Request body validation failed.`, - ); - } + async validate({ pair, operation }: ValidatorParams) { + const { request } = pair; + const parser = new RequestBodyParser(operation, { ajv }); + const fetchRequest = mockttpToFetchRequest(request); + await parser.parse(fetchRequest); } } -export class ResponseBodyValidator implements Validator { +class ResponseBodyValidator implements Validator { schema: OpenAPIObject; constructor(schema: OpenAPIObject) { this.schema = schema; } - async validate(pair: ValidatorParams) { - const { - pair: { response, request }, - operationSchema, - } = pair; - const responseSchema = this.findResponseSchema(operationSchema, response); - if (!responseSchema) { - throw RequestErrorFactory.createRequestError( - request, - `No response schema found for ${response.statusCode}`, - ); - } - const body = await response.body.getText(); - if (!responseSchema.content && body?.length) { - throw RequestErrorFactory.createRequestError( - request, - 'No content found in response', - ); - } else if (!responseSchema.content && !body?.length) { - // If there is no content in the response schema and no body in the response, then the response is valid - return; - } - if (!responseSchema.content!['application/json']) { - throw RequestErrorFactory.createRequestError( - request, - 'No application/json content type found in response', - ); - } - const schema = responseSchema.content!['application/json'].schema; - if (!schema) { - throw RequestErrorFactory.createRequestError( - request, - 'No schema found in response', - ); - } - if ('$ref' in schema) { - throw RequestErrorFactory.createRequestError( - request, - 'Reference objects are not supported', - ); - } - - const validate = ajv.compile(schema); - const valid = validate(await response.body.getJson()); - if (!valid) { - console.log(await response.body.getJson()); - console.error(validate.errors); - throw RequestErrorFactory.createRequestError( - request, - 'Response body validation failed', - ); - } - } - - private findResponseSchema( - operationSchema: OperationObject, - response: CompletedResponse, - ): ResponseObject | undefined { - const { statusCode } = response; - return operationSchema.responses?.[statusCode]; + async validate({ pair, operation }: ValidatorParams) { + const { response } = pair; + const parser = new ResponseBodyParser(operation, { ajv }); + const fetchResponse = mockttpToFetchResponse(response); + await parser.parse(fetchResponse); } } @@ -518,28 +69,28 @@ export class OpenApiProxyValidator { this.validators = [ new ParameterValidator(this.schema), new RequestBodyValidator(this.schema), - // new ResponseBodyValidator(this.schema), + new ResponseBodyValidator(this.schema), ]; } async validate(request: CompletedRequest, response: CompletedResponse) { - const operation = this.findOperation(request); - if (!operation) { - throw RequestErrorFactory.createRequestError( - request, + const operationPathTuple = this.findOperation(request); + if (!operationPathTuple) { + throw new OperationError( + { path: request.path, method: request.method } as Operation, `No operation schema found for ${request.url}`, ); } - const [path, operationSchema] = operation; + const [path, operationSchema] = operationPathTuple; + const operation = { path, method: request.method, schema: operationSchema }; const validators = this.validators!; await Promise.all( validators.map(validator => validator.validate({ pair: { request, response }, - operationSchema, - path, + operation, }), ), ); diff --git a/yarn.lock b/yarn.lock index 036ac5c852..bcc6a951fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3740,8 +3740,10 @@ __metadata: dependencies: "@apidevtools/swagger-parser": ^10.1.0 "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/types": "workspace:^" "@types/express": ^4.17.6 "@types/express-serve-static-core": ^4.17.5 ajv: ^8.16.0 @@ -3751,6 +3753,7 @@ __metadata: json-schema-to-ts: ^3.0.0 lodash: ^4.17.21 mockttp: ^3.13.0 + msw: ^1.0.0 openapi-merge: ^1.3.2 openapi3-ts: ^3.1.2 supertest: ^7.0.0 From ea0b6b42628c71a5b5eb4321006d4966635432bd Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 7 Jul 2024 14:50:12 -0400 Subject: [PATCH 096/291] adding test for request and response validation and adjusting error messages Signed-off-by: aramissennyeydd --- .../schemas/withJsonRequestBody.json | 29 +++++ .../schemas/withJsonResponseBody.json | 3 +- .../schemas/withPathParameter.json | 25 +++++ .../src/schema/errors.ts | 16 ++- .../src/schema/parameter-validation.test.ts | 101 +++++++++++++++--- .../src/schema/parameter-validation.ts | 49 ++++++--- .../schema/request-body-validation.test.ts | 82 ++++++++++++++ .../src/schema/request-body-validation.ts | 65 ++++++----- .../schema/response-body-validation.test.ts | 81 ++++++++++++++ .../src/schema/response-body-validation.ts | 76 ++++++++++--- .../src/schema/validation.test.ts | 66 ++++++------ .../src/schema/validation.ts | 4 +- 12 files changed, 493 insertions(+), 104 deletions(-) create mode 100644 packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonRequestBody.json create mode 100644 packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withPathParameter.json create mode 100644 packages/backend-openapi-utils/src/schema/request-body-validation.test.ts create mode 100644 packages/backend-openapi-utils/src/schema/response-body-validation.test.ts diff --git a/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonRequestBody.json b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonRequestBody.json new file mode 100644 index 0000000000..c5eda7ebb4 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonRequestBody.json @@ -0,0 +1,29 @@ +{ + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": { + "/api/search": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "query": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + } + } +} diff --git a/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonResponseBody.json b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonResponseBody.json index 378ea0a168..6243a640b9 100644 --- a/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonResponseBody.json +++ b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withJsonResponseBody.json @@ -23,7 +23,8 @@ } } } - } + }, + "additionalProperties": false } } } diff --git a/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withPathParameter.json b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withPathParameter.json new file mode 100644 index 0000000000..98e25d0975 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/__fixtures__/schemas/withPathParameter.json @@ -0,0 +1,25 @@ +{ + "openapi": "3.0.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": { + "/api/item/{id}": { + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + } + } +} diff --git a/packages/backend-openapi-utils/src/schema/errors.ts b/packages/backend-openapi-utils/src/schema/errors.ts index aab5210f46..44de054c53 100644 --- a/packages/backend-openapi-utils/src/schema/errors.ts +++ b/packages/backend-openapi-utils/src/schema/errors.ts @@ -18,6 +18,20 @@ import { Operation } from './types'; export class OperationError extends Error { constructor(operation: Operation, message: string) { - super(`[${operation.path} (${operation.method})]: ${message}`); + super( + `["${operation.method.toLocaleUpperCase('en-US')} ${ + operation.path + }"] ${message}`, + ); + } +} + +export class OperationResponseError extends Error { + constructor(operation: Operation, response: Response, message: string) { + super( + `["${operation.method.toLocaleUpperCase('en-US')} ${operation.path}" (${ + response.status + })]: ${message}`, + ); } } diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts index 2fb8f371b1..673f22f99b 100644 --- a/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts @@ -16,7 +16,11 @@ import _ from 'lodash'; import withQueryParameter from './__fixtures__/schemas/withQueryParameter.json'; -import { QueryParameterParser } from './parameter-validation'; +import withPathParameter from './__fixtures__/schemas/withPathParameter.json'; +import { + PathParameterParser, + QueryParameterParser, +} from './parameter-validation'; import { OperationObject, ParameterObject } from 'openapi3-ts'; import Ajv from 'ajv'; import { Operation } from './types'; @@ -54,7 +58,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Unexpected query parameters: extra"`, + `"["GET /api/search"] Unexpected query parameters: extra"`, ); }); @@ -66,7 +70,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Required query parameter param not found"`, + `"["GET /api/search"] Required query parameter param not found"`, ); }); }); @@ -90,7 +94,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Query parameter validation failed"`, + `"["GET /api/search"] Query parameter validation failed"`, ); }); }); @@ -132,7 +136,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Required query parameter param not found"`, + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -143,7 +147,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Unexpected query parameters: extra"`, + `"["GET /api/search"] Unexpected query parameters: extra"`, ); }); }); @@ -177,7 +181,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Required query parameter param not found"`, + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -188,7 +192,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Unexpected query parameters: extra"`, + `"["GET /api/search"] Unexpected query parameters: extra"`, ); }); }); @@ -215,7 +219,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Required query parameter param not found"`, + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -226,7 +230,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Unexpected query parameters: extra"`, + `"["GET /api/search"] Unexpected query parameters: extra"`, ); }); }); @@ -252,7 +256,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Required query parameter param not found"`, + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -263,7 +267,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Unexpected query parameters: extra"`, + `"["GET /api/search"] Unexpected query parameters: extra"`, ); }); }); @@ -304,7 +308,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Required query parameter param not found"`, + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -361,7 +365,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Required query parameter param not found"`, + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -372,7 +376,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Unexpected query parameters: extra"`, + `"["GET /api/search"] Unexpected query parameters: extra"`, ); }); }); @@ -416,7 +420,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Required query parameter param not found"`, + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -427,7 +431,7 @@ describe('query parameters', () => { await expect( parser.parse(request), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (get)]: Unexpected query parameters: extra"`, + `"["GET /api/search"] Unexpected query parameters: extra"`, ); }); @@ -456,3 +460,66 @@ describe('query parameters', () => { }); }); }); + +describe('path parameters', () => { + let operation: Operation; + let parser: PathParameterParser; + let schema: (typeof withPathParameter)['paths']['/api/item/{id}']['get']; + + beforeEach(() => { + schema = _.cloneDeep(withPathParameter.paths['/api/item/{id}'].get); + operation = { + schema: schema as OperationObject, + path: '/api/item/{id}', + method: 'get', + }; + parser = new PathParameterParser(operation, { ajv }); + }); + describe('primitives', () => { + describe('string', () => { + it('should parse a string', async () => { + const request = { + url: 'http://localhost:8080/api/item/test', + } as Request; + const result = await parser.parse(request); + expect(result.id).toBe('test'); + }); + + it('should throw an error if the parameter is required but missing', async () => { + (schema.parameters![0] as ParameterObject).required = true; + const request = { + url: 'http://localhost:8080/api/item', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/item/{id}"] Path parts do not match"`, + ); + }); + }); + + describe('number', () => { + beforeEach(() => { + schema.parameters![0].schema.type = 'number'; + }); + it('should parse a number', async () => { + const request = { + url: 'http://localhost:8080/api/item/42', + } as Request; + const result = await parser.parse(request); + expect(result.id).toBe(42); + }); + + it('should throw an error if the parameter is not a number', async () => { + const request = { + url: 'http://localhost:8080/api/item/hello', + } as Request; + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/item/{id}"] Path parameter validation failed"`, + ); + }); + }); + }); +}); diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.ts index 15570e120c..ddc0efbe45 100644 --- a/packages/backend-openapi-utils/src/schema/parameter-validation.ts +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.ts @@ -36,7 +36,11 @@ class BaseParameterParser { ajv: Ajv; operation: Operation; parameters: Record = {}; - constructor(operation: Operation, options: ParserOptions) { + constructor( + parameterIn: string, + operation: Operation, + options: ParserOptions, + ) { this.ajv = options.ajv; this.operation = operation; const { schema, path, method } = operation; @@ -60,18 +64,31 @@ class BaseParameterParser { 'Reference objects are not supported for parameters', ); } - if (parameter.in === 'query') { + if (parameter.in === parameterIn) { this.parameters[parameter.name] = parameter as ReferencelessParameterObject; } } } + + optimisticallyParseValue(value: string, schema: SchemaObject) { + if (schema.type === 'integer') { + return parseInt(value, 10); + } + if (schema.type === 'number') { + return parseFloat(value); + } + return value; + } } export class QueryParameterParser extends BaseParameterParser implements RequestParser> { + constructor(operation: Operation, options: ParserOptions) { + super('query', operation, options); + } async parse(request: Request) { const { searchParams } = new URL(request.url); const remainingQueryParameters = new Set(searchParams.keys()); @@ -127,13 +144,8 @@ export class QueryParameterParser } else if (!param && !parameter.required) { continue; } - if (parameter.schema.type === 'integer') { - // Try to parse the integer as AJV won't do it for us. - param = parseInt(param, 10); - } - if (parameter.schema.type === 'number') { - // Try to parse the number as AJV won't do it for us. - param = parseFloat(param); + if (param) { + param = this.optimisticallyParseValue(param, parameter.schema); } const validate = this.ajv.compile(parameter.schema); const valid = validate(param); @@ -295,6 +307,9 @@ export class HeaderParameterParser extends BaseParameterParser implements RequestParser> { + constructor(operation: Operation, options: ParserOptions) { + super('header', operation, options); + } async parse(request: Request) { const headerParameters: Record = {}; for (const [name, parameter] of Object.entries(this.parameters)) { @@ -339,15 +354,19 @@ export class PathParameterParser extends BaseParameterParser implements RequestParser> { + constructor(operation: Operation, options: ParserOptions) { + super('path', operation, options); + } async parse(request: Request) { const { pathname } = new URL(request.url); const params = this.parsePath({ path: pathname, schema: this.operation.path, }); - const pathParameters: Record = {}; + const pathParameters: Record = {}; for (const [name, parameter] of Object.entries(this.parameters)) { - if (!params[name] && parameter.required) { + let param: string | number = params[name]; + if (!param && parameter.required) { throw new OperationError( this.operation, `Path parameter ${name} not found`, @@ -356,8 +375,12 @@ export class PathParameterParser continue; } + if (param) { + param = this.optimisticallyParseValue(param, parameter.schema); + } + const validate = this.ajv.compile(parameter.schema); - const valid = validate(params[name]); + const valid = validate(param); if (!valid) { throw new OperationError( @@ -365,7 +388,7 @@ export class PathParameterParser 'Path parameter validation failed', ); } - pathParameters[name] = params[name]; + pathParameters[name] = param; } return pathParameters; } diff --git a/packages/backend-openapi-utils/src/schema/request-body-validation.test.ts b/packages/backend-openapi-utils/src/schema/request-body-validation.test.ts new file mode 100644 index 0000000000..a4452763fc --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/request-body-validation.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2024 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 withJsonRequestBody from './__fixtures__/schemas/withJsonRequestBody.json'; +import { RequestBodyParser } from './request-body-validation'; +import Ajv from 'ajv'; +import { Operation, RequestParser } from './types'; +import _ from 'lodash'; +import { OperationObject, RequestBodyObject } from 'openapi3-ts'; +import { JsonObject } from '@backstage/types'; + +const ajv = new Ajv(); + +function toRequest(body?: object, headers?: Record): Request { + return { + text: async () => JSON.stringify(body), + json: async () => body, + url: '/api/search', + method: 'post', + headers: new Headers({ 'content-type': 'application/json', ...headers }), + } as Request; +} + +describe('request body', () => { + let operation: Operation; + let parser: RequestParser; + let schema: (typeof withJsonRequestBody)['paths']['/api/search']['post']; + beforeEach(() => { + schema = _.cloneDeep(withJsonRequestBody.paths['/api/search'].post); + operation = { + method: 'post', + schema: schema as OperationObject, + path: '/api/search', + }; + parser = new RequestBodyParser(operation, { + ajv, + }); + }); + it('should validate request body', async () => { + const requestBody = { + query: 'test', + }; + const result = await parser.parse(toRequest(requestBody)); + expect(result).toEqual(requestBody); + }); + + it('should throw error if request body is not valid', async () => { + const requestBody = { + query: 1, + }; + await expect( + parser.parse(toRequest(requestBody)), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["POST /api/search"] Request body validation failed."`, + ); + }); + + it('should throw error if request body is required but missing', async () => { + (schema.requestBody as RequestBodyObject).required = true; + parser = RequestBodyParser.fromOperation(operation, { + ajv, + }); + await expect( + parser.parse(toRequest()), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["POST /api/search"] No request body found for /api/search"`, + ); + }); +}); diff --git a/packages/backend-openapi-utils/src/schema/request-body-validation.ts b/packages/backend-openapi-utils/src/schema/request-body-validation.ts index f35b8f07c7..22873c8100 100644 --- a/packages/backend-openapi-utils/src/schema/request-body-validation.ts +++ b/packages/backend-openapi-utils/src/schema/request-body-validation.ts @@ -18,24 +18,51 @@ import { JsonObject } from '@backstage/types'; import { Operation, ParserOptions, RequestParser } from './types'; import { ValidateFunction } from 'ajv'; import { OperationError } from './errors'; +import { RequestBodyObject, SchemaObject } from 'openapi3-ts'; +class DisabledRequestBodyParser + implements RequestParser +{ + operation: Operation; + constructor(operation: Operation) { + this.operation = operation; + } + async parse(request: Request): Promise { + const bodyText = await request.text(); + if (bodyText?.length) { + throw new OperationError( + this.operation, + 'Received a body but no schema was found', + ); + } + return undefined; + } +} export class RequestBodyParser implements RequestParser { operation: Operation; - validate: - | { fn: ValidateFunction; disabled: false } - | { - fn: undefined; - disabled: true; - }; + disabled: boolean = false; + validate!: ValidateFunction; + schema!: SchemaObject; + requestBodySchema!: RequestBodyObject; + + static fromOperation(operation: Operation, options: ParserOptions) { + return operation.schema.requestBody + ? new RequestBodyParser(operation, options) + : new DisabledRequestBodyParser(operation); + } + constructor(operation: Operation, options: ParserOptions) { this.operation = operation; const { schema: operationSchema } = this.operation; const requestBody = operationSchema.requestBody; + if (!requestBody) { - this.validate = { disabled: true, fn: undefined }; - return; + throw new OperationError( + this.operation, + 'No request body found in operation', + ); } if ('$ref' in requestBody!) { @@ -69,27 +96,17 @@ export class RequestBodyParser 'Reference objects are not supported', ); } - this.validate = { - disabled: false, - fn: options.ajv.compile(operation.schema), - }; + this.validate = options.ajv.compile(schema); + this.schema = schema; + this.requestBodySchema = requestBody; } async parse(request: Request): Promise { - const { disabled, fn } = this.validate; const bodyText = await request.text(); - if (!disabled && bodyText?.length) { + if (this.requestBodySchema.required && !bodyText?.length) { throw new OperationError( this.operation, `No request body found for ${request.url}`, ); - } else if (disabled && !bodyText?.length) { - // If there is no request body in the schema and no body in the request, then the request is valid - return undefined; - } else if (disabled && bodyText?.length) { - throw new OperationError( - this.operation, - 'Received a body but no schema was found', - ); } const contentType = @@ -101,10 +118,8 @@ export class RequestBodyParser ); } const body = (await request.json()) as JsonObject; - const valid = fn!(body); + const valid = this.validate(body); if (!valid) { - console.log(body); - console.error(fn!.errors); throw new OperationError( this.operation, `Request body validation failed.`, diff --git a/packages/backend-openapi-utils/src/schema/response-body-validation.test.ts b/packages/backend-openapi-utils/src/schema/response-body-validation.test.ts new file mode 100644 index 0000000000..9ad47b8a19 --- /dev/null +++ b/packages/backend-openapi-utils/src/schema/response-body-validation.test.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2024 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 { JsonObject } from '@backstage/types'; +import withJsonResponseBody from './__fixtures__/schemas/withJsonResponseBody.json'; +import { Operation, ResponseParser } from './types'; +import { ResponseBodyParser } from './response-body-validation'; +import Ajv from 'ajv'; +import { OperationObject, ResponsesObject } from 'openapi3-ts'; +import _ from 'lodash'; + +const ajv = new Ajv(); + +function toResponse(body?: object): Response { + return { + json: async () => body, + text: async () => JSON.stringify(body), + status: 200, + } as Response; +} + +describe('response body', () => { + let operation: Operation; + let parser: ResponseParser; + let schema: (typeof withJsonResponseBody)['paths']['/api/search']['get']; + beforeEach(() => { + schema = _.cloneDeep(withJsonResponseBody.paths['/api/search'].get); + operation = { + path: '/api/search', + method: 'get', + schema: schema as OperationObject, + }; + parser = ResponseBodyParser.fromOperation(operation, { + ajv, + }); + }); + + it('should validate response body', async () => { + const responseBody = { + results: [{ id: 'test' }], + }; + const result = await parser.parse(toResponse(responseBody)); + expect(result).toEqual(responseBody); + }); + + it('should throw error if response body is not valid', async () => { + const responseBody = { + result: 1, + }; + await expect( + parser.parse(toResponse(responseBody)), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search" (200)]: Response body validation failed"`, + ); + }); + + it('should throw error if response body is required but missing', async () => { + (schema.responses as ResponsesObject)['200'].required = true; + parser = ResponseBodyParser.fromOperation(operation, { + ajv, + }); + await expect( + parser.parse(toResponse()), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search" (200)]: Response body is required but missing"`, + ); + }); +}); diff --git a/packages/backend-openapi-utils/src/schema/response-body-validation.ts b/packages/backend-openapi-utils/src/schema/response-body-validation.ts index d947a21970..116fc888e8 100644 --- a/packages/backend-openapi-utils/src/schema/response-body-validation.ts +++ b/packages/backend-openapi-utils/src/schema/response-body-validation.ts @@ -16,22 +16,46 @@ import { JsonObject } from '@backstage/types'; import { Operation, ParserOptions, ResponseParser } from './types'; -import { OperationError } from './errors'; +import { OperationError, OperationResponseError } from './errors'; import Ajv from 'ajv'; import { OperationObject, ResponseObject } from 'openapi3-ts'; +class DisabledResponseBodyParser + implements ResponseParser +{ + operation: Operation; + constructor(operation: Operation) { + this.operation = operation; + } + async parse(response: Response): Promise { + const body = await response.text(); + if (body?.length) { + throw new OperationError( + this.operation, + 'Received a body but no schema was found', + ); + } + return undefined; + } +} + export class ResponseBodyParser implements ResponseParser { operation: Operation; ajv: Ajv; + + static fromOperation(operation: Operation, options: ParserOptions) { + return operation.schema.responses && + Object.keys(operation.schema.responses).length + ? new ResponseBodyParser(operation, options) + : new DisabledResponseBodyParser(operation); + } + constructor(operation: Operation, options: ParserOptions) { this.operation = operation; this.ajv = options.ajv; const responseSchemas = operation.schema.responses; - if (!Object.keys(responseSchemas).length) { - throw new OperationError(this.operation, `No response schemas found`); - } for (const [statusCode, schema] of Object.entries(responseSchemas)) { if (!schema.content) { continue; @@ -55,15 +79,30 @@ export class ResponseBodyParser this.operation.schema, response, ); - if (!responseSchema?.content && body?.length) { - throw new OperationError(this.operation, 'No content found in response'); - } else if (!responseSchema?.content && !body?.length) { - // If there is no content in the response schema and no body in the response, then the response is valid + if (!responseSchema?.content && !body?.length) { + // If there is no content in the response schema and no body in the response, then the response is valid. + // eg 204 No Content return undefined; } - if (!responseSchema?.content!['application/json']) { - throw new OperationError( + if (!responseSchema) { + throw new OperationResponseError( this.operation, + response, + `No schema found.`, + ); + } + + if (!responseSchema?.content && body?.length) { + throw new OperationResponseError( + this.operation, + response, + 'Received a body but no schema was found', + ); + } + if (!responseSchema?.content!['application/json']) { + throw new OperationResponseError( + this.operation, + response, 'No application/json content type found in response', ); } @@ -72,18 +111,31 @@ export class ResponseBodyParser throw new OperationError(this.operation, 'No schema found in response'); } if ('$ref' in schema) { - throw new OperationError( + throw new OperationResponseError( this.operation, + response, 'Reference objects are not supported', ); } + if (!schema.required && !body?.length) { + throw new OperationResponseError( + this.operation, + response, + 'Response body is required but missing', + ); + } else if (!schema.required && !body?.length) { + // If there is no content in the response schema and no body in the response, then the response is valid + return undefined; + } + const validate = this.ajv.compile(schema); const jsonBody = (await response.json()) as JsonObject; const valid = validate(jsonBody); if (!valid) { - throw new OperationError( + throw new OperationResponseError( this.operation, + response, 'Response body validation failed', ); } diff --git a/packages/backend-openapi-utils/src/schema/validation.test.ts b/packages/backend-openapi-utils/src/schema/validation.test.ts index 1c440fa7b5..0fab287f1e 100644 --- a/packages/backend-openapi-utils/src/schema/validation.test.ts +++ b/packages/backend-openapi-utils/src/schema/validation.test.ts @@ -112,8 +112,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Received a body but no schema was found]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Received a body but no schema was found"`, ); }); }); @@ -149,8 +149,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Unexpected query parameters: test]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Unexpected query parameters: test"`, ); }); @@ -166,8 +166,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Query parameter validation failed]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Query parameter validation failed"`, ); }); @@ -205,8 +205,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Unexpected query parameters: param]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Unexpected query parameters: param"`, ); }); @@ -224,8 +224,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -241,8 +241,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Invalid object parameter]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Invalid object parameter"`, ); }); }); @@ -265,8 +265,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Unexpected query parameters: param[t]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Unexpected query parameters: param[t"`, ); }); @@ -284,8 +284,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -300,8 +300,8 @@ describe('OpenApiProxyValidator', () => { }); await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Invalid object parameter]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Invalid object parameter"`, ); }); describe('explode', () => { @@ -369,7 +369,7 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"[/api/search (GET)]: Invalid object parameter"`, + `"["GET /api/search"] Invalid object parameter"`, ); }); }); @@ -428,8 +428,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -445,8 +445,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Unexpected query parameters: param[]]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Unexpected query parameters: param[]"`, ); }); }); @@ -499,8 +499,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Required query parameter param not found"`, ); }); @@ -516,8 +516,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Array parameter should not have multiple values]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Array parameter should not have multiple values"`, ); }); }); @@ -609,8 +609,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Required query parameter param not found"`, ); }); }); @@ -662,8 +662,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Required query parameter param not found]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Required query parameter param not found"`, ); }); }); @@ -703,8 +703,8 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), - ).rejects.toMatchInlineSnapshot( - `[Error: [/api/search (GET)]: Response body validation failed]`, + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search" (200)]: Response body is required but missing"`, ); }); }); diff --git a/packages/backend-openapi-utils/src/schema/validation.ts b/packages/backend-openapi-utils/src/schema/validation.ts index b5a96e050a..45255a2ea5 100644 --- a/packages/backend-openapi-utils/src/schema/validation.ts +++ b/packages/backend-openapi-utils/src/schema/validation.ts @@ -40,7 +40,7 @@ class RequestBodyValidator implements Validator { async validate({ pair, operation }: ValidatorParams) { const { request } = pair; - const parser = new RequestBodyParser(operation, { ajv }); + const parser = RequestBodyParser.fromOperation(operation, { ajv }); const fetchRequest = mockttpToFetchRequest(request); await parser.parse(fetchRequest); } @@ -54,7 +54,7 @@ class ResponseBodyValidator implements Validator { async validate({ pair, operation }: ValidatorParams) { const { response } = pair; - const parser = new ResponseBodyParser(operation, { ajv }); + const parser = ResponseBodyParser.fromOperation(operation, { ajv }); const fetchResponse = mockttpToFetchResponse(response); await parser.parse(fetchResponse); } From 71ee97ddba4fd1154a8ed7ed24a5086f6ea3603a Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 7 Jul 2024 15:01:07 -0400 Subject: [PATCH 097/291] add tests for path calculations Signed-off-by: aramissennyeydd --- .../src/schema/parameter-validation.test.ts | 44 +++++++ .../src/schema/parameter-validation.ts | 15 ++- .../src/schema/validation.test.ts | 116 +++++++++++++++++- .../src/schema/validation.ts | 87 ++++++------- 4 files changed, 214 insertions(+), 48 deletions(-) diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts index 673f22f99b..717256e266 100644 --- a/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts @@ -522,4 +522,48 @@ describe('path parameters', () => { }); }); }); + + describe('path parsing', () => { + it('should parse a path with a single parameters', async () => { + const parsedPath = PathParameterParser.parsePath({ + operation, + schema: '/api/item/{id}', + path: '/api/item/test123', + }); + expect(parsedPath).toEqual({ id: 'test123' }); + }); + it('should parse a path with multiple parameters', async () => { + const parsedPath = PathParameterParser.parsePath({ + operation, + schema: '/api/item/{id}/{name}', + path: '/api/item/42/test', + }); + // the string is expected here, but will be optimistically parsed as a number where it makes sense. + expect(parsedPath).toEqual({ id: '42', name: 'test' }); + }); + + it('should throw an error if the path does not have enough parts', async () => { + expect(() => + PathParameterParser.parsePath({ + operation, + schema: '/api/item/{id}', + path: '/api/item', + }), + ).toThrowErrorMatchingInlineSnapshot( + `"["GET /api/item/{id}"] Path parts do not match"`, + ); + }); + + it('should throw an error if the path has too many parts', async () => { + expect(() => + PathParameterParser.parsePath({ + operation, + schema: '/api/item/{id}', + path: '/api/item/test/123', + }), + ).toThrowErrorMatchingInlineSnapshot( + `"["GET /api/item/{id}"] Path parts do not match"`, + ); + }); + }); }); diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.ts index ddc0efbe45..65e6439b7e 100644 --- a/packages/backend-openapi-utils/src/schema/parameter-validation.ts +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.ts @@ -359,7 +359,8 @@ export class PathParameterParser } async parse(request: Request) { const { pathname } = new URL(request.url); - const params = this.parsePath({ + const params = PathParameterParser.parsePath({ + operation: this.operation, path: pathname, schema: this.operation.path, }); @@ -393,11 +394,19 @@ export class PathParameterParser return pathParameters; } - parsePath({ schema, path }: { schema: string; path: string }) { + static parsePath({ + operation, + schema, + path, + }: { + operation: Operation; + schema: string; + path: string; + }) { const parts = path.split('/'); const pathParts = schema.split('/'); if (parts.length !== pathParts.length) { - throw new OperationError(this.operation, 'Path parts do not match'); + throw new OperationError(operation, 'Path parts do not match'); } const params: Record = {}; for (let i = 0; i < parts.length; i++) { diff --git a/packages/backend-openapi-utils/src/schema/validation.test.ts b/packages/backend-openapi-utils/src/schema/validation.test.ts index 0fab287f1e..3f172c16fe 100644 --- a/packages/backend-openapi-utils/src/schema/validation.test.ts +++ b/packages/backend-openapi-utils/src/schema/validation.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { OpenApiProxyValidator } from './validation'; +import { findOperationByRequest, OpenApiProxyValidator } from './validation'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; @@ -22,7 +22,7 @@ import { CompletedBody, CompletedRequest, CompletedResponse } from 'mockttp'; import withResponseBody from './__fixtures__/schemas/withJsonResponseBody.json'; import withQueryParameter from './__fixtures__/schemas/withQueryParameter.json'; import _ from 'lodash'; -import { ParameterObject } from 'openapi3-ts'; +import { OpenAPIObject, ParameterObject } from 'openapi3-ts'; const server = setupServer(); @@ -709,3 +709,115 @@ describe('OpenApiProxyValidator', () => { }); }); }); + +describe('findOperationByRequest', () => { + it('finds an operation by request', () => { + const schema = { + openapi: '3.0.0', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/api/search': { + get: { + parameters: [], + }, + }, + }, + }; + const request = { + method: 'GET', + url: 'http://localhost:8080/api/search', + } as CompletedRequest; + expect(findOperationByRequest(schema as OpenAPIObject, request)).toEqual([ + '/api/search', + schema.paths['/api/search'].get, + ]); + }); + + it('finds an operation by request when there are multiple other paths', () => { + const schema = { + openapi: '3.0.0', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/api/search': { + get: { + parameters: [], + }, + }, + '/api/catalog/by-ref': { + get: { + parameters: [], + }, + }, + }, + }; + const request = { + method: 'GET', + url: 'http://localhost:8080/api/search', + } as CompletedRequest; + expect(findOperationByRequest(schema as OpenAPIObject, request)).toEqual([ + '/api/search', + schema.paths['/api/search'].get, + ]); + }); + + it('finds an operation by request when there are path parameters', () => { + const schema = { + openapi: '3.0.0', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/api/catalog/by-id/{id}': { + get: { + parameters: [], + }, + }, + }, + }; + const request = { + method: 'GET', + url: 'http://localhost:8080/api/catalog/by-id/123', + } as CompletedRequest; + expect(findOperationByRequest(schema as OpenAPIObject, request)).toEqual([ + '/api/catalog/by-id/{id}', + schema.paths['/api/catalog/by-id/{id}'].get, + ]); + }); + + it('finds an operation by request when there are somewhat overlapping path parameters', () => { + const schema = { + openapi: '3.0.0', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/api/catalog/by-id/{id}': { + get: { + parameters: [], + }, + }, + '/api/catalog/by-id': { + get: { + parameters: [], + }, + }, + }, + }; + const request = { + method: 'GET', + url: 'http://localhost:8080/api/catalog/by-id/123', + } as CompletedRequest; + expect(findOperationByRequest(schema as OpenAPIObject, request)).toEqual([ + '/api/catalog/by-id/{id}', + schema.paths['/api/catalog/by-id/{id}'].get, + ]); + }); +}); diff --git a/packages/backend-openapi-utils/src/schema/validation.ts b/packages/backend-openapi-utils/src/schema/validation.ts index 45255a2ea5..51de5d574b 100644 --- a/packages/backend-openapi-utils/src/schema/validation.ts +++ b/packages/backend-openapi-utils/src/schema/validation.ts @@ -60,6 +60,49 @@ class ResponseBodyValidator implements Validator { } } +export function findOperationByRequest( + openApiSchema: OpenAPIObject, + request: CompletedRequest, +): [string, OperationObject] | undefined { + const { url } = request; + const { pathname } = new URL(url); + + const parts = pathname.split('/'); + for (const [path, schema] of Object.entries(openApiSchema.paths)) { + const pathParts = path.split('/'); + if (parts.length !== pathParts.length) { + continue; + } + let found = true; + for (let i = 0; i < parts.length; i++) { + if (pathParts[i] === parts[i]) { + continue; + } + if (pathParts[i].startsWith('{') && pathParts[i].endsWith('}')) { + continue; + } + found = false; + break; + } + if (!found) { + continue; + } + let matchingOperationType: OperationObject | undefined = undefined; + for (const [operationType, operation] of Object.entries(schema)) { + if (operationType === request.method.toLowerCase()) { + matchingOperationType = operation as OperationObject; + break; + } + } + if (!matchingOperationType) { + continue; + } + return [path, matchingOperationType]; + } + + return undefined; +} + export class OpenApiProxyValidator { schema: OpenAPIObject | undefined; validators: Validator[] | undefined; @@ -74,7 +117,7 @@ export class OpenApiProxyValidator { } async validate(request: CompletedRequest, response: CompletedResponse) { - const operationPathTuple = this.findOperation(request); + const operationPathTuple = findOperationByRequest(this.schema!, request); if (!operationPathTuple) { throw new OperationError( { path: request.path, method: request.method } as Operation, @@ -95,46 +138,4 @@ export class OpenApiProxyValidator { ), ); } - - private findOperation( - request: CompletedRequest, - ): [string, OperationObject] | undefined { - const { url } = request; - const { pathname } = new URL(url); - - const parts = pathname.split('/'); - for (const [path, schema] of Object.entries(this.schema!.paths)) { - const pathParts = path.split('/'); - if (parts.length !== pathParts.length) { - continue; - } - let found = true; - for (let i = 0; i < parts.length; i++) { - if (pathParts[i] === parts[i]) { - continue; - } - if (pathParts[i].startsWith('{') && pathParts[i].endsWith('}')) { - continue; - } - found = false; - break; - } - if (!found) { - continue; - } - let matchingOperationType: OperationObject | undefined = undefined; - for (const [operationType, operation] of Object.entries(schema)) { - if (operationType === request.method.toLowerCase()) { - matchingOperationType = operation as OperationObject; - break; - } - } - if (!matchingOperationType) { - continue; - } - return [path, matchingOperationType]; - } - - return undefined; - } } From 1b05177ef616648c7268a4207db2787a02d02e0b Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 7 Jul 2024 15:01:19 -0400 Subject: [PATCH 098/291] linting things Signed-off-by: aramissennyeydd --- packages/backend-openapi-utils/src/schema/validation.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/backend-openapi-utils/src/schema/validation.ts b/packages/backend-openapi-utils/src/schema/validation.ts index 51de5d574b..fda09b2297 100644 --- a/packages/backend-openapi-utils/src/schema/validation.ts +++ b/packages/backend-openapi-utils/src/schema/validation.ts @@ -14,13 +14,7 @@ * limitations under the License. */ import { CompletedRequest, CompletedResponse } from 'mockttp'; -import { - OpenAPIObject, - OperationObject, - ParameterObject, - ResponseObject, - SchemaObject, -} from 'openapi3-ts'; +import { OpenAPIObject, OperationObject } from 'openapi3-ts'; import Ajv from 'ajv'; import Parser from '@apidevtools/swagger-parser'; import { Operation, Validator, ValidatorParams } from './types'; From 2c7750ca57ca826c426e5dddd13855f816157e46 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 7 Jul 2024 15:27:08 -0400 Subject: [PATCH 099/291] better error messages Signed-off-by: aramissennyeydd --- .../src/schema/errors.ts | 30 +++++++++++++++++++ .../src/schema/parameter-validation.test.ts | 20 ++++++------- .../src/schema/parameter-validation.ts | 17 ++++++----- .../schema/request-body-validation.test.ts | 10 +++---- .../src/schema/request-body-validation.ts | 7 +++-- .../schema/response-body-validation.test.ts | 10 +++---- .../src/schema/response-body-validation.ts | 16 ++++++---- .../backend-openapi-utils/src/schema/utils.ts | 17 +++++++++++ .../src/schema/validation.test.ts | 10 +++---- .../src/schema/validation.ts | 11 ++++++- 10 files changed, 107 insertions(+), 41 deletions(-) diff --git a/packages/backend-openapi-utils/src/schema/errors.ts b/packages/backend-openapi-utils/src/schema/errors.ts index 44de054c53..e1950bcce9 100644 --- a/packages/backend-openapi-utils/src/schema/errors.ts +++ b/packages/backend-openapi-utils/src/schema/errors.ts @@ -15,6 +15,8 @@ */ import { Operation } from './types'; +import { ErrorObject } from 'ajv'; +import { humanifyAjvError } from './utils'; export class OperationError extends Error { constructor(operation: Operation, message: string) { @@ -35,3 +37,31 @@ export class OperationResponseError extends Error { ); } } + +export class OperationParsingError extends OperationError { + constructor(operation: Operation, type: string, errors: ErrorObject[]) { + super( + operation, + `${type} validation failed.\n - ${errors + .map(humanifyAjvError) + .join('\n - ')}`, + ); + } +} + +export class OperationParsingResponseError extends OperationResponseError { + constructor( + operation: Operation, + response: Response, + type: string, + errors: ErrorObject[], + ) { + super( + operation, + response, + `${type} validation failed.\n - ${errors + .map(humanifyAjvError) + .join('\n - ')}`, + ); + } +} diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts index 717256e266..6ae2f4f8a0 100644 --- a/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts @@ -91,11 +91,11 @@ describe('query parameters', () => { const request = { url: 'http://localhost:8080/api/search?param=hello', } as Request; - await expect( - parser.parse(request), - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"["GET /api/search"] Query parameter validation failed"`, - ); + await expect(parser.parse(request)).rejects + .toThrowErrorMatchingInlineSnapshot(` + "["GET /api/search"] Query parameter validation failed. + - Value should be of type number" + `); }); }); }); @@ -514,11 +514,11 @@ describe('path parameters', () => { const request = { url: 'http://localhost:8080/api/item/hello', } as Request; - await expect( - parser.parse(request), - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"["GET /api/item/{id}"] Path parameter validation failed"`, - ); + await expect(parser.parse(request)).rejects + .toThrowErrorMatchingInlineSnapshot(` + "["GET /api/item/{id}"] Path parameter validation failed. + - Value should be of type number" + `); }); }); }); diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.ts index 65e6439b7e..19d49ef6ef 100644 --- a/packages/backend-openapi-utils/src/schema/parameter-validation.ts +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.ts @@ -23,7 +23,7 @@ import { ValidatorParams, } from './types'; import Ajv from 'ajv'; -import { OperationError } from './errors'; +import { OperationError, OperationParsingError } from './errors'; import { mockttpToFetchRequest } from './utils'; type ReferencelessSchemaObject = SchemaObject & { $ref?: never }; @@ -150,9 +150,10 @@ export class QueryParameterParser const validate = this.ajv.compile(parameter.schema); const valid = validate(param); if (!valid) { - throw new OperationError( + throw new OperationParsingError( this.operation, - 'Query parameter validation failed', + 'Query parameter', + validate.errors!, ); } queryParameters[name] = param; @@ -339,9 +340,10 @@ export class HeaderParameterParser const valid = validate(header); if (!valid) { - throw new OperationError( + throw new OperationParsingError( this.operation, - 'Header parameter validation failed', + 'Header parameter', + validate.errors!, ); } headerParameters[name] = header; @@ -384,9 +386,10 @@ export class PathParameterParser const valid = validate(param); if (!valid) { - throw new OperationError( + throw new OperationParsingError( this.operation, - 'Path parameter validation failed', + 'Path parameter', + validate.errors!, ); } pathParameters[name] = param; diff --git a/packages/backend-openapi-utils/src/schema/request-body-validation.test.ts b/packages/backend-openapi-utils/src/schema/request-body-validation.test.ts index a4452763fc..df8b33402c 100644 --- a/packages/backend-openapi-utils/src/schema/request-body-validation.test.ts +++ b/packages/backend-openapi-utils/src/schema/request-body-validation.test.ts @@ -61,11 +61,11 @@ describe('request body', () => { const requestBody = { query: 1, }; - await expect( - parser.parse(toRequest(requestBody)), - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"["POST /api/search"] Request body validation failed."`, - ); + await expect(parser.parse(toRequest(requestBody))).rejects + .toThrowErrorMatchingInlineSnapshot(` + "["POST /api/search"] Request body validation failed. + - "/query" should be of type string" + `); }); it('should throw error if request body is required but missing', async () => { diff --git a/packages/backend-openapi-utils/src/schema/request-body-validation.ts b/packages/backend-openapi-utils/src/schema/request-body-validation.ts index 22873c8100..f7681f01c2 100644 --- a/packages/backend-openapi-utils/src/schema/request-body-validation.ts +++ b/packages/backend-openapi-utils/src/schema/request-body-validation.ts @@ -17,7 +17,7 @@ import { JsonObject } from '@backstage/types'; import { Operation, ParserOptions, RequestParser } from './types'; import { ValidateFunction } from 'ajv'; -import { OperationError } from './errors'; +import { OperationError, OperationParsingError } from './errors'; import { RequestBodyObject, SchemaObject } from 'openapi3-ts'; class DisabledRequestBodyParser @@ -120,9 +120,10 @@ export class RequestBodyParser const body = (await request.json()) as JsonObject; const valid = this.validate(body); if (!valid) { - throw new OperationError( + throw new OperationParsingError( this.operation, - `Request body validation failed.`, + `Request body`, + this.validate.errors!, ); } return body; diff --git a/packages/backend-openapi-utils/src/schema/response-body-validation.test.ts b/packages/backend-openapi-utils/src/schema/response-body-validation.test.ts index 9ad47b8a19..6cd098b2e7 100644 --- a/packages/backend-openapi-utils/src/schema/response-body-validation.test.ts +++ b/packages/backend-openapi-utils/src/schema/response-body-validation.test.ts @@ -60,11 +60,11 @@ describe('response body', () => { const responseBody = { result: 1, }; - await expect( - parser.parse(toResponse(responseBody)), - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"["GET /api/search" (200)]: Response body validation failed"`, - ); + await expect(parser.parse(toResponse(responseBody))).rejects + .toThrowErrorMatchingInlineSnapshot(` + "["GET /api/search" (200)]: Response body validation failed. + - The "result" property is not allowed" + `); }); it('should throw error if response body is required but missing', async () => { diff --git a/packages/backend-openapi-utils/src/schema/response-body-validation.ts b/packages/backend-openapi-utils/src/schema/response-body-validation.ts index 116fc888e8..1a2a475afc 100644 --- a/packages/backend-openapi-utils/src/schema/response-body-validation.ts +++ b/packages/backend-openapi-utils/src/schema/response-body-validation.ts @@ -16,7 +16,11 @@ import { JsonObject } from '@backstage/types'; import { Operation, ParserOptions, ResponseParser } from './types'; -import { OperationError, OperationResponseError } from './errors'; +import { + OperationError, + OperationParsingResponseError, + OperationResponseError, +} from './errors'; import Ajv from 'ajv'; import { OperationObject, ResponseObject } from 'openapi3-ts'; @@ -58,6 +62,7 @@ export class ResponseBodyParser const responseSchemas = operation.schema.responses; for (const [statusCode, schema] of Object.entries(responseSchemas)) { if (!schema.content) { + // Skip responses without content, eg 204 No Content. continue; } else if (!schema.content['application/json']) { throw new OperationError( @@ -107,6 +112,7 @@ export class ResponseBodyParser ); } const schema = responseSchema.content!['application/json'].schema; + // This is a bit of type laziness. Ideally, this would be a type-narrowing function, but I wasn't able to get the types to work. if (!schema) { throw new OperationError(this.operation, 'No schema found in response'); } @@ -133,10 +139,11 @@ export class ResponseBodyParser const jsonBody = (await response.json()) as JsonObject; const valid = validate(jsonBody); if (!valid) { - throw new OperationResponseError( + throw new OperationParsingResponseError( this.operation, response, - 'Response body validation failed', + 'Response body', + validate.errors!, ); } return jsonBody; @@ -144,9 +151,8 @@ export class ResponseBodyParser private findResponseSchema( operationSchema: OperationObject, - response: Response, + { status }: Response, ): ResponseObject | undefined { - const { status } = response; return ( operationSchema.responses?.[status] ?? operationSchema.responses?.default ); diff --git a/packages/backend-openapi-utils/src/schema/utils.ts b/packages/backend-openapi-utils/src/schema/utils.ts index cc00d00095..7f9385a404 100644 --- a/packages/backend-openapi-utils/src/schema/utils.ts +++ b/packages/backend-openapi-utils/src/schema/utils.ts @@ -14,6 +14,7 @@ * limitations under the License. */ import { CompletedRequest, CompletedResponse } from 'mockttp'; +import { ErrorObject } from 'ajv'; export function mockttpToFetchRequest(request: CompletedRequest) { const headers = new Headers(request.rawHeaders); @@ -34,3 +35,19 @@ export function mockttpToFetchResponse(response: CompletedResponse) { text: () => response.body?.getText(), } as Response; } + +export function humanifyAjvError(error: ErrorObject) { + switch (error.keyword) { + case 'required': + return `The ${error.params.missingProperty} property is required`; + case 'type': + console.log(error); + return `${ + error.instancePath ? `"${error.instancePath}"` : 'Value' + } should be of type ${error.params.type}`; + case 'additionalProperties': + return `The "${error.params.additionalProperty}" property is not allowed`; + default: + return error.message; + } +} diff --git a/packages/backend-openapi-utils/src/schema/validation.test.ts b/packages/backend-openapi-utils/src/schema/validation.test.ts index 3f172c16fe..3b73250cd9 100644 --- a/packages/backend-openapi-utils/src/schema/validation.test.ts +++ b/packages/backend-openapi-utils/src/schema/validation.test.ts @@ -164,11 +164,11 @@ describe('OpenApiProxyValidator', () => { statusCode: 200, }); - await expect( - async () => await validator.validate(request, response), - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"["GET /api/search"] Query parameter validation failed"`, - ); + await expect(async () => await validator.validate(request, response)) + .rejects.toThrowErrorMatchingInlineSnapshot(` + "["GET /api/search"] Query parameter validation failed. + - Value should be of type number" + `); }); it('accepts valid parameter', async () => { diff --git a/packages/backend-openapi-utils/src/schema/validation.ts b/packages/backend-openapi-utils/src/schema/validation.ts index fda09b2297..d9d80ddcf5 100644 --- a/packages/backend-openapi-utils/src/schema/validation.ts +++ b/packages/backend-openapi-utils/src/schema/validation.ts @@ -24,7 +24,7 @@ import { RequestBodyParser } from './request-body-validation'; import { mockttpToFetchRequest, mockttpToFetchResponse } from './utils'; import { ResponseBodyParser } from './response-body-validation'; -const ajv = new Ajv({ allErrors: true }); // options can be passed, e.g. {allErrors: true} +const ajv = new Ajv({ allErrors: true }); class RequestBodyValidator implements Validator { schema: OpenAPIObject; @@ -34,6 +34,7 @@ class RequestBodyValidator implements Validator { async validate({ pair, operation }: ValidatorParams) { const { request } = pair; + // NOTE: There may be a worthwhile optimization here to cache these results to avoid re-parsing the schema for every request. As is, I don't think this is a big deal. const parser = RequestBodyParser.fromOperation(operation, { ajv }); const fetchRequest = mockttpToFetchRequest(request); await parser.parse(fetchRequest); @@ -48,12 +49,19 @@ class ResponseBodyValidator implements Validator { async validate({ pair, operation }: ValidatorParams) { const { response } = pair; + // NOTE: There may be a worthwhile optimization here to cache these results to avoid re-parsing the schema for every request. As is, I don't think this is a big deal. const parser = ResponseBodyParser.fromOperation(operation, { ajv }); const fetchResponse = mockttpToFetchResponse(response); await parser.parse(fetchResponse); } } +/** + * Find an operation in an OpenAPI schema that matches a request. This is done by comparing the request URL to the paths in the schema. + * @param openApiSchema - The OpenAPI schema to search for the operation in. + * @param request - The request to find the operation for. + * @returns A tuple of the path and the operation object that matches the request. + */ export function findOperationByRequest( openApiSchema: OpenAPIObject, request: CompletedRequest, @@ -72,6 +80,7 @@ export function findOperationByRequest( if (pathParts[i] === parts[i]) { continue; } + // If the path part is a parameter, we can count it as a match. eg /api/{id} will match /api/1 if (pathParts[i].startsWith('{') && pathParts[i].endsWith('}')) { continue; } From 1e952426c1bbf1a7f70ab4325fc44e5c4bb92e79 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 7 Jul 2024 16:03:45 -0400 Subject: [PATCH 100/291] add comments and improve form/explode checks Signed-off-by: aramissennyeydd --- .../src/schema/parameter-validation.test.ts | 45 ++++++ .../src/schema/parameter-validation.ts | 128 +++++++++++++----- .../backend-openapi-utils/src/schema/utils.ts | 1 - .../src/schema/validation.test.ts | 8 +- 4 files changed, 143 insertions(+), 39 deletions(-) diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts index 6ae2f4f8a0..7a34c2c309 100644 --- a/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.test.ts @@ -334,6 +334,51 @@ describe('query parameters', () => { }); expect(result.extra).toEqual(['hello', 'world']); }); + + it('should respect other object encodings', async () => { + const parameter = { + name: 'extra', + in: 'query', + style: 'deepObject', + explode: true, + schema: { type: 'object' }, + required: false, + } as ParameterObject; + schema.parameters!.push(parameter as any); + parser = new QueryParameterParser(operation, { ajv }); + const request = { + url: 'http://localhost:8080/api/search?key=value&otherkey=value2&extra[hello]=world', + } as Request; + + const result = await parser.parse(request); + expect(result.param).toEqual({ + key: 'value', + otherkey: 'value2', + }); + expect(result.extra).toEqual({ hello: 'world' }); + }); + + it('should throw if there are 2 form explode parameters', async () => { + const parameter = { + name: 'extra', + in: 'query', + style: 'form', + explode: true, + schema: { type: 'object' }, + required: false, + } as ParameterObject; + schema.parameters!.push(parameter as any); + parser = new QueryParameterParser(operation, { ajv }); + const request = { + url: 'http://localhost:8080/api/search?key=value&otherkey=value2&extra[hello]=world', + } as Request; + + await expect(() => + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["GET /api/search"] Ambiguous query parameters, you cannot have 2 form explode parameters"`, + ); + }); }); describe('explode=false', () => { diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.ts index 19d49ef6ef..5cdb3dbd6b 100644 --- a/packages/backend-openapi-utils/src/schema/parameter-validation.ts +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.ts @@ -71,6 +71,13 @@ class BaseParameterParser { } } + /** + * Attempt to transform a string value to its expected type, this allows Ajv to perform validation. As these are parameters, + * support for edge cases like nested type casting is not currently supported. + * @param value + * @param schema + * @returns + */ optimisticallyParseValue(value: string, schema: SchemaObject) { if (schema.type === 'integer') { return parseInt(value, 10); @@ -78,10 +85,20 @@ class BaseParameterParser { if (schema.type === 'number') { return parseFloat(value); } + if (schema.type === 'boolean') { + if (['true', 'false'].includes(value)) { + return value === 'true'; + } + throw new Error('Invalid boolean value must be either "true" or "false"'); + } return value; } } +const PLACE_A_BEFORE_B = -1; +const PLACE_A_AFTER_B = 1; +const EQUAL = 0; + export class QueryParameterParser extends BaseParameterParser implements RequestParser> @@ -93,18 +110,52 @@ export class QueryParameterParser const { searchParams } = new URL(request.url); const remainingQueryParameters = new Set(searchParams.keys()); const queryParameters: Record = {}; + + // object parameters with form/explode style should be processed last as they collect all remaining parameters. const parameterIterator = Object.entries(this.parameters).toSorted( - ([_, parameter]) => { - if (parameter.schema.type !== 'object') { - return -1; + ([_, parameterA], [_B, parameterB]) => { + if ( + parameterA.schema.type !== 'object' && + parameterB.schema.type !== 'object' + ) { + return EQUAL; } - if (parameter.style === 'form' || !parameter.style) { - if (parameter.explode || typeof parameter.explode === 'undefined') { - return 1; - } - return 0; + if ( + parameterA.schema.type === 'object' && + parameterB.schema.type !== 'object' + ) { + return PLACE_A_AFTER_B; } - return 0; + if ( + parameterA.schema.type !== 'object' && + parameterB.schema.type === 'object' + ) { + return PLACE_A_BEFORE_B; + } + const isParameterAForm = + parameterA.style === 'form' || !parameterA.style; + const isParameterAFormExplode = + isParameterAForm && + (parameterA.explode || typeof parameterA.explode === 'undefined'); + const isParameterBForm = + parameterB.style === 'form' || !parameterB.style; + const isParameterBFormExplode = + isParameterBForm && + (parameterB.explode || typeof parameterB.explode === 'undefined'); + // Sort the form explode to the bottom of the array. + if (isParameterAFormExplode && isParameterBFormExplode) { + throw new OperationError( + this.operation, + 'Ambiguous query parameters, you cannot have 2 form explode parameters', + ); + } + if (isParameterAFormExplode) { + return PLACE_A_AFTER_B; + } + if (isParameterBFormExplode) { + return PLACE_A_BEFORE_B; + } + return EQUAL; }, ); for (const [name, parameter] of parameterIterator) { @@ -123,13 +174,15 @@ export class QueryParameterParser // eslint-disable-next-line prefer-const let [param, indices]: [any | null, string[]] = this.#findQueryParameters( this.parameters, - queryParameters, + remainingQueryParameters, searchParams, name, ); if (!!param) { indices.forEach(index => remainingQueryParameters.delete(index)); } + + // The query parameters can be either a single value or an array of values, try to wrangle them into the expected format if they're not explicitly an array. if (parameter.schema.type !== 'array' && Array.isArray(param)) { param = param.length > 0 ? param[0] : undefined; } @@ -145,6 +198,7 @@ export class QueryParameterParser continue; } if (param) { + // We do this here because all query parameters are strings but the schema will expect the real value. param = this.optimisticallyParseValue(param, parameter.schema); } const validate = this.ajv.compile(parameter.schema); @@ -171,19 +225,26 @@ export class QueryParameterParser #findQueryParameters( parameters: Record, - currentQueryParameters: Record, + remainingQueryParameters: Set, searchParams: URLSearchParams, name: string, ): [any | null, string[]] { const parameter = parameters[name]; const schema = parameter.schema as SchemaObject; + // Since getAll will return an empty array if the key is not found, we need to check if the key exists first. const getIfExists = (key: string) => searchParams.has(key) ? searchParams.getAll(key) : null; if (schema.type === 'array') { - if (parameter.style === 'form' || !parameter.style) { + // Form is the default array format. + if ( + parameter.style === 'form' || + typeof parameter.style === 'undefined' + ) { + // As is explode = true. if (parameter.explode || typeof parameter.explode === 'undefined') { + // Support for qs explode format. Every value is stored as a separate query parameter. if (!searchParams.has(name) && searchParams.has(`${name}[0]`)) { const values: string[] = []; const indices: string[] = []; @@ -195,15 +256,18 @@ export class QueryParameterParser } return [values, indices]; } + // If not qs format, grab all values with the same name from search params. return [getIfExists(name), [name]]; } + // Add support for qs non-standard array format. This is helpful for search-backend, since that uses qs still. if (!searchParams.has(name) && searchParams.has(`${name}[]`)) { return [searchParams.get(`${name}[]`)?.split(','), [`${name}[]`]]; } + // Non-explode arrays should be comma separated. if (searchParams.has(name) && searchParams.getAll(name).length > 1) { throw new OperationError( this.operation, - 'Array parameter should not have multiple values', + 'Arrays must be comma separated in non-explode mode', ); } return [searchParams.get(name)?.split(','), [name]]; @@ -218,14 +282,19 @@ export class QueryParameterParser ); } if (schema.type === 'object') { - if (parameter.style === 'form' || !parameter.style) { + // Form is the default object format. + if ( + parameter.style === 'form' || + typeof parameter.style === 'undefined' + ) { if (parameter.explode) { + // Object form/explode is a collection of disjoint keys, there's no mapping for what they are so we collect all of them. + // This means we need to run this as the last query parameter that is processed. const obj: Record = {}; const indices: string[] = []; for (const [key, value] of searchParams.entries()) { - if ( - this.#matchesOtherQueryParameters(currentQueryParameters, key) - ) { + // Have we processed this query parameter as part of another parameter parsing? If not, consider it to be a part of this object. + if (!remainingQueryParameters.has(key)) { continue; } indices.push(key); @@ -233,6 +302,7 @@ export class QueryParameterParser } return [obj, indices]; } + // For non-explode, the schema is comma separated key,value "pairs", so filter=key1,value1,key2,value2 would parse to {key1: value1, key2: value2}. const obj: Record = {}; const value = searchParams.get(name); if (value) { @@ -240,7 +310,7 @@ export class QueryParameterParser if (parts.length % 2 !== 0) { throw new OperationError( this.operation, - 'Invalid object parameter', + 'Invalid object query parameter, must have an even number of key-value pairs', ); } for (let i = 0; i < parts.length; i += 2) { @@ -249,6 +319,8 @@ export class QueryParameterParser } return [obj, [name]]; } else if (parameter.style === 'deepObject') { + // Deep object is a nested object structure, so we need to parse the keys to build the object. + // example: ?filter[key1]=value1&filter[key2]=value2 => { key1: value1, key2: value2 } const obj: Record = {}; const indices: string[] = []; for (const [key, value] of searchParams.entries()) { @@ -261,7 +333,7 @@ export class QueryParameterParser if (!part.includes(']')) { throw new OperationError( this.operation, - 'Invalid object parameter', + `Invalid object parameter, missing closing bracket for key "${key}"`, ); } const objKey = part.split(']')[0]; @@ -274,7 +346,7 @@ export class QueryParameterParser if (!lastPart.includes(']')) { throw new OperationError( this.operation, - 'Invalid object parameter', + `Invalid object parameter, missing closing bracket for key "${key}"`, ); } currentLayer[lastPart.split(']')[0]] = value; @@ -284,24 +356,12 @@ export class QueryParameterParser } throw new OperationError( this.operation, - 'Unsupported style for object parameter', + `Unsupported style for object parameter, "${parameter.style}"`, ); } // For everything else, just return the value. return [getIfExists(name), [name]]; } - - #matchesOtherQueryParameters( - parameters: Record, - nameToMatch: string, - ) { - for (const [name] of Object.entries(parameters)) { - if (name === nameToMatch) { - return true; - } - } - return false; - } } export class HeaderParameterParser @@ -368,7 +428,7 @@ export class PathParameterParser }); const pathParameters: Record = {}; for (const [name, parameter] of Object.entries(this.parameters)) { - let param: string | number = params[name]; + let param: string | number | boolean = params[name]; if (!param && parameter.required) { throw new OperationError( this.operation, diff --git a/packages/backend-openapi-utils/src/schema/utils.ts b/packages/backend-openapi-utils/src/schema/utils.ts index 7f9385a404..9c5a400ec9 100644 --- a/packages/backend-openapi-utils/src/schema/utils.ts +++ b/packages/backend-openapi-utils/src/schema/utils.ts @@ -41,7 +41,6 @@ export function humanifyAjvError(error: ErrorObject) { case 'required': return `The ${error.params.missingProperty} property is required`; case 'type': - console.log(error); return `${ error.instancePath ? `"${error.instancePath}"` : 'Value' } should be of type ${error.params.type}`; diff --git a/packages/backend-openapi-utils/src/schema/validation.test.ts b/packages/backend-openapi-utils/src/schema/validation.test.ts index 3b73250cd9..fdb1350625 100644 --- a/packages/backend-openapi-utils/src/schema/validation.test.ts +++ b/packages/backend-openapi-utils/src/schema/validation.test.ts @@ -242,7 +242,7 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"["GET /api/search"] Invalid object parameter"`, + `"["GET /api/search"] Invalid object parameter, missing closing bracket for key "param[t""`, ); }); }); @@ -301,7 +301,7 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"["GET /api/search"] Invalid object parameter"`, + `"["GET /api/search"] Invalid object query parameter, must have an even number of key-value pairs"`, ); }); describe('explode', () => { @@ -369,7 +369,7 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"["GET /api/search"] Invalid object parameter"`, + `"["GET /api/search"] Invalid object query parameter, must have an even number of key-value pairs"`, ); }); }); @@ -517,7 +517,7 @@ describe('OpenApiProxyValidator', () => { await expect( async () => await validator.validate(request, response), ).rejects.toThrowErrorMatchingInlineSnapshot( - `"["GET /api/search"] Array parameter should not have multiple values"`, + `"["GET /api/search"] Arrays must be comma separated in non-explode mode"`, ); }); }); From f63ad78082a6157d3cc9b09882547c2bc6715b61 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 7 Jul 2024 16:37:12 -0400 Subject: [PATCH 101/291] more fixes and validating with search-backend Signed-off-by: aramissennyeydd --- packages/backend-openapi-utils/package.json | 1 + .../backend-openapi-utils/src/proxy/setup.ts | 17 +++++++---- .../schema/request-body-validation.test.ts | 28 ++++++++++++++++++- .../src/schema/request-body-validation.ts | 10 +++++-- .../src/schema/response-body-validation.ts | 21 ++++++++++---- .../backend-openapi-utils/src/testUtils.ts | 4 +-- yarn.lock | 8 ++++++ 7 files changed, 72 insertions(+), 17 deletions(-) diff --git a/packages/backend-openapi-utils/package.json b/packages/backend-openapi-utils/package.json index 5fb1f98e1b..04653c5b32 100644 --- a/packages/backend-openapi-utils/package.json +++ b/packages/backend-openapi-utils/package.json @@ -44,6 +44,7 @@ "express": "^4.17.1", "express-openapi-validator": "^5.0.4", "express-promise-router": "^4.1.0", + "get-port": "^7.1.0", "json-schema-to-ts": "^3.0.0", "lodash": "^4.17.21", "mockttp": "^3.13.0", diff --git a/packages/backend-openapi-utils/src/proxy/setup.ts b/packages/backend-openapi-utils/src/proxy/setup.ts index 8f24aa2512..da84d44afc 100644 --- a/packages/backend-openapi-utils/src/proxy/setup.ts +++ b/packages/backend-openapi-utils/src/proxy/setup.ts @@ -16,6 +16,8 @@ import * as mockttp from 'mockttp'; import { OpenApiProxyValidator } from '../schema/validation'; +import getPort from 'get-port'; +import { Server } from 'http'; export class Proxy { server: mockttp.Mockttp; @@ -25,6 +27,8 @@ export class Proxy { mockttp.CompletedResponse >(); validator: OpenApiProxyValidator; + public forwardTo: { port: number } = { port: 0 }; + express: { server: Server | undefined } = { server: undefined }; constructor() { this.server = mockttp.getLocal(); this.validator = new OpenApiProxyValidator(); @@ -32,9 +36,10 @@ export class Proxy { async setup() { await this.server.start(); + this.forwardTo.port = await getPort(); this.server .forAnyRequest() - .thenForwardTo(`http://localhost:${process.env.PORT}`); + .thenForwardTo(`http://localhost:${this.forwardTo.port}`); await this.server.on('request', request => { this.#openRequests[request.id] = request; }); @@ -53,10 +58,9 @@ export class Proxy { }); } - async initialize() { - await this.validator.initialize( - `http://localhost:${process.env.PORT}/openapi.json`, - ); + async initialize(url: string, server: Server) { + await this.validator.initialize(`${url}/openapi.json`); + this.express.server = server; } stop() { @@ -64,6 +68,9 @@ export class Proxy { throw new Error('There are still open requests'); } this.server.stop(); + + // If this isn't expressly closed, it will cause a jest memory leak warning. + this.express.server?.close(); } get url() { diff --git a/packages/backend-openapi-utils/src/schema/request-body-validation.test.ts b/packages/backend-openapi-utils/src/schema/request-body-validation.test.ts index df8b33402c..807eaa4569 100644 --- a/packages/backend-openapi-utils/src/schema/request-body-validation.test.ts +++ b/packages/backend-openapi-utils/src/schema/request-body-validation.test.ts @@ -19,7 +19,12 @@ import { RequestBodyParser } from './request-body-validation'; import Ajv from 'ajv'; import { Operation, RequestParser } from './types'; import _ from 'lodash'; -import { OperationObject, RequestBodyObject } from 'openapi3-ts'; +import { + ContentObject, + MediaTypeObject, + OperationObject, + RequestBodyObject, +} from 'openapi3-ts'; import { JsonObject } from '@backstage/types'; const ajv = new Ajv(); @@ -79,4 +84,25 @@ describe('request body', () => { `"["POST /api/search"] No request body found for /api/search"`, ); }); + + it('should throw error if request body is not application/json', async () => { + const request = toRequest({}, { 'content-type': 'text/plain' }); + await expect( + parser.parse(request), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"["POST /api/search"] Content type is not application/json"`, + ); + }); + + it('should NOT throw error if request body is not just application/json', async () => { + (schema.requestBody.content as ContentObject)[ + 'application/json; charset=utf-8' + ] = schema.requestBody.content['application/json'] as MediaTypeObject; + delete (schema.requestBody.content as ContentObject)['application/json']; + parser = new RequestBodyParser(operation, { + ajv, + }); + const request = toRequest({}); + expect(await parser.parse(request)).toEqual({}); + }); }); diff --git a/packages/backend-openapi-utils/src/schema/request-body-validation.ts b/packages/backend-openapi-utils/src/schema/request-body-validation.ts index f7681f01c2..0075bae641 100644 --- a/packages/backend-openapi-utils/src/schema/request-body-validation.ts +++ b/packages/backend-openapi-utils/src/schema/request-body-validation.ts @@ -77,13 +77,17 @@ export class RequestBodyParser 'No content found in request body', ); } - if (!requestBody!.content['application/json']) { + const contentTypes = requestBody!.content; + const jsonContentType = Object.keys(contentTypes).find(contentType => + contentType.split(';').includes('application/json'), + ); + if (!jsonContentType) { throw new OperationError( this.operation, 'No application/json content type found in request body', ); } - const schema = requestBody!.content['application/json'].schema; + const schema = requestBody!.content[jsonContentType].schema; if (!schema) { throw new OperationError( this.operation, @@ -111,7 +115,7 @@ export class RequestBodyParser const contentType = request.headers.get('content-type') || 'application/json'; - if (contentType !== 'application/json') { + if (!contentType.split(';').includes('application/json')) { throw new OperationError( this.operation, 'Content type is not application/json', diff --git a/packages/backend-openapi-utils/src/schema/response-body-validation.ts b/packages/backend-openapi-utils/src/schema/response-body-validation.ts index 1a2a475afc..71a4512e75 100644 --- a/packages/backend-openapi-utils/src/schema/response-body-validation.ts +++ b/packages/backend-openapi-utils/src/schema/response-body-validation.ts @@ -61,15 +61,20 @@ export class ResponseBodyParser this.ajv = options.ajv; const responseSchemas = operation.schema.responses; for (const [statusCode, schema] of Object.entries(responseSchemas)) { - if (!schema.content) { + const contentTypes = schema.content; + if (!contentTypes) { // Skip responses without content, eg 204 No Content. continue; - } else if (!schema.content['application/json']) { + } + const jsonContentType = Object.keys(contentTypes).find(contentType => + contentType.split(';').includes('application/json'), + ); + if (!jsonContentType) { throw new OperationError( this.operation, `No application/json content type found in response for status code ${statusCode}`, ); - } else if ('$ref' in schema.content['application/json'].schema) { + } else if ('$ref' in contentTypes[jsonContentType].schema) { throw new OperationError( this.operation, 'Reference objects are not supported', @@ -97,21 +102,25 @@ export class ResponseBodyParser ); } - if (!responseSchema?.content && body?.length) { + const contentTypes = responseSchema.content; + if (!contentTypes && body?.length) { throw new OperationResponseError( this.operation, response, 'Received a body but no schema was found', ); } - if (!responseSchema?.content!['application/json']) { + const jsonContentType = Object.keys(contentTypes ?? {}).find(contentType => + contentType.split(';').includes('application/json'), + ); + if (!jsonContentType) { throw new OperationResponseError( this.operation, response, 'No application/json content type found in response', ); } - const schema = responseSchema.content!['application/json'].schema; + const schema = responseSchema.content![jsonContentType].schema; // This is a bit of type laziness. Ideally, this would be a type-narrowing function, but I wasn't able to get the types to work. if (!schema) { throw new OperationError(this.operation, 'No schema found in response'); diff --git a/packages/backend-openapi-utils/src/testUtils.ts b/packages/backend-openapi-utils/src/testUtils.ts index f96a0b1b76..d93029fa8e 100644 --- a/packages/backend-openapi-utils/src/testUtils.ts +++ b/packages/backend-openapi-utils/src/testUtils.ts @@ -28,8 +28,8 @@ afterAll(() => { }); export async function wrapServer(app: Express): Promise { - const server = app.listen(+process.env.PORT!); - await proxy.initialize(); + const server = app.listen(proxy.forwardTo.port); + await proxy.initialize(`http://localhost:${proxy.forwardTo.port}`, server); return { ...server, address: () => new URL(proxy.url) } as any; } diff --git a/yarn.lock b/yarn.lock index bcc6a951fa..bf267d84fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3750,6 +3750,7 @@ __metadata: express: ^4.17.1 express-openapi-validator: ^5.0.4 express-promise-router: ^4.1.0 + get-port: ^7.1.0 json-schema-to-ts: ^3.0.0 lodash: ^4.17.21 mockttp: ^3.13.0 @@ -28304,6 +28305,13 @@ __metadata: languageName: node linkType: hard +"get-port@npm:^7.1.0": + version: 7.1.0 + resolution: "get-port@npm:7.1.0" + checksum: f4d23b43026124007663a899578cc87ff37bfcf645c5c72651e9810ebafc759857784e409fb8e0ada9b90e5c5db089b0ae2f5f6b49fba1ce2e0aff86094ab17d + languageName: node + linkType: hard + "get-stdin@npm:^9.0.0": version: 9.0.0 resolution: "get-stdin@npm:9.0.0" From ea8ffdb9dc8c7e207b1af818f7e0eefb0f85c97f Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 7 Jul 2024 16:39:10 -0400 Subject: [PATCH 102/291] don't validate 400 request body or query params Signed-off-by: aramissennyeydd --- packages/backend-openapi-utils/src/schema/utils.ts | 2 +- packages/backend-openapi-utils/src/schema/validation.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/backend-openapi-utils/src/schema/utils.ts b/packages/backend-openapi-utils/src/schema/utils.ts index 9c5a400ec9..aaf3fc6c41 100644 --- a/packages/backend-openapi-utils/src/schema/utils.ts +++ b/packages/backend-openapi-utils/src/schema/utils.ts @@ -39,7 +39,7 @@ export function mockttpToFetchResponse(response: CompletedResponse) { export function humanifyAjvError(error: ErrorObject) { switch (error.keyword) { case 'required': - return `The ${error.params.missingProperty} property is required`; + return `The "${error.params.missingProperty}" property is required`; case 'type': return `${ error.instancePath ? `"${error.instancePath}"` : 'Value' diff --git a/packages/backend-openapi-utils/src/schema/validation.ts b/packages/backend-openapi-utils/src/schema/validation.ts index d9d80ddcf5..5f7c6fe353 100644 --- a/packages/backend-openapi-utils/src/schema/validation.ts +++ b/packages/backend-openapi-utils/src/schema/validation.ts @@ -33,7 +33,12 @@ class RequestBodyValidator implements Validator { } async validate({ pair, operation }: ValidatorParams) { - const { request } = pair; + const { request, response } = pair; + if (response.statusCode === 400) { + // If the response is a 400, then the request is invalid and we shouldn't validate the parameters + return; + } + // NOTE: There may be a worthwhile optimization here to cache these results to avoid re-parsing the schema for every request. As is, I don't think this is a big deal. const parser = RequestBodyParser.fromOperation(operation, { ajv }); const fetchRequest = mockttpToFetchRequest(request); From 66af016ab7fcdb75bbacbb8cbfda07bd4dd8ea02 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 7 Jul 2024 16:49:52 -0400 Subject: [PATCH 103/291] add changesets Signed-off-by: aramissennyeydd --- .changeset/eleven-beds-play.md | 6 ++++++ .changeset/smart-jobs-sit.md | 5 +++++ 2 files changed, 11 insertions(+) create mode 100644 .changeset/eleven-beds-play.md create mode 100644 .changeset/smart-jobs-sit.md diff --git a/.changeset/eleven-beds-play.md b/.changeset/eleven-beds-play.md new file mode 100644 index 0000000000..32ae11ff49 --- /dev/null +++ b/.changeset/eleven-beds-play.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-search-backend': patch +'@backstage/plugin-catalog-backend': patch +--- + +Updated to use the improved OpenAPI Jest validation. diff --git a/.changeset/smart-jobs-sit.md b/.changeset/smart-jobs-sit.md new file mode 100644 index 0000000000..edd21e67b5 --- /dev/null +++ b/.changeset/smart-jobs-sit.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-openapi-utils': minor +--- + +Improved support for OpenAPI validation during Jest tests. Now, OpenAPI validation can happen as you are writing your Jest tests - you no longer have to run `repo schema openapi test`. From 28744791cb44365eff09f0b8f03bbf8f53847179 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 7 Jul 2024 16:51:40 -0400 Subject: [PATCH 104/291] remove configuration around throwing Signed-off-by: aramissennyeydd --- packages/backend-openapi-utils/src/proxy/setup.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/backend-openapi-utils/src/proxy/setup.ts b/packages/backend-openapi-utils/src/proxy/setup.ts index da84d44afc..c49f0d87de 100644 --- a/packages/backend-openapi-utils/src/proxy/setup.ts +++ b/packages/backend-openapi-utils/src/proxy/setup.ts @@ -49,12 +49,7 @@ export class Proxy { this.requestResponsePairs.set(request, response); } delete this.#openRequests[response.id]; - this.validator.validate(request, response).catch(err => { - if (process.env.THROW) { - throw err; - } - console.error(err); - }); + this.validator.validate(request, response); }); } From 85b4e92f5ca5a96561422f331d4d0cf2bff8fd05 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 7 Jul 2024 17:44:17 -0400 Subject: [PATCH 105/291] small fixes for CI checks Signed-off-by: aramissennyeydd --- packages/backend-openapi-utils/report.api.md | 8 ++ packages/backend-openapi-utils/src/index.ts | 6 +- .../src/schema/parameter-validation.ts | 88 +++++++++---------- .../src/schema/validation.test.ts | 4 +- .../backend-openapi-utils/src/testUtils.ts | 26 ++++-- .../src/service/createRouter.test.ts | 4 +- .../search-backend/src/service/router.test.ts | 4 +- 7 files changed, 84 insertions(+), 56 deletions(-) diff --git a/packages/backend-openapi-utils/report.api.md b/packages/backend-openapi-utils/report.api.md index a0495f39b5..5a2c89ee81 100644 --- a/packages/backend-openapi-utils/report.api.md +++ b/packages/backend-openapi-utils/report.api.md @@ -3,6 +3,8 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +/// + import type { ContentObject } from 'openapi3-ts'; import type core from 'express-serve-static-core'; import { Express as Express_2 } from 'express'; @@ -682,6 +684,9 @@ type SchemaRef = Schema extends { [Key in keyof Schema]: SchemaRef; }; +// @public +export function setupProxyHooks(): void; + // @public type TemplateToDocPath< Doc extends PathDoc, @@ -718,6 +723,9 @@ type ValueOf = T[keyof T]; // @public export const wrapInOpenApiTestServer: (app: Express_2) => Server | Express_2; +// @public +export function wrapServer(app: Express_2): Promise; + // Warnings were encountered during analysis: // // src/router.d.ts:8:5 - (ae-undocumented) Missing documentation for "get". diff --git a/packages/backend-openapi-utils/src/index.ts b/packages/backend-openapi-utils/src/index.ts index 57d9f755c2..24f4183311 100644 --- a/packages/backend-openapi-utils/src/index.ts +++ b/packages/backend-openapi-utils/src/index.ts @@ -32,4 +32,8 @@ export type { } from './utility'; export type { ApiRouter } from './router'; export { createValidatedOpenApiRouter, getOpenApiSpecRoute } from './stub'; -export { wrapInOpenApiTestServer, wrapServer } from './testUtils'; +export { + wrapInOpenApiTestServer, + wrapServer, + setupProxyHooks, +} from './testUtils'; diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.ts index 5cdb3dbd6b..90953e6e2a 100644 --- a/packages/backend-openapi-utils/src/schema/parameter-validation.ts +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.ts @@ -111,53 +111,51 @@ export class QueryParameterParser const remainingQueryParameters = new Set(searchParams.keys()); const queryParameters: Record = {}; + const parameterIterator = Object.entries(this.parameters); + // object parameters with form/explode style should be processed last as they collect all remaining parameters. - const parameterIterator = Object.entries(this.parameters).toSorted( - ([_, parameterA], [_B, parameterB]) => { - if ( - parameterA.schema.type !== 'object' && - parameterB.schema.type !== 'object' - ) { - return EQUAL; - } - if ( - parameterA.schema.type === 'object' && - parameterB.schema.type !== 'object' - ) { - return PLACE_A_AFTER_B; - } - if ( - parameterA.schema.type !== 'object' && - parameterB.schema.type === 'object' - ) { - return PLACE_A_BEFORE_B; - } - const isParameterAForm = - parameterA.style === 'form' || !parameterA.style; - const isParameterAFormExplode = - isParameterAForm && - (parameterA.explode || typeof parameterA.explode === 'undefined'); - const isParameterBForm = - parameterB.style === 'form' || !parameterB.style; - const isParameterBFormExplode = - isParameterBForm && - (parameterB.explode || typeof parameterB.explode === 'undefined'); - // Sort the form explode to the bottom of the array. - if (isParameterAFormExplode && isParameterBFormExplode) { - throw new OperationError( - this.operation, - 'Ambiguous query parameters, you cannot have 2 form explode parameters', - ); - } - if (isParameterAFormExplode) { - return PLACE_A_AFTER_B; - } - if (isParameterBFormExplode) { - return PLACE_A_BEFORE_B; - } + parameterIterator.sort(([_, parameterA], [_B, parameterB]) => { + if ( + parameterA.schema.type !== 'object' && + parameterB.schema.type !== 'object' + ) { return EQUAL; - }, - ); + } + if ( + parameterA.schema.type === 'object' && + parameterB.schema.type !== 'object' + ) { + return PLACE_A_AFTER_B; + } + if ( + parameterA.schema.type !== 'object' && + parameterB.schema.type === 'object' + ) { + return PLACE_A_BEFORE_B; + } + const isParameterAForm = parameterA.style === 'form' || !parameterA.style; + const isParameterAFormExplode = + isParameterAForm && + (parameterA.explode || typeof parameterA.explode === 'undefined'); + const isParameterBForm = parameterB.style === 'form' || !parameterB.style; + const isParameterBFormExplode = + isParameterBForm && + (parameterB.explode || typeof parameterB.explode === 'undefined'); + // Sort the form explode to the bottom of the array. + if (isParameterAFormExplode && isParameterBFormExplode) { + throw new OperationError( + this.operation, + 'Ambiguous query parameters, you cannot have 2 form explode parameters', + ); + } + if (isParameterAFormExplode) { + return PLACE_A_AFTER_B; + } + if (isParameterBFormExplode) { + return PLACE_A_BEFORE_B; + } + return EQUAL; + }); for (const [name, parameter] of parameterIterator) { if (!parameter.schema) { throw new OperationError( diff --git a/packages/backend-openapi-utils/src/schema/validation.test.ts b/packages/backend-openapi-utils/src/schema/validation.test.ts index fdb1350625..9c8c4589a3 100644 --- a/packages/backend-openapi-utils/src/schema/validation.test.ts +++ b/packages/backend-openapi-utils/src/schema/validation.test.ts @@ -17,7 +17,7 @@ import { findOperationByRequest, OpenApiProxyValidator } from './validation'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; -import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { registerMswTestHooks } from '@backstage/test-utils'; import { CompletedBody, CompletedRequest, CompletedResponse } from 'mockttp'; import withResponseBody from './__fixtures__/schemas/withJsonResponseBody.json'; import withQueryParameter from './__fixtures__/schemas/withQueryParameter.json'; @@ -61,7 +61,7 @@ function createMockttpResponse(response: { } describe('OpenApiProxyValidator', () => { - setupRequestMockHandlers(server); + registerMswTestHooks(server); let validator: OpenApiProxyValidator; async function mockSchema(schema: any) { diff --git a/packages/backend-openapi-utils/src/testUtils.ts b/packages/backend-openapi-utils/src/testUtils.ts index d93029fa8e..ea329332bd 100644 --- a/packages/backend-openapi-utils/src/testUtils.ts +++ b/packages/backend-openapi-utils/src/testUtils.ts @@ -19,14 +19,28 @@ import { Proxy } from './proxy/setup'; const proxy = new Proxy(); -beforeAll(async () => { - await proxy.setup(); -}); +/** + * Setup the proxy hooks for the test suite. This will start the proxy before all tests and stop it after all tests. + * @public + */ +export function setupProxyHooks() { + beforeAll(async () => { + await proxy.setup(); + }); -afterAll(() => { - proxy.stop(); -}); + afterAll(() => { + proxy.stop(); + }); +} +/** + * !!! THIS CURRENTLY ONLY SUPPORTS SUPERTEST !!! + * Setup a server with a custom OpenAPI proxy. This proxy will capture all requests and responses and make sure they + * conform to the spec. + * @param app - express server, needed to ensure we have the correct ports for the proxy. + * @returns - a configured HTTP server that should be used with supertest. + * @public + */ export async function wrapServer(app: Express): Promise { const server = app.listen(proxy.forwardTo.port); await proxy.initialize(`http://localhost:${proxy.forwardTo.port}`, server); diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 6654187526..3b478a2a84 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -38,7 +38,7 @@ import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common/a import { CatalogProcessingOrchestrator } from '../processing/types'; import { z } from 'zod'; import { decodeCursor, encodeCursor } from './util'; -import { wrapServer } from '@backstage/backend-openapi-utils'; +import { setupProxyHooks, wrapServer } from '@backstage/backend-openapi-utils'; import { Server } from 'http'; import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; import { LocationAnalyzer } from '@backstage/plugin-catalog-node'; @@ -53,6 +53,8 @@ describe('createRouter readonly disabled', () => { let locationAnalyzer: jest.Mocked; let permissionsService: jest.Mocked; + setupProxyHooks(); + beforeAll(async () => { entitiesCatalog = { entities: jest.fn(), diff --git a/plugins/search-backend/src/service/router.test.ts b/plugins/search-backend/src/service/router.test.ts index 5b6900ede1..41b03501a3 100644 --- a/plugins/search-backend/src/service/router.test.ts +++ b/plugins/search-backend/src/service/router.test.ts @@ -23,7 +23,7 @@ import { import express from 'express'; import request from 'supertest'; import { createRouter } from './router'; -import { wrapServer } from '@backstage/backend-openapi-utils'; +import { setupProxyHooks, wrapServer } from '@backstage/backend-openapi-utils'; import { Server } from 'http'; import { mockCredentials, @@ -55,6 +55,8 @@ describe('createRouter', () => { }, }; + setupProxyHooks(); + beforeAll(async () => { const logger = mockServices.logger.mock(); mockSearchEngine = { From 79655cb580b68ceea81f7c35023d9fe39fd8295c Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 7 Jul 2024 18:02:00 -0400 Subject: [PATCH 106/291] use a cjs compatible get-port Signed-off-by: aramissennyeydd --- packages/backend-openapi-utils/package.json | 2 +- yarn.lock | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/backend-openapi-utils/package.json b/packages/backend-openapi-utils/package.json index 04653c5b32..0a302ac27f 100644 --- a/packages/backend-openapi-utils/package.json +++ b/packages/backend-openapi-utils/package.json @@ -44,7 +44,7 @@ "express": "^4.17.1", "express-openapi-validator": "^5.0.4", "express-promise-router": "^4.1.0", - "get-port": "^7.1.0", + "get-port": "^5.1.1", "json-schema-to-ts": "^3.0.0", "lodash": "^4.17.21", "mockttp": "^3.13.0", diff --git a/yarn.lock b/yarn.lock index bf267d84fd..a326566b38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3750,7 +3750,7 @@ __metadata: express: ^4.17.1 express-openapi-validator: ^5.0.4 express-promise-router: ^4.1.0 - get-port: ^7.1.0 + get-port: ^5.1.1 json-schema-to-ts: ^3.0.0 lodash: ^4.17.21 mockttp: ^3.13.0 @@ -28305,13 +28305,6 @@ __metadata: languageName: node linkType: hard -"get-port@npm:^7.1.0": - version: 7.1.0 - resolution: "get-port@npm:7.1.0" - checksum: f4d23b43026124007663a899578cc87ff37bfcf645c5c72651e9810ebafc759857784e409fb8e0ada9b90e5c5db089b0ae2f5f6b49fba1ce2e0aff86094ab17d - languageName: node - linkType: hard - "get-stdin@npm:^9.0.0": version: 9.0.0 resolution: "get-stdin@npm:9.0.0" From 94018ea5a8ef0912a23c166c8b755f7cfdbf082d Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Sun, 7 Jul 2024 18:03:29 -0400 Subject: [PATCH 107/291] remove optic files for test cases that we're moving over to new jest plugin Signed-off-by: aramissennyeydd --- plugins/catalog-backend/optic.yml | 15 --------------- plugins/search-backend/optic.yml | 15 --------------- 2 files changed, 30 deletions(-) delete mode 100644 plugins/catalog-backend/optic.yml delete mode 100644 plugins/search-backend/optic.yml diff --git a/plugins/catalog-backend/optic.yml b/plugins/catalog-backend/optic.yml deleted file mode 100644 index dbbdefcf68..0000000000 --- a/plugins/catalog-backend/optic.yml +++ /dev/null @@ -1,15 +0,0 @@ -ruleset: - - breaking-changes -capture: - src/schema/openapi.yaml: - # 🔧 Runnable example with simple get requests. - # Run with "PORT=3000 optic capture src/schema/openapi.yaml --update interactive" in 'plugins/catalog-backend' - # You can change the server and the 'requests' section to experiment - server: - # This will not be used by 'backstage-repo-tools schema openapi test', but may be useful for interactive updates. - url: http://localhost:3000 - requests: - # ℹ️ Requests should be sent to the Optic proxy, the address of which is injected into 'run.command's env as OPTIC_PROXY (or the value of 'run.proxy_variable'). - run: - # 🔧 Specify a command that will generate traffic - command: yarn backstage-cli package test --no-watch "src/service/router.test.ts" "src/service/createRouter.test.ts" diff --git a/plugins/search-backend/optic.yml b/plugins/search-backend/optic.yml deleted file mode 100644 index 75f20f1bb8..0000000000 --- a/plugins/search-backend/optic.yml +++ /dev/null @@ -1,15 +0,0 @@ -ruleset: - - breaking-changes -capture: - src/schema/openapi.yaml: - # 🔧 Runnable example with simple get requests. - # Run with "PORT=3000 optic capture src/schema/openapi.yaml --update interactive" in 'plugins/search-backend' - # You can change the server and the 'requests' section to experiment - server: - # This will not be used by 'backstage-repo-tools schema openapi test', but may be useful for interactive updates. - url: http://localhost:3000 - requests: - # ℹ️ Requests should be sent to the Optic proxy, the address of which is injected into 'run.command's env as OPTIC_PROXY (or the value of 'run.proxy_variable'). - run: - # 🔧 Specify a command that will generate traffic - command: yarn backstage-cli package test --no-watch "src/service/router.test.ts" "src/service/createRouter.test.ts" From 395e6b9c3c83c16f2adb4c9260aee7c7741e39de Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Tue, 24 Sep 2024 19:05:30 -0400 Subject: [PATCH 108/291] only mention schema changes in the changeset Signed-off-by: aramissennyeydd --- .changeset/eleven-beds-play.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.changeset/eleven-beds-play.md b/.changeset/eleven-beds-play.md index 32ae11ff49..fd51922cb4 100644 --- a/.changeset/eleven-beds-play.md +++ b/.changeset/eleven-beds-play.md @@ -1,6 +1,5 @@ --- '@backstage/plugin-search-backend': patch -'@backstage/plugin-catalog-backend': patch --- -Updated to use the improved OpenAPI Jest validation. +Fix to schema to allow arbitrary query parameters. From 0af3a4998b0bc7becb88cd162e352b5a9a507d8c Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Tue, 24 Sep 2024 19:05:46 -0400 Subject: [PATCH 109/291] address feedback and double fix schema Signed-off-by: aramissennyeydd --- .../backend-openapi-utils/src/testUtils.ts | 38 +++++++++++-------- .../src/service/createRouter.test.ts | 4 +- .../src/schema/openapi.generated.ts | 15 +++++++- .../search-backend/src/schema/openapi.yaml | 13 ++++++- .../search-backend/src/service/router.test.ts | 4 +- 5 files changed, 49 insertions(+), 25 deletions(-) diff --git a/packages/backend-openapi-utils/src/testUtils.ts b/packages/backend-openapi-utils/src/testUtils.ts index ea329332bd..ebe22fce26 100644 --- a/packages/backend-openapi-utils/src/testUtils.ts +++ b/packages/backend-openapi-utils/src/testUtils.ts @@ -17,21 +17,7 @@ import { Express } from 'express'; import { Server } from 'http'; import { Proxy } from './proxy/setup'; -const proxy = new Proxy(); - -/** - * Setup the proxy hooks for the test suite. This will start the proxy before all tests and stop it after all tests. - * @public - */ -export function setupProxyHooks() { - beforeAll(async () => { - await proxy.setup(); - }); - - afterAll(() => { - proxy.stop(); - }); -} +const proxiesToCleanup: Proxy[] = []; /** * !!! THIS CURRENTLY ONLY SUPPORTS SUPERTEST !!! @@ -42,11 +28,33 @@ export function setupProxyHooks() { * @public */ export async function wrapServer(app: Express): Promise { + const proxy = new Proxy(); + await proxy.setup(); const server = app.listen(proxy.forwardTo.port); await proxy.initialize(`http://localhost:${proxy.forwardTo.port}`, server); + return { ...server, address: () => new URL(proxy.url) } as any; } +let registered = false; +function registerHooks() { + if (typeof afterAll !== 'function' || typeof beforeAll !== 'function') { + return; + } + if (registered) { + return; + } + registered = true; + + afterAll(() => { + for (const proxy of proxiesToCleanup) { + proxy.stop(); + } + }); +} + +registerHooks(); + /** * !!! THIS CURRENTLY ONLY SUPPORTS SUPERTEST !!! * Running against supertest, we need some way to hit the optic proxy. This ensures that diff --git a/plugins/catalog-backend/src/service/createRouter.test.ts b/plugins/catalog-backend/src/service/createRouter.test.ts index 3b478a2a84..6654187526 100644 --- a/plugins/catalog-backend/src/service/createRouter.test.ts +++ b/plugins/catalog-backend/src/service/createRouter.test.ts @@ -38,7 +38,7 @@ import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common/a import { CatalogProcessingOrchestrator } from '../processing/types'; import { z } from 'zod'; import { decodeCursor, encodeCursor } from './util'; -import { setupProxyHooks, wrapServer } from '@backstage/backend-openapi-utils'; +import { wrapServer } from '@backstage/backend-openapi-utils'; import { Server } from 'http'; import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; import { LocationAnalyzer } from '@backstage/plugin-catalog-node'; @@ -53,8 +53,6 @@ describe('createRouter readonly disabled', () => { let locationAnalyzer: jest.Mocked; let permissionsService: jest.Mocked; - setupProxyHooks(); - beforeAll(async () => { entitiesCatalog = { entities: jest.fn(), diff --git a/plugins/search-backend/src/schema/openapi.generated.ts b/plugins/search-backend/src/schema/openapi.generated.ts index 56aaeb2bbe..1e6adfb08f 100644 --- a/plugins/search-backend/src/schema/openapi.generated.ts +++ b/plugins/search-backend/src/schema/openapi.generated.ts @@ -207,8 +207,7 @@ export const spec = { name: 'filters', in: 'query', required: false, - style: 'form', - explode: true, + style: 'deepObject', allowReserved: true, schema: { $ref: '#/components/schemas/JsonObject', @@ -244,6 +243,18 @@ export const spec = { type: 'integer', }, }, + { + name: 'unknown', + in: 'query', + required: false, + style: 'form', + explode: true, + allowReserved: true, + schema: { + type: 'object', + additionalProperties: true, + }, + }, ], }, }, diff --git a/plugins/search-backend/src/schema/openapi.yaml b/plugins/search-backend/src/schema/openapi.yaml index a49b783b64..cbf3db273a 100644 --- a/plugins/search-backend/src/schema/openapi.yaml +++ b/plugins/search-backend/src/schema/openapi.yaml @@ -139,8 +139,7 @@ paths: - name: filters in: query required: false - style: form - explode: true + style: deepObject allowReserved: true schema: # JsonObject is used here instead of the full ZOD schema definition as @@ -167,3 +166,13 @@ paths: allowReserved: true schema: type: integer + - name: unknown + in: query + required: false + # explode form is the equivalent to allow any extra query parameters + style: form + explode: true + allowReserved: true + schema: + type: object + additionalProperties: true diff --git a/plugins/search-backend/src/service/router.test.ts b/plugins/search-backend/src/service/router.test.ts index 41b03501a3..5b6900ede1 100644 --- a/plugins/search-backend/src/service/router.test.ts +++ b/plugins/search-backend/src/service/router.test.ts @@ -23,7 +23,7 @@ import { import express from 'express'; import request from 'supertest'; import { createRouter } from './router'; -import { setupProxyHooks, wrapServer } from '@backstage/backend-openapi-utils'; +import { wrapServer } from '@backstage/backend-openapi-utils'; import { Server } from 'http'; import { mockCredentials, @@ -55,8 +55,6 @@ describe('createRouter', () => { }, }; - setupProxyHooks(); - beforeAll(async () => { const logger = mockServices.logger.mock(); mockSearchEngine = { From 74aa8f8b49bdf3f9e56d179acf868022bfd9b9ad Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Tue, 24 Sep 2024 19:16:27 -0400 Subject: [PATCH 110/291] fix api report Signed-off-by: aramissennyeydd --- packages/backend-openapi-utils/report.api.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/backend-openapi-utils/report.api.md b/packages/backend-openapi-utils/report.api.md index 5a2c89ee81..f543a9cfb7 100644 --- a/packages/backend-openapi-utils/report.api.md +++ b/packages/backend-openapi-utils/report.api.md @@ -684,9 +684,6 @@ type SchemaRef = Schema extends { [Key in keyof Schema]: SchemaRef; }; -// @public -export function setupProxyHooks(): void; - // @public type TemplateToDocPath< Doc extends PathDoc, From 7869607165df158236c0f436e541c7902f0c0a5e Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Tue, 24 Sep 2024 19:16:45 -0400 Subject: [PATCH 111/291] simplify parameter sorting Signed-off-by: aramissennyeydd --- packages/backend-openapi-utils/src/index.ts | 6 +- .../src/schema/parameter-validation.ts | 77 +++++++------------ 2 files changed, 30 insertions(+), 53 deletions(-) diff --git a/packages/backend-openapi-utils/src/index.ts b/packages/backend-openapi-utils/src/index.ts index 24f4183311..57d9f755c2 100644 --- a/packages/backend-openapi-utils/src/index.ts +++ b/packages/backend-openapi-utils/src/index.ts @@ -32,8 +32,4 @@ export type { } from './utility'; export type { ApiRouter } from './router'; export { createValidatedOpenApiRouter, getOpenApiSpecRoute } from './stub'; -export { - wrapInOpenApiTestServer, - wrapServer, - setupProxyHooks, -} from './testUtils'; +export { wrapInOpenApiTestServer, wrapServer } from './testUtils'; diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.ts index 90953e6e2a..cbcb098166 100644 --- a/packages/backend-openapi-utils/src/schema/parameter-validation.ts +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.ts @@ -95,10 +95,6 @@ class BaseParameterParser { } } -const PLACE_A_BEFORE_B = -1; -const PLACE_A_AFTER_B = 1; -const EQUAL = 0; - export class QueryParameterParser extends BaseParameterParser implements RequestParser> @@ -111,51 +107,36 @@ export class QueryParameterParser const remainingQueryParameters = new Set(searchParams.keys()); const queryParameters: Record = {}; - const parameterIterator = Object.entries(this.parameters); + let parameterIterator = Object.entries(this.parameters); + + const isFormExplode = (parameter: ReferencelessParameterObject) => { + return ( + parameter.schema?.type === 'object' && + (parameter.style === 'form' || !parameter.style) && + parameter.explode + ); + }; + + const regularParameters = parameterIterator.filter( + ([_, parameter]) => !isFormExplode(parameter), + ); + + const formExplodeParameters = parameterIterator.filter(([_, parameter]) => + isFormExplode(parameter), + ); + + if (formExplodeParameters.length > 1) { + throw new OperationError( + this.operation, + 'Ambiguous query parameters, you cannot have 2 form explode parameters', + ); + } + + // Sort the parameters so that form explode parameters are processed last. + parameterIterator = [...regularParameters, ...formExplodeParameters]; + + console.log(parameterIterator); - // object parameters with form/explode style should be processed last as they collect all remaining parameters. - parameterIterator.sort(([_, parameterA], [_B, parameterB]) => { - if ( - parameterA.schema.type !== 'object' && - parameterB.schema.type !== 'object' - ) { - return EQUAL; - } - if ( - parameterA.schema.type === 'object' && - parameterB.schema.type !== 'object' - ) { - return PLACE_A_AFTER_B; - } - if ( - parameterA.schema.type !== 'object' && - parameterB.schema.type === 'object' - ) { - return PLACE_A_BEFORE_B; - } - const isParameterAForm = parameterA.style === 'form' || !parameterA.style; - const isParameterAFormExplode = - isParameterAForm && - (parameterA.explode || typeof parameterA.explode === 'undefined'); - const isParameterBForm = parameterB.style === 'form' || !parameterB.style; - const isParameterBFormExplode = - isParameterBForm && - (parameterB.explode || typeof parameterB.explode === 'undefined'); - // Sort the form explode to the bottom of the array. - if (isParameterAFormExplode && isParameterBFormExplode) { - throw new OperationError( - this.operation, - 'Ambiguous query parameters, you cannot have 2 form explode parameters', - ); - } - if (isParameterAFormExplode) { - return PLACE_A_AFTER_B; - } - if (isParameterBFormExplode) { - return PLACE_A_BEFORE_B; - } - return EQUAL; - }); for (const [name, parameter] of parameterIterator) { if (!parameter.schema) { throw new OperationError( From bc92f808c6aeb0a94a5e5a71db1fce8d51eb4727 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Tue, 24 Sep 2024 19:19:24 -0400 Subject: [PATCH 112/291] cleanup new proxies Signed-off-by: aramissennyeydd --- packages/backend-openapi-utils/src/testUtils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/backend-openapi-utils/src/testUtils.ts b/packages/backend-openapi-utils/src/testUtils.ts index ebe22fce26..11716050db 100644 --- a/packages/backend-openapi-utils/src/testUtils.ts +++ b/packages/backend-openapi-utils/src/testUtils.ts @@ -29,7 +29,9 @@ const proxiesToCleanup: Proxy[] = []; */ export async function wrapServer(app: Express): Promise { const proxy = new Proxy(); + proxiesToCleanup.push(proxy); await proxy.setup(); + const server = app.listen(proxy.forwardTo.port); await proxy.initialize(`http://localhost:${proxy.forwardTo.port}`, server); From 4fe128466155ef29ccc995c5f584f92f92a590a7 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Tue, 24 Sep 2024 20:28:47 -0400 Subject: [PATCH 113/291] add backend/test-utils Signed-off-by: aramissennyeydd --- packages/backend-openapi-utils/package.json | 3 +- yarn.lock | 126 ++------------------ 2 files changed, 9 insertions(+), 120 deletions(-) diff --git a/packages/backend-openapi-utils/package.json b/packages/backend-openapi-utils/package.json index 0a302ac27f..2a4cc4372c 100644 --- a/packages/backend-openapi-utils/package.json +++ b/packages/backend-openapi-utils/package.json @@ -35,8 +35,8 @@ "dependencies": { "@apidevtools/swagger-parser": "^10.1.0", "@backstage/backend-plugin-api": "workspace:^", - "@backstage/backend-test-utils": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/test-utils": "workspace:^", "@backstage/types": "workspace:^", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", @@ -53,6 +53,7 @@ "openapi3-ts": "^3.1.2" }, "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "supertest": "^7.0.0" } diff --git a/yarn.lock b/yarn.lock index a326566b38..5982e6a9bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3743,6 +3743,7 @@ __metadata: "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@types/express": ^4.17.6 "@types/express-serve-static-core": ^4.17.5 @@ -10311,16 +10312,7 @@ __metadata: languageName: node linkType: hard -"@httptoolkit/httpolyglot@npm:^2.0.1, @httptoolkit/httpolyglot@npm:^2.1.1": - version: 2.1.1 - resolution: "@httptoolkit/httpolyglot@npm:2.1.1" - dependencies: - "@types/node": ^16.7.10 - checksum: 138ccd61355de334c509e2fc4ac9ade9e1aa6aa770ed2271e0bd1d883ed815eb742d0a4de37837edd03a9a243c05d6da32c5febe970f4518c46e2d76e6ff10d5 - languageName: node - linkType: hard - -"@httptoolkit/httpolyglot@npm:^2.2.1": +"@httptoolkit/httpolyglot@npm:^2.0.1, @httptoolkit/httpolyglot@npm:^2.2.1": version: 2.2.1 resolution: "@httptoolkit/httpolyglot@npm:2.2.1" dependencies: @@ -18283,7 +18275,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^16.11.26, @types/node@npm:^16.7.10": +"@types/node@npm:^16.11.26": version: 16.18.112 resolution: "@types/node@npm:16.18.112" checksum: d634729e60d2e7bd951843fddf5fb59ae786ca707f384e8f90881b011076962b4e3fee3393e3be4be4fb7d86943893e0bda9650aea24ac692bdf407bf1a5d84d @@ -21373,7 +21365,7 @@ __metadata: languageName: node linkType: hard -"async@npm:^2.6.2, async@npm:^2.6.4": +"async@npm:^2.6.4": version: 2.6.4 resolution: "async@npm:2.6.4" dependencies: @@ -22129,13 +22121,6 @@ __metadata: languageName: node linkType: hard -"brotli-wasm@npm:^1.1.0": - version: 1.3.1 - resolution: "brotli-wasm@npm:1.3.1" - checksum: ec2931a989ee6f0bb52c2aabf23a0d230232d3bd69fb68ee3dab9542fc9ae2d4085d0e5338f71520c25a4a26cf1cfc991ce02910c24d63d42c7915c5722a3713 - languageName: node - linkType: hard - "brotli-wasm@npm:^3.0.0": version: 3.0.1 resolution: "brotli-wasm@npm:3.0.1" @@ -24703,7 +24688,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:^3.1.1, debug@npm:^3.2.7": +"debug@npm:^3.2.7": version: 3.2.7 resolution: "debug@npm:3.2.7" dependencies: @@ -25018,15 +25003,6 @@ __metadata: languageName: node linkType: hard -"destroyable-server@npm:^1.0.0": - version: 1.0.0 - resolution: "destroyable-server@npm:1.0.0" - dependencies: - "@types/node": "*" - checksum: ac81b26f616a9d0aaa9cb759fa5a5a186f887025362329f7ddc909f53090f4aea0d1b75c4dda23e210faee536e2d7352de017261e1625b7b18108f0e630efa1f - languageName: node - linkType: hard - "destroyable-server@npm:^1.0.2": version: 1.0.2 resolution: "destroyable-server@npm:1.0.2" @@ -29331,17 +29307,6 @@ __metadata: languageName: node linkType: hard -"http-encoding@npm:^1.5.1": - version: 1.5.1 - resolution: "http-encoding@npm:1.5.1" - dependencies: - brotli-wasm: ^1.1.0 - pify: ^5.0.0 - zstd-codec: ^0.1.4 - checksum: 534aa2facb0ae529fa88b9778867472247711626b90030fd4351572c6147fb5e895d9d2e305e7dc5cc993345f2fbdb17ca99345651bf76dbac39a07f552af2ac - languageName: node - linkType: hard - "http-encoding@npm:^2.0.1": version: 2.0.1 resolution: "http-encoding@npm:2.0.1" @@ -29501,16 +29466,6 @@ __metadata: languageName: node linkType: hard -"http2-wrapper@npm:^2.2.0": - version: 2.2.0 - resolution: "http2-wrapper@npm:2.2.0" - dependencies: - quick-lru: ^5.1.1 - resolve-alpn: ^1.2.0 - checksum: 6fd20e5cb6a58151715b3581e06a62a47df943187d2d1f69e538a50cccb7175dd334ecfde7900a37d18f3e13a1a199518a2c211f39860e81e9a16210c199cfaa - languageName: node - linkType: hard - "http2-wrapper@npm:^2.2.1": version: 2.2.1 resolution: "http2-wrapper@npm:2.2.1" @@ -34561,7 +34516,7 @@ __metadata: languageName: node linkType: hard -"mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.4, mkdirp@npm:^0.5.5, mkdirp@npm:^0.5.6": +"mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.4, mkdirp@npm:^0.5.6": version: 0.5.6 resolution: "mkdirp@npm:0.5.6" dependencies: @@ -34597,7 +34552,7 @@ __metadata: languageName: node linkType: hard -"mockttp@npm:^3.13.0": +"mockttp@npm:^3.13.0, mockttp@npm:^3.9.1": version: 3.15.2 resolution: "mockttp@npm:3.15.2" dependencies: @@ -34649,55 +34604,6 @@ __metadata: languageName: node linkType: hard -"mockttp@npm:^3.9.1": - version: 3.9.4 - resolution: "mockttp@npm:3.9.4" - dependencies: - "@graphql-tools/schema": ^8.5.0 - "@graphql-tools/utils": ^8.8.0 - "@httptoolkit/httpolyglot": ^2.1.1 - "@httptoolkit/subscriptions-transport-ws": ^0.11.2 - "@httptoolkit/websocket-stream": ^6.0.1 - "@types/cors": ^2.8.6 - "@types/node": "*" - base64-arraybuffer: ^0.1.5 - body-parser: ^1.15.2 - cacheable-lookup: ^6.0.0 - common-tags: ^1.8.0 - connect: ^3.7.0 - cors: ^2.8.4 - cors-gate: ^1.1.3 - cross-fetch: ^3.1.5 - destroyable-server: ^1.0.0 - express: ^4.14.0 - graphql: ^14.0.2 || ^15.5 - graphql-http: ^1.22.0 - graphql-subscriptions: ^1.1.0 - graphql-tag: ^2.12.6 - http-encoding: ^1.5.1 - http2-wrapper: ^2.2.0 - https-proxy-agent: ^5.0.1 - isomorphic-ws: ^4.0.1 - lodash: ^4.16.4 - lru-cache: ^7.14.0 - native-duplexpair: ^1.0.0 - node-forge: ^1.2.1 - pac-proxy-agent: ^7.0.0 - parse-multipart-data: ^1.4.0 - performance-now: ^2.1.0 - portfinder: 1.0.28 - read-tls-client-hello: ^1.0.0 - semver: ^7.5.3 - socks-proxy-agent: ^7.0.0 - typed-error: ^3.0.2 - uuid: ^8.3.2 - ws: ^8.8.0 - bin: - mockttp: dist/admin/admin-bin.js - checksum: 2e0b984d77a94e6a754e44c85a7ff2ded13ba42fd6cabf125b677a8a57eff543c896bf3ecb522799d3efbe18733bf019fbe707044f098fdd5e8e4bc2c0b1df4f - languageName: node - linkType: hard - "module-details-from-path@npm:^1.0.3": version: 1.0.3 resolution: "module-details-from-path@npm:1.0.3" @@ -37203,17 +37109,6 @@ __metadata: languageName: node linkType: hard -"portfinder@npm:1.0.28": - version: 1.0.28 - resolution: "portfinder@npm:1.0.28" - dependencies: - async: ^2.6.2 - debug: ^3.1.1 - mkdirp: ^0.5.5 - checksum: 91fef602f13f8f4c64385d0ad2a36cc9dc6be0b8d10a2628ee2c3c7b9917ab4fefb458815b82cea2abf4b785cd11c9b4e2d917ac6fa06f14b6fa880ca8f8928c - languageName: node - linkType: hard - "portfinder@npm:^1.0.28, portfinder@npm:^1.0.32": version: 1.0.32 resolution: "portfinder@npm:1.0.32" @@ -45373,13 +45268,6 @@ __metadata: languageName: node linkType: hard -"zstd-codec@npm:^0.1.4": - version: 0.1.4 - resolution: "zstd-codec@npm:0.1.4" - checksum: 8689bc0defc4f387d1be990b8b8ca8ca56690d17dfc8dd4703db798465b92a21e64e54e886acfaa376147d9d07d879a68627b09fddc34a0c93f0dc5c610a790c - languageName: node - linkType: hard - "zstd-codec@npm:^0.1.5": version: 0.1.5 resolution: "zstd-codec@npm:0.1.5" From 7263f925b4c803a25e7779ae75ac3121801bf880 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Tue, 24 Sep 2024 20:29:18 -0400 Subject: [PATCH 114/291] remove unused import Signed-off-by: aramissennyeydd --- packages/backend-openapi-utils/package.json | 3 +-- yarn.lock | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/backend-openapi-utils/package.json b/packages/backend-openapi-utils/package.json index 2a4cc4372c..6c391e8f70 100644 --- a/packages/backend-openapi-utils/package.json +++ b/packages/backend-openapi-utils/package.json @@ -36,7 +36,6 @@ "@apidevtools/swagger-parser": "^10.1.0", "@backstage/backend-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", - "@backstage/test-utils": "workspace:^", "@backstage/types": "workspace:^", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", @@ -53,8 +52,8 @@ "openapi3-ts": "^3.1.2" }, "devDependencies": { - "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", + "@backstage/test-utils": "workspace:^", "supertest": "^7.0.0" } } diff --git a/yarn.lock b/yarn.lock index 5982e6a9bd..880145f6bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3740,7 +3740,6 @@ __metadata: dependencies: "@apidevtools/swagger-parser": ^10.1.0 "@backstage/backend-plugin-api": "workspace:^" - "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/test-utils": "workspace:^" From 3047813d42e0b0a34555fc5c070e727d7c98a38c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 7 Oct 2024 16:43:37 +0200 Subject: [PATCH 115/291] cli: add --successCache option for repo test Signed-off-by: Patrik Oldsberg --- .changeset/small-donkeys-attack.md | 4 +- docs/tooling/cli/02-build-system.md | 1 + packages/cli/cli-report.md | 2 + packages/cli/config/jest.js | 8 +- .../cli/config/jestCacheResultProcessor.cjs | 40 ++++ packages/cli/package.json | 2 + packages/cli/src/commands/index.ts | 8 + packages/cli/src/commands/repo/test.ts | 186 +++++++++++++++++- yarn.lock | 2 + 9 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 packages/cli/config/jestCacheResultProcessor.cjs diff --git a/.changeset/small-donkeys-attack.md b/.changeset/small-donkeys-attack.md index 116cc611b5..ecfc969452 100644 --- a/.changeset/small-donkeys-attack.md +++ b/.changeset/small-donkeys-attack.md @@ -2,6 +2,6 @@ '@backstage/cli': patch --- -Added a new `--successCache` option to the `backstage-cli repo lint` command. The cache keeps track of successful lint runs and avoids re-running linting of individual packages if they haven't changed. This option is primarily intended to be used in CI. +Added a new `--successCache` option to the `backstage-cli repo test` and `backstage-cli repo lint` commands. The cache keeps track of successful runs and avoids re-running for individual packages if they haven't changed. This option is intended only to be used in CI. -In addition a `--successCacheDir` option has also been added to be able to override the default cache directory. +In addition a `--successCacheDir ` option has also been added to be able to override the default cache directory. diff --git a/docs/tooling/cli/02-build-system.md b/docs/tooling/cli/02-build-system.md index 52410001f7..dfd95ae6eb 100644 --- a/docs/tooling/cli/02-build-system.md +++ b/docs/tooling/cli/02-build-system.md @@ -564,6 +564,7 @@ The overrides in a single `package.json` may for example look like this: Caching is used sparingly throughout the Backstage build system. It is always used as a way to squeeze out a little bit of extra performance, rather than requirement to keep things fast. The following is a list of places where optional caching is available: - **TypeScript** - The default `tsconfig.json` used by Backstage projects has `incremental` set to `true`, which enables local caching of type checking results. It is however generally not recommended in CI, where `yarn tsc:full` is preferred, which sets `--incremental false`. +- **Testing** - The `backstage-cli repo test` command has a `--successCache` flag that enables caching of successful test results. This is done at the package level, meaning that if a package has not been changed since the last test run and it was successful, the testing will be skipped. This is recommended to be used in CI, but not during local development. - **Linting** - The `backstage-cli repo lint` command has a `--successCache` flag that enables caching of successful linting results. This is done at the package level, meaning that if a package has not been changed since the last lint run and it was successful, the linting will be skipped. This is recommended to be used in CI, but not during local development. - **Webpack** - It is possible to enable experimental caching of frontend package builds using the `BACKSTAGE_CLI_EXPERIMENTAL_BUILD_CACHE` environment variable. This will enable the Webpack filesystem cache. diff --git a/packages/cli/cli-report.md b/packages/cli/cli-report.md index 72cf7c4505..f164e5e1fc 100644 --- a/packages/cli/cli-report.md +++ b/packages/cli/cli-report.md @@ -469,6 +469,8 @@ Usage: backstage-cli repo test [options] Options: --since + --successCache + --successCacheDir --jest-help -h, --help ``` diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index d99db11656..94164b09ab 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -306,7 +306,7 @@ async function getRootConfig() { ), ).then(_ => _.flat()); - const configs = await Promise.all( + let configs = await Promise.all( projectPaths.flat().map(async projectPath => { const packagePath = path.resolve(projectPath, 'package.json'); if (!(await fs.pathExists(packagePath))) { @@ -331,9 +331,15 @@ async function getRootConfig() { }), ).then(cs => cs.filter(Boolean)); + const cache = global.__backstageCli_jestSuccessCache; + if (cache) { + configs = await cache.filterConfigs(configs, globalRootConfig); + } + return { rootDir: paths.targetRoot, projects: configs, + testResultsProcessor: require.resolve('./jestCacheResultProcessor.cjs'), ...globalRootConfig, }; } diff --git a/packages/cli/config/jestCacheResultProcessor.cjs b/packages/cli/config/jestCacheResultProcessor.cjs new file mode 100644 index 0000000000..ae7de0ba3c --- /dev/null +++ b/packages/cli/config/jestCacheResultProcessor.cjs @@ -0,0 +1,40 @@ +/* + * Copyright 2024 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. + */ + +module.exports = async results => { + const cache = global.__backstageCli_jestSuccessCache; + if (!cache) { + return results; + } + + const successful = new Set(); + const failed = new Set(); + for (const testResult of results.testResults) { + const projectName = testResult.displayName.name; + if (testResult.numFailingTests > 0) { + failed.add(projectName); + successful.delete(projectName); + } else if (!failed.has(projectName)) { + successful.add(projectName); + } + } + + await cache.reportResults({ + successful: successful, + }); + + return results; +}; diff --git a/packages/cli/package.json b/packages/cli/package.json index 67f69c988c..f438cae444 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -113,6 +113,7 @@ "html-webpack-plugin": "^5.3.1", "inquirer": "^8.2.0", "jest": "^29.7.0", + "jest-cli": "^29.7.0", "jest-css-modules": "^2.1.0", "jest-environment-jsdom": "^29.0.2", "jest-runtime": "^29.0.2", @@ -152,6 +153,7 @@ "webpack-dev-server": "^5.0.0", "webpack-node-externals": "^3.0.0", "yaml": "^2.0.0", + "yargs": "^16.2.0", "yml-loader": "^2.1.0", "yn": "^4.0.0", "zod": "^3.22.4" diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 0d72400bc4..a3060f2d67 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -105,6 +105,14 @@ export function registerRepoCommand(program: Command) { '--since ', 'Only test packages that changed since the specified ref', ) + .option( + '--successCache', + 'Enable success caching, which skips running tests for unchanged packages that were successful in the previous run', + ) + .option( + '--successCacheDir ', + 'Set the success cache location, (default: node_modules/.cache/backstage-cli)', + ) .option( '--jest-help', 'Show help for Jest CLI options, which are passed through', diff --git a/packages/cli/src/commands/repo/test.ts b/packages/cli/src/commands/repo/test.ts index 1ca0da5495..72c9f3aada 100644 --- a/packages/cli/src/commands/repo/test.ts +++ b/packages/cli/src/commands/repo/test.ts @@ -15,10 +15,76 @@ */ import os from 'os'; +import crypto from 'node:crypto'; +import fs from 'fs-extra'; +import yargs from 'yargs'; +import { resolve as resolvePath, relative as relativePath } from 'path'; import { Command, OptionValues } from 'commander'; -import { PackageGraph } from '@backstage/cli-node'; +import { Lockfile, PackageGraph } from '@backstage/cli-node'; import { paths } from '../../lib/paths'; -import { runCheck } from '../../lib/run'; +import { runCheck, runPlain } from '../../lib/run'; + +type JestProject = { + displayName: string; +}; + +interface GlobalWithCache extends Global { + __backstageCli_jestSuccessCache?: { + filterConfigs( + projectConfigs: JestProject[], + globalConfig: unknown, + ): Promise; + reportResults(options: { successful: Set }): Promise; + }; +} + +const CACHE_FILE_NAME = 'test-cache.json'; + +type Cache = string[]; + +async function readCache(dir: string): Promise { + try { + const data = await fs.readJson(resolvePath(dir, CACHE_FILE_NAME)); + if (!Array.isArray(data)) { + return undefined; + } + if (data.some(x => typeof x !== 'string')) { + return undefined; + } + return data as Cache; + } catch { + return undefined; + } +} + +function writeCache(dir: string, cache: Cache) { + fs.mkdirpSync(dir); + fs.writeJsonSync(resolvePath(dir, CACHE_FILE_NAME), cache, { spaces: 2 }); +} + +/** + * Use git to get the HEAD tree hashes of each package in the project. + */ +async function getPackageTreeHashes(graph: PackageGraph) { + const pkgs = Array.from(graph.values()); + const output = await runPlain( + 'git', + 'ls-tree', + '--object-only', + 'HEAD', + '--', + ...pkgs.map(pkg => relativePath(paths.targetRoot, pkg.dir)), + ); + + const treeShaList = output.trim().split(/\r?\n/); + if (treeShaList.length !== pkgs.length) { + throw new Error( + `Error listing project git tree hashes, output length does not equal input length`, + ); + } + + return new Map(pkgs.map((pkg, i) => [pkg.packageJson.name, treeShaList[i]])); +} export function createFlagFinder(args: string[]) { const flags = new Set(); @@ -120,9 +186,18 @@ export async function command(opts: OptionValues, cmd: Command): Promise { removeOptionArg(args, '--since'); } - if (opts.since && !hasFlags('--selectProjects')) { + let packageGraph: PackageGraph | undefined; + async function getPackageGraph() { + if (packageGraph) { + return packageGraph; + } const packages = await PackageGraph.listTargetPackages(); - const graph = PackageGraph.fromPackages(packages); + packageGraph = PackageGraph.fromPackages(packages); + return packageGraph; + } + + if (opts.since && !hasFlags('--selectProjects')) { + const graph = await getPackageGraph(); const changedPackages = await graph.listChangedPackages({ ref: opts.since, analyzeLockfile: true, @@ -163,5 +238,106 @@ export async function command(opts: OptionValues, cmd: Command): Promise { (process.stdout as any)._handle.setBlocking(true); } - await require('jest').run(args); + const jestCli = require('jest-cli'); + + // This code path is enabled by the --successCache flag, which is specific to + // the `repo test` command in the Backstage CLI. + if (opts.successCache) { + removeOptionArg(args, '--successCache'); + removeOptionArg(args, '--successCacheDir'); + + const cacheDir = resolvePath( + opts.successCacheDir ?? 'node_modules/.cache/backstage-cli', + ); + + // Parse the args to ensure that no file filters are provided, in which case we refuse to run + const { _: parsedArgs } = await yargs(args).options(jestCli.yargsOptions) + .argv; + if (parsedArgs.length > 0) { + throw new Error( + `The --successCache flag can not be combined with the following arguments: ${parsedArgs.join( + ', ', + )}`, + ); + } + // Likewise, it's not possible to combine sharding and the success cache + if (args.includes('--shard')) { + throw new Error( + `The --successCache flag can not be combined with the --shard flag`, + ); + } + + // Shared state for the bridge + const projectHashes = new Map(); + const outputSuccessCache = new Array(); + + // Set up a bridge with the @backstage/cli/config/jest configuration file. These methods + // are picked up by the config script itself, as well as the custom result processor. + const globalWithCache = global as GlobalWithCache; + globalWithCache.__backstageCli_jestSuccessCache = { + async filterConfigs(projectConfigs, globalRootConfig) { + const graph = await getPackageGraph(); + const cache = await readCache(cacheDir); + const lockfile = await Lockfile.load( + paths.resolveTargetRoot('yarn.lock'), + ); + const packageTreeHashes = await getPackageTreeHashes(graph); + + // Base hash shared by all projects + const baseHash = crypto.createHash('sha1'); + baseHash.update('v1'); // The version of this implementation + baseHash.update('\0'); + baseHash.update(process.version); // Node.js version + baseHash.update('\0'); + baseHash.update(JSON.stringify(globalRootConfig)); // Variable global jest config + const baseSha = baseHash.digest('hex'); + + return projectConfigs.filter(project => { + const packageName = project.displayName; + const pkg = graph.get(packageName); + if (!pkg) { + throw new Error( + `Package ${packageName} not found in package graph`, + ); + } + + const hash = crypto.createHash('sha1'); + + const packageTreeSha = packageTreeHashes.get(packageName); + if (!packageTreeSha) { + throw new Error(`Tree sha not found for ${packageName}`); + } + hash.update(baseSha); + hash.update(packageTreeSha); + // The project ID is a hash of the transform configuration, which helps + // us bust the cache when any changes are made to the transform implementation. + hash.update(JSON.stringify(project)); + hash.update(lockfile.getDependencyTreeHash(packageName)); + + const sha = hash.digest('hex'); + + projectHashes.set(packageName, sha); + + if (cache?.includes(sha)) { + console.log(`Skipped ${packageName} due to cache hit`); + outputSuccessCache.push(sha); + return undefined; + } + + return project; + }); + }, + async reportResults(options) { + for (const packageName of options.successful) { + const sha = projectHashes.get(packageName); + if (sha) { + outputSuccessCache.push(sha); + } + } + writeCache(cacheDir, outputSuccessCache); + }, + }; + } + + await jestCli.run(args); } diff --git a/yarn.lock b/yarn.lock index 2d1cc17f58..4029c9aef2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3988,6 +3988,7 @@ __metadata: html-webpack-plugin: ^5.3.1 inquirer: ^8.2.0 jest: ^29.7.0 + jest-cli: ^29.7.0 jest-css-modules: ^2.1.0 jest-environment-jsdom: ^29.0.2 jest-runtime: ^29.0.2 @@ -4032,6 +4033,7 @@ __metadata: webpack-dev-server: ^5.0.0 webpack-node-externals: ^3.0.0 yaml: ^2.0.0 + yargs: ^16.2.0 yml-loader: ^2.1.0 yn: ^4.0.0 zod: ^3.22.4 From a99a28570b56de92fb2655180582b15db52dd011 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 7 Oct 2024 16:44:51 +0200 Subject: [PATCH 116/291] workflows: use --successCache option for repo test Signed-off-by: Patrik Oldsberg --- .github/workflows/ci.yml | 2 +- .github/workflows/verify_windows.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abe36f40de..f448531b21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -237,7 +237,7 @@ jobs: run: yarn backstage-cli repo lint --since origin/master --successCache - name: test changed packages - run: yarn backstage-cli repo test --maxWorkers=3 --workerIdleMemoryLimit=1300M --since origin/master + run: yarn backstage-cli repo test --maxWorkers=3 --workerIdleMemoryLimit=1300M --since origin/master --successCache env: BACKSTAGE_TEST_DISABLE_DOCKER: 1 BACKSTAGE_TEST_DATABASE_POSTGRES16_CONNECTION_STRING: postgresql://postgres:postgres@localhost:${{ job.services.postgres16.ports[5432] }} diff --git a/.github/workflows/verify_windows.yml b/.github/workflows/verify_windows.yml index 80db28c2e1..4cb2cab220 100644 --- a/.github/workflows/verify_windows.yml +++ b/.github/workflows/verify_windows.yml @@ -55,7 +55,7 @@ jobs: run: yarn lint:type-deps - name: test - run: yarn backstage-cli repo test --maxWorkers=3 --workerIdleMemoryLimit=1300M + run: yarn backstage-cli repo test --maxWorkers=3 --workerIdleMemoryLimit=1300M --successCache env: BACKSTAGE_TEST_DISABLE_DOCKER: 1 From b3b4825162aca2056a0c6c9a7c96467a8d9557a9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 7 Oct 2024 17:23:15 +0200 Subject: [PATCH 117/291] cli: fix for repo test arg trimming removing too many args Signed-off-by: Patrik Oldsberg --- packages/cli/src/commands/repo/test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/repo/test.ts b/packages/cli/src/commands/repo/test.ts index 72c9f3aada..64ea23a99e 100644 --- a/packages/cli/src/commands/repo/test.ts +++ b/packages/cli/src/commands/repo/test.ts @@ -112,7 +112,7 @@ export function createFlagFinder(args: string[]) { }; } -function removeOptionArg(args: string[], option: string) { +function removeOptionArg(args: string[], option: string, size: number = 2) { let changed = false; do { changed = false; @@ -120,7 +120,7 @@ function removeOptionArg(args: string[], option: string) { const index = args.indexOf(option); if (index >= 0) { changed = true; - args.splice(index, 2); + args.splice(index, size); } const indexEq = args.findIndex(arg => arg.startsWith(`${option}=`)); if (indexEq >= 0) { @@ -243,7 +243,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise { // This code path is enabled by the --successCache flag, which is specific to // the `repo test` command in the Backstage CLI. if (opts.successCache) { - removeOptionArg(args, '--successCache'); + removeOptionArg(args, '--successCache', 1); removeOptionArg(args, '--successCacheDir'); const cacheDir = resolvePath( From 033c9573215c5f9ae69f5b4bda69544d4278bdb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 7 Oct 2024 14:17:34 +0200 Subject: [PATCH 118/291] use the catalog mock in more tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../CustomizableTemplate.stories.tsx | 7 +- .../templates/DefaultTemplate.stories.tsx | 7 +- .../DefaultApiExplorerPage.test.tsx | 14 +- .../ApisCards/ConsumedApisCard.test.tsx | 6 +- .../components/ApisCards/HasApisCard.test.tsx | 6 +- .../ApisCards/ProvidedApisCard.test.tsx | 6 +- .../ConsumingComponentsCard.test.tsx | 6 +- .../ProvidingComponentsCard.test.tsx | 6 +- .../CatalogAuthResolverContext.test.ts | 21 +-- .../src/api/CatalogImportClient.test.ts | 24 +-- .../DefaultImportPage.test.tsx | 4 +- .../components/ImportPage/ImportPage.test.tsx | 4 +- .../StepPrepareSelectLocations.test.tsx | 14 +- .../EntityAutocompletePicker.test.tsx | 22 +-- .../EntityLifecyclePicker.test.tsx | 10 +- .../EntityNamespacePicker.test.tsx | 40 ++--- .../EntityOwnerPicker.test.tsx | 53 +++---- .../useFacetsEntities.test.ts | 44 +++--- .../useQueryEntities.test.ts | 20 +-- .../EntityPeekAheadPopover.stories.tsx | 17 +-- .../EntityPeekAheadPopover.test.tsx | 36 ++--- .../EntityTagPicker/EntityTagPicker.test.tsx | 36 ++--- .../UnregisterEntityDialog.test.tsx | 16 +- .../UserListPicker/UserListPicker.test.tsx | 10 +- .../useAllEntitiesCount.test.tsx | 15 +- .../useOwnedEntitiesCount.test.tsx | 24 ++- .../useStarredEntitiesCount.test.tsx | 14 +- .../src/hooks/useEntityListProvider.test.tsx | 6 +- .../src/hooks/useEntityOwnership.test.tsx | 15 +- .../DefaultEntityPresentationApi.test.ts | 10 +- .../components/AboutCard/AboutCard.test.tsx | 13 +- .../CatalogPage/DefaultCatalogPage.test.tsx | 10 +- .../DependencyOfComponentsCard.test.tsx | 10 +- .../DependsOnComponentsCard.test.tsx | 10 +- .../DependsOnResourcesCard.test.tsx | 10 +- .../EntityLayout/EntityLayout.test.tsx | 45 ++---- .../DeleteEntityDialog.test.tsx | 6 +- .../EntityProcessingErrorsPanel.test.tsx | 12 +- .../EntityRelationWarning.test.tsx | 21 +-- .../HasComponentsCard.test.tsx | 10 +- .../HasResourcesCard.test.tsx | 10 +- .../HasSubcomponentsCard.test.tsx | 10 +- .../HasSubdomainsCard.test.tsx | 10 +- .../HasSystemsCard/HasSystemsCard.test.tsx | 10 +- .../SystemDiagramCard.test.tsx | 83 +++++------ .../FeaturedDocsCard/Content.test.tsx | 21 +-- .../FeaturedDocsCard.stories.tsx | 9 +- .../StarredEntities/Content.test.tsx | 8 +- .../StarredEntities.stories.tsx | 5 +- .../GroupListPicker/GroupListPicker.test.tsx | 8 +- plugins/org/src/__testUtils__/catalogMocks.ts | 6 +- .../MembersList/MembersListCard.test.tsx | 68 +++++---- .../OwnershipCard/OwnershipCard.test.tsx | 68 ++------- .../MyGroupsSidebarItem.test.tsx | 139 ++++++++---------- plugins/org/src/helpers/helpers.test.ts | 7 +- .../TemplateListPage.test.tsx | 32 ++-- .../ListTasksPage/ListTaskPage.test.tsx | 11 +- .../columns/OwnerEntityColumn.test.tsx | 11 +- .../fields/EntityPicker/EntityPicker.test.tsx | 23 +-- .../MultiEntityPicker.test.tsx | 23 +-- .../MyGroupsPicker/MyGroupsPicker.test.tsx | 6 +- .../fields/OwnerPicker/OwnerPicker.test.tsx | 27 +--- .../components/DefaultTechDocsHome.test.tsx | 28 ++-- .../Grids/EntityListDocsGrid.test.tsx | 13 +- .../components/TechDocsCustomHome.test.tsx | 28 ++-- 65 files changed, 525 insertions(+), 799 deletions(-) diff --git a/packages/app/src/components/home/templates/CustomizableTemplate.stories.tsx b/packages/app/src/components/home/templates/CustomizableTemplate.stories.tsx index 33a1bc2012..eb90b1fe84 100644 --- a/packages/app/src/components/home/templates/CustomizableTemplate.stories.tsx +++ b/packages/app/src/components/home/templates/CustomizableTemplate.stories.tsx @@ -25,6 +25,7 @@ import { entityRouteRef, catalogApiRef, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { wrapInTestApp, TestApiProvider } from '@backstage/test-utils'; import { configApiRef } from '@backstage/core-plugin-api'; import { ConfigReader } from '@backstage/config'; @@ -67,9 +68,7 @@ const entities = [ }, ]; -const mockCatalogApi = { - getEntities: async () => ({ items: entities }), -}; +const catalogApi = catalogApiMock({ entities }); const starredEntitiesApi = new MockStarredEntitiesApi(); starredEntitiesApi.toggleStarred('component:default/example-starred-entity'); @@ -85,7 +84,7 @@ export default { <> Promise.resolve({ results: [] }) }], [ diff --git a/packages/app/src/components/home/templates/DefaultTemplate.stories.tsx b/packages/app/src/components/home/templates/DefaultTemplate.stories.tsx index 64e6a87f60..cb3d684389 100644 --- a/packages/app/src/components/home/templates/DefaultTemplate.stories.tsx +++ b/packages/app/src/components/home/templates/DefaultTemplate.stories.tsx @@ -29,6 +29,7 @@ import { entityRouteRef, catalogApiRef, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { configApiRef } from '@backstage/core-plugin-api'; import { ConfigReader } from '@backstage/config'; import { HomePageSearchBar, searchPlugin } from '@backstage/plugin-search'; @@ -74,9 +75,7 @@ const entities = [ }, ]; -const mockCatalogApi = { - getEntities: async () => ({ items: entities }), -}; +const catalogApi = catalogApiMock({ entities }); const starredEntitiesApi = new MockStarredEntitiesApi(); starredEntitiesApi.toggleStarred('component:default/example-starred-entity'); @@ -92,7 +91,7 @@ export default { <> Promise.resolve({ results: [] }) }], [ diff --git a/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx b/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx index 7938770c4c..b8d8b07436 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.test.tsx @@ -16,21 +16,17 @@ import { ConfigReader } from '@backstage/core-app-api'; import { TableColumn, TableProps } from '@backstage/core-components'; -import { - ConfigApi, - configApiRef, - storageApiRef, -} from '@backstage/core-plugin-api'; +import { configApiRef, storageApiRef } from '@backstage/core-plugin-api'; import { CatalogTableRow, DefaultStarredEntitiesApi, } from '@backstage/plugin-catalog'; import { - CatalogApi, catalogApiRef, entityRouteRef, starredEntitiesApiRef, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { MockPermissionApi, MockStorageApi, @@ -45,7 +41,7 @@ import { DefaultApiExplorerPage } from './DefaultApiExplorerPage'; import { permissionApiRef } from '@backstage/plugin-permission-react'; describe('DefaultApiExplorerPage', () => { - const catalogApi: Partial = { + const catalogApi = catalogApiMock.mock({ getEntities: () => Promise.resolve({ items: [ @@ -74,9 +70,9 @@ describe('DefaultApiExplorerPage', () => { pageInfo: {}, totalItems: 0, }), - }; + }); - const configApi: ConfigApi = new ConfigReader({ + const configApi = new ConfigReader({ organization: { name: 'My Company', }, diff --git a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx index 95e279bad5..7ee4e9db97 100644 --- a/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ConsumedApisCard.test.tsx @@ -16,11 +16,11 @@ import { Entity, RELATION_CONSUMES_API } from '@backstage/catalog-model'; import { - CatalogApi, catalogApiRef, EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; @@ -31,9 +31,7 @@ describe('', () => { const apiDocsConfig: jest.Mocked = { getApiDefinitionWidget: jest.fn(), } as any; - const catalogApi: jest.Mocked = { - getEntitiesByRefs: jest.fn(), - } as any; + const catalogApi = catalogApiMock.mock(); let Wrapper: React.ComponentType>; beforeEach(() => { diff --git a/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx index 169311d0c8..01f86364c4 100644 --- a/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/HasApisCard.test.tsx @@ -16,7 +16,6 @@ import { Entity, RELATION_HAS_PART } from '@backstage/catalog-model'; import { - CatalogApi, catalogApiRef, EntityProvider, entityRouteRef, @@ -26,14 +25,13 @@ import { waitFor } from '@testing-library/react'; import React from 'react'; import { ApiDocsConfig, apiDocsConfigRef } from '../../config'; import { HasApisCard } from './HasApisCard'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; describe('', () => { const apiDocsConfig: jest.Mocked = { getApiDefinitionWidget: jest.fn(), } as any; - const catalogApi: jest.Mocked = { - getEntitiesByRefs: jest.fn(), - } as any; + const catalogApi = catalogApiMock.mock(); let Wrapper: React.ComponentType>; beforeEach(() => { diff --git a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx index 8c4f7dd26b..badc5a6a00 100644 --- a/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx +++ b/plugins/api-docs/src/components/ApisCards/ProvidedApisCard.test.tsx @@ -16,7 +16,6 @@ import { Entity, RELATION_PROVIDES_API } from '@backstage/catalog-model'; import { - CatalogApi, catalogApiRef, EntityProvider, entityRouteRef, @@ -26,14 +25,13 @@ import { waitFor } from '@testing-library/react'; import React from 'react'; import { ApiDocsConfig, apiDocsConfigRef } from '../../config'; import { ProvidedApisCard } from './ProvidedApisCard'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; describe('', () => { const apiDocsConfig: jest.Mocked = { getApiDefinitionWidget: jest.fn(), } as any; - const catalogApi: jest.Mocked = { - getEntitiesByRefs: jest.fn(), - } as any; + const catalogApi = catalogApiMock.mock(); let Wrapper: React.ComponentType>; beforeEach(() => { diff --git a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx index 20a60562b0..fe29c94793 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ConsumingComponentsCard.test.tsx @@ -16,20 +16,18 @@ import { Entity, RELATION_API_CONSUMED_BY } from '@backstage/catalog-model'; import { - CatalogApi, catalogApiRef, EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { ConsumingComponentsCard } from './ConsumingComponentsCard'; describe('', () => { - const catalogApi: jest.Mocked = { - getEntitiesByRefs: jest.fn(), - } as any; + const catalogApi = catalogApiMock.mock(); let Wrapper: React.ComponentType>; beforeEach(() => { diff --git a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx index 0801d7103a..5ec7d623d4 100644 --- a/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx +++ b/plugins/api-docs/src/components/ComponentsCards/ProvidingComponentsCard.test.tsx @@ -16,7 +16,6 @@ import { Entity, RELATION_API_PROVIDED_BY } from '@backstage/catalog-model'; import { - CatalogApi, catalogApiRef, EntityProvider, entityRouteRef, @@ -25,11 +24,10 @@ import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor } from '@testing-library/react'; import React from 'react'; import { ProvidingComponentsCard } from './ProvidingComponentsCard'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; describe('', () => { - const catalogApi: jest.Mocked = { - getEntitiesByRefs: jest.fn(), - } as any; + const catalogApi = catalogApiMock.mock(); let Wrapper: React.ComponentType>; beforeEach(() => { diff --git a/plugins/auth-backend/src/lib/resolvers/CatalogAuthResolverContext.test.ts b/plugins/auth-backend/src/lib/resolvers/CatalogAuthResolverContext.test.ts index 7fb759200c..a1ca3c53ac 100644 --- a/plugins/auth-backend/src/lib/resolvers/CatalogAuthResolverContext.test.ts +++ b/plugins/auth-backend/src/lib/resolvers/CatalogAuthResolverContext.test.ts @@ -15,34 +15,37 @@ */ import { CatalogAuthResolverContext } from './CatalogAuthResolverContext'; -import { CatalogApi } from '@backstage/catalog-client'; import { mockServices } from '@backstage/backend-test-utils'; import { TokenIssuer } from '../../identity/types'; import { DiscoveryService } from '@backstage/backend-plugin-api'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { NotFoundError } from '@backstage/errors'; describe('CatalogAuthResolverContext', () => { beforeEach(() => { jest.clearAllMocks(); }); - const mockCatalogApi = { - getEntities: jest.fn().mockResolvedValue({ items: [{}] }), - } as Partial>; + const catalogApi = catalogServiceMock.mock({ + getEntities: jest.fn().mockResolvedValue({ items: [] }), + }); it('adds kind to filter when missing', async () => { const context = CatalogAuthResolverContext.create({ logger: mockServices.logger.mock(), - catalogApi: mockCatalogApi as CatalogApi, + catalogApi, tokenIssuer: {} as TokenIssuer, discovery: {} as DiscoveryService, auth: mockServices.auth(), httpAuth: mockServices.httpAuth(), }); - await context.findCatalogUser({ - filter: [{}, { kind: 'group' }, { KIND: 'USER' }], - }); - expect(mockCatalogApi.getEntities).toHaveBeenCalledWith( + await expect( + context.findCatalogUser({ + filter: [{}, { kind: 'group' }, { KIND: 'USER' }], + }), + ).rejects.toThrow(NotFoundError); + expect(catalogApi.getEntities).toHaveBeenCalledWith( { filter: [{ kind: 'user' }, { kind: 'group' }, { KIND: 'USER' }], }, diff --git a/plugins/catalog-import/src/api/CatalogImportClient.test.ts b/plugins/catalog-import/src/api/CatalogImportClient.test.ts index fa646e3ca4..fb78066ac0 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.test.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -56,7 +56,7 @@ jest.mock('./AzureRepoApiClient', () => { import { ConfigReader, UrlPatternDiscovery } from '@backstage/core-app-api'; import { ScmIntegrations } from '@backstage/integration'; import { ScmAuthApi } from '@backstage/integration-react'; -import { CatalogApi } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { MockFetchApi, registerMswTestHooks } from '@backstage/test-utils'; import { Octokit } from '@octokit/rest'; import { rest } from 'msw'; @@ -94,19 +94,7 @@ describe('CatalogImportClient', () => { }), ); - const catalogApi = { - getEntities: jest.fn(), - addLocation: jest.fn(), - removeLocationById: jest.fn(), - getEntityByRef: jest.fn(), - getLocationByRef: jest.fn(), - getLocationById: jest.fn(), - removeEntityByUid: jest.fn(), - refreshEntity: jest.fn(), - getEntityAncestors: jest.fn(), - getEntityFacets: jest.fn(), - validateEntity: jest.fn(), - }; + const catalogApi = catalogApiMock.mock(); let catalogImportClient: CatalogImportClient; @@ -116,7 +104,7 @@ describe('CatalogImportClient', () => { scmAuthApi, scmIntegrationsApi, fetchApi, - catalogApi: catalogApi as Partial as CatalogApi, + catalogApi: catalogApi, configApi: new ConfigReader({ app: { baseUrl: 'https://demo.backstage.io/', @@ -462,7 +450,7 @@ describe('CatalogImportClient', () => { scmAuthApi, scmIntegrationsApi, fetchApi, - catalogApi: catalogApi as Partial as CatalogApi, + catalogApi: catalogApi, configApi: new ConfigReader({ catalog: { import: { @@ -720,7 +708,7 @@ describe('CatalogImportClient', () => { scmAuthApi, scmIntegrationsApi, fetchApi, - catalogApi: catalogApi as Partial as CatalogApi, + catalogApi: catalogApi, configApi: new ConfigReader({ catalog: { import: { @@ -806,7 +794,7 @@ describe('CatalogImportClient', () => { scmAuthApi, scmIntegrationsApi, fetchApi, - catalogApi: catalogApi as Partial as CatalogApi, + catalogApi: catalogApi, configApi: new ConfigReader({ catalog: { import: { diff --git a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx index 929594f22a..0e9378543e 100644 --- a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx +++ b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import { CatalogClient } from '@backstage/catalog-client'; import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; import { configApiRef } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { screen } from '@testing-library/react'; import React from 'react'; @@ -34,7 +34,7 @@ describe('', () => { beforeEach(() => { apis = TestApiRegistry.from( [configApiRef, new ConfigReader({ integrations: {} })], - [catalogApiRef, new CatalogClient({ discoveryApi: {} as any })], + [catalogApiRef, catalogApiMock()], [ catalogImportApiRef, new CatalogImportClient({ diff --git a/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx b/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx index e1c4edb896..3f6efeb685 100644 --- a/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx +++ b/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import { CatalogClient } from '@backstage/catalog-client'; import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; import { FetchApi, configApiRef } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { screen } from '@testing-library/react'; import React from 'react'; @@ -40,7 +40,7 @@ describe('', () => { beforeEach(() => { apis = TestApiRegistry.from( [configApiRef, new ConfigReader({ integrations: {} })], - [catalogApiRef, new CatalogClient({ discoveryApi: {} as any })], + [catalogApiRef, catalogApiMock()], [ catalogImportApiRef, new CatalogImportClient({ diff --git a/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.test.tsx b/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.test.tsx index e5a4174859..88b26b737a 100644 --- a/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.test.tsx +++ b/plugins/catalog-import/src/components/StepPrepareSelectLocations/StepPrepareSelectLocations.test.tsx @@ -21,15 +21,13 @@ import React from 'react'; import { AnalyzeResult } from '../../api'; import { StepPrepareSelectLocations } from './StepPrepareSelectLocations'; import { - CatalogApi, catalogApiRef, entityPresentationApiRef, } from '@backstage/plugin-catalog-react'; import { DefaultEntityPresentationApi } from '@backstage/plugin-catalog'; -import { Entity } from '@backstage/catalog-model'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; describe('', () => { - let entities: Entity[]; const analyzeResult = { type: 'locations', locations: [ @@ -61,19 +59,11 @@ describe('', () => { ], } as Extract; - const catalogApi: jest.Mocked = { - getLocationById: jest.fn(), - getEntityByName: jest.fn(), - getEntities: jest.fn(async () => ({ items: entities })), - addLocation: jest.fn(), - getLocationByRef: jest.fn(), - removeEntityByUid: jest.fn(), - } as any; + const catalogApi = catalogApiMock(); let Wrapper: React.ComponentType>; beforeEach(() => { jest.resetAllMocks(); - catalogApi.getEntities.mockResolvedValue({ items: entities }); Wrapper = ({ children }: { children?: React.ReactNode }) => ( > => { - return { +function makeMockCatalogApi(opts: string[] = defaultOptions) { + return catalogApiMock.mock({ getEntityFacets: jest.fn().mockResolvedValue({ facets: { 'spec.options': opts.map((value, idx) => ({ value, count: idx })), }, }), - }; -}; + }); +} describe('', () => { beforeEach(() => { @@ -64,9 +64,9 @@ describe('', () => { }); it('renders all options', async () => { - const mockCatalogApi = makeMockCatalogApi(); + const catalogApi = makeMockCatalogApi(); render( - + label="Options" @@ -82,7 +82,7 @@ describe('', () => { ); // should have called catalog backend without any filters applied - expect(mockCatalogApi.getEntityFacets).toHaveBeenCalledWith({ + expect(catalogApi.getEntityFacets).toHaveBeenCalledWith({ facets: ['spec.options'], filter: {}, }); diff --git a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx index 701e1047c9..77a47185bb 100644 --- a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx @@ -16,17 +16,17 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import React from 'react'; -import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; +import { + MockEntityListContextProvider, + catalogApiMock, +} from '@backstage/plugin-catalog-react/testUtils'; import { EntityLifecycleFilter } from '../../filters'; import { EntityLifecyclePicker } from './EntityLifecyclePicker'; import { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; import { catalogApiRef } from '../../api'; -import { CatalogApi } from '@backstage/catalog-client'; describe('', () => { - const catalogApi = { - getEntityFacets: jest.fn(), - } as unknown as jest.Mocked; + const catalogApi = catalogApiMock.mock(); beforeEach(() => { catalogApi.getEntityFacets.mockResolvedValue({ diff --git a/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx b/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx index 5253dfae28..dfd1636939 100644 --- a/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityNamespacePicker/EntityNamespacePicker.test.tsx @@ -16,17 +16,19 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'; import React from 'react'; -import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; +import { + MockEntityListContextProvider, + catalogApiMock, +} from '@backstage/plugin-catalog-react/testUtils'; import { EntityNamespaceFilter } from '../../filters'; import { EntityNamespacePicker } from './EntityNamespacePicker'; import { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; import { catalogApiRef } from '../../api'; -import { CatalogApi } from '@backstage/catalog-client'; const namespaces = ['namespace-1', 'namespace-2', 'namespace-3']; describe('', () => { - const mockCatalogApiRef = { + const catalogApi = catalogApiMock.mock({ getEntityFacets: async () => ({ facets: { 'metadata.namespace': namespaces.map((value, idx) => ({ @@ -35,11 +37,11 @@ describe('', () => { })), }, }), - } as unknown as CatalogApi; + }); it('renders all namespaces', async () => { await renderInTestApp( - + @@ -57,7 +59,7 @@ describe('', () => { it('renders unique namespaces in alphabetical order', async () => { await renderInTestApp( - + @@ -80,7 +82,7 @@ describe('', () => { const updateFilters = jest.fn(); const queryParameters = { namespace: ['namespace-1'] }; await renderInTestApp( - + ', () => { it('adds namespaces to filters', async () => { const updateFilters = jest.fn(); await renderInTestApp( - + ', () => { it('removes namespaces from filters', async () => { const updateFilters = jest.fn(); await renderInTestApp( - + ', () => { it('responds to external queryParameters changes', async () => { const updateFilters = jest.fn(); const rendered = await renderInTestApp( - + ', () => { }), ); rendered.rerender( - + ', () => { }); it('removes namespaces from filters if there are no available namespaces', async () => { const updateFilters = jest.fn(); - const mockCatalogApiRefNoNamespace = { + const mockCatalogApiRefNoNamespace = catalogApiMock.mock({ getEntityFacets: async () => ({ facets: { - 'metadata.namespace': {}, + 'metadata.namespace': [], }, }), - } as unknown as CatalogApi; + }); await renderInTestApp( @@ -217,7 +219,7 @@ describe('', () => { }); it('namespace picker is visible if there are only 1 available option', async () => { const defaultNamespaces = ['default', 'default', 'default']; - const mockCatalogApiRefDefaultNamespace = { + const mockCatalogApiRefDefaultNamespace = catalogApiMock.mock({ getEntityFacets: async () => ({ facets: { 'metadata.namespace': defaultNamespaces.map((value, idx) => ({ @@ -226,7 +228,7 @@ describe('', () => { })), }, }), - } as unknown as CatalogApi; + }); await renderInTestApp( ', () => { ); }); it('namespace picker is invisible if there is zero available option', async () => { - const mockCatalogApiRefDefaultNamespace = { + const mockCatalogApiRefDefaultNamespace = catalogApiMock.mock({ getEntityFacets: async () => ({ facets: { 'metadata.namespace': [], }, }), - } as unknown as CatalogApi; + }); await renderInTestApp( ', () => { }); it('renders initially selected namespaces', async () => { renderInTestApp( - + = - jest.fn(); - -const mockedGetEntitiesByRef: jest.MockedFn = - jest.fn(); - -const mockedGetEntityFacets: jest.MockedFn = - jest.fn(); - -const mockCatalogApi: Partial = { - queryEntities: mockedQueryEntities, - getEntitiesByRefs: mockedGetEntitiesByRef, - getEntityFacets: mockedGetEntityFacets, -}; - +const mockCatalogApi = catalogApiMock.mock(); const mockErrorApi = new MockErrorApi(); describe('', () => { @@ -134,7 +123,7 @@ describe('', () => { beforeEach(() => { jest.resetAllMocks(); - mockedQueryEntities.mockImplementation(async request => { + mockCatalogApi.queryEntities.mockImplementation(async request => { const totalItems = ownerEntitiesBatch1.length + ownerEntitiesBatch2.length; if ((request as QueryEntitiesCursorRequest).cursor) { @@ -178,8 +167,8 @@ describe('', () => { expect(screen.getByText(owner)).toBeInTheDocument(); }); - expect(mockedQueryEntities).toHaveBeenCalledTimes(1); - expect(mockedGetEntitiesByRef).not.toHaveBeenCalled(); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(1); + expect(mockCatalogApi.getEntitiesByRefs).not.toHaveBeenCalled(); fireEvent.scroll(screen.getByTestId('owner-picker-listbox')); @@ -195,7 +184,7 @@ describe('', () => { expect(screen.getByText(owner)).toBeInTheDocument(); }); - expect(mockedQueryEntities).toHaveBeenCalledTimes(2); + expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(2); }); it('respects the query parameter filter value', async () => { @@ -214,7 +203,7 @@ describe('', () => { , ); - expect(mockedGetEntitiesByRef).toHaveBeenCalledWith({ + expect(mockCatalogApi.getEntitiesByRefs).toHaveBeenCalledWith({ entityRefs: ['another-owner'], }); expect(updateFilters).toHaveBeenLastCalledWith({ @@ -226,7 +215,7 @@ describe('', () => { const updateFilters = jest.fn(); const queryParameters = { owners: ['another-owner'] }; - mockedGetEntitiesByRef.mockResolvedValue({ + mockCatalogApi.getEntitiesByRefs.mockResolvedValue({ items: [ { metadata: { @@ -260,7 +249,7 @@ describe('', () => { ).toBeInTheDocument(), ); - expect(mockedGetEntitiesByRef).toHaveBeenCalledWith({ + expect(mockCatalogApi.getEntitiesByRefs).toHaveBeenCalledWith({ entityRefs: ['another-owner'], }); @@ -268,7 +257,7 @@ describe('', () => { await waitFor(() => screen.getByText('Some Owner 2')); fireEvent.click(screen.getByText('Some Owner 2')); - expect(mockedGetEntitiesByRef).toHaveBeenCalledTimes(1); + expect(mockCatalogApi.getEntitiesByRefs).toHaveBeenCalledTimes(1); await waitFor(() => expect( @@ -292,7 +281,7 @@ describe('', () => { , ); - expect(mockedGetEntitiesByRef).not.toHaveBeenCalled(); + expect(mockCatalogApi.getEntitiesByRefs).not.toHaveBeenCalled(); expect(updateFilters).toHaveBeenLastCalledWith({ owners: undefined, }); @@ -320,7 +309,7 @@ describe('', () => { , ); - expect(mockedGetEntitiesByRef).toHaveBeenCalledWith({ + expect(mockCatalogApi.getEntitiesByRefs).toHaveBeenCalledWith({ entityRefs: ['group:default/some-owner'], }); expect(updateFilters).toHaveBeenLastCalledWith({ @@ -352,7 +341,7 @@ describe('', () => { , ); - expect(mockedGetEntitiesByRef).toHaveBeenCalledWith({ + expect(mockCatalogApi.getEntitiesByRefs).toHaveBeenCalledWith({ entityRefs: ['team-a'], }); expect(updateFilters).toHaveBeenLastCalledWith({ @@ -385,7 +374,7 @@ describe('', () => { beforeEach(() => { jest.resetAllMocks(); - mockedGetEntityFacets.mockResolvedValue({ + mockCatalogApi.getEntityFacets.mockResolvedValue({ facets: { 'relations.ownedBy': [ ...[...ownerEntitiesBatch1, ...ownerEntitiesBatch2].map(o => ({ @@ -396,7 +385,7 @@ describe('', () => { }, }); - mockedGetEntitiesByRef.mockResolvedValue({ + mockCatalogApi.getEntitiesByRefs.mockResolvedValue({ items: [...ownerEntitiesBatch1, ...ownerEntitiesBatch2], }); }); @@ -425,7 +414,7 @@ describe('', () => { expect(screen.getByText(owner)).toBeInTheDocument(); }); - expect(mockedGetEntityFacets).toHaveBeenCalledTimes(1); + expect(mockCatalogApi.getEntityFacets).toHaveBeenCalledTimes(1); fireEvent.scroll(screen.getByTestId('owner-picker-listbox')); @@ -476,7 +465,7 @@ describe('', () => { , ); - expect(mockedGetEntitiesByRef).toHaveBeenCalledWith({ + expect(mockCatalogApi.getEntitiesByRefs).toHaveBeenCalledWith({ entityRefs: [...ownerEntitiesBatch1, ...ownerEntitiesBatch2].map(entity => stringifyEntityRef(entity), ), diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.test.ts b/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.test.ts index 1d9eaa8af7..a3901825b3 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.test.ts +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/useFacetsEntities.test.ts @@ -13,21 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { renderHook, waitFor } from '@testing-library/react'; import { useFacetsEntities } from './useFacetsEntities'; -import { CatalogApi } from '@backstage/catalog-client'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { Entity, parseEntityRef } from '@backstage/catalog-model'; -const mockedGetEntityFacets: jest.MockedFn = - jest.fn(); - -const mockedGetEntitiesByRefs: jest.MockedFn = - jest.fn(); - -const mockCatalogApi: Partial = { - getEntityFacets: mockedGetEntityFacets, - getEntitiesByRefs: mockedGetEntitiesByRefs, -}; +const mockCatalogApi = catalogApiMock.mock(); jest.mock('@backstage/core-plugin-api', () => ({ ...jest.requireActual('@backstage/core-plugin-api'), @@ -65,16 +57,16 @@ describe('useFacetsEntities', () => { }); it(`should return empty items when facets are loading`, () => { - mockedGetEntityFacets.mockReturnValue(new Promise(() => {})); + mockCatalogApi.getEntityFacets.mockReturnValue(new Promise(() => {})); const { result } = renderHook(() => useFacetsEntities({ enabled: true })); expect(result.current[0]).toEqual({ value: { items: [] }, loading: true }); }); it(`should return empty response when facet is not present`, async () => { - mockedGetEntityFacets.mockResolvedValueOnce({ + mockCatalogApi.getEntityFacets.mockResolvedValueOnce({ facets: { 'metadata.tags': [{ value: 'tag', count: 1 }] }, }); - mockedGetEntitiesByRefs.mockResolvedValueOnce({ items: [] }); + mockCatalogApi.getEntitiesByRefs.mockResolvedValueOnce({ items: [] }); const { result } = renderHook(() => useFacetsEntities({ enabled: true })); result.current[1]({ text: '' }); await waitFor(() => { @@ -87,8 +79,10 @@ describe('useFacetsEntities', () => { it(`should return the owners`, async () => { const entityRefs = ['component:default/e1', 'component:default/e2']; - mockedGetEntityFacets.mockResolvedValue(facetsFromEntityRefs(entityRefs)); - mockedGetEntitiesByRefs.mockResolvedValue( + mockCatalogApi.getEntityFacets.mockResolvedValue( + facetsFromEntityRefs(entityRefs), + ); + mockCatalogApi.getEntitiesByRefs.mockResolvedValue( entitiesFromEntityRefs(entityRefs), ); @@ -152,8 +146,10 @@ describe('useFacetsEntities', () => { }, }; - mockedGetEntityFacets.mockResolvedValue(facetsFromEntityRefs(entityRefs)); - mockedGetEntitiesByRefs.mockResolvedValue( + mockCatalogApi.getEntityFacets.mockResolvedValue( + facetsFromEntityRefs(entityRefs), + ); + mockCatalogApi.getEntitiesByRefs.mockResolvedValue( entitiesFromEntityRefs(entityRefs, enrichedEntities), ); @@ -225,8 +221,10 @@ describe('useFacetsEntities', () => { 'component:default/b', ]; - mockedGetEntityFacets.mockResolvedValue(facetsFromEntityRefs(entityRefs)); - mockedGetEntitiesByRefs.mockResolvedValue( + mockCatalogApi.getEntityFacets.mockResolvedValue( + facetsFromEntityRefs(entityRefs), + ); + mockCatalogApi.getEntitiesByRefs.mockResolvedValue( entitiesFromEntityRefs(entityRefs), ); @@ -336,7 +334,9 @@ describe('useFacetsEntities', () => { 'component:default/nade', ]; - mockedGetEntityFacets.mockResolvedValue(facetsFromEntityRefs(entityRefs)); + mockCatalogApi.getEntityFacets.mockResolvedValue( + facetsFromEntityRefs(entityRefs), + ); const enrichedEntities: { [key: string]: Entity } = { 'group:default/go': { apiVersion: 'backstage.io/v1beta1', @@ -352,7 +352,7 @@ describe('useFacetsEntities', () => { }, }, }; - mockedGetEntitiesByRefs.mockResolvedValue( + mockCatalogApi.getEntitiesByRefs.mockResolvedValue( entitiesFromEntityRefs(entityRefs, enrichedEntities), ); diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/useQueryEntities.test.ts b/plugins/catalog-react/src/components/EntityOwnerPicker/useQueryEntities.test.ts index 64cc7769a6..2dd905a6e8 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/useQueryEntities.test.ts +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/useQueryEntities.test.ts @@ -13,16 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { renderHook, waitFor } from '@testing-library/react'; -import { CatalogApi } from '@backstage/catalog-client'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { useQueryEntities } from './useQueryEntities'; -const mockedQueryEntities: jest.MockedFn = - jest.fn(); - -const mockCatalogApi: Partial = { - queryEntities: mockedQueryEntities, -}; +const mockCatalogApi = catalogApiMock.mock(); jest.mock('@backstage/core-plugin-api', () => ({ ...jest.requireActual('@backstage/core-plugin-api'), @@ -35,18 +31,18 @@ describe('useQueryEntities', () => { }); it(`should not invoke queryEntities on mount`, () => { - mockedQueryEntities.mockResolvedValue({ + mockCatalogApi.queryEntities.mockResolvedValue({ items: [], pageInfo: {}, totalItems: 0, }); renderHook(() => useQueryEntities()); - expect(mockedQueryEntities).not.toHaveBeenCalled(); + expect(mockCatalogApi.queryEntities).not.toHaveBeenCalled(); }); it(`should fetch the data accordingly`, async () => { - mockedQueryEntities + mockCatalogApi.queryEntities .mockResolvedValueOnce({ items: [ { apiVersion: '1', kind: 'kind', metadata: { name: 'name-1' } }, @@ -74,7 +70,7 @@ describe('useQueryEntities', () => { cursor: 'next', }), ); - expect(mockedQueryEntities).toHaveBeenCalledWith({ + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ filter: { kind: ['User', 'Group'] }, fullTextFilter: { fields: [ @@ -98,7 +94,7 @@ describe('useQueryEntities', () => { ], }), ); - expect(mockedQueryEntities).toHaveBeenCalledWith({ + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ cursor: 'next', limit: 20, }); diff --git a/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.stories.tsx b/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.stories.tsx index d50fdc964b..72480cc72a 100644 --- a/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.stories.tsx +++ b/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.stories.tsx @@ -24,15 +24,16 @@ import { wrapInTestApp, TestApiProvider } from '@backstage/test-utils'; import { catalogApiRef } from '../../api'; import { CompoundEntityRef, + Entity, parseEntityRef, stringifyEntityRef, } from '@backstage/catalog-model'; import { entityRouteRef } from '../../routes'; -import { CatalogApi } from '@backstage/catalog-client'; import { Table, TableColumn } from '@backstage/core-components'; import { EntityRefLink } from '../EntityRefLink'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; -const mockCatalogApi = { +const mockCatalogApi = catalogApiMock.mock({ getEntityByRef: async (entityRef: string) => { if (entityRef === 'component:default/playback') { return { @@ -42,7 +43,7 @@ const mockCatalogApi = { namespace: 'default', description: 'Details about the playback service', }, - }; + } as unknown as Entity; } if (entityRef === 'user:default/fname.lname') { return { @@ -56,7 +57,7 @@ const mockCatalogApi = { email: 'fname.lname@example.com', }, }, - }; + } as unknown as Entity; } if (entityRef === 'component:default/slow.catalog.item') { await new Promise(resolve => setTimeout(resolve, 3000)); @@ -67,11 +68,11 @@ const mockCatalogApi = { namespace: 'default', description: 'Details about the slow.catalog.item service', }, - }; + } as unknown as Entity; } return undefined; }, -}; +}); const defaultArgs = { entityRef: 'component:default/playback', @@ -83,9 +84,7 @@ export default { (Story: ComponentType>) => wrapInTestApp( <> - + , diff --git a/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.test.tsx b/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.test.tsx index 41df859916..b129d42402 100644 --- a/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.test.tsx +++ b/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.test.tsx @@ -20,30 +20,26 @@ import React from 'react'; import { EntityPeekAheadPopover } from './EntityPeekAheadPopover'; import { ApiProvider } from '@backstage/core-app-api'; import { TestApiRegistry, renderInTestApp } from '@backstage/test-utils'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { catalogApiRef } from '../../api'; -import { Entity } from '@backstage/catalog-model'; -import { CatalogApi } from '@backstage/catalog-client'; import Button from '@material-ui/core/Button'; import { entityRouteRef } from '../../routes'; -const catalogApi: Partial = { - getEntityByRef: async (entityRef: string): Promise => { - if (entityRef === 'component:default/service1') { - return { - apiVersion: '', - kind: 'Component', - metadata: { - namespace: 'default', - name: 'service1', - }, - spec: { - tags: ['java'], - }, - }; - } - return undefined; - }, -}; +const catalogApi = catalogApiMock({ + entities: [ + { + apiVersion: '', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'service1', + }, + spec: { + tags: ['java'], + }, + }, + ], +}); const apis = TestApiRegistry.from([catalogApiRef, catalogApi]); diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx index 9b7fa2d2a3..ca0c242b8b 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx @@ -16,27 +16,29 @@ import { fireEvent, waitFor, screen, act } from '@testing-library/react'; import React from 'react'; -import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; +import { + MockEntityListContextProvider, + catalogApiMock, +} from '@backstage/plugin-catalog-react/testUtils'; import { EntityTagFilter } from '../../filters'; import { EntityTagPicker } from './EntityTagPicker'; import { TestApiProvider, renderInTestApp } from '@backstage/test-utils'; import { catalogApiRef } from '../../api'; -import { CatalogApi } from '@backstage/catalog-client'; const tags = ['tag1', 'tag2', 'tag3', 'tag4']; describe('', () => { - const mockCatalogApiRef = { + const catalogApi = catalogApiMock.mock({ getEntityFacets: async () => ({ facets: { 'metadata.tags': tags.map((value, idx) => ({ value, count: idx })), }, }), - } as unknown as CatalogApi; + }); it('renders all tags', async () => { await renderInTestApp( - + @@ -52,7 +54,7 @@ describe('', () => { it('renders unique tags in alphabetical order', async () => { await renderInTestApp( - + @@ -72,7 +74,7 @@ describe('', () => { it('renders tags with counts', async () => { await renderInTestApp( - + @@ -94,7 +96,7 @@ describe('', () => { const updateFilters = jest.fn(); const queryParameters = { tags: ['tag3'] }; await renderInTestApp( - + ', () => { it('adds tags to filters', async () => { const updateFilters = jest.fn(); await renderInTestApp( - + ', () => { it('removes tags from filters', async () => { const updateFilters = jest.fn(); await renderInTestApp( - + ', () => { it('responds to external queryParameters changes', async () => { const updateFilters = jest.fn(); const rendered = await renderInTestApp( - + ', () => { }), ); rendered.rerender( - + ', () => { it('verify that user can select tags after query string has been set', async () => { const updateFilters = jest.fn(); await renderInTestApp( - + ', () => { it('removes tags from filters if there are none available', async () => { const updateFilters = jest.fn(); - const mockCatalogApiRefNoTags = { + const mockCatalogApiRefNoTags = catalogApiMock.mock({ getEntityFacets: async () => ({ - facets: { - 'metadata.tags': {}, - }, + facets: {}, }), - } as unknown as CatalogApi; + }); await renderInTestApp( diff --git a/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx b/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx index 7c8ddb3afb..1a5553fbb5 100644 --- a/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx +++ b/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx @@ -20,25 +20,15 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { UnregisterEntityDialog } from './UnregisterEntityDialog'; import { ANNOTATION_ORIGIN_LOCATION } from '@backstage/catalog-model'; -import { CatalogClient } from '@backstage/catalog-client'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { catalogApiRef } from '../../api'; import { entityRouteRef } from '../../routes'; import { screen, waitFor } from '@testing-library/react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import * as state from './useUnregisterEntityDialogState'; - -import { - AlertApi, - alertApiRef, - DiscoveryApi, -} from '@backstage/core-plugin-api'; +import { AlertApi, alertApiRef } from '@backstage/core-plugin-api'; describe('UnregisterEntityDialog', () => { - const discoveryApi: DiscoveryApi = { - async getBaseUrl(pluginId) { - return `http://example.com/${pluginId}`; - }, - }; const alertApi: AlertApi = { post() { return undefined; @@ -68,7 +58,7 @@ describe('UnregisterEntityDialog', () => { const Wrapper = (props: { children?: React.ReactNode }) => ( diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx index 5efb7339cf..888940f30b 100644 --- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx +++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx @@ -18,7 +18,10 @@ import React from 'react'; import { fireEvent, waitFor, screen } from '@testing-library/react'; import { UserEntity } from '@backstage/catalog-model'; import { UserListPicker, UserListPickerProps } from './UserListPicker'; -import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; +import { + MockEntityListContextProvider, + catalogApiMock, +} from '@backstage/plugin-catalog-react/testUtils'; import { EntityKindFilter, EntityNamespaceFilter, @@ -62,10 +65,7 @@ const mockConfigApi = { getOptionalString: () => 'Test Company', } as Partial; -const mockCatalogApi = { - getEntityByRef: jest.fn(), - queryEntities: jest.fn(), -} as Partial>; +const mockCatalogApi = catalogApiMock.mock(); const mockIdentityApi = { getBackstageIdentity: jest.fn(), diff --git a/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.test.tsx b/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.test.tsx index dc3bc5d2bd..2449d47a1c 100644 --- a/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.test.tsx +++ b/plugins/catalog-react/src/components/UserListPicker/useAllEntitiesCount.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import React, { PropsWithChildren } from 'react'; -import { CatalogApi } from '@backstage/catalog-client'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { useAllEntitiesCount } from './useAllEntitiesCount'; import { renderHook, waitFor } from '@testing-library/react'; import { EntityListProvider, useEntityList } from '../../hooks'; @@ -24,10 +24,7 @@ import { MemoryRouter } from 'react-router-dom'; import { EntityOwnerFilter } from '../../filters'; import { useMountEffect } from '@react-hookz/web'; -const mockQueryEntities: jest.MockedFn = jest.fn(); -const mockCatalogApi: jest.Mocked> = { - queryEntities: mockQueryEntities, -}; +const mockCatalogApi = catalogApiMock.mock(); jest.mock('@backstage/core-plugin-api', () => { const actual = jest.requireActual('@backstage/core-plugin-api'); @@ -44,7 +41,7 @@ describe('useAllEntitiesCount', () => { }); it('should return the count', async () => { - mockQueryEntities.mockResolvedValue({ + mockCatalogApi.queryEntities.mockResolvedValue({ items: [], totalItems: 10, pageInfo: {}, @@ -72,7 +69,7 @@ describe('useAllEntitiesCount', () => { }); await waitFor(() => - expect(mockQueryEntities).toHaveBeenCalledWith({ + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ filter: { 'relations.ownedBy': ['user:default/owner'], }, @@ -83,7 +80,7 @@ describe('useAllEntitiesCount', () => { }); it(`shouldn't invoke the endpoint at startup, when filters are missing`, async () => { - mockQueryEntities.mockResolvedValue({ + mockCatalogApi.queryEntities.mockResolvedValue({ items: [], totalItems: 10, pageInfo: {}, @@ -98,7 +95,7 @@ describe('useAllEntitiesCount', () => { }); await expect( - waitFor(() => expect(mockQueryEntities).toHaveBeenCalled()), + waitFor(() => expect(mockCatalogApi.queryEntities).toHaveBeenCalled()), ).rejects.toThrow(); expect(result.current).toEqual({ count: 0, loading: false }); }); diff --git a/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.test.tsx b/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.test.tsx index aeaa20c535..4c620060fe 100644 --- a/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.test.tsx +++ b/plugins/catalog-react/src/components/UserListPicker/useOwnedEntitiesCount.test.tsx @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React, { PropsWithChildren } from 'react'; -import { CatalogApi } from '@backstage/catalog-client'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { renderHook, waitFor } from '@testing-library/react'; import { DefaultEntityFilters, @@ -36,10 +37,7 @@ import { } from '../../filters'; import { useMountEffect } from '@react-hookz/web'; -const mockQueryEntities: jest.MockedFn = jest.fn(); -const mockCatalogApi: jest.Mocked> = { - queryEntities: mockQueryEntities, -}; +const mockCatalogApi = catalogApiMock.mock(); const mockGetBackstageIdentity: jest.MockedFn< IdentityApi['getBackstageIdentity'] @@ -76,7 +74,7 @@ describe('useOwnedEntitiesCount', () => { }); it(`shouldn't invoke queryEntities when filters are loading`, async () => { - mockQueryEntities.mockResolvedValue({ + mockCatalogApi.queryEntities.mockResolvedValue({ items: [], totalItems: 10, pageInfo: {}, @@ -89,7 +87,7 @@ describe('useOwnedEntitiesCount', () => { await waitFor(() => expect(mockGetBackstageIdentity).toHaveBeenCalled()); await expect( - waitFor(() => expect(mockQueryEntities).toHaveBeenCalled()), + waitFor(() => expect(mockCatalogApi.queryEntities).toHaveBeenCalled()), ).rejects.toThrow(); expect(result.current).toEqual({ @@ -104,7 +102,7 @@ describe('useOwnedEntitiesCount', () => { }); it(`should properly apply the filters`, async () => { - mockQueryEntities.mockResolvedValue({ + mockCatalogApi.queryEntities.mockResolvedValue({ items: [], totalItems: 10, pageInfo: {}, @@ -119,7 +117,7 @@ describe('useOwnedEntitiesCount', () => { await waitFor(() => expect(mockGetBackstageIdentity).toHaveBeenCalled()); await waitFor(() => - expect(mockQueryEntities).toHaveBeenCalledWith({ + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ filter: { 'metadata.namespace': ['a-namespace'], 'relations.ownedBy': ['user:default/spiderman', 'user:group/a-group'], @@ -140,7 +138,7 @@ describe('useOwnedEntitiesCount', () => { }); it(`should return count 0 without invoking queryEntities if owners filter doesn't have claims in common with logged in user`, async () => { - mockQueryEntities.mockResolvedValue({ + mockCatalogApi.queryEntities.mockResolvedValue({ items: [], totalItems: 10, pageInfo: {}, @@ -156,7 +154,7 @@ describe('useOwnedEntitiesCount', () => { await waitFor(() => expect(mockGetBackstageIdentity).toHaveBeenCalled()); await expect( - waitFor(() => expect(mockQueryEntities).toHaveBeenCalled()), + waitFor(() => expect(mockCatalogApi.queryEntities).toHaveBeenCalled()), ).rejects.toThrow(); expect(result.current).toEqual({ @@ -171,7 +169,7 @@ describe('useOwnedEntitiesCount', () => { }); it(`should send claims in common between owners filter and logged in user`, async () => { - mockQueryEntities.mockResolvedValue({ + mockCatalogApi.queryEntities.mockResolvedValue({ items: [], totalItems: 10, pageInfo: {}, @@ -190,7 +188,7 @@ describe('useOwnedEntitiesCount', () => { await waitFor(() => expect(mockGetBackstageIdentity).toHaveBeenCalled()); await waitFor(() => - expect(mockQueryEntities).toHaveBeenCalledWith({ + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ filter: { 'metadata.namespace': ['a-namespace'], 'relations.ownedBy': ['user:group/a-group'], diff --git a/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.test.tsx b/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.test.tsx index 5fe648e1bc..096ad01ddd 100644 --- a/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.test.tsx +++ b/plugins/catalog-react/src/components/UserListPicker/useStarredEntitiesCount.test.tsx @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React from 'react'; -import { CatalogApi } from '@backstage/catalog-client'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { EntityListProvider, useStarredEntities } from '../../hooks'; import { catalogApiRef } from '../../api'; import { ApiRef } from '@backstage/core-plugin-api'; @@ -22,10 +23,7 @@ import { MemoryRouter } from 'react-router-dom'; import { useStarredEntitiesCount } from './useStarredEntitiesCount'; import { renderHook, waitFor } from '@testing-library/react'; -const mockQueryEntities: jest.MockedFn = jest.fn(); -const mockCatalogApi: jest.Mocked> = { - queryEntities: mockQueryEntities, -}; +const mockCatalogApi = catalogApiMock.mock(); const mockStarredEntities: jest.MockedFn<() => Set> = jest.fn(); @@ -58,7 +56,7 @@ describe('useStarredEntitiesCount', () => { mockStarredEntities.mockReturnValue( new Set(['component:default/favourite1', 'component:default/favourite2']), ); - mockQueryEntities.mockResolvedValue({ + mockCatalogApi.queryEntities.mockResolvedValue({ items: [ { apiVersion: '1', @@ -84,7 +82,7 @@ describe('useStarredEntitiesCount', () => { }); await waitFor(() => { - expect(mockQueryEntities).toHaveBeenCalledWith({ + expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({ filter: { 'metadata.name': ['favourite1', 'favourite2'], }, @@ -116,7 +114,7 @@ describe('useStarredEntitiesCount', () => { }); await expect( - waitFor(() => expect(mockQueryEntities).toHaveBeenCalled()), + waitFor(() => expect(mockCatalogApi.queryEntities).toHaveBeenCalled()), ).rejects.toThrow(); expect(result.current).toEqual({ count: 0, diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index 762fba1897..0bda2d19ec 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { CatalogApi } from '@backstage/catalog-client'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { Entity } from '@backstage/catalog-model'; import { alertApiRef, @@ -81,7 +81,7 @@ const mockIdentityApi: Partial = { }), getCredentials: async () => ({ token: undefined }), }; -const mockCatalogApi: Partial> = { +const mockCatalogApi = catalogApiMock.mock({ getEntities: jest.fn().mockResolvedValue({ items: entities }), queryEntities: jest.fn().mockResolvedValue({ items: entities, @@ -89,7 +89,7 @@ const mockCatalogApi: Partial> = { totalItems: 10, }), getEntityByRef: jest.fn().mockResolvedValue(undefined), -}; +}); const createWrapper = (options: { location?: string; pagination: EntityListPagination }) => diff --git a/plugins/catalog-react/src/hooks/useEntityOwnership.test.tsx b/plugins/catalog-react/src/hooks/useEntityOwnership.test.tsx index 6c9519eb08..5516a249ea 100644 --- a/plugins/catalog-react/src/hooks/useEntityOwnership.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityOwnership.test.tsx @@ -14,36 +14,24 @@ * limitations under the License. */ -import { CatalogApi } from '@backstage/catalog-client'; import { ComponentEntity, RELATION_OWNED_BY } from '@backstage/catalog-model'; import { IdentityApi, identityApiRef } from '@backstage/core-plugin-api'; import { TestApiProvider } from '@backstage/test-utils'; import { renderHook, waitFor } from '@testing-library/react'; import React from 'react'; -import { catalogApiRef } from '../api'; import { useEntityOwnership } from './useEntityOwnership'; describe('useEntityOwnership', () => { type MockIdentityApi = jest.Mocked>; - type MockCatalogApi = jest.Mocked>; const mockIdentityApi: MockIdentityApi = { getBackstageIdentity: jest.fn(), }; - const mockCatalogApi: MockCatalogApi = { - getEntityByRef: jest.fn(), - }; const identityApi = mockIdentityApi as unknown as IdentityApi; - const catalogApi = mockCatalogApi as unknown as CatalogApi; const Wrapper = (props: { children?: React.ReactNode }) => ( - + {props.children} ); @@ -81,7 +69,6 @@ describe('useEntityOwnership', () => { userEntityRef: 'user:default/user1', ownershipEntityRefs: ['user:default/user1', 'group:default/group1'], }); - mockCatalogApi.getEntityByRef.mockResolvedValue(undefined); const { result } = renderHook(() => useEntityOwnership(), { wrapper: Wrapper, diff --git a/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.test.ts b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.test.ts index 2c3fb9352b..c55103b8bc 100644 --- a/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.test.ts +++ b/plugins/catalog/src/apis/EntityPresentationApi/DefaultEntityPresentationApi.test.ts @@ -14,13 +14,13 @@ * limitations under the License. */ -import { CatalogApi } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; import { EntityRefPresentation, EntityRefPresentationSnapshot, } from '@backstage/plugin-catalog-react'; import { DefaultEntityPresentationApi } from './DefaultEntityPresentationApi'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; describe('DefaultEntityPresentationApi', () => { it('works in local mode', async () => { @@ -99,12 +99,8 @@ describe('DefaultEntityPresentationApi', () => { }); it('works in catalog mode', async () => { - const catalogApi = { - getEntitiesByRefs: jest.fn(), - }; - const api = DefaultEntityPresentationApi.create({ - catalogApi: catalogApi as Partial as any, - }); + const catalogApi = catalogApiMock.mock(); + const api = DefaultEntityPresentationApi.create({ catalogApi }); catalogApi.getEntitiesByRefs.mockResolvedValueOnce({ items: [ diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx index fcea659fc6..b175593b68 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx @@ -15,11 +15,11 @@ */ import { - CatalogApi, EntityProvider, catalogApiRef, entityRouteRef, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { ScmIntegrationsApi, scmIntegrationsApiRef, @@ -42,16 +42,7 @@ const mockAuthorize = jest.fn(); const mockPermissionApi = { authorize: mockAuthorize }; describe('', () => { - const catalogApi: jest.Mocked = { - getLocationById: jest.fn(), - getEntityByName: jest.fn(), - getEntityByRef: jest.fn(), - getEntities: jest.fn(), - addLocation: jest.fn(), - getLocationByRef: jest.fn(), - removeEntityByUid: jest.fn(), - refreshEntity: jest.fn(), - } as any; + const catalogApi = catalogApiMock.mock(); beforeEach(() => { jest.clearAllMocks(); diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx index 23c681a145..f5a16e3af3 100644 --- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.test.tsx @@ -14,10 +14,7 @@ * limitations under the License. */ -import { - CatalogApi, - QueryEntitiesInitialRequest, -} from '@backstage/catalog-client'; +import { QueryEntitiesInitialRequest } from '@backstage/catalog-client'; import { RELATION_OWNED_BY } from '@backstage/catalog-model'; import { TableColumn, TableProps } from '@backstage/core-components'; import { @@ -48,6 +45,7 @@ import { DefaultCatalogPage } from './DefaultCatalogPage'; import { CatalogTableColumnsFunc } from '../CatalogTable/types'; import { permissionApiRef } from '@backstage/plugin-permission-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; describe('DefaultCatalogPage', () => { const origReplaceState = window.history.replaceState; @@ -60,7 +58,7 @@ describe('DefaultCatalogPage', () => { jest.clearAllMocks(); }); - const catalogApi: jest.Mocked> = { + const catalogApi = catalogApiMock.mock({ getEntities: jest.fn().mockImplementation(() => Promise.resolve({ items: [ @@ -166,7 +164,7 @@ describe('DefaultCatalogPage', () => { // all items return { items: [], totalItems: 2, pageInfo: {} }; }), - }; + }); const testProfile: Partial = { displayName: 'Display Name', diff --git a/plugins/catalog/src/components/DependencyOfComponentsCard/DependencyOfComponentsCard.test.tsx b/plugins/catalog/src/components/DependencyOfComponentsCard/DependencyOfComponentsCard.test.tsx index 0ebda6e64d..464b97126a 100644 --- a/plugins/catalog/src/components/DependencyOfComponentsCard/DependencyOfComponentsCard.test.tsx +++ b/plugins/catalog/src/components/DependencyOfComponentsCard/DependencyOfComponentsCard.test.tsx @@ -16,25 +16,23 @@ import { Entity, RELATION_DEPENDENCY_OF } from '@backstage/catalog-model'; import { - CatalogApi, catalogApiRef, EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { screen, waitFor } from '@testing-library/react'; import React from 'react'; import { DependencyOfComponentsCard } from './DependencyOfComponentsCard'; describe('', () => { - const getEntitiesByRefs: jest.MockedFunction< - CatalogApi['getEntitiesByRefs'] - > = jest.fn(); + const catalogApi = catalogApiMock.mock(); let Wrapper: React.ComponentType>; beforeEach(() => { Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} ); @@ -87,7 +85,7 @@ describe('', () => { }, ], }; - getEntitiesByRefs.mockResolvedValue({ + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx b/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx index ee8931143f..9457e329b0 100644 --- a/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx +++ b/plugins/catalog/src/components/DependsOnComponentsCard/DependsOnComponentsCard.test.tsx @@ -16,25 +16,23 @@ import { Entity, RELATION_DEPENDS_ON } from '@backstage/catalog-model'; import { - CatalogApi, catalogApiRef, EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor, screen } from '@testing-library/react'; import React from 'react'; import { DependsOnComponentsCard } from './DependsOnComponentsCard'; describe('', () => { - const getEntitiesByRefs: jest.MockedFunction< - CatalogApi['getEntitiesByRefs'] - > = jest.fn(); + const catalogApi = catalogApiMock.mock(); let Wrapper: React.ComponentType>; beforeEach(() => { Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} ); @@ -87,7 +85,7 @@ describe('', () => { }, ], }; - getEntitiesByRefs.mockResolvedValue({ + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx b/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx index 0e760f9155..163a71206c 100644 --- a/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx +++ b/plugins/catalog/src/components/DependsOnResourcesCard/DependsOnResourcesCard.test.tsx @@ -16,25 +16,23 @@ import { Entity, RELATION_DEPENDS_ON } from '@backstage/catalog-model'; import { - CatalogApi, catalogApiRef, EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor, screen } from '@testing-library/react'; import React from 'react'; import { DependsOnResourcesCard } from './DependsOnResourcesCard'; describe('', () => { - const getEntitiesByRefs: jest.MockedFunction< - CatalogApi['getEntitiesByRefs'] - > = jest.fn(); + const catalogApi = catalogApiMock.mock(); let Wrapper: React.ComponentType>; beforeEach(() => { Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} ); @@ -87,7 +85,7 @@ describe('', () => { }, ], }; - getEntitiesByRefs.mockResolvedValue({ + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx index c222c2d7b6..9fb663abc2 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import { CatalogApi } from '@backstage/catalog-client'; import { ANNOTATION_ORIGIN_LOCATION, Entity, @@ -30,6 +29,7 @@ import { starredEntitiesApiRef, MockStarredEntitiesApi, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { permissionApiRef } from '@backstage/plugin-permission-react'; import { MockPermissionApi, @@ -52,7 +52,7 @@ describe('EntityLayout', () => { } as Entity; const mockApis = TestApiRegistry.from( - [catalogApiRef, {} as CatalogApi], + [catalogApiRef, catalogApiMock()], [alertApiRef, {} as AlertApi], [starredEntitiesApiRef, new MockStarredEntitiesApi()], [permissionApiRef, new MockPermissionApi()], @@ -302,20 +302,13 @@ describe('EntityLayout - CleanUpAfterRemoval', () => { }, ], }; - const getLocationByRef: jest.MockedFunction = - jest.fn(); - const getEntities: jest.MockedFunction = jest.fn(); - const removeEntityByUid: jest.MockedFunction< - CatalogApi['removeEntityByUid'] - > = jest.fn(); - const getEntityFacets: jest.MockedFunction = - jest.fn(); - getLocationByRef.mockResolvedValue(undefined); - getEntities.mockResolvedValue({ items: [{ ...entity }] }); - getEntityFacets.mockResolvedValue({ - facets: { - 'relations.ownedBy': [{ count: 1, value: 'group:default/tools' }], - }, + const catalogApi = catalogApiMock.mock({ + getEntities: async () => ({ items: [{ ...entity }] }), + getEntityFacets: async () => ({ + facets: { + 'relations.ownedBy': [{ count: 1, value: 'group:default/tools' }], + }, + }), }); const alertApi: AlertApi = { @@ -331,15 +324,7 @@ describe('EntityLayout - CleanUpAfterRemoval', () => { await renderInTestApp( { await renderInTestApp( { alert$: jest.fn(), }; - const catalogClient: jest.Mocked = { - removeEntityByUid: jest.fn(), - } as any; + const catalogClient = catalogApiMock.mock(); const entity = { apiVersion: 'backstage.io/v1alpha1', diff --git a/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx b/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx index b21c6292ff..1eabcc6e87 100644 --- a/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx +++ b/plugins/catalog/src/components/EntityProcessingErrorsPanel/EntityProcessingErrorsPanel.test.tsx @@ -18,21 +18,19 @@ import { AlphaEntity } from '@backstage/catalog-model/alpha'; import { stringifyEntityRef } from '@backstage/catalog-model'; import { ApiProvider } from '@backstage/core-app-api'; import { - CatalogApi, catalogApiRef, EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { screen } from '@testing-library/react'; import React from 'react'; import { EntityProcessingErrorsPanel } from './EntityProcessingErrorsPanel'; describe('', () => { - const getEntityAncestors: jest.MockedFunction< - CatalogApi['getEntityAncestors'] - > = jest.fn(); - const apis = TestApiRegistry.from([catalogApiRef, { getEntityAncestors }]); + const catalogApi = catalogApiMock.mock(); + const apis = TestApiRegistry.from([catalogApiRef, catalogApi]); it('renders EntityProcessErrors if the entity has errors', async () => { const entity: AlphaEntity = { @@ -98,7 +96,7 @@ describe('', () => { }, }; - getEntityAncestors.mockResolvedValue({ + catalogApi.getEntityAncestors.mockResolvedValue({ rootEntityRef: stringifyEntityRef(entity), items: [{ entity, parentEntityRefs: [] }], }); @@ -199,7 +197,7 @@ describe('', () => { ], }, }; - getEntityAncestors.mockResolvedValue({ + catalogApi.getEntityAncestors.mockResolvedValue({ rootEntityRef: stringifyEntityRef(entity), items: [ { entity, parentEntityRefs: [stringifyEntityRef(parent)] }, diff --git a/plugins/catalog/src/components/EntityRelationWarning/EntityRelationWarning.test.tsx b/plugins/catalog/src/components/EntityRelationWarning/EntityRelationWarning.test.tsx index 41e5f7e3d5..2808a8397b 100644 --- a/plugins/catalog/src/components/EntityRelationWarning/EntityRelationWarning.test.tsx +++ b/plugins/catalog/src/components/EntityRelationWarning/EntityRelationWarning.test.tsx @@ -16,11 +16,8 @@ import { Entity } from '@backstage/catalog-model'; import { ApiProvider } from '@backstage/core-app-api'; -import { - CatalogApi, - catalogApiRef, - EntityProvider, -} from '@backstage/plugin-catalog-react'; +import { catalogApiRef, EntityProvider } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { screen } from '@testing-library/react'; import React from 'react'; @@ -30,10 +27,8 @@ import { } from './EntityRelationWarning'; describe('', () => { - const getEntitiesByRefs: jest.MockedFunction< - CatalogApi['getEntitiesByRefs'] - > = jest.fn(); - const apis = TestApiRegistry.from([catalogApiRef, { getEntitiesByRefs }]); + const catalogApi = catalogApiMock.mock(); + const apis = TestApiRegistry.from([catalogApiRef, catalogApi]); const entityExisting: Entity = { apiVersion: 'v1', @@ -62,7 +57,7 @@ describe('', () => { ], }; - getEntitiesByRefs.mockResolvedValue({ + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [undefined, entityExisting], }); await renderInTestApp( @@ -102,7 +97,7 @@ describe('', () => { ], }; - getEntitiesByRefs.mockResolvedValue({ + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [entityExisting], }); await renderInTestApp( @@ -141,7 +136,7 @@ describe('', () => { ], }; - getEntitiesByRefs.mockResolvedValue({ + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [undefined, entityExisting], }); @@ -165,7 +160,7 @@ describe('', () => { ], }; - getEntitiesByRefs.mockResolvedValue({ + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [entityExisting], }); diff --git a/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx index 67c3f6b650..bc00c93815 100644 --- a/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx +++ b/plugins/catalog/src/components/HasComponentsCard/HasComponentsCard.test.tsx @@ -16,7 +16,6 @@ import { Entity, RELATION_HAS_PART } from '@backstage/catalog-model'; import { - CatalogApi, catalogApiRef, EntityProvider, entityRouteRef, @@ -25,16 +24,15 @@ import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor, screen } from '@testing-library/react'; import React from 'react'; import { HasComponentsCard } from './HasComponentsCard'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; describe('', () => { - const getEntitiesByRefs: jest.MockedFunction< - CatalogApi['getEntitiesByRefs'] - > = jest.fn(); + const catalogApi = catalogApiMock.mock(); let Wrapper: React.ComponentType>; beforeEach(() => { Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} ); @@ -87,7 +85,7 @@ describe('', () => { }, ], }; - getEntitiesByRefs.mockResolvedValue({ + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx b/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx index d78d307c96..1d524100f2 100644 --- a/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx +++ b/plugins/catalog/src/components/HasResourcesCard/HasResourcesCard.test.tsx @@ -16,25 +16,23 @@ import { Entity, RELATION_HAS_PART } from '@backstage/catalog-model'; import { - CatalogApi, catalogApiRef, EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor, screen } from '@testing-library/react'; import React from 'react'; import { HasResourcesCard } from './HasResourcesCard'; describe('', () => { - const getEntitiesByRefs: jest.MockedFunction< - CatalogApi['getEntitiesByRefs'] - > = jest.fn(); + const catalogApi = catalogApiMock.mock(); let Wrapper: React.ComponentType>; beforeEach(() => { Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} ); @@ -82,7 +80,7 @@ describe('', () => { }, ], }; - getEntitiesByRefs.mockResolvedValue({ + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx index 713313b7c6..07193b841f 100644 --- a/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx +++ b/plugins/catalog/src/components/HasSubcomponentsCard/HasSubcomponentsCard.test.tsx @@ -16,25 +16,23 @@ import { Entity, RELATION_HAS_PART } from '@backstage/catalog-model'; import { - CatalogApi, catalogApiRef, EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor, screen } from '@testing-library/react'; import React from 'react'; import { HasSubcomponentsCard } from './HasSubcomponentsCard'; describe('', () => { - const getEntitiesByRefs: jest.MockedFunction< - CatalogApi['getEntitiesByRefs'] - > = jest.fn(); + const catalogApi = catalogApiMock.mock(); let Wrapper: React.ComponentType>; beforeEach(() => { Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} ); @@ -87,7 +85,7 @@ describe('', () => { }, ], }; - getEntitiesByRefs.mockResolvedValue({ + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/HasSubdomainsCard/HasSubdomainsCard.test.tsx b/plugins/catalog/src/components/HasSubdomainsCard/HasSubdomainsCard.test.tsx index 6c145b3228..0498cacfe3 100644 --- a/plugins/catalog/src/components/HasSubdomainsCard/HasSubdomainsCard.test.tsx +++ b/plugins/catalog/src/components/HasSubdomainsCard/HasSubdomainsCard.test.tsx @@ -16,25 +16,23 @@ import { Entity, RELATION_HAS_PART } from '@backstage/catalog-model'; import { - CatalogApi, catalogApiRef, EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor, screen } from '@testing-library/react'; import React from 'react'; import { HasSubdomainsCard } from './HasSubdomainsCard'; describe('', () => { - const getEntitiesByRefs: jest.MockedFunction< - CatalogApi['getEntitiesByRefs'] - > = jest.fn(); + const catalogApi = catalogApiMock.mock(); let Wrapper: React.ComponentType>; beforeEach(() => { Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} ); @@ -87,7 +85,7 @@ describe('', () => { }, ], }; - getEntitiesByRefs.mockResolvedValue({ + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx index 9844708f85..51b2ce87ee 100644 --- a/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx +++ b/plugins/catalog/src/components/HasSystemsCard/HasSystemsCard.test.tsx @@ -16,25 +16,23 @@ import { Entity, RELATION_HAS_PART } from '@backstage/catalog-model'; import { - CatalogApi, catalogApiRef, EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { waitFor, screen } from '@testing-library/react'; import React from 'react'; import { HasSystemsCard } from './HasSystemsCard'; describe('', () => { - const getEntitiesByRefs: jest.MockedFunction< - CatalogApi['getEntitiesByRefs'] - > = jest.fn(); + const catalogApi = catalogApiMock.mock(); let Wrapper: React.ComponentType>; beforeEach(() => { Wrapper = ({ children }: { children?: React.ReactNode }) => ( - + {children} ); @@ -87,7 +85,7 @@ describe('', () => { }, ], }; - getEntitiesByRefs.mockResolvedValue({ + catalogApi.getEntitiesByRefs.mockResolvedValue({ items: [ { apiVersion: 'v1', diff --git a/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.test.tsx b/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.test.tsx index 2fcd1c2702..58aa892c5f 100644 --- a/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.test.tsx +++ b/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.test.tsx @@ -16,7 +16,6 @@ import { catalogApiRef, - CatalogApi, EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; @@ -25,6 +24,7 @@ import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { screen } from '@testing-library/react'; import React from 'react'; import { SystemDiagramCard } from './SystemDiagramCard'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; describe('', () => { beforeAll(() => { @@ -37,12 +37,7 @@ describe('', () => { afterEach(() => jest.resetAllMocks()); it('shows empty list if no relations', async () => { - const catalogApi: Partial = { - getEntities: () => - Promise.resolve({ - items: [] as Entity[], - }), - }; + const catalogApi = catalogApiMock(); const entity: Entity = { apiVersion: 'v1', @@ -73,26 +68,25 @@ describe('', () => { }); it('shows related systems', async () => { - const catalogApi: Partial = { - getEntities: () => - Promise.resolve({ - items: [ - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'entity', - namespace: 'namespace', - }, - spec: { - owner: 'not-tools@example.com', - type: 'service', - system: 'system', - }, + const catalogApi = catalogApiMock.mock({ + getEntities: async () => ({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'entity', + namespace: 'namespace', }, - ] as Entity[], - }), - }; + spec: { + owner: 'not-tools@example.com', + type: 'service', + system: 'system', + }, + }, + ], + }), + }); const entity: Entity = { apiVersion: 'v1', @@ -128,26 +122,25 @@ describe('', () => { }); it('should truncate long domains, systems or entities', async () => { - const catalogApi: Partial = { - getEntities: () => - Promise.resolve({ - items: [ - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'alongentitythatshouldgettruncated', - namespace: 'namespace', - }, - spec: { - owner: 'not-tools@example.com', - type: 'service', - system: 'system', - }, + const catalogApi = catalogApiMock.mock({ + getEntities: async () => ({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'alongentitythatshouldgettruncated', + namespace: 'namespace', }, - ] as Entity[], - }), - }; + spec: { + owner: 'not-tools@example.com', + type: 'service', + system: 'system', + }, + }, + ] as Entity[], + }), + }); const entity: Entity = { apiVersion: 'v1', diff --git a/plugins/home/src/homePageComponents/FeaturedDocsCard/Content.test.tsx b/plugins/home/src/homePageComponents/FeaturedDocsCard/Content.test.tsx index c49b41eb18..9a923f2481 100644 --- a/plugins/home/src/homePageComponents/FeaturedDocsCard/Content.test.tsx +++ b/plugins/home/src/homePageComponents/FeaturedDocsCard/Content.test.tsx @@ -18,6 +18,7 @@ import { Content } from './Content'; import React from 'react'; import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; const docsEntities = [ { @@ -34,20 +35,14 @@ const docsEntities = [ ]; describe('', () => { - const mockCatalogApi = { - getEntities: jest - .fn() - .mockImplementation(async () => ({ items: docsEntities })), - }; - let Wrapper: React.ComponentType>; + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); - beforeAll(() => { - Wrapper = ({ children }: { children?: React.ReactNode }) => ( - - {children} - - ); - }); it('should show expected featured doc and title', async () => { const { getByTestId, getByText } = await renderInTestApp( diff --git a/plugins/home/src/homePageComponents/FeaturedDocsCard/FeaturedDocsCard.stories.tsx b/plugins/home/src/homePageComponents/FeaturedDocsCard/FeaturedDocsCard.stories.tsx index 4d04a1788c..c9830f1294 100644 --- a/plugins/home/src/homePageComponents/FeaturedDocsCard/FeaturedDocsCard.stories.tsx +++ b/plugins/home/src/homePageComponents/FeaturedDocsCard/FeaturedDocsCard.stories.tsx @@ -18,6 +18,7 @@ import { FeaturedDocsCard } from '../../plugin'; import React, { ComponentType, PropsWithChildren } from 'react'; import { wrapInTestApp, TestApiProvider } from '@backstage/test-utils'; import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import Grid from '@material-ui/core/Grid'; const docsEntities = [ @@ -36,16 +37,14 @@ const docsEntities = [ }, ]; -const mockCatalogApi = { - getEntities: async () => ({ items: docsEntities }), -}; - export default { title: 'Plugins/Home/Components/FeaturedDocsCard', decorators: [ (Story: ComponentType>) => wrapInTestApp( - + , { diff --git a/plugins/home/src/homePageComponents/StarredEntities/Content.test.tsx b/plugins/home/src/homePageComponents/StarredEntities/Content.test.tsx index c41d7f586c..dab67d0b76 100644 --- a/plugins/home/src/homePageComponents/StarredEntities/Content.test.tsx +++ b/plugins/home/src/homePageComponents/StarredEntities/Content.test.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { catalogApiRef, @@ -20,6 +21,7 @@ import { MockStarredEntitiesApi, entityRouteRef, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import React from 'react'; import { Content } from './Content'; @@ -48,11 +50,7 @@ describe('StarredEntitiesContent', () => { mockedApi.toggleStarred('component:default/mock-starred-entity-2'); mockedApi.toggleStarred('component:default/mock-starred-entity-3'); - const mockCatalogApi = { - getEntitiesByRefs: jest - .fn() - .mockImplementation(async () => ({ items: entities })), - }; + const mockCatalogApi = catalogApiMock({ entities }); const { getByText, queryByText } = await renderInTestApp( ({ items: entities }), -}; +const mockCatalogApi = catalogApiMock({ entities }); export default { title: 'Plugins/Home/Components/StarredEntities', diff --git a/plugins/org-react/src/components/GroupListPicker/GroupListPicker.test.tsx b/plugins/org-react/src/components/GroupListPicker/GroupListPicker.test.tsx index 21b85ffe5c..056a91826b 100644 --- a/plugins/org-react/src/components/GroupListPicker/GroupListPicker.test.tsx +++ b/plugins/org-react/src/components/GroupListPicker/GroupListPicker.test.tsx @@ -19,10 +19,10 @@ import { render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ApiProvider } from '@backstage/core-app-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { CatalogApi } from '@backstage/catalog-client'; import { GroupListPicker } from '../GroupListPicker'; import { GroupEntity } from '@backstage/catalog-model'; import { TestApiRegistry } from '@backstage/test-utils'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; const mockGroups: GroupEntity[] = [ { @@ -57,9 +57,9 @@ const mockGroups: GroupEntity[] = [ }, ]; -const mockCatalogApi = { - getEntities: () => Promise.resolve({ items: mockGroups }), -} as Partial; +const mockCatalogApi = catalogApiMock.mock({ + getEntities: jest.fn(() => Promise.resolve({ items: mockGroups })), +}); const apis = TestApiRegistry.from([catalogApiRef, mockCatalogApi]); diff --git a/plugins/org/src/__testUtils__/catalogMocks.ts b/plugins/org/src/__testUtils__/catalogMocks.ts index a97caa9971..792eeedcc0 100644 --- a/plugins/org/src/__testUtils__/catalogMocks.ts +++ b/plugins/org/src/__testUtils__/catalogMocks.ts @@ -15,7 +15,6 @@ */ import { - CatalogApi, GetEntitiesByRefsRequest, GetEntitiesRequest, } from '@backstage/catalog-client'; @@ -25,6 +24,7 @@ import { GroupEntity, stringifyEntityRef, } from '@backstage/catalog-model'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; export const groupA: GroupEntity = { apiVersion: 'backstage.io/v1alpha1', @@ -308,7 +308,7 @@ const mockedMembersMapping = new Map([ type Nullable = T | undefined; -export const mockedCatalogApiSupportingGroups: Partial = { +export const mockedCatalogApiSupportingGroups = catalogApiMock.mock({ getEntities: async (request?: GetEntitiesRequest) => { const actualFilter = (request?.filter as Nullable<{ 'relations.memberof': string[]; @@ -331,4 +331,4 @@ export const mockedCatalogApiSupportingGroups: Partial = { ); return { items }; }, -}; +}); diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx index 69d6090687..6b8261e015 100644 --- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx +++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx @@ -14,9 +14,8 @@ * limitations under the License. */ -import { Entity, GroupEntity } from '@backstage/catalog-model'; +import { GroupEntity } from '@backstage/catalog-model'; import { - CatalogApi, catalogApiRef, EntityProvider, entityRouteRef, @@ -35,6 +34,7 @@ import { EntityLayout, catalogPlugin } from '@backstage/plugin-catalog'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Observable } from '@backstage/types'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; const mockedStarredEntitiesApi: Partial = { starredEntitie$: () => { @@ -68,38 +68,36 @@ describe('MemberTab Test', () => { }, }; - const catalogApi: Partial = { - getEntities: () => - Promise.resolve({ - items: [ - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'User', - metadata: { - name: 'tara.macgovern', - namespace: 'foo-bar', - uid: 'a5gerth56', - description: 'Super Awesome Developer', - }, - relations: [ - { - type: 'memberOf', - targetRef: 'group:default/team-d', - }, - ], - spec: { - profile: { - displayName: 'Tara MacGovern', - email: 'tara-macgovern@example.com', - picture: 'https://example.com/staff/tara.jpeg', - }, - memberOf: ['team-d'], - }, + const catalogApi = catalogApiMock.mock({ + getEntities: async () => ({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'tara.macgovern', + namespace: 'foo-bar', + uid: 'a5gerth56', + description: 'Super Awesome Developer', }, - ] as Entity[], - }), - }; - const getEntitiesSpy = jest.spyOn(catalogApi, 'getEntities'); + relations: [ + { + type: 'memberOf', + targetRef: 'group:default/team-d', + }, + ], + spec: { + profile: { + displayName: 'Tara MacGovern', + email: 'tara-macgovern@example.com', + picture: 'https://example.com/staff/tara.jpeg', + }, + memberOf: ['team-d'], + }, + }, + ], + }), + }); it('Display Profile Card', async () => { await renderInTestApp( @@ -116,7 +114,7 @@ describe('MemberTab Test', () => { }, }, ); - expect(getEntitiesSpy).toHaveBeenCalledWith({ + expect(catalogApi.getEntities).toHaveBeenCalledWith({ filter: { kind: 'User', 'relations.memberof': ['group:default/team-d'], @@ -171,7 +169,7 @@ describe('MemberTab Test', () => { }, ); - expect(getEntitiesSpy).toHaveBeenCalledWith({ + expect(catalogApi.getEntities).toHaveBeenCalledWith({ filter: { kind: 'User', 'relations.leaderof': ['group:default/team-d'], diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx index 004fb8b964..084a14a825 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx @@ -19,16 +19,13 @@ import { GetEntitiesResponse, } from '@backstage/catalog-client'; import { Entity, GroupEntity, UserEntity } from '@backstage/catalog-model'; -import { - CatalogApi, - catalogApiRef, - EntityProvider, -} from '@backstage/plugin-catalog-react'; +import { catalogApiRef, EntityProvider } from '@backstage/plugin-catalog-react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { queryByText } from '@testing-library/react'; import React from 'react'; import { catalogIndexRouteRef } from '../../../routes'; import { OwnershipCard } from './OwnershipCard'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; const items = [ { @@ -161,11 +158,7 @@ describe('OwnershipCard', () => { }; it('displays entity counts', async () => { - const catalogApi: jest.Mocked = { - getEntities: jest.fn(), - } as any; - - catalogApi.getEntities.mockImplementation(getEntitiesMock); + const catalogApi = catalogApiMock.mock({ getEntities: getEntitiesMock }); const { getByText } = await renderInTestApp( @@ -215,11 +208,7 @@ describe('OwnershipCard', () => { }); it('applies CustomFilterDefinition', async () => { - const catalogApi: jest.Mocked = { - getEntities: jest.fn(), - } as any; - - catalogApi.getEntities.mockImplementation(getEntitiesMock); + const catalogApi = catalogApiMock.mock({ getEntities: getEntitiesMock }); const { getByText } = await renderInTestApp( @@ -252,11 +241,7 @@ describe('OwnershipCard', () => { }); it('links to the catalog with the group filter', async () => { - const catalogApi: jest.Mocked = { - getEntities: jest.fn(), - } as any; - - catalogApi.getEntities.mockImplementation(getEntitiesMock); + const catalogApi = catalogApiMock.mock({ getEntities: getEntitiesMock }); const { getByText } = await renderInTestApp( @@ -279,11 +264,7 @@ describe('OwnershipCard', () => { }); it('links to the catalog with the user and groups filters from an user profile', async () => { - const catalogApi: jest.Mocked = { - getEntities: jest.fn(), - } as any; - - catalogApi.getEntities.mockImplementation(getEntitiesMock); + const catalogApi = catalogApiMock.mock({ getEntities: getEntitiesMock }); const { getByText } = await renderInTestApp( @@ -308,11 +289,7 @@ describe('OwnershipCard', () => { describe('OwnershipCard relations', () => { it('shows relations toggle', async () => { - const catalogApi: jest.Mocked = { - getEntities: jest.fn(), - } as any; - - catalogApi.getEntities.mockImplementation(getEntitiesMock); + const catalogApi = catalogApiMock.mock({ getEntities: getEntitiesMock }); const { getByTitle } = await renderInTestApp( @@ -331,11 +308,7 @@ describe('OwnershipCard', () => { }); it('hides relations toggle', async () => { - const catalogApi: jest.Mocked = { - getEntities: jest.fn(), - } as any; - - catalogApi.getEntities.mockImplementation(getEntitiesMock); + const catalogApi = catalogApiMock.mock({ getEntities: getEntitiesMock }); const rendered = await renderInTestApp( @@ -352,12 +325,9 @@ describe('OwnershipCard', () => { expect(rendered.queryByText('Direct Relations')).toBeNull(); }); - it('overrides relation type', async () => { - const catalogApi: jest.Mocked = { - getEntities: jest.fn(), - } as any; - catalogApi.getEntities.mockImplementation(getEntitiesMock); + it('overrides relation type', async () => { + const catalogApi = catalogApiMock.mock({ getEntities: getEntitiesMock }); const { getByTitle } = await renderInTestApp( @@ -376,11 +346,7 @@ describe('OwnershipCard', () => { }); it('defaults to aggregated for User entity kind', async () => { - const catalogApi: jest.Mocked = { - getEntities: jest.fn(), - } as any; - - catalogApi.getEntities.mockImplementation(getEntitiesMock); + const catalogApi = catalogApiMock.mock({ getEntities: getEntitiesMock }); const { getByLabelText } = await renderInTestApp( @@ -399,11 +365,7 @@ describe('OwnershipCard', () => { }); it('defaults to direct for all entity kinds except User', async () => { - const catalogApi: jest.Mocked = { - getEntities: jest.fn(), - } as any; - - catalogApi.getEntities.mockImplementation(getEntitiesMock); + const catalogApi = catalogApiMock.mock({ getEntities: getEntitiesMock }); const { getByLabelText } = await renderInTestApp( @@ -422,11 +384,7 @@ describe('OwnershipCard', () => { }); it('defaults to provided relationsType', async () => { - const catalogApi: jest.Mocked = { - getEntities: jest.fn(), - } as any; - - catalogApi.getEntities.mockImplementation(getEntitiesMock); + const catalogApi = catalogApiMock.mock({ getEntities: getEntitiesMock }); const { getByLabelText } = await renderInTestApp( diff --git a/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.test.tsx b/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.test.tsx index bd212bf972..9eb9b3f70f 100644 --- a/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.test.tsx +++ b/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.test.tsx @@ -19,9 +19,9 @@ import React from 'react'; import { MyGroupsSidebarItem } from './MyGroupsSidebarItem'; import GroupIcon from '@material-ui/icons/People'; import { IdentityApi, identityApiRef } from '@backstage/core-plugin-api'; -import { CatalogApi } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; describe('MyGroupsSidebarItem Test', () => { describe('For guests or users with no groups', () => { @@ -33,12 +33,7 @@ describe('MyGroupsSidebarItem Test', () => { ownershipEntityRefs: ['user:default/guest'], }), }; - const catalogApi: Partial = { - getEntities: () => - Promise.resolve({ - items: [] as Entity[], - }), - }; + const catalogApi = catalogApiMock(); const rendered = await renderInTestApp( { ownershipEntityRefs: ['user:default/nigel.manning'], }), }; - const catalogApi: Partial = { - getEntities: () => - Promise.resolve({ - items: [ - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Group', - metadata: { - name: 'team-a', - title: 'Team A', - namespace: 'default', - }, - spec: { - type: 'team', - children: [], - }, + const catalogApi = catalogApiMock.mock({ + getEntities: async () => ({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'team-a', + title: 'Team A', + namespace: 'default', }, - ] as Entity[], - }), - }; + spec: { + type: 'team', + children: [], + }, + }, + ] as Entity[], + }), + }); const rendered = await renderInTestApp( { ownershipEntityRefs: ['user:default/nigel.manning'], }), }; - const catalogApi: Partial = { - getEntities: () => - Promise.resolve({ - items: [ - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Group', - metadata: { - name: 'team-a', - title: 'Team A', - namespace: 'default', - }, - spec: { - type: 'team', - children: [], - }, + const catalogApi = catalogApiMock.mock({ + getEntities: async () => ({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'team-a', + title: 'Team A', + namespace: 'default', }, - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Group', - metadata: { - name: 'team-b', - title: 'Team B', - namespace: 'default', - }, - spec: { - type: 'team', - children: [], - }, + spec: { + type: 'team', + children: [], }, - { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Group', - metadata: { - name: 'team-c', - title: 'Team C', - namespace: 'default', - }, - spec: { - type: 'team', - children: [], - }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'team-b', + title: 'Team B', + namespace: 'default', }, - ] as Entity[], - }), - }; + spec: { + type: 'team', + children: [], + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'team-c', + title: 'Team C', + namespace: 'default', + }, + spec: { + type: 'team', + children: [], + }, + }, + ] as Entity[], + }), + }); const rendered = await renderInTestApp( { ownershipEntityRefs: ['user:default/guest'], }), }; - const mockCatalogApi: Partial = { - getEntities: jest.fn(), - }; + const mockCatalogApi = catalogApiMock.mock(); await renderInTestApp( { ownershipEntityRefs: ['user:default/guest'], }), }; - const mockCatalogApi: Partial = { - getEntities: jest.fn(), - }; + const mockCatalogApi = catalogApiMock.mock(); await renderInTestApp( { it('getAllDesendantMembersForGroupEntity correctly recursively returns all descendant members', async () => { - const catalogApi = mockedCatalogApiSupportingGroups as CatalogApi; + const catalogApi = mockedCatalogApiSupportingGroups; const actualGroupADescendantMembers = await getAllDesendantMembersForGroupEntity(groupA, catalogApi); @@ -60,7 +59,7 @@ describe('Helper functions', () => { }); it('getMembersFromGroups correctly returns all members of provided groups', async () => { - const catalogApi = mockedCatalogApiSupportingGroups as CatalogApi; + const catalogApi = mockedCatalogApiSupportingGroups; const actualNoGroupsMembers = await getMembersFromGroups([], catalogApi); const actualGroupAMembers = await getMembersFromGroups( @@ -123,7 +122,7 @@ describe('Helper functions', () => { it('getDescendantGroupsFromGroup correctly recursively returns descendant groups, ignoring duplicates', async () => { const actualDescendantGroups = await getDescendantGroupsFromGroup( groupA, - mockedCatalogApiSupportingGroups as CatalogApi, + mockedCatalogApiSupportingGroups, ); expect(actualDescendantGroups).toStrictEqual([ groupBRef, diff --git a/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.test.tsx index ba00845577..6079ec2e1d 100644 --- a/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.test.tsx +++ b/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.test.tsx @@ -13,11 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { DefaultStarredEntitiesApi } from '@backstage/plugin-catalog'; import { catalogApiRef, starredEntitiesApiRef, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { permissionApiRef } from '@backstage/plugin-permission-react'; import { MockStorageApi, @@ -29,26 +31,18 @@ import { rootRouteRef } from '../../../routes'; import { TemplateListPage } from './TemplateListPage'; describe('TemplateListPage', () => { - const mockCatalogApi = { - getEntities: async () => ({ - items: [ - { - apiVersion: 'scaffolder.backstage.io/v1beta3', - kind: 'Template', - metadata: { name: 'blob', tags: ['blob'] }, - spec: { - type: 'service', - }, + const mockCatalogApi = catalogApiMock({ + entities: [ + { + apiVersion: 'scaffolder.backstage.io/v1beta3', + kind: 'Template', + metadata: { name: 'blob', tags: ['blob'] }, + spec: { + type: 'service', }, - ], - }), - getEntityFacets: async () => ({ - facets: { 'spec.type': [{ value: 'service', count: 1 }] }, - }), - getEntitiesByRefs: async () => ({ - items: [], - }), - }; + }, + ], + }); it('should render the search bar for templates', async () => { const { getByPlaceholderText } = await renderInTestApp( diff --git a/plugins/scaffolder/src/components/ListTasksPage/ListTaskPage.test.tsx b/plugins/scaffolder/src/components/ListTasksPage/ListTaskPage.test.tsx index f7f75bc3e8..96374662a4 100644 --- a/plugins/scaffolder/src/components/ListTasksPage/ListTaskPage.test.tsx +++ b/plugins/scaffolder/src/components/ListTasksPage/ListTaskPage.test.tsx @@ -16,11 +16,8 @@ import { Entity } from '@backstage/catalog-model'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { - CatalogApi, - catalogApiRef, - entityRouteRef, -} from '@backstage/plugin-catalog-react'; +import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import React from 'react'; import { identityApiRef } from '@backstage/core-plugin-api'; import { ListTasksPage } from './ListTasksPage'; @@ -32,9 +29,7 @@ import { act, fireEvent } from '@testing-library/react'; import { rootRouteRef } from '../../routes'; describe('', () => { - const catalogApi: jest.Mocked = { - getEntityByRef: jest.fn(), - } as any; + const catalogApi = catalogApiMock.mock(); const identityApi = { getBackstageIdentity: jest.fn(), diff --git a/plugins/scaffolder/src/components/ListTasksPage/columns/OwnerEntityColumn.test.tsx b/plugins/scaffolder/src/components/ListTasksPage/columns/OwnerEntityColumn.test.tsx index 84b86599d5..35eb9ce0b7 100644 --- a/plugins/scaffolder/src/components/ListTasksPage/columns/OwnerEntityColumn.test.tsx +++ b/plugins/scaffolder/src/components/ListTasksPage/columns/OwnerEntityColumn.test.tsx @@ -16,19 +16,14 @@ import { Entity } from '@backstage/catalog-model'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { - CatalogApi, - catalogApiRef, - entityRouteRef, -} from '@backstage/plugin-catalog-react'; +import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import React from 'react'; import { OwnerEntityColumn } from './OwnerEntityColumn'; import { identityApiRef } from '@backstage/core-plugin-api'; describe('', () => { - const catalogApi: jest.Mocked = { - getEntityByRef: jest.fn(), - } as any; + const catalogApi = catalogApiMock.mock(); const identityApi = { getBackstageIdentity: jest.fn(), diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx index dcb52a3949..3f8b1adc5a 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.test.tsx @@ -17,7 +17,6 @@ import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; import { - CatalogApi, catalogApiRef, entityPresentationApiRef, } from '@backstage/plugin-catalog-react'; @@ -28,6 +27,7 @@ import { EntityPicker } from './EntityPicker'; import { EntityPickerProps } from './schema'; import { ScaffolderRJSFFieldProps as FieldProps } from '@backstage/plugin-scaffolder-react'; import { DefaultEntityPresentationApi } from '@backstage/plugin-catalog'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; const makeEntity = (kind: string, namespace: string, name: string): Entity => ({ apiVersion: 'scaffolder.backstage.io/v1beta3', @@ -36,7 +36,10 @@ const makeEntity = (kind: string, namespace: string, name: string): Entity => ({ }); describe('', () => { - let entities: Entity[]; + const entities: Entity[] = [ + makeEntity('Group', 'default', 'team-a'), + makeEntity('Group', 'default', 'squad-b'), + ]; const onChange = jest.fn(); const schema = {}; const required = false; @@ -46,23 +49,13 @@ describe('', () => { let props: FieldProps; - const catalogApi: jest.Mocked = { - getLocationById: jest.fn(), - getEntityByName: jest.fn(), + const catalogApi = catalogApiMock.mock({ getEntities: jest.fn(async () => ({ items: entities })), - addLocation: jest.fn(), - getLocationByRef: jest.fn(), - removeEntityByUid: jest.fn(), - } as any; + }); let Wrapper: React.ComponentType>; beforeEach(() => { - entities = [ - makeEntity('Group', 'default', 'team-a'), - makeEntity('Group', 'default', 'squad-b'), - ]; - Wrapper = ({ children }: { children?: React.ReactNode }) => ( ', () => { rawErrors, formData, } as unknown as FieldProps; - - catalogApi.getEntities.mockResolvedValue({ items: entities }); }); it('searches for all entities', async () => { diff --git a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx index edd777652b..74b9dadda3 100644 --- a/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/MultiEntityPicker/MultiEntityPicker.test.tsx @@ -17,7 +17,6 @@ import { CATALOG_FILTER_EXISTS } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; import { - CatalogApi, catalogApiRef, entityPresentationApiRef, } from '@backstage/plugin-catalog-react'; @@ -30,6 +29,7 @@ import { MultiEntityPicker } from './MultiEntityPicker'; import { MultiEntityPickerProps } from './schema'; import { ScaffolderRJSFFieldProps as FieldProps } from '@backstage/plugin-scaffolder-react'; import { DefaultEntityPresentationApi } from '@backstage/plugin-catalog'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; const makeEntity = (kind: string, namespace: string, name: string): Entity => ({ apiVersion: 'scaffolder.backstage.io/v1beta3', @@ -38,7 +38,10 @@ const makeEntity = (kind: string, namespace: string, name: string): Entity => ({ }); describe('', () => { - let entities: Entity[]; + const entities: Entity[] = [ + makeEntity('Group', 'default', 'team-a'), + makeEntity('Group', 'default', 'squad-b'), + ]; const onChange = jest.fn(); const schema = {}; const required = false; @@ -48,22 +51,12 @@ describe('', () => { let props: FieldProps; - const catalogApi: jest.Mocked = { - getLocationById: jest.fn(), - getEntityByName: jest.fn(), + const catalogApi = catalogApiMock.mock({ getEntities: jest.fn(async () => ({ items: entities })), - addLocation: jest.fn(), - getLocationByRef: jest.fn(), - removeEntityByUid: jest.fn(), - } as any; + }); let Wrapper: React.ComponentType>; beforeEach(() => { - entities = [ - makeEntity('Group', 'default', 'team-a'), - makeEntity('Group', 'default', 'squad-b'), - ]; - Wrapper = ({ children }: { children?: React.ReactNode }) => ( ', () => { rawErrors, formData, } as unknown as FieldProps; - - catalogApi.getEntities.mockResolvedValue({ items: entities }); }); it('searches for all entities', async () => { diff --git a/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.test.tsx b/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.test.tsx index 74af578a21..d4a37d0752 100644 --- a/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/MyGroupsPicker/MyGroupsPicker.test.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { waitFor } from '@testing-library/react'; -import { CatalogApi } from '@backstage/catalog-client'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { MyGroupsPicker } from './MyGroupsPicker'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { @@ -60,9 +60,9 @@ describe('', () => { const schema = {}; const required = false; - const catalogApi: jest.Mocked = { + const catalogApi = catalogApiMock.mock({ getEntities: jest.fn(async () => ({ items: entities })), - } as any; + }); const mockErrorApi: jest.Mocked = { post: jest.fn(), diff --git a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.test.tsx b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.test.tsx index 79105e05c3..329448b224 100644 --- a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.test.tsx +++ b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.test.tsx @@ -17,10 +17,10 @@ import { type EntityFilterQuery } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; import { - CatalogApi, catalogApiRef, entityPresentationApiRef, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; import { ScaffolderRJSFFieldProps as FieldProps } from '@backstage/plugin-scaffolder-react'; import React from 'react'; @@ -34,7 +34,10 @@ const makeEntity = (kind: string, namespace: string, name: string): Entity => ({ }); describe('', () => { - let entities: Entity[]; + const entities: Entity[] = [ + makeEntity('Group', 'default', 'team-a'), + makeEntity('Group', 'default', 'squad-b'), + ]; const onChange = jest.fn(); const schema = {}; const required = false; @@ -52,22 +55,12 @@ describe('', () => { let props: FieldProps; - const catalogApi: jest.Mocked = { - getLocationById: jest.fn(), - getEntityByName: jest.fn(), + const catalogApi = catalogApiMock.mock({ getEntities: jest.fn(async () => ({ items: entities })), - addLocation: jest.fn(), - getLocationByRef: jest.fn(), - removeEntityByUid: jest.fn(), - } as any; + }); let Wrapper: React.ComponentType>; beforeEach(() => { - entities = [ - makeEntity('Group', 'default', 'team-a'), - makeEntity('Group', 'default', 'squad-b'), - ]; - Wrapper = ({ children }: { children?: React.ReactNode }) => ( ', () => { rawErrors, formData, } as unknown as FieldProps; - - catalogApi.getEntities.mockResolvedValue({ items: entities }); }); it('searches for users and groups', async () => { @@ -134,8 +125,6 @@ describe('', () => { rawErrors, formData, } as unknown as FieldProps; - - catalogApi.getEntities.mockResolvedValue({ items: entities }); }); it('searches for users', async () => { @@ -228,8 +217,6 @@ describe('', () => { rawErrors, formData, } as unknown as FieldProps; - - catalogApi.getEntities.mockResolvedValue({ items: entities }); }); it('searches for users and groups or teams and business units', async () => { diff --git a/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx b/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx index 1f073e184d..513a56265c 100644 --- a/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx +++ b/plugins/techdocs/src/home/components/DefaultTechDocsHome.test.tsx @@ -21,11 +21,11 @@ import { storageApiRef, } from '@backstage/core-plugin-api'; import { - CatalogApi, catalogApiRef, starredEntitiesApiRef, MockStarredEntitiesApi, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { MockStorageApi, renderInTestApp, @@ -36,22 +36,18 @@ import React from 'react'; import { rootDocsRouteRef } from '../../routes'; import { DefaultTechDocsHome } from './DefaultTechDocsHome'; -const mockCatalogApi: Partial = { - getEntityFacets: async () => ({ facets: { 'relations.ownedBy': [] } }), - getEntitiesByRefs: () => Promise.resolve({ items: [] }), - getEntities: async () => ({ - items: [ - { - apiVersion: 'version', - kind: 'User', - metadata: { - name: 'owned', - namespace: 'default', - }, +const mockCatalogApi = catalogApiMock({ + entities: [ + { + apiVersion: 'version', + kind: 'User', + metadata: { + name: 'owned', + namespace: 'default', }, - ], - }), -}; + }, + ], +}); describe('TechDocs Home', () => { const configApi: ConfigApi = new ConfigReader({ diff --git a/plugins/techdocs/src/home/components/Grids/EntityListDocsGrid.test.tsx b/plugins/techdocs/src/home/components/Grids/EntityListDocsGrid.test.tsx index 1e65efb587..947c81ae85 100644 --- a/plugins/techdocs/src/home/components/Grids/EntityListDocsGrid.test.tsx +++ b/plugins/techdocs/src/home/components/Grids/EntityListDocsGrid.test.tsx @@ -21,12 +21,14 @@ import { storageApiRef, } from '@backstage/core-plugin-api'; import { - CatalogApi, catalogApiRef, starredEntitiesApiRef, MockStarredEntitiesApi, } from '@backstage/plugin-catalog-react'; -import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; +import { + MockEntityListContextProvider, + catalogApiMock, +} from '@backstage/plugin-catalog-react/testUtils'; import { MockStorageApi, renderInTestApp, @@ -62,12 +64,7 @@ const entities = [ }, ]; -const mockCatalogApi = { - getEntityByRef: () => Promise.resolve(), - getEntities: async () => ({ - items: entities, - }), -} as Partial; +const mockCatalogApi = catalogApiMock({ entities }); describe('Entity List Docs Grid', () => { beforeEach(() => { diff --git a/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx b/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx index 6f09a70b5e..409d68a593 100644 --- a/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx +++ b/plugins/techdocs/src/home/components/TechDocsCustomHome.test.tsx @@ -14,7 +14,8 @@ * limitations under the License. */ -import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { screen } from '@testing-library/react'; import React from 'react'; @@ -22,21 +23,18 @@ import { TechDocsCustomHome, PanelType } from './TechDocsCustomHome'; import { ApiProvider } from '@backstage/core-app-api'; import { rootDocsRouteRef } from '../../routes'; -const mockCatalogApi = { - getEntityByRef: jest.fn(), - getEntities: async () => ({ - items: [ - { - apiVersion: 'version', - kind: 'User', - metadata: { - name: 'owned', - namespace: 'default', - }, +const mockCatalogApi = catalogApiMock({ + entities: [ + { + apiVersion: 'version', + kind: 'User', + metadata: { + name: 'owned', + namespace: 'default', }, - ], - }), -} as Partial; + }, + ], +}); describe('TechDocsCustomHome', () => { const apiRegistry = TestApiRegistry.from([catalogApiRef, mockCatalogApi]); From 3bd050177620bb7a5edf06b0514eb9f4ff8cfb08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 7 Oct 2024 17:17:36 +0200 Subject: [PATCH 119/291] some more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../CatalogGraphCard.test.tsx | 16 ++----------- .../CatalogGraphPage.test.tsx | 24 +++++++------------ .../SelectedKindsFilter.test.tsx | 8 +++---- .../EntityRelationsGraph.test.tsx | 15 ++---------- .../useEntityStore.test.ts | 16 ++----------- .../StepPrepareCreatePullRequest.test.tsx | 15 ++---------- .../src/hooks/useRelatedEntities.test.tsx | 7 +++--- .../StarredEntities/Content.test.tsx | 8 +++---- 8 files changed, 27 insertions(+), 82 deletions(-) diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx index 9d4c01f84e..916653f600 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.test.tsx @@ -22,6 +22,7 @@ import { EntityProvider, entityRouteRef, } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { MockAnalyticsApi, renderInTestApp, @@ -38,20 +39,7 @@ import Button from '@material-ui/core/Button'; describe('', () => { let entity: Entity; let wrapper: JSX.Element; - const catalog = { - getEntities: jest.fn(), - getEntityByRef: jest.fn(), - getEntitiesByRefs: jest.fn(), - removeEntityByUid: jest.fn(), - getLocationById: jest.fn(), - getLocationByRef: jest.fn(), - addLocation: jest.fn(), - removeLocationById: jest.fn(), - refreshEntity: jest.fn(), - getEntityAncestors: jest.fn(), - getEntityFacets: jest.fn(), - validateEntity: jest.fn(), - }; + const catalog = catalogApiMock.mock(); let apis: TestApiRegistry; beforeEach(() => { diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx index e188dcc7ff..9a081747ad 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/CatalogGraphPage.test.tsx @@ -14,9 +14,14 @@ * limitations under the License. */ -import { RELATION_HAS_PART, RELATION_PART_OF } from '@backstage/catalog-model'; +import { + Entity, + RELATION_HAS_PART, + RELATION_PART_OF, +} from '@backstage/catalog-model'; import { analyticsApiRef } from '@backstage/core-plugin-api'; import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { MockAnalyticsApi, renderInTestApp, @@ -112,24 +117,11 @@ describe.skip('', () => { }, ], }; - const allEntities: Record = { + const allEntities: Record = { 'b:d/c': entityC, 'b:d/e': entityE, }; - const catalog = { - getEntities: jest.fn(), - getEntityByRef: jest.fn(), - getEntitiesByRefs: jest.fn(), - removeEntityByUid: jest.fn(), - getLocationById: jest.fn(), - getLocationByRef: jest.fn(), - addLocation: jest.fn(), - removeLocationById: jest.fn(), - refreshEntity: jest.fn(), - getEntityAncestors: jest.fn(), - getEntityFacets: jest.fn(), - validateEntity: jest.fn(), - }; + const catalog = catalogApiMock.mock(); beforeEach(() => { wrapper = ( diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx index 511592af0c..33b6dfdb13 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import { GetEntityFacetsResponse } from '@backstage/catalog-client'; import { ApiProvider } from '@backstage/core-app-api'; import { AlertApi, alertApiRef } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; @@ -23,8 +22,9 @@ import { waitFor, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { SelectedKindsFilter } from './SelectedKindsFilter'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; -const catalogApi = { +const catalogApi = catalogApiMock.mock({ getEntityFacets: jest.fn().mockResolvedValue({ facets: { kind: [ @@ -34,8 +34,8 @@ const catalogApi = { { value: 'Resource', count: 1 }, ], }, - } as GetEntityFacetsResponse), -}; + }), +}); const apis = TestApiRegistry.from( [catalogApiRef, catalogApi], [alertApiRef, {} as AlertApi], diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx index 0ba9a3375a..48c7f28336 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/EntityRelationsGraph.test.tsx @@ -28,6 +28,7 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React, { FunctionComponent } from 'react'; import { EntityRelationsGraph } from './EntityRelationsGraph'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; /* The tests in this file have been disabled for the following error: @@ -142,19 +143,7 @@ describe.skip('', () => { ], }, }; - const catalog = { - getEntities: jest.fn(), - getEntityByRef: jest.fn(), - removeEntityByUid: jest.fn(), - getLocationById: jest.fn(), - getLocationByRef: jest.fn(), - addLocation: jest.fn(), - removeLocationById: jest.fn(), - refreshEntity: jest.fn(), - getEntityAncestors: jest.fn(), - getEntityFacets: jest.fn(), - validateEntity: jest.fn(), - }; + const catalog = catalogApiMock.mock(); const CUSTOM_TEST_ID = 'custom-test-id'; beforeEach(() => { diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts index 786c37ca7e..d13ae25abf 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/useEntityStore.test.ts @@ -18,26 +18,14 @@ import { Entity } from '@backstage/catalog-model'; import { useApi as useApiMocked } from '@backstage/core-plugin-api'; import { act, renderHook, waitFor } from '@testing-library/react'; import { useEntityStore } from './useEntityStore'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; jest.mock('@backstage/core-plugin-api'); const useApi = useApiMocked as jest.Mocked; describe('useEntityStore', () => { - const catalogApi = { - getEntities: jest.fn(), - getEntityByRef: jest.fn(), - getEntitiesByRefs: jest.fn(), - removeEntityByUid: jest.fn(), - getLocationById: jest.fn(), - getLocationByRef: jest.fn(), - addLocation: jest.fn(), - removeLocationById: jest.fn(), - refreshEntity: jest.fn(), - getEntityAncestors: jest.fn(), - getEntityFacets: jest.fn(), - validateEntity: jest.fn(), - }; + const catalogApi = catalogApiMock.mock(); beforeEach(() => { useApi.mockReturnValue(catalogApi); diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx index ec7762f50f..6e81b27c55 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.test.tsx @@ -27,6 +27,7 @@ import { generateEntities, StepPrepareCreatePullRequest, } from './StepPrepareCreatePullRequest'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; describe('', () => { const catalogImportApi: jest.Mocked = { @@ -35,19 +36,7 @@ describe('', () => { preparePullRequest: jest.fn(), }; - const catalogApi = { - getEntities: jest.fn(), - addLocation: jest.fn(), - getEntityByRef: jest.fn(), - getLocationByRef: jest.fn(), - getLocationById: jest.fn(), - removeLocationById: jest.fn(), - removeEntityByUid: jest.fn(), - refreshEntity: jest.fn(), - getEntityAncestors: jest.fn(), - getEntityFacets: jest.fn(), - validateEntity: jest.fn(), - }; + const catalogApi = catalogApiMock.mock(); const errorApi: jest.Mocked = { error$: jest.fn(), diff --git a/plugins/catalog-react/src/hooks/useRelatedEntities.test.tsx b/plugins/catalog-react/src/hooks/useRelatedEntities.test.tsx index 092f8e164e..6dbbac44f5 100644 --- a/plugins/catalog-react/src/hooks/useRelatedEntities.test.tsx +++ b/plugins/catalog-react/src/hooks/useRelatedEntities.test.tsx @@ -20,6 +20,7 @@ import { renderHook, waitFor } from '@testing-library/react'; import React, { ComponentType, PropsWithChildren } from 'react'; import { catalogApiRef } from '../api'; import { useRelatedEntities } from './useRelatedEntities'; +import { catalogApiMock } from '../testUtils'; describe('useRelatedEntities', () => { afterEach(() => { @@ -46,9 +47,7 @@ describe('useRelatedEntities', () => { ], }; - const catalogApi = { - getEntitiesByRefs: jest.fn(), - }; + const catalogApi = catalogApiMock.mock(); const wrapper: ComponentType> = ({ children }) => { return ( @@ -60,7 +59,7 @@ describe('useRelatedEntities', () => { it('filters and requests entities', async () => { catalogApi.getEntitiesByRefs.mockResolvedValueOnce({ - items: [entity, null], // one of them doesn't exist + items: [entity, undefined], // one of them doesn't exist }); const rendered = renderHook( diff --git a/plugins/home/src/homePageComponents/StarredEntities/Content.test.tsx b/plugins/home/src/homePageComponents/StarredEntities/Content.test.tsx index dab67d0b76..ad26482961 100644 --- a/plugins/home/src/homePageComponents/StarredEntities/Content.test.tsx +++ b/plugins/home/src/homePageComponents/StarredEntities/Content.test.tsx @@ -84,11 +84,11 @@ describe('StarredEntitiesContent', () => { it('should display call to action message if no entities are starred', async () => { const mockedApi = new MockStarredEntitiesApi(); - const mockCatalogApi = { + const mockCatalogApi = catalogApiMock.mock({ getEntitiesByRefs: jest .fn() .mockImplementation(async () => ({ items: entities })), - }; + }); const { getByText } = await renderInTestApp( { it('should display user provided message if no entities are starred', async () => { const mockedApi = new MockStarredEntitiesApi(); - const mockCatalogApi = { + const mockCatalogApi = catalogApiMock.mock({ getEntitiesByRefs: jest .fn() .mockImplementation(async () => ({ items: entities })), - }; + }); const { getByText } = await renderInTestApp( Date: Mon, 7 Oct 2024 18:55:26 +0200 Subject: [PATCH 120/291] cli: only configure jest result processor if success cache is used Signed-off-by: Patrik Oldsberg --- packages/cli/config/jest.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index 94164b09ab..36ffd359aa 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -339,7 +339,9 @@ async function getRootConfig() { return { rootDir: paths.targetRoot, projects: configs, - testResultsProcessor: require.resolve('./jestCacheResultProcessor.cjs'), + testResultsProcessor: cache + ? require.resolve('./jestCacheResultProcessor.cjs') + : undefined, ...globalRootConfig, }; } From 25943ab18b56af18f5941779500b8d01605b1352 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Oct 2024 00:14:53 +0200 Subject: [PATCH 121/291] cli: move repot test result processing to runner, and by path Signed-off-by: Patrik Oldsberg --- .../cli/config/jestCacheResultProcessor.cjs | 21 +-------- packages/cli/src/commands/repo/test.ts | 44 ++++++++++++++++--- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/packages/cli/config/jestCacheResultProcessor.cjs b/packages/cli/config/jestCacheResultProcessor.cjs index ae7de0ba3c..4af401d8b5 100644 --- a/packages/cli/config/jestCacheResultProcessor.cjs +++ b/packages/cli/config/jestCacheResultProcessor.cjs @@ -16,25 +16,8 @@ module.exports = async results => { const cache = global.__backstageCli_jestSuccessCache; - if (!cache) { - return results; + if (cache) { + await cache.reportResults(results); } - - const successful = new Set(); - const failed = new Set(); - for (const testResult of results.testResults) { - const projectName = testResult.displayName.name; - if (testResult.numFailingTests > 0) { - failed.add(projectName); - successful.delete(projectName); - } else if (!failed.has(projectName)) { - successful.add(projectName); - } - } - - await cache.reportResults({ - successful: successful, - }); - return results; }; diff --git a/packages/cli/src/commands/repo/test.ts b/packages/cli/src/commands/repo/test.ts index 64ea23a99e..4e11e07515 100644 --- a/packages/cli/src/commands/repo/test.ts +++ b/packages/cli/src/commands/repo/test.ts @@ -23,6 +23,7 @@ import { Command, OptionValues } from 'commander'; import { Lockfile, PackageGraph } from '@backstage/cli-node'; import { paths } from '../../lib/paths'; import { runCheck, runPlain } from '../../lib/run'; +import { isChildPath } from '@backstage/cli-common'; type JestProject = { displayName: string; @@ -34,7 +35,18 @@ interface GlobalWithCache extends Global { projectConfigs: JestProject[], globalConfig: unknown, ): Promise; - reportResults(options: { successful: Set }): Promise; + reportResults(results: { + testResults: Array<{ + displayName?: { name: string }; + numFailingTests: number; + testFilePath: string; + testExecError: { + message: string; + stack: string; + }; + failureMessage: string; + }>; + }): Promise; }; } @@ -267,6 +279,8 @@ export async function command(opts: OptionValues, cmd: Command): Promise { ); } + const graph = await getPackageGraph(); + // Shared state for the bridge const projectHashes = new Map(); const outputSuccessCache = new Array(); @@ -276,7 +290,6 @@ export async function command(opts: OptionValues, cmd: Command): Promise { const globalWithCache = global as GlobalWithCache; globalWithCache.__backstageCli_jestSuccessCache = { async filterConfigs(projectConfigs, globalRootConfig) { - const graph = await getPackageGraph(); const cache = await readCache(cacheDir); const lockfile = await Lockfile.load( paths.resolveTargetRoot('yarn.lock'), @@ -327,14 +340,33 @@ export async function command(opts: OptionValues, cmd: Command): Promise { return project; }); }, - async reportResults(options) { - for (const packageName of options.successful) { - const sha = projectHashes.get(packageName); + async reportResults(results) { + const successful = new Set(); + const failed = new Set(); + for (const testResult of results.testResults) { + for (const [pkgName, pkg] of graph) { + if (isChildPath(pkg.dir, testResult.testFilePath)) { + if ( + testResult.testExecError || + testResult.failureMessage || + testResult.numFailingTests > 0 + ) { + failed.add(pkgName); + successful.delete(pkgName); + } else if (!failed.has(pkgName)) { + successful.add(pkgName); + } + break; + } + } + } + for (const pkgName of successful) { + const sha = projectHashes.get(pkgName); if (sha) { outputSuccessCache.push(sha); } } - writeCache(cacheDir, outputSuccessCache); + await writeCache(cacheDir, outputSuccessCache); }, }; } From eeb62e3346f1403b42769330f6551bab3e794bc2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Oct 2024 00:18:07 +0200 Subject: [PATCH 122/291] cli: few extra comments for repo test Signed-off-by: Patrik Oldsberg --- packages/cli/src/commands/repo/test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/cli/src/commands/repo/test.ts b/packages/cli/src/commands/repo/test.ts index 4e11e07515..06e96b766b 100644 --- a/packages/cli/src/commands/repo/test.ts +++ b/packages/cli/src/commands/repo/test.ts @@ -289,6 +289,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise { // are picked up by the config script itself, as well as the custom result processor. const globalWithCache = global as GlobalWithCache; globalWithCache.__backstageCli_jestSuccessCache = { + // This is called by `config/jest.js` after the project configs have been gathered async filterConfigs(projectConfigs, globalRootConfig) { const cache = await readCache(cacheDir); const lockfile = await Lockfile.load( @@ -340,9 +341,11 @@ export async function command(opts: OptionValues, cmd: Command): Promise { return project; }); }, + // This is called by `config/jestCacheResultProcess.cjs` after all tests have run async reportResults(results) { const successful = new Set(); const failed = new Set(); + for (const testResult of results.testResults) { for (const [pkgName, pkg] of graph) { if (isChildPath(pkg.dir, testResult.testFilePath)) { @@ -360,12 +363,14 @@ export async function command(opts: OptionValues, cmd: Command): Promise { } } } + for (const pkgName of successful) { const sha = projectHashes.get(pkgName); if (sha) { outputSuccessCache.push(sha); } } + await writeCache(cacheDir, outputSuccessCache); }, }; From 809776acd73de48e08a109de187ab281bd5d0c6f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Oct 2024 00:43:57 +0200 Subject: [PATCH 123/291] events-backend: skip tests if postgres tests are skipped Signed-off-by: Patrik Oldsberg --- plugins/events-backend/src/migrations.test.ts | 11 +++++++---- .../src/service/hub/DatabaseEventBusStore.test.ts | 5 ++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/plugins/events-backend/src/migrations.test.ts b/plugins/events-backend/src/migrations.test.ts index 1c90260ed3..3aefeda864 100644 --- a/plugins/events-backend/src/migrations.test.ts +++ b/plugins/events-backend/src/migrations.test.ts @@ -41,11 +41,14 @@ async function migrateUntilBefore(knex: Knex, target: string): Promise { jest.setTimeout(60_000); -describe('migrations', () => { - const databases = TestDatabases.create({ - ids: ['POSTGRES_9', 'POSTGRES_13', 'POSTGRES_16'], - }); +const databases = TestDatabases.create({ + ids: ['POSTGRES_9', 'POSTGRES_13', 'POSTGRES_16'], +}); +const maybeDescribe = + databases.eachSupportedId().length > 0 ? describe : describe.skip; + +maybeDescribe('migrations', () => { it.each(databases.eachSupportedId())( '20240523100528_init.js, %p', async databaseId => { diff --git a/plugins/events-backend/src/service/hub/DatabaseEventBusStore.test.ts b/plugins/events-backend/src/service/hub/DatabaseEventBusStore.test.ts index 7086d90cbf..ade462a920 100644 --- a/plugins/events-backend/src/service/hub/DatabaseEventBusStore.test.ts +++ b/plugins/events-backend/src/service/hub/DatabaseEventBusStore.test.ts @@ -27,7 +27,10 @@ const databases = TestDatabases.create({ ids: ['POSTGRES_9', 'POSTGRES_13', 'POSTGRES_16'], }); -describe('DatabaseEventBusStore', () => { +const maybeDescribe = + databases.eachSupportedId().length > 0 ? describe : describe.skip; + +maybeDescribe('DatabaseEventBusStore', () => { it.each(databases.eachSupportedId())( 'should clean up old events, %p', async databaseId => { From 217458a9a8bfff1708cb419ec5bd7b5c8de30945 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 4 Oct 2024 16:58:36 +0200 Subject: [PATCH 124/291] auth-node: add allowedDomains options for emailLocalPartMatchingUserEntityName + fixes Signed-off-by: Patrik Oldsberg --- .changeset/cuddly-stingrays-smell.md | 18 ++++++++++++++++++ .changeset/flat-seals-type.md | 5 +++++ .../config.d.ts | 5 ++++- .../config.d.ts | 5 ++++- .../config.d.ts | 5 ++++- .../config.d.ts | 5 ++++- .../config.d.ts | 5 ++++- .../config.d.ts | 5 ++++- .../config.d.ts | 5 ++++- .../config.d.ts | 5 ++++- .../config.d.ts | 5 ++++- .../config.d.ts | 5 ++++- .../config.d.ts | 5 ++++- .../config.d.ts | 5 ++++- .../config.d.ts | 5 ++++- .../config.d.ts | 5 ++++- plugins/auth-node/package.json | 3 ++- .../src/oauth/createOAuthProviderFactory.ts | 5 +---- .../proxy/createProxyAuthProviderFactory.ts | 5 +---- .../src/sign-in/commonSignInResolvers.ts | 17 ++++++++++++++++- .../src/sign-in/createSignInResolverFactory.ts | 12 ++++++++++-- yarn.lock | 9 +++++---- 22 files changed, 114 insertions(+), 30 deletions(-) create mode 100644 .changeset/cuddly-stingrays-smell.md create mode 100644 .changeset/flat-seals-type.md diff --git a/.changeset/cuddly-stingrays-smell.md b/.changeset/cuddly-stingrays-smell.md new file mode 100644 index 0000000000..5a8a8ac236 --- /dev/null +++ b/.changeset/cuddly-stingrays-smell.md @@ -0,0 +1,18 @@ +--- +'@backstage/plugin-auth-backend-module-cloudflare-access-provider': patch +'@backstage/plugin-auth-backend-module-vmware-cloud-provider': patch +'@backstage/plugin-auth-backend-module-atlassian-provider': patch +'@backstage/plugin-auth-backend-module-bitbucket-provider': patch +'@backstage/plugin-auth-backend-module-microsoft-provider': patch +'@backstage/plugin-auth-backend-module-onelogin-provider': patch +'@backstage/plugin-auth-backend-module-aws-alb-provider': patch +'@backstage/plugin-auth-backend-module-gcp-iap-provider': patch +'@backstage/plugin-auth-backend-module-github-provider': patch +'@backstage/plugin-auth-backend-module-gitlab-provider': patch +'@backstage/plugin-auth-backend-module-google-provider': patch +'@backstage/plugin-auth-backend-module-oauth2-provider': patch +'@backstage/plugin-auth-backend-module-oidc-provider': patch +'@backstage/plugin-auth-backend-module-okta-provider': patch +--- + +Updated configuration schema to include the new `allowedDomains` option for the `emailLocalPartMatchingUserEntityName` sign-in resolver. diff --git a/.changeset/flat-seals-type.md b/.changeset/flat-seals-type.md new file mode 100644 index 0000000000..9574c74fc9 --- /dev/null +++ b/.changeset/flat-seals-type.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-node': patch +--- + +Added a new `allowedDomains` option for the common `emailLocalPartMatchingUserEntityName` sign-in resolver. diff --git a/plugins/auth-backend-module-atlassian-provider/config.d.ts b/plugins/auth-backend-module-atlassian-provider/config.d.ts index b6ca96d62f..b1edea3d6c 100644 --- a/plugins/auth-backend-module-atlassian-provider/config.d.ts +++ b/plugins/auth-backend-module-atlassian-provider/config.d.ts @@ -31,7 +31,10 @@ export interface Config { signIn?: { resolvers: Array< | { resolver: 'usernameMatchingUserEntityName' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-aws-alb-provider/config.d.ts b/plugins/auth-backend-module-aws-alb-provider/config.d.ts index d76ae5bada..2e38490b28 100644 --- a/plugins/auth-backend-module-aws-alb-provider/config.d.ts +++ b/plugins/auth-backend-module-aws-alb-provider/config.d.ts @@ -41,7 +41,10 @@ export interface Config { region: string; signIn?: { resolvers: Array< - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-bitbucket-provider/config.d.ts b/plugins/auth-backend-module-bitbucket-provider/config.d.ts index df68209469..552a66d700 100644 --- a/plugins/auth-backend-module-bitbucket-provider/config.d.ts +++ b/plugins/auth-backend-module-bitbucket-provider/config.d.ts @@ -29,7 +29,10 @@ export interface Config { signIn?: { resolvers: Array< | { resolver: 'userIdMatchingUserEntityAnnotation' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts b/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts index 21b839b8d7..72dc599cea 100644 --- a/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts +++ b/plugins/auth-backend-module-cloudflare-access-provider/config.d.ts @@ -31,7 +31,10 @@ export interface Config { authorizationCookieName?: string; signIn?: { resolvers: Array< - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-gcp-iap-provider/config.d.ts b/plugins/auth-backend-module-gcp-iap-provider/config.d.ts index 4ca426d10e..d4fe015b7c 100644 --- a/plugins/auth-backend-module-gcp-iap-provider/config.d.ts +++ b/plugins/auth-backend-module-gcp-iap-provider/config.d.ts @@ -36,7 +36,10 @@ export interface Config { resolvers: Array< | { resolver: 'emailMatchingUserEntityAnnotation' } | { resolver: 'idMatchingUserEntityAnnotation' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-github-provider/config.d.ts b/plugins/auth-backend-module-github-provider/config.d.ts index 2ca17998f0..b1bc0dc477 100644 --- a/plugins/auth-backend-module-github-provider/config.d.ts +++ b/plugins/auth-backend-module-github-provider/config.d.ts @@ -31,7 +31,10 @@ export interface Config { signIn?: { resolvers: Array< | { resolver: 'usernameMatchingUserEntityName' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-gitlab-provider/config.d.ts b/plugins/auth-backend-module-gitlab-provider/config.d.ts index d21c2bd62a..cbb9f01e02 100644 --- a/plugins/auth-backend-module-gitlab-provider/config.d.ts +++ b/plugins/auth-backend-module-gitlab-provider/config.d.ts @@ -31,7 +31,10 @@ export interface Config { signIn?: { resolvers: Array< | { resolver: 'usernameMatchingUserEntityName' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-google-provider/config.d.ts b/plugins/auth-backend-module-google-provider/config.d.ts index 3abab05cfe..e788860fa8 100644 --- a/plugins/auth-backend-module-google-provider/config.d.ts +++ b/plugins/auth-backend-module-google-provider/config.d.ts @@ -30,7 +30,10 @@ export interface Config { signIn?: { resolvers: Array< | { resolver: 'emailMatchingUserEntityAnnotation' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-microsoft-provider/config.d.ts b/plugins/auth-backend-module-microsoft-provider/config.d.ts index b1ed2d5766..f63f37921d 100644 --- a/plugins/auth-backend-module-microsoft-provider/config.d.ts +++ b/plugins/auth-backend-module-microsoft-provider/config.d.ts @@ -32,7 +32,10 @@ export interface Config { signIn?: { resolvers: Array< | { resolver: 'emailMatchingUserEntityAnnotation' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-oauth2-provider/config.d.ts b/plugins/auth-backend-module-oauth2-provider/config.d.ts index fa9dec7ce1..5cee3a5407 100644 --- a/plugins/auth-backend-module-oauth2-provider/config.d.ts +++ b/plugins/auth-backend-module-oauth2-provider/config.d.ts @@ -35,7 +35,10 @@ export interface Config { signIn?: { resolvers: Array< | { resolver: 'usernameMatchingUserEntityName' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-oidc-provider/config.d.ts b/plugins/auth-backend-module-oidc-provider/config.d.ts index dbc06e51bb..d59c40697a 100644 --- a/plugins/auth-backend-module-oidc-provider/config.d.ts +++ b/plugins/auth-backend-module-oidc-provider/config.d.ts @@ -33,7 +33,10 @@ export interface Config { prompt?: string; signIn?: { resolvers: Array< - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-okta-provider/config.d.ts b/plugins/auth-backend-module-okta-provider/config.d.ts index 0689171153..d2b0c4aa02 100644 --- a/plugins/auth-backend-module-okta-provider/config.d.ts +++ b/plugins/auth-backend-module-okta-provider/config.d.ts @@ -33,7 +33,10 @@ export interface Config { signIn?: { resolvers: Array< | { resolver: 'emailMatchingUserEntityAnnotation' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-onelogin-provider/config.d.ts b/plugins/auth-backend-module-onelogin-provider/config.d.ts index 8257e2ac71..da7c312dd3 100644 --- a/plugins/auth-backend-module-onelogin-provider/config.d.ts +++ b/plugins/auth-backend-module-onelogin-provider/config.d.ts @@ -30,7 +30,10 @@ export interface Config { signIn?: { resolvers: Array< | { resolver: 'usernameMatchingUserEntityName' } - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts b/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts index 8bb8320c0c..67db735713 100644 --- a/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts +++ b/plugins/auth-backend-module-vmware-cloud-provider/config.d.ts @@ -27,7 +27,10 @@ export interface Config { additionalScopes?: string | string[]; signIn?: { resolvers: Array< - | { resolver: 'emailLocalPartMatchingUserEntityName' } + | { + resolver: 'emailLocalPartMatchingUserEntityName'; + allowedDomains?: string[]; + } | { resolver: 'emailMatchingUserEntityProfileEmail' } >; }; diff --git a/plugins/auth-node/package.json b/plugins/auth-node/package.json index 6925fc82b6..b27a5fd942 100644 --- a/plugins/auth-node/package.json +++ b/plugins/auth-node/package.json @@ -53,7 +53,8 @@ "passport": "^0.7.0", "winston": "^3.2.1", "zod": "^3.22.4", - "zod-to-json-schema": "^3.21.4" + "zod-to-json-schema": "^3.21.4", + "zod-validation-error": "^3.4.0" }, "devDependencies": { "@backstage/backend-test-utils": "workspace:^", diff --git a/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts b/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts index fccfcb94de..f7f56371a3 100644 --- a/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts +++ b/plugins/auth-node/src/oauth/createOAuthProviderFactory.ts @@ -34,10 +34,7 @@ export function createOAuthProviderFactory(options: { profileTransform?: ProfileTransform>; signInResolver?: SignInResolver>; signInResolverFactories?: { - [name in string]: SignInResolverFactory< - OAuthAuthenticatorResult, - unknown - >; + [name in string]: SignInResolverFactory; }; }): AuthProviderFactory { return ctx => { diff --git a/plugins/auth-node/src/proxy/createProxyAuthProviderFactory.ts b/plugins/auth-node/src/proxy/createProxyAuthProviderFactory.ts index 36e7b04e1e..0ce0102444 100644 --- a/plugins/auth-node/src/proxy/createProxyAuthProviderFactory.ts +++ b/plugins/auth-node/src/proxy/createProxyAuthProviderFactory.ts @@ -31,10 +31,7 @@ export function createProxyAuthProviderFactory(options: { authenticator: ProxyAuthenticator; profileTransform?: ProfileTransform; signInResolver?: SignInResolver; - signInResolverFactories?: Record< - string, - SignInResolverFactory - >; + signInResolverFactories?: Record; }): AuthProviderFactory { return ctx => { const signInResolver = diff --git a/plugins/auth-node/src/sign-in/commonSignInResolvers.ts b/plugins/auth-node/src/sign-in/commonSignInResolvers.ts index f664e7e33b..6f0fc7fdb2 100644 --- a/plugins/auth-node/src/sign-in/commonSignInResolvers.ts +++ b/plugins/auth-node/src/sign-in/commonSignInResolvers.ts @@ -14,7 +14,9 @@ * limitations under the License. */ +import { z } from 'zod'; import { createSignInResolverFactory } from './createSignInResolverFactory'; +import { NotAllowedError } from '@backstage/errors'; // This splits an email "joe+work@acme.com" into ["joe", "+work", "@acme.com"] // so that we can remove the plus addressing. May output a shorter array: @@ -77,7 +79,13 @@ export namespace commonSignInResolvers { */ export const emailLocalPartMatchingUserEntityName = createSignInResolverFactory({ - create() { + optionsSchema: z + .object({ + allowedDomains: z.array(z.string()).optional(), + }) + .optional(), + create(options = {}) { + const { allowedDomains } = options; return async (info, ctx) => { const { profile } = info; @@ -87,6 +95,13 @@ export namespace commonSignInResolvers { ); } const [localPart] = profile.email.split('@'); + const domain = profile.email.slice(localPart.length + 1); + + if (allowedDomains && !allowedDomains.includes(domain)) { + throw new NotAllowedError( + 'Sign-in user email is not from an allowed domain', + ); + } return ctx.signInWithCatalogUser({ entityRef: { name: localPart }, diff --git a/plugins/auth-node/src/sign-in/createSignInResolverFactory.ts b/plugins/auth-node/src/sign-in/createSignInResolverFactory.ts index 0632b704c3..e857a0fe3f 100644 --- a/plugins/auth-node/src/sign-in/createSignInResolverFactory.ts +++ b/plugins/auth-node/src/sign-in/createSignInResolverFactory.ts @@ -18,10 +18,11 @@ import { ZodSchema, ZodTypeDef } from 'zod'; import { SignInResolver } from '../types'; import zodToJsonSchema from 'zod-to-json-schema'; import { JsonObject } from '@backstage/types'; +import { fromError } from 'zod-validation-error'; import { InputError } from '@backstage/errors'; /** @public */ -export interface SignInResolverFactory { +export interface SignInResolverFactory { ( ...options: undefined extends TOptions ? [options?: TOptions] @@ -66,7 +67,14 @@ export function createSignInResolverFactory< ? [options?: TOptionsInput] : [options: TOptionsInput] ) => { - const parsedOptions = optionsSchema.parse(resolverOptions); + let parsedOptions; + try { + parsedOptions = optionsSchema.parse(resolverOptions); + } catch (error) { + throw new InputError( + `Invalid sign-in resolver options, ${fromError(error)}`, + ); + } return options.create(parsedOptions); }; diff --git a/yarn.lock b/yarn.lock index 93d7efa51d..790454ed4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5461,6 +5461,7 @@ __metadata: winston: ^3.2.1 zod: ^3.22.4 zod-to-json-schema: ^3.21.4 + zod-validation-error: ^3.4.0 languageName: unknown linkType: soft @@ -45286,12 +45287,12 @@ __metadata: languageName: node linkType: hard -"zod-validation-error@npm:^3.0.3": - version: 3.1.0 - resolution: "zod-validation-error@npm:3.1.0" +"zod-validation-error@npm:^3.0.3, zod-validation-error@npm:^3.4.0": + version: 3.4.0 + resolution: "zod-validation-error@npm:3.4.0" peerDependencies: zod: ^3.18.0 - checksum: 84df01c91d594701eaf7f5f007be881e47f7adef2e3f3765f7be031cb78033f9be0924273106cb81b586d8020da9885dbb81b3da363f00a51df00f26274f2b23 + checksum: b07fbfc39582dbdf6972f5f5f0c3bac9e6b5e6d2e55ef3dd891fd08f1966ebf1023a4bc270e9b569eaa48ed1684ac2252c9f260b0bd07b167671596e6e4d0fa8 languageName: node linkType: hard From 2dc35262149b47963b840fafd37d57f25614adcd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 4 Oct 2024 17:18:22 +0200 Subject: [PATCH 125/291] docs/auth: update sign-in resolver docs to encourage use of allowedDomains Signed-off-by: Patrik Oldsberg --- docs/auth/identity-resolver.md | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md index b8bcd95a5e..293300f429 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -132,12 +132,27 @@ GitHub provider, but you could also choose to use the resolvers, which are common to all auth providers. :::warning -When using the `emailLocalPartMatchingUserEntityName` resolver it is important -to only allow users to sign in with email addresses from expected domains. This -is typically controlled as part of the OAuth configuration in the provider -itself. +When using the `emailLocalPartMatchingUserEntityName` resolver it is strongly +recommended to set the `allowedDomains` option to ensure that only authorized users +are able to sign-in. ::: +If you are using the `emailLocalPartMatchingUserEntityName` resolver, it is +recommended to also set the `allowedDomains` option, for example: + +```yaml title="Within the provider configuration" +auth: + providers: + github: + development: + ... + signIn: + resolvers: + - resolver: emailLocalPartMatchingUserEntityName + allowedDomains: + - acme.org +``` + ### Building Custom Resolvers If the builtins don't work for you, you can also provide a completely custom From f25c9e3fd12bedde0df044167f4696e5edac84eb Mon Sep 17 00:00:00 2001 From: Stephen Glass Date: Mon, 7 Oct 2024 19:44:07 -0400 Subject: [PATCH 126/291] fix entity picker single result behavior Signed-off-by: Stephen Glass --- .changeset/gorgeous-months-fix.md | 5 +++++ .../src/components/fields/EntityPicker/EntityPicker.tsx | 9 +-------- .../fields/MultiEntityPicker/MultiEntityPicker.tsx | 9 +-------- .../components/fields/MyGroupsPicker/MyGroupsPicker.tsx | 9 +-------- 4 files changed, 8 insertions(+), 24 deletions(-) create mode 100644 .changeset/gorgeous-months-fix.md diff --git a/.changeset/gorgeous-months-fix.md b/.changeset/gorgeous-months-fix.md new file mode 100644 index 0000000000..ca899b4e2f --- /dev/null +++ b/.changeset/gorgeous-months-fix.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Change behavior of scaffolder entity pickers (EntityPicker, MultiEntityPicker, MyGroupsPicker) to not auto-fill and disable the field if there is only a single value option. diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx index 2ef5171c7b..67da593a9e 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx @@ -35,7 +35,7 @@ import Autocomplete, { AutocompleteChangeReason, createFilterOptions, } from '@material-ui/lab/Autocomplete'; -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback } from 'react'; import useAsync from 'react-use/esm/useAsync'; import { EntityPickerFilterQueryValue, @@ -166,12 +166,6 @@ export const EntityPicker = (props: EntityPickerProps) => { entities?.catalogEntities.find(e => stringifyEntityRef(e) === formData) ?? (allowArbitraryValues && formData ? getLabel(formData) : ''); - useEffect(() => { - if (entities?.catalogEntities.length === 1 && selectedEntity === '') { - onChange(stringifyEntityRef(entities.catalogEntities[0])); - } - }, [entities, onChange, selectedEntity]); - return ( { error={rawErrors?.length > 0 && !formData} > { [onChange, formData, defaultKind, defaultNamespace, allowArbitraryValues], ); - useEffect(() => { - if (entities?.entities?.length === 1) { - onChange([stringifyEntityRef(entities?.entities[0])]); - } - }, [entities, onChange]); - return ( { { groups?.catalogEntities.find(e => stringifyEntityRef(e) === formData) || null; - useEffect(() => { - if (groups?.catalogEntities.length === 1 && !selectedEntity) { - onChange(stringifyEntityRef(groups.catalogEntities[0])); - } - }, [groups, onChange, selectedEntity]); - return ( { error={rawErrors?.length > 0} > Date: Tue, 8 Oct 2024 04:55:42 +0000 Subject: [PATCH 127/291] chore(deps): update dependency @openapitools/openapi-generator-cli to v2.14.0 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 59 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/yarn.lock b/yarn.lock index cd65c170d6..7fb40612d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12951,8 +12951,8 @@ __metadata: linkType: hard "@openapitools/openapi-generator-cli@npm:^2.4.26, @openapitools/openapi-generator-cli@npm:^2.7.0": - version: 2.13.12 - resolution: "@openapitools/openapi-generator-cli@npm:2.13.12" + version: 2.14.0 + resolution: "@openapitools/openapi-generator-cli@npm:2.14.0" dependencies: "@nestjs/axios": 3.0.3 "@nestjs/common": 10.4.3 @@ -12965,7 +12965,7 @@ __metadata: concurrently: 6.5.1 console.table: 0.10.0 fs-extra: 10.1.0 - glob: 7.2.3 + glob: 9.3.5 https-proxy-agent: 7.0.5 inquirer: 8.2.6 lodash: 4.17.21 @@ -12974,7 +12974,7 @@ __metadata: tslib: 2.7.0 bin: openapi-generator-cli: main.js - checksum: c2487abfef5dbfc8619824222424c4ab9c0accfdc77f1d6099efa7d4a0b960894351219fe75b12b5663afe4cd2136c3986297e141bf4b380fbfc4aec40704ef2 + checksum: 13cb5b17849611d3bc760b9ef65f8b5537fd68e5cf7c8867495b63dbcfa31289230fcfbf00e38589856a72e94d5943584370240b21d835e12adfe42cfcc5008a languageName: node linkType: hard @@ -28419,17 +28419,15 @@ __metadata: languageName: node linkType: hard -"glob@npm:7.2.3, glob@npm:^7.0.0, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6, glob@npm:^7.1.7": - version: 7.2.3 - resolution: "glob@npm:7.2.3" +"glob@npm:9.3.5": + version: 9.3.5 + resolution: "glob@npm:9.3.5" dependencies: fs.realpath: ^1.0.0 - inflight: ^1.0.4 - inherits: 2 - minimatch: ^3.1.1 - once: ^1.3.0 - path-is-absolute: ^1.0.0 - checksum: 29452e97b38fa704dabb1d1045350fb2467cf0277e155aa9ff7077e90ad81d1ea9d53d3ee63bd37c05b09a065e90f16aec4a65f5b8de401d1dac40bc5605d133 + minimatch: ^8.0.2 + minipass: ^4.2.4 + path-scurry: ^1.6.1 + checksum: 94b093adbc591bc36b582f77927d1fb0dbf3ccc231828512b017601408be98d1fe798fc8c0b19c6f2d1a7660339c3502ce698de475e9d938ccbb69b47b647c84 languageName: node linkType: hard @@ -28449,6 +28447,20 @@ __metadata: languageName: node linkType: hard +"glob@npm:^7.0.0, glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.6, glob@npm:^7.1.7": + version: 7.2.3 + resolution: "glob@npm:7.2.3" + dependencies: + fs.realpath: ^1.0.0 + inflight: ^1.0.4 + inherits: 2 + minimatch: ^3.1.1 + once: ^1.3.0 + path-is-absolute: ^1.0.0 + checksum: 29452e97b38fa704dabb1d1045350fb2467cf0277e155aa9ff7077e90ad81d1ea9d53d3ee63bd37c05b09a065e90f16aec4a65f5b8de401d1dac40bc5605d133 + languageName: node + linkType: hard + "glob@npm:^8.0.1, glob@npm:^8.0.3, glob@npm:^8.1.0": version: 8.1.0 resolution: "glob@npm:8.1.0" @@ -34315,6 +34327,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^8.0.2": + version: 8.0.4 + resolution: "minimatch@npm:8.0.4" + dependencies: + brace-expansion: ^2.0.1 + checksum: 2e46cffb86bacbc524ad45a6426f338920c529dd13f3a732cc2cf7618988ee1aae88df4ca28983285aca9e0f45222019ac2d14ebd17c1edadd2ee12221ab801a + languageName: node + linkType: hard + "minimatch@npm:^9.0.0, minimatch@npm:^9.0.3, minimatch@npm:^9.0.4, minimatch@npm:^9.0.5": version: 9.0.5 resolution: "minimatch@npm:9.0.5" @@ -34449,12 +34470,10 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^4.0.0": - version: 4.0.0 - resolution: "minipass@npm:4.0.0" - dependencies: - yallist: ^4.0.0 - checksum: 7a609afbf394abfcf9c48e6c90226f471676c8f2a67f07f6838871afb03215ede431d1433feffe1b855455bcb13ef0eb89162841b9796109d6fed8d89790f381 +"minipass@npm:^4.0.0, minipass@npm:^4.2.4": + version: 4.2.8 + resolution: "minipass@npm:4.2.8" + checksum: 7f4914d5295a9a30807cae5227a37a926e6d910c03f315930fde52332cf0575dfbc20295318f91f0baf0e6bb11a6f668e30cde8027dea7a11b9d159867a3c830 languageName: node linkType: hard @@ -36615,7 +36634,7 @@ __metadata: languageName: node linkType: hard -"path-scurry@npm:^1.11.1": +"path-scurry@npm:^1.11.1, path-scurry@npm:^1.6.1": version: 1.11.1 resolution: "path-scurry@npm:1.11.1" dependencies: From a7ef006e1572945d02ac92683e13b22c935b9815 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Oct 2024 10:12:27 +0200 Subject: [PATCH 128/291] update API reports Signed-off-by: Patrik Oldsberg --- .../report.api.md | 5 ++++- .../report.api.md | 10 ---------- plugins/auth-node/report.api.md | 17 +++++++---------- 3 files changed, 11 insertions(+), 21 deletions(-) diff --git a/plugins/auth-backend-module-oidc-provider/report.api.md b/plugins/auth-backend-module-oidc-provider/report.api.md index df717a9e1e..bee9a2fdd9 100644 --- a/plugins/auth-backend-module-oidc-provider/report.api.md +++ b/plugins/auth-backend-module-oidc-provider/report.api.md @@ -39,7 +39,10 @@ export type OidcAuthResult = { export namespace oidcSignInResolvers { const emailLocalPartMatchingUserEntityName: SignInResolverFactory< unknown, - unknown + | { + allowedDomains?: string[] | undefined; + } + | undefined >; const emailMatchingUserEntityProfileEmail: SignInResolverFactory< unknown, diff --git a/plugins/auth-backend-module-vmware-cloud-provider/report.api.md b/plugins/auth-backend-module-vmware-cloud-provider/report.api.md index c970b237a9..1bd10c7eae 100644 --- a/plugins/auth-backend-module-vmware-cloud-provider/report.api.md +++ b/plugins/auth-backend-module-vmware-cloud-provider/report.api.md @@ -5,10 +5,8 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; import { OAuthAuthenticator } from '@backstage/plugin-auth-node'; -import { OAuthAuthenticatorResult } from '@backstage/plugin-auth-node'; import { PassportOAuthAuthenticatorHelper } from '@backstage/plugin-auth-node'; import { PassportProfile } from '@backstage/plugin-auth-node'; -import { SignInResolverFactory } from '@backstage/plugin-auth-node'; import { Strategy } from 'passport-oauth2'; // @public @@ -31,14 +29,6 @@ export interface VMwareCloudAuthenticatorContext { providerStrategy: Strategy; } -// @public -export namespace vmwareCloudSignInResolvers { - const profileEmailMatchingUserEntityEmail: SignInResolverFactory< - OAuthAuthenticatorResult, - unknown - >; -} - // @public (undocumented) export type VMwarePassportProfile = PassportProfile & { organizationId?: string; diff --git a/plugins/auth-node/report.api.md b/plugins/auth-node/report.api.md index 4cf9d5fd4b..5d3fce8f23 100644 --- a/plugins/auth-node/report.api.md +++ b/plugins/auth-node/report.api.md @@ -148,7 +148,10 @@ export namespace commonSignInResolvers { >; const emailLocalPartMatchingUserEntityName: SignInResolverFactory< unknown, - unknown + | { + allowedDomains?: string[] | undefined; + } + | undefined >; } @@ -178,10 +181,7 @@ export function createOAuthProviderFactory(options: { profileTransform?: ProfileTransform>; signInResolver?: SignInResolver>; signInResolverFactories?: { - [name in string]: SignInResolverFactory< - OAuthAuthenticatorResult, - unknown - >; + [name in string]: SignInResolverFactory; }; }): AuthProviderFactory; @@ -200,10 +200,7 @@ export function createProxyAuthProviderFactory(options: { authenticator: ProxyAuthenticator; profileTransform?: ProfileTransform; signInResolver?: SignInResolver; - signInResolverFactories?: Record< - string, - SignInResolverFactory - >; + signInResolverFactories?: Record; }): AuthProviderFactory; // @public (undocumented) @@ -648,7 +645,7 @@ export type SignInResolver = ( ) => Promise; // @public (undocumented) -export interface SignInResolverFactory { +export interface SignInResolverFactory { // (undocumented) ( ...options: undefined extends TOptions From 06490f8c1e57157a47bb1480391c94524e4f7f87 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Oct 2024 10:17:51 +0200 Subject: [PATCH 129/291] Update packages/backend-openapi-utils/src/schema/parameter-validation.ts Signed-off-by: Patrik Oldsberg --- .../backend-openapi-utils/src/schema/parameter-validation.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/backend-openapi-utils/src/schema/parameter-validation.ts b/packages/backend-openapi-utils/src/schema/parameter-validation.ts index cbcb098166..7a61578850 100644 --- a/packages/backend-openapi-utils/src/schema/parameter-validation.ts +++ b/packages/backend-openapi-utils/src/schema/parameter-validation.ts @@ -135,8 +135,6 @@ export class QueryParameterParser // Sort the parameters so that form explode parameters are processed last. parameterIterator = [...regularParameters, ...formExplodeParameters]; - console.log(parameterIterator); - for (const [name, parameter] of parameterIterator) { if (!parameter.schema) { throw new OperationError( From 190241b2ffa1bf78d74b0ce65d8a1bc37c3daf42 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Oct 2024 09:10:41 +0000 Subject: [PATCH 130/291] chore(deps): update actions/checkout digest to eef6144 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/api-breaking-changes-comment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/api-breaking-changes-comment.yml b/.github/workflows/api-breaking-changes-comment.yml index e28f147706..038061adac 100644 --- a/.github/workflows/api-breaking-changes-comment.yml +++ b/.github/workflows/api-breaking-changes-comment.yml @@ -90,7 +90,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4 # Identify comment to be updated - name: Find comment for API Changes From d5f254f8e462c403cdf4a556bf7317a04b4fb1e8 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 8 Oct 2024 11:27:54 +0200 Subject: [PATCH 131/291] chore: check that it's not already a bound version Signed-off-by: blam --- packages/yarn-plugin/src/resolver/BackstageResolver.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/yarn-plugin/src/resolver/BackstageResolver.ts b/packages/yarn-plugin/src/resolver/BackstageResolver.ts index 82e6c013a3..01ff66f439 100644 --- a/packages/yarn-plugin/src/resolver/BackstageResolver.ts +++ b/packages/yarn-plugin/src/resolver/BackstageResolver.ts @@ -44,7 +44,10 @@ export class BackstageResolver implements Resolver { * the version in backstage.json changes. */ bindDescriptor(descriptor: Descriptor): Descriptor { - if (descriptor.range !== 'backstage:^') { + if ( + descriptor.range !== 'backstage:^' && + !descriptor.range.startsWith('backstage:') + ) { throw new Error( `Unsupported version range "${ descriptor.range From 4cf6b59e660ec622ad4427c118b7527438de9a56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 8 Oct 2024 11:29:20 +0200 Subject: [PATCH 132/291] make the code palette optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- packages/theme/report.api.md | 2 +- packages/theme/src/base/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/theme/report.api.md b/packages/theme/report.api.md index 0c9e87e958..faec0b8456 100644 --- a/packages/theme/report.api.md +++ b/packages/theme/report.api.md @@ -81,7 +81,7 @@ export type BackstagePaletteAdditions = { closeButtonColor?: string; warning?: string; }; - code: { + code?: { background?: string; }; }; diff --git a/packages/theme/src/base/types.ts b/packages/theme/src/base/types.ts index 72a7632dd9..8f22155e09 100644 --- a/packages/theme/src/base/types.ts +++ b/packages/theme/src/base/types.ts @@ -82,7 +82,7 @@ export type BackstagePaletteAdditions = { closeButtonColor?: string; warning?: string; }; - code: { + code?: { background?: string; }; }; From 7955f9bbc0ba0886f75d209577f58503ca5202c3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Oct 2024 11:19:31 +0200 Subject: [PATCH 133/291] cli: disable feature detection for backend builds Signed-off-by: Patrik Oldsberg --- .changeset/neat-geckos-end.md | 5 +++++ packages/cli/src/commands/buildWorkspace.ts | 1 + packages/cli/src/commands/pack.ts | 6 +++++- .../src/lib/packager/createDistWorkspace.ts | 16 ++++++++++++--- .../cli/src/lib/packager/productionPack.ts | 20 ++++++++----------- 5 files changed, 32 insertions(+), 16 deletions(-) create mode 100644 .changeset/neat-geckos-end.md diff --git a/.changeset/neat-geckos-end.md b/.changeset/neat-geckos-end.md new file mode 100644 index 0000000000..02a281b447 --- /dev/null +++ b/.changeset/neat-geckos-end.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Tweaked the new package feature detection to not be active when building backend packages. diff --git a/packages/cli/src/commands/buildWorkspace.ts b/packages/cli/src/commands/buildWorkspace.ts index 685e4b1cb0..a20663fc32 100644 --- a/packages/cli/src/commands/buildWorkspace.ts +++ b/packages/cli/src/commands/buildWorkspace.ts @@ -29,5 +29,6 @@ export default async (dir: string, packages: string[], options: Options) => { await createDistWorkspace(packages, { targetDir: dir, alwaysYarnPack: options.alwaysYarnPack, + enableFeatureDetection: true, }); }; diff --git a/packages/cli/src/commands/pack.ts b/packages/cli/src/commands/pack.ts index 8fbbdb3d74..24f2a90c3f 100644 --- a/packages/cli/src/commands/pack.ts +++ b/packages/cli/src/commands/pack.ts @@ -21,6 +21,7 @@ import { import { paths } from '../lib/paths'; import fs from 'fs-extra'; import { publishPreflightCheck } from '../lib/publishing'; +import { createTypeDistProject } from '../lib/typeDistProject'; export const pre = async () => { publishPreflightCheck({ @@ -28,7 +29,10 @@ export const pre = async () => { packageJson: await fs.readJson(paths.resolveTarget('package.json')), }); - await productionPack({ packageDir: paths.targetDir }); + await productionPack({ + packageDir: paths.targetDir, + featureDetectionProject: await createTypeDistProject(), + }); }; export const post = async () => { diff --git a/packages/cli/src/lib/packager/createDistWorkspace.ts b/packages/cli/src/lib/packager/createDistWorkspace.ts index 7b7db43889..2363023ddd 100644 --- a/packages/cli/src/lib/packager/createDistWorkspace.ts +++ b/packages/cli/src/lib/packager/createDistWorkspace.ts @@ -105,6 +105,12 @@ type Options = { */ alwaysYarnPack?: boolean; + /** + * If set to true, the TypeScript feature detection will be enabled, which + * annotates the package exports field with the `backstage` export type. + */ + enableFeatureDetection?: boolean; + /** * If set to true, the generated code will be minified. */ @@ -237,6 +243,7 @@ export async function createDistWorkspace( targetDir, targets, Boolean(options.alwaysYarnPack), + Boolean(options.enableFeatureDetection), ); const files: FileEntry[] = options.files ?? ['yarn.lock', 'package.json']; @@ -280,6 +287,7 @@ async function moveToDistWorkspace( workspaceDir: string, localPackages: PackageGraphNode[], alwaysYarnPack: boolean, + enableFeatureDetection: boolean, ): Promise { const [fastPackPackages, slowPackPackages] = partition( localPackages, @@ -288,8 +296,10 @@ async function moveToDistWorkspace( FAST_PACK_SCRIPTS.includes(pkg.packageJson.scripts?.prepack), ); - const tsMorphProject = - fastPackPackages.length > 0 ? await createTypeDistProject() : undefined; + const featureDetectionProject = + fastPackPackages.length > 0 && enableFeatureDetection + ? await createTypeDistProject() + : undefined; // New an improved flow where we avoid calling `yarn pack` await Promise.all( @@ -301,7 +311,7 @@ async function moveToDistWorkspace( await productionPack({ packageDir: target.dir, targetDir: absoluteOutputPath, - project: tsMorphProject, + featureDetectionProject, }); }), ); diff --git a/packages/cli/src/lib/packager/productionPack.ts b/packages/cli/src/lib/packager/productionPack.ts index 85f50352cc..c15a7f8b9e 100644 --- a/packages/cli/src/lib/packager/productionPack.ts +++ b/packages/cli/src/lib/packager/productionPack.ts @@ -19,10 +19,7 @@ import npmPackList from 'npm-packlist'; import { resolve as resolvePath, posix as posixPath } from 'path'; import { BackstagePackageJson } from '@backstage/cli-node'; import { readEntryPoints } from '../entryPoints'; -import { - createTypeDistProject, - getEntryPointDefaultFeatureType, -} from '../typeDistProject'; +import { getEntryPointDefaultFeatureType } from '../typeDistProject'; import { Project } from 'ts-morph'; const PKG_PATH = 'package.json'; @@ -35,9 +32,9 @@ interface ProductionPackOptions { packageDir: string; targetDir?: string; /** - * A ts-morph project to share across packages + * Enables package feature detection using this TS-morph project. */ - project?: Project; + featureDetectionProject?: Project; } export async function productionPack(options: ProductionPackOptions) { @@ -55,7 +52,7 @@ export async function productionPack(options: ProductionPackOptions) { const writeCompatibilityEntryPoints = await prepareExportsEntryPoints( pkg, packageDir, - options.project, + options.featureDetectionProject, ); // TODO(Rugvip): Once exports are rolled out more broadly we should deprecate and remove this behavior @@ -144,7 +141,7 @@ const EXPORT_MAP = { async function prepareExportsEntryPoints( pkg: BackstagePackageJson, packageDir: string, - commonProject?: Project, + featureDetectionProject?: Project, ) { const distPath = resolvePath(packageDir, 'dist'); if (!(await fs.pathExists(distPath))) { @@ -158,7 +155,6 @@ async function prepareExportsEntryPoints( >(); const entryPoints = readEntryPoints(pkg); - const project = commonProject || (await createTypeDistProject()); for (const entryPoint of entryPoints) { if (!SCRIPT_EXTS.includes(entryPoint.ext)) { @@ -177,14 +173,14 @@ async function prepareExportsEntryPoints( exp.default = exp.require ?? exp.import; - // Find the default export type for the entry point - if (exp.types) { + // Find the default export type for the entry point, if feature detection is active + if (exp.types && featureDetectionProject) { const defaultFeatureType = pkg.backstage?.role && getEntryPointDefaultFeatureType( pkg.backstage?.role, packageDir, - project, + featureDetectionProject, exp.types, ); From aba538db72473621bbdd216089bf43a37112a817 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 8 Oct 2024 11:57:32 +0200 Subject: [PATCH 134/291] chore: support fixed versions of backstage version in resolver Signed-off-by: blam --- .../src/resolver/BackstageResolver.test.ts | 11 ++++++++--- .../src/resolver/BackstageResolver.ts | 19 +++++-------------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/packages/yarn-plugin/src/resolver/BackstageResolver.test.ts b/packages/yarn-plugin/src/resolver/BackstageResolver.test.ts index 9ac3401041..d7d9126923 100644 --- a/packages/yarn-plugin/src/resolver/BackstageResolver.test.ts +++ b/packages/yarn-plugin/src/resolver/BackstageResolver.test.ts @@ -114,15 +114,20 @@ describe('BackstageResolver', () => { }); describe('with range "backstage:1.23.45"', () => { - it('throws an error', () => { - expect(() => + it('returns the correct descriptor', () => { + expect( backstageResolver.bindDescriptor( structUtils.makeDescriptor( structUtils.makeIdent('backstage', 'core'), 'backstage:1.23.45', ), ), - ).toThrow(/unsupported version range/i); + ).toEqual( + structUtils.makeDescriptor( + structUtils.makeIdent('backstage', 'core'), + 'backstage:1.23.45', + ), + ); }); }); }); diff --git a/packages/yarn-plugin/src/resolver/BackstageResolver.ts b/packages/yarn-plugin/src/resolver/BackstageResolver.ts index 01ff66f439..7e794f5693 100644 --- a/packages/yarn-plugin/src/resolver/BackstageResolver.ts +++ b/packages/yarn-plugin/src/resolver/BackstageResolver.ts @@ -44,23 +44,14 @@ export class BackstageResolver implements Resolver { * the version in backstage.json changes. */ bindDescriptor(descriptor: Descriptor): Descriptor { - if ( - descriptor.range !== 'backstage:^' && - !descriptor.range.startsWith('backstage:') - ) { - throw new Error( - `Unsupported version range "${ - descriptor.range - }" for package ${structUtils.stringifyIdent( - descriptor, - )}. The backstage protocol only supports the range "backstage:^".`, + if (descriptor.range === 'backstage:^') { + return structUtils.makeDescriptor( + descriptor, + `${PROTOCOL}${getCurrentBackstageVersion()}`, ); } - return structUtils.makeDescriptor( - descriptor, - `${PROTOCOL}${getCurrentBackstageVersion()}`, - ); + return descriptor; } /** From f0514c72c38c2f98f673db2d30fc99fcd0bd397d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Oct 2024 12:38:15 +0200 Subject: [PATCH 135/291] cli: disable parsing of input source maps in tests Signed-off-by: Patrik Oldsberg --- .changeset/ten-apes-turn.md | 5 +++++ packages/cli/config/jestSwcTransform.js | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 .changeset/ten-apes-turn.md diff --git a/.changeset/ten-apes-turn.md b/.changeset/ten-apes-turn.md new file mode 100644 index 0000000000..750f48beba --- /dev/null +++ b/.changeset/ten-apes-turn.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Disabled parsing of input source maps in the SWC transform for Jest. diff --git a/packages/cli/config/jestSwcTransform.js b/packages/cli/config/jestSwcTransform.js index c44d0fe4b0..83abacc9b5 100644 --- a/packages/cli/config/jestSwcTransform.js +++ b/packages/cli/config/jestSwcTransform.js @@ -18,7 +18,10 @@ const { createTransformer: createSwcTransformer } = require('@swc/jest'); const ESM_REGEX = /\b(?:import|export)\b/; function createTransformer(config) { - const swcTransformer = createSwcTransformer(config); + const swcTransformer = createSwcTransformer({ + inputSourceMap: false, + ...config, + }); const process = (source, filePath, jestOptions) => { if (filePath.endsWith('.js') && !ESM_REGEX.test(source)) { return { code: source }; From a97b5e7ad10fcc62c7ca7b04b6f8b56bfd10faf9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Oct 2024 11:16:34 +0000 Subject: [PATCH 136/291] chore(deps): update dependency @types/d3-selection to v3.0.11 Signed-off-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index f998b87e11..1b9a25d950 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17579,9 +17579,9 @@ __metadata: linkType: hard "@types/d3-selection@npm:*, @types/d3-selection@npm:^3.0.1": - version: 3.0.10 - resolution: "@types/d3-selection@npm:3.0.10" - checksum: 8a1b0940eca565d754c1898b9e4f86e2778e4135878b76b3b8a89d497e37675d423ec3376f248577a502bccb55c1218cc9f6b5688a29a3b500973de8fc5f1c5c + version: 3.0.11 + resolution: "@types/d3-selection@npm:3.0.11" + checksum: 4b76630f76dffdafc73cdc786d73e7b4c96f40546483074b3da0e7fe83fd7f5ed9bc6c50f79bcef83595f943dcc9ed6986953350f39371047af644cc39c41b43 languageName: node linkType: hard From 14d7cfa9af79560b98709f94b11e46f20739d4a7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 8 Oct 2024 13:33:04 +0200 Subject: [PATCH 137/291] cli: fix repo test success cache hashing to include local dependencies Signed-off-by: Patrik Oldsberg --- packages/cli/src/commands/repo/test.ts | 29 +++++++++++++++++++------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/repo/test.ts b/packages/cli/src/commands/repo/test.ts index 06e96b766b..c55969d696 100644 --- a/packages/cli/src/commands/repo/test.ts +++ b/packages/cli/src/commands/repo/test.ts @@ -77,7 +77,7 @@ function writeCache(dir: string, cache: Cache) { /** * Use git to get the HEAD tree hashes of each package in the project. */ -async function getPackageTreeHashes(graph: PackageGraph) { +async function readPackageTreeHashes(graph: PackageGraph) { const pkgs = Array.from(graph.values()); const output = await runPlain( 'git', @@ -95,7 +95,16 @@ async function getPackageTreeHashes(graph: PackageGraph) { ); } - return new Map(pkgs.map((pkg, i) => [pkg.packageJson.name, treeShaList[i]])); + const map = new Map( + pkgs.map((pkg, i) => [pkg.packageJson.name, treeShaList[i]]), + ); + return (pkgName: string) => { + const sha = map.get(pkgName); + if (!sha) { + throw new Error(`Tree sha not found for ${pkgName}`); + } + return sha; + }; } export function createFlagFinder(args: string[]) { @@ -295,7 +304,7 @@ export async function command(opts: OptionValues, cmd: Command): Promise { const lockfile = await Lockfile.load( paths.resolveTargetRoot('yarn.lock'), ); - const packageTreeHashes = await getPackageTreeHashes(graph); + const getPackageTreeHash = await readPackageTreeHashes(graph); // Base hash shared by all projects const baseHash = crypto.createHash('sha1'); @@ -317,12 +326,16 @@ export async function command(opts: OptionValues, cmd: Command): Promise { const hash = crypto.createHash('sha1'); - const packageTreeSha = packageTreeHashes.get(packageName); - if (!packageTreeSha) { - throw new Error(`Tree sha not found for ${packageName}`); + hash.update(baseSha); // Global base hash + + const packageTreeSha = getPackageTreeHash(packageName); + hash.update(packageTreeSha); // Hash for target package contents + + for (const [depName, depPkg] of pkg.allLocalDependencies) { + const depHash = getPackageTreeHash(depPkg.name); + hash.update(`${depName}:${depHash}`); // Hash for each local monorepo dependency contents } - hash.update(baseSha); - hash.update(packageTreeSha); + // The project ID is a hash of the transform configuration, which helps // us bust the cache when any changes are made to the transform implementation. hash.update(JSON.stringify(project)); From 56a811849a60cfc0688edee8242071880b486d10 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 8 Oct 2024 11:42:14 +0000 Subject: [PATCH 138/291] Version Packages (next) --- .changeset/create-app-1728387650.md | 5 + .changeset/pre.json | 51 + docs/releases/v1.32.0-next.2-changelog.md | 2207 +++++++++++++++++ package.json | 2 +- packages/app-defaults/CHANGELOG.md | 11 + packages/app-defaults/package.json | 2 +- packages/app-next-example-plugin/CHANGELOG.md | 8 + packages/app-next-example-plugin/package.json | 2 +- packages/app-next/CHANGELOG.md | 45 + packages/app-next/package.json | 2 +- packages/app/CHANGELOG.md | 41 + packages/app/package.json | 2 +- packages/backend-app-api/CHANGELOG.md | 16 + packages/backend-app-api/package.json | 2 +- packages/backend-defaults/CHANGELOG.md | 25 + packages/backend-defaults/package.json | 2 +- .../CHANGELOG.md | 25 + .../package.json | 2 +- packages/backend-legacy/CHANGELOG.md | 41 + packages/backend-legacy/package.json | 2 +- packages/backend-openapi-utils/CHANGELOG.md | 13 + packages/backend-openapi-utils/package.json | 2 +- packages/backend-plugin-api/CHANGELOG.md | 12 + packages/backend-plugin-api/package.json | 2 +- packages/backend-test-utils/CHANGELOG.md | 15 + packages/backend-test-utils/package.json | 2 +- packages/backend/CHANGELOG.md | 37 + packages/backend/package.json | 2 +- packages/catalog-client/CHANGELOG.md | 9 + packages/catalog-client/package.json | 2 +- packages/cli-node/CHANGELOG.md | 10 + packages/cli-node/package.json | 2 +- packages/cli/CHANGELOG.md | 25 + packages/cli/package.json | 2 +- packages/core-compat-api/CHANGELOG.md | 9 + packages/core-compat-api/package.json | 2 +- packages/core-components/CHANGELOG.md | 11 + packages/core-components/package.json | 2 +- packages/create-app/CHANGELOG.md | 8 + packages/create-app/package.json | 2 +- packages/dev-utils/CHANGELOG.md | 14 + packages/dev-utils/package.json | 2 +- packages/e2e-test/CHANGELOG.md | 9 + packages/e2e-test/package.json | 2 +- packages/eslint-plugin/CHANGELOG.md | 6 + packages/eslint-plugin/package.json | 2 +- packages/frontend-app-api/CHANGELOG.md | 14 + packages/frontend-app-api/package.json | 2 +- packages/frontend-defaults/CHANGELOG.md | 11 + packages/frontend-defaults/package.json | 2 +- packages/frontend-internal/CHANGELOG.md | 9 + packages/frontend-internal/package.json | 2 +- packages/frontend-plugin-api/CHANGELOG.md | 10 + packages/frontend-plugin-api/package.json | 2 +- packages/frontend-test-utils/CHANGELOG.md | 14 + packages/frontend-test-utils/package.json | 2 +- packages/integration-react/CHANGELOG.md | 9 + packages/integration-react/package.json | 2 +- packages/integration/CHANGELOG.md | 9 + packages/integration/package.json | 2 +- packages/repo-tools/CHANGELOG.md | 12 + packages/repo-tools/package.json | 2 +- packages/scaffolder-internal/CHANGELOG.md | 7 + packages/scaffolder-internal/package.json | 2 +- .../techdocs-cli-embedded-app/CHANGELOG.md | 19 + .../techdocs-cli-embedded-app/package.json | 2 +- packages/techdocs-cli/CHANGELOG.md | 11 + packages/techdocs-cli/package.json | 2 +- packages/test-utils/CHANGELOG.md | 13 + packages/test-utils/package.json | 2 +- packages/theme/CHANGELOG.md | 6 + packages/theme/package.json | 2 +- plugins/api-docs/CHANGELOG.md | 15 + plugins/api-docs/package.json | 2 +- plugins/app-backend/CHANGELOG.md | 14 + plugins/app-backend/package.json | 2 +- plugins/app-node/CHANGELOG.md | 8 + plugins/app-node/package.json | 2 +- plugins/app-visualizer/CHANGELOG.md | 9 + plugins/app-visualizer/package.json | 2 +- plugins/app/CHANGELOG.md | 11 + plugins/app/package.json | 2 +- .../CHANGELOG.md | 9 + .../package.json | 2 +- .../CHANGELOG.md | 8 + .../package.json | 2 +- .../CHANGELOG.md | 11 + .../package.json | 2 +- .../CHANGELOG.md | 10 + .../package.json | 2 +- .../CHANGELOG.md | 9 + .../package.json | 2 +- .../CHANGELOG.md | 8 + .../package.json | 2 +- .../CHANGELOG.md | 11 + .../package.json | 2 +- .../CHANGELOG.md | 11 + .../package.json | 2 +- .../CHANGELOG.md | 9 + .../package.json | 2 +- .../CHANGELOG.md | 9 + .../package.json | 2 +- .../CHANGELOG.md | 9 + .../package.json | 2 +- .../CHANGELOG.md | 10 + .../package.json | 2 +- .../CHANGELOG.md | 10 + .../package.json | 2 +- .../CHANGELOG.md | 9 + .../package.json | 2 +- .../CHANGELOG.md | 9 + .../package.json | 2 +- .../CHANGELOG.md | 10 + .../package.json | 2 +- .../CHANGELOG.md | 9 + .../package.json | 2 +- .../CHANGELOG.md | 9 + .../package.json | 2 +- .../CHANGELOG.md | 9 + .../package.json | 2 +- .../CHANGELOG.md | 14 + .../package.json | 2 +- plugins/auth-backend/CHANGELOG.md | 31 + plugins/auth-backend/package.json | 2 +- plugins/auth-node/CHANGELOG.md | 13 + plugins/auth-node/package.json | 2 +- plugins/auth-react/CHANGELOG.md | 9 + plugins/auth-react/package.json | 2 +- plugins/bitbucket-cloud-common/CHANGELOG.md | 7 + plugins/bitbucket-cloud-common/package.json | 2 +- .../catalog-backend-module-aws/CHANGELOG.md | 16 + .../catalog-backend-module-aws/package.json | 2 +- .../catalog-backend-module-azure/CHANGELOG.md | 11 + .../catalog-backend-module-azure/package.json | 2 +- .../CHANGELOG.md | 12 + .../package.json | 2 +- .../CHANGELOG.md | 43 + .../package.json | 2 +- .../CHANGELOG.md | 12 + .../package.json | 2 +- .../catalog-backend-module-gcp/CHANGELOG.md | 11 + .../catalog-backend-module-gcp/package.json | 2 +- .../CHANGELOG.md | 11 + .../package.json | 2 +- .../CHANGELOG.md | 11 + .../package.json | 2 +- .../CHANGELOG.md | 16 + .../package.json | 2 +- .../CHANGELOG.md | 10 + .../package.json | 2 +- .../CHANGELOG.md | 14 + .../package.json | 2 +- .../CHANGELOG.md | 14 + .../package.json | 2 +- .../catalog-backend-module-ldap/CHANGELOG.md | 13 + .../catalog-backend-module-ldap/package.json | 2 +- .../catalog-backend-module-logs/CHANGELOG.md | 9 + .../catalog-backend-module-logs/package.json | 2 +- .../CHANGELOG.md | 11 + .../package.json | 2 +- .../CHANGELOG.md | 14 + .../package.json | 2 +- .../CHANGELOG.md | 12 + .../package.json | 2 +- .../CHANGELOG.md | 11 + .../package.json | 2 +- .../CHANGELOG.md | 13 + .../package.json | 2 +- plugins/catalog-backend/CHANGELOG.md | 22 + plugins/catalog-backend/package.json | 2 +- plugins/catalog-graph/CHANGELOG.md | 21 + plugins/catalog-graph/package.json | 2 +- plugins/catalog-import/CHANGELOG.md | 19 + plugins/catalog-import/package.json | 2 +- plugins/catalog-node/CHANGELOG.md | 15 + plugins/catalog-node/package.json | 2 +- plugins/catalog-react/CHANGELOG.md | 26 + plugins/catalog-react/package.json | 2 +- .../catalog-unprocessed-entities/CHANGELOG.md | 10 + .../catalog-unprocessed-entities/package.json | 2 +- plugins/catalog/CHANGELOG.md | 25 + plugins/catalog/package.json | 2 +- plugins/config-schema/CHANGELOG.md | 10 + plugins/config-schema/package.json | 2 +- plugins/devtools-backend/CHANGELOG.md | 15 + plugins/devtools-backend/package.json | 2 +- plugins/devtools/CHANGELOG.md | 13 + plugins/devtools/package.json | 2 +- .../CHANGELOG.md | 10 + .../package.json | 2 +- .../events-backend-module-azure/CHANGELOG.md | 8 + .../events-backend-module-azure/package.json | 2 +- .../CHANGELOG.md | 8 + .../package.json | 2 +- .../events-backend-module-gerrit/CHANGELOG.md | 8 + .../events-backend-module-gerrit/package.json | 2 +- .../events-backend-module-github/CHANGELOG.md | 9 + .../events-backend-module-github/package.json | 2 +- .../events-backend-module-gitlab/CHANGELOG.md | 9 + .../events-backend-module-gitlab/package.json | 2 +- .../events-backend-test-utils/CHANGELOG.md | 7 + .../events-backend-test-utils/package.json | 2 +- plugins/events-backend/CHANGELOG.md | 12 + plugins/events-backend/package.json | 2 +- plugins/events-node/CHANGELOG.md | 9 + plugins/events-node/package.json | 2 +- .../example-todo-list-backend/CHANGELOG.md | 9 + .../example-todo-list-backend/package.json | 2 +- plugins/example-todo-list/CHANGELOG.md | 8 + plugins/example-todo-list/package.json | 2 +- plugins/home-react/CHANGELOG.md | 8 + plugins/home-react/package.json | 2 +- plugins/home/CHANGELOG.md | 21 + plugins/home/package.json | 2 +- plugins/kubernetes-backend/CHANGELOG.md | 19 + plugins/kubernetes-backend/package.json | 2 +- plugins/kubernetes-cluster/CHANGELOG.md | 13 + plugins/kubernetes-cluster/package.json | 2 +- plugins/kubernetes-node/CHANGELOG.md | 10 + plugins/kubernetes-node/package.json | 2 +- plugins/kubernetes-react/CHANGELOG.md | 13 + plugins/kubernetes-react/package.json | 2 +- plugins/kubernetes/CHANGELOG.md | 15 + plugins/kubernetes/package.json | 2 +- .../CHANGELOG.md | 15 + .../package.json | 2 +- plugins/notifications-backend/CHANGELOG.md | 17 + plugins/notifications-backend/package.json | 2 +- plugins/notifications-node/CHANGELOG.md | 11 + plugins/notifications-node/package.json | 2 +- plugins/notifications/CHANGELOG.md | 13 + plugins/notifications/package.json | 2 +- plugins/org-react/CHANGELOG.md | 11 + plugins/org-react/package.json | 2 +- plugins/org/CHANGELOG.md | 13 + plugins/org/package.json | 2 +- .../CHANGELOG.md | 10 + .../package.json | 2 +- plugins/permission-backend/CHANGELOG.md | 12 + plugins/permission-backend/package.json | 2 +- plugins/permission-node/CHANGELOG.md | 11 + plugins/permission-node/package.json | 2 +- plugins/proxy-backend/CHANGELOG.md | 9 + plugins/proxy-backend/package.json | 2 +- .../CHANGELOG.md | 12 + .../package.json | 2 +- .../CHANGELOG.md | 12 + .../package.json | 2 +- .../CHANGELOG.md | 12 + .../package.json | 2 +- .../CHANGELOG.md | 13 + .../package.json | 2 +- .../CHANGELOG.md | 12 + .../package.json | 2 +- .../CHANGELOG.md | 13 + .../package.json | 2 +- .../CHANGELOG.md | 11 + .../package.json | 2 +- .../CHANGELOG.md | 11 + .../package.json | 2 +- .../CHANGELOG.md | 11 + .../package.json | 2 +- .../CHANGELOG.md | 30 + .../package.json | 2 +- .../CHANGELOG.md | 15 + .../package.json | 2 +- .../CHANGELOG.md | 10 + .../package.json | 2 +- .../CHANGELOG.md | 12 + .../package.json | 2 +- .../CHANGELOG.md | 10 + .../package.json | 2 +- .../CHANGELOG.md | 10 + .../package.json | 2 +- plugins/scaffolder-backend/CHANGELOG.md | 31 + plugins/scaffolder-backend/package.json | 2 +- .../scaffolder-node-test-utils/CHANGELOG.md | 9 + .../scaffolder-node-test-utils/package.json | 2 +- plugins/scaffolder-node/CHANGELOG.md | 13 + plugins/scaffolder-node/package.json | 2 +- plugins/scaffolder-react/CHANGELOG.md | 17 + plugins/scaffolder-react/package.json | 2 +- plugins/scaffolder/CHANGELOG.md | 29 + plugins/scaffolder/package.json | 2 +- .../CHANGELOG.md | 16 + .../package.json | 2 +- .../CHANGELOG.md | 11 + .../package.json | 2 +- .../CHANGELOG.md | 11 + .../package.json | 2 +- plugins/search-backend-module-pg/CHANGELOG.md | 10 + plugins/search-backend-module-pg/package.json | 2 +- .../CHANGELOG.md | 10 + .../package.json | 2 +- .../CHANGELOG.md | 16 + .../package.json | 2 +- plugins/search-backend-node/CHANGELOG.md | 12 + plugins/search-backend-node/package.json | 2 +- plugins/search-backend/CHANGELOG.md | 17 + plugins/search-backend/package.json | 2 +- plugins/search-react/CHANGELOG.md | 13 + plugins/search-react/package.json | 2 +- plugins/search/CHANGELOG.md | 17 + plugins/search/package.json | 2 +- plugins/signals-backend/CHANGELOG.md | 12 + plugins/signals-backend/package.json | 2 +- plugins/signals-node/CHANGELOG.md | 11 + plugins/signals-node/package.json | 2 +- plugins/signals/CHANGELOG.md | 11 + plugins/signals/package.json | 2 +- .../techdocs-addons-test-utils/CHANGELOG.md | 15 + .../techdocs-addons-test-utils/package.json | 2 +- plugins/techdocs-backend/CHANGELOG.md | 19 + plugins/techdocs-backend/package.json | 2 +- .../CHANGELOG.md | 13 + .../package.json | 2 +- plugins/techdocs-node/CHANGELOG.md | 17 + plugins/techdocs-node/package.json | 2 +- plugins/techdocs-react/CHANGELOG.md | 11 + plugins/techdocs-react/package.json | 2 +- plugins/techdocs/CHANGELOG.md | 29 + plugins/techdocs/package.json | 2 +- plugins/user-settings-backend/CHANGELOG.md | 14 + plugins/user-settings-backend/package.json | 2 +- plugins/user-settings/CHANGELOG.md | 19 + plugins/user-settings/package.json | 2 +- yarn.lock | 14 +- 327 files changed, 4637 insertions(+), 163 deletions(-) create mode 100644 .changeset/create-app-1728387650.md create mode 100644 docs/releases/v1.32.0-next.2-changelog.md diff --git a/.changeset/create-app-1728387650.md b/.changeset/create-app-1728387650.md new file mode 100644 index 0000000000..b50d431d4b --- /dev/null +++ b/.changeset/create-app-1728387650.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Bumped create-app version. diff --git a/.changeset/pre.json b/.changeset/pre.json index 1d47683d1b..e1c8fb99d1 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -195,24 +195,37 @@ "@internal/scaffolder": "0.0.1" }, "changesets": [ + "angry-cycles-call", "angry-mayflies-collect", "angry-windows-decide", "big-rules-nail", "breezy-bulldogs-smell", "brown-frogs-walk", + "calm-owls-move", "chair-fairs-drive", "chilled-dolphins-join", + "chilled-melons-smash", "clever-paws-stare", "cold-nails-rescue", + "crash-loop-baby", + "crash-loop-honey", + "crash-loop-yeah", "create-app-1727774359", + "create-app-1728387650", + "cuddly-stingrays-smell", "curly-foxes-brake", + "curly-tomatoes-reply", "cyan-cooks-sing", "cyan-peaches-lay", "cyan-vans-study", + "dependabot-a3fd85a", "dry-frogs-drum", "early-drinks-kneel", "early-sloths-cross", + "eight-clocks-complain", "eight-steaks-chew", + "eighty-mice-turn", + "eleven-beds-play", "eleven-pugs-hear", "fair-chairs-drive", "famous-bobcats-remain", @@ -220,15 +233,23 @@ "five-gorillas-pay", "five-turkeys-taste", "flat-eels-exist", + "flat-seals-type", + "fluffy-dolphins-battle", "fluffy-pears-cry", "four-moons-watch", + "friendly-coins-approve", "friendly-cougars-return", "funny-rocks-train", "fuzzy-elephants-tease", "giant-kiwis-retire", "gold-pots-end", + "great-eagles-repair", "green-bottles-live", + "green-cooks-sort", "happy-ligers-think", + "healthy-shoes-judge", + "healthy-years-search", + "heavy-ties-tell", "honest-impalas-rescue", "hungry-buckets-repair", "large-hats-reply", @@ -236,27 +257,57 @@ "light-rats-travel", "long-humans-hunt", "loud-hotels-tan", + "lovely-bees-walk", "nasty-lamps-greet", + "neat-geckos-end", "nice-badgers-travel", + "olive-walls-wave", "polite-days-flash", + "poor-dodos-wait", + "pretty-buses-repair", "pretty-plants-hammer", + "purple-toys-heal", "quiet-dingos-bathe", + "quiet-needles-impress", + "rare-crabs-cheat", + "rare-rabbits-flow", "renovate-156753b", + "renovate-7874fad", + "renovate-85a184e", + "renovate-87a3bd2", + "renovate-9f7136b", + "renovate-f88b005", "rich-deers-attend", "rich-needles-collect", + "rotten-camels-deny", "rude-apricots-eat", + "sharp-lamps-fix", "shy-olives-swim", + "shy-plants-retire", + "silly-geckos-learn", + "silly-readers-build", + "slimy-ravens-end", "slow-gorillas-thank", "slow-trees-compare", + "small-donkeys-attack", + "smart-jobs-sit", "sour-grapes-trade", "sour-phones-fix", + "stale-ravens-clap", "stale-roses-serve", "strange-bees-attack", "strong-monkeys-melt", "sweet-chicken-smash", + "ten-apes-turn", + "ten-rings-look", + "thick-tables-give", "thirty-pets-fry", + "thirty-pianos-mix", "tiny-pugs-kick", + "tough-fireants-itch", "tough-pillows-sip", + "tricky-shoes-lie", + "twenty-cups-knock", "two-plums-fail", "weak-bottles-cross" ] diff --git a/docs/releases/v1.32.0-next.2-changelog.md b/docs/releases/v1.32.0-next.2-changelog.md new file mode 100644 index 0000000000..6d6ce1e4fd --- /dev/null +++ b/docs/releases/v1.32.0-next.2-changelog.md @@ -0,0 +1,2207 @@ +# Release v1.32.0-next.2 + +Upgrade Helper: [https://backstage.github.io/upgrade-helper/?to=1.32.0-next.2](https://backstage.github.io/upgrade-helper/?to=1.32.0-next.2) + +## @backstage/backend-openapi-utils@0.2.0-next.1 + +### Minor Changes + +- 66af016: Improved support for OpenAPI validation during Jest tests. Now, OpenAPI validation can happen as you are writing your Jest tests - you no longer have to run `repo schema openapi test`. + +### Patch Changes + +- Updated dependencies + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/errors@1.2.4 + - @backstage/types@1.1.1 + +## @backstage/theme@0.6.0-next.1 + +### Minor Changes + +- e77ff3d: Adds support for custom background colors in code blocks and inline code within TechDocs. + +## @backstage/plugin-auth-backend-module-vmware-cloud-provider@0.4.0-next.1 + +### Minor Changes + +- d0edfec: **BREAKING**: The `profileEmailMatchingUserEntityEmail` sign-in resolver has been removed as it was using an insecure fallback for resolving user identities. See for how to create a custom sign-in resolver if needed as a replacement. + +### Patch Changes + +- 217458a: Updated configuration schema to include the new `allowedDomains` option for the `emailLocalPartMatchingUserEntityName` sign-in resolver. +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + +## @backstage/plugin-catalog@1.24.0-next.2 + +### Minor Changes + +- cec8e8c: Adding negation keyword for entity filtering + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-react@1.14.0-next.2 + - @backstage/catalog-client@1.7.1-next.0 + - @backstage/catalog-model@1.7.0 + - @backstage/core-compat-api@0.3.1-next.2 + - @backstage/core-components@0.15.1-next.2 + - @backstage/core-plugin-api@1.10.0-next.1 + - @backstage/errors@1.2.4 + - @backstage/frontend-plugin-api@0.9.0-next.2 + - @backstage/integration-react@1.2.0-next.2 + - @backstage/types@1.1.1 + - @backstage/plugin-catalog-common@1.1.0 + - @backstage/plugin-permission-react@0.4.27-next.1 + - @backstage/plugin-scaffolder-common@1.5.6 + - @backstage/plugin-search-common@1.2.14 + - @backstage/plugin-search-react@1.8.1-next.2 + +## @backstage/plugin-catalog-backend-module-bitbucket-cloud@0.4.0-next.2 + +### Minor Changes + +- 6343c8d: Fixes the event-based updates at `BitbucketCloudEntityProvider`. + + Previously, this entity provider had optional event support for legacy backends + that could be enabled by passing `catalogApi`, `events`, and `tokenManager`. + + For the new/current backend system, the `catalogModuleBitbucketCloudEntityProvider` + (`catalog.bitbucket-cloud-entity-provider`), event support was enabled by default. + + A recent change removed `tokenManager` as a dependency from the module as well as removed it as input. + While this didn't break the instantiation of the module, it broke the event-based updates, + and led to a runtime misbehavior, accompanied by an info log message. + + This change will replace the use of `tokenManager` with the use of `auth` (`AuthService`). + + Additionally, to simplify, it will make `catalogApi` and `events` required dependencies. + For the current backend system, this change is transparent and doesn't require any action. + For the legacy backend system, this change will require you to pass those dependencies + if you didn't do it already. + + BREAKING CHANGES: + + _(For legacy backend users only.)_ + + Previously optional `catalogApi`, and `events` are required now. + A new required dependency `auth` was added. + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/integration@1.15.1-next.1 + - @backstage/catalog-client@1.7.1-next.0 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/config@1.2.0 + - @backstage/plugin-bitbucket-cloud-common@0.2.24-next.1 + - @backstage/plugin-catalog-common@1.1.0 + - @backstage/plugin-events-node@0.4.1-next.1 + +## @backstage/plugin-catalog-react@1.14.0-next.2 + +### Minor Changes + +- 0801db6: Add catalog service mocks under the `/testUtils` subpath export. + + You can now use e.g. `const catalog = catalogApiMock.mock()` in your test and then do assertions on `catalog.getEntities` without awkward type casting. + +### Patch Changes + +- Updated dependencies + - @backstage/frontend-test-utils@0.2.1-next.2 + - @backstage/catalog-client@1.7.1-next.0 + - @backstage/catalog-model@1.7.0 + - @backstage/core-compat-api@0.3.1-next.2 + - @backstage/core-components@0.15.1-next.2 + - @backstage/core-plugin-api@1.10.0-next.1 + - @backstage/errors@1.2.4 + - @backstage/frontend-plugin-api@0.9.0-next.2 + - @backstage/integration-react@1.2.0-next.2 + - @backstage/types@1.1.1 + - @backstage/version-bridge@1.0.10-next.0 + - @backstage/plugin-catalog-common@1.1.0 + - @backstage/plugin-permission-common@0.8.1 + - @backstage/plugin-permission-react@0.4.27-next.1 + +## @backstage/plugin-home@0.8.0-next.2 + +### Minor Changes + +- 9893bb5: **BREAKING** Implement usage of unused `limit` query parameter in visits API `.list()` function + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-react@1.14.0-next.2 + - @backstage/theme@0.6.0-next.1 + - @backstage/catalog-client@1.7.1-next.0 + - @backstage/catalog-model@1.7.0 + - @backstage/config@1.2.0 + - @backstage/core-app-api@1.15.1-next.1 + - @backstage/core-compat-api@0.3.1-next.2 + - @backstage/core-components@0.15.1-next.2 + - @backstage/core-plugin-api@1.10.0-next.1 + - @backstage/frontend-plugin-api@0.9.0-next.2 + - @backstage/plugin-home-react@0.1.18-next.2 + +## @backstage/plugin-scaffolder-backend-module-gitlab@0.6.0-next.2 + +### Minor Changes + +- 73f2ccf: declare correct type (number) for publish:gitlab output.projectId + +### Patch Changes + +- Updated dependencies + - @backstage/integration@1.15.1-next.1 + - @backstage/plugin-scaffolder-node@0.5.0-next.2 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/config@1.2.0 + - @backstage/errors@1.2.4 + +## @backstage/plugin-techdocs@1.11.0-next.2 + +### Minor Changes + +- e77ff3d: Adds support for custom background colors in code blocks and inline code within TechDocs. + +### Patch Changes + +- e918061: Add support for mkdocs material palette conditional hashes. +- 720a2f9: Updated dependency `git-url-parse` to `^15.0.0`. +- e8b4966: Use more of the available space for the navigation sidebar. +- Updated dependencies + - @backstage/plugin-catalog-react@1.14.0-next.2 + - @backstage/integration@1.15.1-next.1 + - @backstage/theme@0.6.0-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/config@1.2.0 + - @backstage/core-compat-api@0.3.1-next.2 + - @backstage/core-components@0.15.1-next.2 + - @backstage/core-plugin-api@1.10.0-next.1 + - @backstage/errors@1.2.4 + - @backstage/frontend-plugin-api@0.9.0-next.2 + - @backstage/integration-react@1.2.0-next.2 + - @backstage/plugin-auth-react@0.1.7-next.2 + - @backstage/plugin-search-common@1.2.14 + - @backstage/plugin-search-react@1.8.1-next.2 + - @backstage/plugin-techdocs-common@0.1.0 + - @backstage/plugin-techdocs-react@1.2.9-next.2 + +## @backstage/app-defaults@1.5.12-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/theme@0.6.0-next.1 + - @backstage/core-app-api@1.15.1-next.1 + - @backstage/core-components@0.15.1-next.2 + - @backstage/core-plugin-api@1.10.0-next.1 + - @backstage/plugin-permission-react@0.4.27-next.1 + +## @backstage/backend-app-api@1.0.1-next.1 + +### Patch Changes + +- ffd1f4a: Plugin lifecycle shutdown hooks are now performed before root lifecycle shutdown hooks. +- fd6e6f4: build(deps): bump `cookie` from 0.6.0 to 0.7.0 +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/cli-common@0.1.14 + - @backstage/config@1.2.0 + - @backstage/config-loader@1.9.1 + - @backstage/errors@1.2.4 + - @backstage/types@1.1.1 + - @backstage/plugin-permission-node@0.8.4-next.1 + +## @backstage/backend-defaults@0.5.1-next.2 + +### Patch Changes + +- ffd1f4a: Plugin lifecycle shutdown hooks are now performed before root lifecycle shutdown hooks. +- ffd1f4a: The database manager now attempts to close any database connections in a root lifecycle shutdown hook. +- e36d12f: The task scheduler now attempts to abort any tasks if it detects that Backstage is being shut down. +- fd6e6f4: build(deps): bump `cookie` from 0.6.0 to 0.7.0 +- 720a2f9: Updated dependency `git-url-parse` to `^15.0.0`. +- Updated dependencies + - @backstage/cli-node@0.2.9-next.0 + - @backstage/backend-app-api@1.0.1-next.1 + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/integration@1.15.1-next.1 + - @backstage/backend-dev-utils@0.1.5 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/cli-common@0.1.14 + - @backstage/config@1.2.0 + - @backstage/config-loader@1.9.1 + - @backstage/errors@1.2.4 + - @backstage/integration-aws-node@0.1.12 + - @backstage/types@1.1.1 + - @backstage/plugin-events-node@0.4.1-next.1 + - @backstage/plugin-permission-node@0.8.4-next.1 + +## @backstage/backend-dynamic-feature-service@0.4.2-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/cli-node@0.2.9-next.0 + - @backstage/backend-app-api@1.0.1-next.1 + - @backstage/backend-defaults@0.5.1-next.2 + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/plugin-catalog-backend@1.26.2-next.2 + - @backstage/plugin-scaffolder-node@0.5.0-next.2 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/cli-common@0.1.14 + - @backstage/config@1.2.0 + - @backstage/config-loader@1.9.1 + - @backstage/errors@1.2.4 + - @backstage/types@1.1.1 + - @backstage/plugin-app-node@0.1.26-next.1 + - @backstage/plugin-events-backend@0.3.13-next.1 + - @backstage/plugin-events-node@0.4.1-next.1 + - @backstage/plugin-permission-common@0.8.1 + - @backstage/plugin-permission-node@0.8.4-next.1 + - @backstage/plugin-search-backend-node@1.3.3-next.2 + - @backstage/plugin-search-common@1.2.14 + +## @backstage/backend-plugin-api@1.0.1-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/cli-common@0.1.14 + - @backstage/config@1.2.0 + - @backstage/errors@1.2.4 + - @backstage/types@1.1.1 + - @backstage/plugin-permission-common@0.8.1 + +## @backstage/backend-test-utils@1.0.1-next.2 + +### Patch Changes + +- fd6e6f4: build(deps): bump `cookie` from 0.6.0 to 0.7.0 +- Updated dependencies + - @backstage/backend-app-api@1.0.1-next.1 + - @backstage/backend-defaults@0.5.1-next.2 + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/config@1.2.0 + - @backstage/errors@1.2.4 + - @backstage/types@1.1.1 + - @backstage/plugin-events-node@0.4.1-next.1 + +## @backstage/catalog-client@1.7.1-next.0 + +### Patch Changes + +- 0040632: Add missing doc string to API +- Updated dependencies + - @backstage/catalog-model@1.7.0 + - @backstage/errors@1.2.4 + +## @backstage/cli@0.28.0-next.2 + +### Patch Changes + +- ea16633: Preserve directory structure for CommonJS build output, just like ESM. This makes the build output more stable and easier to browse, and allows for more effective tree shaking and lazy imports. + +- 7955f9b: Tweaked the new package feature detection to not be active when building backend packages. + +- 720a2f9: Updated dependency `git-url-parse` to `^15.0.0`. + +- 2c5ecf5: Support `--max-warnings` flag for package linting + +- 8fe740d: Added a new `--successCache` option to the `backstage-cli repo test` and `backstage-cli repo lint` commands. The cache keeps track of successful runs and avoids re-running for individual packages if they haven't changed. This option is intended only to be used in CI. + + In addition a `--successCacheDir ` option has also been added to be able to override the default cache directory. + +- f0514c7: Disabled parsing of input source maps in the SWC transform for Jest. + +- Updated dependencies + - @backstage/cli-node@0.2.9-next.0 + - @backstage/eslint-plugin@0.1.10-next.1 + - @backstage/integration@1.15.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/cli-common@0.1.14 + - @backstage/config@1.2.0 + - @backstage/config-loader@1.9.1 + - @backstage/errors@1.2.4 + - @backstage/release-manifests@0.0.11 + - @backstage/types@1.1.1 + +## @backstage/cli-node@0.2.9-next.0 + +### Patch Changes + +- fec7278: Added new `lockfile.getDependencyTreeHash(name)` utility. +- Updated dependencies + - @backstage/cli-common@0.1.14 + - @backstage/errors@1.2.4 + - @backstage/types@1.1.1 + +## @backstage/core-compat-api@0.3.1-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/core-plugin-api@1.10.0-next.1 + - @backstage/frontend-plugin-api@0.9.0-next.2 + - @backstage/version-bridge@1.0.10-next.0 + +## @backstage/core-components@0.15.1-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/theme@0.6.0-next.1 + - @backstage/config@1.2.0 + - @backstage/core-plugin-api@1.10.0-next.1 + - @backstage/errors@1.2.4 + - @backstage/version-bridge@1.0.10-next.0 + +## @backstage/create-app@0.5.21-next.2 + +### Patch Changes + +- Bumped create-app version. +- Updated dependencies + - @backstage/cli-common@0.1.14 + +## @backstage/dev-utils@1.1.2-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-react@1.14.0-next.2 + - @backstage/theme@0.6.0-next.1 + - @backstage/app-defaults@1.5.12-next.2 + - @backstage/catalog-model@1.7.0 + - @backstage/core-app-api@1.15.1-next.1 + - @backstage/core-components@0.15.1-next.2 + - @backstage/core-plugin-api@1.10.0-next.1 + - @backstage/integration-react@1.2.0-next.2 + +## @backstage/eslint-plugin@0.1.10-next.1 + +### Patch Changes + +- b1c2a2d: Exclude `@material-ui/data-grid` + +## @backstage/frontend-app-api@0.10.0-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/config@1.2.0 + - @backstage/core-app-api@1.15.1-next.1 + - @backstage/core-plugin-api@1.10.0-next.1 + - @backstage/errors@1.2.4 + - @backstage/frontend-defaults@0.1.1-next.2 + - @backstage/frontend-plugin-api@0.9.0-next.2 + - @backstage/types@1.1.1 + - @backstage/version-bridge@1.0.10-next.0 + +## @backstage/frontend-defaults@0.1.1-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/config@1.2.0 + - @backstage/errors@1.2.4 + - @backstage/frontend-app-api@0.10.0-next.2 + - @backstage/frontend-plugin-api@0.9.0-next.2 + - @backstage/plugin-app@0.1.1-next.2 + +## @backstage/frontend-plugin-api@0.9.0-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.15.1-next.2 + - @backstage/core-plugin-api@1.10.0-next.1 + - @backstage/types@1.1.1 + - @backstage/version-bridge@1.0.10-next.0 + +## @backstage/frontend-test-utils@0.2.1-next.2 + +### Patch Changes + +- 0801db6: Added an `ApiMock`, analogous to `ServiceMock` from the backend test utils. +- Updated dependencies + - @backstage/config@1.2.0 + - @backstage/frontend-app-api@0.10.0-next.2 + - @backstage/frontend-plugin-api@0.9.0-next.2 + - @backstage/test-utils@1.6.1-next.2 + - @backstage/types@1.1.1 + - @backstage/version-bridge@1.0.10-next.0 + - @backstage/plugin-app@0.1.1-next.2 + +## @backstage/integration@1.15.1-next.1 + +### Patch Changes + +- 720a2f9: Updated dependency `git-url-parse` to `^15.0.0`. +- Updated dependencies + - @backstage/config@1.2.0 + - @backstage/errors@1.2.4 + +## @backstage/integration-react@1.2.0-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/integration@1.15.1-next.1 + - @backstage/config@1.2.0 + - @backstage/core-plugin-api@1.10.0-next.1 + +## @backstage/repo-tools@0.10.0-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/cli-node@0.2.9-next.0 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/cli-common@0.1.14 + - @backstage/config-loader@1.9.1 + - @backstage/errors@1.2.4 + +## @techdocs/cli@1.8.20-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/backend-defaults@0.5.1-next.2 + - @backstage/plugin-techdocs-node@1.12.12-next.2 + - @backstage/catalog-model@1.7.0 + - @backstage/cli-common@0.1.14 + - @backstage/config@1.2.0 + +## @backstage/test-utils@1.6.1-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/theme@0.6.0-next.1 + - @backstage/config@1.2.0 + - @backstage/core-app-api@1.15.1-next.1 + - @backstage/core-plugin-api@1.10.0-next.1 + - @backstage/types@1.1.1 + - @backstage/plugin-permission-common@0.8.1 + - @backstage/plugin-permission-react@0.4.27-next.1 + +## @backstage/plugin-api-docs@0.11.11-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog@1.24.0-next.2 + - @backstage/plugin-catalog-react@1.14.0-next.2 + - @backstage/catalog-model@1.7.0 + - @backstage/core-compat-api@0.3.1-next.2 + - @backstage/core-components@0.15.1-next.2 + - @backstage/core-plugin-api@1.10.0-next.1 + - @backstage/frontend-plugin-api@0.9.0-next.2 + - @backstage/plugin-catalog-common@1.1.0 + - @backstage/plugin-permission-react@0.4.27-next.1 + +## @backstage/plugin-app@0.1.1-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/theme@0.6.0-next.1 + - @backstage/core-components@0.15.1-next.2 + - @backstage/core-plugin-api@1.10.0-next.1 + - @backstage/frontend-plugin-api@0.9.0-next.2 + - @backstage/plugin-permission-react@0.4.27-next.1 + +## @backstage/plugin-app-backend@0.3.76-next.1 + +### Patch Changes + +- 2c4ee26: Fixed unexpected behaviour where configuration supplied with `APP_CONFIG_*` environment variables where not filtered by the configuration schema. +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/config@1.2.0 + - @backstage/config-loader@1.9.1 + - @backstage/errors@1.2.4 + - @backstage/types@1.1.1 + - @backstage/plugin-app-node@0.1.26-next.1 + +## @backstage/plugin-app-node@0.1.26-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/config-loader@1.9.1 + +## @backstage/plugin-app-visualizer@0.1.11-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.15.1-next.2 + - @backstage/core-plugin-api@1.10.0-next.1 + - @backstage/frontend-plugin-api@0.9.0-next.2 + +## @backstage/plugin-auth-backend@0.23.1-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-backend-module-cloudflare-access-provider@0.3.1-next.1 + - @backstage/plugin-auth-backend-module-atlassian-provider@0.3.1-next.1 + - @backstage/plugin-auth-backend-module-bitbucket-provider@0.2.1-next.1 + - @backstage/plugin-auth-backend-module-microsoft-provider@0.2.1-next.1 + - @backstage/plugin-auth-backend-module-onelogin-provider@0.2.1-next.1 + - @backstage/plugin-auth-backend-module-aws-alb-provider@0.2.1-next.1 + - @backstage/plugin-auth-backend-module-gcp-iap-provider@0.3.1-next.1 + - @backstage/plugin-auth-backend-module-github-provider@0.2.1-next.1 + - @backstage/plugin-auth-backend-module-gitlab-provider@0.2.1-next.1 + - @backstage/plugin-auth-backend-module-google-provider@0.2.1-next.1 + - @backstage/plugin-auth-backend-module-oauth2-provider@0.3.1-next.1 + - @backstage/plugin-auth-backend-module-oidc-provider@0.3.1-next.1 + - @backstage/plugin-auth-backend-module-okta-provider@0.1.1-next.1 + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/catalog-client@1.7.1-next.0 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/config@1.2.0 + - @backstage/errors@1.2.4 + - @backstage/types@1.1.1 + - @backstage/plugin-auth-backend-module-auth0-provider@0.1.1-next.1 + - @backstage/plugin-auth-backend-module-azure-easyauth-provider@0.2.1-next.1 + - @backstage/plugin-auth-backend-module-bitbucket-server-provider@0.1.1-next.1 + - @backstage/plugin-auth-backend-module-oauth2-proxy-provider@0.2.1-next.1 + +## @backstage/plugin-auth-backend-module-atlassian-provider@0.3.1-next.1 + +### Patch Changes + +- 217458a: Updated configuration schema to include the new `allowedDomains` option for the `emailLocalPartMatchingUserEntityName` sign-in resolver. +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + +## @backstage/plugin-auth-backend-module-auth0-provider@0.1.1-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + +## @backstage/plugin-auth-backend-module-aws-alb-provider@0.2.1-next.1 + +### Patch Changes + +- 217458a: Updated configuration schema to include the new `allowedDomains` option for the `emailLocalPartMatchingUserEntityName` sign-in resolver. +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/errors@1.2.4 + - @backstage/plugin-auth-backend@0.23.1-next.1 + +## @backstage/plugin-auth-backend-module-azure-easyauth-provider@0.2.1-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/errors@1.2.4 + +## @backstage/plugin-auth-backend-module-bitbucket-provider@0.2.1-next.1 + +### Patch Changes + +- 217458a: Updated configuration schema to include the new `allowedDomains` option for the `emailLocalPartMatchingUserEntityName` sign-in resolver. +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + +## @backstage/plugin-auth-backend-module-bitbucket-server-provider@0.1.1-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + +## @backstage/plugin-auth-backend-module-cloudflare-access-provider@0.3.1-next.1 + +### Patch Changes + +- 217458a: Updated configuration schema to include the new `allowedDomains` option for the `emailLocalPartMatchingUserEntityName` sign-in resolver. +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/config@1.2.0 + - @backstage/errors@1.2.4 + +## @backstage/plugin-auth-backend-module-gcp-iap-provider@0.3.1-next.1 + +### Patch Changes + +- 217458a: Updated configuration schema to include the new `allowedDomains` option for the `emailLocalPartMatchingUserEntityName` sign-in resolver. +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/errors@1.2.4 + - @backstage/types@1.1.1 + +## @backstage/plugin-auth-backend-module-github-provider@0.2.1-next.1 + +### Patch Changes + +- 217458a: Updated configuration schema to include the new `allowedDomains` option for the `emailLocalPartMatchingUserEntityName` sign-in resolver. +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + +## @backstage/plugin-auth-backend-module-gitlab-provider@0.2.1-next.1 + +### Patch Changes + +- 217458a: Updated configuration schema to include the new `allowedDomains` option for the `emailLocalPartMatchingUserEntityName` sign-in resolver. +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + +## @backstage/plugin-auth-backend-module-google-provider@0.2.1-next.1 + +### Patch Changes + +- 217458a: Updated configuration schema to include the new `allowedDomains` option for the `emailLocalPartMatchingUserEntityName` sign-in resolver. +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + +## @backstage/plugin-auth-backend-module-guest-provider@0.2.1-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/errors@1.2.4 + +## @backstage/plugin-auth-backend-module-microsoft-provider@0.2.1-next.1 + +### Patch Changes + +- 217458a: Updated configuration schema to include the new `allowedDomains` option for the `emailLocalPartMatchingUserEntityName` sign-in resolver. +- daa02d6: Add `skipUserProfile` config flag to Microsoft authenticator +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + +## @backstage/plugin-auth-backend-module-oauth2-provider@0.3.1-next.1 + +### Patch Changes + +- 217458a: Updated configuration schema to include the new `allowedDomains` option for the `emailLocalPartMatchingUserEntityName` sign-in resolver. +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + +## @backstage/plugin-auth-backend-module-oauth2-proxy-provider@0.2.1-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/errors@1.2.4 + +## @backstage/plugin-auth-backend-module-oidc-provider@0.3.1-next.1 + +### Patch Changes + +- 217458a: Updated configuration schema to include the new `allowedDomains` option for the `emailLocalPartMatchingUserEntityName` sign-in resolver. +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/plugin-auth-backend@0.23.1-next.1 + +## @backstage/plugin-auth-backend-module-okta-provider@0.1.1-next.1 + +### Patch Changes + +- 217458a: Updated configuration schema to include the new `allowedDomains` option for the `emailLocalPartMatchingUserEntityName` sign-in resolver. +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + +## @backstage/plugin-auth-backend-module-onelogin-provider@0.2.1-next.1 + +### Patch Changes + +- 217458a: Updated configuration schema to include the new `allowedDomains` option for the `emailLocalPartMatchingUserEntityName` sign-in resolver. +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + +## @backstage/plugin-auth-backend-module-pinniped-provider@0.2.1-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/config@1.2.0 + +## @backstage/plugin-auth-node@0.5.3-next.1 + +### Patch Changes + +- 217458a: Added a new `allowedDomains` option for the common `emailLocalPartMatchingUserEntityName` sign-in resolver. +- Updated dependencies + - @backstage/catalog-client@1.7.1-next.0 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/config@1.2.0 + - @backstage/errors@1.2.4 + - @backstage/types@1.1.1 + +## @backstage/plugin-auth-react@0.1.7-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/core-components@0.15.1-next.2 + - @backstage/core-plugin-api@1.10.0-next.1 + - @backstage/errors@1.2.4 + +## @backstage/plugin-bitbucket-cloud-common@0.2.24-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/integration@1.15.1-next.1 + +## @backstage/plugin-catalog-backend@1.26.2-next.2 + +### Patch Changes + +- 720a2f9: Updated dependency `git-url-parse` to `^15.0.0`. +- f1cab41: Update catalog search table in transaction +- Updated dependencies + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/integration@1.15.1-next.1 + - @backstage/backend-openapi-utils@0.2.0-next.1 + - @backstage/catalog-client@1.7.1-next.0 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/config@1.2.0 + - @backstage/errors@1.2.4 + - @backstage/types@1.1.1 + - @backstage/plugin-catalog-common@1.1.0 + - @backstage/plugin-events-node@0.4.1-next.1 + - @backstage/plugin-permission-common@0.8.1 + - @backstage/plugin-permission-node@0.8.4-next.1 + - @backstage/plugin-search-backend-module-catalog@0.2.3-next.2 + +## @backstage/plugin-catalog-backend-module-aws@0.4.3-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/backend-defaults@0.5.1-next.2 + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/integration@1.15.1-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/config@1.2.0 + - @backstage/errors@1.2.4 + - @backstage/integration-aws-node@0.1.12 + - @backstage/plugin-catalog-common@1.1.0 + - @backstage/plugin-kubernetes-common@0.8.3 + +## @backstage/plugin-catalog-backend-module-azure@0.2.3-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/integration@1.15.1-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/config@1.2.0 + - @backstage/plugin-catalog-common@1.1.0 + +## @backstage/plugin-catalog-backend-module-backstage-openapi@0.4.1-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/backend-openapi-utils@0.2.0-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/config@1.2.0 + - @backstage/errors@1.2.4 + +## @backstage/plugin-catalog-backend-module-bitbucket-server@0.2.3-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/integration@1.15.1-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/config@1.2.0 + - @backstage/errors@1.2.4 + +## @backstage/plugin-catalog-backend-module-gcp@0.3.1-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/config@1.2.0 + - @backstage/plugin-kubernetes-common@0.8.3 + +## @backstage/plugin-catalog-backend-module-gerrit@0.2.3-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/integration@1.15.1-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/config@1.2.0 + - @backstage/errors@1.2.4 + +## @backstage/plugin-catalog-backend-module-github@0.7.5-next.2 + +### Patch Changes + +- 720a2f9: Updated dependency `git-url-parse` to `^15.0.0`. +- Updated dependencies + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/integration@1.15.1-next.1 + - @backstage/plugin-catalog-backend@1.26.2-next.2 + - @backstage/catalog-client@1.7.1-next.0 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/config@1.2.0 + - @backstage/plugin-catalog-common@1.1.0 + - @backstage/plugin-events-node@0.4.1-next.1 + +## @backstage/plugin-catalog-backend-module-github-org@0.3.2-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/plugin-catalog-backend-module-github@0.7.5-next.2 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/config@1.2.0 + - @backstage/plugin-events-node@0.4.1-next.1 + +## @backstage/plugin-catalog-backend-module-gitlab@0.4.3-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/backend-defaults@0.5.1-next.2 + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/integration@1.15.1-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/config@1.2.0 + - @backstage/plugin-catalog-common@1.1.0 + - @backstage/plugin-events-node@0.4.1-next.1 + +## @backstage/plugin-catalog-backend-module-gitlab-org@0.2.1-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/plugin-catalog-backend-module-gitlab@0.4.3-next.2 + - @backstage/plugin-events-node@0.4.1-next.1 + +## @backstage/plugin-catalog-backend-module-incremental-ingestion@0.5.5-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/plugin-catalog-backend@1.26.2-next.2 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/config@1.2.0 + - @backstage/errors@1.2.4 + - @backstage/plugin-events-node@0.4.1-next.1 + - @backstage/plugin-permission-common@0.8.1 + +## @backstage/plugin-catalog-backend-module-ldap@0.9.1-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/config@1.2.0 + - @backstage/errors@1.2.4 + - @backstage/types@1.1.1 + - @backstage/plugin-catalog-common@1.1.0 + +## @backstage/plugin-catalog-backend-module-logs@0.1.2-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-backend@1.26.2-next.2 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/plugin-events-node@0.4.1-next.1 + +## @backstage/plugin-catalog-backend-module-msgraph@0.6.3-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/config@1.2.0 + - @backstage/plugin-catalog-common@1.1.0 + +## @backstage/plugin-catalog-backend-module-openapi@0.2.2-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/integration@1.15.1-next.1 + - @backstage/plugin-catalog-backend@1.26.2-next.2 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/config@1.2.0 + - @backstage/types@1.1.1 + - @backstage/plugin-catalog-common@1.1.0 + +## @backstage/plugin-catalog-backend-module-puppetdb@0.2.3-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/config@1.2.0 + - @backstage/errors@1.2.4 + - @backstage/types@1.1.1 + +## @backstage/plugin-catalog-backend-module-scaffolder-entity-model@0.2.1-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/plugin-catalog-common@1.1.0 + - @backstage/plugin-scaffolder-common@1.5.6 + +## @backstage/plugin-catalog-backend-module-unprocessed@0.5.1-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-auth-node@0.5.3-next.1 + - @backstage/plugin-catalog-node@1.13.1-next.1 + - @backstage/backend-plugin-api@1.0.1-next.1 + - @backstage/catalog-model@1.7.0 + - @backstage/errors@1.2.4 + - @backstage/plugin-catalog-unprocessed-entities-common@0.0.4 + - @backstage/plugin-permission-common@0.8.1 + +## @backstage/plugin-catalog-graph@0.4.11-next.2 + +### Patch Changes + +- 4d9f39e: Added InfoCard `action` attribute for CatalogGraphCard + + ```tsx + const action =