From 765632fac76ef19dcaff6806a6914982660149b4 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 9 Jun 2021 12:05:04 +0200 Subject: [PATCH 01/59] feat: introduce collection of feature flags and make `FlatRoutes` treat feature flags as first class citizens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Johan Haals Co-authored-by: Patrik Oldsberg Co-authored-by: Fredrik Adelöw Signed-off-by: blam --- packages/app/package.json | 2 ++ packages/app/src/App.tsx | 11 ++++--- packages/core-app-api/src/app/App.tsx | 10 ++++++- .../src/routing/FeatureFlagged.tsx | 29 +++++++++++++++++++ .../core-app-api/src/routing/FlatRoutes.tsx | 26 ++++++++++++++--- .../core-app-api/src/routing/collectors.tsx | 11 +++++++ packages/core-app-api/src/routing/index.ts | 1 + 7 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 packages/core-app-api/src/routing/FeatureFlagged.tsx diff --git a/packages/app/package.json b/packages/app/package.json index 1b0b3aa22c..bfd2b458ae 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -8,6 +8,8 @@ "@backstage/cli": "^0.7.0", "@backstage/core": "^0.7.12", "@backstage/integration-react": "^0.1.3", + "@backstage/core-app-api": "^0.1.1", + "@backstage/core-components": "^0.1.1", "@backstage/plugin-api-docs": "^0.4.15", "@backstage/plugin-badges": "^0.2.2", "@backstage/plugin-catalog": "^0.6.2", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index b14f9a3163..12cbe89f26 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -14,13 +14,12 @@ * limitations under the License. */ +import { createApp, FlatRoutes, FeatureFlagged } from '@backstage/core-app-api'; import { AlertDisplay, - createApp, - FlatRoutes, OAuthRequestDialog, SignInPage, -} from '@backstage/core'; +} from '@backstage/core-components'; import { apiDocsPlugin, ApiExplorerPage } from '@backstage/plugin-api-docs'; import { CatalogEntityPage, @@ -65,6 +64,7 @@ const app = createApp({ // Custom icon example alert: AlarmIcon, }, + components: { SignInPage: props => { return ( @@ -114,8 +114,11 @@ const routes = ( path="/tech-radar" element={} /> - } /> + + } /> + } /> + } /> } /> } /> diff --git a/packages/core-app-api/src/app/App.tsx b/packages/core-app-api/src/app/App.tsx index a5ba5c4bca..93b56bd965 100644 --- a/packages/core-app-api/src/app/App.tsx +++ b/packages/core-app-api/src/app/App.tsx @@ -57,6 +57,7 @@ import { } from '../extensions/traversal'; import { pluginCollector } from '../plugins/collectors'; import { + featureFlagCollector, routeObjectCollector, routeParentCollector, routePathCollector, @@ -224,6 +225,7 @@ export class PrivateAppImpl implements BackstageApp { routeParents: routeParentCollector, routeObjects: routeObjectCollector, collectedPlugins: pluginCollector, + featureFlags: featureFlagCollector, }, }); @@ -237,7 +239,13 @@ export class PrivateAppImpl implements BackstageApp { this.verifyPlugins(this.plugins); // Initialize APIs once all plugins are available - this.getApiHolder(); + const apiHolder = this.getApiHolder(); + + // Register feature flags that have been discovered + const featureFlagApi = apiHolder.get(featureFlagsApiRef)!; + for (const name of result.featureFlags) { + featureFlagApi.registerFlag({ name, pluginId: '' }); + } return result; }, [children]); diff --git a/packages/core-app-api/src/routing/FeatureFlagged.tsx b/packages/core-app-api/src/routing/FeatureFlagged.tsx new file mode 100644 index 0000000000..0e28988f17 --- /dev/null +++ b/packages/core-app-api/src/routing/FeatureFlagged.tsx @@ -0,0 +1,29 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api'; + +export type FeatureFlaggedProps = { + flag: string; + children: JSX.Element | null; +}; + +export const FeatureFlagged = ({ children, flag }: FeatureFlaggedProps) => { + const featureFlagApi = useApi(featureFlagsApiRef); + const isEnabled = featureFlagApi.isActive(flag); + return isEnabled ? children :
NAT ENABLED
; +}; diff --git a/packages/core-app-api/src/routing/FlatRoutes.tsx b/packages/core-app-api/src/routing/FlatRoutes.tsx index 54ef092965..9e3a0a9956 100644 --- a/packages/core-app-api/src/routing/FlatRoutes.tsx +++ b/packages/core-app-api/src/routing/FlatRoutes.tsx @@ -16,7 +16,13 @@ import React, { ReactNode, Children, isValidElement, Fragment } from 'react'; import { useRoutes } from 'react-router-dom'; -import { useApp } from '@backstage/core-plugin-api'; +import { + useApi, + useApp, + featureFlagsApiRef, + FeatureFlagsApi, +} from '@backstage/core-plugin-api'; +import { FeatureFlagged, FeatureFlaggedProps } from './FeatureFlagged'; type RouteObject = { path: string; @@ -26,7 +32,10 @@ type RouteObject = { // Similar to the same function from react-router, this collects routes from the // children, but only the first level of routes -function createRoutesFromChildren(childrenNode: ReactNode): RouteObject[] { +function createRoutesFromChildren( + childrenNode: ReactNode, + featureFlagsApi: FeatureFlagsApi, +): RouteObject[] { return Children.toArray(childrenNode).flatMap(child => { if (!isValidElement(child)) { return []; @@ -35,7 +44,15 @@ function createRoutesFromChildren(childrenNode: ReactNode): RouteObject[] { const { children } = child.props; if (child.type === Fragment) { - return createRoutesFromChildren(children); + return createRoutesFromChildren(children, featureFlagsApi); + } + + if (child.type === FeatureFlagged) { + const { flag } = child.props as FeatureFlaggedProps; + if (featureFlagsApi.isActive(flag)) { + return createRoutesFromChildren(children, featureFlagsApi); + } + return []; } let path = child.props.path as string | undefined; @@ -67,8 +84,9 @@ type FlatRoutesProps = { export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => { const app = useApp(); + const featureFlagsApi = useApi(featureFlagsApiRef); const { NotFoundErrorPage } = app.getComponents(); - const routes = createRoutesFromChildren(props.children) + const routes = createRoutesFromChildren(props.children, featureFlagsApi) // Routes are sorted to work around a bug where prefixes are unexpectedly matched .sort((a, b) => b.path.localeCompare(a.path)) // We make sure all routes have '/*' appended, except '/' diff --git a/packages/core-app-api/src/routing/collectors.tsx b/packages/core-app-api/src/routing/collectors.tsx index e940a2cada..31c1ab6361 100644 --- a/packages/core-app-api/src/routing/collectors.tsx +++ b/packages/core-app-api/src/routing/collectors.tsx @@ -19,6 +19,7 @@ import { RouteRef } from '@backstage/core-plugin-api'; import { BackstageRouteObject } from './types'; import { getComponentData } from '../extensions'; import { createCollector } from '../extensions/traversal'; +import { FeatureFlagged, FeatureFlaggedProps } from './FeatureFlagged'; function getMountPoint(node: ReactElement): RouteRef | undefined { const element: ReactNode = node.props?.element; @@ -171,3 +172,13 @@ export const routeObjectCollector = createCollector( return parentObj; }, ); + +export const featureFlagCollector = createCollector( + () => new Set(), + (acc, node) => { + if (node.type === FeatureFlagged) { + const { flag } = node.props as FeatureFlaggedProps; + acc.add(flag); + } + }, +); diff --git a/packages/core-app-api/src/routing/index.ts b/packages/core-app-api/src/routing/index.ts index 7982333f4b..7e82eeb25d 100644 --- a/packages/core-app-api/src/routing/index.ts +++ b/packages/core-app-api/src/routing/index.ts @@ -15,3 +15,4 @@ */ export { FlatRoutes } from './FlatRoutes'; +export { FeatureFlagged, FeatureFlaggedProps } from './FeatureFlagged'; From 794af0de8e5664130c6c9875cbc82296be16d0dc Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 9 Jun 2021 15:30:15 +0200 Subject: [PATCH 02/59] feat: more work with the nice API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Patrik Oldsberg Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Signed-off-by: blam --- .../src/extensions/pennywise.ts | 97 +++++++++++++++++++ .../components/EntityLayout/EntityLayout.tsx | 64 ++++++------ 2 files changed, 125 insertions(+), 36 deletions(-) create mode 100644 packages/core-plugin-api/src/extensions/pennywise.ts diff --git a/packages/core-plugin-api/src/extensions/pennywise.ts b/packages/core-plugin-api/src/extensions/pennywise.ts new file mode 100644 index 0000000000..4574bafb3b --- /dev/null +++ b/packages/core-plugin-api/src/extensions/pennywise.ts @@ -0,0 +1,97 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { getComponentData } from './componentData'; + +/** + * Returns an array of each component data value for a given key of each + * element in the entire react element tree starting at the provided children. + * + * - This was needed to grab the actual component data once we had narrowed down the set of children + */ +export const useCollectComponentData = ( + children: React.ReactNode, + componentDataKey: string, +) => { + const stack = [children]; + const found: T[] = []; + + while (stack.length) { + const current: React.ReactNode = stack.pop()!; + + React.Children.forEach(current, child => { + if (!React.isValidElement(child)) { + return; + } + + const data = getComponentData(child, componentDataKey); + if (data) { + found.push(data); + } + + if (child.props.children) { + stack.push(child.props.children); + } + }); + } + + return found; +}; + +/** + * Returns an array of all values of the children prop of each element with the entire + * react element tree that has component data for the given key. + * + * - this was needed to collect the children of ScaffolderFieldExtensions elements + */ +export const useCollectChildren = ( + component: React.ReactNode, + componentDataKey: string, +) => { + const stack = [component]; + const found: React.ReactNode[] = []; + + while (stack.length) { + const current: React.ReactNode = stack.pop()!; + + React.Children.forEach(current, child => { + if (!React.isValidElement(child)) { + return; + } + + if (child.props.children) { + if (getComponentData(child, componentDataKey)) { + found.push(child.props.children); + } + stack.push(child.props.children); + } + }); + } + + return found; +}; + +/** + * + * + * TODO: + * support: + * - entity layout route traversal + * - scaffolder field extension enumeration + * - FlatRoutes + * - Respecting feature flags + */ +export function useElementCollection(children: ReactNode) {} diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index dfd27631f8..68b254ec59 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -28,6 +28,10 @@ import { Page, Progress, RoutedTabs, + FeatureFlagsApi, + getComponentData, + featureFlagsApiRef, + useApi, } from '@backstage/core'; import { EntityContext, @@ -58,46 +62,14 @@ type SubRoute = { tabProps?: TabProps; }; +const dataKey = 'plugin.catalog.entityLayoutRoute'; + const Route: (props: SubRoute) => null = () => null; +attachComponentData(Route, dataKey, true); // This causes all mount points that are discovered within this route to use the path of the route itself attachComponentData(Route, 'core.gatherMountPoints', true); -function createSubRoutesFromChildren( - childrenProps: React.ReactNode, - entity: Entity | undefined, -): SubRoute[] { - // Directly comparing child.type with Route will not work with in - // combination with react-hot-loader in storybook - // https://github.com/gaearon/react-hot-loader/issues/304 - const routeType = ( - -
- - ).type; - - return Children.toArray(childrenProps).flatMap(child => { - if (!isValidElement(child)) { - return []; - } - - if (child.type === Fragment) { - return createSubRoutesFromChildren(child.props.children, entity); - } - - if (child.type !== routeType) { - throw new Error('Child of EntityLayout must be an EntityLayout.Route'); - } - - const { path, title, children, if: condition, tabProps } = child.props; - if (condition && entity && !condition(entity)) { - return []; - } - - return [{ path, title, children, tabProps }]; - }); -} - const EntityLayoutTitle = ({ entity, title, @@ -195,7 +167,27 @@ export const EntityLayout = ({ const { kind, namespace, name } = useEntityCompoundName(); const { entity, loading, error } = useContext(EntityContext); - const routes = createSubRoutesFromChildren(children, entity); + const routes = useElementCollection(children) + .findByComponentData({ + key: dataKey, + withStrictError: 'Child of EntityLayout must be an EntityLayout.Route', + }) + .listElements() // all nodes, element data, maintain structure or not? + .flatMap(({ props }) => { + if (props.condition && entity && !props.condition(entity)) { + return []; + } + + return [ + { + path: props.path, + title: props.title, + children: props.children, + tabProps: props.tabProps, + }, + ]; + }); + const { headerTitle, headerType } = headerProps( kind, namespace, From d7f0b0bed6ae3789f8a7fc281acef74731033ea9 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 9 Jun 2021 17:00:12 +0200 Subject: [PATCH 03/59] chore: working through beginning API Signed-off-by: blam --- .../app/src/components/catalog/EntityPage.tsx | 17 +++++++---- packages/core-app-api/src/app/App.tsx | 2 +- .../src/routing/FeatureFlagged.tsx | 11 +++++-- .../src/extensions/pennywise.ts | 19 ++++++++++-- plugins/scaffolder/src/components/Router.tsx | 30 +++++++++++++------ 5 files changed, 57 insertions(+), 22 deletions(-) diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index f646992c97..61d62136de 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -108,6 +108,7 @@ import { isTravisciAvailable, } from '@roadiehq/backstage-plugin-travis-ci'; import { EntityCodeCoverageContent } from '@backstage/plugin-code-coverage'; +import { FeatureFlagged } from '@backstage/core-app-api'; const EntityLayoutWrapper = (props: { children?: ReactNode }) => { const [badgesDialogOpen, setBadgesDialogOpen] = useState(false); @@ -282,9 +283,11 @@ const serviceEntityPage = ( {overviewContent} - - {cicdContent} - + + + {cicdContent} + + {errorsContent} @@ -303,9 +306,11 @@ const serviceEntityPage = ( - - - + + + + + diff --git a/packages/core-app-api/src/app/App.tsx b/packages/core-app-api/src/app/App.tsx index 93b56bd965..1d77282635 100644 --- a/packages/core-app-api/src/app/App.tsx +++ b/packages/core-app-api/src/app/App.tsx @@ -244,7 +244,7 @@ export class PrivateAppImpl implements BackstageApp { // Register feature flags that have been discovered const featureFlagApi = apiHolder.get(featureFlagsApiRef)!; for (const name of result.featureFlags) { - featureFlagApi.registerFlag({ name, pluginId: '' }); + featureFlagApi.registerFlag({ name, pluginId: '' }); } return result; diff --git a/packages/core-app-api/src/routing/FeatureFlagged.tsx b/packages/core-app-api/src/routing/FeatureFlagged.tsx index 0e28988f17..0adda78842 100644 --- a/packages/core-app-api/src/routing/FeatureFlagged.tsx +++ b/packages/core-app-api/src/routing/FeatureFlagged.tsx @@ -14,8 +14,11 @@ * limitations under the License. */ -import React from 'react'; -import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api'; +import { + featureFlagsApiRef, + useApi, + attachComponentData, +} from '@backstage/core-plugin-api'; export type FeatureFlaggedProps = { flag: string; @@ -25,5 +28,7 @@ export type FeatureFlaggedProps = { export const FeatureFlagged = ({ children, flag }: FeatureFlaggedProps) => { const featureFlagApi = useApi(featureFlagsApiRef); const isEnabled = featureFlagApi.isActive(flag); - return isEnabled ? children :
NAT ENABLED
; + return isEnabled ? children : null; }; + +attachComponentData(FeatureFlagged, 'core.featureFlagged', true); diff --git a/packages/core-plugin-api/src/extensions/pennywise.ts b/packages/core-plugin-api/src/extensions/pennywise.ts index 4574bafb3b..ce365a5a0f 100644 --- a/packages/core-plugin-api/src/extensions/pennywise.ts +++ b/packages/core-plugin-api/src/extensions/pennywise.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import { getComponentData } from './componentData'; /** @@ -84,7 +84,7 @@ export const useCollectChildren = ( return found; }; -/** +/* * * * TODO: @@ -94,4 +94,17 @@ export const useCollectChildren = ( * - FlatRoutes * - Respecting feature flags */ -export function useElementCollection(children: ReactNode) {} + +class ElementCollection { + constructor(private readonly children: ReactNode) {} + findByComponentData(query: { key: string; withStrictError?: string }) { + const next = applyFilterStuff(this.children); + return new ElementCollection(next); + } + listComponentData(query: { key: string }): T[] {} + // listElements +} + +export function useElementCollection(children: ReactNode) { + return new ElementCollection(children); +} diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx index 5ac663c7ea..ba062f62f7 100644 --- a/plugins/scaffolder/src/components/Router.tsx +++ b/plugins/scaffolder/src/components/Router.tsx @@ -32,16 +32,28 @@ import { collectComponentData, collectChildren } from '../extensions/helpers'; export const Router = () => { const outlet = useOutlet(); - const fieldExtensions = useMemo(() => { - const registeredExtensions = collectComponentData( - collectChildren(outlet, FIELD_EXTENSION_WRAPPER_KEY).flat(), - FIELD_EXTENSION_KEY, - ); + const foundExtensions = useElementCollection(outlet) + .findByComponentData({ + key: FIELD_EXTENSION_WRAPPER_KEY, + }) + .findByComponentData({ + key: FIELD_EXTENSION_KEY, + }) + .listComponentData(); - return registeredExtensions.length - ? registeredExtensions - : DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS; - }, [outlet]); + const fieldExtensions = foundExtensions.length + ? foundExtensions + : DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS; + // const fieldExtensions = useMemo(() => { + // const registeredExtensions = collectComponentData( + // collectChildren(outlet, FIELD_EXTENSION_WRAPPER_KEY).flat(), + // FIELD_EXTENSION_KEY, + // ); + + // return registeredExtensions.length + // ? registeredExtensions + // : DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS; + // }, [outlet]); return ( From 78ccf9ea9831e352e9e79ef5906d4b5d664426bc Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 10 Jun 2021 11:54:14 +0200 Subject: [PATCH 04/59] feat: replace implementations of children introspection with our new thing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Patrik Oldsberg Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Signed-off-by: blam --- .../core-api/src/routing/collectors.test.tsx | 2 +- .../core-app-api/src/routing/FlatRoutes.tsx | 88 ++++------ packages/core-app-api/src/routing/index.ts | 3 +- .../core-plugin-api/src/extensions/index.ts | 1 + .../src/extensions/pennywise.ts | 160 +++++++++--------- .../components/EntityLayout/EntityLayout.tsx | 18 +- plugins/scaffolder/src/components/Router.tsx | 17 +- .../src/extensions/helpers.test.tsx | 107 ------------ plugins/scaffolder/src/extensions/helpers.ts | 73 -------- 9 files changed, 121 insertions(+), 348 deletions(-) delete mode 100644 plugins/scaffolder/src/extensions/helpers.test.tsx delete mode 100644 plugins/scaffolder/src/extensions/helpers.ts diff --git a/packages/core-api/src/routing/collectors.test.tsx b/packages/core-api/src/routing/collectors.test.tsx index 498d1dd9fc..4441d4abf3 100644 --- a/packages/core-api/src/routing/collectors.test.tsx +++ b/packages/core-api/src/routing/collectors.test.tsx @@ -38,7 +38,7 @@ const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( const plugin = createPlugin({ id: 'my-plugin' }); -const ref1 = createRouteRef({ path: '/foo1', title: 'Foo' }); +/const ref1 = createRouteRef({ path: '/foo1', title: 'Foo' }); const ref2 = createRouteRef({ path: '/foo2', title: 'Foo' }); const ref3 = createRouteRef({ path: '/foo3', title: 'Foo' }); const ref4 = createRouteRef({ path: '/foo4', title: 'Foo' }); diff --git a/packages/core-app-api/src/routing/FlatRoutes.tsx b/packages/core-app-api/src/routing/FlatRoutes.tsx index 9e3a0a9956..c185373dc1 100644 --- a/packages/core-app-api/src/routing/FlatRoutes.tsx +++ b/packages/core-app-api/src/routing/FlatRoutes.tsx @@ -14,79 +14,49 @@ * limitations under the License. */ -import React, { ReactNode, Children, isValidElement, Fragment } from 'react'; +import React, { ReactNode } from 'react'; import { useRoutes } from 'react-router-dom'; -import { - useApi, - useApp, - featureFlagsApiRef, - FeatureFlagsApi, -} from '@backstage/core-plugin-api'; -import { FeatureFlagged, FeatureFlaggedProps } from './FeatureFlagged'; +import { useApp, useElementCollection } from '@backstage/core-plugin-api'; type RouteObject = { path: string; - element: JSX.Element; + element: ReactNode; children?: RouteObject[]; }; -// Similar to the same function from react-router, this collects routes from the -// children, but only the first level of routes -function createRoutesFromChildren( - childrenNode: ReactNode, - featureFlagsApi: FeatureFlagsApi, -): RouteObject[] { - return Children.toArray(childrenNode).flatMap(child => { - if (!isValidElement(child)) { - return []; - } - - const { children } = child.props; - - if (child.type === Fragment) { - return createRoutesFromChildren(children, featureFlagsApi); - } - - if (child.type === FeatureFlagged) { - const { flag } = child.props as FeatureFlaggedProps; - if (featureFlagsApi.isActive(flag)) { - return createRoutesFromChildren(children, featureFlagsApi); - } - return []; - } - - let path = child.props.path as string | undefined; - - // TODO(Rugvip): Work around plugins registering empty paths, remove once deprecated routes are gone - if (path === '') { - return []; - } - path = path?.replace(/\/\*$/, '') ?? '/'; - - return [ - { - path, - element: child, - children: children && [ - { - path: '/*', - element: children, - }, - ], - }, - ]; - }); -} - type FlatRoutesProps = { children: ReactNode; }; export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => { const app = useApp(); - const featureFlagsApi = useApi(featureFlagsApiRef); const { NotFoundErrorPage } = app.getComponents(); - const routes = createRoutesFromChildren(props.children, featureFlagsApi) + const routes = useElementCollection(props.children) + .listElements<{ path?: string; children: ReactNode }>() + .flatMap(child => { + let path = child.props.path; + + // TODO(Rugvip): Work around plugins registering empty paths, remove once deprecated routes are gone + if (path === '') { + return []; + } + path = path?.replace(/\/\*$/, '') ?? '/'; + + return [ + { + path, + element: child, + children: child.props.children + ? [ + { + path: '/*', + element: child.props.children, + }, + ] + : undefined, + }, + ]; + }) // Routes are sorted to work around a bug where prefixes are unexpectedly matched .sort((a, b) => b.path.localeCompare(a.path)) // We make sure all routes have '/*' appended, except '/' diff --git a/packages/core-app-api/src/routing/index.ts b/packages/core-app-api/src/routing/index.ts index 7e82eeb25d..b37b51e919 100644 --- a/packages/core-app-api/src/routing/index.ts +++ b/packages/core-app-api/src/routing/index.ts @@ -15,4 +15,5 @@ */ export { FlatRoutes } from './FlatRoutes'; -export { FeatureFlagged, FeatureFlaggedProps } from './FeatureFlagged'; +export { FeatureFlagged } from './FeatureFlagged'; +export type { FeatureFlaggedProps } from './FeatureFlagged'; diff --git a/packages/core-plugin-api/src/extensions/index.ts b/packages/core-plugin-api/src/extensions/index.ts index 26a0c597b1..8d2d6872d9 100644 --- a/packages/core-plugin-api/src/extensions/index.ts +++ b/packages/core-plugin-api/src/extensions/index.ts @@ -20,3 +20,4 @@ export { createRoutableExtension, createComponentExtension, } from './extensions'; +export { useElementCollection } from './pennywise'; diff --git a/packages/core-plugin-api/src/extensions/pennywise.ts b/packages/core-plugin-api/src/extensions/pennywise.ts index ce365a5a0f..d6d92a267e 100644 --- a/packages/core-plugin-api/src/extensions/pennywise.ts +++ b/packages/core-plugin-api/src/extensions/pennywise.ts @@ -13,98 +13,100 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { ReactNode } from 'react'; +import { + Children, + Fragment, + isValidElement, + ReactNode, + ReactElement, +} from 'react'; import { getComponentData } from './componentData'; +import { useApi, FeatureFlagsApi, featureFlagsApiRef } from '../apis'; -/** - * Returns an array of each component data value for a given key of each - * element in the entire react element tree starting at the provided children. - * - * - This was needed to grab the actual component data once we had narrowed down the set of children - */ -export const useCollectComponentData = ( - children: React.ReactNode, - componentDataKey: string, -) => { - const stack = [children]; - const found: T[] = []; +function selectChildren( + rootNode: ReactNode, + featureFlagsApi: FeatureFlagsApi, + selector?: (element: ReactElement) => boolean, + strictError?: string, +): Array> { + return Children.toArray(rootNode).flatMap(node => { + if (!isValidElement(node)) { + return []; + } - while (stack.length) { - const current: React.ReactNode = stack.pop()!; + const { children } = node.props; - React.Children.forEach(current, child => { - if (!React.isValidElement(child)) { - return; + if (node.type === Fragment) { + return selectChildren(children, featureFlagsApi, selector, strictError); + } + + if (getComponentData(node, 'core.featureFlagged')) { + const { flag } = node.props as { flag: string }; + if (featureFlagsApi.isActive(flag)) { + return selectChildren( + node.props.children, + featureFlagsApi, + selector, + strictError, + ); } + return []; + } - const data = getComponentData(child, componentDataKey); - if (data) { - found.push(data); - } + if (selector === undefined || selector(node)) { + return [node]; + } - if (child.props.children) { - stack.push(child.props.children); - } - }); - } + if (strictError) { + throw new Error(strictError); + } - return found; -}; - -/** - * Returns an array of all values of the children prop of each element with the entire - * react element tree that has component data for the given key. - * - * - this was needed to collect the children of ScaffolderFieldExtensions elements - */ -export const useCollectChildren = ( - component: React.ReactNode, - componentDataKey: string, -) => { - const stack = [component]; - const found: React.ReactNode[] = []; - - while (stack.length) { - const current: React.ReactNode = stack.pop()!; - - React.Children.forEach(current, child => { - if (!React.isValidElement(child)) { - return; - } - - if (child.props.children) { - if (getComponentData(child, componentDataKey)) { - found.push(child.props.children); - } - stack.push(child.props.children); - } - }); - } - - return found; -}; - -/* - * - * - * TODO: - * support: - * - entity layout route traversal - * - scaffolder field extension enumeration - * - FlatRoutes - * - Respecting feature flags - */ + return selectChildren( + node.props.children, + featureFlagsApi, + selector, + strictError, + ); + }); +} class ElementCollection { - constructor(private readonly children: ReactNode) {} + constructor( + private readonly children: ReactNode, + private readonly featureFlagsApi: FeatureFlagsApi, + ) {} + findByComponentData(query: { key: string; withStrictError?: string }) { - const next = applyFilterStuff(this.children); - return new ElementCollection(next); + const selection = selectChildren( + this.children, + this.featureFlagsApi, + node => Boolean(getComponentData(node, query.key)), + query.withStrictError, + ); + return new ElementCollection(selection, this.featureFlagsApi); + } + + listComponentData(query: { key: string }): T[] { + const selection = selectChildren( + this.children, + this.featureFlagsApi, + node => Boolean(getComponentData(node, query.key)), + ); + return selection + .map(node => getComponentData(node, query.key)) + .filter((data: T | undefined): data is T => Boolean(data)); + } + + listElements(): Array< + ReactElement + > { + return selectChildren(this.children, this.featureFlagsApi) as Array< + ReactElement + >; } - listComponentData(query: { key: string }): T[] {} - // listElements } export function useElementCollection(children: ReactNode) { - return new ElementCollection(children); + const featureFlagsApi = useApi(featureFlagsApiRef); + return new ElementCollection(children, featureFlagsApi); } diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index 68b254ec59..7cd4ab3dd1 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -28,11 +28,8 @@ import { Page, Progress, RoutedTabs, - FeatureFlagsApi, - getComponentData, - featureFlagsApiRef, - useApi, } from '@backstage/core'; +import { useElementCollection } from '@backstage/core-plugin-api'; import { EntityContext, EntityRefLinks, @@ -41,14 +38,7 @@ import { } from '@backstage/plugin-catalog-react'; import { Box, TabProps } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; -import { - Children, - default as React, - Fragment, - isValidElement, - useContext, - useState, -} from 'react'; +import React, { useContext, useState } from 'react'; import { useNavigate } from 'react-router'; import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; @@ -172,9 +162,9 @@ export const EntityLayout = ({ key: dataKey, withStrictError: 'Child of EntityLayout must be an EntityLayout.Route', }) - .listElements() // all nodes, element data, maintain structure or not? + .listElements() // all nodes, element data, maintain structure or not? .flatMap(({ props }) => { - if (props.condition && entity && !props.condition(entity)) { + if (props.if && entity && !props.if(entity)) { return []; } diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx index ba062f62f7..57efd43e74 100644 --- a/plugins/scaffolder/src/components/Router.tsx +++ b/plugins/scaffolder/src/components/Router.tsx @@ -27,7 +27,7 @@ import { FIELD_EXTENSION_KEY, DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS, } from '../extensions'; -import { collectComponentData, collectChildren } from '../extensions/helpers'; +import { useElementCollection } from '@backstage/core-plugin-api'; export const Router = () => { const outlet = useOutlet(); @@ -36,24 +36,13 @@ export const Router = () => { .findByComponentData({ key: FIELD_EXTENSION_WRAPPER_KEY, }) - .findByComponentData({ + .listComponentData({ key: FIELD_EXTENSION_KEY, - }) - .listComponentData(); + }); const fieldExtensions = foundExtensions.length ? foundExtensions : DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS; - // const fieldExtensions = useMemo(() => { - // const registeredExtensions = collectComponentData( - // collectChildren(outlet, FIELD_EXTENSION_WRAPPER_KEY).flat(), - // FIELD_EXTENSION_KEY, - // ); - - // return registeredExtensions.length - // ? registeredExtensions - // : DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS; - // }, [outlet]); return ( diff --git a/plugins/scaffolder/src/extensions/helpers.test.tsx b/plugins/scaffolder/src/extensions/helpers.test.tsx deleted file mode 100644 index ed3b74a210..0000000000 --- a/plugins/scaffolder/src/extensions/helpers.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright 2021 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import { collectComponentData, collectChildren } from './helpers'; -import { attachComponentData } from '@backstage/core'; - -describe('Extension Helpers', () => { - const createElementWithComponentData = ({ - type, - data, - }: { - type: string; - data: any; - }) => { - const element: React.ComponentType = () => null; - attachComponentData(element, type, data); - return element; - }; - - describe('collectChildren', () => { - it('should return the children of the component which has the correct componentData flag', () => { - const SearchElement = createElementWithComponentData({ - type: 'find.me', - data: {}, - }); - - const DontCareAboutme = createElementWithComponentData({ - type: 'dont.find.me', - data: {}, - }); - - const child1 = ( -
- hello -
- ); - - const child2 = ( -
-

Hello2

-
- ); - - const testCase = ( -
- - {child1} - {child1} - - {child2} - -

Hello!

- {child1} -
-
- ); - - const children = collectChildren(testCase, 'find.me'); - - expect(children).toEqual([[child1, child1], child2, child1]); - }); - }); - - describe('collectComponentData', () => { - it('should return the componentData for particular nodes', () => { - const componentData1 = { help: 'im something' }; - const componentData2 = { help: 'im something else' }; - - const FirstElement = createElementWithComponentData({ - type: 'find.me', - data: componentData1, - }); - - const SecondElement = createElementWithComponentData({ - type: 'dont.find.me', - data: componentData2, - }); - - const testCase = [ - , - , - , - , - ]; - const returnedData = collectComponentData(testCase, 'find.me'); - - expect(returnedData).toEqual([ - componentData1, - componentData1, - componentData1, - ]); - }); - }); -}); diff --git a/plugins/scaffolder/src/extensions/helpers.ts b/plugins/scaffolder/src/extensions/helpers.ts deleted file mode 100644 index 2eac9780e5..0000000000 --- a/plugins/scaffolder/src/extensions/helpers.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2021 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import React from 'react'; -import { getComponentData } from '@backstage/core'; - -export const collectComponentData = ( - children: React.ReactNode, - componentDataKey: string, -) => { - const stack = [children]; - const found: T[] = []; - - while (stack.length) { - const current: React.ReactNode = stack.pop()!; - - React.Children.forEach(current, child => { - if (!React.isValidElement(child)) { - return; - } - - const data = getComponentData(child, componentDataKey); - if (data) { - found.push(data); - } - - if (child.props.children) { - stack.push(child.props.children); - } - }); - } - - return found; -}; - -export const collectChildren = ( - component: React.ReactNode, - componentDataKey: string, -) => { - const stack = [component]; - const found: React.ReactNode[] = []; - - while (stack.length) { - const current: React.ReactNode = stack.pop()!; - - React.Children.forEach(current, child => { - if (!React.isValidElement(child)) { - return; - } - - if (child.props.children) { - if (getComponentData(child, componentDataKey)) { - found.push(child.props.children); - } - stack.push(child.props.children); - } - }); - } - - return found; -}; From 969c6750affd37412055dda7798b6cb51899baa6 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 10 Jun 2021 17:01:26 +0200 Subject: [PATCH 05/59] feat: writing some tests and have a nicer api now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Patrik Oldsberg Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Signed-off-by: blam --- .../core-api/src/routing/collectors.test.tsx | 2 +- packages/core-plugin-api/package.json | 1 + .../src/extensions/componentData.tsx | 2 + .../core-plugin-api/src/extensions/index.ts | 2 +- .../src/extensions/useElementFilter.test.tsx | 249 ++++++++++++++++++ .../{pennywise.ts => useElementFilter.tsx} | 38 +-- plugins/scaffolder/package.json | 7 +- plugins/scaffolder/src/components/Router.tsx | 23 +- 8 files changed, 295 insertions(+), 29 deletions(-) create mode 100644 packages/core-plugin-api/src/extensions/useElementFilter.test.tsx rename packages/core-plugin-api/src/extensions/{pennywise.ts => useElementFilter.tsx} (72%) diff --git a/packages/core-api/src/routing/collectors.test.tsx b/packages/core-api/src/routing/collectors.test.tsx index 4441d4abf3..498d1dd9fc 100644 --- a/packages/core-api/src/routing/collectors.test.tsx +++ b/packages/core-api/src/routing/collectors.test.tsx @@ -38,7 +38,7 @@ const MockComponent = ({ children }: PropsWithChildren<{ path?: string }>) => ( const plugin = createPlugin({ id: 'my-plugin' }); -/const ref1 = createRouteRef({ path: '/foo1', title: 'Foo' }); +const ref1 = createRouteRef({ path: '/foo1', title: 'Foo' }); const ref2 = createRouteRef({ path: '/foo2', title: 'Foo' }); const ref3 = createRouteRef({ path: '/foo3', title: 'Foo' }); const ref4 = createRouteRef({ path: '/foo4', title: 'Foo' }); diff --git a/packages/core-plugin-api/package.json b/packages/core-plugin-api/package.json index 816727ed9e..b1d53e17c0 100644 --- a/packages/core-plugin-api/package.json +++ b/packages/core-plugin-api/package.json @@ -42,6 +42,7 @@ }, "devDependencies": { "@backstage/cli": "^0.7.0", + "@backstage/core-app-api": "^0.1.1", "@backstage/test-utils": "^0.1.13", "@backstage/test-utils-core": "^0.1.1", "@testing-library/jest-dom": "^5.10.1", diff --git a/packages/core-plugin-api/src/extensions/componentData.tsx b/packages/core-plugin-api/src/extensions/componentData.tsx index f835ad38a8..059b8e1304 100644 --- a/packages/core-plugin-api/src/extensions/componentData.tsx +++ b/packages/core-plugin-api/src/extensions/componentData.tsx @@ -50,6 +50,8 @@ export function attachComponentData

( } container.map.set(type, data); + + return component; } export function getComponentData( diff --git a/packages/core-plugin-api/src/extensions/index.ts b/packages/core-plugin-api/src/extensions/index.ts index 8d2d6872d9..71373db1a3 100644 --- a/packages/core-plugin-api/src/extensions/index.ts +++ b/packages/core-plugin-api/src/extensions/index.ts @@ -20,4 +20,4 @@ export { createRoutableExtension, createComponentExtension, } from './extensions'; -export { useElementCollection } from './pennywise'; +export { useElementFilter } from './useElementFilter'; diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx new file mode 100644 index 0000000000..4725de66c2 --- /dev/null +++ b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx @@ -0,0 +1,249 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { ReactNode } from 'react'; +import { useElementFilter } from './useElementFilter'; +import { renderHook } from '@testing-library/react-hooks'; +import { attachComponentData } from './componentData'; +import { featureFlagsApiRef } from '../apis'; +import { + ApiProvider, + ApiRegistry, + LocalStorageFeatureFlags, +} from '@backstage/core-app-api'; + +const WRAPPING_COMPONENT_KEY = 'core.blob.testing'; +const INNER_COMPONENT_KEY = 'core.blob2.testing'; + +const WrappingComponent = (props: { children: ReactNode }) => null; +attachComponentData(WrappingComponent, WRAPPING_COMPONENT_KEY, { + message: 'hey! im wrapping component data', +}); +const InnerComponent = () => null; +attachComponentData(InnerComponent, INNER_COMPONENT_KEY, { + message: 'hey! im the inner component', +}); +const MockComponent = (props: { children: ReactNode }) => null; + +const FeatureFlagComponent = (props: { children: ReactNode; flag: string }) => + null; +attachComponentData(FeatureFlagComponent, 'core.featureFlagged', true); +const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); +const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + +); + +describe('useElementFilter', () => { + it('should select elements based on a component data key', () => { + const tree = ( + + + + + + + + + + + ); + + const { result } = renderHook( + props => + useElementFilter(props.tree, elements => + elements + .selectByComponentData({ key: WRAPPING_COMPONENT_KEY }) + .getElements(), + ), + { + initialProps: { tree }, + wrapper: Wrapper, + }, + ); + + expect(result.current.length).toBe(2); + expect(result.current[0].key).toBe('.$.$first'); + expect(result.current[1].key).toBe('.$.$second'); + }); + + it('should find componentData', () => { + const tree = ( + + + + + + + + + + + ); + + const { result } = renderHook( + props => + useElementFilter(props.tree, elements => + elements.findComponentData({ key: WRAPPING_COMPONENT_KEY }), + ), + { + initialProps: { tree }, + wrapper: Wrapper, + }, + ); + + expect(result.current.length).toBe(2); + expect(result.current[0]).toEqual({ + message: 'hey! im wrapping component data', + }); + expect(result.current[1]).toEqual({ + message: 'hey! im wrapping component data', + }); + }); + + it('can be combined to together to filter the selection', () => { + const tree = ( + + + + + + + + + + + + ); + + const { result } = renderHook( + props => + useElementFilter(props.tree, elements => + elements + .selectByComponentData({ key: WRAPPING_COMPONENT_KEY }) + .findComponentData({ key: INNER_COMPONENT_KEY }), + ), + { + initialProps: { tree }, + wrapper: Wrapper, + }, + ); + + expect(result.current.length).toBe(2); + expect(result.current[0]).toEqual({ + message: 'hey! im the inner component', + }); + expect(result.current[1]).toEqual({ + message: 'hey! im the inner component', + }); + }); + + it('should not discover deeper than the feature gate if the feature flag is disabled', () => { + jest.spyOn(mockFeatureFlagsApi, 'isActive').mockImplementation(() => false); + const tree = ( + + + + + + + + + + + + + + ); + + const { result } = renderHook( + props => + useElementFilter(props.tree, elements => + elements + .selectByComponentData({ key: WRAPPING_COMPONENT_KEY }) + .getElements(), + ), + { + initialProps: { tree }, + wrapper: Wrapper, + }, + ); + + expect(result.current.length).toBe(1); + expect(result.current[0].key).toContain('second'); + }); + + it('should discover components behind a feature flag if the flag is enabled', () => { + jest.spyOn(mockFeatureFlagsApi, 'isActive').mockImplementation(() => true); + const tree = ( + + + + + + + + + + + + + + ); + + const { result } = renderHook( + props => + useElementFilter(props.tree, elements => + elements + .selectByComponentData({ key: WRAPPING_COMPONENT_KEY }) + .getElements(), + ), + { + initialProps: { tree }, + wrapper: Wrapper, + }, + ); + + expect(result.current.length).toBe(2); + }); + + it('should reject with', () => { + const tree = ( + +

Hello

+ + ); + + const { result } = renderHook( + props => + useElementFilter(props.tree, elements => + elements + .selectByComponentData({ + key: WRAPPING_COMPONENT_KEY, + withStrictError: 'Could not find component', + // errorIfNotFullMatch? + }) + .findComponentData({ key: INNER_COMPONENT_KEY }), + ), + { + initialProps: { tree }, + wrapper: Wrapper, + }, + ); + + expect(result.error.message).toEqual('Could not find component'); + }); +}); diff --git a/packages/core-plugin-api/src/extensions/pennywise.ts b/packages/core-plugin-api/src/extensions/useElementFilter.tsx similarity index 72% rename from packages/core-plugin-api/src/extensions/pennywise.ts rename to packages/core-plugin-api/src/extensions/useElementFilter.tsx index d6d92a267e..9ed070fd6c 100644 --- a/packages/core-plugin-api/src/extensions/pennywise.ts +++ b/packages/core-plugin-api/src/extensions/useElementFilter.tsx @@ -19,6 +19,7 @@ import { isValidElement, ReactNode, ReactElement, + useMemo, } from 'react'; import { getComponentData } from './componentData'; import { useApi, FeatureFlagsApi, featureFlagsApiRef } from '../apis'; @@ -34,10 +35,13 @@ function selectChildren( return []; } - const { children } = node.props; - if (node.type === Fragment) { - return selectChildren(children, featureFlagsApi, selector, strictError); + return selectChildren( + node.props.children, + featureFlagsApi, + selector, + strictError, + ); } if (getComponentData(node, 'core.featureFlagged')) { @@ -72,13 +76,13 @@ function selectChildren( class ElementCollection { constructor( - private readonly children: ReactNode, + private readonly node: ReactNode, private readonly featureFlagsApi: FeatureFlagsApi, ) {} - findByComponentData(query: { key: string; withStrictError?: string }) { + selectByComponentData(query: { key: string; withStrictError?: string }) { const selection = selectChildren( - this.children, + this.node, this.featureFlagsApi, node => Boolean(getComponentData(node, query.key)), query.withStrictError, @@ -86,27 +90,31 @@ class ElementCollection { return new ElementCollection(selection, this.featureFlagsApi); } - listComponentData(query: { key: string }): T[] { - const selection = selectChildren( - this.children, - this.featureFlagsApi, - node => Boolean(getComponentData(node, query.key)), + findComponentData(query: { key: string }): T[] { + const selection = selectChildren(this.node, this.featureFlagsApi, node => + Boolean(getComponentData(node, query.key)), ); return selection .map(node => getComponentData(node, query.key)) .filter((data: T | undefined): data is T => Boolean(data)); } - listElements(): Array< + getElements(): Array< ReactElement > { - return selectChildren(this.children, this.featureFlagsApi) as Array< + return selectChildren(this.node, this.featureFlagsApi) as Array< ReactElement >; } } -export function useElementCollection(children: ReactNode) { +export function useElementFilter( + node: ReactNode, + filterFn: (arg: ElementCollection) => T, + dependencies: any[] = [], +) { const featureFlagsApi = useApi(featureFlagsApiRef); - return new ElementCollection(children, featureFlagsApi); + const elements = new ElementCollection(node, featureFlagsApi); + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => filterFn(elements), [node, ...dependencies]); } diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index a7541a5ddc..3cb046966a 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -33,11 +33,12 @@ "@backstage/catalog-client": "^0.3.13", "@backstage/catalog-model": "^0.8.2", "@backstage/config": "^0.1.5", - "@backstage/errors": "^0.1.1", "@backstage/core": "^0.7.12", "@backstage/integration": "^0.5.6", "@backstage/integration-react": "^0.1.3", "@backstage/plugin-catalog-react": "^0.2.2", + "@backstage/core-plugin-api": "^0.1.1", + "@backstage/errors": "^0.1.1", "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", @@ -46,10 +47,10 @@ "@rjsf/material-ui": "^2.4.0", "@types/react": "^16.9", "classnames": "^2.2.6", - "json-schema": "^0.3.0", "git-url-parse": "^11.4.4", "humanize-duration": "^3.25.1", "immer": "^9.0.1", + "json-schema": "^0.3.0", "luxon": "^1.25.0", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -66,9 +67,9 @@ "@backstage/test-utils": "^0.1.13", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", + "@testing-library/react-hooks": "^3.3.0", "@testing-library/user-event": "^13.1.8", "@types/humanize-duration": "^3.18.1", - "@testing-library/react-hooks": "^3.3.0", "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx index 57efd43e74..e72b9a9a1e 100644 --- a/plugins/scaffolder/src/components/Router.tsx +++ b/plugins/scaffolder/src/components/Router.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { useMemo } from 'react'; +import React from 'react'; import { Routes, Route, useOutlet } from 'react-router'; import { ScaffolderPage } from './ScaffolderPage'; import { TemplatePage } from './TemplatePage'; @@ -27,18 +27,23 @@ import { FIELD_EXTENSION_KEY, DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS, } from '../extensions'; -import { useElementCollection } from '@backstage/core-plugin-api'; +import { useElementFilter } from '@backstage/core-plugin-api'; export const Router = () => { const outlet = useOutlet(); - const foundExtensions = useElementCollection(outlet) - .findByComponentData({ - key: FIELD_EXTENSION_WRAPPER_KEY, - }) - .listComponentData({ - key: FIELD_EXTENSION_KEY, - }); + const foundExtensions = useElementFilter(outlet, elements => + elements + .selectByComponentData({ + key: FIELD_EXTENSION_WRAPPER_KEY, + }) + .select({ + key: FIELD_EXTENSION_WRAPPER_KEY, + }) + .getComponentData({ + key: FIELD_EXTENSION_KEY, + }), + ); const fieldExtensions = foundExtensions.length ? foundExtensions From 359a118902e6a652f2a2343acfd9cc852d3cf972 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 11 Jun 2021 10:33:10 +0200 Subject: [PATCH 06/59] chore: reworking some thigns Signed-off-by: blam --- .../core-app-api/src/routing/FlatRoutes.tsx | 66 ++++++++++--------- .../src/extensions/useElementFilter.test.tsx | 3 +- plugins/catalog/package.json | 1 + .../components/EntityLayout/EntityLayout.tsx | 42 ++++++------ plugins/scaffolder/src/components/Router.tsx | 5 +- yarn.lock | 1 + 6 files changed, 60 insertions(+), 58 deletions(-) diff --git a/packages/core-app-api/src/routing/FlatRoutes.tsx b/packages/core-app-api/src/routing/FlatRoutes.tsx index c185373dc1..ad1f764e6c 100644 --- a/packages/core-app-api/src/routing/FlatRoutes.tsx +++ b/packages/core-app-api/src/routing/FlatRoutes.tsx @@ -16,7 +16,7 @@ import React, { ReactNode } from 'react'; import { useRoutes } from 'react-router-dom'; -import { useApp, useElementCollection } from '@backstage/core-plugin-api'; +import { useApp, useElementFilter } from '@backstage/core-plugin-api'; type RouteObject = { path: string; @@ -31,39 +31,41 @@ type FlatRoutesProps = { export const FlatRoutes = (props: FlatRoutesProps): JSX.Element | null => { const app = useApp(); const { NotFoundErrorPage } = app.getComponents(); - const routes = useElementCollection(props.children) - .listElements<{ path?: string; children: ReactNode }>() - .flatMap(child => { - let path = child.props.path; + const routes = useElementFilter(props.children, elements => + elements + .getElements<{ path?: string; children: ReactNode }>() + .flatMap(child => { + let path = child.props.path; - // TODO(Rugvip): Work around plugins registering empty paths, remove once deprecated routes are gone - if (path === '') { - return []; - } - path = path?.replace(/\/\*$/, '') ?? '/'; + // TODO(Rugvip): Work around plugins registering empty paths, remove once deprecated routes are gone + if (path === '') { + return []; + } + path = path?.replace(/\/\*$/, '') ?? '/'; - return [ - { - path, - element: child, - children: child.props.children - ? [ - { - path: '/*', - element: child.props.children, - }, - ] - : undefined, - }, - ]; - }) - // Routes are sorted to work around a bug where prefixes are unexpectedly matched - .sort((a, b) => b.path.localeCompare(a.path)) - // We make sure all routes have '/*' appended, except '/' - .map(obj => { - obj.path = obj.path === '/' ? '/' : `${obj.path}/*`; - return obj; - }); + return [ + { + path, + element: child, + children: child.props.children + ? [ + { + path: '/*', + element: child.props.children, + }, + ] + : undefined, + }, + ]; + }) + // Routes are sorted to work around a bug where prefixes are unexpectedly matched + .sort((a, b) => b.path.localeCompare(a.path)) + // We make sure all routes have '/*' appended, except '/' + .map(obj => { + obj.path = obj.path === '/' ? '/' : `${obj.path}/*`; + return obj; + }), + ); // TODO(Rugvip): Possibly add a way to skip this, like a noNotFoundPage prop routes.push({ diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx index 4725de66c2..23a7fd77a7 100644 --- a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx +++ b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx @@ -220,7 +220,7 @@ describe('useElementFilter', () => { expect(result.current.length).toBe(2); }); - it('should reject with', () => { + it('should reject when strict mode is enabled with the correct string', () => { const tree = (

Hello

@@ -234,7 +234,6 @@ describe('useElementFilter', () => { .selectByComponentData({ key: WRAPPING_COMPONENT_KEY, withStrictError: 'Could not find component', - // errorIfNotFullMatch? }) .findComponentData({ key: INNER_COMPONENT_KEY }), ), diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index ce72d95103..56299462c6 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -33,6 +33,7 @@ "@backstage/catalog-client": "^0.3.13", "@backstage/catalog-model": "^0.8.2", "@backstage/core": "^0.7.12", + "@backstage/core-plugin-api": "^0.1.1", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.6", "@backstage/integration-react": "^0.1.3", diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index 7cd4ab3dd1..aceb1da6f2 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -29,7 +29,7 @@ import { Progress, RoutedTabs, } from '@backstage/core'; -import { useElementCollection } from '@backstage/core-plugin-api'; +import { useElementFilter } from '@backstage/core-plugin-api'; import { EntityContext, EntityRefLinks, @@ -157,26 +157,28 @@ export const EntityLayout = ({ const { kind, namespace, name } = useEntityCompoundName(); const { entity, loading, error } = useContext(EntityContext); - const routes = useElementCollection(children) - .findByComponentData({ - key: dataKey, - withStrictError: 'Child of EntityLayout must be an EntityLayout.Route', - }) - .listElements() // all nodes, element data, maintain structure or not? - .flatMap(({ props }) => { - if (props.if && entity && !props.if(entity)) { - return []; - } + const routes = useElementFilter(children, elements => + elements + .selectByComponentData({ + key: dataKey, + withStrictError: 'Child of EntityLayout must be an EntityLayout.Route', + }) + .getElements() // all nodes, element data, maintain structure or not? + .flatMap(({ props }) => { + if (props.if && entity && !props.if(entity)) { + return []; + } - return [ - { - path: props.path, - title: props.title, - children: props.children, - tabProps: props.tabProps, - }, - ]; - }); + return [ + { + path: props.path, + title: props.title, + children: props.children, + tabProps: props.tabProps, + }, + ]; + }), + ); const { headerTitle, headerType } = headerProps( kind, diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx index e72b9a9a1e..ef0dd4082e 100644 --- a/plugins/scaffolder/src/components/Router.tsx +++ b/plugins/scaffolder/src/components/Router.tsx @@ -37,10 +37,7 @@ export const Router = () => { .selectByComponentData({ key: FIELD_EXTENSION_WRAPPER_KEY, }) - .select({ - key: FIELD_EXTENSION_WRAPPER_KEY, - }) - .getComponentData({ + .findComponentData({ key: FIELD_EXTENSION_KEY, }), ); diff --git a/yarn.lock b/yarn.lock index 4bf6d53619..8c7cf9ee8f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1394,6 +1394,7 @@ "@backstage/catalog-client" "^0.3.13" "@backstage/catalog-model" "^0.8.2" "@backstage/core" "^0.7.12" + "@backstage/core-plugin-api" "^0.1.1" "@backstage/errors" "^0.1.1" "@backstage/integration" "^0.5.6" "@backstage/integration-react" "^0.1.3" From 31e067d346c20cd2d21197a3da73e2d603f09a6a Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 11 Jun 2021 10:52:51 +0200 Subject: [PATCH 07/59] chore: actually use the right packages - went and messed up a bit Signed-off-by: blam --- packages/app/package.json | 2 +- packages/core-plugin-api/package.json | 2 +- .../src/extensions/useElementFilter.test.tsx | 48 +++++++++++++++++-- plugins/catalog/package.json | 2 +- plugins/scaffolder/package.json | 2 +- yarn.lock | 2 +- 6 files changed, 50 insertions(+), 8 deletions(-) diff --git a/packages/app/package.json b/packages/app/package.json index bfd2b458ae..76d32b57ad 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -8,7 +8,7 @@ "@backstage/cli": "^0.7.0", "@backstage/core": "^0.7.12", "@backstage/integration-react": "^0.1.3", - "@backstage/core-app-api": "^0.1.1", + "@backstage/core-app-api": "^0.1.2", "@backstage/core-components": "^0.1.1", "@backstage/plugin-api-docs": "^0.4.15", "@backstage/plugin-badges": "^0.2.2", diff --git a/packages/core-plugin-api/package.json b/packages/core-plugin-api/package.json index b1d53e17c0..48706a5877 100644 --- a/packages/core-plugin-api/package.json +++ b/packages/core-plugin-api/package.json @@ -42,7 +42,7 @@ }, "devDependencies": { "@backstage/cli": "^0.7.0", - "@backstage/core-app-api": "^0.1.1", + "@backstage/core-app-api": "^0.1.2", "@backstage/test-utils": "^0.1.13", "@backstage/test-utils-core": "^0.1.1", "@testing-library/jest-dom": "^5.10.1", diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx index 23a7fd77a7..d5c1b0befe 100644 --- a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx +++ b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx @@ -27,7 +27,7 @@ import { const WRAPPING_COMPONENT_KEY = 'core.blob.testing'; const INNER_COMPONENT_KEY = 'core.blob2.testing'; -const WrappingComponent = (props: { children: ReactNode }) => null; +const WrappingComponent = (_props: { children: ReactNode }) => null; attachComponentData(WrappingComponent, WRAPPING_COMPONENT_KEY, { message: 'hey! im wrapping component data', }); @@ -35,9 +35,9 @@ const InnerComponent = () => null; attachComponentData(InnerComponent, INNER_COMPONENT_KEY, { message: 'hey! im the inner component', }); -const MockComponent = (props: { children: ReactNode }) => null; +const MockComponent = (_props: { children: ReactNode }) => null; -const FeatureFlagComponent = (props: { children: ReactNode; flag: string }) => +const FeatureFlagComponent = (_props: { children: ReactNode; flag: string }) => null; attachComponentData(FeatureFlagComponent, 'core.featureFlagged', true); const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); @@ -245,4 +245,46 @@ describe('useElementFilter', () => { expect(result.error.message).toEqual('Could not find component'); }); + + it('should support fragments and text node iteration', () => { + jest.spyOn(mockFeatureFlagsApi, 'isActive').mockImplementation(() => true); + const tree = ( + <> + + <> + + + + + + + + hello my name + <> + + + + + + is text + + + + ); + + const { result } = renderHook( + props => + useElementFilter(props.tree, elements => + elements + .selectByComponentData({ key: WRAPPING_COMPONENT_KEY }) + .getElements(), + ), + { + initialProps: { tree }, + wrapper: Wrapper, + }, + ); + + expect(result.current.length).toBe(2); + }); }); diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 56299462c6..e1e3b388a1 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -33,7 +33,7 @@ "@backstage/catalog-client": "^0.3.13", "@backstage/catalog-model": "^0.8.2", "@backstage/core": "^0.7.12", - "@backstage/core-plugin-api": "^0.1.1", + "@backstage/core-plugin-api": "^0.1.2", "@backstage/errors": "^0.1.1", "@backstage/integration": "^0.5.6", "@backstage/integration-react": "^0.1.3", diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 3cb046966a..9790073211 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -37,7 +37,7 @@ "@backstage/integration": "^0.5.6", "@backstage/integration-react": "^0.1.3", "@backstage/plugin-catalog-react": "^0.2.2", - "@backstage/core-plugin-api": "^0.1.1", + "@backstage/core-plugin-api": "^0.1.2", "@backstage/errors": "^0.1.1", "@backstage/theme": "^0.2.8", "@material-ui/core": "^4.11.0", diff --git a/yarn.lock b/yarn.lock index 8c7cf9ee8f..708b96efcc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1394,7 +1394,7 @@ "@backstage/catalog-client" "^0.3.13" "@backstage/catalog-model" "^0.8.2" "@backstage/core" "^0.7.12" - "@backstage/core-plugin-api" "^0.1.1" + "@backstage/core-plugin-api" "^0.1.2" "@backstage/errors" "^0.1.1" "@backstage/integration" "^0.5.6" "@backstage/integration-react" "^0.1.3" From 124be46a5a35fd46377b649e6b85f18e12affa23 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 11 Jun 2021 11:31:26 +0200 Subject: [PATCH 08/59] chore: resetting yarn lock Signed-off-by: blam --- yarn.lock | 1 - 1 file changed, 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 708b96efcc..4bf6d53619 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1394,7 +1394,6 @@ "@backstage/catalog-client" "^0.3.13" "@backstage/catalog-model" "^0.8.2" "@backstage/core" "^0.7.12" - "@backstage/core-plugin-api" "^0.1.2" "@backstage/errors" "^0.1.1" "@backstage/integration" "^0.5.6" "@backstage/integration-react" "^0.1.3" From 8ddd77544fa9ba262feca47c9a453f36284c6560 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 11 Jun 2021 11:38:30 +0200 Subject: [PATCH 09/59] chore: reworking yarn.lock Signed-off-by: blam --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index 4bf6d53619..708b96efcc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1394,6 +1394,7 @@ "@backstage/catalog-client" "^0.3.13" "@backstage/catalog-model" "^0.8.2" "@backstage/core" "^0.7.12" + "@backstage/core-plugin-api" "^0.1.2" "@backstage/errors" "^0.1.1" "@backstage/integration" "^0.5.6" "@backstage/integration-react" "^0.1.3" From 9eb3729706d5a7a874fb03bb0609b98801dd591b Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 11 Jun 2021 13:16:20 +0200 Subject: [PATCH 10/59] chore: fixing api-docs generation Co-authored-by: Johan Haals Signed-off-by: blam --- packages/core-app-api/api-report.md | 9 +++++++++ packages/core-plugin-api/api-report.md | 4 ++++ .../core-plugin-api/src/extensions/componentData.tsx | 2 -- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index b85c9392d7..2110bede38 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -234,6 +234,15 @@ export type ErrorBoundaryFallbackProps = { resetError: () => void; }; +// @public (undocumented) +export const FeatureFlagged: ({ children, flag }: FeatureFlaggedProps) => JSX.Element | null; + +// @public (undocumented) +export type FeatureFlaggedProps = { + flag: string; + children: JSX.Element | null; +}; + // @public (undocumented) export const FlatRoutes: (props: FlatRoutesProps) => JSX.Element | null; diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index a6f196847a..6e8cda3d83 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -8,6 +8,7 @@ import { BackstageTheme } from '@backstage/theme'; import { ComponentType } from 'react'; import { Config } from '@backstage/config'; import { default as React_2 } from 'react'; +import { ReactElement } from 'react'; import { ReactNode } from 'react'; import { SvgIconProps } from '@material-ui/core'; @@ -514,6 +515,9 @@ export function useApiHolder(): ApiHolder; // @public (undocumented) export const useApp: () => AppContext; +// @public (undocumented) +export function useElementFilter(node: ReactNode, filterFn: (arg: ElementCollection) => T, dependencies?: any[]): T; + // @public (undocumented) export type UserFlags = {}; diff --git a/packages/core-plugin-api/src/extensions/componentData.tsx b/packages/core-plugin-api/src/extensions/componentData.tsx index 059b8e1304..f835ad38a8 100644 --- a/packages/core-plugin-api/src/extensions/componentData.tsx +++ b/packages/core-plugin-api/src/extensions/componentData.tsx @@ -50,8 +50,6 @@ export function attachComponentData

( } container.map.set(type, data); - - return component; } export function getComponentData( From 5f4339b8c597fea8bd76d91b1620347c6a586ef7 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 11 Jun 2021 13:19:33 +0200 Subject: [PATCH 11/59] chore: added changeset Co-authored-by: Johan Haals Signed-off-by: blam --- .changeset/few-windows-whisper.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/few-windows-whisper.md diff --git a/.changeset/few-windows-whisper.md b/.changeset/few-windows-whisper.md new file mode 100644 index 0000000000..565e8798a1 --- /dev/null +++ b/.changeset/few-windows-whisper.md @@ -0,0 +1,8 @@ +--- +'@backstage/core-app-api': patch +'@backstage/core-plugin-api': patch +'@backstage/plugin-catalog': patch +'@backstage/plugin-scaffolder': patch +--- + +Adding `FeatureFlag` component and treating `FeatureFlags` as first class citizens to composability API From c92f5dedee4e0e80002f1176444c62cfe880e546 Mon Sep 17 00:00:00 2001 From: blam Date: Sat, 12 Jun 2021 17:35:08 +0200 Subject: [PATCH 12/59] feat: reworking the api in favour of `with` and `without` for wrapping component Signed-off-by: blam --- .../src/routing/FeatureFlagged.test.tsx | 102 ++++++++++++++++++ .../src/routing/FeatureFlagged.tsx | 20 +++- 2 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 packages/core-app-api/src/routing/FeatureFlagged.test.tsx diff --git a/packages/core-app-api/src/routing/FeatureFlagged.test.tsx b/packages/core-app-api/src/routing/FeatureFlagged.test.tsx new file mode 100644 index 0000000000..e20af76ae1 --- /dev/null +++ b/packages/core-app-api/src/routing/FeatureFlagged.test.tsx @@ -0,0 +1,102 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { FeatureFlagged } from './FeatureFlagged'; +import { render } from '@testing-library/react'; +import { ApiProvider, ApiRegistry, LocalStorageFeatureFlags } from '../apis'; +import { featureFlagsApiRef } from '@backstage/core-plugin-api'; + +const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); +const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + +); + +describe('FeatureFlagged', () => { + describe('with', () => { + it('should render contents when the feature flag is enabled', async () => { + jest + .spyOn(mockFeatureFlagsApi, 'isActive') + .mockImplementation(() => true); + + const { queryByText } = render( + +

+ +

BACKSTAGE!

+
+
+ , + ); + + expect(await queryByText('BACKSTAGE!')).toBeInTheDocument(); + }); + it('should not render contents when the feature flag is disabled', async () => { + jest + .spyOn(mockFeatureFlagsApi, 'isActive') + .mockImplementation(() => false); + + const { queryByText } = render( + +
+ +

BACKSTAGE!

+
+
+
, + ); + + expect(await queryByText('BACKSTAGE!')).not.toBeInTheDocument(); + }); + }); + describe('without', () => { + it('should not render contents when the feature flag is enabled', async () => { + jest + .spyOn(mockFeatureFlagsApi, 'isActive') + .mockImplementation(() => true); + + const { queryByText } = render( + +
+ +

BACKSTAGE!

+
+
+
, + ); + + expect(await queryByText('BACKSTAGE!')).not.toBeInTheDocument(); + }); + it('should render contents when the feature flag is disabled', async () => { + jest + .spyOn(mockFeatureFlagsApi, 'isActive') + .mockImplementation(() => false); + + const { queryByText } = render( + +
+ +

BACKSTAGE!

+
+
+
, + ); + + expect(await queryByText('BACKSTAGE!')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/core-app-api/src/routing/FeatureFlagged.tsx b/packages/core-app-api/src/routing/FeatureFlagged.tsx index 0adda78842..5b20d9aaac 100644 --- a/packages/core-app-api/src/routing/FeatureFlagged.tsx +++ b/packages/core-app-api/src/routing/FeatureFlagged.tsx @@ -20,14 +20,26 @@ import { attachComponentData, } from '@backstage/core-plugin-api'; -export type FeatureFlaggedProps = { - flag: string; +export type FeatureFlaggedWith = { + with: string; +}; + +export type FeatureFlaggedWithout = { + without: string; +}; + +export type FeatureFlaggedSelector = FeatureFlaggedWith | FeatureFlaggedWithout; +export type FeatureFlaggedProps = FeatureFlaggedSelector & { children: JSX.Element | null; }; -export const FeatureFlagged = ({ children, flag }: FeatureFlaggedProps) => { +export const FeatureFlagged = (props: FeatureFlaggedProps) => { + const { children } = props; const featureFlagApi = useApi(featureFlagsApiRef); - const isEnabled = featureFlagApi.isActive(flag); + const isEnabled = + 'with' in props + ? featureFlagApi.isActive(props.with) + : !featureFlagApi.isActive(props.without); return isEnabled ? children : null; }; From 128279fc4f91f9d04cb1287cdf1b5ce7a54acd21 Mon Sep 17 00:00:00 2001 From: blam Date: Sat, 12 Jun 2021 17:41:21 +0200 Subject: [PATCH 13/59] feat: finishing up some other use cases and re-writing some logic with some more tests Signed-off-by: blam --- packages/app/src/App.tsx | 2 +- .../app/src/components/catalog/EntityPage.tsx | 4 +- .../core-app-api/src/routing/collectors.tsx | 4 +- .../src/extensions/useElementFilter.test.tsx | 207 ++++++++++++------ .../src/extensions/useElementFilter.tsx | 8 +- packages/core-plugin-api/src/index.test.ts | 1 + 6 files changed, 158 insertions(+), 68 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 12cbe89f26..de11afeed4 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -114,7 +114,7 @@ const routes = ( path="/tech-radar" element={} /> - + } /> } /> diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 61d62136de..41d2d6f8c2 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -283,7 +283,7 @@ const serviceEntityPage = ( {overviewContent}
- + {cicdContent} @@ -306,7 +306,7 @@ const serviceEntityPage = ( - + diff --git a/packages/core-app-api/src/routing/collectors.tsx b/packages/core-app-api/src/routing/collectors.tsx index 31c1ab6361..b9ca45f54a 100644 --- a/packages/core-app-api/src/routing/collectors.tsx +++ b/packages/core-app-api/src/routing/collectors.tsx @@ -177,8 +177,8 @@ export const featureFlagCollector = createCollector( () => new Set(), (acc, node) => { if (node.type === FeatureFlagged) { - const { flag } = node.props as FeatureFlaggedProps; - acc.add(flag); + const props = node.props as FeatureFlaggedProps; + acc.add('with' in props ? props.with : props.without); } }, ); diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx index d5c1b0befe..bb2bcb06a5 100644 --- a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx +++ b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx @@ -37,8 +37,11 @@ attachComponentData(InnerComponent, INNER_COMPONENT_KEY, { }); const MockComponent = (_props: { children: ReactNode }) => null; -const FeatureFlagComponent = (_props: { children: ReactNode; flag: string }) => - null; +const FeatureFlagComponent = (_props: { + children: ReactNode; + with?: string; + without?: string; +}) => null; attachComponentData(FeatureFlagComponent, 'core.featureFlagged', true); const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); const Wrapper = ({ children }: { children?: React.ReactNode }) => ( @@ -151,73 +154,155 @@ describe('useElementFilter', () => { }); }); - it('should not discover deeper than the feature gate if the feature flag is disabled', () => { - jest.spyOn(mockFeatureFlagsApi, 'isActive').mockImplementation(() => false); - const tree = ( - - - + describe('FeatureFlags', () => { + describe('with', () => { + it('should not discover deeper than the feature gate if the feature flag is disabled', () => { + jest + .spyOn(mockFeatureFlagsApi, 'isActive') + .mockImplementation(() => false); + const tree = ( + + + + + + + + + + + - - - - + + ); + + const { result } = renderHook( + props => + useElementFilter(props.tree, elements => + elements + .selectByComponentData({ key: WRAPPING_COMPONENT_KEY }) + .getElements(), + ), + { + initialProps: { tree }, + wrapper: Wrapper, + }, + ); + + expect(result.current.length).toBe(1); + expect(result.current[0].key).toContain('second'); + }); + + it('should discover components behind a feature flag if the flag is enabled', () => { + jest + .spyOn(mockFeatureFlagsApi, 'isActive') + .mockImplementation(() => true); + const tree = ( + + + + + + + + + + + - - - - - ); + + ); - const { result } = renderHook( - props => - useElementFilter(props.tree, elements => - elements - .selectByComponentData({ key: WRAPPING_COMPONENT_KEY }) - .getElements(), - ), - { - initialProps: { tree }, - wrapper: Wrapper, - }, - ); + const { result } = renderHook( + props => + useElementFilter(props.tree, elements => + elements + .selectByComponentData({ key: WRAPPING_COMPONENT_KEY }) + .getElements(), + ), + { + initialProps: { tree }, + wrapper: Wrapper, + }, + ); - expect(result.current.length).toBe(1); - expect(result.current[0].key).toContain('second'); - }); + expect(result.current.length).toBe(2); + }); + }); - it('should discover components behind a feature flag if the flag is enabled', () => { - jest.spyOn(mockFeatureFlagsApi, 'isActive').mockImplementation(() => true); - const tree = ( - - - + describe('without', () => { + it('should discover deeper than the feature gate if the feature flag is disabled', () => { + jest + .spyOn(mockFeatureFlagsApi, 'isActive') + .mockImplementation(() => false); + const tree = ( + + + + + + + + + + + - - - - + + ); + + const { result } = renderHook( + props => + useElementFilter(props.tree, elements => + elements + .selectByComponentData({ key: WRAPPING_COMPONENT_KEY }) + .getElements(), + ), + { + initialProps: { tree }, + wrapper: Wrapper, + }, + ); + + expect(result.current.length).toBe(2); + }); + + it('should not discover components behind a feature flag if the flag is enabled', () => { + jest + .spyOn(mockFeatureFlagsApi, 'isActive') + .mockImplementation(() => true); + const tree = ( + + + + + + + + + + + - - - - - ); + + ); - const { result } = renderHook( - props => - useElementFilter(props.tree, elements => - elements - .selectByComponentData({ key: WRAPPING_COMPONENT_KEY }) - .getElements(), - ), - { - initialProps: { tree }, - wrapper: Wrapper, - }, - ); + const { result } = renderHook( + props => + useElementFilter(props.tree, elements => + elements + .selectByComponentData({ key: WRAPPING_COMPONENT_KEY }) + .getElements(), + ), + { + initialProps: { tree }, + wrapper: Wrapper, + }, + ); - expect(result.current.length).toBe(2); + expect(result.current.length).toBe(1); + }); + }); }); it('should reject when strict mode is enabled with the correct string', () => { @@ -252,7 +337,7 @@ describe('useElementFilter', () => { <> <> - + diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.tsx index 9ed070fd6c..b33c23e422 100644 --- a/packages/core-plugin-api/src/extensions/useElementFilter.tsx +++ b/packages/core-plugin-api/src/extensions/useElementFilter.tsx @@ -45,8 +45,12 @@ function selectChildren( } if (getComponentData(node, 'core.featureFlagged')) { - const { flag } = node.props as { flag: string }; - if (featureFlagsApi.isActive(flag)) { + const props = node.props as { with: string } | { without: string }; + const isEnabled = + 'with' in props + ? featureFlagsApi.isActive(props.with) + : !featureFlagsApi.isActive(props.without); + if (isEnabled) { return selectChildren( node.props.children, featureFlagsApi, diff --git a/packages/core-plugin-api/src/index.test.ts b/packages/core-plugin-api/src/index.test.ts index 9414a6b730..5d29ead619 100644 --- a/packages/core-plugin-api/src/index.test.ts +++ b/packages/core-plugin-api/src/index.test.ts @@ -40,6 +40,7 @@ describe('index', () => { useApi: expect.any(Function), useApiHolder: expect.any(Function), useApp: expect.any(Function), + useElementFilter: expect.any(Function), useRouteRef: expect.any(Function), useRouteRefParams: expect.any(Function), withApis: expect.any(Function), From 6109f32b788438f08ed9dc240e50a0c208ccd775 Mon Sep 17 00:00:00 2001 From: blam Date: Sat, 12 Jun 2021 17:43:18 +0200 Subject: [PATCH 14/59] chore: fixing up api-docs exporting Signed-off-by: blam --- packages/core-app-api/api-report.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 2110bede38..029b0faff8 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -235,11 +235,10 @@ export type ErrorBoundaryFallbackProps = { }; // @public (undocumented) -export const FeatureFlagged: ({ children, flag }: FeatureFlaggedProps) => JSX.Element | null; +export const FeatureFlagged: (props: FeatureFlaggedProps) => JSX.Element | null; // @public (undocumented) -export type FeatureFlaggedProps = { - flag: string; +export type FeatureFlaggedProps = FeatureFlaggedSelector & { children: JSX.Element | null; }; From 00815e520b78e2658ec061407fed1773b0cca3d0 Mon Sep 17 00:00:00 2001 From: blam Date: Sun, 13 Jun 2021 04:36:06 +0200 Subject: [PATCH 15/59] chore: fixing api-docs and simplifying types a little Signed-off-by: blam --- packages/core-app-api/api-report.md | 8 ++++++-- packages/core-app-api/src/index.test.ts | 1 + .../core-app-api/src/routing/FeatureFlagged.tsx | 17 ++++------------- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 029b0faff8..9b68d2a10d 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -238,9 +238,13 @@ export type ErrorBoundaryFallbackProps = { export const FeatureFlagged: (props: FeatureFlaggedProps) => JSX.Element | null; // @public (undocumented) -export type FeatureFlaggedProps = FeatureFlaggedSelector & { +export type FeatureFlaggedProps = { children: JSX.Element | null; -}; +} & ({ + with: string; +} | { + without: string; +}); // @public (undocumented) export const FlatRoutes: (props: FlatRoutesProps) => JSX.Element | null; diff --git a/packages/core-app-api/src/index.test.ts b/packages/core-app-api/src/index.test.ts index bec176fda8..654c547fca 100644 --- a/packages/core-app-api/src/index.test.ts +++ b/packages/core-app-api/src/index.test.ts @@ -37,6 +37,7 @@ describe('index', () => { ConfigReader: expect.any(Function), ErrorAlerter: expect.any(Function), ErrorApiForwarder: expect.any(Function), + FeatureFlagged: expect.any(Function), GithubAuth: expect.any(Function), GitlabAuth: expect.any(Function), GoogleAuth: expect.any(Function), diff --git a/packages/core-app-api/src/routing/FeatureFlagged.tsx b/packages/core-app-api/src/routing/FeatureFlagged.tsx index 5b20d9aaac..809eb7c69c 100644 --- a/packages/core-app-api/src/routing/FeatureFlagged.tsx +++ b/packages/core-app-api/src/routing/FeatureFlagged.tsx @@ -20,19 +20,10 @@ import { attachComponentData, } from '@backstage/core-plugin-api'; -export type FeatureFlaggedWith = { - with: string; -}; - -export type FeatureFlaggedWithout = { - without: string; -}; - -export type FeatureFlaggedSelector = FeatureFlaggedWith | FeatureFlaggedWithout; -export type FeatureFlaggedProps = FeatureFlaggedSelector & { - children: JSX.Element | null; -}; - +export type FeatureFlaggedProps = { children: JSX.Element | null } & ( + | { with: string } + | { without: string } +); export const FeatureFlagged = (props: FeatureFlaggedProps) => { const { children } = props; const featureFlagApi = useApi(featureFlagsApiRef); From fdb79d1562f5475d1f9458c95c750c15bb64e4c0 Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 14 Jun 2021 10:11:13 +0200 Subject: [PATCH 16/59] chore: fixing tests for FlatRoutes Signed-off-by: blam --- .../src/routing/FlatRoutes.test.tsx | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/core-app-api/src/routing/FlatRoutes.test.tsx b/packages/core-app-api/src/routing/FlatRoutes.test.tsx index a9b83d1016..3ed5d5eb3a 100644 --- a/packages/core-app-api/src/routing/FlatRoutes.test.tsx +++ b/packages/core-app-api/src/routing/FlatRoutes.test.tsx @@ -17,25 +17,36 @@ import { render, RenderResult } from '@testing-library/react'; import React, { ReactNode } from 'react'; import { MemoryRouter, Route, Routes, useOutlet } from 'react-router-dom'; +import { ApiProvider, ApiRegistry, LocalStorageFeatureFlags } from '../apis'; +import { featureFlagsApiRef } from '@backstage/core-plugin-api'; import { AppContext } from '../app'; import { AppContextProvider } from '../app/AppContext'; import { FlatRoutes } from './FlatRoutes'; +const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); +const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + +); + function makeRouteRenderer(node: ReactNode) { let rendered: RenderResult | undefined = undefined; return (path: string) => { const content = ( - ({ - NotFoundErrorPage: () => <>Not Found, - }), - } as unknown) as AppContext - } - > - - + + ({ + NotFoundErrorPage: () => <>Not Found, + }), + } as unknown) as AppContext + } + > + + + ); if (rendered) { rendered.unmount(); From 04fa2df35247dce218e05a59b158b0fc30447b98 Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 14 Jun 2021 10:18:37 +0200 Subject: [PATCH 17/59] chore: wait for the config api to be loaded first before registering feature featureFlags Signed-off-by: blam --- packages/core-app-api/src/app/App.tsx | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/core-app-api/src/app/App.tsx b/packages/core-app-api/src/app/App.tsx index 1d77282635..ecf06955b0 100644 --- a/packages/core-app-api/src/app/App.tsx +++ b/packages/core-app-api/src/app/App.tsx @@ -216,7 +216,12 @@ export class PrivateAppImpl implements BackstageApp { [], ); - const { routePaths, routeParents, routeObjects } = useMemo(() => { + const { + routePaths, + routeParents, + routeObjects, + featureFlags, + } = useMemo(() => { const result = traverseElementTree({ root: children, discoverers: [childDiscoverer, routeElementDiscoverer], @@ -239,14 +244,7 @@ export class PrivateAppImpl implements BackstageApp { this.verifyPlugins(this.plugins); // Initialize APIs once all plugins are available - const apiHolder = this.getApiHolder(); - - // Register feature flags that have been discovered - const featureFlagApi = apiHolder.get(featureFlagsApiRef)!; - for (const name of result.featureFlags) { - featureFlagApi.registerFlag({ name, pluginId: '' }); - } - + this.getApiHolder(); return result; }, [children]); @@ -281,8 +279,14 @@ export class PrivateAppImpl implements BackstageApp { } } } + + // Go through the featureFlags returned from the traversal and + // register those now the configApi has been loaded + for (const name of featureFlags) { + featureFlagsApi.registerFlag({ name, pluginId: '' }); + } } - }, [hasConfigApi, loadedConfig]); + }, [hasConfigApi, loadedConfig, featureFlags]); if ('node' in loadedConfig) { // Loading or error From f547a1473e70ca5c2b9a3803af70363e04d08631 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 15 Jun 2021 11:59:29 +0200 Subject: [PATCH 18/59] feat: update the usage of the EntitySwitch to use the new traversal composability API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Signed-off-by: blam --- plugins/catalog/package.json | 1 + .../EntitySwitch/EntitySwitch.test.tsx | 101 +++++++++++------- .../components/EntitySwitch/EntitySwitch.tsx | 49 ++++----- 3 files changed, 83 insertions(+), 68 deletions(-) diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index e1e3b388a1..16e756eabe 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -55,6 +55,7 @@ }, "devDependencies": { "@backstage/cli": "^0.7.0", + "@backstage/core-app-api": "^0.1.2", "@backstage/dev-utils": "^0.1.17", "@backstage/test-utils": "^0.1.13", "@testing-library/jest-dom": "^5.10.1", diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx index 20b9aaba4f..5b21266d44 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx @@ -20,6 +20,19 @@ import { render } from '@testing-library/react'; import React from 'react'; import { isKind } from './conditions'; import { EntitySwitch } from './EntitySwitch'; +import { featureFlagsApiRef } from '@backstage/core-plugin-api'; +import { + LocalStorageFeatureFlags, + ApiProvider, + ApiRegistry, +} from '@backstage/core-app-api'; + +const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); +const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + +); describe('EntitySwitch', () => { it('should switch child when entity switches', () => { @@ -32,15 +45,17 @@ describe('EntitySwitch', () => { ); const rendered = render( - - {content} - , + + + {content} + + , ); expect(rendered.queryByText('A')).toBeInTheDocument(); @@ -48,15 +63,17 @@ describe('EntitySwitch', () => { expect(rendered.queryByText('C')).not.toBeInTheDocument(); rendered.rerender( - - {content} - , + + + {content} + + , ); expect(rendered.queryByText('A')).not.toBeInTheDocument(); @@ -64,15 +81,17 @@ describe('EntitySwitch', () => { expect(rendered.queryByText('C')).not.toBeInTheDocument(); rendered.rerender( - - {content} - , + + + {content} + + , ); expect(rendered.queryByText('A')).not.toBeInTheDocument(); @@ -88,24 +107,28 @@ describe('EntitySwitch', () => { }; const rendered = render( - - - - - - , + + + + + + + + , ); expect(rendered.queryByText('A')).toBeInTheDocument(); expect(rendered.queryByText('B')).not.toBeInTheDocument(); rendered.rerender( - - - - - - , + + + + + + + + , ); expect(rendered.queryByText('A')).not.toBeInTheDocument(); diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx index a3ede42288..4b12a7249b 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.tsx @@ -16,49 +16,40 @@ import { Entity } from '@backstage/catalog-model'; import { useEntity } from '@backstage/plugin-catalog-react'; +import { PropsWithChildren, ReactNode } from 'react'; import { - Children, - Fragment, - isValidElement, - PropsWithChildren, - ReactNode, - useMemo, -} from 'react'; + attachComponentData, + useElementFilter, +} from '@backstage/core-plugin-api'; + +const ENTITY_SWITCH_KEY = 'core.backstage.entitySwitch'; const EntitySwitchCase = (_: { if?: (entity: Entity) => boolean; children: ReactNode; }) => null; +attachComponentData(EntitySwitchCase, ENTITY_SWITCH_KEY, true); + type SwitchCase = { if?: (entity: Entity) => boolean; children: JSX.Element; }; -function createSwitchCasesFromChildren(childrenNode: ReactNode): SwitchCase[] { - return Children.toArray(childrenNode).flatMap(child => { - if (!isValidElement(child)) { - return []; - } - - if (child.type === Fragment) { - return createSwitchCasesFromChildren(child.props.children); - } - - if (child.type !== EntitySwitchCase) { - throw new Error(`Child of EntitySwitch is not an EntitySwitch.Case`); - } - - const { if: condition, children } = child.props; - return [{ if: condition, children }]; - }); -} - export const EntitySwitch = ({ children }: PropsWithChildren<{}>) => { const { entity } = useEntity(); - const switchCases = useMemo(() => createSwitchCasesFromChildren(children), [ - children, - ]); + const switchCases = useElementFilter(children, collection => + collection + .selectByComponentData({ + key: ENTITY_SWITCH_KEY, + withStrictError: 'Child of EntitySwitch is not an EntitySwitch.Case', + }) + .getElements() + .flatMap((element: React.ReactElement) => { + const { if: condition, children: elementsChildren } = element.props; + return [{ if: condition, children: elementsChildren }]; + }), + ); const matchingCase = switchCases.find(switchCase => switchCase.if ? switchCase.if(entity) : true, From 34900b0ca6e860512ee6bdf1d939c2495df1bbe3 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 15 Jun 2021 12:00:39 +0200 Subject: [PATCH 19/59] chore: removing the test usages in the app, and fixing the typings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Signed-off-by: blam --- packages/app/src/App.tsx | 4 +--- packages/app/src/components/catalog/EntityPage.tsx | 8 +++----- packages/core-app-api/src/routing/FeatureFlagged.tsx | 6 ++++-- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index de11afeed4..a7ed41d2a4 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -114,9 +114,7 @@ const routes = ( path="/tech-radar" element={} /> - - } /> - + } /> } /> } /> diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 41d2d6f8c2..d1c5d44f85 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -306,11 +306,9 @@ const serviceEntityPage = ( - - - - - + + + diff --git a/packages/core-app-api/src/routing/FeatureFlagged.tsx b/packages/core-app-api/src/routing/FeatureFlagged.tsx index 809eb7c69c..40ef445f92 100644 --- a/packages/core-app-api/src/routing/FeatureFlagged.tsx +++ b/packages/core-app-api/src/routing/FeatureFlagged.tsx @@ -19,11 +19,13 @@ import { useApi, attachComponentData, } from '@backstage/core-plugin-api'; +import React, { ReactNode } from 'react'; -export type FeatureFlaggedProps = { children: JSX.Element | null } & ( +export type FeatureFlaggedProps = { children: ReactNode } & ( | { with: string } | { without: string } ); + export const FeatureFlagged = (props: FeatureFlaggedProps) => { const { children } = props; const featureFlagApi = useApi(featureFlagsApiRef); @@ -31,7 +33,7 @@ export const FeatureFlagged = (props: FeatureFlaggedProps) => { 'with' in props ? featureFlagApi.isActive(props.with) : !featureFlagApi.isActive(props.without); - return isEnabled ? children : null; + return <>{isEnabled ? children : null}; }; attachComponentData(FeatureFlagged, 'core.featureFlagged', true); From 14770c4f3ab8140addb76d7fd7007b8afd65399c Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 15 Jun 2021 12:22:26 +0200 Subject: [PATCH 20/59] chore: tidy up the app and fix tsc warnings Signed-off-by: blam --- packages/app/src/App.tsx | 2 +- packages/app/src/components/catalog/EntityPage.tsx | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index a7ed41d2a4..cc65cd65b3 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createApp, FlatRoutes, FeatureFlagged } from '@backstage/core-app-api'; +import { createApp, FlatRoutes } from '@backstage/core-app-api'; import { AlertDisplay, OAuthRequestDialog, diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index d1c5d44f85..f646992c97 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -108,7 +108,6 @@ import { isTravisciAvailable, } from '@roadiehq/backstage-plugin-travis-ci'; import { EntityCodeCoverageContent } from '@backstage/plugin-code-coverage'; -import { FeatureFlagged } from '@backstage/core-app-api'; const EntityLayoutWrapper = (props: { children?: ReactNode }) => { const [badgesDialogOpen, setBadgesDialogOpen] = useState(false); @@ -283,11 +282,9 @@ const serviceEntityPage = ( {overviewContent} - - - {cicdContent} - - + + {cicdContent} + {errorsContent} From d00a3fcd95ba855aa23154b381d54c6b1e00e488 Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 15 Jun 2021 12:24:36 +0200 Subject: [PATCH 21/59] chore: updated API report Signed-off-by: blam --- packages/core-app-api/api-report.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core-app-api/api-report.md b/packages/core-app-api/api-report.md index 9b68d2a10d..5aa709121a 100644 --- a/packages/core-app-api/api-report.md +++ b/packages/core-app-api/api-report.md @@ -235,11 +235,11 @@ export type ErrorBoundaryFallbackProps = { }; // @public (undocumented) -export const FeatureFlagged: (props: FeatureFlaggedProps) => JSX.Element | null; +export const FeatureFlagged: (props: FeatureFlaggedProps) => JSX.Element; // @public (undocumented) export type FeatureFlaggedProps = { - children: JSX.Element | null; + children: ReactNode; } & ({ with: string; } | { From de81880419e5fa372472e4f8753fd646dba87d74 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 14 Feb 2021 19:47:38 +0100 Subject: [PATCH 22/59] auth-backend: refactor CatalogIdentityClient to depend on TokenIssuer directly Signed-off-by: Patrik Oldsberg --- .../lib/catalog/CatalogIdentityClient.test.ts | 20 +++++++++++++++---- .../src/lib/catalog/CatalogIdentityClient.ts | 16 +++++++++------ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts index 0ec65b7a0b..57c2b3345b 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts @@ -16,6 +16,7 @@ import { CatalogApi } from '@backstage/catalog-client'; import { UserEntity } from '@backstage/catalog-model'; +import { TokenIssuer } from '../../identity'; import { CatalogIdentityClient } from './CatalogIdentityClient'; describe('CatalogIdentityClient', () => { @@ -29,25 +30,36 @@ describe('CatalogIdentityClient', () => { getLocationByEntity: jest.fn(), removeEntityByUid: jest.fn(), }; + const tokenIssuer: jest.Mocked = { + issueToken: jest.fn(), + listPublicKeys: jest.fn(), + }; afterEach(() => jest.resetAllMocks()); it('passes through the correct search params', async () => { catalogApi.getEntities.mockResolvedValueOnce({ items: [{} as UserEntity] }); + tokenIssuer.issueToken.mockResolvedValue('my-token'); const client = new CatalogIdentityClient({ - catalogApi: catalogApi as CatalogApi, + catalogApi: catalogApi, + tokenIssuer: tokenIssuer, }); - client.findUser({ annotations: { key: 'value' } }); + await client.findUser({ annotations: { key: 'value' } }); - expect(catalogApi.getEntities).toBeCalledWith( + expect(catalogApi.getEntities).toHaveBeenCalledWith( { filter: { kind: 'user', 'metadata.annotations.key': 'value', }, }, - undefined, + { token: 'my-token' }, ); + expect(tokenIssuer.issueToken).toHaveBeenCalledWith({ + claims: { + sub: 'backstage.io/auth-backend', + }, + }); }); }); diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts index abd3624df2..947b8dac1f 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts @@ -17,6 +17,7 @@ import { ConflictError, NotFoundError } from '@backstage/errors'; import { CatalogApi } from '@backstage/catalog-client'; import { UserEntity } from '@backstage/catalog-model'; +import { TokenIssuer } from '../../identity'; type UserQuery = { annotations: Record; @@ -27,9 +28,11 @@ type UserQuery = { */ export class CatalogIdentityClient { private readonly catalogApi: CatalogApi; + private readonly tokenIssuer: TokenIssuer; - constructor(options: { catalogApi: CatalogApi }) { + constructor(options: { catalogApi: CatalogApi; tokenIssuer: TokenIssuer }) { this.catalogApi = options.catalogApi; + this.tokenIssuer = options.tokenIssuer; } /** @@ -37,10 +40,7 @@ export class CatalogIdentityClient { * * Throws a NotFoundError or ConflictError if 0 or multiple users are found. */ - async findUser( - query: UserQuery, - options?: { token?: string }, - ): Promise { + async findUser(query: UserQuery): Promise { const filter: Record = { kind: 'user', }; @@ -48,7 +48,11 @@ export class CatalogIdentityClient { filter[`metadata.annotations.${key}`] = value; } - const { items } = await this.catalogApi.getEntities({ filter }, options); + // TODO(Rugvip): cache the token + const token = await this.tokenIssuer.issueToken({ + claims: { sub: 'backstage.io/auth-backend' }, + }); + const { items } = await this.catalogApi.getEntities({ filter }, { token }); if (items.length !== 1) { if (items.length > 1) { From 3430f0d1ff8898f52d7094f276f53cbaea76777c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 14 Feb 2021 19:49:36 +0100 Subject: [PATCH 23/59] auth-backend: add ent to possible claims in TokenParams Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/src/identity/TokenFactory.ts | 5 +++-- plugins/auth-backend/src/identity/types.ts | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/auth-backend/src/identity/TokenFactory.ts b/plugins/auth-backend/src/identity/TokenFactory.ts index 1c6192d5c5..ac8f974cdb 100644 --- a/plugins/auth-backend/src/identity/TokenFactory.ts +++ b/plugins/auth-backend/src/identity/TokenFactory.ts @@ -67,13 +67,14 @@ export class TokenFactory implements TokenIssuer { const iss = this.issuer; const sub = params.claims.sub; + const ent = params.claims.ent; const aud = 'backstage'; const iat = Math.floor(Date.now() / MS_IN_S); const exp = iat + this.keyDurationSeconds; - this.logger.info(`Issuing token for ${sub}`); + this.logger.info(`Issuing token for ${sub}, with entities ${ent ?? []}`); - return JWS.sign({ iss, sub, aud, iat, exp }, key, { + return JWS.sign({ iss, sub, aud, iat, exp, ent }, key, { alg: key.alg, kid: key.kid, }); diff --git a/plugins/auth-backend/src/identity/types.ts b/plugins/auth-backend/src/identity/types.ts index 6e984d41cc..2080b08cb6 100644 --- a/plugins/auth-backend/src/identity/types.ts +++ b/plugins/auth-backend/src/identity/types.ts @@ -28,6 +28,8 @@ export type TokenParams = { claims: { /** The token subject, i.e. User ID */ sub: string; + /** A list of entity references that the user claims ownership through */ + ent?: string[]; }; }; From 02d2b8681f24b489d7ff68543170600c6c218c3a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 14 Feb 2021 19:50:41 +0100 Subject: [PATCH 24/59] auth-backend: add getEntityClaims helper that figures out the claims needed to represent an entity Signed-off-by: Patrik Oldsberg --- .../auth-backend/src/lib/catalog/helpers.ts | 49 +++++++++++++++++++ plugins/auth-backend/src/lib/catalog/index.ts | 1 + 2 files changed, 50 insertions(+) create mode 100644 plugins/auth-backend/src/lib/catalog/helpers.ts diff --git a/plugins/auth-backend/src/lib/catalog/helpers.ts b/plugins/auth-backend/src/lib/catalog/helpers.ts new file mode 100644 index 0000000000..a73b797368 --- /dev/null +++ b/plugins/auth-backend/src/lib/catalog/helpers.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + UserEntity, + serializeEntityRef, + RELATION_MEMBER_OF, +} from '@backstage/catalog-model'; +import { TokenParams } from '../../identity'; + +export function getEntityClaims(entity: UserEntity): TokenParams['claims'] { + const userRef = serializeEntityRef(entity); + if (typeof userRef !== 'string') { + throw new Error( + `Failed to serialize user entity ref, ${JSON.stringify(userRef)}`, + ); + } + + const membershipRefs = + entity.relations + ?.filter(r => r.type === RELATION_MEMBER_OF) + .map(r => { + const ref = serializeEntityRef(r.target); + if (typeof ref !== 'string') { + throw new Error( + `Failed to serialize relation entity ref, ${JSON.stringify(ref)}`, + ); + } + return ref; + }) ?? []; + + return { + sub: userRef, + ent: [userRef, ...membershipRefs], + }; +} diff --git a/plugins/auth-backend/src/lib/catalog/index.ts b/plugins/auth-backend/src/lib/catalog/index.ts index f15469668f..fbd58081e3 100644 --- a/plugins/auth-backend/src/lib/catalog/index.ts +++ b/plugins/auth-backend/src/lib/catalog/index.ts @@ -15,3 +15,4 @@ */ export { CatalogIdentityClient } from './CatalogIdentityClient'; +export { getEntityClaims } from './helpers'; From 48a5dcf9d5caf9d91e4a9439bc2060e6ff042427 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 15 Feb 2021 12:35:02 +0100 Subject: [PATCH 25/59] auth-backend: added SignInResolver and ProfileTransform types Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/src/providers/types.ts | 54 ++++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 700af3ebef..1f71bfe59c 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -16,10 +16,12 @@ import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { CatalogApi } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import express from 'express'; import { Logger } from 'winston'; import { TokenIssuer } from '../identity/types'; +import { CatalogIdentityClient } from '../lib/catalog'; export type AuthProviderConfig = { /** @@ -148,14 +150,30 @@ export type AuthResponse = { export type BackstageIdentity = { /** - * The backstage user ID. + * An opaque ID that uniquely identifies the user within Backstage. + * + * This is typically the same as the user entity `metadata.name`. */ id: string; /** - * An ID token that can be used to authenticate the user within Backstage. + * This is deprecated, use `token` instead. + * @deprecated */ idToken?: string; + + /** + * The token used to authenticate the user within Backstage. + */ + token?: string; + + /** + * The entity that the user is represented by within Backstage. + * + * This entity may or may not exist within the Catalog, and it can be used + * to read and store additional metadata about the user. + */ + entity?: Entity; }; /** @@ -179,3 +197,35 @@ export type ProfileInfo = { */ picture?: string; }; + +export type SignInInfo = { + /** + * The simple profile passed down for use in the frontend. + */ + profile: ProfileInfo; + + /** + * The authentication result that was received from the authentication provider. + */ + result: AuthResult; +}; + +export type SignInResolver = ( + info: SignInInfo, + context: { + tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; + }, +) => Promise; + +/** + * A transformation function called every time the user authenticates using the provider. + * + * The transform should return a profile that represents the session for the user in the frontend. + * + * Throwing an error in the function will cause the authentication to fail, making it + * possible to use this function as a way to limit access to a certain group of users. + */ +export type ProfileTransform = ( + input: AuthResult, +) => Promise; From 1111c0753c152962b8bd02663b5ff8b1f446a3e7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 15 Feb 2021 12:35:25 +0100 Subject: [PATCH 26/59] auth-backend: implement sign-in and profile options for google provider Signed-off-by: Patrik Oldsberg --- .../src/providers/google/provider.ts | 189 +++++++++++------- plugins/auth-backend/src/providers/index.ts | 1 + 2 files changed, 114 insertions(+), 76 deletions(-) diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index a714674447..56295640be 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -17,8 +17,8 @@ import express from 'express'; import passport from 'passport'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; -import { Logger } from 'winston'; -import { CatalogIdentityClient } from '../../lib/catalog'; +import { TokenIssuer } from '../../identity/types'; +import { CatalogIdentityClient, getEntityClaims } from '../../lib/catalog'; import { encodeState, OAuthAdapter, @@ -27,8 +27,8 @@ import { OAuthProviderOptions, OAuthRefreshRequest, OAuthResponse, - OAuthStartRequest, OAuthResult, + OAuthStartRequest, } from '../../lib/oauth'; import { executeFetchUserProfileStrategy, @@ -38,30 +38,36 @@ import { makeProfileInfo, PassportDoneCallback, } from '../../lib/passport'; -import { AuthProviderFactory, RedirectInfo } from '../types'; -import { TokenIssuer } from '../../identity/types'; +import { + AuthProviderFactory, + ProfileTransform, + RedirectInfo, + SignInResolver, +} from '../types'; type PrivateInfo = { refreshToken: string; }; type Options = OAuthProviderOptions & { - logger: Logger; - identityClient: CatalogIdentityClient; + signInResolver?: SignInResolver; + profileTransform: ProfileTransform; tokenIssuer: TokenIssuer; + catalogIdentityClient: CatalogIdentityClient; }; export class GoogleAuthProvider implements OAuthHandlers { private readonly _strategy: GoogleStrategy; - private readonly logger: Logger; - private readonly identityClient: CatalogIdentityClient; + private readonly signInResolver?: SignInResolver; + private readonly profileTransform: ProfileTransform; private readonly tokenIssuer: TokenIssuer; + private readonly catalogIdentityClient: CatalogIdentityClient; constructor(options: Options) { - this.logger = options.logger; - this.identityClient = options.identityClient; + this.signInResolver = options.signInResolver; + this.profileTransform = options.profileTransform; this.tokenIssuer = options.tokenIssuer; - // TODO: throw error if env variables not set? + this.catalogIdentityClient = options.catalogIdentityClient; this._strategy = new GoogleStrategy( { clientID: options.clientId, @@ -111,18 +117,8 @@ export class GoogleAuthProvider implements OAuthHandlers { PrivateInfo >(req, this._strategy); - const profile = makeProfileInfo(result.fullProfile, result.params.id_token); - return { - response: await this.populateIdentity({ - providerInfo: { - idToken: result.params.id_token, - accessToken: result.accessToken, - scope: result.params.scope, - expiresInSeconds: result.params.expires_in, - }, - profile, - }), + response: await this.handleResult(result), refreshToken: privateInfo.refreshToken, }; } @@ -133,89 +129,130 @@ export class GoogleAuthProvider implements OAuthHandlers { req.refreshToken, req.scope, ); - const fullProfile = await executeFetchUserProfileStrategy( this._strategy, accessToken, ); - const profile = makeProfileInfo(fullProfile, params.id_token); - - return this.populateIdentity({ - providerInfo: { - accessToken, - idToken: params.id_token, - expiresInSeconds: params.expires_in, - scope: params.scope, - }, - profile, + return this.handleResult({ + fullProfile, + params, + accessToken, + refreshToken: req.refreshToken, }); } - private async populateIdentity( - response: OAuthResponse, - ): Promise { - const { profile } = response; + private async handleResult(result: OAuthResult) { + const profile = await this.profileTransform(result); - if (!profile.email) { - throw new Error('Google profile contained no email'); - } + const response: OAuthResponse = { + providerInfo: { + idToken: result.params.id_token, + accessToken: result.accessToken, + scope: result.params.scope, + expiresInSeconds: result.params.expires_in, + }, + profile, + }; - try { - const token = await this.tokenIssuer.issueToken({ - claims: { sub: 'backstage.io/auth-backend' }, - }); - const user = await this.identityClient.findUser( + if (this.signInResolver) { + response.backstageIdentity = await this.signInResolver( { - annotations: { - 'google.com/email': profile.email, - }, + result, + profile, }, - { token }, - ); - - return { - ...response, - backstageIdentity: { - id: user.metadata.name, + { + tokenIssuer: this.tokenIssuer, + catalogIdentityClient: this.catalogIdentityClient, }, - }; - } catch (error) { - this.logger.warn( - `Failed to look up user, ${error}, falling back to allowing login based on email pattern, this will probably break in the future`, ); - return { - ...response, - backstageIdentity: { id: profile.email.split('@')[0] }, - }; } + + return response; } } -export type GoogleProviderOptions = {}; +const emailSignInResolver: SignInResolver = async (info, ctx) => { + const { profile } = info; + + if (!profile.email) { + throw new Error('Google profile contained no email'); + } + + const entity = await ctx.catalogIdentityClient.findUser({ + annotations: { + 'google.com/email': profile.email, + }, + }); + + const claims = getEntityClaims(entity); + const token = await ctx.tokenIssuer.issueToken({ claims }); + + return { id: entity.metadata.name, entity, token }; +}; + +export type GoogleProviderOptions = { + /** + * The profile transformation function used to verify and convert the auth response + * into the profile that will be presented to the user. + */ + profileTransform?: ProfileTransform; + + /** + * Configure sign-in for this provider, without it the provider can not be used to sign users in. + */ + signIn?: { + /** + * Maps an auth result to a Backstage identity for the user. + * + * Set to `'email'` to use the default email-based sign in resolver, which will search + * for the catalog for a single user entity that has a matching `google.com/email` annotation. + */ + resolver?: 'email' | SignInResolver; + }; +}; export const createGoogleProvider = ( - _options?: GoogleProviderOptions, + options?: GoogleProviderOptions, ): AuthProviderFactory => { - return ({ - providerId, - globalConfig, - config, - logger, - tokenIssuer, - catalogApi, - }) => + return ({ providerId, globalConfig, config, tokenIssuer, catalogApi }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { const clientId = envConfig.getString('clientId'); const clientSecret = envConfig.getString('clientSecret'); const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const catalogIdentityClient = new CatalogIdentityClient({ + catalogApi, + tokenIssuer, + }); + + let profileTransform: ProfileTransform = async ({ + fullProfile, + params, + }) => makeProfileInfo(fullProfile, params.id_token); + if (options?.profileTransform) { + profileTransform = options.profileTransform; + } + + let signInResolver: SignInResolver | undefined = undefined; + const resolver = options?.signIn?.resolver; + if (resolver === 'email') { + signInResolver = emailSignInResolver; + } else if (typeof resolver === 'function') { + signInResolver = info => + resolver(info, { + catalogIdentityClient, + tokenIssuer, + }); + } + const provider = new GoogleAuthProvider({ clientId, clientSecret, callbackUrl, - logger, + signInResolver, + profileTransform, tokenIssuer, - identityClient: new CatalogIdentityClient({ catalogApi }), + catalogIdentityClient, }); return OAuthAdapter.fromConfig(globalConfig, provider, { diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 3f3d16f28f..eedc79cf09 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export * from './google'; export { factories as defaultAuthProviderFactories } from './factories'; // Export the minimal interface required for implementing a From 11e3e0fc24a17cc91cdf58feb635a8df1d0cc2a6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 15 Feb 2021 12:43:41 +0100 Subject: [PATCH 27/59] backend: mock custom google auth provider Signed-off-by: Patrik Oldsberg --- packages/backend/src/plugins/auth.ts | 31 ++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index 2b1c85f052..43bc8393bb 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import { createRouter } from '@backstage/plugin-auth-backend'; +import { + createGoogleProvider, + createRouter, + defaultAuthProviderFactories, +} from '@backstage/plugin-auth-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; @@ -24,5 +28,28 @@ export default async function createPlugin({ config, discovery, }: PluginEnvironment): Promise { - return await createRouter({ logger, config, database, discovery }); + return await createRouter({ + logger, + config, + database, + discovery, + providerFactories: { + ...defaultAuthProviderFactories, + google: createGoogleProvider({ + signIn: { + // resolver: 'email', + resolver: async ({ profile: { email } }, ctx) => { + if (!email) { + throw new Error('No email associated with user account'); + } + const id = email.split('@')[0]; + const token = await ctx.tokenIssuer.issueToken({ + claims: { sub: id, ent: [id] }, + }); + return { id, token }; + }, + }, + }), + }, + }); } From 17a26e7ed36e4fd2530618e6e560fdd6de99f7b3 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Mon, 19 Apr 2021 14:49:53 +0530 Subject: [PATCH 28/59] auth-backend: Use stringifyEntityRef helper Signed-off-by: Himanshu Mishra --- .../auth-backend/src/lib/catalog/helpers.ts | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/plugins/auth-backend/src/lib/catalog/helpers.ts b/plugins/auth-backend/src/lib/catalog/helpers.ts index a73b797368..c2dfe12455 100644 --- a/plugins/auth-backend/src/lib/catalog/helpers.ts +++ b/plugins/auth-backend/src/lib/catalog/helpers.ts @@ -15,32 +15,19 @@ */ import { - UserEntity, - serializeEntityRef, RELATION_MEMBER_OF, + stringifyEntityRef, + UserEntity, } from '@backstage/catalog-model'; import { TokenParams } from '../../identity'; export function getEntityClaims(entity: UserEntity): TokenParams['claims'] { - const userRef = serializeEntityRef(entity); - if (typeof userRef !== 'string') { - throw new Error( - `Failed to serialize user entity ref, ${JSON.stringify(userRef)}`, - ); - } + const userRef = stringifyEntityRef(entity); const membershipRefs = entity.relations ?.filter(r => r.type === RELATION_MEMBER_OF) - .map(r => { - const ref = serializeEntityRef(r.target); - if (typeof ref !== 'string') { - throw new Error( - `Failed to serialize relation entity ref, ${JSON.stringify(ref)}`, - ); - } - return ref; - }) ?? []; + .map(r => stringifyEntityRef(r.target)) ?? []; return { sub: userRef, From a848395e2bff6ec5799823c609b4e596e0d3e6f5 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 29 Apr 2021 13:09:29 +0200 Subject: [PATCH 29/59] auth-backend: getEntityClaims make sure target is group kind We are interested in the User isMemberOf Group relation. So this is just an additional check that the target is of Group kind. Signed-off-by: Himanshu Mishra --- plugins/auth-backend/src/lib/catalog/helpers.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/auth-backend/src/lib/catalog/helpers.ts b/plugins/auth-backend/src/lib/catalog/helpers.ts index c2dfe12455..e155c7defe 100644 --- a/plugins/auth-backend/src/lib/catalog/helpers.ts +++ b/plugins/auth-backend/src/lib/catalog/helpers.ts @@ -26,7 +26,11 @@ export function getEntityClaims(entity: UserEntity): TokenParams['claims'] { const membershipRefs = entity.relations - ?.filter(r => r.type === RELATION_MEMBER_OF) + ?.filter( + r => + r.type === RELATION_MEMBER_OF && + r.target.kind.toLocaleLowerCase() === 'group', + ) .map(r => stringifyEntityRef(r.target)) ?? []; return { From 7303b6520094295bdff9941cbd192664378e5eeb Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 6 May 2021 13:24:57 +0200 Subject: [PATCH 30/59] auth: remove duplicated use of defaultAuthProviderFactories Signed-off-by: Himanshu Mishra --- packages/backend/src/plugins/auth.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index 43bc8393bb..1d8bbdc892 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -17,7 +17,6 @@ import { createGoogleProvider, createRouter, - defaultAuthProviderFactories, } from '@backstage/plugin-auth-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; @@ -34,7 +33,6 @@ export default async function createPlugin({ database, discovery, providerFactories: { - ...defaultAuthProviderFactories, google: createGoogleProvider({ signIn: { // resolver: 'email', From a0949d58928f62e4ae0184b2821d6319ff9cebe5 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 6 May 2021 21:34:21 +0200 Subject: [PATCH 31/59] docs: Add docs explaining sign-in resolvers and profile transform Signed-off-by: Himanshu Mishra --- docs/auth/identity-resolver.md | 161 ++++++++++++++++++ microsite/sidebars.json | 1 + mkdocs.yml | 1 + .../src/providers/google/provider.ts | 2 +- 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 docs/auth/identity-resolver.md diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md new file mode 100644 index 0000000000..1e5c8dcdba --- /dev/null +++ b/docs/auth/identity-resolver.md @@ -0,0 +1,161 @@ +--- +id: identity-resolver +title: Identity resolver +description: Identity resolvers of Backstage users after they sign-in +--- + +This guide explains how the identity of a Backstage user is stored inside their +Backstage Identity Token and how you can customize the Sign In resolvers to +include identity and group membership information of the user from other +external systems. This ultimately helps with determining the ownership of a +Backstage entity by a user. The ideas here were originally proposed in the RFC +[#4089](https://github.com/backstage/backstage/issues/4089). + +When a user signs in to Backstage, inside the `claims` field of their Backstage +ID Token (which are standard JWT tokens) a special `ent` field is set. `ent` +contains a list of [entity references](../features/software-catalog/references), +each of which denotes an identity or a membership that is relevant to the user. +There is no guarantee that these correspond to actual existing catalog entities. + +Let's take an example sign-in resolver for the Google auth provider and explore +how the `ent` field inside `claims` can be set. + +Inside your `packages/backend/src/plugins/auth.ts` file, you can provide custom +sign-in resolvers and set them for any of the Authentication providers inside +`providerFactories` of the `createRouter` imported from the +`@backstage/plugin-auth-backend` plugin. + +```ts +export default async function createPlugin({ + ... +}: PluginEnvironment): Promise { + return await createRouter({ + ... + providerFactories: { + google: createGoogleProvider({ + signIn: { + resolver: async ({ profile: { email } }, ctx) => { + if (!email) { + throw new Error('No email associated with user account'); + } + + // Ignore email addresses which do not belong to company's domain name + if (email.split('@')[1] != 'mycompany.com') { + throw new Error('Unrecognized domain name of the email ID used to sign in.') + } + + // List of entity references that denote the identity and membership of the user + const ent = []; + + // Let's use the username in the email ID as the user's default unique identifier inside Backstage + const id = email.split('@')[0]; + // Let's add the unique ID in the list + ent.push(id) + // Note: While the complete entity references look like `kind:namespace/name`, it should be safe to assume + // that standalone strings without any : or / can be interpresed as user kind in the default namespace. So, + // a 'freben' inside the `ent` list should be translated to `User:default/freben` when making any assertions. + + // Let's call the GitHub Enterprise API inside the company and get the teams that the user belongs to + const gheUsername = getGheUsername(email); + const gheTeams = getGheTeams(gheUsername); + + // Let's add the GHE identities to ent claims inside a new ghe namespace to keep things separate from the + // default namespace. + ent.push(`User:ghe/${gheUsername}`) + gheTeams.forEach(team => ent.push(`Group:ghe/${team}`)) + + // Let's call the internal LDAP provider to get a list of groups the user belongs to + const ldapGroups = getLdapGroups(email); + ldapGroups.forEach(ldapGroup => ent.push(`Group:myldap/${ldapGroup}`)) + + // Issue the token containing the entity claims + const token = await ctx.tokenIssuer.issueToken({ + claims: { sub: id, ent }, + }); + return { id, token }; + }, + }, + }), + }, + }); +} +``` + +As you can see, the generated Backstage ID Token now contains all the claims +about the identity and membership of the user. Once the sign-in process is +complete, and we need to find out if a user owns an Entity in the Software +Catalog, these `ent` claims can be used to determine the ownership. A full +algorithm as proposed in the RFC is as follows + +The definition of the ownership of an entity E, for a user U, is as follows: + +- Get all the `ownedBy` relations of E, and call them O +- Get all the claims of the user U and call them C +- If any C matches any O, return `true` +- Get all Group entities that U is a member of, using the regular + `memberOf`/`hasMember` relation mechanism, and call them G +- If any G matches any O, return `true` +- Otherwise, return `false` + +## Default sign-in resolvers + +Of course you don't have to customize the sign-in resolver if you don't need to. +The Auth backend plugin comes with a set of default sign-in resolvers which you +can use. For example - the Google provider has a default email-based sign in +resolver, which will search the catalog for a single user entity that has a +matching `google.com/email` annotation. + +It can be enabled like this + +```tsx +# File: packages/backend/src/plugins/auth.ts +... +export default async function createPlugin({ + ... +}: PluginEnvironment): Promise { + return await createRouter({ + ... + providerFactories: { + google: createGoogleProvider({ + signIn: { + resolver: 'email' + } +... +``` + +## Profile transform + +Similar to a custom sign-in resolver, you can also write custom profile +transformation function which is used to verify and convert the auth response +into the profile that will be presented to the user. This is where you can +customize things like display name and profile picture. + +```tsx +# File: packages/backend/src/plugins/auth.ts +... +export default async function createPlugin({ + ... +}: PluginEnvironment): Promise { + return await createRouter({ + ... + providerFactories: { + google: createGoogleProvider({ + signIn: { + resolver: 'email' + }, + profileTransform: async ({ + fullProfile // Type: passport.Profile, + idToken // Type: (Optional) string, + }): ProfileInfo => { + // Do stuff + return { + email, + picture, + displayName, + }; + } + }) + } + }) +} +``` diff --git a/microsite/sidebars.json b/microsite/sidebars.json index f2ce425307..a9e1b07d62 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -206,6 +206,7 @@ }, "auth/add-auth-provider", "auth/using-auth", + "auth/identity-resolver", "auth/auth-backend", "auth/oauth", "auth/auth-backend-classes", diff --git a/mkdocs.yml b/mkdocs.yml index 67b97b6ffb..90e84c3533 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -133,6 +133,7 @@ nav: - OneLogin: 'auth/onelogin/provider.md' - Adding authentication providers: 'auth/add-auth-provider.md' - Using authentication and identity: 'auth/using-auth.md' + - Sign in resolvers: 'auth/identity-resolver.md' - Auth backend: 'auth/auth-backend.md' - OAuth and OpenID Connect: 'auth/oauth.md' - Auth backend classes: 'auth/auth-backend-classes.md' diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index 56295640be..b97e625699 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -205,7 +205,7 @@ export type GoogleProviderOptions = { * Maps an auth result to a Backstage identity for the user. * * Set to `'email'` to use the default email-based sign in resolver, which will search - * for the catalog for a single user entity that has a matching `google.com/email` annotation. + * the catalog for a single user entity that has a matching `google.com/email` annotation. */ resolver?: 'email' | SignInResolver; }; From 46c52d0aca5724c4721108faed4d5215dc0f3b88 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 6 May 2021 21:41:18 +0200 Subject: [PATCH 32/59] docs: fix doc reference links Signed-off-by: Himanshu Mishra --- docs/auth/identity-resolver.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md index 1e5c8dcdba..f1f99bde71 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -13,9 +13,10 @@ Backstage entity by a user. The ideas here were originally proposed in the RFC When a user signs in to Backstage, inside the `claims` field of their Backstage ID Token (which are standard JWT tokens) a special `ent` field is set. `ent` -contains a list of [entity references](../features/software-catalog/references), -each of which denotes an identity or a membership that is relevant to the user. -There is no guarantee that these correspond to actual existing catalog entities. +contains a list of +[entity references](../features/software-catalog/references.md), each of which +denotes an identity or a membership that is relevant to the user. There is no +guarantee that these correspond to actual existing catalog entities. Let's take an example sign-in resolver for the Google auth provider and explore how the `ent` field inside `claims` can be set. From c467cc4b912119e27443c0ccb96bc3c069ffdd2d Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 6 May 2021 21:53:14 +0200 Subject: [PATCH 33/59] auth-backend: Add changeset for the sign-in resolver work Signed-off-by: Himanshu Mishra --- .changeset/seven-adults-act.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/seven-adults-act.md diff --git a/.changeset/seven-adults-act.md b/.changeset/seven-adults-act.md new file mode 100644 index 0000000000..93541e06a6 --- /dev/null +++ b/.changeset/seven-adults-act.md @@ -0,0 +1,15 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Adds custom sign-in resolvers and profile transformation for Google auth provider. Read more about what this means for Backstage user identity and determining ownership of entities https://backstage.io/docs/auth/identity-resolver +Related the [RFC] From Identity to Ownership, v2 https://github.com/backstage/backstage/issues/4089 + +Adds `ent` field in the claims of Backstage ID Token with a list of entity references containing identity and membership info about the user across multiple systems. + +Adds an optional `providerFactories` to the `createRouter` exported by the auth-backend plugin. + +Updates `BackstageIdentity` so that + +- `idToken` is deprecated in favor of `token` +- An optional `entity` field is added which represents the entity that the user is represented by within Backstage. From 7dd84f37f0575f721f7c59d89a034e03d511d352 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 20 May 2021 11:53:47 +0200 Subject: [PATCH 34/59] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Himanshu Mishra Co-authored-by: Fredrik Adelöw Co-authored-by: Patrik Oldsberg --- docs/auth/identity-resolver.md | 16 ++++++++-------- plugins/auth-backend/src/lib/catalog/helpers.ts | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md index f1f99bde71..fa344afcef 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -12,7 +12,7 @@ Backstage entity by a user. The ideas here were originally proposed in the RFC [#4089](https://github.com/backstage/backstage/issues/4089). When a user signs in to Backstage, inside the `claims` field of their Backstage -ID Token (which are standard JWT tokens) a special `ent` field is set. `ent` +Token (which are standard JWT tokens) a special `ent` claim is set. `ent` contains a list of [entity references](../features/software-catalog/references.md), each of which denotes an identity or a membership that is relevant to the user. There is no @@ -41,7 +41,7 @@ export default async function createPlugin({ } // Ignore email addresses which do not belong to company's domain name - if (email.split('@')[1] != 'mycompany.com') { + if (email.split('@')[1] !== 'mycompany.com') { throw new Error('Unrecognized domain name of the email ID used to sign in.') } @@ -82,11 +82,11 @@ export default async function createPlugin({ } ``` -As you can see, the generated Backstage ID Token now contains all the claims -about the identity and membership of the user. Once the sign-in process is -complete, and we need to find out if a user owns an Entity in the Software -Catalog, these `ent` claims can be used to determine the ownership. A full -algorithm as proposed in the RFC is as follows +As you can see, the generated Backstage Token now contains all the claims about +the identity and membership of the user. Once the sign-in process is complete, +and we need to find out if a user owns an Entity in the Software Catalog, these +`ent` claims can be used to determine the ownership. A full algorithm as +proposed in the RFC is as follows The definition of the ownership of an entity E, for a user U, is as follows: @@ -126,7 +126,7 @@ export default async function createPlugin({ ## Profile transform -Similar to a custom sign-in resolver, you can also write custom profile +Similar to a custom sign-in resolver, you can also write a custom profile transformation function which is used to verify and convert the auth response into the profile that will be presented to the user. This is where you can customize things like display name and profile picture. diff --git a/plugins/auth-backend/src/lib/catalog/helpers.ts b/plugins/auth-backend/src/lib/catalog/helpers.ts index e155c7defe..c60198dc98 100644 --- a/plugins/auth-backend/src/lib/catalog/helpers.ts +++ b/plugins/auth-backend/src/lib/catalog/helpers.ts @@ -29,7 +29,7 @@ export function getEntityClaims(entity: UserEntity): TokenParams['claims'] { ?.filter( r => r.type === RELATION_MEMBER_OF && - r.target.kind.toLocaleLowerCase() === 'group', + r.target.kind.toLocaleLowerCase('en-US') === 'group', ) .map(r => stringifyEntityRef(r.target)) ?? []; From db1d558411e7c4964537f702fd76f8d7d8aac5d7 Mon Sep 17 00:00:00 2001 From: Himanshu Mishra Date: Thu, 20 May 2021 15:47:01 +0200 Subject: [PATCH 35/59] auth-backend: always use full user/group entity refs Signed-off-by: Himanshu Mishra --- docs/auth/identity-resolver.md | 5 +---- packages/backend/src/plugins/auth.ts | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md index fa344afcef..9414c9aa14 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -51,10 +51,7 @@ export default async function createPlugin({ // Let's use the username in the email ID as the user's default unique identifier inside Backstage const id = email.split('@')[0]; // Let's add the unique ID in the list - ent.push(id) - // Note: While the complete entity references look like `kind:namespace/name`, it should be safe to assume - // that standalone strings without any : or / can be interpresed as user kind in the default namespace. So, - // a 'freben' inside the `ent` list should be translated to `User:default/freben` when making any assertions. + ent.push(`User:default/${id}`) // Let's call the GitHub Enterprise API inside the company and get the teams that the user belongs to const gheUsername = getGheUsername(email); diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index 1d8bbdc892..3157284df7 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -42,7 +42,7 @@ export default async function createPlugin({ } const id = email.split('@')[0]; const token = await ctx.tokenIssuer.issueToken({ - claims: { sub: id, ent: [id] }, + claims: { sub: id, ent: [`User:default/${id}`] }, }); return { id, token }; }, From 785a42f802b512be3813bc3be035608b0fd7fbc8 Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Tue, 15 Jun 2021 15:00:15 -0600 Subject: [PATCH 36/59] Move installation instructions to READMEs Signed-off-by: Tim Hansen --- docs/features/software-catalog/index.md | 5 +- .../features/software-catalog/installation.md | 177 ----------- .../software-templates/configuration.md | 52 ++++ docs/features/software-templates/index.md | 6 +- .../software-templates/installation.md | 280 ------------------ .../techdocs/creating-and-publishing.md | 8 +- docs/plugins/github-apps.md | 9 + microsite/sidebars.json | 3 +- mkdocs.yml | 3 +- plugins/catalog-backend/README.md | 88 +++++- plugins/catalog/README.md | 105 ++++++- plugins/scaffolder-backend/README.md | 67 ++++- plugins/scaffolder/README.md | 84 +++++- 13 files changed, 371 insertions(+), 516 deletions(-) delete mode 100644 docs/features/software-catalog/installation.md create mode 100644 docs/features/software-templates/configuration.md delete mode 100644 docs/features/software-templates/installation.md diff --git a/docs/features/software-catalog/index.md b/docs/features/software-catalog/index.md index 70541b85db..2189b7d799 100644 --- a/docs/features/software-catalog/index.md +++ b/docs/features/software-catalog/index.md @@ -34,9 +34,8 @@ More specifically, the Service Catalog enables two main use-cases: ## Getting Started The Software Catalog is available to browse at `/catalog`. If you've followed -[Installing in your Backstage App](./installation.md) in your separate App or -[Getting Started with Backstage](../../getting-started) for this repo, you -should be able to browse the catalog at `http://localhost:3000`. +[Getting Started with Backstage](../../getting-started), you should be able to +browse the catalog at `http://localhost:3000`. ![](../../assets/software-catalog/service-catalog-home.png) diff --git a/docs/features/software-catalog/installation.md b/docs/features/software-catalog/installation.md deleted file mode 100644 index 0b622fb093..0000000000 --- a/docs/features/software-catalog/installation.md +++ /dev/null @@ -1,177 +0,0 @@ ---- -id: installation -title: Installing in your Backstage App -description: Documentation on How to install Backstage Plugin ---- - -The catalog plugin comes in two packages, `@backstage/plugin-catalog` and -`@backstage/plugin-catalog-backend`. Each has their own installation steps, -outlined below. - -## Installing @backstage/plugin-catalog - -> **Note that if you used `npx @backstage/create-app`, the plugin is already -> installed and you can skip to -> [adding entries to the catalog](#adding-entries-to-the-catalog)** - -The catalog frontend plugin should be installed in your `app` package, which is -created as a part of `@backstage/create-app`. To install the package, run: - -```bash -# From your Backstage root directory -cd packages/app -yarn add @backstage/plugin-catalog -``` - -### Adding the Plugin to your `packages/app` - -Add the two pages that the catalog plugin provides to your app. You can choose -any name for these routes, but we recommend the following: - -```tsx -// packages/app/src/App.tsx -import { - catalogPlugin, - CatalogIndexPage, - CatalogEntityPage, -} from '@backstage/plugin-catalog'; - -// Add to the top-level routes, directly within -} /> -}> - {/* - This is the root of the custom entity pages for your app, refer to the example app - in the main repo or the output of @backstage/create-app for an example - */} - - -``` - -The catalog plugin also has one external route that needs to be bound for it to -function: the `createComponent` route which should link to the page where the -user can create components. In a typical setup the create component route will -be linked to the Scaffolder plugin's template index page: - -```ts -// packages/app/src/App.tsx -import { catalogPlugin } from '@backstage/plugin-catalog'; -import { scaffolderPlugin } from '@backstage/plugin-scaffolder'; - -const app = createApp({ - // ... - bindRoutes({ bind }) { - bind(catalogPlugin.externalRoutes, { - createComponent: scaffolderPlugin.routes.root, - }); - }, -}); -``` - -You may also want to add a link to the catalog index page to your sidebar: - -```tsx -// packages/app/src/components/Root.tsx -import HomeIcon from '@material-ui/icons/Home'; - -// Somewhere within the -; -``` - -This is all that is needed for the frontend part of the Catalog plugin to work! - -## Gotchas that we will fix - -Since the catalog plugin currently ships with a sentry plugin `InfoCard` -installed by default, you'll need to set `sentry.organization` in your -`app-config.yaml`. For example: - -```yaml -sentry: - organization: Acme Corporation -``` - -If you've created an app with an older version of `@backstage/create-app` or -`@backstage/cli create-app`, be sure to remove the Welcome plugin from the app, -as that will conflict with the catalog routes. - -## Installing @backstage/plugin-catalog-backend - -> **Note that if you used `npx @backstage/create-app`, the plugin is already -> installed and you can skip to -> [adding entries to the catalog](#adding-entries-to-the-catalog)** - -The catalog backend should be installed in your `backend` package, which is -created as a part of `@backstage/create-app`. To install the package, run: - -```bash -# From your Backstage root directory -cd packages/backend -yarn add @backstage/plugin-catalog-backend -``` - -### Adding the Plugin to your `packages/backend` - -You'll need to add the plugin to the `backend`'s router. You can do this by -creating a file called `packages/backend/src/plugins/catalog.ts` with contents -matching -[catalog.ts in the create-app template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts). - -Once the `catalog.ts` router setup file is in place, add the router to -`packages/backend/src/index.ts`: - -```ts -import catalog from './plugins/catalog'; - -const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); - -const apiRouter = Router(); -/** several different routers */ -apiRouter.use('/catalog', await catalog(catalogEnv)); -``` - -### Adding Entries to the Catalog - -At this point the catalog backend is installed in your backend package, but you -will not have any entities loaded. - -To get up and running and try out some templates quickly, you can add some of -our example templates through static configuration. Add the following to the -`catalog.locations` section in your `app-config.yaml`: - -```yaml -catalog: - locations: - # Backstage Example Components - - type: url - target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/artist-lookup-component.yaml - - type: url - target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/playback-order-component.yaml - - type: url - target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/podcast-api-component.yaml - - type: url - target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/queue-proxy-component.yaml - - type: url - target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/searcher-component.yaml - - type: url - target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/playback-lib-component.yaml - - type: url - target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/www-artist-component.yaml - - type: url - target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/components/shuffle-api-component.yaml -``` - -### Running the Backend - -Finally, start up Backstage with the new configuration: - -```bash -# Run from the root to start both backend and frontend -yarn dev - -# Alternatively, run only the backend from its own package -cd packages/backend -yarn start -``` - -If you've also set up the frontend plugin, you should be ready to go browse the -catalog at [localhost:3000](http://localhost:3000) now! diff --git a/docs/features/software-templates/configuration.md b/docs/features/software-templates/configuration.md new file mode 100644 index 0000000000..cdd8166889 --- /dev/null +++ b/docs/features/software-templates/configuration.md @@ -0,0 +1,52 @@ +--- +id: configuration +title: Software Template Configuration +sidebar_label: Configuration +description: Configuration options for Backstage Software Templates +--- + +Backstage software templates create source code, so your Backstage application +needs to be set up to allow repository creation. + +This is done in your `app-config.yaml` by adding +[Backstage integrations](https://backstage.io/docs/integrations/) for the +appropriate source code repository for your organization. + +> Note: Integrations may already be set up as part of your `app-config.yaml`. + +The next step is to add +[add templates](http://backstage.io/docs/features/software-templates/adding-templates) +to your Backstage app. + +### GitHub + +For GitHub, you can configure who can see the new repositories that are created +by specifying `visibility` option. Valid options are `public`, `private` and +`internal`. The `internal` option is for GitHub Enterprise clients, which means +public within the enterprise. + +```yaml +scaffolder: + github: + visibility: public # or 'internal' or 'private' +``` + +### Disabling Docker in Docker situation (Optional) + +Software Templates use +[Cookiecutter](https://github.com/cookiecutter/cookiecutter) as a templating +library. By default it will use the +[scaffolder-backend/Cookiecutter](https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/scripts/Cookiecutter.dockerfile) +docker image. + +If you are running Backstage from a Docker container and you want to avoid +calling a container inside a container, you can set up Cookiecutter in your own +image, this will use the local installation instead. + +You can do so by including the following lines in the last step of your +`Dockerfile`: + +```Dockerfile +RUN apt-get update && apt-get install -y python3 python3-pip +RUN pip3 install cookiecutter +``` diff --git a/docs/features/software-templates/index.md b/docs/features/software-templates/index.md index 12ce6e3ed3..1434d62a29 100644 --- a/docs/features/software-templates/index.md +++ b/docs/features/software-templates/index.md @@ -17,10 +17,8 @@ locations like GitHub or GitLab. ### Getting Started -> Be sure to have covered [Installing in your Backstage App](./installation.md) -> for your separate App or -> [Getting Started with Backstage](../../getting-started) for this repo before -> proceeding. +> Be sure to have covered +> [Getting Started with Backstage](../../getting-started) before proceeding. The Software Templates are available under `/create`. For local development you should be able to reach them at `http://localhost:3000/create`. diff --git a/docs/features/software-templates/installation.md b/docs/features/software-templates/installation.md deleted file mode 100644 index d5643ca955..0000000000 --- a/docs/features/software-templates/installation.md +++ /dev/null @@ -1,280 +0,0 @@ ---- -id: installation -title: Installing in your Backstage App -description: Documentation on How to install Backstage App ---- - -The scaffolder plugin comes in two packages, `@backstage/plugin-scaffolder` and -`@backstage/plugin-scaffolder-backend`. Each has their own installation steps, -outlined below. - -The Scaffolder plugin also depends on the Software Catalog. Instructions for how -to set that up can be found [here](../software-catalog/installation.md). - -## Installing @backstage/plugin-scaffolder - -> **Note that if you used `npx @backstage/create-app`, the plugin may already be -> present** - -The scaffolder frontend plugin should be installed in your `app` package, which -is created as a part of `@backstage/create-app`. To install the package, run: - -```bash -# From your Backstage root directory -cd packages/app -yarn add @backstage/plugin-scaffolder -``` - -### Adding the Plugin to your `packages/app` - -Add the root page that the Scaffolder plugin provides to your app. You can -choose any path for the route, but we recommend the following: - -```tsx -import { ScaffolderPage } from '@backstage/plugin-scaffolder'; - -// Add to the top-level routes, directly within -} />; -``` - -You may also want to add a link to the template index page to your sidebar: - -```tsx -import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; - -// Somewhere within the -; -``` - -This is all that is needed for the frontend part of the Scaffolder plugin to -work! - -## Installing @backstage/plugin-scaffolder-backend - -> **Note that if you used `npx @backstage/create-app`, the plugin may already be -> present** - -The scaffolder backend should be installed in your `backend` package, which is -created as a part of `@backstage/create-app`. To install the package, run: - -```bash -# From your Backstage root directory -cd packages/backend -yarn add @backstage/plugin-scaffolder-backend -``` - -### Adding the Plugin to your `packages/backend` - -You'll need to add the plugin to the `backend`'s router. You can do this by -creating a file called `packages/backend/src/plugins/scaffolder.ts` with the -following contents to get you up and running quickly. - -```ts -import { - DockerContainerRunner, - SingleHostDiscovery, -} from '@backstage/backend-common'; -import { - CookieCutter, - createRouter, - Preparers, - Publishers, - CreateReactAppTemplater, - Templaters, -} from '@backstage/plugin-scaffolder-backend'; -import type { PluginEnvironment } from '../types'; -import Docker from 'dockerode'; -import { CatalogClient } from '@backstage/catalog-client'; - -export default async function createPlugin({ - logger, - config, - database, - reader, -}: PluginEnvironment) { - const dockerClient = new Docker(); - const containerRunner = new DockerContainerRunner({ dockerClient }); - - const cookiecutterTemplater = new CookieCutter({ containerRunner }); - const craTemplater = new CreateReactAppTemplater({ containerRunner }); - const templaters = new Templaters(); - - templaters.register('cookiecutter', cookiecutterTemplater); - templaters.register('cra', craTemplater); - - const preparers = await Preparers.fromConfig(config, { logger }); - const publishers = await Publishers.fromConfig(config, { logger }); - - const discovery = SingleHostDiscovery.fromConfig(config); - const catalogClient = new CatalogClient({ discoveryApi: discovery }); - - return await createRouter({ - preparers, - templaters, - publishers, - logger, - config, - database, - catalogClient, - reader, - }); -} -``` - -Once the `scaffolder.ts` router setup file is in place, add the router to -`packages/backend/src/index.ts`: - -```ts -import scaffolder from './plugins/scaffolder'; - -const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder')); - -const apiRouter = Router(); -/* several router .use calls */ - -/* add this line */ -apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); -``` - -### Adding Templates - -At this point the scaffolder backend is installed in your backend package, but -you will not have any templates available to use. These need to be added to the -software catalog, as they are represented as entities of kind -[Template](../software-catalog/descriptor-format.md#kind-template). You can find -out more about adding templates [here](./adding-templates.md). - -To get up and running and try out some templates quickly, you can add some of -our example templates through static configuration. Add the following to the -`catalog.locations` section in your `app-config.yaml`: - -```yaml -catalog: - locations: - # Backstage Example Templates - - type: url - target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/react-ssr-template/template.yaml - - type: url - target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/springboot-grpc-template/template.yaml - - type: url - target: https://github.com/backstage/backstage/blob/master/plugins/scaffolder-backend/sample-templates/create-react-app/template.yaml - - type: url - target: https://github.com/spotify/cookiecutter-golang/blob/master/template.yaml -``` - -### Runtime Dependencies / Configuration - -For the scaffolder backend plugin to function, you'll need to setup the -integrations config in your `app-config.yaml`. - -You can find help for different providers below. - -> Note: Some of this configuration may already be set up as part of your -> `app-config.yaml`. We're moving away from the duplicated config for -> authentication in the `scaffolder` section and using `integrations` instead. - -#### GitHub - -The GitHub access token is retrieved from environment variables via the config. -The config file needs to specify what environment variable the token is -retrieved from. Your config should have the following objects. - -You can configure who can see the new repositories that the scaffolder creates -by specifying `visibility` option. Valid options are `public`, `private` and -`internal`. The `internal` option is for GitHub Enterprise clients, which means -public within the enterprise. - -```yaml -integrations: - github: - - host: github.com - token: ${GITHUB_TOKEN} - -scaffolder: - github: - visibility: public # or 'internal' or 'private' -``` - -#### GitLab - -For GitLab, we currently support the configuration of the GitLab publisher and -allows to configure the private access token and the base URL of a GitLab -instance: - -```yaml -integrations: - gitlab: - - host: gitlab.com - token: ${GITLAB_TOKEN} -``` - -#### Bitbucket - -For Bitbucket there are two authentication methods supported. Either `token` or -a combination of `appPassword` and `username`. It looks like either of the -following: - -```yaml -integrations: - bitbucket: - - host: bitbucket.org - token: ${BITBUCKET_TOKEN} -``` - -or - -```yaml -integrations: - bitbucket: - - host: bitbucket.org - appPassword: ${BITBUCKET_APP_PASSWORD} - username: ${BITBUCKET_USERNAME} -``` - -#### Azure DevOps - -For Azure DevOps we support both the preparer and publisher stage with the -configuration of a private access token (PAT). For the publisher it's also -required to define the base URL for the client to connect to the service. This -will hopefully support on-prem installations as well but that has not been -verified. - -```yaml -integrations: - azure: - - host: dev.azure.com - token: ${AZURE_TOKEN} -``` - -### Running the Backend - -Finally, make sure you have a local Docker daemon running, and start up the -backend with the new configuration: - -```bash -cd packages/backend -GITHUB_TOKEN= yarn start -``` - -If you've also set up the frontend plugin, so you should be ready to go browse -the templates at [localhost:3000/create](http://localhost:3000/create) now! - -### Disabling Docker in Docker situation (Optional) - -Software Templates use -[Cookiecutter](https://github.com/cookiecutter/cookiecutter) as templating -library. By default it will use the -[spotify/backstage-cookiecutter](https://github.com/backstage/backstage/blob/37e35b910afc7d1270855aed0ec4718aba366c91/plugins/scaffolder-backend/scripts/Cookiecutter.dockerfile) -docker image. - -If you are running Backstage from a Docker container and you want to avoid -calling a container inside a container, you can set up Cookiecutter in your own -image, this will use the local installation instead. - -You can do so by including the following lines in the last step of your -`Dockerfile`: - -```Dockerfile -RUN apt-get update && apt-get install -y python3 python3-pip -RUN pip3 install cookiecutter -``` diff --git a/docs/features/techdocs/creating-and-publishing.md b/docs/features/techdocs/creating-and-publishing.md index b9340330a8..3fdc942ac4 100644 --- a/docs/features/techdocs/creating-and-publishing.md +++ b/docs/features/techdocs/creating-and-publishing.md @@ -28,10 +28,10 @@ scratch. ### Use the documentation template Your working Backstage instance should by default have a documentation template -added. If not, follow these -[instructions](../software-templates/installation.md#adding-templates) to add -the documentation template. The template creates a component with only TechDocs -configuration and default markdown files as below mentioned in manual +added. If not, copy the catalog locations from the +[create-app template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/app-config.yaml.hbs) +to add the documentation template. The template creates a component with only +TechDocs configuration and default markdown files as below mentioned in manual documentation setup, and is otherwise empty. ![Documentation Template](../../assets/techdocs/documentation-template.png) diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md index 87d23b45d5..b7ad60b12f 100644 --- a/docs/plugins/github-apps.md +++ b/docs/plugins/github-apps.md @@ -84,3 +84,12 @@ integrations: apps: - $include: example-backstage-app-credentials.yaml ``` + +### Permissions for pull requests + +These are the minimum permissions required for creating a pull request with +Backstage software templates: + +- Read and Write permissions for `Contents`. +- Read and write permissions for `Pull Requests` and `Issues`. +- Read permissions on `Metadata`. diff --git a/microsite/sidebars.json b/microsite/sidebars.json index f2ce425307..6caf14e854 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -33,7 +33,6 @@ "label": "Software Catalog", "ids": [ "features/software-catalog/software-catalog-overview", - "features/software-catalog/installation", "features/software-catalog/configuration", "features/software-catalog/system-model", "features/software-catalog/descriptor-format", @@ -62,7 +61,7 @@ "label": "Software Templates", "ids": [ "features/software-templates/software-templates-index", - "features/software-templates/installation", + "features/software-templates/configuration", "features/software-templates/adding-templates", "features/software-templates/writing-templates", "features/software-templates/builtin-actions", diff --git a/mkdocs.yml b/mkdocs.yml index 67b97b6ffb..c62824d39e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -30,7 +30,6 @@ nav: - Core Features: - Software Catalog: - Overview: 'features/software-catalog/index.md' - - Installing in your Backstage App: 'features/software-catalog/installation.md' - Catalog Configuration: 'features/software-catalog/configuration.md' - System Model: 'features/software-catalog/system-model.md' - YAML File Format: 'features/software-catalog/descriptor-format.md' @@ -49,7 +48,7 @@ nav: - Troubleshooting: 'features/kubernetes/troubleshooting.md' - Software Templates: - Overview: 'features/software-templates/index.md' - - Installing in your Backstage App: 'features/software-templates/installation.md' + - Configuration: 'features/software-templates/configuration.md' - Adding your own Templates: 'features/software-templates/adding-templates.md' - Writing Templates: 'features/software-templates/writing-templates.md' - Builtin Actions: 'features/software-templates/builtin-actions.md' diff --git a/plugins/catalog-backend/README.md b/plugins/catalog-backend/README.md index 2f06b9c062..ce97bb7a54 100644 --- a/plugins/catalog-backend/README.md +++ b/plugins/catalog-backend/README.md @@ -1,21 +1,80 @@ # Catalog Backend -This is the backend part of the default catalog plugin. +This is the backend for the default Backstage [software +catalog](http://backstage.io/docs/features/software-catalog/software-catalog-overview). +This provides an API for consumers such as the frontend [catalog +plugin](https://github.com/backstage/backstage/tree/master/plugins/catalog). -It comes with a builtin database backed implementation of the catalog, that can store -and serve your catalog for you. +It comes with a builtin database-backed implementation of the catalog that can +store and serve your catalog for you. -It can also act as a bridge to your existing catalog solutions, either ingesting their -data to store in the database, or by effectively proxying calls to an external catalog -service. +It can also act as a bridge to your existing catalog solutions, either ingesting +data to store in the database, or by effectively proxying calls to an +external catalog service. -## Getting Started +## Installation -This backend plugin can be started in a standalone mode from directly in this package -with `yarn start`. However, it will have limited functionality and that process is -most convenient when developing the catalog backend plugin itself. +This `@backstage/plugin-catalog-backend` package comes installed by default in +any Backstage application created with `npx @backstage/create-app`, so +installation is not usually required. -To evaluate the catalog and have a greater amount of functionality available, instead do +To check if you already have the package, look under +`packages/backend/package.json`, in the `dependencies` block, for +`@backstage/plugin-catalog-backend`. The instructions below walk through +restoring the plugin, if you previously removed it. + +### Install the package + +```bash +# From your Backstage root directory +cd packages/backend +yarn add @backstage/plugin-catalog-backend +``` + +### Adding the plugin to your `packages/backend` + +You'll need to add the plugin to the router in your `backend` package. You can +do this by creating a file called `packages/backend/src/plugins/catalog.ts` with +contents matching [catalog.ts in the create-app +template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/packages/backend/src/plugins/catalog.ts). + +With the `catalog.ts` router setup in place, add the router to +`packages/backend/src/index.ts`: + +```diff ++import catalog from './plugins/catalog'; + +async function main() { + ... + const createEnv = makeCreateEnv(config); + ++ const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); + const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder')); + + const apiRouter = Router(); ++ apiRouter.use('/catalog', await catalog(catalogEnv)); + ... + apiRouter.use(notFoundHandler()); + +``` + +### Adding catalog entities + +At this point the `catalog-backend` is installed in your backend package, but +you will not have any catalog entities loaded. See [Catalog +Configuration](https://backstage.io/docs/features/software-catalog/configuration) +for how to add locations, or copy the catalog locations from the [create-app +template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/app-config.yaml.hbs) +to get up and running quickly. + +## Development + +This backend plugin can be started in a standalone mode from directly in this +package with `yarn start`. However, it will have limited functionality and that +process is most convenient when developing the catalog backend plugin itself. + +To evaluate the catalog and have a greater amount of functionality available, +run the entire Backstage example application from the root folder: ```bash # in one terminal window, run this from from the very root of the Backstage project @@ -23,9 +82,10 @@ cd packages/backend yarn start ``` -This will launch the full example backend, populated some example entities. +This will launch both frontend and backend in the same window, populated with +some example entities. ## Links -- [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/catalog) -- [The Backstage homepage](https://backstage.io) +- [catalog](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend) + is the frontend interface for this plugin. diff --git a/plugins/catalog/README.md b/plugins/catalog/README.md index 611d2989e8..8527c8da7c 100644 --- a/plugins/catalog/README.md +++ b/plugins/catalog/README.md @@ -1,26 +1,107 @@ # Backstage Catalog Frontend -This is the frontend part of the default catalog plugin. +This is the React frontend for the default Backstage [software +catalog](http://backstage.io/docs/features/software-catalog/software-catalog-overview). +This package supplies interfaces related to listing catalog entities or showing +more information about them on entity pages. -It will implement the core API for handling your catalog of software, and -supply the base views to show and manage them. +## Installation -## Getting Started +This `@backstage/plugin-catalog` package comes installed by default in any +Backstage application created with `npx @backstage/create-app`, so installation +is not usually required. -This frontend plugin can be started in a standalone mode from directly in this package -with `yarn start`. However, it will have limited functionality and that process is -most convenient when developing the catalog frontend plugin itself. +To check if you already have the package, look under +`packages/app/package.json`, in the `dependencies` block, for +`@backstage/plugin-catalog`. The instructions below walk through restoring the +plugin, if you previously removed it. -To evaluate the catalog and have a greater amount of functionality available, from the main -Backstage root folder, instead do: +### Install the package + +```bash +# From your Backstage root directory +cd packages/app +yarn add @backstage/plugin-catalog +``` + +### Add the plugin to your `packages/app` + +Add the two pages that the catalog plugin provides to your app. You can choose +any name for these routes, but we recommend the following: + +```diff +// packages/app/src/App.tsx +import { + CatalogIndexPage, + CatalogEntityPage, +} from '@backstage/plugin-catalog'; +import { entityPage } from './components/catalog/EntityPage'; + + ++ } /> ++ }> ++ {/* ++ This is the root of the custom entity pages for your app, refer to the example app ++ in the main repo or the output of @backstage/create-app for an example ++ */} ++ {entityPage} ++ + ... + +``` + +The catalog plugin also has one external route that needs to be bound for it to +function: the `createComponent` route which should link to the page where the +user can create components. In a typical setup the create component route will +be linked to the scaffolder plugin's template index page: + +```diff +// packages/app/src/App.tsx ++import { catalogPlugin } from '@backstage/plugin-catalog'; ++import { scaffolderPlugin } from '@backstage/plugin-scaffolder'; + +const app = createApp({ + // ... + bindRoutes({ bind }) { ++ bind(catalogPlugin.externalRoutes, { ++ createComponent: scaffolderPlugin.routes.root, ++ }); + }, +}); +``` + +You may also want to add a link to the catalog index page to your application +sidebar: + +```diff +// packages/app/src/components/Root/Root.tsx ++import HomeIcon from '@material-ui/icons/Home'; + +export const Root = ({ children }: PropsWithChildren<{}>) => ( + + ++ + ... + +``` + +## Development + +This frontend plugin can be started in a standalone mode from directly in this +package with `yarn start`. However, it will have limited functionality and that +process is most convenient when developing the catalog frontend plugin itself. + +To evaluate the catalog and have a greater amount of functionality available, +run the entire Backstage example application from the root folder: ```bash yarn dev ``` -This will launch both frontend and backend in the same window, populated with some example entities. +This will launch both frontend and backend in the same window, populated with +some example entities. ## Links -- [Backend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend) -- [The Backstage homepage](https://backstage.io) +- [catalog-backend](https://github.com/backstage/backstage/tree/master/plugins/catalog-backend) + provides the backend API for this frontend. diff --git a/plugins/scaffolder-backend/README.md b/plugins/scaffolder-backend/README.md index 304d43a750..7efe33f7f6 100644 --- a/plugins/scaffolder-backend/README.md +++ b/plugins/scaffolder-backend/README.md @@ -1,25 +1,64 @@ # Scaffolder Backend -Welcome to the scaffolder plugin! +This is the backend for the default Backstage [software +templates](https://backstage.io/docs/features/software-templates/software-templates-index). +This provides the API for the frontend [scaffolder +plugin](https://github.com/backstage/backstage/tree/master/plugins/scaffolder), +as well as the built-in template actions, tasks and stages. -## Jobs +## Installation -Documentation for `Jobs` here +This `@backstage/plugin-scaffolder-backend` package comes installed by default +in any Backstage application created with `npx @backstage/create-app`, so +installation is not usually required. -## Stages +To check if you already have the package, look under +`packages/backend/package.json`, in the `dependencies` block, for +`@backstage/plugin-scaffolder-backend`. The instructions below walk through +restoring the plugin, if you previously removed it. -Documentation for `Stages` here +### Install the package -## Tasks +```bash +# From your Backstage root directory +cd packages/backend +yarn add @backstage/plugin-scaffolder-backend +``` -Documentation for `Tasks` here +### Adding the plugin to your `packages/backend` -## Actions +You'll need to add the plugin to the router in your `backend` package. You can +do this by creating a file called `packages/backend/src/plugins/scaffolder.ts` +with contents matching [scaffolder.ts in the create-app +template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/packages/backend/src/plugins/scaffolder.ts). -### Built-in: +With the `scaffolder.ts` router setup in place, add the router to +`packages/backend/src/index.ts`: -- #### GitHub Pull Request - - Minimum permissions required for GitHub App for creating a Pull Request with the built-in action: - - Read and Write permissions for `Contents`. - - Read and write permissions for `Pull Requests` and `Issues`. - - Read permissions on `Metadata`. +```diff ++import scaffolder from './plugins/scaffolder'; + +async function main() { + ... + const createEnv = makeCreateEnv(config); + + const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); ++ const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder')); + + const apiRouter = Router(); ++ apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv)); + ... + apiRouter.use(notFoundHandler()); + +``` + +### Adding templates + +At this point the scaffolder backend is installed in your backend package, but +you will not have any templates available to use. These need to be [added to the +software +catalog](https://backstage.io/docs/features/software-templates/adding-templates). + +To get up and running and try out some templates quickly, you can or copy the +catalog locations from the [create-app +template](https://github.com/backstage/backstage/blob/master/packages/create-app/templates/default-app/app-config.yaml.hbs). diff --git a/plugins/scaffolder/README.md b/plugins/scaffolder/README.md index 05a68dafdd..0bfd2f8a35 100644 --- a/plugins/scaffolder/README.md +++ b/plugins/scaffolder/README.md @@ -1,10 +1,86 @@ # Scaffolder Frontend -WORK IN PROGRESS +This is the React frontend for the default Backstage [software +templates](https://backstage.io/docs/features/software-templates/software-templates-index). +This package supplies interfaces related to showing available templates in the +Backstage catalog and the workflow to create software using those templates. -This is the frontend part of the default scaffolder plugin. +## Installation + +This `@backstage/plugin-scaffolder` package comes installed by default in any +Backstage application created with `npx @backstage/create-app`, so installation +is not usually required. + +To check if you already have the package, look under +`packages/app/package.json`, in the `dependencies` block, for +`@backstage/plugin-scaffolder`. The instructions below walk through restoring +the plugin, if you previously removed it. + +### Install the package + +```bash +# From your Backstage root directory +cd packages/app +yarn add @backstage/plugin-scaffolder +``` + +### Add the plugin to your `packages/app` + +Add the root page that the scaffolder plugin provides to your app. You can +choose any path for the route, but we recommend the following: + +```diff +// packages/app/src/App.tsx ++import { ScaffolderPage } from '@backstage/plugin-scaffolder'; + + + + } /> + }> + {entityPage} + ++ } />; + ... + +``` + +The scaffolder plugin also has one external route that needs to be bound for it +to function: the `registerComponent` route which should link to the page where +the user can register existing software component. In a typical setup, the +register component route will be linked to the `catalog-import` plugin's import +page: + +```diff +// packages/app/src/App.tsx ++import { scaffolderPlugin } from '@backstage/plugin-scaffolder'; ++import { catalogImportPlugin } from '@backstage/plugin-catalog-import'; + +const app = createApp({ + // ... + bindRoutes({ bind }) { ++ bind(scaffolderPlugin.externalRoutes, { ++ registerComponent: catalogImportPlugin.routes.importPage, ++ }); + }, +}); +``` + +You may also want to add a link to the scaffolder page to your application +sidebar: + +```diff +// packages/app/src/components/Root/Root.tsx ++import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; + +export const Root = ({ children }: PropsWithChildren<{}>) => ( + + ++ ; + ... + +``` ## Links -- [Backend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-backend) -- [The Backstage homepage](https://backstage.io) +- [scaffolder-backend](https://github.com/backstage/backstage/tree/master/plugins/scaffolder-backend) + provides the backend API for this frontend. From 8adb6f6bcd0d20a0aa421af8a5b3b9d66f9f6e4d Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 16 Jun 2021 10:34:48 +0200 Subject: [PATCH 37/59] feat: enable backwards compatability and write a simple test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Co-authored-by: Patrik Oldsberg Signed-off-by: blam --- .../src/providers/google/provider.test.ts | 89 +++++++++++++++++++ .../src/providers/google/provider.ts | 86 +++++++++++++----- plugins/auth-backend/src/providers/types.ts | 1 + 3 files changed, 156 insertions(+), 20 deletions(-) create mode 100644 plugins/auth-backend/src/providers/google/provider.test.ts diff --git a/plugins/auth-backend/src/providers/google/provider.test.ts b/plugins/auth-backend/src/providers/google/provider.test.ts new file mode 100644 index 0000000000..a8b5b92b5e --- /dev/null +++ b/plugins/auth-backend/src/providers/google/provider.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { GoogleAuthProvider } from './provider'; +import * as helpers from '../../lib/passport/PassportStrategyHelper'; +import { OAuthResult } from '../../lib/oauth'; +import { getVoidLogger } from '@backstage/backend-common'; +import { TokenIssuer } from '../../identity/types'; +import { CatalogIdentityClient } from '../../lib/catalog'; + +const mockFrameHandler = (jest.spyOn( + helpers, + 'executeFrameHandlerStrategy', +) as unknown) as jest.MockedFunction< + () => Promise<{ result: OAuthResult; privateInfo: any }> +>; + +describe('createGoogleProvider', () => { + it('should auth', async () => { + const tokenIssuer = { + issueToken: jest.fn(), + listPublicKeys: jest.fn(), + }; + const catalogIdentityClient = { + findUser: jest.fn(), + }; + + const provider = new GoogleAuthProvider({ + logger: getVoidLogger(), + catalogIdentityClient: (catalogIdentityClient as unknown) as CatalogIdentityClient, + tokenIssuer: (tokenIssuer as unknown) as TokenIssuer, + profileTransform: async ({ fullProfile }) => ({ + email: fullProfile.emails![0]!.value, + displayName: fullProfile.displayName, + picture: 'http://google.com/lols', + }), + clientId: 'mock', + clientSecret: 'mock', + callbackUrl: 'mock', + }); + + mockFrameHandler.mockResolvedValueOnce({ + result: { + fullProfile: { + emails: [{ value: 'conrad@example.com' }], + displayName: 'Conrad', + id: 'conrad', + provider: 'google', + }, + params: { + id_token: 'idToken', + scope: 'scope', + expires_in: 123, + }, + accessToken: 'accessToken', + }, + privateInfo: { + refreshToken: 'wacka', + }, + }); + const { response } = await provider.handler({} as any); + expect(response).toEqual({ + providerInfo: { + accessToken: 'accessToken', + expiresInSeconds: 123, + idToken: 'idToken', + scope: 'scope', + }, + profile: { + email: 'conrad@example.com', + displayName: 'Conrad', + picture: 'http://google.com/lols', + }, + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index b97e625699..f2feb7155b 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -44,6 +44,7 @@ import { RedirectInfo, SignInResolver, } from '../types'; +import { Logger } from 'winston'; type PrivateInfo = { refreshToken: string; @@ -54,6 +55,7 @@ type Options = OAuthProviderOptions & { profileTransform: ProfileTransform; tokenIssuer: TokenIssuer; catalogIdentityClient: CatalogIdentityClient; + logger: Logger; }; export class GoogleAuthProvider implements OAuthHandlers { @@ -62,12 +64,14 @@ export class GoogleAuthProvider implements OAuthHandlers { private readonly profileTransform: ProfileTransform; private readonly tokenIssuer: TokenIssuer; private readonly catalogIdentityClient: CatalogIdentityClient; + private readonly logger: Logger; constructor(options: Options) { this.signInResolver = options.signInResolver; this.profileTransform = options.profileTransform; this.tokenIssuer = options.tokenIssuer; this.catalogIdentityClient = options.catalogIdentityClient; + this.logger = options.logger; this._strategy = new GoogleStrategy( { clientID: options.clientId, @@ -163,6 +167,7 @@ export class GoogleAuthProvider implements OAuthHandlers { { tokenIssuer: this.tokenIssuer, catalogIdentityClient: this.catalogIdentityClient, + logger: this.logger, }, ); } @@ -171,7 +176,10 @@ export class GoogleAuthProvider implements OAuthHandlers { } } -const emailSignInResolver: SignInResolver = async (info, ctx) => { +export const googleEmailSignInResolver: SignInResolver = async ( + info, + ctx, +) => { const { profile } = info; if (!profile.email) { @@ -190,6 +198,38 @@ const emailSignInResolver: SignInResolver = async (info, ctx) => { return { id: entity.metadata.name, entity, token }; }; +export const googleDefaultSignInResolver: SignInResolver = async ( + info, + ctx, +) => { + const { profile } = info; + + if (!profile.email) { + throw new Error('Google profile contained no email'); + } + + let userId: string; + try { + const entity = await ctx.catalogIdentityClient.findUser({ + annotations: { + 'google.com/email': profile.email, + }, + }); + userId = entity.metadata.name; + } catch (error) { + ctx.logger.warn( + `Failed to look up user, ${error}, falling back to allowing login based on email pattern, this will probably break in the future`, + ); + userId = profile.email.split('@')[0]; + } + + const token = await ctx.tokenIssuer.issueToken({ + claims: { sub: userId, ent: [`user:default/${userId}`] }, + }); + + return { id: userId, token }; +}; + export type GoogleProviderOptions = { /** * The profile transformation function used to verify and convert the auth response @@ -200,21 +240,28 @@ export type GoogleProviderOptions = { /** * Configure sign-in for this provider, without it the provider can not be used to sign users in. */ + /** + * Maps an auth result to a Backstage identity for the user. + * + * Set to `'email'` to use the default email-based sign in resolver, which will search + * the catalog for a single user entity that has a matching `google.com/email` annotation. + */ signIn?: { - /** - * Maps an auth result to a Backstage identity for the user. - * - * Set to `'email'` to use the default email-based sign in resolver, which will search - * the catalog for a single user entity that has a matching `google.com/email` annotation. - */ - resolver?: 'email' | SignInResolver; + resolver?: SignInResolver; }; }; export const createGoogleProvider = ( options?: GoogleProviderOptions, ): AuthProviderFactory => { - return ({ providerId, globalConfig, config, tokenIssuer, catalogApi }) => + return ({ + providerId, + globalConfig, + config, + tokenIssuer, + catalogApi, + logger, + }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { const clientId = envConfig.getString('clientId'); const clientSecret = envConfig.getString('clientSecret'); @@ -233,17 +280,15 @@ export const createGoogleProvider = ( profileTransform = options.profileTransform; } - let signInResolver: SignInResolver | undefined = undefined; - const resolver = options?.signIn?.resolver; - if (resolver === 'email') { - signInResolver = emailSignInResolver; - } else if (typeof resolver === 'function') { - signInResolver = info => - resolver(info, { - catalogIdentityClient, - tokenIssuer, - }); - } + const signInResolverFn = + options?.signIn?.resolver ?? googleDefaultSignInResolver; + + const signInResolver: SignInResolver = info => + signInResolverFn(info, { + catalogIdentityClient, + tokenIssuer, + logger, + }); const provider = new GoogleAuthProvider({ clientId, @@ -253,6 +298,7 @@ export const createGoogleProvider = ( profileTransform, tokenIssuer, catalogIdentityClient, + logger, }); return OAuthAdapter.fromConfig(globalConfig, provider, { diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 1f71bfe59c..305ff7e776 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -215,6 +215,7 @@ export type SignInResolver = ( context: { tokenIssuer: TokenIssuer; catalogIdentityClient: CatalogIdentityClient; + logger: Logger; }, ) => Promise; From 2fbded87e5dca60c96b46157270b9e490b56eaa5 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 16 Jun 2021 10:38:26 +0200 Subject: [PATCH 38/59] chore: revert the changes in app/backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Co-authored-by: Patrik Oldsberg Signed-off-by: blam --- .changeset/seven-adults-act.md | 18 +++++++++++++---- packages/backend/src/plugins/auth.ts | 29 ++-------------------------- test.yaml | 0 3 files changed, 16 insertions(+), 31 deletions(-) create mode 100644 test.yaml diff --git a/.changeset/seven-adults-act.md b/.changeset/seven-adults-act.md index 93541e06a6..fa2839aad2 100644 --- a/.changeset/seven-adults-act.md +++ b/.changeset/seven-adults-act.md @@ -2,14 +2,24 @@ '@backstage/plugin-auth-backend': patch --- -Adds custom sign-in resolvers and profile transformation for Google auth provider. Read more about what this means for Backstage user identity and determining ownership of entities https://backstage.io/docs/auth/identity-resolver -Related the [RFC] From Identity to Ownership, v2 https://github.com/backstage/backstage/issues/4089 +Adds support for custom sign-in resolvers and profile transformations for the +Google auth provider. -Adds `ent` field in the claims of Backstage ID Token with a list of entity references containing identity and membership info about the user across multiple systems. +Adds an `ent` claim in Backstage tokens, with a list of +[entity references](https://backstage.io/docs/features/software-catalog/references) +related to your signed-in user's identities and groups across multiple systems. -Adds an optional `providerFactories` to the `createRouter` exported by the auth-backend plugin. +Adds an optional `providerFactories` argument to the `createRouter` exported by +the `auth-backend` plugin. Updates `BackstageIdentity` so that - `idToken` is deprecated in favor of `token` - An optional `entity` field is added which represents the entity that the user is represented by within Backstage. + +More information: + +- [The identity resolver documentation](https://backstage.io/docs/auth/identity-resolver) + explains the concepts and shows how to implement your own. +- The [From Identity to Ownership](https://github.com/backstage/backstage/issues/4089) + RFC contains details about how this affects ownership in the catalog diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index 3157284df7..2b1c85f052 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -14,10 +14,7 @@ * limitations under the License. */ -import { - createGoogleProvider, - createRouter, -} from '@backstage/plugin-auth-backend'; +import { createRouter } from '@backstage/plugin-auth-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; @@ -27,27 +24,5 @@ export default async function createPlugin({ config, discovery, }: PluginEnvironment): Promise { - return await createRouter({ - logger, - config, - database, - discovery, - providerFactories: { - google: createGoogleProvider({ - signIn: { - // resolver: 'email', - resolver: async ({ profile: { email } }, ctx) => { - if (!email) { - throw new Error('No email associated with user account'); - } - const id = email.split('@')[0]; - const token = await ctx.tokenIssuer.issueToken({ - claims: { sub: id, ent: [`User:default/${id}`] }, - }); - return { id, token }; - }, - }, - }), - }, - }); + return await createRouter({ logger, config, database, discovery }); } diff --git a/test.yaml b/test.yaml new file mode 100644 index 0000000000..e69de29bb2 From 551c050e2310c259eb8173a0f87ca9e2ce980f8a Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 16 Jun 2021 10:42:48 +0200 Subject: [PATCH 39/59] feat: actually export the googleSignInResovlers for use in apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Co-authored-by: Patrik Oldsberg Signed-off-by: blam --- plugins/auth-backend/src/providers/google/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/auth-backend/src/providers/google/index.ts b/plugins/auth-backend/src/providers/google/index.ts index 2615a2d8e5..8bff8b250b 100644 --- a/plugins/auth-backend/src/providers/google/index.ts +++ b/plugins/auth-backend/src/providers/google/index.ts @@ -14,5 +14,9 @@ * limitations under the License. */ -export { createGoogleProvider } from './provider'; +export { + createGoogleProvider, + googleDefaultSignInResolver, + googleEmailSignInResolver, +} from './provider'; export type { GoogleProviderOptions } from './provider'; From f5f290f1c26ae9d152517a427c9a85020ec9229c Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 16 Jun 2021 10:56:42 +0200 Subject: [PATCH 40/59] feat: update the public api instead of profileTransform it is AuthHandler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Co-authored-by: Patrik Oldsberg Signed-off-by: blam --- .../src/providers/google/provider.test.ts | 10 ++++--- .../src/providers/google/provider.ts | 26 +++++++++---------- plugins/auth-backend/src/providers/types.ts | 10 ++++--- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/plugins/auth-backend/src/providers/google/provider.test.ts b/plugins/auth-backend/src/providers/google/provider.test.ts index a8b5b92b5e..45df5bd319 100644 --- a/plugins/auth-backend/src/providers/google/provider.test.ts +++ b/plugins/auth-backend/src/providers/google/provider.test.ts @@ -42,10 +42,12 @@ describe('createGoogleProvider', () => { logger: getVoidLogger(), catalogIdentityClient: (catalogIdentityClient as unknown) as CatalogIdentityClient, tokenIssuer: (tokenIssuer as unknown) as TokenIssuer, - profileTransform: async ({ fullProfile }) => ({ - email: fullProfile.emails![0]!.value, - displayName: fullProfile.displayName, - picture: 'http://google.com/lols', + authHandler: async ({ fullProfile }) => ({ + profile: { + email: fullProfile.emails![0]!.value, + displayName: fullProfile.displayName, + picture: 'http://google.com/lols', + }, }), clientId: 'mock', clientSecret: 'mock', diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index f2feb7155b..ace0c8e132 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -40,7 +40,7 @@ import { } from '../../lib/passport'; import { AuthProviderFactory, - ProfileTransform, + AuthHandler, RedirectInfo, SignInResolver, } from '../types'; @@ -52,7 +52,7 @@ type PrivateInfo = { type Options = OAuthProviderOptions & { signInResolver?: SignInResolver; - profileTransform: ProfileTransform; + authHandler: AuthHandler; tokenIssuer: TokenIssuer; catalogIdentityClient: CatalogIdentityClient; logger: Logger; @@ -61,14 +61,14 @@ type Options = OAuthProviderOptions & { export class GoogleAuthProvider implements OAuthHandlers { private readonly _strategy: GoogleStrategy; private readonly signInResolver?: SignInResolver; - private readonly profileTransform: ProfileTransform; + private readonly authHandler: AuthHandler; private readonly tokenIssuer: TokenIssuer; private readonly catalogIdentityClient: CatalogIdentityClient; private readonly logger: Logger; constructor(options: Options) { this.signInResolver = options.signInResolver; - this.profileTransform = options.profileTransform; + this.authHandler = options.authHandler; this.tokenIssuer = options.tokenIssuer; this.catalogIdentityClient = options.catalogIdentityClient; this.logger = options.logger; @@ -146,7 +146,7 @@ export class GoogleAuthProvider implements OAuthHandlers { } private async handleResult(result: OAuthResult) { - const profile = await this.profileTransform(result); + const { profile } = await this.authHandler(result); const response: OAuthResponse = { providerInfo: { @@ -235,7 +235,7 @@ export type GoogleProviderOptions = { * The profile transformation function used to verify and convert the auth response * into the profile that will be presented to the user. */ - profileTransform?: ProfileTransform; + authHandler?: AuthHandler; /** * Configure sign-in for this provider, without it the provider can not be used to sign users in. @@ -272,13 +272,11 @@ export const createGoogleProvider = ( tokenIssuer, }); - let profileTransform: ProfileTransform = async ({ - fullProfile, - params, - }) => makeProfileInfo(fullProfile, params.id_token); - if (options?.profileTransform) { - profileTransform = options.profileTransform; - } + const authHandler: AuthHandler = options?.authHandler + ? options.authHandler + : async ({ fullProfile, params }) => ({ + profile: makeProfileInfo(fullProfile, params.id_token), + }); const signInResolverFn = options?.signIn?.resolver ?? googleDefaultSignInResolver; @@ -295,7 +293,7 @@ export const createGoogleProvider = ( clientSecret, callbackUrl, signInResolver, - profileTransform, + authHandler, tokenIssuer, catalogIdentityClient, logger, diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 305ff7e776..e4bbaa091c 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -219,14 +219,16 @@ export type SignInResolver = ( }, ) => Promise; +export type AuthHandlerResult = { profile: ProfileInfo }; + /** - * A transformation function called every time the user authenticates using the provider. + * The AuthHandler function is called every time the user authenticates using the provider. * - * The transform should return a profile that represents the session for the user in the frontend. + * The handler should return a profile that represents the session for the user in the frontend. * * Throwing an error in the function will cause the authentication to fail, making it * possible to use this function as a way to limit access to a certain group of users. */ -export type ProfileTransform = ( +export type AuthHandler = ( input: AuthResult, -) => Promise; +) => Promise; From ca60400ebeba1604c88affe5f290836ae4cb0c7a Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 16 Jun 2021 11:03:57 +0200 Subject: [PATCH 41/59] chore: update documentation a little bit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: blam Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Co-authored-by: Patrik Oldsberg --- docs/auth/identity-resolver.md | 35 +++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md index 9414c9aa14..8c1aefb4d3 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -108,6 +108,8 @@ It can be enabled like this ```tsx # File: packages/backend/src/plugins/auth.ts ... +import { googleEmailSignInResolver } from '@backstage/plugin-auth-backend'; + export default async function createPlugin({ ... }: PluginEnvironment): Promise { @@ -116,21 +118,26 @@ export default async function createPlugin({ providerFactories: { google: createGoogleProvider({ signIn: { - resolver: 'email' + resolver: googleEmailSignInResolver } ... ``` -## Profile transform +## AuthHandler -Similar to a custom sign-in resolver, you can also write a custom profile -transformation function which is used to verify and convert the auth response -into the profile that will be presented to the user. This is where you can -customize things like display name and profile picture. +Similar to a custom sign-in resolver, you can also write a custom auth handler +function which is used to verify and convert the auth response into the profile +that will be presented to the user. This is where you can customize things like +display name and profile picture. + +This is also the place where you can do authorization and validation of the user +and throw errors if the user should not be allowed access in Backstage. ```tsx # File: packages/backend/src/plugins/auth.ts ... +import { googleEmailSignInResolver } from '@backstage/plugin-auth-backend'; + export default async function createPlugin({ ... }: PluginEnvironment): Promise { @@ -139,17 +146,19 @@ export default async function createPlugin({ providerFactories: { google: createGoogleProvider({ signIn: { - resolver: 'email' + resolver: googleEmailSignInResolver }, - profileTransform: async ({ + authHandler: async ({ fullProfile // Type: passport.Profile, idToken // Type: (Optional) string, - }): ProfileInfo => { - // Do stuff + }) => { + // Custom validation return { - email, - picture, - displayName, + profile: { + email, + picture, + displayName, + } }; } }) From 8061d1eb9e0885b01aa743c9bb98fdcf3461d2f0 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 16 Jun 2021 11:40:24 +0200 Subject: [PATCH 42/59] docs: rework the documentation to make the example a little simpler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: blam Co-authored-by: Fredrik Adelöw Co-authored-by: Johan Haals Co-authored-by: Patrik Oldsberg --- docs/auth/identity-resolver.md | 38 +++++++++++++--------------------- 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md index 8c1aefb4d3..28bcd7c211 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -36,35 +36,25 @@ export default async function createPlugin({ google: createGoogleProvider({ signIn: { resolver: async ({ profile: { email } }, ctx) => { - if (!email) { - throw new Error('No email associated with user account'); - } + // Call a custom validator function that checks that the email is + // valid and on our own company's domain, and throws an Error if it + // isn't + validateEmail(email); - // Ignore email addresses which do not belong to company's domain name - if (email.split('@')[1] !== 'mycompany.com') { - throw new Error('Unrecognized domain name of the email ID used to sign in.') - } - - // List of entity references that denote the identity and membership of the user + // List of entity references that denote the identity and + // membership of the user const ent = []; - // Let's use the username in the email ID as the user's default unique identifier inside Backstage - const id = email.split('@')[0]; - // Let's add the unique ID in the list + // Let's use the username in the email ID as the user's default + // unique identifier inside Backstage + const [id] = email.split('@'); + + // Add the unique ID in the list ent.push(`User:default/${id}`) - // Let's call the GitHub Enterprise API inside the company and get the teams that the user belongs to - const gheUsername = getGheUsername(email); - const gheTeams = getGheTeams(gheUsername); - - // Let's add the GHE identities to ent claims inside a new ghe namespace to keep things separate from the - // default namespace. - ent.push(`User:ghe/${gheUsername}`) - gheTeams.forEach(team => ent.push(`Group:ghe/${team}`)) - // Let's call the internal LDAP provider to get a list of groups the user belongs to - const ldapGroups = getLdapGroups(email); - ldapGroups.forEach(ldapGroup => ent.push(`Group:myldap/${ldapGroup}`)) + const ldapGroups = await getLdapGroups(email); + ldapGroups.forEach(ldapGroup => ent.push(`Group:default/${ldapGroup}`)) // Issue the token containing the entity claims const token = await ctx.tokenIssuer.issueToken({ @@ -152,7 +142,7 @@ export default async function createPlugin({ fullProfile // Type: passport.Profile, idToken // Type: (Optional) string, }) => { - // Custom validation + // Custom validation code goes here return { profile: { email, From 4d63ce7c1b6214121abca9ba0e318470292a0a00 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 16 Jun 2021 17:42:06 +0200 Subject: [PATCH 43/59] core-plugin-api: make useElementFilter filter for undefined component data instead of falsy Signed-off-by: Patrik Oldsberg --- .../src/extensions/useElementFilter.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.tsx index b33c23e422..fef777cb95 100644 --- a/packages/core-plugin-api/src/extensions/useElementFilter.tsx +++ b/packages/core-plugin-api/src/extensions/useElementFilter.tsx @@ -88,19 +88,21 @@ class ElementCollection { const selection = selectChildren( this.node, this.featureFlagsApi, - node => Boolean(getComponentData(node, query.key)), + node => getComponentData(node, query.key) !== undefined, query.withStrictError, ); return new ElementCollection(selection, this.featureFlagsApi); } findComponentData(query: { key: string }): T[] { - const selection = selectChildren(this.node, this.featureFlagsApi, node => - Boolean(getComponentData(node, query.key)), + const selection = selectChildren( + this.node, + this.featureFlagsApi, + node => getComponentData(node, query.key) !== undefined, ); return selection .map(node => getComponentData(node, query.key)) - .filter((data: T | undefined): data is T => Boolean(data)); + .filter((data: T | undefined): data is T => data !== undefined); } getElements(): Array< From 814b3d0dc278e138898041f3d573acd7fc2a45d5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 16 Jun 2021 17:51:54 +0200 Subject: [PATCH 44/59] core-plugin-api: document useElementFilter Signed-off-by: Patrik Oldsberg --- .../src/extensions/useElementFilter.test.tsx | 4 +- .../src/extensions/useElementFilter.tsx | 39 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx index bb2bcb06a5..73fbb9e5a3 100644 --- a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx +++ b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx @@ -59,7 +59,9 @@ describe('useElementFilter', () => { - + + + diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.tsx index fef777cb95..e79d2ba508 100644 --- a/packages/core-plugin-api/src/extensions/useElementFilter.tsx +++ b/packages/core-plugin-api/src/extensions/useElementFilter.tsx @@ -84,6 +84,22 @@ class ElementCollection { private readonly featureFlagsApi: FeatureFlagsApi, ) {} + /** + * Narrows the set of selected components by doing a deep traversal and + * only including those that have defined component data for the given `key`. + * + * Whether an element in the tree has component data set for the given key + * is determined by whether `getComponentData` returns undefined. + * + * The traversal does not continue deeper past elements that match the criteria, + * and it also includes the root children in the selection, meaning that if the, + * of all the currently selected elements contain data for the given key, this + * method is a no-op. + * + * If `withStrictError` is set, the resulting selection must be a full match, meaning + * there may be no elements that were excluded in the selection. If the selection + * is not a clean match, an error will be throw with `withStrictError` as the message. + */ selectByComponentData(query: { key: string; withStrictError?: string }) { const selection = selectChildren( this.node, @@ -94,6 +110,10 @@ class ElementCollection { return new ElementCollection(selection, this.featureFlagsApi); } + /** + * Finds all elements using the same criteria as `selectByComponentData`, but + * returns the actual component data of each of those elements instead. + */ findComponentData(query: { key: string }): T[] { const selection = selectChildren( this.node, @@ -105,6 +125,9 @@ class ElementCollection { .filter((data: T | undefined): data is T => data !== undefined); } + /** + * Returns all of the elements currently selected by this collection. + */ getElements(): Array< ReactElement > { @@ -114,6 +137,22 @@ class ElementCollection { } } +/** + * useElementFilter is a utility that helps you narrow down and retrieve data + * from a React element tree, typically operating on the `children` property + * passed in to a component. A common use-case is to construct declarative APIs + * where a React component defines its behavior based on its children, such as + * the relationship between `Routes` and `Route` in `react-router`. + * + * The purpose of this hook is similar to `React.Children.map`, and it expands upon + * it to also handle traversal of fragments and Backstage specific things like the + * `FeatureFlagged` component. + * + * The return value of the hook is computed by the provided filter function, but + * with added memoization based on the input `node`. If further memoization + * dependencies are used in the filter function, they should be added to the + * third `dependencies` argument, just like `useMemo`, `useEffect`, etc. + */ export function useElementFilter( node: ReactNode, filterFn: (arg: ElementCollection) => T, From 64b53d4829e6b398cccb29f6625c95b212eb4386 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 16 Jun 2021 18:31:12 +0200 Subject: [PATCH 45/59] core-plugin-api: export ElementCollection interface + document Signed-off-by: Patrik Oldsberg --- packages/core-plugin-api/api-report.md | 16 +++++- .../core-plugin-api/src/extensions/index.ts | 1 + .../src/extensions/useElementFilter.tsx | 53 +++++++++++++------ 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md index 6e8cda3d83..90ea1f5e46 100644 --- a/packages/core-plugin-api/api-report.md +++ b/packages/core-plugin-api/api-report.md @@ -228,6 +228,20 @@ export type DiscoveryApi = { // @public (undocumented) export const discoveryApiRef: ApiRef; +// @public +export interface ElementCollection { + findComponentData(query: { + key: string; + }): T[]; + getElements(): Array>; + selectByComponentData(query: { + key: string; + withStrictError?: string; + }): ElementCollection; +} + // @public export type ErrorApi = { post(error: Error_2, context?: ErrorContext): void; @@ -515,7 +529,7 @@ export function useApiHolder(): ApiHolder; // @public (undocumented) export const useApp: () => AppContext; -// @public (undocumented) +// @public export function useElementFilter(node: ReactNode, filterFn: (arg: ElementCollection) => T, dependencies?: any[]): T; // @public (undocumented) diff --git a/packages/core-plugin-api/src/extensions/index.ts b/packages/core-plugin-api/src/extensions/index.ts index 71373db1a3..0ee1447b4e 100644 --- a/packages/core-plugin-api/src/extensions/index.ts +++ b/packages/core-plugin-api/src/extensions/index.ts @@ -21,3 +21,4 @@ export { createComponentExtension, } from './extensions'; export { useElementFilter } from './useElementFilter'; +export type { ElementCollection } from './useElementFilter'; diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.tsx index e79d2ba508..0549472ab2 100644 --- a/packages/core-plugin-api/src/extensions/useElementFilter.tsx +++ b/packages/core-plugin-api/src/extensions/useElementFilter.tsx @@ -78,12 +78,17 @@ function selectChildren( }); } -class ElementCollection { - constructor( - private readonly node: ReactNode, - private readonly featureFlagsApi: FeatureFlagsApi, - ) {} - +/** + * A querying interface tailored to traversing a set of selected React elements + * and extracting data. + * + * Methods prefixed with `selectBy` are used to narrow the set of selected elements. + * + * Methods prefixed with `find` return concrete data using a deep traversal of the set. + * + * Methods prefixed with `get` return concrete data using a shallow traversal of the set. + */ +export interface ElementCollection { /** * Narrows the set of selected components by doing a deep traversal and * only including those that have defined component data for the given `key`. @@ -100,6 +105,31 @@ class ElementCollection { * there may be no elements that were excluded in the selection. If the selection * is not a clean match, an error will be throw with `withStrictError` as the message. */ + selectByComponentData(query: { + key: string; + withStrictError?: string; + }): ElementCollection; + + /** + * Finds all elements using the same criteria as `selectByComponentData`, but + * returns the actual component data of each of those elements instead. + */ + findComponentData(query: { key: string }): T[]; + + /** + * Returns all of the elements currently selected by this collection. + */ + getElements(): Array< + ReactElement + >; +} + +class Collection implements ElementCollection { + constructor( + private readonly node: ReactNode, + private readonly featureFlagsApi: FeatureFlagsApi, + ) {} + selectByComponentData(query: { key: string; withStrictError?: string }) { const selection = selectChildren( this.node, @@ -107,13 +137,9 @@ class ElementCollection { node => getComponentData(node, query.key) !== undefined, query.withStrictError, ); - return new ElementCollection(selection, this.featureFlagsApi); + return new Collection(selection, this.featureFlagsApi); } - /** - * Finds all elements using the same criteria as `selectByComponentData`, but - * returns the actual component data of each of those elements instead. - */ findComponentData(query: { key: string }): T[] { const selection = selectChildren( this.node, @@ -125,9 +151,6 @@ class ElementCollection { .filter((data: T | undefined): data is T => data !== undefined); } - /** - * Returns all of the elements currently selected by this collection. - */ getElements(): Array< ReactElement > { @@ -159,7 +182,7 @@ export function useElementFilter( dependencies: any[] = [], ) { const featureFlagsApi = useApi(featureFlagsApiRef); - const elements = new ElementCollection(node, featureFlagsApi); + const elements = new Collection(node, featureFlagsApi); // eslint-disable-next-line react-hooks/exhaustive-deps return useMemo(() => filterFn(elements), [node, ...dependencies]); } From 71416fb64ebcd76772d40081890464f4b4754a40 Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Wed, 16 Jun 2021 11:29:04 -0600 Subject: [PATCH 46/59] changeset Signed-off-by: Tim Hansen --- .changeset/popular-rice-wonder.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/popular-rice-wonder.md diff --git a/.changeset/popular-rice-wonder.md b/.changeset/popular-rice-wonder.md new file mode 100644 index 0000000000..f815ad949a --- /dev/null +++ b/.changeset/popular-rice-wonder.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-catalog': patch +'@backstage/plugin-catalog-backend': patch +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-scaffolder-backend': patch +--- + +Moved installation instructions from the main [backstage.io](https://backstage.io) documentation to the package README file. These instructions are not generally needed, since the plugin comes installed by default with `npx @backstage/create-app`. From c41bb50f94532ee2da5ce87a0df82ff05f7c36ab Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 16 Jun 2021 22:03:51 +0200 Subject: [PATCH 47/59] chore: tidy up docs again Signed-off-by: blam --- docs/auth/identity-resolver.md | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md index 28bcd7c211..814ff63729 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -5,7 +5,7 @@ description: Identity resolvers of Backstage users after they sign-in --- This guide explains how the identity of a Backstage user is stored inside their -Backstage Identity Token and how you can customize the Sign In resolvers to +Backstage Identity Token and how you can customize the Sign-In resolvers to include identity and group membership information of the user from other external systems. This ultimately helps with determining the ownership of a Backstage entity by a user. The ideas here were originally proposed in the RFC @@ -46,15 +46,14 @@ export default async function createPlugin({ const ent = []; // Let's use the username in the email ID as the user's default - // unique identifier inside Backstage + // unique identifier inside Backstage. const [id] = email.split('@'); - - // Add the unique ID in the list ent.push(`User:default/${id}`) - // Let's call the internal LDAP provider to get a list of groups the user belongs to + // Let's call the internal LDAP provider to get a list of groups + // that the user belongs to, and add those to the list as well const ldapGroups = await getLdapGroups(email); - ldapGroups.forEach(ldapGroup => ent.push(`Group:default/${ldapGroup}`)) + ldapGroups.forEach(group => ent.push(`Group:default/${group}`)) // Issue the token containing the entity claims const token = await ctx.tokenIssuer.issueToken({ @@ -72,10 +71,10 @@ export default async function createPlugin({ As you can see, the generated Backstage Token now contains all the claims about the identity and membership of the user. Once the sign-in process is complete, and we need to find out if a user owns an Entity in the Software Catalog, these -`ent` claims can be used to determine the ownership. A full algorithm as -proposed in the RFC is as follows +`ent` claims can be used to determine the ownership. -The definition of the ownership of an entity E, for a user U, is as follows: +According to the RFC, the definition of the ownership of an entity E, for a user +U, is as follows: - Get all the `ownedBy` relations of E, and call them O - Get all the claims of the user U and call them C @@ -89,15 +88,14 @@ The definition of the ownership of an entity E, for a user U, is as follows: Of course you don't have to customize the sign-in resolver if you don't need to. The Auth backend plugin comes with a set of default sign-in resolvers which you -can use. For example - the Google provider has a default email-based sign in +can use. For example - the Google provider has a default email-based sign-in resolver, which will search the catalog for a single user entity that has a matching `google.com/email` annotation. It can be enabled like this ```tsx -# File: packages/backend/src/plugins/auth.ts -... +// File: packages/backend/src/plugins/auth.ts import { googleEmailSignInResolver } from '@backstage/plugin-auth-backend'; export default async function createPlugin({ @@ -124,10 +122,7 @@ This is also the place where you can do authorization and validation of the user and throw errors if the user should not be allowed access in Backstage. ```tsx -# File: packages/backend/src/plugins/auth.ts -... -import { googleEmailSignInResolver } from '@backstage/plugin-auth-backend'; - +// File: packages/backend/src/plugins/auth.ts export default async function createPlugin({ ... }: PluginEnvironment): Promise { @@ -135,9 +130,6 @@ export default async function createPlugin({ ... providerFactories: { google: createGoogleProvider({ - signIn: { - resolver: googleEmailSignInResolver - }, authHandler: async ({ fullProfile // Type: passport.Profile, idToken // Type: (Optional) string, From 5c4e6aee2536fef8ec00cdeca5b0812ce481449c Mon Sep 17 00:00:00 2001 From: Andrew Thauer Date: Wed, 16 Jun 2021 19:46:25 -0400 Subject: [PATCH 48/59] feat(explore): customizable explore page Signed-off-by: Andrew Thauer --- .changeset/cuddly-donuts-whisper.md | 54 ++++++++++ plugins/explore/README.md | 46 ++++++++ plugins/explore/package.json | 2 + .../DefaultExplorePage.test.tsx | 63 +++++++++++ .../DefaultExplorePage/DefaultExplorePage.tsx | 45 ++++++++ .../components/DefaultExplorePage/index.ts | 17 +++ .../DomainExplorerContent.test.tsx | 37 ++++--- .../DomainExplorerContent.tsx | 10 +- .../ExploreLayout/ExploreLayout.test.tsx | 81 ++++++++++++++ .../ExploreLayout/ExploreLayout.tsx | 102 ++++++++++++++++++ .../src/components/ExploreLayout/index.ts | 17 +++ .../ExplorePage/ExplorePage.test.tsx | 48 +++++++++ .../components/ExplorePage/ExplorePage.tsx | 19 +--- .../components/ExplorePage/ExploreTabs.tsx | 34 ------ .../GroupsExplorerContent.test.tsx | 31 ++++-- .../GroupsExplorerContent.tsx | 11 +- .../ToolExplorerContent.test.tsx | 12 +++ .../ToolExplorerContent.tsx | 9 +- plugins/explore/src/components/index.ts | 17 +++ plugins/explore/src/extensions.tsx | 38 ++++++- plugins/explore/src/index.ts | 3 +- 21 files changed, 615 insertions(+), 81 deletions(-) create mode 100644 .changeset/cuddly-donuts-whisper.md create mode 100644 plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx create mode 100644 plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.tsx create mode 100644 plugins/explore/src/components/DefaultExplorePage/index.ts create mode 100644 plugins/explore/src/components/ExploreLayout/ExploreLayout.test.tsx create mode 100644 plugins/explore/src/components/ExploreLayout/ExploreLayout.tsx create mode 100644 plugins/explore/src/components/ExploreLayout/index.ts create mode 100644 plugins/explore/src/components/ExplorePage/ExplorePage.test.tsx delete mode 100644 plugins/explore/src/components/ExplorePage/ExploreTabs.tsx create mode 100644 plugins/explore/src/components/index.ts diff --git a/.changeset/cuddly-donuts-whisper.md b/.changeset/cuddly-donuts-whisper.md new file mode 100644 index 0000000000..fd1b665b99 --- /dev/null +++ b/.changeset/cuddly-donuts-whisper.md @@ -0,0 +1,54 @@ +--- +'@backstage/plugin-explore': patch +--- + +Refactors the explore plugin to be more customizable. This includes the following non-breaking changes: + +- Introduce new `ExploreLayout` page which can be used to create a custom `ExplorePage` +- Refactor `ExplorePage` to use a new `ExploreLayout` component +- Exports existing `DomainExplorerContent`, `GroupsExplorerContent`, & `ToolExplorerContent` components +- Allows `title` props to be customized + +Create a custom explore page in `packages/app/src/components/explore/ExplorePage.tsx`. + +```tsx +import { + DomainExplorerContent, + ExploreLayout, +} from '@backstage/plugin-explore'; +import React from 'react'; +import { InnserSourceExplorerContent } from './InnserSourceExplorerContent'; + +export const ExplorePage = () => { + return ( + + + + + + + + + ); +}; + +export const explorePage = ; +``` + +Now register the new explore page in `packages/app/src/App.tsx`. + +```diff ++ import { explorePage } from './components/explore/ExplorePage'; + +const routes = ( + +- } /> ++ }> ++ {explorePage} ++ + +); +``` diff --git a/plugins/explore/README.md b/plugins/explore/README.md index fea1333e34..8e1cbaed02 100644 --- a/plugins/explore/README.md +++ b/plugins/explore/README.md @@ -33,3 +33,49 @@ import LayersIcon from '@material-ui/icons/Layers'; ``` + +## Customization + +Create a custom explore page in `packages/app/src/components/explore/ExplorePage.tsx`. + +```tsx +import { + DomainExplorerContent, + ExploreLayout, +} from '@backstage/plugin-explore'; +import React from 'react'; +import { InnserSourceExplorerContent } from './InnserSourceExplorerContent'; + +export const ExplorePage = () => { + return ( + + + + + + + + + ); +}; + +export const explorePage = ; +``` + +Now register the new explore page in `packages/app/src/App.tsx`. + +```diff ++ import { explorePage } from './components/explore/ExplorePage'; + +const routes = ( + +- } /> ++ }> ++ {explorePage} ++ + +); +``` diff --git a/plugins/explore/package.json b/plugins/explore/package.json index fd6ff19a93..7a7d923c8d 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -38,9 +38,11 @@ "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", + "@types/react": "^16.9", "classnames": "^2.2.6", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", "react-use": "^17.2.4" }, diff --git a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx new file mode 100644 index 0000000000..8f69ea3527 --- /dev/null +++ b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor, getByText } from '@testing-library/react'; +import React from 'react'; +import { DefaultExplorePage } from './DefaultExplorePage'; + +describe('', () => { + const catalogApi: jest.Mocked = { + addLocation: jest.fn(_a => new Promise(() => {})), + getEntities: jest.fn(), + getOriginLocationByEntity: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + getEntityByName: jest.fn(), + }; + + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders the default explore page', async () => { + catalogApi.getEntities.mockResolvedValue({ items: [] }); + + const { getAllByRole } = await renderInTestApp( + + + , + ); + + await waitFor(() => { + const elements = getAllByRole('tab'); + expect(elements.length).toBe(3); + expect(getByText(elements[0], 'Domains')).toBeInTheDocument(); + expect(getByText(elements[1], 'Groups')).toBeInTheDocument(); + expect(getByText(elements[2], 'Tools')).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.tsx b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.tsx new file mode 100644 index 0000000000..abc73dca31 --- /dev/null +++ b/plugins/explore/src/components/DefaultExplorePage/DefaultExplorePage.tsx @@ -0,0 +1,45 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { configApiRef, useApi } from '@backstage/core'; +import { DomainExplorerContent } from '../DomainExplorerContent'; +import { ExploreLayout } from '../ExploreLayout'; +import { GroupsExplorerContent } from '../GroupsExplorerContent'; +import { ToolExplorerContent } from '../ToolExplorerContent'; + +export const DefaultExplorePage = () => { + const configApi = useApi(configApiRef); + const organizationName = + configApi.getOptionalString('organization.name') ?? 'Backstage'; + + return ( + + + + + + + + + + + + ); +}; diff --git a/plugins/explore/src/components/DefaultExplorePage/index.ts b/plugins/explore/src/components/DefaultExplorePage/index.ts new file mode 100644 index 0000000000..b4df272266 --- /dev/null +++ b/plugins/explore/src/components/DefaultExplorePage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { DefaultExplorePage } from './DefaultExplorePage'; diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx index 1784587a95..1a93463862 100644 --- a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.test.tsx @@ -41,6 +41,12 @@ describe('', () => { ); + const mountedRoutes = { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': catalogEntityRouteRef, + }, + }; + beforeEach(() => { jest.resetAllMocks(); }); @@ -74,11 +80,7 @@ describe('', () => { , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': catalogEntityRouteRef, - }, - }, + mountedRoutes, ); await waitFor(() => { @@ -87,6 +89,19 @@ describe('', () => { }); }); + it('renders a custom title', async () => { + catalogApi.getEntities.mockResolvedValue({ items: [] }); + + const { getByText } = await renderInTestApp( + + + , + mountedRoutes, + ); + + await waitFor(() => expect(getByText('Our Areas')).toBeInTheDocument()); + }); + it('renders empty state', async () => { catalogApi.getEntities.mockResolvedValue({ items: [] }); @@ -94,11 +109,7 @@ describe('', () => { , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': catalogEntityRouteRef, - }, - }, + mountedRoutes, ); await waitFor(() => @@ -114,11 +125,7 @@ describe('', () => { , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': catalogEntityRouteRef, - }, - }, + mountedRoutes, ); await waitFor(() => diff --git a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx index a12811e486..8217413842 100644 --- a/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx +++ b/plugins/explore/src/components/DomainExplorerContent/DomainExplorerContent.tsx @@ -79,10 +79,16 @@ const Body = () => { ); }; -export const DomainExplorerContent = () => { +type DomainExplorerContentProps = { + title?: string; +}; + +export const DomainExplorerContent = ({ + title, +}: DomainExplorerContentProps) => { return ( - + Discover the domains in your ecosystem. diff --git a/plugins/explore/src/components/ExploreLayout/ExploreLayout.test.tsx b/plugins/explore/src/components/ExploreLayout/ExploreLayout.test.tsx new file mode 100644 index 0000000000..1021ff25d0 --- /dev/null +++ b/plugins/explore/src/components/ExploreLayout/ExploreLayout.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderInTestApp } from '@backstage/test-utils'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; +import { ExploreLayout } from './ExploreLayout'; + +describe('', () => { + const Wrapper = ({ children }: { children?: React.ReactNode }) => ( + <>{children} + ); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders an explore tabbed layout page with defaults', async () => { + const { getByText } = await renderInTestApp( + + + +
Tools Content
+
+
+
, + ); + + await waitFor(() => { + expect(getByText('Explore our ecosystem')).toBeInTheDocument(); + expect( + getByText('Discover solutions available in our ecosystem'), + ).toBeInTheDocument(); + }); + }); + + it('renders a custom page title', async () => { + const { getByText } = await renderInTestApp( + + + +
Tools Content
+
+
+
, + ); + + await waitFor(() => + expect(getByText('Explore our universe')).toBeInTheDocument(), + ); + }); + + it('renders a custom page subtitle', async () => { + const { getByText } = await renderInTestApp( + + + +
Tools Content
+
+
+
, + ); + + await waitFor(() => + expect(getByText('Browse the ACME Corp ecosystem')).toBeInTheDocument(), + ); + }); +}); diff --git a/plugins/explore/src/components/ExploreLayout/ExploreLayout.tsx b/plugins/explore/src/components/ExploreLayout/ExploreLayout.tsx new file mode 100644 index 0000000000..7e64ed815c --- /dev/null +++ b/plugins/explore/src/components/ExploreLayout/ExploreLayout.tsx @@ -0,0 +1,102 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { attachComponentData, Header, Page, RoutedTabs } from '@backstage/core'; +import { TabProps } from '@material-ui/core'; +import { Children, default as React, Fragment, isValidElement } from 'react'; + +// TODO: This layout could be a shared based component if it was possible to create custom TabbedLayouts +// A generalized version of createSubRoutesFromChildren, etc. would be required + +type SubRoute = { + path: string; + title: string; + children: JSX.Element; + tabProps?: TabProps; +}; + +const Route: (props: SubRoute) => null = () => null; + +// This causes all mount points that are discovered within this route to use the path of the route itself +attachComponentData(Route, 'core.gatherMountPoints', true); + +function createSubRoutesFromChildren( + childrenProps: React.ReactNode, +): SubRoute[] { + // Directly comparing child.type with Route will not work with in + // combination with react-hot-loader in storybook + // https://github.com/gaearon/react-hot-loader/issues/304 + const routeType = ( + +
+ + ).type; + + return Children.toArray(childrenProps).flatMap(child => { + if (!isValidElement(child)) { + return []; + } + + if (child.type === Fragment) { + return createSubRoutesFromChildren(child.props.children); + } + + if (child.type !== routeType) { + throw new Error('Child of ExploreLayout must be an ExploreLayout.Route'); + } + + const { path, title, children, tabProps } = child.props; + return [{ path, title, children, tabProps }]; + }); +} + +type ExploreLayoutProps = { + title?: string; + subtitle?: string; + children?: React.ReactNode; +}; + +/** + * Explore is a compound component, which allows you to define a custom layout + * + * @example + * ```jsx + * + * + *
This is rendered under /example/anything-here route
+ *
+ *
+ * ``` + */ +export const ExploreLayout = ({ + title, + subtitle, + children, +}: ExploreLayoutProps) => { + const routes = createSubRoutesFromChildren(children); + + return ( + +
+ + + ); +}; + +ExploreLayout.Route = Route; diff --git a/plugins/explore/src/components/ExploreLayout/index.ts b/plugins/explore/src/components/ExploreLayout/index.ts new file mode 100644 index 0000000000..6cbae79a71 --- /dev/null +++ b/plugins/explore/src/components/ExploreLayout/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ExploreLayout } from './ExploreLayout'; diff --git a/plugins/explore/src/components/ExplorePage/ExplorePage.test.tsx b/plugins/explore/src/components/ExplorePage/ExplorePage.test.tsx new file mode 100644 index 0000000000..9034ee72c0 --- /dev/null +++ b/plugins/explore/src/components/ExplorePage/ExplorePage.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { useOutlet } from 'react-router'; +import { ExplorePage } from './ExplorePage'; + +jest.mock('react-router', () => ({ + ...jest.requireActual('react-router'), + useLocation: jest.fn().mockReturnValue({ + search: '', + }), + useOutlet: jest.fn().mockReturnValue('Route Children'), +})); + +jest.mock('../DefaultExplorePage', () => ({ + ...jest.requireActual('../DefaultExplorePage'), + DefaultExplorePage: jest.fn().mockReturnValue('DefaultExplorePageMock'), +})); + +describe('ExplorePage', () => { + it('renders provided router element', async () => { + const { getByText } = await renderInTestApp(); + + expect(getByText('Route Children')).toBeInTheDocument(); + }); + + it('renders default explorer page when no router children are provided', async () => { + (useOutlet as jest.Mock).mockReturnValueOnce(null); + const { getByText } = await renderInTestApp(); + + expect(getByText('DefaultExplorePageMock')).toBeInTheDocument(); + }); +}); diff --git a/plugins/explore/src/components/ExplorePage/ExplorePage.tsx b/plugins/explore/src/components/ExplorePage/ExplorePage.tsx index 3f9394a1ab..e3601614d2 100644 --- a/plugins/explore/src/components/ExplorePage/ExplorePage.tsx +++ b/plugins/explore/src/components/ExplorePage/ExplorePage.tsx @@ -13,22 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { configApiRef, Header, Page, useApi } from '@backstage/core'; + import React from 'react'; -import { ExploreTabs } from './ExploreTabs'; +import { useOutlet } from 'react-router'; +import { DefaultExplorePage } from '../DefaultExplorePage'; export const ExplorePage = () => { - const configApi = useApi(configApiRef); - const organizationName = - configApi.getOptionalString('organization.name') ?? 'Backstage'; - return ( - -
+ const outlet = useOutlet(); - - - ); + return <>{outlet || }; }; diff --git a/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx b/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx deleted file mode 100644 index 0415dd97ae..0000000000 --- a/plugins/explore/src/components/ExplorePage/ExploreTabs.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2021 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { TabbedLayout } from '@backstage/core'; -import React from 'react'; -import { DomainExplorerContent } from '../DomainExplorerContent'; -import { GroupsExplorerContent } from '../GroupsExplorerContent'; -import { ToolExplorerContent } from '../ToolExplorerContent'; - -export const ExploreTabs = () => ( - - - - - - - - - - - -); diff --git a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx index 38c4678d5a..4e92784818 100644 --- a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx +++ b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.test.tsx @@ -40,6 +40,12 @@ describe('', () => { ); + const mountedRoutes = { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }; + beforeEach(() => { jest.resetAllMocks(); @@ -69,11 +75,7 @@ describe('', () => { , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': entityRouteRef, - }, - }, + mountedRoutes, ); await waitFor(() => { @@ -81,6 +83,19 @@ describe('', () => { }); }); + it('renders a custom title', async () => { + catalogApi.getEntities.mockResolvedValue({ items: [] }); + + const { getByText } = await renderInTestApp( + + + , + mountedRoutes, + ); + + await waitFor(() => expect(getByText('Our Teams')).toBeInTheDocument()); + }); + it('renders a friendly error if it cannot collect domains', async () => { const catalogError = new Error('Network timeout'); catalogApi.getEntities.mockRejectedValueOnce(catalogError); @@ -89,11 +104,7 @@ describe('', () => { , - { - mountedRoutes: { - '/catalog/:namespace/:kind/:name': entityRouteRef, - }, - }, + mountedRoutes, ); await waitFor(() => diff --git a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.tsx b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.tsx index c4d46206e4..bf2363f16c 100644 --- a/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.tsx +++ b/plugins/explore/src/components/GroupsExplorerContent/GroupsExplorerContent.tsx @@ -13,14 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { Content, ContentHeader, SupportButton } from '@backstage/core'; import React from 'react'; import { GroupsDiagram } from './GroupsDiagram'; -export const GroupsExplorerContent = () => { +type GroupsExplorerContentProps = { + title?: string; +}; + +export const GroupsExplorerContent = ({ + title, +}: GroupsExplorerContentProps) => { return ( - + Explore your groups. diff --git a/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx index 125a72eb12..5cc444ace5 100644 --- a/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx +++ b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.test.tsx @@ -80,6 +80,18 @@ describe('', () => { }); }); + it('renders a custom title', async () => { + exploreToolsConfigApi.getTools.mockResolvedValue([]); + + const { getByText } = await renderInTestApp( + + + , + ); + + await waitFor(() => expect(getByText('Our Tools')).toBeInTheDocument()); + }); + it('renders empty state', async () => { exploreToolsConfigApi.getTools.mockResolvedValue([]); diff --git a/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.tsx b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.tsx index e00606eeb2..8dcf1957d0 100644 --- a/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.tsx +++ b/plugins/explore/src/components/ToolExplorerContent/ToolExplorerContent.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { Content, ContentHeader, @@ -61,9 +62,13 @@ const Body = () => { ); }; -export const ToolExplorerContent = () => ( +type ToolExplorerContentProps = { + title?: string; +}; + +export const ToolExplorerContent = ({ title }: ToolExplorerContentProps) => ( - + Discover the tools in your ecosystem. diff --git a/plugins/explore/src/components/index.ts b/plugins/explore/src/components/index.ts new file mode 100644 index 0000000000..6cbae79a71 --- /dev/null +++ b/plugins/explore/src/components/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { ExploreLayout } from './ExploreLayout'; diff --git a/plugins/explore/src/extensions.tsx b/plugins/explore/src/extensions.tsx index cdf43d3035..88ea2561fa 100644 --- a/plugins/explore/src/extensions.tsx +++ b/plugins/explore/src/extensions.tsx @@ -14,7 +14,10 @@ * limitations under the License. */ -import { createRoutableExtension } from '@backstage/core'; +import { + createComponentExtension, + createRoutableExtension, +} from '@backstage/core'; import { explorePlugin } from './plugin'; import { exploreRouteRef } from './routes'; @@ -25,3 +28,36 @@ export const ExplorePage = explorePlugin.provide( mountPoint: exploreRouteRef, }), ); + +export const DomainExplorerContent = explorePlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/DomainExplorerContent').then( + m => m.DomainExplorerContent, + ), + }, + }), +); + +export const GroupsExplorerContent = explorePlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/GroupsExplorerContent').then( + m => m.GroupsExplorerContent, + ), + }, + }), +); + +export const ToolExplorerContent = explorePlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/ToolExplorerContent').then( + m => m.ToolExplorerContent, + ), + }, + }), +); diff --git a/plugins/explore/src/index.ts b/plugins/explore/src/index.ts index 70a00f5bbb..bee46a31ec 100644 --- a/plugins/explore/src/index.ts +++ b/plugins/explore/src/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export { ExploreLayout } from './components'; export * from './extensions'; -export { explorePlugin } from './plugin'; +export { explorePlugin, explorePlugin as plugin } from './plugin'; export * from './routes'; From fea7fa0ba6e0d38e4a08747426fb6a7509912159 Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Wed, 9 Jun 2021 10:22:56 +0200 Subject: [PATCH 49/59] Return a `304 Not Modified` from the `/sync/:namespace/:kind/:name` endpoint if nothing was built Signed-off-by: Dominik Henneke --- .changeset/techdocs-poor-forks-repeat.md | 5 +++++ plugins/techdocs-backend/src/DocsBuilder/builder.ts | 10 ++++++++-- plugins/techdocs-backend/src/service/router.ts | 13 ++++++++++--- 3 files changed, 23 insertions(+), 5 deletions(-) create mode 100644 .changeset/techdocs-poor-forks-repeat.md diff --git a/.changeset/techdocs-poor-forks-repeat.md b/.changeset/techdocs-poor-forks-repeat.md new file mode 100644 index 0000000000..89c4e0dc5a --- /dev/null +++ b/.changeset/techdocs-poor-forks-repeat.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-backend': patch +--- + +Return a `304 Not Modified` from the `/sync/:namespace/:kind/:name` endpoint if nothing was built. This enables the caller to know whether a refresh of the docs page will return updated content (-> `201 Created`) or not (-> `304 Not Modified`). diff --git a/plugins/techdocs-backend/src/DocsBuilder/builder.ts b/plugins/techdocs-backend/src/DocsBuilder/builder.ts index dce391ff3f..24549f8b6f 100644 --- a/plugins/techdocs-backend/src/DocsBuilder/builder.ts +++ b/plugins/techdocs-backend/src/DocsBuilder/builder.ts @@ -68,7 +68,11 @@ export class DocsBuilder { this.config = config; } - public async build(): Promise { + /** + * Build the docs and return whether they have been newly generated or have been cached + * @returns true, if the docs have been built. false, if the cached docs are still up-to-date. + */ + public async build(): Promise { if (!this.entity.metadata.uid) { throw new Error( 'Trying to build documentation for entity not in service catalog', @@ -125,7 +129,7 @@ export class DocsBuilder { this.entity, )} are unmodified. Using cache, skipping generate and prepare`, ); - return; + return false; } throw new Error(err.message); } @@ -205,5 +209,7 @@ export class DocsBuilder { // Update the last check time for the entity new BuildMetadataStorage(this.entity.metadata.uid).setLastUpdated(); + + return true; } } diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 1cd4075da0..1a8bdb9c8f 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -16,7 +16,7 @@ import { PluginEndpointDiscovery } from '@backstage/backend-common'; import { Entity, stringifyEntityRef } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; -import { NotFoundError } from '@backstage/errors'; +import { NotFoundError, NotModifiedError } from '@backstage/errors'; import { GeneratorBuilder, getLocationForEntity, @@ -172,10 +172,10 @@ export async function createRouter({ case 'awsS3': case 'azureBlobStorage': case 'openStackSwift': - case 'googleGcs': + case 'googleGcs': { // This block should be valid for all storage implementations. So no need to duplicate in future, // add the publisher type in the list here. - await docsBuilder.build(); + const updated = await docsBuilder.build(); // With a maximum of ~5 seconds wait, check if the files got published and if docs will be fetched // on the user's page. If not, respond with a message asking them to check back later. // The delay here is to make sure GCS/AWS/etc. registers newly uploaded files which is usually <1 second @@ -194,10 +194,17 @@ export async function createRouter({ 'Sorry! It took too long for the generated docs to show up in storage. Check back later.', ); } + + if (!updated) { + throw new NotModifiedError(); + } + res .status(201) .json({ message: 'Docs updated or did not need updating' }); break; + } + default: throw new NotFoundError( `Publisher type ${publisherType} is not supported by techdocs-backend docs builder.`, From 1dfec7a2ae788d8b9c18a2ad278c11fa58c6bf5c Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Wed, 9 Jun 2021 10:49:10 +0200 Subject: [PATCH 50/59] Refactor the implicit logic from `` into an explicit state machine Signed-off-by: Dominik Henneke --- .changeset/techdocs-cool-rivers-suffer.md | 5 + plugins/techdocs/dev/api.ts | 149 ------ plugins/techdocs/dev/index.tsx | 193 +++++++- plugins/techdocs/package.json | 1 + plugins/techdocs/src/api.ts | 4 +- plugins/techdocs/src/client.ts | 14 +- .../techdocs/src/reader/components/Reader.tsx | 155 +++--- .../reader/components/useReaderState.test.tsx | 459 ++++++++++++++++++ .../src/reader/components/useReaderState.ts | 335 +++++++++++++ .../reader/transformers/addBaseUrl.test.ts | 2 +- 10 files changed, 1053 insertions(+), 264 deletions(-) create mode 100644 .changeset/techdocs-cool-rivers-suffer.md delete mode 100644 plugins/techdocs/dev/api.ts create mode 100644 plugins/techdocs/src/reader/components/useReaderState.test.tsx create mode 100644 plugins/techdocs/src/reader/components/useReaderState.ts diff --git a/.changeset/techdocs-cool-rivers-suffer.md b/.changeset/techdocs-cool-rivers-suffer.md new file mode 100644 index 0000000000..2386d27525 --- /dev/null +++ b/.changeset/techdocs-cool-rivers-suffer.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Refactor the implicit logic from `` into an explicit state machine. This resolves some state synchronization issues when content is refreshed or rebuilt in the backend. diff --git a/plugins/techdocs/dev/api.ts b/plugins/techdocs/dev/api.ts deleted file mode 100644 index e764bf82bb..0000000000 --- a/plugins/techdocs/dev/api.ts +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { EntityName } from '@backstage/catalog-model'; -import { Config } from '@backstage/config'; -import { DiscoveryApi, IdentityApi } from '@backstage/core'; -import { NotFoundError } from '@backstage/errors'; -import { TechDocsStorageApi } from '../src/api'; - -export class TechDocsDevStorageApi implements TechDocsStorageApi { - public configApi: Config; - public discoveryApi: DiscoveryApi; - public identityApi: IdentityApi; - - constructor({ - configApi, - discoveryApi, - identityApi, - }: { - configApi: Config; - discoveryApi: DiscoveryApi; - identityApi: IdentityApi; - }) { - this.configApi = configApi; - this.discoveryApi = discoveryApi; - this.identityApi = identityApi; - } - - async getApiOrigin() { - return ( - this.configApi.getOptionalString('techdocs.requestUrl') ?? - (await this.discoveryApi.getBaseUrl('techdocs')) - ); - } - - async getStorageUrl() { - return ( - this.configApi.getOptionalString('techdocs.storageUrl') ?? - `${await this.discoveryApi.getBaseUrl('techdocs')}/static/docs` - ); - } - - async getBuilder() { - return this.configApi.getString('techdocs.builder'); - } - - async fetchUrl(url: string) { - const token = await this.identityApi.getIdToken(); - return fetch(url, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }); - } - - async getEntityDocs(entityId: EntityName, path: string) { - const { kind, namespace, name } = entityId; - - const storageUrl = await this.getStorageUrl(); - const url = `${storageUrl}/${namespace}/${kind}/${name}/${path}`; - const token = await this.identityApi.getIdToken(); - - const request = await fetch( - `${url.endsWith('/') ? url : `${url}/`}index.html`, - { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }, - ); - - let errorMessage = ''; - switch (request.status) { - case 404: - errorMessage = 'Page not found. '; - // path is empty for the home page of an entity's docs site - if (!path) { - errorMessage += - 'This could be because there is no index.md file in the root of the docs directory of this repository.'; - } - throw new NotFoundError(errorMessage); - case 500: - errorMessage = - 'Could not generate documentation or an error in the TechDocs backend. '; - throw new Error(errorMessage); - default: - // Do nothing - break; - } - - return request.text(); - } - - /** - * Check if docs are the latest version and trigger rebuilds if not - * - * @param {EntityName} entityId Object containing entity data like name, namespace, etc. - * @returns {boolean} Whether documents are currently synchronized to newest version - * @throws {Error} Throws error on error from sync endpoint - */ - async syncEntityDocs(entityId: EntityName) { - const { kind, namespace, name } = entityId; - - const apiOrigin = await this.getApiOrigin(); - const url = `${apiOrigin}/sync/${namespace}/${kind}/${name}`; - let request; - let attempts: number = 0; - // retry if request times out, up to 5 times - // can happen due to docs taking too long to generate - while (!request || (request.status === 408 && attempts < 5)) { - attempts++; - request = await this.fetchUrl( - `${url.endsWith('/') ? url : `${url}/`}index.html`, - ); - } - - switch (request.status) { - case 404: - throw (await request.json()).error; - case 200: - case 201: - return true; - // for timeout and misc errors, handle without error to allow viewing older docs - // if older docs not available, - // Reader will show 404 error coming from getEntityDocs - case 408: - default: - return false; - } - } - - async getBaseUrl( - oldBaseUrl: string, - entityId: EntityName, - path: string, - ): Promise { - const { name } = entityId; - const apiOrigin = await this.getApiOrigin(); - return new URL(oldBaseUrl, `${apiOrigin}/${name}/${path}`).toString(); - } -} diff --git a/plugins/techdocs/dev/index.tsx b/plugins/techdocs/dev/index.tsx index 3eefa0a814..a778161b35 100644 --- a/plugins/techdocs/dev/index.tsx +++ b/plugins/techdocs/dev/index.tsx @@ -14,11 +14,105 @@ * limitations under the License. */ -import { configApiRef, discoveryApiRef, identityApiRef } from '@backstage/core'; +import { + configApiRef, + discoveryApiRef, + Header, + identityApiRef, + Page, + TabbedLayout, +} from '@backstage/core'; import { createDevApp } from '@backstage/dev-utils'; -import { techdocsPlugin } from '../src/plugin'; -import { TechDocsDevStorageApi } from './api'; -import { techdocsStorageApiRef } from '../src'; +import { NotFoundError } from '@backstage/errors'; +import React from 'react'; +import { + Reader, + SyncResult, + TechDocsStorageApi, + techdocsStorageApiRef, +} from '../src'; + +// used so each route can provide it's own implementation in the constructor of the react component +let apiHolder: TechDocsStorageApi | undefined = undefined; + +const apiBridge: TechDocsStorageApi = { + getApiOrigin: async () => '', + getBaseUrl: (...args) => apiHolder!.getBaseUrl(...args), + getBuilder: () => apiHolder!.getBuilder(), + getStorageUrl: () => apiHolder!.getStorageUrl(), + getEntityDocs: (...args) => apiHolder!.getEntityDocs(...args), + syncEntityDocs: (...args) => apiHolder!.syncEntityDocs(...args), +}; + +const mockContent = ` +

Hello World!

+

This is an example content that will actually be provided by a MkDocs powered site

+`; + +function createPage({ + entityDocs, + syncDocs, + syncDocsDelay, +}: { + entityDocs?: (props: { + called: number; + content: string; + }) => string | Promise; + syncDocs: () => SyncResult; + syncDocsDelay?: number; +}) { + class Api implements TechDocsStorageApi { + private entityDocsCallCount: number = 0; + + getApiOrigin = async () => ''; + getBaseUrl = async () => ''; + getBuilder = async () => 'local'; + getStorageUrl = async () => ''; + + async getEntityDocs() { + await new Promise(resolve => setTimeout(resolve, 500)); + + if (!entityDocs) { + return mockContent; + } + + return entityDocs({ + called: this.entityDocsCallCount++, + content: mockContent, + }); + } + + async syncEntityDocs() { + if (syncDocsDelay) { + await new Promise(resolve => setTimeout(resolve, syncDocsDelay)); + } + + return syncDocs(); + } + } + + class Component extends React.Component { + constructor(props: {}) { + super(props); + + apiHolder = new Api(); + } + + render() { + return ( + + ); + } + } + + return ; +} createDevApp() .registerApi({ @@ -28,12 +122,89 @@ createDevApp() discoveryApi: discoveryApiRef, identityApi: identityApiRef, }, - factory: ({ configApi, discoveryApi, identityApi }) => - new TechDocsDevStorageApi({ - configApi, - discoveryApi, - identityApi, - }), + factory: () => apiBridge, + }) + + .addPage({ + title: 'TechDocs', + element: ( + +
+ + + {createPage({ + syncDocs: () => 'cached', + })} + + + + {createPage({ + syncDocs: () => 'updated', + syncDocsDelay: 2000, + })} + + + + {createPage({ + entityDocs: ({ called, content }) => { + if (called < 1) { + throw new NotFoundError(); + } + + return content; + }, + syncDocs: () => 'updated', + syncDocsDelay: 10000, + })} + + + + {createPage({ + entityDocs: () => { + throw new NotFoundError('Not found, some error message...'); + }, + syncDocs: () => 'cached', + })} + + + + {createPage({ + entityDocs: () => { + throw new Error('Another more critical error'); + }, + syncDocs: () => 'cached', + })} + + + + {createPage({ + syncDocs: () => { + throw new Error('Some random error'); + }, + syncDocsDelay: 2000, + })} + + + + {createPage({ + entityDocs: () => { + throw new Error('Some random error'); + }, + syncDocs: () => { + throw new Error('Some random error'); + }, + syncDocsDelay: 2000, + })} + + + + {createPage({ + syncDocs: () => 'timeout', + syncDocsDelay: 2000, + })} + + + + ), }) - .registerPlugin(techdocsPlugin) .render(); diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 76bcfd8b0d..c3336ac3b2 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -56,6 +56,7 @@ "@backstage/test-utils": "^0.1.13", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^11.2.5", + "@testing-library/react-hooks": "^3.4.2", "@testing-library/user-event": "^13.1.8", "@types/react": "^16.9", "@types/jest": "^26.0.7", diff --git a/plugins/techdocs/src/api.ts b/plugins/techdocs/src/api.ts index 204cc1b5a8..b9c8725ce9 100644 --- a/plugins/techdocs/src/api.ts +++ b/plugins/techdocs/src/api.ts @@ -28,12 +28,14 @@ export const techdocsApiRef = createApiRef({ description: 'Used to make requests towards techdocs API', }); +export type SyncResult = 'cached' | 'updated' | 'timeout'; + export interface TechDocsStorageApi { getApiOrigin(): Promise; getStorageUrl(): Promise; getBuilder(): Promise; getEntityDocs(entityId: EntityName, path: string): Promise; - syncEntityDocs(entityId: EntityName): Promise; + syncEntityDocs(entityId: EntityName): Promise; getBaseUrl( oldBaseUrl: string, entityId: EntityName, diff --git a/plugins/techdocs/src/client.ts b/plugins/techdocs/src/client.ts index 245cfb0154..16617dcbce 100644 --- a/plugins/techdocs/src/client.ts +++ b/plugins/techdocs/src/client.ts @@ -18,7 +18,7 @@ import { EntityName } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { DiscoveryApi, IdentityApi } from '@backstage/core'; import { NotFoundError } from '@backstage/errors'; -import { TechDocsApi, TechDocsStorageApi } from './api'; +import { SyncResult, TechDocsApi, TechDocsStorageApi } from './api'; import { TechDocsEntityMetadata, TechDocsMetadata } from './types'; /** @@ -195,7 +195,7 @@ export class TechDocsStorageClient implements TechDocsStorageApi { * @returns {boolean} Whether documents are currently synchronized to newest version * @throws {Error} Throws error on error from sync endpoint in Techdocs Backend */ - async syncEntityDocs(entityId: EntityName): Promise { + async syncEntityDocs(entityId: EntityName): Promise { const { kind, namespace, name } = entityId; const apiOrigin = await this.getApiOrigin(); @@ -215,16 +215,20 @@ export class TechDocsStorageClient implements TechDocsStorageApi { switch (request.status) { case 404: throw new NotFoundError((await request.json()).error); + case 200: - case 201: case 304: - return true; + return 'cached'; + + case 201: + return 'updated'; + // for timeout and misc errors, handle without error to allow viewing older docs // if older docs not available, // Reader will show 404 error coming from getEntityDocs case 408: default: - return false; + return 'timeout'; } } diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index c779985364..75dade20fa 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -13,15 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { EntityName } from '@backstage/catalog-model'; import { useApi } from '@backstage/core'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { BackstageTheme } from '@backstage/theme'; import { useTheme } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { useAsync } from 'react-use'; import { techdocsStorageApiRef } from '../../api'; import { addBaseUrl, @@ -37,7 +37,7 @@ import { } from '../transformers'; import { TechDocsNotFound } from './TechDocsNotFound'; import TechDocsProgressBar from './TechDocsProgressBar'; -import { useRawPage } from './useRawPage'; +import { useReaderState } from './useReaderState'; type Props = { entityId: EntityName; @@ -49,61 +49,37 @@ export const Reader = ({ entityId, onReady }: Props) => { const { '*': path } = useParams(); const theme = useTheme(); + const { state, content: rawPage, errorMessage } = useReaderState( + kind, + namespace, + name, + path, + ); + const techdocsStorageApi = useApi(techdocsStorageApiRef); const [sidebars, setSidebars] = useState(); const navigate = useNavigate(); const shadowDomRef = useRef(null); - const [loadedPath, setLoadedPath] = useState(''); - const [atInitialLoad, setAtInitialLoad] = useState(true); - const [newerDocsExist, setNewerDocsExist] = useState(false); const scmIntegrationsApi = useApi(scmIntegrationsApiRef); - const { - value: isSynced, - loading: syncInProgress, - error: syncError, - } = useAsync(async () => { - // Attempt to sync only if `techdocs.builder` in app config is set to 'local' - if ((await techdocsStorageApi.getBuilder()) !== 'local') { - return Promise.resolve({ - value: true, - loading: null, - error: null, + const updateSidebarPosition = useCallback(() => { + if (!!shadowDomRef.current && !!sidebars) { + const mdTabs = shadowDomRef.current!.querySelector( + '.md-container > .md-tabs', + ); + sidebars!.forEach(sidebar => { + const newTop = Math.max( + shadowDomRef.current!.getBoundingClientRect().top, + 0, + ); + sidebar.style.top = mdTabs + ? `${newTop + mdTabs.getBoundingClientRect().height}px` + : `${newTop}px`; }); } - return techdocsStorageApi.syncEntityDocs({ kind, namespace, name }); - }, [techdocsStorageApi, kind, namespace, name]); - - const { - value: rawPage, - loading: docLoading, - error: docLoadError, - retry, - } = useRawPage(path, kind, namespace, name); + }, [shadowDomRef, sidebars]); useEffect(() => { - if (isSynced && newerDocsExist && path !== loadedPath) { - retry(); - } - }); - - useEffect(() => { - const updateSidebarPosition = () => { - if (!!shadowDomRef.current && !!sidebars) { - const mdTabs = shadowDomRef.current!.querySelector( - '.md-container > .md-tabs', - ); - sidebars!.forEach(sidebar => { - const newTop = Math.max( - shadowDomRef.current!.getBoundingClientRect().top, - 0, - ); - sidebar.style.top = mdTabs - ? `${newTop + mdTabs.getBoundingClientRect().height}px` - : `${newTop}px`; - }); - } - }; updateSidebarPosition(); window.addEventListener('scroll', updateSidebarPosition); window.addEventListener('resize', updateSidebarPosition); @@ -111,28 +87,8 @@ export const Reader = ({ entityId, onReady }: Props) => { window.removeEventListener('scroll', updateSidebarPosition); window.removeEventListener('resize', updateSidebarPosition); }; - }, [shadowDomRef, sidebars]); - - useEffect(() => { - if (rawPage) { - setLoadedPath(path); - } - }, [rawPage, path]); - - useEffect(() => { - if (atInitialLoad === false) { - return; - } - setTimeout(() => { - setAtInitialLoad(false); - }, 5000); - }); - - useEffect(() => { - if (!atInitialLoad && !!rawPage && syncInProgress) { - setNewerDocsExist(true); - } - }, [atInitialLoad, rawPage, syncInProgress]); + // an update to "state" might lead to an updated UI so we include it as a trigger + }, [updateSidebarPosition, state]); useEffect(() => { if (!rawPage || !shadowDomRef.current) { @@ -142,12 +98,16 @@ export const Reader = ({ entityId, onReady }: Props) => { onReady(); } // Pre-render - const transformedElement = transformer(rawPage.content, [ + const transformedElement = transformer(rawPage, [ sanitizeDOM(), addBaseUrl({ techdocsStorageApi, - entityId: rawPage.entityId, - path: rawPage.path, + entityId: { + kind, + name, + namespace, + }, + path, }), rewriteDocLinks(), removeMkdocsHeader(), @@ -292,10 +252,6 @@ export const Reader = ({ entityId, onReady }: Props) => { baseUrl: window.location.origin, onClick: (_: MouseEvent, url: string) => { const parsedUrl = new URL(url); - if (newerDocsExist && isSynced) { - // link navigation will load newer docs - setNewerDocsExist(false); - } if (parsedUrl.hash) { navigate(`${parsedUrl.pathname}${parsedUrl.hash}`); @@ -337,6 +293,10 @@ export const Reader = ({ entityId, onReady }: Props) => { }), ]); }, [ + path, + kind, + namespace, + name, rawPage, navigate, onReady, @@ -347,39 +307,40 @@ export const Reader = ({ entityId, onReady }: Props) => { theme.palette.primary.main, theme.palette.background.paper, theme.palette.background.default, - newerDocsExist, - isSynced, scmIntegrationsApi, ]); - // docLoadError not considered an error state if sync request is still ongoing - // or sync just completed and doc is loading again - if ((docLoadError && !syncInProgress && !docLoading) || syncError) { - let errMessage = ''; - if (docLoadError) { - errMessage += ` Load error: ${docLoadError}`; - } - if (syncError) errMessage += ` Build error: ${syncError}`; - return ; - } - return ( <> - {newerDocsExist && !isSynced ? ( + {(state === 'CHECKING' || state === 'INITIAL_BUILD') && ( + + )} + {state === 'CONTENT_STALE_REFRESHING' && ( A newer version of this documentation is being prepared and will be available shortly. - ) : null} - {newerDocsExist && isSynced ? ( + )} + {state === 'CONTENT_STALE_READY' && ( A newer version of this documentation is now available, please refresh to view. - ) : null} - {docLoading || (docLoadError && syncInProgress) ? ( - - ) : null} + )} + {state === 'CONTENT_STALE_TIMEOUT' && ( + + Building a newer version of this documentation took longer than + expected. Please refresh to try again. + + )} + {state === 'CONTENT_STALE_ERROR' && ( + + Building a newer version of this documentation failed. {errorMessage} + + )} + {state === 'CONTENT_NOT_FOUND' && ( + + )}
); diff --git a/plugins/techdocs/src/reader/components/useReaderState.test.tsx b/plugins/techdocs/src/reader/components/useReaderState.test.tsx new file mode 100644 index 0000000000..d5579ddd5a --- /dev/null +++ b/plugins/techdocs/src/reader/components/useReaderState.test.tsx @@ -0,0 +1,459 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiProvider, ApiRegistry } from '@backstage/core'; +import { NotFoundError } from '@backstage/errors'; +import { act, renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { techdocsStorageApiRef } from '../../api'; +import { + calculateDisplayState, + reducer, + useReaderState, +} from './useReaderState'; + +describe('useReaderState', () => { + let Wrapper: React.ComponentType; + + const techdocsStorageApi: jest.Mocked = { + getApiOrigin: jest.fn(), + getBaseUrl: jest.fn(), + getBuilder: jest.fn(), + getEntityDocs: jest.fn(), + getStorageUrl: jest.fn(), + syncEntityDocs: jest.fn(), + }; + + beforeEach(() => { + const apis = ApiRegistry.with(techdocsStorageApiRef, techdocsStorageApi); + + Wrapper = ({ children }: { children?: React.ReactNode }) => ( + {children} + ); + }); + + afterEach(() => jest.resetAllMocks()); + + describe('calculateDisplayState', () => { + it.each` + contentLoading | content | activeSyncState | expected + ${true} | ${''} | ${''} | ${'CHECKING'} + ${false} | ${undefined} | ${'CHECKING'} | ${'CHECKING'} + ${false} | ${undefined} | ${'BUILDING'} | ${'INITIAL_BUILD'} + ${false} | ${undefined} | ${'BUILD_READY'} | ${'CONTENT_NOT_FOUND'} + ${false} | ${undefined} | ${'BUILD_TIMED_OUT'} | ${'CONTENT_NOT_FOUND'} + ${false} | ${undefined} | ${'UP_TO_DATE'} | ${'CONTENT_NOT_FOUND'} + ${false} | ${undefined} | ${'ERROR'} | ${'CONTENT_NOT_FOUND'} + ${false} | ${'asdf'} | ${'CHECKING'} | ${'CONTENT_FRESH'} + ${false} | ${'asdf'} | ${'BUILDING'} | ${'CONTENT_STALE_REFRESHING'} + ${false} | ${'asdf'} | ${'BUILD_READY'} | ${'CONTENT_STALE_READY'} + ${false} | ${'asdf'} | ${'BUILD_TIMED_OUT'} | ${'CONTENT_STALE_TIMEOUT'} + ${false} | ${'asdf'} | ${'UP_TO_DATE'} | ${'CONTENT_FRESH'} + ${false} | ${'asdf'} | ${'ERROR'} | ${'CONTENT_STALE_ERROR'} + `( + 'should, when contentLoading=$contentLoading and content="$content" and activeSyncState=$activeSyncState, resolve to $expected', + ({ contentLoading, content, activeSyncState, expected }) => { + expect( + calculateDisplayState({ + contentLoading, + content, + activeSyncState, + }), + ).toEqual(expected); + }, + ); + }); + + describe('reducer', () => { + const contentReloadFn = jest.fn(); + const oldState: Parameters[0] = { + activeSyncState: 'CHECKING', + contentIsStale: false, + contentLoading: false, + path: '', + contentReload: contentReloadFn, + }; + + it('should return a copy of the state', () => { + expect(reducer(oldState, { type: 'navigate', path: '/' })).toEqual({ + activeSyncState: 'CHECKING', + contentIsStale: false, + contentLoading: false, + path: '/', + contentReload: contentReloadFn, + }); + + expect(oldState).toEqual({ + activeSyncState: 'CHECKING', + contentIsStale: false, + contentLoading: false, + path: '', + contentReload: contentReloadFn, + }); + }); + + describe('"content" action', () => { + it('should work', () => { + expect( + reducer( + { + ...oldState, + content: undefined, + contentLoading: true, + contentReload: undefined, + }, + { + type: 'content', + content: 'asdf', + contentLoading: false, + contentReload: contentReloadFn, + }, + ), + ).toEqual({ + ...oldState, + contentLoading: false, + content: 'asdf', + }); + + expect(contentReloadFn).toBeCalledTimes(0); + }); + + it('should reset staleness', () => { + expect( + reducer( + { + ...oldState, + contentIsStale: true, + activeSyncState: 'BUILD_READY', + }, + { + type: 'content', + content: 'asdf', + contentLoading: false, + contentReload: contentReloadFn, + }, + ), + ).toEqual({ + ...oldState, + content: 'asdf', + contentIsStale: false, + activeSyncState: 'UP_TO_DATE', + }); + }); + }); + + describe('"navigate" action', () => { + it('should work', () => { + expect( + reducer(oldState, { + type: 'navigate', + path: '/', + }), + ).toEqual({ + ...oldState, + path: '/', + }); + + expect(contentReloadFn).toBeCalledTimes(0); + }); + + it('should reset staleness', () => { + expect( + reducer( + { + ...oldState, + contentIsStale: true, + activeSyncState: 'BUILD_READY', + }, + { + type: 'navigate', + path: '', + }, + ), + ).toEqual({ + ...oldState, + contentIsStale: false, + activeSyncState: 'UP_TO_DATE', + }); + }); + }); + + describe('"sync" action', () => { + it('should update state', () => { + expect( + reducer(oldState, { + type: 'sync', + state: 'BUILDING', + }), + ).toEqual({ + ...oldState, + activeSyncState: 'BUILDING', + }); + + expect(contentReloadFn).toBeCalledTimes(0); + }); + + it('should set content to be stale but not reload', () => { + expect( + reducer( + { + ...oldState, + contentReload: undefined, + }, + { + type: 'sync', + state: 'BUILD_READY', + }, + ), + ).toEqual({ + ...oldState, + activeSyncState: 'BUILD_READY', + contentIsStale: true, + contentReload: undefined, + }); + + expect(contentReloadFn).toBeCalledTimes(0); + }); + + it('should not reload existing content', () => { + expect( + reducer( + { + ...oldState, + content: 'any content', + }, + { + type: 'sync', + state: 'BUILD_READY', + }, + ), + ).toEqual({ + ...oldState, + activeSyncState: 'BUILD_READY', + contentIsStale: true, + content: 'any content', + }); + + expect(contentReloadFn).toBeCalledTimes(0); + }); + + it('should trigger a reload', () => { + expect( + reducer(oldState, { + type: 'sync', + state: 'BUILD_READY', + }), + ).toEqual({ + ...oldState, + activeSyncState: 'BUILD_READY', + contentIsStale: true, + contentLoading: true, + }); + + expect(contentReloadFn).toBeCalledTimes(1); + }); + + it('should NOT reset staleness', () => { + expect( + reducer( + { + ...oldState, + contentIsStale: true, + activeSyncState: 'BUILD_READY', + }, + { + type: 'sync', + state: 'BUILDING', + }, + ), + ).toEqual({ + ...oldState, + contentIsStale: true, + activeSyncState: 'BUILDING', + }); + }); + }); + }); + + describe('hook', () => { + it('should handle up-to-date content', async () => { + techdocsStorageApi.getEntityDocs.mockResolvedValue('my content'); + techdocsStorageApi.syncEntityDocs.mockImplementation(async () => { + return 'cached'; + }); + + await act(async () => { + const { result, waitForValueToChange } = await renderHook( + () => useReaderState('Component', 'default', 'backstage', '/example'), + { wrapper: Wrapper }, + ); + + expect(result.current).toEqual({ + state: 'CHECKING', + content: undefined, + errorMessage: '', + }); + + await waitForValueToChange(() => result.current.state); + + expect(result.current).toEqual({ + state: 'CONTENT_FRESH', + content: 'my content', + errorMessage: '', + }); + + expect(techdocsStorageApi.getEntityDocs).toBeCalledWith( + { kind: 'Component', namespace: 'default', name: 'backstage' }, + '/example', + ); + expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({ + kind: 'Component', + namespace: 'default', + name: 'backstage', + }); + }); + }); + + it('should handle stale content', async () => { + techdocsStorageApi.getEntityDocs.mockResolvedValue('my content'); + techdocsStorageApi.syncEntityDocs.mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 1100)); + return 'updated'; + }); + + await act(async () => { + const { result, waitForValueToChange } = await renderHook( + () => useReaderState('Component', 'default', 'backstage', '/example'), + { wrapper: Wrapper }, + ); + + expect(result.current).toEqual({ + state: 'CHECKING', + content: undefined, + errorMessage: '', + }); + + // the content is returned but the sync is in progress + await waitForValueToChange(() => result.current.state); + expect(result.current).toEqual({ + state: 'CONTENT_FRESH', + content: 'my content', + errorMessage: '', + }); + + // the sync takes longer than 1 seconds so the refreshing state starts + await waitForValueToChange(() => result.current.state); + expect(result.current).toEqual({ + state: 'CONTENT_STALE_REFRESHING', + content: 'my content', + errorMessage: '', + }); + + // the content is up-to-date + await waitForValueToChange(() => result.current.state); + expect(result.current).toEqual({ + state: 'CONTENT_STALE_READY', + content: 'my content', + errorMessage: '', + }); + + expect(techdocsStorageApi.getEntityDocs).toBeCalledWith( + { kind: 'Component', namespace: 'default', name: 'backstage' }, + '/example', + ); + expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({ + kind: 'Component', + namespace: 'default', + name: 'backstage', + }); + }); + }); + + it('should handle timed-out refresh', async () => { + techdocsStorageApi.getEntityDocs.mockResolvedValue('my content'); + techdocsStorageApi.syncEntityDocs.mockResolvedValue('timeout'); + + await act(async () => { + const { result, waitForValueToChange } = await renderHook( + () => useReaderState('Component', 'default', 'backstage', '/example'), + { wrapper: Wrapper }, + ); + + expect(result.current).toEqual({ + state: 'CHECKING', + content: undefined, + errorMessage: '', + }); + + // the content is returned but the sync is in progress + await waitForValueToChange(() => result.current.state); + expect(result.current).toEqual({ + state: 'CONTENT_STALE_TIMEOUT', + content: 'my content', + errorMessage: '', + }); + + expect(techdocsStorageApi.getEntityDocs).toBeCalledWith( + { kind: 'Component', namespace: 'default', name: 'backstage' }, + '/example', + ); + expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({ + kind: 'Component', + namespace: 'default', + name: 'backstage', + }); + }); + }); + + it('should handle content error', async () => { + techdocsStorageApi.getEntityDocs.mockRejectedValue( + new NotFoundError('Some error description'), + ); + techdocsStorageApi.syncEntityDocs.mockResolvedValue('cached'); + + await act(async () => { + const { result, waitForValueToChange } = await renderHook( + () => useReaderState('Component', 'default', 'backstage', '/example'), + { wrapper: Wrapper }, + ); + + expect(result.current).toEqual({ + state: 'CHECKING', + content: undefined, + errorMessage: '', + }); + + // the content loading threw an error + await waitForValueToChange(() => result.current.state); + expect(result.current).toEqual({ + state: 'CONTENT_NOT_FOUND', + content: undefined, + errorMessage: ' Load error: NotFoundError: Some error description', + }); + + expect(techdocsStorageApi.getEntityDocs).toBeCalledWith( + { kind: 'Component', namespace: 'default', name: 'backstage' }, + '/example', + ); + expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({ + kind: 'Component', + namespace: 'default', + name: 'backstage', + }); + }); + }); + }); +}); diff --git a/plugins/techdocs/src/reader/components/useReaderState.ts b/plugins/techdocs/src/reader/components/useReaderState.ts new file mode 100644 index 0000000000..f55e7c26e9 --- /dev/null +++ b/plugins/techdocs/src/reader/components/useReaderState.ts @@ -0,0 +1,335 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useApi } from '@backstage/core'; +import { useEffect, useMemo, useReducer } from 'react'; +import { useAsync, useAsyncRetry } from 'react-use'; +import { techdocsStorageApiRef } from '../../api'; + +/** + * A state representation that is used to configure the UI of + */ +type ContentStateTypes = + /** There is nothing to display but a loading indicator */ + | 'CHECKING' + + /** There is no content yet -> present a full screen loading page */ + | 'INITIAL_BUILD' + + /** There is content, but the backend is about to update it */ + | 'CONTENT_STALE_REFRESHING' + + /** There is content, but after a reload, the content will be different */ + | 'CONTENT_STALE_READY' + + /** There is content, the backend tried to update it, but it took too long */ + | 'CONTENT_STALE_TIMEOUT' + + /** There is content, the backend tried to update it, but failed */ + | 'CONTENT_STALE_ERROR' + + /** There is nothing to see but a "not found" page. Is also shown on page load errors */ + | 'CONTENT_NOT_FOUND' + + /** There is only the latest and greatest content */ + | 'CONTENT_FRESH'; + +/** + * Calculate the state that should be reported to the display component. + */ +export function calculateDisplayState({ + contentLoading, + content, + activeSyncState, +}: Pick< + ReducerState, + 'contentLoading' | 'content' | 'activeSyncState' +>): ContentStateTypes { + // we have nothing to display yet + if (contentLoading) { + return 'CHECKING'; + } + + // there is no content, but the sync process is still evaluating + if (!content && activeSyncState === 'CHECKING') { + return 'CHECKING'; + } + + // there is no content yet so we assume that we are building it for the first time + if (!content && activeSyncState === 'BUILDING') { + return 'INITIAL_BUILD'; + } + + // if there is still no content after building, it might just not exist + if (!content) { + return 'CONTENT_NOT_FOUND'; + } + + // we are still building, but we already show stale content + if (activeSyncState === 'BUILDING') { + return 'CONTENT_STALE_REFRESHING'; + } + + // the build is ready, but the content is still stale + if (activeSyncState === 'BUILD_READY') { + return 'CONTENT_STALE_READY'; + } + + // the build timed out, but the content is still stale + if (activeSyncState === 'BUILD_TIMED_OUT') { + return 'CONTENT_STALE_TIMEOUT'; + } + + // the build failed, but the content is still stale + if (activeSyncState === 'ERROR') { + return 'CONTENT_STALE_ERROR'; + } + + // seems like the content is up-to-date (or we don't know yet and the sync process is still evaluating in the background) + return 'CONTENT_FRESH'; +} + +/** + * The state of the synchronization task. It checks whether the docs are + * up-to-date. If they aren't, it triggers a build. + */ +type SyncStates = + /** Checking if it should be synced */ + | 'CHECKING' + + /** Building the documentation */ + | 'BUILDING' + + /** Finished building the documentation */ + | 'BUILD_READY' + + /** Building the documentation timed out */ + | 'BUILD_TIMED_OUT' + + /** No need for a sync. The content was already up-to-date. */ + | 'UP_TO_DATE' + + /** An error occurred */ + | 'ERROR'; + +type ReducerActions = + | { + type: 'sync'; + state: SyncStates; + syncError?: Error; + } + | { + type: 'content'; + content?: string; + contentLoading: boolean; + contentError?: Error; + contentReload: () => void; + } + | { type: 'navigate'; path: string }; + +type ReducerState = { + /** + * The path of the current page + */ + path: string; + + /** + * The current sync state + */ + activeSyncState: SyncStates; + + /** + * If true, the content is downloading from the storage. + */ + contentLoading: boolean; + /** + * The content that has been downloaded and should be displayed. + */ + content?: string; + /** + * When called, the content is reloaded without refreshing the page. + */ + contentReload?: () => void; + /** + * If true, the content is considered stale and should be refreshed by the user via a refresh or a navigation. + */ + contentIsStale: boolean; + + contentError?: Error; + syncError?: Error; +}; + +export function reducer( + oldState: ReducerState, + action: ReducerActions, +): ReducerState { + const newState = { ...oldState }; + + switch (action.type) { + case 'sync': + newState.activeSyncState = action.state; + newState.syncError = action.syncError; + + // whatever is stored as content, it can be considered as being stale + if (newState.activeSyncState === 'BUILD_READY') { + newState.contentIsStale = true; + + // reload the content if this was the initial build OR the page was missing in the old version + if (!newState.content && newState.contentReload) { + newState.contentReload(); + + // eagerly mark the content to load to not get synchronization issues since + // the async hook behind contentReload() doesn't update the reducer instantly + // and might flash the "not found" page + newState.contentLoading = true; + } + } + break; + + case 'content': + newState.content = action.content; + newState.contentLoading = action.contentLoading; + newState.contentReload = action.contentReload; + newState.contentError = action.contentError; + break; + + case 'navigate': + newState.path = action.path; + break; + + default: + throw new Error(); + } + + // a navigation or a content update removes the staleness and resets the sync state + if ( + newState.contentIsStale && + ['content', 'navigate'].includes(action.type) + ) { + newState.contentIsStale = false; + newState.activeSyncState = 'UP_TO_DATE'; + } + + return newState; +} + +export function useReaderState( + kind: string, + namespace: string, + name: string, + path: string, +): { state: ContentStateTypes; content?: string; errorMessage?: string } { + const [state, dispatch] = useReducer(reducer, { + activeSyncState: 'CHECKING', + path, + contentLoading: true, + contentIsStale: false, + }); + + const techdocsStorageApi = useApi(techdocsStorageApiRef); + + // convert all path changes into actions + useEffect(() => { + dispatch({ type: 'navigate', path }); + }, [path]); + + // try to load the content + const { + value: content, + loading: contentLoading, + error: contentError, + retry: contentReload, + } = useAsyncRetry( + async () => + techdocsStorageApi.getEntityDocs( + { + kind, + namespace, + name, + }, + path, + ), + [techdocsStorageApi, kind, namespace, name, path], + ); + + // convert all content changes into actions + useEffect(() => { + dispatch({ + type: 'content', + content, + contentLoading, + contentReload, + contentError, + }); + }, [dispatch, content, contentLoading, contentReload, contentError]); + + // try to derive the state. the function will fire events and we don't care for the return values + useAsync(async () => { + dispatch({ type: 'sync', state: 'CHECKING' }); + + // should only switch to BUILDING if the request takes more than 1 seconds + const buildingTimeout = setTimeout(() => { + dispatch({ type: 'sync', state: 'BUILDING' }); + }, 1000); + + try { + const result = await techdocsStorageApi.syncEntityDocs({ + kind, + namespace, + name, + }); + + if (result === 'updated') { + dispatch({ type: 'sync', state: 'BUILD_READY' }); + } else if (result === 'cached') { + dispatch({ type: 'sync', state: 'UP_TO_DATE' }); + } else { + dispatch({ type: 'sync', state: 'BUILD_TIMED_OUT' }); + } + } catch (e) { + dispatch({ type: 'sync', state: 'ERROR', syncError: e }); + } finally { + // Cancel the timer that sets the state "BUILDING" + clearTimeout(buildingTimeout); + } + }, [kind, name, namespace, techdocsStorageApi, dispatch]); + + const displayState = useMemo( + () => + calculateDisplayState({ + activeSyncState: state.activeSyncState, + contentLoading: state.contentLoading, + content: state.content, + }), + [state.activeSyncState, state.content, state.contentLoading], + ); + + const errorMessage = useMemo(() => { + let errMessage = ''; + if (state.contentError) { + errMessage += ` Load error: ${state.contentError}`; + } + if (state.syncError) errMessage += ` Build error: ${state.syncError}`; + + return errMessage; + }, [state.syncError, state.contentError]); + + return { + state: displayState, + content, + errorMessage, + }; +} diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts index 6b6eae4b0d..9bfcbe624a 100644 --- a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts @@ -27,7 +27,7 @@ const techdocsStorageApi: TechDocsStorageApi = { Promise.resolve(new URL(o, DOC_STORAGE_URL).toString()), ), getEntityDocs: () => new Promise(resolve => resolve('yes!')), - syncEntityDocs: () => new Promise(resolve => resolve(true)), + syncEntityDocs: () => new Promise(resolve => resolve('updated')), getApiOrigin: jest.fn(() => new Promise(resolve => resolve(API_ORIGIN_URL))), getBuilder: jest.fn(), getStorageUrl: jest.fn(), From 2b9d30b1535f22a1d65fb11021cefb1c1baed916 Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Wed, 16 Jun 2021 11:04:26 +0200 Subject: [PATCH 51/59] Fix minor issues from the review comments Signed-off-by: Dominik Henneke --- plugins/techdocs-backend/src/service/router.ts | 9 +++++---- plugins/techdocs/src/client.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/plugins/techdocs-backend/src/service/router.ts b/plugins/techdocs-backend/src/service/router.ts index 1a8bdb9c8f..ff47e16bc9 100644 --- a/plugins/techdocs-backend/src/service/router.ts +++ b/plugins/techdocs-backend/src/service/router.ts @@ -176,6 +176,11 @@ export async function createRouter({ // This block should be valid for all storage implementations. So no need to duplicate in future, // add the publisher type in the list here. const updated = await docsBuilder.build(); + + if (!updated) { + throw new NotModifiedError(); + } + // With a maximum of ~5 seconds wait, check if the files got published and if docs will be fetched // on the user's page. If not, respond with a message asking them to check back later. // The delay here is to make sure GCS/AWS/etc. registers newly uploaded files which is usually <1 second @@ -195,10 +200,6 @@ export async function createRouter({ ); } - if (!updated) { - throw new NotModifiedError(); - } - res .status(201) .json({ message: 'Docs updated or did not need updating' }); diff --git a/plugins/techdocs/src/client.ts b/plugins/techdocs/src/client.ts index 16617dcbce..83cfc88d56 100644 --- a/plugins/techdocs/src/client.ts +++ b/plugins/techdocs/src/client.ts @@ -192,7 +192,7 @@ export class TechDocsStorageClient implements TechDocsStorageApi { * Check if docs are on the latest version and trigger rebuild if not * * @param {EntityName} entityId Object containing entity data like name, namespace, etc. - * @returns {boolean} Whether documents are currently synchronized to newest version + * @returns {SyncResult} Whether documents are currently synchronized to newest version * @throws {Error} Throws error on error from sync endpoint in Techdocs Backend */ async syncEntityDocs(entityId: EntityName): Promise { From ba984f675a121df2260e3a7c717d59a8448cf219 Mon Sep 17 00:00:00 2001 From: Dominik Henneke Date: Wed, 16 Jun 2021 11:04:47 +0200 Subject: [PATCH 52/59] Add a BUILD_READY_RELOAD type that replaces the old contentIsStale logic Signed-off-by: Dominik Henneke --- .../reader/components/useReaderState.test.tsx | 271 +++++++++--------- .../src/reader/components/useReaderState.ts | 114 ++++---- 2 files changed, 180 insertions(+), 205 deletions(-) diff --git a/plugins/techdocs/src/reader/components/useReaderState.test.tsx b/plugins/techdocs/src/reader/components/useReaderState.test.tsx index d5579ddd5a..8a09241588 100644 --- a/plugins/techdocs/src/reader/components/useReaderState.test.tsx +++ b/plugins/techdocs/src/reader/components/useReaderState.test.tsx @@ -49,20 +49,22 @@ describe('useReaderState', () => { describe('calculateDisplayState', () => { it.each` - contentLoading | content | activeSyncState | expected - ${true} | ${''} | ${''} | ${'CHECKING'} - ${false} | ${undefined} | ${'CHECKING'} | ${'CHECKING'} - ${false} | ${undefined} | ${'BUILDING'} | ${'INITIAL_BUILD'} - ${false} | ${undefined} | ${'BUILD_READY'} | ${'CONTENT_NOT_FOUND'} - ${false} | ${undefined} | ${'BUILD_TIMED_OUT'} | ${'CONTENT_NOT_FOUND'} - ${false} | ${undefined} | ${'UP_TO_DATE'} | ${'CONTENT_NOT_FOUND'} - ${false} | ${undefined} | ${'ERROR'} | ${'CONTENT_NOT_FOUND'} - ${false} | ${'asdf'} | ${'CHECKING'} | ${'CONTENT_FRESH'} - ${false} | ${'asdf'} | ${'BUILDING'} | ${'CONTENT_STALE_REFRESHING'} - ${false} | ${'asdf'} | ${'BUILD_READY'} | ${'CONTENT_STALE_READY'} - ${false} | ${'asdf'} | ${'BUILD_TIMED_OUT'} | ${'CONTENT_STALE_TIMEOUT'} - ${false} | ${'asdf'} | ${'UP_TO_DATE'} | ${'CONTENT_FRESH'} - ${false} | ${'asdf'} | ${'ERROR'} | ${'CONTENT_STALE_ERROR'} + contentLoading | content | activeSyncState | expected + ${true} | ${''} | ${''} | ${'CHECKING'} + ${false} | ${undefined} | ${'CHECKING'} | ${'CHECKING'} + ${false} | ${undefined} | ${'BUILDING'} | ${'INITIAL_BUILD'} + ${false} | ${undefined} | ${'BUILD_READY'} | ${'CONTENT_NOT_FOUND'} + ${false} | ${undefined} | ${'BUILD_READY_RELOAD'} | ${'CHECKING'} + ${false} | ${undefined} | ${'BUILD_TIMED_OUT'} | ${'CONTENT_NOT_FOUND'} + ${false} | ${undefined} | ${'UP_TO_DATE'} | ${'CONTENT_NOT_FOUND'} + ${false} | ${undefined} | ${'ERROR'} | ${'CONTENT_NOT_FOUND'} + ${false} | ${'asdf'} | ${'CHECKING'} | ${'CONTENT_FRESH'} + ${false} | ${'asdf'} | ${'BUILDING'} | ${'CONTENT_STALE_REFRESHING'} + ${false} | ${'asdf'} | ${'BUILD_READY'} | ${'CONTENT_STALE_READY'} + ${false} | ${'asdf'} | ${'BUILD_READY_RELOAD'} | ${'CHECKING'} + ${false} | ${'asdf'} | ${'BUILD_TIMED_OUT'} | ${'CONTENT_STALE_TIMEOUT'} + ${false} | ${'asdf'} | ${'UP_TO_DATE'} | ${'CONTENT_FRESH'} + ${false} | ${'asdf'} | ${'ERROR'} | ${'CONTENT_STALE_ERROR'} `( 'should, when contentLoading=$contentLoading and content="$content" and activeSyncState=$activeSyncState, resolve to $expected', ({ contentLoading, content, activeSyncState, expected }) => { @@ -78,48 +80,80 @@ describe('useReaderState', () => { }); describe('reducer', () => { - const contentReloadFn = jest.fn(); const oldState: Parameters[0] = { activeSyncState: 'CHECKING', - contentIsStale: false, contentLoading: false, path: '', - contentReload: contentReloadFn, }; it('should return a copy of the state', () => { expect(reducer(oldState, { type: 'navigate', path: '/' })).toEqual({ activeSyncState: 'CHECKING', - contentIsStale: false, contentLoading: false, path: '/', - contentReload: contentReloadFn, }); expect(oldState).toEqual({ activeSyncState: 'CHECKING', - contentIsStale: false, contentLoading: false, path: '', - contentReload: contentReloadFn, }); }); - describe('"content" action', () => { - it('should work', () => { + it.each` + type | oldActiveSyncState | newActiveSyncState + ${'content'} | ${'BUILD_READY'} | ${'UP_TO_DATE'} + ${'content'} | ${'BUILD_READY_RELOAD'} | ${'UP_TO_DATE'} + ${'navigate'} | ${'BUILD_READY'} | ${'UP_TO_DATE'} + ${'navigate'} | ${'BUILD_READY_RELOAD'} | ${'UP_TO_DATE'} + ${'sync'} | ${'BUILD_READY'} | ${undefined} + ${'sync'} | ${'BUILD_READY_RELOAD'} | ${undefined} + `( + 'should, when type=$type and activeSyncState=$oldActiveSyncState, set activeSyncState=$newActiveSyncState', + ({ type, oldActiveSyncState, newActiveSyncState }) => { expect( reducer( { ...oldState, - content: undefined, + activeSyncState: oldActiveSyncState, + }, + { type }, + ).activeSyncState, + ).toEqual(newActiveSyncState); + }, + ); + + describe('"content" action', () => { + it('should set loading', () => { + expect( + reducer( + { + ...oldState, + content: 'some-old-content', + contentError: new Error(), + }, + { + type: 'content', contentLoading: true, - contentReload: undefined, + }, + ), + ).toEqual({ + ...oldState, + contentLoading: true, + }); + }); + + it('should set content', () => { + expect( + reducer( + { + ...oldState, + contentLoading: true, + contentError: new Error(), }, { type: 'content', content: 'asdf', - contentLoading: false, - contentReload: contentReloadFn, }, ), ).toEqual({ @@ -127,30 +161,25 @@ describe('useReaderState', () => { contentLoading: false, content: 'asdf', }); - - expect(contentReloadFn).toBeCalledTimes(0); }); - it('should reset staleness', () => { + it('should set error', () => { expect( reducer( { ...oldState, - contentIsStale: true, - activeSyncState: 'BUILD_READY', + contentLoading: true, + content: 'asdf', }, { type: 'content', - content: 'asdf', - contentLoading: false, - contentReload: contentReloadFn, + contentError: new Error(), }, ), ).toEqual({ ...oldState, - content: 'asdf', - contentIsStale: false, - activeSyncState: 'UP_TO_DATE', + contentLoading: false, + contentError: new Error(), }); }); }); @@ -166,28 +195,6 @@ describe('useReaderState', () => { ...oldState, path: '/', }); - - expect(contentReloadFn).toBeCalledTimes(0); - }); - - it('should reset staleness', () => { - expect( - reducer( - { - ...oldState, - contentIsStale: true, - activeSyncState: 'BUILD_READY', - }, - { - type: 'navigate', - path: '', - }, - ), - ).toEqual({ - ...oldState, - contentIsStale: false, - activeSyncState: 'UP_TO_DATE', - }); }); }); @@ -202,88 +209,6 @@ describe('useReaderState', () => { ...oldState, activeSyncState: 'BUILDING', }); - - expect(contentReloadFn).toBeCalledTimes(0); - }); - - it('should set content to be stale but not reload', () => { - expect( - reducer( - { - ...oldState, - contentReload: undefined, - }, - { - type: 'sync', - state: 'BUILD_READY', - }, - ), - ).toEqual({ - ...oldState, - activeSyncState: 'BUILD_READY', - contentIsStale: true, - contentReload: undefined, - }); - - expect(contentReloadFn).toBeCalledTimes(0); - }); - - it('should not reload existing content', () => { - expect( - reducer( - { - ...oldState, - content: 'any content', - }, - { - type: 'sync', - state: 'BUILD_READY', - }, - ), - ).toEqual({ - ...oldState, - activeSyncState: 'BUILD_READY', - contentIsStale: true, - content: 'any content', - }); - - expect(contentReloadFn).toBeCalledTimes(0); - }); - - it('should trigger a reload', () => { - expect( - reducer(oldState, { - type: 'sync', - state: 'BUILD_READY', - }), - ).toEqual({ - ...oldState, - activeSyncState: 'BUILD_READY', - contentIsStale: true, - contentLoading: true, - }); - - expect(contentReloadFn).toBeCalledTimes(1); - }); - - it('should NOT reset staleness', () => { - expect( - reducer( - { - ...oldState, - contentIsStale: true, - activeSyncState: 'BUILD_READY', - }, - { - type: 'sync', - state: 'BUILDING', - }, - ), - ).toEqual({ - ...oldState, - contentIsStale: true, - activeSyncState: 'BUILDING', - }); }); }); }); @@ -327,6 +252,68 @@ describe('useReaderState', () => { }); }); + it('should reload initially missing content', async () => { + techdocsStorageApi.getEntityDocs + .mockRejectedValueOnce(new NotFoundError('Page Not Found')) + .mockImplementationOnce(async () => { + await new Promise(resolve => setTimeout(resolve, 500)); + return 'my content'; + }); + techdocsStorageApi.syncEntityDocs.mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 1100)); + return 'updated'; + }); + + await act(async () => { + const { result, waitForValueToChange } = await renderHook( + () => useReaderState('Component', 'default', 'backstage', '/example'), + { wrapper: Wrapper }, + ); + + expect(result.current).toEqual({ + state: 'CHECKING', + content: undefined, + errorMessage: '', + }); + + await waitForValueToChange(() => result.current.state); + + expect(result.current).toEqual({ + state: 'INITIAL_BUILD', + content: undefined, + errorMessage: ' Load error: NotFoundError: Page Not Found', + }); + + await waitForValueToChange(() => result.current.state); + + expect(result.current).toEqual({ + state: 'CHECKING', + content: undefined, + errorMessage: '', + }); + + await waitForValueToChange(() => result.current.state); + + expect(result.current).toEqual({ + state: 'CONTENT_FRESH', + content: 'my content', + errorMessage: '', + }); + + expect(techdocsStorageApi.getEntityDocs).toBeCalledTimes(2); + expect(techdocsStorageApi.getEntityDocs).toBeCalledWith( + { kind: 'Component', namespace: 'default', name: 'backstage' }, + '/example', + ); + expect(techdocsStorageApi.syncEntityDocs).toBeCalledTimes(1); + expect(techdocsStorageApi.syncEntityDocs).toBeCalledWith({ + kind: 'Component', + namespace: 'default', + name: 'backstage', + }); + }); + }); + it('should handle stale content', async () => { techdocsStorageApi.getEntityDocs.mockResolvedValue('my content'); techdocsStorageApi.syncEntityDocs.mockImplementation(async () => { diff --git a/plugins/techdocs/src/reader/components/useReaderState.ts b/plugins/techdocs/src/reader/components/useReaderState.ts index f55e7c26e9..1dc4bc2677 100644 --- a/plugins/techdocs/src/reader/components/useReaderState.ts +++ b/plugins/techdocs/src/reader/components/useReaderState.ts @@ -15,7 +15,7 @@ */ import { useApi } from '@backstage/core'; -import { useEffect, useMemo, useReducer } from 'react'; +import { useEffect, useMemo, useReducer, useRef } from 'react'; import { useAsync, useAsyncRetry } from 'react-use'; import { techdocsStorageApiRef } from '../../api'; @@ -63,6 +63,11 @@ export function calculateDisplayState({ return 'CHECKING'; } + // the build is ready, but it triggered a content reload and the content variable is not trusted + if (activeSyncState === 'BUILD_READY_RELOAD') { + return 'CHECKING'; + } + // there is no content, but the sync process is still evaluating if (!content && activeSyncState === 'CHECKING') { return 'CHECKING'; @@ -116,6 +121,12 @@ type SyncStates = /** Finished building the documentation */ | 'BUILD_READY' + /** + * Finished building the documentation and triggered a content reload. + * This state is left toward UP_TO_DATE when the content loading has finished. + */ + | 'BUILD_READY_RELOAD' + /** Building the documentation timed out */ | 'BUILD_TIMED_OUT' @@ -134,9 +145,8 @@ type ReducerActions = | { type: 'content'; content?: string; - contentLoading: boolean; + contentLoading?: true; contentError?: Error; - contentReload: () => void; } | { type: 'navigate'; path: string }; @@ -159,14 +169,6 @@ type ReducerState = { * The content that has been downloaded and should be displayed. */ content?: string; - /** - * When called, the content is reloaded without refreshing the page. - */ - contentReload?: () => void; - /** - * If true, the content is considered stale and should be refreshed by the user via a refresh or a navigation. - */ - contentIsStale: boolean; contentError?: Error; syncError?: Error; @@ -182,27 +184,11 @@ export function reducer( case 'sync': newState.activeSyncState = action.state; newState.syncError = action.syncError; - - // whatever is stored as content, it can be considered as being stale - if (newState.activeSyncState === 'BUILD_READY') { - newState.contentIsStale = true; - - // reload the content if this was the initial build OR the page was missing in the old version - if (!newState.content && newState.contentReload) { - newState.contentReload(); - - // eagerly mark the content to load to not get synchronization issues since - // the async hook behind contentReload() doesn't update the reducer instantly - // and might flash the "not found" page - newState.contentLoading = true; - } - } break; case 'content': newState.content = action.content; - newState.contentLoading = action.contentLoading; - newState.contentReload = action.contentReload; + newState.contentLoading = action.contentLoading ?? false; newState.contentError = action.contentError; break; @@ -214,12 +200,11 @@ export function reducer( throw new Error(); } - // a navigation or a content update removes the staleness and resets the sync state + // a navigation or a content update loads fresh content so the build is updated to being up-to-date if ( - newState.contentIsStale && + ['BUILD_READY', 'BUILD_READY_RELOAD'].includes(newState.activeSyncState) && ['content', 'navigate'].includes(action.type) ) { - newState.contentIsStale = false; newState.activeSyncState = 'UP_TO_DATE'; } @@ -236,7 +221,6 @@ export function useReaderState( activeSyncState: 'CHECKING', path, contentLoading: true, - contentIsStale: false, }); const techdocsStorageApi = useApi(techdocsStorageApiRef); @@ -246,35 +230,33 @@ export function useReaderState( dispatch({ type: 'navigate', path }); }, [path]); - // try to load the content - const { - value: content, - loading: contentLoading, - error: contentError, - retry: contentReload, - } = useAsyncRetry( - async () => - techdocsStorageApi.getEntityDocs( - { - kind, - namespace, - name, - }, - path, - ), - [techdocsStorageApi, kind, namespace, name, path], - ); + // try to load the content. the function will fire events and we don't care for the return values + const { retry: contentReload } = useAsyncRetry(async () => { + dispatch({ type: 'content', contentLoading: true }); - // convert all content changes into actions - useEffect(() => { - dispatch({ - type: 'content', - content, - contentLoading, - contentReload, - contentError, - }); - }, [dispatch, content, contentLoading, contentReload, contentError]); + try { + const entityDocs = await techdocsStorageApi.getEntityDocs( + { kind, namespace, name }, + path, + ); + + dispatch({ type: 'content', content: entityDocs }); + + return entityDocs; + } catch (e) { + dispatch({ type: 'content', contentError: e }); + } + + return undefined; + }, [techdocsStorageApi, kind, namespace, name, path]); + + // create a ref that holds the latest content. This provides a useAsync hook + // with the latest content without restarting the useAsync hook. + const contentRef = useRef<{ content?: string; reload: () => void }>({ + content: undefined, + reload: () => {}, + }); + contentRef.current = { content: state.content, reload: contentReload }; // try to derive the state. the function will fire events and we don't care for the return values useAsync(async () => { @@ -293,7 +275,13 @@ export function useReaderState( }); if (result === 'updated') { - dispatch({ type: 'sync', state: 'BUILD_READY' }); + // if there was no content prior to building, retry the loading + if (!contentRef.current.content) { + contentRef.current.reload(); + dispatch({ type: 'sync', state: 'BUILD_READY_RELOAD' }); + } else { + dispatch({ type: 'sync', state: 'BUILD_READY' }); + } } else if (result === 'cached') { dispatch({ type: 'sync', state: 'UP_TO_DATE' }); } else { @@ -305,7 +293,7 @@ export function useReaderState( // Cancel the timer that sets the state "BUILDING" clearTimeout(buildingTimeout); } - }, [kind, name, namespace, techdocsStorageApi, dispatch]); + }, [kind, name, namespace, techdocsStorageApi, dispatch, contentRef]); const displayState = useMemo( () => @@ -329,7 +317,7 @@ export function useReaderState( return { state: displayState, - content, + content: state.content, errorMessage, }; } From 5429bfa69edd7e28ab00d5832b9f9e433dba44c7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 17 Jun 2021 10:22:43 +0200 Subject: [PATCH 53/59] scripts/api-extractor: update to create reports for plugin packages too Signed-off-by: Patrik Oldsberg --- scripts/api-extractor.ts | 74 ++++++++++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 22 deletions(-) diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index f4aa0a7ddc..9ba59543f8 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -60,34 +60,60 @@ PackageJsonLookup.prototype.tryGetPackageJsonFilePathFor = function tryGetPackag return old.call(this, path); }; -const DOCUMENTED_PACKAGES = [ - 'packages/backend-common', - 'packages/backend-test-utils', - 'packages/catalog-client', - 'packages/catalog-model', - 'packages/cli-common', - 'packages/config', - 'packages/config-loader', - 'packages/core-app-api', +const PACKAGE_ROOTS = ['packages', 'plugins']; + +const SKIPPED_PACKAGES = [ + 'packages/app', + 'packages/backend', + 'packages/cli', + 'packages/codemods', + 'packages/create-app', + 'packages/docgen', + 'packages/e2e-test', + 'packages/storybook', + 'packages/techdocs-cli', + // TODO(Rugvip): Enable these once `import * as ...` and `import()` PRs have landed, #1796 & #1916. - // 'packages/core-components', - 'packages/core-plugin-api', - 'packages/dev-utils', - 'packages/errors', - 'packages/integration', - 'packages/integration-react', - 'packages/search-common', - 'packages/techdocs-common', - 'packages/test-utils', - 'packages/test-utils-core', - 'packages/theme', + 'packages/core', + 'packages/core-api', + 'packages/core-components', + 'plugins/catalog', + 'plugins/catalog-backend', + 'plugins/catalog-react', + 'plugins/github-deployments', + 'plugins/sentry-backend', ]; +async function findPackageDirs() { + const packageDirs = new Array(); + const projectRoot = resolvePath(__dirname, '..'); + + for (const packageRoot of PACKAGE_ROOTS) { + const dirs = await fs.readdir(resolvePath(projectRoot, packageRoot)); + for (const dir of dirs) { + const fullPackageDir = resolvePath(packageRoot, dir); + + const stat = await fs.stat(fullPackageDir); + if (!stat.isDirectory()) { + continue; + } + + const packageDir = relativePath(projectRoot, fullPackageDir); + if (!SKIPPED_PACKAGES.includes(packageDir)) { + packageDirs.push(packageDir); + } + } + } + + return packageDirs; +} + interface ApiExtractionOptions { packageDirs: string[]; outputDir: string; isLocalBuild: boolean; } + async function runApiExtraction({ packageDirs, outputDir, @@ -110,7 +136,9 @@ async function runApiExtraction({ configObject: { mainEntryPointFilePath: resolvePath( __dirname, - '../dist-types/packages//src/index.d.ts', + '../dist-types', + packageDir, + 'src/index.d.ts', ), bundledPackages: [], @@ -307,9 +335,11 @@ async function main() { const isCiBuild = process.argv.includes('--ci'); const isDocsBuild = process.argv.includes('--docs'); + const packageDirs = await findPackageDirs(); + console.log('# Generating package API reports'); await runApiExtraction({ - packageDirs: DOCUMENTED_PACKAGES, + packageDirs, outputDir: tmpDir, isLocalBuild: !isCiBuild, }); From 74f8b8fcb1d5ce24ba8f30a812ff4178fd0a33ba Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 17 Jun 2021 11:20:27 +0200 Subject: [PATCH 54/59] catalog-model: deprecated usage of yup-based validators Signed-off-by: Patrik Oldsberg --- packages/catalog-model/api-report.md | 6 +++--- packages/catalog-model/src/location/validation.ts | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md index 0dab9670de..68644bef87 100644 --- a/packages/catalog-model/api-report.md +++ b/packages/catalog-model/api-report.md @@ -10,7 +10,7 @@ import { JsonValue } from '@backstage/config'; import { SerializedError } from '@backstage/errors'; import * as yup from 'yup'; -// @public (undocumented) +// @public @deprecated (undocumented) export const analyzeLocationSchema: yup.ObjectSchema<{ location: LocationSpec; }, object>; @@ -316,7 +316,7 @@ export { LocationEntityV1alpha1 } // @public (undocumented) export const locationEntityV1alpha1Validator: KindValidator; -// @public (undocumented) +// @public @deprecated (undocumented) export const locationSchema: yup.ObjectSchema; // @public (undocumented) @@ -326,7 +326,7 @@ export type LocationSpec = { presence?: 'optional' | 'required'; }; -// @public (undocumented) +// @public @deprecated (undocumented) export const locationSpecSchema: yup.ObjectSchema; // @public (undocumented) diff --git a/packages/catalog-model/src/location/validation.ts b/packages/catalog-model/src/location/validation.ts index 4d2e602862..3a2fee5089 100644 --- a/packages/catalog-model/src/location/validation.ts +++ b/packages/catalog-model/src/location/validation.ts @@ -17,6 +17,7 @@ import * as yup from 'yup'; import { LocationSpec, Location } from './types'; +/** @deprecated */ export const locationSpecSchema = yup .object({ type: yup.string().required(), @@ -26,6 +27,7 @@ export const locationSpecSchema = yup .noUnknown() .required(); +/** @deprecated */ export const locationSchema = yup .object({ id: yup.string().required(), @@ -35,6 +37,7 @@ export const locationSchema = yup .noUnknown() .required(); +/** @deprecated */ export const analyzeLocationSchema = yup .object<{ location: LocationSpec }>({ location: locationSpecSchema, From d4f20e5be3555e28213cdaec89c3093c67734bf7 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 17 Jun 2021 11:40:22 +0200 Subject: [PATCH 55/59] chore: dont replace empty strings to undefined in `input` parsing Signed-off-by: blam --- .../src/scaffolder/tasks/TaskWorker.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index 94eaa62c85..bff10fc7e9 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -170,11 +170,6 @@ export class TaskWorker { preventIndent: true, })(templateCtx); - // If it's just an empty string, treat it as undefined - if (templated === '') { - return undefined; - } - // If it smells like a JSON object then give it a parse as an object and if it fails return the string if ( (templated.startsWith('"') && templated.endsWith('"')) || @@ -213,6 +208,10 @@ export class TaskWorker { // Keep track of all tmp dirs that are created by the action so we can remove them after const tmpDirs = new Array(); + this.options.logger.debug(`Running ${action.id} with input`, { + input: JSON.stringify(input, null, 2), + }); + await action.handler({ baseUrl: task.spec.baseUrl, logger: taskLogger, From b492221760757d4686cbae533f4b5f1242136f5e Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 17 Jun 2021 11:41:56 +0200 Subject: [PATCH 56/59] chore: added changeset Signed-off-by: blam --- .changeset/unlucky-peas-nail.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/unlucky-peas-nail.md diff --git a/.changeset/unlucky-peas-nail.md b/.changeset/unlucky-peas-nail.md new file mode 100644 index 0000000000..a4b5da1f90 --- /dev/null +++ b/.changeset/unlucky-peas-nail.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Keep the empty string as empty string in `input` rather than replacing with `undefined` to make empty values ok for `cookiecutter` From f4fbbf1552a4bd95a33261da58cbcc5c7ba4c7f4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 17 Jun 2021 11:08:46 +0000 Subject: [PATCH 57/59] Version Packages --- .changeset/unlucky-peas-nail.md | 5 ----- packages/create-app/CHANGELOG.md | 7 +++++++ packages/create-app/package.json | 2 +- plugins/scaffolder-backend/CHANGELOG.md | 6 ++++++ plugins/scaffolder-backend/package.json | 2 +- 5 files changed, 15 insertions(+), 7 deletions(-) delete mode 100644 .changeset/unlucky-peas-nail.md diff --git a/.changeset/unlucky-peas-nail.md b/.changeset/unlucky-peas-nail.md deleted file mode 100644 index a4b5da1f90..0000000000 --- a/.changeset/unlucky-peas-nail.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-scaffolder-backend': patch ---- - -Keep the empty string as empty string in `input` rather than replacing with `undefined` to make empty values ok for `cookiecutter` diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index a1df0f294c..4658e413c1 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,12 @@ # @backstage/create-app +## 0.3.27 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-scaffolder-backend@0.12.2 + ## 0.3.26 ### Patch Changes diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 95add27d6a..0eb5d9e29c 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/create-app", "description": "Create app package for Backstage", - "version": "0.3.26", + "version": "0.3.27", "private": false, "publishConfig": { "access": "public" diff --git a/plugins/scaffolder-backend/CHANGELOG.md b/plugins/scaffolder-backend/CHANGELOG.md index 4ea79dfeed..f747fce610 100644 --- a/plugins/scaffolder-backend/CHANGELOG.md +++ b/plugins/scaffolder-backend/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/plugin-scaffolder-backend +## 0.12.2 + +### Patch Changes + +- b49222176: Keep the empty string as empty string in `input` rather than replacing with `undefined` to make empty values ok for `cookiecutter` + ## 0.12.1 ### Patch Changes diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index b9737bc2fc..f3e76720af 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-scaffolder-backend", - "version": "0.12.1", + "version": "0.12.2", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", From e3d31b3815e406b89ec894eaa2f5d108c4d6cb25 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 17 Jun 2021 12:44:21 +0200 Subject: [PATCH 58/59] cli: disable GitHub App webhook by default Signed-off-by: Patrik Oldsberg --- .changeset/odd-humans-exercise.md | 5 +++++ docs/plugins/github-apps.md | 4 ++++ .../src/commands/create-github-app/GithubCreateAppServer.ts | 1 + 3 files changed, 10 insertions(+) create mode 100644 .changeset/odd-humans-exercise.md diff --git a/.changeset/odd-humans-exercise.md b/.changeset/odd-humans-exercise.md new file mode 100644 index 0000000000..136ec86b69 --- /dev/null +++ b/.changeset/odd-humans-exercise.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Make the `create-github-app` command disable webhooks by default. diff --git a/docs/plugins/github-apps.md b/docs/plugins/github-apps.md index 87d23b45d5..51d3fb9854 100644 --- a/docs/plugins/github-apps.md +++ b/docs/plugins/github-apps.md @@ -43,6 +43,10 @@ root of the project which you can then use as an `include` in your `app-config.yaml`. You can go ahead and [skip ahead](#including-in-integrations-config) if you've already got an app. +Note that the created app will have a webhook that is disabled by default and +points to `smee.io`, which is intended for local development. There's also +currently no part of Backstage that makes use of the webhook. + ### GitHub Enterprise You have to create the GitHub Application manually using these diff --git a/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts b/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts index 45671c2ead..0ffc1a08ff 100644 --- a/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts +++ b/packages/cli/src/commands/create-github-app/GithubCreateAppServer.ts @@ -120,6 +120,7 @@ export class GithubCreateAppServer { redirect_url: `${baseUrl}/callback`, hook_attributes: { url: this.webhookUrl, + active: false, }, }; const manifestJson = JSON.stringify(manifest).replace(/\"/g, '"'); From d8d7226fce8ed099a7dffd2ad1fac0f9d62c0675 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 17 Jun 2021 11:00:16 +0200 Subject: [PATCH 59/59] plugins: generate api reports Signed-off-by: Patrik Oldsberg --- plugins/api-docs/api-report.md | 115 ++++ plugins/app-backend/api-report.md | 28 + plugins/auth-backend/api-report.md | 211 +++++++ plugins/badges-backend/api-report.md | 113 ++++ plugins/badges/api-report.md | 21 + plugins/bitrise/api-report.md | 22 + plugins/catalog-graphql/api-report.md | 25 + plugins/catalog-import/api-report.md | 130 ++++ plugins/circleci/api-report.md | 81 +++ plugins/cloudbuild/api-report.md | 283 +++++++++ plugins/code-coverage-backend/api-report.md | 43 ++ plugins/code-coverage/api-report.md | 32 + plugins/config-schema/api-report.md | 42 ++ plugins/cost-insights/api-report.md | 621 ++++++++++++++++++++ plugins/explore-react/api-report.md | 31 + plugins/explore/api-report.md | 38 ++ plugins/fossa/api-report.md | 27 + plugins/gcp-projects/api-report.md | 86 +++ plugins/git-release-manager/api-report.md | 25 + plugins/github-actions/api-report.md | 213 +++++++ plugins/gitops-profiles/api-report.md | 197 +++++++ plugins/graphiql/api-report.md | 82 +++ plugins/graphql/api-report.md | 25 + plugins/ilert/api-report.md | 205 +++++++ plugins/jenkins/api-report.md | 83 +++ plugins/kafka-backend/api-report.md | 17 + plugins/kafka/api-report.md | 41 ++ plugins/kubernetes-backend/api-report.md | 103 ++++ plugins/kubernetes-common/api-report.md | 130 ++++ plugins/kubernetes/api-report.md | 46 ++ plugins/lighthouse/api-report.md | 186 ++++++ plugins/newrelic/api-report.md | 25 + plugins/org/api-report.md | 69 +++ plugins/pagerduty/api-report.md | 62 ++ plugins/proxy-backend/api-report.md | 18 + plugins/register-component/api-report.md | 32 + plugins/rollbar-backend/api-report.md | 60 ++ plugins/rollbar/api-report.md | 78 +++ plugins/scaffolder-backend/api-report.md | 518 ++++++++++++++++ plugins/scaffolder/api-report.md | 112 ++++ plugins/search-backend-node/api-report.md | 68 +++ plugins/search-backend/api-report.md | 17 + plugins/search/api-report.md | 102 ++++ plugins/sentry/api-report.md | 100 ++++ plugins/shortcuts/api-report.md | 56 ++ plugins/sonarqube/api-report.md | 41 ++ plugins/splunk-on-call/api-report.md | 68 +++ plugins/tech-radar/api-report.md | 123 ++++ plugins/techdocs-backend/api-report.md | 24 + plugins/techdocs/api-report.md | 146 +++++ plugins/todo-backend/api-report.md | 98 +++ plugins/todo/api-report.md | 23 + plugins/user-settings/api-report.md | 45 ++ plugins/welcome/api-report.md | 22 + 54 files changed, 5209 insertions(+) create mode 100644 plugins/api-docs/api-report.md create mode 100644 plugins/app-backend/api-report.md create mode 100644 plugins/auth-backend/api-report.md create mode 100644 plugins/badges-backend/api-report.md create mode 100644 plugins/badges/api-report.md create mode 100644 plugins/bitrise/api-report.md create mode 100644 plugins/catalog-graphql/api-report.md create mode 100644 plugins/catalog-import/api-report.md create mode 100644 plugins/circleci/api-report.md create mode 100644 plugins/cloudbuild/api-report.md create mode 100644 plugins/code-coverage-backend/api-report.md create mode 100644 plugins/code-coverage/api-report.md create mode 100644 plugins/config-schema/api-report.md create mode 100644 plugins/cost-insights/api-report.md create mode 100644 plugins/explore-react/api-report.md create mode 100644 plugins/explore/api-report.md create mode 100644 plugins/fossa/api-report.md create mode 100644 plugins/gcp-projects/api-report.md create mode 100644 plugins/git-release-manager/api-report.md create mode 100644 plugins/github-actions/api-report.md create mode 100644 plugins/gitops-profiles/api-report.md create mode 100644 plugins/graphiql/api-report.md create mode 100644 plugins/graphql/api-report.md create mode 100644 plugins/ilert/api-report.md create mode 100644 plugins/jenkins/api-report.md create mode 100644 plugins/kafka-backend/api-report.md create mode 100644 plugins/kafka/api-report.md create mode 100644 plugins/kubernetes-backend/api-report.md create mode 100644 plugins/kubernetes-common/api-report.md create mode 100644 plugins/kubernetes/api-report.md create mode 100644 plugins/lighthouse/api-report.md create mode 100644 plugins/newrelic/api-report.md create mode 100644 plugins/org/api-report.md create mode 100644 plugins/pagerduty/api-report.md create mode 100644 plugins/proxy-backend/api-report.md create mode 100644 plugins/register-component/api-report.md create mode 100644 plugins/rollbar-backend/api-report.md create mode 100644 plugins/rollbar/api-report.md create mode 100644 plugins/scaffolder-backend/api-report.md create mode 100644 plugins/scaffolder/api-report.md create mode 100644 plugins/search-backend-node/api-report.md create mode 100644 plugins/search-backend/api-report.md create mode 100644 plugins/search/api-report.md create mode 100644 plugins/sentry/api-report.md create mode 100644 plugins/shortcuts/api-report.md create mode 100644 plugins/sonarqube/api-report.md create mode 100644 plugins/splunk-on-call/api-report.md create mode 100644 plugins/tech-radar/api-report.md create mode 100644 plugins/techdocs-backend/api-report.md create mode 100644 plugins/techdocs/api-report.md create mode 100644 plugins/todo-backend/api-report.md create mode 100644 plugins/todo/api-report.md create mode 100644 plugins/user-settings/api-report.md create mode 100644 plugins/welcome/api-report.md diff --git a/plugins/api-docs/api-report.md b/plugins/api-docs/api-report.md new file mode 100644 index 0000000000..5a09cfceea --- /dev/null +++ b/plugins/api-docs/api-report.md @@ -0,0 +1,115 @@ +## API Report File for "@backstage/plugin-api-docs" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiEntity } from '@backstage/catalog-model'; +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { CatalogTableRow } from '@backstage/plugin-catalog'; +import { Entity } from '@backstage/catalog-model'; +import { ExternalRouteRef } from '@backstage/core'; +import { default as React_2 } from 'react'; +import { RouteRef } from '@backstage/core'; +import { TableColumn } from '@backstage/core'; +import { UserListFilterKind } from '@backstage/plugin-catalog-react'; + +// @public (undocumented) +export const ApiDefinitionCard: (_: Props) => JSX.Element; + +// @public (undocumented) +export type ApiDefinitionWidget = { + type: string; + title: string; + component: (definition: string) => React_2.ReactElement; + rawLanguage?: string; +}; + +// @public (undocumented) +export const apiDocsConfigRef: ApiRef; + +// @public (undocumented) +const apiDocsPlugin: BackstagePlugin<{ + root: RouteRef; +}, { + createComponent: ExternalRouteRef; +}>; + +export { apiDocsPlugin } + +export { apiDocsPlugin as plugin } + +// @public (undocumented) +export const ApiExplorerPage: ({ initiallySelectedFilter, columns, }: ApiExplorerPageProps) => JSX.Element; + +// @public (undocumented) +export const ApiTypeTitle: ({ apiEntity }: { + apiEntity: ApiEntity; +}) => JSX.Element; + +// @public (undocumented) +export const AsyncApiDefinitionWidget: ({ definition }: Props_5) => JSX.Element; + +// @public (undocumented) +export const ConsumedApisCard: ({ variant }: Props_2) => JSX.Element; + +// @public (undocumented) +export const ConsumingComponentsCard: ({ variant }: Props_6) => JSX.Element; + +// @public (undocumented) +export function defaultDefinitionWidgets(): ApiDefinitionWidget[]; + +// @public (undocumented) +export const EntityApiDefinitionCard: (_: { + apiEntity?: ApiEntity | undefined; +}) => JSX.Element; + +// @public (undocumented) +export const EntityConsumedApisCard: ({ variant }: { + entity?: Entity| undefined; + variant?: "gridItem" | undefined; +}) => JSX.Element; + +// @public (undocumented) +export const EntityConsumingComponentsCard: ({ variant }: { + entity?: Entity| undefined; + variant?: "gridItem" | undefined; +}) => JSX.Element; + +// @public (undocumented) +export const EntityHasApisCard: ({ variant }: { + variant?: "gridItem" | undefined; +}) => JSX.Element; + +// @public (undocumented) +export const EntityProvidedApisCard: ({ variant }: { + entity?: Entity| undefined; + variant?: "gridItem" | undefined; +}) => JSX.Element; + +// @public (undocumented) +export const EntityProvidingComponentsCard: ({ variant }: { + entity?: Entity| undefined; + variant?: "gridItem" | undefined; +}) => JSX.Element; + +// @public (undocumented) +export const HasApisCard: ({ variant }: Props_3) => JSX.Element; + +// @public (undocumented) +export const OpenApiDefinitionWidget: ({ definition }: Props_8) => JSX.Element; + +// @public (undocumented) +export const PlainApiDefinitionWidget: ({ definition, language }: Props_9) => JSX.Element; + +// @public (undocumented) +export const ProvidedApisCard: ({ variant }: Props_4) => JSX.Element; + +// @public (undocumented) +export const ProvidingComponentsCard: ({ variant }: Props_7) => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/app-backend/api-report.md b/plugins/app-backend/api-report.md new file mode 100644 index 0000000000..4f59102265 --- /dev/null +++ b/plugins/app-backend/api-report.md @@ -0,0 +1,28 @@ +## API Report File for "@backstage/plugin-app-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Config } from '@backstage/config'; +import express from 'express'; +import { Logger } from 'winston'; + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public (undocumented) +export interface RouterOptions { + appPackageName: string; + // (undocumented) + config: Config; + disableConfigInjection?: boolean; + // (undocumented) + logger: Logger; + staticFallbackHandler?: express.Handler; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md new file mode 100644 index 0000000000..dea5500d53 --- /dev/null +++ b/plugins/auth-backend/api-report.md @@ -0,0 +1,211 @@ +## API Report File for "@backstage/plugin-auth-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { CatalogApi } from '@backstage/catalog-client'; +import { Config } from '@backstage/config'; +import express from 'express'; +import { JSONWebKey } from 'jose'; +import { Logger } from 'winston'; +import { PluginDatabaseManager } from '@backstage/backend-common'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { Profile } from 'passport'; + +// @public (undocumented) +export type AuthProviderFactory = (options: AuthProviderFactoryOptions) => AuthProviderRouteHandlers; + +// @public (undocumented) +export type AuthProviderFactoryOptions = { + providerId: string; + globalConfig: AuthProviderConfig; + config: Config; + logger: Logger; + tokenIssuer: TokenIssuer; + discovery: PluginEndpointDiscovery; + catalogApi: CatalogApi; + identityResolver?: ExperimentalIdentityResolver; +}; + +// @public +export interface AuthProviderRouteHandlers { + frameHandler(req: express.Request, res: express.Response): Promise; + logout?(req: express.Request, res: express.Response): Promise; + refresh?(req: express.Request, res: express.Response): Promise; + start(req: express.Request, res: express.Response): Promise; +} + +// @public (undocumented) +export type AuthResponse = { + providerInfo: ProviderInfo; + profile: ProfileInfo; + backstageIdentity?: BackstageIdentity; +}; + +// @public (undocumented) +export type BackstageIdentity = { + id: string; + idToken?: string; +}; + +// @public (undocumented) +export function createRouter({ logger, config, discovery, database, providerFactories, }: RouterOptions): Promise; + +// @public (undocumented) +export const defaultAuthProviderFactories: { + [providerId: string]: AuthProviderFactory; +}; + +// @public (undocumented) +export const encodeState: (state: OAuthState) => string; + +// @public (undocumented) +export const ensuresXRequestedWith: (req: express.Request) => boolean; + +// @public +export class IdentityClient { + constructor(options: { + discovery: PluginEndpointDiscovery; + issuer: string; + }); + authenticate(token: string | undefined): Promise; + static getBearerToken(authorizationHeader: string | undefined): string | undefined; + listPublicKeys(): Promise<{ + keys: JSONWebKey[]; + }>; + } + +// @public (undocumented) +export class OAuthAdapter implements AuthProviderRouteHandlers { + constructor(handlers: OAuthHandlers, options: Options); + // (undocumented) + frameHandler(req: express.Request, res: express.Response): Promise; + // (undocumented) + static fromConfig(config: AuthProviderConfig, handlers: OAuthHandlers, options: Pick): OAuthAdapter; + // (undocumented) + logout(req: express.Request, res: express.Response): Promise; + // (undocumented) + refresh(req: express.Request, res: express.Response): Promise; + // (undocumented) + start(req: express.Request, res: express.Response): Promise; +} + +// @public (undocumented) +export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers { + constructor(handlers: Map); + // (undocumented) + frameHandler(req: express.Request, res: express.Response): Promise; + // (undocumented) + logout(req: express.Request, res: express.Response): Promise; + // (undocumented) + static mapConfig(config: Config, factoryFunc: (envConfig: Config) => AuthProviderRouteHandlers): OAuthEnvironmentHandler; + // (undocumented) + refresh(req: express.Request, res: express.Response): Promise; + // (undocumented) + start(req: express.Request, res: express.Response): Promise; +} + +// @public +export interface OAuthHandlers { + handler(req: express.Request): Promise<{ + response: AuthResponse; + refreshToken?: string; + }>; + logout?(): Promise; + refresh?(req: OAuthRefreshRequest): Promise>; + start(req: OAuthStartRequest): Promise; +} + +// @public (undocumented) +export type OAuthProviderInfo = { + accessToken: string; + idToken?: string; + expiresInSeconds?: number; + scope: string; + refreshToken?: string; +}; + +// @public +export type OAuthProviderOptions = { + clientId: string; + clientSecret: string; + callbackUrl: string; +}; + +// @public (undocumented) +export type OAuthRefreshRequest = express.Request<{}> & { + scope: string; + refreshToken: string; +}; + +// @public (undocumented) +export type OAuthResponse = AuthResponse; + +// @public (undocumented) +export type OAuthResult = { + fullProfile: Profile; + params: { + id_token?: string; + scope: string; + expires_in: number; + }; + accessToken: string; + refreshToken?: string; +}; + +// @public (undocumented) +export type OAuthStartRequest = express.Request<{}> & { + scope: string; + state: OAuthState; +}; + +// @public (undocumented) +export type OAuthState = { + nonce: string; + env: string; +}; + +// @public (undocumented) +export const postMessageResponse: (res: express.Response, appOrigin: string, response: WebMessageResponse) => void; + +// @public +export type ProfileInfo = { + email?: string; + displayName?: string; + picture?: string; +}; + +// @public (undocumented) +export const readState: (stateString: string) => OAuthState; + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + config: Config; + // (undocumented) + database: PluginDatabaseManager; + // (undocumented) + discovery: PluginEndpointDiscovery; + // (undocumented) + logger: Logger; + // (undocumented) + providerFactories?: ProviderFactories; +} + +// @public (undocumented) +export const verifyNonce: (req: express.Request, providerId: string) => void; + +// @public +export type WebMessageResponse = { + type: 'authorization_response'; + response: AuthResponse; +} | { + type: 'authorization_response'; + error: Error; +}; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/badges-backend/api-report.md b/plugins/badges-backend/api-report.md new file mode 100644 index 0000000000..1523d685b4 --- /dev/null +++ b/plugins/badges-backend/api-report.md @@ -0,0 +1,113 @@ +## API Report File for "@backstage/plugin-badges-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { CatalogApi } from '@backstage/catalog-client'; +import { Config } from '@backstage/config'; +import { Entity } from '@backstage/catalog-model'; +import express from 'express'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; + +// @public (undocumented) +export interface Badge { + color?: string; + description?: string; + kind?: 'entity'; + label: string; + labelColor?: string; + link?: string; + message: string; + style?: BadgeStyle; +} + +// @public (undocumented) +export const BADGE_STYLES: readonly ["plastic", "flat", "flat-square", "for-the-badge", "social"]; + +// @public (undocumented) +export type BadgeBuilder = { + getBadges(): Promise; + createBadgeJson(options: BadgeOptions): Promise; + createBadgeSvg(options: BadgeOptions): Promise; +}; + +// @public (undocumented) +export interface BadgeContext { + // (undocumented) + badgeUrl: string; + // (undocumented) + config: Config; + // (undocumented) + entity?: Entity; +} + +// @public (undocumented) +export interface BadgeFactories { + // (undocumented) + [id: string]: BadgeFactory; +} + +// @public (undocumented) +export interface BadgeFactory { + // (undocumented) + createBadge(context: BadgeContext): Badge; +} + +// @public (undocumented) +export type BadgeInfo = { + id: string; +}; + +// @public (undocumented) +export type BadgeOptions = { + badgeInfo: BadgeInfo; + context: BadgeContext; +}; + +// @public (undocumented) +export type BadgeSpec = { + id: string; + badge: Badge; + url: string; + markdown: string; +}; + +// @public (undocumented) +export type BadgeStyle = typeof BADGE_STYLES[number]; + +// @public (undocumented) +export const createDefaultBadgeFactories: () => BadgeFactories; + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public (undocumented) +export class DefaultBadgeBuilder implements BadgeBuilder { + constructor(factories: BadgeFactories); + // (undocumented) + createBadgeJson(options: BadgeOptions): Promise; + // (undocumented) + createBadgeSvg(options: BadgeOptions): Promise; + // (undocumented) + getBadges(): Promise; + } + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + badgeBuilder?: BadgeBuilder; + // (undocumented) + badgeFactories?: BadgeFactories; + // (undocumented) + catalog?: CatalogApi; + // (undocumented) + config: Config; + // (undocumented) + discovery: PluginEndpointDiscovery; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/badges/api-report.md b/plugins/badges/api-report.md new file mode 100644 index 0000000000..dfe7cbdae8 --- /dev/null +++ b/plugins/badges/api-report.md @@ -0,0 +1,21 @@ +## API Report File for "@backstage/plugin-badges" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BackstagePlugin } from '@backstage/core'; + +// @public (undocumented) +export const badgesPlugin: BackstagePlugin<{}, {}>; + +// @public (undocumented) +export const EntityBadgesDialog: ({ open, onClose }: { + open: boolean; + onClose?: (() => any) | undefined; +}) => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/bitrise/api-report.md b/plugins/bitrise/api-report.md new file mode 100644 index 0000000000..c07178111d --- /dev/null +++ b/plugins/bitrise/api-report.md @@ -0,0 +1,22 @@ +## API Report File for "@backstage/plugin-bitrise" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BackstagePlugin } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; + +// @public (undocumented) +export const bitrisePlugin: BackstagePlugin<{}, {}>; + +// @public (undocumented) +export const EntityBitriseContent: () => JSX.Element; + +// @public (undocumented) +export const isBitriseAvailable: (entity: Entity) => boolean; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/catalog-graphql/api-report.md b/plugins/catalog-graphql/api-report.md new file mode 100644 index 0000000000..cbe325f4c5 --- /dev/null +++ b/plugins/catalog-graphql/api-report.md @@ -0,0 +1,25 @@ +## API Report File for "@backstage/plugin-catalog-graphql" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Config } from '@backstage/config'; +import { GraphQLModule } from '@graphql-modules/core'; +import { Logger } from 'winston'; + +// @public (undocumented) +export function createModule(options: ModuleOptions): Promise; + +// @public (undocumented) +export interface ModuleOptions { + // (undocumented) + config: Config; + // (undocumented) + logger: Logger; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/catalog-import/api-report.md b/plugins/catalog-import/api-report.md new file mode 100644 index 0000000000..34baed46b5 --- /dev/null +++ b/plugins/catalog-import/api-report.md @@ -0,0 +1,130 @@ +## API Report File for "@backstage/plugin-catalog-import" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { CatalogApi } from '@backstage/catalog-client'; +import { ConfigApi } from '@backstage/core'; +import { Control } from 'react-hook-form'; +import { DiscoveryApi } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { EntityName } from '@backstage/catalog-model'; +import { FieldErrors } from 'react-hook-form'; +import { IdentityApi } from '@backstage/core'; +import { InfoCardVariants } from '@backstage/core'; +import { OAuthApi } from '@backstage/core'; +import { default as React_2 } from 'react'; +import { RouteRef } from '@backstage/core'; +import { ScmIntegrationRegistry } from '@backstage/integration'; +import { SubmitHandler } from 'react-hook-form'; +import { TextFieldProps } from '@material-ui/core/TextField/TextField'; +import { UnpackNestedValue } from 'react-hook-form'; +import { UseControllerOptions } from 'react-hook-form'; +import { UseFormMethods } from 'react-hook-form'; +import { UseFormOptions } from 'react-hook-form'; + +// @public (undocumented) +export type AnalyzeResult = { + type: 'locations'; + locations: Array<{ + target: string; + entities: EntityName[]; + }>; +} | { + type: 'repository'; + url: string; + integrationType: string; + generatedEntities: PartialEntity[]; +}; + +// @public (undocumented) +export const AutocompleteTextField: ({ name, options, required, control, errors, rules, loading, loadingText, helperText, errorHelperText, textFieldProps, }: Props_4) => JSX.Element; + +// @public (undocumented) +export interface CatalogImportApi { + // (undocumented) + analyzeUrl(url: string): Promise; + // (undocumented) + submitPullRequest(options: { + repositoryUrl: string; + fileContent: string; + title: string; + body: string; + }): Promise<{ + link: string; + location: string; + }>; +} + +// @public (undocumented) +export const catalogImportApiRef: ApiRef; + +// @public (undocumented) +export class CatalogImportClient implements CatalogImportApi { + constructor(options: { + discoveryApi: DiscoveryApi; + githubAuthApi: OAuthApi; + identityApi: IdentityApi; + scmIntegrationsApi: ScmIntegrationRegistry; + catalogApi: CatalogApi; + }); + // (undocumented) + analyzeUrl(url: string): Promise; + // (undocumented) + submitPullRequest({ repositoryUrl, fileContent, title, body, }: { + repositoryUrl: string; + fileContent: string; + title: string; + body: string; + }): Promise<{ + link: string; + location: string; + }>; +} + +// @public (undocumented) +export const CatalogImportPage: (opts: StepperProviderOpts) => JSX.Element; + +// @public (undocumented) +const catalogImportPlugin: BackstagePlugin<{ + importPage: RouteRef; +}, {}>; + +export { catalogImportPlugin } + +export { catalogImportPlugin as plugin } + +// @public +export function defaultGenerateStepper(flow: ImportFlows, defaults: StepperProvider): StepperProvider; + +// @public (undocumented) +export const EntityListComponent: ({ locations, collapsed, locationListItemIcon, onItemClick, firstListItem, withLinks, }: Props_2) => JSX.Element; + +// @public (undocumented) +export const ImportStepper: ({ initialUrl, generateStepper, variant, opts, }: Props) => JSX.Element; + +// @public +export const PreparePullRequestForm: >({ defaultValues, onSubmit, render, }: Props_5) => JSX.Element; + +// @public (undocumented) +export const PreviewCatalogInfoComponent: ({ repositoryUrl, entities, classes, }: Props_6) => JSX.Element; + +// @public (undocumented) +export const PreviewPullRequestComponent: ({ title, description, classes, }: Props_7) => JSX.Element; + +// @public (undocumented) +export const Router: (opts: StepperProviderOpts) => JSX.Element; + +// @public +export const StepInitAnalyzeUrl: ({ onAnalysis, analysisUrl, disablePullRequest, }: Props_3) => JSX.Element; + +// @public (undocumented) +export const StepPrepareCreatePullRequest: ({ analyzeResult, onPrepare, onGoBack, renderFormFields, defaultTitle, defaultBody, }: Props_8) => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/circleci/api-report.md b/plugins/circleci/api-report.md new file mode 100644 index 0000000000..6c774cc6b0 --- /dev/null +++ b/plugins/circleci/api-report.md @@ -0,0 +1,81 @@ +## API Report File for "@backstage/plugin-circleci" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { BuildStepAction } from 'circleci-api'; +import { BuildSummary } from 'circleci-api'; +import { BuildSummaryResponse } from 'circleci-api'; +import { BuildWithSteps } from 'circleci-api'; +import { CircleCIOptions } from 'circleci-api'; +import { DiscoveryApi } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { GitType } from 'circleci-api'; +import { Me } from 'circleci-api'; +import { RouteRef } from '@backstage/core'; + +export { BuildStepAction } + +export { BuildSummary } + +export { BuildWithSteps } + +// @public (undocumented) +export const CIRCLECI_ANNOTATION = "circleci.com/project-slug"; + +// @public (undocumented) +export class CircleCIApi { + constructor(options: Options); + // (undocumented) + getBuild(buildNumber: number, options: Partial): Promise; + // (undocumented) + getBuilds({ limit, offset }: { + limit: number; + offset: number; + }, options: Partial): Promise; + // (undocumented) + getUser(options: Partial): Promise; + // (undocumented) + retry(buildNumber: number, options: Partial): Promise; +} + +// @public (undocumented) +export const circleCIApiRef: ApiRef; + +// @public (undocumented) +export const circleCIBuildRouteRef: RouteRef; + +// @public (undocumented) +const circleCIPlugin: BackstagePlugin<{}, {}>; + +export { circleCIPlugin } + +export { circleCIPlugin as plugin } + +// @public (undocumented) +export const circleCIRouteRef: RouteRef; + +// @public (undocumented) +export const EntityCircleCIContent: (_props: { + entity?: Entity| undefined; +}) => JSX.Element; + +export { GitType } + +// @public (undocumented) +const isCircleCIAvailable: (entity: Entity) => boolean; + +export { isCircleCIAvailable } + +export { isCircleCIAvailable as isPluginApplicableToEntity } + +// @public (undocumented) +export const Router: (_props: Props) => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/cloudbuild/api-report.md b/plugins/cloudbuild/api-report.md new file mode 100644 index 0000000000..58dc59c175 --- /dev/null +++ b/plugins/cloudbuild/api-report.md @@ -0,0 +1,283 @@ +## API Report File for "@backstage/plugin-cloudbuild" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { OAuthApi } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export type ActionsGetWorkflowResponseData = { + id: string; + status: string; + source: Source; + createTime: string; + startTime: string; + steps: Step[]; + timeout: string; + projectId: string; + logsBucket: string; + sourceProvenance: SourceProvenance; + buildTriggerId: string; + options: Options; + logUrl: string; + substitutions: Substitutions; + tags: string[]; + queueTtl: string; + name: string; + finishTime: any; + results: Results; + timing: Timing2; +}; + +// @public (undocumented) +export interface ActionsListWorkflowRunsForRepoResponseData { + // (undocumented) + builds: ActionsGetWorkflowResponseData[]; +} + +// @public (undocumented) +export interface BUILD { + // (undocumented) + endTime: string; + // (undocumented) + startTime: string; +} + +// @public (undocumented) +export const CLOUDBUILD_ANNOTATION = "google.com/cloudbuild-project-slug"; + +// @public (undocumented) +export type CloudbuildApi = { + listWorkflowRuns: (request: { + projectId: string; + }) => Promise; + getWorkflow: ({ projectId, id, }: { + projectId: string; + id: string; + }) => Promise; + getWorkflowRun: ({ projectId, id, }: { + projectId: string; + id: string; + }) => Promise; + reRunWorkflow: ({ projectId, runId, }: { + projectId: string; + runId: string; + }) => Promise; +}; + +// @public (undocumented) +export const cloudbuildApiRef: ApiRef; + +// @public (undocumented) +export class CloudbuildClient implements CloudbuildApi { + constructor(googleAuthApi: OAuthApi); + // (undocumented) + getToken(): Promise; + // (undocumented) + getWorkflow({ projectId, id, }: { + projectId: string; + id: string; + }): Promise; + // (undocumented) + getWorkflowRun({ projectId, id, }: { + projectId: string; + id: string; + }): Promise; + // (undocumented) + listWorkflowRuns({ projectId, }: { + projectId: string; + }): Promise; + // (undocumented) + reRunWorkflow({ projectId, runId, }: { + projectId: string; + runId: string; + }): Promise; +} + +// @public (undocumented) +const cloudbuildPlugin: BackstagePlugin<{ + entityContent: RouteRef; +}, {}>; + +export { cloudbuildPlugin } + +export { cloudbuildPlugin as plugin } + +// @public (undocumented) +export const EntityCloudbuildContent: (_props: { + entity?: Entity| undefined; +}) => JSX.Element; + +// @public (undocumented) +export const EntityLatestCloudbuildRunCard: ({ branch, }: { + entity?: Entity| undefined; + branch: string; +}) => JSX.Element; + +// @public (undocumented) +export const EntityLatestCloudbuildsForBranchCard: ({ branch, }: { + entity?: Entity| undefined; + branch: string; +}) => JSX.Element; + +// @public (undocumented) +export interface FETCHSOURCE { + // (undocumented) + endTime: string; + // (undocumented) + startTime: string; +} + +// @public (undocumented) +const isCloudbuildAvailable: (entity: Entity) => boolean; + +export { isCloudbuildAvailable } + +export { isCloudbuildAvailable as isPluginApplicableToEntity } + +// @public (undocumented) +export const LatestWorkflowRunCard: ({ branch, }: { + entity?: Entity | undefined; + branch: string; +}) => JSX.Element; + +// @public (undocumented) +export const LatestWorkflowsForBranchCard: ({ branch, }: { + entity?: Entity | undefined; + branch: string; +}) => JSX.Element; + +// @public (undocumented) +export interface Options { + // (undocumented) + dynamicSubstitutions: boolean; + // (undocumented) + logging: string; + // (undocumented) + machineType: string; + // (undocumented) + substitutionOption: string; +} + +// @public (undocumented) +export interface PullTiming { + // (undocumented) + endTime: string; + // (undocumented) + startTime: string; +} + +// @public (undocumented) +export interface ResolvedStorageSource { + // (undocumented) + bucket: string; + // (undocumented) + generation: string; + // (undocumented) + object: string; +} + +// @public (undocumented) +export interface Results { + // (undocumented) + buildStepImages: string[]; + // (undocumented) + buildStepOutputs: string[]; +} + +// @public (undocumented) +export const Router: (_props: Props) => JSX.Element; + +// @public (undocumented) +export interface Source { + // (undocumented) + storageSource: StorageSource; +} + +// @public (undocumented) +export interface SourceProvenance { + // (undocumented) + fileHashes: {}; + // (undocumented) + resolvedStorageSource: {}; +} + +// @public (undocumented) +export interface Step { + // (undocumented) + args: string[]; + // (undocumented) + dir: string; + // (undocumented) + entrypoint: string; + // (undocumented) + id: string; + // (undocumented) + name: string; + // (undocumented) + pullTiming: PullTiming; + // (undocumented) + status: string; + // (undocumented) + timing: Timing; + // (undocumented) + volumes: Volume[]; + // (undocumented) + waitFor: string[]; +} + +// @public (undocumented) +export interface StorageSource { + // (undocumented) + bucket: string; + // (undocumented) + object: string; +} + +// @public (undocumented) +export interface Substitutions { + // (undocumented) + BRANCH_NAME: string; + // (undocumented) + COMMIT_SHA: string; + // (undocumented) + REPO_NAME: string; + // (undocumented) + REVISION_ID: string; + // (undocumented) + SHORT_SHA: string; +} + +// @public (undocumented) +export interface Timing { + // (undocumented) + endTime: string; + // (undocumented) + startTime: string; +} + +// @public (undocumented) +export interface Timing2 { + // (undocumented) + BUILD: BUILD; + // (undocumented) + FETCHSOURCE: FETCHSOURCE; +} + +// @public (undocumented) +export interface Volume { + // (undocumented) + name: string; + // (undocumented) + path: string; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/code-coverage-backend/api-report.md b/plugins/code-coverage-backend/api-report.md new file mode 100644 index 0000000000..f8f1575d0b --- /dev/null +++ b/plugins/code-coverage-backend/api-report.md @@ -0,0 +1,43 @@ +## API Report File for "@backstage/plugin-code-coverage-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Config } from '@backstage/config'; +import express from 'express'; +import { Logger } from 'winston'; +import { PluginDatabaseManager } from '@backstage/backend-common'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { UrlReader } from '@backstage/backend-common'; + +// @public (undocumented) +export interface CodeCoverageApi { + // (undocumented) + name: string; +} + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public (undocumented) +export const makeRouter: (options: RouterOptions) => Promise; + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + config: Config; + // (undocumented) + database: PluginDatabaseManager; + // (undocumented) + discovery: PluginEndpointDiscovery; + // (undocumented) + logger: Logger; + // (undocumented) + urlReader: UrlReader; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/code-coverage/api-report.md b/plugins/code-coverage/api-report.md new file mode 100644 index 0000000000..bade5ddb8a --- /dev/null +++ b/plugins/code-coverage/api-report.md @@ -0,0 +1,32 @@ +## API Report File for "@backstage/plugin-code-coverage" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BackstagePlugin } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const codeCoveragePlugin: BackstagePlugin<{ + root: RouteRef; +}, {}>; + +// @public (undocumented) +export const EntityCodeCoverageContent: () => JSX.Element; + +// @public (undocumented) +const isCodeCoverageAvailable: (entity: Entity) => boolean; + +export { isCodeCoverageAvailable } + +export { isCodeCoverageAvailable as isPluginApplicableToEntity } + +// @public (undocumented) +export const Router: () => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/config-schema/api-report.md b/plugins/config-schema/api-report.md new file mode 100644 index 0000000000..67497be6b6 --- /dev/null +++ b/plugins/config-schema/api-report.md @@ -0,0 +1,42 @@ +## API Report File for "@backstage/plugin-config-schema" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { Observable } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; +import { Schema } from 'jsonschema'; + +// @public (undocumented) +export interface ConfigSchemaApi { + // (undocumented) + schema$(): Observable; +} + +// @public (undocumented) +export const configSchemaApiRef: ApiRef; + +// @public (undocumented) +export const ConfigSchemaPage: () => JSX.Element; + +// @public (undocumented) +export const configSchemaPlugin: BackstagePlugin<{ + root: RouteRef; +}, {}>; + +// @public +export class StaticSchemaLoader implements ConfigSchemaApi { + constructor({ url }?: { + url?: string; + }); + // (undocumented) + schema$(): Observable; + } + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/cost-insights/api-report.md b/plugins/cost-insights/api-report.md new file mode 100644 index 0000000000..7476f75cad --- /dev/null +++ b/plugins/cost-insights/api-report.md @@ -0,0 +1,621 @@ +## API Report File for "@backstage/plugin-cost-insights" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePalette } from '@backstage/theme'; +import { BackstagePlugin } from '@backstage/core'; +import { BackstageTheme } from '@backstage/theme'; +import { ContentRenderer } from 'recharts'; +import { Dispatch } from 'react'; +import { ForwardRefExoticComponent } from 'react'; +import { PaletteOptions } from '@material-ui/core/styles/createPalette'; +import { PropsWithChildren } from 'react'; +import { ReactNode } from 'react'; +import { RechartsFunction } from 'recharts'; +import { RefAttributes } from 'react'; +import { RouteRef } from '@backstage/core'; +import { SetStateAction } from 'react'; +import { TooltipProps } from 'recharts'; +import { TypographyProps } from '@material-ui/core'; + +// @public +export type Alert = { + title: string | JSX.Element; + subtitle: string | JSX.Element; + element?: JSX.Element; + status?: AlertStatus; + url?: string; + buttonText?: string; + SnoozeForm?: Maybe; + AcceptForm?: Maybe; + DismissForm?: Maybe; + onSnoozed?(options: AlertOptions): Promise; + onAccepted?(options: AlertOptions): Promise; + onDismissed?(options: AlertOptions): Promise; +}; + +// @public (undocumented) +export interface AlertCost { + // (undocumented) + aggregation: [number, number]; + // (undocumented) + id: string; +} + +// @public (undocumented) +export interface AlertDismissFormData { + // (undocumented) + feedback: Maybe; + // (undocumented) + other: Maybe; + // (undocumented) + reason: AlertDismissReason; +} + +// @public (undocumented) +export interface AlertDismissOption { + // (undocumented) + label: string; + // (undocumented) + reason: string; +} + +// @public (undocumented) +export const AlertDismissOptions: AlertDismissOption[]; + +// @public (undocumented) +export enum AlertDismissReason { + // (undocumented) + Expected = "expected", + // (undocumented) + Migration = "migration", + // (undocumented) + NotApplicable = "not-applicable", + // (undocumented) + Other = "other", + // (undocumented) + Resolved = "resolved", + // (undocumented) + Seasonal = "seasonal" +} + +// @public (undocumented) +export type AlertForm = ForwardRefExoticComponent & RefAttributes>; + +// @public (undocumented) +export type AlertFormProps = { + alert: A; + onSubmit: (data: FormData) => void; + disableSubmit: (isDisabled: boolean) => void; +}; + +// @public (undocumented) +export interface AlertOptions { + // (undocumented) + data: T; + // (undocumented) + group: string; +} + +// @public +export interface AlertSnoozeFormData { + // (undocumented) + intervals: string; +} + +// @public (undocumented) +export type AlertSnoozeOption = { + label: string; + duration: Duration; +}; + +// @public (undocumented) +export const AlertSnoozeOptions: AlertSnoozeOption[]; + +// @public (undocumented) +export enum AlertStatus { + // (undocumented) + Accepted = "accepted", + // (undocumented) + Dismissed = "dismissed", + // (undocumented) + Snoozed = "snoozed" +} + +// @public (undocumented) +export const BarChart: ({ resources, responsive, displayAmount, options, tooltip, onClick, onMouseMove, }: BarChartProps) => JSX.Element; + +// @public +export interface BarChartData extends BarChartOptions { +} + +// @public (undocumented) +export const BarChartLegend: ({ costStart, costEnd, options, children, }: PropsWithChildren) => JSX.Element; + +// @public (undocumented) +export type BarChartLegendOptions = { + previousName: string; + previousFill: string; + currentName: string; + currentFill: string; + hideMarker?: boolean; +}; + +// @public (undocumented) +export type BarChartLegendProps = { + costStart: number; + costEnd: number; + options?: Partial; +}; + +// @public (undocumented) +export interface BarChartOptions { + // (undocumented) + currentFill: string; + // (undocumented) + currentName: string; + // (undocumented) + previousFill: string; + // (undocumented) + previousName: string; +} + +// @public (undocumented) +export type BarChartProps = { + resources: ResourceData[]; + responsive?: boolean; + displayAmount?: number; + options?: Partial; + tooltip?: ContentRenderer; + onClick?: RechartsFunction; + onMouseMove?: RechartsFunction; +}; + +// @public (undocumented) +export const BarChartTooltip: ({ title, content, subtitle, topRight, actions, children, }: PropsWithChildren) => JSX.Element; + +// @public (undocumented) +export const BarChartTooltipItem: ({ item }: BarChartTooltipItemProps) => JSX.Element; + +// @public (undocumented) +export type BarChartTooltipItemProps = { + item: TooltipItem; +}; + +// @public (undocumented) +export type BarChartTooltipProps = { + title: string; + content?: ReactNode | string; + subtitle?: ReactNode; + topRight?: ReactNode; + actions?: ReactNode; +}; + +// @public (undocumented) +export interface ChangeStatistic { + // (undocumented) + amount: number; + // (undocumented) + ratio?: number; +} + +// @public (undocumented) +export enum ChangeThreshold { + // (undocumented) + lower = -0.05, + // (undocumented) + upper = 0.05 +} + +// @public (undocumented) +export type ChartData = { + date: number; + trend: number; + dailyCost: number; + [key: string]: number; +}; + +// @public (undocumented) +export interface Cost { + // (undocumented) + aggregation: DateAggregation[]; + // (undocumented) + change?: ChangeStatistic; + // (undocumented) + groupedCosts?: Record; + // (undocumented) + id: string; + // (undocumented) + trendline?: Trendline; +} + +// @public (undocumented) +export const CostGrowth: ({ change, duration }: CostGrowthProps) => JSX.Element; + +// @public (undocumented) +export const CostGrowthIndicator: ({ change, formatter, className, ...props }: CostGrowthIndicatorProps) => JSX.Element; + +// @public (undocumented) +export type CostGrowthIndicatorProps = TypographyProps & { + change: ChangeStatistic; + formatter?: (change: ChangeStatistic) => Maybe; +}; + +// @public (undocumented) +export type CostGrowthProps = { + change: ChangeStatistic; + duration: Duration; +}; + +// @public (undocumented) +export type CostInsightsApi = { + getLastCompleteBillingDate(): Promise; + getUserGroups(userId: string): Promise; + getGroupProjects(group: string): Promise; + getGroupDailyCost(group: string, intervals: string): Promise; + getProjectDailyCost(project: string, intervals: string): Promise; + getDailyMetricData(metric: string, intervals: string): Promise; + getProductInsights(options: ProductInsightsOptions): Promise; + getAlerts(group: string): Promise; +}; + +// @public (undocumented) +export const costInsightsApiRef: ApiRef; + +// @public (undocumented) +export const CostInsightsLabelDataflowInstructionsPage: () => JSX.Element; + +// @public (undocumented) +export const CostInsightsPage: () => JSX.Element; + +// @public (undocumented) +export type CostInsightsPalette = BackstagePalette & CostInsightsPaletteAdditions; + +// @public (undocumented) +export type CostInsightsPaletteOptions = PaletteOptions & CostInsightsPaletteAdditions; + +// @public (undocumented) +const costInsightsPlugin: BackstagePlugin<{ + root: RouteRef; + growthAlerts: RouteRef; + unlabeledDataflowAlerts: RouteRef; +}, {}>; + +export { costInsightsPlugin } + +export { costInsightsPlugin as plugin } + +// @public (undocumented) +export const CostInsightsProjectGrowthInstructionsPage: () => JSX.Element; + +// @public (undocumented) +export interface CostInsightsTheme extends BackstageTheme { + // (undocumented) + palette: CostInsightsPalette; +} + +// @public (undocumented) +export interface CostInsightsThemeOptions extends PaletteOptions { + // (undocumented) + palette: CostInsightsPaletteOptions; +} + +// @public (undocumented) +export interface Currency { + // (undocumented) + kind: string | null; + // (undocumented) + label: string; + // (undocumented) + prefix?: string; + // (undocumented) + rate?: number; + // (undocumented) + unit: string; +} + +// @public (undocumented) +export enum CurrencyType { + // (undocumented) + Beers = "BEERS", + // (undocumented) + CarbonOffsetTons = "CARBON_OFFSET_TONS", + // (undocumented) + IceCream = "PINTS_OF_ICE_CREAM", + // (undocumented) + USD = "USD" +} + +// @public (undocumented) +export enum DataKey { + // (undocumented) + Current = "current", + // (undocumented) + Name = "name", + // (undocumented) + Previous = "previous" +} + +// @public (undocumented) +export type DateAggregation = { + date: string; + amount: number; +}; + +// @public (undocumented) +export const DEFAULT_DATE_FORMAT = "YYYY-MM-DD"; + +// @public +export enum Duration { + // (undocumented) + P30D = "P30D", + // (undocumented) + P3M = "P3M", + // (undocumented) + P7D = "P7D", + // (undocumented) + P90D = "P90D" +} + +// @public (undocumented) +export const EngineerThreshold = 0.5; + +// @public (undocumented) +export interface Entity { + // (undocumented) + aggregation: [number, number]; + // (undocumented) + change: ChangeStatistic; + // (undocumented) + entities: Record; + // (undocumented) + id: Maybe; +} + +// @public (undocumented) +export class ExampleCostInsightsClient implements CostInsightsApi { + // (undocumented) + getAlerts(group: string): Promise; + // (undocumented) + getDailyMetricData(metric: string, intervals: string): Promise; + // (undocumented) + getGroupDailyCost(group: string, intervals: string): Promise; + // (undocumented) + getGroupProjects(group: string): Promise; + // (undocumented) + getLastCompleteBillingDate(): Promise; + // (undocumented) + getProductInsights(options: ProductInsightsOptions): Promise; + // (undocumented) + getProjectDailyCost(project: string, intervals: string): Promise; + // (undocumented) + getUserGroups(userId: string): Promise; + } + +// @public (undocumented) +export type Group = { + id: string; +}; + +// @public (undocumented) +export enum GrowthType { + // (undocumented) + Excess = 2, + // (undocumented) + Negligible = 0, + // (undocumented) + Savings = 1 +} + +// @public (undocumented) +export type Icon = { + kind: string; + component: JSX.Element; +}; + +// @public (undocumented) +export enum IconType { + // (undocumented) + Compute = "compute", + // (undocumented) + Data = "data", + // (undocumented) + Database = "database", + // (undocumented) + ML = "ml", + // (undocumented) + Search = "search", + // (undocumented) + Storage = "storage" +} + +// @public (undocumented) +export const LegendItem: ({ title, tooltipText, markerColor, children, }: PropsWithChildren) => JSX.Element; + +// @public (undocumented) +export type LegendItemProps = { + title: string; + tooltipText?: string; + markerColor?: string; +}; + +// @public (undocumented) +export type Loading = Record; + +// @public (undocumented) +export type Maybe = T | null; + +// @public (undocumented) +export type Metric = { + kind: string; + name: string; + default: boolean; +}; + +// @public (undocumented) +export interface MetricData { + // (undocumented) + aggregation: DateAggregation[]; + // (undocumented) + change: ChangeStatistic; + // (undocumented) + format: 'number' | 'currency'; + // (undocumented) + id: string; +} + +// @public (undocumented) +export const MockConfigProvider: ({ children, ...context }: MockConfigProviderProps) => JSX.Element; + +// @public (undocumented) +export const MockCurrencyProvider: ({ children, ...context }: MockCurrencyProviderProps) => JSX.Element; + +// @public (undocumented) +export interface PageFilters { + // (undocumented) + duration: Duration; + // (undocumented) + group: Maybe; + // (undocumented) + metric: string | null; + // (undocumented) + project: Maybe; +} + +// @public (undocumented) +export interface Product { + // (undocumented) + kind: string; + // (undocumented) + name: string; +} + +// @public (undocumented) +export type ProductFilters = Array; + +// @public (undocumented) +export type ProductInsightsOptions = { + product: string; + group: string; + intervals: string; + project: Maybe; +}; + +// @public (undocumented) +export interface ProductPeriod { + // (undocumented) + duration: Duration; + // (undocumented) + productType: string; +} + +// @public (undocumented) +export interface Project { + // (undocumented) + id: string; + // (undocumented) + name?: string; +} + +// @public +export class ProjectGrowthAlert implements Alert { + constructor(data: ProjectGrowthData); + // (undocumented) + data: ProjectGrowthData; + // (undocumented) + get element(): JSX.Element; + // (undocumented) + get subtitle(): string; + // (undocumented) + get title(): string; + // (undocumented) + get url(): string; +} + +// @public (undocumented) +export interface ProjectGrowthData { + // (undocumented) + aggregation: [number, number]; + // (undocumented) + change: ChangeStatistic; + // (undocumented) + periodEnd: string; + // (undocumented) + periodStart: string; + // (undocumented) + products: Array; + // (undocumented) + project: string; +} + +// @public (undocumented) +export interface ResourceData { + // (undocumented) + current: number; + // (undocumented) + name: Maybe; + // (undocumented) + previous: number; +} + +// @public (undocumented) +export type TooltipItem = { + fill: string; + label: string; + value: string; +}; + +// @public (undocumented) +export type Trendline = { + slope: number; + intercept: number; +}; + +// @public +export class UnlabeledDataflowAlert implements Alert { + constructor(data: UnlabeledDataflowData); + // (undocumented) + data: UnlabeledDataflowData; + // (undocumented) + get element(): JSX.Element; + // (undocumented) + status?: AlertStatus; + // (undocumented) + get subtitle(): string; + // (undocumented) + get title(): string; + // (undocumented) + get url(): string; +} + +// @public (undocumented) +export interface UnlabeledDataflowAlertProject { + // (undocumented) + id: string; + // (undocumented) + labeledCost: number; + // (undocumented) + unlabeledCost: number; +} + +// @public (undocumented) +export interface UnlabeledDataflowData { + // (undocumented) + labeledCost: number; + // (undocumented) + periodEnd: string; + // (undocumented) + periodStart: string; + // (undocumented) + projects: Array; + // (undocumented) + unlabeledCost: number; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/explore-react/api-report.md b/plugins/explore-react/api-report.md new file mode 100644 index 0000000000..aaf272a06a --- /dev/null +++ b/plugins/explore-react/api-report.md @@ -0,0 +1,31 @@ +## API Report File for "@backstage/plugin-explore-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; + +// @public (undocumented) +export type ExploreTool = { + title: string; + description?: string; + url: string; + image: string; + tags?: string[]; + lifecycle?: string; +}; + +// @public (undocumented) +export interface ExploreToolsConfig { + // (undocumented) + getTools: () => Promise; +} + +// @public (undocumented) +export const exploreToolsConfigRef: ApiRef; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/explore/api-report.md b/plugins/explore/api-report.md new file mode 100644 index 0000000000..590af53de8 --- /dev/null +++ b/plugins/explore/api-report.md @@ -0,0 +1,38 @@ +## API Report File for "@backstage/plugin-explore" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BackstagePlugin } from '@backstage/core'; +import { ExternalRouteRef } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const catalogEntityRouteRef: ExternalRouteRef<{ + name: string; + kind: string; + namespace: string; +}, false>; + +// @public (undocumented) +export const ExplorePage: () => JSX.Element; + +// @public (undocumented) +export const explorePlugin: BackstagePlugin<{ + explore: RouteRef; +}, { + catalogEntity: ExternalRouteRef<{ + name: string; + kind: string; + namespace: string; + }, false>; +}>; + +// @public (undocumented) +export const exploreRouteRef: RouteRef; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/fossa/api-report.md b/plugins/fossa/api-report.md new file mode 100644 index 0000000000..edb2cfdd0a --- /dev/null +++ b/plugins/fossa/api-report.md @@ -0,0 +1,27 @@ +## API Report File for "@backstage/plugin-fossa" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BackstagePlugin } from '@backstage/core'; +import { InfoCardVariants } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const EntityFossaCard: ({ variant }: { + variant?: InfoCardVariants| undefined; +}) => JSX.Element; + +// @public (undocumented) +export const FossaPage: () => JSX.Element; + +// @public (undocumented) +export const fossaPlugin: BackstagePlugin<{ + fossaOverview: RouteRef; +}, {}>; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/gcp-projects/api-report.md b/plugins/gcp-projects/api-report.md new file mode 100644 index 0000000000..22812822c0 --- /dev/null +++ b/plugins/gcp-projects/api-report.md @@ -0,0 +1,86 @@ +## API Report File for "@backstage/plugin-gcp-projects" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { OAuthApi } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export type GcpApi = { + listProjects(): Promise; + getProject(projectId: string): Promise; + createProject(options: { + projectId: string; + projectName: string; + }): Promise; +}; + +// @public (undocumented) +export const gcpApiRef: ApiRef; + +// @public (undocumented) +export class GcpClient implements GcpApi { + constructor(googleAuthApi: OAuthApi); + // (undocumented) + createProject(options: { + projectId: string; + projectName: string; + }): Promise; + // (undocumented) + getProject(projectId: string): Promise; + // (undocumented) + getToken(): Promise; + // (undocumented) + listProjects(): Promise; +} + +// @public (undocumented) +export const GcpProjectsPage: () => JSX.Element; + +// @public (undocumented) +const gcpProjectsPlugin: BackstagePlugin<{ + root: RouteRef; +}, {}>; + +export { gcpProjectsPlugin } + +export { gcpProjectsPlugin as plugin } + +// @public (undocumented) +export type Operation = { + name: string; + metadata: string; + done: boolean; + error: Status; + response: string; +}; + +// @public (undocumented) +export type Project = { + name: string; + projectNumber?: string; + projectId: string; + lifecycleState?: string; + createTime?: string; +}; + +// @public (undocumented) +export type ProjectDetails = { + details: string; +}; + +// @public (undocumented) +export type Status = { + code: number; + message: string; + details: string[]; +}; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/git-release-manager/api-report.md b/plugins/git-release-manager/api-report.md new file mode 100644 index 0000000000..d898f83dd6 --- /dev/null +++ b/plugins/git-release-manager/api-report.md @@ -0,0 +1,25 @@ +## API Report File for "@backstage/plugin-git-release-manager" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const gitReleaseManagerApiRef: ApiRef; + +// @public (undocumented) +export const GitReleaseManagerPage: GitReleaseManager; + +// @public (undocumented) +export const gitReleaseManagerPlugin: BackstagePlugin<{ + root: RouteRef; +}, {}>; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/github-actions/api-report.md b/plugins/github-actions/api-report.md new file mode 100644 index 0000000000..1795bbdbea --- /dev/null +++ b/plugins/github-actions/api-report.md @@ -0,0 +1,213 @@ +## API Report File for "@backstage/plugin-github-actions" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { ConfigApi } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { InfoCardVariants } from '@backstage/core'; +import { OAuthApi } from '@backstage/core'; +import { RestEndpointMethodTypes } from '@octokit/rest'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export enum BuildStatus { + // (undocumented) + 'failure' = 1, + // (undocumented) + 'pending' = 2, + // (undocumented) + 'running' = 3, + // (undocumented) + 'success' = 0 +} + +// @public (undocumented) +export const EntityGithubActionsContent: (_props: { + entity?: Entity| undefined; +}) => JSX.Element; + +// @public (undocumented) +export const EntityLatestGithubActionRunCard: ({ branch, variant, }: { + entity?: Entity| undefined; + branch: string; + variant?: InfoCardVariants| undefined; +}) => JSX.Element; + +// @public (undocumented) +export const EntityLatestGithubActionsForBranchCard: ({ branch, variant, }: { + entity?: Entity| undefined; + branch: string; + variant?: InfoCardVariants| undefined; +}) => JSX.Element; + +// @public (undocumented) +export const EntityRecentGithubActionsRunsCard: ({ branch, dense, limit, variant, }: Props) => JSX.Element; + +// @public (undocumented) +export const GITHUB_ACTIONS_ANNOTATION = "github.com/project-slug"; + +// @public (undocumented) +export type GithubActionsApi = { + listWorkflowRuns: ({ hostname, owner, repo, pageSize, page, branch, }: { + hostname?: string; + owner: string; + repo: string; + pageSize?: number; + page?: number; + branch?: string; + }) => Promise; + getWorkflow: ({ hostname, owner, repo, id, }: { + hostname?: string; + owner: string; + repo: string; + id: number; + }) => Promise; + getWorkflowRun: ({ hostname, owner, repo, id, }: { + hostname?: string; + owner: string; + repo: string; + id: number; + }) => Promise; + reRunWorkflow: ({ hostname, owner, repo, runId, }: { + hostname?: string; + owner: string; + repo: string; + runId: number; + }) => Promise; + listJobsForWorkflowRun: ({ hostname, owner, repo, id, pageSize, page, }: { + hostname?: string; + owner: string; + repo: string; + id: number; + pageSize?: number; + page?: number; + }) => Promise; + downloadJobLogsForWorkflowRun: ({ hostname, owner, repo, runId, }: { + hostname?: string; + owner: string; + repo: string; + runId: number; + }) => Promise; +}; + +// @public (undocumented) +export const githubActionsApiRef: ApiRef; + +// @public (undocumented) +export class GithubActionsClient implements GithubActionsApi { + constructor(options: { + configApi: ConfigApi; + githubAuthApi: OAuthApi; + }); + // (undocumented) + downloadJobLogsForWorkflowRun({ hostname, owner, repo, runId, }: { + hostname?: string; + owner: string; + repo: string; + runId: number; + }): Promise; + // (undocumented) + getWorkflow({ hostname, owner, repo, id, }: { + hostname?: string; + owner: string; + repo: string; + id: number; + }): Promise; + // (undocumented) + getWorkflowRun({ hostname, owner, repo, id, }: { + hostname?: string; + owner: string; + repo: string; + id: number; + }): Promise; + // (undocumented) + listJobsForWorkflowRun({ hostname, owner, repo, id, pageSize, page, }: { + hostname?: string; + owner: string; + repo: string; + id: number; + pageSize?: number; + page?: number; + }): Promise; + // (undocumented) + listWorkflowRuns({ hostname, owner, repo, pageSize, page, branch, }: { + hostname?: string; + owner: string; + repo: string; + pageSize?: number; + page?: number; + branch?: string; + }): Promise; + // (undocumented) + reRunWorkflow({ hostname, owner, repo, runId, }: { + hostname?: string; + owner: string; + repo: string; + runId: number; + }): Promise; +} + +// @public (undocumented) +const githubActionsPlugin: BackstagePlugin<{ + entityContent: RouteRef; +}, {}>; + +export { githubActionsPlugin } + +export { githubActionsPlugin as plugin } + +// @public (undocumented) +const isGithubActionsAvailable: (entity: Entity) => boolean; + +export { isGithubActionsAvailable } + +export { isGithubActionsAvailable as isPluginApplicableToEntity } + +// @public (undocumented) +export type Job = { + html_url: string; + status: string; + conclusion: string; + started_at: string; + completed_at: string; + id: number; + name: string; + steps: Step[]; +}; + +// @public (undocumented) +export type Jobs = { + total_count: number; + jobs: Job[]; +}; + +// @public (undocumented) +export const LatestWorkflowRunCard: ({ branch, variant, }: Props_3) => JSX.Element; + +// @public (undocumented) +export const LatestWorkflowsForBranchCard: ({ branch, variant, }: Props_3) => JSX.Element; + +// @public (undocumented) +export const RecentWorkflowRunsCard: ({ branch, dense, limit, variant, }: Props) => JSX.Element; + +// @public (undocumented) +export const Router: (_props: Props_2) => JSX.Element; + +// @public (undocumented) +export type Step = { + name: string; + status: string; + conclusion?: string; + number: number; + started_at: string; + completed_at: string; +}; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/gitops-profiles/api-report.md b/plugins/gitops-profiles/api-report.md new file mode 100644 index 0000000000..aad08f2b43 --- /dev/null +++ b/plugins/gitops-profiles/api-report.md @@ -0,0 +1,197 @@ +## API Report File for "@backstage/plugin-gitops-profiles" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export interface ApplyProfileRequest { + // (undocumented) + gitHubToken: string; + // (undocumented) + gitHubUser: string; + // (undocumented) + profiles: string[]; + // (undocumented) + targetOrg: string; + // (undocumented) + targetRepo: string; +} + +// @public (undocumented) +export interface ChangeClusterStateRequest { + // (undocumented) + clusterState: 'present' | 'absent'; + // (undocumented) + gitHubToken: string; + // (undocumented) + gitHubUser: string; + // (undocumented) + targetOrg: string; + // (undocumented) + targetRepo: string; +} + +// @public (undocumented) +export interface CloneFromTemplateRequest { + // (undocumented) + gitHubToken: string; + // (undocumented) + gitHubUser: string; + // (undocumented) + secrets: { + awsAccessKeyId: string; + awsSecretAccessKey: string; + }; + // (undocumented) + targetOrg: string; + // (undocumented) + targetRepo: string; + // (undocumented) + templateRepository: string; +} + +// @public (undocumented) +export interface ClusterStatus { + // (undocumented) + conclusion: string; + // (undocumented) + link: string; + // (undocumented) + name: string; + // (undocumented) + runStatus: Status[]; + // (undocumented) + status: string; +} + +// @public (undocumented) +export class FetchError extends Error { + // (undocumented) + static forResponse(resp: Response): Promise; + // (undocumented) + get name(): string; +} + +// @public (undocumented) +export interface GithubUserInfoRequest { + // (undocumented) + accessToken: string; +} + +// @public (undocumented) +export interface GithubUserInfoResponse { + // (undocumented) + login: string; +} + +// @public (undocumented) +export type GitOpsApi = { + url: string; + fetchLog(req: PollLogRequest): Promise; + changeClusterState(req: ChangeClusterStateRequest): Promise; + cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise; + applyProfiles(req: ApplyProfileRequest): Promise; + listClusters(req: ListClusterRequest): Promise; + fetchUserInfo(req: GithubUserInfoRequest): Promise; +}; + +// @public (undocumented) +export const gitOpsApiRef: ApiRef; + +// @public (undocumented) +export const GitopsProfilesClusterListPage: () => JSX.Element; + +// @public (undocumented) +export const GitopsProfilesClusterPage: () => JSX.Element; + +// @public (undocumented) +export const GitopsProfilesCreatePage: () => JSX.Element; + +// @public (undocumented) +const gitopsProfilesPlugin: BackstagePlugin<{ + listPage: RouteRef; + detailsPage: RouteRef<{ + owner: string; + repo: string; + }>; + createPage: RouteRef; +}, {}>; + +export { gitopsProfilesPlugin } + +export { gitopsProfilesPlugin as plugin } + +// @public (undocumented) +export class GitOpsRestApi implements GitOpsApi { + constructor(url?: string); + // (undocumented) + applyProfiles(req: ApplyProfileRequest): Promise; + // (undocumented) + changeClusterState(req: ChangeClusterStateRequest): Promise; + // (undocumented) + cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise; + // (undocumented) + fetchLog(req: PollLogRequest): Promise; + // (undocumented) + fetchUserInfo(req: GithubUserInfoRequest): Promise; + // (undocumented) + listClusters(req: ListClusterRequest): Promise; + // (undocumented) + url: string; +} + +// @public (undocumented) +export interface ListClusterRequest { + // (undocumented) + gitHubToken: string; + // (undocumented) + gitHubUser: string; +} + +// @public (undocumented) +export interface ListClusterStatusesResponse { + // (undocumented) + result: ClusterStatus[]; +} + +// @public (undocumented) +export interface PollLogRequest { + // (undocumented) + gitHubToken: string; + // (undocumented) + gitHubUser: string; + // (undocumented) + targetOrg: string; + // (undocumented) + targetRepo: string; +} + +// @public (undocumented) +export interface Status { + // (undocumented) + conclusion: string; + // (undocumented) + message: string; + // (undocumented) + status: string; +} + +// @public (undocumented) +export interface StatusResponse { + // (undocumented) + link: string; + // (undocumented) + result: Status[]; + // (undocumented) + status: string; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/graphiql/api-report.md b/plugins/graphiql/api-report.md new file mode 100644 index 0000000000..7c73b6a985 --- /dev/null +++ b/plugins/graphiql/api-report.md @@ -0,0 +1,82 @@ +## API Report File for "@backstage/plugin-graphiql" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core-plugin-api'; +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { ErrorApi } from '@backstage/core-plugin-api'; +import { IconComponent } from '@backstage/core'; +import { OAuthApi } from '@backstage/core-plugin-api'; +import { RouteRef } from '@backstage/core-plugin-api'; + +// @public (undocumented) +export type EndpointConfig = { + id: string; + title: string; + url: string; + method?: 'POST'; + headers?: { + [name in string]: string; + }; +}; + +// @public (undocumented) +export type GithubEndpointConfig = { + id: string; + title: string; + url?: string; + errorApi?: ErrorApi; + githubAuthApi: OAuthApi; +}; + +// @public (undocumented) +export const GraphiQLIcon: IconComponent; + +// @public (undocumented) +export const GraphiQLPage: () => JSX.Element; + +// @public (undocumented) +const graphiqlPlugin: BackstagePlugin<{}, {}>; + +export { graphiqlPlugin } + +export { graphiqlPlugin as plugin } + +// @public (undocumented) +export const graphiQLRouteRef: RouteRef; + +// @public (undocumented) +export type GraphQLBrowseApi = { + getEndpoints(): Promise; +}; + +// @public (undocumented) +export const graphQlBrowseApiRef: ApiRef; + +// @public (undocumented) +export type GraphQLEndpoint = { + id: string; + title: string; + fetcher: (body: any) => Promise; +}; + +// @public (undocumented) +export class GraphQLEndpoints implements GraphQLBrowseApi { + // (undocumented) + static create(config: EndpointConfig): GraphQLEndpoint; + // (undocumented) + static from(endpoints: GraphQLEndpoint[]): GraphQLEndpoints; + // (undocumented) + getEndpoints(): Promise; + static github(config: GithubEndpointConfig): GraphQLEndpoint; +} + +// @public (undocumented) +export const Router: () => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/graphql/api-report.md b/plugins/graphql/api-report.md new file mode 100644 index 0000000000..8109c94122 --- /dev/null +++ b/plugins/graphql/api-report.md @@ -0,0 +1,25 @@ +## API Report File for "@backstage/plugin-graphql-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Config } from '@backstage/config'; +import express from 'express'; +import { Logger } from 'winston'; + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + config: Config; + // (undocumented) + logger: Logger; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/ilert/api-report.md b/plugins/ilert/api-report.md new file mode 100644 index 0000000000..f61b43b90b --- /dev/null +++ b/plugins/ilert/api-report.md @@ -0,0 +1,205 @@ +## API Report File for "@backstage/plugin-ilert" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { ConfigApi } from '@backstage/core'; +import { DiscoveryApi } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { IconComponent } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const EntityILertCard: () => JSX.Element; + +// @public (undocumented) +export type GetIncidentsCountOpts = { + states?: IncidentStatus[]; +}; + +// @public (undocumented) +export type GetIncidentsOpts = { + maxResults?: number; + startIndex?: number; + states?: IncidentStatus[]; + alertSources?: number[]; +}; + +// @public (undocumented) +export interface ILertApi { + // (undocumented) + acceptIncident(incident: Incident, userName: string): Promise; + // (undocumented) + addImmediateMaintenance(alertSourceId: number, minutes: number): Promise; + // (undocumented) + assignIncident(incident: Incident, responder: IncidentResponder): Promise; + // (undocumented) + createIncident(eventRequest: EventRequest): Promise; + // (undocumented) + disableAlertSource(alertSource: AlertSource): Promise; + // (undocumented) + enableAlertSource(alertSource: AlertSource): Promise; + // (undocumented) + fetchAlertSource(idOrIntegrationKey: number | string): Promise; + // (undocumented) + fetchAlertSourceOnCalls(alertSource: AlertSource): Promise; + // (undocumented) + fetchAlertSources(): Promise; + // (undocumented) + fetchIncident(id: number): Promise; + // (undocumented) + fetchIncidentActions(incident: Incident): Promise; + // (undocumented) + fetchIncidentResponders(incident: Incident): Promise; + // (undocumented) + fetchIncidents(opts?: GetIncidentsOpts): Promise; + // (undocumented) + fetchIncidentsCount(opts?: GetIncidentsCountOpts): Promise; + // (undocumented) + fetchOnCallSchedules(): Promise; + // (undocumented) + fetchUptimeMonitor(id: number): Promise; + // (undocumented) + fetchUptimeMonitors(): Promise; + // (undocumented) + fetchUsers(): Promise; + // (undocumented) + getAlertSourceDetailsURL(alertSource: AlertSource | null): string; + // (undocumented) + getEscalationPolicyDetailsURL(escalationPolicy: EscalationPolicy): string; + // (undocumented) + getIncidentDetailsURL(incident: Incident): string; + // (undocumented) + getScheduleDetailsURL(schedule: Schedule): string; + // (undocumented) + getUptimeMonitorDetailsURL(uptimeMonitor: UptimeMonitor): string; + // (undocumented) + getUserInitials(user: User | null): string; + // (undocumented) + getUserPhoneNumber(user: User | null): string; + // (undocumented) + overrideShift(scheduleId: number, userId: number, start: string, end: string): Promise; + // (undocumented) + pauseUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise; + // (undocumented) + resolveIncident(incident: Incident, userName: string): Promise; + // (undocumented) + resumeUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise; + // (undocumented) + triggerIncidentAction(incident: Incident, action: IncidentAction): Promise; +} + +// @public (undocumented) +export const ilertApiRef: ApiRef; + +// @public (undocumented) +export const ILertCard: () => JSX.Element; + +// @public (undocumented) +export class ILertClient implements ILertApi { + constructor(opts: Options); + // (undocumented) + acceptIncident(incident: Incident, userName: string): Promise; + // (undocumented) + addImmediateMaintenance(alertSourceId: number, minutes: number): Promise; + // (undocumented) + assignIncident(incident: Incident, responder: IncidentResponder): Promise; + // (undocumented) + createIncident(eventRequest: EventRequest): Promise; + // (undocumented) + disableAlertSource(alertSource: AlertSource): Promise; + // (undocumented) + enableAlertSource(alertSource: AlertSource): Promise; + // (undocumented) + fetchAlertSource(idOrIntegrationKey: number | string): Promise; + // (undocumented) + fetchAlertSourceOnCalls(alertSource: AlertSource): Promise; + // (undocumented) + fetchAlertSources(): Promise; + // (undocumented) + fetchIncident(id: number): Promise; + // (undocumented) + fetchIncidentActions(incident: Incident): Promise; + // (undocumented) + fetchIncidentResponders(incident: Incident): Promise; + // (undocumented) + fetchIncidents(opts?: GetIncidentsOpts): Promise; + // (undocumented) + fetchIncidentsCount(opts?: GetIncidentsCountOpts): Promise; + // (undocumented) + fetchOnCallSchedules(): Promise; + // (undocumented) + fetchUptimeMonitor(id: number): Promise; + // (undocumented) + fetchUptimeMonitors(): Promise; + // (undocumented) + fetchUsers(): Promise; + // (undocumented) + static fromConfig(configApi: ConfigApi, discoveryApi: DiscoveryApi): ILertClient; + // (undocumented) + getAlertSourceDetailsURL(alertSource: AlertSource | null): string; + // (undocumented) + getEscalationPolicyDetailsURL(escalationPolicy: EscalationPolicy): string; + // (undocumented) + getIncidentDetailsURL(incident: Incident): string; + // (undocumented) + getScheduleDetailsURL(schedule: Schedule): string; + // (undocumented) + getUptimeMonitorDetailsURL(uptimeMonitor: UptimeMonitor): string; + // (undocumented) + getUserInitials(user: User | null): string; + // (undocumented) + getUserPhoneNumber(user: User | null): string; + // (undocumented) + overrideShift(scheduleId: number, userId: number, start: string, end: string): Promise; + // (undocumented) + pauseUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise; + // (undocumented) + resolveIncident(incident: Incident, userName: string): Promise; + // (undocumented) + resumeUptimeMonitor(uptimeMonitor: UptimeMonitor): Promise; + // (undocumented) + triggerIncidentAction(incident: Incident, action: IncidentAction): Promise; +} + +// @public (undocumented) +export const ILertIcon: IconComponent; + +// @public (undocumented) +export const ILertPage: () => JSX.Element; + +// @public (undocumented) +const ilertPlugin: BackstagePlugin<{ + root: RouteRef; +}, {}>; + +export { ilertPlugin } + +export { ilertPlugin as plugin } + +// @public (undocumented) +export const iLertRouteRef: RouteRef; + +// @public (undocumented) +const isPluginApplicableToEntity: (entity: Entity) => boolean; + +export { isPluginApplicableToEntity as isILertAvailable } + +export { isPluginApplicableToEntity } + +// @public (undocumented) +export const Router: () => JSX.Element; + +// @public (undocumented) +export type TableState = { + page: number; + pageSize: number; +}; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/jenkins/api-report.md b/plugins/jenkins/api-report.md new file mode 100644 index 0000000000..4c88aab70c --- /dev/null +++ b/plugins/jenkins/api-report.md @@ -0,0 +1,83 @@ +## API Report File for "@backstage/plugin-jenkins" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { DiscoveryApi } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { InfoCardVariants } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const EntityJenkinsContent: (_props: { + entity?: Entity| undefined; +}) => JSX.Element; + +// @public (undocumented) +export const EntityLatestJenkinsRunCard: ({ branch, variant, }: { + branch: string; + variant?: InfoCardVariants| undefined; +}) => JSX.Element; + +// @public (undocumented) +const isJenkinsAvailable: (entity: Entity) => boolean; + +export { isJenkinsAvailable } + +export { isJenkinsAvailable as isPluginApplicableToEntity } + +// @public (undocumented) +export const JENKINS_ANNOTATION = "jenkins.io/github-folder"; + +// @public (undocumented) +export class JenkinsApi { + constructor(options: Options); + // (undocumented) + extractJobDetailsFromBuildName(buildName: string): { + jobName: string; + buildNumber: number; + }; + // (undocumented) + extractScmDetailsFromJob(jobDetails: any): any | undefined; + // (undocumented) + getBuild(buildName: string): Promise; + // (undocumented) + getFolder(folderName: string): Promise; + // (undocumented) + getJob(jobName: string): Promise; + // (undocumented) + getLastBuild(jobName: string): Promise; + // (undocumented) + mapJenkinsBuildToCITable(jenkinsResult: any, jobScmInfo?: any): CITableBuildInfo; + // (undocumented) + retry(buildName: string): Promise; +} + +// @public (undocumented) +export const jenkinsApiRef: ApiRef; + +// @public (undocumented) +const jenkinsPlugin: BackstagePlugin<{ + entityContent: RouteRef; +}, {}>; + +export { jenkinsPlugin } + +export { jenkinsPlugin as plugin } + +// @public (undocumented) +export const LatestRunCard: ({ branch, variant, }: { + branch: string; + variant?: InfoCardVariants | undefined; +}) => JSX.Element; + +// @public (undocumented) +export const Router: (_props: Props) => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/kafka-backend/api-report.md b/plugins/kafka-backend/api-report.md new file mode 100644 index 0000000000..47754ad572 --- /dev/null +++ b/plugins/kafka-backend/api-report.md @@ -0,0 +1,17 @@ +## API Report File for "@backstage/plugin-kafka-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Config } from '@backstage/config'; +import express from 'express'; +import { Logger } from 'winston'; + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/kafka/api-report.md b/plugins/kafka/api-report.md new file mode 100644 index 0000000000..acb213fd53 --- /dev/null +++ b/plugins/kafka/api-report.md @@ -0,0 +1,41 @@ +## API Report File for "@backstage/plugin-kafka" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BackstagePlugin } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const EntityKafkaContent: (_props: { + entity?: Entity| undefined; +}) => JSX.Element; + +// @public (undocumented) +const isPluginApplicableToEntity: (entity: Entity) => boolean; + +export { isPluginApplicableToEntity as isKafkaAvailable } + +export { isPluginApplicableToEntity } + +// @public (undocumented) +export const KAFKA_CONSUMER_GROUP_ANNOTATION = "kafka.apache.org/consumer-groups"; + +// @public (undocumented) +const kafkaPlugin: BackstagePlugin<{ + entityContent: RouteRef; +}, {}>; + +export { kafkaPlugin } + +export { kafkaPlugin as plugin } + +// @public (undocumented) +export const Router: (_props: Props) => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/kubernetes-backend/api-report.md b/plugins/kubernetes-backend/api-report.md new file mode 100644 index 0000000000..fe43e8888a --- /dev/null +++ b/plugins/kubernetes-backend/api-report.md @@ -0,0 +1,103 @@ +## API Report File for "@backstage/plugin-kubernetes-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Config } from '@backstage/config'; +import express from 'express'; +import { FetchResponse } from '@backstage/plugin-kubernetes-common'; +import { KubernetesFetchError } from '@backstage/plugin-kubernetes-common'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; +import { Logger } from 'winston'; + +// @public (undocumented) +export interface ClusterDetails { + // (undocumented) + authProvider: string; + // (undocumented) + name: string; + // (undocumented) + serviceAccountToken?: string | undefined; + // (undocumented) + skipTLSVerify?: boolean; + // (undocumented) + url: string; +} + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public (undocumented) +export interface CustomResource { + // (undocumented) + apiVersion: string; + // (undocumented) + group: string; + // (undocumented) + plural: string; +} + +// @public (undocumented) +export interface FetchResponseWrapper { + // (undocumented) + errors: KubernetesFetchError[]; + // (undocumented) + responses: FetchResponse[]; +} + +// @public (undocumented) +export interface KubernetesClustersSupplier { + // (undocumented) + getClusters(): Promise; +} + +// @public (undocumented) +export interface KubernetesFetcher { + // (undocumented) + fetchObjectsForService(params: ObjectFetchParams): Promise; +} + +// @public (undocumented) +export type KubernetesObjectTypes = 'pods' | 'services' | 'configmaps' | 'deployments' | 'replicasets' | 'horizontalpodautoscalers' | 'ingresses' | 'customresources'; + +// @public (undocumented) +export interface KubernetesServiceLocator { + // (undocumented) + getClustersByServiceId(serviceId: string): Promise; +} + +// @public (undocumented) +export const makeRouter: (logger: Logger, kubernetesFanOutHandler: KubernetesFanOutHandler, clusterDetails: ClusterDetails[]) => express.Router; + +// @public (undocumented) +export interface ObjectFetchParams { + // (undocumented) + clusterDetails: ClusterDetails; + // (undocumented) + customResources: CustomResource[]; + // (undocumented) + labelSelector: string; + // (undocumented) + objectTypesToFetch: Set; + // (undocumented) + serviceId: string; +} + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + clusterSupplier?: KubernetesClustersSupplier; + // (undocumented) + config: Config; + // (undocumented) + logger: Logger; +} + +// @public (undocumented) +export type ServiceLocatorMethod = 'multiTenant' | 'http'; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/kubernetes-common/api-report.md b/plugins/kubernetes-common/api-report.md new file mode 100644 index 0000000000..a8abec517e --- /dev/null +++ b/plugins/kubernetes-common/api-report.md @@ -0,0 +1,130 @@ +## API Report File for "@backstage/plugin-kubernetes-common" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Entity } from '@backstage/catalog-model'; +import { ExtensionsV1beta1Ingress } from '@kubernetes/client-node'; +import { V1ConfigMap } from '@kubernetes/client-node'; +import { V1Deployment } from '@kubernetes/client-node'; +import { V1HorizontalPodAutoscaler } from '@kubernetes/client-node'; +import { V1Pod } from '@kubernetes/client-node'; +import { V1ReplicaSet } from '@kubernetes/client-node'; +import { V1Service } from '@kubernetes/client-node'; + +// @public (undocumented) +export type AuthProviderType = 'google' | 'serviceAccount' | 'aws'; + +// @public (undocumented) +export interface ClusterObjects { + // (undocumented) + cluster: { + name: string; + }; + // (undocumented) + errors: KubernetesFetchError[]; + // (undocumented) + resources: FetchResponse[]; +} + +// @public (undocumented) +export interface ConfigMapFetchResponse { + // (undocumented) + resources: Array; + // (undocumented) + type: 'configmaps'; +} + +// @public (undocumented) +export interface CustomResourceFetchResponse { + // (undocumented) + resources: Array; + // (undocumented) + type: 'customresources'; +} + +// @public (undocumented) +export interface DeploymentFetchResponse { + // (undocumented) + resources: Array; + // (undocumented) + type: 'deployments'; +} + +// @public (undocumented) +export type FetchResponse = PodFetchResponse | ServiceFetchResponse | ConfigMapFetchResponse | DeploymentFetchResponse | ReplicaSetsFetchResponse | HorizontalPodAutoscalersFetchResponse | IngressesFetchResponse | CustomResourceFetchResponse; + +// @public (undocumented) +export interface HorizontalPodAutoscalersFetchResponse { + // (undocumented) + resources: Array; + // (undocumented) + type: 'horizontalpodautoscalers'; +} + +// @public (undocumented) +export interface IngressesFetchResponse { + // (undocumented) + resources: Array; + // (undocumented) + type: 'ingresses'; +} + +// @public (undocumented) +export type KubernetesErrorTypes = 'BAD_REQUEST' | 'UNAUTHORIZED_ERROR' | 'SYSTEM_ERROR' | 'UNKNOWN_ERROR'; + +// @public (undocumented) +export interface KubernetesFetchError { + // (undocumented) + errorType: KubernetesErrorTypes; + // (undocumented) + resourcePath?: string; + // (undocumented) + statusCode?: number; +} + +// @public (undocumented) +export interface KubernetesRequestBody { + // (undocumented) + auth?: { + google?: string; + }; + // (undocumented) + entity: Entity; +} + +// @public (undocumented) +export interface ObjectsByEntityResponse { + // (undocumented) + items: ClusterObjects[]; +} + +// @public (undocumented) +export interface PodFetchResponse { + // (undocumented) + resources: Array; + // (undocumented) + type: 'pods'; +} + +// @public (undocumented) +export interface ReplicaSetsFetchResponse { + // (undocumented) + resources: Array; + // (undocumented) + type: 'replicasets'; +} + +// @public (undocumented) +export interface ServiceFetchResponse { + // (undocumented) + resources: Array; + // (undocumented) + type: 'services'; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/kubernetes/api-report.md b/plugins/kubernetes/api-report.md new file mode 100644 index 0000000000..2f1b5155fd --- /dev/null +++ b/plugins/kubernetes/api-report.md @@ -0,0 +1,46 @@ +## API Report File for "@backstage/plugin-kubernetes" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { KubernetesRequestBody } from '@backstage/plugin-kubernetes-common'; +import { OAuthApi } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const EntityKubernetesContent: (_props: { + entity?: Entity| undefined; +}) => JSX.Element; + +// @public (undocumented) +export class KubernetesAuthProviders implements KubernetesAuthProvidersApi { + constructor(options: { + googleAuthApi: OAuthApi; + }); + // (undocumented) + decorateRequestBodyForAuth(authProvider: string, requestBody: KubernetesRequestBody): Promise; + } + +// @public (undocumented) +export const kubernetesAuthProvidersApiRef: ApiRef; + +// @public (undocumented) +const kubernetesPlugin: BackstagePlugin<{ + entityContent: RouteRef; +}, {}>; + +export { kubernetesPlugin } + +export { kubernetesPlugin as plugin } + +// @public (undocumented) +export const Router: (_props: Props) => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/lighthouse/api-report.md b/plugins/lighthouse/api-report.md new file mode 100644 index 0000000000..18d8358257 --- /dev/null +++ b/plugins/lighthouse/api-report.md @@ -0,0 +1,186 @@ +## API Report File for "@backstage/plugin-lighthouse" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { Config } from '@backstage/config'; +import { Entity } from '@backstage/catalog-model'; +import { InfoCardVariants } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export type Audit = AuditRunning | AuditFailed | AuditCompleted; + +// @public (undocumented) +export interface AuditCompleted extends AuditBase { + // (undocumented) + categories: Record; + // (undocumented) + report: Object; + // (undocumented) + status: 'COMPLETED'; + // (undocumented) + timeCompleted: string; +} + +// @public (undocumented) +export interface AuditFailed extends AuditBase { + // (undocumented) + status: 'FAILED'; + // (undocumented) + timeCompleted: string; +} + +// @public (undocumented) +export interface AuditRunning extends AuditBase { + // (undocumented) + status: 'RUNNING'; +} + +// @public (undocumented) +export const EmbeddedRouter: (_props: Props) => JSX.Element; + +// @public (undocumented) +export const EntityLastLighthouseAuditCard: ({ dense, variant, }: { + dense?: boolean | undefined; + variant?: InfoCardVariants| undefined; +}) => JSX.Element; + +// @public (undocumented) +export const EntityLighthouseContent: (_props: { + entity?: Entity| undefined; +}) => JSX.Element; + +// @public (undocumented) +export class FetchError extends Error { + // (undocumented) + static forResponse(resp: Response): Promise; + // (undocumented) + get name(): string; +} + +// @public (undocumented) +const isLighthouseAvailable: (entity: Entity) => boolean; + +export { isLighthouseAvailable } + +export { isLighthouseAvailable as isPluginApplicableToEntity } + +// @public (undocumented) +export interface LASListRequest { + // (undocumented) + limit?: number; + // (undocumented) + offset?: number; +} + +// @public (undocumented) +export interface LASListResponse { + // (undocumented) + items: Item[]; + // (undocumented) + limit: number; + // (undocumented) + offset: number; + // (undocumented) + total: number; +} + +// @public (undocumented) +export const LastLighthouseAuditCard: ({ dense, variant, }: { + dense?: boolean | undefined; + variant?: InfoCardVariants | undefined; +}) => JSX.Element; + +// @public (undocumented) +export type LighthouseApi = { + url: string; + getWebsiteList: (listOptions: LASListRequest) => Promise; + getWebsiteForAuditId: (auditId: string) => Promise; + triggerAudit: (payload: TriggerAuditPayload) => Promise; + getWebsiteByUrl: (websiteUrl: string) => Promise; +}; + +// @public (undocumented) +export const lighthouseApiRef: ApiRef; + +// @public (undocumented) +export interface LighthouseCategoryAbbr { + // (undocumented) + id: LighthouseCategoryId; + // (undocumented) + score: number; + // (undocumented) + title: string; +} + +// @public (undocumented) +export type LighthouseCategoryId = 'pwa' | 'seo' | 'performance' | 'accessibility' | 'best-practices'; + +// @public (undocumented) +export const LighthousePage: () => JSX.Element; + +// @public (undocumented) +const lighthousePlugin: BackstagePlugin<{ + root: RouteRef; + entityContent: RouteRef; +}, {}>; + +export { lighthousePlugin } + +export { lighthousePlugin as plugin } + +// @public (undocumented) +export class LighthouseRestApi implements LighthouseApi { + constructor(url: string); + // (undocumented) + static fromConfig(config: Config): LighthouseRestApi; + // (undocumented) + getWebsiteByUrl(websiteUrl: string): Promise; + // (undocumented) + getWebsiteForAuditId(auditId: string): Promise; + // (undocumented) + getWebsiteList({ limit, offset, }?: LASListRequest): Promise; + // (undocumented) + triggerAudit(payload: TriggerAuditPayload): Promise; + // (undocumented) + url: string; +} + +// @public (undocumented) +export const Router: () => JSX.Element; + +// @public (undocumented) +export interface TriggerAuditPayload { + // (undocumented) + options: { + lighthouseConfig: { + settings: { + emulatedFormFactor: string; + }; + }; + }; + // (undocumented) + url: string; +} + +// @public (undocumented) +export interface Website { + // (undocumented) + audits: Audit[]; + // (undocumented) + lastAudit: Audit; + // (undocumented) + url: string; +} + +// @public (undocumented) +export type WebsiteListResponse = LASListResponse; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/newrelic/api-report.md b/plugins/newrelic/api-report.md new file mode 100644 index 0000000000..e472405e22 --- /dev/null +++ b/plugins/newrelic/api-report.md @@ -0,0 +1,25 @@ +## API Report File for "@backstage/plugin-newrelic" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BackstagePlugin } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const NewRelicPage: () => JSX.Element; + +// @public (undocumented) +const newRelicPlugin: BackstagePlugin<{ + root: RouteRef; +}, {}>; + +export { newRelicPlugin } + +export { newRelicPlugin as plugin } + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/org/api-report.md b/plugins/org/api-report.md new file mode 100644 index 0000000000..cb3fa445f0 --- /dev/null +++ b/plugins/org/api-report.md @@ -0,0 +1,69 @@ +## API Report File for "@backstage/plugin-org" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BackstagePlugin } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { GroupEntity } from '@backstage/catalog-model'; +import { InfoCardVariants } from '@backstage/core'; +import { UserEntity } from '@backstage/catalog-model'; + +// @public (undocumented) +export const EntityGroupProfileCard: ({ variant, }: { + entity?: GroupEntity| undefined; + variant?: InfoCardVariants| undefined; +}) => JSX.Element; + +// @public (undocumented) +export const EntityMembersListCard: (_props: { + entity?: GroupEntity| undefined; +}) => JSX.Element; + +// @public (undocumented) +export const EntityOwnershipCard: ({ variant, }: { + entity?: Entity| undefined; + variant?: InfoCardVariants| undefined; +}) => JSX.Element; + +// @public (undocumented) +export const EntityUserProfileCard: ({ variant, }: { + entity?: UserEntity| undefined; + variant?: InfoCardVariants| undefined; +}) => JSX.Element; + +// @public (undocumented) +export const GroupProfileCard: ({ variant, }: { + entity?: GroupEntity | undefined; + variant?: InfoCardVariants | undefined; +}) => JSX.Element; + +// @public (undocumented) +export const MembersListCard: (_props: { + entity?: GroupEntity; +}) => JSX.Element; + +// @public (undocumented) +const orgPlugin: BackstagePlugin<{}, {}>; + +export { orgPlugin } + +export { orgPlugin as plugin } + +// @public (undocumented) +export const OwnershipCard: ({ variant, }: { + entity?: Entity | undefined; + variant?: InfoCardVariants | undefined; +}) => JSX.Element; + +// @public (undocumented) +export const UserProfileCard: ({ variant, }: { + entity?: UserEntity | undefined; + variant?: InfoCardVariants | undefined; +}) => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/pagerduty/api-report.md b/plugins/pagerduty/api-report.md new file mode 100644 index 0000000000..b3457ccb12 --- /dev/null +++ b/plugins/pagerduty/api-report.md @@ -0,0 +1,62 @@ +## API Report File for "@backstage/plugin-pagerduty" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { ConfigApi } from '@backstage/core'; +import { DiscoveryApi } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { PropsWithChildren } from 'react'; + +// @public (undocumented) +export const EntityPagerDutyCard: () => JSX.Element; + +// @public (undocumented) +const isPluginApplicableToEntity: (entity: Entity) => boolean; + +export { isPluginApplicableToEntity as isPagerDutyAvailable } + +export { isPluginApplicableToEntity } + +// @public (undocumented) +export const pagerDutyApiRef: ApiRef; + +// @public (undocumented) +export const PagerDutyCard: () => JSX.Element; + +// @public (undocumented) +export class PagerDutyClient implements PagerDutyApi { + constructor(config: ClientApiConfig); + // (undocumented) + static fromConfig(configApi: ConfigApi, discoveryApi: DiscoveryApi): PagerDutyClient; + // (undocumented) + getIncidentsByServiceId(serviceId: string): Promise; + // (undocumented) + getOnCallByPolicyId(policyId: string): Promise; + // (undocumented) + getServiceByIntegrationKey(integrationKey: string): Promise; + // (undocumented) + triggerAlarm({ integrationKey, source, description, userName, }: TriggerAlarmRequest): Promise; +} + +// @public (undocumented) +const pagerDutyPlugin: BackstagePlugin<{}, {}>; + +export { pagerDutyPlugin } + +export { pagerDutyPlugin as plugin } + +// @public (undocumented) +export function TriggerButton({ children, }: PropsWithChildren): JSX.Element; + +// @public (undocumented) +export class UnauthorizedError extends Error { +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/proxy-backend/api-report.md b/plugins/proxy-backend/api-report.md new file mode 100644 index 0000000000..1b1eba3b00 --- /dev/null +++ b/plugins/proxy-backend/api-report.md @@ -0,0 +1,18 @@ +## API Report File for "@backstage/plugin-proxy-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Config } from '@backstage/config'; +import express from 'express'; +import { Logger } from 'winston'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/register-component/api-report.md b/plugins/register-component/api-report.md new file mode 100644 index 0000000000..401031d0ac --- /dev/null +++ b/plugins/register-component/api-report.md @@ -0,0 +1,32 @@ +## API Report File for "@backstage/plugin-register-component" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BackstagePlugin } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const RegisterComponentPage: ({ catalogRouteRef, }: { + catalogRouteRef: RouteRef; +}) => JSX.Element; + +// @public (undocumented) +const registerComponentPlugin: BackstagePlugin<{ + root: RouteRef; +}, {}>; + +export { registerComponentPlugin as plugin } + +export { registerComponentPlugin } + +// @public @deprecated +export const Router: ({ catalogRouteRef }: { + catalogRouteRef: RouteRef; +}) => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/rollbar-backend/api-report.md b/plugins/rollbar-backend/api-report.md new file mode 100644 index 0000000000..fa8c67d4f0 --- /dev/null +++ b/plugins/rollbar-backend/api-report.md @@ -0,0 +1,60 @@ +## API Report File for "@backstage/plugin-rollbar-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Config } from '@backstage/config'; +import express from 'express'; +import { Logger } from 'winston'; + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public (undocumented) +export function getRequestHeaders(token: string): { + headers: { + 'X-Rollbar-Access-Token': string; + }; +}; + +// @public (undocumented) +export class RollbarApi { + constructor(accessToken: string, logger: Logger); + // (undocumented) + getActivatedCounts(projectName: string, options?: { + environment: string; + item_id?: number; + }): Promise; + // (undocumented) + getAllProjects(): Promise; + // (undocumented) + getOccuranceCounts(projectName: string, options?: { + environment: string; + item_id?: number; + }): Promise; + // (undocumented) + getProject(projectName: string): Promise; + // (undocumented) + getProjectItems(projectName: string): Promise; + // (undocumented) + getTopActiveItems(projectName: string, options?: { + hours: number; + environment: string; + }): Promise; + } + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + config: Config; + // (undocumented) + logger: Logger; + // (undocumented) + rollbarApi?: RollbarApi; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/rollbar/api-report.md b/plugins/rollbar/api-report.md new file mode 100644 index 0000000000..7049414389 --- /dev/null +++ b/plugins/rollbar/api-report.md @@ -0,0 +1,78 @@ +## API Report File for "@backstage/plugin-rollbar" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { DiscoveryApi } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { IdentityApi } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const EntityPageRollbar: (_props: Props) => JSX.Element; + +// @public (undocumented) +export const EntityRollbarContent: (_props: { + entity?: Entity| undefined; +}) => JSX.Element; + +// @public (undocumented) +const isPluginApplicableToEntity: (entity: Entity) => boolean; + +export { isPluginApplicableToEntity } + +export { isPluginApplicableToEntity as isRollbarAvailable } + +// @public (undocumented) +export const ROLLBAR_ANNOTATION = "rollbar.com/project-slug"; + +// @public (undocumented) +export interface RollbarApi { + // (undocumented) + getAllProjects(): Promise; + // (undocumented) + getProject(projectName: string): Promise; + // (undocumented) + getProjectItems(project: string): Promise; + // (undocumented) + getTopActiveItems(project: string, hours?: number): Promise; +} + +// @public (undocumented) +export const rollbarApiRef: ApiRef; + +// @public (undocumented) +export class RollbarClient implements RollbarApi { + constructor(options: { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }); + // (undocumented) + getAllProjects(): Promise; + // (undocumented) + getProject(projectName: string): Promise; + // (undocumented) + getProjectItems(project: string): Promise; + // (undocumented) + getTopActiveItems(project: string, hours?: number, environment?: string): Promise; + } + +// @public (undocumented) +const rollbarPlugin: BackstagePlugin<{ + entityContent: RouteRef; +}, {}>; + +export { rollbarPlugin as plugin } + +export { rollbarPlugin } + +// @public (undocumented) +export const Router: (_props: Props_2) => JSX.Element; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/scaffolder-backend/api-report.md b/plugins/scaffolder-backend/api-report.md new file mode 100644 index 0000000000..69c54343e3 --- /dev/null +++ b/plugins/scaffolder-backend/api-report.md @@ -0,0 +1,518 @@ +## API Report File for "@backstage/plugin-scaffolder-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { AzureIntegrationConfig } from '@backstage/integration'; +import { BitbucketIntegrationConfig } from '@backstage/integration'; +import { CatalogApi } from '@backstage/catalog-client'; +import { Config } from '@backstage/config'; +import { ContainerRunner } from '@backstage/backend-common'; +import { createPullRequest } from 'octokit-plugin-create-pull-request'; +import express from 'express'; +import { GithubCredentialsProvider } from '@backstage/integration'; +import { GitHubIntegrationConfig } from '@backstage/integration'; +import { Gitlab } from '@gitbeaker/core'; +import { GitLabIntegrationConfig } from '@backstage/integration'; +import gitUrlParse from 'git-url-parse'; +import { JsonObject } from '@backstage/config'; +import { JsonValue } from '@backstage/config'; +import { Logger } from 'winston'; +import { PluginDatabaseManager } from '@backstage/backend-common'; +import { Schema } from 'jsonschema'; +import { ScmIntegrationRegistry } from '@backstage/integration'; +import { ScmIntegrations } from '@backstage/integration'; +import { TemplateEntityV1alpha1 } from '@backstage/catalog-model'; +import { TemplateEntityV1beta2 } from '@backstage/catalog-model'; +import { UrlReader } from '@backstage/backend-common'; +import { Writable } from 'stream'; + +// @public (undocumented) +export type ActionContext = { + baseUrl?: string; + logger: Logger; + logStream: Writable; + token?: string | undefined; + workspacePath: string; + input: Input; + output(name: string, value: JsonValue): void; + createTemporaryDirectory(): Promise; +}; + +// @public (undocumented) +export class AzurePreparer implements PreparerBase { + constructor(config: { + token?: string; + }); + // (undocumented) + static fromConfig(config: AzureIntegrationConfig): AzurePreparer; + // (undocumented) + prepare({ url, workspacePath, logger }: PreparerOptions): Promise; +} + +// @public (undocumented) +export class AzurePublisher implements PublisherBase { + constructor(config: { + token: string; + }); + // (undocumented) + static fromConfig(config: AzureIntegrationConfig): Promise; + // (undocumented) + publish({ values, workspacePath, logger, }: PublisherOptions): Promise; +} + +// @public (undocumented) +export class BitbucketPreparer implements PreparerBase { + constructor(config: { + username?: string; + token?: string; + appPassword?: string; + }); + // (undocumented) + static fromConfig(config: BitbucketIntegrationConfig): BitbucketPreparer; + // (undocumented) + prepare({ url, workspacePath, logger }: PreparerOptions): Promise; +} + +// @public (undocumented) +export class BitbucketPublisher implements PublisherBase { + constructor(config: { + host: string; + token?: string; + appPassword?: string; + username?: string; + apiBaseUrl?: string; + repoVisibility: RepoVisibilityOptions_2; + }); + // (undocumented) + static fromConfig(config: BitbucketIntegrationConfig, { repoVisibility }: { + repoVisibility: RepoVisibilityOptions_2; + }): Promise; + // (undocumented) + publish({ values, workspacePath, logger, }: PublisherOptions): Promise; +} + +// @public +export class CatalogEntityClient { + constructor(catalogClient: CatalogApi); + findTemplate(templateName: string, options?: { + token?: string; + }): Promise; +} + +// @public (undocumented) +export class CookieCutter implements TemplaterBase { + constructor({ containerRunner }: { + containerRunner: ContainerRunner; + }); + // (undocumented) + run({ workspacePath, values, logStream, }: TemplaterRunOptions): Promise; +} + +// @public (undocumented) +export const createBuiltinActions: (options: { + reader: UrlReader; + integrations: ScmIntegrations; + catalogClient: CatalogApi; + templaters: TemplaterBuilder; +}) => TemplateAction[]; + +// @public (undocumented) +export function createCatalogRegisterAction(options: { + catalogClient: CatalogApi; + integrations: ScmIntegrations; +}): TemplateAction; + +// @public +export function createDebugLogAction(): TemplateAction; + +// @public (undocumented) +export function createFetchCookiecutterAction(options: { + reader: UrlReader; + integrations: ScmIntegrations; + templaters: TemplaterBuilder; +}): TemplateAction; + +// @public (undocumented) +export function createFetchPlainAction(options: { + reader: UrlReader; + integrations: ScmIntegrations; +}): TemplateAction; + +// @public (undocumented) +export function createLegacyActions(options: Options): TemplateAction[]; + +// @public (undocumented) +export function createPublishAzureAction(options: { + integrations: ScmIntegrationRegistry; +}): TemplateAction; + +// @public (undocumented) +export function createPublishBitbucketAction(options: { + integrations: ScmIntegrationRegistry; +}): TemplateAction; + +// @public +export function createPublishFileAction(): TemplateAction; + +// @public (undocumented) +export function createPublishGithubAction(options: { + integrations: ScmIntegrationRegistry; +}): TemplateAction; + +// @public (undocumented) +export const createPublishGithubPullRequestAction: ({ integrations, clientFactory, }: CreateGithubPullRequestActionOptions) => TemplateAction; + +// @public (undocumented) +export function createPublishGitlabAction(options: { + integrations: ScmIntegrationRegistry; +}): TemplateAction; + +// @public (undocumented) +export class CreateReactAppTemplater implements TemplaterBase { + constructor({ containerRunner }: { + containerRunner: ContainerRunner; + }); + // (undocumented) + run({ workspacePath, values, logStream, }: TemplaterRunOptions): Promise; +} + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public (undocumented) +export const createTemplateAction: | undefined; +}>>(templateAction: TemplateAction) => TemplateAction; + +// @public (undocumented) +export class FilePreparer implements PreparerBase { + // (undocumented) + prepare({ url, workspacePath }: PreparerOptions): Promise; +} + +// @public +export const getTemplaterKey: (entity: TemplateEntityV1alpha1) => string; + +// @public (undocumented) +export class GithubPreparer implements PreparerBase { + constructor(config: { + credentialsProvider: GithubCredentialsProvider; + }); + // (undocumented) + static fromConfig(config: GitHubIntegrationConfig): GithubPreparer; + // (undocumented) + prepare({ url, workspacePath, logger }: PreparerOptions): Promise; +} + +// @public @deprecated (undocumented) +export class GithubPublisher implements PublisherBase { + constructor(config: { + credentialsProvider: GithubCredentialsProvider; + repoVisibility: RepoVisibilityOptions; + apiBaseUrl: string | undefined; + }); + // (undocumented) + static fromConfig(config: GitHubIntegrationConfig, { repoVisibility }: { + repoVisibility: RepoVisibilityOptions; + }): Promise; + // (undocumented) + publish({ values, workspacePath, logger, }: PublisherOptions): Promise; +} + +// @public (undocumented) +export class GitlabPreparer implements PreparerBase { + constructor(config: { + token?: string; + }); + // (undocumented) + static fromConfig(config: GitLabIntegrationConfig): GitlabPreparer; + // (undocumented) + prepare({ url, workspacePath, logger }: PreparerOptions): Promise; +} + +// @public (undocumented) +export class GitlabPublisher implements PublisherBase { + constructor(config: { + token: string; + client: Gitlab; + repoVisibility: RepoVisibilityOptions_3; + }); + // (undocumented) + static fromConfig(config: GitLabIntegrationConfig, { repoVisibility }: { + repoVisibility: RepoVisibilityOptions_3; + }): Promise; + // (undocumented) + publish({ values, workspacePath, logger, }: PublisherOptions): Promise; +} + +// @public (undocumented) +export type Job = { + id: string; + context: StageContext; + status: ProcessorStatus; + stages: StageResult[]; + error?: Error; +}; + +// @public (undocumented) +export type JobAndDirectoryTuple = { + job: Job; + directory: string; +}; + +// @public (undocumented) +export class JobProcessor implements Processor { + constructor(workingDirectory: string); + // (undocumented) + create({ entity, values, stages, }: { + entity: TemplateEntityV1alpha1; + values: TemplaterValues; + stages: StageInput[]; + }): Job; + // (undocumented) + static fromConfig({ config, logger, }: { + config: Config; + logger: Logger; + }): Promise; + // (undocumented) + get(id: string): Job | undefined; + // (undocumented) + run(job: Job): Promise; + } + +// @public (undocumented) +export function joinGitUrlPath(repoUrl: string, path?: string): string; + +// @public (undocumented) +export type ParsedLocationAnnotation = { + protocol: 'file' | 'url'; + location: string; +}; + +// @public (undocumented) +export const parseLocationAnnotation: (entity: TemplateEntityV1alpha1) => ParsedLocationAnnotation; + +// @public (undocumented) +export interface PreparerBase { + prepare(opts: PreparerOptions): Promise; +} + +// @public (undocumented) +export type PreparerBuilder = { + register(host: string, preparer: PreparerBase): void; + get(url: string): PreparerBase; +}; + +// @public (undocumented) +export type PreparerOptions = { + url: string; + workspacePath: string; + logger: Logger; +}; + +// @public (undocumented) +export class Preparers implements PreparerBuilder { + // (undocumented) + static fromConfig(config: Config, _: { + logger: Logger; + }): Promise; + // (undocumented) + get(url: string): PreparerBase; + // (undocumented) + register(host: string, preparer: PreparerBase): void; +} + +// @public (undocumented) +export type Processor = { + create({ entity, values, stages, }: { + entity: TemplateEntityV1alpha1; + values: TemplaterValues; + stages: StageInput[]; + }): Job; + get(id: string): Job | undefined; + run(job: Job): Promise; +}; + +// @public (undocumented) +export type ProcessorStatus = 'PENDING' | 'STARTED' | 'COMPLETED' | 'FAILED'; + +// @public +export type PublisherBase = { + publish(opts: PublisherOptions): Promise; +}; + +// @public (undocumented) +export type PublisherBuilder = { + register(host: string, publisher: PublisherBase): void; + get(storePath: string): PublisherBase; +}; + +// @public (undocumented) +export type PublisherOptions = { + values: TemplaterValues; + workspacePath: string; + logger: Logger; +}; + +// @public (undocumented) +export type PublisherResult = { + remoteUrl: string; + catalogInfoUrl?: string; +}; + +// @public (undocumented) +export class Publishers implements PublisherBuilder { + // (undocumented) + static fromConfig(config: Config, _options: { + logger: Logger; + }): Promise; + // (undocumented) + get(url: string): PublisherBase; + // (undocumented) + register(host: string, preparer: PublisherBase | undefined): void; +} + +// @public (undocumented) +export type RepoVisibilityOptions = 'private' | 'internal' | 'public'; + +// @public +export type RequiredTemplateValues = { + owner: string; + storePath: string; + destination?: { + git?: gitUrlParse.GitUrl; + }; +}; + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + actions?: TemplateAction[]; + // (undocumented) + catalogClient: CatalogApi; + // (undocumented) + config: Config; + // (undocumented) + database: PluginDatabaseManager; + // (undocumented) + logger: Logger; + // (undocumented) + preparers: PreparerBuilder; + // (undocumented) + publishers: PublisherBuilder; + // (undocumented) + reader: UrlReader; + // (undocumented) + taskWorkers?: number; + // (undocumented) + templaters: TemplaterBuilder; +} + +// @public (undocumented) +export const runCommand: ({ command, args, logStream, }: RunCommandOptions) => Promise; + +// @public (undocumented) +export type RunCommandOptions = { + command: string; + args: string[]; + logStream?: Writable; +}; + +// @public (undocumented) +export type StageContext = { + values: TemplaterValues; + entity: TemplateEntityV1alpha1; + logger: Logger; + logStream: Writable; + workspacePath: string; +} & T; + +// @public (undocumented) +export interface StageInput { + // (undocumented) + handler(ctx: StageContext): Promise; + // (undocumented) + name: string; +} + +// @public (undocumented) +export interface StageResult extends StageInput { + // (undocumented) + endedAt?: number; + // (undocumented) + log: string[]; + // (undocumented) + startedAt?: number; + // (undocumented) + status: ProcessorStatus; +} + +// @public +export type SupportedTemplatingKey = 'cookiecutter' | string; + +// @public (undocumented) +export type TemplateAction = { + id: string; + description?: string; + schema?: { + input?: Schema; + output?: Schema; + }; + handler: (ctx: ActionContext) => Promise; +}; + +// @public (undocumented) +export class TemplateActionRegistry { + // (undocumented) + get(actionId: string): TemplateAction; + // (undocumented) + list(): TemplateAction[]; + // (undocumented) + register(action: TemplateAction): void; +} + +// @public (undocumented) +export type TemplaterBase = { + run(opts: TemplaterRunOptions): Promise; +}; + +// @public +export type TemplaterBuilder = { + register(protocol: SupportedTemplatingKey, templater: TemplaterBase): void; + get(templater: string): TemplaterBase; +}; + +// @public (undocumented) +export type TemplaterConfig = { + templater?: TemplaterBase; +}; + +// @public +export type TemplaterRunOptions = { + workspacePath: string; + values: TemplaterValues; + logStream?: Writable; +}; + +// @public +export type TemplaterRunResult = { + resultDir: string; +}; + +// @public (undocumented) +export class Templaters implements TemplaterBuilder { + // (undocumented) + get(templaterId: string): TemplaterBase; + // (undocumented) + register(templaterKey: SupportedTemplatingKey, templater: TemplaterBase): void; + } + +// @public (undocumented) +export type TemplaterValues = RequiredTemplateValues & Record; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md new file mode 100644 index 0000000000..85c2142d5e --- /dev/null +++ b/plugins/scaffolder/api-report.md @@ -0,0 +1,112 @@ +## API Report File for "@backstage/plugin-scaffolder" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { DiscoveryApi } from '@backstage/core'; +import { EntityName } from '@backstage/catalog-model'; +import { Extension } from '@backstage/core'; +import { ExternalRouteRef } from '@backstage/core'; +import { FieldProps } from '@rjsf/core'; +import { FieldValidation } from '@rjsf/core'; +import { IdentityApi } from '@backstage/core'; +import { JsonObject } from '@backstage/config'; +import { JSONSchema } from '@backstage/catalog-model'; +import { JsonValue } from '@backstage/config'; +import { Observable } from '@backstage/core'; +import { default as React_2 } from 'react'; +import { RouteRef } from '@backstage/core'; +import { ScmIntegrationRegistry } from '@backstage/integration'; + +// @public (undocumented) +export function createScaffolderFieldExtension(options: FieldExtensionOptions): Extension<() => null>; + +// @public (undocumented) +export const EntityPickerFieldExtension: () => null; + +// @public (undocumented) +export const OwnerPickerFieldExtension: () => null; + +// @public (undocumented) +export const RepoUrlPickerFieldExtension: () => null; + +// @public (undocumented) +export interface ScaffolderApi { + // (undocumented) + getIntegrationsList(options: { + allowedHosts: string[]; + }): Promise<{ + type: string; + title: string; + host: string; + }[]>; + // (undocumented) + getTask(taskId: string): Promise; + // (undocumented) + getTemplateParameterSchema(templateName: EntityName): Promise; + // (undocumented) + listActions(): Promise; + scaffold(templateName: string, values: Record): Promise; + // (undocumented) + streamLogs({ taskId, after, }: { + taskId: string; + after?: number; + }): Observable; +} + +// @public (undocumented) +export const scaffolderApiRef: ApiRef; + +// @public (undocumented) +export class ScaffolderClient implements ScaffolderApi { + constructor(options: { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + scmIntegrationsApi: ScmIntegrationRegistry; + }); + // (undocumented) + getIntegrationsList(options: { + allowedHosts: string[]; + }): Promise<{ + type: string; + title: string; + host: string; + }[]>; + // (undocumented) + getTask(taskId: string): Promise; + // (undocumented) + getTemplateParameterSchema(templateName: EntityName): Promise; + // (undocumented) + listActions(): Promise; + scaffold(templateName: string, values: Record): Promise; + // (undocumented) + streamLogs({ taskId, after, }: { + taskId: string; + after?: number; + }): Observable; +} + +// @public (undocumented) +export const ScaffolderFieldExtensions: React_2.ComponentType; + +// @public (undocumented) +export const ScaffolderPage: () => JSX.Element; + +// @public (undocumented) +const scaffolderPlugin: BackstagePlugin<{ + root: RouteRef; +}, { + registerComponent: ExternalRouteRef; +}>; + +export { scaffolderPlugin as plugin } + +export { scaffolderPlugin } + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/search-backend-node/api-report.md b/plugins/search-backend-node/api-report.md new file mode 100644 index 0000000000..03585b6b99 --- /dev/null +++ b/plugins/search-backend-node/api-report.md @@ -0,0 +1,68 @@ +## API Report File for "@backstage/plugin-search-backend-node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { DocumentCollator } from '@backstage/search-common'; +import { DocumentDecorator } from '@backstage/search-common'; +import { IndexableDocument } from '@backstage/search-common'; +import { Logger } from 'winston'; +import { default as lunr_2 } from 'lunr'; +import { SearchQuery } from '@backstage/search-common'; +import { SearchResultSet } from '@backstage/search-common'; + +// @public (undocumented) +export class IndexBuilder { + constructor({ logger, searchEngine }: IndexBuilderOptions); + addCollator({ collator, defaultRefreshIntervalSeconds, }: RegisterCollatorParameters): void; + addDecorator({ decorator }: RegisterDecoratorParameters): void; + build(): Promise<{ + scheduler: Scheduler; + }>; + // (undocumented) + getSearchEngine(): SearchEngine; + } + +// @public (undocumented) +export class LunrSearchEngine implements SearchEngine { + constructor({ logger }: { + logger: Logger; + }); + // (undocumented) + protected docStore: Record; + // (undocumented) + index(type: string, documents: IndexableDocument[]): void; + // (undocumented) + protected logger: Logger; + // (undocumented) + protected lunrIndices: Record; + // (undocumented) + query(query: SearchQuery): Promise; + // (undocumented) + setTranslator(translator: LunrQueryTranslator): void; + // (undocumented) + protected translator: QueryTranslator; +} + +// @public +export class Scheduler { + constructor({ logger }: { + logger: Logger; + }); + addToSchedule(task: Function, interval: number): void; + start(): void; + stop(): void; +} + +// @public +export interface SearchEngine { + index(type: string, documents: IndexableDocument[]): void; + query(query: SearchQuery): Promise; + setTranslator(translator: QueryTranslator): void; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/search-backend/api-report.md b/plugins/search-backend/api-report.md new file mode 100644 index 0000000000..9e617b73ed --- /dev/null +++ b/plugins/search-backend/api-report.md @@ -0,0 +1,17 @@ +## API Report File for "@backstage/plugin-search-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import express from 'express'; +import { Logger } from 'winston'; +import { SearchEngine } from '@backstage/plugin-search-backend-node'; + +// @public (undocumented) +export function createRouter({ engine, logger, }: RouterOptions): Promise; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/search/api-report.md b/plugins/search/api-report.md new file mode 100644 index 0000000000..181f049f63 --- /dev/null +++ b/plugins/search/api-report.md @@ -0,0 +1,102 @@ +## API Report File for "@backstage/plugin-search" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { AsyncState } from 'react-use/lib/useAsync'; +import { BackstagePlugin } from '@backstage/core'; +import { IndexableDocument } from '@backstage/search-common'; +import { JsonObject } from '@backstage/config'; +import { default as React_2 } from 'react'; +import { ReactElement } from 'react'; +import { RouteRef } from '@backstage/core'; +import { SearchQuery } from '@backstage/search-common'; +import { SearchResult as SearchResult_2 } from '@backstage/search-common'; +import { SearchResultSet } from '@backstage/search-common'; + +// @public (undocumented) +export const DefaultResultListItem: ({ result }: { + result: IndexableDocument; +}) => JSX.Element; + +// @public (undocumented) +export const Filters: ({ filters, filterOptions, resetFilters, updateSelected, updateChecked, }: FiltersProps) => JSX.Element; + +// @public (undocumented) +export const FiltersButton: ({ numberOfSelectedFilters, handleToggleFilters, }: FiltersButtonProps) => JSX.Element; + +// @public (undocumented) +export type FiltersState = { + selected: string; + checked: Array; +}; + +// @public (undocumented) +export const Router: () => JSX.Element; + +// @public (undocumented) +export const searchApiRef: ApiRef; + +// @public (undocumented) +export const SearchBar: ({ className, debounceTime }: Props) => JSX.Element; + +// @public @deprecated (undocumented) +export const SearchBarNext: ({ className, debounceTime }: { + className?: string | undefined; + debounceTime?: number | undefined; +}) => JSX.Element; + +// @public (undocumented) +export const SearchContextProvider: ({ initialState, children, }: React_2.PropsWithChildren<{ + initialState?: SettableSearchContext | undefined; +}>) => JSX.Element; + +// @public (undocumented) +export const SearchFilter: { + ({ component: Element, ...props }: Props_2): JSX.Element; + Checkbox(props: Omit & Component): JSX.Element; + Select(props: Omit & Component): JSX.Element; +}; + +// @public @deprecated (undocumented) +export const SearchFilterNext: { + ({ component: Element, ...props }: Props_2): JSX.Element; + Checkbox(props: Omit & Component): JSX.Element; + Select(props: Omit & Component): JSX.Element; +}; + +// @public (undocumented) +export const SearchPage: () => JSX.Element; + +// @public @deprecated (undocumented) +export const SearchPageNext: () => JSX.Element; + +// @public (undocumented) +const searchPlugin: BackstagePlugin<{ + root: RouteRef; + nextRoot: RouteRef; +}, {}>; + +export { searchPlugin as plugin } + +export { searchPlugin } + +// @public (undocumented) +export const SearchResult: ({ children }: { + children: (results: { + results: SearchResult_2[]; + }) => JSX.Element; +}) => JSX.Element; + +// @public (undocumented) +export const SidebarSearch: () => JSX.Element; + +// @public (undocumented) +export const useSearch: () => SearchContextValue; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/sentry/api-report.md b/plugins/sentry/api-report.md new file mode 100644 index 0000000000..ffd3071b92 --- /dev/null +++ b/plugins/sentry/api-report.md @@ -0,0 +1,100 @@ +## API Report File for "@backstage/plugin-sentry" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { DiscoveryApi } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { InfoCardVariants } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const EntitySentryCard: () => JSX.Element; + +// @public (undocumented) +export const EntitySentryContent: () => JSX.Element; + +// @public (undocumented) +export class MockSentryApi implements SentryApi { + // (undocumented) + fetchIssues(): Promise; +} + +// @public (undocumented) +export class ProductionSentryApi implements SentryApi { + constructor(discoveryApi: DiscoveryApi, organization: string); + // (undocumented) + fetchIssues(project: string, statsFor: string): Promise; + } + +// @public (undocumented) +export const Router: ({ entity }: { + entity: Entity; +}) => JSX.Element; + +// @public (undocumented) +export interface SentryApi { + // (undocumented) + fetchIssues(project: string, statsFor: string): Promise; +} + +// @public (undocumented) +export const sentryApiRef: ApiRef; + +// @public (undocumented) +export type SentryIssue = { + platform: SentryPlatform; + lastSeen: string; + numComments: number; + userCount: number; + stats: { + '24h'?: EventPoint[]; + '12h'?: EventPoint[]; + }; + culprit: string; + title: string; + id: string; + assignedTo: any; + logger: any; + type: string; + annotations: any[]; + metadata: SentryIssueMetadata; + status: string; + subscriptionDetails: any; + isPublic: boolean; + hasSeen: boolean; + shortId: string; + shareId: string | null; + firstSeen: string; + count: string; + permalink: string; + level: string; + isSubscribed: boolean; + isBookmarked: boolean; + project: SentryProject; + statusDetails: any; +}; + +// @public (undocumented) +export const SentryIssuesWidget: ({ entity, statsFor, variant, }: { + entity: Entity; + statsFor?: "12h" | "24h" | undefined; + variant?: InfoCardVariants | undefined; +}) => JSX.Element; + +// @public (undocumented) +const sentryPlugin: BackstagePlugin<{ + root: RouteRef; +}, {}>; + +export { sentryPlugin as plugin } + +export { sentryPlugin } + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/shortcuts/api-report.md b/plugins/shortcuts/api-report.md new file mode 100644 index 0000000000..4c0b3132d6 --- /dev/null +++ b/plugins/shortcuts/api-report.md @@ -0,0 +1,56 @@ +## API Report File for "@backstage/plugin-shortcuts" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { Observable } from '@backstage/core'; +import ObservableImpl from 'zen-observable'; +import { StorageApi } from '@backstage/core'; + +// @public +export class LocalStoredShortcuts implements ShortcutApi { + constructor(storageApi: StorageApi); + // (undocumented) + add(shortcut: Omit): Promise; + // (undocumented) + getColor(url: string): string; + // (undocumented) + remove(id: string): Promise; + // (undocumented) + shortcut$(): ObservableImpl; + // (undocumented) + update(shortcut: Shortcut): Promise; +} + +// @public (undocumented) +export type Shortcut = { + id: string; + url: string; + title: string; +}; + +// @public (undocumented) +export interface ShortcutApi { + add(shortcut: Omit): Promise; + getColor(url: string): string; + remove(id: string): Promise; + shortcut$(): Observable; + update(shortcut: Shortcut): Promise; +} + +// @public (undocumented) +export const Shortcuts: () => JSX.Element; + +// @public (undocumented) +export const shortcutsApiRef: ApiRef; + +// @public (undocumented) +export const shortcutsPlugin: BackstagePlugin<{}, {}>; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/sonarqube/api-report.md b/plugins/sonarqube/api-report.md new file mode 100644 index 0000000000..6d4af3d548 --- /dev/null +++ b/plugins/sonarqube/api-report.md @@ -0,0 +1,41 @@ +## API Report File for "@backstage/plugin-sonarqube" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BackstagePlugin } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { InfoCardVariants } from '@backstage/core'; + +// @public (undocumented) +export const EntitySonarQubeCard: ({ variant, duplicationRatings, }: { + entity?: Entity| undefined; + variant?: InfoCardVariants| undefined; + duplicationRatings?: { + greaterThan: number; + rating: "1.0" | "2.0" | "3.0" | "4.0" | "5.0"; + }[] | undefined; +}) => JSX.Element; + +// @public (undocumented) +export const isSonarQubeAvailable: (entity: Entity) => boolean; + +// @public (undocumented) +export const SonarQubeCard: ({ variant, duplicationRatings, }: { + entity?: Entity | undefined; + variant?: InfoCardVariants | undefined; + duplicationRatings?: DuplicationRating[] | undefined; +}) => JSX.Element; + +// @public (undocumented) +const sonarQubePlugin: BackstagePlugin<{}, {}>; + +export { sonarQubePlugin as plugin } + +export { sonarQubePlugin } + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/splunk-on-call/api-report.md b/plugins/splunk-on-call/api-report.md new file mode 100644 index 0000000000..3a2f085b0b --- /dev/null +++ b/plugins/splunk-on-call/api-report.md @@ -0,0 +1,68 @@ +## API Report File for "@backstage/plugin-splunk-on-call" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { ConfigApi } from '@backstage/core'; +import { DiscoveryApi } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const EntitySplunkOnCallCard: () => JSX.Element; + +// @public (undocumented) +export const isSplunkOnCallAvailable: (entity: Entity) => boolean; + +// @public (undocumented) +export const splunkOnCallApiRef: ApiRef; + +// @public (undocumented) +export class SplunkOnCallClient implements SplunkOnCallApi { + constructor(config: ClientApiConfig); + // (undocumented) + static fromConfig(configApi: ConfigApi, discoveryApi: DiscoveryApi): SplunkOnCallClient; + // (undocumented) + getEscalationPolicies(): Promise; + // (undocumented) + getIncidents(): Promise; + // (undocumented) + getOnCallUsers(): Promise; + // (undocumented) + getTeams(): Promise; + // (undocumented) + getUsers(): Promise; + // (undocumented) + incidentAction({ routingKey, incidentType, incidentId, incidentDisplayName, incidentMessage, incidentStartTime, }: TriggerAlarmRequest): Promise; + } + +// @public (undocumented) +export const SplunkOnCallPage: { + ({ title, subtitle, pageTitle, }: SplunkOnCallPageProps): JSX.Element; + defaultProps: { + title: string; + subtitle: string; + pageTitle: string; + }; +}; + +// @public (undocumented) +const splunkOnCallPlugin: BackstagePlugin<{ + root: RouteRef; +}, {}>; + +export { splunkOnCallPlugin as plugin } + +export { splunkOnCallPlugin } + +// @public (undocumented) +export class UnauthorizedError extends Error { +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/tech-radar/api-report.md b/plugins/tech-radar/api-report.md new file mode 100644 index 0000000000..20367c5525 --- /dev/null +++ b/plugins/tech-radar/api-report.md @@ -0,0 +1,123 @@ +## API Report File for "@backstage/plugin-tech-radar" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export interface RadarEntry { + // (undocumented) + description?: string; + // (undocumented) + id: string; + // (undocumented) + key: string; + // (undocumented) + quadrant: string; + // (undocumented) + timeline: Array; + // (undocumented) + title: string; + // (undocumented) + url: string; +} + +// @public (undocumented) +export interface RadarEntrySnapshot { + // (undocumented) + date: Date; + // (undocumented) + description?: string; + // (undocumented) + moved?: MovedState; + // (undocumented) + ringId: string; +} + +// @public (undocumented) +export interface RadarQuadrant { + // (undocumented) + id: string; + // (undocumented) + name: string; +} + +// @public +export interface RadarRing { + // (undocumented) + color: string; + // (undocumented) + id: string; + // (undocumented) + name: string; +} + +// @public (undocumented) +export const Router: { + ({ title, subtitle, pageTitle, ...props }: TechRadarPageProps): JSX.Element; + defaultProps: { + title: string; + subtitle: string; + pageTitle: string; + }; +}; + +// @public (undocumented) +export interface TechRadarApi { + // (undocumented) + load: () => Promise; +} + +// @public (undocumented) +export const techRadarApiRef: ApiRef; + +// @public (undocumented) +export const TechRadarComponent: (props: TechRadarComponentProps) => JSX.Element; + +// @public +export interface TechRadarComponentProps { + // (undocumented) + height: number; + // (undocumented) + svgProps?: object; + // (undocumented) + width: number; +} + +// @public +export interface TechRadarLoaderResponse { + // (undocumented) + entries: RadarEntry[]; + // (undocumented) + quadrants: RadarQuadrant[]; + // (undocumented) + rings: RadarRing[]; +} + +// @public (undocumented) +export const TechRadarPage: { + ({ title, subtitle, pageTitle, ...props }: TechRadarPageProps): JSX.Element; + defaultProps: { + title: string; + subtitle: string; + pageTitle: string; + }; +}; + +// @public (undocumented) +const techRadarPlugin: BackstagePlugin<{ + root: RouteRef; +}, {}>; + +export { techRadarPlugin as plugin } + +export { techRadarPlugin } + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/techdocs-backend/api-report.md b/plugins/techdocs-backend/api-report.md new file mode 100644 index 0000000000..d433d428e0 --- /dev/null +++ b/plugins/techdocs-backend/api-report.md @@ -0,0 +1,24 @@ +## API Report File for "@backstage/plugin-techdocs-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Config } from '@backstage/config'; +import express from 'express'; +import { GeneratorBuilder } from '@backstage/techdocs-common'; +import { Knex } from 'knex'; +import { Logger } from 'winston'; +import { PluginEndpointDiscovery } from '@backstage/backend-common'; +import { PreparerBuilder } from '@backstage/techdocs-common'; +import { PublisherBase } from '@backstage/techdocs-common'; + +// @public (undocumented) +export function createRouter({ preparers, generators, publisher, config, logger, discovery, }: RouterOptions): Promise; + + +export * from "@backstage/techdocs-common"; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/techdocs/api-report.md b/plugins/techdocs/api-report.md new file mode 100644 index 0000000000..801eabf368 --- /dev/null +++ b/plugins/techdocs/api-report.md @@ -0,0 +1,146 @@ +## API Report File for "@backstage/plugin-techdocs" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { Config } from '@backstage/config'; +import { CSSProperties } from '@material-ui/styles'; +import { DiscoveryApi } from '@backstage/core'; +import { Entity } from '@backstage/catalog-model'; +import { EntityName } from '@backstage/catalog-model'; +import { IdentityApi } from '@backstage/core'; +import { Location as Location_2 } from '@backstage/catalog-model'; +import { RouteRef } from '@backstage/core'; + +// @public (undocumented) +export const DocsCardGrid: ({ entities, }: { + entities: Entity[] | undefined; +}) => JSX.Element | null; + +// @public (undocumented) +export const DocsTable: ({ entities, title, }: { + entities: Entity[] | undefined; + title?: string | undefined; +}) => JSX.Element | null; + +// @public (undocumented) +export const EmbeddedDocsRouter: (_props: Props) => JSX.Element; + +// @public (undocumented) +export const EntityTechdocsContent: (_props: { + entity?: Entity| undefined; +}) => JSX.Element; + +// @public (undocumented) +export type PanelType = 'DocsCardGrid' | 'DocsTable'; + +// @public (undocumented) +export const Reader: ({ entityId, onReady }: Props_2) => JSX.Element; + +// @public (undocumented) +export const Router: () => JSX.Element; + +// @public (undocumented) +export interface TechDocsApi { + // (undocumented) + getApiOrigin(): Promise; + // (undocumented) + getEntityMetadata(entityId: EntityName): Promise; + // (undocumented) + getTechDocsMetadata(entityId: EntityName): Promise; +} + +// @public (undocumented) +export const techdocsApiRef: ApiRef; + +// @public +export class TechDocsClient implements TechDocsApi { + constructor({ configApi, discoveryApi, identityApi, }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }); + // (undocumented) + configApi: Config; + // (undocumented) + discoveryApi: DiscoveryApi; + // (undocumented) + getApiOrigin(): Promise; + getEntityMetadata(entityId: EntityName): Promise; + getTechDocsMetadata(entityId: EntityName): Promise; + // (undocumented) + identityApi: IdentityApi; +} + +// @public (undocumented) +export const TechDocsCustomHome: ({ tabsConfig, }: { + tabsConfig: TabsConfig; +}) => JSX.Element; + +// @public (undocumented) +export const TechdocsPage: () => JSX.Element; + +// @public (undocumented) +const techdocsPlugin: BackstagePlugin<{ + root: RouteRef; + entityContent: RouteRef; +}, {}>; + +export { techdocsPlugin as plugin } + +export { techdocsPlugin } + +// @public (undocumented) +export const TechDocsReaderPage: () => JSX.Element; + +// @public (undocumented) +export interface TechDocsStorageApi { + // (undocumented) + getApiOrigin(): Promise; + // (undocumented) + getBaseUrl(oldBaseUrl: string, entityId: EntityName, path: string): Promise; + // (undocumented) + getBuilder(): Promise; + // (undocumented) + getEntityDocs(entityId: EntityName, path: string): Promise; + // (undocumented) + getStorageUrl(): Promise; + // (undocumented) + syncEntityDocs(entityId: EntityName): Promise; +} + +// @public (undocumented) +export const techdocsStorageApiRef: ApiRef; + +// @public +export class TechDocsStorageClient implements TechDocsStorageApi { + constructor({ configApi, discoveryApi, identityApi, }: { + configApi: Config; + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + }); + // (undocumented) + configApi: Config; + // (undocumented) + discoveryApi: DiscoveryApi; + // (undocumented) + getApiOrigin(): Promise; + // (undocumented) + getBaseUrl(oldBaseUrl: string, entityId: EntityName, path: string): Promise; + // (undocumented) + getBuilder(): Promise; + getEntityDocs(entityId: EntityName, path: string): Promise; + // (undocumented) + getStorageUrl(): Promise; + // (undocumented) + identityApi: IdentityApi; + syncEntityDocs(entityId: EntityName): Promise; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/todo-backend/api-report.md b/plugins/todo-backend/api-report.md new file mode 100644 index 0000000000..8bcfe30aee --- /dev/null +++ b/plugins/todo-backend/api-report.md @@ -0,0 +1,98 @@ +## API Report File for "@backstage/plugin-todo-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { CatalogApi } from '@backstage/catalog-client'; +import { Config } from '@backstage/config'; +import { EntityName } from '@backstage/catalog-model'; +import express from 'express'; +import { Logger } from 'winston'; +import { ScmIntegrations } from '@backstage/integration'; +import { UrlReader } from '@backstage/backend-common'; + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public (undocumented) +export function createTodoParser(options?: TodoParserOptions): TodoParser; + +// @public (undocumented) +export type ListTodosRequest = { + entity?: EntityName; + offset?: number; + limit?: number; + orderBy?: { + field: Fields; + direction: 'asc' | 'desc'; + }; + filters?: { + field: Fields; + value: string; + }[]; +}; + +// @public (undocumented) +export type ListTodosResponse = { + items: TodoItem[]; + totalCount: number; + offset: number; + limit: number; +}; + +// @public (undocumented) +export type ReadTodosOptions = { + url: string; +}; + +// @public (undocumented) +export type ReadTodosResult = { + items: TodoItem[]; +}; + +// @public (undocumented) +export type TodoItem = { + text: string; + tag: string; + author?: string; + viewUrl?: string; + lineNumber?: number; + repoFilePath?: string; +}; + +// @public (undocumented) +export interface TodoReader { + readTodos(options: ReadTodosOptions): Promise; +} + +// @public (undocumented) +export class TodoReaderService implements TodoService { + constructor(options: Options_2); + // (undocumented) + listTodos(req: ListTodosRequest, options?: { + token?: string; + }): Promise; + } + +// @public (undocumented) +export class TodoScmReader implements TodoReader { + constructor(options: Options); + // (undocumented) + static fromConfig(config: Config, options: Omit): TodoScmReader; + // (undocumented) + readTodos({ url }: ReadTodosOptions): Promise; +} + +// @public (undocumented) +export interface TodoService { + // (undocumented) + listTodos(req: ListTodosRequest, options?: { + token?: string; + }): Promise; +} + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/todo/api-report.md b/plugins/todo/api-report.md new file mode 100644 index 0000000000..bdfea8b8f9 --- /dev/null +++ b/plugins/todo/api-report.md @@ -0,0 +1,23 @@ +## API Report File for "@backstage/plugin-todo" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core-plugin-api'; +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { Entity } from '@backstage/catalog-model'; + +// @public (undocumented) +export const EntityTodoContent: () => JSX.Element; + +// @public (undocumented) +export const todoApiRef: ApiRef; + +// @public (undocumented) +export const todoPlugin: BackstagePlugin<{}, {}>; + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/user-settings/api-report.md b/plugins/user-settings/api-report.md new file mode 100644 index 0000000000..2f882f3742 --- /dev/null +++ b/plugins/user-settings/api-report.md @@ -0,0 +1,45 @@ +## API Report File for "@backstage/plugin-user-settings" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApiRef } from '@backstage/core'; +import { BackstagePlugin } from '@backstage/core'; +import { IconComponent } from '@backstage/core'; +import { RouteRef } from '@backstage/core'; +import { SessionApi } from '@backstage/core'; + +// @public (undocumented) +export const AuthProviders: ({ providerSettings }: Props_2) => JSX.Element; + +// @public (undocumented) +export const DefaultProviderSettings: ({ configuredProviders }: Props_3) => JSX.Element; + +// @public (undocumented) +export const ProviderSettingsItem: ({ title, description, icon: Icon, apiRef, }: Props_4) => JSX.Element; + +// @public (undocumented) +export const Router: ({ providerSettings }: Props) => JSX.Element; + +// @public (undocumented) +export const Settings: () => JSX.Element; + +// @public (undocumented) +export const UserSettingsPage: ({ providerSettings }: { + providerSettings?: JSX.Element | undefined; +}) => JSX.Element; + +// @public (undocumented) +const userSettingsPlugin: BackstagePlugin<{ + settingsPage: RouteRef; +}, {}>; + +export { userSettingsPlugin as plugin } + +export { userSettingsPlugin } + + +// (No @packageDocumentation comment for this package) + +``` diff --git a/plugins/welcome/api-report.md b/plugins/welcome/api-report.md new file mode 100644 index 0000000000..8d6e1cb156 --- /dev/null +++ b/plugins/welcome/api-report.md @@ -0,0 +1,22 @@ +## API Report File for "@backstage/plugin-welcome" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BackstagePlugin } from '@backstage/core'; + +// @public (undocumented) +export const WelcomePage: () => JSX.Element; + +// @public (undocumented) +const welcomePlugin: BackstagePlugin<{}, {}>; + +export { welcomePlugin as plugin } + +export { welcomePlugin } + + +// (No @packageDocumentation comment for this package) + +```