1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-26 01:17:00 +02:00

open api implementation - client features controller (#1745)

* open api implementation - client features controller

* open api implementation - client features controller

* bug fix

* test fix

* PR comments

* OAS for client-api metrics.ts

* Refactoring

* Refactoring

* bug fix

* fix PR comments

* PR comment

* PR comment
This commit is contained in:
andreas-unleash 2022-06-30 12:54:14 +03:00 committed by GitHub
parent b67aca8fbf
commit e875e67d24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 936 additions and 18 deletions

View File

@ -82,10 +82,14 @@ import { emailSchema } from './spec/email-schema';
import { strategySchema } from './spec/strategy-schema';
import { strategiesSchema } from './spec/strategies-schema';
import { upsertStrategySchema } from './spec/upsert-strategy-schema';
import { clientFeaturesQuerySchema } from './spec/client-features-query-schema';
import { clientFeatureSchema } from './spec/client-feature-schema';
import { clientFeaturesSchema } from './spec/client-features-schema';
import { eventSchema } from './spec/event-schema';
import { eventsSchema } from './spec/events-schema';
import { featureEventsSchema } from './spec/feature-events-schema';
import { clientApplicationSchema } from './spec/client-application-schema';
import { clientVariantSchema } from './spec/client-variant-schema';
import { IServerOption } from '../types';
import { URL } from 'url';
@ -101,6 +105,10 @@ export const schemas = {
applicationsSchema,
clientApplicationSchema,
cloneFeatureSchema,
clientFeatureSchema,
clientFeaturesSchema,
clientVariantSchema,
clientFeaturesQuerySchema,
changePasswordSchema,
constraintSchema,
contextFieldSchema,

View File

@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`clientFeaturesSchema no fields 1`] = `
Object {
"errors": Array [
Object {
"instancePath": "",
"keyword": "required",
"message": "must have required property 'version'",
"params": Object {
"missingProperty": "version",
},
"schemaPath": "#/required",
},
],
"schema": "#/components/schemas/clientFeaturesSchema",
}
`;

View File

@ -4,13 +4,13 @@ exports[`featureSchema constraints 1`] = `
Object {
"errors": Array [
Object {
"instancePath": "/strategies/0",
"instancePath": "/strategies/0/constraints/0",
"keyword": "required",
"message": "must have required property 'id'",
"message": "must have required property 'operator'",
"params": Object {
"missingProperty": "id",
"missingProperty": "operator",
},
"schemaPath": "#/required",
"schemaPath": "#/components/schemas/constraintSchema/required",
},
],
"schema": "#/components/schemas/featureSchema",

View File

@ -0,0 +1,70 @@
import { FromSchema } from 'json-schema-to-ts';
import { constraintSchema } from './constraint-schema';
import { parametersSchema } from './parameters-schema';
import { featureStrategySchema } from './feature-strategy-schema';
import { clientVariantSchema } from './client-variant-schema';
export const clientFeatureSchema = {
$id: '#/components/schemas/clientFeatureSchema',
type: 'object',
required: ['name', 'enabled'],
additionalProperties: false,
properties: {
name: {
type: 'string',
},
type: {
type: 'string',
},
description: {
type: 'string',
nullable: true,
},
createdAt: {
type: 'string',
format: 'date-time',
nullable: true,
},
lastSeenAt: {
type: 'string',
format: 'date-time',
nullable: true,
},
enabled: {
type: 'boolean',
},
stale: {
type: 'boolean',
},
impressionData: {
type: 'boolean',
nullable: true,
},
project: {
type: 'string',
},
strategies: {
type: 'array',
items: {
$ref: '#/components/schemas/featureStrategySchema',
},
},
variants: {
type: 'array',
items: {
$ref: '#/components/schemas/clientVariantSchema',
},
nullable: true,
},
},
components: {
schemas: {
constraintSchema,
parametersSchema,
featureStrategySchema,
clientVariantSchema,
},
},
} as const;
export type ClientFeatureSchema = FromSchema<typeof clientFeatureSchema>;

View File

@ -0,0 +1,24 @@
import { validateSchema } from '../validate';
import { ClientFeaturesQuerySchema } from './client-features-query-schema';
test('clientFeatureQuerySchema empty', () => {
const data: ClientFeaturesQuerySchema = {};
expect(
validateSchema('#/components/schemas/clientFeaturesQuerySchema', data),
).toBeUndefined();
});
test('clientFeatureQuerySchema all fields', () => {
const data: ClientFeaturesQuerySchema = {
tag: [['some-tag', 'some-other-tag']],
project: ['default'],
namePrefix: 'some-prefix',
environment: 'some-env',
inlineSegmentConstraints: true,
};
expect(
validateSchema('#/components/schemas/clientFeaturesQuerySchema', data),
).toBeUndefined();
});

View File

@ -0,0 +1,39 @@
import { FromSchema } from 'json-schema-to-ts';
export const clientFeaturesQuerySchema = {
$id: '#/components/schemas/clientFeaturesQuerySchema',
type: 'object',
required: [],
additionalProperties: false,
properties: {
tag: {
type: 'array',
items: {
type: 'array',
items: {
type: 'string',
},
},
},
project: {
type: 'array',
items: {
type: 'string',
},
},
namePrefix: {
type: 'string',
},
environment: {
type: 'string',
},
inlineSegmentConstraints: {
type: 'boolean',
},
},
components: {},
} as const;
export type ClientFeaturesQuerySchema = FromSchema<
typeof clientFeaturesQuerySchema
>;

View File

@ -0,0 +1,395 @@
import { validateSchema } from '../validate';
import { ClientFeaturesSchema } from './client-features-schema';
test('clientFeaturesSchema no fields', () => {
expect(
validateSchema('#/components/schemas/clientFeaturesSchema', {}),
).toMatchSnapshot();
});
test('clientFeaturesSchema required fields', () => {
const data: ClientFeaturesSchema = {
version: 0,
query: {},
features: [
{
name: 'some-name',
enabled: false,
impressionData: false,
},
],
};
expect(
validateSchema('#/components/schemas/clientFeaturesSchema', data),
).toBeUndefined();
});
test('clientFeaturesSchema java-sdk expected response', () => {
const json = `{
"version": 2,
"segments": [
{
"id": 1,
"name": "some-name",
"description": null,
"constraints": [
{
"contextName": "some-name",
"operator": "IN",
"value": "name",
"inverted": false,
"caseInsensitive": true
}
]
}
],
"features": [
{
"name": "Test.old",
"description": "No variants here!",
"enabled": true,
"strategies": [
{
"name": "default"
}
],
"variants": null,
"createdAt": "2019-01-24T10:38:10.370Z"
},
{
"name": "Test.variants",
"description": null,
"enabled": true,
"strategies": [
{
"name": "default",
"segments": [
1
]
}
],
"variants": [
{
"name": "variant1",
"weight": 50
},
{
"name": "variant2",
"weight": 50
}
],
"createdAt": "2019-01-24T10:41:45.236Z"
},
{
"name": "featureX",
"enabled": true,
"strategies": [
{
"name": "default"
}
]
},
{
"name": "featureY",
"enabled": false,
"strategies": [
{
"name": "baz",
"parameters": {
"foo": "bar"
}
}
]
},
{
"name": "featureZ",
"enabled": true,
"strategies": [
{
"name": "default"
},
{
"name": "hola",
"parameters": {
"name": "val"
},
"segments": [1]
}
]
}
]
}
`;
expect(
validateSchema(
'#/components/schemas/clientFeaturesSchema',
JSON.parse(json),
),
).toBeUndefined();
});
test('clientFeaturesSchema unleash-proxy expected response', () => {
const json = `{
"version": 2,
"segments": [
{
"id": 1,
"name": "some-name",
"description": null,
"constraints": [
{
"contextName": "some-name",
"operator": "IN",
"value": "name",
"inverted": false,
"caseInsensitive": true
}
]
}
],
"features": [
{
"name": "Test.old",
"description": "No variants here!",
"enabled": true,
"strategies": [
{
"name": "default"
}
],
"variants": null,
"createdAt": "2019-01-24T10:38:10.370Z"
},
{
"name": "Test.variants",
"description": null,
"enabled": true,
"strategies": [
{
"name": "default",
"segments": [
1
]
}
],
"variants": [
{
"name": "variant1",
"weight": 50
},
{
"name": "variant2",
"weight": 50
}
],
"createdAt": "2019-01-24T10:41:45.236Z"
},
{
"name": "featureX",
"enabled": true,
"strategies": [
{
"name": "default"
}
]
},
{
"name": "featureY",
"enabled": false,
"strategies": [
{
"name": "baz",
"parameters": {
"foo": "bar"
}
}
]
},
{
"name": "featureZ",
"enabled": true,
"strategies": [
{
"name": "default"
},
{
"name": "hola",
"parameters": {
"name": "val"
},
"segments": [1]
}
]
}
]
}
`;
expect(
validateSchema(
'#/components/schemas/clientFeaturesSchema',
JSON.parse(json),
),
).toBeUndefined();
});
test('clientFeaturesSchema client specification test 15', () => {
const json = `{
"version": 2,
"features": [
{
"name": "F9.globalSegmentOn",
"description": "With global segment referencing constraint in on state",
"enabled": true,
"strategies": [
{
"name": "default",
"parameters": {},
"segments": [1]
}
]
},
{
"name": "F9.globalSegmentOff",
"description": "With global segment referencing constraint in off state",
"enabled": true,
"strategies": [
{
"name": "default",
"parameters": {},
"segments": [2]
}
]
},
{
"name": "F9.globalSegmentAndConstraint",
"description": "With global segment and constraint both on",
"enabled": true,
"strategies": [
{
"name": "default",
"parameters": {},
"constraints": [
{
"contextName": "version",
"operator": "SEMVER_EQ",
"value": "1.2.2"
}
],
"segments": [1]
}
]
},
{
"name": "F9.withExtraParams",
"description": "With global segment that doesn't exist",
"enabled": true,
"project": "some-project",
"strategies": [
{
"name": "default",
"parameters": {},
"constraints": [
{
"contextName": "version",
"operator": "SEMVER_EQ",
"value": "1.2.2"
}
],
"segments": [3]
}
]
},
{
"name": "F9.withSeveralConstraintsAndSegments",
"description": "With several segments and constraints",
"enabled": true,
"strategies": [
{
"name": "default",
"parameters": {},
"constraints": [
{
"contextName": "customNumber",
"operator": "NUM_LT",
"value": "10"
},
{
"contextName": "version",
"operator": "SEMVER_LT",
"value": "3.2.2"
}
],
"segments": [1, 4, 5]
}
]
}
],
"segments": [
{
"id": 1,
"constraints": [
{
"contextName": "version",
"operator": "SEMVER_EQ",
"value": "1.2.2"
}
]
},
{
"id": 2,
"constraints": [
{
"contextName": "version",
"operator": "SEMVER_EQ",
"value": "3.1.4"
}
]
},
{
"id": 3,
"constraints": [
{
"contextName": "version",
"operator": "SEMVER_EQ",
"value": "3.1.4"
}
]
},
{
"id": 4,
"constraints": [
{
"contextName": "customName",
"operator": "STR_CONTAINS",
"values": ["Pi"]
}
]
},
{
"id": 5,
"constraints": [
{
"contextName": "slicesLeft",
"operator": "NUM_LTE",
"value": "4"
}
]
}
]
}
`;
expect(
validateSchema(
'#/components/schemas/clientFeaturesSchema',
JSON.parse(json),
),
).toBeUndefined();
});

View File

@ -0,0 +1,51 @@
import { FromSchema } from 'json-schema-to-ts';
import { clientFeaturesQuerySchema } from './client-features-query-schema';
import { segmentSchema } from './segment-schema';
import { constraintSchema } from './constraint-schema';
import { environmentSchema } from './environment-schema';
import { overrideSchema } from './override-schema';
import { parametersSchema } from './parameters-schema';
import { featureStrategySchema } from './feature-strategy-schema';
import { variantSchema } from './variant-schema';
import { clientFeatureSchema } from './client-feature-schema';
export const clientFeaturesSchema = {
$id: '#/components/schemas/clientFeaturesSchema',
type: 'object',
required: ['version', 'features'],
properties: {
version: {
type: 'number',
},
features: {
type: 'array',
items: {
$ref: '#/components/schemas/clientFeatureSchema',
},
},
segments: {
type: 'array',
items: {
$ref: '#/components/schemas/segmentSchema',
},
},
query: {
$ref: '#/components/schemas/clientFeaturesQuerySchema',
},
},
components: {
schemas: {
constraintSchema,
clientFeatureSchema,
environmentSchema,
segmentSchema,
clientFeaturesQuerySchema,
overrideSchema,
parametersSchema,
featureStrategySchema,
variantSchema,
},
},
} as const;
export type ClientFeaturesSchema = FromSchema<typeof clientFeaturesSchema>;

View File

@ -0,0 +1,31 @@
import { FromSchema } from 'json-schema-to-ts';
export const clientVariantSchema = {
$id: '#/components/schemas/clientVariantSchema',
type: 'object',
additionalProperties: false,
required: ['name', 'weight'],
properties: {
name: {
type: 'string',
},
weight: {
type: 'number',
},
payload: {
type: 'object',
required: ['type', 'value'],
properties: {
type: {
type: 'string',
},
value: {
type: 'string',
},
},
},
},
components: {},
} as const;
export type ClientVariantSchema = FromSchema<typeof clientVariantSchema>;

View File

@ -6,7 +6,7 @@ export const featureStrategySchema = {
$id: '#/components/schemas/featureStrategySchema',
type: 'object',
additionalProperties: false,
required: ['name', 'id'],
required: ['name'],
properties: {
id: {
type: 'string',

View File

@ -5,13 +5,17 @@ export const segmentSchema = {
$id: '#/components/schemas/segmentSchema',
type: 'object',
additionalProperties: false,
required: ['name', 'constraints'],
required: ['id', 'constraints'],
properties: {
id: {
type: 'number',
},
name: {
type: 'string',
},
description: {
type: 'string',
nullable: true,
},
constraints: {
type: 'array',

View File

@ -70,7 +70,10 @@ test('should get empty getFeatures via client', () => {
test('if caching is enabled should memoize', async () => {
const getClientFeatures = jest.fn().mockReturnValue([]);
const getActive = jest.fn().mockReturnValue([]);
const respondWithValidation = jest.fn().mockReturnValue({});
const validPath = jest.fn().mockReturnValue(jest.fn());
const clientSpecService = new ClientSpecService({ getLogger });
const openApiService = { respondWithValidation, validPath };
const featureToggleServiceV2 = { getClientFeatures };
const segmentService = { getActive };
@ -78,6 +81,8 @@ test('if caching is enabled should memoize', async () => {
{
clientSpecService,
// @ts-expect-error
openApiService,
// @ts-expect-error
featureToggleServiceV2,
// @ts-expect-error
segmentService,
@ -99,14 +104,19 @@ test('if caching is enabled should memoize', async () => {
test('if caching is not enabled all calls goes to service', async () => {
const getClientFeatures = jest.fn().mockReturnValue([]);
const getActive = jest.fn().mockReturnValue([]);
const respondWithValidation = jest.fn().mockReturnValue({});
const validPath = jest.fn().mockReturnValue(jest.fn());
const clientSpecService = new ClientSpecService({ getLogger });
const featureToggleServiceV2 = { getClientFeatures };
const segmentService = { getActive };
const openApiService = { respondWithValidation, validPath };
const controller = new FeatureController(
{
clientSpecService,
// @ts-expect-error
openApiService,
// @ts-expect-error
featureToggleServiceV2,
// @ts-expect-error
segmentService,

View File

@ -1,8 +1,7 @@
import memoizee from 'memoizee';
import { Response } from 'express';
import Controller from '../controller';
import { IUnleashServices } from '../../types/services';
import { IUnleashConfig } from '../../types/option';
import { IUnleashConfig, IUnleashServices } from '../../types';
import FeatureToggleService from '../../services/feature-toggle-service';
import { Logger } from '../../logger';
import { querySchema } from '../../schema/feature-schema';
@ -14,6 +13,18 @@ import { ALL, isAllProjects } from '../../types/models/api-token';
import { SegmentService } from '../../services/segment-service';
import { FeatureConfigurationClient } from '../../types/stores/feature-strategies-store';
import { ClientSpecService } from '../../services/client-spec-service';
import { OpenApiService } from '../../services/openapi-service';
import { NONE } from '../../types/permissions';
import { createResponseSchema } from '../../openapi';
import { ClientFeaturesQuerySchema } from '../../openapi/spec/client-features-query-schema';
import {
clientFeatureSchema,
ClientFeatureSchema,
} from '../../openapi/spec/client-feature-schema';
import {
clientFeaturesSchema,
ClientFeaturesSchema,
} from '../../openapi/spec/client-features-schema';
const version = 2;
@ -31,6 +42,8 @@ export default class FeatureController extends Controller {
private clientSpecService: ClientSpecService;
private openApiService: OpenApiService;
private readonly cache: boolean;
private cachedFeatures: any;
@ -40,9 +53,13 @@ export default class FeatureController extends Controller {
featureToggleServiceV2,
segmentService,
clientSpecService,
openApiService,
}: Pick<
IUnleashServices,
'featureToggleServiceV2' | 'segmentService' | 'clientSpecService'
| 'featureToggleServiceV2'
| 'segmentService'
| 'clientSpecService'
| 'openApiService'
>,
config: IUnleashConfig,
) {
@ -51,10 +68,40 @@ export default class FeatureController extends Controller {
this.featureToggleServiceV2 = featureToggleServiceV2;
this.segmentService = segmentService;
this.clientSpecService = clientSpecService;
this.openApiService = openApiService;
this.logger = config.getLogger('client-api/feature.js');
this.get('/', this.getAll);
this.get('/:featureName', this.getFeatureToggle);
this.route({
method: 'get',
path: '/:featureName',
handler: this.getFeatureToggle,
permission: NONE,
middleware: [
openApiService.validPath({
operationId: 'getClientFeature',
tags: ['client'],
responses: {
200: createResponseSchema('clientFeaturesSchema'),
},
}),
],
});
this.route({
method: 'get',
path: '',
handler: this.getAll,
permission: NONE,
middleware: [
openApiService.validPath({
operationId: 'getAllClientFeatures',
tags: ['client'],
responses: {
200: createResponseSchema('clientFeaturesSchema'),
},
}),
],
});
if (clientFeatureCaching?.enabled) {
this.cache = true;
@ -148,7 +195,10 @@ export default class FeatureController extends Controller {
return query;
}
async getAll(req: IAuthRequest, res: Response): Promise<void> {
async getAll(
req: IAuthRequest,
res: Response<ClientFeaturesSchema>,
): Promise<void> {
const query = await this.resolveQuery(req);
const [features, segments] = this.cache
@ -156,13 +206,26 @@ export default class FeatureController extends Controller {
: await this.resolveFeaturesAndSegments(query);
if (this.clientSpecService.requestSupportsSpec(req, 'segments')) {
res.json({ version, features, query, segments });
this.openApiService.respondWithValidation(
200,
res,
clientFeaturesSchema.$id,
{ version, features, query: { ...query }, segments },
);
} else {
res.json({ version, features, query });
this.openApiService.respondWithValidation(
200,
res,
clientFeaturesSchema.$id,
{ version, features, query },
);
}
}
async getFeatureToggle(req: IAuthRequest, res: Response): Promise<void> {
async getFeatureToggle(
req: IAuthRequest<{ featureName: string }, ClientFeaturesQuerySchema>,
res: Response<ClientFeatureSchema>,
): Promise<void> {
const name = req.params.featureName;
const featureQuery = await this.resolveQuery(req);
const q = { ...featureQuery, namePrefix: name };
@ -172,6 +235,13 @@ export default class FeatureController extends Controller {
if (!toggle) {
throw new NotFoundError(`Could not find feature toggle ${name}`);
}
res.json(toggle).end();
this.openApiService.respondWithValidation(
200,
res,
clientFeatureSchema.$id,
{
...toggle,
},
);
}
}

View File

@ -352,6 +352,151 @@ Object {
],
"type": "object",
},
"clientFeatureSchema": Object {
"additionalProperties": false,
"properties": Object {
"createdAt": Object {
"format": "date-time",
"nullable": true,
"type": "string",
},
"description": Object {
"nullable": true,
"type": "string",
},
"enabled": Object {
"type": "boolean",
},
"impressionData": Object {
"nullable": true,
"type": "boolean",
},
"lastSeenAt": Object {
"format": "date-time",
"nullable": true,
"type": "string",
},
"name": Object {
"type": "string",
},
"project": Object {
"type": "string",
},
"stale": Object {
"type": "boolean",
},
"strategies": Object {
"items": Object {
"$ref": "#/components/schemas/featureStrategySchema",
},
"type": "array",
},
"type": Object {
"type": "string",
},
"variants": Object {
"items": Object {
"$ref": "#/components/schemas/clientVariantSchema",
},
"nullable": true,
"type": "array",
},
},
"required": Array [
"name",
"enabled",
],
"type": "object",
},
"clientFeaturesQuerySchema": Object {
"additionalProperties": false,
"properties": Object {
"environment": Object {
"type": "string",
},
"inlineSegmentConstraints": Object {
"type": "boolean",
},
"namePrefix": Object {
"type": "string",
},
"project": Object {
"items": Object {
"type": "string",
},
"type": "array",
},
"tag": Object {
"items": Object {
"items": Object {
"type": "string",
},
"type": "array",
},
"type": "array",
},
},
"required": Array [],
"type": "object",
},
"clientFeaturesSchema": Object {
"properties": Object {
"features": Object {
"items": Object {
"$ref": "#/components/schemas/clientFeatureSchema",
},
"type": "array",
},
"query": Object {
"$ref": "#/components/schemas/clientFeaturesQuerySchema",
},
"segments": Object {
"items": Object {
"$ref": "#/components/schemas/segmentSchema",
},
"type": "array",
},
"version": Object {
"type": "number",
},
},
"required": Array [
"version",
"features",
],
"type": "object",
},
"clientVariantSchema": Object {
"additionalProperties": false,
"properties": Object {
"name": Object {
"type": "string",
},
"payload": Object {
"properties": Object {
"type": Object {
"type": "string",
},
"value": Object {
"type": "string",
},
},
"required": Array [
"type",
"value",
],
"type": "object",
},
"weight": Object {
"type": "number",
},
},
"required": Array [
"name",
"weight",
],
"type": "object",
},
"cloneFeatureSchema": Object {
"properties": Object {
"name": Object {
@ -952,7 +1097,6 @@ Object {
},
"required": Array [
"name",
"id",
],
"type": "object",
},
@ -1537,14 +1681,18 @@ Object {
"type": "array",
},
"description": Object {
"nullable": true,
"type": "string",
},
"id": Object {
"type": "number",
},
"name": Object {
"type": "string",
},
},
"required": Array [
"name",
"id",
"constraints",
],
"type": "object",
@ -5266,6 +5414,56 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
"/api/client/features": Object {
"get": Object {
"operationId": "getAllClientFeatures",
"responses": Object {
"200": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/clientFeaturesSchema",
},
},
},
"description": "clientFeaturesSchema",
},
},
"tags": Array [
"client",
],
},
},
"/api/client/features/{featureName}": Object {
"get": Object {
"operationId": "getClientFeature",
"parameters": Array [
Object {
"in": "path",
"name": "featureName",
"required": true,
"schema": Object {
"type": "string",
},
},
],
"responses": Object {
"200": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/clientFeaturesSchema",
},
},
},
"description": "clientFeaturesSchema",
},
},
"tags": Array [
"client",
],
},
},
"/api/client/register": Object {
"post": Object {
"operationId": "registerClientApplication",