1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

feat: event search on new endpoint, first test (#7739)

Changed the url of event search to search/events to align with
search/features. With that created a search controller to keep all
searches under there.
Added first test.
This commit is contained in:
Jaanus Sellin 2024-08-02 15:07:21 +03:00 committed by GitHub
parent 993d87516d
commit 57a8b9da79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 244 additions and 87 deletions

View File

@ -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<IUnleashServices, 'eventService' | 'openApiService'>,
) {
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<any, any, any, EventSearchQueryParameters>,
res: Response<EventSearchResponseSchema>,
): Promise<void> {
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;
}
}

View File

@ -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<IEventList> {
async searchEvents(search: IEventSearchParams): Promise<IEventList> {
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) {

View File

@ -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 {

View File

@ -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: [

View File

@ -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;

View File

@ -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<any, any, any, EventSearchQueryParameters>,
res: Response<EventSearchResponseSchema>,
): Promise<void> {
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,
}),
);
}
}

View File

@ -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',

View File

@ -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,
);
}
}

View File

@ -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<IEvent, number>,
Pick<EventEmitter, 'on' | 'setMaxListeners' | 'emit' | 'off'> {

View File

@ -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,
});
});