From eb0699ca031a68ca4c316a4cd7e16c26fc14002c Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Mon, 16 Dec 2024 08:46:15 +0200 Subject: [PATCH] feat: move delta controller to new path (#8981) Feature delta is now at api//client/delta --- .../client-feature-toggle-cache-controller.ts | 183 ++++++++++++++++++ .../client-feature-toggle.controller.ts | 58 +----- .../tests/client-feature-toggles.e2e.test.ts | 4 +- .../spec/client-features-delta-schema.ts | 56 ++++++ src/lib/openapi/spec/index.ts | 1 + src/lib/routes/client-api/index.ts | 5 + 6 files changed, 248 insertions(+), 59 deletions(-) create mode 100644 src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache-controller.ts create mode 100644 src/lib/openapi/spec/client-features-delta-schema.ts diff --git a/src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache-controller.ts b/src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache-controller.ts new file mode 100644 index 0000000000..82c5a745fc --- /dev/null +++ b/src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache-controller.ts @@ -0,0 +1,183 @@ +import type { Response } from 'express'; +import Controller from '../../../routes/controller'; +import type { + IFlagResolver, + IUnleashConfig, + IUnleashServices, +} from '../../../types'; +import type { Logger } from '../../../logger'; +import { querySchema } from '../../../schema/feature-schema'; +import type { IFeatureToggleQuery } from '../../../types/model'; +import NotFoundError from '../../../error/notfound-error'; +import type { IAuthRequest } from '../../../routes/unleash-types'; +import ApiUser from '../../../types/api-user'; +import { ALL, isAllProjects } from '../../../types/models/api-token'; +import type { ClientSpecService } from '../../../services/client-spec-service'; +import type { OpenApiService } from '../../../services/openapi-service'; +import { NONE } from '../../../types/permissions'; +import { createResponseSchema } from '../../../openapi/util/create-response-schema'; +import type { ClientFeatureToggleService } from '../client-feature-toggle-service'; +import type { RevisionCacheEntry } from './client-feature-toggle-cache'; +import { clientFeaturesDeltaSchema } from '../../../openapi'; +import type { QueryOverride } from '../client-feature-toggle.controller'; + +export default class ClientFeatureToggleDeltaController extends Controller { + private readonly logger: Logger; + + private clientFeatureToggleService: ClientFeatureToggleService; + + private clientSpecService: ClientSpecService; + + private openApiService: OpenApiService; + + private flagResolver: IFlagResolver; + + constructor( + { + clientFeatureToggleService, + clientSpecService, + openApiService, + }: Pick< + IUnleashServices, + | 'clientFeatureToggleService' + | 'clientSpecService' + | 'openApiService' + | 'featureToggleService' + >, + config: IUnleashConfig, + ) { + super(config); + this.clientFeatureToggleService = clientFeatureToggleService; + this.clientSpecService = clientSpecService; + this.openApiService = openApiService; + this.flagResolver = config.flagResolver; + this.logger = config.getLogger('client-api/delta.js'); + + this.route({ + method: 'get', + path: '', + handler: this.getDelta, + permission: NONE, + middleware: [ + openApiService.validPath({ + summary: 'Get partial updates (SDK)', + description: + 'Initially returns the full set of feature flags available to the provided API key. When called again with the returned etag, only returns the flags that have changed', + operationId: 'getDelta', + tags: ['Unstable'], + responses: { + 200: createResponseSchema('clientFeaturesDeltaSchema'), + }, + }), + ], + }); + } + + private async resolveQuery( + req: IAuthRequest, + ): Promise { + const { user, query } = req; + + const override: QueryOverride = {}; + if (user instanceof ApiUser) { + if (!isAllProjects(user.projects)) { + override.project = user.projects; + } + if (user.environment !== ALL) { + override.environment = user.environment; + } + } + + const inlineSegmentConstraints = + !this.clientSpecService.requestSupportsSpec(req, 'segments'); + + return this.prepQuery({ + ...query, + ...override, + inlineSegmentConstraints, + }); + } + + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + private paramToArray(param: any) { + if (!param) { + return param; + } + return Array.isArray(param) ? param : [param]; + } + + private async prepQuery({ + tag, + project, + namePrefix, + environment, + inlineSegmentConstraints, + }: IFeatureToggleQuery): Promise { + if ( + !tag && + !project && + !namePrefix && + !environment && + !inlineSegmentConstraints + ) { + return {}; + } + + const tagQuery = this.paramToArray(tag); + const projectQuery = this.paramToArray(project); + const query = await querySchema.validateAsync({ + tag: tagQuery, + project: projectQuery, + namePrefix, + environment, + inlineSegmentConstraints, + }); + + if (query.tag) { + query.tag = query.tag.map((q) => q.split(':')); + } + + return query; + } + + async getDelta( + req: IAuthRequest, + res: Response, + ): Promise { + if (!this.flagResolver.isEnabled('deltaApi')) { + throw new NotFoundError(); + } + const query = await this.resolveQuery(req); + const etag = req.headers['if-none-match']; + + const currentSdkRevisionId = etag ? Number.parseInt(etag) : undefined; + + const changedFeatures = + await this.clientFeatureToggleService.getClientDelta( + currentSdkRevisionId, + query, + ); + + if (!changedFeatures) { + res.status(304); + res.getHeaderNames().forEach((header) => res.removeHeader(header)); + res.end(); + return; + } + + if (changedFeatures.revisionId === currentSdkRevisionId) { + res.status(304); + res.getHeaderNames().forEach((header) => res.removeHeader(header)); + res.end(); + return; + } + + res.setHeader('ETag', changedFeatures.revisionId.toString()); + this.openApiService.respondWithValidation( + 200, + res, + clientFeaturesDeltaSchema.$id, + changedFeatures, + ); + } +} diff --git a/src/lib/features/client-feature-toggles/client-feature-toggle.controller.ts b/src/lib/features/client-feature-toggles/client-feature-toggle.controller.ts index de1f6a41c3..3cac9e924e 100644 --- a/src/lib/features/client-feature-toggles/client-feature-toggle.controller.ts +++ b/src/lib/features/client-feature-toggles/client-feature-toggle.controller.ts @@ -33,11 +33,10 @@ import { } from '../../openapi/spec/client-features-schema'; import type ConfigurationRevisionService from '../feature-toggle/configuration-revision-service'; import type { ClientFeatureToggleService } from './client-feature-toggle-service'; -import type { RevisionCacheEntry } from './cache/client-feature-toggle-cache'; const version = 2; -interface QueryOverride { +export interface QueryOverride { project?: string[]; environment?: string; } @@ -95,25 +94,6 @@ export default class FeatureController extends Controller { this.flagResolver = config.flagResolver; this.logger = config.getLogger('client-api/feature.js'); - this.route({ - method: 'get', - path: '/delta', - handler: this.getDelta, - permission: NONE, - middleware: [ - openApiService.validPath({ - summary: 'Get partial updates (SDK)', - description: - 'Initially returns the full set of feature flags available to the provided API key. When called again with the returned etag, only returns the flags that have changed', - operationId: 'getDelta', - tags: ['Unstable'], - responses: { - 200: createResponseSchema('clientFeaturesSchema'), - }, - }), - ], - }); - this.route({ method: 'get', path: '/:featureName', @@ -298,42 +278,6 @@ export default class FeatureController extends Controller { } } - async getDelta( - req: IAuthRequest, - res: Response, - ): Promise { - if (!this.flagResolver.isEnabled('deltaApi')) { - throw new NotFoundError(); - } - const query = await this.resolveQuery(req); - const etag = req.headers['if-none-match']; - - const currentSdkRevisionId = etag ? Number.parseInt(etag) : undefined; - - const changedFeatures = - await this.clientFeatureToggleService.getClientDelta( - currentSdkRevisionId, - query, - ); - - if (!changedFeatures) { - res.status(304); - res.getHeaderNames().forEach((header) => res.removeHeader(header)); - res.end(); - return; - } - - if (changedFeatures.revisionId === currentSdkRevisionId) { - res.status(304); - res.getHeaderNames().forEach((header) => res.removeHeader(header)); - res.end(); - return; - } - - res.setHeader('ETag', changedFeatures.revisionId.toString()); - res.send(changedFeatures); - } - async calculateMeta(query: IFeatureToggleQuery): Promise { // TODO: We will need to standardize this to be able to implement this a cross languages (Edge in Rust?). const revisionId = diff --git a/src/lib/features/client-feature-toggles/tests/client-feature-toggles.e2e.test.ts b/src/lib/features/client-feature-toggles/tests/client-feature-toggles.e2e.test.ts index 63e446239f..e8166a4f29 100644 --- a/src/lib/features/client-feature-toggles/tests/client-feature-toggles.e2e.test.ts +++ b/src/lib/features/client-feature-toggles/tests/client-feature-toggles.e2e.test.ts @@ -324,7 +324,7 @@ test('should match snapshot from /api/client/features', async () => { expect(result.body).toMatchSnapshot(); }); -test('should match with /api/client/features/delta', async () => { +test('should match with /api/client/delta', async () => { await setupFeatures(db, app); const { body } = await app.request @@ -333,7 +333,7 @@ test('should match with /api/client/features/delta', async () => { .expect(200); const { body: deltaBody } = await app.request - .get('/api/client/features/delta') + .get('/api/client/delta') .expect('Content-Type', /json/) .expect(200); diff --git a/src/lib/openapi/spec/client-features-delta-schema.ts b/src/lib/openapi/spec/client-features-delta-schema.ts new file mode 100644 index 0000000000..b7eb60444c --- /dev/null +++ b/src/lib/openapi/spec/client-features-delta-schema.ts @@ -0,0 +1,56 @@ +import type { FromSchema } from 'json-schema-to-ts'; +import { constraintSchema } from './constraint-schema'; +import { clientFeatureSchema } from './client-feature-schema'; +import { environmentSchema } from './environment-schema'; +import { clientSegmentSchema } from './client-segment-schema'; +import { overrideSchema } from './override-schema'; +import { parametersSchema } from './parameters-schema'; +import { featureStrategySchema } from './feature-strategy-schema'; +import { strategyVariantSchema } from './strategy-variant-schema'; +import { variantSchema } from './variant-schema'; +import { dependentFeatureSchema } from './dependent-feature-schema'; + +export const clientFeaturesDeltaSchema = { + $id: '#/components/schemas/clientFeaturesDeltaSchema', + type: 'object', + required: ['updated', 'revisionId', 'removed'], + description: 'Schema for delta updates of feature configurations.', + properties: { + updated: { + description: 'A list of updated feature configurations.', + type: 'array', + items: { + $ref: '#/components/schemas/clientFeatureSchema', + }, + }, + revisionId: { + type: 'number', + description: 'The revision ID of the delta update.', + }, + removed: { + description: 'A list of feature names that were removed.', + type: 'array', + items: { + type: 'string', + }, + }, + }, + components: { + schemas: { + constraintSchema, + clientFeatureSchema, + environmentSchema, + clientSegmentSchema, + overrideSchema, + parametersSchema, + featureStrategySchema, + strategyVariantSchema, + variantSchema, + dependentFeatureSchema, + }, + }, +} as const; + +export type ClientFeaturesDeltaSchema = FromSchema< + typeof clientFeaturesDeltaSchema +>; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 3aa488d5f4..db6f696b10 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -37,6 +37,7 @@ export * from './bulk-toggle-features-schema'; export * from './change-password-schema'; export * from './client-application-schema'; export * from './client-feature-schema'; +export * from './client-features-delta-schema'; export * from './client-features-query-schema'; export * from './client-features-schema'; export * from './client-metrics-env-schema'; diff --git a/src/lib/routes/client-api/index.ts b/src/lib/routes/client-api/index.ts index 4e0ca30985..60ef1d7417 100644 --- a/src/lib/routes/client-api/index.ts +++ b/src/lib/routes/client-api/index.ts @@ -3,11 +3,16 @@ import FeatureController from '../../features/client-feature-toggles/client-feat import MetricsController from '../../features/metrics/instance/metrics'; import RegisterController from '../../features/metrics/instance/register'; import type { IUnleashConfig, IUnleashServices } from '../../types'; +import ClientFeatureToggleDeltaController from '../../features/client-feature-toggles/cache/client-feature-toggle-cache-controller'; export default class ClientApi extends Controller { constructor(config: IUnleashConfig, services: IUnleashServices) { super(config); + this.use( + '/delta', + new ClientFeatureToggleDeltaController(services, config).router, + ); this.use('/features', new FeatureController(services, config).router); this.use('/metrics', new MetricsController(services, config).router); this.use('/register', new RegisterController(services, config).router);