diff --git a/src/lib/features/events/event-service.ts b/src/lib/features/events/event-service.ts index b585136c0b..f6b55e6c44 100644 --- a/src/lib/features/events/event-service.ts +++ b/src/lib/features/events/event-service.ts @@ -3,11 +3,14 @@ import type { IFeatureTagStore, IUnleashStores } from '../../types/stores'; import type { Logger } from '../../logger'; import type { IEventStore } from '../../types/stores/event-store'; import type { IBaseEvent, IEventList } from '../../types/events'; -import type { SearchEventsSchema } from '../../openapi/spec/search-events-schema'; +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'; export default class EventService { private logger: Logger; @@ -40,9 +43,38 @@ export default class EventService { }; } - async searchEvents(search: SearchEventsSchema): Promise { - const totalEvents = await this.eventStore.filteredCount(search); - const events = await this.eventStore.searchEvents(search); + async deprecatedSearchEvents( + search: DeprecatedSearchEventsSchema, + ): Promise { + const totalEvents = + await this.eventStore.deprecatedFilteredCount(search); + const events = await this.eventStore.deprecatedSearchEvents(search); + return { + events, + totalEvents, + }; + } + + async searchEvents( + search: EventSearchQueryParameters, + ): Promise { + const queryParams = this.convertToDbParams(search); + const totalEvents = await this.eventStore.searchEventsCount( + { + limit: search.limit, + offset: search.offset, + query: search.query, + }, + queryParams, + ); + const events = await this.eventStore.searchEvents( + { + limit: search.limit, + offset: search.offset, + query: search.query, + }, + queryParams, + ); return { events, totalEvents, @@ -110,4 +142,46 @@ export default class EventService { }); } } + + convertToDbParams = (params: EventSearchQueryParameters): IQueryParam[] => { + const queryParams: IQueryParam[] = []; + + if (params.createdAtFrom) { + queryParams.push({ + field: 'created_at', + operator: 'IS_ON_OR_AFTER', + values: [params.createdAtFrom], + }); + } + + if (params.createdAtTo) { + queryParams.push({ + field: 'created_at', + operator: 'IS_BEFORE', + values: [params.createdAtTo], + }); + } + + if (params.createdBy) { + const parsed = parseSearchOperatorValue( + 'created_by', + params.createdBy, + ); + if (parsed) queryParams.push(parsed); + } + + if (params.type) { + const parsed = parseSearchOperatorValue('type', params.type); + if (parsed) queryParams.push(parsed); + } + + ['feature', 'project'].forEach((field) => { + if (params[field]) { + const parsed = parseSearchOperatorValue(field, params[field]); + if (parsed) queryParams.push(parsed); + } + }); + + return queryParams; + }; } diff --git a/src/lib/features/events/event-store.test.ts b/src/lib/features/events/event-store.test.ts index 07524beb05..5026a1dd4a 100644 --- a/src/lib/features/events/event-store.test.ts +++ b/src/lib/features/events/event-store.test.ts @@ -27,7 +27,9 @@ test('Trying to get events by name if db fails should yield empty list', async ( client: 'pg', }); const store = new EventStore(db, getLogger); - const events = await store.searchEvents({ type: 'application-created' }); + const events = await store.deprecatedSearchEvents({ + type: 'application-created', + }); expect(events).toBeTruthy(); expect(events.length).toBe(0); await db.destroy(); diff --git a/src/lib/features/events/event-store.ts b/src/lib/features/events/event-store.ts index e71677759a..4390bb4d3e 100644 --- a/src/lib/features/events/event-store.ts +++ b/src/lib/features/events/event-store.ts @@ -9,12 +9,14 @@ import { import type { Logger, LogProvider } from '../../logger'; import type { IEventStore } from '../../types/stores/event-store'; import type { ITag } from '../../types/model'; -import type { SearchEventsSchema } from '../../openapi/spec/search-events-schema'; import { sharedEventEmitter } from '../../util/anyEventEmitter'; import type { Db } from '../../db/db'; import type { Knex } from 'knex'; import type EventEmitter from 'events'; import { ADMIN_TOKEN_USER, SYSTEM_USER_ID } from '../../types'; +import type { DeprecatedSearchEventsSchema } from '../../openapi'; +import type { IQueryParam } from '../feature-toggle/types/feature-toggle-strategies-store-type'; +import { applyGenericQueryParams } from '../feature-search/search-utils'; const EVENT_COLUMNS = [ 'id', @@ -85,6 +87,12 @@ export interface IEventTable { tags: ITag[]; } +export interface IEventSearchParams { + query: string | undefined; + offset: string | undefined; + limit: string | undefined; +} + const TABLE = 'events'; class EventStore implements IEventStore { @@ -125,7 +133,9 @@ class EventStore implements IEventStore { } } - async filteredCount(eventSearch: SearchEventsSchema): Promise { + async deprecatedFilteredCount( + eventSearch: DeprecatedSearchEventsSchema, + ): Promise { let query = this.db(TABLE); if (eventSearch.type) { query = query.andWhere({ type: eventSearch.type }); @@ -147,6 +157,22 @@ class EventStore implements IEventStore { } } + async searchEventsCount( + params: IEventSearchParams, + queryParams: IQueryParam[], + ): Promise { + const query = this.buildSearchQuery(params, queryParams); + const count = await query.count().first(); + if (!count) { + return 0; + } + if (typeof count.count === 'string') { + return Number.parseInt(count.count, 10); + } else { + return count.count; + } + } + async batchStore(events: IBaseEvent[]): Promise { try { await this.db(TABLE).insert(events.map(this.eventToDbRow)); @@ -320,7 +346,46 @@ class EventStore implements IEventStore { } } - async searchEvents(search: SearchEventsSchema = {}): Promise { + async searchEvents( + params: IEventSearchParams, + queryParams: IQueryParam[], + ): Promise { + const query = this.buildSearchQuery(params, queryParams) + .select(EVENT_COLUMNS) + .orderBy('created_at', 'desc') + .limit(Number(params.limit) ?? 100) + .offset(Number(params.offset) ?? 0); + + try { + return (await query).map(this.rowToEvent); + } catch (err) { + return []; + } + } + + private buildSearchQuery( + params: IEventSearchParams, + queryParams: IQueryParam[], + ) { + let query = this.db.from(TABLE); + + applyGenericQueryParams(query, queryParams); + + if (params.query) { + query = query.where((where) => + where + .orWhereRaw('data::text ILIKE ?', `%${params.query}%`) + .orWhereRaw('tags::text ILIKE ?', `%${params.query}%`) + .orWhereRaw('pre_data::text ILIKE ?', `%${params.query}%`), + ); + } + + return query; + } + + async deprecatedSearchEvents( + search: DeprecatedSearchEventsSchema = {}, + ): Promise { let query = this.db .select(EVENT_COLUMNS) .from(TABLE) diff --git a/src/lib/features/feature-search/feature-search-controller.ts b/src/lib/features/feature-search/feature-search-controller.ts index 94a42aa338..8ed3ea781a 100644 --- a/src/lib/features/feature-search/feature-search-controller.ts +++ b/src/lib/features/feature-search/feature-search-controller.ts @@ -104,19 +104,26 @@ export default class FeatureSearchController extends Controller { state, status, favoritesFirst, + sortBy, } = req.query; const userId = req.user.id; const { normalizedQuery, - normalizedSortBy, normalizedSortOrder, normalizedOffset, normalizedLimit, - } = normalizeQueryParams(req.query, { - limitDefault: 50, - maxLimit: 100, - sortByDefault: 'createdAt', - }); + } = normalizeQueryParams( + { + query, + offset: req.query.offset, + limit: req.query.limit, + sortOrder: req.query.sortOrder, + }, + { + limitDefault: 50, + maxLimit: 100, + }, + ); const normalizedStatus = status ?.map((tag) => tag.split(':')) @@ -136,10 +143,10 @@ export default class FeatureSearchController extends Controller { state, createdAt, createdBy, + sortBy, status: normalizedStatus, offset: normalizedOffset, limit: normalizedLimit, - sortBy: normalizedSortBy, sortOrder: normalizedSortOrder, favoritesFirst: normalizedFavoritesFirst, }); diff --git a/src/lib/features/feature-search/feature-search-service.ts b/src/lib/features/feature-search/feature-search-service.ts index d82cb5fecd..a07d860b0d 100644 --- a/src/lib/features/feature-search/feature-search-service.ts +++ b/src/lib/features/feature-search/feature-search-service.ts @@ -6,9 +6,9 @@ import type { } from '../../types'; import type { IFeatureSearchParams, - IQueryOperator, IQueryParam, } from '../feature-toggle/types/feature-toggle-strategies-store-type'; +import { parseSearchOperatorValue } from './search-utils'; export class FeatureSearchService { private featureSearchStore: IFeatureSearchStore; @@ -28,6 +28,7 @@ export class FeatureSearchService { { ...params, limit: params.limit, + sortBy: params.sortBy || 'createdAt', }, queryParams, ); @@ -38,27 +39,11 @@ export class FeatureSearchService { }; } - parseOperatorValue = (field: string, value: string): IQueryParam | null => { - const pattern = - /^(IS|IS_NOT|IS_ANY_OF|IS_NONE_OF|INCLUDE|DO_NOT_INCLUDE|INCLUDE_ALL_OF|INCLUDE_ANY_OF|EXCLUDE_IF_ANY_OF|EXCLUDE_ALL|IS_BEFORE|IS_ON_OR_AFTER):(.+)$/; - const match = value.match(pattern); - - if (match) { - return { - field, - operator: match[1] as IQueryOperator, - values: match[2].split(','), - }; - } - - return null; - }; - convertToQueryParams = (params: IFeatureSearchParams): IQueryParam[] => { const queryParams: IQueryParam[] = []; if (params.state) { - const parsedState = this.parseOperatorValue('stale', params.state); + const parsedState = parseSearchOperatorValue('stale', params.state); if (parsedState) { parsedState.values = parsedState.values.map((value) => value === 'active' ? 'false' : 'true', @@ -68,7 +53,7 @@ export class FeatureSearchService { } if (params.createdAt) { - const parsed = this.parseOperatorValue( + const parsed = parseSearchOperatorValue( 'features.created_at', params.createdAt, ); @@ -76,7 +61,7 @@ export class FeatureSearchService { } if (params.createdBy) { - const parsed = this.parseOperatorValue( + const parsed = parseSearchOperatorValue( 'users.id', params.createdBy, ); @@ -84,7 +69,7 @@ export class FeatureSearchService { } if (params.type) { - const parsed = this.parseOperatorValue( + const parsed = parseSearchOperatorValue( 'features.type', params.type, ); @@ -93,7 +78,7 @@ export class FeatureSearchService { ['tag', 'segment', 'project'].forEach((field) => { if (params[field]) { - const parsed = this.parseOperatorValue(field, params[field]); + const parsed = parseSearchOperatorValue(field, params[field]); if (parsed) queryParams.push(parsed); } }); diff --git a/src/lib/features/feature-search/feature.search.e2e.test.ts b/src/lib/features/feature-search/feature.search.e2e.test.ts index f0a3ff95ed..47a1e15f00 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -475,7 +475,7 @@ test('should sort features', async () => { const { body: defaultCreatedAt } = await sortFeatures({ sortBy: '', - sortOrder: '', + sortOrder: 'asc', }); expect(defaultCreatedAt).toMatchObject({ diff --git a/src/lib/features/feature-search/search-utils.ts b/src/lib/features/feature-search/search-utils.ts index dea6385a46..026f4ee04d 100644 --- a/src/lib/features/feature-search/search-utils.ts +++ b/src/lib/features/feature-search/search-utils.ts @@ -1,13 +1,29 @@ import type { Knex } from 'knex'; -import type { IQueryParam } from '../feature-toggle/types/feature-toggle-strategies-store-type'; +import type { + IQueryOperator, + IQueryParam, +} from '../feature-toggle/types/feature-toggle-strategies-store-type'; export interface NormalizeParamsDefaults { limitDefault: number; maxLimit?: number; // Optional because you might not always want to enforce a max limit - sortByDefault: string; typeDefault?: string; // Optional field for type, not required for every call } +export type SearchParams = { + query?: string; + offset?: string | number; + limit?: string | number; + sortOrder?: 'asc' | 'desc'; +}; + +export type NormalizedSearchParams = { + normalizedQuery?: string[]; + normalizedLimit: number; + normalizedOffset: number; + normalizedSortOrder: 'asc' | 'desc'; +}; + export const applySearchFilters = ( qb: Knex.QueryBuilder, searchParams: string[] | undefined, @@ -54,16 +70,10 @@ export const applyGenericQueryParams = ( }; export const normalizeQueryParams = ( - params, + params: SearchParams, defaults: NormalizeParamsDefaults, -) => { - const { - query, - offset, - limit = defaults.limitDefault, - sortOrder, - sortBy = defaults.sortByDefault, - } = params; +): NormalizedSearchParams => { + const { query, offset, limit = defaults.limitDefault, sortOrder } = params; const normalizedQuery = query ?.split(',') @@ -78,7 +88,6 @@ export const normalizeQueryParams = ( const normalizedOffset = Number(offset) > 0 ? Number(offset) : 0; - const normalizedSortBy = sortBy; const normalizedSortOrder = sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc'; @@ -86,7 +95,25 @@ export const normalizeQueryParams = ( normalizedQuery, normalizedLimit, normalizedOffset, - normalizedSortBy, normalizedSortOrder, }; }; + +export const parseSearchOperatorValue = ( + field: string, + value: string, +): IQueryParam | null => { + const pattern = + /^(IS|IS_NOT|IS_ANY_OF|IS_NONE_OF|INCLUDE|DO_NOT_INCLUDE|INCLUDE_ALL_OF|INCLUDE_ANY_OF|EXCLUDE_IF_ANY_OF|EXCLUDE_ALL|IS_BEFORE|IS_ON_OR_AFTER):(.+)$/; + const match = value.match(pattern); + + if (match) { + return { + field, + operator: match[1] as IQueryOperator, + values: match[2].split(','), + }; + } + + return null; +}; diff --git a/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts b/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts index aa01ebb61d..4226de53b8 100644 --- a/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts +++ b/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts @@ -34,7 +34,7 @@ export interface IFeatureSearchParams { offset: number; favoritesFirst?: boolean; limit: number; - sortBy: string; + sortBy?: string; sortOrder: 'asc' | 'desc'; } diff --git a/src/lib/features/instance-stats/instance-stats-service.ts b/src/lib/features/instance-stats/instance-stats-service.ts index 3980eeb291..63cc6229c0 100644 --- a/src/lib/features/instance-stats/instance-stats-service.ts +++ b/src/lib/features/instance-stats/instance-stats-service.ts @@ -269,8 +269,12 @@ export class InstanceStatsService { this.hasSAML(), this.hasOIDC(), this.appCount ? this.appCount : this.refreshAppCountSnapshot(), - this.eventStore.filteredCount({ type: FEATURES_EXPORTED }), - this.eventStore.filteredCount({ type: FEATURES_IMPORTED }), + this.eventStore.deprecatedFilteredCount({ + type: FEATURES_EXPORTED, + }), + this.eventStore.deprecatedFilteredCount({ + type: FEATURES_IMPORTED, + }), this.getProductionChanges(), this.clientMetricsStore.countPreviousDayHourlyMetricsBuckets(), ]); diff --git a/src/lib/features/metrics/instance/instance-service.ts b/src/lib/features/metrics/instance/instance-service.ts index 30ff24ce14..ace2adca8a 100644 --- a/src/lib/features/metrics/instance/instance-service.ts +++ b/src/lib/features/metrics/instance/instance-service.ts @@ -177,8 +177,9 @@ export default class ClientInstanceService { query: IClientApplicationsSearchParams, userId: number, ): Promise { - const applications = - await this.clientApplicationsStore.getApplications(query); + const applications = await this.clientApplicationsStore.getApplications( + { ...query, sortBy: query.sortBy || 'appName' }, + ); const accessibleProjects = await this.privateProjectChecker.getUserAccessibleProjects(userId); if (accessibleProjects.mode === 'all') { diff --git a/src/lib/features/project/project-controller.ts b/src/lib/features/project/project-controller.ts index caf52052b9..a6bab18827 100644 --- a/src/lib/features/project/project-controller.ts +++ b/src/lib/features/project/project-controller.ts @@ -325,14 +325,12 @@ export default class ProjectController extends Controller { const { normalizedQuery, - normalizedSortBy, normalizedSortOrder, normalizedOffset, normalizedLimit, } = normalizeQueryParams(req.query, { limitDefault: 50, maxLimit: 100, - sortByDefault: 'appName', }); const applications = await this.projectService.getApplications({ @@ -340,7 +338,7 @@ export default class ProjectController extends Controller { project: projectId, offset: normalizedOffset, limit: normalizedLimit, - sortBy: normalizedSortBy, + sortBy: req.query.sortBy, sortOrder: normalizedSortOrder, }); diff --git a/src/lib/features/project/project-service.ts b/src/lib/features/project/project-service.ts index b8cdecae83..15bea9bc4a 100644 --- a/src/lib/features/project/project-service.ts +++ b/src/lib/features/project/project-service.ts @@ -1117,8 +1117,10 @@ export default class ProjectService { async getApplications( searchParams: IProjectApplicationsSearchParams, ): Promise { - const applications = - await this.projectStore.getApplicationsByProject(searchParams); + const applications = await this.projectStore.getApplicationsByProject({ + ...searchParams, + sortBy: searchParams.sortBy || 'appName', + }); return applications; } diff --git a/src/lib/openapi/spec/search-events-schema.ts b/src/lib/openapi/spec/deprecated-search-events-schema.ts similarity index 88% rename from src/lib/openapi/spec/search-events-schema.ts rename to src/lib/openapi/spec/deprecated-search-events-schema.ts index 8d28958c51..03cce57a42 100644 --- a/src/lib/openapi/spec/search-events-schema.ts +++ b/src/lib/openapi/spec/deprecated-search-events-schema.ts @@ -1,8 +1,8 @@ import type { FromSchema } from 'json-schema-to-ts'; import { IEventTypes } from '../../types'; -export const searchEventsSchema = { - $id: '#/components/schemas/searchEventsSchema', +export const deprecatedSearchEventsSchema = { + $id: '#/components/schemas/deprecatedSearchEventsSchema', type: 'object', description: ` Search for events by type, project, feature, free-text query, @@ -50,4 +50,6 @@ export const searchEventsSchema = { components: {}, } as const; -export type SearchEventsSchema = FromSchema; +export type DeprecatedSearchEventsSchema = FromSchema< + typeof deprecatedSearchEventsSchema +>; diff --git a/src/lib/openapi/spec/event-search-query-parameters.ts b/src/lib/openapi/spec/event-search-query-parameters.ts new file mode 100644 index 0000000000..fd8454904e --- /dev/null +++ b/src/lib/openapi/spec/event-search-query-parameters.ts @@ -0,0 +1,105 @@ +import type { FromQueryParams } from '../util/from-query-params'; + +export const eventSearchQueryParameters = [ + { + name: 'query', + schema: { + type: 'string', + 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).', + in: 'query', + }, + { + name: 'feature', + schema: { + type: 'string', + example: 'IS:myfeature', + pattern: '^(IS|IS_ANY_OF):(.*?)(,([a-zA-Z0-9_]+))*$', + }, + description: + 'Filter by feature name using supported operators: IS, IS_ANY_OF', + in: 'query', + }, + { + name: 'project', + schema: { + type: 'string', + example: 'IS:default', + pattern: '^(IS|IS_ANY_OF):(.*?)(,([a-zA-Z0-9_]+))*$', + }, + description: + 'Filter by projects ID using supported operators: IS, IS_ANY_OF.', + in: 'query', + }, + { + name: 'type', + schema: { + type: 'string', + example: 'IS:change-added', + pattern: '^(IS|IS_ANY_OF):(.*?)(,([a-zA-Z0-9_]+))*$', + }, + description: + 'Filter by event type using supported operators: IS, IS_ANY_OF.', + in: 'query', + }, + + { + name: 'createdBy', + schema: { + type: 'string', + example: 'IS:2', + 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.', + in: 'query', + }, + { + name: 'createdAtFrom', + schema: { + type: 'string', + example: 'IS:2024-01-01', + pattern: '^(IS):\\d{4}-\\d{2}-\\d{2}$', + }, + description: + 'The starting date of the creation date range in IS:yyyy-MM-dd format', + in: 'query', + }, + { + name: 'createdAtTo', + schema: { + type: 'string', + example: 'IS:2024-01-31', + pattern: '^(IS):\\d{4}-\\d{2}-\\d{2}$', + }, + description: + 'The ending date of the creation date range in IS:yyyy-MM-dd format', + in: 'query', + }, + { + name: 'offset', + schema: { + type: 'string', + example: '50', + }, + description: + 'The number of features to skip when returning a page. By default it is set to 0.', + in: 'query', + }, + { + name: 'limit', + schema: { + type: 'string', + example: '50', + }, + description: + 'The number of feature environments to return in a page. By default it is set to 50.', + in: 'query', + }, +] as const; + +export type EventSearchQueryParameters = Partial< + FromQueryParams +>; diff --git a/src/lib/openapi/spec/event-search-response-schema.ts b/src/lib/openapi/spec/event-search-response-schema.ts new file mode 100644 index 0000000000..f2631ba94d --- /dev/null +++ b/src/lib/openapi/spec/event-search-response-schema.ts @@ -0,0 +1,34 @@ +import type { FromSchema } from 'json-schema-to-ts'; +import { eventSchema } from './event-schema'; +import { tagSchema } from './tag-schema'; + +export const eventSearchResponseSchema = { + $id: '#/components/schemas/eventsSearchResponseSchema', + type: 'object', + additionalProperties: false, + required: ['events', 'total'], + description: 'A list of events that have been registered by the system', + properties: { + events: { + description: 'The list of events', + type: 'array', + items: { $ref: eventSchema.$id }, + }, + total: { + type: 'integer', + description: 'The total count of events', + minimum: 0, + example: 842, + }, + }, + components: { + schemas: { + eventSchema, + tagSchema, + }, + }, +} as const; + +export type EventSearchResponseSchema = FromSchema< + typeof eventSearchResponseSchema +>; diff --git a/src/lib/openapi/spec/feature-search-query-parameters.ts b/src/lib/openapi/spec/feature-search-query-parameters.ts index 4559a731d0..bed2b45b2d 100644 --- a/src/lib/openapi/spec/feature-search-query-parameters.ts +++ b/src/lib/openapi/spec/feature-search-query-parameters.ts @@ -129,6 +129,7 @@ export const featureSearchQueryParameters = [ name: 'sortOrder', schema: { type: 'string', + enum: ['asc', 'desc'] as any, example: 'desc', }, description: diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index ee722429a6..75225a079e 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -61,6 +61,7 @@ export * from './date-schema'; export * from './dependencies-exist-schema'; export * from './dependent-feature-schema'; export * from './deprecated-project-overview-schema'; +export * from './deprecated-search-events-schema'; export * from './dora-features-schema'; export * from './edge-token-schema'; export * from './email-schema'; @@ -69,6 +70,7 @@ export * from './environment-schema'; export * from './environments-project-schema'; export * from './environments-schema'; export * from './event-schema'; +export * from './event-search-response-schema'; export * from './events-schema'; export * from './export-query-schema'; export * from './export-result-schema'; @@ -161,7 +163,6 @@ export * from './role-schema'; export * from './roles-schema'; export * from './sdk-context-schema'; export * from './sdk-flat-context-schema'; -export * from './search-events-schema'; export * from './search-features-schema'; export * from './segment-schema'; export * from './segment-strategies-schema'; diff --git a/src/lib/routes/admin-api/event.ts b/src/lib/routes/admin-api/event.ts index 7f691e1b93..8ab8d02813 100644 --- a/src/lib/routes/admin-api/event.ts +++ b/src/lib/routes/admin-api/event.ts @@ -19,8 +19,18 @@ import { } from '../../../lib/openapi/spec/feature-events-schema'; import { getStandardResponses } from '../../../lib/openapi/util/standard-responses'; import { createRequestSchema } from '../../openapi/util/create-request-schema'; -import type { SearchEventsSchema } from '../../openapi/spec/search-events-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; @@ -98,6 +108,27 @@ export default class EventController extends Controller { this.route({ method: 'post', path: '/search', + handler: this.deprecatedSearchEvents, + permission: NONE, + middleware: [ + openApiService.validPath({ + operationId: 'deprecatedSearchEvents', + tags: ['Events'], + deprecated: true, + summary: 'Search for events (deprecated)', + description: + 'Allows searching for events matching the search criteria in the request body', + requestBody: createRequestSchema( + 'deprecatedSearchEventsSchema', + ), + responses: { 200: createResponseSchema('eventsSchema') }, + }), + ], + }); + + this.route({ + method: 'get', + path: '/search', handler: this.searchEvents, permission: NONE, middleware: [ @@ -106,9 +137,11 @@ export default class EventController extends Controller { tags: ['Events'], summary: 'Search for events', description: - 'Allows searching for events matching the search criteria in the request body', - requestBody: createRequestSchema('searchEventsSchema'), - responses: { 200: createResponseSchema('eventsSchema') }, + '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'), + }, }), ], }); @@ -128,7 +161,9 @@ export default class EventController extends Controller { const { project } = req.query; let eventList: IEventList; if (project) { - eventList = await this.eventService.searchEvents({ project }); + eventList = await this.eventService.deprecatedSearchEvents({ + project, + }); } else { eventList = await this.eventService.getEvents(); } @@ -152,7 +187,9 @@ export default class EventController extends Controller { res: Response, ): Promise { const feature = req.params.featureName; - const eventList = await this.eventService.searchEvents({ feature }); + const eventList = await this.eventService.deprecatedSearchEvents({ + feature, + }); const response = { version, @@ -169,11 +206,13 @@ export default class EventController extends Controller { ); } - async searchEvents( - req: Request, + async deprecatedSearchEvents( + req: Request, res: Response, ): Promise { - const eventList = await this.eventService.searchEvents(req.body); + const eventList = await this.eventService.deprecatedSearchEvents( + req.body, + ); const response = { version, @@ -188,4 +227,33 @@ 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/metrics.ts b/src/lib/routes/admin-api/metrics.ts index 87c659bffb..868febcd75 100644 --- a/src/lib/routes/admin-api/metrics.ts +++ b/src/lib/routes/admin-api/metrics.ts @@ -246,14 +246,12 @@ class MetricsController extends Controller { const { user } = req; const { normalizedQuery, - normalizedSortBy, normalizedSortOrder, normalizedOffset, normalizedLimit, } = normalizeQueryParams(req.query, { limitDefault: 1000, maxLimit: 1000, - sortByDefault: 'appName', }); const applications = await this.clientInstanceService.getApplications( @@ -261,7 +259,7 @@ class MetricsController extends Controller { searchParams: normalizedQuery, offset: normalizedOffset, limit: normalizedLimit, - sortBy: normalizedSortBy, + sortBy: req.query.sortBy, sortOrder: normalizedSortOrder, }, extractUserIdFromUser(user), diff --git a/src/lib/services/version-service.ts b/src/lib/services/version-service.ts index 4c77a8f707..4e2648e638 100644 --- a/src/lib/services/version-service.ts +++ b/src/lib/services/version-service.ts @@ -280,8 +280,12 @@ export default class VersionService { this.strategyStore.count(), this.hasSAML(), this.hasOIDC(), - this.eventStore.filteredCount({ type: FEATURES_EXPORTED }), - this.eventStore.filteredCount({ type: FEATURES_IMPORTED }), + this.eventStore.deprecatedFilteredCount({ + type: FEATURES_EXPORTED, + }), + this.eventStore.deprecatedFilteredCount({ + type: FEATURES_IMPORTED, + }), this.userStats(), this.productionChanges(), this.postgresVersion(), diff --git a/src/lib/types/stores/event-store.ts b/src/lib/types/stores/event-store.ts index db4ca65ff9..9c36ea48fc 100644 --- a/src/lib/types/stores/event-store.ts +++ b/src/lib/types/stores/event-store.ts @@ -1,8 +1,12 @@ import type { IBaseEvent, IEvent } from '../events'; import type { Store } from './store'; -import type { SearchEventsSchema } from '../../openapi/spec/search-events-schema'; +import type { DeprecatedSearchEventsSchema } from '../../openapi'; import type EventEmitter from 'events'; -import type { IQueryOperations } from '../../features/events/event-store'; +import type { + IEventSearchParams, + IQueryOperations, +} from '../../features/events/event-store'; +import type { IQueryParam } from '../../features/feature-toggle/types/feature-toggle-strategies-store-type'; export interface IEventStore extends Store, @@ -12,8 +16,20 @@ export interface IEventStore batchStore(events: IBaseEvent[]): Promise; getEvents(): Promise; count(): Promise; - filteredCount(search: SearchEventsSchema): Promise; - searchEvents(search: SearchEventsSchema): Promise; + deprecatedFilteredCount( + search: DeprecatedSearchEventsSchema, + ): Promise; + searchEventsCount( + params: IEventSearchParams, + queryParams: IQueryParam[], + ): Promise; + deprecatedSearchEvents( + search: DeprecatedSearchEventsSchema, + ): Promise; + searchEvents( + params: IEventSearchParams, + queryParams: IQueryParam[], + ): Promise; getMaxRevisionId(currentMax?: number): Promise; query(operations: IQueryOperations[]): Promise; queryCount(operations: IQueryOperations[]): Promise; diff --git a/src/test/e2e/services/setting-service.test.ts b/src/test/e2e/services/setting-service.test.ts index a051b19339..2da0f1d3ad 100644 --- a/src/test/e2e/services/setting-service.test.ts +++ b/src/test/e2e/services/setting-service.test.ts @@ -36,7 +36,7 @@ test('Can create new setting', async () => { expect(actual).toStrictEqual(someData); const { eventStore } = stores; - const createdEvents = await eventStore.searchEvents({ + const createdEvents = await eventStore.deprecatedSearchEvents({ type: SETTING_CREATED, }); expect(createdEvents).toHaveLength(1); @@ -51,7 +51,7 @@ test('Can delete setting', async () => { const actual = await service.get('some-setting'); expect(actual).toBeUndefined(); const { eventStore } = stores; - const createdEvents = await eventStore.searchEvents({ + const createdEvents = await eventStore.deprecatedSearchEvents({ type: SETTING_DELETED, }); expect(createdEvents).toHaveLength(1); @@ -66,7 +66,7 @@ test('Sentitive SSO settings are redacted in event log', async () => { const actual = await service.get(property); const { eventStore } = stores; - const updatedEvents = await eventStore.searchEvents({ + const updatedEvents = await eventStore.deprecatedSearchEvents({ type: SETTING_UPDATED, }); expect(updatedEvents[0].preData).toEqual({ hideEventDetails: true }); @@ -83,7 +83,7 @@ test('Can update setting', async () => { TEST_AUDIT_USER, false, ); - const updatedEvents = await eventStore.searchEvents({ + const updatedEvents = await eventStore.deprecatedSearchEvents({ type: SETTING_UPDATED, }); expect(updatedEvents).toHaveLength(1); diff --git a/src/test/e2e/stores/event-store.e2e.test.ts b/src/test/e2e/stores/event-store.e2e.test.ts index bedbfaf7e1..5e7ad9883d 100644 --- a/src/test/e2e/stores/event-store.e2e.test.ts +++ b/src/test/e2e/stores/event-store.e2e.test.ts @@ -234,11 +234,11 @@ test('Should get all events of type', async () => { return eventStore.store(event); }), ); - const featureCreatedEvents = await eventStore.searchEvents({ + const featureCreatedEvents = await eventStore.deprecatedSearchEvents({ type: FEATURE_CREATED, }); expect(featureCreatedEvents).toHaveLength(3); - const featureDeletedEvents = await eventStore.searchEvents({ + const featureDeletedEvents = await eventStore.deprecatedSearchEvents({ type: FEATURE_DELETED, }); expect(featureDeletedEvents).toHaveLength(3); diff --git a/src/test/fixtures/fake-event-store.ts b/src/test/fixtures/fake-event-store.ts index 153f885f04..8787a28948 100644 --- a/src/test/fixtures/fake-event-store.ts +++ b/src/test/fixtures/fake-event-store.ts @@ -2,7 +2,7 @@ import type { IEventStore } from '../../lib/types/stores/event-store'; import type { IBaseEvent, IEvent } from '../../lib/types/events'; import { sharedEventEmitter } from '../../lib/util/anyEventEmitter'; import type { IQueryOperations } from '../../lib/features/events/event-store'; -import type { SearchEventsSchema } from '../../lib/openapi'; +import type { DeprecatedSearchEventsSchema } from '../../lib/openapi'; import type EventEmitter from 'events'; class FakeEventStore implements IEventStore { @@ -61,7 +61,13 @@ class FakeEventStore implements IEventStore { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - filteredCount(search: SearchEventsSchema): Promise { + searchEventsCount(): Promise { + return Promise.resolve(0); + } + + deprecatedFilteredCount( + search: DeprecatedSearchEventsSchema, + ): Promise { return Promise.resolve(0); } @@ -79,23 +85,11 @@ class FakeEventStore implements IEventStore { return this.events; } - async searchEvents(): Promise { + async deprecatedSearchEvents(): Promise { throw new Error('Method not implemented.'); } - - async getForFeatures( - features: string[], - environments: string[], - query: { type: string; projectId: string }, - ): Promise { - return this.events.filter((event) => { - return ( - event.type === query.type && - event.project === query.projectId && - features.includes(event.data.featureName) && - environments.includes(event.data.environment) - ); - }); + async searchEvents(): Promise { + throw new Error('Method not implemented.'); } async query(operations: IQueryOperations[]): Promise {