1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-04 00:18:01 +01:00

feat: add event search endpoint (#1893)

* feat: add event search endpoint

* refactor: expand variable names

* refactor: add table type to query builder

* refactor: improve schema limit/offset types

* refactor: describe searchEventsSchema fields
This commit is contained in:
olav 2022-08-09 16:14:50 +02:00 committed by GitHub
parent 49095025ff
commit a34c674971
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 289 additions and 86 deletions

View File

@ -24,7 +24,7 @@ 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.getEventsFilterByType('application-created');
const events = await store.searchEvents({ type: 'application-created' });
expect(events).toBeTruthy();
expect(events.length).toBe(0);
});

View File

@ -1,9 +1,10 @@
import { EventEmitter } from 'events';
import { Knex } from 'knex';
import { DROP_FEATURES, IEvent, IBaseEvent } from '../types/events';
import { IEvent, IBaseEvent } from '../types/events';
import { LogProvider, Logger } from '../logger';
import { IEventStore } from '../types/stores/event-store';
import { ITag } from '../types/model';
import { SearchEventsSchema } from '../openapi/spec/search-events-schema';
const EVENT_COLUMNS = [
'id',
@ -115,50 +116,44 @@ class EventStore extends EventEmitter implements IEventStore {
}
}
async getEventsFilterByType(name: string): Promise<IEvent[]> {
try {
const rows = await this.db
.select(EVENT_COLUMNS)
.from(TABLE)
.limit(100)
.where('type', name)
.andWhere(
'id',
'>=',
this.db
.select(this.db.raw('coalesce(max(id),0) as id'))
.from(TABLE)
.where({ type: DROP_FEATURES }),
)
.orderBy('created_at', 'desc');
return rows.map(this.rowToEvent);
} catch (err) {
this.logger.error(err);
return [];
}
}
async searchEvents(search: SearchEventsSchema = {}): Promise<IEvent[]> {
let query = this.db
.select(EVENT_COLUMNS)
.from<IEventTable>(TABLE)
.limit(search.limit ?? 100)
.offset(search.offset ?? 0)
.orderBy('created_at', 'desc');
async getEventsFilterByProject(project: string): Promise<IEvent[]> {
try {
const rows = await this.db
.select(EVENT_COLUMNS)
.from(TABLE)
.where({ project })
.orderBy('created_at', 'desc');
return rows.map(this.rowToEvent);
} catch (err) {
return [];
if (search.type) {
query = query.andWhere({
type: search.type,
});
}
if (search.project) {
query = query.andWhere({
project: search.project,
});
}
if (search.feature) {
query = query.andWhere({
feature_name: search.feature,
});
}
if (search.query) {
query = query.where((where) =>
where
.orWhereRaw('type::text ILIKE ?', `%${search.query}%`)
.orWhereRaw('created_by::text ILIKE ?', `%${search.query}%`)
.orWhereRaw('data::text ILIKE ?', `%${search.query}%`)
.orWhereRaw('pre_data::text ILIKE ?', `%${search.query}%`),
);
}
}
async getEventsForFeature(featureName: string): Promise<IEvent[]> {
try {
const rows = await this.db
.select(EVENT_COLUMNS)
.from(TABLE)
.where({ feature_name: featureName })
.orderBy('created_at', 'desc');
return rows.map(this.rowToEvent);
return (await query).map(this.rowToEvent);
} catch (err) {
return [];
}

View File

@ -104,6 +104,7 @@ import { groupSchema } from './spec/group-schema';
import { groupsSchema } from './spec/groups-schema';
import { groupUserModelSchema } from './spec/group-user-model-schema';
import { usersGroupsBaseSchema } from './spec/users-groups-base-schema';
import { searchEventsSchema } from './spec/search-events-schema';
// All schemas in `openapi/spec` should be listed here.
export const schemas = {
@ -178,6 +179,7 @@ export const schemas = {
resetPasswordSchema,
roleSchema,
sdkContextSchema,
searchEventsSchema,
segmentSchema,
setStrategySortOrderSchema,
sortOrderSchema,

View File

@ -6,7 +6,7 @@ export const featureEventsSchema = {
$id: '#/components/schemas/featureEventsSchema',
type: 'object',
additionalProperties: false,
required: ['toggleName', 'events'],
required: ['events'],
properties: {
version: { type: 'number' },
toggleName: {

View File

@ -0,0 +1,47 @@
import { FromSchema } from 'json-schema-to-ts';
export const searchEventsSchema = {
$id: '#/components/schemas/searchEventsSchema',
type: 'object',
description: `
Search for events by type, project, feature, free-text query,
or a combination thereof. Pass an empty object to fetch all events.
`,
properties: {
type: {
type: 'string',
description: 'Find events by event type (case-sensitive).',
},
project: {
type: 'string',
description: 'Find events by project ID (case-sensitive).',
},
feature: {
type: 'string',
description: 'Find events by feature toggle name (case-sensitive).',
},
query: {
type: 'string',
description: `
Find events by a free-text search query.
The query will be matched against the event type,
the username or email that created the event (if any),
and the event data payload (if any).
`,
},
limit: {
type: 'integer',
minimum: 1,
maximum: 100,
default: 100,
},
offset: {
type: 'integer',
minimum: 0,
default: 0,
},
},
components: {},
} as const;
export type SearchEventsSchema = FromSchema<typeof searchEventsSchema>;

View File

@ -19,6 +19,8 @@ import {
FeatureEventsSchema,
} from '../../../lib/openapi/spec/feature-events-schema';
import { getStandardResponses } from '../../../lib/openapi/util/standard-responses';
import { createRequestSchema } from '../../openapi/util/create-request-schema';
import { SearchEventsSchema } from '../../openapi/spec/search-events-schema';
const version = 1;
export default class EventController extends Controller {
@ -86,9 +88,24 @@ export default class EventController extends Controller {
}),
],
});
this.route({
method: 'post',
path: '/search',
handler: this.searchEvents,
permission: NONE,
middleware: [
openApiService.validPath({
operationId: 'searchEvents',
tags: ['admin'],
requestBody: createRequestSchema('searchEventsSchema'),
responses: { 200: createResponseSchema('eventsSchema') },
}),
],
});
}
fixEvents(events: IEvent[]): IEvent[] {
maybeAnonymiseEvents(events: IEvent[]): IEvent[] {
if (this.anonymise) {
return events.map((e: IEvent) => ({
...e,
@ -105,15 +122,16 @@ export default class EventController extends Controller {
const { project } = req.query;
let events: IEvent[];
if (project) {
events = await this.eventService.getEventsForProject(project);
events = await this.eventService.searchEvents({ project });
} else {
events = await this.eventService.getEvents();
}
const response: EventsSchema = {
version,
events: serializeDates(this.fixEvents(events)),
events: serializeDates(this.maybeAnonymiseEvents(events)),
};
this.openApiService.respondWithValidation(
200,
res,
@ -126,13 +144,32 @@ export default class EventController extends Controller {
req: Request<{ featureName: string }>,
res: Response<FeatureEventsSchema>,
): Promise<void> {
const toggleName = req.params.featureName;
const events = await this.eventService.getEventsForToggle(toggleName);
const feature = req.params.featureName;
const events = await this.eventService.searchEvents({ feature });
const response = {
version,
toggleName,
events: serializeDates(this.fixEvents(events)),
toggleName: feature,
events: serializeDates(this.maybeAnonymiseEvents(events)),
};
this.openApiService.respondWithValidation(
200,
res,
featureEventsSchema.$id,
response,
);
}
async searchEvents(
req: Request<unknown, unknown, SearchEventsSchema>,
res: Response<EventsSchema>,
): Promise<void> {
const events = await this.eventService.searchEvents(req.body);
const response = {
version,
events: serializeDates(this.maybeAnonymiseEvents(events)),
};
this.openApiService.respondWithValidation(

View File

@ -3,6 +3,7 @@ import { IUnleashStores } from '../types/stores';
import { Logger } from '../logger';
import { IEventStore } from '../types/stores/event-store';
import { IEvent } from '../types/events';
import { SearchEventsSchema } from '../openapi/spec/search-events-schema';
export default class EventService {
private logger: Logger;
@ -21,12 +22,8 @@ export default class EventService {
return this.eventStore.getEvents();
}
async getEventsForToggle(name: string): Promise<IEvent[]> {
return this.eventStore.getEventsForFeature(name);
}
async getEventsForProject(project: string): Promise<IEvent[]> {
return this.eventStore.getEventsFilterByProject(project);
async searchEvents(search: SearchEventsSchema): Promise<IEvent[]> {
return this.eventStore.searchEvents(search);
}
}

View File

@ -1,12 +1,11 @@
import EventEmitter from 'events';
import { IBaseEvent, IEvent } from '../events';
import { Store } from './store';
import { SearchEventsSchema } from '../../openapi/spec/search-events-schema';
export interface IEventStore extends Store<IEvent, number>, EventEmitter {
store(event: IBaseEvent): Promise<void>;
batchStore(events: IBaseEvent[]): Promise<void>;
getEvents(): Promise<IEvent[]>;
getEventsFilterByType(name: string): Promise<IEvent[]>;
getEventsForFeature(featureName: string): Promise<IEvent[]>;
getEventsFilterByProject(project: string): Promise<IEvent[]>;
searchEvents(search: SearchEventsSchema): Promise<IEvent[]>;
}

View File

@ -1,8 +1,9 @@
import { IUnleashTest, setupApp } from '../../helpers/test-helper';
import dbInit, { ITestDb } from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
import { FEATURE_CREATED } from '../../../../lib/types/events';
import { FEATURE_CREATED, IBaseEvent } from '../../../../lib/types/events';
import { IEventStore } from '../../../../lib/types/stores/event-store';
import { randomId } from '../../../../lib/util/random-id';
let app: IUnleashTest;
let db: ITestDb;
@ -14,6 +15,10 @@ beforeAll(async () => {
eventStore = db.stores.eventStore;
});
beforeEach(async () => {
await eventStore.deleteAll();
});
afterAll(async () => {
await app.destroy();
await db.destroy();
@ -60,3 +65,61 @@ test('Can filter by project', async () => {
expect(res.body.events[0].data.id).toEqual('feature');
});
});
test('can search for events', async () => {
const events: IBaseEvent[] = [
{
type: FEATURE_CREATED,
project: randomId(),
data: { id: randomId() },
tags: [],
createdBy: randomId(),
},
{
type: FEATURE_CREATED,
project: randomId(),
data: { id: randomId() },
preData: { id: randomId() },
tags: [],
createdBy: randomId(),
},
];
await Promise.all(
events.map((event) => {
return eventStore.store(event);
}),
);
await app.request
.post('/api/admin/events/search')
.send({})
.expect(200)
.expect((res) => {
expect(res.body.events).toHaveLength(2);
});
await app.request
.post('/api/admin/events/search')
.send({ limit: 1, offset: 1 })
.expect(200)
.expect((res) => {
expect(res.body.events).toHaveLength(1);
expect(res.body.events[0].data.id).toEqual(events[0].data.id);
});
await app.request
.post('/api/admin/events/search')
.send({ query: events[1].data.id })
.expect(200)
.expect((res) => {
expect(res.body.events).toHaveLength(1);
expect(res.body.events[0].data.id).toEqual(events[1].data.id);
});
await app.request
.post('/api/admin/events/search')
.send({ query: events[1].preData.id })
.expect(200)
.expect((res) => {
expect(res.body.events).toHaveLength(1);
expect(res.body.events[0].preData.id).toEqual(events[1].preData.id);
});
});

View File

@ -1010,7 +1010,6 @@ Object {
},
},
"required": Array [
"toggleName",
"events",
],
"type": "object",
@ -2218,6 +2217,47 @@ Object {
],
"type": "object",
},
"searchEventsSchema": Object {
"description": "
Search for events by type, project, feature, free-text query,
or a combination thereof. Pass an empty object to fetch all events.
",
"properties": Object {
"feature": Object {
"description": "Find events by feature toggle name (case-sensitive).",
"type": "string",
},
"limit": Object {
"default": 100,
"maximum": 100,
"minimum": 1,
"type": "integer",
},
"offset": Object {
"default": 0,
"minimum": 0,
"type": "integer",
},
"project": Object {
"description": "Find events by project ID (case-sensitive).",
"type": "string",
},
"query": Object {
"description": "
Find events by a free-text search query.
The query will be matched against the event type,
the username or email that created the event (if any),
and the event data payload (if any).
",
"type": "string",
},
"type": Object {
"description": "Find events by event type (case-sensitive).",
"type": "string",
},
},
"type": "object",
},
"segmentSchema": Object {
"additionalProperties": false,
"properties": Object {
@ -3736,6 +3776,37 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
"/api/admin/events/search": Object {
"post": Object {
"operationId": "searchEvents",
"requestBody": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/searchEventsSchema",
},
},
},
"description": "searchEventsSchema",
"required": true,
},
"responses": Object {
"200": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/eventsSchema",
},
},
},
"description": "eventsSchema",
},
},
"tags": Array [
"admin",
],
},
},
"/api/admin/events/{featureName}": Object {
"get": Object {
"description": "Returns all events related to the specified feature toggle. If the feature toggle does not exist, the list of events will be empty.",

View File

@ -32,9 +32,9 @@ test('Can create new setting', async () => {
expect(actual).toStrictEqual(someData);
const { eventStore } = stores;
const createdEvents = await eventStore.getEventsFilterByType(
SETTING_CREATED,
);
const createdEvents = await eventStore.searchEvents({
type: SETTING_CREATED,
});
expect(createdEvents).toHaveLength(1);
});
@ -46,9 +46,9 @@ test('Can delete setting', async () => {
const actual = await service.get('some-setting');
expect(actual).toBeUndefined();
const { eventStore } = stores;
const createdEvents = await eventStore.getEventsFilterByType(
SETTING_DELETED,
);
const createdEvents = await eventStore.searchEvents({
type: SETTING_DELETED,
});
expect(createdEvents).toHaveLength(1);
});
@ -61,8 +61,8 @@ test('Can update setting', async () => {
{ ...someData, test: 'fun' },
'test-user',
);
const updatedEvents = await eventStore.getEventsFilterByType(
SETTING_UPDATED,
);
const updatedEvents = await eventStore.searchEvents({
type: SETTING_UPDATED,
});
expect(updatedEvents).toHaveLength(1);
});

View File

@ -209,12 +209,12 @@ test('Should get all events of type', async () => {
return eventStore.store(event);
}),
);
const featureCreatedEvents = await eventStore.getEventsFilterByType(
FEATURE_CREATED,
);
const featureCreatedEvents = await eventStore.searchEvents({
type: FEATURE_CREATED,
});
expect(featureCreatedEvents).toHaveLength(3);
const featureDeletedEvents = await eventStore.getEventsFilterByType(
FEATURE_DELETED,
);
const featureDeletedEvents = await eventStore.searchEvents({
type: FEATURE_DELETED,
});
expect(featureDeletedEvents).toHaveLength(3);
});

View File

@ -11,10 +11,6 @@ class FakeEventStore extends EventEmitter implements IEventStore {
this.events = [];
}
async getEventsForFeature(featureName: string): Promise<IEvent[]> {
return this.events.filter((e) => e.featureName === featureName);
}
store(event: IEvent): Promise<void> {
this.events.push(event);
this.emit(event.type, event);
@ -58,12 +54,8 @@ class FakeEventStore extends EventEmitter implements IEventStore {
return this.events;
}
async getEventsFilterByType(type: string): Promise<IEvent[]> {
return this.events.filter((e) => e.type === type);
}
async getEventsFilterByProject(project: string): Promise<IEvent[]> {
return this.events.filter((e) => e.project === project);
async searchEvents(): Promise<IEvent[]> {
throw new Error('Method not implemented.');
}
}