Skip to content

Commit

Permalink
feat: List possible parent variants (#6733)
Browse files Browse the repository at this point in the history
  • Loading branch information
kwasniew committed Mar 28, 2024
1 parent 664ceae commit 42355b0
Show file tree
Hide file tree
Showing 8 changed files with 205 additions and 16 deletions.
Expand Up @@ -15,7 +15,10 @@ import {
createResponseSchema,
emptyResponse,
getStandardResponses,
parentFeatureOptionsSchema,
type ParentFeatureOptionsSchema,
type ParentVariantOptionsSchema,
parentVariantOptionsSchema,
} from '../../openapi';
import type { IAuthRequest } from '../../routes/unleash-types';
import type { DependentFeaturesService } from './dependent-features-service';
Expand All @@ -29,6 +32,10 @@ interface FeatureParams extends ProjectParams {
child: string;
}

interface ParentVariantsParams extends ProjectParams {
parent: string;
}

interface DeleteDependencyParams extends ProjectParams {
child: string;
parent: string;
Expand All @@ -39,6 +46,7 @@ const PATH_FEATURE = `${PATH}/:child`;
const PATH_DEPENDENCIES = `${PATH_FEATURE}/dependencies`;
const PATH_DEPENDENCIES_CHECK = `/:projectId/dependencies`;
const PATH_PARENTS = `${PATH_FEATURE}/parents`;
const PATH_PARENT_VARIANTS = `${PATH}/:parent/parent-variants`;
const PATH_DEPENDENCY = `${PATH_FEATURE}/dependencies/:parent`;

type DependentFeaturesServices = Pick<
Expand Down Expand Up @@ -136,7 +144,7 @@ export default class DependentFeaturesController extends Controller {
this.route({
method: 'get',
path: PATH_PARENTS,
handler: this.getParentOptions,
handler: this.getPossibleParentFeatures,
permission: NONE,
middleware: [
openApiService.validPath({
Expand All @@ -153,6 +161,26 @@ export default class DependentFeaturesController extends Controller {
],
});

this.route({
method: 'get',
path: PATH_PARENT_VARIANTS,
handler: this.getPossibleParentVariants,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['Dependencies'],
summary: 'List parent feature variants.',
description:
'List available parent variants across all strategy variants and feature environment variants.',
operationId: 'listParentVariantOptions',
responses: {
200: createResponseSchema('parentVariantOptionsSchema'),
...getStandardResponses(401, 403, 404),
},
}),
],
});

this.route({
method: 'get',
path: PATH_DEPENDENCIES_CHECK,
Expand Down Expand Up @@ -227,15 +255,42 @@ export default class DependentFeaturesController extends Controller {
res.status(200).end();
}

async getParentOptions(
async getPossibleParentFeatures(
req: IAuthRequest<FeatureParams, any, any>,
res: Response<ParentFeatureOptionsSchema>,
): Promise<void> {
const { child } = req.params;

const parentOptions =
await this.dependentFeaturesService.getParentOptions(child);
res.send(parentOptions);
const options =
await this.dependentFeaturesService.getPossibleParentFeatures(
child,
);

this.openApiService.respondWithValidation(
200,
res,
parentFeatureOptionsSchema.$id,
options,
);
}

async getPossibleParentVariants(
req: IAuthRequest<ParentVariantsParams, any, any>,
res: Response<ParentVariantOptionsSchema>,
): Promise<void> {
const { parent } = req.params;

const options =
await this.dependentFeaturesService.getPossibleParentVariants(
parent,
);

this.openApiService.respondWithValidation(
200,
res,
parentVariantOptionsSchema.$id,
options,
);
}

async checkDependenciesExist(
Expand Down
Expand Up @@ -7,7 +7,8 @@ export interface IDependentFeaturesReadModel {
getOrphanParents(parentsAndChildren: string[]): Promise<string[]>;
getParents(child: string): Promise<IDependency[]>;
getDependencies(children: string[]): Promise<IFeatureDependency[]>;
getParentOptions(child: string): Promise<string[]>;
getPossibleParentFeatures(child: string): Promise<string[]>;
getPossibleParentVariants(parent: string): Promise<string[]>;
haveDependencies(features: string[]): Promise<boolean>;
hasAnyDependencies(): Promise<boolean>;
}
Expand Up @@ -2,6 +2,10 @@ import type { Db } from '../../db/db';
import type { IDependentFeaturesReadModel } from './dependent-features-read-model-type';
import type { IDependency, IFeatureDependency } from '../../types';

interface IVariantName {
variant_name: string;
}

export class DependentFeaturesReadModel implements IDependentFeaturesReadModel {
private db: Db;

Expand Down Expand Up @@ -59,7 +63,7 @@ export class DependentFeaturesReadModel implements IDependentFeaturesReadModel {
}));
}

async getParentOptions(child: string): Promise<string[]> {
async getPossibleParentFeatures(child: string): Promise<string[]> {
const result = await this.db('features')
.where('features.name', child)
.select('features.project');
Expand All @@ -82,6 +86,35 @@ export class DependentFeaturesReadModel implements IDependentFeaturesReadModel {
return rows.map((item) => item.name);
}

async getPossibleParentVariants(parent: string): Promise<string[]> {
const strategyVariantsQuery = this.db('feature_strategies')
.select(
this.db.raw(
"jsonb_array_elements(variants)->>'name' as variant_name",
),
)
.where('feature_name', parent);

const featureEnvironmentVariantsQuery = this.db('feature_environments')
.select(
this.db.raw(
"jsonb_array_elements(variants)->>'name' as variant_name",
),
)
.where('feature_name', parent);

const results = await Promise.all([
strategyVariantsQuery,
featureEnvironmentVariantsQuery,
]);
const flatResults = results
.flat()
.map((item) => (item as unknown as IVariantName).variant_name);
const uniqueResults = [...new Set(flatResults)];

return uniqueResults.sort();
}

async haveDependencies(features: string[]): Promise<boolean> {
const parents = await this.db('dependent_features')
.whereIn('parent', features)
Expand Down
12 changes: 10 additions & 2 deletions src/lib/features/dependent-features/dependent-features-service.ts
Expand Up @@ -227,8 +227,16 @@ export class DependentFeaturesService {
);
}

async getParentOptions(feature: string): Promise<string[]> {
return this.dependentFeaturesReadModel.getParentOptions(feature);
async getPossibleParentFeatures(feature: string): Promise<string[]> {
return this.dependentFeaturesReadModel.getPossibleParentFeatures(
feature,
);
}

async getPossibleParentVariants(parentFeature: string): Promise<string[]> {
return this.dependentFeaturesReadModel.getPossibleParentVariants(
parentFeature,
);
}

async checkDependenciesExist(): Promise<boolean> {
Expand Down
83 changes: 77 additions & 6 deletions src/lib/features/dependent-features/dependent.features.e2e.test.ts
Expand Up @@ -12,6 +12,7 @@ import {
FEATURE_DEPENDENCY_REMOVED,
type IEventStore,
} from '../../types';
import { DEFAULT_ENV } from '../../util';

let app: IUnleashTest;
let db: ITestDb;
Expand Down Expand Up @@ -55,6 +56,7 @@ afterAll(async () => {
beforeEach(async () => {
await db.stores.dependentFeaturesStore.deleteAll();
await db.stores.featureToggleStore.deleteAll();
await db.stores.featureEnvironmentStore.deleteAll();
});

const addFeatureDependency = async (
Expand Down Expand Up @@ -93,12 +95,69 @@ const deleteFeatureDependencies = async (
.expect(expectedCode);
};

const getParentOptions = async (childFeature: string, expectedCode = 200) => {
const getPossibleParentFeatures = async (
childFeature: string,
expectedCode = 200,
) => {
return app.request
.get(`/api/admin/projects/default/features/${childFeature}/parents`)
.expect(expectedCode);
};

const getPossibleParentVariants = async (
parentFeature: string,
expectedCode = 200,
) => {
return app.request
.get(
`/api/admin/projects/default/features/${parentFeature}/parent-variants`,
)
.expect(expectedCode);
};

const addStrategyVariants = async (parent: string, variants: string[]) => {
await app.addStrategyToFeatureEnv(
{
name: 'flexibleRollout',
constraints: [],
parameters: { rollout: '100', stickiness: 'default' },
variants: variants.map((name) => ({
name,
weight: 1000,
weightType: 'variable',
stickiness: 'default',
})),
},
DEFAULT_ENV,
parent,
);
};

const addFeatureEnvironmentVariant = async (
parent: string,
variant: string,
) => {
await app.request
.patch(
`/api/admin/projects/default/features/${parent}/environments/${DEFAULT_ENV}/variants`,
)
.set('Content-Type', 'application/json')
.send([
{
op: 'add',
path: '/0',
value: {
name: variant,
weightType: 'variable',
weight: 1000,
overrides: [],
stickiness: 'default',
},
},
])
.expect(200);
};

const checkDependenciesExist = async (expectedCode = 200) => {
return app.request
.get(`/api/admin/projects/default/dependencies`)
Expand All @@ -111,8 +170,8 @@ test('should add and delete feature dependencies', async () => {
await app.createFeature(parent);
await app.createFeature(child);

const { body: parentOptions } = await getParentOptions(child);
expect(parentOptions).toStrictEqual([parent]);
const { body: options } = await getPossibleParentFeatures(child);
expect(options).toStrictEqual([parent]);

// save explicit enabled and variants
await addFeatureDependency(child, {
Expand All @@ -136,7 +195,7 @@ test('should add and delete feature dependencies', async () => {
]);
});

test('should sort parent options alphabetically', async () => {
test('should sort potential parent features alphabetically', async () => {
const parent1 = `a${uuidv4()}`;
const parent2 = `c${uuidv4()}`;
const parent3 = `b${uuidv4()}`;
Expand All @@ -146,8 +205,20 @@ test('should sort parent options alphabetically', async () => {
await app.createFeature(parent3);
await app.createFeature(child);

const { body: parentOptions } = await getParentOptions(child);
expect(parentOptions).toStrictEqual([parent1, parent3, parent2]);
const { body: options } = await getPossibleParentFeatures(child);
expect(options).toStrictEqual([parent1, parent3, parent2]);
});

test('should sort potential parent variants', async () => {
const parent = uuidv4();
await app.createFeature(parent);
await addFeatureEnvironmentVariant(parent, 'e');
await addStrategyVariants(parent, ['c', 'a', 'd']);
await addStrategyVariants(parent, ['b', 'd']);

const { body: variants } = await getPossibleParentVariants(parent);

expect(variants).toStrictEqual(['a', 'b', 'c', 'd', 'e']);
});

test('should not allow to add grandparent', async () => {
Expand Down
Expand Up @@ -15,7 +15,11 @@ export class FakeDependentFeaturesReadModel
return Promise.resolve([]);
}

getParentOptions(): Promise<string[]> {
getPossibleParentFeatures(): Promise<string[]> {
return Promise.resolve([]);
}

getPossibleParentVariants(): Promise<string[]> {
return Promise.resolve([]);
}

Expand Down
1 change: 1 addition & 0 deletions src/lib/openapi/spec/index.ts
Expand Up @@ -117,6 +117,7 @@ export * from './outdated-sdks-schema';
export * from './override-schema';
export * from './parameters-schema';
export * from './parent-feature-options-schema';
export * from './parent-variant-options-schema';
export * from './password-schema';
export * from './pat-schema';
export * from './patch-schema';
Expand Down
16 changes: 16 additions & 0 deletions src/lib/openapi/spec/parent-variant-options-schema.ts
@@ -0,0 +1,16 @@
import type { FromSchema } from 'json-schema-to-ts';

export const parentVariantOptionsSchema = {
$id: '#/components/schemas/parentVariantOptionsSchema',
type: 'array',
description:
'A list of parent variant names available for a given parent feature. This list includes strategy variants and feature environment variants.',
items: {
type: 'string',
},
components: {},
} as const;

export type ParentVariantOptionsSchema = FromSchema<
typeof parentVariantOptionsSchema
>;

0 comments on commit 42355b0

Please sign in to comment.