diff --git a/src/lib/openapi/spec/tag-schema.ts b/src/lib/openapi/spec/tag-schema.ts index 9e267bc067..c738cd357d 100644 --- a/src/lib/openapi/spec/tag-schema.ts +++ b/src/lib/openapi/spec/tag-schema.ts @@ -1,5 +1,7 @@ import { FromSchema } from 'json-schema-to-ts'; +export const TAG_MIN_LENGTH = 2; +export const TAG_MAX_LENGTH = 50; export const tagSchema = { $id: '#/components/schemas/tagSchema', type: 'object', @@ -8,11 +10,20 @@ export const tagSchema = { properties: { value: { type: 'string', + minLength: TAG_MIN_LENGTH, + maxLength: TAG_MAX_LENGTH, }, type: { type: 'string', + minLength: TAG_MIN_LENGTH, + maxLength: TAG_MAX_LENGTH, + default: 'simple', }, }, + example: { + value: 'tag-value', + type: 'simple', + }, components: {}, } as const; diff --git a/src/lib/openapi/spec/update-tags-schema.ts b/src/lib/openapi/spec/update-tags-schema.ts index 43bfed7fb9..4d46365e6b 100644 --- a/src/lib/openapi/spec/update-tags-schema.ts +++ b/src/lib/openapi/spec/update-tags-schema.ts @@ -20,6 +20,20 @@ export const updateTagsSchema = { }, }, }, + example: { + addedTags: [ + { + value: 'tag-to-add', + type: 'simple', + }, + ], + removedTags: [ + { + value: 'tag-to-remove', + type: 'simple', + }, + ], + }, components: { schemas: { tagSchema, diff --git a/src/lib/routes/admin-api/feature.ts b/src/lib/routes/admin-api/feature.ts index 9305f53869..a66596331a 100644 --- a/src/lib/routes/admin-api/feature.ts +++ b/src/lib/routes/admin-api/feature.ts @@ -29,7 +29,10 @@ import { createResponseSchema, resourceCreatedResponseSchema, } from '../../openapi/util/create-response-schema'; -import { emptyResponse } from '../../openapi/util/standard-responses'; +import { + emptyResponse, + getStandardResponses, +} from '../../openapi/util/standard-responses'; import { UpdateTagsSchema } from '../../openapi/spec/update-tags-schema'; const version = 1; @@ -110,9 +113,15 @@ class FeatureController extends Controller { permission: NONE, middleware: [ openApiService.validPath({ + summary: 'Get all tags for a feature.', + description: + 'Retrieves all the tags for a feature name. If the feature does not exist it returns an empty list.', tags: ['Features'], operationId: 'listTags', - responses: { 200: createResponseSchema('tagsSchema') }, + responses: { + 200: createResponseSchema('tagsSchema'), + ...getStandardResponses(401), + }, }), ], }); @@ -124,11 +133,15 @@ class FeatureController extends Controller { handler: this.addTag, middleware: [ openApiService.validPath({ + summary: 'Adds a tag to a feature.', + description: + 'Adds a tag to a feature if the feature and tag type exist in the system. The operation is idempotent, so adding an existing tag will result in a successful response.', tags: ['Features'], operationId: 'addTag', requestBody: createRequestSchema('tagSchema'), responses: { 201: resourceCreatedResponseSchema('tagSchema'), + ...getStandardResponses(400, 401, 403, 404), }, }), ], @@ -141,11 +154,15 @@ class FeatureController extends Controller { handler: this.updateTags, middleware: [ openApiService.validPath({ + summary: 'Updates multiple tags for a feature.', + description: + 'Receives a list of tags to add and a list of tags to remove that are mandatory but can be empty. All tags under addedTags are first added to the feature and then all tags under removedTags are removed from the feature.', tags: ['Features'], operationId: 'updateTags', requestBody: createRequestSchema('updateTagsSchema'), responses: { 200: resourceCreatedResponseSchema('tagsSchema'), + ...getStandardResponses(400, 401, 403, 404), }, }), ], @@ -159,9 +176,15 @@ class FeatureController extends Controller { handler: this.removeTag, middleware: [ openApiService.validPath({ + summary: 'Removes a tag from a feature.', + description: + 'Removes a tag from a feature. If the feature exists but the tag does not, it returns a successful response.', tags: ['Features'], operationId: 'removeTag', - responses: { 200: emptyResponse }, + responses: { + 200: emptyResponse, + ...getStandardResponses(404), + }, }), ], }); diff --git a/src/lib/services/feature-tag-service.ts b/src/lib/services/feature-tag-service.ts index 413f73f886..cf8124b34d 100644 --- a/src/lib/services/feature-tag-service.ts +++ b/src/lib/services/feature-tag-service.ts @@ -11,6 +11,7 @@ import { import { IEventStore } from '../types/stores/event-store'; import { ITagStore } from '../types/stores/tag-store'; import { ITag } from '../types/model'; +import { BadDataError, FOREIGN_KEY_VIOLATION } from '../../lib/error'; class FeatureTagService { private tagStore: ITagStore; @@ -129,12 +130,20 @@ class FeatureTagService { await this.tagStore.getTag(tag.type, tag.value); } catch (error) { if (error instanceof NotFoundError) { - await this.tagStore.createTag(tag); - await this.eventStore.store({ - type: TAG_CREATED, - createdBy: userName, - data: tag, - }); + try { + await this.tagStore.createTag(tag); + await this.eventStore.store({ + type: TAG_CREATED, + createdBy: userName, + data: tag, + }); + } catch (err) { + if (err.code === FOREIGN_KEY_VIOLATION) { + throw new BadDataError( + `Tag type '${tag.type}' does not exist`, + ); + } + } } } } diff --git a/src/lib/services/tag-schema.ts b/src/lib/services/tag-schema.ts index 78cff4ba45..e39a0ab208 100644 --- a/src/lib/services/tag-schema.ts +++ b/src/lib/services/tag-schema.ts @@ -1,11 +1,16 @@ import Joi from 'joi'; import { customJoi } from '../routes/util'; +import { TAG_MAX_LENGTH, TAG_MIN_LENGTH } from '../../lib/openapi'; export const tagSchema = Joi.object() .keys({ - value: Joi.string().min(2).max(50), - type: customJoi.isUrlFriendly().min(2).max(50).default('simple'), + value: Joi.string().min(TAG_MIN_LENGTH).max(TAG_MAX_LENGTH), + type: customJoi + .isUrlFriendly() + .min(TAG_MIN_LENGTH) + .max(TAG_MAX_LENGTH) + .default('simple'), }) .options({ allowUnknown: false, 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 e34d979270..2b95168d6c 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 @@ -3706,11 +3706,20 @@ Stats are divided into current and previous **windows**. }, "tagSchema": { "additionalProperties": false, + "example": { + "type": "simple", + "value": "tag-value", + }, "properties": { "type": { + "default": "simple", + "maxLength": 50, + "minLength": 2, "type": "string", }, "value": { + "maxLength": 50, + "minLength": 2, "type": "string", }, }, @@ -4019,6 +4028,20 @@ Stats are divided into current and previous **windows**. }, "updateTagsSchema": { "additionalProperties": false, + "example": { + "addedTags": [ + { + "type": "simple", + "value": "tag-to-add", + }, + ], + "removedTags": [ + { + "type": "simple", + "value": "tag-to-remove", + }, + ], + }, "properties": { "addedTags": { "items": { @@ -5474,6 +5497,7 @@ If the provided project does not exist, the list of events will be empty.", }, "/api/admin/features/{featureName}/tags": { "get": { + "description": "Retrieves all the tags for a feature name. If the feature does not exist it returns an empty list.", "operationId": "listTags", "parameters": [ { @@ -5496,12 +5520,17 @@ If the provided project does not exist, the list of events will be empty.", }, "description": "tagsSchema", }, + "401": { + "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 tags for a feature.", "tags": [ "Features", ], }, "post": { + "description": "Adds a tag to a feature if the feature and tag type exist in the system. The operation is idempotent, so adding an existing tag will result in a successful response.", "operationId": "addTag", "parameters": [ { @@ -5544,12 +5573,26 @@ If the provided project does not exist, the list of events will be empty.", }, }, }, + "400": { + "description": "The request data does not match what we expect.", + }, + "401": { + "description": "Authorization information is missing or invalid. Provide a valid API token as the \`authorization\` header, e.g. \`authorization:*.*.my-admin-token\`.", + }, + "403": { + "description": "User credentials are valid but does not have enough privileges to execute this operation", + }, + "404": { + "description": "The requested resource was not found.", + }, }, + "summary": "Adds a tag to a feature.", "tags": [ "Features", ], }, "put": { + "description": "Receives a list of tags to add and a list of tags to remove that are mandatory but can be empty. All tags under addedTags are first added to the feature and then all tags under removedTags are removed from the feature.", "operationId": "updateTags", "parameters": [ { @@ -5592,7 +5635,20 @@ If the provided project does not exist, the list of events will be empty.", }, }, }, + "400": { + "description": "The request data does not match what we expect.", + }, + "401": { + "description": "Authorization information is missing or invalid. Provide a valid API token as the \`authorization\` header, e.g. \`authorization:*.*.my-admin-token\`.", + }, + "403": { + "description": "User credentials are valid but does not have enough privileges to execute this operation", + }, + "404": { + "description": "The requested resource was not found.", + }, }, + "summary": "Updates multiple tags for a feature.", "tags": [ "Features", ], @@ -5600,6 +5656,7 @@ If the provided project does not exist, the list of events will be empty.", }, "/api/admin/features/{featureName}/tags/{type}/{value}": { "delete": { + "description": "Removes a tag from a feature. If the feature exists but the tag does not, it returns a successful response.", "operationId": "removeTag", "parameters": [ { @@ -5631,7 +5688,11 @@ If the provided project does not exist, the list of events will be empty.", "200": { "description": "This response has no body.", }, + "404": { + "description": "The requested resource was not found.", + }, }, + "summary": "Removes a tag from a feature.", "tags": [ "Features", ],