diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index d22d8c2be4..3033bc095e 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -39,13 +39,14 @@ import { PluginEnvironment } from './types'; const DEFAULT_PORT = 7000; const PORT = parseInt(process.env.PORT ?? '', 10) || DEFAULT_PORT; -const pluginEnvironment: PluginEnvironment = { - logger: getRootLogger().child({ type: 'plugin' }), -}; async function main() { const database = await buildDatabase(getRootLogger()); - console.log(await database.select().from('locations')); + + const pluginEnvironment: PluginEnvironment = { + logger: getRootLogger().child({ type: 'plugin' }), + database, + }; const app = express(); diff --git a/packages/backend/src/plugins/inventory.ts b/packages/backend/src/plugins/inventory.ts index d48a62b5fe..bf382df2ce 100644 --- a/packages/backend/src/plugins/inventory.ts +++ b/packages/backend/src/plugins/inventory.ts @@ -15,22 +15,12 @@ */ import { - AggregatorInventory, + DatabaseInventory, createRouter, - StaticInventory, } from '@backstage/plugin-inventory-backend'; -import type { PluginEnvironment } from '../types'; - -export default async function ({ logger }: PluginEnvironment) { - const inventory = new AggregatorInventory(); - inventory.enlist( - new StaticInventory([ - { id: 'component1' }, - { id: 'component2' }, - { id: 'component3' }, - { id: 'component4' }, - ]), - ); +import { PluginEnvironment } from '../types'; +export default async function({ logger, database }: PluginEnvironment) { + const inventory = new DatabaseInventory(database); return await createRouter({ inventory, logger }); } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 196337aec7..ce681a6bdd 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -14,8 +14,10 @@ * limitations under the License. */ -import type { Logger } from 'winston'; +import Knex from 'knex'; +import { Logger } from 'winston'; export type PluginEnvironment = { logger: Logger; + database: Knex; }; diff --git a/plugins/inventory-backend/package.json b/plugins/inventory-backend/package.json index 9a72daed5e..abebfe11e1 100644 --- a/plugins/inventory-backend/package.json +++ b/plugins/inventory-backend/package.json @@ -18,11 +18,16 @@ "express": "^4.17.1", "express-promise-router": "^3.0.3", "helmet": "^3.22.0", + "knex": "^0.21.1", "morgan": "^1.10.0", - "winston": "^3.2.1" + "uuid": "^8.0.0", + "winston": "^3.2.1", + "yup": "^0.28.5" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.4", + "@types/uuid": "^7.0.3", + "@types/yup": "^0.28.2", "jest-fetch-mock": "^3.0.3", "tsc-watch": "^4.2.3" }, diff --git a/plugins/inventory-backend/src/inventory/AggregatorInventory.ts b/plugins/inventory-backend/src/inventory/AggregatorInventory.ts deleted file mode 100644 index b768b8eb23..0000000000 --- a/plugins/inventory-backend/src/inventory/AggregatorInventory.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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 { NotFoundError } from '@backstage/backend-common'; -import { Component, Inventory } from './types'; - -export class AggregatorInventory implements Inventory { - inventories: Inventory[] = []; - - async list(): Promise { - const lists = await Promise.all(this.inventories.map((i) => i.list())); - return lists.flat(); - } - - item(id: string): Promise { - return new Promise((resolve, reject) => { - const promises = this.inventories.map((i) => - i.item(id).then(resolve, () => { - // For now, just swallow errors in individual inventories; - // should handle partial errors better - }), - ); - Promise.all(promises).then(() => - reject(new NotFoundError(`Found no component with ID ${id}`)), - ); - }); - } - - enlist(inventory: Inventory) { - this.inventories.push(inventory); - } -} diff --git a/plugins/inventory-backend/src/inventory/DatabaseInventory.ts b/plugins/inventory-backend/src/inventory/DatabaseInventory.ts new file mode 100644 index 0000000000..0afa1a6bcf --- /dev/null +++ b/plugins/inventory-backend/src/inventory/DatabaseInventory.ts @@ -0,0 +1,64 @@ +/* + * 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 { NotFoundError } from '@backstage/backend-common'; +import Knex from 'knex'; +import { v4 as uuidv4 } from 'uuid'; +import { AddLocationRequest, Component, Inventory, Location } from './types'; + +export class DatabaseInventory implements Inventory { + constructor(private readonly database: Knex) {} + + async components(): Promise { + throw new Error('Not supported'); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async component(id: string): Promise { + throw new Error('Not supported'); + } + + async addLocation(location: AddLocationRequest): Promise { + const id = uuidv4(); + const { type, target } = location; + await this.database('locations').insert({ id, type, target }); + return await this.location(id); + } + + async removeLocation(id: string): Promise { + const result = await this.database('locations') + .where({ id }) + .del(); + + if (!result) { + throw new NotFoundError(`Found no location with ID ${id}`); + } + } + + async location(id: string): Promise { + const items = await this.database('locations') + .where({ id }) + .select(); + if (!items.length) { + throw new NotFoundError(`Found no location with ID ${id}`); + } + return items[0]; + } + + async locations(): Promise { + return await this.database('locations').select(); + } +} diff --git a/plugins/inventory-backend/src/inventory/StaticInventory.ts b/plugins/inventory-backend/src/inventory/StaticInventory.ts index 4569ae8ed2..3ce6dfcd76 100644 --- a/plugins/inventory-backend/src/inventory/StaticInventory.ts +++ b/plugins/inventory-backend/src/inventory/StaticInventory.ts @@ -15,20 +15,49 @@ */ import { NotFoundError } from '@backstage/backend-common'; -import { Component, Inventory } from './types'; +import { v4 as uuidv4 } from 'uuid'; +import { AddLocationRequest, Component, Inventory, Location } from './types'; export class StaticInventory implements Inventory { - constructor(private components: Component[]) {} + private _components: Component[]; + private _locations: Location[]; - async list(): Promise { - return this.components.slice(); + constructor(components: Component[], locations: Location[]) { + this._components = components; + this._locations = locations; } - async item(id: string): Promise { - const item = this.components.find((i) => i.id === id); + async components(): Promise { + return this._components.slice(); + } + + async component(id: string): Promise { + const item = this._components.find(i => i.id === id); if (!item) { throw new NotFoundError(`Found no component with ID ${id}`); } return item; } + + async addLocation(location: AddLocationRequest): Promise { + const l = { id: uuidv4(), type: location.type, target: location.target }; + this._locations.push(l); + return l; + } + + async removeLocation(id: string): Promise { + this._locations = this._locations.filter(l => l.id !== id); + } + + async location(id: string): Promise { + const location = this._locations.find(l => l.id === id); + if (!location) { + throw new NotFoundError(`Found no location with ID ${id}`); + } + return location; + } + + async locations(): Promise { + return this._locations; + } } diff --git a/plugins/inventory-backend/src/inventory/index.ts b/plugins/inventory-backend/src/inventory/index.ts index 3affa1109a..e61ff20e19 100644 --- a/plugins/inventory-backend/src/inventory/index.ts +++ b/plugins/inventory-backend/src/inventory/index.ts @@ -14,6 +14,6 @@ * limitations under the License. */ -export * from './AggregatorInventory'; +export * from './DatabaseInventory'; export * from './StaticInventory'; export * from './types'; diff --git a/plugins/inventory-backend/src/inventory/types.ts b/plugins/inventory-backend/src/inventory/types.ts index 863dccb291..6d6ef38df1 100644 --- a/plugins/inventory-backend/src/inventory/types.ts +++ b/plugins/inventory-backend/src/inventory/types.ts @@ -14,11 +14,35 @@ * limitations under the License. */ +import * as yup from 'yup'; + export type Component = { id: string; }; -export type Inventory = { - list(): Promise; - item(id: string): Promise; +export type Location = { + id: string; + type: string; + target: string; +}; + +export type AddLocationRequest = { + type: string; + target: string; +}; + +export const addLocationRequestShape: yup.Schema = yup + .object({ + type: yup.string().required(), + target: yup.string().required(), + }) + .noUnknown(); + +export type Inventory = { + components(): Promise; + component(id: string): Promise; + addLocation(location: AddLocationRequest): Promise; + removeLocation(id: string): Promise; + location(id: string): Promise; + locations(): Promise; }; diff --git a/plugins/inventory-backend/src/service/router.ts b/plugins/inventory-backend/src/service/router.ts index a05fac08be..06077d29c4 100644 --- a/plugins/inventory-backend/src/service/router.ts +++ b/plugins/inventory-backend/src/service/router.ts @@ -14,34 +14,84 @@ * limitations under the License. */ -import express from 'express'; -import { Logger } from 'winston'; +import { InputError } from '@backstage/backend-common'; +import express, { Request } from 'express'; import Router from 'express-promise-router'; -import { Inventory } from '../inventory'; +import { Logger } from 'winston'; +import yup from 'yup'; +import { addLocationRequestShape, Inventory } from '../inventory'; export interface RouterOptions { inventory: Inventory; logger: Logger; } +async function validateRequestBody( + req: Request, + schema: yup.Schema, +): Promise { + const contentType = req.header('content-type'); + if (!contentType) { + throw new InputError('Content-Type missing'); + } else if (!contentType.match(/^application\/json($|;)/)) { + throw new InputError('Illegal Content-Type'); + } + + const body = req.body; + if (!body) { + throw new InputError('Missing request body'); + } + + try { + await schema.validate(body, { strict: true }); + } catch (e) { + throw new InputError(`Malformed request: ${e}`); + } + + return body as T; +} + export async function createRouter( options: RouterOptions, ): Promise { const inventory = options.inventory; const logger = options.logger.child({ plugin: 'inventory' }); - const router = Router(); + + // Components router - .get('/', async (req, res) => { - const components = await inventory.list(); + .get('/components', async (req, res) => { + const components = await inventory.components(); res.status(200).send(components); }) - .get('/:id', async (req, res) => { + .get('/components/:id', async (req, res) => { const { id } = req.params; - const component = await inventory.item(id); + const component = await inventory.component(id); res.status(200).send(component); }); + // Locations + router + .post('/locations', async (req, res) => { + const input = await validateRequestBody(req, addLocationRequestShape); + const output = await inventory.addLocation(input); + res.status(201).send(output); + }) + .get('/locations', async (req, res) => { + const output = await inventory.locations(); + res.status(200).send(output); + }) + .get('/locations/:id', async (req, res) => { + const { id } = req.params; + const output = await inventory.location(id); + res.status(200).send(output); + }) + .delete('/locations/:id', async (req, res) => { + const { id } = req.params; + await inventory.removeLocation(id); + res.status(200).send(); + }); + const app = express(); app.set('logger', logger); app.use('/', router); diff --git a/plugins/inventory-backend/src/service/standaloneServer.ts b/plugins/inventory-backend/src/service/standaloneServer.ts index be339c5ecc..76d57ff852 100644 --- a/plugins/inventory-backend/src/service/standaloneServer.ts +++ b/plugins/inventory-backend/src/service/standaloneServer.ts @@ -16,7 +16,7 @@ import { Server } from 'http'; import { Logger } from 'winston'; -import { AggregatorInventory, StaticInventory } from '../inventory'; +import { StaticInventory } from '../inventory'; import { createStandaloneApplication } from './standaloneApplication'; export interface ServerOptions { @@ -30,14 +30,14 @@ export async function startStandaloneServer( ): Promise { const logger = options.logger.child({ service: 'inventory-backend' }); - const inventory = new AggregatorInventory(); - inventory.enlist( - new StaticInventory([ + const inventory = new StaticInventory( + [ { id: 'component1' }, { id: 'component2' }, { id: 'component3' }, { id: 'component4' }, - ]), + ], + [], ); logger.debug('Creating application...'); diff --git a/plugins/inventory-backend/tsconfig.json b/plugins/inventory-backend/tsconfig.json index dd13cfe1bc..015a967f76 100644 --- a/plugins/inventory-backend/tsconfig.json +++ b/plugins/inventory-backend/tsconfig.json @@ -6,7 +6,7 @@ "sourceMap": true, "declaration": true, "strict": true, - "target": "es5", + "target": "es2019", "module": "commonjs", "esModuleInterop": true, "lib": ["es2019"], diff --git a/yarn.lock b/yarn.lock index 4ffa6ed014..6b716af0dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -956,6 +956,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.9.6": + version "7.9.6" + resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.6.tgz#a9102eb5cadedf3f31d08a9ecf294af7827ea29f" + integrity sha512-64AF1xY3OAkFHqOb9s4jpgk1Mm5vDZ4L3acHvAml+53nO1XbXLuDodsVpO4OIUsmemlUHMxNdYMNJmsvOwLrvQ== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.4.0", "@babel/template@^7.7.4", "@babel/template@^7.8.3", "@babel/template@^7.8.6": version "7.8.6" resolved "https://registry.npmjs.org/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b" @@ -4542,6 +4549,11 @@ dependencies: source-map "^0.6.1" +"@types/uuid@^7.0.3": + version "7.0.3" + resolved "https://registry.npmjs.org/@types/uuid/-/uuid-7.0.3.tgz#45cd03e98e758f8581c79c535afbd4fc27ba7ac8" + integrity sha512-PUdqTZVrNYTNcIhLHkiaYzoOIaUi5LFg/XLerAdgvwQrUCx+oSbtoBze1AMyvYbcwzUSNC+Isl58SM4Sm/6COw== + "@types/webpack-dev-server@*", "@types/webpack-dev-server@^3.10.0": version "3.10.1" resolved "https://registry.npmjs.org/@types/webpack-dev-server/-/webpack-dev-server-3.10.1.tgz#93b7133cc9dab1ca1b76659f5ef8b763ad54c28a" @@ -4598,6 +4610,11 @@ dependencies: "@types/yargs-parser" "*" +"@types/yup@^0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@types/yup/-/yup-0.28.2.tgz#ea6597d7bd48ecea94b81f8de7fd60fba07a4628" + integrity sha512-VvXPMsvUnkDIWHFIalJROljLSv9m9TaXKng5sLFUkmuybgY64AeBbZ5tOPZ9sjSpqhJFeOXIc22JSQDO6SCONg== + "@types/zen-observable@^0.8.0": version "0.8.0" resolved "https://registry.npmjs.org/@types/zen-observable/-/zen-observable-0.8.0.tgz#8b63ab7f1aa5321248aad5ac890a485656dcea4d" @@ -9912,6 +9929,11 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" +fn-name@~3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/fn-name/-/fn-name-3.0.0.tgz#0596707f635929634d791f452309ab41558e3c5c" + integrity sha512-eNMNr5exLoavuAMhIUVsOKF79SWd/zG104ef6sxBTSw+cZc6BXdQXDvYcGvp0VbxVVSp1XDUNoz7mg1xMtSznA== + focus-lock@^0.6.6: version "0.6.6" resolved "https://registry.npmjs.org/focus-lock/-/focus-lock-0.6.6.tgz#98119a755a38cfdbeda0280eaa77e307eee850c7" @@ -13920,6 +13942,11 @@ lockfile@^1.0.4: dependencies: signal-exit "^3.0.2" +lodash-es@^4.17.11: + version "4.17.15" + resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78" + integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ== + lodash._baseuniq@~4.6.0: version "4.6.0" resolved "https://registry.npmjs.org/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz#0ebb44e456814af7905c6212fa2c9b2d51b841e8" @@ -17300,6 +17327,11 @@ prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.0, object-assign "^4.1.1" react-is "^16.8.1" +property-expr@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/property-expr/-/property-expr-2.0.2.tgz#fff2a43919135553a3bc2fdd94bdb841965b2330" + integrity sha512-bc/5ggaYZxNkFKj374aLbEDqVADdYaLcFo8XBkishUWbaAdjlphaBFns9TvRA2pUseVL/wMFmui9X3IdNDU37g== + property-information@^5.0.0: version "5.4.0" resolved "https://registry.npmjs.org/property-information/-/property-information-5.4.0.tgz#16e08f13f4e5c4a7be2e4ec431c01c4f8dba869a" @@ -20210,6 +20242,11 @@ symbol.prototype.description@^1.0.0: es-abstract "^1.17.0-next.1" has-symbols "^1.0.1" +synchronous-promise@^2.0.10: + version "2.0.11" + resolved "https://registry.npmjs.org/synchronous-promise/-/synchronous-promise-2.0.11.tgz#e92022b0754e916f556d3ace1626d24a24214b7e" + integrity sha512-8/L5FOCjnlK0FoAfj+NqdCaImMKvEyOEzGmdfcezKp5K9HIukm4akx72endvM87eS/gU8kOxiMQflpC/CdkQAg== + table@^5.2.3: version "5.4.6" resolved "https://registry.npmjs.org/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e" @@ -20604,6 +20641,11 @@ toposort@^1.0.0: resolved "https://registry.npmjs.org/toposort/-/toposort-1.0.7.tgz#2e68442d9f64ec720b8cc89e6443ac6caa950029" integrity sha1-LmhELZ9k7HILjMieZEOsbKqVACk= +toposort@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" + integrity sha1-riF2gXXRVZ1IvvNUILL0li8JwzA= + touch@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b" @@ -21244,6 +21286,11 @@ uuid@^7.0.3: resolved "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b" integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== +uuid@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz#bc6ccf91b5ff0ac07bbcdbf1c7c4e150db4dbb6c" + integrity sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw== + v8-compile-cache@^2.0.3: version "2.1.0" resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e" @@ -22223,6 +22270,19 @@ yn@3.1.1: resolved "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== +yup@^0.28.5: + version "0.28.5" + resolved "https://registry.npmjs.org/yup/-/yup-0.28.5.tgz#85cabb4000d3623ef69be81551190692e631a8a5" + integrity sha512-7JZcvpUGUxMKoaEtcoMEM8lCWRaueGNH/A3EhL/UWqfbFm3uloiI+x59Yq4nzhbbYWUTwAsCteaZOJ+VbqI1uw== + dependencies: + "@babel/runtime" "^7.9.6" + fn-name "~3.0.0" + lodash "^4.17.15" + lodash-es "^4.17.11" + property-expr "^2.0.2" + synchronous-promise "^2.0.10" + toposort "^2.0.2" + zen-observable@^0.8.15: version "0.8.15" resolved "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15"