diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 0a97c6aae6..493af27747 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -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, diff --git a/src/lib/openapi/spec/__snapshots__/context-field-schema.test.ts.snap b/src/lib/openapi/spec/__snapshots__/context-field-schema.test.ts.snap new file mode 100644 index 0000000000..97ceeef2cf --- /dev/null +++ b/src/lib/openapi/spec/__snapshots__/context-field-schema.test.ts.snap @@ -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", +} +`; diff --git a/src/lib/openapi/spec/context-field-schema.test.ts b/src/lib/openapi/spec/context-field-schema.test.ts new file mode 100644 index 0000000000..73a19ee3e9 --- /dev/null +++ b/src/lib/openapi/spec/context-field-schema.test.ts @@ -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(); +}); diff --git a/src/lib/openapi/spec/context-field-schema.ts b/src/lib/openapi/spec/context-field-schema.ts new file mode 100644 index 0000000000..7df88fe87c --- /dev/null +++ b/src/lib/openapi/spec/context-field-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/context-fields-schema.ts b/src/lib/openapi/spec/context-fields-schema.ts new file mode 100644 index 0000000000..180996c348 --- /dev/null +++ b/src/lib/openapi/spec/context-fields-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/legal-value-schema.ts b/src/lib/openapi/spec/legal-value-schema.ts new file mode 100644 index 0000000000..8fe2d141ca --- /dev/null +++ b/src/lib/openapi/spec/legal-value-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/name-schema.ts b/src/lib/openapi/spec/name-schema.ts new file mode 100644 index 0000000000..c3536e3a58 --- /dev/null +++ b/src/lib/openapi/spec/name-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/upsert-context-field-schema.ts b/src/lib/openapi/spec/upsert-context-field-schema.ts new file mode 100644 index 0000000000..78c16c6175 --- /dev/null +++ b/src/lib/openapi/spec/upsert-context-field-schema.ts @@ -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 +>; diff --git a/src/lib/routes/admin-api/context.ts b/src/lib/routes/admin-api/context.ts index 328eb71684..3c1a169088 100644 --- a/src/lib/routes/admin-api/context.ts +++ b/src/lib/routes/admin-api/context.ts @@ -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, + { + contextService, + openApiService, + }: Pick, ) { 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 { - const fields = await this.contextService.getAll(); - res.status(200).json(fields).end(); + async getContextFields( + req: Request, + res: Response, + ): Promise { + res.status(200) + .json(serializeDates(await this.contextService.getAll())) + .end(); } - async getContextField(req: Request, res: Response): Promise { + async getContextField( + req: Request, + res: Response, + ): Promise { 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 { + async createContextField( + req: IAuthRequest, + res: Response, + ): Promise { 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 { + async updateContextField( + req: IAuthRequest, + res: Response, + ): Promise { 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 { + async deleteContextField( + req: IAuthRequest, + res: Response, + ): Promise { 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 { + async validate( + req: Request, + res: Response, + ): Promise { const { name } = req.body; await this.contextService.validateName(name); res.status(200).end(); } } -export default ContextController; -module.exports = ContextController; diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index 45a6679679..83ea3af5cd 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -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'; diff --git a/src/lib/types/stores/context-field-store.ts b/src/lib/types/stores/context-field-store.ts index bf7273608c..b36295a7b6 100644 --- a/src/lib/types/stores/context-field-store.ts +++ b/src/lib/types/stores/context-field-store.ts @@ -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[]; } diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 23db22dfc6..942b0c6e13 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -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",