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:
@@ -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();
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user