1
0
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:
Jaanus Sellin 2023-06-09 12:00:17 +03:00 committed by GitHub
parent 209017e421
commit ec6e4d70b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 317 additions and 4 deletions

View File

@ -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}`);
}

View File

@ -72,6 +72,7 @@ export const createFakeExportImportTogglesService = (
projectStore,
eventStore,
contextFieldStore,
featureStrategiesStore,
},
{ getLogger },
);
@ -166,6 +167,7 @@ export const createExportImportTogglesService = (
projectStore,
eventStore,
contextFieldStore,
featureStrategiesStore,
},
{ getLogger },
);

View File

@ -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.

View File

@ -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: {

View 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
>;

View File

@ -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),
);
}
}

View File

@ -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,

View File

@ -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[],

View File

@ -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' }],
});
});

View File

@ -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.",

View File

@ -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({