From ec6e4d70b5392a7aed46df6c26304c589d928312 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Fri, 9 Jun 2023 12:00:17 +0300 Subject: [PATCH] feat: context field usage backend (#3921) --- src/lib/db/feature-strategy-store.ts | 17 ++++ .../createExportImportService.ts | 2 + src/lib/openapi/index.ts | 2 + src/lib/openapi/spec/context-field-schema.ts | 15 ++++ .../spec/context-field-strategies-schema.ts | 57 ++++++++++++ src/lib/routes/admin-api/context.ts | 35 ++++++++ src/lib/services/context-service.ts | 28 +++++- .../types/stores/feature-strategies-store.ts | 3 + src/test/e2e/api/admin/context.e2e.test.ts | 61 ++++++++++++- .../__snapshots__/openapi.e2e.test.ts.snap | 90 +++++++++++++++++++ .../fixtures/fake-feature-strategies-store.ts | 11 +++ 11 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 src/lib/openapi/spec/context-field-strategies-schema.ts diff --git a/src/lib/db/feature-strategy-store.ts b/src/lib/db/feature-strategy-store.ts index 767d2e4a16..e4ac30e595 100644 --- a/src/lib/db/feature-strategy-store.ts +++ b/src/lib/db/feature-strategy-store.ts @@ -645,6 +645,23 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { return rows.map(mapRow); } + async getStrategiesByContextField( + contextFieldName: string, + ): Promise { + const stopTimer = this.timer('getStrategiesByContextField'); + const rows = await this.db + .select(this.prefixColumns()) + .from(T.featureStrategies) + .where( + this.db.raw( + "EXISTS (SELECT 1 FROM jsonb_array_elements(constraints) AS elem WHERE elem ->> 'contextName' = ?)", + contextFieldName, + ), + ); + stopTimer(); + return rows.map(mapRow); + } + prefixColumns(): string[] { return COLUMNS.map((c) => `${T.featureStrategies}.${c}`); } diff --git a/src/lib/features/export-import-toggles/createExportImportService.ts b/src/lib/features/export-import-toggles/createExportImportService.ts index a69fcb24cd..3937ff76d9 100644 --- a/src/lib/features/export-import-toggles/createExportImportService.ts +++ b/src/lib/features/export-import-toggles/createExportImportService.ts @@ -72,6 +72,7 @@ export const createFakeExportImportTogglesService = ( projectStore, eventStore, contextFieldStore, + featureStrategiesStore, }, { getLogger }, ); @@ -166,6 +167,7 @@ export const createExportImportTogglesService = ( projectStore, eventStore, contextFieldStore, + featureStrategiesStore, }, { getLogger }, ); diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index b919c91c56..f2ce9e8fc0 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -152,6 +152,7 @@ import { clientMetricsEnvSchema } from './spec/client-metrics-env-schema'; import { updateTagsSchema } from './spec/update-tags-schema'; import { batchStaleSchema } from './spec/batch-stale-schema'; import { createApplicationSchema } from './spec/create-application-schema'; +import { contextFieldStrategiesSchema } from './spec/context-field-strategies-schema'; // Schemas must have an $id property on the form "#/components/schemas/mySchema". export type SchemaId = typeof schemas[keyof typeof schemas]['$id']; @@ -329,6 +330,7 @@ export const schemas: UnleashSchemas = { importTogglesSchema, importTogglesValidateSchema, importTogglesValidateItemSchema, + contextFieldStrategiesSchema, }; // Remove JSONSchema keys that would result in an invalid OpenAPI spec. diff --git a/src/lib/openapi/spec/context-field-schema.ts b/src/lib/openapi/spec/context-field-schema.ts index 7df88fe87c..79cac839a3 100644 --- a/src/lib/openapi/spec/context-field-schema.ts +++ b/src/lib/openapi/spec/context-field-schema.ts @@ -12,6 +12,7 @@ export const contextFieldSchema = { }, description: { type: 'string', + nullable: true, }, stickiness: { type: 'boolean', @@ -24,6 +25,20 @@ export const contextFieldSchema = { format: 'date-time', nullable: true, }, + usedInFeatures: { + type: 'number', + description: + 'Number of projects where this context field is used in', + example: 3, + nullable: true, + }, + usedInProjects: { + type: 'number', + description: + 'Number of projects where this context field is used in', + example: 2, + nullable: true, + }, legalValues: { type: 'array', items: { diff --git a/src/lib/openapi/spec/context-field-strategies-schema.ts b/src/lib/openapi/spec/context-field-strategies-schema.ts new file mode 100644 index 0000000000..24f59513b1 --- /dev/null +++ b/src/lib/openapi/spec/context-field-strategies-schema.ts @@ -0,0 +1,57 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const contextFieldStrategiesSchema = { + $id: '#/components/schemas/segmentStrategiesSchema', + type: 'object', + description: + 'A wrapper object containing all for strategies using a specific context field', + required: ['strategies'], + properties: { + strategies: { + type: 'array', + description: 'List of strategies using the context field', + items: { + type: 'object', + required: [ + 'id', + 'featureName', + 'projectId', + 'environment', + 'strategyName', + ], + properties: { + id: { + type: 'string', + example: '433ae8d9-dd69-4ad0-bc46-414aedbe9c55', + description: 'The ID of the strategy.', + }, + featureName: { + type: 'string', + example: 'best-feature', + description: + 'The name of the feature that contains this strategy.', + }, + projectId: { + type: 'string', + description: + 'The ID of the project that contains this feature.', + }, + environment: { + type: 'string', + description: + 'The ID of the environment where this strategy is in.', + }, + strategyName: { + type: 'string', + description: 'The name of the strategy.', + }, + }, + }, + }, + }, + components: {}, +} as const; + +export type ContextFieldStrategiesSchema = FromSchema< + typeof contextFieldStrategiesSchema +>; diff --git a/src/lib/routes/admin-api/context.ts b/src/lib/routes/admin-api/context.ts index 04dab48951..a68ea6126c 100644 --- a/src/lib/routes/admin-api/context.ts +++ b/src/lib/routes/admin-api/context.ts @@ -32,6 +32,7 @@ import { serializeDates } from '../../types/serialize-dates'; import NotFoundError from '../../error/notfound-error'; import { NameSchema } from '../../openapi/spec/name-schema'; import { emptyResponse } from '../../openapi/util/standard-responses'; +import { contextFieldStrategiesSchema } from '../../openapi/spec/context-field-strategies-schema'; interface ContextParam { contextField: string; @@ -88,6 +89,24 @@ export class ContextController extends Controller { ], }); + this.route({ + method: 'get', + path: '/:contextField/strategies', + handler: this.getStrategiesByContextField, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['Strategies'], + operationId: 'getStrategiesByContextField', + responses: { + 200: createResponseSchema( + 'contextFieldStrategiesSchema', + ), + }, + }), + ], + }); + this.route({ method: 'post', path: '', @@ -243,4 +262,20 @@ export class ContextController extends Controller { await this.contextService.validateName(name); res.status(200).end(); } + + async getStrategiesByContextField( + req: IAuthRequest<{ contextField: string }>, + res: Response, + ): Promise { + const { contextField } = req.params; + const contextFields = + await this.contextService.getStrategiesByContextField(contextField); + + this.openApiService.respondWithValidation( + 200, + res, + contextFieldStrategiesSchema.$id, + serializeDates(contextFields), + ); + } } diff --git a/src/lib/services/context-service.ts b/src/lib/services/context-service.ts index 584613c54c..e382478791 100644 --- a/src/lib/services/context-service.ts +++ b/src/lib/services/context-service.ts @@ -6,8 +6,9 @@ import { } from '../types/stores/context-field-store'; import { IEventStore } from '../types/stores/event-store'; import { IProjectStore } from '../types/stores/project-store'; -import { IUnleashStores } from '../types/stores'; +import { IFeatureStrategiesStore, IUnleashStores } from '../types/stores'; import { IUnleashConfig } from '../types/option'; +import { ContextFieldStrategiesSchema } from '../openapi/spec/context-field-strategies-schema'; const { contextSchema, nameSchema } = require('./context-schema'); const NameExistsError = require('../error/name-exists-error'); @@ -25,6 +26,8 @@ class ContextService { private contextFieldStore: IContextFieldStore; + private featureStrategiesStore: IFeatureStrategiesStore; + private logger: Logger; constructor( @@ -32,15 +35,20 @@ class ContextService { projectStore, eventStore, contextFieldStore, + featureStrategiesStore, }: Pick< IUnleashStores, - 'projectStore' | 'eventStore' | 'contextFieldStore' + | 'projectStore' + | 'eventStore' + | 'contextFieldStore' + | 'featureStrategiesStore' >, { getLogger }: Pick, ) { this.projectStore = projectStore; this.eventStore = eventStore; this.contextFieldStore = contextFieldStore; + this.featureStrategiesStore = featureStrategiesStore; this.logger = getLogger('services/context-service.js'); } @@ -52,6 +60,22 @@ class ContextService { return this.contextFieldStore.get(name); } + async getStrategiesByContextField( + name: string, + ): Promise { + const strategies = + await this.featureStrategiesStore.getStrategiesByContextField(name); + return { + strategies: strategies.map((strategy) => ({ + id: strategy.id, + projectId: strategy.projectId, + featureName: strategy.featureName, + strategyName: strategy.strategyName, + environment: strategy.environment, + })), + }; + } + async createContextField( value: IContextFieldDto, userName: string, diff --git a/src/lib/types/stores/feature-strategies-store.ts b/src/lib/types/stores/feature-strategies-store.ts index 975fdb8df2..7cb7f13eb4 100644 --- a/src/lib/types/stores/feature-strategies-store.ts +++ b/src/lib/types/stores/feature-strategies-store.ts @@ -58,6 +58,9 @@ export interface IFeatureStrategiesStore newProjectId: string, ): Promise; getStrategiesBySegment(segmentId: number): Promise; + getStrategiesByContextField( + contextFieldName: string, + ): Promise; updateSortOrder(id: string, sortOrder: number): Promise; getAllByFeatures( features: string[], diff --git a/src/test/e2e/api/admin/context.e2e.test.ts b/src/test/e2e/api/admin/context.e2e.test.ts index 04417287b2..1122520998 100644 --- a/src/test/e2e/api/admin/context.e2e.test.ts +++ b/src/test/e2e/api/admin/context.e2e.test.ts @@ -1,5 +1,8 @@ import dbInit from '../../helpers/database-init'; -import { IUnleashTest, setupApp } from '../../helpers/test-helper'; +import { + IUnleashTest, + setupAppWithCustomConfig, +} from '../../helpers/test-helper'; import getLogger from '../../../fixtures/no-logger'; let db; @@ -7,7 +10,13 @@ let app: IUnleashTest; beforeAll(async () => { db = await dbInit('context_api_serial', getLogger); - app = await setupApp(db.stores); + app = await setupAppWithCustomConfig(db.stores, { + experimental: { + flags: { + strictSchemaValidation: true, + }, + }, + }); }); afterAll(async () => { @@ -225,3 +234,51 @@ test('should update context field with stickiness', async () => { expect(contextField.description).toBe('asd'); expect(contextField.stickiness).toBe(true); }); + +test('should show context field usage', async () => { + const context = 'appName'; + const feature = 'contextFeature'; + await app.request + .post('/api/admin/projects/default/features') + .send({ + name: feature, + enabled: false, + strategies: [{ name: 'default' }], + }) + .set('Content-Type', 'application/json') + .expect(201); + await app.request + .post( + `/api/admin/projects/default/features/${feature}/environments/default/strategies`, + ) + .send({ + name: 'default', + parameters: { + userId: '14', + }, + constraints: [ + { + contextName: context, + operator: 'IN', + values: ['test'], + caseInsensitive: false, + inverted: false, + }, + ], + }) + .expect(200); + + await app.request.post('/api/admin/context').send({ + name: context, + description: context, + }); + + const { body } = await app.request.get( + `/api/admin/context/${context}/strategies`, + ); + + expect(body.strategies).toHaveLength(1); + expect(body).toMatchObject({ + strategies: [{ environment: 'default', featureName: 'contextFeature' }], + }); +}); 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 d76938edfa..159f949b83 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 @@ -1391,6 +1391,7 @@ The provider you choose for your addon dictates what properties the \`parameters "type": "string", }, "description": { + "nullable": true, "type": "string", }, "legalValues": { @@ -1408,12 +1409,71 @@ The provider you choose for your addon dictates what properties the \`parameters "stickiness": { "type": "boolean", }, + "usedInFeatures": { + "description": "Number of projects where this context field is used in", + "example": 3, + "nullable": true, + "type": "number", + }, + "usedInProjects": { + "description": "Number of projects where this context field is used in", + "example": 2, + "nullable": true, + "type": "number", + }, }, "required": [ "name", ], "type": "object", }, + "contextFieldStrategiesSchema": { + "description": "A wrapper object containing all for strategies using a specific context field", + "properties": { + "strategies": { + "description": "List of strategies using the context field", + "items": { + "properties": { + "environment": { + "description": "The ID of the environment where this strategy is in.", + "type": "string", + }, + "featureName": { + "description": "The name of the feature that contains this strategy.", + "example": "best-feature", + "type": "string", + }, + "id": { + "description": "The ID of the strategy.", + "example": "433ae8d9-dd69-4ad0-bc46-414aedbe9c55", + "type": "string", + }, + "projectId": { + "description": "The ID of the project that contains this feature.", + "type": "string", + }, + "strategyName": { + "description": "The name of the strategy.", + "type": "string", + }, + }, + "required": [ + "id", + "featureName", + "projectId", + "environment", + "strategyName", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": [ + "strategies", + ], + "type": "object", + }, "contextFieldsSchema": { "items": { "$ref": "#/components/schemas/contextFieldSchema", @@ -6712,6 +6772,36 @@ Note: passing \`null\` as a value for the description property will set it to an ], }, }, + "/api/admin/context/{contextField}/strategies": { + "get": { + "operationId": "getStrategiesByContextField", + "parameters": [ + { + "in": "path", + "name": "contextField", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/contextFieldStrategiesSchema", + }, + }, + }, + "description": "contextFieldStrategiesSchema", + }, + }, + "tags": [ + "Strategies", + ], + }, + }, "/api/admin/environments": { "get": { "description": "Retrieves all environments that exist in this Unleash instance.", diff --git a/src/test/fixtures/fake-feature-strategies-store.ts b/src/test/fixtures/fake-feature-strategies-store.ts index b09cf6ead6..79ad134f0a 100644 --- a/src/test/fixtures/fake-feature-strategies-store.ts +++ b/src/test/fixtures/fake-feature-strategies-store.ts @@ -35,6 +35,17 @@ export default class FakeFeatureStrategiesStore return Promise.resolve(newStrat); } + async getStrategiesByContextField( + contextFieldName: string, + ): Promise { + const strategies = this.featureStrategies.filter((strategy) => + strategy.constraints.some( + (constraint) => constraint.contextName === contextFieldName, + ), + ); + return Promise.resolve(strategies); + } + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types async createFeature(feature: any): Promise { this.featureToggles.push({