From 76e6336312aee1ec0132194fc886a5449545e7b8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Mar 2021 22:18:04 +0100 Subject: [PATCH] todo-backend: full TodoReaderService implementation Signed-off-by: Patrik Oldsberg --- packages/backend/src/plugins/todo.ts | 1 - .../src/lib/TodoReader/TodoScmReader.ts | 4 +- .../src/service/TodoReaderService.ts | 123 ++++++++++++++++-- 3 files changed, 111 insertions(+), 17 deletions(-) diff --git a/packages/backend/src/plugins/todo.ts b/packages/backend/src/plugins/todo.ts index 38fb43f78e..6141108a27 100644 --- a/packages/backend/src/plugins/todo.ts +++ b/packages/backend/src/plugins/todo.ts @@ -33,7 +33,6 @@ export default async function createPlugin({ }); const catalogClient = new CatalogClient({ discoveryApi: discovery }); const todoService = new TodoReaderService({ - logger, todoReader, catalogClient, }); diff --git a/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.ts b/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.ts index bf309cd673..f5457d0dd3 100644 --- a/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.ts +++ b/plugins/todo-backend/src/lib/TodoReader/TodoScmReader.ts @@ -32,9 +32,9 @@ export class TodoScmReader implements TodoReader { this.reader = options.reader; } - async readTodos(_options: ReadTodosOptions): Promise { + async readTodos({ url }: ReadTodosOptions): Promise { return { - items: [{ text: 'My mock todo' }], + items: [{ text: 'My mock todo', viewUrl: url }], }; } } diff --git a/plugins/todo-backend/src/service/TodoReaderService.ts b/plugins/todo-backend/src/service/TodoReaderService.ts index 4d9afc3099..e646a29634 100644 --- a/plugins/todo-backend/src/service/TodoReaderService.ts +++ b/plugins/todo-backend/src/service/TodoReaderService.ts @@ -14,40 +14,135 @@ * limitations under the License. */ +import { InputError, NotFoundError } from '@backstage/backend-common'; import { CatalogClient } from '@backstage/catalog-client'; -import { Logger } from 'winston'; +import { + LOCATION_ANNOTATION, + SOURCE_LOCATION_ANNOTATION, + serializeEntityRef, + Entity, + parseLocationReference, +} from '@backstage/catalog-model'; import { TodoReader } from '../lib'; import { ListTodosRequest, ListTodosResponse, TodoService } from './types'; +const DEFAULT_DEFAULT_PAGE_SIZE = 10; +const DEFAULT_MAX_PAGE_SIZE = 50; + type Options = { - logger: Logger; todoReader: TodoReader; catalogClient: CatalogClient; + maxPageSize?: number; + defaultPageSize?: number; }; export class TodoReaderService implements TodoService { - private readonly logger: Logger; private readonly todoReader: TodoReader; private readonly catalogClient: CatalogClient; + private readonly maxPageSize: number; + private readonly defaultPageSize: number; constructor(options: Options) { - this.logger = options.logger; this.todoReader = options.todoReader; this.catalogClient = options.catalogClient; + this.maxPageSize = options.maxPageSize ?? DEFAULT_MAX_PAGE_SIZE; + this.defaultPageSize = options.defaultPageSize ?? DEFAULT_DEFAULT_PAGE_SIZE; } - async listTodos(_req: ListTodosRequest): Promise { - const todos = await this.todoReader.readTodos({ - url: 'https://github.com/backstage/backstage', - }); + async listTodos({ + entity: entityName, + cursor, + }: ListTodosRequest): Promise { + if (!entityName) { + throw new InputError('entity filter is required to list todos'); + } + const entity = await this.catalogClient.getEntityByName(entityName); + if (!entity) { + throw new NotFoundError( + `Entity not found, ${serializeEntityRef(entityName)}`, + ); + } + + const url = this.getEntitySourceUrl(entity); + const todos = await this.todoReader.readTodos({ url }); + + const { offset, limit } = this.parseCursor(cursor); return { - items: todos.items.slice(0, 10), + items: todos.items.slice(offset, offset + limit), totalCount: todos.items.length, - cursors: { - prev: 'prev', - self: 'self', - next: 'next', - }, + cursors: this.calculateCursors(offset, limit, todos.items.length), + }; + } + + private getEntitySourceUrl(entity: Entity) { + const sourceLocation = + entity.metadata.annotations?.[SOURCE_LOCATION_ANNOTATION]; + if (sourceLocation) { + const parsed = parseLocationReference(sourceLocation); + if (parsed.type !== 'url') { + throw new InputError( + `Invalid entity source location type for ${serializeEntityRef( + entity, + )}, got ${parsed.type}`, + ); + } + return parsed.target; + } + + const location = entity.metadata.annotations?.[LOCATION_ANNOTATION]; + if (location) { + const parsed = parseLocationReference(location); + if (parsed.type !== 'url') { + throw new InputError( + `Invalid entity source location type for ${serializeEntityRef( + entity, + )}, got ${parsed.type}`, + ); + } + return parsed.target; + } + throw new InputError( + `No entity location annotation found for ${serializeEntityRef(entity)}`, + ); + } + + private parseCursor( + cursor: string | undefined, + ): { offset: number; limit: number } { + if (!cursor) { + return { offset: 0, limit: this.defaultPageSize }; + } + + const [offsetStr, limitStr] = cursor.split(','); + + const offset = parseInt(offsetStr, 10); + if (!Number.isInteger(offset) || offset < 0) { + throw new InputError(`Invalid cursor, ${cursor}`); + } + + let limit = parseInt(limitStr, 10); + if (!Number.isInteger(limit) || limit < 0) { + throw new InputError(`Invalid cursor, ${cursor}`); + } + if (limit > this.maxPageSize) { + limit = this.maxPageSize; + } + + return { offset, limit }; + } + + private calculateCursors( + offset: number, + limit: number, + total: number, + ): ListTodosResponse['cursors'] { + const prevOffset = Math.max(offset - limit, 0); + const nextOffset = Math.min(offset + limit, total - limit); + + return { + prev: `${prevOffset},${limit}`, + self: `${offset},${limit}`, + next: `${nextOffset},${limit}`, }; } }