diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml
index 7704aa36cd..32fb15d329 100644
--- a/.github/workflows/cli.yml
+++ b/.github/workflows/cli.yml
@@ -13,6 +13,18 @@ jobs:
build:
runs-on: ${{ matrix.os }}
+ services:
+ postgres:
+ image: postgres:latest
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: postgres
+ ports:
+ - 5432/tcp
+ # needed because the postgres container does not provide a healthcheck
+ options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
+
strategy:
matrix:
os: [ubuntu-latest]
@@ -44,6 +56,11 @@ jobs:
- run: yarn tsc
- run: yarn build
- name: verify app and plugin creation
+ env:
+ POSTGRES_HOST: localhost
+ POSTGRES_PORT: ${{ job.services.postgres.ports[5432] }}
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
run: |
sudo sysctl fs.inotify.max_user_watches=524288
node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js
diff --git a/ADOPTERS.md b/ADOPTERS.md
index 3af423df9e..7a96198529 100644
--- a/ADOPTERS.md
+++ b/ADOPTERS.md
@@ -1,13 +1,13 @@
-| Organization | Contact | Description of Use |
-| ---------------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------- |
-| [Spotify](https://www.spotify.com) | [@stefanalund](https://github.com/stefanalund) | Main interface towards all of Spotify's infrastructure and technical documentation. |
-| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. |
-| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. |
-| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up |
-| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. |
-| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. |
-| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. |
-| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications |
-| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. |
-| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. |
-| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D
+| Organization | Contact | Description of Use |
+| --------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
+| [Spotify](https://www.spotify.com) | [@stefanalund](https://github.com/stefanalund) | Main interface towards all of Spotify's infrastructure and technical documentation. |
+| [bol.com](https://www.bol.com) | [@RoyJacobs](https://github.com/RoyJacobs) | Initial work being done to unify platform tooling. |
+| [DFDS](https://www.dfds.com) | [@carlsendk](https://github.com/carlsendk) | V2 self-service platform. |
+| [Roadie](https://roadie.io) | [@dtuite](https://github.com/dtuite) | Hosted, managed Backstage with easy set-up |
+| [Roku](https://www.roku.com) | [@timurista](https://github.com/timurista) | Initial work on Cloud engineering service platform. |
+| [SDA SE](https://sda.se) | [@Fox32](https://github.com/Fox32) | Central place for developing and sharing services in our insurance ecosystem. |
+| [H-E-B](https://www.heb.com) | [@german-j-rodriguez](https://github.com/german-j-rodriguez) | Initial work on Engineering Portal service platform. |
+| [American Airlines](https://www.aa.com) | [@paulpach](https://github.com/paulpach) | Central place for developers to develop and maintain applications |
+| [Kiwi.com](https://kiwi.com) | [@aexvir](https://github.com/aexvir) | Replacing the frontend of [The Zoo](https://github.com/kiwicom/the-zoo), their service registry. |
+| [Voi](https://www.voiscooters.com/) | [@K-Phoen](https://github.com/K-Phoen) | Developer portal, main gateway to our infrastructure, documentation and internal tooling. |
+| [Talkdesk](https://www.talkdesk.com) | [@jaime-talkdesk](https://github.com/jaime-talkdesk) | Initial work for Engineering Portal and Self Provisioning to R&D |
diff --git a/app-config.yaml b/app-config.yaml
index 35cc7b6e1a..e5f872201d 100644
--- a/app-config.yaml
+++ b/app-config.yaml
@@ -25,3 +25,72 @@ techdocs:
sentry:
organization: spotify
+
+auth:
+ providers:
+ google:
+ development:
+ appOrigin: "http://localhost:3000/"
+ secure: false
+ clientId:
+ $secret:
+ env: AUTH_GOOGLE_CLIENT_ID
+ clientSecret:
+ $secret:
+ env: AUTH_GOOGLE_CLIENT_SECRET
+ github:
+ development:
+ appOrigin: "http://localhost:3000/"
+ secure: false
+ clientId:
+ $secret:
+ env: AUTH_GITHUB_CLIENT_ID
+ clientSecret:
+ $secret:
+ env: AUTH_GITHUB_CLIENT_SECRET
+ gitlab:
+ development:
+ appOrigin: "http://localhost:3000/"
+ secure: false
+ clientId:
+ $secret:
+ env: AUTH_GITLAB_CLIENT_ID
+ clientSecret:
+ $secret:
+ env: AUTH_GITLAB_CLIENT_SECRET
+ audience:
+ $secret:
+ env: GITLAB_BASE_URL
+ # saml:
+ # development:
+ # entryPoint: "http://localhost:7001/"
+ # issuer: "passport-saml"
+ okta:
+ development:
+ appOrigin: "http://localhost:3000/"
+ secure: false
+ clientId:
+ $secret:
+ env: AUTH_OKTA_CLIENT_ID
+ clientSecret:
+ $secret:
+ env: AUTH_OKTA_CLIENT_SECRET
+ audience:
+ $secret:
+ env: AUTH_OKTA_AUDIENCE
+ oauth2:
+ development:
+ appOrigin: "http://localhost:3000/"
+ secure: false
+ clientId:
+ $secret:
+ env: AUTH_OAUTH2_CLIENT_ID
+ clientSecret:
+ $secret:
+ env: AUTH_OAUTH2_CLIENT_SECRET
+ authorizationURL:
+ $secret:
+ env: AUTH_OAUTH2_AUTH_URL
+ tokenURL:
+ $secret:
+ env: AUTH_OAUTH2_TOKEN_URL
diff --git a/docs/README.md b/docs/README.md
index 6763554afb..b6ff4fbd6d 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -77,7 +77,8 @@ better yet, a pull request.
- [Figma resources](dls/figma.md)
- API references
- TypeScript API
- - [Utilities](api/utility-apis.md)
+ - [Utility APIs](api/utility-apis.md)
+ - [Utility API References](reference/utility-apis/README.md)
- [createPlugin](reference/createPlugin.md)
- [createPlugin-feature-flags](reference/createPlugin-feature-flags.md)
- [createPlugin-router](reference/createPlugin-router.md)
@@ -93,6 +94,7 @@ better yet, a pull request.
- [ADR004 - Module Export Structure](architecture-decisions/adr004-module-export-structure.md)
- [ADR005 - Catalog Core Entities](architecture-decisions/adr005-catalog-core-entities.md)
- [ADR006 - Avoid React.FC and React.SFC](architecture-decisions/adr006-avoid-react-fc.md)
+ - [ADR007 - Use MSW for Mocking Network Requests](architecture-decisions/adr007-use-msw-to-mock-service-requests.md)
- [Contribute](../CONTRIBUTING.md)
- [Support](overview/support.md)
- [FAQ](FAQ.md)
diff --git a/docs/api/utility-apis.md b/docs/api/utility-apis.md
index 2b15da0295..f87db15e7e 100644
--- a/docs/api/utility-apis.md
+++ b/docs/api/utility-apis.md
@@ -19,7 +19,8 @@ during their entire life cycle.
Each Utility API is tied to an `ApiRef` instance, which is a global singleton
object without any additional state or functionality, its only purpose is to
reference Utility APIs. `ApiRef`s are create using `createApiRef`, which is
-exported by `@backstage/core`. There are many predefined Utility APIs defined in
+exported by `@backstage/core`. There are many
+[predefined Utility APIs](../reference/utility-apis/README.md) defined in
`@backstage/core`, and they're all exported with a name of the pattern
`*ApiRef`, for example `errorApiRef`.
diff --git a/docs/architecture-decisions/adr007-use-msw-to-mock-service-requests.md b/docs/architecture-decisions/adr007-use-msw-to-mock-service-requests.md
new file mode 100644
index 0000000000..bdb221cea3
--- /dev/null
+++ b/docs/architecture-decisions/adr007-use-msw-to-mock-service-requests.md
@@ -0,0 +1,60 @@
+# ADR007: Use MSW to mock http requests
+
+## Context
+
+Network request mocking can be a total pain sometimes, in all different types of
+tests, unit tests to e2e tests always have their own implementation of mocking
+these requests. There's been traction in the outer community towards using this
+library to mock network requests by using an express style declaration for
+routes. react-testing-library suggests using this library instead of mocking
+fetch directly wether this be in a browser or in node.
+
+https://github.com/mswjs/msw
+
+## Decision
+
+Moving forward, we have decided that any `fetch` or `XMLHTTPRequest` that
+happens, should be mocked by using `msw`.
+
+Here is an example:
+
+```ts
+import { setupWorker, rest } from 'msw';
+
+const worker = setupWorker(
+ rest.get('*/user/:userId', (req, res, ctx) => {
+ return res(
+ ctx.json({
+ firstName: 'John',
+ lastName: 'Maverick',
+ }),
+ );
+ }),
+);
+
+// Start the Mock Service Worker
+worker.start();
+```
+
+and in a more real life scenario, taken from
+[CatalogClient.test.ts](https://github.com/spotify/backstage/blob/f3245c4f8f0b6b2625c4a6d5d50161b612fb4757/plugins/catalog/src/api/CatalogClient.test.ts)
+
+```ts
+beforeEach(() => {
+ server.use(
+ rest.get(`${mockApiOrigin}${mockBasePath}/entities`, (_, res, ctx) => {
+ return res(ctx.json(defaultResponse));
+ }),
+ );
+});
+
+it('should entities from correct endpoint', async () => {
+ const entities = await client.getEntities();
+ expect(entities).toEqual(defaultResponse);
+});
+```
+
+## Consequences
+
+- A little more code to write
+- Gradually will replace the codebase with `msw`
diff --git a/docs/auth/index.md b/docs/auth/index.md
index 0cb56501ab..6f9af17cba 100644
--- a/docs/auth/index.md
+++ b/docs/auth/index.md
@@ -26,7 +26,8 @@ OAuth helps in that regard.
The method with which frontend plugins request access to third party services is
through [Utility APIs](../api/utility-apis.md) for each service provider. For a
-full list of providers, see [TODO](#TODO).
+full list of providers, see the
+[Utility API References](../reference/utility-apis/README.md).
### Identity - WIP
diff --git a/docs/reference/utility-apis/AlertApi.md b/docs/reference/utility-apis/AlertApi.md
new file mode 100644
index 0000000000..1f0db84e29
--- /dev/null
+++ b/docs/reference/utility-apis/AlertApi.md
@@ -0,0 +1,114 @@
+# AlertApi
+
+The AlertApi type is defined at
+[packages/core-api/src/apis/definitions/AlertApi.ts:29](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/AlertApi.ts#L29).
+
+The following Utility API implements this type: [alertApiRef](./README.md#alert)
+
+## Members
+
+### post()
+
+Post an alert for handling by the application.
+
+
+post(alert: AlertMessage): void
+
+
+### alert\$()
+
+Observe alerts posted by other parts of the application.
+
+
+alert$(): Observable<AlertMessage>
+
+
+## Supporting types
+
+These types are part of the API declaration, but may not be unique to this API.
+
+### AlertMessage
+
+
+export type AlertMessage = {
+ message: string;
+ // Severity will default to success since that is what material ui defaults the value to.
+ severity?: 'success' | 'info' | 'warning' | 'error';
+}
+
+
+Defined at
+[packages/core-api/src/apis/definitions/AlertApi.ts:19](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/AlertApi.ts#L19).
+
+Referenced by: [post](#post), [alert\$](#alert).
+
+### Observable
+
+Observable sequence of values and errors, see TC39.
+
+https://github.com/tc39/proposal-observable
+
+This is used as a common return type for observable values and can be created
+using many different observable implementations, such as zen-observable or
+RxJS 5.
+
+
+export type Observable<T> = {
+ /**
+ * Subscribes to this observable to start receiving new values.
+ */
+ subscribe(observer: Observer<T>): Subscription;
+ subscribe(
+ onNext: (value: T) => void,
+ onError?: (error: Error) => void,
+ onComplete?: () => void,
+ ): Subscription;
+}
+
+
+Defined at
+[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L53).
+
+Referenced by: [alert\$](#alert).
+
+### Observer
+
+This file contains non-react related core types used throught Backstage.
+
+Observer interface for consuming an Observer, see TC39.
+
+
+export type Observer<T> = {
+ next?(value: T): void;
+ error?(error: Error): void;
+ complete?(): void;
+}
+
+
+Defined at
+[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L24).
+
+Referenced by: [Observable](#observable).
+
+### Subscription
+
+Subscription returned when subscribing to an Observable, see TC39.
+
+
+export type Subscription = {
+ /**
+ * Cancels the subscription
+ */
+ unsubscribe(): void;
+
+ /**
+ * Value indicating whether the subscription is closed.
+ */
+ readonly closed: Boolean;
+}
+
+
+Defined at
+[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L33).
+
+Referenced by: [Observable](#observable).
diff --git a/docs/reference/utility-apis/AppThemeApi.md b/docs/reference/utility-apis/AppThemeApi.md
new file mode 100644
index 0000000000..02ab83afff
--- /dev/null
+++ b/docs/reference/utility-apis/AppThemeApi.md
@@ -0,0 +1,225 @@
+# AppThemeApi
+
+The AppThemeApi type is defined at
+[packages/core-api/src/apis/definitions/AppThemeApi.ts:50](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/AppThemeApi.ts#L50).
+
+The following Utility API implements this type:
+[appThemeApiRef](./README.md#apptheme)
+
+## Members
+
+### getInstalledThemes()
+
+Get a list of available themes.
+
+
+getInstalledThemes(): AppTheme[]
+
+
+### activeThemeId\$()
+
+Observe the currently selected theme. A value of undefined means no specific
+theme has been selected.
+
+
+activeThemeId$(): Observable<string | undefined>
+
+
+### getActiveThemeId()
+
+Get the current theme ID. Returns undefined if no specific theme is selected.
+
+
+getActiveThemeId(): string | undefined
+
+
+### setActiveThemeId()
+
+Set a specific theme to use in the app, overriding the default theme selection.
+
+Clear the selection by passing in undefined.
+
+
+setActiveThemeId(themeId?: string): void
+
+
+## Supporting types
+
+These types are part of the API declaration, but may not be unique to this API.
+
+### AppTheme
+
+Describes a theme provided by the app.
+
+
+export type AppTheme = {
+ /**
+ * ID used to remember theme selections.
+ */
+ id: string;
+
+ /**
+ * Title of the theme
+ */
+ title: string;
+
+ /**
+ * Theme variant
+ */
+ variant: 'light' | 'dark';
+
+ /**
+ * The specialized MaterialUI theme instance.
+ */
+ theme: BackstageTheme;
+}
+
+
+Defined at
+[packages/core-api/src/apis/definitions/AppThemeApi.ts:24](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/AppThemeApi.ts#L24).
+
+Referenced by: [getInstalledThemes](#getinstalledthemes).
+
+### BackstagePalette
+
+
+export type BackstagePalette = Palette & PaletteAdditions
+
+
+Defined at
+[packages/theme/src/types.ts:63](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/theme/src/types.ts#L63).
+
+Referenced by: [BackstageTheme](#backstagetheme).
+
+### BackstageTheme
+
+
+export interface BackstageTheme extends Theme {
+ palette: BackstagePalette;
+}
+
+
+Defined at
+[packages/theme/src/types.ts:66](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/theme/src/types.ts#L66).
+
+Referenced by: [AppTheme](#apptheme).
+
+### Observable
+
+Observable sequence of values and errors, see TC39.
+
+https://github.com/tc39/proposal-observable
+
+This is used as a common return type for observable values and can be created
+using many different observable implementations, such as zen-observable or
+RxJS 5.
+
+
+export type Observable<T> = {
+ /**
+ * Subscribes to this observable to start receiving new values.
+ */
+ subscribe(observer: Observer<T>): Subscription;
+ subscribe(
+ onNext: (value: T) => void,
+ onError?: (error: Error) => void,
+ onComplete?: () => void,
+ ): Subscription;
+}
+
+
+Defined at
+[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L53).
+
+Referenced by: [activeThemeId\$](#activethemeid).
+
+### Observer
+
+This file contains non-react related core types used throught Backstage.
+
+Observer interface for consuming an Observer, see TC39.
+
+
+export type Observer<T> = {
+ next?(value: T): void;
+ error?(error: Error): void;
+ complete?(): void;
+}
+
+
+Defined at
+[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L24).
+
+Referenced by: [Observable](#observable).
+
+### PaletteAdditions
+
+
+type PaletteAdditions = {
+ status: {
+ ok: string;
+ warning: string;
+ error: string;
+ pending: string;
+ running: string;
+ aborted: string;
+ };
+ border: string;
+ textContrast: string;
+ textVerySubtle: string;
+ textSubtle: string;
+ highlight: string;
+ errorBackground: string;
+ warningBackground: string;
+ infoBackground: string;
+ errorText: string;
+ infoText: string;
+ warningText: string;
+ linkHover: string;
+ link: string;
+ gold: string;
+ sidebar: string;
+ tabbar: {
+ indicator: string;
+ };
+ bursts: {
+ fontColor: string;
+ slackChannelText: string;
+ backgroundColor: {
+ default: string;
+ };
+ };
+ pinSidebarButton: {
+ icon: string;
+ background: string;
+ };
+}
+
+
+Defined at
+[packages/theme/src/types.ts:23](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/theme/src/types.ts#L23).
+
+Referenced by: [BackstagePalette](#backstagepalette).
+
+### Subscription
+
+Subscription returned when subscribing to an Observable, see TC39.
+
+
+export type Subscription = {
+ /**
+ * Cancels the subscription
+ */
+ unsubscribe(): void;
+
+ /**
+ * Value indicating whether the subscription is closed.
+ */
+ readonly closed: Boolean;
+}
+
+
+Defined at
+[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L33).
+
+Referenced by: [Observable](#observable).
diff --git a/docs/reference/utility-apis/BackstageIdentityApi.md b/docs/reference/utility-apis/BackstageIdentityApi.md
new file mode 100644
index 0000000000..a9e0203403
--- /dev/null
+++ b/docs/reference/utility-apis/BackstageIdentityApi.md
@@ -0,0 +1,88 @@
+# BackstageIdentityApi
+
+The BackstageIdentityApi type is defined at
+[packages/core-api/src/apis/definitions/auth.ts:144](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L144).
+
+The following Utility APIs implement this type:
+
+- [githubAuthApiRef](./README.md#githubauth)
+
+- [gitlabAuthApiRef](./README.md#gitlabauth)
+
+- [googleAuthApiRef](./README.md#googleauth)
+
+- [oktaAuthApiRef](./README.md#oktaauth)
+
+## Members
+
+### getBackstageIdentity()
+
+Get the user's identity within Backstage. This should normally not be called
+directly, use the @IdentityApi instead.
+
+If the optional flag is not set, a session is guaranteed to be returned, while
+if the optional flag is set, the session may be undefined. See
+@AuthRequestOptions for more details.
+
+
+getBackstageIdentity(
+ options?: AuthRequestOptions,
+ ): Promise<BackstageIdentity | undefined>
+
+
+## Supporting types
+
+These types are part of the API declaration, but may not be unique to this API.
+
+### AuthRequestOptions
+
+
+export type AuthRequestOptions = {
+ /**
+ * If this is set to true, the user will not be prompted to log in,
+ * and an empty response will be returned if there is no existing session.
+ *
+ * This can be used to perform a check whether the user is logged in, or if you don't
+ * want to force a user to be logged in, but provide functionality if they already are.
+ *
+ * @default false
+ */
+ optional?: boolean;
+
+ /**
+ * If this is set to true, the request will bypass the regular oauth login modal
+ * and open the login popup directly.
+ *
+ * The method must be called synchronously from a user action for this to work in all browsers.
+ *
+ * @default false
+ */
+ instantPopup?: boolean;
+}
+
+
+Defined at
+[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L40).
+
+Referenced by: [getBackstageIdentity](#getbackstageidentity).
+
+### BackstageIdentity
+
+
+export type BackstageIdentity = {
+ /**
+ * The backstage user ID.
+ */
+ id: string;
+
+ /**
+ * An ID token that can be used to authenticate the user within Backstage.
+ */
+ idToken: string;
+}
+
+
+Defined at
+[packages/core-api/src/apis/definitions/auth.ts:157](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L157).
+
+Referenced by: [getBackstageIdentity](#getbackstageidentity).
diff --git a/docs/reference/utility-apis/Config.md b/docs/reference/utility-apis/Config.md
new file mode 100644
index 0000000000..bca590bc9b
--- /dev/null
+++ b/docs/reference/utility-apis/Config.md
@@ -0,0 +1,179 @@
+# Config
+
+The Config type is defined at
+[packages/config/src/types.ts:32](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/config/src/types.ts#L32).
+
+The following Utility API implements this type:
+[configApiRef](./README.md#config)
+
+## Members
+
+### keys()
+
+
+keys(): string[]
+
+
+### get()
+
+
+get(key: string): JsonValue
+
+
+### getOptional()
+
+
+getOptional(key: string): JsonValue | undefined
+
+
+### getConfig()
+
+
+getConfig(key: string): Config
+
+
+### getOptionalConfig()
+
+
+getOptionalConfig(key: string): Config | undefined
+
+
+### getConfigArray()
+
+
+getConfigArray(key: string): Config[]
+
+
+### getOptionalConfigArray()
+
+
+getOptionalConfigArray(key: string): Config[] | undefined
+
+
+### getNumber()
+
+
+getNumber(key: string): number
+
+
+### getOptionalNumber()
+
+
+getOptionalNumber(key: string): number | undefined
+
+
+### getBoolean()
+
+
+getBoolean(key: string): boolean
+
+
+### getOptionalBoolean()
+
+
+getOptionalBoolean(key: string): boolean | undefined
+
+
+### getString()
+
+
+getString(key: string): string
+
+
+### getOptionalString()
+
+
+getOptionalString(key: string): string | undefined
+
+
+### getStringArray()
+
+
+getStringArray(key: string): string[]
+
+
+### getOptionalStringArray()
+
+
+getOptionalStringArray(key: string): string[] | undefined
+
+
+## Supporting types
+
+These types are part of the API declaration, but may not be unique to this API.
+
+### Config
+
+
+export type Config = {
+ keys(): string[];
+
+ get(key: string): JsonValue;
+ getOptional(key: string): JsonValue | undefined;
+
+ getConfig(key: string): Config;
+ getOptionalConfig(key: string): Config | undefined;
+
+ getConfigArray(key: string): Config[];
+ getOptionalConfigArray(key: string): Config[] | undefined;
+
+ getNumber(key: string): number;
+ getOptionalNumber(key: string): number | undefined;
+
+ getBoolean(key: string): boolean;
+ getOptionalBoolean(key: string): boolean | undefined;
+
+ getString(key: string): string;
+ getOptionalString(key: string): string | undefined;
+
+ getStringArray(key: string): string[];
+ getOptionalStringArray(key: string): string[] | undefined;
+}
+
+
+Defined at
+[packages/config/src/types.ts:32](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/config/src/types.ts#L32).
+
+Referenced by: [getConfig](#getconfig), [getOptionalConfig](#getoptionalconfig),
+[getConfigArray](#getconfigarray),
+[getOptionalConfigArray](#getoptionalconfigarray), [Config](#config).
+
+### JsonArray
+
+
+export type JsonArray = JsonValue[]
+
+
+Defined at
+[packages/config/src/types.ts:18](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/config/src/types.ts#L18).
+
+Referenced by: [JsonValue](#jsonvalue).
+
+### JsonObject
+
+
+export type JsonObject = { [key in string]?: JsonValue }
+
+
+Defined at
+[packages/config/src/types.ts:17](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/config/src/types.ts#L17).
+
+Referenced by: [JsonValue](#jsonvalue).
+
+### JsonValue
+
+
+export type JsonValue =
+ | JsonObject
+ | JsonArray
+ | number
+ | string
+ | boolean
+ | null
+
+
+Defined at
+[packages/config/src/types.ts:19](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/config/src/types.ts#L19).
+
+Referenced by: [get](#get), [getOptional](#getoptional),
+[JsonObject](#jsonobject), [JsonArray](#jsonarray), [Config](#config).
diff --git a/docs/reference/utility-apis/ErrorApi.md b/docs/reference/utility-apis/ErrorApi.md
new file mode 100644
index 0000000000..10ae47e6df
--- /dev/null
+++ b/docs/reference/utility-apis/ErrorApi.md
@@ -0,0 +1,134 @@
+# ErrorApi
+
+The ErrorApi type is defined at
+[packages/core-api/src/apis/definitions/ErrorApi.ts:53](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/ErrorApi.ts#L53).
+
+The following Utility API implements this type: [errorApiRef](./README.md#error)
+
+## Members
+
+### post()
+
+Post an error for handling by the application.
+
+
+post(error: Error, context?: ErrorContext): void
+
+
+### error\$()
+
+Observe errors posted by other parts of the application.
+
+
+error$(): Observable<{ error: Error; context?: ErrorContext }>
+
+
+## Supporting types
+
+These types are part of the API declaration, but may not be unique to this API.
+
+### Error
+
+Mirrors the javascript Error class, for the purpose of providing documentation
+and optional fields.
+
+
+type Error = {
+ name: string;
+ message: string;
+ stack?: string;
+}
+
+
+Defined at
+[packages/core-api/src/apis/definitions/ErrorApi.ts:24](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/ErrorApi.ts#L24).
+
+Referenced by: [post](#post), [error\$](#error).
+
+### ErrorContext
+
+Provides additional information about an error that was posted to the
+application.
+
+
+export type ErrorContext = {
+ // If set to true, this error should not be displayed to the user. Defaults to false.
+ hidden?: boolean;
+}
+
+
+Defined at
+[packages/core-api/src/apis/definitions/ErrorApi.ts:33](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/ErrorApi.ts#L33).
+
+Referenced by: [post](#post), [error\$](#error).
+
+### Observable
+
+Observable sequence of values and errors, see TC39.
+
+https://github.com/tc39/proposal-observable
+
+This is used as a common return type for observable values and can be created
+using many different observable implementations, such as zen-observable or
+RxJS 5.
+
+
+export type Observable<T> = {
+ /**
+ * Subscribes to this observable to start receiving new values.
+ */
+ subscribe(observer: Observer<T>): Subscription;
+ subscribe(
+ onNext: (value: T) => void,
+ onError?: (error: Error) => void,
+ onComplete?: () => void,
+ ): Subscription;
+}
+
+
+Defined at
+[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L53).
+
+Referenced by: [error\$](#error).
+
+### Observer
+
+This file contains non-react related core types used throught Backstage.
+
+Observer interface for consuming an Observer, see TC39.
+
+
+export type Observer<T> = {
+ next?(value: T): void;
+ error?(error: Error): void;
+ complete?(): void;
+}
+
+
+Defined at
+[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L24).
+
+Referenced by: [Observable](#observable).
+
+### Subscription
+
+Subscription returned when subscribing to an Observable, see TC39.
+
+
+export type Subscription = {
+ /**
+ * Cancels the subscription
+ */
+ unsubscribe(): void;
+
+ /**
+ * Value indicating whether the subscription is closed.
+ */
+ readonly closed: Boolean;
+}
+
+
+Defined at
+[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L33).
+
+Referenced by: [Observable](#observable).
diff --git a/docs/reference/utility-apis/FeatureFlagsApi.md b/docs/reference/utility-apis/FeatureFlagsApi.md
new file mode 100644
index 0000000000..5069c9272c
--- /dev/null
+++ b/docs/reference/utility-apis/FeatureFlagsApi.md
@@ -0,0 +1,33 @@
+# FeatureFlagsApi
+
+The FeatureFlagsApi type is defined at
+[packages/core-api/src/apis/definitions/FeatureFlagsApi.ts:41](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L41).
+
+The following Utility API implements this type:
+[featureFlagsApiRef](./README.md#featureflags)
+
+## Members
+
+### registeredFeatureFlags
+
+Store a list of registered feature flags.
+
+
+registeredFeatureFlags: FeatureFlagsRegistryItem[]
+
+
+### getFlags()
+
+Get a list of all feature flags from the current user.
+
+
+getFlags(): UserFlags
+
+
+### getRegisteredFlags()
+
+Get a list of all registered flags.
+
+
+getRegisteredFlags(): FeatureFlagsRegistry
+
diff --git a/docs/reference/utility-apis/IdentityApi.md b/docs/reference/utility-apis/IdentityApi.md
new file mode 100644
index 0000000000..2c94b7fcd0
--- /dev/null
+++ b/docs/reference/utility-apis/IdentityApi.md
@@ -0,0 +1,81 @@
+# IdentityApi
+
+The IdentityApi type is defined at
+[packages/core-api/src/apis/definitions/IdentityApi.ts:22](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/IdentityApi.ts#L22).
+
+The following Utility API implements this type:
+[identityApiRef](./README.md#identity)
+
+## Members
+
+### getUserId()
+
+The ID of the signed in user. This ID is not meant to be presented to the user,
+but used as an opaque string to pass on to backends or use in frontend logic.
+
+TODO: The intention of the user ID is to be able to tie the user to an identity
+that is known by the catalog and/or identity backend. It should for example be
+possible to fetch all owned components using this ID.
+
+
+getUserId(): string
+
+
+### getProfile()
+
+The profile of the signed in user.
+
+
+getProfile(): ProfileInfo
+
+
+### getIdToken()
+
+An OpenID Connect ID Token which proves the identity of the signed in user.
+
+The ID token will be undefined if the signed in user does not have a verified
+identity, such as a demo user or mocked user for e2e tests.
+
+
+getIdToken(): Promise<string | undefined>
+
+
+### logout()
+
+Log out the current user
+
+
+logout(): Promise<void>
+
+
+## Supporting types
+
+These types are part of the API declaration, but may not be unique to this API.
+
+### ProfileInfo
+
+Profile information of the user.
+
+
+export type ProfileInfo = {
+ /**
+ * Email ID.
+ */
+ email?: string;
+
+ /**
+ * Display name that can be presented to the user.
+ */
+ displayName?: string;
+
+ /**
+ * URL to an avatar image of the user.
+ */
+ picture?: string;
+}
+
+
+Defined at
+[packages/core-api/src/apis/definitions/auth.ts:172](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L172).
+
+Referenced by: [getProfile](#getprofile).
diff --git a/docs/reference/utility-apis/OAuthApi.md b/docs/reference/utility-apis/OAuthApi.md
new file mode 100644
index 0000000000..09a4c4b1b4
--- /dev/null
+++ b/docs/reference/utility-apis/OAuthApi.md
@@ -0,0 +1,119 @@
+# OAuthApi
+
+The OAuthApi type is defined at
+[packages/core-api/src/apis/definitions/auth.ts:67](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L67).
+
+The following Utility APIs implement this type:
+
+- [githubAuthApiRef](./README.md#githubauth)
+
+- [gitlabAuthApiRef](./README.md#gitlabauth)
+
+- [googleAuthApiRef](./README.md#googleauth)
+
+- [oauth2ApiRef](./README.md#oauth2)
+
+- [oktaAuthApiRef](./README.md#oktaauth)
+
+## Members
+
+### getAccessToken()
+
+Requests an OAuth 2 Access Token, optionally with a set of scopes. The access
+token allows you to make requests on behalf of the user, and the copes may grant
+you broader access, depending on the auth provider.
+
+Each auth provider has separate handling of scope, so you need to look at the
+documentation for each one to know what scope you need to request.
+
+This method is cheap and should be called each time an access token is used. Do
+not for example store the access token in React component state, as that could
+cause the token to expire. Instead fetch a new access token for each request.
+
+Be sure to include all required scopes when requesting an access token. When
+testing your implementation it is best to log out the Backstage session and then
+visit your plugin page directly, as you might already have some required scopes
+in your existing session. Not requesting the correct scopes can lead to 403 or
+other authorization errors, which can be tricky to debug.
+
+If the user has not yet granted access to the provider and the set of requested
+scopes, the user will be prompted to log in. The returned promise will not
+resolve until the user has successfully logged in. The returned promise can be
+rejected, but only if the user rejects the login request.
+
+
+getAccessToken(
+ scope?: OAuthScope,
+ options?: AuthRequestOptions,
+ ): Promise<string>
+
+
+### logout()
+
+Log out the user's session. This will reload the page.
+
+
+logout(): Promise<void>
+
+
+## Supporting types
+
+These types are part of the API declaration, but may not be unique to this API.
+
+### AuthRequestOptions
+
+
+export type AuthRequestOptions = {
+ /**
+ * If this is set to true, the user will not be prompted to log in,
+ * and an empty response will be returned if there is no existing session.
+ *
+ * This can be used to perform a check whether the user is logged in, or if you don't
+ * want to force a user to be logged in, but provide functionality if they already are.
+ *
+ * @default false
+ */
+ optional?: boolean;
+
+ /**
+ * If this is set to true, the request will bypass the regular oauth login modal
+ * and open the login popup directly.
+ *
+ * The method must be called synchronously from a user action for this to work in all browsers.
+ *
+ * @default false
+ */
+ instantPopup?: boolean;
+}
+
+
+Defined at
+[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L40).
+
+Referenced by: [getAccessToken](#getaccesstoken).
+
+### OAuthScope
+
+This file contains declarations for common interfaces of auth-related APIs. The
+declarations should be used to signal which type of authentication and
+authorization methods each separate auth provider supports.
+
+For example, a Google OAuth provider that supports OAuth 2 and OpenID Connect,
+would be declared as follows:
+
+const googleAuthApiRef = createApiRef({ ... })
+
+An array of scopes, or a scope string formatted according to the auth provider,
+which is typically a space separated list.
+
+See the documentation for each auth provider for the list of scopes supported by
+each provider.
+
+
+export type OAuthScope = string | string[]
+
+
+Defined at
+[packages/core-api/src/apis/definitions/auth.ts:38](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L38).
+
+Referenced by: [getAccessToken](#getaccesstoken).
diff --git a/docs/reference/utility-apis/OAuthRequestApi.md b/docs/reference/utility-apis/OAuthRequestApi.md
new file mode 100644
index 0000000000..fc38ca827b
--- /dev/null
+++ b/docs/reference/utility-apis/OAuthRequestApi.md
@@ -0,0 +1,232 @@
+# OAuthRequestApi
+
+The OAuthRequestApi type is defined at
+[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:99](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L99).
+
+The following Utility API implements this type:
+[oauthRequestApiRef](./README.md#oauthrequest)
+
+## Members
+
+### createAuthRequester()
+
+A utility for showing login popups or similar things, and merging together
+multiple requests for different scopes into one request that inclues all scopes.
+
+The passed in options provide information about the login provider, and how to
+handle auth requests.
+
+The returned AuthRequester function is used to request login with new scopes.
+These requests are merged together and forwarded to the auth handler, as soon as
+a consumer of auth requests triggers an auth flow.
+
+See AuthRequesterOptions, AuthRequester, and handleAuthRequests for more info.
+
+
+createAuthRequester<AuthResponse>(
+ options: AuthRequesterOptions<AuthResponse>,
+ ): AuthRequester<AuthResponse>
+
+
+### authRequest\$()
+
+Observers panding auth requests. The returned observable will emit all current
+active auth request, at most one for each created auth requester.
+
+Each request has its own info about the login provider, forwarded from the auth
+requester options.
+
+Depending on user interaction, the request should either be rejected, or used to
+trigger the auth handler. If the request is rejected, all pending AuthRequester
+calls will fail with a "RejectedError". If a auth is triggered, and the auth
+handler resolves successfully, then all currently pending AuthRequester calls
+will resolve to the value returned by the onAuthRequest call.
+
+
+authRequest$(): Observable<PendingAuthRequest[]>
+
+
+## Supporting types
+
+These types are part of the API declaration, but may not be unique to this API.
+
+### AuthProvider
+
+Information about the auth provider that we're requesting a login towards.
+
+This should be shown to the user so that they can be informed about what login
+is being requested before a popup is shown.
+
+
+export type AuthProvider = {
+ /**
+ * Title for the auth provider, for example "GitHub"
+ */
+ title: string;
+
+ /**
+ * Icon for the auth provider.
+ */
+ icon: IconComponent;
+}
+
+
+Defined at
+[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:27](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L27).
+
+Referenced by: [AuthRequesterOptions](#authrequesteroptions),
+[PendingAuthRequest](#pendingauthrequest).
+
+### AuthRequester
+
+Function used to trigger new auth requests for a set of scopes.
+
+The returned promise will resolve to the same value returned by the
+onAuthRequest in the AuthRequesterOptions. Or rejected, if the request is
+rejected.
+
+This function can be called multiple times before the promise resolves. All
+calls will be merged into one request, and the scopes forwarded to the
+onAuthRequest will be the union of all requested scopes.
+
+
+export type AuthRequester<AuthResponse> = (
+ scopes: Set<string>,
+) => Promise<AuthResponse>
+
+
+Defined at
+[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:66](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L66).
+
+Referenced by: [createAuthRequester](#createauthrequester).
+
+### AuthRequesterOptions
+
+Describes how to handle auth requests. Both how to show them to the user, and
+what to do when the user accesses the auth request.
+
+
+export type AuthRequesterOptions<AuthResponse> = {
+ /**
+ * Information about the auth provider, which will be forwarded to auth requests.
+ */
+ provider: AuthProvider;
+
+ /**
+ * Implementation of the auth flow, which will be called synchronously when
+ * trigger() is called on an auth requests.
+ */
+ onAuthRequest(scopes: Set<string>): Promise<AuthResponse>;
+}
+
+
+Defined at
+[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:43](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L43).
+
+Referenced by: [createAuthRequester](#createauthrequester).
+
+### Observable
+
+Observable sequence of values and errors, see TC39.
+
+https://github.com/tc39/proposal-observable
+
+This is used as a common return type for observable values and can be created
+using many different observable implementations, such as zen-observable or
+RxJS 5.
+
+
+export type Observable<T> = {
+ /**
+ * Subscribes to this observable to start receiving new values.
+ */
+ subscribe(observer: Observer<T>): Subscription;
+ subscribe(
+ onNext: (value: T) => void,
+ onError?: (error: Error) => void,
+ onComplete?: () => void,
+ ): Subscription;
+}
+
+
+Defined at
+[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L53).
+
+Referenced by: [authRequest\$](#authrequest).
+
+### Observer
+
+This file contains non-react related core types used throught Backstage.
+
+Observer interface for consuming an Observer, see TC39.
+
+
+export type Observer<T> = {
+ next?(value: T): void;
+ error?(error: Error): void;
+ complete?(): void;
+}
+
+
+Defined at
+[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L24).
+
+Referenced by: [Observable](#observable).
+
+### PendingAuthRequest
+
+An pending auth request for a single auth provider. The request will remain in
+this pending state until either reject() or trigger() is called.
+
+Any new requests for the same provider are merged into the existing pending
+request, meaning there will only ever be a single pending request for a given
+provider.
+
+
+export type PendingAuthRequest = {
+ /**
+ * Information about the auth provider, as given in the AuthRequesterOptions
+ */
+ provider: AuthProvider;
+
+ /**
+ * Rejects the request, causing all pending AuthRequester calls to fail with "RejectedError".
+ */
+ reject: () => void;
+
+ /**
+ * Trigger the auth request to continue the auth flow, by for example showing a popup.
+ *
+ * Synchronously calls onAuthRequest with all scope currently in the request.
+ */
+ trigger(): Promise<void>;
+}
+
+
+Defined at
+[packages/core-api/src/apis/definitions/OAuthRequestApi.ts:77](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L77).
+
+Referenced by: [authRequest\$](#authrequest).
+
+### Subscription
+
+Subscription returned when subscribing to an Observable, see TC39.
+
+
+export type Subscription = {
+ /**
+ * Cancels the subscription
+ */
+ unsubscribe(): void;
+
+ /**
+ * Value indicating whether the subscription is closed.
+ */
+ readonly closed: Boolean;
+}
+
+
+Defined at
+[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L33).
+
+Referenced by: [Observable](#observable).
diff --git a/docs/reference/utility-apis/OpenIdConnectApi.md b/docs/reference/utility-apis/OpenIdConnectApi.md
new file mode 100644
index 0000000000..ecbf439b84
--- /dev/null
+++ b/docs/reference/utility-apis/OpenIdConnectApi.md
@@ -0,0 +1,75 @@
+# OpenIdConnectApi
+
+The OpenIdConnectApi type is defined at
+[packages/core-api/src/apis/definitions/auth.ts:104](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L104).
+
+The following Utility APIs implement this type:
+
+- [googleAuthApiRef](./README.md#googleauth)
+
+- [oauth2ApiRef](./README.md#oauth2)
+
+- [oktaAuthApiRef](./README.md#oktaauth)
+
+## Members
+
+### getIdToken()
+
+Requests an OpenID Connect ID Token.
+
+This method is cheap and should be called each time an ID token is used. Do not
+for example store the id token in React component state, as that could cause the
+token to expire. Instead fetch a new id token for each request.
+
+If the user has not yet logged in to Google inside Backstage, the user will be
+prompted to log in. The returned promise will not resolve until the user has
+successfully logged in. The returned promise can be rejected, but only if the
+user rejects the login request.
+
+
+getIdToken(options?: AuthRequestOptions): Promise<string>
+
+
+### logout()
+
+Log out the user's session. This will reload the page.
+
+
+logout(): Promise<void>
+
+
+## Supporting types
+
+These types are part of the API declaration, but may not be unique to this API.
+
+### AuthRequestOptions
+
+
+export type AuthRequestOptions = {
+ /**
+ * If this is set to true, the user will not be prompted to log in,
+ * and an empty response will be returned if there is no existing session.
+ *
+ * This can be used to perform a check whether the user is logged in, or if you don't
+ * want to force a user to be logged in, but provide functionality if they already are.
+ *
+ * @default false
+ */
+ optional?: boolean;
+
+ /**
+ * If this is set to true, the request will bypass the regular oauth login modal
+ * and open the login popup directly.
+ *
+ * The method must be called synchronously from a user action for this to work in all browsers.
+ *
+ * @default false
+ */
+ instantPopup?: boolean;
+}
+
+
+Defined at
+[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L40).
+
+Referenced by: [getIdToken](#getidtoken).
diff --git a/docs/reference/utility-apis/ProfileInfoApi.md b/docs/reference/utility-apis/ProfileInfoApi.md
new file mode 100644
index 0000000000..469c923cb0
--- /dev/null
+++ b/docs/reference/utility-apis/ProfileInfoApi.md
@@ -0,0 +1,94 @@
+# ProfileInfoApi
+
+The ProfileInfoApi type is defined at
+[packages/core-api/src/apis/definitions/auth.ts:127](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L127).
+
+The following Utility APIs implement this type:
+
+- [githubAuthApiRef](./README.md#githubauth)
+
+- [gitlabAuthApiRef](./README.md#gitlabauth)
+
+- [googleAuthApiRef](./README.md#googleauth)
+
+- [oauth2ApiRef](./README.md#oauth2)
+
+- [oktaAuthApiRef](./README.md#oktaauth)
+
+## Members
+
+### getProfile()
+
+Get profile information for the user as supplied by this auth provider.
+
+If the optional flag is not set, a session is guaranteed to be returned, while
+if the optional flag is set, the session may be undefined. See
+@AuthRequestOptions for more details.
+
+
+getProfile(options?: AuthRequestOptions): Promise<ProfileInfo | undefined>
+
+
+## Supporting types
+
+These types are part of the API declaration, but may not be unique to this API.
+
+### AuthRequestOptions
+
+
+export type AuthRequestOptions = {
+ /**
+ * If this is set to true, the user will not be prompted to log in,
+ * and an empty response will be returned if there is no existing session.
+ *
+ * This can be used to perform a check whether the user is logged in, or if you don't
+ * want to force a user to be logged in, but provide functionality if they already are.
+ *
+ * @default false
+ */
+ optional?: boolean;
+
+ /**
+ * If this is set to true, the request will bypass the regular oauth login modal
+ * and open the login popup directly.
+ *
+ * The method must be called synchronously from a user action for this to work in all browsers.
+ *
+ * @default false
+ */
+ instantPopup?: boolean;
+}
+
+
+Defined at
+[packages/core-api/src/apis/definitions/auth.ts:40](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L40).
+
+Referenced by: [getProfile](#getprofile).
+
+### ProfileInfo
+
+Profile information of the user.
+
+
+export type ProfileInfo = {
+ /**
+ * Email ID.
+ */
+ email?: string;
+
+ /**
+ * Display name that can be presented to the user.
+ */
+ displayName?: string;
+
+ /**
+ * URL to an avatar image of the user.
+ */
+ picture?: string;
+}
+
+
+Defined at
+[packages/core-api/src/apis/definitions/auth.ts:172](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L172).
+
+Referenced by: [getProfile](#getprofile).
diff --git a/docs/reference/utility-apis/README.md b/docs/reference/utility-apis/README.md
new file mode 100644
index 0000000000..66da8403b0
--- /dev/null
+++ b/docs/reference/utility-apis/README.md
@@ -0,0 +1,139 @@
+# Backstage Core Utility APIs
+
+The following is a list of all Utility APIs defined by `@backstage/core`. They
+are available to use by plugins and components, and can be accessed using the
+`useApi` hook, also provided by `@backstage/core`. For more information, see
+https://github.com/spotify/backstage/blob/master/docs/api/utility-apis.md.
+
+### alert
+
+Used to report alerts and forward them to the app
+
+Implemented type: [AlertApi](./AlertApi.md)
+
+ApiRef:
+[alertApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/AlertApi.ts#L41)
+
+### appTheme
+
+API Used to configure the app theme, and enumerate options
+
+Implemented type: [AppThemeApi](./AppThemeApi.md)
+
+ApiRef:
+[appThemeApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/AppThemeApi.ts#L74)
+
+### config
+
+Used to access runtime configuration
+
+Implemented type: [Config](./Config.md)
+
+ApiRef:
+[configApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/ConfigApi.ts#L22)
+
+### error
+
+Used to report errors and forward them to the app
+
+Implemented type: [ErrorApi](./ErrorApi.md)
+
+ApiRef:
+[errorApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/ErrorApi.ts#L65)
+
+### featureFlags
+
+Used to toggle functionality in features across Backstage
+
+Implemented type: [FeatureFlagsApi](./FeatureFlagsApi.md)
+
+ApiRef:
+[featureFlagsApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts#L58)
+
+### githubAuth
+
+Provides authentication towards Github APIs
+
+Implemented types: [OAuthApi](./OAuthApi.md),
+[ProfileInfoApi](./ProfileInfoApi.md),
+[BackstageIdentityApi](./BackstageIdentityApi.md),
+[SessionStateApi](./SessionStateApi.md)
+
+ApiRef:
+[githubAuthApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L230)
+
+### gitlabAuth
+
+Provides authentication towards Gitlab APIs
+
+Implemented types: [OAuthApi](./OAuthApi.md),
+[ProfileInfoApi](./ProfileInfoApi.md),
+[BackstageIdentityApi](./BackstageIdentityApi.md),
+[SessionStateApi](./SessionStateApi.md)
+
+ApiRef:
+[gitlabAuthApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L260)
+
+### googleAuth
+
+Provides authentication towards Google APIs and identities
+
+Implemented types: [OAuthApi](./OAuthApi.md),
+[OpenIdConnectApi](./OpenIdConnectApi.md),
+[ProfileInfoApi](./ProfileInfoApi.md),
+[BackstageIdentityApi](./BackstageIdentityApi.md),
+[SessionStateApi](./SessionStateApi.md)
+
+ApiRef:
+[googleAuthApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L213)
+
+### identity
+
+Provides access to the identity of the signed in user
+
+Implemented type: [IdentityApi](./IdentityApi.md)
+
+ApiRef:
+[identityApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/IdentityApi.ts#L54)
+
+### oauth2
+
+Example of how to use oauth2 custom provider
+
+Implemented types: [OAuthApi](./OAuthApi.md),
+[OpenIdConnectApi](./OpenIdConnectApi.md),
+[ProfileInfoApi](./ProfileInfoApi.md), [SessionStateApi](./SessionStateApi.md)
+
+ApiRef:
+[oauth2ApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L270)
+
+### oauthRequest
+
+An API for implementing unified OAuth flows in Backstage
+
+Implemented type: [OAuthRequestApi](./OAuthRequestApi.md)
+
+ApiRef:
+[oauthRequestApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/OAuthRequestApi.ts#L130)
+
+### oktaAuth
+
+Provides authentication towards Okta APIs
+
+Implemented types: [OAuthApi](./OAuthApi.md),
+[OpenIdConnectApi](./OpenIdConnectApi.md),
+[ProfileInfoApi](./ProfileInfoApi.md),
+[BackstageIdentityApi](./BackstageIdentityApi.md),
+[SessionStateApi](./SessionStateApi.md)
+
+ApiRef:
+[oktaAuthApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L243)
+
+### storage
+
+Provides the ability to store data which is unique to the user
+
+Implemented type: [StorageApi](./StorageApi.md)
+
+ApiRef:
+[storageApiRef](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/StorageApi.ts#L68)
diff --git a/docs/reference/utility-apis/SessionStateApi.md b/docs/reference/utility-apis/SessionStateApi.md
new file mode 100644
index 0000000000..7ed67cbc62
--- /dev/null
+++ b/docs/reference/utility-apis/SessionStateApi.md
@@ -0,0 +1,115 @@
+# SessionStateApi
+
+The SessionStateApi type is defined at
+[packages/core-api/src/apis/definitions/auth.ts:201](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L201).
+
+The following Utility APIs implement this type:
+
+- [githubAuthApiRef](./README.md#githubauth)
+
+- [gitlabAuthApiRef](./README.md#gitlabauth)
+
+- [googleAuthApiRef](./README.md#googleauth)
+
+- [oauth2ApiRef](./README.md#oauth2)
+
+- [oktaAuthApiRef](./README.md#oktaauth)
+
+## Members
+
+### sessionState\$()
+
+
+sessionState$(): Observable<SessionState>
+
+
+## Supporting types
+
+These types are part of the API declaration, but may not be unique to this API.
+
+### Observable
+
+Observable sequence of values and errors, see TC39.
+
+https://github.com/tc39/proposal-observable
+
+This is used as a common return type for observable values and can be created
+using many different observable implementations, such as zen-observable or
+RxJS 5.
+
+
+export type Observable<T> = {
+ /**
+ * Subscribes to this observable to start receiving new values.
+ */
+ subscribe(observer: Observer<T>): Subscription;
+ subscribe(
+ onNext: (value: T) => void,
+ onError?: (error: Error) => void,
+ onComplete?: () => void,
+ ): Subscription;
+}
+
+
+Defined at
+[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L53).
+
+Referenced by: [sessionState\$](#sessionstate).
+
+### Observer
+
+This file contains non-react related core types used throught Backstage.
+
+Observer interface for consuming an Observer, see TC39.
+
+
+export type Observer<T> = {
+ next?(value: T): void;
+ error?(error: Error): void;
+ complete?(): void;
+}
+
+
+Defined at
+[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L24).
+
+Referenced by: [Observable](#observable).
+
+### SessionState
+
+Session state values passed to subscribers of the SessionStateApi.
+
+
+export enum SessionState {
+ SignedIn = 'SignedIn',
+ SignedOut = 'SignedOut',
+}
+
+
+Defined at
+[packages/core-api/src/apis/definitions/auth.ts:192](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/auth.ts#L192).
+
+Referenced by: [sessionState\$](#sessionstate).
+
+### Subscription
+
+Subscription returned when subscribing to an Observable, see TC39.
+
+
+export type Subscription = {
+ /**
+ * Cancels the subscription
+ */
+ unsubscribe(): void;
+
+ /**
+ * Value indicating whether the subscription is closed.
+ */
+ readonly closed: Boolean;
+}
+
+
+Defined at
+[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L33).
+
+Referenced by: [Observable](#observable).
diff --git a/docs/reference/utility-apis/StorageApi.md b/docs/reference/utility-apis/StorageApi.md
new file mode 100644
index 0000000000..1eadec1302
--- /dev/null
+++ b/docs/reference/utility-apis/StorageApi.md
@@ -0,0 +1,186 @@
+# StorageApi
+
+The StorageApi type is defined at
+[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/StorageApi.ts#L31).
+
+The following Utility API implements this type:
+[storageApiRef](./README.md#storage)
+
+## Members
+
+### forBucket()
+
+Create a bucket to store data in.
+
+
+forBucket(name: string): StorageApi
+
+
+### get()
+
+Get the current value for persistent data, use observe\$ to be notified of
+updates.
+
+
+get<T>(key: string): T | undefined
+
+
+### remove()
+
+Remove persistent data.
+
+
+remove(key: string): Promise<void>
+
+
+### set()
+
+Save persistant data, and emit messages to anyone that is using observe\$ for
+this key
+
+
+set(key: string, data: any): Promise<void>
+
+
+### observe\$()
+
+Observe changes on a particular key in the bucket
+
+
+observe$<T>(key: string): Observable<StorageValueChange<T>>
+
+
+## Supporting types
+
+These types are part of the API declaration, but may not be unique to this API.
+
+### Observable
+
+Observable sequence of values and errors, see TC39.
+
+https://github.com/tc39/proposal-observable
+
+This is used as a common return type for observable values and can be created
+using many different observable implementations, such as zen-observable or
+RxJS 5.
+
+
+export type Observable<T> = {
+ /**
+ * Subscribes to this observable to start receiving new values.
+ */
+ subscribe(observer: Observer<T>): Subscription;
+ subscribe(
+ onNext: (value: T) => void,
+ onError?: (error: Error) => void,
+ onComplete?: () => void,
+ ): Subscription;
+}
+
+
+Defined at
+[packages/core-api/src/types.ts:53](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L53).
+
+Referenced by: [observe\$](#observe), [StorageApi](#storageapi).
+
+### Observer
+
+This file contains non-react related core types used throught Backstage.
+
+Observer interface for consuming an Observer, see TC39.
+
+
+export type Observer<T> = {
+ next?(value: T): void;
+ error?(error: Error): void;
+ complete?(): void;
+}
+
+
+Defined at
+[packages/core-api/src/types.ts:24](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L24).
+
+Referenced by: [Observable](#observable).
+
+### StorageApi
+
+
+export interface StorageApi {
+ /**
+ * Create a bucket to store data in.
+ * @param {String} name Namespace for the storage to be stored under,
+ * will inherit previous namespaces too
+ */
+ forBucket(name: string): StorageApi;
+
+ /**
+ * Get the current value for persistent data, use observe$ to be notified of updates.
+ *
+ * @param {String} key Unique key associated with the data.
+ * @return {Object} data The data that should is stored.
+ */
+ get<T>(key: string): T | undefined;
+
+ /**
+ * Remove persistent data.
+ *
+ * @param {String} key Unique key associated with the data.
+ */
+ remove(key: string): Promise<void>;
+
+ /**
+ * Save persistant data, and emit messages to anyone that is using observe$ for this key
+ *
+ * @param {String} key Unique key associated with the data.
+ */
+ set(key: string, data: any): Promise<void>;
+
+ /**
+ * Observe changes on a particular key in the bucket
+ * @param {String} key Unique key associated with the data
+ */
+ observe$<T>(key: string): Observable<StorageValueChange<T>>;
+}
+
+
+Defined at
+[packages/core-api/src/apis/definitions/StorageApi.ts:31](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/StorageApi.ts#L31).
+
+Referenced by: [forBucket](#forbucket).
+
+### StorageValueChange
+
+
+export type StorageValueChange<T = any> = {
+ key: string;
+ newValue?: T;
+}
+
+
+Defined at
+[packages/core-api/src/apis/definitions/StorageApi.ts:21](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/apis/definitions/StorageApi.ts#L21).
+
+Referenced by: [observe\$](#observe), [StorageApi](#storageapi).
+
+### Subscription
+
+Subscription returned when subscribing to an Observable, see TC39.
+
+
+export type Subscription = {
+ /**
+ * Cancels the subscription
+ */
+ unsubscribe(): void;
+
+ /**
+ * Value indicating whether the subscription is closed.
+ */
+ readonly closed: Boolean;
+}
+
+
+Defined at
+[packages/core-api/src/types.ts:33](https://github.com/spotify/backstage/blob/53a229ea7576b1432835e54e41e0b9526038afa4/packages/core-api/src/types.ts#L33).
+
+Referenced by: [Observable](#observable).
diff --git a/mkdocs.yml b/mkdocs.yml
index 168ebb4b37..7d5d58b64a 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -91,6 +91,7 @@ nav:
- ADR004 - Module Export Structure: 'architecture-decisions/adr004-module-export-structure.md'
- ADR005 - Catalog Core Entities: 'architecture-decisions/adr005-catalog-core-entities.md'
- ADR006 - Avoid React.FC and React.SFC: 'architecture-decisions/adr006-avoid-react-fc.md'
+ - ADR007 - Use MSW for Network Request Mocking: 'architecture-decisions/adr007-use-msw-to-mock-service-requests.md'
- Contribute: '../CONTRIBUTING.md'
- Support: 'overview/support.md'
- FAQ: FAQ.md
diff --git a/package.json b/package.json
index 500b688990..254142a733 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"lint": "lerna run lint --since origin/master --",
"lint:all": "lerna run lint --",
"lint:type-deps": "node scripts/check-type-dependencies.js",
+ "docgen": "lerna run docgen",
"docker-build": "yarn workspace example-app build && docker build . -t spotify/backstage",
"docker-build:all": "yarn tsc && yarn build && yarn docker-build && yarn workspace example-backend build-image",
"create-plugin": "backstage-cli create-plugin",
diff --git a/packages/app/package.json b/packages/app/package.json
index ea4c308380..168506d498 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -39,7 +39,7 @@
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
- "@types/jest": "^25.2.2",
+ "@types/jest": "^26.0.7",
"@types/jquery": "^3.3.34",
"@types/node": "^12.0.0",
"@types/react-dom": "^16.9.8",
diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index c569308fc7..9e9ca1edd0 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -19,6 +19,8 @@ import {
AlertDisplay,
OAuthRequestDialog,
SignInPage,
+ useApi,
+ configApiRef,
} from '@backstage/core';
import React, { FC } from 'react';
import Root from './components/Root';
@@ -30,12 +32,13 @@ const app = createApp({
apis,
plugins: Object.values(plugins),
components: {
- SignInPage: props => (
-
- ),
+ SignInPage: props => {
+ const configApi = useApi(configApiRef);
+ const providersConfig = configApi.getOptionalConfig('auth.providers');
+ const providers = providersConfig?.keys() ?? [];
+
+ return ;
+ },
},
});
diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json
index 3c944846d1..18131236df 100644
--- a/packages/backend-common/package.json
+++ b/packages/backend-common/package.json
@@ -35,6 +35,7 @@
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.17.1",
+ "express-promise-router": "^3.0.3",
"helmet": "^3.22.0",
"morgan": "^1.10.0",
"stoppable": "^1.1.0",
diff --git a/packages/backend-common/src/middleware/errorHandler.ts b/packages/backend-common/src/middleware/errorHandler.ts
index 798d173d38..7365ce8b93 100644
--- a/packages/backend-common/src/middleware/errorHandler.ts
+++ b/packages/backend-common/src/middleware/errorHandler.ts
@@ -28,9 +28,9 @@ export type ErrorHandlerOptions = {
showStackTraces?: boolean;
/**
- * Logger
+ * Logger instance to log any 5xx errors.
*
- * If not specified, by default shows stack traces only in development mode.
+ * If not specified, the root logger will be used.
*/
logger?: Logger;
};
diff --git a/packages/backend-common/src/middleware/index.ts b/packages/backend-common/src/middleware/index.ts
index 083b36c3e9..76c52d8830 100644
--- a/packages/backend-common/src/middleware/index.ts
+++ b/packages/backend-common/src/middleware/index.ts
@@ -17,3 +17,4 @@
export * from './errorHandler';
export * from './notFoundHandler';
export * from './requestLoggingHandler';
+export * from './statusCheckHandler';
diff --git a/packages/backend-common/src/middleware/statusCheckHandler.test.ts b/packages/backend-common/src/middleware/statusCheckHandler.test.ts
new file mode 100644
index 0000000000..7ed1a65b58
--- /dev/null
+++ b/packages/backend-common/src/middleware/statusCheckHandler.test.ts
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import express from 'express';
+import request from 'supertest';
+import { statusCheckHandler } from './statusCheckHandler';
+
+describe('statusCheckHandler', () => {
+ it('gives status 200 when using default', async () => {
+ const app = express();
+ app.use('/healthcheck', await statusCheckHandler());
+
+ const response = await request(app).get('/healthcheck');
+
+ expect(response.status).toBe(200);
+ expect(response.text).toBe(JSON.stringify({ status: 'ok' }));
+ });
+
+ it('gives status 200 when status function returns true', async () => {
+ const app = express();
+ const status = { foo: 'bar' };
+ const statusCheck = () => Promise.resolve(status);
+ app.use('/healthcheck', await statusCheckHandler({ statusCheck }));
+
+ const response = await request(app).get('/healthcheck');
+
+ expect(response.status).toBe(200);
+ expect(response.text).toBe(JSON.stringify(status));
+ });
+
+ it('gives status 500 when status check throws an error', async () => {
+ const app = express();
+ const statusCheck = () => {
+ throw Error('error!');
+ };
+ app.use('/healthcheck', await statusCheckHandler({ statusCheck }));
+
+ const response = await request(app).get('/healthcheck');
+
+ expect(response.status).toBe(500);
+ });
+});
diff --git a/packages/backend-common/src/middleware/statusCheckHandler.ts b/packages/backend-common/src/middleware/statusCheckHandler.ts
new file mode 100644
index 0000000000..3f62f04f59
--- /dev/null
+++ b/packages/backend-common/src/middleware/statusCheckHandler.ts
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { NextFunction, Request, Response, RequestHandler } from 'express';
+
+export type StatusCheck = () => Promise;
+
+export interface StatusCheckHandlerOptions {
+ /**
+ * Optional status function which returns a message.
+ */
+ statusCheck?: StatusCheck;
+}
+
+/**
+ * Express middleware for status checks.
+ *
+ * This is commonly used to implement healthcheck and readiness routes.
+ *
+ * @param options An optional configuration object.
+ * @returns An Express error request handler
+ */
+export async function statusCheckHandler(
+ options: StatusCheckHandlerOptions = {},
+): Promise {
+ const statusCheck: StatusCheck = options.statusCheck
+ ? options.statusCheck
+ : () => Promise.resolve({ status: 'ok' });
+
+ return async (_request: Request, response: Response, next: NextFunction) => {
+ try {
+ const status = await statusCheck();
+ response.status(200).header('').send(status);
+ } catch (err) {
+ next(err);
+ }
+ };
+}
diff --git a/packages/backend-common/src/service/createStatusCheckRouter.test.ts b/packages/backend-common/src/service/createStatusCheckRouter.test.ts
new file mode 100644
index 0000000000..6c15e7b5f9
--- /dev/null
+++ b/packages/backend-common/src/service/createStatusCheckRouter.test.ts
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import express from 'express';
+import * as winston from 'winston';
+import request from 'supertest';
+import { createStatusCheckRouter } from './createStatusCheckRouter';
+
+describe('createStatusCheckRouter', () => {
+ const logger = winston.createLogger();
+
+ it('gives status 200 when using default path', async () => {
+ const app = express();
+ app.use('', await createStatusCheckRouter({ logger }));
+
+ const response = await request(app).get('/healthcheck');
+
+ expect(response.status).toBe(200);
+ expect(response.text).toBe(JSON.stringify({ status: 'ok' }));
+ });
+
+ it('gives status 200 when using custom path', async () => {
+ const app = express();
+ app.use('', await createStatusCheckRouter({ logger, path: '/ready' }));
+
+ const response = await request(app).get('/ready');
+
+ expect(response.status).toBe(200);
+ expect(response.text).toBe(JSON.stringify({ status: 'ok' }));
+ });
+
+ it('gives status 500 when status check throws an error', async () => {
+ const app = express();
+ const statusCheck = () => {
+ throw Error('error!');
+ };
+ app.use('', await createStatusCheckRouter({ logger, statusCheck }));
+
+ const response = await request(app).get('/healthcheck');
+
+ expect(response.status).toBe(500);
+ });
+});
diff --git a/packages/backend-common/src/service/createStatusCheckRouter.ts b/packages/backend-common/src/service/createStatusCheckRouter.ts
new file mode 100644
index 0000000000..c6014cbfc2
--- /dev/null
+++ b/packages/backend-common/src/service/createStatusCheckRouter.ts
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Logger } from 'winston';
+import Router from 'express-promise-router';
+import express from 'express';
+import { errorHandler, statusCheckHandler, StatusCheck } from '../middleware';
+
+export interface StatusCheckRouterOptions {
+ logger: Logger;
+ path?: string;
+ statusCheck?: StatusCheck;
+}
+
+export async function createStatusCheckRouter(
+ options: StatusCheckRouterOptions,
+): Promise {
+ const router = Router();
+ const { path = '/healthcheck', statusCheck } = options;
+
+ router.use(path, await statusCheckHandler({ statusCheck }));
+ router.use(errorHandler());
+
+ return router;
+}
diff --git a/packages/backend-common/src/service/index.ts b/packages/backend-common/src/service/index.ts
index 9bba9d6baf..58e310032d 100644
--- a/packages/backend-common/src/service/index.ts
+++ b/packages/backend-common/src/service/index.ts
@@ -15,4 +15,5 @@
*/
export { createServiceBuilder } from './createServiceBuilder';
+export { createStatusCheckRouter } from './createStatusCheckRouter';
export type { ServiceBuilder } from './types';
diff --git a/packages/backend-common/src/service/types.ts b/packages/backend-common/src/service/types.ts
index b39df27527..070794969f 100644
--- a/packages/backend-common/src/service/types.ts
+++ b/packages/backend-common/src/service/types.ts
@@ -16,7 +16,7 @@
import { ConfigReader } from '@backstage/config';
import cors from 'cors';
-import { Router } from 'express';
+import { Router, RequestHandler } from 'express';
import { Server } from 'http';
import { Logger } from 'winston';
@@ -64,7 +64,7 @@ export type ServiceBuilder = {
* @param root The root URL to bind to (e.g. "/api/function1")
* @param router An express router
*/
- addRouter(root: string, router: Router): ServiceBuilder;
+ addRouter(root: string, router: Router | RequestHandler): ServiceBuilder;
/**
* Starts the server using the given settings.
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 75a7af44a2..7ca11fc636 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -35,6 +35,7 @@
"dockerode": "^3.2.0",
"express": "^4.17.1",
"knex": "^0.21.1",
+ "pg": "^8.3.0",
"sqlite3": "^4.2.0",
"winston": "^3.2.1"
},
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
index 7c98140270..3fccfe9c59 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -29,7 +29,8 @@ import {
} from '@backstage/backend-common';
import { ConfigReader, AppConfig } from '@backstage/config';
import { loadConfig } from '@backstage/config-loader';
-import knex from 'knex';
+import knex, { PgConnectionConfig } from 'knex';
+import healthcheck from './plugins/healthcheck';
import auth from './plugins/auth';
import catalog from './plugins/catalog';
import identity from './plugins/identity';
@@ -46,11 +47,36 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) {
return (plugin: string): PluginEnvironment => {
const logger = getRootLogger().child({ type: 'plugin', plugin });
- const database = knex({
- client: 'sqlite3',
- connection: ':memory:',
- useNullAsDefault: true,
- });
+ // Supported DBs are sqlite and postgres
+ const isPg = [
+ 'POSTGRES_USER',
+ 'POSTGRES_HOST',
+ 'POSTGRES_PASSWORD',
+ ].every(key => config.getOptional(`backend.${key}`));
+
+ let knexConfig;
+
+ if (isPg) {
+ knexConfig = {
+ client: 'pg',
+ useNullAsDefault: true,
+ connection: {
+ port: config.getOptionalNumber('backend.POSTGRES_PORT'),
+ host: config.getString('backend.POSTGRES_HOST'),
+ user: config.getString('backend.POSTGRES_USER'),
+ password: config.getString('backend.POSTGRES_PASSWORD'),
+ database: `backstage_plugin_${plugin}`,
+ } as PgConnectionConfig,
+ };
+ } else {
+ knexConfig = {
+ client: 'sqlite3',
+ connection: ':memory:',
+ useNullAsDefault: true,
+ };
+ }
+
+ const database = knex(knexConfig);
database.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
@@ -59,10 +85,11 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) {
}
async function main() {
- const configs = await loadConfig();
+ const configs = await loadConfig({ shouldReadSecrets: true });
const configReader = ConfigReader.fromConfigs(configs);
const createEnv = makeCreateEnv(configs);
+ const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck'));
const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder'));
const authEnv = useHotMemoize(module, () => createEnv('auth'));
@@ -75,6 +102,7 @@ async function main() {
const service = createServiceBuilder(module)
.loadConfig(configReader)
+ .addRouter('', await healthcheck(healthcheckEnv))
.addRouter('/catalog', await catalog(catalogEnv))
.addRouter('/rollbar', await rollbar(rollbarEnv))
.addRouter('/scaffolder', await scaffolder(scaffolderEnv))
diff --git a/packages/backend/src/plugins/healthcheck.ts b/packages/backend/src/plugins/healthcheck.ts
new file mode 100644
index 0000000000..3dd9a67827
--- /dev/null
+++ b/packages/backend/src/plugins/healthcheck.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createStatusCheckRouter } from '@backstage/backend-common';
+import { PluginEnvironment } from '../types';
+
+export default async function createRouter({ logger }: PluginEnvironment) {
+ return await createStatusCheckRouter({ logger, path: '/healthcheck' });
+}
diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json
index 43ac830327..af351842ce 100644
--- a/packages/catalog-model/package.json
+++ b/packages/catalog-model/package.json
@@ -31,7 +31,7 @@
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.16",
"@types/express": "^4.17.6",
- "@types/jest": "^25.2.2",
+ "@types/jest": "^26.0.7",
"@types/lodash": "^4.14.151",
"yaml": "^1.9.2"
},
diff --git a/packages/cli/e2e-test/cli-e2e-test.js b/packages/cli/e2e-test/cli-e2e-test.js
index ef5d6ab243..206ea25ccd 100644
--- a/packages/cli/e2e-test/cli-e2e-test.js
+++ b/packages/cli/e2e-test/cli-e2e-test.js
@@ -16,6 +16,7 @@
const os = require('os');
const fs = require('fs-extra');
+const fetch = require('node-fetch');
const killTree = require('tree-kill');
const { resolve: resolvePath, join: joinPath } = require('path');
const Browser = require('zombie');
@@ -28,6 +29,7 @@ const {
waitForExit,
print,
} = require('./helpers');
+const pgtools = require('pgtools');
async function main() {
const rootDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-e2e-'));
@@ -36,8 +38,9 @@ async function main() {
print('Building dist workspace');
const workspaceDir = await buildDistWorkspace('workspace', rootDir);
+ const isPostgres = Boolean(process.env.POSTGRES_USER);
print('Creating a Backstage App');
- const appDir = await createApp('test-app', workspaceDir, rootDir);
+ const appDir = await createApp('test-app', isPostgres, workspaceDir, rootDir);
print('Creating a Backstage Plugin');
const pluginName = await createPlugin('test-plugin', appDir);
@@ -45,6 +48,9 @@ async function main() {
print('Starting the app');
await testAppServe(pluginName, appDir);
+ print('Testing the backend startup');
+ await testBackendStart(appDir, isPostgres);
+
print('All tests successful, removing test dir');
await fs.remove(rootDir);
}
@@ -97,7 +103,7 @@ async function pinYarnVersion(dir) {
/**
* Creates a new app inside rootDir called test-app, using packages from the workspaceDir
*/
-async function createApp(appName, workspaceDir, rootDir) {
+async function createApp(appName, isPostgres, workspaceDir, rootDir) {
const child = spawnPiped(
[
'node',
@@ -119,6 +125,14 @@ async function createApp(appName, workspaceDir, rootDir) {
await waitFor(() => stdout.includes('Enter a name for the app'));
child.stdin.write(`${appName}\n`);
+ await waitFor(() => stdout.includes('Select database for the backend'));
+
+ if (!isPostgres) {
+ // Simulate down arrow press
+ child.stdin.write(`\u001B\u005B\u0042`);
+ }
+ child.stdin.write(`\n`);
+
print('Waiting for app create script to be done');
await waitForExit(child);
@@ -201,7 +215,7 @@ async function createPlugin(pluginName, appDir) {
await waitForExit(child);
const pluginDir = resolvePath(appDir, 'plugins', pluginName);
- for (const cmd of [['lint'], ['test', '--no-watch']]) {
+ for (const cmd of [['tsc'], ['lint'], ['test', '--no-watch']]) {
print(`Running 'yarn ${cmd.join(' ')}' in newly created plugin`);
await runPlain(['yarn', ...cmd], { cwd: pluginDir });
}
@@ -250,5 +264,87 @@ async function testAppServe(pluginName, appDir) {
}
}
+/** Creates PG databases (drops if exists before) */
+async function createDB(database) {
+ const config = {
+ host: process.env.POSTGRES_HOST,
+ port: process.env.POSTGRES_PORT,
+ user: process.env.POSTGRES_USER,
+ password: process.env.POSTGRES_PASSWORD,
+ };
+
+ try {
+ await pgtools.dropdb({ config }, database);
+ } catch (_) {
+ /* do nothing*/
+ }
+ return pgtools.createdb(config, database);
+}
+
+/**
+ * Start serving the newly created backend and make sure that all db migrations works correctly
+ */
+async function testBackendStart(appDir, isPostgres) {
+ if (isPostgres) {
+ print('Creating DBs');
+ await Promise.all(
+ [
+ 'catalog',
+ 'scaffolder',
+ 'auth',
+ 'identity',
+ 'proxy',
+ 'techdocs',
+ ].map(name => createDB(`backstage_plugin_${name}`)),
+ );
+ print('Created DBs');
+ }
+
+ const child = spawnPiped(['yarn', 'workspace', 'backend', 'start'], {
+ cwd: appDir,
+ });
+
+ let stdout = '';
+ let stderr = '';
+ child.stdout.on('data', data => {
+ stdout = stdout + data.toString('utf8');
+ });
+ child.stderr.on('data', data => {
+ stderr = stderr + data.toString('utf8');
+ });
+ let successful = false;
+
+ try {
+ await waitFor(() => stdout.includes('Listening on ') || stderr !== '');
+ if (stderr !== '') {
+ // Skipping the whole block
+ throw new Error(stderr);
+ }
+
+ print('Try to fetch entities from the backend');
+ // Try fetch entities, should be ok
+ await fetch('http://localhost:7000/catalog/entities').then(res =>
+ res.json(),
+ );
+ print('Entities fetched successfully');
+ successful = true;
+ } catch (error) {
+ throw new Error(`Backend failed to startup: ${error}`);
+ } finally {
+ print('Stopping the child process');
+ // Kill entire process group, otherwise we'll end up with hanging serve processes
+ killTree(child.pid);
+ }
+
+ try {
+ await waitForExit(child);
+ } catch (error) {
+ if (!successful) {
+ throw new Error(`Backend failed to startup: ${stderr}`);
+ }
+ print('Backend startup test finished successfully');
+ }
+}
+
process.on('unhandledRejection', handleError);
main(process.argv.slice(2)).catch(handleError);
diff --git a/packages/cli/e2e-test/helpers.js b/packages/cli/e2e-test/helpers.js
index da2ec343ab..84807119c3 100644
--- a/packages/cli/e2e-test/helpers.js
+++ b/packages/cli/e2e-test/helpers.js
@@ -76,6 +76,12 @@ function handleError(err) {
}
}
+/**
+ * Waits for fn() to be true
+ * Checks every 100ms
+ * .cancel() is available
+ * @returns {Promise} Promise of resolution
+ */
function waitFor(fn) {
return new Promise(resolve => {
const handle = setInterval(() => {
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 6aa3f6458d..07954223b4 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -68,7 +68,9 @@
"jest-css-modules": "^2.1.0",
"jest-esm-transformer": "^1.0.0",
"mini-css-extract-plugin": "^0.9.0",
+ "node-fetch": "^2.6.0",
"ora": "^4.0.3",
+ "pgtools": "^0.3.0",
"raw-loader": "^4.0.1",
"react": "^16.0.0",
"react-dev-utils": "^10.2.1",
diff --git a/packages/cli/src/commands/create-app/createApp.ts b/packages/cli/src/commands/create-app/createApp.ts
index 4f4134957d..b3ddcd3109 100644
--- a/packages/cli/src/commands/create-app/createApp.ts
+++ b/packages/cli/src/commands/create-app/createApp.ts
@@ -104,8 +104,17 @@ export default async (cmd: Command): Promise => {
return true;
},
},
+ {
+ type: 'list',
+ name: 'dbType',
+ message: chalk.blue('Select database for the backend [required]'),
+ // @ts-ignore
+ choices: ['PostgreSQL', 'SQLite'],
+ },
];
const answers: Answers = await inquirer.prompt(questions);
+ answers.dbTypePG = answers.dbType === 'PostgreSQL';
+ answers.dbTypeSqlite = answers.dbType === 'SQLite';
const templateDir = paths.resolveOwn('templates/default-app');
const tempDir = resolvePath(os.tmpdir(), answers.name);
diff --git a/packages/cli/src/lib/packager/index.ts b/packages/cli/src/lib/packager/index.ts
index d3ba1f6b12..f4a2181b40 100644
--- a/packages/cli/src/lib/packager/index.ts
+++ b/packages/cli/src/lib/packager/index.ts
@@ -134,11 +134,15 @@ async function findTargetPackages(pkgNames: string[]): Promise {
throw new Error(`Package '${name}' not found`);
}
- const pkgDeps = Object.keys(node.pkg.dependencies);
- const localDeps: string[] = Array.from(node.localDependencies.keys());
- const filteredDeps = localDeps.filter(dep => pkgDeps.includes(dep));
+ // Don't include dependencies of packages that are marked as bundled
+ if (!node.pkg.get('bundled')) {
+ const pkgDeps = Object.keys(node.pkg.dependencies);
+ const localDeps: string[] = Array.from(node.localDependencies.keys());
+ const filteredDeps = localDeps.filter(dep => pkgDeps.includes(dep));
+
+ searchNames.push(...filteredDeps);
+ }
- searchNames.push(...filteredDeps);
targets.set(name, node.pkg);
}
diff --git a/packages/cli/src/lib/run.ts b/packages/cli/src/lib/run.ts
index 15b827f0c6..69d3d92640 100644
--- a/packages/cli/src/lib/run.ts
+++ b/packages/cli/src/lib/run.ts
@@ -92,7 +92,7 @@ export async function runCheck(cmd: string, ...args: string[]) {
}
export async function waitForExit(
- child: ChildProcess & { exitCode?: number },
+ child: ChildProcess & { exitCode: number | null },
name?: string,
): Promise {
if (typeof child.exitCode === 'number') {
diff --git a/packages/cli/templates/default-app/.eslintrc.js b/packages/cli/templates/default-app/.eslintrc.js
index 13573efa9c..e351352491 100644
--- a/packages/cli/templates/default-app/.eslintrc.js
+++ b/packages/cli/templates/default-app/.eslintrc.js
@@ -1,3 +1,3 @@
module.exports = {
- extends: [require.resolve('@backstage/cli/config/eslint')],
+ root: true,
};
diff --git a/packages/cli/templates/default-app/app-config.yaml b/packages/cli/templates/default-app/app-config.yaml
index 58f7b795ff..0d51cf88ef 100644
--- a/packages/cli/templates/default-app/app-config.yaml
+++ b/packages/cli/templates/default-app/app-config.yaml
@@ -4,3 +4,19 @@ app:
organization:
name: Acme Corporation
+
+backend:
+ baseUrl: http://localhost:7000
+ listen: 0.0.0.0:7000
+ cors:
+ origin: http://localhost:3000
+ methods: [GET, POST, PUT, DELETE]
+ credentials: true
+
+proxy:
+ '/test':
+ target: 'https://example.com'
+ changeOrigin: true
+
+techdocs:
+ storageUrl: https://techdocs-mock-sites.storage.googleapis.com
diff --git a/packages/cli/templates/default-app/packages/app/package.json.hbs b/packages/cli/templates/default-app/packages/app/package.json.hbs
index a34d25f216..f29453b6dc 100644
--- a/packages/cli/templates/default-app/packages/app/package.json.hbs
+++ b/packages/cli/templates/default-app/packages/app/package.json.hbs
@@ -22,7 +22,7 @@
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
- "@types/jest": "^25.2.2",
+ "@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/react-dom": "^16.9.8",
"cross-env": "^7.0.0",
diff --git a/packages/cli/templates/default-app/packages/backend/.eslintrc.js b/packages/cli/templates/default-app/packages/backend/.eslintrc.js
new file mode 100644
index 0000000000..16a033dbc6
--- /dev/null
+++ b/packages/cli/templates/default-app/packages/backend/.eslintrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: [require.resolve('@backstage/cli/config/eslint.backend')],
+};
diff --git a/packages/cli/templates/default-app/packages/backend/Dockerfile b/packages/cli/templates/default-app/packages/backend/Dockerfile
new file mode 100644
index 0000000000..3e8ba36cec
--- /dev/null
+++ b/packages/cli/templates/default-app/packages/backend/Dockerfile
@@ -0,0 +1,9 @@
+FROM node:12
+
+WORKDIR /usr/src/app
+
+COPY . .
+
+RUN yarn install --frozen-lockfile --production
+
+CMD ["node", "packages/backend"]
diff --git a/packages/cli/templates/default-app/packages/backend/README.md b/packages/cli/templates/default-app/packages/backend/README.md
new file mode 100644
index 0000000000..f94904a930
--- /dev/null
+++ b/packages/cli/templates/default-app/packages/backend/README.md
@@ -0,0 +1,68 @@
+# example-backend
+
+This package is an EXAMPLE of a Backstage backend.
+
+The main purpose of this package is to provide a test bed for Backstage plugins
+that have a backend part. Feel free to experiment locally or within your fork
+by adding dependencies and routes to this backend, to try things out.
+
+Our goal is to eventually amend the create-app flow of the CLI, such that a
+production ready version of a backend skeleton is made alongside the frontend
+app. Until then, feel free to experiment here!
+
+## Development
+
+To run the example backend, first go to the project root and run
+
+```bash
+yarn install
+yarn tsc
+yarn build
+```
+
+You should only need to do this once.
+
+After that, go to the `packages/backend` directory and run
+
+```bash
+AUTH_GOOGLE_CLIENT_ID=x AUTH_GOOGLE_CLIENT_SECRET=x \
+AUTH_GITHUB_CLIENT_ID=x AUTH_GITHUB_CLIENT_SECRET=x \
+AUTH_OAUTH2_CLIENT_ID=x AUTH_OAUTH2_CLIENT_SECRET=x \
+AUTH_OAUTH2_AUTH_URL=x AUTH_OAUTH2_TOKEN_URL=x \
+LOG_LEVEL=debug \
+yarn start
+```
+
+Substitute `x` for actual values, or leave them as
+dummy values just to try out the backend without using the auth or sentry features.
+
+The backend starts up on port 7000 per default.
+
+## Populating The Catalog
+
+If you want to use the catalog functionality, you need to add so called locations
+to the backend. These are places where the backend can find some entity descriptor
+data to consume and serve.
+
+To get started, you can issue the following after starting the backend, from inside
+the `plugins/catalog-backend` directory:
+
+```bash
+yarn mock-data
+```
+
+You should then start seeing data on `localhost:7000/catalog/entities`.
+
+The catalog currently runs in-memory only, so feel free to try it out, but it will
+need to be re-populated on next startup.
+
+## Authentication
+
+We chose [Passport](http://www.passportjs.org/) as authentication platform due to its comprehensive set of supported authentication [strategies](http://www.passportjs.org/packages/).
+
+Read more about the [auth-backend](https://github.com/spotify/backstage/blob/master/plugins/auth-backend/README.md) and [how to add a new provider](https://github.com/spotify/backstage/blob/master/docs/auth/add-auth-provider.md)
+
+## Documentation
+
+- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md)
+- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md)
diff --git a/packages/cli/templates/default-app/packages/backend/package.json.hbs b/packages/cli/templates/default-app/packages/backend/package.json.hbs
new file mode 100644
index 0000000000..93c076a967
--- /dev/null
+++ b/packages/cli/templates/default-app/packages/backend/package.json.hbs
@@ -0,0 +1,51 @@
+{
+ "name": "backend",
+ "version": "0.0.0",
+ "main": "dist/index.cjs.js",
+ "types": "src/index.ts",
+ "private": true,
+ "engines": {
+ "node": "12"
+ },
+ "scripts": {
+ "build": "backstage-cli backend:build",
+ "build-image": "backstage-cli backend:build-image example-backend",
+ "start": "backstage-cli backend:dev",
+ "lint": "backstage-cli lint",
+ "test": "backstage-cli test",
+ "clean": "backstage-cli clean",
+ "migrate:create": "knex migrate:make -x ts"
+ },
+ "dependencies": {
+ "@backstage/backend-common": "^{{version}}",
+ "@backstage/catalog-model": "^{{version}}",
+ "@backstage/config": "^0.1.1-alpha.13",
+ "@backstage/config-loader": "^0.1.1-alpha.13",
+ "@backstage/plugin-auth-backend": "^{{version}}",
+ "@backstage/plugin-catalog-backend": "^{{version}}",
+ "@backstage/plugin-identity-backend": "^{{version}}",
+ "@backstage/plugin-proxy-backend": "^{{version}}",
+ "@backstage/plugin-rollbar-backend": "^{{version}}",
+ "@backstage/plugin-scaffolder-backend": "^{{version}}",
+ "@backstage/plugin-sentry-backend": "^{{version}}",
+ "@backstage/plugin-techdocs-backend": "^{{version}}",
+ "@octokit/rest": "^18.0.0",
+ "dockerode": "^3.2.0",
+ "express": "^4.17.1",
+ "knex": "^0.21.1",
+ {{#if dbTypePG}}
+ "pg": "^8.3.0",
+ {{/if}}
+ {{#if dbTypeSqlite}}
+ "sqlite3": "^4.2.0",
+ {{/if}}
+ "winston": "^3.2.1"
+ },
+ "devDependencies": {
+ "@backstage/cli": "^0.1.1-alpha.15",
+ "@types/dockerode": "^2.5.32",
+ "@types/express": "^4.17.6",
+ "@types/express-serve-static-core": "^4.17.5",
+ "@types/helmet": "^0.0.47"
+ }
+}
diff --git a/packages/cli/templates/default-app/packages/backend/src/index.test.ts b/packages/cli/templates/default-app/packages/backend/src/index.test.ts
new file mode 100644
index 0000000000..d18873c1f0
--- /dev/null
+++ b/packages/cli/templates/default-app/packages/backend/src/index.test.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { PluginEnvironment } from './types';
+
+describe('test', () => {
+ it('unbreaks the test runner', () => {
+ const unbreaker = {} as PluginEnvironment;
+ expect(unbreaker).toBeTruthy();
+ });
+});
diff --git a/packages/cli/templates/default-app/packages/backend/src/index.ts.hbs b/packages/cli/templates/default-app/packages/backend/src/index.ts.hbs
new file mode 100644
index 0000000000..591fa1a28f
--- /dev/null
+++ b/packages/cli/templates/default-app/packages/backend/src/index.ts.hbs
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * Hi!
+ *
+ * Note that this is an EXAMPLE Backstage backend. Please check the README.
+ *
+ * Happy hacking!
+ */
+
+import {
+ createServiceBuilder,
+ getRootLogger,
+ useHotMemoize,
+} from '@backstage/backend-common';
+import { ConfigReader, AppConfig } from '@backstage/config';
+import { loadConfig } from '@backstage/config-loader';
+{{#if dbTypePG}}
+import knex, { PgConnectionConfig } from 'knex';
+{{/if}}
+{{#if dbTypeSqlite}}
+import knex from 'knex';
+{{/if}}
+import auth from './plugins/auth';
+import catalog from './plugins/catalog';
+import identity from './plugins/identity';
+import scaffolder from './plugins/scaffolder';
+import proxy from './plugins/proxy';
+import techdocs from './plugins/techdocs';
+import { PluginEnvironment } from './types';
+
+function makeCreateEnv(loadedConfigs: AppConfig[]) {
+ const config = ConfigReader.fromConfigs(loadedConfigs);
+
+ return (plugin: string): PluginEnvironment => {
+ const logger = getRootLogger().child({ type: 'plugin', plugin });
+
+ {{#if dbTypePG}}
+ const knexConfig = {
+ client: 'pg',
+ useNullAsDefault: true,
+ connection: {
+ port: process.env.POSTGRES_PORT,
+ host: process.env.POSTGRES_HOST,
+ user: process.env.POSTGRES_USER,
+ password: process.env.POSTGRES_PASSWORD,
+ database: `backstage_plugin_${plugin}`,
+ } as PgConnectionConfig,
+ };
+ {{/if}}
+ {{#if dbTypeSqlite}}
+ const knexConfig = {
+ client: 'sqlite3',
+ connection: ':memory:',
+ useNullAsDefault: true,
+ };
+ {{/if}}
+ const database = knex(knexConfig);
+ database.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
+ resource.run('PRAGMA foreign_keys = ON', () => {});
+ });
+ return { logger, database, config };
+ };
+}
+
+async function main() {
+ const configs = await loadConfig();
+ const configReader = ConfigReader.fromConfigs(configs);
+ const createEnv = makeCreateEnv(configs);
+
+ const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
+ const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder'));
+ const authEnv = useHotMemoize(module, () => createEnv('auth'));
+ const identityEnv = useHotMemoize(module, () => createEnv('identity'));
+ const proxyEnv = useHotMemoize(module, () => createEnv('proxy'));
+ const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs'));
+
+ const service = createServiceBuilder(module)
+ .loadConfig(configReader)
+ .addRouter('/catalog', await catalog(catalogEnv))
+ .addRouter('/scaffolder', await scaffolder(scaffolderEnv))
+ .addRouter('/auth', await auth(authEnv))
+ .addRouter('/identity', await identity(identityEnv))
+ .addRouter('/techdocs', await techdocs(techdocsEnv))
+ .addRouter('/proxy', await proxy(proxyEnv));
+
+ await service.start().catch(err => {
+ console.log(err);
+ process.exit(1);
+ });
+}
+
+module.hot?.accept();
+main().catch(error => {
+ console.error(`Backend failed to start up, ${error}`);
+ process.exit(1);
+});
diff --git a/packages/cli/templates/default-app/packages/backend/src/plugins/auth.ts b/packages/cli/templates/default-app/packages/backend/src/plugins/auth.ts
new file mode 100644
index 0000000000..b24dd6ca6b
--- /dev/null
+++ b/packages/cli/templates/default-app/packages/backend/src/plugins/auth.ts
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createRouter } from '@backstage/plugin-auth-backend';
+import { PluginEnvironment } from '../types';
+
+export default async function createPlugin({
+ logger,
+ database,
+ config,
+}: PluginEnvironment) {
+ return await createRouter({ logger, config, database });
+}
diff --git a/packages/cli/templates/default-app/packages/backend/src/plugins/catalog.ts b/packages/cli/templates/default-app/packages/backend/src/plugins/catalog.ts
new file mode 100644
index 0000000000..41e4226001
--- /dev/null
+++ b/packages/cli/templates/default-app/packages/backend/src/plugins/catalog.ts
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ createRouter,
+ DatabaseEntitiesCatalog,
+ DatabaseLocationsCatalog,
+ DatabaseManager,
+ HigherOrderOperations,
+ LocationReaders,
+ runPeriodically,
+} from '@backstage/plugin-catalog-backend';
+import { PluginEnvironment } from '../types';
+import { useHotCleanup } from '@backstage/backend-common';
+
+export default async function createPlugin({
+ logger,
+ database,
+}: PluginEnvironment) {
+ const locationReader = new LocationReaders(logger);
+
+ const db = await DatabaseManager.createDatabase(database, { logger });
+ const entitiesCatalog = new DatabaseEntitiesCatalog(db);
+ const locationsCatalog = new DatabaseLocationsCatalog(db);
+ const higherOrderOperation = new HigherOrderOperations(
+ entitiesCatalog,
+ locationsCatalog,
+ locationReader,
+ logger,
+ );
+
+ useHotCleanup(
+ module,
+ runPeriodically(() => higherOrderOperation.refreshAllLocations(), 10000),
+ );
+
+ return await createRouter({
+ entitiesCatalog,
+ locationsCatalog,
+ higherOrderOperation,
+ logger,
+ });
+}
diff --git a/packages/cli/templates/default-app/packages/backend/src/plugins/identity.ts b/packages/cli/templates/default-app/packages/backend/src/plugins/identity.ts
new file mode 100644
index 0000000000..63a326965c
--- /dev/null
+++ b/packages/cli/templates/default-app/packages/backend/src/plugins/identity.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createRouter } from '@backstage/plugin-identity-backend';
+import { PluginEnvironment } from '../types';
+
+export default async function createPlugin({ logger }: PluginEnvironment) {
+ return await createRouter({ logger });
+}
diff --git a/packages/cli/templates/default-app/packages/backend/src/plugins/proxy.ts b/packages/cli/templates/default-app/packages/backend/src/plugins/proxy.ts
new file mode 100644
index 0000000000..4964de130e
--- /dev/null
+++ b/packages/cli/templates/default-app/packages/backend/src/plugins/proxy.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+// @ts-ignore
+import { createRouter } from '@backstage/plugin-proxy-backend';
+import { PluginEnvironment } from '../types';
+
+export default async function createPlugin({
+ logger,
+ config,
+}: PluginEnvironment) {
+ return await createRouter({ logger, config });
+}
diff --git a/packages/cli/templates/default-app/packages/backend/src/plugins/scaffolder.ts b/packages/cli/templates/default-app/packages/backend/src/plugins/scaffolder.ts
new file mode 100644
index 0000000000..a9e5f185ec
--- /dev/null
+++ b/packages/cli/templates/default-app/packages/backend/src/plugins/scaffolder.ts
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ CookieCutter,
+ createRouter,
+ FilePreparer,
+ GithubPreparer,
+ Preparers,
+ GithubPublisher,
+ CreateReactAppTemplater,
+ Templaters,
+} from '@backstage/plugin-scaffolder-backend';
+import { Octokit } from '@octokit/rest';
+import type { PluginEnvironment } from '../types';
+import Docker from 'dockerode';
+
+export default async function createPlugin({ logger }: PluginEnvironment) {
+ const cookiecutterTemplater = new CookieCutter();
+ const craTemplater = new CreateReactAppTemplater();
+ const templaters = new Templaters();
+ templaters.register('cookiecutter', cookiecutterTemplater);
+ templaters.register('cra', craTemplater);
+
+ const filePreparer = new FilePreparer();
+ const githubPreparer = new GithubPreparer();
+ const preparers = new Preparers();
+
+ preparers.register('file', filePreparer);
+ preparers.register('github', githubPreparer);
+
+ const githubClient = new Octokit({ auth: process.env.GITHUB_ACCESS_TOKEN });
+ const publisher = new GithubPublisher({ client: githubClient });
+
+ const dockerClient = new Docker();
+ return await createRouter({
+ preparers,
+ templaters,
+ publisher,
+ logger,
+ dockerClient,
+ });
+}
diff --git a/packages/cli/templates/default-app/packages/backend/src/plugins/techdocs.ts b/packages/cli/templates/default-app/packages/backend/src/plugins/techdocs.ts
new file mode 100644
index 0000000000..0d03bfabab
--- /dev/null
+++ b/packages/cli/templates/default-app/packages/backend/src/plugins/techdocs.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createRouter } from '@backstage/plugin-techdocs-backend';
+import { PluginEnvironment } from '../types';
+
+export default async function createPlugin({ logger }: PluginEnvironment) {
+ return await createRouter({ logger });
+}
diff --git a/packages/cli/templates/default-app/packages/backend/src/types.ts b/packages/cli/templates/default-app/packages/backend/src/types.ts
new file mode 100644
index 0000000000..f7df3d05c6
--- /dev/null
+++ b/packages/cli/templates/default-app/packages/backend/src/types.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import Knex from 'knex';
+import { Logger } from 'winston';
+import { Config } from '@backstage/config';
+
+export type PluginEnvironment = {
+ logger: Logger;
+ database: Knex;
+ config: Config;
+};
diff --git a/packages/cli/templates/default-app/tsconfig.json b/packages/cli/templates/default-app/tsconfig.json
index 52cdd1e825..9dbc3bbe27 100644
--- a/packages/cli/templates/default-app/tsconfig.json
+++ b/packages/cli/templates/default-app/tsconfig.json
@@ -1,8 +1,14 @@
{
"extends": "@backstage/cli/config/tsconfig.json",
- "include": ["packages/*/src", "plugins/*/src", "plugins/*/dev"],
- "exclude": ["**/node_modules"],
+ "include": [
+ "packages/*/src",
+ "plugins/*/src",
+ "plugins/*/dev",
+ "plugins/*/migrations"
+ ],
+ "exclude": ["node_modules"],
"compilerOptions": {
- "outDir": "dist"
+ "outDir": "dist",
+ "skipLibCheck": true
}
}
diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs
index da611a13b7..d5d25b974a 100644
--- a/packages/cli/templates/default-plugin/package.json.hbs
+++ b/packages/cli/templates/default-plugin/package.json.hbs
@@ -36,7 +36,7 @@
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
- "@types/jest": "^25.2.2",
+ "@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"jest-fetch-mock": "^3.0.3"
},
diff --git a/packages/config-loader/package.json b/packages/config-loader/package.json
index 72bfc0378b..4cc52c9cbf 100644
--- a/packages/config-loader/package.json
+++ b/packages/config-loader/package.json
@@ -36,7 +36,7 @@
"yup": "^0.29.1"
},
"devDependencies": {
- "@types/jest": "^25.2.2",
+ "@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/yup": "^0.28.2"
},
diff --git a/packages/config/package.json b/packages/config/package.json
index a401bc9af5..f264304e85 100644
--- a/packages/config/package.json
+++ b/packages/config/package.json
@@ -33,7 +33,7 @@
"lodash": "^4.17.15"
},
"devDependencies": {
- "@types/jest": "^25.2.2",
+ "@types/jest": "^26.0.7",
"@types/node": "^12.0.0"
},
"files": [
diff --git a/packages/core-api/package.json b/packages/core-api/package.json
index 38abdf9487..5aca780e30 100644
--- a/packages/core-api/package.json
+++ b/packages/core-api/package.json
@@ -46,7 +46,7 @@
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
- "@types/jest": "^25.2.2",
+ "@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/zen-observable": "^0.8.0",
"jest-fetch-mock": "^3.0.3"
diff --git a/packages/core-api/src/apis/definitions/ConfigApi.ts b/packages/core-api/src/apis/definitions/ConfigApi.ts
index 63a588fc10..1fa6e70d1f 100644
--- a/packages/core-api/src/apis/definitions/ConfigApi.ts
+++ b/packages/core-api/src/apis/definitions/ConfigApi.ts
@@ -17,7 +17,7 @@ import { createApiRef } from '../ApiRef';
import { Config } from '@backstage/config';
// Using interface to make the ConfigApi name show up in docs
-export interface ConfigApi extends Config {}
+export type ConfigApi = Config;
export const configApiRef = createApiRef({
id: 'core.config',
diff --git a/packages/core/package.json b/packages/core/package.json
index 4b29f22ba3..3faaa33ddf 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -61,7 +61,7 @@
"@testing-library/user-event": "^12.0.7",
"@types/classnames": "^2.2.9",
"@types/google-protobuf": "^3.7.2",
- "@types/jest": "^25.2.2",
+ "@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/react-helmet": "^5.0.15",
"@types/zen-observable": "^0.8.0",
diff --git a/packages/core/src/layout/Sidebar/UserSettings.tsx b/packages/core/src/layout/Sidebar/UserSettings.tsx
index 5f66fc6558..e69d63186d 100644
--- a/packages/core/src/layout/Sidebar/UserSettings.tsx
+++ b/packages/core/src/layout/Sidebar/UserSettings.tsx
@@ -22,6 +22,7 @@ import {
oauth2ApiRef,
oktaAuthApiRef,
useApi,
+ configApiRef,
} from '@backstage/core-api';
import Collapse from '@material-ui/core/Collapse';
import SignOutIcon from '@material-ui/icons/MeetingRoom';
@@ -39,6 +40,9 @@ export function SidebarUserSettings() {
const { isOpen: sidebarOpen } = useContext(SidebarContext);
const [open, setOpen] = React.useState(false);
const identityApi = useApi(identityApiRef);
+ const configApi = useApi(configApiRef);
+ const providersConfig = configApi.getOptionalConfig('auth.providers');
+ const providers = providersConfig?.keys() ?? [];
// Close the provider list when sidebar collapse
useEffect(() => {
@@ -49,31 +53,41 @@ export function SidebarUserSettings() {
<>
-
-
-
-
-
+ {providers.includes('google') && (
+
+ )}
+ {providers.includes('github') && (
+
+ )}
+ {providers.includes('gitlab') && (
+
+ )}
+ {providers.includes('okta') && (
+
+ )}
+ {providers.includes('oauth2') && (
+
+ )}
{
+ it('should generate empty API doc', () => {
+ const program = createMemProgram(
+ `
+ import MyApi from './type';
+
+ type MyApiType = {};
+
+ export const myApi = new MyApi({
+ id: 'my-id',
+ description: 'my-description',
+ });
+ `,
+ {
+ '/mem/type.ts': `export default class MyApi {
+ constructor(private readonly info: { id: string, description: string }) {}
+ }`,
+ },
+ );
+
+ const typeLocator = TypeLocator.fromProgram(program, '/');
+
+ const { apiInstances } = typeLocator.findExportedInstances({
+ apiInstances: typeLocator.getExportedType('/mem/type.ts'),
+ });
+
+ expect(apiInstances.length).toBe(1);
+ const [apiInstance] = apiInstances;
+
+ const docGenerator = ApiDocGenerator.fromProgram(program, '/');
+ const doc = docGenerator.toDoc(apiInstance);
+
+ expect(doc.id).toBe('my-id');
+ expect(doc.description).toBe('my-description');
+ expect(doc.name).toBe('myApi');
+ expect(doc.file).toBe('mem/index.ts');
+ expect(doc.lineInFile).toBe(6);
+ expect(doc.interfaceInfos).toEqual([
+ {
+ dependentTypes: [],
+ docs: [],
+ file: 'mem/index.ts',
+ lineInFile: 4,
+ members: [],
+ name: 'MyApiType',
+ },
+ ]);
+ });
+
+ it('should generate API docs', () => {
+ const program = createMemProgram(
+ `
+ import MyApi from './type';
+
+ /** MySubSubType Docs */
+ type MySubSubType = { n: number; };
+
+ /** MySubType Docs */
+ type MySubType = {
+ /** Field a docs */
+ a: boolean;
+ // Field b docs
+ b: MySubSubType;
+ }
+
+ /** MySecondSubType Docs */
+
+ /** With multiple comments */
+ export type MySecondSubType = { s: string };
+
+ // MyThirdSubType Docs that shouldn't show up
+ type MyThirdSubType = { b: boolean };
+
+ /** MyApiType Docs */
+ type MyApiType = {
+ /** Docs for x */
+ x: string;
+ // Line comments shouldn't show up
+ y: MySubType;
+ /** Multiple */
+ /** JsDoc */
+ /** Comments */
+ z(a: Promise): Array;
+ };
+
+ /** Should not show up */
+ export const myApi = new MyApi({
+ id: 'my-id',
+ description: 'my-description',
+ });
+ `,
+ {
+ '/mem/type.ts': `export default class MyApi {
+ constructor(private readonly info: { id: string, description: string }) {}
+ }`,
+ },
+ );
+
+ const source = program.getSourceFile('/mem/index.ts');
+
+ // Figure out type IDs so we can make sure they match later
+ const checker = program.getTypeChecker();
+ const symbols = checker.getSymbolsInScope(
+ source!.getChildren().slice(-1)[0],
+ ts.SymbolFlags.TypeAlias,
+ );
+ const Ids = [
+ 'MySubType',
+ 'MySubSubType',
+ 'MySecondSubType',
+ 'MyThirdSubType',
+ ].reduce((ids, name) => {
+ const symbol = symbols.find(s => s.escapedName === name)!;
+ const type = checker.getTypeAtLocation(symbol.declarations[0]);
+ ids[name] = (type.aliasSymbol as any).id;
+ return ids;
+ }, {} as { [key in string]: number });
+
+ const typeLocator = TypeLocator.fromProgram(program, '/mem');
+
+ const { apiInstances } = typeLocator.findExportedInstances({
+ apiInstances: typeLocator.getExportedType('/mem/type.ts'),
+ });
+
+ expect(apiInstances.length).toBe(1);
+ const [apiInstance] = apiInstances;
+
+ const docGenerator = ApiDocGenerator.fromProgram(program, '/mem');
+ const doc = docGenerator.toDoc(apiInstance);
+
+ expect(doc.id).toBe('my-id');
+ expect(doc.description).toBe('my-description');
+ expect(doc.name).toBe('myApi');
+ expect(doc.file).toBe('index.ts');
+ expect(doc.interfaceInfos.length).toBe(1);
+ expect(doc.interfaceInfos[0].docs).toEqual(['MyApiType Docs']);
+ expect(doc.interfaceInfos[0].file).toBe('index.ts');
+ expect(doc.interfaceInfos[0].lineInFile).toBe(24);
+ expect(doc.interfaceInfos[0].name).toBe('MyApiType');
+ expect(doc.interfaceInfos[0].members).toEqual([
+ {
+ type: 'prop',
+ name: 'x',
+ path: 'MyApiType.x',
+ text: 'x: string',
+ docs: ['Docs for x'],
+ links: [],
+ },
+ {
+ type: 'prop',
+ name: 'y',
+ path: 'MyApiType.y',
+ text: 'y: MySubType',
+ docs: [],
+ links: [
+ {
+ id: Ids.MySubType,
+ name: 'MySubType',
+ path: 'index.ts/MySubType',
+ location: [3, 12],
+ },
+ ],
+ },
+ {
+ type: 'method',
+ name: 'z',
+ path: 'MyApiType.z',
+ text:
+ 'z(a: Promise): Array',
+ docs: ['Multiple', 'JsDoc', 'Comments'],
+ links: [
+ {
+ id: Ids.MySecondSubType,
+ name: 'MySecondSubType',
+ path: 'index.ts/MySecondSubType',
+ location: [27, 42],
+ },
+ {
+ id: Ids.MyThirdSubType,
+ name: 'MyThirdSubType',
+ path: 'index.ts/MyThirdSubType',
+ location: [56, 70],
+ },
+ ],
+ },
+ ]);
+ expect(doc.interfaceInfos[0].dependentTypes).toEqual([
+ {
+ id: Ids.MySubType,
+ name: 'MySubType',
+ path: 'index.ts/MySubType',
+ file: 'index.ts',
+ lineInFile: 8,
+ text: `type MySubType = {
+ /** Field a docs */
+ a: boolean;
+ // Field b docs
+ b: MySubSubType;
+ }`,
+ docs: ['MySubType Docs'],
+ links: [
+ {
+ id: Ids.MySubSubType,
+ name: 'MySubSubType',
+ path: 'index.ts/MySubSubType',
+ location: [102, 114],
+ },
+ ],
+ children: [],
+ },
+ {
+ id: Ids.MySubSubType,
+ name: 'MySubSubType',
+ path: 'index.ts/MySubSubType',
+ file: 'index.ts',
+ lineInFile: 5,
+ text: 'type MySubSubType = { n: number; }',
+ docs: ['MySubSubType Docs'],
+ links: [],
+ children: [],
+ },
+ {
+ id: Ids.MySecondSubType,
+ name: 'MySecondSubType',
+ path: 'index.ts/MySecondSubType',
+ file: 'index.ts',
+ lineInFile: 18,
+ text: 'export type MySecondSubType = { s: string }',
+ docs: ['MySecondSubType Docs', 'With multiple comments'],
+ links: [],
+ children: [],
+ },
+ {
+ id: Ids.MyThirdSubType,
+ name: 'MyThirdSubType',
+ path: 'index.ts/MyThirdSubType',
+ file: 'index.ts',
+ lineInFile: 21,
+ text: 'type MyThirdSubType = { b: boolean }',
+ docs: [],
+ links: [],
+ children: [],
+ },
+ ]);
+ });
+});
diff --git a/packages/docgen/src/docgen/ApiDocGenerator.ts b/packages/docgen/src/docgen/ApiDocGenerator.ts
new file mode 100644
index 0000000000..bfe449155b
--- /dev/null
+++ b/packages/docgen/src/docgen/ApiDocGenerator.ts
@@ -0,0 +1,334 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import ts from 'typescript';
+import { relative } from 'path';
+import {
+ ExportedInstance,
+ ApiDoc,
+ InterfaceInfo,
+ FieldInfo,
+ TypeInfo,
+ TypeLink,
+} from './types';
+
+/**
+ * The ApiDocGenerator uses the typescript compiler API to build the data structure that
+ * describes a Backstage API and all of it's related types.
+ *
+ * It receives an exported instance that of the form
+ * `export name = createApiRef({id: ..., description: ...})`.
+ * It will then traverse all fields and methods on the interface type, and also
+ * types and declaration of all types that are used by those, both directly and indirectly.
+ * While traversing, it collects information such as names, location in source, docs, etc.
+ */
+export default class ApiDocGenerator {
+ static fromProgram(program: ts.Program, sourcePath: string) {
+ return new ApiDocGenerator(program.getTypeChecker(), sourcePath);
+ }
+
+ constructor(
+ private readonly checker: ts.TypeChecker,
+ private readonly basePath: string,
+ ) {}
+
+ /**
+ * Generate documentation information for a given exported symbol.
+ */
+ toDoc(apiInstance: ExportedInstance): ApiDoc {
+ const { name, source, args, typeArgs } = apiInstance;
+
+ const [info] = args;
+ if (!ts.isObjectLiteralExpression(info)) {
+ throw new Error('api info is not an object literal');
+ }
+
+ const id = this.getObjectPropertyLiteral(info, 'id');
+ const description = this.getObjectPropertyLiteral(info, 'description');
+ const file = relative(this.basePath, source.fileName);
+ const { line } = source.getLineAndCharacterOfPosition(
+ apiInstance.node.getStart(),
+ );
+
+ const rootTypeNode = typeArgs[0];
+ const typeNodes = ts.isIntersectionTypeNode(rootTypeNode)
+ ? rootTypeNode.types.slice()
+ : [rootTypeNode];
+ const interfaceInfos = typeNodes.map(typeNode =>
+ this.getInterfaceInfo(typeNode),
+ );
+
+ return {
+ id,
+ name,
+ file,
+ lineInFile: line + 1,
+ description,
+ interfaceInfos,
+ };
+ }
+
+ /**
+ * Grab jsDoc and regular comments for a node
+ */
+ private getNodeDocs(node: ts.Node): string[] {
+ const docNodes = ((node && (node as any).jsDoc) || []) as ts.JSDoc[];
+ const docs = docNodes.map(docNode => docNode.comment || '').filter(Boolean);
+ return docs;
+ }
+
+ /**
+ * Collect information about a top-level type.
+ */
+ private getInterfaceInfo(typeNode: ts.TypeNode): InterfaceInfo {
+ if (ts.isTypeQueryNode(typeNode)) {
+ throw new Error(
+ 'APIs must have a proper type parameter, TypeQueries are now allowed',
+ );
+ }
+ if (!ts.isTypeReferenceNode(typeNode)) {
+ throw new Error('Interface is not a type node');
+ }
+
+ const type = this.checker.getTypeFromTypeNode(typeNode);
+ const interfaceMembers = type.symbol.members as Map;
+ if (!interfaceMembers) {
+ throw new Error('Interface does not have any members');
+ }
+ const name = (type.aliasSymbol || type.symbol).name;
+ const [declaration] = (type.aliasSymbol || type.symbol).declarations;
+ const sourceFile = declaration.getSourceFile();
+ const file = relative(this.basePath, sourceFile.fileName);
+ const { line } = sourceFile.getLineAndCharacterOfPosition(
+ declaration.getStart(),
+ );
+ const docs = this.getNodeDocs(declaration);
+
+ const membersAndTypes = Array.from(
+ interfaceMembers.values(),
+ ).flatMap(fieldSymbol => this.getMemberInfo(name, fieldSymbol));
+
+ const members = membersAndTypes.map(t => t.member);
+ const dependentTypes = this.flattenTypes(
+ membersAndTypes.flatMap(t => t.dependentTypes),
+ );
+
+ return { name, docs, file, lineInFile: line + 1, members, dependentTypes };
+ }
+
+ /**
+ * Grab primitive values from an object literal expression.
+ */
+ private getObjectPropertyLiteral(
+ objectLiteral: ts.ObjectLiteralExpression,
+ propertyName: string,
+ ): string {
+ const matchingProp = objectLiteral.properties.filter(
+ prop =>
+ ts.isPropertyAssignment(prop) &&
+ ts.isIdentifier(prop.name) &&
+ prop.name.text === propertyName,
+ )[0] as ts.PropertyAssignment;
+
+ if (!matchingProp) {
+ throw new Error(`no identifier found for property ${propertyName}`);
+ }
+
+ const { initializer } = matchingProp;
+ if (!ts.isStringLiteral(initializer)) {
+ throw new Error(`no string literal for ${propertyName}`);
+ }
+
+ return initializer.text;
+ }
+
+ /**
+ * Get field definitions for a given symbol.
+ */
+ private getMemberInfo(
+ parentName: string,
+ symbol: ts.Symbol,
+ ): { member: FieldInfo; dependentTypes: TypeInfo[] } {
+ const declaration = symbol.valueDeclaration;
+
+ const { links, infos: dependentTypes } = this.findAllTypeReferences(
+ declaration,
+ );
+
+ let type: FieldInfo['type'] = 'prop';
+ if (
+ ts.isPropertySignature(declaration) &&
+ declaration.type &&
+ ts.isFunctionTypeNode(declaration.type)
+ ) {
+ type = 'method';
+ } else if (ts.isMethodSignature(declaration)) {
+ type = 'method';
+ }
+
+ return {
+ member: {
+ type,
+ name: symbol.name,
+ path: `${parentName}.${symbol.name}`,
+ text: declaration.getText().replace(/;$/, ''),
+ docs: this.getNodeDocs(declaration),
+ links: this.recalculateLinkOffsets(declaration, links),
+ },
+ dependentTypes,
+ };
+ }
+
+ /**
+ * Recursively search for references to types that we care about and want to include in the documentation.
+ */
+ private findAllTypeReferences = (
+ node: ts.Node | undefined,
+ visited: Set = new Set(),
+ ): { links: TypeLink[]; infos: TypeInfo[] } => {
+ if (!node || visited.has(node)) {
+ return { links: [], infos: [] };
+ }
+ // This makes sure we don't end up in a loop.
+ // It doesn't exclude repeated types, e.g. Array>, since we're exiting on duplicate nodes, not types.
+ visited.add(node);
+
+ const children = node
+ .getChildren()
+ .flatMap(child => this.findAllTypeReferences(child, visited));
+
+ if (!ts.isTypeReferenceNode(node)) {
+ return {
+ links: children.flatMap(child => child.links),
+ infos: children.flatMap(child => child.infos),
+ };
+ }
+
+ const type = this.checker.getTypeFromTypeNode(node);
+
+ const info = this.validateTypeDeclaration(type);
+ if (!info) {
+ return {
+ links: children.flatMap(child => child.links),
+ infos: children.flatMap(child => child.infos),
+ };
+ }
+ const { symbol, declaration } = info;
+
+ const typeRefs = this.findAllTypeReferences(declaration, visited);
+
+ const sourceFile = declaration.getSourceFile();
+ const { line } = sourceFile.getLineAndCharacterOfPosition(
+ declaration.getStart(),
+ );
+ const file = relative(this.basePath, sourceFile.fileName);
+ const typeInfo = {
+ id: (symbol as any).id,
+ name: symbol.name,
+ text: declaration.getText().replace(/;$/, ''),
+ docs: this.getNodeDocs(declaration),
+ path: `${file}/${symbol.name}`,
+ file,
+ lineInFile: line + 1, // TS line is 0-based
+ links: this.recalculateLinkOffsets(declaration, typeRefs.links),
+ children: typeRefs.infos,
+ };
+
+ const link = {
+ id: typeInfo.id,
+ path: typeInfo.path,
+ name: typeInfo.name,
+ location: [node.typeName.getStart(), node.typeName.getEnd()] as const,
+ };
+
+ return {
+ links: [link, ...children.flatMap(child => child.links)],
+ infos: [typeInfo, ...children.flatMap(child => child.infos)],
+ };
+ };
+
+ /**
+ * Check if a given type declaration is one that we care about.
+ */
+ private validateTypeDeclaration(
+ type: ts.Type | undefined,
+ ): undefined | { symbol: ts.Symbol; declaration: ts.Declaration } {
+ if (!type) {
+ return undefined;
+ }
+ const symbol = type.aliasSymbol || type.symbol;
+ if (!symbol) {
+ return undefined;
+ }
+
+ const [declaration] = symbol.declarations;
+
+ // Don't generate standalone infos for type paramters
+ if (ts.isTypeParameterDeclaration(declaration)) {
+ return undefined;
+ }
+
+ if (declaration.getSourceFile().fileName.includes('node_modules')) {
+ return undefined;
+ }
+
+ return { symbol, declaration };
+ }
+
+ /**
+ * Flatten a tree of TypeInfos with children into a flat
+ * list of TypeInfos with duplicates removed.
+ */
+ private flattenTypes(descs: TypeInfo[]): TypeInfo[] {
+ function getChildren(parent: TypeInfo): TypeInfo[] {
+ return [
+ {
+ ...parent,
+ children: [],
+ },
+ ...parent.children.flatMap(getChildren),
+ ];
+ }
+
+ const flatDescs = descs.flatMap(getChildren);
+ const seenTypes = new Set();
+ return flatDescs.filter(desc => {
+ if (seenTypes.has(desc.id)) {
+ return false;
+ }
+ seenTypes.add(desc.id);
+ return true;
+ });
+ }
+
+ /**
+ * Calculate link positions within a block of code, with 0 being
+ * the first character in the block.
+ */
+ private recalculateLinkOffsets(
+ parent: ts.Node,
+ links: TypeLink[],
+ ): TypeLink[] {
+ const parentStart = parent.getStart();
+ return links.map(link => {
+ const [start, end] = link.location;
+ return {
+ ...link,
+ location: [start - parentStart, end - parentStart],
+ };
+ });
+ }
+}
diff --git a/packages/docgen/src/docgen/ApiDocsPrinter.ts b/packages/docgen/src/docgen/ApiDocsPrinter.ts
new file mode 100644
index 0000000000..fdd53afc8e
--- /dev/null
+++ b/packages/docgen/src/docgen/ApiDocsPrinter.ts
@@ -0,0 +1,163 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import sortSelector from './sortSelector';
+import { ApiDoc, InterfaceInfo, MarkdownPrinter } from './types';
+
+/**
+ * The ApiDocPrinter takes a ApiDoc data structure, typically generated by an ApiDocGenerator,
+ * and prints it out as a markdown doc with custom code highlighting and links.
+ */
+export default class ApiDocPrinter {
+ printerFactory: () => MarkdownPrinter;
+
+ constructor(printerFactory: () => MarkdownPrinter) {
+ this.printerFactory = printerFactory;
+ }
+
+ /**
+ * Print an index file with all ApiRefs and what types they implement.
+ */
+ printApiIndex(apiDocs: ApiDoc[]): Buffer {
+ const printer = this.printerFactory();
+
+ printer.header(1, 'Backstage Core Utility APIs');
+
+ printer.paragraph(
+ 'The following is a list of all Utility APIs defined by `@backstage/core`.',
+ 'They are available to use by plugins and components, and can be accessed ',
+ 'using the `useApi` hook, also provided by `@backstage/core`.',
+ 'For more information, see https://github.com/spotify/backstage/blob/master/docs/api/utility-apis.md.',
+ );
+
+ for (const api of apiDocs) {
+ printer.header(3, `${this.apiDisplayName(api)}`, api.id);
+
+ printer.paragraph(api.description);
+
+ const typeLinks = api.interfaceInfos.map(
+ i => `[${i.name}](${printer.pageLink(i.name)})`,
+ );
+ printer.paragraph(
+ `Implemented type${typeLinks.length > 1 ? 's' : ''}: ${typeLinks.join(
+ ', ',
+ )}`,
+ );
+
+ printer.paragraph(`ApiRef: ${printer.srcLink(api, api.name)}`);
+ }
+
+ return printer.toBuffer();
+ }
+
+ /**
+ * Print documentation page for a type implemented by an ApiRef and
+ */
+ printInterface(apiType: InterfaceInfo, apiDocs: ApiDoc[]): Buffer {
+ const printer = this.printerFactory();
+
+ printer.header(1, apiType.name);
+
+ printer.paragraph(
+ `The ${apiType.name} type is defined at ${printer.srcLink(apiType)}.`,
+ );
+
+ const apiLinks = apiDocs
+ .filter(ad => ad.interfaceInfos.some(i => i.name === apiType.name))
+ .map(ad => {
+ const link =
+ printer.indexLink() +
+ printer.headerLink(this.apiDisplayName(ad), ad.id);
+ return `[${ad.name}](${link})`;
+ });
+
+ if (apiLinks.length === 1) {
+ printer.paragraph(
+ `The following Utility API implements this type: ${apiLinks}`,
+ );
+ } else {
+ printer.paragraph(`The following Utility APIs implement this type:`);
+ for (const link of apiLinks) {
+ printer.text(` - ${link}`);
+ }
+ }
+
+ printer.header(2, 'Members');
+
+ this.addInterfaceMembers(printer, apiType);
+
+ if (apiType.dependentTypes.length) {
+ printer.header(2, 'Supporting types');
+ printer.paragraph(
+ 'These types are part of the API declaration, but may not be unique to this API.',
+ );
+
+ this.addInterfaceTypes(printer, apiType);
+ }
+
+ return printer.toBuffer();
+ }
+
+ private addInterfaceMembers(
+ printer: MarkdownPrinter,
+ apiType: InterfaceInfo,
+ ) {
+ for (const member of apiType.members) {
+ printer.header(
+ 3,
+ `${member.name}${member.type === 'method' ? '()' : ''}`,
+ member.path,
+ );
+
+ for (const doc of member.docs) {
+ printer.text(doc);
+ }
+
+ printer.code(member);
+ }
+ }
+
+ private addInterfaceTypes(printer: MarkdownPrinter, apiType: InterfaceInfo) {
+ for (const type of apiType.dependentTypes
+ .slice()
+ .sort(sortSelector(x => x.name))) {
+ printer.header(3, `${type.name}`, type.path);
+
+ for (const doc of type.docs) {
+ printer.text(doc);
+ }
+ printer.code(type);
+
+ printer.paragraph(`Defined at ${printer.srcLink(type)}.`);
+
+ const usageLinks = [...apiType.members, ...apiType.dependentTypes]
+ .filter(member => {
+ return member.links.some(link => link.id === type.id);
+ })
+ .map(
+ ({ name, path }) => `[${name}](${printer.headerLink(name, path)})`,
+ );
+
+ if (usageLinks.length) {
+ printer.paragraph(`Referenced by: ${usageLinks.join(', ')}.`);
+ }
+ }
+ }
+
+ private apiDisplayName(api: ApiDoc): string {
+ return api.name.replace(/ApiRef$/, '');
+ }
+}
diff --git a/packages/docgen/src/docgen/GitHubMarkdownPrinter.ts b/packages/docgen/src/docgen/GitHubMarkdownPrinter.ts
new file mode 100644
index 0000000000..64d38d8b15
--- /dev/null
+++ b/packages/docgen/src/docgen/GitHubMarkdownPrinter.ts
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import GithubSlugger from 'github-slugger';
+import sortSelector from './sortSelector';
+import { MarkdownPrinter, TypeLink } from './types';
+import { execSync } from 'child_process';
+
+// TODO(Rugvip): provide through options?
+const GH_BASE_URL = 'https://github.com/spotify/backstage';
+
+const COMMIT_SHA =
+ process.env.COMMIT_SHA ||
+ execSync('git rev-parse HEAD').toString('utf8').trim();
+
+/**
+ * The GithubMarkdownPrinter is a MarkdownPrinter for printing Github-flavored markdown documents.
+ */
+export default class GithubMarkdownPrinter implements MarkdownPrinter {
+ private str: string = '';
+
+ private line(line: string = '') {
+ this.str += `${line}\n`;
+ }
+
+ text(text: string) {
+ this.line(text);
+ this.line();
+ }
+
+ header(level: number, text: string) {
+ this.line(`${'#'.repeat(level)} ${text}`);
+ this.line();
+ }
+
+ headerLink(heading: string): string {
+ const slug = GithubSlugger.slug(heading);
+ return `#${slug}`;
+ }
+
+ indexLink() {
+ return './README.md';
+ }
+
+ pageLink(name: string) {
+ return `./${name}.md`;
+ }
+
+ srcLink(
+ { file, lineInFile }: { file: string; lineInFile: number },
+ text?: string,
+ ): string {
+ const linkText = text ?? `${file}:${lineInFile}`;
+ const href = `${GH_BASE_URL}/blob/${COMMIT_SHA}/${file}#L${lineInFile}`;
+ return `[${linkText}](${href})`;
+ }
+
+ paragraph(...text: string[]) {
+ this.line(
+ text
+ .join('\n')
+ .trim()
+ .split('\n')
+ .map(line => line.trim())
+ .join('\n'),
+ );
+ this.line();
+ }
+
+ code({ text, links = [] }: { text: string; links?: TypeLink[] }) {
+ this.line('');
+ this.line(this.formatWithLinks({ text, links }));
+ this.line('');
+ this.line();
+ }
+
+ private escapeText: (text: string) => string = (() => {
+ const escapes: { [char in string]: string } = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ };
+
+ return (text: string) => text.replace(/[&<>]/g, char => escapes[char]);
+ })();
+
+ private formatWithLinks({
+ text,
+ links = [],
+ }: {
+ text: string;
+ links?: TypeLink[];
+ }): string {
+ const sortedLinks = links.slice().sort(sortSelector(x => x.location[0]));
+
+ sortedLinks.reduce((lastEnd, link) => {
+ if (link.location[0] <= lastEnd) {
+ throw new Error(
+ `overlapping link detected for ${link.path}, ${link.location}`,
+ );
+ }
+ return link.location[1];
+ }, -1);
+
+ const parts: Array<{ text: string; link?: boolean }> = [];
+
+ const endLocation = sortedLinks.reduce((prev, link) => {
+ const [start, end] = link.location;
+ parts.push(
+ { text: text.slice(prev, start) },
+ { text: text.slice(start, end), link: true },
+ );
+ return end;
+ }, 0);
+
+ parts.push({ text: text.slice(endLocation) });
+
+ return parts
+ .map(part => {
+ if (part.link) {
+ const link = this.headerLink(part.text);
+ return `${this.escapeText(part.text)}`;
+ }
+ return this.escapeText(part.text);
+ })
+ .join('');
+ }
+
+ toBuffer(): Buffer {
+ return Buffer.from(this.str, 'utf8');
+ }
+}
diff --git a/packages/docgen/src/docgen/TechdocsMarkdownPrinter.ts b/packages/docgen/src/docgen/TechdocsMarkdownPrinter.ts
new file mode 100644
index 0000000000..f2453f314f
--- /dev/null
+++ b/packages/docgen/src/docgen/TechdocsMarkdownPrinter.ts
@@ -0,0 +1,161 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import sortSelector from './sortSelector';
+import { Highlighter, MarkdownPrinter, TypeLink } from './types';
+import { execSync } from 'child_process';
+
+// TODO(Rugvip): provide through options?
+const GH_BASE_URL = 'https://github.com/spotify/backstage';
+
+const COMMIT_SHA =
+ process.env.COMMIT_SHA || execSync('git rev-parse HEAD').toString('utf8');
+
+/**
+ * The TechdocsMarkdownPrinter is a MarkdownPrinter for building TechDocs markdown documents.
+ */
+export default class TechdocsMarkdownPrinter implements MarkdownPrinter {
+ private str: string = '';
+ private readonly highlighter: Highlighter;
+
+ constructor(highlighter: Highlighter) {
+ this.highlighter = highlighter;
+
+ // Remove line numbers from codeblocks
+ this.style('.linenodiv{ display: none }');
+ }
+
+ private line(line: string = '') {
+ this.str += `${line}\n`;
+ }
+
+ text(text: string) {
+ this.line(text);
+ this.line();
+ }
+
+ style(style: string) {
+ this.line('');
+ }
+
+ header(level: number, text: string, id?: string) {
+ this.line(`${'#'.repeat(level)} ${text}${id ? ` {#${id}}` : ''}`);
+ this.line();
+ }
+
+ headerLink(heading: string, id?: string): string {
+ return `#${id ?? heading}`;
+ }
+
+ indexLink() {
+ return '../';
+ }
+
+ pageLink(name: string) {
+ return `./${name}/`;
+ }
+
+ srcLink(
+ { file, lineInFile }: { file: string; lineInFile: number },
+ text?: string,
+ ): string {
+ const linkText = text ?? `${file}:${lineInFile}`;
+ const href = `${GH_BASE_URL}/blob/${COMMIT_SHA}/${file}#L${lineInFile}`;
+ return `[${linkText}](${href})`;
+ }
+
+ paragraph(...text: string[]) {
+ this.line(
+ text
+ .join('\n')
+ .trim()
+ .split('\n')
+ .map(line => line.trim())
+ .join('\n'),
+ );
+ this.line();
+ }
+
+ code({ text, links = [] }: { text: string; links?: TypeLink[] }) {
+ this.line('');
+ this.line(
+ '
',
+ );
+ this.line('
');
+ this.line(this.formatWithLinks({ text, links }));
+ this.line('');
+ this.line('
');
+ this.line('
');
+ }
+
+ private escapeText: (text: string) => string = (() => {
+ const escapes: { [char in string]: string } = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ };
+
+ return (text: string) => text.replace(/[&<>]/g, char => escapes[char]);
+ })();
+
+ private formatWithLinks({
+ text,
+ links = [],
+ }: {
+ text: string;
+ links?: TypeLink[];
+ }): string {
+ const sortedLinks = links.slice().sort(sortSelector(x => x.location[0]));
+
+ sortedLinks.reduce((lastEnd, link) => {
+ if (link.location[0] <= lastEnd) {
+ throw new Error(
+ `overlapping link detected for ${link.path}, ${link.location}`,
+ );
+ }
+ return link.location[1];
+ }, -1);
+
+ const parts: Array<{ text: string; path?: string }> = [];
+
+ const endLocation = sortedLinks.reduce((prev, link) => {
+ const [start, end] = link.location;
+ parts.push(
+ { text: text.slice(prev, start) },
+ { text: text.slice(start, end), path: link.path },
+ );
+ return end;
+ }, 0);
+
+ parts.push({ text: text.slice(endLocation) });
+
+ return parts
+ .map(part => {
+ if (part.path) {
+ const link = this.headerLink(part.text, part.path);
+ return `${this.escapeText(part.text)}`;
+ }
+ return this.highlighter.highlight(this.escapeText(part.text));
+ })
+ .join('');
+ }
+
+ toBuffer(): Buffer {
+ return Buffer.from(this.str, 'utf8');
+ }
+}
diff --git a/packages/docgen/src/docgen/TypeLocator.test.ts b/packages/docgen/src/docgen/TypeLocator.test.ts
new file mode 100644
index 0000000000..5a264fd41c
--- /dev/null
+++ b/packages/docgen/src/docgen/TypeLocator.test.ts
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import ts from 'typescript';
+import TypeLocator from './TypeLocator';
+import { createMemProgram } from './testUtils';
+
+describe('TypeLocator', () => {
+ it('should find a default export', () => {
+ const program = createMemProgram('export default class MyApi {}');
+
+ const typeLocator = TypeLocator.fromProgram(program, '/mem');
+
+ const apiType = typeLocator.getExportedType('/mem/index.ts');
+ expect(apiType.symbol.name).toBe('default');
+ const [declaration] = apiType.symbol.declarations;
+ expect(declaration.kind).toBe(ts.SyntaxKind.ClassDeclaration);
+ expect((declaration as ts.ClassDeclaration).name).toBeDefined();
+ expect((declaration as ts.ClassDeclaration).name!.text).toBe('MyApi');
+ }, 10000);
+
+ it('should find api instance export', () => {
+ const program = createMemProgram(
+ `
+ import MyApi from './type';
+
+ type MyApiType = {};
+
+ export const myApi = new MyApi();
+ `,
+ {
+ '/mem/type.ts': 'export default class MyApi {}',
+ },
+ );
+
+ const typeLocator = TypeLocator.fromProgram(program, '/mem');
+
+ const { apiInstances } = typeLocator.findExportedInstances({
+ apiInstances: typeLocator.getExportedType('/mem/type.ts'),
+ });
+
+ expect(apiInstances.length).toBe(1);
+ const [apiInstance] = apiInstances;
+ expect(apiInstance.name).toBe('myApi');
+ expect(apiInstance.source.fileName).toBe('/mem/index.ts');
+ expect(apiInstance.args).toEqual([]);
+
+ expect(apiInstance.typeArgs.length).toBe(1);
+ const [typeArg] = apiInstance.typeArgs;
+ expect(typeArg.kind).toBe(ts.SyntaxKind.TypeReference);
+ expect((typeArg as ts.TypeReferenceNode).typeName.getText()).toBe(
+ 'MyApiType',
+ );
+ });
+});
diff --git a/packages/docgen/src/docgen/TypeLocator.ts b/packages/docgen/src/docgen/TypeLocator.ts
new file mode 100644
index 0000000000..6030258112
--- /dev/null
+++ b/packages/docgen/src/docgen/TypeLocator.ts
@@ -0,0 +1,142 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import ts from 'typescript';
+import { ExportedInstance } from './types';
+
+/**
+ * The TypeLocator is used to extract typescrint Type structures from a compiled program,
+ * as well as finding locations where types are used in according to a matching pattern.
+ *
+ * This is used to e.g. find exported APIs that we should generate documentation for.
+ */
+export default class TypeLocator {
+ static fromProgram(program: ts.Program, sourcePath: string) {
+ return new TypeLocator(program.getTypeChecker(), program, sourcePath);
+ }
+
+ constructor(
+ private readonly checker: ts.TypeChecker,
+ private readonly program: ts.Program,
+ private readonly sourcePath: string,
+ ) {}
+
+ /**
+ * Get the type of a symbol by export path and name
+ */
+ getExportedType(path: string, exportedName: string = 'default'): ts.Type {
+ const source = this.program.getSourceFile(path);
+ if (!source) {
+ throw new Error(`Source not found for path '${path}'`);
+ }
+ const exported = this.checker.getExportsOfModule(
+ this.checker.getSymbolAtLocation(source)!,
+ );
+ const [symbol] = exported.filter(e => e.name === exportedName);
+ if (!symbol) {
+ throw new Error(`No export '${exportedName}' found in ${path}`);
+ }
+ const type = this.checker.getTypeOfSymbolAtLocation(symbol, source);
+ return type;
+ }
+
+ /**
+ * Find exported instances and return values from calls using the types
+ * provided in the lookup table.
+ */
+ findExportedInstances(
+ typeLookupTable: { [key in T]: ts.Type },
+ ): { [key in T]: ExportedInstance[] } {
+ const docMap = new Map();
+ for (const type of Object.values(typeLookupTable)) {
+ docMap.set(type, []);
+ }
+
+ this.program.getSourceFiles().forEach(source => {
+ const inRoot = source.fileName.startsWith(this.sourcePath);
+ if (!inRoot) {
+ return;
+ }
+ ts.forEachChild(source, node => {
+ const decl = this.getExportedConstructorDeclaration(node);
+ if (!decl || !docMap.has(decl.constructorType)) {
+ return;
+ }
+
+ docMap.get(decl.constructorType)!.push({
+ node,
+ name: decl.name,
+ source,
+ args: Array.from(decl.initializer.arguments || []),
+ typeArgs: Array.from(decl.initializer.typeArguments || []),
+ });
+ });
+ });
+
+ const result: { [key in T]?: ExportedInstance[] } = {};
+
+ for (const key in typeLookupTable) {
+ if (typeLookupTable.hasOwnProperty(key)) {
+ const type = typeLookupTable[key];
+ result[key] = docMap.get(type)!;
+ }
+ }
+
+ return result as { [key in T]: ExportedInstance[] };
+ }
+
+ private getExportedConstructorDeclaration(
+ node: ts.Node,
+ ):
+ | {
+ constructorType: ts.Type;
+ initializer: ts.CallExpression | ts.NewExpression;
+ name: string;
+ }
+ | undefined {
+ if (!ts.isVariableStatement(node)) {
+ return undefined;
+ }
+ if (
+ !node.modifiers ||
+ !node.modifiers.some(mod => mod.kind === ts.SyntaxKind.ExportKeyword)
+ ) {
+ return undefined;
+ }
+ const { declarations } = node.declarationList;
+ if (declarations.length !== 1) {
+ return undefined;
+ }
+
+ const [declaration] = declarations;
+ const { initializer, name } = declaration;
+
+ if (!initializer || !name) {
+ return undefined;
+ }
+ if (!ts.isCallOrNewExpression(initializer)) {
+ return undefined;
+ }
+ if (!ts.isIdentifier(name)) {
+ return undefined;
+ }
+
+ const constructorType = this.checker.getTypeAtLocation(
+ initializer.expression,
+ );
+ return { constructorType, initializer, name: name.text };
+ }
+}
diff --git a/packages/docgen/src/docgen/TypescriptHighlighter.ts b/packages/docgen/src/docgen/TypescriptHighlighter.ts
new file mode 100644
index 0000000000..ffdd4f1906
--- /dev/null
+++ b/packages/docgen/src/docgen/TypescriptHighlighter.ts
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Highlighter } from './types';
+
+// Simple syntax highlighter that mimics hilite
+export default class TypescriptHighlighter implements Highlighter {
+ private static readonly basicTypes = [
+ 'boolean',
+ 'number',
+ 'string',
+ 'Array',
+ 'object',
+ 'Record',
+ 'Set',
+ 'Map',
+ 'true',
+ 'false',
+ 'null',
+ 'undefined',
+ 'void',
+ 'Promise',
+ 'any',
+ '[0-9\\.]+',
+ ];
+
+ // List of highlightings to apply, each with a match regex and the style that should be applied
+ private static readonly highlighters = [
+ [/(\/\*\*?(?:.|\n)+?\*\/)/g, 'color: #60a0b0; font-style: italic'], // block comment
+ [/(\/\/.*)/gm, 'color: #60a0b0; font-style: italic'], // line comment
+ [/('[^']*?'|"[^"]*?")/g, 'color: #4070a0'], // string literals
+ [
+ new RegExp(
+ `\\b(${TypescriptHighlighter.basicTypes.join('|')})\\b(?!:|,)`,
+ 'g',
+ ),
+ 'color: #902000',
+ ], // basic types
+ [
+ /^((?:export\s)?(?:type\s)?(?:interface\s)?)/g,
+ 'color: #007020; font-weight: bold',
+ ], // keywords
+ ] as readonly [RegExp, string][];
+
+ highlight(fullText: string): string {
+ // Each part is either plain text that can be highlighted or text that is already highlighted
+ type HighlightPart = { text: string; highlighted?: boolean };
+
+ const painter = (regex: RegExp, style: string) => (part: HighlightPart) => {
+ if (part.highlighted) {
+ return [part];
+ }
+ // Apply highlighting to all matches of the regex by splitting into parts
+ return part.text.split(regex).map((text, index) => {
+ // Odd parts are the ones that matched the regex and should be highlighted.
+ if (index % 2 === 1) {
+ return {
+ text: `${text}`,
+ highlighted: true,
+ };
+ }
+ return { text };
+ });
+ };
+
+ // Order here is important, e.g. comments must be first to avoid string literals inside comments being highlighted
+ return TypescriptHighlighter.highlighters
+ .reduce((parts, highlighter) => parts.flatMap(painter(...highlighter)), [
+ { text: fullText },
+ ])
+ .map(({ text }) => text)
+ .join('');
+ }
+}
diff --git a/packages/docgen/src/docgen/sortSelector.test.ts b/packages/docgen/src/docgen/sortSelector.test.ts
new file mode 100644
index 0000000000..a3da3e2950
--- /dev/null
+++ b/packages/docgen/src/docgen/sortSelector.test.ts
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import sortSelector from './sortSelector';
+
+describe('sortSelector', () => {
+ it('should stable sort', () => {
+ const arr = [
+ [3, 1],
+ [1, 2],
+ [1, 1],
+ [1, 3],
+ [2, 1],
+ ];
+
+ const sortedByFirst = arr.slice().sort(sortSelector(([first]) => first));
+ expect(sortedByFirst).toEqual([
+ [1, 2],
+ [1, 1],
+ [1, 3],
+ [2, 1],
+ [3, 1],
+ ]);
+
+ const sortedBySecond = arr
+ .slice()
+ .sort(sortSelector(([, second]) => second));
+ expect(sortedBySecond).toEqual([
+ [3, 1],
+ [1, 1],
+ [2, 1],
+ [1, 2],
+ [1, 3],
+ ]);
+ });
+});
diff --git a/packages/docgen/src/docgen/sortSelector.ts b/packages/docgen/src/docgen/sortSelector.ts
new file mode 100644
index 0000000000..7590ae39ec
--- /dev/null
+++ b/packages/docgen/src/docgen/sortSelector.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * The sortSelector is a utility that makes a sort function by selecting the value to sort on with a lambda.
+ */
+export default function sortSelector(
+ selector: (x: T) => any,
+): (a: T, b: T) => -1 | 1 | 0 {
+ return (a: T, b: T) => {
+ const aV = selector(a);
+ const bV = selector(b);
+ if (aV < bV) {
+ return -1;
+ } else if (aV > bV) {
+ return 1;
+ }
+ return 0;
+ };
+}
diff --git a/packages/docgen/src/docgen/testUtils.ts b/packages/docgen/src/docgen/testUtils.ts
new file mode 100644
index 0000000000..552776016a
--- /dev/null
+++ b/packages/docgen/src/docgen/testUtils.ts
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import ts from 'typescript';
+
+export function createMemProgram(
+ indexSource: string,
+ otherSourceFiles: { [fileName in string]: string } = {},
+): ts.Program {
+ const rootDir = '/mem';
+
+ const options = { noEmit: true };
+ const baseHost = ts.createCompilerHost(options);
+ const files: { [fileName in string]: string } = {
+ [`${rootDir}/index.ts`]: indexSource,
+ ...otherSourceFiles,
+ };
+
+ // Custom compiler hosts that reads from a map of in-memory files, but
+ // falls back to reading from disc for ts libs etc.
+ const compilerHost: ts.CompilerHost = {
+ ...baseHost,
+ readFile(fileName): string | undefined {
+ if (fileName in files) {
+ return files[fileName];
+ }
+ return baseHost.readFile(fileName);
+ },
+ getCurrentDirectory(): string {
+ return rootDir;
+ },
+ directoryExists(dir) {
+ if (dir === rootDir) {
+ return true;
+ }
+ if (baseHost.directoryExists) {
+ return baseHost.directoryExists(dir);
+ }
+ return false;
+ },
+ fileExists(fileName: string): boolean {
+ return !!files[fileName] || baseHost.fileExists(fileName);
+ },
+ getSourceFile(fileName, ...rest): ts.SourceFile | undefined {
+ const file = files[fileName];
+ if (file) {
+ return ts.createSourceFile(fileName, file, ts.ScriptTarget.ES2017);
+ }
+ return baseHost.getSourceFile(fileName, ...rest);
+ },
+ };
+
+ return ts.createProgram(['/mem/index.ts'], options, compilerHost);
+}
diff --git a/packages/docgen/src/docgen/types.ts b/packages/docgen/src/docgen/types.ts
new file mode 100644
index 0000000000..7aff54d04c
--- /dev/null
+++ b/packages/docgen/src/docgen/types.ts
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import ts from 'typescript';
+
+/**
+ * A TypeLink is a link to a different type.
+ */
+export type TypeLink = {
+ // The ID of the linked type
+ id: number;
+ // The path to the type from the project root
+ path: string;
+ // The name of the type, to display
+ name: string;
+ // The location of the type name as it appears in it's parent text.
+ location: readonly [number, number];
+};
+
+/**
+ * TypeInfo describes a Typescript Type.
+ */
+export type TypeInfo = {
+ id: number;
+ name: string;
+ path: string;
+ file: string;
+ lineInFile: number;
+ text: string;
+ docs: string[];
+ links: TypeLink[];
+ children: TypeInfo[];
+};
+
+/**
+ * FieldInfo describes a property or method in a documented API.
+ */
+export type FieldInfo = {
+ type: 'prop' | 'method';
+ name: string;
+ path: string;
+ text: string;
+ docs: string[];
+ links: TypeLink[];
+};
+
+/**
+ * InterfaceInfo describes the type of a documented API.
+ */
+export type InterfaceInfo = {
+ name: string;
+ docs: string[];
+ file: string;
+ lineInFile: number;
+ members: Array;
+ dependentTypes: TypeInfo[];
+};
+
+/**
+ * ApiDoc describes a documented API.
+ */
+export type ApiDoc = {
+ id: string;
+ name: string;
+ description: string;
+ file: string;
+ lineInFile: number;
+ interfaceInfos: InterfaceInfo[];
+};
+
+/**
+ * ExportedInstance describes an expression matching `export {name} = new {Contructor}<{typeArgs}...>({args}...)`
+ */
+export type ExportedInstance = {
+ node: ts.Node;
+ name: string;
+ source: ts.SourceFile;
+ args: Array;
+ typeArgs: Array;
+};
+
+export type Highlighter = {
+ highlight(test: string): string;
+};
+
+/**
+ * Markdown printer is an abstraction for printing markdown documents of different flavors.
+ */
+export type MarkdownPrinter = {
+ text(text: string): void;
+ header(level: number, text: string, id?: string): void;
+ paragraph(...text: string[]): void;
+ code(options: { text: string; links?: TypeLink[] }): void;
+
+ headerLink(header: string, id?: string): string;
+ /** Link from pages to index */
+ indexLink(): string;
+ /** Link from index to pages */
+ pageLink(name: string): string;
+ srcLink(
+ { file, lineInFile }: { file: string; lineInFile: number },
+ text?: string,
+ ): string;
+
+ toBuffer(): Buffer;
+};
diff --git a/packages/docgen/src/generate.ts b/packages/docgen/src/generate.ts
new file mode 100644
index 0000000000..3587e2776b
--- /dev/null
+++ b/packages/docgen/src/generate.ts
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as ts from 'typescript';
+import fs from 'fs-extra';
+import { resolve as resolvePath, join as joinPath } from 'path';
+import ApiDocGenerator from './docgen/ApiDocGenerator';
+import sortSelector from './docgen/sortSelector';
+import TypeLocator from './docgen/TypeLocator';
+import ApiDocPrinter from './docgen/ApiDocsPrinter';
+import TypescriptHighlighter from './docgen/TypescriptHighlighter';
+import GitHubMarkdownPrinter from './docgen/GitHubMarkdownPrinter';
+import TechdocsMarkdownPrinter from './docgen/TechdocsMarkdownPrinter';
+
+const FORMATS = ['github', 'techdocs'] as const;
+
+export async function generate(
+ targetPath: string,
+ format: typeof FORMATS[number],
+) {
+ if (!FORMATS.includes(format)) {
+ throw new TypeError(
+ `Invalid format, '${format}', must be one of ${FORMATS.join(', ')}`,
+ );
+ }
+
+ const rootDir = resolvePath(__dirname, '../../..');
+ const srcDir = resolvePath(rootDir, 'packages', 'core-api', 'src');
+ const targetDir = resolvePath(targetPath);
+
+ const options = await fs.readJson(resolvePath('../cli/config/tsconfig.json'));
+
+ delete options.moduleResolution;
+ options.noEmit = true;
+
+ const program = ts.createProgram([resolvePath(srcDir, 'index.ts')], options);
+
+ const typeLocator = TypeLocator.fromProgram(program, srcDir);
+
+ const { apis } = typeLocator.findExportedInstances({
+ apis: typeLocator.getExportedType(
+ resolvePath(srcDir, 'index.ts'),
+ 'createApiRef',
+ ),
+ });
+
+ const apiDocGenerator = ApiDocGenerator.fromProgram(program, rootDir);
+ const apiDocs = apis
+ .map(api => {
+ try {
+ return apiDocGenerator.toDoc(api);
+ } catch (error) {
+ throw new Error(
+ `Doc generation failed for API in ${api.source.fileName}, ${error.stack}`,
+ );
+ }
+ })
+ .sort(sortSelector(x => x.name));
+
+ const apiTypes = Object.values(
+ Object.fromEntries(
+ apiDocs.flatMap(d => d.interfaceInfos).map(i => [i.name, i]),
+ ),
+ ).sort(sortSelector(i => i.name));
+
+ if (format === 'techdocs') {
+ const docsDir = resolvePath(targetDir, 'docs');
+ await fs.ensureDir(docsDir);
+
+ const apiDocPrinter = new ApiDocPrinter(
+ () => new TechdocsMarkdownPrinter(new TypescriptHighlighter()),
+ );
+
+ await fs.writeFile(
+ joinPath(docsDir, 'README.md'),
+ apiDocPrinter.printApiIndex(apiDocs),
+ );
+
+ for (const apiType of Object.values(apiTypes)) {
+ const data = apiDocPrinter.printInterface(apiType, apiDocs);
+
+ await fs.writeFile(joinPath(docsDir, `${apiType.name}.md`), data);
+ }
+
+ await fs.writeFile(
+ resolvePath(targetDir, 'mkdocs.yml'),
+ [
+ 'site_name: Backstage Core Utility API References',
+ 'nav:',
+ ` - API Index: 'README.md'`,
+ ...apiTypes.map(({ name }) => ` - ${name}: '${name}.md'`),
+ 'plugins:',
+ ' - techdocs-core',
+ ].join('\n'),
+ 'utf8',
+ );
+ } else {
+ await fs.ensureDir(targetDir);
+
+ const apiDocPrinter = new ApiDocPrinter(() => new GitHubMarkdownPrinter());
+
+ await fs.writeFile(
+ joinPath(targetDir, 'README.md'),
+ apiDocPrinter.printApiIndex(apiDocs),
+ );
+
+ for (const apiType of Object.values(apiTypes)) {
+ const data = apiDocPrinter.printInterface(apiType, apiDocs);
+
+ await fs.writeFile(joinPath(targetDir, `${apiType.name}.md`), data);
+ }
+ }
+}
diff --git a/packages/docgen/src/index.ts b/packages/docgen/src/index.ts
new file mode 100644
index 0000000000..866299151a
--- /dev/null
+++ b/packages/docgen/src/index.ts
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import program from 'commander';
+import { resolve as resolvePath } from 'path';
+import chalk from 'chalk';
+import fs from 'fs-extra';
+import { generate } from './generate';
+
+const main = (argv: string[]) => {
+ const pkgJson = fs.readJsonSync(resolvePath(__dirname, '../package.json'));
+ program.name('docgen').version(pkgJson.version);
+
+ program
+ .command('generate')
+ .description(
+ 'Generate documentation for the declarations in the core-api package',
+ )
+ .option('--output