1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-11 00:08:30 +01:00
unleash.unleash/src/lib/features/client-feature-toggles/client-feature-toggle.controller.ts
Fredrik Strand Oseberg f34d187cd9
Refactor/separate client and admin store (#5006)
This PR is the first step in separating the client and admin stores.
Currently our feature toggle services uses the client store to serve
multiple purposes. 

Admin API uses the feature toggle service to serve both the feature
toggle list and playground features, while the client API uses the
feature toggle service to serve client features. The admin API can
change often and have very different requirements than the client API,
which changes infrequently and generally keeps the same stable structure
for long periods of time. This architecture is error prone, because when
you need to make changes to the admin API, you can very easily affect
the client API.

I aim to put up a stone wall between the two APIs. Complete separation
between the two APIs, at the cost of some duplication.

In this PR I have created a feature oriented architecture for client
features and disconnected the client API from the feature toggle
service. It now goes through it's own service to it's own store. For
feature toggle service I have duplicated and replaced the functionality
that serves /api/admin/features, I have kept a lot of the ugliness in
the code and haven't removed anything in order to avoid breaking
changes.

Next steps: 
* Move playground to admin API
* Remove client-feature-toggle-store from feature-toggle-service
2023-10-12 13:58:23 +02:00

335 lines
11 KiB
TypeScript

import memoizee from 'memoizee';
import { Response } from 'express';
// eslint-disable-next-line import/no-extraneous-dependencies
import hashSum from 'hash-sum';
import Controller from '../../routes/controller';
import {
IClientSegment,
IFeatureToggleStore,
IFlagResolver,
IUnleashConfig,
IUnleashServices,
} from '../../types';
import FeatureToggleService from '../feature-toggle/feature-toggle-service';
import { Logger } from '../../logger';
import { querySchema } from '../../schema/feature-schema';
import { IFeatureToggleQuery } from '../../types/model';
import NotFoundError from '../../error/notfound-error';
import { IAuthRequest } from '../../routes/unleash-types';
import ApiUser from '../../types/api-user';
import { ALL, isAllProjects } from '../../types/models/api-token';
import { FeatureConfigurationClient } from '../feature-toggle/types/feature-toggle-strategies-store-type';
import { ClientSpecService } from '../../services/client-spec-service';
import { OpenApiService } from '../../services/openapi-service';
import { NONE } from '../../types/permissions';
import { createResponseSchema } from '../../openapi/util/create-response-schema';
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';
import { ISegmentService } from '../../segments/segment-service-interface';
import ConfigurationRevisionService from '../feature-toggle/configuration-revision-service';
import { ClientFeatureToggleService } from './client-feature-toggle-service';
const version = 2;
interface QueryOverride {
project?: string[];
environment?: string;
}
interface IMeta {
revisionId: number;
etag: string;
queryHash: string;
}
export default class FeatureController extends Controller {
private readonly logger: Logger;
private clientFeatureToggleService: ClientFeatureToggleService;
private segmentService: ISegmentService;
private clientSpecService: ClientSpecService;
private openApiService: OpenApiService;
private configurationRevisionService: ConfigurationRevisionService;
private featureToggleService: FeatureToggleService;
private flagResolver: IFlagResolver;
private featuresAndSegments: (
query: IFeatureToggleQuery,
etag: string,
) => Promise<[FeatureConfigurationClient[], IClientSegment[]]>;
constructor(
{
clientFeatureToggleService,
segmentService,
clientSpecService,
openApiService,
configurationRevisionService,
featureToggleService,
}: Pick<
IUnleashServices,
| 'clientFeatureToggleService'
| 'segmentService'
| 'clientSpecService'
| 'openApiService'
| 'configurationRevisionService'
| 'featureToggleService'
>,
config: IUnleashConfig,
) {
super(config);
const { clientFeatureCaching } = config;
this.clientFeatureToggleService = clientFeatureToggleService;
this.segmentService = segmentService;
this.clientSpecService = clientSpecService;
this.openApiService = openApiService;
this.configurationRevisionService = configurationRevisionService;
this.featureToggleService = featureToggleService;
this.flagResolver = config.flagResolver;
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 toggle',
description:
'Gets all the client data for a single toggle. Contains the exact same information about a toggle as the `/api/client/features` endpoint does, but only contains data about the specified toggle. 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 toggles (SDK)',
description:
'Returns the SDK configuration for all feature toggles 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(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(query: IFeatureToggleQuery, etag: string) =>
this.resolveFeaturesAndSegments(query),
{
promise: true,
maxAge: clientFeatureCaching.maxAge,
normalizer(args) {
// args is arguments object as accessible in memoized function
return args[1];
},
},
);
} else {
this.featuresAndSegments = this.resolveFeaturesAndSegments;
}
}
private async resolveFeaturesAndSegments(
query?: IFeatureToggleQuery,
): Promise<[FeatureConfigurationClient[], IClientSegment[]]> {
if (this.flagResolver.isEnabled('separateAdminClientApi')) {
return Promise.all([
this.clientFeatureToggleService.getClientFeatures(query),
this.segmentService.getActiveForClient(),
]);
}
return Promise.all([
this.featureToggleService.getClientFeatures(query),
this.segmentService.getActiveForClient(),
]);
}
private async resolveQuery(
req: IAuthRequest,
): Promise<IFeatureToggleQuery> {
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<IFeatureToggleQuery> {
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 getAll(
req: IAuthRequest,
res: Response<ClientFeaturesSchema>,
): Promise<void> {
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.end();
return;
} else {
this.logger.debug(
`Provided revision: ${userVersion}, calculated revision: ${etag}`,
);
}
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: IFeatureToggleQuery): Promise<IMeta> {
// TODO: We will need to standardize this to be able to implement this a cross languages (Edge in Rust?).
const revisionId =
await this.configurationRevisionService.getMaxRevisionId();
// TODO: We will need to standardize this to be able to implement this a cross languages (Edge in Rust?).
const queryHash = hashSum(query);
const etag = `"${queryHash}:${revisionId}"`;
return { revisionId, etag, queryHash };
}
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 };
let toggles = await this.featureToggleService.getClientFeatures(q);
if (this.flagResolver.isEnabled('separateAdminClientApi')) {
toggles = await this.clientFeatureToggleService.getClientFeatures(
q,
);
}
const toggle = toggles.find((t) => t.name === name);
if (!toggle) {
throw new NotFoundError(`Could not find feature toggle ${name}`);
}
this.openApiService.respondWithValidation(
200,
res,
clientFeatureSchema.$id,
{
...toggle,
},
);
}
}