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 ', 'Output directory [./dist]') + .option( + '--format ', + 'Output format, either techdocs or github [techdocs]', + ) + .action(async cmd => { + await generate(cmd.output ?? './dist', cmd.format ?? 'techdocs'); + }); + + program.on('command:*', () => { + console.log(); + console.log( + chalk.red(`Invalid command: ${chalk.cyan(program.args.join(' '))}`), + ); + console.log(chalk.red('See --help for a list of available commands.')); + console.log(); + process.exit(1); + }); + + if (!process.argv.slice(2).length) { + program.outputHelp(chalk.yellow); + } + + program.parse(argv); +}; + +process.on('unhandledRejection', rejection => { + console.error(String(rejection)); + process.exit(1); +}); + +main(process.argv); diff --git a/packages/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js index f4b7557b89..051d95de05 100644 --- a/packages/storybook/.storybook/apis.js +++ b/packages/storybook/.storybook/apis.js @@ -18,10 +18,14 @@ import { OAuthRequestManager, OktaAuth, oktaAuthApiRef, + configApiRef, + ConfigReader, } from '@backstage/core'; const builder = ApiRegistry.builder(); +builder.add(configApiRef, ConfigReader.fromConfigs([])); + const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder())); diff --git a/packages/test-utils-core/package.json b/packages/test-utils-core/package.json index 390aad87d6..1824a56916 100644 --- a/packages/test-utils-core/package.json +++ b/packages/test-utils-core/package.json @@ -36,7 +36,7 @@ "react-dom": "^16.12.0" }, "devDependencies": { - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, "files": [ diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index ed3ee31540..95845b6159 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -45,7 +45,7 @@ "zen-observable": "^0.8.15" }, "devDependencies": { - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0" }, "files": [ diff --git a/plugins/auth-backend/src/lib/EnvironmentHandler.ts b/plugins/auth-backend/src/lib/EnvironmentHandler.ts index 76c1f40b73..3eab4e793c 100644 --- a/plugins/auth-backend/src/lib/EnvironmentHandler.ts +++ b/plugins/auth-backend/src/lib/EnvironmentHandler.ts @@ -33,7 +33,7 @@ export class EnvironmentHandler implements AuthProviderRouteHandlers { ): AuthProviderRouteHandlers | undefined { const env = req.query.env?.toString(); - if (this.providers.hasOwnProperty(env)) { + if (env && this.providers.hasOwnProperty(env)) { return this.providers[env]; } diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 4c9caf214b..dcce98a986 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -24,6 +24,11 @@ import { createOAuth2Provider } from './oauth2'; import { createOktaProvider } from './okta'; import { createSamlProvider } from './saml'; import { AuthProviderConfig, AuthProviderFactory } from './types'; +import { Config } from '@backstage/config'; +import { + EnvironmentHandlers, + EnvironmentHandler, +} from '../lib/EnvironmentHandler'; const factories: { [providerId: string]: AuthProviderFactory } = { google: createGoogleProvider, @@ -37,7 +42,7 @@ const factories: { [providerId: string]: AuthProviderFactory } = { export const createAuthProviderRouter = ( providerId: string, globalConfig: AuthProviderConfig, - providerConfig: any, // TODO: make this a config reader object of sorts + providerConfig: Config, logger: Logger, issuer: TokenIssuer, ) => { @@ -46,17 +51,28 @@ export const createAuthProviderRouter = ( throw Error(`No auth provider available for '${providerId}'`); } - const provider = factory(globalConfig, providerConfig, logger, issuer); - const router = Router(); - router.get('/start', provider.start.bind(provider)); - router.get('/handler/frame', provider.frameHandler.bind(provider)); - router.post('/handler/frame', provider.frameHandler.bind(provider)); - if (provider.logout) { - router.post('/logout', provider.logout.bind(provider)); + const envs = providerConfig.keys(); + const envProviders: EnvironmentHandlers = {}; + + for (const env of envs) { + const envConfig = providerConfig.getConfig(env); + const provider = factory(globalConfig, env, envConfig, logger, issuer); + if (provider) { + envProviders[env] = provider; + } } - if (provider.refresh) { - router.get('/refresh', provider.refresh.bind(provider)); + + const handler = new EnvironmentHandler(providerId, envProviders); + + router.get('/start', handler.start.bind(handler)); + router.get('/handler/frame', handler.frameHandler.bind(handler)); + router.post('/handler/frame', handler.frameHandler.bind(handler)); + if (handler.logout) { + router.post('/logout', handler.logout.bind(handler)); + } + if (handler.refresh) { + router.get('/refresh', handler.refresh.bind(handler)); } return router; diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts index bd7d4aa712..37d661f9ab 100644 --- a/plugins/auth-backend/src/providers/github/provider.ts +++ b/plugins/auth-backend/src/providers/github/provider.ts @@ -25,20 +25,15 @@ import { OAuthProviderHandlers, AuthProviderConfig, RedirectInfo, - EnvironmentProviderConfig, OAuthProviderOptions, - OAuthProviderConfig, OAuthResponse, PassportDoneCallback, } from '../types'; import { OAuthProvider } from '../../lib/OAuthProvider'; -import { - EnvironmentHandlers, - EnvironmentHandler, -} from '../../lib/EnvironmentHandler'; import { Logger } from 'winston'; import { TokenIssuer } from '../../identity'; import passport from 'passport'; +import { Config } from '@backstage/config'; export class GithubAuthProvider implements OAuthProviderHandlers { private readonly _strategy: GithubStrategy; @@ -131,44 +126,43 @@ export class GithubAuthProvider implements OAuthProviderHandlers { export function createGithubProvider( { baseUrl }: AuthProviderConfig, - providerConfig: EnvironmentProviderConfig, + env: string, + envConfig: Config, logger: Logger, tokenIssuer: TokenIssuer, ) { const providerId = 'github'; - const envProviders: EnvironmentHandlers = {}; + const secure = envConfig.getBoolean('secure'); + const appOrigin = envConfig.getString('appOrigin'); + const clientID = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const callbackURL = `${baseUrl}/${providerId}/handler/frame?env=${env}`; - for (const [env, envConfig] of Object.entries(providerConfig)) { - const config = (envConfig as unknown) as OAuthProviderConfig; - const { secure, appOrigin } = config; - const opts = { - clientID: config.clientId, - clientSecret: config.clientSecret, - callbackURL: `${baseUrl}/${providerId}/handler/frame?env=${env}`, - }; + const opts = { + clientID, + clientSecret, + callbackURL, + }; - if (!opts.clientID || !opts.clientSecret) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'Failed to initialize Github auth provider, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars', - ); - } - - logger.warn( - 'Github auth provider disabled, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars to enable', + if (!opts.clientID || !opts.clientSecret) { + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'Failed to initialize Github auth provider, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars', ); - continue; } - envProviders[env] = new OAuthProvider(new GithubAuthProvider(opts), { - disableRefresh: true, - persistScopes: true, - providerId, - secure, - baseUrl, - appOrigin, - tokenIssuer, - }); + logger.warn( + 'Github auth provider disabled, set AUTH_GITHUB_CLIENT_ID and AUTH_GITHUB_CLIENT_SECRET env vars to enable', + ); + return undefined; } - return new EnvironmentHandler(providerId, envProviders); + return new OAuthProvider(new GithubAuthProvider(opts), { + disableRefresh: true, + persistScopes: true, + providerId, + secure, + baseUrl, + appOrigin, + tokenIssuer, + }); } diff --git a/plugins/auth-backend/src/providers/gitlab/provider.ts b/plugins/auth-backend/src/providers/gitlab/provider.ts index af1d57fcce..c5c546c6f3 100644 --- a/plugins/auth-backend/src/providers/gitlab/provider.ts +++ b/plugins/auth-backend/src/providers/gitlab/provider.ts @@ -25,20 +25,15 @@ import { OAuthProviderHandlers, AuthProviderConfig, RedirectInfo, - EnvironmentProviderConfig, OAuthProviderOptions, - OAuthProviderConfig, OAuthResponse, PassportDoneCallback, } from '../types'; import { OAuthProvider } from '../../lib/OAuthProvider'; -import { - EnvironmentHandlers, - EnvironmentHandler, -} from '../../lib/EnvironmentHandler'; import { Logger } from 'winston'; import { TokenIssuer } from '../../identity'; import passport from 'passport'; +import { Config } from '@backstage/config'; export class GitlabAuthProvider implements OAuthProviderHandlers { private readonly _strategy: GitlabStrategy; @@ -138,49 +133,45 @@ export class GitlabAuthProvider implements OAuthProviderHandlers { export function createGitlabProvider( { baseUrl }: AuthProviderConfig, - providerConfig: EnvironmentProviderConfig, + env: string, + envConfig: Config, logger: Logger, tokenIssuer: TokenIssuer, ) { const providerId = 'gitlab'; - const envProviders: EnvironmentHandlers = {}; + const secure = envConfig.getBoolean('secure'); + const appOrigin = envConfig.getString('appOrigin'); + const clientID = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const audience = envConfig.getString('audience'); + const baseURL = audience || 'https://gitlab.com'; + const callbackURL = `${baseUrl}/${providerId}/handler/frame?env=${env}`; - for (const [env, envConfig] of Object.entries(providerConfig)) { - const { - secure, - appOrigin, - clientId, - clientSecret, - audience, - } = (envConfig as unknown) as OAuthProviderConfig; - const opts = { - clientID: clientId, - clientSecret: clientSecret, - callbackURL: `${baseUrl}/${providerId}/handler/frame?env=${env}`, - baseURL: audience, - }; + const opts = { + clientID, + clientSecret, + callbackURL, + baseURL, + }; - if (!opts.clientID || !opts.clientSecret) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'Failed to initialize Gitlab auth provider, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars', - ); - } - - logger.warn( - 'Gitlab auth provider disabled, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars to enable', + if (!opts.clientID || !opts.clientSecret) { + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'Failed to initialize Gitlab auth provider, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars', ); - continue; } - envProviders[env] = new OAuthProvider(new GitlabAuthProvider(opts), { - disableRefresh: true, - providerId, - secure, - baseUrl, - appOrigin, - tokenIssuer, - }); + logger.warn( + 'Gitlab auth provider disabled, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars to enable', + ); + return undefined; } - return new EnvironmentHandler(providerId, envProviders); + return new OAuthProvider(new GitlabAuthProvider(opts), { + disableRefresh: true, + providerId, + secure, + baseUrl, + appOrigin, + tokenIssuer, + }); } diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index 3023c09886..5749d1465f 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -27,20 +27,15 @@ import { OAuthProviderHandlers, RedirectInfo, AuthProviderConfig, - EnvironmentProviderConfig, OAuthProviderOptions, - OAuthProviderConfig, OAuthResponse, PassportDoneCallback, } from '../types'; import { OAuthProvider } from '../../lib/OAuthProvider'; import passport from 'passport'; -import { - EnvironmentHandler, - EnvironmentHandlers, -} from '../../lib/EnvironmentHandler'; import { Logger } from 'winston'; import { TokenIssuer } from '../../identity'; +import { Config } from '@backstage/config'; type PrivateInfo = { refreshToken: string; @@ -150,43 +145,42 @@ export class GoogleAuthProvider implements OAuthProviderHandlers { export function createGoogleProvider( { baseUrl }: AuthProviderConfig, - providerConfig: EnvironmentProviderConfig, + env: string, + envConfig: Config, logger: Logger, tokenIssuer: TokenIssuer, ) { const providerId = 'google'; - const envProviders: EnvironmentHandlers = {}; + const secure = envConfig.getBoolean('secure'); + const appOrigin = envConfig.getString('appOrigin'); + const clientID = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const callbackURL = `${baseUrl}/${providerId}/handler/frame?env=${env}`; - for (const [env, envConfig] of Object.entries(providerConfig)) { - const config = (envConfig as unknown) as OAuthProviderConfig; - const { secure, appOrigin } = config; - const opts = { - clientID: config.clientId, - clientSecret: config.clientSecret, - callbackURL: `${baseUrl}/${providerId}/handler/frame?env=${env}`, - }; + const opts = { + clientID, + clientSecret, + callbackURL, + }; - if (!opts.clientID || !opts.clientSecret) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'Failed to initialize Google auth provider, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars', - ); - } - - logger.warn( - 'Google auth provider disabled, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars to enable', + if (!opts.clientID || !opts.clientSecret) { + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'Failed to initialize Google auth provider, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars', ); - continue; } - envProviders[env] = new OAuthProvider(new GoogleAuthProvider(opts), { - disableRefresh: false, - providerId, - secure, - baseUrl, - appOrigin, - tokenIssuer, - }); + logger.warn( + 'Google auth provider disabled, set AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET env vars to enable', + ); + return undefined; } - return new EnvironmentHandler(providerId, envProviders); + return new OAuthProvider(new GoogleAuthProvider(opts), { + disableRefresh: false, + providerId, + secure, + baseUrl, + appOrigin, + tokenIssuer, + }); } diff --git a/plugins/auth-backend/src/providers/oauth2/provider.ts b/plugins/auth-backend/src/providers/oauth2/provider.ts index 60b6bc605b..967fd011f0 100644 --- a/plugins/auth-backend/src/providers/oauth2/provider.ts +++ b/plugins/auth-backend/src/providers/oauth2/provider.ts @@ -19,10 +19,6 @@ import passport from 'passport'; import { Strategy as OAuth2Strategy } from 'passport-oauth2'; import { Logger } from 'winston'; import { TokenIssuer } from '../../identity'; -import { - EnvironmentHandler, - EnvironmentHandlers, -} from '../../lib/EnvironmentHandler'; import { OAuthProvider } from '../../lib/OAuthProvider'; import { executeFetchUserProfileStrategy, @@ -33,14 +29,13 @@ import { } from '../../lib/PassportStrategyHelper'; import { AuthProviderConfig, - EnvironmentProviderConfig, - GenericOAuth2ProviderConfig, GenericOAuth2ProviderOptions, OAuthProviderHandlers, OAuthResponse, PassportDoneCallback, RedirectInfo, } from '../types'; +import { Config } from '@backstage/config'; type PrivateInfo = { refreshToken: string; @@ -148,51 +143,51 @@ export class OAuth2AuthProvider implements OAuthProviderHandlers { export function createOAuth2Provider( { baseUrl }: AuthProviderConfig, - providerConfig: EnvironmentProviderConfig, + env: string, + envConfig: Config, logger: Logger, tokenIssuer: TokenIssuer, ) { const providerId = 'oauth2'; - const envProviders: EnvironmentHandlers = {}; + const secure = envConfig.getBoolean('secure'); + const appOrigin = envConfig.getString('appOrigin'); + const clientID = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const callbackURL = `${baseUrl}/${providerId}/handler/frame?env=${env}`; + const authorizationURL = envConfig.getString('authorizationURL'); + const tokenURL = envConfig.getString('tokenURL'); - for (const [env, envConfig] of Object.entries(providerConfig)) { - const config = (envConfig as unknown) as GenericOAuth2ProviderConfig; - const { secure, appOrigin } = config; - const opts = { - clientID: config.clientId, - clientSecret: config.clientSecret, - callbackURL: `${baseUrl}/${providerId}/handler/frame?env=${env}`, - authorizationURL: config.authorizationURL, - tokenURL: config.tokenURL, - }; + const opts = { + clientID, + clientSecret, + callbackURL, + authorizationURL, + tokenURL, + }; - if ( - !opts.clientID || - !opts.clientSecret || - !opts.authorizationURL || - !opts.tokenURL - ) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'Failed to initialize OAuth2 auth provider, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars', - ); - } - - logger.warn( - 'OAuth2 auth provider disabled, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars to enable', + if ( + !opts.clientID || + !opts.clientSecret || + !opts.authorizationURL || + !opts.tokenURL + ) { + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'Failed to initialize OAuth2 auth provider, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars', ); - continue; } - envProviders[env] = new OAuthProvider(new OAuth2AuthProvider(opts), { - disableRefresh: false, - providerId, - secure, - baseUrl, - appOrigin, - tokenIssuer, - }); + logger.warn( + 'OAuth2 auth provider disabled, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars to enable', + ); + return undefined; } - - return new EnvironmentHandler(providerId, envProviders); + return new OAuthProvider(new OAuth2AuthProvider(opts), { + disableRefresh: false, + providerId, + secure, + baseUrl, + appOrigin, + tokenIssuer, + }); } diff --git a/plugins/auth-backend/src/providers/okta/provider.ts b/plugins/auth-backend/src/providers/okta/provider.ts index 01d7684e97..56e8b1220d 100644 --- a/plugins/auth-backend/src/providers/okta/provider.ts +++ b/plugins/auth-backend/src/providers/okta/provider.ts @@ -28,19 +28,14 @@ import { OAuthProviderHandlers, RedirectInfo, AuthProviderConfig, - EnvironmentProviderConfig, OAuthProviderOptions, - OAuthProviderConfig, OAuthResponse, PassportDoneCallback, } from '../types'; -import { - EnvironmentHandler, - EnvironmentHandlers, -} from '../../lib/EnvironmentHandler'; import { Logger } from 'winston'; import { StateStore } from 'passport-oauth2'; import { TokenIssuer } from '../../identity'; +import { Config } from '@backstage/config'; type PrivateInfo = { refreshToken: string; @@ -170,45 +165,44 @@ export class OktaAuthProvider implements OAuthProviderHandlers { export function createOktaProvider( { baseUrl }: AuthProviderConfig, - providerConfig: EnvironmentProviderConfig, + env: string, + envConfig: Config, logger: Logger, tokenIssuer: TokenIssuer, ) { const providerId = 'okta'; - const envProviders: EnvironmentHandlers = {}; + const secure = envConfig.getBoolean('secure'); + const appOrigin = envConfig.getString('appOrigin'); + const clientID = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const audience = envConfig.getString('audience'); + const callbackURL = `${baseUrl}/${providerId}/handler/frame?env=${env}`; - for (const [env, envConfig] of Object.entries(providerConfig)) { - const config = (envConfig as unknown) as OAuthProviderConfig; - const { secure, appOrigin } = config; - const opts = { - audience: config.audience, - clientID: config.clientId, - clientSecret: config.clientSecret, - callbackURL: `${baseUrl}/${providerId}/handler/frame?env=${env}`, - }; + const opts = { + audience, + clientID, + clientSecret, + callbackURL, + }; - if (!opts.clientID || !opts.clientSecret || !opts.audience) { - if (process.env.NODE_ENV !== 'development') { - throw new Error( - 'Failed to initialize Okta auth provider, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars', - ); - } - - logger.warn( - 'Okta auth provider disabled, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars to enable', + if (!opts.clientID || !opts.clientSecret || !opts.audience) { + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'Failed to initialize Okta auth provider, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars', ); - continue; } - envProviders[env] = new OAuthProvider(new OktaAuthProvider(opts), { - disableRefresh: false, - providerId, - secure, - baseUrl, - appOrigin, - tokenIssuer, - }); + logger.warn( + 'Okta auth provider disabled, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars to enable', + ); + return undefined; } - - return new EnvironmentHandler(providerId, envProviders); + return new OAuthProvider(new OktaAuthProvider(opts), { + disableRefresh: false, + providerId, + secure, + baseUrl, + appOrigin, + tokenIssuer, + }); } diff --git a/plugins/auth-backend/src/providers/saml/provider.ts b/plugins/auth-backend/src/providers/saml/provider.ts index 40c80de4e4..3ddfba4bb0 100644 --- a/plugins/auth-backend/src/providers/saml/provider.ts +++ b/plugins/auth-backend/src/providers/saml/provider.ts @@ -27,18 +27,13 @@ import { import { AuthProviderConfig, AuthProviderRouteHandlers, - EnvironmentProviderConfig, - SAMLProviderConfig, PassportDoneCallback, ProfileInfo, } from '../types'; import { postMessageResponse } from '../../lib/OAuthProvider'; -import { - EnvironmentHandlers, - EnvironmentHandler, -} from '../../lib/EnvironmentHandler'; import { Logger } from 'winston'; import { TokenIssuer } from '../../identity'; +import { Config } from '@backstage/config'; type SamlInfo = { userId: string; @@ -122,30 +117,25 @@ type SAMLProviderOptions = { export function createSamlProvider( _authProviderConfig: AuthProviderConfig, - providerConfig: EnvironmentProviderConfig, + _env: string, + envConfig: Config, logger: Logger, tokenIssuer: TokenIssuer, ) { - const envProviders: EnvironmentHandlers = {}; + const entryPoint = envConfig.getString('entryPoint'); + const issuer = envConfig.getString('issuer'); + const opts = { + entryPoint, + issuer, + path: '/auth/saml/handler/frame', + tokenIssuer, + }; - for (const [env, envConfig] of Object.entries(providerConfig)) { - const config = (envConfig as unknown) as SAMLProviderConfig; - const opts = { - entryPoint: config.entryPoint, - issuer: config.issuer, - path: '/auth/saml/handler/frame', - tokenIssuer, - }; - - if (!opts.entryPoint || !opts.issuer) { - logger.warn( - 'SAML auth provider disabled, set entryPoint and entryPoint in saml auth config to enable', - ); - continue; - } - - envProviders[env] = new SamlAuthProvider(opts); + if (!opts.entryPoint || !opts.issuer) { + logger.warn( + 'SAML auth provider disabled, set entryPoint and entryPoint in saml auth config to enable', + ); + return undefined; } - - return new EnvironmentHandler('saml', envProviders); + return new SamlAuthProvider(opts); } diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index de4e076919..93d459bb0d 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -17,6 +17,9 @@ import express from 'express'; import { Logger } from 'winston'; import { TokenIssuer } from '../identity'; +import { Config } from '@backstage/config'; +import { OAuthProvider } from '../lib/OAuthProvider'; +import { SamlAuthProvider } from './saml/provider'; export type OAuthProviderOptions = { /** @@ -204,10 +207,11 @@ export interface AuthProviderRouteHandlers { export type AuthProviderFactory = ( globalConfig: AuthProviderConfig, - providerConfig: EnvironmentProviderConfig, + env: string, + envConfig: Config, logger: Logger, issuer: TokenIssuer, -) => AuthProviderRouteHandlers; +) => OAuthProvider | SamlAuthProvider | undefined; export type AuthResponse = { providerInfo: ProviderInfo; diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 6b296cba2d..69f058f6db 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -36,8 +36,6 @@ export async function createRouter( const router = Router(); const logger = options.logger.child({ plugin: 'auth' }); - const appUrl = new URL(options.config.getString('app.baseUrl')); - const appOrigin = appUrl.origin; const backendUrl = options.config.getString('backend.baseUrl'); const authUrl = `${backendUrl}/auth`; @@ -57,71 +55,13 @@ export async function createRouter( router.use(bodyParser.urlencoded({ extended: false })); router.use(bodyParser.json()); - const config = { - backend: { - baseUrl: backendUrl, - }, - auth: { - providers: { - google: { - development: { - appOrigin, - secure: false, - clientId: process.env.AUTH_GOOGLE_CLIENT_ID!, - clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET!, - }, - }, - github: { - development: { - appOrigin, - secure: false, - clientId: process.env.AUTH_GITHUB_CLIENT_ID!, - clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!, - }, - }, - gitlab: { - development: { - appOrigin, - secure: false, - clientId: process.env.AUTH_GITLAB_CLIENT_ID!, - clientSecret: process.env.AUTH_GITLAB_CLIENT_SECRET!, - audience: process.env.GITLAB_BASE_URL! || 'https://gitlab.com', - }, - }, - saml: { - development: { - entryPoint: 'http://localhost:7001/', - issuer: 'passport-saml', - }, - }, - okta: { - development: { - appOrigin, - secure: false, - clientId: process.env.AUTH_OKTA_CLIENT_ID!, - clientSecret: process.env.AUTH_OKTA_CLIENT_SECRET!, - audience: process.env.AUTH_OKTA_AUDIENCE, - }, - }, - oauth2: { - development: { - appOrigin, - secure: false, - clientId: process.env.AUTH_OAUTH2_CLIENT_ID!, - clientSecret: process.env.AUTH_OAUTH2_CLIENT_SECRET!, - authorizationURL: process.env.AUTH_OAUTH2_AUTH_URL!, - tokenURL: process.env.AUTH_OAUTH2_TOKEN_URL!, - }, - }, - }, - }, - }; + const providersConfig = options.config.getConfig('auth.providers'); + const providers = providersConfig.keys(); - const providerConfigs = config.auth.providers; - - for (const [providerId, providerConfig] of Object.entries(providerConfigs)) { + for (const providerId of providers) { logger.info(`Configuring provider, ${providerId}`); try { + const providerConfig = providersConfig.getConfig(providerId); const providerRouter = createAuthProviderRouter( providerId, { baseUrl: authUrl }, diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index b84267c854..7b62e5579e 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -46,6 +46,7 @@ "@types/uuid": "^8.0.0", "@types/yup": "^0.28.2", "jest-fetch-mock": "^3.0.3", + "msw": "^0.19.5", "supertest": "^4.0.2" }, "files": [ diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts index 8ec575d6ec..e5ef410456 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -27,7 +27,9 @@ import { EntityPolicyProcessor } from './processors/EntityPolicyProcessor'; import { FileReaderProcessor } from './processors/FileReaderProcessor'; import { GithubReaderProcessor } from './processors/GithubReaderProcessor'; import { GithubApiReaderProcessor } from './processors/GithubApiReaderProcessor'; +import { GitlabApiReaderProcessor } from './processors/GitlabApiReaderProcessor'; import { GitlabReaderProcessor } from './processors/GitlabReaderProcessor'; +import { UrlReaderProcessor } from './processors/UrlReaderProcessor'; import { LocationRefProcessor } from './processors/LocationEntityProcessor'; import * as result from './processors/results'; import { @@ -59,7 +61,9 @@ export class LocationReaders implements LocationReader { new FileReaderProcessor(), new GithubReaderProcessor(), new GithubApiReaderProcessor(), + new GitlabApiReaderProcessor(), new GitlabReaderProcessor(), + new UrlReaderProcessor(), new YamlProcessor(), new EntityPolicyProcessor(entityPolicy), new LocationRefProcessor(), diff --git a/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.test.ts new file mode 100644 index 0000000000..a429a0e6fb --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.test.ts @@ -0,0 +1,94 @@ +/* + * 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 { GitlabApiReaderProcessor } from './GitlabApiReaderProcessor'; + +describe('GitlabApiReaderProcessor', () => { + it('should build raw api', () => { + const processor = new GitlabApiReaderProcessor(); + + const tests = [ + { + target: + 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + url: new URL( + 'https://gitlab.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml?ref=branch', + ), + err: undefined, + }, + { + target: + 'https://gitlab.example.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/my/path/to/file.yaml', + url: new URL( + 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml?ref=branch', + ), + err: undefined, + }, + { + target: + 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/to/file.yaml', // Repo not in subgroup + url: new URL( + 'https://gitlab.example.com/api/v4/projects/12345/repository/files/my%2Fpath%2Fto%2Ffile.yaml?ref=branch', + ), + err: undefined, + }, + { + target: + 'https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/', + url: null, + err: + 'Incorrect url: https://gitlab.com/groupA/teams/teamA/repoA/-/blob/branch/my/path/, Error: Gitlab url does not end in .ya?ml', + }, + ]; + + for (const test of tests) { + if (test.err) { + expect(() => processor.buildRawUrl(test.target, 12345)).toThrowError( + test.err, + ); + } else { + expect(processor.buildRawUrl(test.target, 12345)).toEqual(test.url); + } + } + }); + + it('should return request options', () => { + const tests = [ + { + token: '0123456789', + expect: { + headers: { + 'PRIVATE-TOKEN': '0123456789', + }, + }, + }, + { + token: '', + expect: { + headers: { + 'PRIVATE-TOKEN': '', + }, + }, + }, + ]; + + for (const test of tests) { + process.env.GITLAB_PRIVATE_TOKEN = test.token; + const processor = new GitlabApiReaderProcessor(); + expect(processor.getRequestOptions()).toEqual(test.expect); + } + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts new file mode 100644 index 0000000000..6964640eb4 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/GitlabApiReaderProcessor.ts @@ -0,0 +1,133 @@ +/* + * 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 { LocationSpec } from '@backstage/catalog-model'; +import fetch, { RequestInit, HeadersInit } from 'node-fetch'; +import * as result from './results'; +import { LocationProcessor, LocationProcessorEmit } from './types'; + +export class GitlabApiReaderProcessor implements LocationProcessor { + private privateToken: string = process.env.GITLAB_PRIVATE_TOKEN || ''; + + getRequestOptions(): RequestInit { + const headers: HeadersInit = { 'PRIVATE-TOKEN': '' }; + if (this.privateToken !== '') { + headers['PRIVATE-TOKEN'] = this.privateToken; + } + + const requestOptions: RequestInit = { + headers, + }; + + return requestOptions; + } + + async readLocation( + location: LocationSpec, + optional: boolean, + emit: LocationProcessorEmit, + ): Promise { + if (location.type !== 'gitlab/api') { + return false; + } + + try { + const projectID = await this.getProjectID(location.target); + const url = this.buildRawUrl(location.target, projectID); + const response = await fetch(url.toString(), this.getRequestOptions()); + if (response.ok) { + const data = await response.buffer(); + emit(result.data(location, data)); + } else { + const message = `${location.target} could not be read as ${url}, ${response.status} ${response.statusText}`; + if (response.status === 404) { + if (!optional) { + emit(result.notFoundError(location, message)); + } + } else { + emit(result.generalError(location, message)); + } + } + } catch (e) { + const message = `Unable to read ${location.type} ${location.target}, ${e}`; + emit(result.generalError(location, message)); + } + return true; + } + + // convert https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath + // to https://gitlab.com/api/v4/projects//repository/files/filepath?ref=branch + buildRawUrl(target: string, projectID: Number): URL { + try { + const url = new URL(target); + + const branchAndfilePath = url.pathname.split('/-/blob/')[1]; + + if (!branchAndfilePath.match(/\.ya?ml$/)) { + throw new Error('Gitlab url does not end in .ya?ml'); + } + + const [branch, ...filePath] = branchAndfilePath.split('/'); + + url.pathname = [ + '/api/v4/projects', + projectID, + 'repository/files', + encodeURIComponent(filePath.join('/')), + 'raw', + ].join('/'); + url.search = `?ref=${branch}`; + + return url; + } catch (e) { + throw new Error(`Incorrect url: ${target}, ${e}`); + } + } + + async getProjectID(target: string): Promise { + const url = new URL(target); + + if ( + // absPaths to gitlab files should contain /-/blob + // ex: https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath + !url.pathname.match(/\/\-\/blob\//) + ) { + throw new Error('Please provide full path to yaml file from Gitlab'); + } + try { + const repo = url.pathname.split('/-/blob/')[0]; + + // Find ProjectID from url + // convert 'https://gitlab.com/groupA/teams/teamA/subgroupA/repoA/-/blob/branch/filepath' + // to 'https://gitlab.com/api/v4/projects/groupA%2Fteams%2FsubgroupA%2FteamA%2Frepo' + const repoIDLookup = new URL( + `${url.protocol + url.hostname}/api/v4/projects/${encodeURIComponent( + repo.replace(/^\//, ''), + )}`, + ); + const response = await fetch( + repoIDLookup.toString(), + this.getRequestOptions(), + ); + const projectIDJson = await response.json(); + const projectID: Number = projectIDJson.id; + + return projectID; + } catch (e) { + throw new Error(`Could not get Gitlab ProjectID for: ${target}, ${e}`); + } + } +} diff --git a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts new file mode 100644 index 0000000000..e30b320bf4 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.test.ts @@ -0,0 +1,80 @@ +/* + * 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 { UrlReaderProcessor } from './UrlReaderProcessor'; +import { + LocationProcessorDataResult, + LocationProcessorResult, + LocationProcessorErrorResult, +} from './types'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; + +describe('UrlReaderProcessor', () => { + const mockApiOrigin = 'http://localhost:23000'; + const server = setupServer(); + + beforeAll(() => server.listen()); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + + it('should load from url', async () => { + const processor = new UrlReaderProcessor(); + const spec = { + type: 'url', + target: `${mockApiOrigin}/component.yaml`, + }; + + server.use( + rest.get(`${mockApiOrigin}/component.yaml`, (_, res, ctx) => + res(ctx.body('Hello')), + ), + ); + + const generated = (await new Promise(emit => + processor.readLocation(spec, false, emit), + )) as LocationProcessorDataResult; + + expect(generated.type).toBe('data'); + expect(generated.location).toBe(spec); + expect(generated.data.toString('utf8')).toBe('Hello'); + }); + + it('should fail load from url with error', async () => { + const processor = new UrlReaderProcessor(); + const spec = { + type: 'url', + target: `${mockApiOrigin}/component-notfound.yaml`, + }; + + server.use( + rest.get(`${mockApiOrigin}/component-notfound.yaml`, (_, res, ctx) => { + return res(ctx.status(404)); + }), + ); + + const generated = (await new Promise(emit => + processor.readLocation(spec, false, emit), + )) as LocationProcessorErrorResult; + + expect(generated.type).toBe('error'); + expect(generated.location).toBe(spec); + expect(generated.error.name).toBe('NotFoundError'); + expect(generated.error.message).toBe( + `${mockApiOrigin}/component-notfound.yaml could not be read, 404 Not Found`, + ); + }); +}); diff --git a/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.ts new file mode 100644 index 0000000000..0c879ea70c --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/processors/UrlReaderProcessor.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 { LocationSpec } from '@backstage/catalog-model'; +import fetch from 'node-fetch'; +import * as result from './results'; +import { LocationProcessor, LocationProcessorEmit } from './types'; + +export class UrlReaderProcessor implements LocationProcessor { + async readLocation( + location: LocationSpec, + optional: boolean, + emit: LocationProcessorEmit, + ): Promise { + if (location.type !== 'url') { + return false; + } + + try { + const response = await fetch(location.target); + + if (response.ok) { + const data = await response.buffer(); + emit(result.data(location, data)); + } else { + const message = `${location.target} could not be read, ${response.status} ${response.statusText}`; + if (response.status === 404) { + if (!optional) { + emit(result.notFoundError(location, message)); + } + } else { + emit(result.generalError(location, message)); + } + } + } catch (e) { + const message = `Unable to read ${location.type} ${location.target}, ${e}`; + emit(result.generalError(location, message)); + } + + return true; + } +} diff --git a/plugins/catalog-backend/src/setupTests.ts b/plugins/catalog-backend/src/setupTests.ts index f7b6ca962d..ba33cf996b 100644 --- a/plugins/catalog-backend/src/setupTests.ts +++ b/plugins/catalog-backend/src/setupTests.ts @@ -14,6 +14,4 @@ * limitations under the License. */ -require('jest-fetch-mock').enableMocks(); - export {}; diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index cc96d18d92..f306c03ff8 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -45,7 +45,7 @@ "@testing-library/react": "^10.4.1", "@testing-library/react-hooks": "^3.3.0", "@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", "msw": "^0.19.0", diff --git a/plugins/circleci/package.json b/plugins/circleci/package.json index b5dfcb3211..3cf357bbce 100644 --- a/plugins/circleci/package.json +++ b/plugins/circleci/package.json @@ -41,7 +41,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-lazylog": "^4.5.0", "jest-fetch-mock": "^3.0.3" diff --git a/plugins/explore/package.json b/plugins/explore/package.json index 4dc8542a12..6840cf5e52 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -38,7 +38,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/plugins/github-actions/README.md b/plugins/github-actions/README.md index 7d09a67066..24bc96937f 100644 --- a/plugins/github-actions/README.md +++ b/plugins/github-actions/README.md @@ -27,6 +27,7 @@ TBD description: backstage.io annotations: github.com/project-slug: 'spotify/backstage' + spec: type: website lifecycle: production diff --git a/plugins/github-actions/package.json b/plugins/github-actions/package.json index e20b719c31..55f1aab4de 100644 --- a/plugins/github-actions/package.json +++ b/plugins/github-actions/package.json @@ -44,7 +44,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/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index 80063c6295..8157cfe519 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -37,7 +37,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/plugins/graphiql/package.json b/plugins/graphiql/package.json index 2d5f727f4e..193fe3d133 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -50,7 +50,7 @@ "@testing-library/react": "^10.4.1", "@testing-library/user-event": "^12.0.7", "@types/codemirror": "^0.0.96", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "jest-fetch-mock": "^3.0.3", "react-router-dom": "6.0.0-beta.0" diff --git a/plugins/graphql/README.md b/plugins/graphql/README.md index 4a251363ce..3f85ec766e 100644 --- a/plugins/graphql/README.md +++ b/plugins/graphql/README.md @@ -6,8 +6,7 @@ _This plugin was created through the Backstage CLI_ ## Getting started -Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn -start` in the root directory, and then navigating to [/graphql](http://localhost:3000/graphql). +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/graphql](http://localhost:3000/graphql). You can also serve the plugin in isolation by running `yarn start` in the plugin directory. This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index f9fd64ee69..eea841c174 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/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/node": "^12.0.0", "jest-fetch-mock": "^3.0.3" }, diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index 1dcbb86a9c..e1a095f1e4 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -41,7 +41,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/plugins/rollbar/package.json b/plugins/rollbar/package.json index 2055cc9525..d2308b869e 100644 --- a/plugins/rollbar/package.json +++ b/plugins/rollbar/package.json @@ -42,7 +42,7 @@ "@testing-library/react": "^10.4.1", "@testing-library/react-hooks": "^3.3.0", "@testing-library/user-event": "^12.0.7", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/react": "^16.9", "jest-fetch-mock": "^3.0.3" diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index a52acfed43..e28387c30e 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -47,7 +47,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/plugins/sentry/package.json b/plugins/sentry/package.json index 1557ca409a..3a5cbd5094 100644 --- a/plugins/sentry/package.json +++ b/plugins/sentry/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/node": "^12.0.0", "jest-fetch-mock": "^3.0.3" }, diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index 1bcc13cd55..0018a6faeb 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -42,7 +42,7 @@ "@testing-library/user-event": "^12.0.7", "@types/color": "^3.0.1", "@types/d3-force": "^1.2.1", - "@types/jest": "^25.2.2", + "@types/jest": "^26.0.7", "@types/node": "^12.0.0", "@types/react": "^16.9", "jest-fetch-mock": "^3.0.3" diff --git a/plugins/techdocs-backend/examples/documented-component/docs/index.md b/plugins/techdocs-backend/examples/documented-component/docs/index.md new file mode 100644 index 0000000000..bf0895710f --- /dev/null +++ b/plugins/techdocs-backend/examples/documented-component/docs/index.md @@ -0,0 +1,3 @@ +# example docs + +This is a basic example of documentation. diff --git a/plugins/techdocs-backend/examples/documented-component/documented-component.yaml b/plugins/techdocs-backend/examples/documented-component/documented-component.yaml new file mode 100644 index 0000000000..d5a1214b15 --- /dev/null +++ b/plugins/techdocs-backend/examples/documented-component/documented-component.yaml @@ -0,0 +1,11 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: documented-component + description: A Service with TechDocs documentation + annotations: + backstage.io/techdocs-ref: 'dir:./' +spec: + type: service + lifecycle: experimental + owner: documented@example.com diff --git a/plugins/techdocs-backend/examples/documented-component/mkdocs.yml b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml new file mode 100644 index 0000000000..6a3f85d020 --- /dev/null +++ b/plugins/techdocs-backend/examples/documented-component/mkdocs.yml @@ -0,0 +1,7 @@ +site_name: 'example-docs' + +nav: + - Home: index.md + +plugins: + - techdocs-core diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index 528f130e4b..edc8908cfc 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -17,7 +17,8 @@ "test": "backstage-cli test", "prepack": "backstage-cli prepack", "postpack": "backstage-cli postpack", - "clean": "backstage-cli clean" + "clean": "backstage-cli clean", + "mock-data": "./scripts/mock-data.sh" }, "dependencies": { "@backstage/backend-common": "^0.1.1-alpha.16", diff --git a/plugins/techdocs-backend/scripts/mock-data.sh b/plugins/techdocs-backend/scripts/mock-data.sh new file mode 100755 index 0000000000..0630490694 --- /dev/null +++ b/plugins/techdocs-backend/scripts/mock-data.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +for URL in \ + 'documented-component/documented-component.yaml' \ +; do \ + curl \ + --location \ + --request POST 'localhost:7000/catalog/locations' \ + --header 'Content-Type: application/json' \ + --data-raw "{\"type\": \"file\", \"target\": \"$(pwd)/examples/${URL}\"}" + echo +done diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index dfd8393b5f..210d297c7d 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -41,7 +41,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/plugins/welcome/package.json b/plugins/welcome/package.json index 71a19d59bc..66fec93779 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -37,7 +37,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/yarn.lock b/yarn.lock index 1dd2c9cae4..4541fdaa69 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3748,17 +3748,18 @@ integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g== "@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.5": - version "4.17.5" - resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.5.tgz#a00ac7dadd746ae82477443e4d480a6a93ea083c" - integrity sha512-578YH5Lt88AKoADy0b2jQGwJtrBxezXtVe/MBqWXKZpqx91SnC0pVkVCcxcytz3lWW+cHBYDi3Ysh0WXc+rAYw== + version "4.17.9" + resolved "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.9.tgz#2d7b34dcfd25ec663c25c85d76608f8b249667f1" + integrity sha512-DG0BYg6yO+ePW+XoDENYz8zhNGC3jDDEpComMYn7WJc4mY1Us8Rw9ax2YhJXxpyk2SF47PQAoQ0YyVT1a0bEkA== dependencies: "@types/node" "*" + "@types/qs" "*" "@types/range-parser" "*" "@types/express@*", "@types/express@^4.17.6": - version "4.17.6" - resolved "https://registry.npmjs.org/@types/express/-/express-4.17.6.tgz#6bce49e49570507b86ea1b07b806f04697fac45e" - integrity sha512-n/mr9tZI83kd4azlPG5y997C/M4DNABK9yErhFM6hKdym4kkmd9j0vtsJyjFIwfRBxtrxZtAfGZCNRIBMFLK5w== + version "4.17.7" + resolved "https://registry.npmjs.org/@types/express/-/express-4.17.7.tgz#42045be6475636d9801369cd4418ef65cdb0dd59" + integrity sha512-dCOT5lcmV/uC2J9k0rPafATeeyz+99xTt54ReX11/LObZgfzJqZNcW27zGhYyX+9iSEGXGt5qLPwRSvBZcLvtQ== dependencies: "@types/body-parser" "*" "@types/express-serve-static-core" "*" @@ -3794,6 +3795,11 @@ resolved "https://registry.npmjs.org/@types/git-url-parse/-/git-url-parse-9.0.0.tgz#aac1315a44fa4ed5a52c3820f6c3c2fb79cbd12d" integrity sha512-kA2RxBT/r/ZuDDKwMl+vFWn1Z0lfm1/Ik6Qb91wnSzyzCDa/fkM8gIOq6ruB7xfr37n6Mj5dyivileUVKsidlg== +"@types/github-slugger@^1.3.0": + version "1.3.0" + resolved "https://registry.npmjs.org/@types/github-slugger/-/github-slugger-1.3.0.tgz#16ab393b30d8ae2a111ac748a015ac05a1fc5524" + integrity sha512-J/rMZa7RqiH/rT29TEVZO4nBoDP9XJOjnbbIofg7GQKs4JIduEO3WLpte+6WeUz/TcrXKlY+bM7FYrp8yFB+3g== + "@types/glob@^7.1.1": version "7.1.1" resolved "https://registry.npmjs.org/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575" @@ -3919,10 +3925,10 @@ "@types/istanbul-lib-coverage" "*" "@types/istanbul-lib-report" "*" -"@types/jest@*", "@types/jest@^25.2.2": - version "25.2.3" - resolved "https://registry.npmjs.org/@types/jest/-/jest-25.2.3.tgz#33d27e4c4716caae4eced355097a47ad363fdcaf" - integrity sha512-JXc1nK/tXHiDhV55dvfzqtmP4S3sy3T3ouV2tkViZgxY/zeUkcpQcQPGRlgF4KmWzWW5oiWYSZwtCB+2RsE4Fw== +"@types/jest@*", "@types/jest@^26.0.7": + version "26.0.7" + resolved "https://registry.npmjs.org/@types/jest/-/jest-26.0.7.tgz#495cb1d1818c1699dbc3b8b046baf1c86ef5e324" + integrity sha512-+x0077/LoN6MjqBcVOe1y9dpryWnfDZ+Xfo3EqGeBcfPRJlQp3Lw62RvNlWxuGv7kOEwlHriAa54updi3Jvvwg== dependencies: jest-diff "^25.2.1" pretty-format "^25.2.1" @@ -4033,20 +4039,25 @@ "@types/node" "*" form-data "^3.0.0" -"@types/node@*", "@types/node@>= 8", "@types/node@^13.7.2": - version "13.9.2" - resolved "https://registry.npmjs.org/@types/node/-/node-13.9.2.tgz#ace1880c03594cc3e80206d96847157d8e7fa349" - integrity sha512-bnoqK579sAYrQbp73wwglccjJ4sfRdKU7WNEZ5FW4K2U6Kc0/eZ5kvXG0JKsEKFB50zrFmfFt52/cvBbZa7eXg== +"@types/node@*", "@types/node@>= 8": + version "14.0.26" + resolved "https://registry.npmjs.org/@types/node/-/node-14.0.26.tgz#22a3b8a46510da8944b67bfc27df02c34a35331c" + integrity sha512-W+fpe5s91FBGE0pEa0lnqGLL4USgpLgs4nokw16SrBBco/gQxuua7KnArSEOd5iaMqbbSHV10vUDkJYJJqpXKA== "@types/node@^10.1.0": - version "10.17.27" - resolved "https://registry.npmjs.org/@types/node/-/node-10.17.27.tgz#391cb391c75646c8ad2a7b6ed3bbcee52d1bdf19" - integrity sha512-J0oqm9ZfAXaPdwNXMMgAhylw5fhmXkToJd06vuDUSAgEDZ/n/69/69UmyBZbc+zT34UnShuDSBqvim3SPnozJg== + version "10.17.28" + resolved "https://registry.npmjs.org/@types/node/-/node-10.17.28.tgz#0e36d718a29355ee51cec83b42d921299200f6d9" + integrity sha512-dzjES1Egb4c1a89C7lKwQh8pwjYmlOAG9dW1pBgxEk57tMrLnssOfEthz8kdkNaBd7lIqQx7APm5+mZ619IiCQ== "@types/node@^12.0.0": - version "12.12.30" - resolved "https://registry.npmjs.org/@types/node/-/node-12.12.30.tgz#3501e6f09b954de9c404671cefdbcc5d9d7c45f6" - integrity sha512-sz9MF/zk6qVr3pAnM0BSQvYIBK44tS75QC5N+VbWSE4DjCV/pJ+UzCW/F+vVnl7TkOPcuwQureKNtSSwjBTaMg== + version "12.12.53" + resolved "https://registry.npmjs.org/@types/node/-/node-12.12.53.tgz#be0d375933c3d15ef2380dafb3b0350ea7021129" + integrity sha512-51MYTDTyCziHb70wtGNFRwB4l+5JNvdqzFSkbDvpbftEgVUBEE+T5f7pROhWMp/fxp07oNIEQZd5bbfAH22ohQ== + +"@types/node@^13.7.2": + version "13.13.15" + resolved "https://registry.npmjs.org/@types/node/-/node-13.13.15.tgz#fe1cc3aa465a3ea6858b793fd380b66c39919766" + integrity sha512-kwbcs0jySLxzLsa2nWUAGOd/s21WU1jebrEdtzhsj1D4Yps1EOuyI1Qcu+FD56dL7NRNIJtDDjcqIG22NwkgLw== "@types/nodegit@0.26.5": version "0.26.5" @@ -4142,9 +4153,9 @@ integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== "@types/qs@*": - version "6.9.1" - resolved "https://registry.npmjs.org/@types/qs/-/qs-6.9.1.tgz#937fab3194766256ee09fcd40b781740758617e7" - integrity sha512-lhbQXx9HKZAPgBkISrBcmAcMpZsmpe/Cd/hY7LGZS5OfkySUBItnPZHgQPssWYUET8elF+yCFBbP1Q0RZPTdaw== + version "6.9.4" + resolved "https://registry.npmjs.org/@types/qs/-/qs-6.9.4.tgz#a59e851c1ba16c0513ea123830dd639a0a15cb6a" + integrity sha512-+wYo+L6ZF6BMoEjtf8zB2esQsqdV6WsjRK/GP9WOgLPrq87PbNWgIxS76dS5uvl/QXtHGakZmwTznIfcPXcKlQ== "@types/range-parser@*": version "1.2.3" @@ -4422,12 +4433,7 @@ "@types/serve-static" "*" "@types/webpack" "*" -"@types/webpack-env@^1.15.0": - version "1.15.1" - resolved "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.15.1.tgz#c8e84705e08eed430b5e15b39c65b0944e4d1422" - integrity sha512-eWN5ElDTeBc5lRDh95SqA8x18D0ll2pWudU3uWiyfsRmIZcmUXpEsxPU+7+BsdCrO2vfLRC629u/MmjbmF+2tA== - -"@types/webpack-env@^1.15.2": +"@types/webpack-env@^1.15.0", "@types/webpack-env@^1.15.2": version "1.15.2" resolved "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.15.2.tgz#927997342bb9f4a5185a86e6579a0a18afc33b0a" integrity sha512-67ZgZpAlhIICIdfQrB5fnDvaKFcDxpKibxznfYRVAT4mQE41Dido/3Ty+E3xGBmTogc5+0Qb8tWhna+5B8z1iQ== @@ -6182,6 +6188,16 @@ buffer-indexof@^1.0.0: resolved "https://registry.npmjs.org/buffer-indexof/-/buffer-indexof-1.1.1.tgz#52fabcc6a606d1a00302802648ef68f639da268c" integrity sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g== +buffer-writer@1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/buffer-writer/-/buffer-writer-1.0.1.tgz#22a936901e3029afcd7547eb4487ceb697a3bf08" + integrity sha1-Iqk2kB4wKa/NdUfrRIfOtpejvwg= + +buffer-writer@2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz#ce7eb81a38f7829db09c873f2fbb792c0c98ec04" + integrity sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw== + buffer-xor@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" @@ -6392,6 +6408,11 @@ camelcase@^2.0.0: resolved "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f" integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8= +camelcase@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a" + integrity sha1-MvxLn82vhF/N9+c7uXysImHwqwo= + camelcase@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" @@ -6689,6 +6710,15 @@ clipboard@^2.0.0: select "^1.1.2" tiny-emitter "^2.0.0" +cliui@^3.2.0: + version "3.2.0" + resolved "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d" + integrity sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0= + dependencies: + string-width "^1.0.1" + strip-ansi "^3.0.1" + wrap-ansi "^2.0.0" + cliui@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" @@ -7841,7 +7871,7 @@ decamelize-keys@^1.0.0: decamelize "^1.1.0" map-obj "^1.0.0" -decamelize@^1.1.0, decamelize@^1.1.2, decamelize@^1.2.0: +decamelize@^1.1.0, decamelize@^1.1.1, decamelize@^1.1.2, decamelize@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= @@ -8441,6 +8471,11 @@ elliptic@^6.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.0" +"emoji-regex@>=6.0.0 <=6.1.1": + version "6.1.1" + resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-6.1.1.tgz#c6cd0ec1b0642e2a3c67a1137efc5e796da4f88e" + integrity sha1-xs0OwbBkLio8Z6ETfvxeeW2k+I4= + emoji-regex@^7.0.1, emoji-regex@^7.0.2: version "7.0.3" resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" @@ -9889,6 +9924,11 @@ generic-names@^2.0.1: dependencies: loader-utils "^1.1.0" +generic-pool@2.4.3: + version "2.4.3" + resolved "https://registry.npmjs.org/generic-pool/-/generic-pool-2.4.3.tgz#780c36f69dfad05a5a045dd37be7adca11a4f6ff" + integrity sha1-eAw29p360FpaBF3Te+etyhGk9v8= + genfun@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/genfun/-/genfun-5.0.0.tgz#9dd9710a06900a5c4a5bf57aca5da4e52fe76537" @@ -9899,6 +9939,11 @@ gensync@^1.0.0-beta.1: resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269" integrity sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg== +get-caller-file@^1.0.1: + version "1.0.3" + resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" + integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w== + get-caller-file@^2.0.1: version "2.0.5" resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" @@ -10035,6 +10080,13 @@ gitconfiglocal@^1.0.0: dependencies: ini "^1.3.2" +github-slugger@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/github-slugger/-/github-slugger-1.3.0.tgz#9bd0a95c5efdfc46005e82a906ef8e2a059124c9" + integrity sha512-gwJScWVNhFYSRDvURk/8yhcFBee6aFjye2a7Lhb2bUyRulpIoek9p0I9Kt7PT67d/nUlZbFu8L9RLiA0woQN8Q== + dependencies: + emoji-regex ">=6.0.0 <=6.1.1" + glob-base@^0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4" @@ -11255,6 +11307,11 @@ invariant@^2.2.2, invariant@^2.2.3, invariant@^2.2.4: dependencies: loose-envify "^1.0.0" +invert-kv@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" + integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY= + ip-regex@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" @@ -12301,6 +12358,11 @@ js-cookie@^2.2.1: resolved "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8" integrity sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ== +js-string-escape@1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef" + integrity sha1-4mJbrbwNZ8dTPp7cEGjFh65BN+8= + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -12709,6 +12771,13 @@ lazy-universal-dotenv@^3.0.1: dotenv "^8.0.0" dotenv-expand "^5.1.0" +lcid@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" + integrity sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU= + dependencies: + invert-kv "^1.0.0" + left-pad@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e" @@ -12978,6 +13047,11 @@ lodash._reinterpolate@^3.0.0: resolved "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0= +lodash.assign@^4.1.0, lodash.assign@^4.2.0: + version "4.2.0" + resolved "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7" + integrity sha1-DZnzzNem0mHRm9rrkkUAXShYCOc= + lodash.camelcase@^4.3.0: version "4.3.0" resolved "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" @@ -14359,6 +14433,11 @@ oauth@0.9.x: resolved "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1" integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE= +object-assign@4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.0.tgz#7a3b3d0e98063d43f4c03f2e8ae6cd51a86883a0" + integrity sha1-ejs9DpgGPUP0wD8uiubNUahog6A= + object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -14608,6 +14687,13 @@ os-homedir@^1.0.0: resolved "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= +os-locale@^1.4.0: + version "1.4.0" + resolved "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz#20f9f17ae29ed345e8bde583b13d2009803c14d9" + integrity sha1-IPnxeuKe00XoveWDsT0gCYA8FNk= + dependencies: + lcid "^1.0.0" + os-name@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/os-name/-/os-name-3.1.0.tgz#dec19d966296e1cd62d701a5a66ee1ddeae70801" @@ -14788,6 +14874,16 @@ package-json@^6.3.0: registry-url "^5.0.0" semver "^6.2.0" +packet-reader@0.3.1: + version "0.3.1" + resolved "https://registry.npmjs.org/packet-reader/-/packet-reader-0.3.1.tgz#cd62e60af8d7fea8a705ec4ff990871c46871f27" + integrity sha1-zWLmCvjX/qinBexP+ZCHHEaHHyc= + +packet-reader@1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz#9238e5480dedabacfe1fe3f2771063f164157d74" + integrity sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ== + pako@~1.0.5: version "1.0.11" resolved "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" @@ -15156,11 +15252,111 @@ performance-now@^2.1.0: resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= +pg-connection-string@0.1.3, pg-connection-string@^0.1.3: + version "0.1.3" + resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz#da1847b20940e42ee1492beaf65d49d91b245df7" + integrity sha1-2hhHsglA5C7hSSvq9l1J2RskXfc= + pg-connection-string@2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.2.0.tgz#caab4d38a9de4fdc29c9317acceed752897de41c" integrity sha512-xB/+wxcpFipUZOQcSzcgkjcNOosGhEoPSjz06jC89lv1dj7mc9bZv6wLVy8M2fVjP0a/xN0N988YDq1L0FhK3A== +pg-connection-string@^2.3.0: + version "2.3.0" + resolved "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.3.0.tgz#c13fcb84c298d0bfa9ba12b40dd6c23d946f55d6" + integrity sha512-ukMTJXLI7/hZIwTW7hGMZJ0Lj0S2XQBCJ4Shv4y1zgQ/vqVea+FLhzywvPj0ujSuofu+yA4MYHGZPTsgjBgJ+w== + +pg-int8@1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" + integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== + +pg-pool@1.*: + version "1.8.0" + resolved "https://registry.npmjs.org/pg-pool/-/pg-pool-1.8.0.tgz#f7ec73824c37a03f076f51bfdf70e340147c4f37" + integrity sha1-9+xzgkw3oD8Hb1G/33DjQBR8Tzc= + dependencies: + generic-pool "2.4.3" + object-assign "4.1.0" + +pg-pool@^3.2.1: + version "3.2.1" + resolved "https://registry.npmjs.org/pg-pool/-/pg-pool-3.2.1.tgz#5f4afc0f58063659aeefa952d36af49fa28b30e0" + integrity sha512-BQDPWUeKenVrMMDN9opfns/kZo4lxmSWhIqo+cSAF7+lfi9ZclQbr9vfnlNaPr8wYF3UYjm5X0yPAhbcgqNOdA== + +pg-protocol@^1.2.5: + version "1.2.5" + resolved "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.2.5.tgz#28a1492cde11646ff2d2d06bdee42a3ba05f126c" + integrity sha512-1uYCckkuTfzz/FCefvavRywkowa6M5FohNMF5OjKrqo9PSR8gYc8poVmwwYQaBxhmQdBjhtP514eXy9/Us2xKg== + +pg-types@1.*: + version "1.13.0" + resolved "https://registry.npmjs.org/pg-types/-/pg-types-1.13.0.tgz#75f490b8a8abf75f1386ef5ec4455ecf6b345c63" + integrity sha512-lfKli0Gkl/+za/+b6lzENajczwZHc7D5kiUCZfgm914jipD2kIOIvEkAhZ8GrW3/TUoP9w8FHjwpPObBye5KQQ== + dependencies: + pg-int8 "1.0.1" + postgres-array "~1.0.0" + postgres-bytea "~1.0.0" + postgres-date "~1.0.0" + postgres-interval "^1.1.0" + +pg-types@^2.1.0: + version "2.2.0" + resolved "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz#2d0250d636454f7cfa3b6ae0382fdfa8063254a3" + integrity sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA== + dependencies: + pg-int8 "1.0.1" + postgres-array "~2.0.0" + postgres-bytea "~1.0.0" + postgres-date "~1.0.4" + postgres-interval "^1.1.0" + +pg@^6.1.0: + version "6.4.2" + resolved "https://registry.npmjs.org/pg/-/pg-6.4.2.tgz#c364011060eac7a507a2ae063eb857ece910e27f" + integrity sha1-w2QBEGDqx6UHoq4GPrhX7OkQ4n8= + dependencies: + buffer-writer "1.0.1" + js-string-escape "1.0.1" + packet-reader "0.3.1" + pg-connection-string "0.1.3" + pg-pool "1.*" + pg-types "1.*" + pgpass "1.*" + semver "4.3.2" + +pg@^8.3.0: + version "8.3.0" + resolved "https://registry.npmjs.org/pg/-/pg-8.3.0.tgz#941383300d38eef51ecb88a0188cec441ab64d81" + integrity sha512-jQPKWHWxbI09s/Z9aUvoTbvGgoj98AU7FDCcQ7kdejupn/TcNpx56v2gaOTzXkzOajmOEJEdi9eTh9cA2RVAjQ== + dependencies: + buffer-writer "2.0.0" + packet-reader "1.0.0" + pg-connection-string "^2.3.0" + pg-pool "^3.2.1" + pg-protocol "^1.2.5" + pg-types "^2.1.0" + pgpass "1.x" + semver "4.3.2" + +pgpass@1.*, pgpass@1.x: + version "1.0.2" + resolved "https://registry.npmjs.org/pgpass/-/pgpass-1.0.2.tgz#2a7bb41b6065b67907e91da1b07c1847c877b306" + integrity sha1-Knu0G2BltnkH6R2hsHwYR8h3swY= + dependencies: + split "^1.0.0" + +pgtools@^0.3.0: + version "0.3.0" + resolved "https://registry.npmjs.org/pgtools/-/pgtools-0.3.0.tgz#ee7decf4183ada28299c63df71e1e73c95b5bc53" + integrity sha512-8NxDCJ8xJ6hOp9hVNZqxi+TZl7hM1Jc8pQyj8DlAbyaWnk5OsGwf3gB/UyDODdOguiim9QzbzPsslp//apO+Uw== + dependencies: + bluebird "^3.3.5" + pg "^6.1.0" + pg-connection-string "^0.1.3" + yargs "^5.0.0" + picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1, picomatch@^2.2.2: version "2.2.2" resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" @@ -15692,6 +15888,33 @@ postcss@^6.0.1: source-map "^0.6.1" supports-color "^5.4.0" +postgres-array@~1.0.0: + version "1.0.3" + resolved "https://registry.npmjs.org/postgres-array/-/postgres-array-1.0.3.tgz#c561fc3b266b21451fc6555384f4986d78ec80f5" + integrity sha512-5wClXrAP0+78mcsNX3/ithQ5exKvCyK5lr5NEEEeGwwM6NJdQgzIJBVxLvRW+huFpX92F2QnZ5CcokH0VhK2qQ== + +postgres-array@~2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz#48f8fce054fbc69671999329b8834b772652d82e" + integrity sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA== + +postgres-bytea@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz#027b533c0aa890e26d172d47cf9ccecc521acd35" + integrity sha1-AntTPAqokOJtFy1Hz5zOzFIazTU= + +postgres-date@~1.0.0, postgres-date@~1.0.4: + version "1.0.5" + resolved "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.5.tgz#710b27de5f27d550f6e80b5d34f7ba189213c2ee" + integrity sha512-pdau6GRPERdAYUQwkBnGKxEfPyhVZXG/JiS44iZWiNdSOWE09N2lUgN6yshuq6fVSon4Pm0VMXd1srUUkLe9iA== + +postgres-interval@^1.1.0: + version "1.2.0" + resolved "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz#b460c82cb1587507788819a06aa0fffdb3544695" + integrity sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ== + dependencies: + xtend "^4.0.0" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -17016,6 +17239,11 @@ require-directory@^2.1.1: resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= +require-main-filename@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" + integrity sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE= + require-main-filename@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" @@ -17459,6 +17687,11 @@ semver-regex@^2.0.0: resolved "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== +semver@4.3.2: + version "4.3.2" + resolved "https://registry.npmjs.org/semver/-/semver-4.3.2.tgz#c7a07158a80bedd052355b770d82d6640f803be7" + integrity sha1-x6BxWKgL7dBSNVt3DYLWZA+AO+c= + semver@7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" @@ -18241,7 +18474,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -string-width@^1.0.1: +string-width@^1.0.1, string-width@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= @@ -20062,6 +20295,11 @@ whatwg-fetch@^2.0.0, whatwg-fetch@^2.0.4: resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f" integrity sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng== +whatwg-fetch@^3.2.0: + version "3.2.0" + resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.2.0.tgz#8e134f701f0a4ab5fda82626f113e2b647fd16dc" + integrity sha512-SdGPoQMMnzVYThUbSrEvqTlkvC1Ux27NehaJ/GUHBfNrh5Mjg+1/uRyFMwVnxO2MrikMWvWAqUGgQOfVU4hT7w== + whatwg-mimetype@^2.1.0, whatwg-mimetype@^2.2.0, whatwg-mimetype@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz#3d4b1e0312d2079879f826aff18dbeeca5960fbf" @@ -20094,6 +20332,11 @@ whatwg-url@^8.0.0: tr46 "^2.0.2" webidl-conversions "^5.0.0" +which-module@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f" + integrity sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8= + which-module@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" @@ -20132,6 +20375,11 @@ widest-line@^3.1.0: dependencies: string-width "^4.0.0" +window-size@^0.2.0: + version "0.2.0" + resolved "https://registry.npmjs.org/window-size/-/window-size-0.2.0.tgz#b4315bb4214a3d7058ebeee892e13fa24d98b075" + integrity sha1-tDFbtCFKPXBY6+7okuE/ok2YsHU= + windows-release@^3.1.0: version "3.2.0" resolved "https://registry.npmjs.org/windows-release/-/windows-release-3.2.0.tgz#8122dad5afc303d833422380680a79cdfa91785f" @@ -20186,6 +20434,14 @@ worker-rpc@^0.1.0: dependencies: microevent.ts "~0.1.1" +wrap-ansi@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" + integrity sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU= + dependencies: + string-width "^1.0.1" + strip-ansi "^3.0.1" + wrap-ansi@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-3.0.1.tgz#288a04d87eda5c286e060dfe8f135ce8d007f8ba" @@ -20385,6 +20641,11 @@ xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1: resolved "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== +y18n@^3.2.1: + version "3.2.1" + resolved "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" + integrity sha1-bRX7qITAhnnA136I53WegR4H+kE= + y18n@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" @@ -20441,6 +20702,14 @@ yargs-parser@^15.0.1: camelcase "^5.0.0" decamelize "^1.2.0" +yargs-parser@^3.2.0: + version "3.2.0" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-3.2.0.tgz#5081355d19d9d0c8c5d81ada908cb4e6d186664f" + integrity sha1-UIE1XRnZ0MjF2BrakIy05tGGZk8= + dependencies: + camelcase "^3.0.0" + lodash.assign "^4.1.0" + yargs@^13.3.2: version "13.3.2" resolved "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" @@ -20491,6 +20760,26 @@ yargs@^15.3.1: y18n "^4.0.0" yargs-parser "^18.1.1" +yargs@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/yargs/-/yargs-5.0.0.tgz#3355144977d05757dbb86d6e38ec056123b3a66e" + integrity sha1-M1UUSXfQV1fbuG1uOOwFYSOzpm4= + dependencies: + cliui "^3.2.0" + decamelize "^1.1.1" + get-caller-file "^1.0.1" + lodash.assign "^4.2.0" + os-locale "^1.4.0" + read-pkg-up "^1.0.1" + require-directory "^2.1.1" + require-main-filename "^1.0.1" + set-blocking "^2.0.0" + string-width "^1.0.2" + which-module "^1.0.0" + window-size "^0.2.0" + y18n "^3.2.1" + yargs-parser "^3.2.0" + yauzl@2.10.0, yauzl@^2.10.0: version "2.10.0" resolved "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"