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:
parent
49095025ff
commit
a34c674971
@ -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);
|
||||
});
|
||||
|
@ -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 [];
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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: {
|
||||
|
47
src/lib/openapi/spec/search-events-schema.ts
Normal file
47
src/lib/openapi/spec/search-events-schema.ts
Normal 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>;
|
@ -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(
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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[]>;
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
@ -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.",
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
|
12
src/test/fixtures/fake-event-store.ts
vendored
12
src/test/fixtures/fake-event-store.ts
vendored
@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user