mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-19 01:17:18 +02:00
feat: move delta controller to new path (#8981)
Feature delta is now at api//client/delta
This commit is contained in:
parent
3afcf690de
commit
eb0699ca03
183
src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache-controller.ts
vendored
Normal file
183
src/lib/features/client-feature-toggles/cache/client-feature-toggle-cache-controller.ts
vendored
Normal file
@ -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<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 getDelta(
|
||||||
|
req: IAuthRequest,
|
||||||
|
res: Response<RevisionCacheEntry>,
|
||||||
|
): Promise<void> {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -33,11 +33,10 @@ import {
|
|||||||
} from '../../openapi/spec/client-features-schema';
|
} from '../../openapi/spec/client-features-schema';
|
||||||
import type ConfigurationRevisionService from '../feature-toggle/configuration-revision-service';
|
import type ConfigurationRevisionService from '../feature-toggle/configuration-revision-service';
|
||||||
import type { ClientFeatureToggleService } from './client-feature-toggle-service';
|
import type { ClientFeatureToggleService } from './client-feature-toggle-service';
|
||||||
import type { RevisionCacheEntry } from './cache/client-feature-toggle-cache';
|
|
||||||
|
|
||||||
const version = 2;
|
const version = 2;
|
||||||
|
|
||||||
interface QueryOverride {
|
export interface QueryOverride {
|
||||||
project?: string[];
|
project?: string[];
|
||||||
environment?: string;
|
environment?: string;
|
||||||
}
|
}
|
||||||
@ -95,25 +94,6 @@ export default class FeatureController extends Controller {
|
|||||||
this.flagResolver = config.flagResolver;
|
this.flagResolver = config.flagResolver;
|
||||||
this.logger = config.getLogger('client-api/feature.js');
|
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({
|
this.route({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
path: '/:featureName',
|
path: '/:featureName',
|
||||||
@ -298,42 +278,6 @@ export default class FeatureController extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDelta(
|
|
||||||
req: IAuthRequest,
|
|
||||||
res: Response<RevisionCacheEntry>,
|
|
||||||
): Promise<void> {
|
|
||||||
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<IMeta> {
|
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?).
|
// TODO: We will need to standardize this to be able to implement this a cross languages (Edge in Rust?).
|
||||||
const revisionId =
|
const revisionId =
|
||||||
|
@ -324,7 +324,7 @@ test('should match snapshot from /api/client/features', async () => {
|
|||||||
expect(result.body).toMatchSnapshot();
|
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);
|
await setupFeatures(db, app);
|
||||||
|
|
||||||
const { body } = await app.request
|
const { body } = await app.request
|
||||||
@ -333,7 +333,7 @@ test('should match with /api/client/features/delta', async () => {
|
|||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
const { body: deltaBody } = await app.request
|
const { body: deltaBody } = await app.request
|
||||||
.get('/api/client/features/delta')
|
.get('/api/client/delta')
|
||||||
.expect('Content-Type', /json/)
|
.expect('Content-Type', /json/)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
|
56
src/lib/openapi/spec/client-features-delta-schema.ts
Normal file
56
src/lib/openapi/spec/client-features-delta-schema.ts
Normal file
@ -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
|
||||||
|
>;
|
@ -37,6 +37,7 @@ export * from './bulk-toggle-features-schema';
|
|||||||
export * from './change-password-schema';
|
export * from './change-password-schema';
|
||||||
export * from './client-application-schema';
|
export * from './client-application-schema';
|
||||||
export * from './client-feature-schema';
|
export * from './client-feature-schema';
|
||||||
|
export * from './client-features-delta-schema';
|
||||||
export * from './client-features-query-schema';
|
export * from './client-features-query-schema';
|
||||||
export * from './client-features-schema';
|
export * from './client-features-schema';
|
||||||
export * from './client-metrics-env-schema';
|
export * from './client-metrics-env-schema';
|
||||||
|
@ -3,11 +3,16 @@ import FeatureController from '../../features/client-feature-toggles/client-feat
|
|||||||
import MetricsController from '../../features/metrics/instance/metrics';
|
import MetricsController from '../../features/metrics/instance/metrics';
|
||||||
import RegisterController from '../../features/metrics/instance/register';
|
import RegisterController from '../../features/metrics/instance/register';
|
||||||
import type { IUnleashConfig, IUnleashServices } from '../../types';
|
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 {
|
export default class ClientApi extends Controller {
|
||||||
constructor(config: IUnleashConfig, services: IUnleashServices) {
|
constructor(config: IUnleashConfig, services: IUnleashServices) {
|
||||||
super(config);
|
super(config);
|
||||||
|
|
||||||
|
this.use(
|
||||||
|
'/delta',
|
||||||
|
new ClientFeatureToggleDeltaController(services, config).router,
|
||||||
|
);
|
||||||
this.use('/features', new FeatureController(services, config).router);
|
this.use('/features', new FeatureController(services, config).router);
|
||||||
this.use('/metrics', new MetricsController(services, config).router);
|
this.use('/metrics', new MetricsController(services, config).router);
|
||||||
this.use('/register', new RegisterController(services, config).router);
|
this.use('/register', new RegisterController(services, config).router);
|
||||||
|
Loading…
Reference in New Issue
Block a user