diff --git a/plugins/notifications-backend/package.json b/plugins/notifications-backend/package.json index 5957f28a20e..d0f3368a865 100644 --- a/plugins/notifications-backend/package.json +++ b/plugins/notifications-backend/package.json @@ -21,18 +21,23 @@ "clean": "backstage-cli package clean", "prepack": "backstage-cli package prepack", "postpack": "backstage-cli package postpack", - "tsc": "tsc" + "tsc": "tsc", + "openapi": "npx openapicmd typegen src/openapi.yaml > src/openapi.d.ts" }, "dependencies": { "@backstage/backend-common": "0.19.8", + "@backstage/backend-openapi-utils": "^0.1.0", "@backstage/catalog-client": "^1.4.5", "@backstage/config": "^1.1.1", "@types/express": "*", + "ajv-formats": "^2.1.1", "express": "^4.18.2", "express-promise-router": "^4.1.1", "knex": "2.5.1", "lodash": "^4.17.21", "node-fetch": "^3.3.2", + "openapi": "^1.0.1", + "openapi-backend": "^5.10.5", "winston": "^3.11.0", "yn": "^4.0.0" }, diff --git a/plugins/notifications-backend/src/openapi.d.ts b/plugins/notifications-backend/src/openapi.d.ts new file mode 100644 index 00000000000..59fa4701290 --- /dev/null +++ b/plugins/notifications-backend/src/openapi.d.ts @@ -0,0 +1,217 @@ +import type { + AxiosRequestConfig, + OpenAPIClient, + OperationResponse, + Parameters, + UnknownParamsObject, +} from 'openapi-client-axios'; + +declare namespace Components { + namespace Schemas { + export interface Action { + id: string; + title: string; + url: string; + } + export interface CreateBody { + origin: string; + title: string; + message?: string; + actions?: { + title: string; + url: string; + }[]; + topic?: string; + targetUsers?: string[]; + targetGroups?: string[]; + } + export interface Notification { + id: string; + created: string; // date-time + readByUser: boolean; + isSystem: boolean; + origin: string; + title: string; + message?: string; + topic?: string; + actions: Action[]; + } + export type Notifications = Notification[]; + } +} +declare namespace Paths { + namespace CreateNotification { + export type RequestBody = Components.Schemas.CreateBody; + namespace Responses { + export interface $200 { + /** + * example: + * bc9f19de-8b7b-49a8-9262-c5036a1ed35e + */ + messageId: string; + } + } + } + namespace GetNotifications { + namespace Parameters { + export type ContainsText = string; + export type CreatedAfter = string; // date-time + export type MessageScope = 'all' | 'user' | 'system'; + export type OrderBy = + | 'title' + | 'message' + | 'created' + | 'topic' + | 'origin'; + export type OrderByDirec = 'asc' | 'desc'; + export type PageNumber = number; + export type PageSize = number; + export type Read = boolean; + export type User = string; + } + export interface QueryParameters { + pageSize?: Parameters.PageSize; + pageNumber?: Parameters.PageNumber; + orderBy?: Parameters.OrderBy; + orderByDirec?: Parameters.OrderByDirec; + containsText?: Parameters.ContainsText; + createdAfter?: Parameters.CreatedAfter /* date-time */; + messageScope?: Parameters.MessageScope; + user?: Parameters.User; + read?: Parameters.Read; + } + namespace Responses { + export type $200 = Components.Schemas.Notifications; + } + } + namespace GetNotificationsCount { + namespace Parameters { + export type ContainsText = string; + export type CreatedAfter = string; // date-time + export type MessageScope = 'all' | 'user' | 'system'; + export type Read = boolean; + export type User = string; + } + export interface QueryParameters { + containsText?: Parameters.ContainsText; + createdAfter?: Parameters.CreatedAfter /* date-time */; + messageScope?: Parameters.MessageScope; + user?: Parameters.User; + read?: Parameters.Read; + } + namespace Responses { + export interface $200 { + count: number; + } + } + } + namespace SetRead { + namespace Parameters { + export type MessageId = string; + export type Read = boolean; + export type User = string; + } + export interface QueryParameters { + messageId: Parameters.MessageId; + user: Parameters.User; + read: Parameters.Read; + } + namespace Responses { + export interface $200 {} + } + } +} + +export interface OperationMethods { + /** + * getNotifications - Gets notifications + * + * Gets notifications + */ + 'getNotifications'( + parameters?: Parameters | null, + data?: any, + config?: AxiosRequestConfig, + ): OperationResponse; + /** + * createNotification - Create notification + * + * Create notification + */ + 'createNotification'( + parameters?: Parameters | null, + data?: Paths.CreateNotification.RequestBody, + config?: AxiosRequestConfig, + ): OperationResponse; + /** + * getNotificationsCount - Get notifications count + * + * Gets notifications count + */ + 'getNotificationsCount'( + parameters?: Parameters | null, + data?: any, + config?: AxiosRequestConfig, + ): OperationResponse; + /** + * setRead - Set notification as read/unread + * + * Set notification as read/unread + */ + 'setRead'( + parameters?: Parameters | null, + data?: any, + config?: AxiosRequestConfig, + ): OperationResponse; +} + +export interface PathsDictionary { + ['/notifications']: { + /** + * createNotification - Create notification + * + * Create notification + */ + 'post'( + parameters?: Parameters | null, + data?: Paths.CreateNotification.RequestBody, + config?: AxiosRequestConfig, + ): OperationResponse; + /** + * getNotifications - Gets notifications + * + * Gets notifications + */ + 'get'( + parameters?: Parameters | null, + data?: any, + config?: AxiosRequestConfig, + ): OperationResponse; + }; + ['/notifications/count']: { + /** + * getNotificationsCount - Get notifications count + * + * Gets notifications count + */ + 'get'( + parameters?: Parameters | null, + data?: any, + config?: AxiosRequestConfig, + ): OperationResponse; + }; + ['/notifications/read']: { + /** + * setRead - Set notification as read/unread + * + * Set notification as read/unread + */ + 'put'( + parameters?: Parameters | null, + data?: any, + config?: AxiosRequestConfig, + ): OperationResponse; + }; +} + +export type Client = OpenAPIClient; diff --git a/plugins/notifications-backend/src/service/handlers.ts b/plugins/notifications-backend/src/service/handlers.ts index 90f19d86aa6..69dc267414c 100644 --- a/plugins/notifications-backend/src/service/handlers.ts +++ b/plugins/notifications-backend/src/service/handlers.ts @@ -3,16 +3,16 @@ import { CatalogClient } from '@backstage/catalog-client'; import { Knex } from 'knex'; +import { Components, Paths } from '../openapi'; import { ActionsInsert, MessagesInsert } from './db'; import { - CreateNotificationRequest, DefaultMessageScope, DefaultOrderBy, DefaultOrderDirection, DefaultPageNumber, DefaultPageSize, DefaultUser, - Notification, + MessageScopes, NotificationsFilterRequest, NotificationsOrderByDirections, NotificationsOrderByFields, @@ -24,8 +24,8 @@ import { export async function createNotification( dbClient: Knex, catalogClient: CatalogClient, - req: CreateNotificationRequest, -): Promise<{ messageId: string }> { + req: Paths.CreateNotification.RequestBody, +): Promise { let isUser = false; // validate users @@ -138,7 +138,7 @@ export async function getNotifications( pageSize: number = DefaultPageSize, pageNumber: number = DefaultPageNumber, sorting: NotificationsSortingRequest, -): Promise { +): Promise { if ( pageSize < 0 || pageNumber < 0 || @@ -152,17 +152,21 @@ export async function getNotifications( if (!filter.messageScope) { filter.messageScope = DefaultMessageScope; + } else if (!MessageScopes.includes(filter.messageScope)) { + throw new Error( + `messageScope parameter must be one of ${MessageScopes.join()}`, + ); } if (!filter.user) { filter.user = DefaultUser; } - const orderBy = sorting.fieldName || DefaultOrderBy; - const direction = sorting.direction || DefaultOrderDirection; + const orderBy = sorting.orderBy || DefaultOrderBy; + const orderByDirec = sorting.OrderByDirec || DefaultOrderDirection; if ( !NotificationsOrderByFields.includes(orderBy) || - !NotificationsOrderByDirections.includes(direction) + !NotificationsOrderByDirections.includes(orderByDirec) ) { throw new Error( `The orderBy parameter can be one of ${NotificationsOrderByFields.join( @@ -177,15 +181,15 @@ export async function getNotifications( const query = createQuery(dbClient, filter, userGroups); - query.orderBy(orderBy, direction); + query.orderBy(orderBy, orderByDirec); if (pageNumber > 0) { query.limit(pageSize).offset((pageNumber - 1) * pageSize); } - const notifications: Notification[] = await query.select('*').then(messages => + const notifications = await query.select('*').then(messages => messages.map((message: any) => { - const notification: Notification = { + const notification: Components.Schemas.Notification = { id: message.id, created: message.created, isSystem: message.is_system, @@ -225,7 +229,7 @@ export async function getNotificationsCount( dbClient: Knex, catalogClient: CatalogClient, filter: NotificationsFilterRequest, -): Promise<{ count: number }> { +): Promise { if (!filter.messageScope) { filter.messageScope = DefaultMessageScope; } @@ -364,10 +368,10 @@ function createQuery( // filter by read/unread switch (filter.read) { - case 'true': + case true: query.andWhere('read', true); break; - case 'false': + case false: query.andWhere(function () { this.where('read', false).orWhereNull('read'); }); diff --git a/plugins/notifications-backend/src/service/router.ts b/plugins/notifications-backend/src/service/router.ts index 8f5dbaf12b4..ef2e070e50e 100644 --- a/plugins/notifications-backend/src/service/router.ts +++ b/plugins/notifications-backend/src/service/router.ts @@ -1,11 +1,13 @@ -import { errorHandler } from '@backstage/backend-common'; import { CatalogClient } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; +import { fullFormats } from 'ajv-formats/dist/formats'; import express from 'express'; import Router from 'express-promise-router'; +import { Context, OpenAPIBackend, Request } from 'openapi-backend'; import { Logger } from 'winston'; +import { Paths } from '../openapi'; import { initDB } from './db'; import { createNotification, @@ -13,7 +15,6 @@ import { getNotificationsCount, setRead, } from './handlers'; -import { NotificationsSortingRequest } from './types'; interface RouterOptions { logger: Logger; @@ -26,104 +27,107 @@ export async function createRouter( ): Promise { const { logger, dbConfig, catalogClient } = options; - const router = Router(); - router.use(express.json()); - + // create DB client and tables if (!dbConfig) { logger.error('Missing dbConfig'); throw new Error('Missing database config'); } - // create DB client and tables const dbClient = await initDB(dbConfig); - // rest endpoints/operations - router.get('/health', (_, response) => { - response.json({ status: 'ok' }); + // create openapi requests handler + + const api = new OpenAPIBackend({ + ajvOpts: { + formats: fullFormats, // open issue: https://github.com/openapistack/openapi-backend/issues/280 + }, + validate: true, + definition: '../../plugins/notifications-backend/src/openapi.yaml', }); - router.get('/notifications/count', (request, response, next) => { - getNotificationsCount(dbClient, catalogClient, request.query) - .then(result => response.json(result)) + await api.init(); + + api.register( + 'createNotification', + ( + c: Context, + _, + res: express.Response, + next, + ) => { + createNotification(dbClient, catalogClient, c.request.requestBody) + .then(result => res.json(result)) + .catch(next); + }, + ); + + api.register('getNotifications', (c, _, res: express.Response, next) => { + const q: Paths.GetNotifications.QueryParameters = Object.assign( + {}, + c.request.query, + ); + + // we need to convert strings to real types due to open PR https://github.com/openapistack/openapi-backend/pull/571 + q.pageNumber = stringToNumber(q.pageNumber); + q.pageSize = stringToNumber(q.pageSize); + q.read = stringToBool(q.read); + + getNotifications(dbClient, catalogClient, q, q.pageSize, q.pageNumber, q) + .then(notifications => res.json(notifications)) .catch(next); }); - router.get('/notifications', (request, response, next) => { - const { pageSize, pageNumber, orderBy, orderByDirec } = request.query; + api.register('getNotificationsCount', (c, _, res: express.Response, next) => { + const q: Paths.GetNotificationsCount.QueryParameters = Object.assign( + {}, + c.request.query, + ); - if ( - (typeof pageSize !== 'string' && typeof pageSize !== 'undefined') || - (typeof pageNumber !== 'string' && typeof pageNumber !== 'undefined') - ) { - throw new Error( - 'either pageSize or pageNumber query string parameters are missing/invalid', - ); - } - - const pageSizeNum = pageSize ? Number.parseInt(pageSize, 10) : undefined; - const pageNumberNum = pageNumber - ? Number.parseInt(pageNumber, 10) - : undefined; + // we need to convert strings to real types due to open PR https://github.com/openapistack/openapi-backend/pull/571 + q.read = q.read = stringToBool(q.read); - if (Number.isNaN(pageSizeNum) || Number.isNaN(pageNumberNum)) { - throw new Error('either pageSize or pageNumber is not a number'); - } - - const sorting: NotificationsSortingRequest = { - fieldName: orderBy?.toString(), - direction: orderByDirec?.toString(), - }; - - getNotifications( - dbClient, - catalogClient, - request.query, - pageSizeNum, - pageNumberNum, - sorting, - ) - .then(notifications => response.json(notifications)) + getNotificationsCount(dbClient, catalogClient, q) + .then(result => res.json(result)) .catch(next); }); - router.post('/notifications', (request, response, next) => { - createNotification(dbClient, catalogClient, request.body) - .then(result => response.json(result)) + api.register('setRead', (c, _, res: express.Response, next) => { + const messageId = c.request.query.messageId.toString(); + const user = c.request.query.user.toString(); + const read = c.request.query.read.toString() === 'true'; + + setRead(dbClient, messageId, user, read) + .then(result => res.json(result)) .catch(next); }); - router.put('/notifications/read', (request, response, next) => { - const { messageId, user, read } = request.query; - if ( - typeof messageId !== 'string' || - typeof user !== 'string' || - typeof read !== 'string' - ) { - throw new Error( - 'the following query parameters must be provided: messageId - string, user - string, read - false/true (boolean)', - ); - } + // create router - let readBool: boolean; - - switch (read) { - case 'true': - readBool = true; - break; - case 'false': - readBool = false; - break; - default: - throw new Error( - 'value of parameter "read" must be either "false" or "true"', - ); + const router = Router(); + router.use(express.json()); + router.use((req, res, next) => { + if (!next) { + throw new Error('next is undefined'); + } + const validation = api.validateRequest(req as Request); + if (!validation.valid) { + throw validation.errors; } - setRead(dbClient, messageId, user, readBool) - .then(() => response.end()) - .catch(next); + api.handleRequest(req as Request, req, res, next); }); - router.use(errorHandler()); return router; } + +function stringToNumber(s: number | undefined): number | undefined { + return s ? Number.parseInt(s.toString(), 10) : undefined; +} + +function stringToBool(s: boolean | undefined): boolean | undefined { + if (!s) { + return undefined; + } + + return s.toString() === 'true' ? true : false; +} diff --git a/plugins/notifications-backend/src/service/types.ts b/plugins/notifications-backend/src/service/types.ts index 689ff26ca37..2191e4c3cde 100644 --- a/plugins/notifications-backend/src/service/types.ts +++ b/plugins/notifications-backend/src/service/types.ts @@ -1,39 +1,3 @@ -export type NotificationAction = { - id: string; // UUID - title: string; - url: string; -}; - -/** - * Basic object representing a notification. - */ -export type Notification = { - id: string; // UUID - created: Date; - readByUser: boolean; - isSystem: boolean; - - origin: string; - title: string; - message?: string; - topic?: string; - - actions: NotificationAction[]; -}; - -/** - * Input data for the POST request (create a notification). - */ -export type CreateNotificationRequest = { - origin: string; - title: string; - message?: string; - actions?: { title: string; url: string }[]; - topic?: string; - targetUsers?: string[]; - targetGroups?: string[]; -}; - export type NotificationsFilterRequest = { /** * Filter notifications whose either title or message contains the provided string. @@ -43,7 +7,7 @@ export type NotificationsFilterRequest = { /** * Only notifications created after this timestamp will be included. */ - createdAfter?: Date; + createdAfter?: string; /** * See MessageScopes @@ -61,24 +25,17 @@ export type NotificationsFilterRequest = { * 'false' for user's unread messages, 'true' for read ones. * If undefined, then both marks. */ - read?: string; + read?: boolean; }; /** * How the result set is sorted. */ export type NotificationsSortingRequest = { - fieldName?: string; - direction?: string; + orderBy?: string; + OrderByDirec?: string; }; -export type NotificationsOrderByFieldsType = - | 'title' - | 'message' - | 'created' - | 'topic' - | 'origin'; - export const NotificationsOrderByFields: string[] = [ 'title', 'message', @@ -87,15 +44,8 @@ export const NotificationsOrderByFields: string[] = [ 'origin', ]; -export type NotificationsOrderByDirectionsType = 'asc' | 'desc'; - export const NotificationsOrderByDirections: string[] = ['asc', 'desc']; -export type NotificationsQuerySorting = { - fieldName: NotificationsOrderByFieldsType; - direction: NotificationsOrderByDirectionsType; -}; - /** * MessageScopes * When 'user' is requested, then messages whose targetUsers or targetGroups are matching the "user".