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

feat: add OpenAPI spec to events controller. (#1754)

* Feat: add initial event schema

* Feat: add events-schema plus tests

* Feat(broken): add openapi validation to getEvents endpoint

* Add schema to basic events endpoint

* Feat: Add openapi for feature events

* Fix: fix recursive schema inclusion

* Feat: add test for recursive function

* Fix: make nullable fields nullable

* Fix: remove `ADMIN` permission for toggle events.

* fix: add new schemas to the snapshot

* Fix: remove recursive schema inclusion

* Feat: test feature events schema

* Fix: add correct permissions for feature events endpoint.

* Refactor: rename "name" to "featureName" for clearer docs

* Fix: Add missing "version" field to feature events

* Feat: add descriptions and extra responses to events endpoints.

* Fix: update openapi snapshot

* Simplify standard responses function

* Refactor: move endpoint descriptions into own file.

* Refactor: simplify type signature.

* Feat: specify type of data and preData properties to object

* Fix: update snapshot

* Refactor: move standard-responses into /util/ directory.
This commit is contained in:
Thomas Heartman 2022-06-30 10:12:34 +02:00 committed by GitHub
parent 7816d8a1f9
commit 4dec126199
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 594 additions and 14 deletions

View File

@ -0,0 +1,15 @@
export const endpointDescriptions = {
admin: {
events: {
description:
'Returns **the last 100** from the Unleash instance when called without a query parameter. When called with a `project` parameter, returns **all events** for the specified project.\n\nIf the provided project does not exist, the list of events will be empty.',
summary:
'Get the most recent events from the Unleash instance or all events related to a project.',
},
eventsPerFeature: {
description:
'Returns all events related to the specified feature toggle. If the feature toggle does not exist, the list of events will be empty.',
summary: 'Get all events related to a specific feature toggle.',
},
},
} as const;

View File

@ -82,6 +82,9 @@ import { emailSchema } from './spec/email-schema';
import { strategySchema } from './spec/strategy-schema';
import { strategiesSchema } from './spec/strategies-schema';
import { upsertStrategySchema } from './spec/upsert-strategy-schema';
import { eventSchema } from './spec/event-schema';
import { eventsSchema } from './spec/events-schema';
import { featureEventsSchema } from './spec/feature-events-schema';
import { clientApplicationSchema } from './spec/client-application-schema';
import { IServerOption } from '../types';
import { URL } from 'url';
@ -109,9 +112,12 @@ export const schemas = {
emailSchema,
environmentSchema,
environmentsSchema,
eventSchema,
eventsSchema,
exportParametersSchema,
featureEnvironmentSchema,
featureEnvironmentMetricsSchema,
featureEventsSchema,
featureSchema,
featureMetricsSchema,
featureUsageSchema,

View File

@ -0,0 +1,31 @@
import { validateSchema } from '../validate';
import { EventSchema } from './event-schema';
test('eventSchema', () => {
const data: EventSchema = {
id: 899,
type: 'feature-created',
createdBy: 'user@company.com',
createdAt: '2022-05-31T13:32:20.560Z',
data: {
name: 'new-feature',
description: 'Toggle description',
type: 'release',
project: 'my-project',
stale: false,
variants: [],
createdAt: '2022-05-31T13:32:20.547Z',
lastSeenAt: null,
impressionData: true,
},
preData: null,
tags: [{ type: 'simple', value: 'my-val' }],
featureName: 'new-feature',
project: 'my-project',
environment: null,
};
expect(
validateSchema('#/components/schemas/eventSchema', data),
).toBeUndefined();
});

View File

@ -0,0 +1,53 @@
import { FromSchema } from 'json-schema-to-ts';
import { tagSchema } from './tag-schema';
export const eventSchema = {
$id: '#/components/schemas/eventSchema',
type: 'object',
additionalProperties: false,
required: ['id', 'createdAt', 'type', 'createdBy'],
properties: {
id: {
type: 'integer',
minimum: 1,
},
createdAt: {
type: 'string',
format: 'date-time',
},
type: {
type: 'string',
},
createdBy: {
type: 'string',
},
environment: {
type: 'string',
nullable: true,
},
project: {
type: 'string',
nullable: true,
},
featureName: {
type: 'string',
nullable: true,
},
data: { type: 'object', nullable: true },
preData: { type: 'object', nullable: true },
tags: {
type: 'array',
items: {
$ref: tagSchema.$id,
},
nullable: true,
},
},
components: {
schemas: {
tagSchema,
},
},
} as const;
export type EventSchema = FromSchema<typeof eventSchema>;

View File

@ -0,0 +1,77 @@
import { validateSchema } from '../validate';
import { EventsSchema } from './events-schema';
test('eventsSchema', () => {
const data: EventsSchema = {
version: 1,
events: [
{
id: 899,
type: 'feature-created',
createdBy: 'user@company.com',
createdAt: '2022-05-31T13:32:20.560Z',
data: {
name: 'new-feature',
description: 'Toggle description',
type: 'release',
project: 'my-project',
stale: false,
variants: [],
createdAt: '2022-05-31T13:32:20.547Z',
lastSeenAt: null,
impressionData: true,
},
preData: null,
tags: [],
featureName: 'new-feature',
project: 'my-project',
environment: null,
},
],
};
expect(
validateSchema('#/components/schemas/eventsSchema', data),
).toBeUndefined();
});
test('eventsSchema types', () => {
const data: EventsSchema = {
version: 1,
events: [
{
// @ts-expect-error
id: '1',
type: 'feature-created',
createdBy: 'user@company.com',
createdAt: '2022-05-31T13:32:20.560Z',
data: {
name: 'new-feature',
description: 'Toggle description',
type: 'release',
project: 'my-project',
stale: false,
variants: [],
createdAt: '2022-05-31T13:32:20.547Z',
lastSeenAt: null,
impressionData: true,
},
preData: null,
tags: [
{
type: '',
// @ts-expect-error
value: 1,
},
],
featureName: 'new-feature',
project: 'my-project',
environment: null,
},
],
};
expect(
validateSchema('#/components/schemas/eventsSchema', data),
).not.toBeUndefined();
});

View File

@ -0,0 +1,28 @@
import { FromSchema } from 'json-schema-to-ts';
import { eventSchema } from './event-schema';
import { tagSchema } from './tag-schema';
export const eventsSchema = {
$id: '#/components/schemas/eventsSchema',
type: 'object',
additionalProperties: false,
required: ['version', 'events'],
properties: {
version: {
type: 'integer',
minimum: 1,
},
events: {
type: 'array',
items: { $ref: eventSchema.$id },
},
},
components: {
schemas: {
eventSchema,
tagSchema,
},
},
} as const;
export type EventsSchema = FromSchema<typeof eventsSchema>;

View File

@ -0,0 +1,76 @@
import { validateSchema } from '../validate';
import {
FeatureEventsSchema,
featureEventsSchema,
} from './feature-events-schema';
test('featureEventsSchema', () => {
const data: FeatureEventsSchema = {
toggleName: 'my-feature',
events: [
{
id: 899,
type: 'feature-created',
createdBy: 'user@company.com',
createdAt: '2022-05-31T13:32:20.560Z',
data: {
name: 'new-feature',
description: 'Toggle description',
type: 'release',
project: 'my-project',
stale: false,
variants: [],
createdAt: '2022-05-31T13:32:20.547Z',
lastSeenAt: null,
impressionData: true,
},
preData: null,
tags: [],
featureName: 'new-feature',
project: 'my-project',
environment: null,
},
],
};
expect(validateSchema(featureEventsSchema.$id, data)).toBeUndefined();
});
test('featureEventsSchema types', () => {
const data: FeatureEventsSchema = {
toggleName: 'my-feature',
events: [
{
// @ts-expect-error
id: '1',
type: 'feature-created',
createdBy: 'user@company.com',
createdAt: '2022-05-31T13:32:20.560Z',
data: {
name: 'new-feature',
description: 'Toggle description',
type: 'release',
project: 'my-project',
stale: false,
variants: [],
createdAt: '2022-05-31T13:32:20.547Z',
lastSeenAt: null,
impressionData: true,
},
preData: null,
tags: [
{
type: '',
// @ts-expect-error
value: 1,
},
],
featureName: 'new-feature',
project: 'my-project',
environment: null,
},
],
};
expect(validateSchema(featureEventsSchema.$id, data)).not.toBeUndefined();
});

View File

@ -0,0 +1,28 @@
import { FromSchema } from 'json-schema-to-ts';
import { eventSchema } from './event-schema';
import { tagSchema } from './tag-schema';
export const featureEventsSchema = {
$id: '#/components/schemas/featureEventsSchema',
type: 'object',
additionalProperties: false,
required: ['toggleName', 'events'],
properties: {
version: { type: 'number' },
toggleName: {
type: 'string',
},
events: {
type: 'array',
items: { $ref: eventSchema.$id },
},
},
components: {
schemas: {
eventSchema,
tagSchema,
},
},
} as const;
export type FeatureEventsSchema = FromSchema<typeof featureEventsSchema>;

View File

@ -0,0 +1,21 @@
export const unauthorizedResponse = {
description:
'Authorization information is missing or invalid. Provide a valid API token as the `authorization` header, e.g. `authorization:*.*.my-admin-token`.',
} as const;
const standardResponses = {
401: unauthorizedResponse,
} as const;
type StandardResponses = typeof standardResponses;
export const getStandardResponses = (
...statusCodes: (keyof StandardResponses)[]
): Partial<StandardResponses> =>
statusCodes.reduce(
(acc, n) => ({
...acc,
[n]: standardResponses[n],
}),
{} as Partial<StandardResponses>,
);

View File

@ -2,10 +2,23 @@ import { Request, Response } from 'express';
import { IUnleashConfig } from '../../types/option';
import { IUnleashServices } from '../../types/services';
import EventService from '../../services/event-service';
import { ADMIN } from '../../types/permissions';
import { ADMIN, NONE } from '../../types/permissions';
import { IEvent } from '../../types/events';
import Controller from '../controller';
import { anonymise } from '../../util/anonymise';
import { OpenApiService } from '../../services/openapi-service';
import { createResponseSchema } from '../../../lib/openapi';
import { endpointDescriptions } from '../../../lib/openapi/endpoint-descriptions';
import {
eventsSchema,
EventsSchema,
} from '../../../lib/openapi/spec/events-schema';
import { serializeDates } from '../../../lib/types/serialize-dates';
import {
featureEventsSchema,
FeatureEventsSchema,
} from '../../../lib/openapi/spec/feature-events-schema';
import { getStandardResponses } from '../../../lib/openapi/util/standard-responses';
const version = 1;
export default class EventController extends Controller {
@ -13,15 +26,66 @@ export default class EventController extends Controller {
private anonymise: boolean = false;
private openApiService: OpenApiService;
constructor(
config: IUnleashConfig,
{ eventService }: Pick<IUnleashServices, 'eventService'>,
{
eventService,
openApiService,
}: Pick<IUnleashServices, 'eventService' | 'openApiService'>,
) {
super(config);
this.eventService = eventService;
this.anonymise = config.experimental?.anonymiseEventLog;
this.get('/', this.getEvents, ADMIN);
this.get('/:name', this.getEventsForToggle);
this.openApiService = openApiService;
this.route({
method: 'get',
path: '',
handler: this.getEvents,
permission: ADMIN,
middleware: [
openApiService.validPath({
operationId: 'getEvents',
tags: ['admin'],
responses: {
...getStandardResponses(401),
200: createResponseSchema('eventsSchema'),
},
parameters: [
{
name: 'project',
description:
'The name of the project whose events you want to retrieve',
schema: { type: 'string' },
in: 'query',
},
],
...endpointDescriptions.admin.events,
}),
],
});
this.route({
method: 'get',
path: '/:featureName',
handler: this.getEventsForToggle,
permission: NONE,
middleware: [
openApiService.validPath({
operationId: 'getEventsForToggle',
tags: ['admin'],
responses: {
...getStandardResponses(401),
200: createResponseSchema('featureEventsSchema'),
},
...endpointDescriptions.admin.eventsPerFeature,
}),
],
});
}
fixEvents(events: IEvent[]): IEvent[] {
@ -36,7 +100,7 @@ export default class EventController extends Controller {
async getEvents(
req: Request<any, any, any, { project?: string }>,
res: Response,
res: Response<EventsSchema>,
): Promise<void> {
const { project } = req.query;
let events: IEvent[];
@ -45,23 +109,37 @@ export default class EventController extends Controller {
} else {
events = await this.eventService.getEvents();
}
res.json({
const response: EventsSchema = {
version,
events: this.fixEvents(events),
});
events: serializeDates(this.fixEvents(events)),
};
this.openApiService.respondWithValidation(
200,
res,
eventsSchema.$id,
response,
);
}
async getEventsForToggle(
req: Request<{ name: string }>,
res: Response,
req: Request<{ featureName: string }>,
res: Response<FeatureEventsSchema>,
): Promise<void> {
const toggleName = req.params.name;
const toggleName = req.params.featureName;
const events = await this.eventService.getEventsForToggle(toggleName);
res.json({
const response = {
version,
toggleName,
events: this.fixEvents(events),
});
events: serializeDates(this.fixEvents(events)),
};
this.openApiService.respondWithValidation(
200,
res,
featureEventsSchema.$id,
response,
);
}
}

View File

@ -615,6 +615,79 @@ Object {
],
"type": "object",
},
"eventSchema": Object {
"additionalProperties": false,
"properties": Object {
"createdAt": Object {
"format": "date-time",
"type": "string",
},
"createdBy": Object {
"type": "string",
},
"data": Object {
"nullable": true,
"type": "object",
},
"environment": Object {
"nullable": true,
"type": "string",
},
"featureName": Object {
"nullable": true,
"type": "string",
},
"id": Object {
"minimum": 1,
"type": "integer",
},
"preData": Object {
"nullable": true,
"type": "object",
},
"project": Object {
"nullable": true,
"type": "string",
},
"tags": Object {
"items": Object {
"$ref": "#/components/schemas/tagSchema",
},
"nullable": true,
"type": "array",
},
"type": Object {
"type": "string",
},
},
"required": Array [
"id",
"createdAt",
"type",
"createdBy",
],
"type": "object",
},
"eventsSchema": Object {
"additionalProperties": false,
"properties": Object {
"events": Object {
"items": Object {
"$ref": "#/components/schemas/eventSchema",
},
"type": "array",
},
"version": Object {
"minimum": 1,
"type": "integer",
},
},
"required": Array [
"version",
"events",
],
"type": "object",
},
"exportParametersSchema": Object {
"properties": Object {
"download": Object {
@ -743,6 +816,28 @@ Object {
],
"type": "object",
},
"featureEventsSchema": Object {
"additionalProperties": false,
"properties": Object {
"events": Object {
"items": Object {
"$ref": "#/components/schemas/eventSchema",
},
"type": "array",
},
"toggleName": Object {
"type": "string",
},
"version": Object {
"type": "number",
},
},
"required": Array [
"toggleName",
"events",
],
"type": "object",
},
"featureMetricsSchema": Object {
"additionalProperties": false,
"properties": Object {
@ -2873,6 +2968,78 @@ Object {
],
},
},
"/api/admin/events": Object {
"get": Object {
"description": "Returns **the last 100** from the Unleash instance when called without a query parameter. When called with a \`project\` parameter, returns **all events** for the specified project.
If the provided project does not exist, the list of events will be empty.",
"operationId": "getEvents",
"parameters": Array [
Object {
"description": "The name of the project whose events you want to retrieve",
"in": "query",
"name": "project",
"schema": Object {
"type": "string",
},
},
],
"responses": Object {
"200": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/eventsSchema",
},
},
},
"description": "eventsSchema",
},
"401": Object {
"description": "Authorization information is missing or invalid. Provide a valid API token as the \`authorization\` header, e.g. \`authorization:*.*.my-admin-token\`.",
},
},
"summary": "Get the most recent events from the Unleash instance or all events related to a project.",
"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.",
"operationId": "getEventsForToggle",
"parameters": Array [
Object {
"in": "path",
"name": "featureName",
"required": true,
"schema": Object {
"type": "string",
},
},
],
"responses": Object {
"200": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/featureEventsSchema",
},
},
},
"description": "featureEventsSchema",
},
"401": Object {
"description": "Authorization information is missing or invalid. Provide a valid API token as the \`authorization\` header, e.g. \`authorization:*.*.my-admin-token\`.",
},
},
"summary": "Get all events related to a specific feature toggle.",
"tags": Array [
"admin",
],
},
},
"/api/admin/feature-types": Object {
"get": Object {
"operationId": "getAllFeatureTypes",