diff --git a/src/lib/features/events/event-search-controller.ts b/src/lib/features/events/event-search-controller.ts new file mode 100644 index 0000000000..d7979e31a7 --- /dev/null +++ b/src/lib/features/events/event-search-controller.ts @@ -0,0 +1,101 @@ +import type { Response } from 'express'; +import type { IUnleashConfig } from '../../types/option'; +import type { IUnleashServices } from '../../types/services'; +import type EventService from '../../features/events/event-service'; +import { NONE } from '../../types/permissions'; +import type { OpenApiService } from '../../services/openapi-service'; +import { createResponseSchema } from '../../openapi/util/create-response-schema'; +import { serializeDates } from '../../../lib/types/serialize-dates'; +import type { IFlagResolver } from '../../types/experimental'; +import { + type EventSearchQueryParameters, + eventSearchQueryParameters, +} from '../../openapi/spec/event-search-query-parameters'; +import { + type EventSearchResponseSchema, + eventSearchResponseSchema, +} from '../../openapi'; +import { normalizeQueryParams } from '../../features/feature-search/search-utils'; +import Controller from '../../routes/controller'; +import type { IAuthRequest } from '../../server-impl'; +import type { IEvent } from '../../types'; +import { anonymiseKeys } from '../../util'; + +const ANON_KEYS = ['email', 'username', 'createdBy']; +const version = 1 as const; +export default class EventSearchController extends Controller { + private eventService: EventService; + + private flagResolver: IFlagResolver; + + private openApiService: OpenApiService; + + constructor( + config: IUnleashConfig, + { + eventService, + openApiService, + }: Pick, + ) { + super(config); + this.eventService = eventService; + this.flagResolver = config.flagResolver; + this.openApiService = openApiService; + + this.route({ + method: 'get', + path: '', + handler: this.searchEvents, + permission: NONE, + middleware: [ + openApiService.validPath({ + operationId: 'searchEvents', + tags: ['Events'], + summary: 'Search for events', + description: + 'Allows searching for events matching the search criteria in the request body. This operation is deprecated. You should perform a GET request to the same endpoint with your query encoded as query parameters instead.', + parameters: [...eventSearchQueryParameters], + responses: { + 200: createResponseSchema('eventSearchResponseSchema'), + }, + }), + ], + }); + } + + async searchEvents( + req: IAuthRequest, + res: Response, + ): Promise { + const { normalizedLimit, normalizedOffset } = normalizeQueryParams( + req.query, + { + limitDefault: 50, + maxLimit: 1000, + }, + ); + + const { events, totalEvents } = await this.eventService.searchEvents({ + ...req.query, + offset: normalizedOffset, + limit: normalizedLimit, + }); + + this.openApiService.respondWithValidation( + 200, + res, + eventSearchResponseSchema.$id, + serializeDates({ + events: serializeDates(this.maybeAnonymiseEvents(events)), + total: totalEvents, + }), + ); + } + + maybeAnonymiseEvents(events: IEvent[]): IEvent[] { + if (this.flagResolver.isEnabled('anonymiseEventLog')) { + return anonymiseKeys(events, ANON_KEYS); + } + return events; + } +} diff --git a/src/lib/features/events/event-service.ts b/src/lib/features/events/event-service.ts index f6b55e6c44..bebf0f4569 100644 --- a/src/lib/features/events/event-service.ts +++ b/src/lib/features/events/event-service.ts @@ -1,14 +1,16 @@ import type { IUnleashConfig } from '../../types/option'; import type { IFeatureTagStore, IUnleashStores } from '../../types/stores'; import type { Logger } from '../../logger'; -import type { IEventStore } from '../../types/stores/event-store'; +import type { + IEventSearchParams, + IEventStore, +} from '../../types/stores/event-store'; import type { IBaseEvent, IEventList } from '../../types/events'; import type { DeprecatedSearchEventsSchema } from '../../openapi/spec/deprecated-search-events-schema'; import type EventEmitter from 'events'; import type { IApiUser, ITag, IUser } from '../../types'; import { ApiTokenType } from '../../types/models/api-token'; import { EVENTS_CREATED_BY_PROCESSED } from '../../metric-events'; -import type { EventSearchQueryParameters } from '../../openapi/spec/event-search-query-parameters'; import type { IQueryParam } from '../feature-toggle/types/feature-toggle-strategies-store-type'; import { parseSearchOperatorValue } from '../feature-search/search-utils'; @@ -55,9 +57,7 @@ export default class EventService { }; } - async searchEvents( - search: EventSearchQueryParameters, - ): Promise { + async searchEvents(search: IEventSearchParams): Promise { const queryParams = this.convertToDbParams(search); const totalEvents = await this.eventStore.searchEventsCount( { @@ -143,7 +143,7 @@ export default class EventService { } } - convertToDbParams = (params: EventSearchQueryParameters): IQueryParam[] => { + convertToDbParams = (params: IEventSearchParams): IQueryParam[] => { const queryParams: IQueryParam[] = []; if (params.createdAtFrom) { diff --git a/src/lib/features/events/event-store.ts b/src/lib/features/events/event-store.ts index 4390bb4d3e..9ef38c4769 100644 --- a/src/lib/features/events/event-store.ts +++ b/src/lib/features/events/event-store.ts @@ -7,7 +7,10 @@ import { SEGMENT_UPDATED, } from '../../types/events'; import type { Logger, LogProvider } from '../../logger'; -import type { IEventStore } from '../../types/stores/event-store'; +import type { + IEventSearchParams, + IEventStore, +} from '../../types/stores/event-store'; import type { ITag } from '../../types/model'; import { sharedEventEmitter } from '../../util/anyEventEmitter'; import type { Db } from '../../db/db'; @@ -87,12 +90,6 @@ export interface IEventTable { tags: ITag[]; } -export interface IEventSearchParams { - query: string | undefined; - offset: string | undefined; - limit: string | undefined; -} - const TABLE = 'events'; class EventStore implements IEventStore { diff --git a/src/lib/features/feature-search/feature-search-controller.ts b/src/lib/features/feature-search/feature-search-controller.ts index 8ed3ea781a..a29889cfdf 100644 --- a/src/lib/features/feature-search/feature-search-controller.ts +++ b/src/lib/features/feature-search/feature-search-controller.ts @@ -24,8 +24,6 @@ import { import { normalizeQueryParams } from './search-utils'; import { anonymise } from '../../util'; -const PATH = '/features'; - type FeatureSearchServices = Pick< IUnleashServices, 'openApiService' | 'featureSearchService' @@ -54,7 +52,7 @@ export default class FeatureSearchController extends Controller { this.route({ method: 'get', - path: PATH, + path: '', handler: this.searchFeatures, permission: NONE, middleware: [ diff --git a/src/lib/openapi/spec/event-search-query-parameters.ts b/src/lib/openapi/spec/event-search-query-parameters.ts index fd8454904e..1c921353a7 100644 --- a/src/lib/openapi/spec/event-search-query-parameters.ts +++ b/src/lib/openapi/spec/event-search-query-parameters.ts @@ -8,7 +8,7 @@ export const eventSearchQueryParameters = [ example: 'admin@example.com', }, description: - 'Find events by a free-text search query. The query will be matched against the event type and the event data payload (if any).', + 'Find events by a free-text search query. The query will be matched against the event data payload (if any).', in: 'query', }, { @@ -53,7 +53,7 @@ export const eventSearchQueryParameters = [ pattern: '^(IS|IS_ANY_OF):(.*?)(,([a-zA-Z0-9_]+))*$', }, description: - 'The ID of event creator to filter by. The creators can be specified with an operator. The supported operators are IS, IS_ANY_OF.', + 'Filter by the ID of the event creator, using supported operators: IS, IS_ANY_OF.', in: 'query', }, { @@ -83,6 +83,7 @@ export const eventSearchQueryParameters = [ schema: { type: 'string', example: '50', + default: '0', }, description: 'The number of features to skip when returning a page. By default it is set to 0.', @@ -93,9 +94,10 @@ export const eventSearchQueryParameters = [ schema: { type: 'string', example: '50', + default: '50', }, description: - 'The number of feature environments to return in a page. By default it is set to 50.', + 'The number of feature environments to return in a page. By default it is set to 50. The maximum is 1000.', in: 'query', }, ] as const; diff --git a/src/lib/routes/admin-api/event.ts b/src/lib/routes/admin-api/event.ts index 8ab8d02813..76c3146ac4 100644 --- a/src/lib/routes/admin-api/event.ts +++ b/src/lib/routes/admin-api/event.ts @@ -21,16 +21,6 @@ import { getStandardResponses } from '../../../lib/openapi/util/standard-respons import { createRequestSchema } from '../../openapi/util/create-request-schema'; import type { DeprecatedSearchEventsSchema } from '../../openapi/spec/deprecated-search-events-schema'; import type { IFlagResolver } from '../../types/experimental'; -import { - type EventSearchQueryParameters, - eventSearchQueryParameters, -} from '../../openapi/spec/event-search-query-parameters'; -import type { IAuthRequest } from '../unleash-types'; -import { - type EventSearchResponseSchema, - eventSearchResponseSchema, -} from '../../openapi'; -import { normalizeQueryParams } from '../../features/feature-search/search-utils'; const ANON_KEYS = ['email', 'username', 'createdBy']; const version = 1 as const; @@ -125,26 +115,6 @@ export default class EventController extends Controller { }), ], }); - - this.route({ - method: 'get', - path: '/search', - handler: this.searchEvents, - permission: NONE, - middleware: [ - openApiService.validPath({ - operationId: 'searchEvents', - tags: ['Events'], - summary: 'Search for events', - description: - 'Allows searching for events matching the search criteria in the request body. This operation is deprecated. You should perform a GET request to the same endpoint with your query encoded as query parameters instead.', - parameters: [...eventSearchQueryParameters], - responses: { - 200: createResponseSchema('eventSearchResponseSchema'), - }, - }), - ], - }); } maybeAnonymiseEvents(events: IEvent[]): IEvent[] { @@ -227,33 +197,4 @@ export default class EventController extends Controller { response, ); } - - async searchEvents( - req: IAuthRequest, - res: Response, - ): Promise { - const { normalizedLimit, normalizedOffset } = normalizeQueryParams( - req.query, - { - limitDefault: 50, - maxLimit: 1000, - }, - ); - - const { events, totalEvents } = await this.eventService.searchEvents({ - ...req.body, - offset: normalizedOffset, - limit: normalizedLimit, - }); - - this.openApiService.respondWithValidation( - 200, - res, - eventSearchResponseSchema.$id, - serializeDates({ - events: serializeDates(this.maybeAnonymiseEvents(events)), - total: totalEvents, - }), - ); - } } diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index 907d510fe5..ad9fcabf70 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -32,9 +32,9 @@ import { createKnexTransactionStarter } from '../../db/transaction'; import type { Db } from '../../db/db'; import ExportImportController from '../../features/export-import-toggles/export-import-controller'; import { SegmentsController } from '../../features/segment/segment-controller'; -import FeatureSearchController from '../../features/feature-search/feature-search-controller'; import { InactiveUsersController } from '../../users/inactive/inactive-users-controller'; import { UiObservabilityController } from '../../features/ui-observability-controller/ui-observability-controller'; +import { SearchApi } from './search'; export class AdminApi extends Controller { constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) { @@ -158,10 +158,7 @@ export class AdminApi extends Controller { new TelemetryController(config, services).router, ); - this.app.use( - '/search', - new FeatureSearchController(config, services).router, - ); + this.app.use('/search', new SearchApi(config, services, db).router); this.app.use( '/record-ui-error', diff --git a/src/lib/routes/admin-api/search/index.ts b/src/lib/routes/admin-api/search/index.ts new file mode 100644 index 0000000000..db5eec1f13 --- /dev/null +++ b/src/lib/routes/admin-api/search/index.ts @@ -0,0 +1,24 @@ +import EventSearchController from '../../../features/events/event-search-controller'; +import FeatureSearchController from '../../../features/feature-search/feature-search-controller'; +import type { + Db, + IUnleashConfig, + IUnleashServices, +} from '../../../server-impl'; +import Controller from '../../controller'; + +export class SearchApi extends Controller { + constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) { + super(config); + + this.app.use( + '/features', + new FeatureSearchController(config, services).router, + ); + + this.app.use( + '/events', + new EventSearchController(config, services).router, + ); + } +} diff --git a/src/lib/types/stores/event-store.ts b/src/lib/types/stores/event-store.ts index 9c36ea48fc..52ac5e9eca 100644 --- a/src/lib/types/stores/event-store.ts +++ b/src/lib/types/stores/event-store.ts @@ -2,12 +2,20 @@ import type { IBaseEvent, IEvent } from '../events'; import type { Store } from './store'; import type { DeprecatedSearchEventsSchema } from '../../openapi'; import type EventEmitter from 'events'; -import type { - IEventSearchParams, - IQueryOperations, -} from '../../features/events/event-store'; +import type { IQueryOperations } from '../../features/events/event-store'; import type { IQueryParam } from '../../features/feature-toggle/types/feature-toggle-strategies-store-type'; +export interface IEventSearchParams { + project?: string; + query?: string; + createdAtFrom?: string; + createdAtTo?: string; + createdBy?: string; + type?: string; + offset: number; + limit: number; +} + export interface IEventStore extends Store, Pick { diff --git a/src/test/e2e/api/admin/event-search.e2e.test.ts b/src/test/e2e/api/admin/event-search.e2e.test.ts new file mode 100644 index 0000000000..1a43783818 --- /dev/null +++ b/src/test/e2e/api/admin/event-search.e2e.test.ts @@ -0,0 +1,89 @@ +import type { EventSearchQueryParameters } from '../../../../lib/openapi/spec/event-search-query-parameters'; +import dbInit, { type ITestDb } from '../../helpers/database-init'; + +import { FEATURE_CREATED } from '../../../../lib/types'; +import { EventService } from '../../../../lib/services'; +import EventEmitter from 'events'; +import getLogger from '../../../fixtures/no-logger'; +import { + type IUnleashTest, + setupAppWithCustomConfig, +} from '../../helpers/test-helper'; + +let app: IUnleashTest; +let db: ITestDb; +let eventService: EventService; +const TEST_USER_ID = -9999; + +beforeAll(async () => { + db = await dbInit('event_search', getLogger); + app = await setupAppWithCustomConfig(db.stores, { + experimental: { + flags: { + strictSchemaValidation: true, + }, + }, + }); + + eventService = new EventService(db.stores, { + getLogger, + eventBus: new EventEmitter(), + }); +}); + +afterAll(async () => { + await app.destroy(); + await db.destroy(); +}); + +beforeEach(async () => { + await db.stores.featureToggleStore.deleteAll(); + await db.stores.segmentStore.deleteAll(); + await db.stores.eventStore.deleteAll(); +}); + +const searchEvents = async ( + queryParams: EventSearchQueryParameters, + expectedCode = 200, +) => { + const query = new URLSearchParams(queryParams as any).toString(); + return app.request + .get(`/api/admin/search/events?${query}`) + .expect(expectedCode); +}; + +test('should search events by query', async () => { + await eventService.storeEvent({ + type: FEATURE_CREATED, + project: 'something-else', + data: { id: 'some-other-feature' }, + tags: [], + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + ip: '127.0.0.1', + }); + + await eventService.storeEvent({ + type: FEATURE_CREATED, + project: 'something-else', + data: { id: 'my-other-feature' }, + tags: [], + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + ip: '127.0.0.1', + }); + + const { body } = await searchEvents({ query: 'some-other-feature' }); + + expect(body).toMatchObject({ + events: [ + { + type: 'feature-created', + data: { + id: 'some-other-feature', + }, + }, + ], + total: 1, + }); +});