Merge branch 'master' into jhaals/dep-favoriteEntityTooltip

This commit is contained in:
Patrik Oldsberg
2022-03-03 14:23:35 +01:00
committed by GitHub
295 changed files with 5790 additions and 1524 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/catalog-model': patch
---
Move `@types/json-schema` to be a dev dependency
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/catalog-client': minor
---
**BREAKING**: Removed the old deprecated request/response types:
- `CatalogEntitiesRequest` - please use `GetEntitiesRequest` instead
- `CatalogEntityAncestorsRequest` - please use `GetEntityAncestorsRequest` instead
- `CatalogEntityAncestorsResponse` - please use `GetEntityAncestorsResponse` instead
- `CatalogListResponse` - please use `GetEntitiesResponse` instead
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-backend': patch
---
Added a new interface that allows for customization of when to build techdocs
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-msgraph': patch
---
add config option `groupExpand` to allow expanding a single relationship
+20
View File
@@ -0,0 +1,20 @@
---
'@backstage/plugin-api-docs': patch
'@backstage/plugin-auth-backend': patch
'@backstage/plugin-badges-backend': patch
'@backstage/plugin-catalog': patch
'@backstage/plugin-catalog-graph': patch
'@backstage/plugin-catalog-import': patch
'@backstage/plugin-catalog-react': patch
'@backstage/plugin-code-coverage-backend': patch
'@backstage/plugin-explore': patch
'@backstage/plugin-fossa': patch
'@backstage/plugin-jenkins-backend': patch
'@backstage/plugin-rollbar': patch
'@backstage/plugin-scaffolder-backend': patch
'@backstage/plugin-techdocs': patch
'@backstage/plugin-techdocs-backend': patch
'@backstage/plugin-todo-backend': patch
---
Use `getEntityByRef` instead of `getEntityByName` in the catalog client
+13
View File
@@ -0,0 +1,13 @@
---
'@backstage/plugin-catalog': patch
'@backstage/plugin-catalog-graph': patch
'@backstage/plugin-catalog-import': patch
'@backstage/plugin-catalog-react': patch
'@backstage/plugin-explore': patch
'@backstage/plugin-fossa': patch
'@backstage/plugin-org': patch
'@backstage/plugin-scaffolder': patch
'@backstage/plugin-techdocs': patch
---
- **DEPRECATION**: Deprecated `formatEntityRefTitle` in favor of the new `humanizeEntityRef` method instead. Please migrate to using the new method instead.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/integration': minor
'@backstage/backend-common': patch
'@backstage/plugin-scaffolder-backend': patch
---
ensure `apiBaseUrl` being set for Bitbucket integrations, replace hardcoded defaults
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': minor
---
- **BREAKING**: Removed the `FavouriteTemplate` export in favor of the `FavoriteEntity` from `@backstage/plugin-catalog-react`. Please migrate any usages to that component instead if you are creating your own `TemplateCard` page.
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/catalog-model': patch
---
**DEPRECATION**: Deprecated the `EntityName` type, and added the better-named `CompoundEntityRef` to replace it.
**DEPRECATION**: Deprecated the `getEntityName` function, and added the better-named `getCompoundEntityRef` to replace it.
Please switch over to using the new symbols, as the old ones may be removed in a future release.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Export FetchUrlReader to facilitate more flexible configuration of the backend.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': minor
---
**BREAKING**: Removed the deprecated `app.<key>` template variables from the `index.html` templating. These should be replaced by using `config.getString("app.<key>")` instead.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
- **BREAKING**: The `isOwnerOf` function has been marked as `@alpha` and is now only available via the `@backstage/plugin-catalog-react/alpha` import. The limitations of this function with regards to only supporting direct relations have also been documented.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-permission-node': patch
---
Export some utility functions for parsing PermissionCriteria
`isAndCriteria`, `isOrCriteria`, `isNotCriteria` are now exported.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-msgraph': patch
---
add documentation for config options `userGroupMemberSearch` and `groupSearch`
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-airbrake': patch
'@backstage/plugin-airbrake-backend': patch
---
The Airbrake plugin installation instructions have been updated to work better and conform to how the frontend and backend plugins are supposed to be integrated into a Backstage instance.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/catalog-client': patch
---
**DEPRECATION**: Deprecated `getEntityByName` from `CatalogApi` and added `getEntityByRef` instead, which accepts both string and compound ref forms.
@@ -0,0 +1,18 @@
---
'@backstage/plugin-search-backend-node': minor
'@backstage/search-common': minor
---
**BREAKING**
The Backstage Search Platform's indexing process has been rewritten as a stream
pipeline in order to improve efficiency and performance on large document sets.
The concepts of `Collator` and `Decorator` have been replaced with readable and
transform object streams (respectively), as well as factory classes to
instantiate them. Accordingly, the `SearchEngine.index()` method has also been
replaced with a `getIndexer()` factory method that resolves to a writable
object stream.
Check [this upgrade guide](https://backstage.io/docs/features/search/how-to-guides#how-to-migrate-from-search-alpha-to-beta)
for further details.
+12
View File
@@ -0,0 +1,12 @@
---
'@backstage/plugin-search-backend-module-pg': minor
---
**BREAKING**
The `PgSearchEngine` implements the new stream-based indexing process expected
by the latest `@backstage/search-backend-node`.
When updating to this version, you must also update to the latest version of
`@backstage/search-backend-node`. Check [this upgrade guide](https://backstage.io/docs/features/search/how-to-guides#how-to-migrate-from-search-alpha-to-beta)
for further details.
+13
View File
@@ -0,0 +1,13 @@
---
'@backstage/plugin-techdocs-backend': patch
---
A `DefaultTechDocsCollatorFactory`, which works with the new stream-based
search indexing subsystem, is now available. The `DefaultTechDocsCollator` will
continue to be available for those unable to upgrade to the stream-based
`@backstage/search-backend-node` (and related packages), however it is now
marked as deprecated and will be removed in a future version.
To upgrade this plugin and the search indexing subsystem in one go, check
[this upgrade guide](https://backstage.io/docs/features/search/how-to-guides#how-to-migrate-from-search-alpha-to-beta)
for necessary changes to your search backend plugin configuration.
+43
View File
@@ -0,0 +1,43 @@
---
'@backstage/create-app': patch
---
The Backstage Search Platform's indexing process has been rewritten as a stream
pipeline in order to improve efficiency and performance on large document sets.
To take advantage of this, upgrade to the latest version of
`@backstage/plugin-search-backend-node`, as well as any backend plugins whose
collators you are using. Then, make the following changes to your
`/packages/backend/src/plugins/search.ts` file:
```diff
-import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend';
-import { DefaultTechDocsCollator } from '@backstage/plugin-techdocs-backend';
+import { DefaultCatalogCollatorFactory } from '@backstage/plugin-catalog-backend';
+import { DefaultTechDocsCollatorFactory } from '@backstage/plugin-techdocs-backend';
// ...
const indexBuilder = new IndexBuilder({ logger, searchEngine });
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
- collator: DefaultCatalogCollator.fromConfig(config, { discovery }),
+ factory: DefaultCatalogCollatorFactory.fromConfig(config, { discovery }),
});
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
- collator: DefaultTechDocsCollator.fromConfig(config, {
+ factory: DefaultTechDocsCollatorFactory.fromConfig(config, {
discovery,
logger,
}),
});
```
If you've written custom collators, decorators, or search engines in your
Backstage backend instance, you will need to re-implement them as readable,
transform, and writable streams respectively (including factory classes for
instantiating them). [A how-to guide for refactoring](https://backstage.io/docs/features/search/how-to-guides#rewriting-alpha-style-collators-for-beta)
existing implementations is available.
@@ -0,0 +1,12 @@
---
'@backstage/plugin-search-backend-module-elasticsearch': minor
---
**BREAKING**
The `ElasticSearchSearchEngine` implements the new stream-based indexing
process expected by the latest `@backstage/search-backend-node`.
When updating to this version, you must also update to the latest version of
`@backstage/search-backend-node`. Check [this upgrade guide](https://backstage.io/docs/features/search/how-to-guides#how-to-migrate-from-search-alpha-to-beta)
for further details.
+13
View File
@@ -0,0 +1,13 @@
---
'@backstage/plugin-catalog-backend': patch
---
A `DefaultCatalogCollatorFactory`, which works with the new stream-based
search indexing subsystem, is now available. The `DefaultCatalogCollator` will
continue to be available for those unable to upgrade to the stream-based
`@backstage/search-backend-node` (and related packages), however it is now
marked as deprecated and will be removed in a future version.
To upgrade this plugin and the search indexing subsystem in one go, check
[this upgrade guide](https://backstage.io/docs/features/search/how-to-guides#how-to-migrate-from-search-alpha-to-beta)
for necessary changes to your search backend plugin configuration.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': minor
---
**BREAKING**: Removed the deprecated `GithubAuth.normalizeScopes` method.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': minor
---
**BREAKING**: The `TokenFactory.issueToken` used by custom sign-in resolvers now ensures that the sub claim given is a full entity reference of the format `<kind>:<namespace>/<name>`. Any existing custom sign-in resolver functions that do not supply a full entity reference must be updated.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend-module-msgraph': patch
---
add `userExpand` config option to allow expanding a single relationship
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/techdocs-common': patch
---
Modify techdocs builder to automatically append techdocs-core plugin to mkdocs.yaml file if it is missing. Adds an optional configuration item if this plugin needs to be omitted.
+22
View File
@@ -0,0 +1,22 @@
---
'@backstage/catalog-client': patch
'@backstage/plugin-auth-backend': patch
'@backstage/plugin-catalog': patch
'@backstage/plugin-catalog-backend': patch
'@backstage/plugin-catalog-graph': patch
'@backstage/plugin-catalog-import': patch
'@backstage/plugin-catalog-react': patch
'@backstage/plugin-code-coverage': patch
'@backstage/plugin-code-coverage-backend': patch
'@backstage/plugin-fossa': patch
'@backstage/plugin-jenkins': patch
'@backstage/plugin-jenkins-backend': patch
'@backstage/plugin-scaffolder-backend': patch
'@backstage/plugin-tech-insights': patch
'@backstage/plugin-tech-insights-backend': patch
'@backstage/plugin-techdocs': patch
'@backstage/plugin-techdocs-backend': patch
'@backstage/plugin-todo-backend': patch
---
Use `CompoundEntityRef` instead of `EntityName`, and `getCompoundEntityRef` instead of `getEntityName`, from `@backstage/catalog-model`.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-catalog-backend': patch
---
**DEPRECATION**: Deprecated the `RefreshIntervalFunction` and `createRandomRefreshInterval` in favour of the `ProcessingIntervalFunction` and `createRandomProcessingInterval` type and method respectively. Please migrate to use the new names.
**DEPRECATION**: Deprecated the `setRefreshInterval` and `setRefreshIntervalSeconds` methods on the `CatalogBuilder` for the new `setProcessingInterval` and `setProcessingIntervalSeconds` methods. Please migrate to use the new names.
+1 -5
View File
@@ -20,7 +20,6 @@ Autoscaling
autoselect
Avro
aws
backrub
backported
backporting
Bigtable
@@ -39,8 +38,6 @@ Changesets
chanwit
Chanwit
ci
cisphobia
cissexist
classname
cli
cloudbuild
@@ -68,7 +65,6 @@ css
Datadog
dataflow
dayjs
deadnaming
debounce
Debounce
declaratively
@@ -171,7 +167,6 @@ Minikube
Minio
misconfiguration
misconfigured
misgendering
mkdocs
Mkdocs
monorepo
@@ -213,6 +208,7 @@ parallelization
Patrik
Peloton
performant
Performant
plantuml
Platformize
Podman
+2
View File
@@ -2,6 +2,8 @@
# [Backstage](https://backstage.io)
_During March 7 to March 11 the maintainers will be taking part in Spotify's annual hack week. Development will continue as usual, but expect a slower pace for discussions and PR reviews. Why not take this opportunity to [build a plugin](https://backstage.io/docs/plugins/)?_
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![CNCF Status](https://img.shields.io/badge/cncf%20status-sandbox-blue.svg)](https://www.cncf.io/projects)
[![Main CI Build](https://github.com/backstage/backstage/workflows/Main%20Master%20Build/badge.svg)](https://github.com/backstage/backstage/actions?query=workflow%3A%22Main+Master+Build%22)
+3
View File
@@ -0,0 +1,3 @@
# Search
Contributions/extensions to the Search plugin
@@ -0,0 +1,95 @@
ConfluenceCollator.ts reference
```ts
import { DocumentCollator } from '@backstage/search-common';
import fetch from 'cross-fetch';
export class ConfluenceCollator implements DocumentCollator {
public readonly type: string = 'confluence';
async execute() {
const ConfluenceUrlBase =
'https://{CONFLUENCE-ORG-NAME}.atlassian.net/wiki/rest/api';
async function getConfluenceData(requestUrl: string) {
var emptyJson = {};
try {
const res = await fetch(requestUrl, {
method: 'get',
headers: {
Authorization: `Basic ${process.env.CONFLUENCE_TOKEN}`,
},
});
if (res.ok) {
return await res.json();
}
} catch (err) {
console.error(err);
}
return emptyJson;
}
async function getSpaces(): Promise<string[]> {
const data = await getConfluenceData(
`${ConfluenceUrlBase}/space?&limit=1000&type=global&status=current`,
);
let spacesList = [];
if (data['results']) {
const results = data['results'];
for (const result of results) {
spacesList.push(result['key']);
}
}
return spacesList;
}
async function getDocumentsFromSpaces(spaces: string[]): Promise<string[]> {
let documentsList = [];
for (var space of spaces) {
let next = true;
let requestUrl = `${ConfluenceUrlBase}/content?limit=1000&status=current&spaceKey=${space}`;
while (next) {
const data = await getConfluenceData(requestUrl);
if (data['results']) {
const results = data['results'];
for (const result of results) {
documentsList.push(result['_links']['self']);
}
if (data['_links']['next']) {
requestUrl = data['_links']['base'] + data['_links']['next'];
} else {
next = false;
}
} else {
break;
}
}
}
return documentsList;
}
async function getDocumentInfo(documents: string[]) {
let documentInfo = [];
for (var documentUrl of documents) {
const data = await getConfluenceData(
documentUrl + '?expand=body.storage',
);
if (data['status'] && data['status'] == 'current') {
const documentMetaData = {
title: data['title'],
text: data['body']['storage']['value'],
location: data['_links']['base'] + data['_links']['webui'],
};
documentInfo.push(documentMetaData);
}
}
return documentInfo;
}
const spacesList = await getSpaces();
const documentsList = await getDocumentsFromSpaces(spacesList);
const documentMetaDataList = await getDocumentInfo(documentsList);
return documentMetaDataList;
}
}
```
@@ -0,0 +1,59 @@
ConfluenceResultListItem.tsx reference
```tsx
import React from 'react';
import { Link } from '@backstage/core-components';
import { IndexableDocument } from '@backstage/search-common';
import {
Divider,
ListItem,
ListItemIcon,
ListItemText,
} from '@material-ui/core';
type Props = {
result: IndexableDocument;
};
export const ConfluenceResultListItem = ({ result }: Props) => {
// Remove html tags from document text before displaying
const chars = [];
let isTag = false;
for (const c of result.text.substring(0, 500)) {
if (c === '<') {
isTag = true;
continue;
}
if (c === '>') {
isTag = false;
chars.push(' ');
continue;
}
if (!isTag) {
chars.push(c);
}
}
const excerpt =
chars.join('').substring(0, 80) + (result.text.length > 80 ? '...' : '');
return (
<Link to={result.location}>
<ListItem alignItems="center">
<ListItemIcon>
<img
width="20"
height="20"
src="https://cdn.worldvectorlogo.com/logos/confluence-1.svg"
/>
</ListItemIcon>
<ListItemText
primaryTypographyProps={{ variant: 'h6' }}
primary={result.title}
secondary={excerpt}
/>
</ListItem>
<Divider />
</Link>
);
};
```
+32
View File
@@ -0,0 +1,32 @@
# Confluence
These files help you add Confluence as a source to the Backstage Search plugin.
To do so, add both files in this directory under the packages/backend/src/plugins/search/ pathway in your Backstage app.
Then, add the following code to your packages/app/src/components/search/SearchPage.tsx:
```tsx
import { ConfluenceResultListItem } from './ConfluenceResultListItem';
```
```tsx
case 'confluence':
return (
<ConfluenceResultListItem
key={document.location}
result={document}
/>
);
```
and the following to packages/backend/src/plugins/search.ts:
```ts
import { ConfluenceCollator } from './search/ConfluenceCollator';
```
```ts
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
collator: new ConfluenceCollator(),
});
```
+16 -9
View File
@@ -54,13 +54,14 @@ An index is a collection of such documents of a given type.
### Collators
You need to be able to search something! Collators are the way to define what
can be searched. Specifically, they're classes which return documents conforming
to a minimum set of fields (including a document title, location, and text), but
which can contain any other fields as defined by the collator itself. One
collator is responsible for defining and collecting documents of a type.
can be searched. Specifically, they're readable object streams of documents that
conform to a minimum set of fields (including a document title, location, and
text), but which can contain any other fields as defined by the collator itself.
One collator is responsible for defining and collecting documents of a type.
Some plugins, like the Catalog Backend, provide so-called "default" collators
which you can use out-of-the-box to start searching across Backstage quickly.
Some plugins, like the Catalog Backend, provide so-called "default" collator
factories which you can use out-of-the-box to start searching across Backstage
quickly.
### Decorators
@@ -68,9 +69,15 @@ Sometimes you want to add extra information to a set of documents in your search
index that the collator may not be aware of. For example, the Software Catalog
knows about software entities, but it may not know about their usage or quality.
Decorators are classes which can add extra fields to pre-collated documents.
This extra metadata could then be used to bias search results or otherwise
improve the search experience in your Backstage instance.
Decorators are transform streams which sit between a collator (read stream) and
an indexer (write stream) during the indexing process. It can be used to add
extra fields to documents as they are being collated and indexed. This extra
metadata could then be used to bias search results or otherwise improve the
search experience in your Backstage instance.
In addition to adding extra metadata, decorators (like any transform stream) can
also be used to remove metadata, filter out, or even add extra documents at
index-time.
### The Scheduler
+268 -6
View File
@@ -48,10 +48,10 @@ const app = createApp({
## How to index TechDocs documents
The TechDocs plugin has supported integrations to Search, meaning that it
provides a default collator ready to be used.
provides a default collator factory ready to be used.
The purpose of this guide is to walk you through how to register the
[DefaultTechDocsCollator](https://github.com/backstage/backstage/blob/master/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts)
[DefaultTechDocsCollatorFactory](https://github.com/backstage/backstage/blob/master/plugins/techdocs-backend/src/search/DefaultTechDocsCollatorFactory.ts)
in your App, so that you can get TechDocs documents indexed.
If you have been through the
@@ -60,18 +60,19 @@ you should have the `packages/backend/src/plugins/search.ts` file available. If
so, you can go ahead and follow this guide - if not, start by going through the
getting started guide.
1. Import the DefaultTechDocsCollator from `@backstage/plugin-techdocs-backend`.
1. Import the `DefaultTechDocsCollatorFactory` from
`@backstage/plugin-techdocs-backend`.
```typescript
import { DefaultTechDocsCollator } from '@backstage/plugin-techdocs-backend';
import { DefaultTechDocsCollatorFactory } from '@backstage/plugin-techdocs-backend';
```
2. Register the DefaultTechDocsCollator with the IndexBuilder.
2. Register the `DefaultTechDocsCollatorFactory` with the IndexBuilder.
```typescript
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
collator: DefaultTechDocsCollator.fromConfig(config, {
factory: DefaultTechDocsCollatorFactory.fromConfig(config, {
discovery,
logger,
tokenManager,
@@ -131,3 +132,264 @@ indexBuilder.addCollator({
As shown above, you can add a catalog entity filter to narrow down what catalog
entities are indexed by the search engine.
## How to migrate from Search Alpha to Beta
For the purposes of this guide, Search Beta version is defined as:
- **Search Plugin**: At least `v0.x.y`
- **Search Backend Plugin**: At least `v0.x.y`
- **Search Backend Node**: At least `v0.x.y`
In the Beta version, the Search Platform's indexing process has been rewritten
as a stream pipeline in order to improve efficiency and performance on large
sets of documents.
If you've not yet extended the Search Platform with custom code, and have
instead taken advantage of default collators, decorators, and search engines
provided by existing plugins, the migration process is fairly straightforward:
1. Upgrade to at least version `0.x.y` of
`@backstage/plugin-search-backend-node`, as well as any backend plugins whose
collators you are using (e.g. at least version `0.x.y` of
`@backstage/plugin-catalog-backend` and/or version `0.x.y` of
`@backstage/plugin-techdocs-backend`).
2. Then, make the following changes to your
`/packages/backend/src/plugins/search.ts` file:
```diff
-import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend';
-import { DefaultTechDocsCollator } from '@backstage/plugin-techdocs-backend';
+import { DefaultCatalogCollatorFactory } from '@backstage/plugin-catalog-backend';
+import { DefaultTechDocsCollatorFactory } from '@backstage/plugin-techdocs-backend';
// ...
const indexBuilder = new IndexBuilder({ logger, searchEngine });
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
- collator: DefaultCatalogCollator.fromConfig(config, { discovery }),
+ factory: DefaultCatalogCollatorFactory.fromConfig(config, { discovery }),
});
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
- collator: DefaultTechDocsCollator.fromConfig(config, {
+ factory: DefaultTechDocsCollatorFactory.fromConfig(config, {
discovery,
logger,
}),
});
```
Any custom collators, decorators, or search engine implementations will require
minor refactoring. Continue on for details.
### Rewriting alpha-style collators for beta
In alpha versions of the Backstage Search Platform, collators were classes that
implemented an `execute` method which resolved an `IndexableDocument` array.
In beta versions, the logic encapsulated by the aforementioned `execute` method
is contained within an [object-mode][obj-mode] `Readable` stream where each
object pushed onto the stream is of type `IndexableDocument`. Instances of this
stream are instantiated by a factory class conforming to the
`DocumentCollatorFactory` interface.
The optimal conversion strategy will vary depending on the collator's logic, but
the simplest conversion can follow a process like this:
1. Rename your collator class to something like `YourCollatorFactory` and update
it to implement `DocumentCollatorFactory` instead of `DocumentCollator`.
2. Update its `execute` method so that it resolves
`AsyncGenerator<YourIndexableDocument>` instead of `YourIndexableDocument[]`.
3. Implement `DocumentCollatorFactory`'s `getCollator` method which resolves to
`Readable.from(this.execute())` (which is a utility for creating [readable
streams][read-stream] from [async generators][async-gen]).
```ts
import { DocumentCollatorFactory } from '@backstage/plugin-search-backend-node';
import { Readable } from 'stream';
export class YourCollatorFactory implements DocumentCollatorFactory {
public readonly type: string = 'your-type';
async *execute(): AsyncGenerator<YourIndexableDocument> {
const widgets = await this.client.getWidgets();
for (const widget of widgets) {
yield {
title: widget.name,
location: widget.url,
text: widget.description,
};
}
}
getCollator() {
return Readable.from(this.execute());
}
}
```
Note: it may be possible to simplify your collator dramatically! If your custom
collator was previously using streams under the hood (for example, by reading
newline delimited JSON from a local or remote file), you could just expose the
stream directly via a simple factory class:
```ts
import { DocumentCollatorFactory } from '@backstage/plugin-search-backend-node';
import { createReadStream } from 'fs';
import { parse } from '@jsonlines/core';
export class YourCollatorFactory implements DocumentCollatorFactory {
public readonly type: string = 'your-type';
async getCollator() {
const parseStream = parse();
return createReadStream('./documents.ndjson').pipe(parseStream);
}
}
```
### Rewriting alpha-style decorators for beta
In alpha versions of the Backstage Search Platform, decorators were classes that
implemented an `execute` method which took an `IndexableDocument` array as an
argument, and resolved a modified array of the same type.
In beta versions, the logic encapsulated by the aforementioned `execute` method
is contained within an object-mode `Transform` stream which reads objects of
type `IndexableDocument`, and writes objects of a conforming type. Similar to
collators, instances of this stream are instantiated by a factory class
conforming to the `DocumentDecoratorFactory` interface.
Although you can choose to implement a `Transform` stream from scratch, the
`@backstage/plugin-search-backend-node` package provides a `DecoratorBase` class
in order to simplify the developer experience. With this base class, all that's
needed is to transfer your old decorator class logic into the base class' three
methods (`initialize`, `decorate`, and `finalize`), and implement the factory
class that instantiates the stream:
```ts
import { DecoratorBase } from '@backstage/plugin-search-backend-node';
export class YourDecorator extends DecoratorBase {
async initialize() {
// Setup logic. Performed once before any documents are consumed.
}
async decorate(
document: YourIndexableDocument,
): Promise<YourIndexableDocument | YourIndexableDocument[] | undefined> {
// Perform transformation logic here.
return document;
}
async finalize() {
// Teardown logic. Performed once after all documents have been consumed.
}
}
export class YourDecoratorFactory implements DocumentDecoratorFactory {
async getDecorator() {
return new YourDecorator();
}
}
```
Note the return type of the `decorate` method and how each can be used to
different effect.
- By resolving a single `YourIndexableDocument` object, your decorator can be
used to make simple transformations:
```ts
class BooleanWidgetCoolnessDecorator extends DecoratorBase {
async decorator(widget) {
// Perform a simple, 1:1 transformation.
widget.isCool = widget.isCool === 'true' ? true : false;
return widget;
}
}
```
- By resolving `undefined`, your decorator can filter out documents which
shouldn't be in the index:
```ts
class OnlyCoolWidgetsDecorator extends DecoratorBase {
async decorator(widget) {
// Perform a simple filter operation.
return widget.isCool ? widget : undefined;
}
}
```
- By resolving an array of `YourIndexableDocument` objects, you can generate
multiple documents based on the content of one:
```ts
class WidgetByVariantDecorator extends DecoratorBase {
async decorator(widget) {
// Generate one widget doc per widget variant.
return widget.variants.map(variant => {
// Each widget doc is the given widget plus a "variant" property
// pulled from a widget.variants string array.
return {
...widget,
variant,
};
});
}
}
```
In alpha versions, a decorator had access to every `IndexableDocument`
simultaneously. This is no longer possible in beta versions (precisely to make
the indexing process more efficient and performant). You will need to modify
your decorator's logic so that it does not need access to every document at
once.
### Rewriting alpha-style search engines for beta
Search Engines are responsible for both querying and indexing documents to an
underlying search engine technology. While the search engine query interface
didn't change between alpha and beta versions, the indexing half of the
interface _did_ change.
In alpha versions of the Backstage Search Platform, a search engine implemented
an `index` method which took a `type` and an `IndexableDocument` array and was
responsible for writing these documents to the underlying search engine.
In beta versions, the logic encapsulated by the aforementioned `index` method is
contained within an object-mode `Writable` stream which expects objects of type
`IndexableDocument`. On the search engine class itself, the `index` method is
replaced with a `getIndexer` factory method which still takes the `type`, but
resolves an instance of the aforementioned `Writable` stream.
Although you can choose to implement a `Writable` stream from scratch, the
`@backstage/plugin-search-backend-node` package provides a
`BatchSearchEngineIndexer` class in order to simplify the developer experience.
With this base class, which collects documents in batches of a configurable size
on your behalf, all that's needed is to transfer your old `index` method logic
into the base class' three methods (`initialize`, `index`, and `finalize`), and
implement the factory method that instantiates the stream:
```ts
import { BatchSearchEngineIndexer } from '@backstage/plugin-search-backend-node';
import { SearchEngine } from '@backstage/search-common';
export class YourSearchEngineIndexer extends BatchSearchEngineIndexer {
constructor({ type }: { type: string }) {
// Customize the number of documents passed to the index method per batch.
super({ batchSize: 500 });
// An imaginary search engine indexing client.
this.index = new SomeSearchEngineIndex({ indexName: type });
}
async initialize() {
// Setup logic. Performed once before any documents are consumed.
}
async index(documents: IndexableDocument[]) {
await this.index.batchOf(documents);
}
async finalize() {
// Teardown logic. Performed once after all documents have been consumed.
}
}
export class YourSearchEngine implements SearchEngine {
async getIndexer(type: string) {
return new YourSearchEngineIndexer({ type });
}
}
```
[obj-mode]: https://nodejs.org/docs/latest-v14.x/api/stream.html#stream_object_mode
[read-stream]: https://nodejs.org/docs/latest-v14.x/api/stream.html#stream_readable_streams
[async-gen]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of#iterating_over_async_generators
+2
View File
@@ -130,6 +130,8 @@ Options:
if not found.
--etag <ETAG> A unique identifier for the prepared tree e.g. commit SHA. If provided it will be stored
in techdocs_metadata.json.
--omitTechdocsCoreMkdocsPlugin An option to disable automatic addition of techdocs-core plugin to the mkdocs.yaml files.
Defaults to false, which means that the techdocs-core plugin is always added to the mkdocs file.
-v --verbose Enable verbose output. (default: false)
-h, --help display help for command
```
+18
View File
@@ -46,6 +46,24 @@ between `techdocs-backend` and the storage)
[TechDocs Backend](https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend)
## TechDocs Build Strategy
To accommodate more complex logic surrounding whether or not to build TechDocs, the TechDocs backend
supports selecting a Build Strategy.
The Build Strategy is responsible for deciding whether the documentation requested should be built locally
by the TechDocs backend or not.
Customization of the Build Strategy allows for more complex behaviour regarding whether the TechDocs backend
is responsible for building TechDocs, whether an external process is responsible, or whether a combination
of local builds and an external process is responsible, on an entity-by-entity basis.
The default Build Strategy results in the TechDocs backend building documentation locally if the
`techdocs.builder` configuration option is set to `'local'`, and skipping any building otherwise.
However any logic that satisfies the Build Strategy interface can be implemented, using the Backstage
config as well as the entity being processed to make a decision.
For an example of how the Build Strategy can be used to implement a 'hybrid' build model, refer to
the [How to implement a hybrid build strategy](./how-to-guides.md#how-to-implement-a-hybrid-build-strategy) guide.
## TechDocs Container
The TechDocs container is a Docker container available at
+15 -5
View File
@@ -37,12 +37,22 @@ techdocs:
pullImage: true
mkdocs:
# (Optional) techdocs.generator.omitTechdocsCoreMkdocsPlugin can be used to disable automatic addition of techdocs-core plugin to the mkdocs.yaml files.
# Defaults to false, which means that the techdocs-core plugin is always added to the mkdocs file.
omitTechdocsCorePlugin: false
# techdocs.builder can be either 'local' or 'external.
# If builder is set to 'local' and you open a TechDocs page, techdocs-backend will try to generate the docs, publish to storage
# and show the generated docs afterwords. This is the "Basic" setup of the TechDocs Architecture.
# If builder is set to 'external', techdocs-backend will only fetch the docs and will NOT try to generate and publish. In this case of 'external',
# we assume that docs are being built by an external process (e.g. in the CI/CD pipeline of the repository). This is the "Recommended" setup of
# the architecture. Read more here https://backstage.io/docs/features/techdocs/architecture
# Using the default build strategy, if builder is set to 'local' and you open a TechDocs page,
# techdocs-backend will try to generate the docs, publish to storage and show the generated docs afterwords.
# This is the "Basic" setup of the TechDocs Architecture.
# Using the default build strategy, if builder is set to 'external' (or anything other than 'local'), techdocs-backend
# will only fetch the docs and will NOT try to generate and publish.
# In this case, we assume that docs are being built by an external process (e.g. in the CI/CD pipeline of the repository).
# This is the "Recommended" setup of the architecture.
# Note that custom build strategies may alter this behaviour.
# Read more about the "Basic" and "Recommended" setups here https://backstage.io/docs/features/techdocs/architecture
# Read more about build strategies here: https://backstage.io/docs/features/techdocs/concepts#techdocs-build-strategy
builder: 'local'
@@ -79,6 +79,9 @@ plugins:
- techdocs-core
```
> Note - The plugins section above is optional. Backstage automatically adds the `techdocs-core` plugin to the
> mkdocs file if it is missing. This functionality can be turned off with a [configuration option](./configuration.md) in Backstage.
Update your component's entity description by adding the following lines to its
`catalog-info.yaml` in the root of its repository:
+55
View File
@@ -538,3 +538,58 @@ Done! Now you have a support of the following diagrams along with mermaid:
- `Vega`
- `Vega-Lite`
- `WaveDrom`
## How to implement a hybrid build strategy
One limitation of the [Recommended deployment](./architecture.md#recommended-deployment) is that
the experience for users requires modifying their CI/CD process to publish
their TechDocs. For some users, this may be unnecessary, and provides a barrier
to entry for onboarding users to Backstage. However, a purely local TechDocs
build restricts TechDocs creators to using the tooling provided in Backstage,
as well as the plugins and features provided in the Backstage-included `mkdocs`
installation.
To accommodate both of these use-cases, users can implement a custom [Build Strategy](./concepts.md#techdocs-build-strategy)
with logic to encode which TechDocs should be built locally, and which will be
built externally.
To achieve this hybrid build model:
1. In your Backstage instance's `app-config.yaml`, set `techdocs.builder` to
`'local'`. This ensures that Backstage will build docs for users who want the
'out-of-the-box' experience.
2. Configure external storage of TechDocs as normal for a production deployment.
This allows Backstage to publish documentation to your storage, as well as
allowing other users to publish documentation from their CI/CD pipelines.
3. Create a custom build strategy, that implements the `DocsBuildStrategy` interface,
and which implements your custom logic for determining whether to build docs for
a given entity.
For example, to only build docs when an entity has the `company.com/techdocs-builder`
annotation set to `'local'`:
```typescript
export class AnnotationBasedBuildStrategy {
private readonly config: Config;
constructor(config: Config) {
this.config = config;
}
async shouldBuild(_: Entity): Promise<boolean> {
return (
this.entity.metadata?.annotations?.['company.com/techdocs-builder'] ===
'local'
);
}
}
```
4. Pass an instance of this Build Strategy as the `docsBuildStrategy` parameter of the
TechDocs backend `createRouter` method.
Users should now be able to choose to have their documentation built and published by
the TechDocs backend by adding the `company.com/techdocs-builder` annotation to their
entity. If the value of this annotation is `'local'`, the TechDocs backend will build
and publish the documentation for them. If the value of the `company.com/techdocs-builder`
annotation is anything other than `'local'`, the user is responsible for publishing
documentation to the appropriate location in the TechDocs external storage.
+33
View File
@@ -552,3 +552,36 @@ The following is an excerpt of a typical setup of an isomorphic library package:
},
"files": ["dist"],
```
## Experimental Type Build
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.
+9
View File
@@ -67,3 +67,12 @@ process is used to release an emergency fix as version `6.5.1` in the patch rele
- [ ] The fix, which you can likely cherry-pick from your patch branch: `git cherry-pick origin/patch/v1.18.0^`
- [ ] An updated `CHANGELOG.md` of all patched packages from the tip of the patch branch, `git checkout origin/patch/v1.18.0 -- {packages,plugins}/*/CHANGELOG.md`.
- [ ] A changeset with the message "Applied the fix from version `6.5.1` of this package, which is part of the `v1.18.1` release of Backstage."
- [ ] An entry in `.changeset/patched.json` that sets the current release version to `6.5.1`:
```json
{
"currentReleaseVersion": {
"@backstage/plugin-foo": "6.5.1"
}
}
```
+15
View File
@@ -234,6 +234,21 @@ export type ErrorHandlerOptions = {
logClientErrors?: boolean;
};
// @public
export class FetchUrlReader implements UrlReader {
static factory: ReaderFactory;
// (undocumented)
read(url: string): Promise<Buffer>;
// (undocumented)
readTree(): Promise<ReadTreeResponse>;
// (undocumented)
readUrl(url: string, options?: ReadUrlOptions): Promise<ReadUrlResponse>;
// (undocumented)
search(): Promise<SearchResponse>;
// (undocumented)
toString(): string;
}
// @public
export type FromReadableArrayOptions = Array<{
data: Readable;
@@ -270,22 +270,6 @@ describe('BitbucketUrlReader', () => {
expect(response.etag).toBe('12ab34cd56ef');
});
it('should throw error when apiBaseUrl is missing', () => {
expect(() => {
/* eslint-disable no-new */
new BitbucketUrlReader(
new BitbucketIntegration(
readBitbucketIntegrationConfig(
new ConfigReader({
host: 'bitbucket.mycompany.net',
}),
),
),
{ treeResponseFactory },
);
}).toThrowError('must configure an explicit apiBaseUrl');
});
});
describe('search hosted', () => {
@@ -62,14 +62,9 @@ export class BitbucketUrlReader implements UrlReader {
private readonly integration: BitbucketIntegration,
private readonly deps: { treeResponseFactory: ReadTreeResponseFactory },
) {
const { host, apiBaseUrl, token, username, appPassword } =
integration.config;
const { host, token, username, appPassword } = integration.config;
if (!apiBaseUrl) {
throw new Error(
`Bitbucket integration for '${host}' must configure an explicit apiBaseUrl`,
);
} else if (!token && username && !appPassword) {
if (!token && username && !appPassword) {
throw new Error(
`Bitbucket integration for '${host}' has configured a username but is missing a required appPassword.`,
);
@@ -19,6 +19,7 @@ export { BitbucketUrlReader } from './BitbucketUrlReader';
export { GithubUrlReader } from './GithubUrlReader';
export { GitlabUrlReader } from './GitlabUrlReader';
export { AwsS3UrlReader } from './AwsS3UrlReader';
export { FetchUrlReader } from './FetchUrlReader';
export type {
FromReadableArrayOptions,
ReaderFactory,
+4 -4
View File
@@ -18,7 +18,7 @@ import {
useHotCleanup,
} from '@backstage/backend-common';
import { Config } from '@backstage/config';
import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend';
import { DefaultCatalogCollatorFactory } from '@backstage/plugin-catalog-backend';
import { createRouter } from '@backstage/plugin-search-backend';
import { ElasticSearchSearchEngine } from '@backstage/plugin-search-backend-module-elasticsearch';
import { PgSearchEngine } from '@backstage/plugin-search-backend-module-pg';
@@ -27,7 +27,7 @@ import {
LunrSearchEngine,
SearchEngine,
} from '@backstage/plugin-search-backend-node';
import { DefaultTechDocsCollator } from '@backstage/plugin-techdocs-backend';
import { DefaultTechDocsCollatorFactory } from '@backstage/plugin-techdocs-backend';
import { Logger } from 'winston';
import { PluginEnvironment } from '../types';
@@ -70,7 +70,7 @@ export default async function createPlugin({
// particular collator gathers entities from the software catalog.
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
collator: DefaultCatalogCollator.fromConfig(config, {
factory: DefaultCatalogCollatorFactory.fromConfig(config, {
discovery,
tokenManager,
}),
@@ -78,7 +78,7 @@ export default async function createPlugin({
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
collator: DefaultTechDocsCollator.fromConfig(config, {
factory: DefaultTechDocsCollatorFactory.fromConfig(config, {
discovery,
logger,
tokenManager,
+12 -15
View File
@@ -3,8 +3,8 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { CompoundEntityRef } from '@backstage/catalog-model';
import { Entity } from '@backstage/catalog-model';
import { EntityName } from '@backstage/catalog-model';
// @public
export type AddLocationRequest = {
@@ -38,8 +38,13 @@ export interface CatalogApi {
request: GetEntityAncestorsRequest,
options?: CatalogRequestOptions,
): Promise<GetEntityAncestorsResponse>;
// @deprecated
getEntityByName(
name: EntityName,
name: CompoundEntityRef,
options?: CatalogRequestOptions,
): Promise<Entity | undefined>;
getEntityByRef(
entityRef: string | CompoundEntityRef,
options?: CatalogRequestOptions,
): Promise<Entity | undefined>;
getEntityFacets(
@@ -91,7 +96,11 @@ export class CatalogClient implements CatalogApi {
options?: CatalogRequestOptions,
): Promise<GetEntityAncestorsResponse>;
getEntityByName(
compoundName: EntityName,
compoundName: CompoundEntityRef,
options?: CatalogRequestOptions,
): Promise<Entity | undefined>;
getEntityByRef(
entityRef: string | CompoundEntityRef,
options?: CatalogRequestOptions,
): Promise<Entity | undefined>;
getEntityFacets(
@@ -130,18 +139,6 @@ export class CatalogClient implements CatalogApi {
): Promise<void>;
}
// @public @deprecated (undocumented)
export type CatalogEntitiesRequest = GetEntitiesRequest;
// @public @deprecated (undocumented)
export type CatalogEntityAncestorsRequest = GetEntityAncestorsRequest;
// @public @deprecated (undocumented)
export type CatalogEntityAncestorsResponse = GetEntityAncestorsResponse;
// @public @deprecated (undocumented)
export type CatalogListResponse<_Entity> = GetEntitiesResponse;
// @public
export interface CatalogRequestOptions {
// (undocumented)
@@ -195,6 +195,60 @@ describe('CatalogClient', () => {
});
});
describe('getEntityByRef', () => {
const existingEntity: Entity = {
apiVersion: 'v1',
kind: 'CustomKind',
metadata: {
namespace: 'default',
name: 'exists',
},
};
beforeEach(() => {
server.use(
rest.get(
`${mockBaseUrl}/entities/by-name/customkind/default/exists`,
(_, res, ctx) => {
return res(ctx.json(existingEntity));
},
),
rest.get(
`${mockBaseUrl}/entities/by-name/customkind/default/missing`,
(_, res, ctx) => {
return res(ctx.status(404));
},
),
);
});
it('finds by string and compound', async () => {
await expect(
client.getEntityByRef('customkind:default/exists'),
).resolves.toEqual(existingEntity);
await expect(
client.getEntityByRef({
kind: 'CustomKind',
namespace: 'default',
name: 'exists',
}),
).resolves.toEqual(existingEntity);
});
it('returns undefined for 404s', async () => {
await expect(
client.getEntityByRef('customkind:default/missing'),
).resolves.toBeUndefined();
await expect(
client.getEntityByRef({
kind: 'CustomKind',
namespace: 'default',
name: 'missing',
}),
).resolves.toBeUndefined();
});
});
describe('getLocationById', () => {
const defaultResponse = {
data: {
+23 -2
View File
@@ -18,7 +18,7 @@ import {
ANNOTATION_LOCATION,
ANNOTATION_ORIGIN_LOCATION,
Entity,
EntityName,
CompoundEntityRef,
parseEntityRef,
stringifyEntityRef,
stringifyLocationRef,
@@ -170,11 +170,32 @@ export class CatalogClient implements CatalogApi {
return { items: entities.sort(refCompare) };
}
/**
* {@inheritdoc CatalogApi.getEntityByRef}
*/
async getEntityByRef(
entityRef: string | CompoundEntityRef,
options?: CatalogRequestOptions,
): Promise<Entity | undefined> {
const { kind, namespace, name } = parseEntityRef(entityRef);
return this.requestOptional(
'GET',
`/entities/by-name/${encodeURIComponent(kind)}/${encodeURIComponent(
namespace,
)}/${encodeURIComponent(name)}`,
options,
);
}
// NOTE(freben): When we deprecate getEntityByName from the interface, we may
// still want to leave this implementation in place for quite some time
// longer, to minimize the risk for breakages. Suggested date for removal:
// August 2022
/**
* {@inheritdoc CatalogApi.getEntityByName}
*/
async getEntityByName(
compoundName: EntityName,
compoundName: CompoundEntityRef,
options?: CatalogRequestOptions,
): Promise<Entity | undefined> {
const { kind, namespace = 'default', name } = compoundName;
+16 -2
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Entity, EntityName } from '@backstage/catalog-model';
import { CompoundEntityRef, Entity } from '@backstage/catalog-model';
/**
* This symbol can be used in place of a value when passed to filters in e.g.
@@ -306,11 +306,25 @@ export interface CatalogApi {
* Gets a single entity from the catalog by its ref (kind, namespace, name)
* triplet.
*
* @param entityRef - A complete entity ref, either on string or compound form
* @param options - Additional options
* @returns The matching entity, or undefined if there was no entity with that ref
*/
getEntityByRef(
entityRef: string | CompoundEntityRef,
options?: CatalogRequestOptions,
): Promise<Entity | undefined>;
/**
* Gets a single entity from the catalog by its ref (kind, namespace, name)
* triplet.
*
* @deprecated Use getEntityRef instead
* @param name - A complete entity ref
* @param options - Additional options
*/
getEntityByName(
name: EntityName,
name: CompoundEntityRef,
options?: CatalogRequestOptions,
): Promise<Entity | undefined>;
@@ -1,43 +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.
*/
import {
GetEntitiesRequest,
GetEntitiesResponse,
GetEntityAncestorsRequest,
GetEntityAncestorsResponse,
} from './api';
/**
* @public
* @deprecated use GetEntitiesRequest instead
*/
export type CatalogEntitiesRequest = GetEntitiesRequest;
/**
* @public
* @deprecated use GetEntitiesResponse instead
*/
export type CatalogListResponse<_Entity> = GetEntitiesResponse;
/**
* @public
* @deprecated use GetEntityAncestorsRequest instead
*/
export type CatalogEntityAncestorsRequest = GetEntityAncestorsRequest;
/**
* @public
* @deprecated use GetEntityAncestorsResponse instead
*/
export type CatalogEntityAncestorsResponse = GetEntityAncestorsResponse;
@@ -28,5 +28,4 @@ export type {
GetEntityFacetsRequest,
GetEntityFacetsResponse,
} from './api';
export * from './deprecated';
export { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from './status';
+16 -10
View File
@@ -88,6 +88,13 @@ export { ComponentEntityV1alpha1 };
// @public
export const componentEntityV1alpha1Validator: KindValidator;
// @public
export type CompoundEntityRef = {
kind: string;
namespace: string;
name: string;
};
// @public
export const DEFAULT_NAMESPACE = 'default';
@@ -166,12 +173,8 @@ export type EntityMeta = JsonObject & {
links?: EntityLink[];
};
// @public
export type EntityName = {
kind: string;
namespace: string;
name: string;
};
// @public @deprecated
export type EntityName = CompoundEntityRef;
// @public
export const EntityPolicies: {
@@ -196,7 +199,7 @@ export type EntityRef =
// @public
export type EntityRelation = {
type: string;
target: EntityName;
target: CompoundEntityRef;
targetRef: string;
};
@@ -229,7 +232,10 @@ export class FieldFormatEntityPolicy implements EntityPolicy {
}
// @public
export function getEntityName(entity: Entity): EntityName;
export function getCompoundEntityRef(entity: Entity): CompoundEntityRef;
// @public @deprecated
export const getEntityName: typeof getCompoundEntityRef;
// @public
export function getEntitySourceLocation(entity: Entity): {
@@ -337,7 +343,7 @@ export function parseEntityName(
defaultKind?: string;
defaultNamespace?: string;
},
): EntityName;
): CompoundEntityRef;
// @public
export function parseEntityRef(
@@ -352,7 +358,7 @@ export function parseEntityRef(
defaultKind?: string;
defaultNamespace?: string;
},
): EntityName;
): CompoundEntityRef;
// @public
export function parseLocationRef(ref: string): {
+1 -1
View File
@@ -37,7 +37,6 @@
"@backstage/config": "^0.1.15",
"@backstage/errors": "^0.2.2",
"@backstage/types": "^0.1.3",
"@types/json-schema": "^7.0.5",
"ajv": "^7.0.3",
"json-schema": "^0.4.0",
"lodash": "^4.17.21",
@@ -46,6 +45,7 @@
"devDependencies": {
"@backstage/cli": "^0.14.1",
"@types/jest": "^26.0.7",
"@types/json-schema": "^7.0.5",
"@types/lodash": "^4.14.151",
"yaml": "^1.9.2"
},
+2 -2
View File
@@ -15,7 +15,7 @@
*/
import { JsonObject } from '@backstage/types';
import { EntityName } from '../types';
import { CompoundEntityRef } from '../types';
import { EntityStatus } from './EntityStatus';
/**
@@ -201,7 +201,7 @@ export type EntityRelation = {
*
* @deprecated use targetRef instead
*/
target: EntityName;
target: CompoundEntityRef;
/**
* The entity ref of the target of this relation.
@@ -34,6 +34,7 @@ export type {
} from './EntityStatus';
export * from './policies';
export {
getCompoundEntityRef,
getEntityName,
parseEntityName,
parseEntityRef,
+18 -7
View File
@@ -15,7 +15,7 @@
*/
import { DEFAULT_NAMESPACE } from './constants';
import { EntityName } from '../types';
import { CompoundEntityRef } from '../types';
import { Entity } from './Entity';
function parseRefString(ref: string): {
@@ -38,14 +38,25 @@ function parseRefString(ref: string): {
}
/**
* Extracts the kind, namespace and name that form the name triplet of the
* given entity.
* Extracts the kind, namespace and name that form the compound entity ref
* triplet of the given entity.
*
* @public
* @deprecated Use getCompoundEntityRef instead
* @param entity - An entity
* @returns The compound entity ref
*/
export const getEntityName = getCompoundEntityRef;
/**
* Extracts the kind, namespace and name that form the compound entity ref
* triplet of the given entity.
*
* @public
* @param entity - An entity
* @returns The complete entity name
* @returns The compound entity ref
*/
export function getEntityName(entity: Entity): EntityName {
export function getCompoundEntityRef(entity: Entity): CompoundEntityRef {
return {
kind: entity.kind,
namespace: entity.metadata.namespace || DEFAULT_NAMESPACE,
@@ -77,7 +88,7 @@ export function parseEntityName(
/** The default namespace, if none is given in the reference */
defaultNamespace?: string;
} = {},
): EntityName {
): CompoundEntityRef {
const { kind, namespace, name } = parseEntityRef(ref, {
defaultNamespace: DEFAULT_NAMESPACE,
...context,
@@ -114,7 +125,7 @@ export function parseEntityRef(
/** The default namespace, if none is given in the reference */
defaultNamespace?: string;
},
): EntityName {
): CompoundEntityRef {
if (!ref) {
throw new Error(`Entity reference must not be empty`);
}
+1 -1
View File
@@ -24,5 +24,5 @@ export * from './entity';
export { EntityPolicies } from './EntityPolicies';
export * from './kinds';
export * from './location';
export type { EntityName, EntityRef } from './types';
export type { EntityName, EntityRef, CompoundEntityRef } from './types';
export * from './validation';
+11 -2
View File
@@ -15,16 +15,25 @@
*/
/**
* A complete entity name, with the full kind-namespace-name triplet.
* All parts of a complete entity ref, forming a full kind-namespace-name
* triplet.
*
* @public
*/
export type EntityName = {
export type CompoundEntityRef = {
kind: string;
namespace: string;
name: string;
};
/**
* A complete entity name, with the full kind-namespace-name triplet.
*
* @deprecated Use CompoundEntityRef instead
* @public
*/
export type EntityName = CompoundEntityRef;
/**
* A reference by name to an entity, either as a compact string representation,
* or as a compound reference structure.
-33
View File
@@ -15,7 +15,6 @@
*/
import fs from 'fs-extra';
import chalk from 'chalk';
import { resolve as resolvePath } from 'path';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import HtmlWebpackPlugin from 'html-webpack-plugin';
@@ -118,43 +117,11 @@ export async function createConfig(
}),
);
const appParamDeprecationMsg = chalk.red(
'DEPRECATION WARNING: using `app.<key>` in the index.html template is deprecated, use `config.getString("app.<key>")` instead.',
);
plugins.push(
new HtmlWebpackPlugin({
template: paths.targetHtml,
templateParameters: {
publicPath: validBaseUrl.pathname.replace(/\/$/, ''),
app: {
get title() {
console.warn(appParamDeprecationMsg);
return frontendConfig.getString('app.title');
},
get baseUrl() {
console.warn(appParamDeprecationMsg);
return validBaseUrl.href;
},
get googleAnalyticsTrackingId() {
console.warn(appParamDeprecationMsg);
return frontendConfig.getOptionalString(
'app.googleAnalyticsTrackingId',
);
},
get datadogRum() {
console.warn(appParamDeprecationMsg);
return {
env: frontendConfig.getOptionalString('app.datadogRum.env'),
clientToken: frontendConfig.getOptionalString(
'app.datadogRum.clientToken',
),
applicationId: frontendConfig.getOptionalString(
'app.datadogRum.applicationId',
),
site: frontendConfig.getOptionalString('app.datadogRum.site'),
};
},
},
config: frontendConfig,
},
}),
@@ -182,7 +182,7 @@ function compileTsSchemas(paths: string[]) {
program,
// All schemas should export a `Config` symbol
'Config',
// This enables usage of @visibility and @deprecated in doc comments
// This enables the use of these tags in TSDoc comments
{
required: true,
validationKeywords: ['visibility', 'deprecated'],
-2
View File
@@ -376,8 +376,6 @@ export type FlatRoutesProps = {
export class GithubAuth {
// (undocumented)
static create(options: OAuthApiCreateOptions): typeof githubAuthApiRef.T;
// @deprecated (undocumented)
static normalizeScope(scope?: string): Set<string>;
}
// @public
@@ -47,19 +47,4 @@ export default class GithubAuth {
defaultScopes,
});
}
/**
* @deprecated This method is deprecated and will be removed in a future release.
*/
static normalizeScope(scope?: string): Set<string> {
if (!scope) {
return new Set();
}
const scopeList = Array.isArray(scope)
? scope
: scope.split(/[\s|,]/).filter(Boolean);
return new Set(scopeList);
}
}
@@ -5,8 +5,8 @@ import {
LunrSearchEngine,
} from '@backstage/plugin-search-backend-node';
import { PluginEnvironment } from '../types';
import { DefaultCatalogCollator } from '@backstage/plugin-catalog-backend';
import { DefaultTechDocsCollator } from '@backstage/plugin-techdocs-backend';
import { DefaultCatalogCollatorFactory } from '@backstage/plugin-catalog-backend';
import { DefaultTechDocsCollatorFactory } from '@backstage/plugin-techdocs-backend';
export default async function createPlugin({
logger,
@@ -23,7 +23,7 @@ export default async function createPlugin({
// collator gathers entities from the software catalog.
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
collator: DefaultCatalogCollator.fromConfig(config, {
factory: DefaultCatalogCollatorFactory.fromConfig(config, {
discovery,
tokenManager,
}),
@@ -32,7 +32,7 @@ export default async function createPlugin({
// collator gathers entities from techdocs.
indexBuilder.addCollator({
defaultRefreshIntervalSeconds: 600,
collator: DefaultTechDocsCollator.fromConfig(config, {
factory: DefaultTechDocsCollatorFactory.fromConfig(config, {
discovery,
logger,
tokenManager,
+1 -1
View File
@@ -88,7 +88,7 @@ export class BitbucketIntegration implements ScmIntegration {
// @public
export type BitbucketIntegrationConfig = {
host: string;
apiBaseUrl?: string;
apiBaseUrl: string;
token?: string;
username?: string;
appPassword?: string;
+5 -5
View File
@@ -36,12 +36,10 @@ export type BitbucketIntegrationConfig = {
* The base URL of the API of this provider, e.g. "https://api.bitbucket.org/2.0",
* with no trailing slash.
*
* May be omitted specifically for Bitbucket Cloud; then it will be deduced.
*
* The API will always be preferred if both its base URL and a token are
* present.
* Values omitted at the optional property at the app-config will be deduced
* from the "host" value.
*/
apiBaseUrl?: string;
apiBaseUrl: string;
/**
* The authorization token to use for requests to a Bitbucket Server provider.
@@ -90,6 +88,8 @@ export function readBitbucketIntegrationConfig(
apiBaseUrl = trimEnd(apiBaseUrl, '/');
} else if (host === BITBUCKET_HOST) {
apiBaseUrl = BITBUCKET_API_BASE_URL;
} else {
apiBaseUrl = `https://${host}/rest/api/1.0`;
}
return {
+8 -2
View File
@@ -24,7 +24,10 @@ import {
describe('basicIntegrations', () => {
describe('byUrl', () => {
it('handles hosts without a port', () => {
const integration = new BitbucketIntegration({ host: 'host.com' });
const integration = new BitbucketIntegration({
host: 'host.com',
apiBaseUrl: 'a',
});
const integrations = basicIntegrations<BitbucketIntegration>(
[integration],
i => i.config.host,
@@ -33,7 +36,10 @@ describe('basicIntegrations', () => {
expect(integrations.byUrl('https://host.com:8080/a')).toBeUndefined();
});
it('handles hosts with a port', () => {
const integration = new BitbucketIntegration({ host: 'host.com:8080' });
const integration = new BitbucketIntegration({
host: 'host.com:8080',
apiBaseUrl: 'a',
});
const integrations = basicIntegrations<BitbucketIntegration>(
[integration],
i => i.config.host,
+20 -37
View File
@@ -3,38 +3,33 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="node" />
import { JsonObject } from '@backstage/types';
import { Permission } from '@backstage/plugin-permission-common';
import { Readable } from 'stream';
import { Transform } from 'stream';
import { Writable } from 'stream';
// Warning: (ae-missing-release-tag) "DocumentCollator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export interface DocumentCollator {
// (undocumented)
execute(): Promise<IndexableDocument[]>;
// @beta
export interface DocumentCollatorFactory {
getCollator(): Promise<Readable>;
readonly type: string;
readonly visibilityPermission?: Permission;
}
// Warning: (ae-missing-release-tag) "DocumentDecorator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export interface DocumentDecorator {
// (undocumented)
execute(documents: IndexableDocument[]): Promise<IndexableDocument[]>;
// @beta
export interface DocumentDecoratorFactory {
getDecorator(): Promise<Transform>;
readonly types?: string[];
}
// Warning: (ae-missing-release-tag) "DocumentTypeInfo" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
// @beta
export type DocumentTypeInfo = {
visibilityPermission?: Permission;
};
// Warning: (ae-missing-release-tag) "IndexableDocument" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
// @beta
export interface IndexableDocument {
authorization?: {
resourceRef: string;
@@ -44,23 +39,17 @@ export interface IndexableDocument {
title: string;
}
// Warning: (ae-missing-release-tag) "QueryRequestOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @beta
export type QueryRequestOptions = {
token?: string;
};
// Warning: (ae-missing-release-tag) "QueryTranslator" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
// @beta
export type QueryTranslator = (query: SearchQuery) => unknown;
// Warning: (ae-missing-release-tag) "SearchEngine" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
// @beta
export interface SearchEngine {
index(type: string, documents: IndexableDocument[]): Promise<void>;
getIndexer(type: string): Promise<Writable>;
query(
query: SearchQuery,
options?: QueryRequestOptions,
@@ -68,9 +57,7 @@ export interface SearchEngine {
setTranslator(translator: QueryTranslator): void;
}
// Warning: (ae-missing-release-tag) "SearchQuery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @beta (undocumented)
export interface SearchQuery {
// (undocumented)
filters?: JsonObject;
@@ -82,9 +69,7 @@ export interface SearchQuery {
types?: string[];
}
// Warning: (ae-missing-release-tag) "SearchResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @beta (undocumented)
export interface SearchResult {
// (undocumented)
document: IndexableDocument;
@@ -92,9 +77,7 @@ export interface SearchResult {
type: string;
}
// Warning: (ae-missing-release-tag) "SearchResultSet" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @beta (undocumented)
export interface SearchResultSet {
// (undocumented)
nextPageCursor?: string;
+41 -10
View File
@@ -16,7 +16,11 @@
import { Permission } from '@backstage/plugin-permission-common';
import { JsonObject } from '@backstage/types';
import { Readable, Transform, Writable } from 'stream';
/**
* @beta
*/
export interface SearchQuery {
term: string;
filters?: JsonObject;
@@ -24,11 +28,17 @@ export interface SearchQuery {
pageCursor?: string;
}
/**
* @beta
*/
export interface SearchResult {
type: string;
document: IndexableDocument;
}
/**
* @beta
*/
export interface SearchResultSet {
results: SearchResult[];
nextPageCursor?: string;
@@ -38,6 +48,7 @@ export interface SearchResultSet {
/**
* Base properties that all indexed documents must include, as well as some
* common properties that documents are encouraged to use where appropriate.
* @beta
*/
export interface IndexableDocument {
/**
@@ -72,6 +83,7 @@ export interface IndexableDocument {
* Information about a specific document type. Intended to be used in the
* {@link @backstage/search-backend-node#IndexBuilder} to collect information
* about the types stored in the index.
* @beta
*/
export type DocumentTypeInfo = {
/**
@@ -82,10 +94,10 @@ export type DocumentTypeInfo = {
};
/**
* Interface that must be implemented in order to expose new documents to
* search.
* Factory class for instantiating collators.
* @beta
*/
export interface DocumentCollator {
export interface DocumentCollatorFactory {
/**
* The type or name of the document set returned by this collator. Used as an
* index name by Search Engines.
@@ -98,29 +110,41 @@ export interface DocumentCollator {
*/
readonly visibilityPermission?: Permission;
execute(): Promise<IndexableDocument[]>;
/**
* Instantiates and resolves a document collator.
*/
getCollator(): Promise<Readable>;
}
/**
* Interface that must be implemented in order to decorate existing documents with
* additional metadata.
* Factory class for instantiating decorators.
* @beta
*/
export interface DocumentDecorator {
export interface DocumentDecoratorFactory {
/**
* An optional array of document/index types on which this decorator should
* be applied. If no types are provided, this decorator will be applied to
* all document/index types.
*/
readonly types?: string[];
execute(documents: IndexableDocument[]): Promise<IndexableDocument[]>;
/**
* Instantiates and resolves a document decorator.
*/
getDecorator(): Promise<Transform>;
}
/**
* A type of function responsible for translating an abstract search query into
* a concrete query relevant to a particular search engine.
* @beta
*/
export type QueryTranslator = (query: SearchQuery) => unknown;
/**
* Options when querying a search engine.
* @beta
*/
export type QueryRequestOptions = {
token?: string;
};
@@ -129,6 +153,7 @@ export type QueryRequestOptions = {
* Interface that must be implemented by specific search engines, responsible
* for performing indexing and querying and translating abstract queries into
* concrete, search engine-specific queries.
* @beta
*/
export interface SearchEngine {
/**
@@ -137,9 +162,15 @@ export interface SearchEngine {
setTranslator(translator: QueryTranslator): void;
/**
* Add the given documents to the SearchEngine index of the given type.
* Factory method for getting a search engine indexer for a given document
* type.
*
* @param type - The type or name of the document set for which an indexer
* should be retrieved. This corresponds to the `type` property on the
* document collator/decorator factories and will most often be used to
* identify an index or group to which documents should be written.
*/
index(type: string, documents: IndexableDocument[]): Promise<void>;
getIndexer(type: string): Promise<Writable>;
/**
* Perform a search query against the SearchEngine.
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { EntityName } from '@backstage/catalog-model';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import {
scmIntegrationsApiRef,
@@ -81,7 +81,7 @@ class TechDocsDevStorageApi implements TechDocsStorageApi {
return this.configApi.getString('techdocs.builder');
}
async getEntityDocs(_entityId: EntityName, path: string) {
async getEntityDocs(_entityId: CompoundEntityRef, path: string) {
const apiOrigin = await this.getApiOrigin();
// Irrespective of the entity, use mkdocs server to find the file for the path.
const url = `${apiOrigin}/${path}`;
@@ -97,7 +97,7 @@ class TechDocsDevStorageApi implements TechDocsStorageApi {
return request.text();
}
async syncEntityDocs(_: EntityName): Promise<SyncResult> {
async syncEntityDocs(_: CompoundEntityRef): Promise<SyncResult> {
// this is just stub of this function as we don't need to check if docs are up to date,
// we always want to retrigger a new build
return 'cached';
@@ -106,7 +106,7 @@ class TechDocsDevStorageApi implements TechDocsStorageApi {
// Used by transformer to modify the request to assets (CSS, Image) from inside the HTML.
async getBaseUrl(
oldBaseUrl: string,
_entityId: EntityName,
_entityId: CompoundEntityRef,
path: string,
): Promise<string> {
const apiOrigin = await this.getApiOrigin();
@@ -154,7 +154,7 @@ class TechDocsDevApi implements TechDocsApi {
};
}
async getTechDocsMetadata(_entityId: EntityName) {
async getTechDocsMetadata(_entityId: CompoundEntityRef) {
return {
site_name: 'Live preview environment',
site_description: '',
@@ -29,7 +29,7 @@ import LightIcon from '@material-ui/icons/Brightness7';
import DarkIcon from '@material-ui/icons/Brightness4';
import { lightTheme, darkTheme } from '@backstage/theme';
import { EntityName } from '@backstage/catalog-model';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { Content } from '@backstage/core-components';
@@ -127,7 +127,7 @@ const TechDocsPageContent = ({
onReady,
entityRef,
}: {
entityRef: EntityName;
entityRef: CompoundEntityRef;
onReady: () => void;
}) => {
const classes = useStyles();
@@ -39,6 +39,7 @@ export default async function generate(cmd: Command) {
const sourceDir = resolve(cmd.sourceDir);
const outputDir = resolve(cmd.outputDir);
const omitTechdocsCorePlugin = cmd.omitTechdocsCoreMkdocsPlugin;
const dockerImage = cmd.dockerImage;
const pullImage = cmd.pull;
@@ -55,6 +56,9 @@ export default async function generate(cmd: Command) {
runIn: cmd.docker ? 'docker' : 'local',
dockerImage,
pullImage,
mkdocs: {
omitTechdocsCorePlugin,
},
},
},
});
@@ -54,6 +54,11 @@ export function registerCommands(program: CommanderStatic) {
'A unique identifier for the prepared tree e.g. commit SHA. If provided it will be stored in techdocs_metadata.json.',
)
.option('-v --verbose', 'Enable verbose output.', false)
.option(
'--omitTechdocsCoreMkdocsPlugin',
"Don't patch MkDocs file automatically with techdocs-core plugin.",
false,
)
.alias('build')
.action(lazy(() => import('./generate/generate').then(m => m.default)));
+4 -2
View File
@@ -5,10 +5,10 @@
```ts
/// <reference types="node" />
import { CompoundEntityRef } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { ContainerRunner } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { EntityName } from '@backstage/catalog-model';
import express from 'express';
import { IndexableDocument } from '@backstage/search-common';
import { Logger as Logger_2 } from 'winston';
@@ -157,7 +157,9 @@ export class Publisher {
// @public
export interface PublisherBase {
docsRouter(): express.Handler;
fetchTechDocsMetadata(entityName: EntityName): Promise<TechDocsMetadata>;
fetchTechDocsMetadata(
entityName: CompoundEntityRef,
): Promise<TechDocsMetadata>;
getReadiness(): Promise<ReadinessResponse>;
hasDocsBeenGenerated(entityName: Entity): Promise<boolean>;
migrateDocsCase?(migrateRequest: MigrateRequest): Promise<void>;
@@ -0,0 +1,6 @@
site_name: Test site name
site_description: Test site description
docs_dir: docs/
plugins:
- not-techdocs-core
- also-not-techdocs-core
@@ -0,0 +1,5 @@
site_name: Test site name
site_description: Test site description
# This is a comment that is removed after editing
plugins:
- techdocs-core
@@ -0,0 +1,3 @@
site_name: Test site name
site_description: Test site description
docs_dir: docs/
@@ -28,10 +28,14 @@ import {
getMkdocsYml,
getRepoUrlFromLocationAnnotation,
patchIndexPreBuild,
patchMkdocsYmlPreBuild,
storeEtagMetadata,
validateMkdocsYaml,
} from './helpers';
import {
patchMkdocsYmlPreBuild,
pathMkdocsYmlWithTechdocsPlugin,
} from './mkDocsPatchers';
import yaml from 'js-yaml';
const mockEntity = {
apiVersion: 'version',
@@ -65,6 +69,15 @@ const mkdocsYmlWithInvalidDocDir2 = fs.readFileSync(
const mkdocsYmlWithComments = fs.readFileSync(
resolvePath(__filename, '../__fixtures__/mkdocs_with_comments.yml'),
);
const mkdocsYmlWithTechdocsPlugins = fs.readFileSync(
resolvePath(__filename, '../__fixtures__/mkdocs_with_techdocs_plugin.yml'),
);
const mkdocsYmlWithoutPlugins = fs.readFileSync(
resolvePath(__filename, '../__fixtures__/mkdocs_without_plugins.yml'),
);
const mkdocsYmlWithAdditionalPlugins = fs.readFileSync(
resolvePath(__filename, '../__fixtures__/mkdocs_with_additional_plugins.yml'),
);
const mockLogger = getVoidLogger();
const warn = jest.spyOn(mockLogger, 'warn');
@@ -289,6 +302,60 @@ describe('helpers', () => {
});
});
describe('pathMkdocsYmlWithTechdocsPlugin', () => {
beforeEach(() => {
mockFs({
'/mkdocs_with_techdocs_plugin.yml': mkdocsYmlWithTechdocsPlugins,
'/mkdocs_without_plugins.yml': mkdocsYmlWithoutPlugins,
'/mkdocs_with_additional_plugins.yml': mkdocsYmlWithAdditionalPlugins,
});
});
it('should not add additional plugins if techdocs exists already in mkdocs file', async () => {
await pathMkdocsYmlWithTechdocsPlugin(
'/mkdocs_with_techdocs_plugin.yml',
mockLogger,
);
const updatedMkdocsYml = await fs.readFile(
'/mkdocs_with_techdocs_plugin.yml',
);
const parsedYml = yaml.load(updatedMkdocsYml.toString()) as {
plugins: string[];
};
expect(parsedYml.plugins).toHaveLength(1);
expect(parsedYml.plugins).toContain('techdocs-core');
});
it("should add the needed plugin if it doesn't exist in mkdocs file", async () => {
await pathMkdocsYmlWithTechdocsPlugin(
'/mkdocs_without_plugins.yml',
mockLogger,
);
const updatedMkdocsYml = await fs.readFile('/mkdocs_without_plugins.yml');
const parsedYml = yaml.load(updatedMkdocsYml.toString()) as {
plugins: string[];
};
expect(parsedYml.plugins).toHaveLength(1);
expect(parsedYml.plugins).toContain('techdocs-core');
});
it('should not override existing plugins', async () => {
await pathMkdocsYmlWithTechdocsPlugin(
'/mkdocs_with_additional_plugins.yml',
mockLogger,
);
const updatedMkdocsYml = await fs.readFile(
'/mkdocs_with_additional_plugins.yml',
);
const parsedYml = yaml.load(updatedMkdocsYml.toString()) as {
plugins: string[];
};
expect(parsedYml.plugins).toHaveLength(3);
expect(parsedYml.plugins).toContain('techdocs-core');
expect(parsedYml.plugins).toContain('not-techdocs-core');
expect(parsedYml.plugins).toContain('also-not-techdocs-core');
});
});
describe('patchIndexPreBuild', () => {
afterEach(() => {
warn.mockClear();
@@ -125,7 +125,7 @@ class UnknownTag {
constructor(public readonly data: any, public readonly type?: string) {}
}
const MKDOCS_SCHEMA = DEFAULT_SCHEMA.extend([
export const MKDOCS_SCHEMA = DEFAULT_SCHEMA.extend([
new Type('', {
kind: 'scalar',
multi: true,
@@ -203,101 +203,6 @@ export const validateMkdocsYaml = async (
return parsedMkdocsYml.docs_dir;
};
/**
* Update the mkdocs.yml file before TechDocs generator uses it to generate docs site.
*
* List of tasks:
* - Add repo_url or edit_uri if it does not exists
* If mkdocs.yml has a repo_url, the generated docs site gets an Edit button on the pages by default.
* If repo_url is missing in mkdocs.yml, we will use techdocs annotation of the entity to possibly get
* the repository URL.
*
* This function will not throw an error since this is not critical to the whole TechDocs pipeline.
* Instead it will log warnings if there are any errors in reading, parsing or writing YAML.
*
* @param mkdocsYmlPath - Absolute path to mkdocs.yml or equivalent of a docs site
* @param logger - A logger instance
* @param parsedLocationAnnotation - Object with location url and type
* @param scmIntegrations - the scmIntegration to do url transformations
*/
export const patchMkdocsYmlPreBuild = async (
mkdocsYmlPath: string,
logger: Logger,
parsedLocationAnnotation: ParsedLocationAnnotation,
scmIntegrations: ScmIntegrationRegistry,
) => {
// We only want to override the mkdocs.yml if it has actually changed. This is relevant if
// used with a 'dir' location on the file system as this would permanently update the file.
let didEdit = false;
let mkdocsYmlFileString;
try {
mkdocsYmlFileString = await fs.readFile(mkdocsYmlPath, 'utf8');
} catch (error) {
assertError(error);
logger.warn(
`Could not read MkDocs YAML config file ${mkdocsYmlPath} before running the generator: ${error.message}`,
);
return;
}
let mkdocsYml: any;
try {
mkdocsYml = yaml.load(mkdocsYmlFileString, { schema: MKDOCS_SCHEMA });
// mkdocsYml should be an object type after successful parsing.
// But based on its type definition, it can also be a string or undefined, which we don't want.
if (typeof mkdocsYml === 'string' || typeof mkdocsYml === 'undefined') {
throw new Error('Bad YAML format.');
}
} catch (error) {
assertError(error);
logger.warn(
`Error in parsing YAML at ${mkdocsYmlPath} before running the generator. ${error.message}`,
);
return;
}
// Add edit_uri and/or repo_url to mkdocs.yml if it is missing.
// This will enable the Page edit button generated by MkDocs.
// If the either has been set, keep the original value
if (!('repo_url' in mkdocsYml) && !('edit_uri' in mkdocsYml)) {
const result = getRepoUrlFromLocationAnnotation(
parsedLocationAnnotation,
scmIntegrations,
mkdocsYml.docs_dir,
);
if (result.repo_url || result.edit_uri) {
mkdocsYml.repo_url = result.repo_url;
mkdocsYml.edit_uri = result.edit_uri;
didEdit = true;
logger.info(
`Set ${JSON.stringify(
result,
)}. You can disable this feature by manually setting 'repo_url' or 'edit_uri' according to the MkDocs documentation at https://www.mkdocs.org/user-guide/configuration/#repo_url`,
);
}
}
try {
if (didEdit) {
await fs.writeFile(
mkdocsYmlPath,
yaml.dump(mkdocsYml, { schema: MKDOCS_SCHEMA }),
'utf8',
);
}
} catch (error) {
assertError(error);
logger.warn(
`Could not write to ${mkdocsYmlPath} after updating it before running the generator. ${error.message}`,
);
return;
}
};
/**
* Update docs/index.md file before TechDocs generator uses it to generate docs site,
* falling back to docs/README.md or README.md in case a default docs/index.md
@@ -0,0 +1,166 @@
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Logger } from 'winston';
import fs from 'fs-extra';
import yaml from 'js-yaml';
import { ParsedLocationAnnotation } from '../../helpers';
import { getRepoUrlFromLocationAnnotation, MKDOCS_SCHEMA } from './helpers';
import { assertError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
type MkDocsObject = {
plugins?: string[];
docs_dir: string;
repo_url?: string;
edit_uri?: string;
};
const patchMkdocsFile = async (
mkdocsYmlPath: string,
logger: Logger,
updateAction: (mkdocsYml: MkDocsObject) => boolean,
) => {
// We only want to override the mkdocs.yml if it has actually changed. This is relevant if
// used with a 'dir' location on the file system as this would permanently update the file.
let didEdit = false;
let mkdocsYmlFileString;
try {
mkdocsYmlFileString = await fs.readFile(mkdocsYmlPath, 'utf8');
} catch (error) {
assertError(error);
logger.warn(
`Could not read MkDocs YAML config file ${mkdocsYmlPath} before running the generator: ${error.message}`,
);
return;
}
let mkdocsYml: any;
try {
mkdocsYml = yaml.load(mkdocsYmlFileString, { schema: MKDOCS_SCHEMA });
// mkdocsYml should be an object type after successful parsing.
// But based on its type definition, it can also be a string or undefined, which we don't want.
if (typeof mkdocsYml === 'string' || typeof mkdocsYml === 'undefined') {
throw new Error('Bad YAML format.');
}
} catch (error) {
assertError(error);
logger.warn(
`Error in parsing YAML at ${mkdocsYmlPath} before running the generator. ${error.message}`,
);
return;
}
didEdit = updateAction(mkdocsYml);
try {
if (didEdit) {
await fs.writeFile(
mkdocsYmlPath,
yaml.dump(mkdocsYml, { schema: MKDOCS_SCHEMA }),
'utf8',
);
}
} catch (error) {
assertError(error);
logger.warn(
`Could not write to ${mkdocsYmlPath} after updating it before running the generator. ${error.message}`,
);
return;
}
};
/**
* Update the mkdocs.yml file before TechDocs generator uses it to generate docs site.
*
* List of tasks:
* - Add repo_url or edit_uri if it does not exists
* If mkdocs.yml has a repo_url, the generated docs site gets an Edit button on the pages by default.
* If repo_url is missing in mkdocs.yml, we will use techdocs annotation of the entity to possibly get
* the repository URL.
*
* This function will not throw an error since this is not critical to the whole TechDocs pipeline.
* Instead it will log warnings if there are any errors in reading, parsing or writing YAML.
*
* @param mkdocsYmlPath - Absolute path to mkdocs.yml or equivalent of a docs site
* @param logger - A logger instance
* @param parsedLocationAnnotation - Object with location url and type
* @param scmIntegrations - the scmIntegration to do url transformations
*/
export const patchMkdocsYmlPreBuild = async (
mkdocsYmlPath: string,
logger: Logger,
parsedLocationAnnotation: ParsedLocationAnnotation,
scmIntegrations: ScmIntegrationRegistry,
) => {
await patchMkdocsFile(mkdocsYmlPath, logger, mkdocsYml => {
if (!('repo_url' in mkdocsYml) && !('edit_uri' in mkdocsYml)) {
// Add edit_uri and/or repo_url to mkdocs.yml if it is missing.
// This will enable the Page edit button generated by MkDocs.
// If the either has been set, keep the original value
const result = getRepoUrlFromLocationAnnotation(
parsedLocationAnnotation,
scmIntegrations,
mkdocsYml.docs_dir,
);
if (result.repo_url || result.edit_uri) {
mkdocsYml.repo_url = result.repo_url;
mkdocsYml.edit_uri = result.edit_uri;
logger.info(
`Set ${JSON.stringify(
result,
)}. You can disable this feature by manually setting 'repo_url' or 'edit_uri' according to the MkDocs documentation at https://www.mkdocs.org/user-guide/configuration/#repo_url`,
);
return true;
}
}
return false;
});
};
/**
* Update the mkdocs.yml file before TechDocs generator uses it to generate docs site.
*
* List of tasks:
* - Add techdocs-core plugin to mkdocs file if it doesn't exist
*
* This function will not throw an error since this is not critical to the whole TechDocs pipeline.
* Instead it will log warnings if there are any errors in reading, parsing or writing YAML.
*
* @param mkdocsYmlPath - Absolute path to mkdocs.yml or equivalent of a docs site
* @param logger - A logger instance
*/
export const pathMkdocsYmlWithTechdocsPlugin = async (
mkdocsYmlPath: string,
logger: Logger,
) => {
await patchMkdocsFile(mkdocsYmlPath, logger, mkdocsYml => {
// Modify mkdocs.yaml to contain the needed techdocs-core plugin if it is not there
if (!('plugins' in mkdocsYml)) {
mkdocsYml.plugins = ['techdocs-core'];
return true;
}
if (mkdocsYml.plugins && !mkdocsYml.plugins.includes('techdocs-core')) {
mkdocsYml.plugins.push('techdocs-core');
return true;
}
return false;
});
};
@@ -26,11 +26,15 @@ import {
createOrUpdateMetadata,
getMkdocsYml,
patchIndexPreBuild,
patchMkdocsYmlPreBuild,
runCommand,
storeEtagMetadata,
validateMkdocsYaml,
} from './helpers';
import {
patchMkdocsYmlPreBuild,
pathMkdocsYmlWithTechdocsPlugin,
} from './mkDocsPatchers';
import {
GeneratorBase,
GeneratorConfig,
@@ -110,6 +114,10 @@ export class TechdocsGenerator implements GeneratorBase {
await patchIndexPreBuild({ inputDir, logger: childLogger, docsDir });
}
if (!this.options.omitTechdocsCoreMkdocsPlugin) {
await pathMkdocsYmlWithTechdocsPlugin(mkdocsYmlPath, childLogger);
}
// Directories to bind on container
const mountDirs = {
[inputDir]: '/input',
@@ -207,5 +215,8 @@ export function readGeneratorConfig(
'docker',
dockerImage: config.getOptionalString('techdocs.generator.dockerImage'),
pullImage: config.getOptionalBoolean('techdocs.generator.pullImage'),
omitTechdocsCoreMkdocsPlugin: config.getOptionalBoolean(
'techdocs.generator.mkdocs.omitTechdocsCorePlugin',
),
};
}
@@ -39,6 +39,7 @@ export type GeneratorConfig = {
runIn: GeneratorRunInType;
dockerImage?: string;
pullImage?: boolean;
omitTechdocsCoreMkdocsPlugin?: boolean;
};
/**
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity, EntityName } from '@backstage/catalog-model';
import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { assertError, ForwardedError } from '@backstage/errors';
import aws, { Credentials } from 'aws-sdk';
@@ -321,7 +321,7 @@ export class AwsS3Publish implements PublisherBase {
}
async fetchTechDocsMetadata(
entityName: EntityName,
entityName: CompoundEntityRef,
): Promise<TechDocsMetadata> {
try {
return await new Promise<TechDocsMetadata>(async (resolve, reject) => {
@@ -19,7 +19,7 @@ import {
ContainerClient,
StorageSharedKeyCredential,
} from '@azure/storage-blob';
import { Entity, EntityName } from '@backstage/catalog-model';
import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { assertError, ForwardedError } from '@backstage/errors';
import express from 'express';
@@ -300,7 +300,7 @@ export class AzureBlobStoragePublish implements PublisherBase {
}
async fetchTechDocsMetadata(
entityName: EntityName,
entityName: CompoundEntityRef,
): Promise<TechDocsMetadata> {
const entityTriplet = `${entityName.namespace}/${entityName.kind}/${entityName.name}`;
const entityRootDir = this.legacyPathCasing
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity, EntityName } from '@backstage/catalog-model';
import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { assertError } from '@backstage/errors';
import { File, FileExistsResponse, Storage } from '@google-cloud/storage';
@@ -238,7 +238,9 @@ export class GoogleGCSPublish implements PublisherBase {
return { objects };
}
fetchTechDocsMetadata(entityName: EntityName): Promise<TechDocsMetadata> {
fetchTechDocsMetadata(
entityName: CompoundEntityRef,
): Promise<TechDocsMetadata> {
return new Promise((resolve, reject) => {
const entityTriplet = `${entityName.namespace}/${entityName.kind}/${entityName.name}`;
const entityDir = this.legacyPathCasing
@@ -17,7 +17,7 @@ import {
PluginEndpointDiscovery,
resolvePackagePath,
} from '@backstage/backend-common';
import { Entity, EntityName } from '@backstage/catalog-model';
import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import express from 'express';
import fs from 'fs-extra';
@@ -142,7 +142,7 @@ export class LocalPublish implements PublisherBase {
}
async fetchTechDocsMetadata(
entityName: EntityName,
entityName: CompoundEntityRef,
): Promise<TechDocsMetadata> {
const metadataPath = this.staticEntityPathJoin(
entityName.namespace,
@@ -17,7 +17,7 @@
import { getVoidLogger } from '@backstage/backend-common';
import {
Entity,
EntityName,
CompoundEntityRef,
DEFAULT_NAMESPACE,
} from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/config';
@@ -45,7 +45,7 @@ const createMockEntity = (annotations = {}): Entity => {
};
};
const createMockEntityName = (): EntityName => ({
const createMockEntityName = (): CompoundEntityRef => ({
kind: 'TestKind',
name: 'test-component-name',
namespace: 'test-namespace',
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity, EntityName } from '@backstage/catalog-model';
import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import express from 'express';
import fs from 'fs-extra';
@@ -194,7 +194,7 @@ export class OpenStackSwiftPublish implements PublisherBase {
}
async fetchTechDocsMetadata(
entityName: EntityName,
entityName: CompoundEntityRef,
): Promise<TechDocsMetadata> {
return await new Promise<TechDocsMetadata>(async (resolve, reject) => {
const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`;
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity, EntityName } from '@backstage/catalog-model';
import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { Logger } from 'winston';
import express from 'express';
@@ -133,7 +133,9 @@ export interface PublisherBase {
* Retrieve TechDocs Metadata about a site e.g. name, contributors, last updated, etc.
* This API uses the techdocs_metadata.json file that co-exists along with the generated docs.
*/
fetchTechDocsMetadata(entityName: EntityName): Promise<TechDocsMetadata>;
fetchTechDocsMetadata(
entityName: CompoundEntityRef,
): Promise<TechDocsMetadata>;
/**
* Route middleware to serve static documentation files for an entity.
+1 -1
View File
@@ -24,7 +24,7 @@ This method of serving the plugin provides quicker iteration speed and a faster
3. Go into the plugin's directory and run it in standalone mode by running `yarn start`.
Access it from http://localhost:7007/api/airbrake. Or use the Airbrake plugin which will talk to it automatically.
Access it from http://localhost:7007/api/airbrake. Or use the [Airbrake plugin in standalone mode](../airbrake/README.md#local-development) which will talk to it automatically.
Here are some example endpoints:
+51 -13
View File
@@ -20,7 +20,7 @@ The Airbrake plugin provides connectivity between Backstage and Airbrake (https:
yarn add @backstage/plugin-airbrake-backend
```
3. Add the `EntityAirbrakeContent` to `packages/app/src/components/catalog/EntityPage.tsx`:
3. Add the `EntityAirbrakeContent` to `packages/app/src/components/catalog/EntityPage.tsx` for all the entity pages you want Airbrake to be in:
```typescript jsx
import { EntityAirbrakeContent } from '@backstage/plugin-airbrake';
@@ -32,40 +32,78 @@ The Airbrake plugin provides connectivity between Backstage and Airbrake (https:
</EntityLayout.Route>
</EntityLayoutWrapper>
);
const websiteEntityPage = (
<EntityLayoutWrapper>
<EntityLayout.Route path="/airbrake" title="Airbrake">
<EntityAirbrakeContent />
</EntityLayout.Route>
</EntityLayoutWrapper>
);
const defaultEntityPage = (
<EntityLayoutWrapper>
<EntityLayout.Route path="/airbrake" title="Airbrake">
<EntityAirbrakeContent />
</EntityLayout.Route>
</EntityLayoutWrapper>
);
```
4. Setup the Backend code in `packages/backend/src/index.ts`:
4. Create `packages/backend/src/plugins/airbrake.ts` with these contents:
```typescript
import { Router } from 'express';
import { PluginEnvironment } from '../types';
import {
createRouter as createAirbrakeRouter,
createRouter,
extractAirbrakeConfig,
} from '@backstage/plugin-airbrake-backend';
async function main() {
//... After const config = await loadBackendConfig({ ...
const airbrakeRouter = await createAirbrakeRouter({
export default async function createPlugin({
logger,
config,
}: PluginEnvironment): Promise<Router> {
return createRouter({
logger,
airbrakeConfig: extractAirbrakeConfig(config),
});
const service = createServiceBuilder(module)
// ... Add the airbrakeRouter here
.addRouter('/api/airbrake', airbrakeRouter);
}
```
5. Add this config as a top level section in your `app-config.yaml`:
5. Setup the Backend code in `packages/backend/src/index.ts`:
```typescript
import airbrake from './plugins/airbrake';
async function main() {
//... After const createEnv = makeCreateEnv(config) ...
const airbrakeEnv = useHotMemoize(module, () => createEnv('airbrake'));
//... After const apiRouter = Router() ...
apiRouter.use('/airbrake', await airbrake(airbrakeEnv));
}
```
6. Add this config as a top level section in your `app-config.yaml`:
```yaml
airbrake:
apiKey: ${AIRBRAKE_API_KEY}
```
6. Set an environment variable `AIRBRAKE_API_KEY` with your [API key](https://airbrake.io/docs/api/#authentication)
7. Set an environment variable `AIRBRAKE_API_KEY` with your [API key](https://airbrake.io/docs/api/#authentication)
before starting Backstage backend.
8. Add the following annotation to the `catalog-info.yaml` for a repo you want to link to an Airbrake project:
```yaml
metadata:
annotations:
airbrake.io/project-id: '123456'
```
## Local Development
Start this plugin in standalone mode by running `yarn start` inside the plugin directory. This method of serving the plugin provides quicker
+2 -2
View File
@@ -53,8 +53,8 @@ createDevApp()
items: mockEntities.slice(),
};
},
async getEntityByName(name: string) {
return mockEntities.find(e => e.metadata.name === name);
async getEntityByRef(ref: string) {
return mockEntities.find(e => e.metadata.name === ref);
},
} as unknown as typeof catalogApiRef.T),
})
@@ -14,7 +14,11 @@
* limitations under the License.
*/
import { Entity, RELATION_MEMBER_OF } from '@backstage/catalog-model';
import {
Entity,
parseEntityRef,
RELATION_MEMBER_OF,
} from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/core-app-api';
import { TableColumn, TableProps } from '@backstage/core-components';
import {
@@ -60,11 +64,11 @@ describe('DefaultApiExplorerPage', () => {
}),
getLocationByRef: () =>
Promise.resolve({ id: 'id', type: 'url', target: 'url' }),
getEntityByName: async entityName => {
getEntityByRef: async entityRef => {
return {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: { name: entityName.name },
metadata: { name: parseEntityRef(entityRef).name },
relations: [
{
type: RELATION_MEMBER_OF,
@@ -18,6 +18,7 @@ import { MemoryKeyStore } from './MemoryKeyStore';
import { TokenFactory } from './TokenFactory';
import { getVoidLogger } from '@backstage/backend-common';
import { JWKS, JSONWebKey, JWT } from 'jose';
import { stringifyEntityRef } from '@backstage/catalog-model';
const logger = getVoidLogger();
@@ -28,6 +29,12 @@ function jwtKid(jwt: string): string {
return header.kid;
}
const entityRef = stringifyEntityRef({
kind: 'User',
namespace: 'default',
name: 'JackFrost',
});
describe('TokenFactory', () => {
it('should issue valid tokens signed by a listed key', async () => {
const keyDurationSeconds = 5;
@@ -39,7 +46,7 @@ describe('TokenFactory', () => {
});
await expect(factory.listPublicKeys()).resolves.toEqual({ keys: [] });
const token = await factory.issueToken({ claims: { sub: 'foo' } });
const token = await factory.issueToken({ claims: { sub: entityRef } });
const { keys } = await factory.listPublicKeys();
const keyStore = JWKS.asKeyStore({
@@ -53,7 +60,7 @@ describe('TokenFactory', () => {
expect(payload).toEqual({
iss: 'my-issuer',
aud: 'backstage',
sub: 'foo',
sub: entityRef,
iat: expect.any(Number),
exp: expect.any(Number),
});
@@ -71,8 +78,12 @@ describe('TokenFactory', () => {
logger,
});
const token1 = await factory.issueToken({ claims: { sub: 'foo' } });
const token2 = await factory.issueToken({ claims: { sub: 'foo' } });
const token1 = await factory.issueToken({
claims: { sub: entityRef },
});
const token2 = await factory.issueToken({
claims: { sub: entityRef },
});
expect(jwtKid(token1)).toBe(jwtKid(token2));
await expect(factory.listPublicKeys()).resolves.toEqual({
@@ -89,7 +100,9 @@ describe('TokenFactory', () => {
keys: [],
});
const token3 = await factory.issueToken({ claims: { sub: 'foo' } });
const token3 = await factory.issueToken({
claims: { sub: entityRef },
});
expect(jwtKid(token3)).not.toBe(jwtKid(token2));
await expect(factory.listPublicKeys()).resolves.toEqual({
@@ -100,4 +113,20 @@ describe('TokenFactory', () => {
],
});
});
it('should throw an error with a non entityRef sub claim', async () => {
const keyDurationSeconds = 5;
const factory = new TokenFactory({
issuer: 'my-issuer',
keyStore: new MemoryKeyStore(),
keyDurationSeconds,
logger,
});
await expect(() => {
return factory.issueToken({
claims: { sub: 'UserId' },
});
}).rejects.toThrowError();
});
});
@@ -19,6 +19,7 @@ import { JSONWebKey, JWK, JWS } from 'jose';
import { Logger } from 'winston';
import { v4 as uuid } from 'uuid';
import { DateTime } from 'luxon';
import { parseEntityRef } from '@backstage/catalog-model';
const MS_IN_S = 1000;
@@ -72,6 +73,15 @@ export class TokenFactory implements TokenIssuer {
const iat = Math.floor(Date.now() / MS_IN_S);
const exp = iat + this.keyDurationSeconds;
// Validate that the subject claim is a valid EntityRef
try {
parseEntityRef(sub);
} catch (error) {
throw new Error(
'"sub" claim provided by the auth resolver is not a valid EntityRef.',
);
}
this.logger.info(`Issuing token for ${sub}, with entities ${ent ?? []}`);
return JWS.sign({ iss, sub, aud, iat, exp, ent }, key, {
@@ -26,6 +26,7 @@ import { CatalogIdentityClient } from './CatalogIdentityClient';
describe('CatalogIdentityClient', () => {
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByRef: jest.fn(),
getEntityByName: jest.fn(),
getEntities: jest.fn(),
addLocation: jest.fn(),
@@ -18,7 +18,7 @@ import { Logger } from 'winston';
import { ConflictError, NotFoundError } from '@backstage/errors';
import { CatalogApi } from '@backstage/catalog-client';
import {
EntityName,
CompoundEntityRef,
parseEntityRef,
RELATION_MEMBER_OF,
stringifyEntityRef,
@@ -96,7 +96,7 @@ export class CatalogIdentityClient {
return null;
}
})
.filter((ref): ref is EntityName => ref !== null);
.filter((ref): ref is CompoundEntityRef => ref !== null);
const filter = resolvedEntityRefs.map(ref => ({
kind: ref.kind,
@@ -60,6 +60,7 @@ describe('createRouter', () => {
catalog = {
addLocation: jest.fn(),
getEntities: jest.fn(),
getEntityByRef: jest.fn(),
getEntityByName: jest.fn(),
getLocationByRef: jest.fn(),
getLocationById: jest.fn(),
@@ -103,7 +104,7 @@ describe('createRouter', () => {
describe('GET /entity/:namespace/:kind/:name/badge-specs', () => {
it('returns all badge specs for entity', async () => {
catalog.getEntityByName.mockResolvedValueOnce(entity);
catalog.getEntityByRef.mockResolvedValueOnce(entity);
badgeBuilder.getBadges.mockResolvedValueOnce([{ id: badge.id }]);
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
@@ -115,8 +116,8 @@ describe('createRouter', () => {
expect(response.status).toEqual(200);
expect(response.text).toEqual(JSON.stringify([badge], null, 2));
expect(catalog.getEntityByName).toHaveBeenCalledTimes(1);
expect(catalog.getEntityByName).toHaveBeenCalledWith(
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1);
expect(catalog.getEntityByRef).toHaveBeenCalledWith(
{
namespace: 'default',
kind: 'service',
@@ -142,7 +143,7 @@ describe('createRouter', () => {
describe('GET /entity/:namespace/:kind/:name/badge/test-badge', () => {
it('returns badge for entity', async () => {
catalog.getEntityByName.mockResolvedValueOnce(entity);
catalog.getEntityByRef.mockResolvedValueOnce(entity);
const image = '<svg>...</svg>';
badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image);
@@ -154,8 +155,8 @@ describe('createRouter', () => {
expect(response.status).toEqual(200);
expect(response.body).toEqual(Buffer.from(image));
expect(catalog.getEntityByName).toHaveBeenCalledTimes(1);
expect(catalog.getEntityByName).toHaveBeenCalledWith(
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1);
expect(catalog.getEntityByRef).toHaveBeenCalledWith(
{
namespace: 'default',
kind: 'service',
@@ -179,7 +180,7 @@ describe('createRouter', () => {
});
it('returns badge spec for entity', async () => {
catalog.getEntityByName.mockResolvedValueOnce(entity);
catalog.getEntityByRef.mockResolvedValueOnce(entity);
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
const url = '/entity/default/service/test/badge/test-badge?format=json';
@@ -192,7 +193,7 @@ describe('createRouter', () => {
describe('Errors', () => {
it('returns 404 for unknown entities', async () => {
catalog.getEntityByName.mockResolvedValue(undefined);
catalog.getEntityByRef.mockResolvedValue(undefined);
async function testUrl(url: string) {
const response = await request(app).get(url);
expect(response.status).toEqual(404);

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