UNPKG

unleash-server

Version:

Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.

227 lines • 10 kB
import memoizee from 'memoizee'; // eslint-disable-next-line import/no-extraneous-dependencies import hashSum from 'hash-sum'; import Controller from '../../routes/controller.js'; import { querySchema } from '../../schema/feature-schema.js'; import NotFoundError from '../../error/notfound-error.js'; import ApiUser from '../../types/api-user.js'; import { ALL, isAllProjects } from '../../types/models/api-token.js'; import { NONE } from '../../types/permissions.js'; import { createResponseSchema } from '../../openapi/util/create-response-schema.js'; import { clientFeatureSchema, } from '../../openapi/spec/client-feature-schema.js'; import { clientFeaturesSchema, } from '../../openapi/spec/client-features-schema.js'; import { CLIENT_FEATURES_MEMORY, CLIENT_METRICS_NAMEPREFIX, CLIENT_METRICS_TAGS, } from '../../internals.js'; import isEqual from 'lodash.isequal'; import { diff } from 'json-diff'; const version = 2; export default class FeatureController extends Controller { constructor({ clientFeatureToggleService, clientSpecService, openApiService, configurationRevisionService, featureToggleService, }, config) { super(config); this.clientFeaturesCacheMap = new Map(); this.deepEqualIgnoreOrder = (obj1, obj2) => { const sortedObj1 = JSON.parse(JSON.stringify(obj1, Object.keys(obj1).sort())); const sortedObj2 = JSON.parse(JSON.stringify(obj2, Object.keys(obj2).sort())); return isEqual(sortedObj1, sortedObj2); }; const { clientFeatureCaching } = config; this.clientFeatureToggleService = clientFeatureToggleService; this.clientSpecService = clientSpecService; this.openApiService = openApiService; this.configurationRevisionService = configurationRevisionService; this.featureToggleService = featureToggleService; this.flagResolver = config.flagResolver; this.eventBus = config.eventBus; this.logger = config.getLogger('client-api/feature.js'); this.route({ method: 'get', path: '/:featureName', handler: this.getFeatureToggle, permission: NONE, middleware: [ openApiService.validPath({ operationId: 'getClientFeature', summary: 'Get a single feature flag', description: 'Gets all the client data for a single flag. Contains the exact same information about a flag as the `/api/client/features` endpoint does, but only contains data about the specified flag. All SDKs should use `/api/client/features`', tags: ['Client'], responses: { 200: createResponseSchema('clientFeatureSchema'), }, }), ], }); this.route({ method: 'get', path: '', handler: this.getAll, permission: NONE, middleware: [ openApiService.validPath({ summary: 'Get all flags (SDK)', description: 'Returns the SDK configuration for all feature flags that are available to the provided API key. Used by SDKs to configure local evaluation', operationId: 'getAllClientFeatures', tags: ['Client'], responses: { 200: createResponseSchema('clientFeaturesSchema'), }, }), ], }); if (clientFeatureCaching.enabled) { this.featuresAndSegments = memoizee((query, _etag) => this.resolveFeaturesAndSegments(query), { promise: true, maxAge: clientFeatureCaching.maxAge, normalizer([_query, etag]) { return etag; }, }); } else { this.featuresAndSegments = this.resolveFeaturesAndSegments; } } async resolveFeaturesAndSegments(query) { if (this.flagResolver.isEnabled('deltaDiff')) { const features = await this.clientFeatureToggleService.getClientFeatures(query); const segments = await this.clientFeatureToggleService.getActiveSegmentsForClient(); try { const featuresSize = this.getCacheSizeInBytes(features); const segmentsSize = this.getCacheSizeInBytes(segments); this.clientFeaturesCacheMap.set(JSON.stringify(query), featuresSize + segmentsSize); const delta = await this.clientFeatureToggleService.getClientDelta(undefined, query); const sortedToggles = features.sort((a, b) => a.name.localeCompare(b.name)); if (delta?.events[0].type === 'hydration') { const hydrationEvent = delta?.events[0]; const sortedNewToggles = hydrationEvent.features.sort((a, b) => a.name.localeCompare(b.name)); if (!this.deepEqualIgnoreOrder(sortedToggles, sortedNewToggles)) { this.logger.warn(`old features and new features are different. Old count ${features.length}, new count ${hydrationEvent.features.length}, query ${JSON.stringify(query)}, diff ${JSON.stringify(diff(sortedToggles, sortedNewToggles))}`); } } else { this.logger.warn(`Delta diff should have only hydration event, query ${JSON.stringify(query)}`); } this.storeFootprint(); } catch (e) { this.logger.error('Delta diff failed', e); } return [features, segments]; } return Promise.all([ this.clientFeatureToggleService.getClientFeatures(query), this.clientFeatureToggleService.getActiveSegmentsForClient(), ]); } async resolveQuery(req) { const { user, query } = req; const override = {}; 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 paramToArray(param) { if (!param) { return param; } return Array.isArray(param) ? param : [param]; } async prepQuery({ tag, project, namePrefix, environment, inlineSegmentConstraints, }) { if (!tag && !project && !namePrefix && !environment && !inlineSegmentConstraints) { return {}; } if (namePrefix) { this.eventBus.emit(CLIENT_METRICS_NAMEPREFIX); } if (tag) { this.eventBus.emit(CLIENT_METRICS_TAGS); } 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 getAll(req, res) { const query = await this.resolveQuery(req); const userVersion = req.headers['if-none-match']; const meta = await this.calculateMeta(query); const { etag } = meta; res.setHeader('ETag', etag); if (etag === userVersion) { res.status(304); res.getHeaderNames().forEach((header) => { res.removeHeader(header); }); res.end(); return; } const [features, segments] = await this.featuresAndSegments(query, etag); if (this.clientSpecService.requestSupportsSpec(req, 'segments')) { this.openApiService.respondWithValidation(200, res, clientFeaturesSchema.$id, { version, features, query: { ...query }, segments, meta, }); } else { this.openApiService.respondWithValidation(200, res, clientFeaturesSchema.$id, { version, features, query, meta }); } } async calculateMeta(query) { const revisionId = await this.configurationRevisionService.getMaxRevisionId(query.environment); const queryHash = hashSum(query); const etag = `"${queryHash}:${revisionId}:v1"`; return { revisionId, etag, queryHash }; } async getFeatureToggle(req, res) { const name = req.params.featureName; const featureQuery = await this.resolveQuery(req); const q = { ...featureQuery, namePrefix: name }; const toggles = await this.clientFeatureToggleService.getClientFeatures(q); const toggle = toggles.find((t) => t.name === name); if (!toggle) { throw new NotFoundError(`Could not find feature flag ${name}`); } this.openApiService.respondWithValidation(200, res, clientFeatureSchema.$id, { ...toggle, }); } storeFootprint() { let memory = 0; for (const value of this.clientFeaturesCacheMap.values()) { memory += value; } this.eventBus.emit(CLIENT_FEATURES_MEMORY, { memory }); } getCacheSizeInBytes(value) { const jsonString = JSON.stringify(value); return Buffer.byteLength(jsonString, 'utf8'); } } //# sourceMappingURL=client-feature-toggle.controller.js.map