1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

feat: add all feature variants to the playground payload (#1835)

* Chore: extract variant creation arbitrary.

* Feat: add "variants" as a required property on playground response

* Wip: fix up some schema generation

* Fix remaining openapi schemas

* Fix: add missing variants property

* Feat: test for variants

* Feat: add `variants` property to playground response

* Chore: update openapi snapshot
This commit is contained in:
Thomas Heartman 2022-07-20 08:54:34 +02:00 committed by GitHub
parent 7a7b86d440
commit 012da8469f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 207 additions and 84 deletions

View File

@ -1,5 +1,5 @@
import fc, { Arbitrary } from 'fast-check'; import fc, { Arbitrary } from 'fast-check';
import { urlFriendlyString } from '../../../test/arbitraries.test'; import { urlFriendlyString, variants } from '../../../test/arbitraries.test';
import { validateSchema } from '../validate'; import { validateSchema } from '../validate';
import { import {
playgroundFeatureSchema, playgroundFeatureSchema,
@ -7,42 +7,67 @@ import {
} from './playground-feature-schema'; } from './playground-feature-schema';
export const generate = (): Arbitrary<PlaygroundFeatureSchema> => export const generate = (): Arbitrary<PlaygroundFeatureSchema> =>
fc.boolean().chain((isEnabled) => fc
fc.record({ .tuple(
isEnabled: fc.constant(isEnabled), fc.boolean(),
projectId: urlFriendlyString(), variants(),
name: urlFriendlyString(), fc.nat(),
variant: fc.record( fc.record({
{ projectId: urlFriendlyString(),
name: urlFriendlyString(), name: urlFriendlyString(),
enabled: fc.constant(isEnabled), }),
payload: fc.oneof( )
fc.record({ .map(([isEnabled, generatedVariants, activeVariantIndex, feature]) => {
type: fc.constant('json' as 'json'), // the active variant is the disabled variant if the feature is
value: fc.json(), // disabled or has no variants.
}), let activeVariant = { name: 'disabled', enabled: false } as {
fc.record({ name: string;
type: fc.constant('csv' as 'csv'), enabled: boolean;
value: fc payload?: {
.array(fc.lorem()) type: 'string' | 'json' | 'csv';
.map((words) => words.join(',')), value: string;
}), };
fc.record({ };
type: fc.constant('string' as 'string'),
value: fc.string(), if (generatedVariants.length && isEnabled) {
}), const targetVariant =
), generatedVariants[
}, activeVariantIndex % generatedVariants.length
{ requiredKeys: ['name', 'enabled'] }, ];
), const targetPayload = targetVariant.payload
}), ? (targetVariant.payload as {
); type: 'string' | 'json' | 'csv';
value: string;
})
: undefined;
activeVariant = {
enabled: isEnabled,
name: targetVariant.name,
payload: targetPayload,
};
}
return {
...feature,
isEnabled,
variants: generatedVariants,
variant: activeVariant,
};
});
test('playgroundFeatureSchema', () => test('playgroundFeatureSchema', () =>
fc.assert( fc.assert(
fc.property( fc.property(
generate(), generate(),
(data: PlaygroundFeatureSchema) => fc.context(),
validateSchema(playgroundFeatureSchema.$id, data) === undefined, (data: PlaygroundFeatureSchema, ctx) => {
const results = validateSchema(
playgroundFeatureSchema.$id,
data,
);
ctx.log(JSON.stringify(results));
return results === undefined;
},
), ),
)); ));

View File

@ -1,4 +1,6 @@
import { FromSchema } from 'json-schema-to-ts'; import { FromSchema } from 'json-schema-to-ts';
import { variantSchema } from './variant-schema';
import { overrideSchema } from './override-schema';
export const playgroundFeatureSchema = { export const playgroundFeatureSchema = {
$id: '#/components/schemas/playgroundFeatureSchema', $id: '#/components/schemas/playgroundFeatureSchema',
@ -6,7 +8,7 @@ export const playgroundFeatureSchema = {
'A simplified feature toggle model intended for the Unleash playground.', 'A simplified feature toggle model intended for the Unleash playground.',
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
required: ['name', 'projectId', 'isEnabled', 'variant'], required: ['name', 'projectId', 'isEnabled', 'variant', 'variants'],
properties: { properties: {
name: { type: 'string', examples: ['my-feature'] }, name: { type: 'string', examples: ['my-feature'] },
projectId: { type: 'string', examples: ['my-project'] }, projectId: { type: 'string', examples: ['my-project'] },
@ -34,8 +36,9 @@ export const playgroundFeatureSchema = {
nullable: true, nullable: true,
examples: ['green'], examples: ['green'],
}, },
variants: { type: 'array', items: { $ref: variantSchema.$id } },
}, },
components: { schemas: {} }, components: { schemas: { variantSchema, overrideSchema } },
} as const; } as const;
export type PlaygroundFeatureSchema = FromSchema< export type PlaygroundFeatureSchema = FromSchema<

View File

@ -5,12 +5,14 @@ import {
} from '../../../lib/openapi/spec/playground-response-schema'; } from '../../../lib/openapi/spec/playground-response-schema';
import { validateSchema } from '../validate'; import { validateSchema } from '../validate';
import { generate as generateInput } from './playground-request-schema.test'; import { generate as generateInput } from './playground-request-schema.test';
import { generate as generateToggles } from './playground-feature-schema.test'; import { generate as generateFeature } from './playground-feature-schema.test';
const generate = (): Arbitrary<PlaygroundResponseSchema> => const generate = (): Arbitrary<PlaygroundResponseSchema> =>
fc.record({ fc.record({
input: generateInput(), input: generateInput(),
features: fc.array(generateToggles()), features: fc.uniqueArray(generateFeature(), {
selector: (feature) => feature.name,
}),
}); });
test('playgroundResponseSchema', () => test('playgroundResponseSchema', () =>

View File

@ -2,6 +2,8 @@ import { FromSchema } from 'json-schema-to-ts';
import { sdkContextSchema } from './sdk-context-schema'; import { sdkContextSchema } from './sdk-context-schema';
import { playgroundRequestSchema } from './playground-request-schema'; import { playgroundRequestSchema } from './playground-request-schema';
import { playgroundFeatureSchema } from './playground-feature-schema'; import { playgroundFeatureSchema } from './playground-feature-schema';
import { variantSchema } from './variant-schema';
import { overrideSchema } from './override-schema';
export const playgroundResponseSchema = { export const playgroundResponseSchema = {
$id: '#/components/schemas/playgroundResponseSchema', $id: '#/components/schemas/playgroundResponseSchema',
@ -15,16 +17,16 @@ export const playgroundResponseSchema = {
}, },
features: { features: {
type: 'array', type: 'array',
items: { items: { $ref: playgroundFeatureSchema.$id },
$ref: playgroundFeatureSchema.$id,
},
}, },
}, },
components: { components: {
schemas: { schemas: {
sdkContextSchema,
playgroundRequestSchema,
playgroundFeatureSchema, playgroundFeatureSchema,
playgroundRequestSchema,
sdkContextSchema,
variantSchema,
overrideSchema,
}, },
}, },
} as const; } as const;

View File

@ -36,6 +36,11 @@ export class PlaygroundService {
if (!head) { if (!head) {
return []; return [];
} else { } else {
const variantsMap = toggles.reduce((acc, feature) => {
acc[feature.name] = feature.variants;
return acc;
}, {});
const client = await offlineUnleashClient( const client = await offlineUnleashClient(
[head, ...rest], [head, ...rest],
context, context,
@ -60,6 +65,7 @@ export class PlaygroundService {
), ),
variant: client.getVariant(feature.name, clientContext), variant: client.getVariant(feature.name, clientContext),
name: feature.name, name: feature.name,
variants: variantsMap[feature.name] || [],
}; };
}), }),
); );

View File

@ -2,7 +2,7 @@ import fc, { Arbitrary } from 'fast-check';
import { ALL_OPERATORS } from '../lib/util/constants'; import { ALL_OPERATORS } from '../lib/util/constants';
import { ClientFeatureSchema } from '../lib/openapi/spec/client-feature-schema'; import { ClientFeatureSchema } from '../lib/openapi/spec/client-feature-schema';
import { WeightType } from '../lib/types/model'; import { IVariant, WeightType } from '../lib/types/model';
import { FeatureStrategySchema } from '../lib/openapi/spec/feature-strategy-schema'; import { FeatureStrategySchema } from '../lib/openapi/spec/feature-strategy-schema';
import { ConstraintSchema } from 'lib/openapi/spec/constraint-schema'; import { ConstraintSchema } from 'lib/openapi/spec/constraint-schema';
@ -92,6 +92,49 @@ export const strategies = (): Arbitrary<FeatureStrategySchema[]> =>
), ),
); );
export const variant = (): Arbitrary<IVariant> =>
fc.record(
{
name: urlFriendlyString(),
weight: fc.nat({ max: 1000 }),
weightType: fc.constant(WeightType.VARIABLE),
stickiness: fc.constant('default'),
payload: fc.option(
fc.oneof(
fc.record({
type: fc.constant('json' as 'json'),
value: fc.json(),
}),
fc.record({
type: fc.constant('csv' as 'csv'),
value: fc
.array(fc.lorem())
.map((words) => words.join(',')),
}),
fc.record({
type: fc.constant('string' as 'string'),
value: fc.string(),
}),
),
{ nil: undefined },
),
},
{ requiredKeys: ['name', 'weight', 'weightType', 'stickiness'] },
);
export const variants = (): Arbitrary<IVariant[]> =>
fc
.uniqueArray(variant(), {
maxLength: 1000,
selector: (variantInstance) => variantInstance.name,
})
.map((allVariants) =>
allVariants.map((variantInstance) => ({
...variantInstance,
weight: Math.round(1000 / allVariants.length),
})),
);
export const clientFeature = (name?: string): Arbitrary<ClientFeatureSchema> => export const clientFeature = (name?: string): Arbitrary<ClientFeatureSchema> =>
fc.record( fc.record(
{ {
@ -111,32 +154,7 @@ export const clientFeature = (name?: string): Arbitrary<ClientFeatureSchema> =>
stale: fc.boolean(), stale: fc.boolean(),
impressionData: fc.option(fc.boolean()), impressionData: fc.option(fc.boolean()),
strategies: strategies(), strategies: strategies(),
variants: fc.array( variants: variants(),
fc.record({
name: urlFriendlyString(),
weight: fc.nat({ max: 1000 }),
weightType: fc.constant(WeightType.VARIABLE),
stickiness: fc.constant('default'),
payload: fc.option(
fc.oneof(
fc.record({
type: fc.constant('json'),
value: fc.json(),
}),
fc.record({
type: fc.constant('csv'),
value: fc
.array(fc.lorem())
.map((words) => words.join(',')),
}),
fc.record({
type: fc.constant('string'),
value: fc.string(),
}),
),
),
}),
),
}, },
{ requiredKeys: ['name', 'enabled', 'project', 'strategies'] }, { requiredKeys: ['name', 'enabled', 'project', 'strategies'] },
); );
@ -157,3 +175,13 @@ test('url-friendly strings are URL-friendly', () =>
/^[\w~.-]+$/.test(input), /^[\w~.-]+$/.test(input),
), ),
)); ));
test('variant payloads are either present or undefined; never null', () =>
fc.assert(
fc.property(
variant(),
(generatedVariant) =>
!!generatedVariant.payload ||
generatedVariant.payload === undefined,
),
));

View File

@ -1821,12 +1821,19 @@ Object {
], ],
"type": "object", "type": "object",
}, },
"variants": Object {
"items": Object {
"$ref": "#/components/schemas/variantSchema",
},
"type": "array",
},
}, },
"required": Array [ "required": Array [
"name", "name",
"projectId", "projectId",
"isEnabled", "isEnabled",
"variant", "variant",
"variants",
], ],
"type": "object", "type": "object",
}, },

View File

@ -7,9 +7,10 @@ import dbInit, { ITestDb } from '../helpers/database-init';
import { IUnleashStores } from '../../../lib/types/stores'; import { IUnleashStores } from '../../../lib/types/stores';
import FeatureToggleService from '../../../lib/services/feature-toggle-service'; import FeatureToggleService from '../../../lib/services/feature-toggle-service';
import { SegmentService } from '../../../lib/services/segment-service'; import { SegmentService } from '../../../lib/services/segment-service';
import { WeightType } from '../../../lib/types/model'; import { FeatureToggleDTO, IVariant } from '../../../lib/types/model';
import { PlaygroundFeatureSchema } from '../../../lib/openapi/spec/playground-feature-schema'; import { PlaygroundFeatureSchema } from '../../../lib/openapi/spec/playground-feature-schema';
import { offlineUnleashClient } from '../../../lib/util/offline-unleash-client'; import { offlineUnleashClient } from '../../../lib/util/offline-unleash-client';
import { ClientFeatureSchema } from '../../../lib/openapi/spec/client-feature-schema';
let stores: IUnleashStores; let stores: IUnleashStores;
let db: ITestDb; let db: ITestDb;
@ -48,6 +49,15 @@ describe('the playground service (e2e)', () => {
enabled: boolean; enabled: boolean;
}) => name === 'disabled' && !enabled; }) => name === 'disabled' && !enabled;
const toFeatureToggleDTO = (
feature: ClientFeatureSchema,
): FeatureToggleDTO => ({
...feature,
// the arbitrary generator takes care of this
variants: feature.variants as IVariant[] | undefined,
createdAt: undefined,
});
test('should return the same enabled toggles as the raw SDK correctly mapped', async () => { test('should return the same enabled toggles as the raw SDK correctly mapped', async () => {
await fc.assert( await fc.assert(
fc fc
@ -59,20 +69,7 @@ describe('the playground service (e2e)', () => {
toggles.map((feature) => toggles.map((feature) =>
stores.featureToggleStore.create( stores.featureToggleStore.create(
feature.project, feature.project,
{ toFeatureToggleDTO(feature),
...feature,
createdAt: undefined,
variants: [
...(feature.variants ?? []).map(
(variant) => ({
...variant,
weightType:
WeightType.VARIABLE,
stickiness: 'default',
}),
),
],
},
), ),
), ),
); );
@ -154,4 +151,57 @@ describe('the playground service (e2e)', () => {
testParams, testParams,
); );
}); });
test('output toggles should have the same variants as input toggles', async () => {
await fc.assert(
fc
.asyncProperty(
clientFeatures({ minLength: 1 }),
generateContext(),
async (toggles, context) => {
await Promise.all(
toggles.map((feature) =>
stores.featureToggleStore.create(
feature.project,
toFeatureToggleDTO(feature),
),
),
);
const projects = '*';
const env = 'default';
const serviceToggles: PlaygroundFeatureSchema[] =
await service.evaluateQuery(projects, env, context);
const variantsMap = toggles.reduce(
(acc, feature) => ({
...acc,
[feature.name]: feature.variants,
}),
{},
);
serviceToggles.forEach((feature) => {
if (variantsMap[feature.name]) {
expect(feature.variants).toEqual(
expect.arrayContaining(
variantsMap[feature.name],
),
);
expect(variantsMap[feature.name]).toEqual(
expect.arrayContaining(feature.variants),
);
} else {
expect(feature.variants).toStrictEqual([]);
}
});
},
)
.afterEach(async () => {
await stores.featureToggleStore.deleteAll();
}),
testParams,
);
});
}); });