diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 35d354fefc..751ec4914d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,4 +4,6 @@ # The last matching pattern takes precedence. # https://help.github.com/articles/about-codeowners/ -* @spotify/backstage-core +* @spotify/backstage-core +/plugins/techdocs @spotify/techdocs-core +/packages/techdocs-cli @spotify/techdocs-core diff --git a/.github/workflows/techdocs.yml b/.github/workflows/techdocs.yml new file mode 100644 index 0000000000..6d6a6e904e --- /dev/null +++ b/.github/workflows/techdocs.yml @@ -0,0 +1,35 @@ +name: TechDocs + +on: + pull_request: + paths: + - '.github/workflows/techdocs.yml' + - 'packages/techdocs-cli/**' + - 'plugins/techdocs/**' + +jobs: + build: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest] + python-version: [3.7] + + env: + TECHDOCS_CORE_PATH: ./plugins/techdocs/mkdocs/container/techdocs-core + + name: Python ${{ matrix.node-version }} on ${{ matrix.os }} + steps: + - uses: actions/checkout@v2 + + # Lint Python code for techdocs-core package + - name: prepare python environment + run: | + python3 -m pip install --index-url https://pypi.org/simple/ setuptools + python3 -m pip install --upgrade pip + python3 -m pip install --index-url https://pypi.org/simple/ -r $TECHDOCS_CORE_PATH/requirements.txt + + - name: lint techdocs-core package + run: | + python3 -m black --check $TECHDOCS_CORE_PATH/src diff --git a/.gitignore b/.gitignore index f5794babbc..a1eb87e8d5 100644 --- a/.gitignore +++ b/.gitignore @@ -116,3 +116,6 @@ dist # Stores VSCode versions used for testing VSCode extensions .vscode-test + +# Temporary change files created by Vim +*.swp diff --git a/ADOPTERS.md b/ADOPTERS.md new file mode 100644 index 0000000000..d0f8c0a97b --- /dev/null +++ b/ADOPTERS.md @@ -0,0 +1,3 @@ +| Organization | Contact | Description of Use | +| ------------ | ------- | ------------------ | +| [Spotify](https://www.spotify.com) |[@alund](https://github.com/alund)| Main interface towards all of Spotify's infrastructure and technical documentation.| diff --git a/app-config.yaml b/app-config.yaml index 6ff336c727..39527c20bd 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -4,6 +4,11 @@ app: backend: baseUrl: http://localhost:7000 + listen: 0.0.0.0:7000 + cors: + origin: http://localhost:3000 + methods: [GET, POST, PUT, DELETE] + credentials: true organization: name: Spotify diff --git a/docs/FAQ.md b/docs/FAQ.md index 5167139d45..8c9febce07 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -97,17 +97,9 @@ app and configures which plugins are available to use in the app. ​ The **software engineer** uses the app's functionality and interacts with its plugins. ​ -### What is a "plugin" in Backstage? +### What is the use of a "plugin" in Backstage? -By far, our most-used plugin is our TechDocs plugin, which we use for creating -technical documentation. Our philosophy at Spotify is to treat "docs like code", -where you write documentation using the same workflow as you write your code. -This makes it easier to create, find, and update documentation. We hope to -release -[the open source version](https://github.com/spotify/backstage/issues/687) in -the future. (See also: -"[Will Spotify's internal plugins be open sourced, too?](https://github.com/spotify/backstage/blob/master/docs/FAQ.md#what-is-a-plugin-in-backstage)" -above) +​ A Backstage Plugin adds functionality to Backstage. ​ ### Do I have to write plugins in TypeScript? diff --git a/docs/auth/README.md b/docs/auth/README.md new file mode 100644 index 0000000000..081d10add6 --- /dev/null +++ b/docs/auth/README.md @@ -0,0 +1,86 @@ +# User Authentication and Authorization in Backstage + +## Summary + +The purpose of the Auth APIs in Backstage are to identify the user, and to +provide a way for plugins to request access to 3rd party services on behalf of +the user (OAuth). This documentation focuses on the implementation of that +solution and how to extend it. For documentation on how to consume the Auth APIs +in a plugin, see [TODO](#TODO). + +### Accessing Third Party Services + +The main pattern for talking to third party services in Backstage is +user-to-server requests, where short-lived OAuth Access Tokens are requested by +plugins to authenticate calls to external services. These calls can be made +either directly to the services or through a backend plugin or service. + +By relying on user-to-server calls we keep the coupling between the frontend and +backend low, and provide a much lower barrier for plugins to make use of third +party services. This is in comparison to for example a session-based system, +where access tokens are stored server-side. Such a solution would require a much +deeper coupling between the auth backend plugin, its session storage, and other +backend plugins or separate services. A goal of Backstage is to make it as easy +as possible to create new plugins, and an auth solution based on user-to-server +OAuth helps in that regard. + +The method with which frontend plugins request access to third party services is +through [Utility APIs](../getting-started/utility-apis.md) for each service +provider. For a full list of providers, see [TODO](#TODO). + +### Identity - WIP + +Identity management is still work in progress, but there are already a couple of +pieces in place that can be used. + +#### Identity for Plugin Developers + +As a plugin developer, there are two main touchpoints for identities: the +`IdentityApi` exported by `@backstage/core` via the `identityApiRef`, and a not +yet existing middleware exported by `@backstage/backend-common`. + +The `IdentityApi` gives access to the signed-in user's identity in the frontend. +It provides access to the user's ID, lightweight profile information, and an ID +token used to make authenticated calls within Backstage. + +The middleware that will be provided by `@backstage/backend-common` allows +verification of Backstage ID tokens, and optionally loading additional +information about the user. The progress is tracked in +https://github.com/spotify/backstage/issues/1435. + +#### Identity for App Developers + +If you're setting up your own Backstage app, or want to add a new identity +provider, there are three touchpoints: the frontend auth APIs in +`@backstage/core-api`, the backend auth providers in `auth-backend`, and the +`SignInPage` component configured in the Backstage app via `createApp`. + +The frontend APIs and backend providers are tightly coupled together for each +auth provider, and together they implement an e2e auth flow. Only some auth +providers also act as identity providers though. For example, at the moment of +writing, the Google Auth provider is able to act as a Backstage identity +provider, but the GitHub one can not. For an auth provider to also act as an +identity provider, it needs to implement the `BackstageIdentityApi` in the +frontend, and in the backend it needs to return a `BackstageIdentity` structure. + +It is up to each provider to implement the mapping between a provider identity +and the corresponding Backstage identity. That is currently still work in +progress, and as a stop-gap for example the Google provider returns the local +part of the user's email as the user ID. + +The final piece of the puzzle is the `SignInPage` component that can be +configured as part of the app. Without a sign-in page, Backstage will fall back +to a `guest` identity for all users, without any ID token. To enable sign-in, a +`SignInPage` needs to be configured, which in turn has to supply a user to the +app. The `@backstage/core` package provides a basic sign-in page that allows +both the user and the app developer to choose between a couple of different +sign-in methods. + +## Further Reading + +More details are provided in dedicated sections of the documentation. + +- [OAuth](./oauth): Description of the generic OAuth flow implemented by the + [auth-backend](../../plugins/auth-backend). +- [Glossary](./glossary): Glossary of some common terms related to the auth + flows. diff --git a/docs/auth/overview.md b/docs/auth/overview.md deleted file mode 100644 index 2806b2dccf..0000000000 --- a/docs/auth/overview.md +++ /dev/null @@ -1,44 +0,0 @@ -# User Authentication and Authorization in Backstage - -## Summary - -The purpose of the Auth APIs in Backstage are to identify the user, and to -provide a way for plugins to request access to 3rd party services on behalf of -the user (OAuth). This documentation focuses on the implementation of that -solution and how to extend it. For documentation on how to consume the Auth APIs -in a plugin, see [TODO](#TODO). - -### Accessing Third Party Services - -The main pattern for talking to third party services in Backstage is -user-to-server requests, where short-lived OAuth Access Tokens are requested by -plugins to authenticate calls to external services. These calls can be made -either directly to the services or through a backend plugin or service. - -By relying on user-to-server calls we keep the coupling between the frontend and -backend low, and provide a much lower barrier for plugins to make use of third -party services. This is in comparison to for example a session-based system, -where access tokens are stored server-side. Such a solution would require a much -deeper coupling between the auth backend plugin, its session storage, and other -backend plugins or separate services. A goal of Backstage is to make it as easy -as possible to create new plugins, and an auth solution based on user-to-server -OAuth helps in that regard. - -The method with which frontend plugins request access to third party services is -through [Utility APIs](../getting-started/utility-apis.md) for each service -provider. For a full list of providers, see [TODO](#TODO). - -### Identity - TODO - -This documentation currently only covers the OAuth use-case, as identity -management is not settled yet and part of an -[upcoming milestone](https://github.com/spotify/backstage/milestone/12). - -## Further Reading - -More details are provided in dedicated sections of the documentation. - -- [OAuth](./oauth): Description of the generic OAuth flow implemented by the - [auth-backend](../../plugins/auth-backend). -- [Glossary](./glossary): Glossary of some common terms related to the auth - flows. diff --git a/lerna.json b/lerna.json index 5d63f88c11..9e00bb25a6 100644 --- a/lerna.json +++ b/lerna.json @@ -2,5 +2,5 @@ "packages": ["packages/*", "plugins/*"], "npmClient": "yarn", "useWorkspaces": true, - "version": "0.1.1-alpha.9" + "version": "0.1.1-alpha.12" } diff --git a/packages/app/package.json b/packages/app/package.json index 6aa82a0b36..118b4c15ff 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,39 +1,44 @@ { "name": "example-app", - "version": "0.1.1-alpha.9", + "version": "0.1.1-alpha.12", "private": true, "dependencies": { - "@backstage/cli": "^0.1.1-alpha.9", - "@backstage/core": "^0.1.1-alpha.9", - "@backstage/plugin-catalog": "^0.1.1-alpha.9", - "@backstage/plugin-circleci": "^0.1.1-alpha.9", - "@backstage/plugin-explore": "^0.1.1-alpha.9", - "@backstage/plugin-gitops-profiles": "^0.1.1-alpha.9", - "@backstage/plugin-lighthouse": "^0.1.1-alpha.9", - "@backstage/plugin-register-component": "^0.1.1-alpha.9", - "@backstage/plugin-scaffolder": "^0.1.1-alpha.9", - "@backstage/plugin-sentry": "^0.1.1-alpha.9", - "@backstage/plugin-tech-radar": "^0.1.1-alpha.9", - "@backstage/plugin-welcome": "^0.1.1-alpha.9", - "@backstage/theme": "^0.1.1-alpha.9", + "@backstage/cli": "^0.1.1-alpha.12", + "@backstage/core": "^0.1.1-alpha.12", + "@backstage/plugin-catalog": "^0.1.1-alpha.12", + "@backstage/plugin-circleci": "^0.1.1-alpha.12", + "@backstage/plugin-explore": "^0.1.1-alpha.12", + "@backstage/plugin-gitops-profiles": "^0.1.1-alpha.12", + "@backstage/plugin-graphiql": "^0.1.1-alpha.12", + "@backstage/plugin-lighthouse": "^0.1.1-alpha.12", + "@backstage/plugin-register-component": "^0.1.1-alpha.12", + "@backstage/plugin-scaffolder": "^0.1.1-alpha.12", + "@backstage/plugin-sentry": "^0.1.1-alpha.12", + "@backstage/plugin-tech-radar": "^0.1.1-alpha.12", + "@backstage/plugin-techdocs": "^0.1.1-alpha.12", + "@backstage/plugin-welcome": "^0.1.1-alpha.12", + "@backstage/test-utils": "^0.1.1-alpha.12", + "@backstage/theme": "^0.1.1-alpha.12", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "prop-types": "^15.7.2", "react": "^16.12.0", "react-dom": "^16.12.0", "react-hot-loader": "^4.12.21", + "react-router": "6.0.0-alpha.5", "react-router-dom": "6.0.0-alpha.5", "react-use": "^14.2.0", "zen-observable": "^0.8.15" }, "devDependencies": { "@testing-library/cypress": "^6.0.0", - "@testing-library/jest-dom": "^5.7.0", - "@testing-library/react": "^9.3.2", - "@testing-library/user-event": "^10.2.4", + "@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/jquery": "^3.3.34", "@types/node": "^12.0.0", + "@types/react-dom": "^16.9.8", "@types/zen-observable": "^0.8.0", "cross-env": "^7.0.0", "cypress": "^4.2.0", diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx index 79f78e45e0..65e8ff64e6 100644 --- a/packages/app/src/App.test.tsx +++ b/packages/app/src/App.test.tsx @@ -15,23 +15,24 @@ */ import React from 'react'; -import { render } from '@testing-library/react'; +import { renderWithEffects } from '@backstage/test-utils'; import App from './App'; describe('App', () => { - beforeAll(() => { - Object.defineProperty(window, 'matchMedia', { - value: jest.fn(() => { - return { - matches: true, - addListener: jest.fn(), - removeListener: jest.fn(), - }; - }), + it('should render', async () => { + Object.defineProperty(process.env, 'APP_CONFIG', { + configurable: true, + value: [ + { + data: { + app: { title: 'Test' }, + }, + context: 'test', + }, + ], }); - }); - it('should render', () => { - const rendered = render(); + + const rendered = await renderWithEffects(); expect(rendered.baseElement).toBeInTheDocument(); }); }); diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 9bfc618216..5011b16eaf 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -45,6 +45,10 @@ import { CircleCIApi, circleCIApiRef } from '@backstage/plugin-circleci'; import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog'; import { gitOpsApiRef, GitOpsRestApi } from '@backstage/plugin-gitops-profiles'; +import { + graphQlBrowseApiRef, + GraphQLEndpoints, +} from '@backstage/plugin-graphiql'; export const apis = (config: ConfigApi) => { // eslint-disable-next-line no-console @@ -78,7 +82,7 @@ export const apis = (config: ConfigApi) => { }), ); - builder.add( + const githubAuthApi = builder.add( githubAuthApiRef, GithubAuth.create({ apiOrigin: 'http://localhost:7000', @@ -105,5 +109,22 @@ export const apis = (config: ConfigApi) => { builder.add(gitOpsApiRef, new GitOpsRestApi('http://localhost:3008')); + builder.add( + graphQlBrowseApiRef, + GraphQLEndpoints.from([ + GraphQLEndpoints.create({ + id: 'gitlab', + title: 'GitLab', + url: 'https://gitlab.com/api/graphql', + }), + GraphQLEndpoints.github({ + id: 'github', + title: 'GitHub', + errorApi, + githubAuthApi, + }), + ]), + ); + return builder.build(); }; diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 453c98bc72..4b92f8281e 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -39,6 +39,7 @@ import { SidebarPinButton, } from '@backstage/core'; import { NavLink } from 'react-router-dom'; +import { graphiQLRouteRef } from '@backstage/plugin-graphiql'; const useSidebarLogoStyles = makeStyles({ root: { @@ -94,6 +95,11 @@ const Root: FC<{}> = ({ children }) => ( + diff --git a/packages/app/src/index.tsx b/packages/app/src/index.tsx index 2ea8d3f1dd..a38159a258 100644 --- a/packages/app/src/index.tsx +++ b/packages/app/src/index.tsx @@ -14,6 +14,8 @@ * limitations under the License. */ +// eslint-disable-next-line monorepo/no-internal-import +import '@backstage/cli/asset-types'; import React from 'react'; import ReactDOM from 'react-dom'; import App from './App'; diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index a6485835f7..09cd117fc0 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -23,3 +23,5 @@ export { plugin as Circleci } from '@backstage/plugin-circleci'; export { plugin as RegisterComponent } from '@backstage/plugin-register-component'; export { plugin as Sentry } from '@backstage/plugin-sentry'; export { plugin as GitopsProfiles } from '@backstage/plugin-gitops-profiles'; +export { plugin as TechDocs } from '@backstage/plugin-techdocs'; +export { plugin as GraphiQL } from '@backstage/plugin-graphiql'; diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 1a52e06013..b0aef88fea 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/backend-common", "description": "Common functionality library for Backstage backends", - "version": "0.1.1-alpha.9", + "version": "0.1.1-alpha.12", "main": "src/index.ts", "types": "src/index.ts", "private": false, @@ -29,6 +29,7 @@ "clean": "backstage-cli clean" }, "dependencies": { + "@backstage/config": "^0.1.1-alpha.12", "@types/cors": "^2.8.6", "@types/express": "^4.17.6", "compression": "^1.7.4", @@ -40,7 +41,7 @@ "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.9", + "@backstage/cli": "^0.1.1-alpha.12", "@types/compression": "^1.7.0", "@types/http-errors": "^1.6.3", "@types/morgan": "^1.9.0", diff --git a/packages/backend-common/src/hot.ts b/packages/backend-common/src/hot.ts index 3d4252b378..c725096c75 100644 --- a/packages/backend-common/src/hot.ts +++ b/packages/backend-common/src/hot.ts @@ -14,10 +14,38 @@ * limitations under the License. */ +// Find all active hot module APIs of all ancestors of a module, including the module itself +function findAllAncestors(_module: NodeModule): NodeModule[] { + const ancestors = new Array(); + const parentIds = new Set(); + + function add(id: string | number, m: NodeModule) { + if (parentIds.has(id)) { + return; + } + parentIds.add(id); + ancestors.push(m); + + for (const parentId of (m as any).parents) { + const parent = require.cache[parentId]; + if (parent) { + add(parentId, parent); + } + } + } + + add(_module.id, _module); + + return ancestors; +} + /** - * This function allows devs to cleanup - * ongoing effects when module gets hot-reloaded + * useHotCleanup allows cleanup of ongoing effects when a module is + * hot-reloaded during development. The cleanup function will be called + * whenever the module itself or any of its parent modules is hot-reloaded. + * * Useful for cleaning intervals, timers, requests etc + * * @example * ```ts * const intervalId = setInterval(doStuff, 1000); @@ -28,69 +56,69 @@ */ export function useHotCleanup(_module: NodeModule, cancelEffect: () => void) { if (_module.hot) { - _module.hot.addDisposeHandler(() => { - cancelEffect(); - }); + const ancestors = findAllAncestors(_module); + let cancelled = false; + + const handler = () => { + if (!cancelled) { + cancelled = true; + cancelEffect(); + } + }; + + for (const m of ancestors) { + m.hot?.addDisposeHandler(handler); + } } } +const CURRENT_HOT_MEMOIZE_INDEX_KEY = 'backstage.io/hmr-memoize-key'; + /** - * This function allows devs to preserve - * some value between hot-reloads. - * Useful for stateful parts of the backend + * Memoizes a generated value across hot-module reloads. This is useful for + * stateful parts of the backend, e.g. to retain a database. + * * @example * ```ts * const db = useHotMemoize(module, () => createDB(dbParams)); * ``` - * @param _module Reference to the current module where you invoke the fn - * @param valueFactory Fn that returns the value you want to memoize + * * @warning Don't use inside conditionals or loops, * same rules as for hooks apply (https://reactjs.org/docs/hooks-rules.html) + * + * @param _module Reference to the current module where you invoke the fn + * @param valueFactory Fn that returns the value you want to memoize */ export function useHotMemoize( _module: NodeModule, valueFactory: () => T, ): T { - const CURRENT_HOT_MEMOIZE_INDEX_KEY = 'backstage.io/hmr-memoize-key'; - if (!_module.hot) { - // Just return value straight away return valueFactory(); } - if (_module.hot && typeof _module.hot.data === 'undefined') { - // First run, init the module data + // When starting blank, reset the counter + if (!_module.hot.data?.[CURRENT_HOT_MEMOIZE_INDEX_KEY]) { + for (const ancestor of findAllAncestors(_module)) { + ancestor.hot?.addDisposeHandler(data => { + data[CURRENT_HOT_MEMOIZE_INDEX_KEY] = 1; + }); + } + _module.hot.data = { - [CURRENT_HOT_MEMOIZE_INDEX_KEY]: 0, + ..._module.hot.data, + [CURRENT_HOT_MEMOIZE_INDEX_KEY]: 1, }; } - // Let's store data per module based on the order of the code invocation - const index = _module.hot.data[CURRENT_HOT_MEMOIZE_INDEX_KEY]; - // Increasing the counter after each call - _module.hot.data[CURRENT_HOT_MEMOIZE_INDEX_KEY] += 1; + // Store data per module, based on the order of the code invocation + const index = _module.hot.data[CURRENT_HOT_MEMOIZE_INDEX_KEY]++; + const value = _module.hot.data[index] ?? valueFactory(); - const prevValue = _module.hot.data[index]; - const createDisposeHandler = (value: any) => (data: { - [key: number]: any; - [indexKey: string]: number; - }) => { - // Preserving the value through the HMR process + // Always add a handler that, upon a HMR event, reinstates the value. + _module.hot.addDisposeHandler(data => { data[index] = value; - // Decreasing the counter after each handler - data[CURRENT_HOT_MEMOIZE_INDEX_KEY] = - // First hot update is still different, need to populate the data - typeof data[CURRENT_HOT_MEMOIZE_INDEX_KEY] === 'undefined' - ? _module.hot!.data[CURRENT_HOT_MEMOIZE_INDEX_KEY] - 1 - : data[CURRENT_HOT_MEMOIZE_INDEX_KEY] - 1; - }; + }); - if (prevValue) { - _module.hot!.addDisposeHandler(createDisposeHandler(prevValue)); - return prevValue; - } - - const newValue = valueFactory(); - _module.hot.addDisposeHandler(createDisposeHandler(newValue)); - return newValue; + return value; } diff --git a/packages/backend-common/src/service/createServiceBuilder.ts b/packages/backend-common/src/service/createServiceBuilder.ts index daef612fcf..c62921afc3 100644 --- a/packages/backend-common/src/service/createServiceBuilder.ts +++ b/packages/backend-common/src/service/createServiceBuilder.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ServiceBuilderImpl } from './ServiceBuilderImpl'; +import { ServiceBuilderImpl } from './lib/ServiceBuilderImpl'; /** * Creates a new service builder. diff --git a/packages/backend-common/src/service/ServiceBuilderImpl.ts b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts similarity index 66% rename from packages/backend-common/src/service/ServiceBuilderImpl.ts rename to packages/backend-common/src/service/lib/ServiceBuilderImpl.ts index 931d29b841..19418b2671 100644 --- a/packages/backend-common/src/service/ServiceBuilderImpl.ts +++ b/packages/backend-common/src/service/lib/ServiceBuilderImpl.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ConfigReader } from '@backstage/config'; import compression from 'compression'; import cors from 'cors'; import express, { Router } from 'express'; @@ -21,37 +22,66 @@ import helmet from 'helmet'; import { Server } from 'http'; import stoppable from 'stoppable'; import { Logger } from 'winston'; -import { getRootLogger } from '../logging'; +import { useHotCleanup } from '../../hot'; +import { getRootLogger } from '../../logging'; import { errorHandler, notFoundHandler, requestLoggingHandler, -} from '../middleware'; -import { ServiceBuilder } from './types'; -import { useHotCleanup } from '../hot'; +} from '../../middleware'; +import { ServiceBuilder } from '../types'; +import { readBaseOptions, readCorsOptions } from './config'; const DEFAULT_PORT = 7000; +const DEFAULT_HOST = 'localhost'; export class ServiceBuilderImpl implements ServiceBuilder { private port: number | undefined; + private host: string | undefined; private logger: Logger | undefined; private corsOptions: cors.CorsOptions | undefined; private routers: [string, Router][]; - /** - * Reference to the module where builder is created - * Needed for the HMR - */ + // Reference to the module where builder is created - needed for hot module + // reloading private module: NodeModule; + constructor(module: NodeModule) { this.routers = []; this.module = module; } + loadConfig(config: ConfigReader): ServiceBuilder { + const backendConfig = config.getOptionalConfig('backend'); + if (!backendConfig) { + return this; + } + + const baseOptions = readBaseOptions(backendConfig); + if (baseOptions.listenPort) { + this.port = baseOptions.listenPort; + } + if (baseOptions.listenHost) { + this.host = baseOptions.listenHost; + } + + const corsOptions = readCorsOptions(backendConfig); + if (corsOptions) { + this.corsOptions = corsOptions; + } + + return this; + } + setPort(port: number): ServiceBuilder { this.port = port; return this; } + setHost(host: string): ServiceBuilder { + this.host = host; + return this; + } + setLogger(logger: Logger): ServiceBuilder { this.logger = logger; return this; @@ -69,7 +99,7 @@ export class ServiceBuilderImpl implements ServiceBuilder { start(): Promise { const app = express(); - const { port, logger, corsOptions } = this.getOptions(); + const { port, host, logger, corsOptions } = this.getOptions(); app.use(helmet()); if (corsOptions) { @@ -89,9 +119,10 @@ export class ServiceBuilderImpl implements ServiceBuilder { logger.error(`Failed to start up on port ${port}, ${e}`); reject(e); }); + const server = stoppable( - app.listen(port, () => { - logger.info(`Listening on port ${port}`); + app.listen(port, host, () => { + logger.info(`Listening on ${host}:${port}`); }), 0, ); @@ -108,26 +139,14 @@ export class ServiceBuilderImpl implements ServiceBuilder { private getOptions(): { port: number; + host: string; logger: Logger; corsOptions?: cors.CorsOptions; } { - let port: number; - if (this.port !== undefined) { - port = this.port; - } else { - port = parseInt(process.env.PORT ?? '', 10) || DEFAULT_PORT; - } - - let logger: Logger; - if (this.logger) { - logger = this.logger; - } else { - logger = getRootLogger(); - } - return { - port, - logger, + port: this.port ?? DEFAULT_PORT, + host: this.host ?? DEFAULT_HOST, + logger: this.logger ?? getRootLogger(), corsOptions: this.corsOptions, }; } diff --git a/packages/backend-common/src/service/lib/config.ts b/packages/backend-common/src/service/lib/config.ts new file mode 100644 index 0000000000..064eacc4f2 --- /dev/null +++ b/packages/backend-common/src/service/lib/config.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 { ConfigReader } from '@backstage/config'; +import { CorsOptions } from 'cors'; + +export type BaseOptions = { + listenPort?: number; + listenHost?: string; +}; + +/** + * Reads some base options out of a config object. + * + * @param config The root of a backend config object + * @returns A base options object + * + * @example + * ```json + * { + * baseUrl: "http://localhost:7000", + * listen: "0.0.0.0:7000" + * } + * ``` + */ +export function readBaseOptions(config: ConfigReader): BaseOptions { + // TODO(freben): Expand this to support more addresses and perhaps optional + const { host, port } = parseListenAddress(config.getString('listen')); + return removeUnknown({ + listenPort: port, + listenHost: host, + }); +} + +/** + * Attempts to read a CORS options object from the root of a config object. + * + * @param config The root of a backend config object + * @returns A CORS options object, or undefined if not specified + * + * @example + * ```json + * { + * cors: { + * origin: "http://localhost:3000", + * credentials: true + * } + * } + * ``` + */ +export function readCorsOptions(config: ConfigReader): CorsOptions | undefined { + const cc = config.getOptionalConfig('cors'); + if (!cc) { + return undefined; + } + + return removeUnknown({ + origin: getOptionalStringOrStrings(cc, 'origin'), + methods: getOptionalStringOrStrings(cc, 'methods'), + allowedHeaders: getOptionalStringOrStrings(cc, 'allowedHeaders'), + exposedHeaders: getOptionalStringOrStrings(cc, 'exposedHeaders'), + credentials: cc.getOptionalBoolean('credentials'), + maxAge: cc.getOptionalNumber('maxAge'), + preflightContinue: cc.getOptionalBoolean('preflightContinue'), + optionsSuccessStatus: cc.getOptionalNumber('optionsSuccessStatus'), + }); +} + +function getOptionalStringOrStrings( + config: ConfigReader, + key: string, +): string | string[] | undefined { + const value = config.getOptional(key); + if ( + value === undefined || + typeof value === 'string' || + isStringArray(value) + ) { + return value; + } + throw new Error(`Expected string or array of strings, got ${typeof value}`); +} + +function isStringArray(value: any): value is string[] { + if (!Array.isArray(value)) { + return false; + } + for (const v of value) { + if (typeof v !== 'string') { + return false; + } + } + return true; +} + +function removeUnknown(obj: T): T { + return Object.fromEntries( + Object.entries(obj).filter(([, v]) => v !== undefined), + ) as T; +} + +function parseListenAddress(value: string): { host?: string; port?: number } { + const parts = value.split(':'); + if (parts.length === 1) { + return { port: parseInt(parts[0], 10) }; + } + if (parts.length === 2) { + return { host: parts[0], port: parseInt(parts[1], 10) }; + } + throw new Error( + `Unable to parse listen address ${value}, expected or :`, + ); +} diff --git a/packages/backend-common/src/service/types.ts b/packages/backend-common/src/service/types.ts index 306f0d587e..b39df27527 100644 --- a/packages/backend-common/src/service/types.ts +++ b/packages/backend-common/src/service/types.ts @@ -14,12 +14,20 @@ * limitations under the License. */ +import { ConfigReader } from '@backstage/config'; import cors from 'cors'; import { Router } from 'express'; import { Server } from 'http'; import { Logger } from 'winston'; export type ServiceBuilder = { + /** + * Sets the service parameters based on configuration. + * + * @param config The configuration to read + */ + loadConfig(config: ConfigReader): ServiceBuilder; + /** * Sets the port to listen on. * diff --git a/packages/backend/package.json b/packages/backend/package.json index 516208bf3b..ec54da358c 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.1.1-alpha.9", + "version": "0.1.1-alpha.12", "main": "dist/index.cjs.js", "types": "src/index.ts", "private": true, @@ -17,22 +17,22 @@ "migrate:create": "knex migrate:make -x ts" }, "dependencies": { - "@backstage/backend-common": "^0.1.1-alpha.9", - "@backstage/catalog-model": "^0.1.1-alpha.9", - "@backstage/config": "^0.1.1-alpha.9", - "@backstage/config-loader": "^0.1.1-alpha.9", - "@backstage/plugin-auth-backend": "^0.1.1-alpha.9", - "@backstage/plugin-catalog-backend": "^0.1.1-alpha.9", - "@backstage/plugin-identity-backend": "^0.1.1-alpha.9", - "@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.9", - "@backstage/plugin-sentry-backend": "^0.1.1-alpha.9", + "@backstage/backend-common": "^0.1.1-alpha.12", + "@backstage/catalog-model": "^0.1.1-alpha.12", + "@backstage/config": "^0.1.1-alpha.12", + "@backstage/config-loader": "^0.1.1-alpha.12", + "@backstage/plugin-auth-backend": "^0.1.1-alpha.12", + "@backstage/plugin-catalog-backend": "^0.1.1-alpha.12", + "@backstage/plugin-identity-backend": "^0.1.1-alpha.12", + "@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.12", + "@backstage/plugin-sentry-backend": "^0.1.1-alpha.12", "express": "^4.17.1", "knex": "^0.21.1", "sqlite3": "^4.2.0", "winston": "^3.2.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.9", + "@backstage/cli": "^0.1.1-alpha.12", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", "@types/helmet": "^0.0.47" diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index df0d08fa7d..3246c5f6f5 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -55,7 +55,9 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) { } async function main() { - const createEnv = makeCreateEnv(await loadConfig()); + 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')); @@ -63,10 +65,7 @@ async function main() { const identityEnv = useHotMemoize(module, () => createEnv('identity')); const service = createServiceBuilder(module) - .enableCors({ - origin: 'http://localhost:3000', - credentials: true, - }) + .loadConfig(configReader) .addRouter('/catalog', await catalog(catalogEnv)) .addRouter('/scaffolder', await scaffolder(scaffolderEnv)) .addRouter( diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts index a9c687cbc4..b24dd6ca6b 100644 --- a/packages/backend/src/plugins/auth.ts +++ b/packages/backend/src/plugins/auth.ts @@ -19,7 +19,8 @@ import { PluginEnvironment } from '../types'; export default async function createPlugin({ logger, + database, config, }: PluginEnvironment) { - return await createRouter({ logger, config }); + return await createRouter({ logger, config, database }); } diff --git a/packages/backend/src/plugins/scaffolder.ts b/packages/backend/src/plugins/scaffolder.ts index 08e700bc74..95d28eacb1 100644 --- a/packages/backend/src/plugins/scaffolder.ts +++ b/packages/backend/src/plugins/scaffolder.ts @@ -17,13 +17,20 @@ import { CookieCutter, createRouter, - DiskStorage, + FilePreparer, + GithubPreparer, + Preparers, } from '@backstage/plugin-scaffolder-backend'; import type { PluginEnvironment } from '../types'; export default async function createPlugin({ logger }: PluginEnvironment) { - const storage = new DiskStorage({ logger }); const templater = new CookieCutter(); + const filePreparer = new FilePreparer(); + const githubPreparer = new GithubPreparer(); + const preparers = new Preparers(); - return await createRouter({ storage, templater, logger }); + preparers.register('file', filePreparer); + preparers.register('github', githubPreparer); + + return await createRouter({ preparers, templater, logger }); } diff --git a/plugins/catalog-backend/examples/artist-lookup-component.yaml b/packages/catalog-model/examples/artist-lookup-component.yaml similarity index 84% rename from plugins/catalog-backend/examples/artist-lookup-component.yaml rename to packages/catalog-model/examples/artist-lookup-component.yaml index bb7d8d5767..448dd13fa3 100644 --- a/plugins/catalog-backend/examples/artist-lookup-component.yaml +++ b/packages/catalog-model/examples/artist-lookup-component.yaml @@ -6,4 +6,4 @@ metadata: spec: type: service lifecycle: experimental - owner: tools@example.com + owner: artists@example.com diff --git a/packages/catalog-model/examples/playback-lib-component.yaml b/packages/catalog-model/examples/playback-lib-component.yaml new file mode 100644 index 0000000000..c32332d4d2 --- /dev/null +++ b/packages/catalog-model/examples/playback-lib-component.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: playback-sdk + description: Audio and video playback SDK +spec: + type: library + lifecycle: experimental + owner: players@example.com diff --git a/plugins/catalog-backend/examples/playback-order-component.yaml b/packages/catalog-model/examples/playback-order-component.yaml similarity index 85% rename from plugins/catalog-backend/examples/playback-order-component.yaml rename to packages/catalog-model/examples/playback-order-component.yaml index fa9f68e77a..4f93b65edf 100644 --- a/plugins/catalog-backend/examples/playback-order-component.yaml +++ b/packages/catalog-model/examples/playback-order-component.yaml @@ -6,4 +6,4 @@ metadata: spec: type: service lifecycle: production - owner: tools@example.com + owner: guest diff --git a/plugins/catalog-backend/examples/podcast-api-component.yaml b/packages/catalog-model/examples/podcast-api-component.yaml similarity index 84% rename from plugins/catalog-backend/examples/podcast-api-component.yaml rename to packages/catalog-model/examples/podcast-api-component.yaml index 2a27a0a2c5..19f9a3d4de 100644 --- a/plugins/catalog-backend/examples/podcast-api-component.yaml +++ b/packages/catalog-model/examples/podcast-api-component.yaml @@ -6,4 +6,4 @@ metadata: spec: type: service lifecycle: experimental - owner: tools@example.com + owner: players@example.com diff --git a/plugins/catalog-backend/examples/queue-proxy-component.yaml b/packages/catalog-model/examples/queue-proxy-component.yaml similarity index 100% rename from plugins/catalog-backend/examples/queue-proxy-component.yaml rename to packages/catalog-model/examples/queue-proxy-component.yaml diff --git a/plugins/catalog-backend/examples/searcher-component.yaml b/packages/catalog-model/examples/searcher-component.yaml similarity index 84% rename from plugins/catalog-backend/examples/searcher-component.yaml rename to packages/catalog-model/examples/searcher-component.yaml index d82f6e42c7..33d765117c 100644 --- a/plugins/catalog-backend/examples/searcher-component.yaml +++ b/packages/catalog-model/examples/searcher-component.yaml @@ -6,4 +6,4 @@ metadata: spec: type: service lifecycle: production - owner: tools@example.com + owner: guest diff --git a/plugins/catalog-backend/examples/shuffle-api-component.yaml b/packages/catalog-model/examples/shuffle-api-component.yaml similarity index 85% rename from plugins/catalog-backend/examples/shuffle-api-component.yaml rename to packages/catalog-model/examples/shuffle-api-component.yaml index ed0517835c..275c48d6ba 100644 --- a/plugins/catalog-backend/examples/shuffle-api-component.yaml +++ b/packages/catalog-model/examples/shuffle-api-component.yaml @@ -6,4 +6,4 @@ metadata: spec: type: service lifecycle: production - owner: tools@example.com + owner: guest diff --git a/packages/catalog-model/examples/www-artist-component.yaml b/packages/catalog-model/examples/www-artist-component.yaml new file mode 100644 index 0000000000..84379c93fc --- /dev/null +++ b/packages/catalog-model/examples/www-artist-component.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: www-artist + description: Artist main website +spec: + type: website + lifecycle: production + owner: artists@example.com diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 2516263e8a..889d9d2e02 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/catalog-model", - "version": "0.1.1-alpha.9", + "version": "0.1.1-alpha.12", "main": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", @@ -20,20 +20,20 @@ "clean": "backstage-cli clean" }, "dependencies": { - "@backstage/config": "^0.1.1-alpha.9", + "@backstage/config": "^0.1.1-alpha.12", "@types/yup": "^0.28.2", "lodash": "^4.17.15", "uuid": "^8.0.0", - "yup": "^0.28.5" + "yup": "^0.29.1" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.9", + "@backstage/cli": "^0.1.1-alpha.12", "@types/express": "^4.17.6", "@types/jest": "^25.2.2", "@types/lodash": "^4.14.151", "yaml": "^1.9.2" }, "files": [ - "dist/**/*.{js,d.ts}" + "dist" ] } diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts index 090c892294..0bef220a63 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts @@ -26,6 +26,7 @@ export interface TemplateEntityV1alpha1 extends Entity { kind: typeof KIND; spec: { type: string; + path?: string; }; } @@ -39,6 +40,7 @@ export class TemplateEntityV1alpha1Policy implements EntityPolicy { spec: yup .object({ type: yup.string().required().min(1), + path: yup.string(), }) .required(), }); diff --git a/packages/cli/src/commands/plugin/assets.d.ts b/packages/cli/asset-types/asset-types.d.ts similarity index 83% rename from packages/cli/src/commands/plugin/assets.d.ts rename to packages/cli/asset-types/asset-types.d.ts index 34bb6acb70..1763d256f5 100644 --- a/packages/cli/src/commands/plugin/assets.d.ts +++ b/packages/cli/asset-types/asset-types.d.ts @@ -14,16 +14,12 @@ * limitations under the License. */ +/* eslint-disable import/no-extraneous-dependencies */ + /// /// /// -declare namespace NodeJS { - interface ProcessEnv { - readonly NODE_ENV: 'development' | 'production' | 'test'; - } -} - declare module '*.bmp' { const src: string; export default src; @@ -54,13 +50,15 @@ declare module '*.webp' { export default src; } +declare module '*.icon.svg' { + import { ComponentType } from 'react'; + import { SvgIconProps } from '@material-ui/core'; + + const Icon: ComponentType; + export default Icon; +} + declare module '*.svg' { - import * as React from 'react'; - - export const ReactComponent: React.FunctionComponent & { title?: string }>; - const src: string; export default src; } @@ -94,7 +92,3 @@ declare module '*.module.sass' { const classes: { readonly [key: string]: string }; export default classes; } - -declare module 'rollup-plugin-image-files' { - export default function image(): any; -} diff --git a/packages/cli/asset-types/asset-types.js b/packages/cli/asset-types/asset-types.js new file mode 100644 index 0000000000..65ef751860 --- /dev/null +++ b/packages/cli/asset-types/asset-types.js @@ -0,0 +1,2 @@ +// NOOP, this is just here to unbreak module resolution +// eslint-disable-next-line notice/notice diff --git a/packages/cli/asset-types/package.json b/packages/cli/asset-types/package.json new file mode 100644 index 0000000000..ffa5e09fc1 --- /dev/null +++ b/packages/cli/asset-types/package.json @@ -0,0 +1,5 @@ +{ + "main": "asset-types.js", + "types": "asset-types.d.ts", + "description": "Provides types for asset files that are handled by the Backstage CLI" +} diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js index ad24976ad0..80e0bcf26c 100644 --- a/packages/cli/config/jest.js +++ b/packages/cli/config/jest.js @@ -38,11 +38,16 @@ async function getConfig() { transform: { '\\.esm\\.js$': require.resolve('jest-esm-transformer'), '\\.(js|jsx|ts|tsx)': require.resolve('ts-jest'), + '\\.(bmp|gif|jpe|png|frag|xml|svg)': require.resolve( + './jestFileTransform.js', + ), }, // Default behaviour is to not apply transforms for node_modules, but we still want // to apply the esm-transformer to .esm.js files, since that's what we use in backstage packages. - transformIgnorePatterns: ['/node_modules/(?!.*\\.esm\\.js$)'], + transformIgnorePatterns: [ + '/node_modules/(?!.*\\.(?:esm\\.js|bmp|gif|jpe|png|frag|xml|svg)$)', + ], }; // Use src/setupTests.ts as the default location for configuring test env diff --git a/packages/cli/config/jestFileTransform.js b/packages/cli/config/jestFileTransform.js new file mode 100644 index 0000000000..e6ff1895f8 --- /dev/null +++ b/packages/cli/config/jestFileTransform.js @@ -0,0 +1,42 @@ +/* + * 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. + */ + +const path = require('path'); + +module.exports = { + process(src, filename) { + const assetFilename = JSON.stringify(path.basename(filename)); + + if (filename.match(/\.icon\.svg$/)) { + return `const React = require('react'); + const SvgIcon = require('@material-ui/core/SvgIcon').default; + module.exports = { + __esModule: true, + default: props => React.createElement(SvgIcon, props, { + $$typeof: Symbol.for('react.element'), + type: 'svg', + ref: ref, + key: null, + props: Object.assign({}, props, { + children: ${assetFilename} + }) + }) + };`; + } + + return `module.exports = ${assetFilename};`; + }, +}; diff --git a/packages/cli/package.json b/packages/cli/package.json index 96ca8e4a4f..f74c1b1fb0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "0.1.1-alpha.9", + "version": "0.1.1-alpha.12", "private": false, "publishConfig": { "access": "public" @@ -29,16 +29,20 @@ "backstage-cli": "bin/backstage-cli" }, "dependencies": { - "@backstage/config": "0.1.1-alpha.9", - "@backstage/config-loader": "^0.1.1-alpha.9", + "@backstage/config": "^0.1.1-alpha.12", + "@backstage/config-loader": "^0.1.1-alpha.12", "@hot-loader/react-dom": "^16.13.0", "@lerna/package-graph": "^3.18.5", "@lerna/project": "^3.18.0", - "@rollup/plugin-commonjs": "^12.0.0", + "@rollup/plugin-commonjs": "^13.0.0", "@rollup/plugin-json": "^4.0.2", - "@rollup/plugin-node-resolve": "^7.1.1", + "@rollup/plugin-node-resolve": "^8.1.0", "@spotify/eslint-config": "^7.0.1", "@sucrase/webpack-loader": "^2.0.0", + "@svgr/plugin-jsx": "4.3.x", + "@svgr/plugin-svgo": "4.3.x", + "@svgr/rollup": "4.3.x", + "@svgr/webpack": "4.3.x", "@types/start-server-webpack-plugin": "^2.2.0", "@types/webpack-env": "^1.15.2", "@types/webpack-node-externals": "^1.7.1", @@ -112,6 +116,7 @@ "zombie": "^6.1.4" }, "files": [ + "asset-types", "templates", "config", "bin", diff --git a/packages/cli/src/lib/bundler/config.ts b/packages/cli/src/lib/bundler/config.ts index 6fe8092e50..aab244f7e9 100644 --- a/packages/cli/src/lib/bundler/config.ts +++ b/packages/cli/src/lib/bundler/config.ts @@ -178,8 +178,7 @@ export function createBackendConfig( context: paths.targetPath, entry: [ 'webpack/hot/poll?100', - paths.targetEntry, - ...(paths.targetRunFile ? [paths.targetRunFile] : []), + paths.targetRunFile ? paths.targetRunFile : paths.targetEntry, ], resolve: { extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'], diff --git a/packages/cli/src/lib/bundler/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts index 0cd800f2ea..5f881bd53c 100644 --- a/packages/cli/src/lib/bundler/transforms.ts +++ b/packages/cli/src/lib/bundler/transforms.ts @@ -17,6 +17,7 @@ import webpack, { Module, Plugin } from 'webpack'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import { BundlingOptions, BackendBundlingOptions } from './types'; +import { svgrTemplate } from '../svgrTemplate'; type Transforms = { loaders: Module['rules']; @@ -46,7 +47,28 @@ export const transforms = ( }, }, { - test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/, /\.frag/, /\.xml/], + test: [/\.icon\.svg$/], + use: [ + { + loader: require.resolve('@sucrase/webpack-loader'), + options: { transforms: ['jsx'] }, + }, + { + loader: require.resolve('@svgr/webpack'), + options: { babel: false, template: svgrTemplate }, + }, + ], + }, + { + test: [ + /\.bmp$/, + /\.gif$/, + /\.jpe?g$/, + /\.png$/, + /\.frag/, + { test: /\.svg/, not: [/\.icon\.svg/] }, + /\.xml/, + ], loader: require.resolve('url-loader'), options: { limit: 10000, diff --git a/packages/cli/src/lib/packager/config.ts b/packages/cli/src/lib/packager/config.ts index 30680ef3b9..5987290604 100644 --- a/packages/cli/src/lib/packager/config.ts +++ b/packages/cli/src/lib/packager/config.ts @@ -23,12 +23,14 @@ import resolve from '@rollup/plugin-node-resolve'; import postcss from 'rollup-plugin-postcss'; import esbuild from 'rollup-plugin-esbuild'; import imageFiles from 'rollup-plugin-image-files'; +import svgr from '@svgr/rollup'; import dts from 'rollup-plugin-dts'; import json from '@rollup/plugin-json'; import { RollupOptions, OutputOptions } from 'rollup'; import { BuildOptions, Output } from './types'; import { paths } from '../paths'; +import { svgrTemplate } from '../svgrTemplate'; export const makeConfigs = async ( options: BuildOptions, @@ -89,8 +91,12 @@ export const makeConfigs = async ( exclude: ['**/*.stories.*', '**/*.test.*'], }), postcss(), - imageFiles(), + imageFiles({ exclude: '**/*.icon.svg' }), json(), + svgr({ + include: '**/*.icon.svg', + template: svgrTemplate, + }), esbuild({ target: 'es2019', }), diff --git a/packages/cli/src/lib/svgrTemplate.ts b/packages/cli/src/lib/svgrTemplate.ts new file mode 100644 index 0000000000..5f7c7f9a4c --- /dev/null +++ b/packages/cli/src/lib/svgrTemplate.ts @@ -0,0 +1,47 @@ +/* + * 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. + */ + +/** + * This template, together with loaders in the bundler and packages, allows + * for SVG to be imported directly as MUI SvgIcon components by suffixing + * them with .icon.svg + */ +export function svgrTemplate( + { template }: any, + _opts: any, + { imports, interfaces, componentName, jsx }: any, +) { + const iconName = { + ...componentName, + name: `${componentName.name.replace(/icon$/, '')}Icon`, + }; + + const defaultExport = { + type: 'ExportDefaultDeclaration', + declaration: iconName, + }; + + const typeScriptTemplate = template.smart({ plugins: ['typescript'] }); + return typeScriptTemplate.ast` +${imports} +import SvgIcon from '@material-ui/core/SvgIcon'; + +${interfaces} + +const ${iconName} = props => React.createElement(SvgIcon, props, ${jsx.children}); + +${defaultExport}`; +} diff --git a/packages/cli/src/types.d.ts b/packages/cli/src/types.d.ts new file mode 100644 index 0000000000..8cb5a1e7df --- /dev/null +++ b/packages/cli/src/types.d.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +declare namespace NodeJS { + interface ProcessEnv { + readonly NODE_ENV: 'development' | 'production' | 'test'; + } +} + +declare module 'rollup-plugin-image-files' { + export default function image(options?: any): any; +} + +declare module '@svgr/rollup' { + export default function svgr(options?: any): any; +} diff --git a/packages/cli/templates/default-app/package.json.hbs b/packages/cli/templates/default-app/package.json.hbs index a96061a5bd..c70e00e4d0 100644 --- a/packages/cli/templates/default-app/package.json.hbs +++ b/packages/cli/templates/default-app/package.json.hbs @@ -31,6 +31,9 @@ "lerna": "^3.20.2", "prettier": "^1.19.1" }, + "resolutions": { + "**/esbuild": "0.5.3" + }, "prettier": "@spotify/prettier-config", "lint-staged": { "*.{js,jsx,ts,tsx}": [ 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 346e09a76a..31e6baa63e 100644 --- a/packages/cli/templates/default-app/packages/app/package.json.hbs +++ b/packages/cli/templates/default-app/packages/app/package.json.hbs @@ -8,20 +8,22 @@ "@material-ui/lab": "4.0.0-alpha.45", "@backstage/cli": "^{{version}}", "@backstage/core": "^{{version}}", + "@backstage/test-utils": "^{{version}}", "@backstage/theme": "^{{version}}", "plugin-welcome": "0.0.0", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-router": "6.0.0-alpha.5", "react-router-dom": "6.0.0-alpha.5", "react-use": "^14.2.0" }, "devDependencies": { - "@testing-library/jest-dom": "^5.7.0", - "@testing-library/react": "^9.3.2", - "@testing-library/user-event": "^10.2.4", + "@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/node": "^12.0.0", - "@types/testing-library__jest-dom": "^5.0.4", + "@types/react-dom": "^16.9.8", "cross-env": "^7.0.0", "cypress": "^4.2.0", "eslint-plugin-cypress": "^2.10.3", diff --git a/packages/cli/templates/default-app/packages/app/public/index.html b/packages/cli/templates/default-app/packages/app/public/index.html index 3d01107696..ea9208ca57 100644 --- a/packages/cli/templates/default-app/packages/app/public/index.html +++ b/packages/cli/templates/default-app/packages/app/public/index.html @@ -8,47 +8,38 @@ name="description" content="Backstage is an open platform for building developer portals" /> - + - - - + + - Backstage + <%= app.title %> - +