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:
parent
993d87516d
commit
57a8b9da79
101
src/lib/features/events/event-search-controller.ts
Normal file
101
src/lib/features/events/event-search-controller.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -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 {
|
||||
|
@ -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: [
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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',
|
||||
|
24
src/lib/routes/admin-api/search/index.ts
Normal file
24
src/lib/routes/admin-api/search/index.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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'> {
|
||||
|
89
src/test/e2e/api/admin/event-search.e2e.test.ts
Normal file
89
src/test/e2e/api/admin/event-search.e2e.test.ts
Normal 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,
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user