From e875e67d24e95a86a2bcb9425a1fb01c91f9c097 Mon Sep 17 00:00:00 2001 From: andreas-unleash <104830839+andreas-unleash@users.noreply.github.com> Date: Thu, 30 Jun 2022 12:54:14 +0300 Subject: [PATCH] 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 --- src/lib/openapi/index.ts | 8 + .../client-features-schema.test.ts.snap | 18 + .../__snapshots__/feature-schema.test.ts.snap | 8 +- src/lib/openapi/spec/client-feature-schema.ts | 70 ++++ .../spec/client-features-query-schema.test.ts | 24 ++ .../spec/client-features-query-schema.ts | 39 ++ .../spec/client-features-schema.test.ts | 395 ++++++++++++++++++ .../openapi/spec/client-features-schema.ts | 51 +++ src/lib/openapi/spec/client-variant-schema.ts | 31 ++ .../openapi/spec/feature-strategy-schema.ts | 2 +- src/lib/openapi/spec/segment-schema.ts | 6 +- src/lib/routes/client-api/feature.test.ts | 10 + src/lib/routes/client-api/feature.ts | 90 +++- .../__snapshots__/openapi.e2e.test.ts.snap | 202 ++++++++- 14 files changed, 936 insertions(+), 18 deletions(-) create mode 100644 src/lib/openapi/spec/__snapshots__/client-features-schema.test.ts.snap create mode 100644 src/lib/openapi/spec/client-feature-schema.ts create mode 100644 src/lib/openapi/spec/client-features-query-schema.test.ts create mode 100644 src/lib/openapi/spec/client-features-query-schema.ts create mode 100644 src/lib/openapi/spec/client-features-schema.test.ts create mode 100644 src/lib/openapi/spec/client-features-schema.ts create mode 100644 src/lib/openapi/spec/client-variant-schema.ts diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index ad28b9368d..e39e2c80c8 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -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, diff --git a/src/lib/openapi/spec/__snapshots__/client-features-schema.test.ts.snap b/src/lib/openapi/spec/__snapshots__/client-features-schema.test.ts.snap new file mode 100644 index 0000000000..43a541910f --- /dev/null +++ b/src/lib/openapi/spec/__snapshots__/client-features-schema.test.ts.snap @@ -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", +} +`; diff --git a/src/lib/openapi/spec/__snapshots__/feature-schema.test.ts.snap b/src/lib/openapi/spec/__snapshots__/feature-schema.test.ts.snap index 97912e538a..76dc708e3f 100644 --- a/src/lib/openapi/spec/__snapshots__/feature-schema.test.ts.snap +++ b/src/lib/openapi/spec/__snapshots__/feature-schema.test.ts.snap @@ -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", diff --git a/src/lib/openapi/spec/client-feature-schema.ts b/src/lib/openapi/spec/client-feature-schema.ts new file mode 100644 index 0000000000..3b7fe12c28 --- /dev/null +++ b/src/lib/openapi/spec/client-feature-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/client-features-query-schema.test.ts b/src/lib/openapi/spec/client-features-query-schema.test.ts new file mode 100644 index 0000000000..173555db5f --- /dev/null +++ b/src/lib/openapi/spec/client-features-query-schema.test.ts @@ -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(); +}); diff --git a/src/lib/openapi/spec/client-features-query-schema.ts b/src/lib/openapi/spec/client-features-query-schema.ts new file mode 100644 index 0000000000..d96576de8f --- /dev/null +++ b/src/lib/openapi/spec/client-features-query-schema.ts @@ -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 +>; diff --git a/src/lib/openapi/spec/client-features-schema.test.ts b/src/lib/openapi/spec/client-features-schema.test.ts new file mode 100644 index 0000000000..ce8395e4f9 --- /dev/null +++ b/src/lib/openapi/spec/client-features-schema.test.ts @@ -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(); +}); diff --git a/src/lib/openapi/spec/client-features-schema.ts b/src/lib/openapi/spec/client-features-schema.ts new file mode 100644 index 0000000000..19504c2b80 --- /dev/null +++ b/src/lib/openapi/spec/client-features-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/client-variant-schema.ts b/src/lib/openapi/spec/client-variant-schema.ts new file mode 100644 index 0000000000..4e3f32f5a7 --- /dev/null +++ b/src/lib/openapi/spec/client-variant-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/feature-strategy-schema.ts b/src/lib/openapi/spec/feature-strategy-schema.ts index 94af957d83..168cfba2bb 100644 --- a/src/lib/openapi/spec/feature-strategy-schema.ts +++ b/src/lib/openapi/spec/feature-strategy-schema.ts @@ -6,7 +6,7 @@ export const featureStrategySchema = { $id: '#/components/schemas/featureStrategySchema', type: 'object', additionalProperties: false, - required: ['name', 'id'], + required: ['name'], properties: { id: { type: 'string', diff --git a/src/lib/openapi/spec/segment-schema.ts b/src/lib/openapi/spec/segment-schema.ts index 98311e1131..7962355317 100644 --- a/src/lib/openapi/spec/segment-schema.ts +++ b/src/lib/openapi/spec/segment-schema.ts @@ -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', diff --git a/src/lib/routes/client-api/feature.test.ts b/src/lib/routes/client-api/feature.test.ts index 8df34ab29d..9519226207 100644 --- a/src/lib/routes/client-api/feature.test.ts +++ b/src/lib/routes/client-api/feature.test.ts @@ -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, diff --git a/src/lib/routes/client-api/feature.ts b/src/lib/routes/client-api/feature.ts index 6a4acb7bbb..a354c7a4e2 100644 --- a/src/lib/routes/client-api/feature.ts +++ b/src/lib/routes/client-api/feature.ts @@ -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 { + async getAll( + req: IAuthRequest, + res: Response, + ): Promise { 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 { + async getFeatureToggle( + req: IAuthRequest<{ featureName: string }, ClientFeaturesQuerySchema>, + res: Response, + ): Promise { 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, + }, + ); } } diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 28ad491a50..859f6a30fa 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -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",