1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-05 17:53:12 +02:00

feat: embed proxy endpoints

This commit is contained in:
olav 2022-08-15 16:04:13 +02:00
parent 934b0cdf88
commit 468b923bda
23 changed files with 1625 additions and 31 deletions

View File

@ -124,6 +124,7 @@
"stoppable": "^1.1.0",
"ts-toolbelt": "^9.6.0",
"type-is": "^1.6.18",
"unleash-client": "3.15.0",
"unleash-frontend": "4.15.0-beta.0",
"uuid": "^8.3.2"
},
@ -173,8 +174,7 @@
"ts-jest": "27.1.5",
"ts-node": "10.9.1",
"tsc-watch": "5.0.3",
"typescript": "4.7.4",
"unleash-client": "3.15.0"
"typescript": "4.7.4"
},
"resolutions": {
"async": "^3.2.3",

View File

@ -106,6 +106,10 @@ import { groupUserModelSchema } from './spec/group-user-model-schema';
import { usersGroupsBaseSchema } from './spec/users-groups-base-schema';
import { openApiTags } from './util/openapi-tags';
import { searchEventsSchema } from './spec/search-events-schema';
import { proxyFeaturesSchema } from './spec/proxy-features-schema';
import { proxyFeatureSchema } from './spec/proxy-feature-schema';
import { proxyClientSchema } from './spec/proxy-client-schema';
import { proxyMetricsSchema } from './spec/proxy-metrics-schema';
// All schemas in `openapi/spec` should be listed here.
export const schemas = {
@ -211,6 +215,10 @@ export const schemas = {
variantSchema,
variantsSchema,
versionSchema,
proxyClientSchema,
proxyFeaturesSchema,
proxyFeatureSchema,
proxyMetricsSchema,
};
// Schemas must have an $id property on the form "#/components/schemas/mySchema".

View File

@ -0,0 +1,50 @@
import { FromSchema } from 'json-schema-to-ts';
export const proxyClientSchema = {
$id: '#/components/schemas/proxyClientSchema',
type: 'object',
required: ['appName', 'interval', 'started', 'strategies'],
properties: {
appName: {
type: 'string',
description: 'Name of the application using Unleash',
},
instanceId: {
type: 'string',
description:
'Instance id for this application (typically hostname, podId or similar)',
},
sdkVersion: {
type: 'string',
description:
'Optional field that describes the sdk version (name:version)',
},
environment: {
type: 'string',
deprecated: true,
},
interval: {
type: 'number',
description:
'At which interval, in milliseconds, will this client be expected to send metrics',
},
started: {
oneOf: [
{ type: 'string', format: 'date-time' },
{ type: 'number' },
],
description:
'When this client started. Should be reported as ISO8601 time.',
},
strategies: {
type: 'array',
items: {
type: 'string',
},
description: 'List of strategies implemented by this application',
},
},
components: {},
} as const;
export type ProxyClientSchema = FromSchema<typeof proxyClientSchema>;

View File

@ -0,0 +1,44 @@
import { FromSchema } from 'json-schema-to-ts';
export const proxyFeatureSchema = {
$id: '#/components/schemas/proxyFeatureSchema',
type: 'object',
required: ['name', 'enabled', 'impressionData'],
additionalProperties: false,
properties: {
name: {
type: 'string',
},
enabled: {
type: 'boolean',
},
impressionData: {
type: 'boolean',
},
variant: {
type: 'object',
required: ['name', 'enabled'],
additionalProperties: false,
properties: {
name: {
type: 'string',
},
enabled: {
type: 'boolean',
},
payload: {
type: 'object',
additionalProperties: false,
required: ['type', 'value'],
properties: {
type: { type: 'string', enum: ['string'] },
value: { type: 'string' },
},
},
},
},
},
components: {},
} as const;
export type ProxyFeatureSchema = FromSchema<typeof proxyFeatureSchema>;

View File

@ -0,0 +1,24 @@
import { FromSchema } from 'json-schema-to-ts';
import { proxyFeatureSchema } from './proxy-feature-schema';
export const proxyFeaturesSchema = {
$id: '#/components/schemas/proxyFeaturesSchema',
type: 'object',
required: ['toggles'],
additionalProperties: false,
properties: {
toggles: {
type: 'array',
items: {
$ref: proxyFeatureSchema.$id,
},
},
},
components: {
schemas: {
proxyFeatureSchema,
},
},
} as const;
export type ProxyFeaturesSchema = FromSchema<typeof proxyFeaturesSchema>;

View File

@ -0,0 +1,55 @@
import { FromSchema } from 'json-schema-to-ts';
export const proxyMetricsSchema = {
$id: '#/components/schemas/proxyMetricsSchema',
type: 'object',
required: ['appName', 'instanceId', 'bucket'],
properties: {
appName: { type: 'string' },
instanceId: { type: 'string' },
environment: { type: 'string' },
bucket: {
type: 'object',
required: ['start', 'stop', 'toggles'],
properties: {
start: { type: 'string', format: 'date-time' },
stop: { type: 'string', format: 'date-time' },
toggles: {
type: 'object',
example: {
myCoolToggle: {
yes: 25,
no: 42,
variants: {
blue: 6,
green: 15,
red: 46,
},
},
myOtherToggle: {
yes: 0,
no: 100,
},
},
additionalProperties: {
type: 'object',
properties: {
yes: { type: 'integer', minimum: 0 },
no: { type: 'integer', minimum: 0 },
variants: {
type: 'object',
additionalProperties: {
type: 'integer',
minimum: 0,
},
},
},
},
},
},
},
},
components: {},
} as const;
export type ProxyMetricsSchema = FromSchema<typeof proxyMetricsSchema>;

View File

@ -72,6 +72,11 @@ const OPENAPI_TAGS = [
'Create, update, and delete [tags and tag types](https://docs.getunleash.io/advanced/tags).',
},
{ name: 'Users', description: 'Manage users and passwords.' },
{
name: 'Unstable',
description:
'Experimental endpoints that may change or disappear at any time.',
},
] as const;
// make the export mutable, so it can be used in a schema

View File

@ -0,0 +1,78 @@
// Copy of https://github.com/Unleash/unleash-proxy/blob/main/src/test/create-context.test.ts.
import { createContext } from './create-context';
test('should remove undefined properties', () => {
const context = createContext({
appName: undefined,
userId: '123',
});
expect(context).not.toHaveProperty('appName');
expect(context).toHaveProperty('userId');
expect(context.userId).toBe('123');
});
test('should move rest props to properties', () => {
const context = createContext({
userId: '123',
tenantId: 'some-tenant',
region: 'eu',
});
expect(context.userId).toBe('123');
expect(context).not.toHaveProperty('tenantId');
expect(context).not.toHaveProperty('region');
expect(context.properties?.region).toBe('eu');
expect(context.properties?.tenantId).toBe('some-tenant');
});
test('should keep properties', () => {
const context = createContext({
userId: '123',
tenantId: 'some-tenant',
region: 'eu',
properties: {
a: 'b',
b: 'test',
},
});
expect(context.userId).toBe('123');
expect(context).not.toHaveProperty('tenantId');
expect(context).not.toHaveProperty('region');
expect(context.properties?.region).toBe('eu');
expect(context.properties?.tenantId).toBe('some-tenant');
expect(context.properties?.a).toBe('b');
expect(context.properties?.b).toBe('test');
});
test('will not blow up if properties is an array', () => {
const context = createContext({
userId: '123',
tenantId: 'some-tenant',
region: 'eu',
properties: ['some'],
});
// console.log(context);
expect(context.userId).toBe('123');
expect(context).not.toHaveProperty('tenantId');
expect(context).not.toHaveProperty('region');
});
test.skip('will not blow up if userId is an array', () => {
const context = createContext({
userId: ['123'],
tenantId: 'some-tenant',
region: 'eu',
properties: ['some'],
});
// console.log(context);
expect(context.userId).toBe('123');
expect(context).not.toHaveProperty('tenantId');
expect(context).not.toHaveProperty('region');
});

View File

@ -0,0 +1,34 @@
// Copy of https://github.com/Unleash/unleash-proxy/blob/main/src/create-context.ts.
/* eslint-disable prefer-object-spread */
import { Context } from 'unleash-client';
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export function createContext(value: any): Context {
const {
appName,
environment,
userId,
sessionId,
remoteAddress,
properties,
...rest
} = value;
// move non root context fields to properties
const context: Context = {
appName,
environment,
userId,
sessionId,
remoteAddress,
properties: Object.assign({}, rest, properties),
};
// Clean undefined properties on the context
const cleanContext = Object.keys(context)
.filter((k) => context[k])
.reduce((a, k) => ({ ...a, [k]: context[k] }), {});
return cleanContext;
}

View File

@ -0,0 +1,121 @@
import EventEmitter from 'events';
import { RepositoryInterface } from 'unleash-client/lib/repository';
import { Segment } from 'unleash-client/lib/strategy/strategy';
import { FeatureInterface } from 'unleash-client/lib/feature';
import ApiUser from '../types/api-user';
import { IUnleashConfig, IUnleashServices, IUnleashStores } from '../types';
import {
mapFeaturesForClient,
mapSegmentsForClient,
} from '../util/offline-unleash-client';
import { ALL_PROJECTS } from '../util/constants';
import { UnleashEvents } from 'unleash-client';
import { ANY_EVENT } from '../util/anyEventEmitter';
import { Logger } from '../logger';
type Config = Pick<IUnleashConfig, 'getLogger'>;
type Stores = Pick<IUnleashStores, 'projectStore' | 'eventStore'>;
type Services = Pick<
IUnleashServices,
'featureToggleServiceV2' | 'segmentService'
>;
export class ProxyRepository
extends EventEmitter
implements RepositoryInterface
{
private readonly config: Config;
private readonly logger: Logger;
private readonly stores: Stores;
private readonly services: Services;
private readonly token: ApiUser;
private features: FeatureInterface[];
private segments: Segment[];
constructor(
config: Config,
stores: Stores,
services: Services,
token: ApiUser,
) {
super();
this.config = config;
this.logger = config.getLogger('proxy-repository.ts');
this.stores = stores;
this.services = services;
this.token = token;
this.onAnyEvent = this.onAnyEvent.bind(this);
}
getSegment(id: number): Segment | undefined {
return this.segments.find((segment) => segment.id === id);
}
getToggle(name: string): FeatureInterface {
return this.features.find((feature) => feature.name === name);
}
getToggles(): FeatureInterface[] {
return this.features;
}
async start(): Promise<void> {
await this.loadDataForToken();
// Reload cached token data whenever something relevant has changed.
// For now, simply reload all the data on any EventStore event.
this.stores.eventStore.on(ANY_EVENT, this.onAnyEvent);
this.emit(UnleashEvents.Ready);
this.emit(UnleashEvents.Changed);
}
stop(): void {
this.stores.eventStore.off(ANY_EVENT, this.onAnyEvent);
}
private async loadDataForToken() {
this.features = await this.featuresForToken();
this.segments = await this.segmentsForToken();
}
private async onAnyEvent() {
try {
await this.loadDataForToken();
} catch (error) {
this.logger.error(error);
}
}
private async featuresForToken(): Promise<FeatureInterface[]> {
return mapFeaturesForClient(
await this.services.featureToggleServiceV2.getClientFeatures({
project: await this.projectNamesForToken(),
environment: this.token.environment,
}),
);
}
private async segmentsForToken(): Promise<Segment[]> {
return mapSegmentsForClient(
await this.services.segmentService.getAll(),
);
}
private async projectNamesForToken(): Promise<string[]> {
if (this.token.projects.includes(ALL_PROJECTS)) {
const allProjects = await this.stores.projectStore.getAll();
return allProjects.map((project) => project.name);
}
return this.token.projects;
}
}

View File

@ -4,11 +4,7 @@ import { IUnleashConfig, IUnleashServices } from '../../types';
import ClientInstanceService from '../../services/client-metrics/instance-service';
import { Logger } from '../../logger';
import { IAuthRequest } from '../unleash-types';
import ApiUser from '../../types/api-user';
import { ALL } from '../../types/models/api-token';
import ClientMetricsServiceV2 from '../../services/client-metrics/metrics-service-v2';
import { User } from '../../server-impl';
import { IClientApp } from '../../types/model';
import { NONE } from '../../types/permissions';
import { OpenApiService } from '../../services/openapi-service';
import { createRequestSchema } from '../../openapi/util/create-request-schema';
@ -66,20 +62,9 @@ export default class ClientMetricsController extends Controller {
});
}
private resolveEnvironment(user: User, data: IClientApp) {
if (user instanceof ApiUser) {
if (user.environment !== ALL) {
return user.environment;
} else if (user.environment === ALL && data.environment) {
return data.environment;
}
}
return 'default';
}
async registerMetrics(req: IAuthRequest, res: Response): Promise<void> {
const { body: data, ip: clientIp, user } = req;
data.environment = this.resolveEnvironment(user, data);
data.environment = this.metricsV2.resolveMetricsEnvironment(user, data);
await this.clientInstanceService.registerInstance(data, clientIp);
try {

View File

@ -9,6 +9,7 @@ const AdminApi = require('./admin-api');
const ClientApi = require('./client-api');
const Controller = require('./controller');
import { HealthCheckController } from './health-check';
import ProxyController from './proxy-api';
class IndexRouter extends Controller {
constructor(config: IUnleashConfig, services: IUnleashServices) {
@ -26,6 +27,7 @@ class IndexRouter extends Controller {
);
this.use('/api/admin', new AdminApi(config, services).router);
this.use('/api/client', new ClientApi(config, services).router);
this.use('/api/frontend', new ProxyController(config, services).router);
}
}

View File

@ -0,0 +1,177 @@
import { Response, Request } from 'express';
import Controller from '../controller';
import { IUnleashConfig, IUnleashServices } from '../../types';
import { Logger } from '../../logger';
import { OpenApiService } from '../../services/openapi-service';
import { NONE } from '../../types/permissions';
import { ProxyService } from '../../services/proxy-service';
import ApiUser from '../../types/api-user';
import {
proxyFeaturesSchema,
ProxyFeaturesSchema,
} from '../../openapi/spec/proxy-features-schema';
import { Context } from 'unleash-client';
import { createContext } from '../../proxy/create-context';
import { ProxyMetricsSchema } from '../../openapi/spec/proxy-metrics-schema';
import { ProxyClientSchema } from '../../openapi/spec/proxy-client-schema';
import { createResponseSchema } from '../../openapi/util/create-response-schema';
import { createRequestSchema } from '../../openapi/util/create-request-schema';
import { emptyResponse } from '../../openapi/util/standard-responses';
interface ApiUserRequest<
PARAM = any,
ResBody = any,
ReqBody = any,
ReqQuery = any,
> extends Request<PARAM, ResBody, ReqBody, ReqQuery> {
user: ApiUser;
}
export default class ProxyController extends Controller {
private readonly logger: Logger;
private proxyService: ProxyService;
private openApiService: OpenApiService;
// TODO(olav): Add CORS config to all proxy endpoints.
constructor(
config: IUnleashConfig,
{
proxyService,
openApiService,
}: Pick<IUnleashServices, 'proxyService' | 'openApiService'>,
) {
super(config);
this.logger = config.getLogger('client-api/feature.js');
this.proxyService = proxyService;
this.openApiService = openApiService;
this.route({
method: 'get',
path: '',
handler: this.getProxyFeatures,
permission: NONE,
middleware: [
this.openApiService.validPath({
tags: ['Unstable'],
operationId: 'getFrontendFeatures',
responses: {
200: createResponseSchema('proxyFeaturesSchema'),
},
}),
],
});
this.route({
method: 'post',
path: '',
handler: ProxyController.endpointNotImplemented,
permission: NONE,
});
this.route({
method: 'get',
path: '/client/features',
handler: ProxyController.endpointNotImplemented,
permission: NONE,
});
this.route({
method: 'post',
path: '/client/metrics',
handler: this.registerProxyMetrics,
permission: NONE,
middleware: [
this.openApiService.validPath({
tags: ['Unstable'],
operationId: 'registerFrontendMetrics',
requestBody: createRequestSchema('proxyMetricsSchema'),
responses: { 200: emptyResponse },
}),
],
});
this.route({
method: 'post',
path: '/client/register',
handler: ProxyController.registerProxyClient,
permission: NONE,
middleware: [
this.openApiService.validPath({
tags: ['Unstable'],
operationId: 'registerFrontendClient',
requestBody: createRequestSchema('proxyClientSchema'),
responses: { 200: emptyResponse },
}),
],
});
this.route({
method: 'get',
path: '/health',
handler: ProxyController.endpointNotImplemented,
permission: NONE,
});
this.route({
method: 'get',
path: '/internal-backstage/prometheus',
handler: ProxyController.endpointNotImplemented,
permission: NONE,
});
}
private static async endpointNotImplemented(
req: ApiUserRequest,
res: Response,
) {
res.status(405).json({
message: 'The frontend API does not support this endpoint.',
});
}
private async getProxyFeatures(
req: ApiUserRequest,
res: Response<ProxyFeaturesSchema>,
) {
const toggles = await this.proxyService.getProxyFeatures(
req.user,
ProxyController.createContext(req),
);
this.openApiService.respondWithValidation(
200,
res,
proxyFeaturesSchema.$id,
{ toggles },
);
}
private async registerProxyMetrics(
req: ApiUserRequest<unknown, unknown, ProxyMetricsSchema>,
res: Response,
) {
await this.proxyService.registerProxyMetrics(
req.user,
req.body,
req.ip,
);
res.sendStatus(200);
}
private static async registerProxyClient(
req: ApiUserRequest<unknown, unknown, ProxyClientSchema>,
res: Response<string>,
) {
// Client registration is not yet supported by @unleash/proxy,
// but proxy clients may still expect a 200 from this endpoint.
res.sendStatus(200);
}
private static createContext(req: ApiUserRequest): Context {
const { query } = req;
query.remoteAddress = query.remoteAddress || req.ip;
query.environment = req.user.environment;
return createContext(query);
}
}

View File

@ -12,6 +12,9 @@ import { hoursToMilliseconds, minutesToMilliseconds } from 'date-fns';
import { IFeatureToggleStore } from '../../types/stores/feature-toggle-store';
import EventEmitter from 'events';
import { CLIENT_METRICS } from '../../types/events';
import ApiUser from '../../types/api-user';
import { ALL } from '../../types/models/api-token';
import User from '../../types/user';
export default class ClientMetricsServiceV2 {
private timer: NodeJS.Timeout;
@ -122,6 +125,17 @@ export default class ClientMetricsServiceV2 {
);
}
resolveMetricsEnvironment(user: User | ApiUser, data: IClientApp): string {
if (user instanceof ApiUser) {
if (user.environment !== ALL) {
return user.environment;
} else if (user.environment === ALL && data.environment) {
return data.environment;
}
}
return 'default';
}
destroy(): void {
clearInterval(this.timer);
this.timer = null;

View File

@ -33,6 +33,7 @@ import { OpenApiService } from './openapi-service';
import { ClientSpecService } from './client-spec-service';
import { PlaygroundService } from './playground-service';
import { GroupService } from './group-service';
import { ProxyService } from './proxy-service';
export const createServices = (
stores: IUnleashStores,
config: IUnleashConfig,
@ -91,6 +92,11 @@ export const createServices = (
featureToggleServiceV2,
segmentService,
});
const proxyService = new ProxyService(config, stores, {
featureToggleServiceV2,
clientMetricsServiceV2,
segmentService,
});
return {
accessService,
@ -125,6 +131,7 @@ export const createServices = (
clientSpecService,
playgroundService,
groupService,
proxyService,
};
};

View File

@ -1,4 +1,4 @@
import User from '../types/user';
import User, { IUser } from '../types/user';
import { AccessService } from './access-service';
import NameExistsError from '../error/name-exists-error';
import InvalidOperationError from '../error/invalid-operation-error';
@ -48,7 +48,7 @@ import { arraysHaveSameItems } from '../util/arraysHaveSameItems';
import { GroupService } from './group-service';
import { IGroupModelWithProjectRole, IGroupRole } from 'lib/types/group';
const getCreatedBy = (user: User) => user.email || user.username;
const getCreatedBy = (user: IUser) => user.email || user.username;
export interface AccessWithRoles {
users: IUserWithRole[];
@ -130,8 +130,8 @@ export default class ProjectService {
}
async createProject(
newProject: Pick<IProject, 'id'>,
user: User,
newProject: Pick<IProject, 'id' | 'name'>,
user: IUser,
): Promise<IProject> {
const data = await projectSchema.validateAsync(newProject);
await this.validateUniqueId(data.id);

View File

@ -0,0 +1,120 @@
import { IUnleashConfig } from '../types/option';
import { Logger } from '../logger';
import { IUnleashServices, IUnleashStores } from '../types';
import { ProxyFeatureSchema } from '../openapi/spec/proxy-feature-schema';
import ApiUser from '../types/api-user';
import {
Context,
InMemStorageProvider,
startUnleash,
Unleash,
UnleashEvents,
} from 'unleash-client';
import { ProxyRepository } from '../proxy/proxy-repository';
import assert from 'assert';
import { ApiTokenType } from '../types/models/api-token';
import { ProxyMetricsSchema } from '../openapi/spec/proxy-metrics-schema';
type Config = Pick<IUnleashConfig, 'getLogger'>;
type Stores = Pick<IUnleashStores, 'projectStore' | 'eventStore'>;
type Services = Pick<
IUnleashServices,
'featureToggleServiceV2' | 'segmentService' | 'clientMetricsServiceV2'
>;
export class ProxyService {
private readonly config: Config;
private readonly logger: Logger;
private readonly stores: Stores;
private readonly services: Services;
private readonly clients: Map<ApiUser['secret'], Unleash> = new Map();
constructor(config: Config, stores: Stores, services: Services) {
this.config = config;
this.logger = config.getLogger('services/proxy-service.ts');
this.stores = stores;
this.services = services;
}
async getProxyFeatures(
token: ApiUser,
context: Context,
): Promise<ProxyFeatureSchema[]> {
const client = await this.clientForProxyToken(token);
const definitions = client.getFeatureToggleDefinitions() || [];
return definitions
.filter((feature) => client.isEnabled(feature.name, context))
.map((feature) => ({
name: feature.name,
enabled: Boolean(feature.enabled),
variant: client.forceGetVariant(feature.name, context),
impressionData: Boolean(feature.impressionData),
}));
}
async registerProxyMetrics(
token: ApiUser,
metrics: ProxyMetricsSchema,
ip: string,
): Promise<void> {
ProxyService.assertExpectedTokenType(token);
const environment =
this.services.clientMetricsServiceV2.resolveMetricsEnvironment(
token,
metrics,
);
await this.services.clientMetricsServiceV2.registerClientMetrics(
{ ...metrics, environment },
ip,
);
}
private async clientForProxyToken(token: ApiUser): Promise<Unleash> {
ProxyService.assertExpectedTokenType(token);
if (!this.clients.has(token.secret)) {
this.clients.set(
token.secret,
await this.createClientForProxyToken(token),
);
}
return this.clients.get(token.secret);
}
private async createClientForProxyToken(token: ApiUser): Promise<Unleash> {
const repository = new ProxyRepository(
this.config,
this.stores,
this.services,
token,
);
const client = await startUnleash({
appName: 'proxy',
url: 'unused',
storageProvider: new InMemStorageProvider(),
disableMetrics: true,
repository,
});
client.on(UnleashEvents.Error, (error) => {
this.logger.error(error);
});
return client;
}
private static assertExpectedTokenType({ type }: ApiUser) {
assert(type === ApiTokenType.PROXY || type === ApiTokenType.ADMIN);
}
}

View File

@ -29,6 +29,7 @@ import { OpenApiService } from '../services/openapi-service';
import { ClientSpecService } from '../services/client-spec-service';
import { PlaygroundService } from 'lib/services/playground-service';
import { GroupService } from '../services/group-service';
import { ProxyService } from '../services/proxy-service';
export interface IUnleashServices {
accessService: AccessService;
@ -63,4 +64,5 @@ export interface IUnleashServices {
openApiService: OpenApiService;
clientSpecService: ClientSpecService;
playgroundService: PlaygroundService;
proxyService: ProxyService;
}

View File

@ -1,7 +1,7 @@
import {
ClientInitOptions,
mapFeaturesForBootstrap,
mapSegmentsForBootstrap,
mapFeaturesForClient,
mapSegmentsForClient,
offlineUnleashClient,
} from './offline-unleash-client';
import {
@ -25,8 +25,8 @@ export const offlineUnleashClientNode = async ({
url: 'not-needed',
storageProvider: new InMemStorageProviderNode(),
bootstrap: {
data: mapFeaturesForBootstrap(features),
segments: mapSegmentsForBootstrap(segments),
data: mapFeaturesForClient(features),
segments: mapSegmentsForClient(segments),
},
});

View File

@ -13,7 +13,7 @@ enum PayloadType {
type NonEmptyList<T> = [T, ...T[]];
export const mapFeaturesForBootstrap = (
export const mapFeaturesForClient = (
features: FeatureConfigurationClient[],
): FeatureInterface[] =>
features.map((feature) => ({
@ -41,7 +41,7 @@ export const mapFeaturesForBootstrap = (
})),
}));
export const mapSegmentsForBootstrap = (segments: ISegment[]): Segment[] =>
export const mapSegmentsForClient = (segments: ISegment[]): Segment[] =>
serializeDates(segments) as Segment[];
export type ClientInitOptions = {
@ -61,8 +61,8 @@ export const offlineUnleashClient = async ({
appName: context.appName,
storageProvider: new InMemStorageProvider(),
bootstrap: {
data: mapFeaturesForBootstrap(features),
segments: mapSegmentsForBootstrap(segments),
data: mapFeaturesForClient(features),
segments: mapSegmentsForClient(segments),
},
});

View File

@ -2129,6 +2129,201 @@ Object {
],
"type": "object",
},
"proxyClientSchema": Object {
"properties": Object {
"appName": Object {
"description": "Name of the application using Unleash",
"type": "string",
},
"environment": Object {
"deprecated": true,
"type": "string",
},
"instanceId": Object {
"description": "Instance id for this application (typically hostname, podId or similar)",
"type": "string",
},
"interval": Object {
"description": "At which interval, in milliseconds, will this client be expected to send metrics",
"type": "number",
},
"sdkVersion": Object {
"description": "Optional field that describes the sdk version (name:version)",
"type": "string",
},
"started": Object {
"description": "When this client started. Should be reported as ISO8601 time.",
"oneOf": Array [
Object {
"format": "date-time",
"type": "string",
},
Object {
"type": "number",
},
],
},
"strategies": Object {
"description": "List of strategies implemented by this application",
"items": Object {
"type": "string",
},
"type": "array",
},
},
"required": Array [
"appName",
"interval",
"started",
"strategies",
],
"type": "object",
},
"proxyFeatureSchema": Object {
"additionalProperties": false,
"properties": Object {
"enabled": Object {
"type": "boolean",
},
"impressionData": Object {
"type": "boolean",
},
"name": Object {
"type": "string",
},
"variant": Object {
"additionalProperties": false,
"properties": Object {
"enabled": Object {
"type": "boolean",
},
"name": Object {
"type": "string",
},
"payload": Object {
"additionalProperties": false,
"properties": Object {
"type": Object {
"enum": Array [
"string",
],
"type": "string",
},
"value": Object {
"type": "string",
},
},
"required": Array [
"type",
"value",
],
"type": "object",
},
},
"required": Array [
"name",
"enabled",
],
"type": "object",
},
},
"required": Array [
"name",
"enabled",
"impressionData",
],
"type": "object",
},
"proxyFeaturesSchema": Object {
"additionalProperties": false,
"properties": Object {
"toggles": Object {
"items": Object {
"$ref": "#/components/schemas/proxyFeatureSchema",
},
"type": "array",
},
},
"required": Array [
"toggles",
],
"type": "object",
},
"proxyMetricsSchema": Object {
"properties": Object {
"appName": Object {
"type": "string",
},
"bucket": Object {
"properties": Object {
"start": Object {
"format": "date-time",
"type": "string",
},
"stop": Object {
"format": "date-time",
"type": "string",
},
"toggles": Object {
"additionalProperties": Object {
"properties": Object {
"no": Object {
"minimum": 0,
"type": "integer",
},
"variants": Object {
"additionalProperties": Object {
"minimum": 0,
"type": "integer",
},
"type": "object",
},
"yes": Object {
"minimum": 0,
"type": "integer",
},
},
"type": "object",
},
"example": Object {
"myCoolToggle": Object {
"no": 42,
"variants": Object {
"blue": 6,
"green": 15,
"red": 46,
},
"yes": 25,
},
"myOtherToggle": Object {
"no": 100,
"yes": 0,
},
},
"type": "object",
},
},
"required": Array [
"start",
"stop",
"toggles",
],
"type": "object",
},
"environment": Object {
"type": "string",
},
"instanceId": Object {
"type": "string",
},
},
"required": Array [
"appName",
"instanceId",
"bucket",
],
"type": "object",
},
"resetPasswordSchema": Object {
"additionalProperties": false,
"properties": Object {
@ -6404,6 +6599,74 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
"/api/frontend": Object {
"get": Object {
"operationId": "getFrontendFeatures",
"responses": Object {
"200": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/proxyFeaturesSchema",
},
},
},
"description": "proxyFeaturesSchema",
},
},
"tags": Array [
"Unstable",
],
},
},
"/api/frontend/client/metrics": Object {
"post": Object {
"operationId": "registerFrontendMetrics",
"requestBody": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/proxyMetricsSchema",
},
},
},
"description": "proxyMetricsSchema",
"required": true,
},
"responses": Object {
"200": Object {
"description": "This response has no body.",
},
},
"tags": Array [
"Unstable",
],
},
},
"/api/frontend/client/register": Object {
"post": Object {
"operationId": "registerFrontendClient",
"requestBody": Object {
"content": Object {
"application/json": Object {
"schema": Object {
"$ref": "#/components/schemas/proxyClientSchema",
},
},
},
"description": "proxyClientSchema",
"required": true,
},
"responses": Object {
"200": Object {
"description": "This response has no body.",
},
},
"tags": Array [
"Unstable",
],
},
},
"/auth/reset/password": Object {
"post": Object {
"operationId": "changePassword",
@ -6637,6 +6900,10 @@ If the provided project does not exist, the list of events will be empty.",
"description": "Create, update, and delete [tags and tag types](https://docs.getunleash.io/advanced/tags).",
"name": "Tags",
},
Object {
"description": "Experimental endpoints that may change or disappear at any time.",
"name": "Unstable",
},
Object {
"description": "Manage users and passwords.",
"name": "Users",

View File

@ -0,0 +1,601 @@
import { IUnleashTest, setupAppWithAuth } from '../../helpers/test-helper';
import dbInit, { ITestDb } from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
import { randomId } from '../../../../lib/util/random-id';
import {
ApiTokenType,
IApiToken,
IApiTokenCreate,
} from '../../../../lib/types/models/api-token';
import { startOfHour } from 'date-fns';
import { IStrategyConfig } from '../../../../lib/types/model';
let app: IUnleashTest;
let db: ITestDb;
beforeAll(async () => {
db = await dbInit('proxy', getLogger);
app = await setupAppWithAuth(db.stores);
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
beforeEach(async () => {
await db.stores.segmentStore.deleteAll();
await db.stores.featureToggleStore.deleteAll();
await db.stores.clientMetricsStoreV2.deleteAll();
await db.stores.apiTokenStore.deleteAll();
});
export const createApiToken = (
type: ApiTokenType,
overrides: Partial<Omit<IApiTokenCreate, 'type' | 'secret'>> = {},
): Promise<IApiToken> => {
return app.services.apiTokenService.createApiTokenWithProjects({
type,
projects: ['default'],
environment: 'default',
username: `${type}-token-${randomId()}`,
...overrides,
});
};
const createFeatureToggle = async ({
name,
project = 'default',
environment = 'default',
strategies,
enabled,
}: {
name: string;
project?: string;
environment?: string;
strategies: IStrategyConfig[];
enabled: boolean;
}): Promise<void> => {
await app.services.featureToggleService.createFeatureToggle(
project,
{ name },
'userName',
true,
);
await Promise.all(
(strategies ?? []).map(async (s) =>
app.services.featureToggleService.createStrategy(
s,
{ projectId: project, featureName: name, environment },
'userName',
),
),
);
await app.services.featureToggleService.updateEnabled(
project,
name,
environment,
enabled,
'userName',
);
};
const createProject = async (id: string): Promise<void> => {
const user = await db.stores.userStore.insert({
name: randomId(),
email: `${randomId()}@example.com`,
});
await app.services.projectService.createProject({ id, name: id }, user);
};
test('should require a proxy token or an admin token', async () => {
await app.request
.get('/api/frontend')
.expect('Content-Type', /json/)
.expect(401);
});
test('should not allow requests with a client token', async () => {
const clientToken = await createApiToken(ApiTokenType.CLIENT);
await app.request
.get('/api/frontend')
.set('Authorization', clientToken.secret)
.expect('Content-Type', /json/)
.expect(403);
});
test('should allow requests with an admin token', async () => {
const adminToken = await createApiToken(ApiTokenType.ADMIN, {
projects: ['*'],
environment: '*',
});
await app.request
.get('/api/frontend')
.set('Authorization', adminToken.secret)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => expect(res.body).toEqual({ toggles: [] }));
});
test('should not allow admin requests with a proxy token', async () => {
const proxyToken = await createApiToken(ApiTokenType.PROXY);
await app.request
.get('/api/admin/features')
.set('Authorization', proxyToken.secret)
.expect('Content-Type', /json/)
.expect(403);
});
test('should not allow client requests with a proxy token', async () => {
const proxyToken = await createApiToken(ApiTokenType.PROXY);
await app.request
.get('/api/client/features')
.set('Authorization', proxyToken.secret)
.expect('Content-Type', /json/)
.expect(403);
});
test('should not allow requests with an invalid proxy token', async () => {
const proxyToken = await createApiToken(ApiTokenType.PROXY);
await app.request
.get('/api/frontend')
.set('Authorization', proxyToken.secret.slice(0, -1))
.expect('Content-Type', /json/)
.expect(401);
});
test('should allow requests with a proxy token', async () => {
const proxyToken = await createApiToken(ApiTokenType.PROXY);
await app.request
.get('/api/frontend')
.set('Authorization', proxyToken.secret)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => expect(res.body).toEqual({ toggles: [] }));
});
test('should return 405 from unimplemented endpoints', async () => {
const proxyToken = await createApiToken(ApiTokenType.PROXY);
await app.request
.post('/api/frontend')
.send({})
.set('Authorization', proxyToken.secret)
.expect('Content-Type', /json/)
.expect(405);
await app.request
.get('/api/frontend/client/features')
.set('Authorization', proxyToken.secret)
.expect('Content-Type', /json/)
.expect(405);
await app.request
.get('/api/frontend/health')
.set('Authorization', proxyToken.secret)
.expect('Content-Type', /json/)
.expect(405);
await app.request
.get('/api/frontend/internal-backstage/prometheus')
.set('Authorization', proxyToken.secret)
.expect('Content-Type', /json/)
.expect(405);
});
// TODO(olav): Test CORS config for all proxy endpoints.
test.todo('should enforce token CORS settings');
test('should accept client registration requests', async () => {
const proxyToken = await createApiToken(ApiTokenType.PROXY);
await app.request
.post('/api/frontend/client/register')
.set('Authorization', proxyToken.secret)
.send({})
.expect('Content-Type', /json/)
.expect(400);
await app.request
.post('/api/frontend/client/register')
.set('Authorization', proxyToken.secret)
.send({
appName: randomId(),
instanceId: randomId(),
sdkVersion: randomId(),
environment: 'default',
interval: 10000,
started: new Date(),
strategies: ['default'],
})
.expect(200)
.expect((res) => expect(res.text).toEqual('OK'));
});
test('should store proxy client metrics', async () => {
const now = new Date();
const appName = randomId();
const instanceId = randomId();
const featureName = randomId();
const proxyToken = await createApiToken(ApiTokenType.PROXY);
const adminToken = await createApiToken(ApiTokenType.ADMIN, {
projects: ['*'],
environment: '*',
});
await app.request
.get(`/api/admin/client-metrics/features/${featureName}`)
.set('Authorization', adminToken.secret)
.expect('Content-Type', /json/)
.expect(200)
.then((res) => {
expect(res.body).toEqual({
featureName,
lastHourUsage: [],
maturity: 'stable',
seenApplications: [],
version: 1,
});
});
await app.request
.post('/api/frontend/client/metrics')
.set('Authorization', proxyToken.secret)
.send({
appName,
instanceId,
bucket: {
start: now,
stop: now,
toggles: { [featureName]: { yes: 1, no: 10 } },
},
})
.expect(200)
.expect((res) => expect(res.text).toEqual('OK'));
await app.request
.post('/api/frontend/client/metrics')
.set('Authorization', proxyToken.secret)
.send({
appName,
instanceId,
bucket: {
start: now,
stop: now,
toggles: { [featureName]: { yes: 2, no: 20 } },
},
})
.expect(200)
.expect((res) => expect(res.text).toEqual('OK'));
await app.request
.get(`/api/admin/client-metrics/features/${featureName}`)
.set('Authorization', adminToken.secret)
.expect('Content-Type', /json/)
.expect(200)
.then((res) => {
expect(res.body).toEqual({
featureName,
lastHourUsage: [
{
environment: 'default',
timestamp: startOfHour(now).toISOString(),
yes: 3,
no: 30,
},
],
maturity: 'stable',
seenApplications: [appName],
version: 1,
});
});
});
test('should filter features by enabled/disabled', async () => {
const proxyToken = await createApiToken(ApiTokenType.PROXY);
await createFeatureToggle({
name: 'enabledFeature1',
enabled: true,
strategies: [{ name: 'default', constraints: [], parameters: {} }],
});
await createFeatureToggle({
name: 'enabledFeature2',
enabled: true,
strategies: [{ name: 'default', constraints: [], parameters: {} }],
});
await createFeatureToggle({
name: 'disabledFeature',
enabled: false,
strategies: [{ name: 'default', constraints: [], parameters: {} }],
});
await app.request
.get('/api/frontend')
.set('Authorization', proxyToken.secret)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
toggles: [
{
name: 'enabledFeature1',
enabled: true,
impressionData: false,
variant: { enabled: false, name: 'disabled' },
},
{
name: 'enabledFeature2',
enabled: true,
impressionData: false,
variant: { enabled: false, name: 'disabled' },
},
],
});
});
});
test('should filter features by strategies', async () => {
const proxyToken = await createApiToken(ApiTokenType.PROXY);
await createFeatureToggle({
name: 'featureWithoutStrategies',
enabled: false,
strategies: [],
});
await createFeatureToggle({
name: 'featureWithUnknownStrategy',
enabled: true,
strategies: [{ name: 'unknown', constraints: [], parameters: {} }],
});
await createFeatureToggle({
name: 'featureWithMultipleStrategies',
enabled: true,
strategies: [
{ name: 'default', constraints: [], parameters: {} },
{ name: 'unknown', constraints: [], parameters: {} },
],
});
await app.request
.get('/api/frontend')
.set('Authorization', proxyToken.secret)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
toggles: [
{
name: 'featureWithMultipleStrategies',
enabled: true,
impressionData: false,
variant: { enabled: false, name: 'disabled' },
},
],
});
});
});
test('should filter features by constraints', async () => {
const proxyToken = await createApiToken(ApiTokenType.PROXY);
await createFeatureToggle({
name: 'featureWithAppNameA',
enabled: true,
strategies: [
{
name: 'default',
constraints: [
{ contextName: 'appName', operator: 'IN', values: ['a'] },
],
parameters: {},
},
],
});
await createFeatureToggle({
name: 'featureWithAppNameAorB',
enabled: true,
strategies: [
{
name: 'default',
constraints: [
{
contextName: 'appName',
operator: 'IN',
values: ['a', 'b'],
},
],
parameters: {},
},
],
});
await app.request
.get('/api/frontend?appName=a')
.set('Authorization', proxyToken.secret)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => expect(res.body.toggles).toHaveLength(2));
await app.request
.get('/api/frontend?appName=b')
.set('Authorization', proxyToken.secret)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => expect(res.body.toggles).toHaveLength(1));
await app.request
.get('/api/frontend?appName=c')
.set('Authorization', proxyToken.secret)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => expect(res.body.toggles).toHaveLength(0));
});
test('should filter features by project', async () => {
const projectA = 'projectA';
const projectB = 'projectB';
await createProject(projectA);
await createProject(projectB);
const proxyTokenDefault = await createApiToken(ApiTokenType.PROXY);
const proxyTokenProjectA = await createApiToken(ApiTokenType.PROXY, {
projects: [projectA],
});
const proxyTokenProjectAB = await createApiToken(ApiTokenType.PROXY, {
projects: [projectA, projectB],
});
await createFeatureToggle({
name: 'featureInProjectDefault',
enabled: true,
strategies: [{ name: 'default', parameters: {} }],
});
await createFeatureToggle({
name: 'featureInProjectA',
project: projectA,
enabled: true,
strategies: [{ name: 'default', parameters: {} }],
});
await createFeatureToggle({
name: 'featureInProjectB',
project: projectB,
enabled: true,
strategies: [{ name: 'default', parameters: {} }],
});
await app.request
.get('/api/frontend')
.set('Authorization', proxyTokenDefault.secret)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
toggles: [
{
name: 'featureInProjectDefault',
enabled: true,
impressionData: false,
variant: { enabled: false, name: 'disabled' },
},
],
});
});
await app.request
.get('/api/frontend')
.set('Authorization', proxyTokenProjectA.secret)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
toggles: [
{
name: 'featureInProjectA',
enabled: true,
impressionData: false,
variant: { enabled: false, name: 'disabled' },
},
],
});
});
await app.request
.get('/api/frontend')
.set('Authorization', proxyTokenProjectAB.secret)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
toggles: [
{
name: 'featureInProjectA',
enabled: true,
impressionData: false,
variant: { enabled: false, name: 'disabled' },
},
{
name: 'featureInProjectB',
enabled: true,
impressionData: false,
variant: { enabled: false, name: 'disabled' },
},
],
});
});
});
test('should filter features by environment', async () => {
const environmentA = 'environmentA';
const environmentB = 'environmentB';
await db.stores.environmentStore.create({
name: environmentA,
type: 'production',
});
await db.stores.environmentStore.create({
name: environmentB,
type: 'production',
});
await app.services.environmentService.addEnvironmentToProject(
environmentA,
'default',
);
await app.services.environmentService.addEnvironmentToProject(
environmentB,
'default',
);
const proxyTokenEnvironmentDefault = await createApiToken(
ApiTokenType.PROXY,
);
const proxyTokenEnvironmentA = await createApiToken(ApiTokenType.PROXY, {
environment: environmentA,
});
const proxyTokenEnvironmentB = await createApiToken(ApiTokenType.PROXY, {
environment: environmentB,
});
await createFeatureToggle({
name: 'featureInEnvironmentDefault',
enabled: true,
strategies: [{ name: 'default', parameters: {} }],
});
await createFeatureToggle({
name: 'featureInEnvironmentA',
environment: environmentA,
enabled: true,
strategies: [{ name: 'default', parameters: {} }],
});
await createFeatureToggle({
name: 'featureInEnvironmentB',
environment: environmentB,
enabled: true,
strategies: [{ name: 'default', parameters: {} }],
});
await app.request
.get('/api/frontend')
.set('Authorization', proxyTokenEnvironmentDefault.secret)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
toggles: [
{
name: 'featureInEnvironmentDefault',
enabled: true,
impressionData: false,
variant: { enabled: false, name: 'disabled' },
},
],
});
});
await app.request
.get('/api/frontend')
.set('Authorization', proxyTokenEnvironmentA.secret)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
toggles: [
{
name: 'featureInEnvironmentA',
enabled: true,
impressionData: false,
variant: { enabled: false, name: 'disabled' },
},
],
});
});
await app.request
.get('/api/frontend')
.set('Authorization', proxyTokenEnvironmentB.secret)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body).toEqual({
toggles: [
{
name: 'featureInEnvironmentB',
enabled: true,
impressionData: false,
variant: { enabled: false, name: 'disabled' },
},
],
});
});
});

View File

@ -19,7 +19,7 @@
"contextFields": [
{ "name": "environment" },
{ "name": "userId" },
{ "name": "appNam" }
{ "name": "appName" }
],
"projects": [
{