1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-11 00:08:30 +01:00

refactor: add OpenAPI schema to context controller (#1711)

* refactor: add OpenAPI schema to context controller

* Update src/lib/routes/admin-api/context.ts

Co-authored-by: olav <mail@olav.io>

* address PR comments, misc fixes and improvements

* refactor: address PR comments

* add createdAt to test

* fix: reverted upsert schema after discussion

Co-authored-by: olav <mail@olav.io>
This commit is contained in:
Nuno Góis 2022-06-17 10:11:55 +01:00 committed by GitHub
parent e6b49e4bce
commit 525fce3e86
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 594 additions and 33 deletions

View File

@ -1,6 +1,8 @@
import { OpenAPIV3 } from 'openapi-types';
import { cloneFeatureSchema } from './spec/clone-feature-schema';
import { constraintSchema } from './spec/constraint-schema';
import { contextFieldSchema } from './spec/context-field-schema';
import { contextFieldsSchema } from './spec/context-fields-schema';
import { createFeatureSchema } from './spec/create-feature-schema';
import { createStrategySchema } from './spec/create-strategy-schema';
import { environmentSchema } from './spec/environment-schema';
@ -15,7 +17,9 @@ import { featuresSchema } from './spec/features-schema';
import { feedbackSchema } from './spec/feedback-schema';
import { healthOverviewSchema } from './spec/health-overview-schema';
import { healthReportSchema } from './spec/health-report-schema';
import { legalValueSchema } from './spec/legal-value-schema';
import { mapValues } from '../util/map-values';
import { nameSchema } from './spec/name-schema';
import { omitKeys } from '../util/omit-keys';
import { overrideSchema } from './spec/override-schema';
import { parametersSchema } from './spec/parameters-schema';
@ -32,6 +36,7 @@ import { tagsSchema } from './spec/tags-schema';
import { uiConfigSchema } from './spec/ui-config-schema';
import { updateFeatureSchema } from './spec/update-feature-schema';
import { updateStrategySchema } from './spec/update-strategy-schema';
import { upsertContextFieldSchema } from './spec/upsert-context-field-schema';
import { variantSchema } from './spec/variant-schema';
import { variantsSchema } from './spec/variants-schema';
import { versionSchema } from './spec/version-schema';
@ -44,6 +49,8 @@ import { validateTagTypeSchema } from './spec/validate-tag-type-schema';
export const schemas = {
cloneFeatureSchema,
constraintSchema,
contextFieldSchema,
contextFieldsSchema,
createFeatureSchema,
createStrategySchema,
environmentSchema,
@ -58,6 +65,8 @@ export const schemas = {
feedbackSchema,
healthOverviewSchema,
healthReportSchema,
legalValueSchema,
nameSchema,
overrideSchema,
parametersSchema,
patchSchema,
@ -76,6 +85,7 @@ export const schemas = {
updateFeatureSchema,
updateStrategySchema,
updateTagTypeSchema,
upsertContextFieldSchema,
validateTagTypeSchema,
variantSchema,
variantsSchema,

View File

@ -0,0 +1,19 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`contextFieldSchema empty 1`] = `
Object {
"data": Object {},
"errors": Array [
Object {
"instancePath": "",
"keyword": "required",
"message": "must have required property 'name'",
"params": Object {
"missingProperty": "name",
},
"schemaPath": "#/required",
},
],
"schema": "#/components/schemas/contextFieldSchema",
}
`;

View File

@ -0,0 +1,23 @@
import { validateSchema } from '../validate';
import { ContextFieldSchema } from './context-field-schema';
test('contextFieldSchema', () => {
const data: ContextFieldSchema = {
name: '',
description: '',
stickiness: false,
sortOrder: 0,
createdAt: '2022-01-01T00:00:00.000Z',
legalValues: [],
};
expect(
validateSchema('#/components/schemas/contextFieldSchema', data),
).toBeUndefined();
});
test('contextFieldSchema empty', () => {
expect(
validateSchema('#/components/schemas/contextFieldSchema', {}),
).toMatchSnapshot();
});

View File

@ -0,0 +1,41 @@
import { FromSchema } from 'json-schema-to-ts';
import { legalValueSchema } from './legal-value-schema';
export const contextFieldSchema = {
$id: '#/components/schemas/contextFieldSchema',
type: 'object',
additionalProperties: false,
required: ['name'],
properties: {
name: {
type: 'string',
},
description: {
type: 'string',
},
stickiness: {
type: 'boolean',
},
sortOrder: {
type: 'number',
},
createdAt: {
type: 'string',
format: 'date-time',
nullable: true,
},
legalValues: {
type: 'array',
items: {
$ref: '#/components/schemas/legalValueSchema',
},
},
},
components: {
schemas: {
legalValueSchema,
},
},
} as const;
export type ContextFieldSchema = FromSchema<typeof contextFieldSchema>;

View File

@ -0,0 +1,19 @@
import { FromSchema } from 'json-schema-to-ts';
import { contextFieldSchema } from './context-field-schema';
import { legalValueSchema } from './legal-value-schema';
export const contextFieldsSchema = {
$id: '#/components/schemas/contextFieldsSchema',
type: 'array',
items: {
$ref: '#/components/schemas/contextFieldSchema',
},
components: {
schemas: {
contextFieldSchema,
legalValueSchema,
},
},
} as const;
export type ContextFieldsSchema = FromSchema<typeof contextFieldsSchema>;

View File

@ -0,0 +1,19 @@
import { FromSchema } from 'json-schema-to-ts';
export const legalValueSchema = {
$id: '#/components/schemas/legalValueSchema',
type: 'object',
additionalProperties: false,
required: ['value'],
properties: {
value: {
type: 'string',
},
description: {
type: 'string',
},
},
components: {},
} as const;
export type LegalValueSchema = FromSchema<typeof legalValueSchema>;

View File

@ -0,0 +1,16 @@
import { FromSchema } from 'json-schema-to-ts';
export const nameSchema = {
$id: '#/components/schemas/nameSchema',
type: 'object',
additionalProperties: false,
required: ['name'],
properties: {
name: {
type: 'string',
},
},
components: {},
} as const;
export type NameSchema = FromSchema<typeof nameSchema>;

View File

@ -0,0 +1,38 @@
import { FromSchema } from 'json-schema-to-ts';
import { legalValueSchema } from './legal-value-schema';
export const upsertContextFieldSchema = {
$id: '#/components/schemas/upsertContextFieldSchema',
type: 'object',
additionalProperties: false,
required: ['name'],
properties: {
name: {
type: 'string',
},
description: {
type: 'string',
},
stickiness: {
type: 'boolean',
},
sortOrder: {
type: 'number',
},
legalValues: {
type: 'array',
items: {
$ref: '#/components/schemas/legalValueSchema',
},
},
},
components: {
schemas: {
legalValueSchema,
},
},
} as const;
export type UpsertContextFieldSchema = FromSchema<
typeof upsertContextFieldSchema
>;

View File

@ -8,6 +8,7 @@ import {
CREATE_CONTEXT_FIELD,
UPDATE_CONTEXT_FIELD,
DELETE_CONTEXT_FIELD,
NONE,
} from '../../types/permissions';
import { IUnleashConfig } from '../../types/option';
import { IUnleashServices } from '../../types/services';
@ -15,53 +16,180 @@ import ContextService from '../../services/context-service';
import { Logger } from '../../logger';
import { IAuthRequest } from '../unleash-types';
class ContextController extends Controller {
private logger: Logger;
import { OpenApiService } from '../../services/openapi-service';
import {
contextFieldSchema,
ContextFieldSchema,
} from '../../openapi/spec/context-field-schema';
import { ContextFieldsSchema } from '../../openapi/spec/context-fields-schema';
import { UpsertContextFieldSchema } from '../../openapi/spec/upsert-context-field-schema';
import { createRequestSchema, createResponseSchema } from '../../openapi';
import { serializeDates } from '../../types/serialize-dates';
import NotFoundError from '../../error/notfound-error';
import { emptyResponse } from '../../openapi/spec/empty-response';
import { NameSchema } from '../../openapi/spec/name-schema';
interface ContextParam {
contextField: string;
}
export class ContextController extends Controller {
private contextService: ContextService;
private openApiService: OpenApiService;
private logger: Logger;
constructor(
config: IUnleashConfig,
{ contextService }: Pick<IUnleashServices, 'contextService'>,
{
contextService,
openApiService,
}: Pick<IUnleashServices, 'contextService' | 'openApiService'>,
) {
super(config);
this.openApiService = openApiService;
this.logger = config.getLogger('/admin-api/context.ts');
this.contextService = contextService;
this.get('/', this.getContextFields);
this.post('/', this.createContextField, CREATE_CONTEXT_FIELD);
this.get('/:contextField', this.getContextField);
this.put(
'/:contextField',
this.updateContextField,
UPDATE_CONTEXT_FIELD,
);
this.delete(
'/:contextField',
this.deleteContextField,
DELETE_CONTEXT_FIELD,
);
this.post('/validate', this.validate, UPDATE_CONTEXT_FIELD);
this.route({
method: 'get',
path: '',
handler: this.getContextFields,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['admin'],
operationId: 'getContextFields',
responses: {
200: createResponseSchema('contextFieldsSchema'),
},
}),
],
});
this.route({
method: 'get',
path: '/:contextField',
handler: this.getContextField,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['admin'],
operationId: 'getContextField',
responses: {
200: createResponseSchema('contextFieldSchema'),
},
}),
],
});
this.route({
method: 'post',
path: '',
handler: this.createContextField,
permission: CREATE_CONTEXT_FIELD,
middleware: [
openApiService.validPath({
tags: ['admin'],
operationId: 'createContextField',
requestBody: createRequestSchema(
'upsertContextFieldSchema',
),
responses: {
201: emptyResponse,
},
}),
],
});
this.route({
method: 'put',
path: '/:contextField',
handler: this.updateContextField,
permission: UPDATE_CONTEXT_FIELD,
middleware: [
openApiService.validPath({
tags: ['admin'],
operationId: 'updateContextField',
requestBody: createRequestSchema(
'upsertContextFieldSchema',
),
responses: {
200: emptyResponse,
},
}),
],
});
this.route({
method: 'delete',
path: '/:contextField',
handler: this.deleteContextField,
acceptAnyContentType: true,
permission: DELETE_CONTEXT_FIELD,
middleware: [
openApiService.validPath({
tags: ['admin'],
operationId: 'deleteContextField',
responses: {
200: emptyResponse,
},
}),
],
});
this.route({
method: 'post',
path: '/validate',
handler: this.validate,
permission: UPDATE_CONTEXT_FIELD,
middleware: [
openApiService.validPath({
tags: ['admin'],
operationId: 'validate',
requestBody: createRequestSchema('nameSchema'),
responses: {
200: emptyResponse,
},
}),
],
});
}
async getContextFields(req: Request, res: Response): Promise<void> {
const fields = await this.contextService.getAll();
res.status(200).json(fields).end();
async getContextFields(
req: Request,
res: Response<ContextFieldsSchema>,
): Promise<void> {
res.status(200)
.json(serializeDates(await this.contextService.getAll()))
.end();
}
async getContextField(req: Request, res: Response): Promise<void> {
async getContextField(
req: Request<ContextParam>,
res: Response<ContextFieldSchema>,
): Promise<void> {
try {
const name = req.params.contextField;
const contextField = await this.contextService.getContextField(
name,
);
res.json(contextField).end();
this.openApiService.respondWithValidation(
200,
res,
contextFieldSchema.$id,
serializeDates(contextField),
);
} catch (err) {
res.status(404).json({ error: 'Could not find context field' });
throw new NotFoundError('Could not find context field');
}
}
async createContextField(req: IAuthRequest, res: Response): Promise<void> {
async createContextField(
req: IAuthRequest<void, void, UpsertContextFieldSchema>,
res: Response,
): Promise<void> {
const value = req.body;
const userName = extractUsername(req);
@ -69,7 +197,10 @@ class ContextController extends Controller {
res.status(201).end();
}
async updateContextField(req: IAuthRequest, res: Response): Promise<void> {
async updateContextField(
req: IAuthRequest<ContextParam, void, UpsertContextFieldSchema>,
res: Response,
): Promise<void> {
const name = req.params.contextField;
const userName = extractUsername(req);
const contextField = req.body;
@ -80,7 +211,10 @@ class ContextController extends Controller {
res.status(200).end();
}
async deleteContextField(req: IAuthRequest, res: Response): Promise<void> {
async deleteContextField(
req: IAuthRequest<ContextParam>,
res: Response,
): Promise<void> {
const name = req.params.contextField;
const userName = extractUsername(req);
@ -88,12 +222,13 @@ class ContextController extends Controller {
res.status(200).end();
}
async validate(req: Request, res: Response): Promise<void> {
async validate(
req: Request<void, void, NameSchema>,
res: Response,
): Promise<void> {
const { name } = req.body;
await this.contextService.validateName(name);
res.status(200).end();
}
}
export default ContextController;
module.exports = ContextController;

View File

@ -10,7 +10,7 @@ import EventController from './event';
import MetricsController from './metrics';
import UserController from './user';
import ConfigController from './config';
import ContextController from './context';
import { ContextController } from './context';
import ClientMetricsController from './client-metrics';
import BootstrapController from './bootstrap';
import StateController from './state';

View File

@ -2,9 +2,9 @@ import { Store } from './store';
export interface IContextFieldDto {
name: string;
description: string;
stickiness: boolean;
sortOrder: number;
description?: string;
stickiness?: boolean;
sortOrder?: number;
legalValues?: ILegalValue[];
}

View File

@ -113,6 +113,44 @@ Object {
],
"type": "object",
},
"contextFieldSchema": Object {
"additionalProperties": false,
"properties": Object {
"createdAt": Object {
"format": "date-time",
"nullable": true,
"type": "string",
},
"description": Object {
"type": "string",
},
"legalValues": Object {
"items": Object {
"$ref": "#/components/schemas/legalValueSchema",
},
"type": "array",
},
"name": Object {
"type": "string",
},
"sortOrder": Object {
"type": "number",
},
"stickiness": Object {
"type": "boolean",
},
},
"required": Array [
"name",
],
"type": "object",
},
"contextFieldsSchema": Object {
"items": Object {
"$ref": "#/components/schemas/contextFieldSchema",
},
"type": "array",
},
"createFeatureSchema": Object {
"properties": Object {
"description": Object {
@ -539,6 +577,33 @@ Object {
],
"type": "object",
},
"legalValueSchema": Object {
"additionalProperties": false,
"properties": Object {
"description": Object {
"type": "string",
},
"value": Object {
"type": "string",
},
},
"required": Array [
"value",
],
"type": "object",
},
"nameSchema": Object {
"additionalProperties": false,
"properties": Object {
"name": Object {
"type": "string",
},
},
"required": Array [
"name",
],
"type": "object",
},
"overrideSchema": Object {
"additionalProperties": false,
"properties": Object {
@ -927,6 +992,33 @@ Object {
},
"type": "object",
},
"upsertContextFieldSchema": Object {
"additionalProperties": false,
"properties": Object {
"description": Object {
"type": "string",
},
"legalValues": Object {
"items": Object {
"$ref": "#/components/schemas/legalValueSchema",
},
"type": "array",
},
"name": Object {
"type": "string",
},
"sortOrder": Object {
"type": "number",
},
"stickiness": Object {
"type": "boolean",
},
},
"required": Array [
"name",
],
"type": "object",
},
"validateTagTypeSchema": Object {
"additionalProperties": false,
"properties": Object {
@ -1175,6 +1267,155 @@ Object {
],
},
},
"/api/admin/context": Object {
"get": Object {
"operationId": "getContextFields",
"responses": Object {
"200": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/contextFieldsSchema",
},
},
},
"description": "contextFieldsSchema",
},
},
"tags": Array [
"admin",
],
},
"post": Object {
"operationId": "createContextField",
"requestBody": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/upsertContextFieldSchema",
},
},
},
"description": "upsertContextFieldSchema",
"required": true,
},
"responses": Object {
"201": Object {
"description": "emptyResponse",
},
},
"tags": Array [
"admin",
],
},
},
"/api/admin/context/validate": Object {
"post": Object {
"operationId": "validate",
"requestBody": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/nameSchema",
},
},
},
"description": "nameSchema",
"required": true,
},
"responses": Object {
"200": Object {
"description": "emptyResponse",
},
},
"tags": Array [
"admin",
],
},
},
"/api/admin/context/{contextField}": Object {
"delete": Object {
"operationId": "deleteContextField",
"parameters": Array [
Object {
"in": "path",
"name": "contextField",
"required": true,
"schema": Object {
"type": "string",
},
},
],
"responses": Object {
"200": Object {
"description": "emptyResponse",
},
},
"tags": Array [
"admin",
],
},
"get": Object {
"operationId": "getContextField",
"parameters": Array [
Object {
"in": "path",
"name": "contextField",
"required": true,
"schema": Object {
"type": "string",
},
},
],
"responses": Object {
"200": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/contextFieldSchema",
},
},
},
"description": "contextFieldSchema",
},
},
"tags": Array [
"admin",
],
},
"put": Object {
"operationId": "updateContextField",
"parameters": Array [
Object {
"in": "path",
"name": "contextField",
"required": true,
"schema": Object {
"type": "string",
},
},
],
"requestBody": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/upsertContextFieldSchema",
},
},
},
"description": "upsertContextFieldSchema",
"required": true,
},
"responses": Object {
"200": Object {
"description": "emptyResponse",
},
},
"tags": Array [
"admin",
],
},
},
"/api/admin/environments": Object {
"get": Object {
"operationId": "getAllEnvironments",