From 1d6dc9b195bbadbbb44f3c4a05b7b0024112e105 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Tue, 23 Jul 2024 10:09:19 +0100 Subject: [PATCH] chore: integration events API (#7639) https://linear.app/unleash/issue/2-2439/create-new-integration-events-endpoint https://linear.app/unleash/issue/2-2436/create-new-integration-event-openapi-schemas This adds a new `/events` endpoint to the Addons API, allowing us to fetch integration events for a specific integration configuration id. ![image](https://github.com/user-attachments/assets/e95b669e-e498-40c0-9d66-55be30a24c13) Also includes: - `IntegrationEventsSchema`: New schema to represent the response object of the list of integration events; - `yarn schema:update`: New `package.json` script to update the OpenAPI spec file; - `BasePaginationParameters`: This is copied from Enterprise. After merging this we should be able to refactor Enterprise to use this one instead of the one it has, so we don't repeat ourselves; We're also now correctly representing the BIGSERIAL as BigInt (string + pattern) in our OpenAPI schema. Otherwise our validation would complain, since we're saying it's a number in the schema but in fact returning a string. --- .husky/update-openapi-spec-list.js | 4 +- package.json | 3 +- .../integration-events-store.ts | 6 +- .../integration-events.e2e.test.ts | 17 +++- .../spec/base-pagination-parameters.ts | 28 ++++++ src/lib/openapi/spec/index.ts | 5 +- .../openapi/spec/integration-event-schema.ts | 8 +- .../openapi/spec/integration-events-schema.ts | 34 +++++++ src/lib/routes/admin-api/addon.ts | 98 ++++++++++++++++++- 9 files changed, 187 insertions(+), 16 deletions(-) create mode 100644 src/lib/openapi/spec/base-pagination-parameters.ts create mode 100644 src/lib/openapi/spec/integration-events-schema.ts diff --git a/.husky/update-openapi-spec-list.js b/.husky/update-openapi-spec-list.js index 2ff0a69630..b3a4fd9d36 100644 --- a/.husky/update-openapi-spec-list.js +++ b/.husky/update-openapi-spec-list.js @@ -20,9 +20,7 @@ fs.readdir(directoryPath, (err, files) => { const script = path.basename(__filename); const message = `/** * Auto-generated file by ${script}. Do not edit. - * To run it manually execute \`node ${script}\` from ${path.basename( - __dirname, - )} + * To run it manually execute \`yarn schema:update\` or \`node ${path.basename(__dirname)}/${script}\` */\n`; fs.writeFileSync(indexPath, `${message}${exports}\n${message}`, (err) => { if (err) { diff --git a/package.json b/package.json index d73a73c3ec..1c10f9a597 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,8 @@ "clean": "del-cli --force dist", "preversion": "./scripts/check-release.sh", "heroku-postbuild": "cd frontend && yarn && yarn build", - "prepack": "./scripts/prepack.sh" + "prepack": "./scripts/prepack.sh", + "schema:update": "node ./.husky/update-openapi-spec-list.js" }, "jest-junit": { "suiteName": "Unleash Unit Tests", diff --git a/src/lib/features/integration-events/integration-events-store.ts b/src/lib/features/integration-events/integration-events-store.ts index 31bf87d4ad..553e19897e 100644 --- a/src/lib/features/integration-events/integration-events-store.ts +++ b/src/lib/features/integration-events/integration-events-store.ts @@ -1,4 +1,5 @@ import { CRUDStore, type CrudStoreConfig } from '../../db/crud/crud-store'; +import type { Row } from '../../db/crud/row-type'; import type { Db } from '../../db/db'; import type { IntegrationEventSchema } from '../../openapi/spec/integration-event-schema'; @@ -11,7 +12,10 @@ export type IntegrationEventState = IntegrationEventWriteModel['state']; export class IntegrationEventsStore extends CRUDStore< IntegrationEventSchema, - IntegrationEventWriteModel + IntegrationEventWriteModel, + Row, + Row, + string > { constructor(db: Db, config: CrudStoreConfig) { super('integration_events', db, config); diff --git a/src/lib/features/integration-events/integration-events.e2e.test.ts b/src/lib/features/integration-events/integration-events.e2e.test.ts index 6c5f00a3ad..136cf78e24 100644 --- a/src/lib/features/integration-events/integration-events.e2e.test.ts +++ b/src/lib/features/integration-events/integration-events.e2e.test.ts @@ -1,7 +1,7 @@ import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init'; import { type IUnleashTest, - setupAppWithAuth, + setupAppWithCustomConfig, } from '../../../test/e2e/helpers/test-helper'; import getLogger from '../../../test/fixtures/no-logger'; import { TEST_AUDIT_USER } from '../../types'; @@ -37,7 +37,7 @@ const EVENT_FAILED: IntegrationEventWriteModel = { beforeAll(async () => { db = await dbInit('integration_events', getLogger); - app = await setupAppWithAuth( + app = await setupAppWithCustomConfig( db.stores, { experimental: { @@ -192,3 +192,16 @@ test('clean up events, keeping the last 100 events', async () => { expect(events).toHaveLength(100); }); + +test('return events from the API', async () => { + await integrationEventsService.registerEvent(getTestEventSuccess()); + await integrationEventsService.registerEvent(getTestEventFailed()); + + const { body } = await app.request.get( + `/api/admin/addons/${integrationId}/events`, + ); + + expect(body.integrationEvents).toHaveLength(2); + expect(body.integrationEvents[0].state).toBe('failed'); + expect(body.integrationEvents[1].state).toBe('success'); +}); diff --git a/src/lib/openapi/spec/base-pagination-parameters.ts b/src/lib/openapi/spec/base-pagination-parameters.ts new file mode 100644 index 0000000000..4ae1f1a728 --- /dev/null +++ b/src/lib/openapi/spec/base-pagination-parameters.ts @@ -0,0 +1,28 @@ +import type { FromQueryParams } from '../util/from-query-params'; + +export const basePaginationParameters = [ + { + name: 'limit', + schema: { + type: 'string', + example: '50', + }, + description: + 'The number of results to return in a page. By default it is set to 50.', + in: 'query', + }, + { + name: 'offset', + schema: { + type: 'string', + example: '50', + }, + description: + 'The number of results to skip when returning a page. By default it is set to 0.', + in: 'query', + }, +] as const; + +export type BasePaginationParameters = Partial< + FromQueryParams +>; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index dca9abff37..ee722429a6 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -1,6 +1,6 @@ /** * Auto-generated file by update-openapi-spec-list.js. Do not edit. - * To run it manually execute `node update-openapi-spec-list.js` from .husky + * To run it manually execute `yarn schema:update` or `node .husky/update-openapi-spec-list.js` */ export * from './addon-create-update-schema'; export * from './addon-parameter-schema'; @@ -112,6 +112,7 @@ export * from './inactive-user-schema'; export * from './inactive-users-schema'; export * from './instance-admin-stats-schema'; export * from './integration-event-schema'; +export * from './integration-events-schema'; export * from './legal-value-schema'; export * from './login-schema'; export * from './maintenance-schema'; @@ -210,5 +211,5 @@ export * from './variants-schema'; export * from './version-schema'; /** * Auto-generated file by update-openapi-spec-list.js. Do not edit. - * To run it manually execute `node update-openapi-spec-list.js` from .husky + * To run it manually execute `yarn schema:update` or `node .husky/update-openapi-spec-list.js` */ diff --git a/src/lib/openapi/spec/integration-event-schema.ts b/src/lib/openapi/spec/integration-event-schema.ts index 04b1a82258..a45f5a4b8c 100644 --- a/src/lib/openapi/spec/integration-event-schema.ts +++ b/src/lib/openapi/spec/integration-event-schema.ts @@ -19,11 +19,11 @@ export const integrationEventSchema = { additionalProperties: false, properties: { id: { - type: 'integer', + type: 'string', + pattern: '^[0-9]+$', // BigInt description: - "The integration event's ID. Integration event IDs are incrementing integers. In other words, a more recently created integration event will always have a higher ID than an older one.", - minimum: 1, - example: 7, + "The integration event's ID. Integration event IDs are incrementing integers. In other words, a more recently created integration event will always have a higher ID than an older one. This ID is represented as a string since it is a BigInt.", + example: '7', }, integrationId: { type: 'integer', diff --git a/src/lib/openapi/spec/integration-events-schema.ts b/src/lib/openapi/spec/integration-events-schema.ts new file mode 100644 index 0000000000..ad04834e31 --- /dev/null +++ b/src/lib/openapi/spec/integration-events-schema.ts @@ -0,0 +1,34 @@ +import type { FromSchema } from 'json-schema-to-ts'; +import { eventSchema } from './event-schema'; +import { tagSchema } from './tag-schema'; +import { variantSchema } from './variant-schema'; +import { integrationEventSchema } from './integration-event-schema'; + +export const integrationEventsSchema = { + $id: '#/components/schemas/integrationEventsSchema', + description: 'A response model with a list of integration events.', + type: 'object', + additionalProperties: false, + required: ['integrationEvents'], + properties: { + integrationEvents: { + type: 'array', + description: 'A list of integration events.', + items: { + $ref: integrationEventSchema.$id, + }, + }, + }, + components: { + schemas: { + integrationEventSchema, + eventSchema, + tagSchema, + variantSchema, + }, + }, +} as const; + +export type IntegrationEventsSchema = FromSchema< + typeof integrationEventsSchema +>; diff --git a/src/lib/routes/admin-api/addon.ts b/src/lib/routes/admin-api/addon.ts index 8e26ec416e..8c8a06952e 100644 --- a/src/lib/routes/admin-api/addon.ts +++ b/src/lib/routes/admin-api/addon.ts @@ -1,10 +1,15 @@ import type { Request, Response } from 'express'; import Controller from '../controller'; -import type { IUnleashConfig, IUnleashServices } from '../../types'; +import type { + IFlagResolver, + IUnleashConfig, + IUnleashServices, +} from '../../types'; import type { Logger } from '../../logger'; import type AddonService from '../../services/addon-service'; import { + ADMIN, CREATE_ADDON, DELETE_ADDON, NONE, @@ -25,8 +30,21 @@ import { getStandardResponses, } from '../../openapi/util/standard-responses'; import type { AddonCreateUpdateSchema } from '../../openapi/spec/addon-create-update-schema'; +import { + type BasePaginationParameters, + basePaginationParameters, +} from '../../openapi/spec/base-pagination-parameters'; +import { + type IntegrationEventsSchema, + integrationEventsSchema, +} from '../../openapi/spec/integration-events-schema'; +import { BadDataError, NotFoundError } from '../../error'; +import type { IntegrationEventsService } from '../../services'; -type AddonServices = Pick; +type AddonServices = Pick< + IUnleashServices, + 'addonService' | 'openApiService' | 'integrationEventsService' +>; const PATH = '/'; @@ -37,14 +55,24 @@ class AddonController extends Controller { private openApiService: OpenApiService; + private integrationEventsService: IntegrationEventsService; + + private flagResolver: IFlagResolver; + constructor( config: IUnleashConfig, - { addonService, openApiService }: AddonServices, + { + addonService, + openApiService, + integrationEventsService, + }: AddonServices, ) { super(config); this.logger = config.getLogger('/admin-api/addon.ts'); this.addonService = addonService; this.openApiService = openApiService; + this.integrationEventsService = integrationEventsService; + this.flagResolver = config.flagResolver; this.route({ method: 'get', @@ -149,6 +177,28 @@ Note: passing \`null\` as a value for the description property will set it to an }), ], }); + + this.route({ + method: 'get', + path: `${PATH}:id/events`, + handler: this.getIntegrationEvents, + permission: ADMIN, + middleware: [ + openApiService.validPath({ + tags: ['Unstable'], + operationId: 'getIntegrationEvents', + summary: + 'Get integration events for a specific integration configuration.', + description: + 'Returns a list of integration events belonging to a specific integration configuration, identified by its id.', + parameters: [...basePaginationParameters], + responses: { + ...getStandardResponses(401, 403, 404), + 200: createResponseSchema(integrationEventsSchema.$id), + }, + }), + ], + }); } async getAddons(req: Request, res: Response): Promise { @@ -216,6 +266,48 @@ Note: passing \`null\` as a value for the description property will set it to an res.status(200).end(); } + + async getIntegrationEvents( + req: IAuthRequest< + { id: number }, + unknown, + unknown, + BasePaginationParameters + >, + res: Response, + ): Promise { + if (!this.flagResolver.isEnabled('integrationEvents')) { + throw new NotFoundError('This feature is not enabled'); + } + + const { id } = req.params; + + if (Number.isNaN(Number(id))) { + throw new BadDataError('Invalid integration configuration id'); + } + + const { limit = '50', offset = '0' } = req.query; + + const normalizedLimit = + Number(limit) > 0 && Number(limit) <= 100 ? Number(limit) : 50; + const normalizedOffset = Number(offset) > 0 ? Number(offset) : 0; + + const integrationEvents = + await this.integrationEventsService.getPaginatedEvents( + id, + normalizedLimit, + normalizedOffset, + ); + + this.openApiService.respondWithValidation( + 200, + res, + integrationEventsSchema.$id, + { + integrationEvents: serializeDates(integrationEvents), + }, + ); + } } export default AddonController; module.exports = AddonController;