diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 3a15694692..364009cd69 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -34,6 +34,10 @@ import { updateStrategySchema } from './spec/update-strategy-schema'; import { variantSchema } from './spec/variant-schema'; import { variantsSchema } from './spec/variants-schema'; import { versionSchema } from './spec/version-schema'; +import { tagTypeSchema } from './spec/tag-type-schema'; +import { tagTypesSchema } from './spec/tag-types-schema'; +import { updateTagTypeSchema } from './spec/update-tag-type-schema'; +import { validateTagTypeSchema } from './spec/validate-tag-type-schema'; // All schemas in `openapi/spec` should be listed here. export const schemas = { @@ -64,9 +68,13 @@ export const schemas = { strategySchema, tagSchema, tagsSchema, + tagTypeSchema, + tagTypesSchema, uiConfigSchema, updateFeatureSchema, updateStrategySchema, + updateTagTypeSchema, + validateTagTypeSchema, variantSchema, variantsSchema, versionSchema, diff --git a/src/lib/openapi/spec/tag-type-schema.ts b/src/lib/openapi/spec/tag-type-schema.ts new file mode 100644 index 0000000000..2c4319edd0 --- /dev/null +++ b/src/lib/openapi/spec/tag-type-schema.ts @@ -0,0 +1,22 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const tagTypeSchema = { + $id: '#/components/schemas/tagTypeSchema', + type: 'object', + additionalProperties: false, + required: ['name'], + properties: { + name: { + type: 'string', + }, + description: { + type: 'string', + }, + icon: { + type: 'string', + }, + }, + components: {}, +} as const; + +export type TagTypeSchema = FromSchema; diff --git a/src/lib/openapi/spec/tag-types-schema.ts b/src/lib/openapi/spec/tag-types-schema.ts new file mode 100644 index 0000000000..ac95abe78f --- /dev/null +++ b/src/lib/openapi/spec/tag-types-schema.ts @@ -0,0 +1,27 @@ +import { tagTypeSchema } from './tag-type-schema'; +import { FromSchema } from 'json-schema-to-ts'; + +export const tagTypesSchema = { + $id: '#/components/schemas/tagTypesSchema', + type: 'object', + additionalProperties: false, + required: ['version', 'tagTypes'], + properties: { + version: { + type: 'integer', + }, + tagTypes: { + type: 'array', + items: { + $ref: '#/components/schemas/tagTypeSchema', + }, + }, + }, + components: { + schemas: { + tagTypeSchema, + }, + }, +} as const; + +export type TagTypesSchema = FromSchema; diff --git a/src/lib/openapi/spec/update-tag-type-schema.ts b/src/lib/openapi/spec/update-tag-type-schema.ts new file mode 100644 index 0000000000..a6606a3bd0 --- /dev/null +++ b/src/lib/openapi/spec/update-tag-type-schema.ts @@ -0,0 +1,18 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const updateTagTypeSchema = { + $id: '#/components/schemas/updateTagTypeSchema', + type: 'object', + additionalProperties: false, + properties: { + description: { + type: 'string', + }, + icon: { + type: 'string', + }, + }, + components: {}, +} as const; + +export type UpdateTagTypeSchema = FromSchema; diff --git a/src/lib/openapi/spec/validate-tag-type-schema.ts b/src/lib/openapi/spec/validate-tag-type-schema.ts new file mode 100644 index 0000000000..9346941901 --- /dev/null +++ b/src/lib/openapi/spec/validate-tag-type-schema.ts @@ -0,0 +1,24 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { tagTypeSchema } from './tag-type-schema'; + +export const validateTagTypeSchema = { + $id: '#/components/schemas/validateTagTypeSchema', + type: 'object', + additionalProperties: false, + required: ['valid', 'tagType'], + properties: { + valid: { + type: 'boolean', + }, + tagType: { + $ref: '#/components/schemas/tagTypeSchema', + }, + }, + components: { + schemas: { + tagTypeSchema, + }, + }, +} as const; + +export type ValidateTagTypeSchema = FromSchema; diff --git a/src/lib/routes/admin-api/tag-type.ts b/src/lib/routes/admin-api/tag-type.ts index f616cdc829..8b56dfca26 100644 --- a/src/lib/routes/admin-api/tag-type.ts +++ b/src/lib/routes/admin-api/tag-type.ts @@ -1,13 +1,27 @@ import { Request, Response } from 'express'; import Controller from '../controller'; -import { DELETE_TAG_TYPE, UPDATE_TAG_TYPE } from '../../types/permissions'; +import { + DELETE_TAG_TYPE, + NONE, + UPDATE_TAG_TYPE, +} from '../../types/permissions'; import { extractUsername } from '../../util/extract-user'; import { IUnleashConfig } from '../../types/option'; import { IUnleashServices } from '../../types/services'; import TagTypeService from '../../services/tag-type-service'; import { Logger } from '../../logger'; import { IAuthRequest } from '../unleash-types'; +import { createRequestSchema, createResponseSchema } from '../../openapi'; +import { TagTypesSchema } from '../../openapi/spec/tag-types-schema'; +import { emptyResponse } from '../../openapi/spec/empty-response'; +import { ValidateTagTypeSchema } from '../../openapi/spec/validate-tag-type-schema'; +import { + tagTypeSchema, + TagTypeSchema, +} from '../../openapi/spec/tag-type-schema'; +import { UpdateTagTypeSchema } from '../../openapi/spec/update-tag-type-schema'; +import { OpenApiService } from '../../services/openapi-service'; const version = 1; @@ -16,32 +30,134 @@ class TagTypeController extends Controller { private tagTypeService: TagTypeService; + private openApiService: OpenApiService; + constructor( config: IUnleashConfig, - { tagTypeService }: Pick, + { + tagTypeService, + openApiService, + }: Pick, ) { super(config); this.logger = config.getLogger('/admin-api/tag-type.js'); this.tagTypeService = tagTypeService; - this.get('/', this.getTagTypes); - this.post('/', this.createTagType, UPDATE_TAG_TYPE); - this.post('/validate', this.validate, UPDATE_TAG_TYPE); - this.get('/:name', this.getTagType); - this.put('/:name', this.updateTagType, UPDATE_TAG_TYPE); - this.delete('/:name', this.deleteTagType, DELETE_TAG_TYPE); + this.openApiService = openApiService; + this.route({ + method: 'get', + path: '', + handler: this.getTagTypes, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['admin'], + operationId: 'getTagTypes', + responses: { 200: createResponseSchema('tagTypesSchema') }, + }), + ], + }); + this.route({ + method: 'post', + path: '', + handler: this.createTagType, + permission: UPDATE_TAG_TYPE, + middleware: [ + openApiService.validPath({ + tags: ['admin'], + operationId: 'createTagType', + responses: { 201: createResponseSchema('tagTypeSchema') }, + requestBody: createRequestSchema('tagTypeSchema'), + }), + ], + }); + this.route({ + method: 'post', + path: '/validate', + handler: this.validateTagType, + permission: UPDATE_TAG_TYPE, + middleware: [ + openApiService.validPath({ + tags: ['admin'], + operationId: 'validateTagType', + responses: { + 200: createResponseSchema('validateTagTypeSchema'), + }, + requestBody: createRequestSchema('tagTypeSchema'), + }), + ], + }); + this.route({ + method: 'get', + path: '/:name', + handler: this.getTagType, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['admin'], + operationId: 'getTagType', + responses: { + 200: createResponseSchema('tagTypeSchema'), + }, + }), + ], + }); + this.route({ + method: 'put', + path: '/:name', + handler: this.updateTagType, + permission: UPDATE_TAG_TYPE, + middleware: [ + openApiService.validPath({ + tags: ['admin'], + operationId: 'updateTagType', + responses: { + 200: emptyResponse, + }, + requestBody: createRequestSchema('updateTagTypeSchema'), + }), + ], + }); + this.route({ + method: 'delete', + path: '/:name', + handler: this.deleteTagType, + acceptAnyContentType: true, + permission: DELETE_TAG_TYPE, + middleware: [ + openApiService.validPath({ + tags: ['admin'], + operationId: 'deleteTagType', + responses: { + 200: emptyResponse, + }, + }), + ], + }); } - async getTagTypes(req: Request, res: Response): Promise { + async getTagTypes( + req: Request, + res: Response, + ): Promise { const tagTypes = await this.tagTypeService.getAll(); res.json({ version, tagTypes }); } - async validate(req: Request, res: Response): Promise { + async validateTagType( + req: Request, + res: Response, + ): Promise { await this.tagTypeService.validate(req.body); - res.status(200).json({ valid: true, tagType: req.body }); + this.openApiService.respondWithValidation(200, res, tagTypeSchema.$id, { + valid: true, + tagType: req.body, + }); } - async createTagType(req: IAuthRequest, res: Response): Promise { + async createTagType( + req: IAuthRequest, + res: Response, + ): Promise { const userName = extractUsername(req); const tagType = await this.tagTypeService.createTagType( req.body, @@ -50,7 +166,10 @@ class TagTypeController extends Controller { res.status(201).json(tagType); } - async updateTagType(req: IAuthRequest, res: Response): Promise { + async updateTagType( + req: IAuthRequest<{ name: string }, unknown, UpdateTagTypeSchema>, + res: Response, + ): Promise { const { description, icon } = req.body; const { name } = req.params; const userName = extractUsername(req); diff --git a/src/test/e2e/api/admin/tag-types.e2e.test.ts b/src/test/e2e/api/admin/tag-types.e2e.test.ts index 5079a39954..18d1a45392 100644 --- a/src/test/e2e/api/admin/tag-types.e2e.test.ts +++ b/src/test/e2e/api/admin/tag-types.e2e.test.ts @@ -46,12 +46,15 @@ test('querying a tag-type that does not exist yields 404', async () => { }); test('Can create a new tag type', async () => { - await app.request.post('/api/admin/tag-types').send({ - name: 'slack', - description: - 'Tag your feature toggles with slack channel to post updates for toggle to', - icon: 'http://icons.iconarchive.com/icons/papirus-team/papirus-apps/32/slack-icon.png', - }); + await app.request + .post('/api/admin/tag-types') + .send({ + name: 'slack', + description: + 'Tag your feature toggles with slack channel to post updates for toggle to', + icon: 'http://icons.iconarchive.com/icons/papirus-team/papirus-apps/32/slack-icon.png', + }) + .expect(201); return app.request .get('/api/admin/tag-types/slack') .expect('Content-Type', /json/) @@ -97,7 +100,7 @@ test('Can update a tag types description and icon', async () => { expect(res.body.tagType.icon).toBe('$'); }); }); -test('Invalid updates gets rejected', async () => { +test('Numbers are coerced to strings for icons and descriptions', async () => { await app.request.get('/api/admin/tag-types/simple').expect(200); await app.request .put('/api/admin/tag-types/simple') @@ -105,13 +108,7 @@ test('Invalid updates gets rejected', async () => { description: 15125, icon: 125, }) - .expect(400) - .expect((res) => { - expect(res.body.details[0].message).toBe( - '"description" must be a string', - ); - expect(res.body.details[1].message).toBe('"icon" must be a string'); - }); + .expect(200); }); test('Validation of tag-types returns 200 for valid tag-types', async () => { @@ -128,6 +125,21 @@ test('Validation of tag-types returns 200 for valid tag-types', async () => { expect(res.body.valid).toBe(true); }); }); + +test('Validation of tag types allows numbers for description and icons because of coercion', async () => { + await app.request + .post('/api/admin/tag-types/validate') + .send({ + name: 'something', + description: 1234, + icon: 56789, + }) + .set('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body.valid).toBe(true); + }); +}); test('Invalid tag-types get refused by validator', async () => { await app.request .post('/api/admin/tag-types/validate') 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 acdad2c1f6..5c1d2bfccc 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 @@ -712,6 +712,43 @@ Object { ], "type": "object", }, + "tagTypeSchema": Object { + "additionalProperties": false, + "properties": Object { + "description": Object { + "type": "string", + }, + "icon": Object { + "type": "string", + }, + "name": Object { + "type": "string", + }, + }, + "required": Array [ + "name", + ], + "type": "object", + }, + "tagTypesSchema": Object { + "additionalProperties": false, + "properties": Object { + "tagTypes": Object { + "items": Object { + "$ref": "#/components/schemas/tagTypeSchema", + }, + "type": "array", + }, + "version": Object { + "type": "integer", + }, + }, + "required": Array [ + "version", + "tagTypes", + ], + "type": "object", + }, "tagsSchema": Object { "additionalProperties": false, "properties": Object { @@ -857,6 +894,34 @@ Object { "required": Array [], "type": "object", }, + "updateTagTypeSchema": Object { + "additionalProperties": false, + "properties": Object { + "description": Object { + "type": "string", + }, + "icon": Object { + "type": "string", + }, + }, + "type": "object", + }, + "validateTagTypeSchema": Object { + "additionalProperties": false, + "properties": Object { + "tagType": Object { + "$ref": "#/components/schemas/tagTypeSchema", + }, + "valid": Object { + "type": "boolean", + }, + }, + "required": Array [ + "valid", + "tagType", + ], + "type": "object", + }, "variantSchema": Object { "additionalProperties": false, "properties": Object { @@ -2414,6 +2479,169 @@ Object { ], }, }, + "/api/admin/tag-types": Object { + "get": Object { + "operationId": "getTagTypes", + "responses": Object { + "200": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/tagTypesSchema", + }, + }, + }, + "description": "tagTypesSchema", + }, + }, + "tags": Array [ + "admin", + ], + }, + "post": Object { + "operationId": "createTagType", + "requestBody": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/tagTypeSchema", + }, + }, + }, + "description": "tagTypeSchema", + "required": true, + }, + "responses": Object { + "201": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/tagTypeSchema", + }, + }, + }, + "description": "tagTypeSchema", + }, + }, + "tags": Array [ + "admin", + ], + }, + }, + "/api/admin/tag-types/validate": Object { + "post": Object { + "operationId": "validateTagType", + "requestBody": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/tagTypeSchema", + }, + }, + }, + "description": "tagTypeSchema", + "required": true, + }, + "responses": Object { + "200": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/validateTagTypeSchema", + }, + }, + }, + "description": "validateTagTypeSchema", + }, + }, + "tags": Array [ + "admin", + ], + }, + }, + "/api/admin/tag-types/{name}": Object { + "delete": Object { + "operationId": "deleteTagType", + "parameters": Array [ + Object { + "in": "path", + "name": "name", + "required": true, + "schema": Object { + "type": "string", + }, + }, + ], + "responses": Object { + "200": Object { + "description": "emptyResponse", + }, + }, + "tags": Array [ + "admin", + ], + }, + "get": Object { + "operationId": "getTagType", + "parameters": Array [ + Object { + "in": "path", + "name": "name", + "required": true, + "schema": Object { + "type": "string", + }, + }, + ], + "responses": Object { + "200": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/tagTypeSchema", + }, + }, + }, + "description": "tagTypeSchema", + }, + }, + "tags": Array [ + "admin", + ], + }, + "put": Object { + "operationId": "updateTagType", + "parameters": Array [ + Object { + "in": "path", + "name": "name", + "required": true, + "schema": Object { + "type": "string", + }, + }, + ], + "requestBody": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/updateTagTypeSchema", + }, + }, + }, + "description": "updateTagTypeSchema", + "required": true, + }, + "responses": Object { + "200": Object { + "description": "emptyResponse", + }, + }, + "tags": Array [ + "admin", + ], + }, + }, "/api/admin/ui-config": Object { "get": Object { "operationId": "getUIConfig",