From ae08281331c96e526f82d9e33930179438511067 Mon Sep 17 00:00:00 2001 From: Oleg S <97077423+RobotSail@users.noreply.github.com> Date: Wed, 15 Feb 2023 14:31:50 -0500 Subject: [PATCH] expose the todos-backend plugin using the new plugin system Signed-off-by: Oleg S <97077423+RobotSail@users.noreply.github.com> --- packages/backend-next/src/index.ts | 2 + plugins/todo-backend/src/lib/utils.ts | 107 ++++++++++++++++++ plugins/todo-backend/src/plugin.ts | 86 ++++++++++++++ .../src/service/TodoReaderService.ts | 35 +++++- plugins/todo-backend/src/service/index.ts | 2 + plugins/todo-backend/src/service/plugin.ts | 94 +++++++++++++++ plugins/todo-backend/src/service/router.ts | 106 ++--------------- plugins/todo-backend/src/service/types.ts | 8 ++ 8 files changed, 340 insertions(+), 100 deletions(-) create mode 100644 plugins/todo-backend/src/lib/utils.ts create mode 100644 plugins/todo-backend/src/plugin.ts create mode 100644 plugins/todo-backend/src/service/plugin.ts diff --git a/packages/backend-next/src/index.ts b/packages/backend-next/src/index.ts index 97b6eaf32b..a2f542afb1 100644 --- a/packages/backend-next/src/index.ts +++ b/packages/backend-next/src/index.ts @@ -18,10 +18,12 @@ import { catalogPlugin } from '@backstage/plugin-catalog-backend'; import { catalogModuleTemplateKind } from '@backstage/plugin-scaffolder-backend'; import { createBackend } from '@backstage/backend-defaults'; import { appPlugin } from '@backstage/plugin-app-backend'; +import { todosPlugin } from '@backstage/plugin-todo-backend'; const backend = createBackend(); backend.add(catalogPlugin()); backend.add(catalogModuleTemplateKind()); backend.add(appPlugin({ appPackageName: 'example-app' })); +backend.add(todosPlugin()); backend.start(); diff --git a/plugins/todo-backend/src/lib/utils.ts b/plugins/todo-backend/src/lib/utils.ts new file mode 100644 index 0000000000..cf0c2e4ea6 --- /dev/null +++ b/plugins/todo-backend/src/lib/utils.ts @@ -0,0 +1,107 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InputError } from '@backstage/errors'; + +export const parseIntegerParam = ( + str: unknown, + ctx: string, +): number | undefined => { + if (str === undefined) { + return undefined; + } + if (typeof str !== 'string') { + throw new InputError(`invalid ${ctx}, must be a string`); + } + const parsed = parseInt(str, 10); + if (!Number.isInteger(parsed) || String(parsed) !== str) { + throw new InputError(`invalid ${ctx}, not an integer`); + } + return parsed; +}; + +export const parseOrderByParam = ( + str: unknown, + allowedFields: T, +): { field: T[number]; direction: 'asc' | 'desc' } | undefined => { + if (str === undefined) { + return undefined; + } + if (typeof str !== 'string') { + throw new InputError(`invalid orderBy query, must be a string`); + } + const [field, direction] = str.split('='); + if (!field) { + throw new InputError(`invalid orderBy query, field name is empty`); + } + if (direction !== 'asc' && direction !== 'desc') { + throw new InputError( + `invalid orderBy query, order direction must be 'asc' or 'desc'`, + ); + } + + if (field && !allowedFields.includes(field)) { + throw new InputError( + `invalid orderBy field, must be one of ${allowedFields.join(', ')}`, + ); + } + return { field, direction }; +}; + +export const parseFilterParam = ( + str: unknown, + allowedFields: T, +): { field: T[number]; value: string }[] | undefined => { + if (str === undefined) { + return undefined; + } + + const filters = new Array<{ field: T[number]; value: string }>(); + + const strs = [str].flat(); + for (const filterStr of strs) { + if (typeof filterStr !== 'string') { + throw new InputError( + `invalid filter query, must be a string or list of strings`, + ); + } + const splitIndex = filterStr.indexOf('='); + if (splitIndex <= 0) { + throw new InputError( + `invalid filter query, must separate field from value using '='`, + ); + } + + const field = filterStr.slice(0, splitIndex); + if (!allowedFields.includes(field)) { + throw new InputError( + `invalid filter field, must be one of ${allowedFields.join(', ')}`, + ); + } + + const value = filterStr.slice(splitIndex + 1); + if (!value) { + throw new InputError(`invalid filter query, value may not be empty`); + } + filters.push({ field, value }); + } + + return filters; +}; + +export const getBearerToken = (header?: string): string | undefined => { + return header?.match(/Bearer\s+(\S+)/i)?.[1]; +}; diff --git a/plugins/todo-backend/src/plugin.ts b/plugins/todo-backend/src/plugin.ts new file mode 100644 index 0000000000..49e8d354b1 --- /dev/null +++ b/plugins/todo-backend/src/plugin.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createBackendPlugin, + coreServices, +} from '@backstage/backend-plugin-api'; +import { CompoundEntityRef, parseEntityRef } from '@backstage/catalog-model'; +import { InputError } from '@backstage/errors'; +import express, { Router } from 'express'; +import { + getBearerToken, + parseFilterParam, + parseIntegerParam, + parseOrderByParam, +} from './lib/utils'; +import { todoReaderServiceRef } from './service/TodoReaderService'; +import { TODO_FIELDS } from './service/types'; + +/** + * The Todos plugin is responsible for aggregating todo comments within source. + * @alpha + */ +export const todosPlugin = createBackendPlugin({ + pluginId: 'todo-backend', + register(env) { + env.registerInit({ + deps: { + todoReader: todoReaderServiceRef, + http: coreServices.httpRouter, + }, + async init({ http, todoReader }) { + const router = Router(); + router.use(express.json()); + + router.get('/v1/todos', async (req, res) => { + const offset = parseIntegerParam(req.query.offset, 'offset query'); + const limit = parseIntegerParam(req.query.limit, 'limit query'); + const orderBy = parseOrderByParam(req.query.orderBy, TODO_FIELDS); + const filters = parseFilterParam(req.query.filter, TODO_FIELDS); + + const entityRef = req.query.entity; + if (entityRef && typeof entityRef !== 'string') { + throw new InputError(`entity query must be a string`); + } + let entity: CompoundEntityRef | undefined = undefined; + if (entityRef) { + try { + entity = parseEntityRef(entityRef); + } catch (error) { + throw new InputError(`Invalid entity ref, ${error}`); + } + } + + const todos = await todoReader.listTodos( + { + entity, + offset, + limit, + orderBy, + filters, + }, + { + token: getBearerToken(req.headers.authorization), + }, + ); + res.json(todos); + }); + http.use(router); + }, + }); + }, +}); diff --git a/plugins/todo-backend/src/service/TodoReaderService.ts b/plugins/todo-backend/src/service/TodoReaderService.ts index 35fd76b8be..93908c0f9f 100644 --- a/plugins/todo-backend/src/service/TodoReaderService.ts +++ b/plugins/todo-backend/src/service/TodoReaderService.ts @@ -20,8 +20,15 @@ import { getEntitySourceLocation, stringifyEntityRef, } from '@backstage/catalog-model'; -import { TodoReader } from '../lib'; +import { TodoReader, TodoScmReader } from '../lib'; import { ListTodosRequest, ListTodosResponse, TodoService } from './types'; +import { + coreServices, + createServiceFactory, + createServiceRef, +} from '@backstage/backend-plugin-api'; +import { catalogServiceRef } from '@backstage/plugin-catalog-node'; +import { loggerToWinstonLogger } from '@backstage/backend-common'; const DEFAULT_DEFAULT_PAGE_SIZE = 10; const DEFAULT_MAX_PAGE_SIZE = 50; @@ -130,3 +137,29 @@ export class TodoReaderService implements TodoService { }; } } + +export const todoReaderServiceRef = createServiceRef({ + id: 'todo-client', + defaultFactory: async service => + createServiceFactory({ + service, + deps: { + catalogApi: catalogServiceRef, + logger: coreServices.logger, + reader: coreServices.urlReader, + config: coreServices.config, + }, + async factory({ catalogApi, logger, reader, config }) { + const winstonLogger = loggerToWinstonLogger(logger); + const todoReader = TodoScmReader.fromConfig(config, { + reader: reader, + logger: winstonLogger, + }); + return new TodoReaderService({ + catalogClient: catalogApi, + todoReader, + // TODO: override defaults here + }); + }, + }), +}); diff --git a/plugins/todo-backend/src/service/index.ts b/plugins/todo-backend/src/service/index.ts index 380d95dbe1..3dfff3f33e 100644 --- a/plugins/todo-backend/src/service/index.ts +++ b/plugins/todo-backend/src/service/index.ts @@ -19,3 +19,5 @@ export type { RouterOptions } from './router'; export type { TodoService, ListTodosRequest, ListTodosResponse } from './types'; export { TodoReaderService } from './TodoReaderService'; export type { TodoReaderServiceOptions } from './TodoReaderService'; +export { todosPlugin } from './plugin'; +export type { TodosPluginDependencies } from './plugin'; diff --git a/plugins/todo-backend/src/service/plugin.ts b/plugins/todo-backend/src/service/plugin.ts new file mode 100644 index 0000000000..54b089221f --- /dev/null +++ b/plugins/todo-backend/src/service/plugin.ts @@ -0,0 +1,94 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createBackendPlugin, + coreServices, + HttpRouterService, +} from '@backstage/backend-plugin-api'; +import { CompoundEntityRef, parseEntityRef } from '@backstage/catalog-model'; +import { InputError } from '@backstage/errors'; +import express, { Router } from 'express'; +import { + getBearerToken, + parseFilterParam, + parseIntegerParam, + parseOrderByParam, +} from '../lib/utils'; +import { todoReaderServiceRef } from './TodoReaderService'; +import { TodoService, TODO_FIELDS } from './types'; + +export type TodosPluginDependencies = { + todoReader: TodoService; + http: HttpRouterService; +}; + +const todosPluginInit = async (params: TodosPluginDependencies) => { + const { http, todoReader } = params; + const router = Router(); + router.use(express.json()); + + router.get('/v1/todos', async (req, res) => { + const offset = parseIntegerParam(req.query.offset, 'offset query'); + const limit = parseIntegerParam(req.query.limit, 'limit query'); + const orderBy = parseOrderByParam(req.query.orderBy, TODO_FIELDS); + const filters = parseFilterParam(req.query.filter, TODO_FIELDS); + + const entityRef = req.query.entity; + if (entityRef && typeof entityRef !== 'string') { + throw new InputError(`entity query must be a string`); + } + let entity: CompoundEntityRef | undefined = undefined; + if (entityRef) { + try { + entity = parseEntityRef(entityRef); + } catch (error) { + throw new InputError(`Invalid entity ref, ${error}`); + } + } + + const todos = await todoReader.listTodos( + { + entity, + offset, + limit, + orderBy, + filters, + }, + { + token: getBearerToken(req.headers.authorization), + }, + ); + res.json(todos); + }); + http.use(router); +}; + +/** + * @alpha + */ +export const todosPlugin = createBackendPlugin({ + pluginId: 'todo-backend', + register(env) { + env.registerInit({ + deps: { + todoReader: todoReaderServiceRef, + http: coreServices.httpRouter, + }, + init: todosPluginInit, + }); + }, +}); diff --git a/plugins/todo-backend/src/service/router.ts b/plugins/todo-backend/src/service/router.ts index 33356a83d6..5954fab009 100644 --- a/plugins/todo-backend/src/service/router.ts +++ b/plugins/todo-backend/src/service/router.ts @@ -18,15 +18,13 @@ import { CompoundEntityRef, parseEntityRef } from '@backstage/catalog-model'; import { InputError } from '@backstage/errors'; import express from 'express'; import Router from 'express-promise-router'; -import { TodoService } from './types'; - -const TODO_FIELDS = [ - 'text', - 'tag', - 'author', - 'viewUrl', - 'repoFilePath', -] as const; +import { type TodoService, TODO_FIELDS } from './types'; +import { + getBearerToken, + parseFilterParam, + parseIntegerParam, + parseOrderByParam, +} from '../lib/utils'; /** @public */ export interface RouterOptions { @@ -78,93 +76,3 @@ export async function createRouter( return router; } - -export function parseIntegerParam( - str: unknown, - ctx: string, -): number | undefined { - if (str === undefined) { - return undefined; - } - if (typeof str !== 'string') { - throw new InputError(`invalid ${ctx}, must be a string`); - } - const parsed = parseInt(str, 10); - if (!Number.isInteger(parsed) || String(parsed) !== str) { - throw new InputError(`invalid ${ctx}, not an integer`); - } - return parsed; -} - -export function parseOrderByParam( - str: unknown, - allowedFields: T, -): { field: T[number]; direction: 'asc' | 'desc' } | undefined { - if (str === undefined) { - return undefined; - } - if (typeof str !== 'string') { - throw new InputError(`invalid orderBy query, must be a string`); - } - const [field, direction] = str.split('='); - if (!field) { - throw new InputError(`invalid orderBy query, field name is empty`); - } - if (direction !== 'asc' && direction !== 'desc') { - throw new InputError( - `invalid orderBy query, order direction must be 'asc' or 'desc'`, - ); - } - - if (field && !allowedFields.includes(field)) { - throw new InputError( - `invalid orderBy field, must be one of ${allowedFields.join(', ')}`, - ); - } - return { field, direction }; -} - -export function parseFilterParam( - str: unknown, - allowedFields: T, -): { field: T[number]; value: string }[] | undefined { - if (str === undefined) { - return undefined; - } - - const filters = new Array<{ field: T[number]; value: string }>(); - - const strs = [str].flat(); - for (const filterStr of strs) { - if (typeof filterStr !== 'string') { - throw new InputError( - `invalid filter query, must be a string or list of strings`, - ); - } - const splitIndex = filterStr.indexOf('='); - if (splitIndex <= 0) { - throw new InputError( - `invalid filter query, must separate field from value using '='`, - ); - } - - const field = filterStr.slice(0, splitIndex); - if (!allowedFields.includes(field)) { - throw new InputError( - `invalid filter field, must be one of ${allowedFields.join(', ')}`, - ); - } - - const value = filterStr.slice(splitIndex + 1); - if (!value) { - throw new InputError(`invalid filter query, value may not be empty`); - } - filters.push({ field, value }); - } - - return filters; -} - -function getBearerToken(header?: string): string | undefined { - return header?.match(/Bearer\s+(\S+)/i)?.[1]; -} diff --git a/plugins/todo-backend/src/service/types.ts b/plugins/todo-backend/src/service/types.ts index 88d2f2b340..ab15e20027 100644 --- a/plugins/todo-backend/src/service/types.ts +++ b/plugins/todo-backend/src/service/types.ts @@ -48,3 +48,11 @@ export interface TodoService { options?: { token?: string }, ): Promise; } + +export const TODO_FIELDS = [ + 'text', + 'tag', + 'author', + 'viewUrl', + 'repoFilePath', +] as const;