expose the todos-backend plugin using the new plugin system

Signed-off-by: Oleg S <97077423+RobotSail@users.noreply.github.com>
This commit is contained in:
Oleg S
2023-02-15 14:31:50 -05:00
parent 72d1919298
commit ae08281331
8 changed files with 340 additions and 100 deletions
+2
View File
@@ -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();
+107
View File
@@ -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 = <T extends readonly string[]>(
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 = <T extends readonly string[]>(
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];
};
+86
View File
@@ -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);
},
});
},
});
@@ -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<TodoService>({
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
});
},
}),
});
@@ -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';
@@ -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,
});
},
});
+7 -99
View File
@@ -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<T extends readonly string[]>(
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<T extends readonly string[]>(
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];
}
@@ -48,3 +48,11 @@ export interface TodoService {
options?: { token?: string },
): Promise<ListTodosResponse>;
}
export const TODO_FIELDS = [
'text',
'tag',
'author',
'viewUrl',
'repoFilePath',
] as const;