Merge remote-tracking branch 'origin/master' into configure-template

This commit is contained in:
Bogdan Nechyporenko
2023-10-23 23:20:34 +02:00
158 changed files with 5590 additions and 1241 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/cli-node': minor
'@backstage/cli': minor
---
Removed support for the `publishConfig.alphaTypes` and `.betaTypes` fields that were used together with `--experimental-type-build` to generate `/alpha` and `/beta` entry points. Use the `exports` field to achieve this instead.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Migrate catalog entity cards to new frontend system extension format.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-graphiql': minor
---
Upgrade to GraphiQL to 3.0.6
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-gitlab': patch
---
Added try catch around fetching gitlab group users to prevent refresh from failing completely while only a select number of groups might not be able to load correctly.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-code-coverage-backend': patch
---
Added support for new backend system
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/repo-tools': minor
'@backstage/cli': minor
---
Remove support for the deprecated `--experimental-type-build` option for `package build`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-azure-devops': patch
---
Consolidated getting the annotation values into a single function to help with future changes
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/plugin-catalog-react': minor
---
The `UserListPicker` component has undergone improvements to enhance its performance.
The previous implementation inferred the number of owned and starred entities based on the entities available in the `EntityListContext`. The updated version no longer relies on the `EntityListContext` for inference, allowing for better decoupling.
The component now loads the entities' count asynchronously, resulting in improved performance and responsiveness. For this purpose, some of the exported filters such as `EntityTagFilter`, `EntityOwnerFilter`, `EntityLifecycleFilter` and `EntityNamespaceFilter` have now the `getCatalogFilters` method implemented.
+13
View File
@@ -0,0 +1,13 @@
---
'@backstage/plugin-user-settings-backend': patch
'@backstage/plugin-scaffolder-backend': patch
'@backstage/plugin-periskop-backend': patch
'@backstage/plugin-scaffolder-node': patch
'@backstage/plugin-bazaar-backend': patch
'@backstage/plugin-kafka-backend': patch
'@backstage/plugin-proxy-backend': patch
'@backstage/plugin-sonarqube': patch
'@backstage/plugin-todo': patch
---
Switched to using `"exports"` field for `/alpha` subpath export.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Reverted the Microsoft auth provider to the previous implementation.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
Allow setting `update: true` in `publish:github:pull-request` scaffolder action
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-gitlab': patch
---
Add examples for `gitlab:projectAccessToken:create` scaffolder action & improve related tests
@@ -34,7 +34,7 @@ jobs:
ref: 'refs/pull/${{ github.event.pull_request.number }}/merge'
- name: fetch base
run: git fetch --depth 1 origin ${{ github.base_ref }}
- uses: backstage/actions/changeset-feedback@v0.6.4
- uses: backstage/actions/changeset-feedback@v0.6.5
name: Generate feedback
with:
diff-ref: 'origin/master'
+3 -3
View File
@@ -40,7 +40,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
@@ -76,7 +76,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
@@ -207,7 +207,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
with:
egress-policy: audit
- uses: backstage/actions/cron@v0.6.4
- uses: backstage/actions/cron@v0.6.5
with:
app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }}
private-key: ${{ secrets.BACKSTAGE_GOALIE_PRIVATE_KEY }}
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
node-version: 18.x
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@497722d45fb5a35c14c721f63ed74e6ad5f629dd # v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v18.x
+2 -2
View File
@@ -68,7 +68,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
@@ -150,7 +150,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
+1 -1
View File
@@ -15,4 +15,4 @@ jobs:
egress-policy: audit
- name: Issue sync
uses: backstage/actions/issue-sync@v0.6.4
uses: backstage/actions/issue-sync@v0.6.5
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
const prNumber = artifact.name.slice('pr_number-'.length)
core.setOutput('pr-number', prNumber);
- uses: backstage/actions/re-review@v0.6.4
- uses: backstage/actions/re-review@v0.6.5
with:
app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }}
private-key: ${{ secrets.BACKSTAGE_GOALIE_PRIVATE_KEY }}
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
egress-policy: audit
- name: PR sync
uses: backstage/actions/pr-sync@v0.6.4
uses: backstage/actions/pr-sync@v0.6.5
with:
github-token: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }}
app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }}
+1 -1
View File
@@ -66,6 +66,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: 'Upload to code-scanning'
uses: github/codeql-action/upload-sarif@fdcae64e1484d349b3366718cdfef3d404390e85 # v2.22.1
uses: github/codeql-action/upload-sarif@49abf0ba24d0b7953cb586944e918a0b92074c80 # v2.22.4
with:
sarif_file: results.sarif
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
@@ -24,7 +24,7 @@ jobs:
node-version: 18.x
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v18.x
+1 -1
View File
@@ -58,6 +58,6 @@ jobs:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
NODE_OPTIONS: --max-old-space-size=7168
- name: Upload Snyk report
uses: github/codeql-action/upload-sarif@v2.21.8
uses: github/codeql-action/upload-sarif@v2.22.4
with:
sarif_file: snyk.sarif
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
registry-url: https://registry.npmjs.org/
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: linux-v18
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
with:
node-version: 18.x
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v18.x
- name: run Lighthouse CI
+3 -3
View File
@@ -55,7 +55,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2.21.8
uses: github/codeql-action/init@v2.22.4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -66,7 +66,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2.21.8
uses: github/codeql-action/autobuild@v2.22.4
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -80,4 +80,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2.21.8
uses: github/codeql-action/analyze@v2.22.4
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
+1 -1
View File
@@ -57,7 +57,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
+1 -1
View File
@@ -78,7 +78,7 @@ jobs:
uses: browser-actions/setup-chrome@803ef6dfb4fdf22089c9563225d95e4a515820a0 # latest
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.4
uses: backstage/actions/yarn-install@v0.6.5
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
- name: storybook yarn install
+1 -7
View File
@@ -172,13 +172,7 @@ We generate API Reports using the [API Extractor](https://api-extractor.com/) to
Each API report contains a list of all the exported types of each package. As long as the API report does not have any warnings it will contain the full publicly facing API of the package, meaning you do not need to consider any other changes to the package from the point of view of TypeScript API stability.
Exported types can be marked with either `@public`, `@alpha` or `@beta` release tags. It is only the `@public` exports that we consider to be part of the stable API. The `@alpha` and `@beta` exports are considered unstable and can be changed at any time without needing a breaking package versions bump. However, this **ONLY** applies if the package has been configured to use experimental type builds, which looks like this in `package.json`:
```json
"build": "backstage-cli package build --experimental-type-build"
```
If a package does not have this configuration, then all exported types are considered stable, even if they are marked as `@alpha` or `@beta`.
Exported types can be marked with either `@public`, `@alpha` or `@beta` release tags. It is only the `@public` exports that we consider to be part of the stable API. The `@alpha` and `@beta` exports are considered unstable and can be changed at any time without needing a breaking package versions bump.
#### Changes that are Not Considered Breaking
-35
View File
@@ -674,38 +674,3 @@ To add subpath exports to an existing package, simply add the desired `"exports"
```bash
yarn backstage-cli migrate package-exports
```
## Experimental Type Build
> Note: Experimental type builds are deprecated and will be removed in the future. They have been replaced by [subpath exports](#subpath-exports).
The Backstage CLI has an experimental feature where multiple different type definition files can be generated for different release stages. The release stages are marked in the [TSDoc](https://tsdoc.org/) for each individual export, using either `@public`, `@alpha`, or `@beta`. Rather than just building a single `index.d.ts` file, the build process will instead output `index.d.ts`, `index.beta.d.ts`, and `index.alpha.d.ts`. Each of these files will have exports from more unstable release stages stripped, meaning that `index.d.ts` will omit all exports marked with `@alpha` or `@beta`, while `index.beta.d.ts` will omit all exports marked with `@alpha`.
This feature is aimed at projects that publish to package registries and wish to maintain different levels of API stability within each package. There is no need to use this within a single monorepo, as it has no effect due to only applying to built and published packages.
In order for the experimental type build to work, `@microsoft/api-extractor` must be installed in your project, as it is an optional peer dependency of the Backstage CLI. There are then three steps that need to be taken for each package where you want to enable this feature:
- Add the `--experimental-type-build` flag to the `"build"` script of the package.
- Add either one or both of `"alphaTypes"` and `"betaTypes"` to the `"publishConfig"` of the package:
```json
"publishConfig": {
...
"types": "dist/index.d.ts",
"alphaTypes": "dist/index.alpha.d.ts",
"betaTypes": "dist/index.beta.d.ts"
},
```
- Add either one or both of `"alpha"` and `"beta"` to the `"files"` of the package:
```json
"files": [
"dist",
"alpha",
"beta"
]
```
Once this setup is complete, users of the published packages will only be able to access the stable API via the main package entry point, for example `@acme/my-plugin`. Exports marked with `@alpha` or `@beta` will only be available via the `/alpha` entry point, for example `@acme/my-plugin/alpha`, and exports marked with `@beta` will only be available via `/beta`. This does not apply within the monorepo that contains the package. There all exports still have to be imported via the main entry point.
Note that these different entry points are only separated during type checking. At runtime they all share the same code which contains the exports from all releases stages.
An example of this setup can be seen in the [`@backstage/catalog-model`](https://github.com/backstage/backstage/blob/da0675bf9f28ed1460f03635a22d3c26abd14707/packages/catalog-model/package.json#L14) package, which has enabled `alpha` type exports. With this setup, exports marked as `@alpha` are only available for import via `@backstage/catalog-model/alpha`. The `@backstage/catalog-model` package currently does not have any exports marked as `@beta`, or a `/beta` entry point.
@@ -349,7 +349,7 @@ import { createRouter } from './service/router';
/**
* The example TODO list backend plugin.
*
* @alpha
* @public
*/
export const exampleTodoListPlugin = createBackendPlugin({
pluginId: 'exampleTodoList',
@@ -41,6 +41,7 @@ import {
EntityAzurePullRequestsContent,
isAzureDevOpsAvailable,
isAzurePipelinesAvailable,
EntityAzureReadmeCard,
} from '@backstage/plugin-azure-devops';
import {
isOctopusDeployAvailable,
@@ -415,6 +416,14 @@ const overviewContent = (
</EntitySwitch.Case>
</EntitySwitch>
<EntitySwitch>
<EntitySwitch.Case if={isAzureDevOpsAvailable}>
<Grid item md={6}>
<EntityAzureReadmeCard />
</Grid>
</EntitySwitch.Case>
</EntitySwitch>
<Grid item md={2}>
<InfoCard title="Rate this entity">
<LikeDislikeButtons />
+1 -1
View File
@@ -39,7 +39,7 @@ backend.add(
import('@backstage/plugin-permission-backend-module-allow-all-policy'),
);
backend.add(import('@backstage/plugin-permission-backend/alpha'));
backend.add(import('@backstage/plugin-proxy-backend'));
backend.add(import('@backstage/plugin-proxy-backend/alpha'));
backend.add(import('@backstage/plugin-scaffolder-backend/alpha'));
backend.add(import('@backstage/plugin-search-backend-module-catalog/alpha'));
backend.add(import('@backstage/plugin-search-backend-module-explore/alpha'));
-2
View File
@@ -53,8 +53,6 @@ export interface BackstagePackageJson {
access?: 'public' | 'restricted';
directory?: string;
registry?: string;
alphaTypes?: string;
betaTypes?: string;
};
// (undocumented)
scripts?: {
@@ -56,8 +56,6 @@ export interface BackstagePackageJson {
access?: 'public' | 'restricted';
directory?: string;
registry?: string;
alphaTypes?: string;
betaTypes?: string;
};
dependencies?: {
-1
View File
@@ -224,7 +224,6 @@ Usage: backstage-cli package build [options]
Options:
--role <name>
--minify
--experimental-type-build
--skip-build-dependencies
--stats
--config <path>
-4
View File
@@ -174,16 +174,12 @@
"type-fest": "^2.19.0"
},
"peerDependencies": {
"@microsoft/api-extractor": "^7.21.2",
"@vitejs/plugin-react": "^4.0.4",
"vite": "^4.4.9",
"vite-plugin-html": "^3.2.0",
"vite-plugin-node-polyfills": "^0.14.1"
},
"peerDependenciesMeta": {
"@microsoft/api-extractor": {
"optional": true
},
"@vitejs/plugin-react": {
"optional": true
},
@@ -65,6 +65,5 @@ export async function command(opts: OptionValues): Promise<void> {
return buildPackage({
outputs,
minify: Boolean(opts.minify),
useApiExtractor: Boolean(opts.experimentalTypeBuild),
});
}
-4
View File
@@ -130,10 +130,6 @@ export function registerScriptCommand(program: Command) {
'--minify',
'Minify the generated code. Does not apply to app or backend packages.',
)
.option(
'--experimental-type-build',
'Enable experimental type build. Does not apply to app or backend packages. [DEPRECATED]',
)
.option(
'--skip-build-dependencies',
'Skip the automatic building of local dependencies. Applies to backend packages only.',
@@ -49,9 +49,6 @@ export async function command() {
if (scripts.build?.includes('--minify')) {
buildCmd.push('--minify');
}
if (scripts.build?.includes('--experimental-type-build')) {
buildCmd.push('--experimental-type-build');
}
if (scripts.build?.includes('--config')) {
buildCmd.push(...(scripts.build.match(configArgPattern) ?? []));
}
-1
View File
@@ -137,7 +137,6 @@ export async function command(opts: OptionValues, cmd: Command): Promise<void> {
outputs,
logPrefix: `${chalk.cyan(relativePath(paths.targetRoot, pkg.dir))}: `,
minify: buildOptions.minify,
useApiExtractor: buildOptions.experimentalTypeBuild,
};
});
@@ -1,129 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import chalk from 'chalk';
import { relative as relativePath, resolve as resolvePath } from 'path';
import { paths } from '../paths';
import { buildTypeDefinitionsWorker } from './buildTypeDefinitionsWorker';
import { runWorkerThreads } from '../parallel';
// These message types are ignored since we want to avoid duplicating the logic of
// handling them correctly, and we already have the API Reports warning about them.
const ignoredMessages = new Set(['tsdoc-undefined-tag', 'ae-forgotten-export']);
export async function buildTypeDefinitions(
targetDirs: string[] = [paths.targetDir],
) {
const packageDirs = targetDirs.map(dir =>
relativePath(paths.targetRoot, dir),
);
const entryPoints = await Promise.all(
packageDirs.map(async dir => {
const entryPoint = paths.resolveTargetRoot(
'dist-types',
dir,
'src/index.d.ts',
);
const declarationsExist = await fs.pathExists(entryPoint);
if (!declarationsExist) {
throw new Error(
`No declaration files found at ${entryPoint}, be sure to run ${chalk.bgRed.white(
'yarn tsc',
)} to generate .d.ts files before packaging`,
);
}
return entryPoint;
}),
);
const workerConfigs = packageDirs.map(packageDir => {
const targetDir = paths.resolveTargetRoot(packageDir);
const targetTypesDir = paths.resolveTargetRoot('dist-types', packageDir);
const extractorOptions = {
configObject: {
mainEntryPointFilePath: resolvePath(targetTypesDir, 'src/index.d.ts'),
bundledPackages: [],
compiler: {
skipLibCheck: true,
tsconfigFilePath: paths.resolveTargetRoot('tsconfig.json'),
},
dtsRollup: {
enabled: true,
untrimmedFilePath: resolvePath(targetDir, 'dist/index.alpha.d.ts'),
betaTrimmedFilePath: resolvePath(targetDir, 'dist/index.beta.d.ts'),
publicTrimmedFilePath: resolvePath(targetDir, 'dist/index.d.ts'),
},
newlineKind: 'lf',
projectFolder: targetDir,
},
configObjectFullPath: targetDir,
packageJsonFullPath: resolvePath(targetDir, 'package.json'),
};
return { extractorOptions, targetTypesDir };
});
const typescriptDir = paths.resolveTargetRoot('node_modules/typescript');
const hasTypescript = await fs.pathExists(typescriptDir);
const typescriptCompilerFolder = hasTypescript ? typescriptDir : undefined;
await runWorkerThreads({
threadCount: 1,
workerData: {
entryPoints,
workerConfigs,
typescriptCompilerFolder,
},
worker: buildTypeDefinitionsWorker,
onMessage: ({
message,
targetTypesDir,
}: {
message: any;
targetTypesDir: string;
}) => {
if (ignoredMessages.has(message.messageId)) {
return;
}
let text = `${message.text} (${message.messageId})`;
if (message.sourceFilePath) {
text += ' at ';
text += relativePath(targetTypesDir, message.sourceFilePath);
if (message.sourceFileLine) {
text += `:${message.sourceFileLine}`;
if (message.sourceFileColumn) {
text += `:${message.sourceFileColumn}`;
}
}
}
if (message.logLevel === 'error') {
console.error(chalk.red(`Error: ${text}`));
} else if (
message.logLevel === 'warning' ||
message.category === 'Extractor'
) {
console.warn(`Warning: ${text}`);
} else {
console.log(text);
}
},
});
}
@@ -1,97 +0,0 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* NOTE: This is a worker thread function that is stringified and executed
* within a `worker_threads.Worker`. Everything in this function must
* be self-contained.
* Using TypeScript is fine as it is transpiled before being stringified.
*/
export async function buildTypeDefinitionsWorker(
workerData: any,
sendMessage: (message: any) => void,
) {
try {
require('@microsoft/api-extractor');
} catch (error) {
throw new Error(
'Failed to resolve @microsoft/api-extractor, it must best installed ' +
'as a dependency of your project in order to use experimental type builds',
);
}
const { dirname } = require('path');
const { entryPoints, workerConfigs, typescriptCompilerFolder } = workerData;
const apiExtractor = require('@microsoft/api-extractor');
const { Extractor, ExtractorConfig, CompilerState } = apiExtractor;
/**
* All of this monkey patching below is because Material UI has these bare package.json file as a method
* for making TypeScript accept imports like `@material-ui/core/Button`, and improve tree-shaking
* by declaring them side effect free.
*
* The package.json lookup logic in api-extractor really doesn't like that though, as it enforces
* that the 'name' field exists in all package.json files that it discovers. This below is just
* making sure that we ignore those file package.json files instead of crashing.
*/
const {
PackageJsonLookup,
// eslint-disable-next-line @backstage/no-undeclared-imports
} = require('@rushstack/node-core-library/lib/PackageJsonLookup');
const old = PackageJsonLookup.prototype.tryGetPackageJsonFilePathFor;
PackageJsonLookup.prototype.tryGetPackageJsonFilePathFor =
function tryGetPackageJsonFilePathForPatch(path: string) {
if (
path.includes('@material-ui') &&
!dirname(path).endsWith('@material-ui')
) {
return undefined;
}
return old.call(this, path);
};
let compilerState;
for (const { extractorOptions, targetTypesDir } of workerConfigs) {
const extractorConfig = ExtractorConfig.prepare(extractorOptions);
if (!compilerState) {
compilerState = CompilerState.create(extractorConfig, {
additionalEntryPoints: entryPoints,
});
}
const extractorResult = Extractor.invoke(extractorConfig, {
compilerState,
localBuild: false,
typescriptCompilerFolder,
showVerboseMessages: false,
showDiagnostics: false,
messageCallback: (message: any) => {
message.handled = true;
sendMessage({ message, targetTypesDir });
},
});
if (!extractorResult.succeeded) {
throw new Error(
`Type definition build completed with ${extractorResult.errorCount} errors` +
` and ${extractorResult.warningCount} warnings`,
);
}
}
}
+1 -1
View File
@@ -151,7 +151,7 @@ export async function makeRollupConfigs(
});
}
if (options.outputs.has(Output.types) && !options.useApiExtractor) {
if (options.outputs.has(Output.types)) {
const input = Object.fromEntries(
scriptEntryPoints.map(e => [
e.name,
-17
View File
@@ -21,7 +21,6 @@ import { relative as relativePath, resolve as resolvePath } from 'path';
import { paths } from '../paths';
import { makeRollupConfigs } from './config';
import { BuildOptions, Output } from './types';
import { buildTypeDefinitions } from './buildTypeDefinitions';
import { PackageRoles } from '@backstage/cli-node';
import { runParallelWorkers } from '../parallel';
@@ -112,10 +111,6 @@ export const buildPackage = async (options: BuildOptions) => {
const buildTasks = rollupConfigs.map(rollupBuild);
if (options.outputs.has(Output.types) && options.useApiExtractor) {
buildTasks.push(buildTypeDefinitions());
}
await Promise.all(buildTasks);
};
@@ -131,18 +126,6 @@ export const buildPackages = async (options: BuildOptions[]) => {
const buildTasks = rollupConfigs.flat().map(opts => () => rollupBuild(opts));
const typeDefinitionTargetDirs = options
.filter(
({ outputs, useApiExtractor }) =>
outputs.has(Output.types) && useApiExtractor,
)
.map(_ => _.targetDir!);
if (typeDefinitionTargetDirs.length > 0) {
// Make sure this one is started first
buildTasks.unshift(() => buildTypeDefinitions(typeDefinitionTargetDirs));
}
await runParallelWorkers({
items: buildTasks,
worker: async task => task(),
-1
View File
@@ -28,5 +28,4 @@ export type BuildOptions = {
packageJson?: BackstagePackageJson;
outputs: Set<Output>;
minify?: boolean;
useApiExtractor?: boolean;
};
@@ -206,7 +206,6 @@ export async function createDistWorkspace(
logPrefix: `${chalk.cyan(relativePath(paths.targetRoot, pkg.dir))}: `,
// No need to detect these for the backend builds, we assume no minification or types
minify: false,
useApiExtractor: false,
});
}
}
@@ -23,7 +23,7 @@ import { readEntryPoints } from '../entryPoints';
const PKG_PATH = 'package.json';
const PKG_BACKUP_PATH = 'package.json-prepack';
const SKIPPED_KEYS = ['access', 'registry', 'tag', 'alphaTypes', 'betaTypes'];
const SKIPPED_KEYS = ['access', 'registry', 'tag'];
const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx'];
interface ProductionPackOptions {
@@ -42,14 +42,6 @@ export async function productionPack(options: ProductionPackOptions) {
await fs.writeFile(PKG_BACKUP_PATH, pkgContent);
}
const hasStageEntry =
!!pkg.publishConfig?.alphaTypes || !!pkg.publishConfig?.betaTypes;
if (pkg.exports && hasStageEntry) {
throw new Error(
'Combining both exports and alpha/beta types is not supported',
);
}
// This mutates pkg to fill in index exports, so call it before applying publishConfig
const writeCompatibilityEntryPoints = await prepareExportsEntryPoints(
pkg,
@@ -99,12 +91,6 @@ export async function productionPack(options: ProductionPackOptions) {
await fs.writeJson(pkgPath, pkg, { encoding: 'utf8', spaces: 2 });
}
if (publishConfig.alphaTypes) {
await writeReleaseStageEntrypoint(pkg, 'alpha', targetDir ?? packageDir);
}
if (publishConfig.betaTypes) {
await writeReleaseStageEntrypoint(pkg, 'beta', targetDir ?? packageDir);
}
if (writeCompatibilityEntryPoints) {
await writeCompatibilityEntryPoints(targetDir ?? packageDir);
}
@@ -118,12 +104,6 @@ export async function revertProductionPack(packageDir: string) {
// Check if we're shipping types for other release stages, clean up in that case
const pkg = await fs.readJson(PKG_PATH);
if (pkg.publishConfig?.alphaTypes) {
await fs.remove(resolvePath(packageDir, 'alpha'));
}
if (pkg.publishConfig?.betaTypes) {
await fs.remove(resolvePath(packageDir, 'beta'));
}
// Remove any extra entrypoint backwards compatibility directories
const entryPoints = readEntryPoints(pkg);
@@ -140,32 +120,6 @@ export async function revertProductionPack(packageDir: string) {
}
}
function resolveEntrypoint(pkg: any, name: string) {
const targetEntry = pkg.publishConfig[name] || pkg[name];
return targetEntry && posixPath.join('..', targetEntry);
}
// Writes e.g. alpha/package.json
async function writeReleaseStageEntrypoint(
pkg: BackstagePackageJson,
stage: 'alpha' | 'beta',
targetDir: string,
) {
await fs.ensureDir(resolvePath(targetDir, stage));
await fs.writeJson(
resolvePath(targetDir, stage, PKG_PATH),
{
name: pkg.name,
version: pkg.version,
main: resolveEntrypoint(pkg, 'main'),
module: resolveEntrypoint(pkg, 'module'),
browser: resolveEntrypoint(pkg, 'browser'),
types: posixPath.join('..', pkg.publishConfig![`${stage}Types`]!),
},
{ encoding: 'utf8', spaces: 2 },
);
}
const EXPORT_MAP = {
import: '.esm.js',
require: '.cjs.js',
@@ -304,7 +304,6 @@ async function findPackageEntryPoints(packageDirs: string[]): Promise<
Array<{
packageDir: string;
name: string;
usesExperimentalTypeBuild?: boolean;
}>
> {
return Promise.all(
@@ -317,9 +316,6 @@ async function findPackageEntryPoints(packageDirs: string[]): Promise<
getPackageExportNames(pkg)?.map(name => ({ packageDir, name })) ?? {
packageDir,
name: 'index',
usesExperimentalTypeBuild: pkg.scripts?.build?.includes(
'--experimental-type-build',
),
}
);
}),
@@ -367,11 +363,7 @@ export async function runApiExtraction({
}
const warnings = new Array<string>();
for (const {
packageDir,
name,
usesExperimentalTypeBuild,
} of packageEntryPoints) {
for (const { packageDir, name } of packageEntryPoints) {
console.log(`## Processing ${packageDir}`);
const noBail = Array.isArray(allowWarnings)
? allowWarnings.some(aw => aw === packageDir || minimatch(packageDir, aw))
@@ -511,7 +503,6 @@ export async function runApiExtraction({
// The root index entrypoint is only allowed @public exports, while /alpha and /beta only allow @alpha and @beta.
if (
validateReleaseTags &&
!usesExperimentalTypeBuild &&
fs.pathExistsSync(extractorConfig.reportFilePath)
) {
if (['index', 'alpha', 'beta'].includes(name)) {
@@ -52,6 +52,10 @@ describe('DefaultApiExplorerPage', () => {
kind: 'API',
metadata: {
name: 'Entity1',
annotations: {
'backstage.io/view-url': 'viewurl',
'backstage.io/edit-url': 'editurl',
},
},
spec: { type: 'openapi' },
},
@@ -63,6 +67,11 @@ describe('DefaultApiExplorerPage', () => {
getEntityFacets: async () => ({
facets: { 'relations.ownedBy': [] },
}),
queryEntities: async () => ({
items: [],
pageInfo: {},
totalItems: 0,
}),
};
const configApi: ConfigApi = new ConfigReader({
@@ -152,37 +161,39 @@ describe('DefaultApiExplorerPage', () => {
it('should render the default actions of an item in the grid', async () => {
await renderWrapped(<DefaultApiExplorerPage />);
expect(await screen.findByText(/All apis \(1\)/)).toBeInTheDocument();
expect(await screen.findByTitle(/View/)).toBeInTheDocument();
expect(await screen.findByTitle(/View/)).toBeInTheDocument();
expect(await screen.findByTitle(/Edit/)).toBeInTheDocument();
expect(await screen.findByTitle(/Add to favorites/)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/All apis \(1\)/)).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByRole('button', { name: /view/i })).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
expect(screen.getByTitle(/Add to favorites/)).toBeInTheDocument();
});
it('should render the custom actions of an item passed as prop', async () => {
const actions: TableProps<CatalogTableRow>['actions'] = [
() => {
return {
icon: () => <DashboardIcon fontSize="small" />,
tooltip: 'Foo Action',
disabled: false,
onClick: () => jest.fn(),
};
{
icon: () => <DashboardIcon fontSize="small" />,
tooltip: 'Foo Action',
disabled: false,
onClick: jest.fn(),
},
() => {
return {
icon: () => <DashboardIcon fontSize="small" />,
tooltip: 'Bar Action',
disabled: true,
onClick: () => jest.fn(),
};
{
icon: () => <DashboardIcon fontSize="small" />,
tooltip: 'Bar Action',
disabled: true,
onClick: jest.fn(),
},
];
await renderWrapped(<DefaultApiExplorerPage actions={actions} />);
expect(await screen.findByText(/All apis \(1\)/)).toBeInTheDocument();
expect(await screen.findByTitle(/Foo Action/)).toBeInTheDocument();
expect(await screen.findByTitle(/Bar Action/)).toBeInTheDocument();
expect((await screen.findByTitle(/Bar Action/)).firstChild).toBeDisabled();
await waitFor(() => {
expect(screen.getByText(/All apis \(1\)/)).toBeInTheDocument();
});
expect(screen.getByTitle(/Foo Action/)).toBeInTheDocument();
expect(screen.getByTitle(/Bar Action/)).toBeInTheDocument();
expect(screen.getByTitle(/Bar Action/).firstChild).toBeDisabled();
});
});
+3 -3
View File
@@ -538,9 +538,9 @@ export const providers: Readonly<{
| undefined,
) => AuthProviderFactory_2;
resolvers: Readonly<{
emailMatchingUserEntityProfileEmail: () => SignInResolver_2<OAuthResult>;
emailLocalPartMatchingUserEntityName: () => SignInResolver_2<OAuthResult>;
emailMatchingUserEntityAnnotation: () => SignInResolver_2<OAuthResult>;
emailLocalPartMatchingUserEntityName: () => SignInResolver<unknown>;
emailMatchingUserEntityProfileEmail: () => SignInResolver<unknown>;
emailMatchingUserEntityAnnotation(): SignInResolver<OAuthResult>;
}>;
}>;
oauth2: Readonly<{
+12
View File
@@ -179,6 +179,18 @@ export interface Config {
};
};
/** @visibility frontend */
microsoft?: {
[authEnv: string]: {
clientId: string;
/**
* @visibility secret
*/
clientSecret: string;
tenantId: string;
callbackUrl?: string;
};
};
/** @visibility frontend */
onelogin?: {
[authEnv: string]: {
clientId: string;
-1
View File
@@ -42,7 +42,6 @@
"@backstage/plugin-auth-backend-module-github-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-gitlab-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-google-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
@@ -0,0 +1,90 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FakeMicrosoftAPI } from './fake';
describe('FakeMicrosoftAPI', () => {
const api = new FakeMicrosoftAPI();
describe('#token', () => {
it('exchanges auth codes', () => {
const { access_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('User.Read'),
}),
);
expect(api.tokenHasScope(access_token, 'User.Read')).toBe(true);
});
it('supports scopes for the first requested audience only', () => {
const { access_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('someaudience/somescope User.Read'),
}),
);
expect(api.tokenHasScope(access_token, 'User.Read')).toBe(false);
});
it('special openid scopes do not count towards the 1-audience limit', () => {
const { access_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('openid offline_access User.Read'),
}),
);
expect(api.tokenHasScope(access_token, 'User.Read')).toBe(true);
});
it('refreshes tokens', () => {
const { access_token } = api.token(
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: api.generateRefreshToken(
'email openid profile User.Read',
),
}),
);
expect(
api.tokenHasScope(access_token, 'email openid profile User.Read'),
).toBe(true);
});
it('requires `openid` scope for ID token', () => {
const { id_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('User.Read'),
}),
);
expect(id_token).toBeUndefined();
});
it('requires `offline_access` scope for refresh token', () => {
const { refresh_token } = api.token(
new URLSearchParams({
grant_type: 'authorization_code',
code: api.generateAuthCode('User.Read'),
}),
);
expect(refresh_token).toBeUndefined();
});
});
});
@@ -0,0 +1,126 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { decodeJwt } from 'jose';
type Claims = { aud: string; scp: string };
export class FakeMicrosoftAPI {
generateAccessToken(scope: string): string {
return this.tokenWithClaims(this.allClaimsForScope(scope)).access_token;
}
generateAuthCode(scope: string): string {
return this.encodeClaims(this.allClaimsForScope(scope));
}
generateRefreshToken(scope: string): string {
return this.encodeClaims(this.allClaimsForScope(scope));
}
token(formData: URLSearchParams): {
access_token: string;
scope: string;
refresh_token?: string;
id_token?: string;
} {
const scopeParameter = formData.get('scope');
const claims =
(scopeParameter && this.allClaimsForScope(scopeParameter)) ??
formData.get('grant_type') === 'refresh_token'
? this.decodeClaims(formData.get('refresh_token')!)
: this.decodeClaims(formData.get('code')!);
return {
...this.tokenWithClaims(claims),
...(this.hasScope(claims, 'offline_access') && {
refresh_token: this.encodeClaims(claims),
}),
...(this.hasScope(claims, 'openid') && {
id_token: 'header.e30K.microsoft',
}),
};
}
tokenHasScope(token: string, scope: string): boolean {
const { aud, scp } = decodeJwt(token);
return this.hasScope({ aud: aud as string, scp: scp as string }, scope);
}
private tokenWithClaims(claims: Claims): {
access_token: string;
scope: string;
} {
const filteredClaims = {
...claims,
scp: claims.scp
.split(' ')
.filter(s => s !== 'offline_access')
.join(' '),
};
return {
access_token: `header.${Buffer.from(
JSON.stringify(filteredClaims),
).toString('base64')}.signature`,
scope: this.scopeFromClaims(filteredClaims),
};
}
private allClaimsForScope(scope: string): Claims {
const scopes = scope.split(' ').map(this.parseScope);
const firstAudience = scopes
.map(({ aud }) => aud)
.find(aud => aud !== 'openid');
return {
aud: firstAudience ?? '00000003-0000-0000-c000-000000000000',
scp: scopes
.filter(({ aud }) => aud === 'openid' || aud === firstAudience)
.map(({ scp }) => scp)
.join(' '),
};
}
// auth codes and refresh tokens in this fake system are base64-encoded JSON
// strings of claims
private encodeClaims(claims: Claims): string {
return Buffer.from(JSON.stringify(claims)).toString('base64');
}
private decodeClaims(encoded: string): Claims {
return JSON.parse(Buffer.from(encoded, 'base64').toString());
}
private hasScope(claims: Claims, scope: string): boolean {
return this.scopeFromClaims(claims).includes(scope);
}
private parseScope(s: string): Claims {
if (s.includes('/')) {
const [aud, scp] = s.split('/');
return { aud, scp };
}
switch (s) {
case 'email':
case 'openid':
case 'offline_access':
case 'profile': {
return { aud: 'openid', scp: s };
}
default:
return { aud: '00000003-0000-0000-c000-000000000000', scp: s };
}
}
private scopeFromClaims(claims: Claims): string {
return claims.scp
.split(' ')
.map(this.parseScope)
.map(({ aud, scp }) =>
aud === 'openid' ||
claims.aud === '00000003-0000-0000-c000-000000000000'
? scp
: `${claims.aud}/${scp}`,
)
.join(' ');
}
}
@@ -0,0 +1,450 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { microsoft } from './provider';
import { getVoidLogger } from '@backstage/backend-common';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { AuthProviderRouteHandlers, AuthResolverContext } from '../types';
import express from 'express';
import crypto from 'crypto';
import { FakeMicrosoftAPI } from './__testUtils__/fake';
describe('MicrosoftAuthProvider', () => {
const nonce = 'AAAAAAAAAAAAAAAAAAAAAA=='; // 16 bytes of zeros in base64
const state = Buffer.from(
`nonce=${encodeURIComponent(nonce)}&env=development`,
).toString('hex');
const mockBackstageToken = `header.${Buffer.from(
JSON.stringify({ sub: 'user:default/mock' }),
'utf8',
).toString('base64')}.backstage`;
const server = setupServer();
const microsoftApi = new FakeMicrosoftAPI();
let provider: AuthProviderRouteHandlers;
let response: jest.Mocked<express.Response>;
setupRequestMockHandlers(server);
beforeEach(() => {
provider = microsoft.create({
signIn: {
resolver: microsoft.resolvers.emailMatchingUserEntityAnnotation(),
},
})({
providerId: 'microsoft',
baseUrl: 'http://backstage.test/api/auth',
appUrl: 'http://backstage.test',
isOriginAllowed: _ => true,
globalConfig: {
baseUrl: 'http://backstage.test/api/auth',
appUrl: 'http://backstage.test',
isOriginAllowed: _ => true,
},
config: new ConfigReader({
development: {
tenantId: 'tenantId',
clientId: 'clientId',
clientSecret: 'clientSecret',
},
}),
logger: getVoidLogger(),
resolverContext: {
issueToken: jest.fn(),
findCatalogUser: jest.fn(),
signInWithCatalogUser: async _ => ({
token: mockBackstageToken,
}),
} as AuthResolverContext,
}) as AuthProviderRouteHandlers;
server.use(
rest.post(
'https://login.microsoftonline.com/tenantId/oauth2/v2.0/token',
async (req, res, ctx) => {
return res(
ctx.json({
...microsoftApi.token(new URLSearchParams(await req.text())),
token_type: 'Bearer',
expires_in: 123,
ext_expires_in: 123,
}),
);
},
),
rest.get('https://graph.microsoft.com/v1.0/me/', (req, res, ctx) => {
if (
!microsoftApi.tokenHasScope(
req.headers.get('authorization')!.replace(/^Bearer /, ''),
'User.Read',
)
) {
return res(ctx.status(403));
}
return res(
ctx.json({
id: 'conrad',
displayName: 'Conrad',
surname: 'Ribas',
givenName: 'Francisco',
mail: 'conrad@example.com',
}),
);
}),
rest.get(
'https://graph.microsoft.com/v1.0/me/photos/*',
async (req, res, ctx) => {
if (
!microsoftApi.tokenHasScope(
req.headers.get('authorization')!.replace(/^Bearer /, ''),
'User.Read',
)
) {
return res(ctx.status(403));
}
const imageBuffer = new Uint8Array([104, 111, 119, 100, 121]).buffer;
return res(
ctx.set('Content-Length', imageBuffer.byteLength.toString()),
ctx.set('Content-Type', 'image/jpeg'),
ctx.body(imageBuffer),
);
},
),
);
response = {
cookie: jest.fn(),
end: jest.fn(),
json: jest.fn(),
setHeader: jest.fn(),
status: jest.fn(),
} as unknown as jest.Mocked<express.Response>;
response.status.mockReturnValue(response);
});
describe('#start', () => {
const randomBytes = jest.spyOn(
crypto,
'randomBytes',
) as unknown as jest.MockedFunction<(size: number) => Buffer>;
afterEach(() => {
randomBytes.mockRestore();
});
it('redirects to authorize URL', async () => {
randomBytes.mockReturnValue(Buffer.from(nonce, 'base64'));
await provider.start(
{
query: {
env: 'development',
scope: 'email openid profile User.Read',
},
} as unknown as express.Request,
response,
);
expect(response.setHeader).toHaveBeenCalledWith(
'Location',
'https://login.microsoftonline.com/tenantId/oauth2/v2.0/authorize' +
'?response_type=code' +
`&redirect_uri=${encodeURIComponent(
'http://backstage.test/api/auth/microsoft/handler/frame',
)}` +
`&scope=${encodeURIComponent('email openid profile User.Read')}` +
`&state=${state}` +
'&client_id=clientId',
);
});
});
describe('#handle', () => {
it('returns provider info and profile with photo data', async () => {
await provider.frameHandler(
{
query: {
env: 'development',
code: microsoftApi.generateAuthCode(
'email openid profile User.Read',
),
state,
},
cookies: {
'microsoft-nonce': nonce,
},
} as unknown as express.Request,
response,
);
expect(response.end).toHaveBeenCalledWith(
expect.stringContaining(
encodeURIComponent(
JSON.stringify({
type: 'authorization_response',
response: {
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'email openid profile User.Read',
),
scope: 'email openid profile User.Read',
expiresInSeconds: 123,
idToken: 'header.e30K.microsoft',
},
profile: {
email: 'conrad@example.com',
picture: 'data:image/jpeg;base64,aG93ZHk=',
displayName: 'Conrad',
},
backstageIdentity: {
token: mockBackstageToken,
identity: {
type: 'user',
userEntityRef: 'user:default/mock',
ownershipEntityRefs: [],
},
},
},
}),
),
),
);
});
it('returns access token for non-microsoft graph scope', async () => {
await provider.frameHandler(
{
query: {
env: 'development',
code: microsoftApi.generateAuthCode('aks-audience/user.read'),
state,
},
cookies: {
'microsoft-nonce': nonce,
},
} as unknown as express.Request,
response,
);
expect(response.end).toHaveBeenCalledWith(
expect.stringContaining(
encodeURIComponent(
JSON.stringify({
type: 'authorization_response',
response: {
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'aks-audience/user.read',
),
scope: 'aks-audience/user.read',
expiresInSeconds: 123,
},
profile: {},
},
}),
),
),
);
});
it('sets refresh token', async () => {
await provider.frameHandler(
{
query: {
env: 'development',
code: microsoftApi.generateAuthCode(
'email offline_access openid profile User.Read',
),
state,
},
cookies: {
'microsoft-nonce': nonce,
},
} as unknown as express.Request,
response,
);
expect(response.cookie).toHaveBeenCalledWith(
'microsoft-refresh-token',
microsoftApi.generateRefreshToken(
'email offline_access openid profile User.Read',
),
{
domain: 'backstage.test',
httpOnly: true,
maxAge: 86400000000,
path: '/api/auth/microsoft',
sameSite: 'lax',
secure: false,
},
);
});
it('omits photo data when fetching it fails', async () => {
server.use(
rest.get('https://graph.microsoft.com/v1.0/me/photos/*', (_, res) =>
res.networkError('remote hung up'),
),
);
await provider.frameHandler(
{
query: {
env: 'development',
code: microsoftApi.generateAuthCode(
'email openid profile User.Read',
),
state,
},
cookies: {
'microsoft-nonce': nonce,
},
} as unknown as express.Request,
response,
);
expect(response.end).toHaveBeenCalledWith(
expect.stringContaining(
encodeURIComponent(
JSON.stringify({
type: 'authorization_response',
response: {
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'email openid profile User.Read',
),
scope: 'email openid profile User.Read',
expiresInSeconds: 123,
idToken: 'header.e30K.microsoft',
},
profile: {
email: 'conrad@example.com',
displayName: 'Conrad',
},
backstageIdentity: {
token: mockBackstageToken,
identity: {
type: 'user',
userEntityRef: 'user:default/mock',
ownershipEntityRefs: [],
},
},
},
}),
),
),
);
});
});
describe('#refresh', () => {
it('returns provider info and profile with photo data', async () => {
await provider.refresh!(
{
query: {
env: 'development',
scope: 'email openid profile User.Read',
},
header: jest.fn(_ => 'XMLHttpRequest'),
cookies: {
'microsoft-refresh-token': microsoftApi.generateRefreshToken(
'email openid profile User.Read',
),
},
get: jest.fn(),
} as unknown as express.Request,
response,
);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'email openid profile User.Read',
),
scope: 'email openid profile User.Read',
expiresInSeconds: 123,
idToken: 'header.e30K.microsoft',
},
profile: {
email: 'conrad@example.com',
picture: 'data:image/jpeg;base64,aG93ZHk=',
displayName: 'Conrad',
},
}),
);
});
it('returns access token for non-microsoft graph scope', async () => {
await provider.refresh!(
{
query: {
env: 'development',
scope: 'aks-audience/user.read',
},
header: jest.fn(_ => 'XMLHttpRequest'),
cookies: {
'microsoft-refresh-token': microsoftApi.generateRefreshToken(
'aks-audience/user.read',
),
},
get: jest.fn(),
} as unknown as express.Request,
response,
);
expect(response.json).toHaveBeenCalledWith({
providerInfo: {
accessToken: microsoftApi.generateAccessToken(
'aks-audience/user.read',
),
expiresInSeconds: 123,
scope: 'aks-audience/user.read',
},
profile: {},
});
});
it('returns backstage identity', async () => {
await provider.refresh!(
{
query: {
env: 'development',
scope: 'email openid profile User.Read',
},
header: jest.fn(_ => 'XMLHttpRequest'),
cookies: {
'microsoft-refresh-token': microsoftApi.generateRefreshToken(
'email openid profile User.Read',
),
},
get: jest.fn(),
} as unknown as express.Request,
response,
);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({
backstageIdentity: expect.objectContaining({
token: mockBackstageToken,
}),
}),
);
});
});
});
@@ -14,25 +14,218 @@
* limitations under the License.
*/
import { SignInResolver, AuthHandler } from '../types';
import { OAuthResult } from '../../lib/oauth';
import express from 'express';
import passport from 'passport';
import { Strategy as MicrosoftStrategy } from 'passport-microsoft';
import {
encodeState,
OAuthAdapter,
OAuthEnvironmentHandler,
OAuthHandlers,
OAuthProviderOptions,
OAuthRefreshRequest,
OAuthResponse,
OAuthResult,
OAuthStartRequest,
} from '../../lib/oauth';
import {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
PassportDoneCallback,
} from '../../lib/passport';
import {
AuthHandler,
OAuthStartResponse,
SignInResolver,
AuthResolverContext,
} from '../types';
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
import {
commonSignInResolvers,
createOAuthProviderFactory,
} from '@backstage/plugin-auth-node';
import {
adaptLegacyOAuthHandler,
adaptLegacyOAuthSignInResolver,
adaptOAuthSignInResolverToLegacy,
} from '../../lib/legacy';
import {
microsoftAuthenticator,
microsoftSignInResolvers,
} from '@backstage/plugin-auth-backend-module-microsoft-provider';
commonByEmailLocalPartResolver,
commonByEmailResolver,
} from '../resolvers';
import { LoggerService } from '@backstage/backend-plugin-api';
import fetch from 'node-fetch';
import { decodeJwt } from 'jose';
import { Profile as PassportProfile } from 'passport';
import { BACKSTAGE_SESSION_EXPIRATION } from '../../lib/session';
type PrivateInfo = {
refreshToken: string;
};
type Options = OAuthProviderOptions & {
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
logger: LoggerService;
resolverContext: AuthResolverContext;
authorizationUrl?: string;
tokenUrl?: string;
};
export class MicrosoftAuthProvider implements OAuthHandlers {
private readonly _strategy: MicrosoftStrategy;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
private readonly logger: LoggerService;
private readonly resolverContext: AuthResolverContext;
constructor(options: Options) {
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.logger = options.logger;
this.resolverContext = options.resolverContext;
this._strategy = new MicrosoftStrategy(
{
clientID: options.clientId,
clientSecret: options.clientSecret,
callbackURL: options.callbackUrl,
authorizationURL: options.authorizationUrl,
tokenURL: options.tokenUrl,
passReqToCallback: false,
skipUserProfile: (
accessToken: string,
done: (err: unknown, skip: boolean) => void,
) => {
done(null, this.skipUserProfile(accessToken));
},
},
(
accessToken: any,
refreshToken: any,
params: any,
fullProfile: passport.Profile,
done: PassportDoneCallback<OAuthResult, PrivateInfo>,
) => {
done(undefined, { fullProfile, accessToken, params }, { refreshToken });
},
);
}
private skipUserProfile = (accessToken: string): boolean => {
const { aud, scp } = decodeJwt(accessToken);
const hasGraphReadScope =
aud === '00000003-0000-0000-c000-000000000000' &&
(scp as string)
.split(' ')
.map(s => s.toLowerCase())
.includes('user.read');
return !hasGraphReadScope;
};
async start(req: OAuthStartRequest): Promise<OAuthStartResponse> {
return await executeRedirectStrategy(req, this._strategy, {
scope: req.scope,
state: encodeState(req.state),
});
}
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
>(req, this._strategy);
return {
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
return {
response: await this.handleResult({
params,
accessToken,
...(!this.skipUserProfile(accessToken) && {
fullProfile: await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
),
}),
}),
refreshToken,
};
}
private async handleResult(result: {
fullProfile?: PassportProfile;
params: {
id_token?: string;
scope: string;
expires_in: number;
};
accessToken: string;
refreshToken?: string;
}): Promise<OAuthResponse> {
let profile = {};
if (result.fullProfile) {
const photo = await this.getUserPhoto(result.accessToken);
result.fullProfile.photos = photo ? [{ value: photo }] : undefined;
({ profile } = await this.authHandler(
result as OAuthResult,
this.resolverContext,
));
}
const expiresInSeconds =
result.params.expires_in === undefined
? BACKSTAGE_SESSION_EXPIRATION
: Math.min(result.params.expires_in, BACKSTAGE_SESSION_EXPIRATION);
return {
providerInfo: {
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds,
...{ idToken: result.params.id_token },
},
profile,
...(result.fullProfile &&
this.signInResolver && {
backstageIdentity: await this.signInResolver(
{ result: result as OAuthResult, profile },
this.resolverContext,
),
}),
};
}
private async getUserPhoto(accessToken: string): Promise<string | undefined> {
try {
const res = await fetch(
'https://graph.microsoft.com/v1.0/me/photos/48x48/$value',
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
const data = await res.buffer();
return `data:image/jpeg;base64,${data.toString('base64')}`;
} catch (error) {
this.logger.warn(
`Could not retrieve user profile photo from Microsoft Graph API: ${error}`,
);
return undefined;
}
}
}
/**
* Auth provider integration for GitLab auth
* Auth provider integration for Microsoft auth
*
* @public
*/
@@ -48,21 +241,75 @@ export const microsoft = createAuthProviderIntegration({
* 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.
*/
resolver: SignInResolver<OAuthResult>;
};
}) {
return createOAuthProviderFactory({
authenticator: microsoftAuthenticator,
profileTransform: adaptLegacyOAuthHandler(options?.authHandler),
signInResolver: adaptLegacyOAuthSignInResolver(options?.signIn?.resolver),
});
return ({ providerId, globalConfig, config, logger, resolverContext }) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const tenantId = envConfig.getString('tenantId');
const customCallbackUrl = envConfig.getOptionalString('callbackUrl');
const callbackUrl =
customCallbackUrl ||
`${globalConfig.baseUrl}/${providerId}/handler/frame`;
const authorizationUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`;
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile ?? {}, params.id_token),
});
const provider = new MicrosoftAuthProvider({
clientId,
clientSecret,
callbackUrl,
authorizationUrl,
tokenUrl,
authHandler,
signInResolver: options?.signIn?.resolver,
logger,
resolverContext,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
providerId,
callbackUrl,
});
});
},
resolvers: {
/**
* Looks up the user by matching their email local part to the entity name.
*/
emailLocalPartMatchingUserEntityName: () => commonByEmailLocalPartResolver,
/**
* Looks up the user by matching their email to the entity email.
*/
emailMatchingUserEntityProfileEmail: () => commonByEmailResolver,
/**
* Looks up the user by matching their email to the `microsoft.com/email` annotation.
*/
emailMatchingUserEntityAnnotation(): SignInResolver<OAuthResult> {
return async (info, ctx) => {
const { profile } = info;
if (!profile.email) {
throw new Error('Microsoft profile contained no email');
}
return ctx.signInWithCatalogUser({
annotations: {
'microsoft.com/email': profile.email,
},
});
};
},
},
resolvers: adaptOAuthSignInResolverToLegacy({
emailLocalPartMatchingUserEntityName:
commonSignInResolvers.emailLocalPartMatchingUserEntityName(),
emailMatchingUserEntityProfileEmail:
commonSignInResolvers.emailMatchingUserEntityProfileEmail(),
emailMatchingUserEntityAnnotation:
microsoftSignInResolvers.emailMatchingUserEntityAnnotation(),
}),
});
@@ -16,14 +16,14 @@
import { BuildTable } from '../BuildTable/BuildTable';
import React from 'react';
import { getAnnotationFromEntity } from '../../utils/getAnnotationFromEntity';
import { getAnnotationValuesFromEntity } from '../../utils/getAnnotationValuesFromEntity';
import { useBuildRuns } from '../../hooks/useBuildRuns';
import { useEntity } from '@backstage/plugin-catalog-react';
export const EntityPageAzurePipelines = (props: { defaultLimit?: number }) => {
const { entity } = useEntity();
const { project, repo, definition } = getAnnotationFromEntity(entity);
const { project, repo, definition } = getAnnotationValuesFromEntity(entity);
const { items, loading, error } = useBuildRuns(
project,
@@ -23,11 +23,9 @@ import {
ErrorPanel,
} from '@backstage/core-components';
import { useEntity } from '@backstage/plugin-catalog-react';
import { useProjectRepoFromEntity } from '../../hooks';
import { useApi } from '@backstage/core-plugin-api';
import React from 'react';
import { azureDevOpsApiRef } from '../../api';
import useAsync from 'react-use/lib/useAsync';
import { useReadme } from '../../hooks';
const useStyles = makeStyles(theme => ({
readMe: {
@@ -85,18 +83,9 @@ const ReadmeCardError = ({ error }: ErrorProps) => {
export const ReadmeCard = (props: Props) => {
const classes = useStyles();
const api = useApi(azureDevOpsApiRef);
const { entity } = useEntity();
const { project, repo } = useProjectRepoFromEntity(entity);
const { loading, error, value } = useAsync(
() =>
api.getReadme({
project,
repo,
}),
[api, project, repo, entity],
);
const { loading, error, item: value } = useReadme(entity);
if (loading) {
return <Progress />;
+1
View File
@@ -16,6 +16,7 @@
export const AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION =
'dev.azure.com/build-definition';
export const AZURE_DEVOPS_HOST_ORG_ANNOTATION = 'dev.azure.com/host-org';
export const AZURE_DEVOPS_PROJECT_ANNOTATION = 'dev.azure.com/project';
export const AZURE_DEVOPS_REPO_ANNOTATION = 'dev.azure.com/project-repo';
export const AZURE_DEVOPS_DEFAULT_TOP: number = 10;
+1 -1
View File
@@ -16,8 +16,8 @@
export * from './useAllTeams';
export * from './useDashboardPullRequests';
export * from './useProjectRepoFromEntity';
export * from './usePullRequests';
export * from './useRepoBuilds';
export * from './useUserEmail';
export * from './useUserTeamIds';
export * from './useReadme';
@@ -0,0 +1,130 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { useGitTags } from './useGitTags';
import { Entity } from '@backstage/catalog-model';
import { GitTag } from '@backstage/plugin-azure-devops-common';
import { TestApiProvider } from '@backstage/test-utils';
import { AzureDevOpsApi, azureDevOpsApiRef } from '../api';
describe('useGitTags', () => {
const azureDevOpsApiMock = {
getGitTags: jest.fn(),
};
const azureDevOpsApi =
azureDevOpsApiMock as Partial<AzureDevOpsApi> as AzureDevOpsApi;
const Wrapper = (props: { children?: React.ReactNode }) => (
<TestApiProvider apis={[[azureDevOpsApiRef, azureDevOpsApi]]}>
{props.children}
</TestApiProvider>
);
it('should provide an array of GitTag', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
annotations: {
'dev.azure.com/project-repo': 'projectName/repoName',
},
},
};
const tags: GitTag[] = [
{
objectId: 'tag-1',
peeledObjectId: 'tag-1',
name: 'tag-1',
createdBy: 'awanlin',
link: 'https://dev.azure.com/org/project/repo',
commitLink: 'https://dev.azure.com/org/project/repo/commit-sha-1',
},
{
objectId: 'tag-2',
peeledObjectId: 'tag-2',
name: 'tag-2',
createdBy: 'awanlin',
link: 'https://dev.azure.com/org/project/repo',
commitLink: 'https://dev.azure.com/org/project/repo/commit-sha-2',
},
{
objectId: 'tag-3',
peeledObjectId: 'tag-3',
name: 'tag-3',
createdBy: 'awanlin',
link: 'https://dev.azure.com/org/project/repo',
commitLink: 'https://dev.azure.com/org/project/repo/commit-sha-3',
},
];
azureDevOpsApiMock.getGitTags.mockResolvedValue({ items: tags });
const { result } = renderHook(() => useGitTags(entity), {
wrapper: Wrapper,
});
expect(result.current.loading).toEqual(true);
await waitFor(() => {
expect(result.current).toEqual({
error: undefined,
items: tags,
loading: false,
});
});
});
it('should return throw when annotation missing', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
},
};
expect(() =>
renderHook(() => useGitTags(entity), {
wrapper: Wrapper,
}),
).toThrow('Value for annotation "dev.azure.com/project" was not found');
});
it('should return throw when annotation invalid', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
annotations: {
'dev.azure.com/project-repo': 'fake',
},
},
};
expect(() =>
renderHook(() => useGitTags(entity), {
wrapper: Wrapper,
}),
).toThrow(
'Invalid value for annotation "dev.azure.com/project-repo"; expected format is: <project-name>/<repo-name>, found: "fake"',
);
});
});
+4 -4
View File
@@ -20,7 +20,7 @@ import { Entity } from '@backstage/catalog-model';
import { azureDevOpsApiRef } from '../api';
import { useApi } from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';
import { useProjectRepoFromEntity } from './useProjectRepoFromEntity';
import { getAnnotationValuesFromEntity } from '../utils';
export function useGitTags(entity: Entity): {
items?: GitTag[];
@@ -28,10 +28,10 @@ export function useGitTags(entity: Entity): {
error?: Error;
} {
const api = useApi(azureDevOpsApiRef);
const { project, repo } = useProjectRepoFromEntity(entity);
const { project, repo } = getAnnotationValuesFromEntity(entity);
const { value, loading, error } = useAsync(() => {
return api.getGitTags(project, repo);
const { value, loading, error } = useAsync(async () => {
return await api.getGitTags(project, repo as string);
}, [api, project, repo]);
return {
@@ -1,47 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { AZURE_DEVOPS_REPO_ANNOTATION } from '../constants';
export function useProjectRepoFromEntity(entity: Entity): {
project: string;
repo: string;
} {
const [project, repo] = (
entity.metadata.annotations?.[AZURE_DEVOPS_REPO_ANNOTATION] ?? ''
).split('/');
if (!project && !repo) {
throw new Error(
'Value for annotation dev.azure.com/project-repo was not in the correct format: <project-name>/<repo-name>',
);
}
if (!project) {
throw new Error(
'Project Name for annotation dev.azure.com/project-repo was not found; expected format is: <project-name>/<repo-name>',
);
}
if (!repo) {
throw new Error(
'Repo Name for annotation dev.azure.com/project-repo was not found; expected format is: <project-name>/<repo-name>',
);
}
return { project, repo };
}
@@ -0,0 +1,129 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { Entity } from '@backstage/catalog-model';
import { PullRequest } from '@backstage/plugin-azure-devops-common';
import { TestApiProvider } from '@backstage/test-utils';
import { AzureDevOpsApi, azureDevOpsApiRef } from '../api';
import { usePullRequests } from './usePullRequests';
describe('usePullRequests', () => {
const azureDevOpsApiMock = {
getPullRequests: jest.fn(),
};
const azureDevOpsApi =
azureDevOpsApiMock as Partial<AzureDevOpsApi> as AzureDevOpsApi;
const Wrapper = (props: { children?: React.ReactNode }) => (
<TestApiProvider apis={[[azureDevOpsApiRef, azureDevOpsApi]]}>
{props.children}
</TestApiProvider>
);
it('should provide an array of PullRequest', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
annotations: {
'dev.azure.com/project-repo': 'projectName/repoName',
},
},
};
const pullRequests: PullRequest[] = [
{
pullRequestId: 1,
repoName: 'repo',
title: 'title-1',
createdBy: 'awanlin',
link: 'https://dev.azure.com/org/project/repo',
},
{
pullRequestId: 2,
repoName: 'repo',
title: 'title-2',
createdBy: 'awanlin',
link: 'https://dev.azure.com/org/project/repo',
},
{
pullRequestId: 3,
repoName: 'repo',
title: 'title-3',
createdBy: 'awanlin',
link: 'https://dev.azure.com/org/project/repo',
},
];
azureDevOpsApiMock.getPullRequests.mockResolvedValue({
items: pullRequests,
});
const { result } = renderHook(() => usePullRequests(entity), {
wrapper: Wrapper,
});
expect(result.current.loading).toEqual(true);
await waitFor(() => {
expect(result.current).toEqual({
error: undefined,
items: pullRequests,
loading: false,
});
});
});
it('should return throw when annotation missing', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
},
};
expect(() =>
renderHook(() => usePullRequests(entity), {
wrapper: Wrapper,
}),
).toThrow('Value for annotation "dev.azure.com/project" was not found');
});
it('should return throw when annotation invalid', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
annotations: {
'dev.azure.com/project-repo': 'fake',
},
},
};
expect(() =>
renderHook(() => usePullRequests(entity), {
wrapper: Wrapper,
}),
).toThrow(
'Invalid value for annotation "dev.azure.com/project-repo"; expected format is: <project-name>/<repo-name>, found: "fake"',
);
});
});
@@ -25,7 +25,7 @@ import { Entity } from '@backstage/catalog-model';
import { azureDevOpsApiRef } from '../api';
import { useApi } from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';
import { useProjectRepoFromEntity } from './useProjectRepoFromEntity';
import { getAnnotationValuesFromEntity } from '../utils';
export function usePullRequests(
entity: Entity,
@@ -44,10 +44,10 @@ export function usePullRequests(
};
const api = useApi(azureDevOpsApiRef);
const { project, repo } = useProjectRepoFromEntity(entity);
const { project, repo } = getAnnotationValuesFromEntity(entity);
const { value, loading, error } = useAsync(() => {
return api.getPullRequests(project, repo, options);
const { value, loading, error } = useAsync(async () => {
return await api.getPullRequests(project, repo as string, options);
}, [api, project, repo, top, status]);
return {
@@ -0,0 +1,108 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { Entity } from '@backstage/catalog-model';
import { Readme } from '@backstage/plugin-azure-devops-common';
import { TestApiProvider } from '@backstage/test-utils';
import { AzureDevOpsApi, azureDevOpsApiRef } from '../api';
import { useReadme } from './useReadme';
describe('useReadme', () => {
const azureDevOpsApiMock = {
getReadme: jest.fn(),
};
const azureDevOpsApi =
azureDevOpsApiMock as Partial<AzureDevOpsApi> as AzureDevOpsApi;
const Wrapper = (props: { children?: React.ReactNode }) => (
<TestApiProvider apis={[[azureDevOpsApiRef, azureDevOpsApi]]}>
{props.children}
</TestApiProvider>
);
it('should provide a Readme', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
annotations: {
'dev.azure.com/project-repo': 'projectName/repoName',
},
},
};
const readme: Readme = {
url: 'https://dev.azure.com/org/project/repo',
content: 'This is some fake README content',
};
azureDevOpsApiMock.getReadme.mockResolvedValue({
item: readme,
});
const { result } = renderHook(() => useReadme(entity), {
wrapper: Wrapper,
});
expect(result.current.loading).toEqual(true);
await waitFor(() => {
expect(result.current.item).toEqual({
item: readme,
});
});
});
it('should return throw when annotation missing', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
},
};
expect(() =>
renderHook(() => useReadme(entity), {
wrapper: Wrapper,
}),
).toThrow('Value for annotation "dev.azure.com/project" was not found');
});
it('should return throw when annotation invalid', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
annotations: {
'dev.azure.com/project-repo': 'fake',
},
},
};
expect(() =>
renderHook(() => useReadme(entity), {
wrapper: Wrapper,
}),
).toThrow(
'Invalid value for annotation "dev.azure.com/project-repo"; expected format is: <project-name>/<repo-name>, found: "fake"',
);
});
});
@@ -0,0 +1,42 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Readme } from '@backstage/plugin-azure-devops-common';
import { Entity } from '@backstage/catalog-model';
import { azureDevOpsApiRef } from '../api';
import { useApi } from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';
import { getAnnotationValuesFromEntity } from '../utils';
export function useReadme(entity: Entity): {
item?: Readme;
loading: boolean;
error?: Error;
} {
const api = useApi(azureDevOpsApiRef);
const { project, repo } = getAnnotationValuesFromEntity(entity);
const { value, loading, error } = useAsync(async () => {
return await api.getReadme({ project, repo: repo as string });
}, [api, project, repo]);
return {
item: value,
loading,
error,
};
}
@@ -0,0 +1,126 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { Entity } from '@backstage/catalog-model';
import { RepoBuild } from '@backstage/plugin-azure-devops-common';
import { TestApiProvider } from '@backstage/test-utils';
import { AzureDevOpsApi, azureDevOpsApiRef } from '../api';
import { useRepoBuilds } from './useRepoBuilds';
describe('useRepoBuilds', () => {
const azureDevOpsApiMock = {
getRepoBuilds: jest.fn(),
};
const azureDevOpsApi =
azureDevOpsApiMock as Partial<AzureDevOpsApi> as AzureDevOpsApi;
const Wrapper = (props: { children?: React.ReactNode }) => (
<TestApiProvider apis={[[azureDevOpsApiRef, azureDevOpsApi]]}>
{props.children}
</TestApiProvider>
);
it('should provide an array of RepoBuild', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
annotations: {
'dev.azure.com/project-repo': 'projectName/repoName',
},
},
};
const repoBuilds: RepoBuild[] = [
{
id: 1,
title: 'title-1',
source: 'awanlin',
link: 'https://dev.azure.com/org/project/repo',
},
{
id: 2,
title: 'title-2',
source: 'awanlin',
link: 'https://dev.azure.com/org/project/repo',
},
{
id: 3,
title: 'title-3',
source: 'awanlin',
link: 'https://dev.azure.com/org/project/repo',
},
];
azureDevOpsApiMock.getRepoBuilds.mockResolvedValue({
items: repoBuilds,
});
const { result } = renderHook(() => useRepoBuilds(entity), {
wrapper: Wrapper,
});
expect(result.current.loading).toEqual(true);
await waitFor(() => {
expect(result.current).toEqual({
error: undefined,
items: repoBuilds,
loading: false,
});
});
});
it('should return throw when annotation missing', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
},
};
expect(() =>
renderHook(() => useRepoBuilds(entity), {
wrapper: Wrapper,
}),
).toThrow('Value for annotation "dev.azure.com/project" was not found');
});
it('should return throw when annotation invalid', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
annotations: {
'dev.azure.com/project-repo': 'fake',
},
},
};
expect(() =>
renderHook(() => useRepoBuilds(entity), {
wrapper: Wrapper,
}),
).toThrow(
'Invalid value for annotation "dev.azure.com/project-repo"; expected format is: <project-name>/<repo-name>, found: "fake"',
);
});
});
@@ -24,7 +24,7 @@ import { Entity } from '@backstage/catalog-model';
import { azureDevOpsApiRef } from '../api';
import { useApi } from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';
import { useProjectRepoFromEntity } from './useProjectRepoFromEntity';
import { getAnnotationValuesFromEntity } from '../utils';
export function useRepoBuilds(
entity: Entity,
@@ -40,10 +40,10 @@ export function useRepoBuilds(
};
const api = useApi(azureDevOpsApiRef);
const { project, repo } = useProjectRepoFromEntity(entity);
const { project, repo } = getAnnotationValuesFromEntity(entity);
const { value, loading, error } = useAsync(() => {
return api.getRepoBuilds(project, repo, options);
const { value, loading, error } = useAsync(async () => {
return await api.getRepoBuilds(project, repo as string, options);
}, [api, project, repo, entity]);
return {
@@ -1,69 +0,0 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION,
AZURE_DEVOPS_PROJECT_ANNOTATION,
AZURE_DEVOPS_REPO_ANNOTATION,
} from '../constants';
import { Entity } from '@backstage/catalog-model';
export function getAnnotationFromEntity(entity: Entity): {
project: string;
repo?: string;
definition?: string;
} {
const annotation =
entity.metadata.annotations?.[AZURE_DEVOPS_REPO_ANNOTATION];
if (annotation) {
const { project, repo } = getProjectRepo(annotation);
const definition = undefined;
return { project, repo, definition };
}
const project =
entity.metadata.annotations?.[AZURE_DEVOPS_PROJECT_ANNOTATION];
if (!project) {
throw new Error('Value for annotation dev.azure.com/project was not found');
}
const definition =
entity.metadata.annotations?.[AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION];
if (!definition) {
throw new Error(
'Value for annotation dev.azure.com/build-definition was not found',
);
}
const repo = undefined;
return { project, repo, definition };
}
function getProjectRepo(annotation: string): {
project: string;
repo: string;
} {
const [project, repo] = annotation.split('/');
if (!project && !repo) {
throw new Error(
'Value for annotation dev.azure.com/project-repo was not in the correct format: <project-name>/<repo-name>',
);
}
return { project, repo };
}
@@ -0,0 +1,310 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { getAnnotationValuesFromEntity } from './getAnnotationValuesFromEntity';
describe('getAnnotationValuesFromEntity', () => {
describe('with valid project-repo annotation', () => {
it('should return project and repo', () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
annotations: {
'dev.azure.com/project-repo': 'projectName/repoName',
},
},
};
const values = getAnnotationValuesFromEntity(entity);
expect(values).toEqual({
project: 'projectName',
repo: 'repoName',
definition: undefined,
host: undefined,
org: undefined,
});
});
});
describe('with invalid project-repo annotation', () => {
it('should throw incorrect format error', () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
annotations: {
'dev.azure.com/project-repo': 'project',
},
},
};
const test = () => {
return getAnnotationValuesFromEntity(entity);
};
expect(test).toThrow(
'Invalid value for annotation "dev.azure.com/project-repo"; expected format is: <project-name>/<repo-name>, found: "project"',
);
});
});
describe('with project-repo annotation missing project', () => {
it('should throw missing project error', () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
annotations: {
'dev.azure.com/project-repo': '/repo',
},
},
};
const test = () => {
return getAnnotationValuesFromEntity(entity);
};
expect(test).toThrow(
'Invalid value for annotation "dev.azure.com/project-repo"; expected format is: <project-name>/<repo-name>, found: "/repo"',
);
});
});
describe('with project-repo annotation missing repo', () => {
it('should throw missing repo error', () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
annotations: {
'dev.azure.com/project-repo': 'project/',
},
},
};
const test = () => {
return getAnnotationValuesFromEntity(entity);
};
expect(test).toThrow(
'Invalid value for annotation "dev.azure.com/project-repo"; expected format is: <project-name>/<repo-name>, found: "project/"',
);
});
});
describe('with valid project and build-definition annotations', () => {
it('should return project and definition', () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-build-definition',
annotations: {
'dev.azure.com/project': 'projectName',
'dev.azure.com/build-definition': 'buildDefinitionName',
},
},
};
const values = getAnnotationValuesFromEntity(entity);
expect(values).toEqual({
project: 'projectName',
repo: undefined,
definition: 'buildDefinitionName',
host: undefined,
org: undefined,
});
});
});
describe('with only project annotation', () => {
it('should should throw annotation not found error', () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project',
annotations: {
'dev.azure.com/project': 'projectName',
},
},
};
const test = () => {
return getAnnotationValuesFromEntity(entity);
};
expect(test).toThrow(
'Value for annotation "dev.azure.com/build-definition" was not found',
);
});
});
describe('with only build-definition annotation', () => {
it('should should throw annotation not found error', () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'build-definition',
annotations: {
'dev.azure.com/build-definition': 'buildDefinitionName',
},
},
};
const test = () => {
return getAnnotationValuesFromEntity(entity);
};
expect(test).toThrow(
'Value for annotation "dev.azure.com/project" was not found',
);
});
});
describe('with valid project-repo and host-org annotations', () => {
it('should return project, repo, host, and org', () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-repo',
annotations: {
'dev.azure.com/project-repo': 'projectName/repoName',
'dev.azure.com/host-org': 'hostName/organizationName',
},
},
};
const values = getAnnotationValuesFromEntity(entity);
expect(values).toEqual({
project: 'projectName',
repo: 'repoName',
definition: undefined,
host: 'hostName',
org: 'organizationName',
});
});
});
describe('with valid project, build-definition, and host-org annotations', () => {
it('should return project, definition, host and org', () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'project-build-definition',
annotations: {
'dev.azure.com/project': 'projectName',
'dev.azure.com/build-definition': 'buildDefinitionName',
'dev.azure.com/host-org': 'hostName/organizationName',
},
},
};
const values = getAnnotationValuesFromEntity(entity);
expect(values).toEqual({
project: 'projectName',
repo: undefined,
definition: 'buildDefinitionName',
host: 'hostName',
org: 'organizationName',
});
});
});
describe('with invalid host-org annotation', () => {
it('should throw incorrect format error', () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'host-org',
annotations: {
'dev.azure.com/host-org': 'host',
},
},
};
const test = () => {
return getAnnotationValuesFromEntity(entity);
};
expect(test).toThrow(
'Invalid value for annotation "dev.azure.com/host-org"; expected format is: <host-name>/<organization-name>, found: "host"',
);
});
});
describe('with host-org annotation missing host', () => {
it('should throw missing project error', () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'host-org',
annotations: {
'dev.azure.com/host-org': '/org',
},
},
};
const test = () => {
return getAnnotationValuesFromEntity(entity);
};
expect(test).toThrow(
'Invalid value for annotation "dev.azure.com/host-org"; expected format is: <host-name>/<organization-name>, found: "/org"',
);
});
});
describe('with host-org annotation missing org', () => {
it('should throw missing repo error', () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'host-org',
annotations: {
'dev.azure.com/host-org': 'host/',
},
},
};
const test = () => {
return getAnnotationValuesFromEntity(entity);
};
expect(test).toThrow(
'Invalid value for annotation "dev.azure.com/host-org"; expected format is: <host-name>/<organization-name>, found: "host/"',
);
});
});
});
@@ -0,0 +1,103 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION,
AZURE_DEVOPS_HOST_ORG_ANNOTATION,
AZURE_DEVOPS_PROJECT_ANNOTATION,
AZURE_DEVOPS_REPO_ANNOTATION,
} from '../constants';
import { Entity } from '@backstage/catalog-model';
export function getAnnotationValuesFromEntity(entity: Entity): {
project: string;
repo?: string;
definition?: string;
host?: string;
org?: string;
} {
const { host, org } = getHostOrg(entity.metadata.annotations);
const projectRepoValues = getProjectRepo(entity.metadata.annotations);
if (projectRepoValues.project && projectRepoValues.repo) {
return {
project: projectRepoValues.project,
repo: projectRepoValues.repo,
host,
org,
};
}
const project =
entity.metadata.annotations?.[AZURE_DEVOPS_PROJECT_ANNOTATION];
if (!project) {
throw new Error(
`Value for annotation "${AZURE_DEVOPS_PROJECT_ANNOTATION}" was not found`,
);
}
const definition =
entity.metadata.annotations?.[AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION];
if (!definition) {
throw new Error(
`Value for annotation "${AZURE_DEVOPS_BUILD_DEFINITION_ANNOTATION}" was not found`,
);
}
return { project, definition, host, org };
}
function getProjectRepo(annotations?: Record<string, string>): {
project?: string;
repo?: string;
} {
const annotation = annotations?.[AZURE_DEVOPS_REPO_ANNOTATION];
if (!annotation) {
return { project: undefined, repo: undefined };
}
if (annotation.includes('/')) {
const [project, repo] = annotation.split('/');
if (project && repo) {
return { project, repo };
}
}
throw new Error(
`Invalid value for annotation "${AZURE_DEVOPS_REPO_ANNOTATION}"; expected format is: <project-name>/<repo-name>, found: "${annotation}"`,
);
}
function getHostOrg(annotations?: Record<string, string>): {
host?: string;
org?: string;
} {
const annotation = annotations?.[AZURE_DEVOPS_HOST_ORG_ANNOTATION];
if (!annotation) {
return { host: undefined, org: undefined };
}
if (annotation.includes('/')) {
const [host, org] = annotation.split('/');
if (host && org) {
return { host, org };
}
}
throw new Error(
`Invalid value for annotation "${AZURE_DEVOPS_HOST_ORG_ANNOTATION}"; expected format is: <host-name>/<organization-name>, found: "${annotation}"`,
);
}
+1 -1
View File
@@ -17,4 +17,4 @@
export * from './arrayHas';
export * from './equalsIgnoreCase';
export * from './getDurationFromDates';
export * from './getAnnotationFromEntity';
export * from './getAnnotationValuesFromEntity';
@@ -0,0 +1,13 @@
## API Report File for "@backstage/plugin-bazaar-backend"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
// @alpha
const _default: () => BackendFeature;
export default _default;
// (No @packageDocumentation comment for this package)
```
-5
View File
@@ -3,17 +3,12 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
import { Config } from '@backstage/config';
import express from 'express';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { Logger } from 'winston';
import { PluginDatabaseManager } from '@backstage/backend-common';
// @alpha
const bazaarPlugin: () => BackendFeature;
export default bazaarPlugin;
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
+17 -5
View File
@@ -5,10 +5,22 @@
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts",
"alphaTypes": "dist/index.alpha.d.ts"
"access": "public"
},
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.ts",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"alpha": [
"src/alpha.ts"
],
"package.json": [
"package.json"
]
}
},
"backstage": {
"role": "backend-plugin"
@@ -21,7 +33,7 @@
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build --experimental-type-build",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"prepack": "backstage-cli package prepack",
@@ -26,7 +26,7 @@ import { createRouter } from './service/router';
*
* @alpha
*/
export const bazaarPlugin = createBackendPlugin({
export default createBackendPlugin({
pluginId: 'bazaar',
register(env) {
env.registerInit({
-1
View File
@@ -15,4 +15,3 @@
*/
export * from './service/router';
export { bazaarPlugin as default } from './plugin';
@@ -13,18 +13,19 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fetch from 'node-fetch';
import {
getGitLabRequestOptions,
GitLabIntegrationConfig,
} from '@backstage/integration';
import fetch from 'node-fetch';
import { Logger } from 'winston';
import {
GitLabGroup,
GitLabDescendantGroupsResponse,
GitLabGroup,
GitLabGroupMembersResponse,
GitLabUser,
PagedResponse,
} from './types';
export type CommonListOptions = {
@@ -44,11 +45,6 @@ interface UserListOptions extends CommonListOptions {
exclude_internal?: boolean | undefined;
}
export type PagedResponse<T> = {
items: T[];
nextPage?: number;
};
export class GitLabClient {
private readonly config: GitLabIntegrationConfig;
private readonly logger: Logger;
@@ -13,9 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TaskScheduleDefinition } from '@backstage/backend-tasks';
export type PagedResponse<T> = {
items: T[];
nextPage?: number;
};
export type GitlabGroupDescription = {
id: number;
web_url: string;
@@ -13,31 +13,31 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PluginTaskScheduler, TaskRunner } from '@backstage/backend-tasks';
import {
ANNOTATION_LOCATION,
ANNOTATION_ORIGIN_LOCATION,
Entity,
GroupEntity,
UserEntity,
} from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { GitLabIntegration, ScmIntegrations } from '@backstage/integration';
import {
EntityProvider,
EntityProviderConnection,
} from '@backstage/plugin-catalog-node';
import { merge } from 'lodash';
import * as uuid from 'uuid';
import { Logger } from 'winston';
import {
GitLabClient,
GitlabProviderConfig,
paginated,
readGitlabConfigs,
} from '../lib';
import { GitLabGroup, GitLabUser } from '../lib/types';
import {
ANNOTATION_LOCATION,
ANNOTATION_ORIGIN_LOCATION,
Entity,
UserEntity,
GroupEntity,
} from '@backstage/catalog-model';
import { merge } from 'lodash';
import { GitLabGroup, GitLabUser, PagedResponse } from '../lib/types';
type Result = {
scanned: number;
@@ -245,9 +245,14 @@ export class GitlabOrgDiscoveryEntityProvider implements EntityProvider {
groupRes.scanned++;
groupRes.matches.push(group);
const groupUsers = await client.getGroupMembers(group.full_path, [
'DIRECT',
]);
let groupUsers: PagedResponse<GitLabUser> = { items: [] };
try {
groupUsers = await client.getGroupMembers(group.full_path, ['DIRECT']);
} catch (e) {
logger.error(
`Failed fetching users for group '${group.full_path}': ${e}`,
);
}
for (const groupUser of groupUsers.items) {
const user = idMappedUser[groupUser.id];
+32 -2
View File
@@ -142,7 +142,7 @@ export const columnFactories: Readonly<{
export type DefaultEntityFilters = {
kind?: EntityKindFilter;
type?: EntityTypeFilter;
user?: UserListFilter;
user?: UserListFilter | EntityUserFilter;
owners?: EntityOwnerFilter;
lifecycles?: EntityLifecycleFilter;
tags?: EntityTagFilter;
@@ -224,6 +224,8 @@ export class EntityLifecycleFilter implements EntityFilter {
// (undocumented)
filterEntity(entity: Entity): boolean;
// (undocumented)
getCatalogFilters(): Record<string, string | string[]>;
// (undocumented)
toQueryValue(): string[];
// (undocumented)
readonly values: string[];
@@ -275,6 +277,8 @@ export class EntityNamespaceFilter implements EntityFilter {
// (undocumented)
filterEntity(entity: Entity): boolean;
// (undocumented)
getCatalogFilters(): Record<string, string | string[]>;
// (undocumented)
toQueryValue(): string[];
// (undocumented)
readonly values: string[];
@@ -289,6 +293,8 @@ export class EntityOrphanFilter implements EntityFilter {
// (undocumented)
filterEntity(entity: Entity): boolean;
// (undocumented)
getCatalogFilters(): Record<string, string | string[]>;
// (undocumented)
readonly value: boolean;
}
@@ -297,6 +303,8 @@ export class EntityOwnerFilter implements EntityFilter {
constructor(values: string[]);
// (undocumented)
filterEntity(entity: Entity): boolean;
// (undocumented)
getCatalogFilters(): Record<string, string | string[]>;
toQueryValue(): string[];
// (undocumented)
readonly values: string[];
@@ -447,6 +455,8 @@ export class EntityTagFilter implements EntityFilter {
// (undocumented)
filterEntity(entity: Entity): boolean;
// (undocumented)
getCatalogFilters(): Record<string, string | string[]>;
// (undocumented)
toQueryValue(): string[];
// (undocumented)
readonly values: string[];
@@ -497,6 +507,26 @@ export interface EntityTypePickerProps {
initialFilter?: string;
}
// @public (undocumented)
export class EntityUserFilter implements EntityFilter {
// (undocumented)
static all(): EntityUserFilter;
// (undocumented)
filterEntity(entity: Entity): boolean;
// (undocumented)
getCatalogFilters(): Record<string, string[]>;
// (undocumented)
static owned(ownershipEntityRefs: string[]): EntityUserFilter;
// (undocumented)
readonly refs?: string[] | undefined;
// (undocumented)
static starred(starredEntityRefs: string[]): EntityUserFilter;
// (undocumented)
toQueryValue(): string;
// (undocumented)
readonly value: UserListFilterKind;
}
// @public
export const FavoriteEntity: (
props: FavoriteEntityProps,
@@ -620,7 +650,7 @@ export function useRelatedEntities(
error: Error | undefined;
};
// @public
// @public @deprecated
export class UserListFilter implements EntityFilter {
constructor(
value: UserListFilterKind,
@@ -16,15 +16,19 @@
import React from 'react';
import { fireEvent, render, waitFor, screen } from '@testing-library/react';
import {
Entity,
RELATION_OWNED_BY,
UserEntity,
} from '@backstage/catalog-model';
import { UserListPicker } from './UserListPicker';
import { UserEntity } from '@backstage/catalog-model';
import { UserListPicker, UserListPickerProps } from './UserListPicker';
import { MockEntityListContextProvider } from '../../testUtils/providers';
import { EntityTagFilter, UserListFilter } from '../../filters';
import { CatalogApi } from '@backstage/catalog-client';
import {
EntityKindFilter,
EntityNamespaceFilter,
EntityTagFilter,
EntityUserFilter,
} from '../../filters';
import {
CatalogApi,
QueryEntitiesInitialRequest,
} from '@backstage/catalog-client';
import { catalogApiRef } from '../../api';
import { MockStorageApi, TestApiRegistry } from '@backstage/test-utils';
import { ApiProvider } from '@backstage/core-app-api';
@@ -35,7 +39,7 @@ import {
identityApiRef,
storageApiRef,
} from '@backstage/core-plugin-api';
import { useEntityOwnership } from '../../hooks';
import { MockStarredEntitiesApi, starredEntitiesApiRef } from '../../apis';
const mockUser: UserEntity = {
apiVersion: 'backstage.io/v1alpha1',
@@ -54,136 +58,151 @@ const mockConfigApi = {
} as Partial<ConfigApi>;
const mockCatalogApi = {
getEntityByRef: () => Promise.resolve(mockUser),
} as Partial<CatalogApi>;
getEntityByRef: jest.fn(),
queryEntities: jest.fn(),
} as Partial<jest.Mocked<CatalogApi>>;
const mockIdentityApi = {
getUserId: () => 'testUser',
getIdToken: async () => undefined,
} as Partial<IdentityApi>;
getBackstageIdentity: jest.fn(),
} as Partial<jest.Mocked<IdentityApi>>;
const mockStarredEntitiesApi = new MockStarredEntitiesApi();
const apis = TestApiRegistry.from(
[configApiRef, mockConfigApi],
[catalogApiRef, mockCatalogApi],
[identityApiRef, mockIdentityApi],
[storageApiRef, MockStorageApi.create()],
[starredEntitiesApiRef, mockStarredEntitiesApi],
);
const mockIsOwnedEntity = jest.fn(
(entity: Entity) => entity.metadata.name === 'component-1',
);
const mockIsStarredEntity = jest.fn(
(entity: Entity) => entity.metadata.name === 'component-3',
);
jest.mock('../../hooks', () => {
const actual = jest.requireActual('../../hooks');
return {
...actual,
useEntityOwnership: jest.fn(() => ({
isOwnedEntity: mockIsOwnedEntity,
})),
useStarredEntities: () => ({
isStarredEntity: mockIsStarredEntity,
}),
};
});
const backendEntities: Entity[] = [
{
apiVersion: '1',
kind: 'Component',
metadata: {
namespace: 'namespace-1',
name: 'component-1',
tags: ['tag1'],
},
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'user:default/testuser',
},
],
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
namespace: 'namespace-2',
name: 'component-2',
tags: ['tag1'],
},
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
namespace: 'namespace-2',
name: 'component-3',
tags: [],
},
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
namespace: 'namespace-2',
name: 'component-4',
tags: [],
},
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'user:default/testuser',
},
],
},
];
const ownershipEntityRefs = ['user:default/testuser'];
describe('<UserListPicker />', () => {
it('renders filter groups', () => {
const mockQueryEntitiesImplementation: CatalogApi['queryEntities'] =
async request => {
if (
(
(request as QueryEntitiesInitialRequest).filter as Record<
string,
string
>
)['relations.ownedBy']
) {
// owned entities
return { items: [], totalItems: 3, pageInfo: {} };
}
if (
(
(request as QueryEntitiesInitialRequest).filter as Record<
string,
string
>
)['metadata.name']
) {
// starred entities
return {
items: [
{
apiVersion: '1',
kind: 'component',
metadata: { name: 'e-1', namespace: 'default' },
},
{
apiVersion: '1',
kind: 'component',
metadata: { name: 'e-2', namespace: 'default' },
},
],
totalItems: 2,
pageInfo: {},
};
}
// all items
return { items: [], totalItems: 10, pageInfo: {} };
};
beforeAll(() => {
mockStarredEntitiesApi.toggleStarred('component:default/e-1');
mockStarredEntitiesApi.toggleStarred('component:default/e-2');
});
beforeEach(() => {
mockCatalogApi.getEntityByRef?.mockResolvedValue(mockUser);
mockIdentityApi.getBackstageIdentity?.mockResolvedValue({
ownershipEntityRefs,
type: 'user',
userEntityRef: 'user:default/testuser',
});
mockCatalogApi.queryEntities?.mockImplementation(
mockQueryEntitiesImplementation,
);
});
afterEach(() => {
jest.resetAllMocks();
});
it('renders filter groups', async () => {
render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider value={{ backendEntities }}>
<MockEntityListContextProvider value={{}}>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
await waitFor(() =>
expect(mockIdentityApi.getBackstageIdentity).toHaveBeenCalled(),
);
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalled(),
);
expect(screen.getByText('Personal')).toBeInTheDocument();
expect(screen.getByText('Test Company')).toBeInTheDocument();
});
it('renders filters', () => {
it('renders filters', async () => {
render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider value={{ backendEntities }}>
<MockEntityListContextProvider
value={{
filters: { namespace: new EntityNamespaceFilter(['default']) },
}}
>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(
screen.getAllByRole('menuitem').map(({ textContent }) => textContent),
).toEqual(['Owned 1', 'Starred 1', 'All 4']);
});
it('includes counts alongside each filter', async () => {
render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider value={{ backendEntities }}>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
await waitFor(() =>
expect(mockIdentityApi.getBackstageIdentity).toHaveBeenCalled(),
);
// Material UI renders ListItemSecondaryActions outside the
// menuitem itself, so we pick off the next sibling.
await waitFor(() => {
await waitFor(() =>
expect(
screen.getAllByRole('menuitem').map(({ textContent }) => textContent),
).toEqual(['Owned 1', 'Starred 1', 'All 4']);
).toEqual(['Owned 3', 'Starred 2', 'All 10']),
);
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: {
'metadata.namespace': ['default'],
},
limit: 0,
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: {
'metadata.namespace': ['default'],
'relations.ownedBy': ['user:default/testuser'],
},
limit: 0,
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: {
'metadata.namespace': ['default'],
'metadata.name': ['e-1', 'e-2'],
},
limit: 1000,
});
});
@@ -192,7 +211,6 @@ describe('<UserListPicker />', () => {
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
backendEntities,
filters: { tags: new EntityTagFilter(['tag1']) },
}}
>
@@ -204,35 +222,74 @@ describe('<UserListPicker />', () => {
await waitFor(() => {
expect(
screen.getAllByRole('menuitem').map(({ textContent }) => textContent),
).toEqual(['Owned 1', 'Starred 0', 'All 2']);
).toEqual(['Owned 3', 'Starred 2', 'All 10']);
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: { 'metadata.tags': ['tag1'] },
limit: 0,
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: { 'metadata.name': ['e-1', 'e-2'], 'metadata.tags': ['tag1'] },
limit: 1000,
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: {
'relations.ownedBy': ['user:default/testuser'],
'metadata.tags': ['tag1'],
},
limit: 0,
});
});
it('respects the query parameter filter value', () => {
it('respects the query parameter filter value', async () => {
const updateFilters = jest.fn();
const queryParameters = { user: 'owned' };
const queryParameters = { user: 'owned', kind: 'component' };
render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ backendEntities, updateFilters, queryParameters }}
value={{
updateFilters,
queryParameters,
filters: { kind: new EntityKindFilter('component') },
}}
>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
await waitFor(() =>
expect(mockIdentityApi.getBackstageIdentity).toHaveBeenCalled(),
);
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter('owned', mockIsOwnedEntity, mockIsStarredEntity),
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: EntityUserFilter.owned(ownershipEntityRefs),
}),
);
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: { kind: 'component' },
limit: 0,
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: { kind: 'component', 'metadata.name': ['e-1', 'e-2'] },
limit: 1000,
});
expect(mockCatalogApi.queryEntities).toHaveBeenCalledWith({
filter: {
kind: 'component',
'relations.ownedBy': ['user:default/testuser'],
},
limit: 0,
});
});
it('updates user filter when a menuitem is selected', () => {
it('updates user filter when a menuitem is selected', async () => {
const updateFilters = jest.fn();
render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ backendEntities, updateFilters }}
>
<MockEntityListContextProvider value={{ updateFilters }}>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
@@ -240,40 +297,55 @@ describe('<UserListPicker />', () => {
fireEvent.click(screen.getByText('Starred'));
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter(
'starred',
mockIsOwnedEntity,
mockIsStarredEntity,
),
});
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: EntityUserFilter.starred([
'component:default/e-1',
'component:default/e-2',
]),
}),
);
});
it('responds to external queryParameters changes', () => {
it('responds to external queryParameters changes', async () => {
const updateFilters = jest.fn();
const rendered = render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
backendEntities,
updateFilters,
queryParameters: { user: ['all'] },
queryParameters: { user: ['all'], kind: 'component' },
filters: {
kind: new EntityKindFilter('component'),
user: undefined,
},
}}
>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter('all', mockIsOwnedEntity, mockIsStarredEntity),
});
await waitFor(() =>
expect(mockIdentityApi.getBackstageIdentity).toHaveBeenCalled(),
);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: EntityUserFilter.all(),
}),
);
rendered.rerender(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
backendEntities,
updateFilters,
queryParameters: { user: ['owned'] },
queryParameters: { user: ['owned'], kind: 'component' },
filters: {
kind: new EntityKindFilter('component'),
user: undefined,
},
}}
>
<UserListPicker />
@@ -281,101 +353,237 @@ describe('<UserListPicker />', () => {
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter('owned', mockIsOwnedEntity, mockIsStarredEntity),
user: EntityUserFilter.owned(ownershipEntityRefs),
});
});
describe.each`
type | filterFn
${'owned'} | ${mockIsOwnedEntity}
${'starred'} | ${mockIsStarredEntity}
`('filter resetting for $type entities', ({ type, filterFn }) => {
let updateFilters: jest.Mock;
describe('filter resetting', () => {
const updateFilters = jest.fn();
const picker = (props: { loading: boolean }) => (
const Picker = ({ ...props }: UserListPickerProps) => (
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ backendEntities, updateFilters, loading: props.loading }}
value={{
updateFilters,
filters: { kind: new EntityKindFilter('component') },
}}
>
<UserListPicker initialFilter={type} />
<UserListPicker {...props} />
</MockEntityListContextProvider>
</ApiProvider>
);
beforeEach(() => {
updateFilters = jest.fn();
});
describe(`when there are no owned entities matching the filter`, () => {
it('does not reset the filter while entities are loading', async () => {
mockCatalogApi.queryEntities?.mockReturnValue(new Promise(() => {}));
describe(`when there are no ${type} entities match the filter`, () => {
beforeEach(() => {
filterFn.mockReturnValue(false);
render(<Picker initialFilter="owned" />);
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalled(),
);
await expect(() =>
waitFor(() => expect(updateFilters).toHaveBeenCalled()),
).rejects.toThrow();
});
it('does not reset the filter while entities are loading', () => {
render(picker({ loading: true }));
it('does not reset the filter while owned entities are loading', async () => {
mockCatalogApi.queryEntities?.mockImplementation(request => {
if (
(
(request as QueryEntitiesInitialRequest).filter as Record<
string,
string
>
)['relations.ownedBy']
) {
return new Promise(() => {});
}
return mockQueryEntitiesImplementation(request);
});
render(<Picker initialFilter="owned" />);
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3),
);
expect(updateFilters).not.toHaveBeenCalledWith({
user: new UserListFilter(
'all',
mockIsOwnedEntity,
mockIsStarredEntity,
),
user: expect.any(Object),
});
});
it('does not reset the filter while owned entities are loading', () => {
const isOwnedEntity = jest.fn(() => false);
(useEntityOwnership as jest.Mock).mockReturnValueOnce({
loading: true,
isOwnedEntity,
it('resets the filter to "all" when entities are loaded', async () => {
mockCatalogApi.queryEntities?.mockImplementation(async request => {
if (
(
(request as QueryEntitiesInitialRequest).filter as Record<
string,
string
>
)['relations.ownedBy']
) {
return { items: [], totalItems: 0, pageInfo: {} };
}
return mockQueryEntitiesImplementation(request);
});
render(picker({ loading: false }));
expect(updateFilters).not.toHaveBeenCalledWith({
user: new UserListFilter('all', isOwnedEntity, mockIsStarredEntity),
});
});
render(<Picker initialFilter="owned" />);
it('resets the filter to "all" when entities are loaded', () => {
render(picker({ loading: false }));
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter(
'all',
mockIsOwnedEntity,
mockIsStarredEntity,
),
});
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: EntityUserFilter.all(),
}),
);
});
});
describe(`when there are some ${type} entities present`, () => {
beforeEach(() => {
filterFn.mockReturnValue(true);
describe(`when there are no starred entities match the filter`, () => {
it('does not reset the filter while entities are loading', async () => {
mockCatalogApi.queryEntities?.mockImplementation(
() => new Promise(() => {}),
);
render(<Picker initialFilter="starred" />);
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalled(),
);
expect(updateFilters).not.toHaveBeenCalled();
});
it('does not reset the filter while entities are loading', () => {
render(picker({ loading: true }));
it('does not reset the filter while starred entities are loading', async () => {
mockCatalogApi.queryEntities?.mockImplementation(request => {
if (
(
(request as QueryEntitiesInitialRequest).filter as Record<
string,
string
>
)['metadata.name']
) {
return new Promise(() => {});
}
return mockQueryEntitiesImplementation(request);
});
render(<Picker initialFilter="starred" />);
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3),
);
expect(updateFilters).not.toHaveBeenCalledWith({
user: new UserListFilter(
'all',
mockIsOwnedEntity,
mockIsStarredEntity,
),
user: expect.any(Object),
});
});
it('does not reset the filter when entities are loaded', () => {
render(picker({ loading: false }));
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter(
type,
mockIsOwnedEntity,
mockIsStarredEntity,
),
it('resets the filter to "all" when entities are loaded', async () => {
mockCatalogApi.queryEntities?.mockImplementation(async request => {
if (
(
(request as QueryEntitiesInitialRequest).filter as Record<
string,
string
>
)['metadata.name']
) {
return { items: [], totalItems: 0, pageInfo: {} };
}
return mockQueryEntitiesImplementation(request);
});
render(<Picker initialFilter="starred" />);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: EntityUserFilter.all(),
}),
);
});
});
describe(`when there are some owned entities present`, () => {
it('does not reset the filter while entities are loading', async () => {
mockCatalogApi.queryEntities?.mockImplementation(request => {
if (
(
(request as QueryEntitiesInitialRequest).filter as Record<
string,
string
>
)['relations.ownedBy']
) {
return new Promise(() => {});
}
return mockQueryEntitiesImplementation(request);
});
render(<Picker initialFilter="owned" />);
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3),
);
expect(updateFilters).not.toHaveBeenCalledWith({
user: EntityUserFilter.all(),
});
});
it('does not reset the filter when entities are loaded', async () => {
render(<Picker initialFilter="owned" />);
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3),
);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: EntityUserFilter.owned(expect.any(Array)),
}),
);
});
});
describe(`when there are some starred entities present`, () => {
it('does not reset the filter while entities are loading', async () => {
mockCatalogApi.queryEntities?.mockImplementation(request => {
if (
(
(request as QueryEntitiesInitialRequest).filter as Record<
string,
string
>
)['metadata.name']
) {
return new Promise(() => {});
}
return mockQueryEntitiesImplementation(request);
});
render(<Picker initialFilter="starred" />);
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3),
);
expect(updateFilters).not.toHaveBeenCalledWith({
user: EntityUserFilter.all(),
});
});
it('does not reset the filter when entities are loaded', async () => {
render(<Picker initialFilter="starred" />);
await waitFor(() =>
expect(mockCatalogApi.queryEntities).toHaveBeenCalledTimes(3),
);
await waitFor(() =>
expect(updateFilters).toHaveBeenLastCalledWith({
user: EntityUserFilter.starred([
'component:default/e-1',
'component:default/e-2',
]),
}),
);
});
});
});
@@ -32,16 +32,13 @@ import {
} from '@material-ui/core';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
import { compact } from 'lodash';
import React, { Fragment, useEffect, useMemo, useState } from 'react';
import { UserListFilter } from '../../filters';
import {
useEntityList,
useStarredEntities,
useEntityOwnership,
} from '../../hooks';
import { EntityUserFilter } from '../../filters';
import { useEntityList } from '../../hooks';
import { UserListFilterKind } from '../../types';
import { reduceEntityFilters } from '../../utils';
import { useOwnedEntitiesCount } from './useOwnedEntitiesCount';
import { useAllEntitiesCount } from './useAllEntitiesCount';
import { useStarredEntitiesCount } from './useStarredEntitiesCount';
/** @public */
export type CatalogReactUserListPickerClassKey =
@@ -133,9 +130,7 @@ export const UserListPicker = (props: UserListPickerProps) => {
const {
filters,
updateFilters,
backendEntities,
queryParameters: { kind: kindParameter, user: userParameter },
loading: loadingBackendEntities,
} = useEntityList();
// Remove group items that aren't in availableFilters and exclude
@@ -153,21 +148,17 @@ export const UserListPicker = (props: UserListPickerProps) => {
}))
.filter(({ items }) => !!items.length);
const { isStarredEntity } = useStarredEntities();
const { isOwnedEntity, loading: loadingEntityOwnership } =
useEntityOwnership();
const loading = loadingBackendEntities || loadingEntityOwnership;
// Static filters; used for generating counts of potentially unselected kinds
const ownedFilter = useMemo(
() => new UserListFilter('owned', isOwnedEntity, isStarredEntity),
[isOwnedEntity, isStarredEntity],
);
const starredFilter = useMemo(
() => new UserListFilter('starred', isOwnedEntity, isStarredEntity),
[isOwnedEntity, isStarredEntity],
);
const {
count: ownedEntitiesCount,
loading: loadingOwnedEntities,
filter: ownedEntitiesFilter,
} = useOwnedEntitiesCount();
const { count: allCount } = useAllEntitiesCount();
const {
count: starredEntitiesCount,
filter: starredEntitiesFilter,
loading: loadingStarredEntities,
} = useStarredEntitiesCount();
const queryParamUserFilter = useMemo(
() => [userParameter].flat()[0],
@@ -175,33 +166,16 @@ export const UserListPicker = (props: UserListPickerProps) => {
);
const [selectedUserFilter, setSelectedUserFilter] = useState(
queryParamUserFilter ?? initialFilter,
(queryParamUserFilter as UserListFilterKind) ?? initialFilter,
);
// To show proper counts for each section, apply all other frontend filters _except_ the user
// filter that's controlled by this picker.
const entitiesWithoutUserFilter = useMemo(
() =>
backendEntities.filter(
reduceEntityFilters(
compact(Object.values({ ...filters, user: undefined })),
),
),
[filters, backendEntities],
);
const filterCounts = useMemo<Record<string, number>>(
() => ({
all: entitiesWithoutUserFilter.length,
starred: entitiesWithoutUserFilter.filter(entity =>
starredFilter.filterEntity(entity),
).length,
owned: entitiesWithoutUserFilter.filter(entity =>
ownedFilter.filterEntity(entity),
).length,
}),
[entitiesWithoutUserFilter, starredFilter, ownedFilter],
);
const filterCounts = useMemo(() => {
return {
all: allCount,
starred: starredEntitiesCount,
owned: ownedEntitiesCount,
};
}, [starredEntitiesCount, ownedEntitiesCount, allCount]);
// Set selected user filter on query parameter updates; this happens at initial page load and from
// external updates to the page location.
@@ -211,6 +185,8 @@ export const UserListPicker = (props: UserListPickerProps) => {
}
}, [queryParamUserFilter]);
const loading = loadingOwnedEntities || loadingStarredEntities;
useEffect(() => {
if (
!loading &&
@@ -223,16 +199,32 @@ export const UserListPicker = (props: UserListPickerProps) => {
}, [loading, filterCounts, selectedUserFilter, setSelectedUserFilter]);
useEffect(() => {
updateFilters({
user: selectedUserFilter
? new UserListFilter(
selectedUserFilter as UserListFilterKind,
isOwnedEntity,
isStarredEntity,
)
: undefined,
});
}, [selectedUserFilter, isOwnedEntity, isStarredEntity, updateFilters]);
if (!selectedUserFilter) {
return;
}
if (loading) {
return;
}
const getFilter = () => {
if (selectedUserFilter === 'owned') {
return ownedEntitiesFilter;
}
if (selectedUserFilter === 'starred') {
return starredEntitiesFilter;
}
return EntityUserFilter.all();
};
updateFilters({ user: getFilter() });
}, [
selectedUserFilter,
starredEntitiesFilter,
ownedEntitiesFilter,
updateFilters,
loading,
]);
return (
<Card className={classes.root}>
@@ -0,0 +1,105 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { PropsWithChildren } from 'react';
import { CatalogApi } from '@backstage/catalog-client';
import { useAllEntitiesCount } from './useAllEntitiesCount';
import { renderHook, waitFor } from '@testing-library/react';
import { EntityListProvider, useEntityList } from '../../hooks';
import { catalogApiRef } from '../../api';
import { ApiRef } from '@backstage/core-plugin-api';
import { MemoryRouter } from 'react-router-dom';
import { EntityOwnerFilter } from '../../filters';
import { useMountEffect } from '@react-hookz/web';
const mockQueryEntities: jest.MockedFn<CatalogApi['queryEntities']> = jest.fn();
const mockCatalogApi: jest.Mocked<Partial<CatalogApi>> = {
queryEntities: mockQueryEntities,
};
jest.mock('@backstage/core-plugin-api', () => {
const actual = jest.requireActual('@backstage/core-plugin-api');
return {
...actual,
useApi: (ref: ApiRef<any>) =>
ref === catalogApiRef ? mockCatalogApi : actual.useApi(ref),
};
});
describe('useAllEntitiesCount', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return the count', async () => {
mockQueryEntities.mockResolvedValue({
items: [],
totalItems: 10,
pageInfo: {},
});
function WrapFilters(props: PropsWithChildren<{}>) {
const { updateFilters } = useEntityList();
useMountEffect(() => {
updateFilters({
owners: new EntityOwnerFilter(['user:default/owner']),
});
});
return <>{props.children}</>;
}
const { result } = renderHook(() => useAllEntitiesCount(), {
wrapper: ({ children }) => (
<MemoryRouter>
<EntityListProvider>
<WrapFilters>{children}</WrapFilters>
</EntityListProvider>
</MemoryRouter>
),
});
await waitFor(() =>
expect(mockQueryEntities).toHaveBeenCalledWith({
filter: {
'relations.ownedBy': ['user:default/owner'],
},
limit: 0,
}),
);
expect(result.current).toEqual({ count: 10, loading: false });
});
it(`shouldn't invoke the endpoint at startup, when filters are missing`, async () => {
mockQueryEntities.mockResolvedValue({
items: [],
totalItems: 10,
pageInfo: {},
});
const { result } = renderHook(() => useAllEntitiesCount(), {
wrapper: ({ children }) => (
<MemoryRouter>
<EntityListProvider>{children}</EntityListProvider>
</MemoryRouter>
),
});
await expect(
waitFor(() => expect(mockQueryEntities).toHaveBeenCalled()),
).rejects.toThrow();
expect(result.current).toEqual({ count: 0, loading: false });
});
});
@@ -0,0 +1,61 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { QueryEntitiesInitialRequest } from '@backstage/catalog-client';
import { useApi } from '@backstage/core-plugin-api';
import { compact, isEqual } from 'lodash';
import { useMemo, useRef } from 'react';
import useAsync from 'react-use/lib/useAsync';
import { catalogApiRef } from '../../api';
import { useEntityList } from '../../hooks';
import { reduceCatalogFilters } from '../../utils';
export function useAllEntitiesCount() {
const catalogApi = useApi(catalogApiRef);
const { filters } = useEntityList();
const prevRequest = useRef<QueryEntitiesInitialRequest>();
const request = useMemo(() => {
const { user, ...allFilters } = filters;
const compacted = compact(Object.values(allFilters));
const filter = reduceCatalogFilters(compacted);
const newRequest: QueryEntitiesInitialRequest = {
filter,
limit: 0,
};
if (Object.keys(filter).length === 0) {
prevRequest.current = undefined;
return prevRequest.current;
}
if (isEqual(newRequest, prevRequest.current)) {
return prevRequest.current;
}
prevRequest.current = newRequest;
return newRequest;
}, [filters]);
const { value: count, loading } = useAsync(async () => {
if (request === undefined) {
return 0;
}
const { totalItems } = await catalogApi.queryEntities(request);
return totalItems;
}, [request]);
return { count, loading };
}
@@ -0,0 +1,235 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { PropsWithChildren } from 'react';
import { CatalogApi } from '@backstage/catalog-client';
import { renderHook, waitFor } from '@testing-library/react';
import {
DefaultEntityFilters,
EntityListProvider,
useEntityList,
} from '../../hooks';
import { catalogApiRef } from '../../api';
import {
ApiRef,
IdentityApi,
identityApiRef,
} from '@backstage/core-plugin-api';
import { MemoryRouter } from 'react-router-dom';
import { useOwnedEntitiesCount } from './useOwnedEntitiesCount';
import {
EntityNamespaceFilter,
EntityOwnerFilter,
EntityUserFilter,
} from '../../filters';
import { useMountEffect } from '@react-hookz/web';
const mockQueryEntities: jest.MockedFn<CatalogApi['queryEntities']> = jest.fn();
const mockCatalogApi: jest.Mocked<Partial<CatalogApi>> = {
queryEntities: mockQueryEntities,
};
const mockGetBackstageIdentity: jest.MockedFn<
IdentityApi['getBackstageIdentity']
> = jest.fn();
jest.mock('@backstage/core-plugin-api', () => {
const actual = jest.requireActual('@backstage/core-plugin-api');
return {
...actual,
useApi: (ref: ApiRef<any>) => {
if (ref === catalogApiRef) {
return mockCatalogApi;
}
if (ref === identityApiRef) {
return {
getBackstageIdentity: mockGetBackstageIdentity,
};
}
return actual.useApi(ref);
},
};
});
describe('useOwnedEntitiesCount', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetBackstageIdentity.mockResolvedValue({
ownershipEntityRefs: ['user:default/spiderman', 'user:group/a-group'],
userEntityRef: 'user:default/spiderman',
type: 'user',
});
});
it(`shouldn't invoke queryEntities when filters are loading`, async () => {
mockQueryEntities.mockResolvedValue({
items: [],
totalItems: 10,
pageInfo: {},
});
const { result } = renderHook(() => useOwnedEntitiesCount(), {
wrapper: createWrapperWithInitialFilters({}),
});
await waitFor(() => expect(mockGetBackstageIdentity).toHaveBeenCalled());
await expect(
waitFor(() => expect(mockQueryEntities).toHaveBeenCalled()),
).rejects.toThrow();
expect(result.current).toEqual({
count: 0,
loading: false,
filter: EntityUserFilter.owned([
'user:default/spiderman',
'user:group/a-group',
]),
ownershipEntityRefs: ['user:default/spiderman', 'user:group/a-group'],
});
});
it(`should properly apply the filters`, async () => {
mockQueryEntities.mockResolvedValue({
items: [],
totalItems: 10,
pageInfo: {},
});
const { result } = renderHook(() => useOwnedEntitiesCount(), {
wrapper: createWrapperWithInitialFilters({
namespace: new EntityNamespaceFilter(['a-namespace']),
}),
});
await waitFor(() => expect(mockGetBackstageIdentity).toHaveBeenCalled());
await waitFor(() =>
expect(mockQueryEntities).toHaveBeenCalledWith({
filter: {
'metadata.namespace': ['a-namespace'],
'relations.ownedBy': ['user:default/spiderman', 'user:group/a-group'],
},
limit: 0,
}),
);
expect(result.current).toEqual({
count: 10,
loading: false,
filter: EntityUserFilter.owned([
'user:default/spiderman',
'user:group/a-group',
]),
ownershipEntityRefs: ['user:default/spiderman', 'user:group/a-group'],
});
});
it(`should return count 0 without invoking queryEntities if owners filter doesn't have claims in common with logged in user`, async () => {
mockQueryEntities.mockResolvedValue({
items: [],
totalItems: 10,
pageInfo: {},
});
const { result } = renderHook(() => useOwnedEntitiesCount(), {
wrapper: createWrapperWithInitialFilters({
namespace: new EntityNamespaceFilter(['a-namespace']),
owners: new EntityOwnerFilter(['group:default/monsters']),
}),
});
await waitFor(() => expect(mockGetBackstageIdentity).toHaveBeenCalled());
await expect(
waitFor(() => expect(mockQueryEntities).toHaveBeenCalled()),
).rejects.toThrow();
expect(result.current).toEqual({
count: 0,
loading: false,
filter: EntityUserFilter.owned([
'user:default/spiderman',
'user:group/a-group',
]),
ownershipEntityRefs: ['user:default/spiderman', 'user:group/a-group'],
});
});
it(`should send claims in common between owners filter and logged in user`, async () => {
mockQueryEntities.mockResolvedValue({
items: [],
totalItems: 10,
pageInfo: {},
});
const { result } = renderHook(() => useOwnedEntitiesCount(), {
wrapper: createWrapperWithInitialFilters({
namespace: new EntityNamespaceFilter(['a-namespace']),
owners: new EntityOwnerFilter([
'group:default/monsters',
'user:group/a-group',
]),
}),
});
await waitFor(() => expect(mockGetBackstageIdentity).toHaveBeenCalled());
await waitFor(() =>
expect(mockQueryEntities).toHaveBeenCalledWith({
filter: {
'metadata.namespace': ['a-namespace'],
'relations.ownedBy': ['user:group/a-group'],
},
limit: 0,
}),
);
expect(result.current).toEqual({
count: 10,
loading: false,
filter: EntityUserFilter.owned([
'user:default/spiderman',
'user:group/a-group',
]),
ownershipEntityRefs: ['user:default/spiderman', 'user:group/a-group'],
});
});
});
function createWrapperWithInitialFilters(
filters: Partial<DefaultEntityFilters>,
) {
function WrapFilters(props: PropsWithChildren<{}>) {
const { updateFilters } = useEntityList();
useMountEffect(() => {
updateFilters(filters);
});
return <>{props.children}</>;
}
return function Wrapper(props: PropsWithChildren<{}>) {
return (
<MemoryRouter>
<EntityListProvider>
<WrapFilters>{props.children}</WrapFilters>
</EntityListProvider>
</MemoryRouter>
);
};
}
@@ -0,0 +1,133 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { QueryEntitiesInitialRequest } from '@backstage/catalog-client';
import { identityApiRef, useApi } from '@backstage/core-plugin-api';
import { compact, intersection, isEqual } from 'lodash';
import { useEffect, useMemo, useRef } from 'react';
import useAsync from 'react-use/lib/useAsync';
import { catalogApiRef } from '../../api';
import { EntityOwnerFilter, EntityUserFilter } from '../../filters';
import { useEntityList } from '../../hooks';
import { reduceCatalogFilters } from '../../utils';
import useAsyncFn from 'react-use/lib/useAsyncFn';
export function useOwnedEntitiesCount() {
const identityApi = useApi(identityApiRef);
const catalogApi = useApi(catalogApiRef);
const { filters } = useEntityList();
const { value: ownershipEntityRefs, loading: loadingEntityRefs } = useAsync(
async () => (await identityApi.getBackstageIdentity()).ownershipEntityRefs,
// load only on mount
[],
);
const prevRequest = useRef<QueryEntitiesInitialRequest>();
const request = useMemo(() => {
const { user, owners, ...allFilters } = filters;
const compacted = compact(Object.values(allFilters));
const allFilter = reduceCatalogFilters(compacted);
const { ['metadata.name']: metadata, ...filter } = allFilter;
const countFilter = getOwnedCountClaims(owners, ownershipEntityRefs);
if (
ownershipEntityRefs?.length === 0 ||
countFilter === undefined ||
Object.keys(filter).length === 0
) {
prevRequest.current = undefined;
return undefined;
}
const newRequest: QueryEntitiesInitialRequest = {
filter: {
...filter,
'relations.ownedBy': countFilter,
},
limit: 0,
};
if (isEqual(newRequest, prevRequest.current)) {
return prevRequest.current;
}
prevRequest.current = newRequest;
return newRequest;
}, [filters, ownershipEntityRefs]);
const [{ value: count, loading: loadingEntityOwnership }, fetchEntities] =
useAsyncFn(
async (
req: QueryEntitiesInitialRequest | undefined,
ownershipEntityRefsParam: string[],
) => {
if (ownershipEntityRefsParam && !req) {
// this implicitly means that there aren't claims in common with
// the logged in users, so avoid invoking the queryEntities endpoint
// which will implicitly returns 0
return 0;
}
const { totalItems } = await catalogApi.queryEntities(req);
return totalItems;
},
[],
{ loading: true },
);
useEffect(() => {
if (ownershipEntityRefs) {
if (request && Object.keys(request).length === 0) {
return;
}
fetchEntities(request, ownershipEntityRefs);
}
}, [fetchEntities, request, ownershipEntityRefs]);
const loading = loadingEntityRefs || loadingEntityOwnership;
const filter = useMemo(
() => EntityUserFilter.owned(ownershipEntityRefs ?? []),
[ownershipEntityRefs],
);
return {
count,
loading,
filter,
ownershipEntityRefs,
};
}
function getOwnedCountClaims(
owners: EntityOwnerFilter | undefined,
ownershipEntityRefs: string[] | undefined,
) {
if (ownershipEntityRefs === undefined) {
return undefined;
}
const ownersRefs = owners?.values ?? [];
if (ownersRefs.length) {
const commonOwnedBy = intersection(ownersRefs, ownershipEntityRefs);
if (commonOwnedBy.length === 0) {
return undefined;
}
return commonOwnedBy;
}
return ownershipEntityRefs;
}
@@ -0,0 +1,127 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { CatalogApi } from '@backstage/catalog-client';
import { EntityListProvider, useStarredEntities } from '../../hooks';
import { catalogApiRef } from '../../api';
import { ApiRef } from '@backstage/core-plugin-api';
import { MemoryRouter } from 'react-router-dom';
import { useStarredEntitiesCount } from './useStarredEntitiesCount';
import { renderHook, waitFor } from '@testing-library/react';
const mockQueryEntities: jest.MockedFn<CatalogApi['queryEntities']> = jest.fn();
const mockCatalogApi: jest.Mocked<Partial<CatalogApi>> = {
queryEntities: mockQueryEntities,
};
const mockStarredEntities: jest.MockedFn<() => Set<string>> = jest.fn();
const mockUseStarredEntities: ReturnType<typeof useStarredEntities> = {
get starredEntities() {
return mockStarredEntities();
},
} as ReturnType<typeof useStarredEntities>;
jest.mock('../../hooks', () => {
const actual = jest.requireActual('../../hooks');
return { ...actual, useStarredEntities: () => mockUseStarredEntities };
});
jest.mock('@backstage/core-plugin-api', () => {
const actual = jest.requireActual('@backstage/core-plugin-api');
return {
...actual,
useApi: (ref: ApiRef<any>) =>
ref === catalogApiRef ? mockCatalogApi : actual.useApi(ref),
};
});
describe('useStarredEntitiesCount', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return the count', async () => {
mockStarredEntities.mockReturnValue(
new Set(['component:default/favourite1', 'component:default/favourite2']),
);
mockQueryEntities.mockResolvedValue({
items: [
{
apiVersion: '1',
kind: 'component',
metadata: { name: 'favourite1' },
},
{
apiVersion: '1',
kind: 'component',
metadata: { name: 'favourite2' },
},
],
totalItems: 2,
pageInfo: {},
});
const { result } = renderHook(() => useStarredEntitiesCount(), {
wrapper: ({ children }) => (
<MemoryRouter>
<EntityListProvider>{children}</EntityListProvider>
</MemoryRouter>
),
});
await waitFor(() => {
expect(mockQueryEntities).toHaveBeenCalledWith({
filter: {
'metadata.name': ['favourite1', 'favourite2'],
},
limit: 1000,
});
expect(result.current).toEqual({
count: 2,
loading: false,
filter: {
refs: [
'component:default/favourite1',
'component:default/favourite2',
],
value: 'starred',
},
});
});
});
it(`shouldn't invoke the endpoint if there are no starred entities`, async () => {
mockStarredEntities.mockReturnValue(new Set());
const { result } = renderHook(() => useStarredEntitiesCount(), {
wrapper: ({ children }) => (
<MemoryRouter>
<EntityListProvider>{children}</EntityListProvider>
</MemoryRouter>
),
});
await expect(
waitFor(() => expect(mockQueryEntities).toHaveBeenCalled()),
).rejects.toThrow();
expect(result.current).toEqual({
count: 0,
loading: false,
filter: { refs: [], value: 'starred' },
});
});
});
@@ -0,0 +1,95 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { QueryEntitiesInitialRequest } from '@backstage/catalog-client';
import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { compact, isEqual } from 'lodash';
import { useMemo, useRef } from 'react';
import useAsync from 'react-use/lib/useAsync';
import { catalogApiRef } from '../../api';
import { EntityUserFilter } from '../../filters';
import { useEntityList, useStarredEntities } from '../../hooks';
import { reduceCatalogFilters } from '../../utils';
export function useStarredEntitiesCount() {
const catalogApi = useApi(catalogApiRef);
const { filters } = useEntityList();
const { starredEntities } = useStarredEntities();
const prevRequest = useRef<QueryEntitiesInitialRequest>();
const request = useMemo(() => {
const { user, ...allFilters } = filters;
const compacted = compact(Object.values(allFilters));
const filter = reduceCatalogFilters(compacted);
const facet = 'metadata.name';
const newRequest: QueryEntitiesInitialRequest = {
filter: {
...filter,
/**
* here we are filtering entities by `name`. Given this filter,
* the response might contain more entities than expected, in case multiple entities
* of different kind or namespace share the same name. Those extra entities are filtered out
* client side by `EntityUserFilter`, so they won't be visible to the user.
*/
[facet]: Array.from(starredEntities).map(e => parseEntityRef(e).name),
},
/**
* limit is set to a high value as we are not expecting many starred entities
*/
limit: 1000,
};
if (isEqual(newRequest, prevRequest.current)) {
return prevRequest.current;
}
prevRequest.current = newRequest;
return newRequest;
}, [filters, starredEntities]);
const { value: count, loading } = useAsync(async () => {
if (!starredEntities.size) {
return 0;
}
/**
* given a list of starred entity refs and some filters coming from CatalogPage,
* it reduces the list of starred entities, to a list of entities that matches the
* provided filters. It won't be possible to getEntitiesByRefs
* as the method doesn't accept any filter.
*/
const response = await catalogApi.queryEntities(request);
return response.items
.map(e =>
stringifyEntityRef({
kind: e.kind,
namespace: e.metadata.namespace,
name: e.metadata.name,
}),
)
.filter(e => starredEntities.has(e)).length;
}, [request, starredEntities]);
const filter = useMemo(
() => EntityUserFilter.starred(Array.from(starredEntities)),
[starredEntities],
);
return { count, loading, filter };
}
+82
View File
@@ -72,6 +72,10 @@ export class EntityTagFilter implements EntityFilter {
return this.values.every(v => (entity.metadata.tags ?? []).includes(v));
}
getCatalogFilters(): Record<string, string | string[]> {
return { 'metadata.tags': this.values };
}
toQueryValue(): string[] {
return this.values;
}
@@ -136,6 +140,10 @@ export class EntityOwnerFilter implements EntityFilter {
}, [] as string[]);
}
getCatalogFilters(): Record<string, string | string[]> {
return { 'relations.ownedBy': this.values };
}
filterEntity(entity: Entity): boolean {
return this.values.some(v =>
getEntityRelations(entity, RELATION_OWNED_BY).some(
@@ -160,6 +168,10 @@ export class EntityOwnerFilter implements EntityFilter {
export class EntityLifecycleFilter implements EntityFilter {
constructor(readonly values: string[]) {}
getCatalogFilters(): Record<string, string | string[]> {
return { 'spec.lifecycle': this.values };
}
filterEntity(entity: Entity): boolean {
return this.values.some(v => entity.spec?.lifecycle === v);
}
@@ -176,6 +188,9 @@ export class EntityLifecycleFilter implements EntityFilter {
export class EntityNamespaceFilter implements EntityFilter {
constructor(readonly values: string[]) {}
getCatalogFilters(): Record<string, string | string[]> {
return { 'metadata.namespace': this.values };
}
filterEntity(entity: Entity): boolean {
return this.values.some(v => entity.metadata.namespace === v);
}
@@ -185,8 +200,66 @@ export class EntityNamespaceFilter implements EntityFilter {
}
}
/**
* @public
*/
export class EntityUserFilter implements EntityFilter {
private constructor(
readonly value: UserListFilterKind,
readonly refs?: string[],
) {}
static owned(ownershipEntityRefs: string[]) {
return new EntityUserFilter('owned', ownershipEntityRefs);
}
static all() {
return new EntityUserFilter('all');
}
static starred(starredEntityRefs: string[]) {
return new EntityUserFilter('starred', starredEntityRefs);
}
getCatalogFilters(): Record<string, string[]> {
if (this.value === 'owned') {
return { 'relations.ownedBy': this.refs ?? [] };
}
if (this.value === 'starred') {
return {
'metadata.name': this.refs?.map(e => parseEntityRef(e).name) ?? [],
};
}
return {};
}
filterEntity(entity: Entity) {
if (this.value === 'starred') {
return this.refs?.includes(stringifyEntityRef(entity)) ?? true;
}
// used only for retro-compatibility with non paginated data.
// This is supposed to return always true for paginated
// owned entities, since the filters are applied server side.
if (this.value === 'owned') {
const relations = getEntityRelations(entity, RELATION_OWNED_BY);
return (
this.refs?.some(v =>
relations.some(o => stringifyEntityRef(o) === v),
) ?? false
);
}
return true;
}
toQueryValue(): string {
return this.value;
}
}
/**
* Filters entities based on whatever the user has starred or owns them.
* @deprecated use EntityUserFilter
* @public
*/
export class UserListFilter implements EntityFilter {
@@ -218,6 +291,14 @@ export class UserListFilter implements EntityFilter {
*/
export class EntityOrphanFilter implements EntityFilter {
constructor(readonly value: boolean) {}
getCatalogFilters(): Record<string, string | string[]> {
if (this.value) {
return { 'metadata.annotations.backstage.io/orphan': String(this.value) };
}
return {};
}
filterEntity(entity: Entity): boolean {
const orphan = entity.metadata.annotations?.['backstage.io/orphan'];
return orphan !== undefined && this.value.toString() === orphan;
@@ -230,6 +311,7 @@ export class EntityOrphanFilter implements EntityFilter {
*/
export class EntityErrorFilter implements EntityFilter {
constructor(readonly value: boolean) {}
filterEntity(entity: Entity): boolean {
const error =
((entity as AlphaEntity)?.status?.items?.length as number) > 0;
@@ -32,7 +32,11 @@ import { MemoryRouter } from 'react-router-dom';
import { catalogApiRef } from '../api';
import { starredEntitiesApiRef, MockStarredEntitiesApi } from '../apis';
import { EntityKindPicker, UserListPicker } from '../components';
import { EntityKindFilter, EntityTypeFilter, UserListFilter } from '../filters';
import {
EntityKindFilter,
EntityTypeFilter,
EntityUserFilter,
} from '../filters';
import { UserListFilterKind } from '../types';
import { EntityListProvider, useEntityList } from './useEntityListProvider';
@@ -62,11 +66,14 @@ const entities: Entity[] = [
const mockConfigApi = {
getOptionalString: () => '',
} as Partial<ConfigApi>;
const ownershipEntityRefs = ['user:default/guest'];
const mockIdentityApi: Partial<IdentityApi> = {
getBackstageIdentity: async () => ({
type: 'user',
userEntityRef: 'user:default/guest',
ownershipEntityRefs: [],
ownershipEntityRefs,
}),
getCredentials: async () => ({ token: undefined }),
};
@@ -148,11 +155,7 @@ describe('<EntityListProvider />', () => {
act(() =>
result.current.updateFilters({
user: new UserListFilter(
'owned',
entity => entity.metadata.name === 'component-1',
() => true,
),
user: EntityUserFilter.owned(ownershipEntityRefs),
}),
);
@@ -193,11 +196,7 @@ describe('<EntityListProvider />', () => {
act(() =>
result.current.updateFilters({
user: new UserListFilter(
'owned',
entity => entity.metadata.name === 'component-1',
() => true,
),
user: EntityUserFilter.owned(ownershipEntityRefs),
}),
);
@@ -41,16 +41,17 @@ import {
EntityTypeFilter,
UserListFilter,
EntityNamespaceFilter,
EntityUserFilter,
} from '../filters';
import { EntityFilter } from '../types';
import { reduceCatalogFilters, reduceEntityFilters } from '../utils';
import { reduceBackendCatalogFilters, reduceEntityFilters } from '../utils';
import { useApi } from '@backstage/core-plugin-api';
/** @public */
export type DefaultEntityFilters = {
kind?: EntityKindFilter;
type?: EntityTypeFilter;
user?: UserListFilter;
user?: UserListFilter | EntityUserFilter;
owners?: EntityOwnerFilter;
lifecycles?: EntityLifecycleFilter;
tags?: EntityTagFilter;
@@ -156,8 +157,8 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>(
async () => {
const compacted = compact(Object.values(requestedFilters));
const entityFilter = reduceEntityFilters(compacted);
const backendFilter = reduceCatalogFilters(compacted);
const previousBackendFilter = reduceCatalogFilters(
const backendFilter = reduceBackendCatalogFilters(compacted);
const previousBackendFilter = reduceBackendCatalogFilters(
compact(Object.values(outputState.appliedFilters)),
);
@@ -41,13 +41,18 @@ export function useEntityOwnership(): {
const identityApi = useApi(identityApiRef);
// Trigger load only on mount
const { loading, value: refs } = useAsync(async () => {
const { ownershipEntityRefs } = await identityApi.getBackstageIdentity();
return ownershipEntityRefs;
}, []);
const { loading, value: refs } = useAsync(
async () => {
const { ownershipEntityRefs } = await identityApi.getBackstageIdentity();
return ownershipEntityRefs;
},
// load only on mount
[],
);
const isOwnedEntity = useMemo(() => {
const myOwnerRefs = new Set(refs ?? []);
return (entity: Entity) => {
const entityOwnerRefs = getEntityRelations(entity, RELATION_OWNED_BY).map(
stringifyEntityRef,
@@ -61,5 +66,5 @@ export function useEntityOwnership(): {
};
}, [refs]);
return useMemo(() => ({ loading, isOwnedEntity }), [loading, isOwnedEntity]);
return { loading, isOwnedEntity };
}
@@ -16,7 +16,8 @@
import { Entity } from '@backstage/catalog-model';
import { TestApiProvider } from '@backstage/test-utils';
import { act, renderHook, waitFor } from '@testing-library/react';
import { act, renderHook } from '@testing-library/react';
import { waitFor } from '@testing-library/react';
import React, { PropsWithChildren } from 'react';
import {
starredEntitiesApiRef,

Some files were not shown because too many files have changed in this diff Show More