diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 96c0a0c3a6..cc858afca7 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -131,6 +131,7 @@ import { importTogglesSchema, importTogglesValidateSchema, importTogglesValidateItemSchema, + stickinessSchema, } from './spec'; import { IServerOption } from '../types'; import { mapValues, omitKeys } from '../util'; @@ -251,6 +252,7 @@ export const schemas = { stateSchema, strategiesSchema, strategySchema, + stickinessSchema, tagsBulkAddSchema, tagSchema, tagsSchema, diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 167fe147c1..ee6456373f 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -129,4 +129,5 @@ export * from './project-overview-schema'; export * from './import-toggles-validate-item-schema'; export * from './import-toggles-validate-schema'; export * from './import-toggles-schema'; +export * from './stickiness-schema'; export * from './tags-bulk-add-schema'; diff --git a/src/lib/openapi/spec/stickiness-schema.ts b/src/lib/openapi/spec/stickiness-schema.ts new file mode 100644 index 0000000000..ecd6d4697a --- /dev/null +++ b/src/lib/openapi/spec/stickiness-schema.ts @@ -0,0 +1,17 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const stickinessSchema = { + $id: '#/components/schemas/stickinessSchema', + type: 'object', + additionalProperties: false, + required: ['stickiness'], + properties: { + stickiness: { + type: 'string', + example: 'userId', + }, + }, + components: {}, +} as const; + +export type StickinessSchema = FromSchema; diff --git a/src/lib/routes/admin-api/project/index.ts b/src/lib/routes/admin-api/project/index.ts index 94feee05b4..3531c4b8ec 100644 --- a/src/lib/routes/admin-api/project/index.ts +++ b/src/lib/routes/admin-api/project/index.ts @@ -7,7 +7,7 @@ import EnvironmentsController from './environments'; import ProjectHealthReport from './health-report'; import ProjectService from '../../../services/project-service'; import VariantsController from './variants'; -import { NONE } from '../../../types/permissions'; +import { NONE, UPDATE_PROJECT } from '../../../types/permissions'; import { projectsSchema, ProjectsSchema, @@ -17,21 +17,31 @@ import { serializeDates } from '../../../types/serialize-dates'; import { createResponseSchema } from '../../../openapi/util/create-response-schema'; import { IAuthRequest } from '../../unleash-types'; import { + emptyResponse, ProjectOverviewSchema, projectOverviewSchema, + stickinessSchema, + StickinessSchema, } from '../../../../lib/openapi'; import { IArchivedQuery, IProjectParam } from '../../../types/model'; import { ProjectApiTokenController } from './api-token'; +import { SettingService } from '../../../services'; + +const STICKINESS_KEY = 'stickiness'; +const DEFAULT_STICKINESS = 'default'; export default class ProjectApi extends Controller { private projectService: ProjectService; + private settingService: SettingService; + private openApiService: OpenApiService; constructor(config: IUnleashConfig, services: IUnleashServices) { super(config); this.projectService = services.projectService; this.openApiService = services.openApiService; + this.settingService = services.settingService; this.route({ path: '', @@ -65,6 +75,40 @@ export default class ProjectApi extends Controller { ], }); + this.route({ + method: 'get', + path: '/:projectId/stickiness', + handler: this.getProjectDefaultStickiness, + permission: NONE, + middleware: [ + services.openApiService.validPath({ + tags: ['Projects'], + operationId: 'getProjectDefaultStickiness', + responses: { + 200: createResponseSchema('stickinessSchema'), + 404: emptyResponse, + }, + }), + ], + }); + + this.route({ + method: 'post', + path: '/:projectId/stickiness', + handler: this.setProjectDefaultStickiness, + permission: UPDATE_PROJECT, + middleware: [ + services.openApiService.validPath({ + tags: ['Projects'], + operationId: 'setProjectDefaultStickiness', + responses: { + 200: createResponseSchema('stickinessSchema'), + 404: emptyResponse, + }, + }), + ], + }); + this.use('/', new ProjectFeaturesController(config, services).router); this.use('/', new EnvironmentsController(config, services).router); this.use('/', new ProjectHealthReport(config, services).router); @@ -111,4 +155,63 @@ export default class ProjectApi extends Controller { serializeDates(overview), ); } + + async getProjectDefaultStickiness( + req: IAuthRequest, + res: Response, + ): Promise { + if (!this.config.flagResolver.isEnabled('projectScopedStickiness')) { + res.status(404); + return Promise.resolve(); + } + const { projectId } = req.params; + const stickinessSettings = await this.settingService.get( + STICKINESS_KEY, + { + [projectId]: 'default', + }, + ); + this.openApiService.respondWithValidation( + 200, + res, + stickinessSchema.$id, + { stickiness: stickinessSettings[projectId] }, + ); + } + + async setProjectDefaultStickiness( + req: IAuthRequest< + IProjectParam, + StickinessSchema, + StickinessSchema, + unknown + >, + res: Response, + ): Promise { + if (!this.config.flagResolver.isEnabled('projectScopedStickiness')) { + res.status(404); + return Promise.resolve(); + } + const { projectId } = req.params; + const { stickiness } = req.body; + const stickinessSettings = await this.settingService.get<{}>( + STICKINESS_KEY, + { + [projectId]: DEFAULT_STICKINESS, + }, + ); + stickinessSettings[projectId] = stickiness; + await this.settingService.insert( + STICKINESS_KEY, + stickinessSettings, + req.user.name, + ); + + this.openApiService.respondWithValidation( + 200, + res, + stickinessSchema.$id, + { stickiness: stickiness }, + ); + } } diff --git a/src/test/e2e/api/admin/project/projects.e2e.test.ts b/src/test/e2e/api/admin/project/projects.e2e.test.ts index e6cd0e3fe0..c50fc540f4 100644 --- a/src/test/e2e/api/admin/project/projects.e2e.test.ts +++ b/src/test/e2e/api/admin/project/projects.e2e.test.ts @@ -36,3 +36,27 @@ test('Should ONLY return default project', async () => { expect(body.projects).toHaveLength(1); expect(body.projects[0].id).toBe('default'); }); + +test('Should store and retrieve default project stickiness', async () => { + const appWithDefaultStickiness = await setupAppWithCustomConfig(db.stores, { + experimental: { + flags: { + projectScopedStickiness: true, + strictSchemaValidation: true, + }, + }, + }); + const reqBody = { stickiness: 'userId' }; + + await appWithDefaultStickiness.request + .post('/api/admin/projects/default/stickiness') + .send(reqBody) + .expect(200); + + const { body } = await appWithDefaultStickiness.request + .get('/api/admin/projects/default/stickiness') + .expect(200) + .expect('Content-Type', /json/); + + expect(body).toStrictEqual(reqBody); +}); 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 8476db710c..6704015741 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 @@ -3504,6 +3504,19 @@ Stats are divided into current and previous **windows**. ], "type": "object", }, + "stickinessSchema": { + "additionalProperties": false, + "properties": { + "stickiness": { + "example": "userId", + "type": "string", + }, + }, + "required": [ + "stickiness", + ], + "type": "object", + }, "strategiesSchema": { "additionalProperties": false, "properties": { @@ -7333,6 +7346,70 @@ If the provided project does not exist, the list of events will be empty.", ], }, }, + "/api/admin/projects/{projectId}/stickiness": { + "get": { + "operationId": "getProjectDefaultStickiness", + "parameters": [ + { + "in": "path", + "name": "projectId", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/stickinessSchema", + }, + }, + }, + "description": "stickinessSchema", + }, + "404": { + "description": "This response has no body.", + }, + }, + "tags": [ + "Projects", + ], + }, + "post": { + "operationId": "setProjectDefaultStickiness", + "parameters": [ + { + "in": "path", + "name": "projectId", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/stickinessSchema", + }, + }, + }, + "description": "stickinessSchema", + }, + "404": { + "description": "This response has no body.", + }, + }, + "tags": [ + "Projects", + ], + }, + }, "/api/admin/splash/{id}": { "post": { "operationId": "updateSplashSettings",