diff --git a/.changeset/bright-kids-raise.md b/.changeset/bright-kids-raise.md new file mode 100644 index 0000000000..e0d69e0c71 --- /dev/null +++ b/.changeset/bright-kids-raise.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-todo-backend': patch +--- + +todo-backend is now exposed as a plugin which uses the new plugin system diff --git a/packages/backend-next/package.json b/packages/backend-next/package.json index 578cee8ad9..a76d7e1dd3 100644 --- a/packages/backend-next/package.json +++ b/packages/backend-next/package.json @@ -28,7 +28,8 @@ "@backstage/backend-defaults": "workspace:^", "@backstage/plugin-app-backend": "workspace:^", "@backstage/plugin-catalog-backend": "workspace:^", - "@backstage/plugin-scaffolder-backend": "workspace:^" + "@backstage/plugin-scaffolder-backend": "workspace:^", + "@backstage/plugin-todo-backend": "workspace:^" }, "devDependencies": { "@backstage/cli": "workspace:^" diff --git a/packages/backend-next/src/index.ts b/packages/backend-next/src/index.ts index 97b6eaf32b..f4979760cf 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 { todoPlugin } from '@backstage/plugin-todo-backend'; const backend = createBackend(); backend.add(catalogPlugin()); backend.add(catalogModuleTemplateKind()); backend.add(appPlugin({ appPackageName: 'example-app' })); +backend.add(todoPlugin()); backend.start(); diff --git a/plugins/todo-backend/api-report.md b/plugins/todo-backend/api-report.md index 277c87fea9..dabe2fbfd4 100644 --- a/plugins/todo-backend/api-report.md +++ b/plugins/todo-backend/api-report.md @@ -3,12 +3,14 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; import { CatalogApi } from '@backstage/catalog-client'; import { CompoundEntityRef } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import express from 'express'; import { Logger } from 'winston'; import { ScmIntegrations } from '@backstage/integration'; +import { ServiceRef } from '@backstage/backend-plugin-api'; import { UrlReader } from '@backstage/backend-common'; // @public (undocumented) @@ -88,6 +90,9 @@ export type TodoParserResult = { lineNumber: number; }; +// @alpha +export const todoPlugin: () => BackendFeature; + // @public (undocumented) export interface TodoReader { readTodos(options: ReadTodosOptions): Promise; @@ -113,6 +118,9 @@ export type TodoReaderServiceOptions = { defaultPageSize?: number; }; +// @alpha (undocumented) +export const todoReaderServiceRef: ServiceRef; + // @public (undocumented) export class TodoScmReader implements TodoReader { constructor(options: TodoScmReaderOptions); diff --git a/plugins/todo-backend/package.json b/plugins/todo-backend/package.json index 6a3b22aab4..e3e15e4f06 100644 --- a/plugins/todo-backend/package.json +++ b/plugins/todo-backend/package.json @@ -30,11 +30,13 @@ }, "dependencies": { "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", "@backstage/catalog-client": "workspace:^", "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/integration": "workspace:^", + "@backstage/plugin-catalog-node": "workspace:^", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", diff --git a/plugins/todo-backend/src/index.ts b/plugins/todo-backend/src/index.ts index 973c91df44..b1654327d6 100644 --- a/plugins/todo-backend/src/index.ts +++ b/plugins/todo-backend/src/index.ts @@ -22,3 +22,4 @@ export * from './lib'; export * from './service'; +export * from './plugin'; diff --git a/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.ts b/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.ts index 2696f0d54b..2804da98cc 100644 --- a/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.ts +++ b/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { UrlReader } from '@backstage/backend-common'; +import { loggerToWinstonLogger, UrlReader } from '@backstage/backend-common'; import { ScmIntegrations } from '@backstage/integration'; import { Logger } from 'winston'; @@ -28,6 +28,11 @@ import { import { Config } from '@backstage/config'; import { createTodoParser } from './createTodoParser'; import path from 'path'; +import { + coreServices, + createServiceFactory, + createServiceRef, +} from '@backstage/backend-plugin-api'; const excludedExtensions = [ '.png', @@ -167,3 +172,26 @@ export class TodoScmReader implements TodoReader { return { result: { items: todos }, etag: tree.etag }; } } + +/** + * @alpha + */ +export const todoReaderServiceRef = createServiceRef({ + id: 'todo.todoReader', + defaultFactory: async service => + createServiceFactory({ + service, + deps: { + reader: coreServices.urlReader, + config: coreServices.config, + logger: coreServices.logger, + }, + factory: async ({ reader, config, logger }) => { + const winstonLogger = loggerToWinstonLogger(logger); + return TodoScmReader.fromConfig(config, { + logger: winstonLogger, + reader, + }); + }, + }), +}); diff --git a/plugins/todo-backend/src/lib/TodoReader/index.ts b/plugins/todo-backend/src/lib/TodoReader/index.ts index d0537861da..a325fcbe65 100644 --- a/plugins/todo-backend/src/lib/TodoReader/index.ts +++ b/plugins/todo-backend/src/lib/TodoReader/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -export { TodoScmReader } from './TodoScmReader'; +export { TodoScmReader, todoReaderServiceRef } from './TodoScmReader'; export type { TodoScmReaderOptions } from './TodoScmReader'; export { createTodoParser } from './createTodoParser'; export type { TodoParserOptions } from './createTodoParser'; 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..cbb581f336 --- /dev/null +++ b/plugins/todo-backend/src/plugin.ts @@ -0,0 +1,46 @@ +/* + * 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 { todoServiceRef } from './service/TodoReaderService'; +import { createRouter } from './service/router'; + +/** + * The Todos plugin is responsible for aggregating todo comments within source. + * @alpha + */ +export const todoPlugin = createBackendPlugin({ + pluginId: 'todo-backend', + register(env) { + env.registerInit({ + deps: { + todoReader: todoServiceRef, + http: coreServices.httpRouter, + }, + async init({ http, todoReader }) { + http.use( + await createRouter({ + todoService: todoReader, + }), + ); + }, + }); + }, +}); diff --git a/plugins/todo-backend/src/service/TodoReaderService.ts b/plugins/todo-backend/src/service/TodoReaderService.ts index 35fd76b8be..c5fa0aa70b 100644 --- a/plugins/todo-backend/src/service/TodoReaderService.ts +++ b/plugins/todo-backend/src/service/TodoReaderService.ts @@ -20,7 +20,14 @@ import { getEntitySourceLocation, stringifyEntityRef, } from '@backstage/catalog-model'; -import { TodoReader } from '../lib'; +import { + createServiceFactory, + createServiceRef, + ServiceRef, +} from '@backstage/backend-plugin-api'; +import { catalogServiceRef } from '@backstage/plugin-catalog-node'; + +import { TodoReader, todoReaderServiceRef } from '../lib'; import { ListTodosRequest, ListTodosResponse, TodoService } from './types'; const DEFAULT_DEFAULT_PAGE_SIZE = 10; @@ -130,3 +137,23 @@ export class TodoReaderService implements TodoService { }; } } + +export const todoServiceRef: ServiceRef = + createServiceRef({ + id: 'todo.client', + defaultFactory: async service => + createServiceFactory({ + service, + deps: { + catalogApi: catalogServiceRef, + todoReader: todoReaderServiceRef, + }, + async factory({ catalogApi, todoReader }) { + const todoReaderService = new TodoReaderService({ + catalogClient: catalogApi, + todoReader, + }); + return todoReaderService; + }, + }), + }); diff --git a/plugins/todo-backend/src/service/router.test.ts b/plugins/todo-backend/src/service/router.test.ts index 0254147681..f2d9f2c7a3 100644 --- a/plugins/todo-backend/src/service/router.test.ts +++ b/plugins/todo-backend/src/service/router.test.ts @@ -18,12 +18,12 @@ import express from 'express'; import request from 'supertest'; import { errorHandler } from '@backstage/backend-common'; +import { createRouter } from './router'; import { - createRouter, parseFilterParam, parseIntegerParam, parseOrderByParam, -} from './router'; +} from '../lib/utils'; import { TodoService } from './types'; const mockListBody = { 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; diff --git a/plugins/todo/package.json b/plugins/todo/package.json index 3a0dd54fa8..cc988b3f45 100644 --- a/plugins/todo/package.json +++ b/plugins/todo/package.json @@ -8,7 +8,8 @@ "publishConfig": { "access": "public", "main": "dist/index.esm.js", - "types": "dist/index.d.ts" + "types": "dist/index.d.ts", + "alphaTypes": "dist/index.alpha.d.ts" }, "backstage": { "role": "frontend-plugin" @@ -20,7 +21,7 @@ "directory": "plugins/todo" }, "scripts": { - "build": "backstage-cli package build", + "build": "backstage-cli package build --experimental-type-build", "start": "backstage-cli package start", "lint": "backstage-cli package lint", "test": "backstage-cli package test", @@ -57,6 +58,7 @@ "msw": "^0.49.0" }, "files": [ - "dist" + "dist", + "alpha" ] } diff --git a/yarn.lock b/yarn.lock index ee6415ebba..aa8558da37 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8407,12 +8407,14 @@ __metadata: resolution: "@backstage/plugin-todo-backend@workspace:plugins/todo-backend" dependencies: "@backstage/backend-common": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" "@backstage/catalog-client": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" + "@backstage/plugin-catalog-node": "workspace:^" "@types/express": ^4.17.6 "@types/supertest": ^2.0.8 express: ^4.17.1 @@ -22505,6 +22507,7 @@ __metadata: "@backstage/plugin-app-backend": "workspace:^" "@backstage/plugin-catalog-backend": "workspace:^" "@backstage/plugin-scaffolder-backend": "workspace:^" + "@backstage/plugin-todo-backend": "workspace:^" languageName: unknown linkType: soft