mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-20 00:08:02 +01:00
feat: context field usage backend (#3921)
This commit is contained in:
parent
209017e421
commit
ec6e4d70b5
@ -645,6 +645,23 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
return rows.map(mapRow);
|
||||
}
|
||||
|
||||
async getStrategiesByContextField(
|
||||
contextFieldName: string,
|
||||
): Promise<IFeatureStrategy[]> {
|
||||
const stopTimer = this.timer('getStrategiesByContextField');
|
||||
const rows = await this.db
|
||||
.select(this.prefixColumns())
|
||||
.from<IFeatureStrategiesTable>(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}`);
|
||||
}
|
||||
|
@ -72,6 +72,7 @@ export const createFakeExportImportTogglesService = (
|
||||
projectStore,
|
||||
eventStore,
|
||||
contextFieldStore,
|
||||
featureStrategiesStore,
|
||||
},
|
||||
{ getLogger },
|
||||
);
|
||||
@ -166,6 +167,7 @@ export const createExportImportTogglesService = (
|
||||
projectStore,
|
||||
eventStore,
|
||||
contextFieldStore,
|
||||
featureStrategiesStore,
|
||||
},
|
||||
{ getLogger },
|
||||
);
|
||||
|
@ -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.
|
||||
|
@ -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: {
|
||||
|
57
src/lib/openapi/spec/context-field-strategies-schema.ts
Normal file
57
src/lib/openapi/spec/context-field-strategies-schema.ts
Normal file
@ -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
|
||||
>;
|
@ -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<void> {
|
||||
const { contextField } = req.params;
|
||||
const contextFields =
|
||||
await this.contextService.getStrategiesByContextField(contextField);
|
||||
|
||||
this.openApiService.respondWithValidation(
|
||||
200,
|
||||
res,
|
||||
contextFieldStrategiesSchema.$id,
|
||||
serializeDates(contextFields),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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<IUnleashConfig, 'getLogger'>,
|
||||
) {
|
||||
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<ContextFieldStrategiesSchema> {
|
||||
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,
|
||||
|
@ -58,6 +58,9 @@ export interface IFeatureStrategiesStore
|
||||
newProjectId: string,
|
||||
): Promise<void>;
|
||||
getStrategiesBySegment(segmentId: number): Promise<IFeatureStrategy[]>;
|
||||
getStrategiesByContextField(
|
||||
contextFieldName: string,
|
||||
): Promise<IFeatureStrategy[]>;
|
||||
updateSortOrder(id: string, sortOrder: number): Promise<void>;
|
||||
getAllByFeatures(
|
||||
features: string[],
|
||||
|
@ -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' }],
|
||||
});
|
||||
});
|
||||
|
@ -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.",
|
||||
|
@ -35,6 +35,17 @@ export default class FakeFeatureStrategiesStore
|
||||
return Promise.resolve(newStrat);
|
||||
}
|
||||
|
||||
async getStrategiesByContextField(
|
||||
contextFieldName: string,
|
||||
): Promise<IFeatureStrategy[]> {
|
||||
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<void> {
|
||||
this.featureToggles.push({
|
||||
|
Loading…
Reference in New Issue
Block a user