mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-17 13:46:47 +02:00
feat: embed proxy endpoints (#1926)
* refactor: remove unused API definition routes * feat: add support for proxy keys * feat: support listening for any event * feat: embed proxy endpoints * refactor: add an experimental flag for the embedded proxy
This commit is contained in:
parent
c82318d4c5
commit
e8d542af0f
@ -124,6 +124,7 @@
|
|||||||
"stoppable": "^1.1.0",
|
"stoppable": "^1.1.0",
|
||||||
"ts-toolbelt": "^9.6.0",
|
"ts-toolbelt": "^9.6.0",
|
||||||
"type-is": "^1.6.18",
|
"type-is": "^1.6.18",
|
||||||
|
"unleash-client": "3.15.0",
|
||||||
"unleash-frontend": "4.15.0-beta.0",
|
"unleash-frontend": "4.15.0-beta.0",
|
||||||
"uuid": "^8.3.2"
|
"uuid": "^8.3.2"
|
||||||
},
|
},
|
||||||
@ -173,8 +174,7 @@
|
|||||||
"ts-jest": "27.1.5",
|
"ts-jest": "27.1.5",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
"tsc-watch": "5.0.3",
|
"tsc-watch": "5.0.3",
|
||||||
"typescript": "4.7.4",
|
"typescript": "4.7.4"
|
||||||
"unleash-client": "3.15.0"
|
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"async": "^3.2.3",
|
"async": "^3.2.3",
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { EventEmitter } from 'events';
|
|
||||||
import { Knex } from 'knex';
|
import { Knex } from 'knex';
|
||||||
import { IEvent, IBaseEvent } from '../types/events';
|
import { IEvent, IBaseEvent } from '../types/events';
|
||||||
import { LogProvider, Logger } from '../logger';
|
import { LogProvider, Logger } from '../logger';
|
||||||
import { IEventStore } from '../types/stores/event-store';
|
import { IEventStore } from '../types/stores/event-store';
|
||||||
import { ITag } from '../types/model';
|
import { ITag } from '../types/model';
|
||||||
import { SearchEventsSchema } from '../openapi/spec/search-events-schema';
|
import { SearchEventsSchema } from '../openapi/spec/search-events-schema';
|
||||||
|
import { AnyEventEmitter } from '../util/anyEventEmitter';
|
||||||
|
|
||||||
const EVENT_COLUMNS = [
|
const EVENT_COLUMNS = [
|
||||||
'id',
|
'id',
|
||||||
@ -34,7 +34,7 @@ export interface IEventTable {
|
|||||||
|
|
||||||
const TABLE = 'events';
|
const TABLE = 'events';
|
||||||
|
|
||||||
class EventStore extends EventEmitter implements IEventStore {
|
class EventStore extends AnyEventEmitter implements IEventStore {
|
||||||
private db: Knex;
|
private db: Knex;
|
||||||
|
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
@ -3,6 +3,7 @@ export interface IExperimentalOptions {
|
|||||||
clientFeatureMemoize?: IExperimentalToggle;
|
clientFeatureMemoize?: IExperimentalToggle;
|
||||||
userGroups?: boolean;
|
userGroups?: boolean;
|
||||||
anonymiseEventLog?: boolean;
|
anonymiseEventLog?: boolean;
|
||||||
|
embedProxy?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IExperimentalToggle {
|
export interface IExperimentalToggle {
|
||||||
|
@ -65,6 +65,7 @@ test('should add user if known token', async () => {
|
|||||||
project: ALL,
|
project: ALL,
|
||||||
environment: ALL,
|
environment: ALL,
|
||||||
type: ApiTokenType.CLIENT,
|
type: ApiTokenType.CLIENT,
|
||||||
|
secret: 'a',
|
||||||
});
|
});
|
||||||
const apiTokenService = {
|
const apiTokenService = {
|
||||||
getUserForToken: jest.fn().mockReturnValue(apiUser),
|
getUserForToken: jest.fn().mockReturnValue(apiUser),
|
||||||
@ -96,6 +97,7 @@ test('should not add user if not /api/client', async () => {
|
|||||||
project: ALL,
|
project: ALL,
|
||||||
environment: ALL,
|
environment: ALL,
|
||||||
type: ApiTokenType.CLIENT,
|
type: ApiTokenType.CLIENT,
|
||||||
|
secret: 'a',
|
||||||
});
|
});
|
||||||
|
|
||||||
const apiTokenService = {
|
const apiTokenService = {
|
||||||
@ -134,6 +136,7 @@ test('should not add user if disabled', async () => {
|
|||||||
project: ALL,
|
project: ALL,
|
||||||
environment: ALL,
|
environment: ALL,
|
||||||
type: ApiTokenType.CLIENT,
|
type: ApiTokenType.CLIENT,
|
||||||
|
secret: 'a',
|
||||||
});
|
});
|
||||||
const apiTokenService = {
|
const apiTokenService = {
|
||||||
getUserForToken: jest.fn().mockReturnValue(apiUser),
|
getUserForToken: jest.fn().mockReturnValue(apiUser),
|
||||||
|
@ -6,14 +6,19 @@ const isClientApi = ({ path }) => {
|
|||||||
return path && path.startsWith('/api/client');
|
return path && path.startsWith('/api/client');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isProxyApi = ({ path }) => {
|
||||||
|
return path && path.startsWith('/api/frontend');
|
||||||
|
};
|
||||||
|
|
||||||
export const TOKEN_TYPE_ERROR_MESSAGE =
|
export const TOKEN_TYPE_ERROR_MESSAGE =
|
||||||
'invalid token: expected an admin token but got a client token instead';
|
'invalid token: expected a different token type for this endpoint';
|
||||||
|
|
||||||
const apiAccessMiddleware = (
|
const apiAccessMiddleware = (
|
||||||
{
|
{
|
||||||
getLogger,
|
getLogger,
|
||||||
authentication,
|
authentication,
|
||||||
}: Pick<IUnleashConfig, 'getLogger' | 'authentication'>,
|
experimental,
|
||||||
|
}: Pick<IUnleashConfig, 'getLogger' | 'authentication' | 'experimental'>,
|
||||||
{ apiTokenService }: any,
|
{ apiTokenService }: any,
|
||||||
): any => {
|
): any => {
|
||||||
const logger = getLogger('/middleware/api-token.ts');
|
const logger = getLogger('/middleware/api-token.ts');
|
||||||
@ -31,9 +36,14 @@ const apiAccessMiddleware = (
|
|||||||
try {
|
try {
|
||||||
const apiToken = req.header('authorization');
|
const apiToken = req.header('authorization');
|
||||||
const apiUser = apiTokenService.getUserForToken(apiToken);
|
const apiUser = apiTokenService.getUserForToken(apiToken);
|
||||||
|
const { CLIENT, PROXY } = ApiTokenType;
|
||||||
|
|
||||||
if (apiUser) {
|
if (apiUser) {
|
||||||
if (apiUser.type === ApiTokenType.CLIENT && !isClientApi(req)) {
|
if (
|
||||||
|
(apiUser.type === CLIENT && !isClientApi(req)) ||
|
||||||
|
(apiUser.type === PROXY && !isProxyApi(req)) ||
|
||||||
|
(apiUser.type === PROXY && !experimental.embedProxy)
|
||||||
|
) {
|
||||||
res.status(403).send({ message: TOKEN_TYPE_ERROR_MESSAGE });
|
res.status(403).send({ message: TOKEN_TYPE_ERROR_MESSAGE });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -47,6 +47,7 @@ function demoAuthentication(
|
|||||||
environment: 'default',
|
environment: 'default',
|
||||||
type: ApiTokenType.CLIENT,
|
type: ApiTokenType.CLIENT,
|
||||||
project: '*',
|
project: '*',
|
||||||
|
secret: 'a',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
|
@ -50,6 +50,7 @@ test('should give api-user ADMIN permission', async () => {
|
|||||||
project: '*',
|
project: '*',
|
||||||
environment: '*',
|
environment: '*',
|
||||||
type: ApiTokenType.ADMIN,
|
type: ApiTokenType.ADMIN,
|
||||||
|
secret: 'a',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -75,6 +76,7 @@ test('should not give api-user ADMIN permission', async () => {
|
|||||||
project: '*',
|
project: '*',
|
||||||
environment: '*',
|
environment: '*',
|
||||||
type: ApiTokenType.CLIENT,
|
type: ApiTokenType.CLIENT,
|
||||||
|
secret: 'a',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -106,6 +106,10 @@ import { groupUserModelSchema } from './spec/group-user-model-schema';
|
|||||||
import { usersGroupsBaseSchema } from './spec/users-groups-base-schema';
|
import { usersGroupsBaseSchema } from './spec/users-groups-base-schema';
|
||||||
import { openApiTags } from './util/openapi-tags';
|
import { openApiTags } from './util/openapi-tags';
|
||||||
import { searchEventsSchema } from './spec/search-events-schema';
|
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.
|
// All schemas in `openapi/spec` should be listed here.
|
||||||
export const schemas = {
|
export const schemas = {
|
||||||
@ -211,6 +215,10 @@ export const schemas = {
|
|||||||
variantSchema,
|
variantSchema,
|
||||||
variantsSchema,
|
variantsSchema,
|
||||||
versionSchema,
|
versionSchema,
|
||||||
|
proxyClientSchema,
|
||||||
|
proxyFeaturesSchema,
|
||||||
|
proxyFeatureSchema,
|
||||||
|
proxyMetricsSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Schemas must have an $id property on the form "#/components/schemas/mySchema".
|
// Schemas must have an $id property on the form "#/components/schemas/mySchema".
|
||||||
|
50
src/lib/openapi/spec/proxy-client-schema.ts
Normal file
50
src/lib/openapi/spec/proxy-client-schema.ts
Normal 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>;
|
44
src/lib/openapi/spec/proxy-feature-schema.ts
Normal file
44
src/lib/openapi/spec/proxy-feature-schema.ts
Normal 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>;
|
24
src/lib/openapi/spec/proxy-features-schema.ts
Normal file
24
src/lib/openapi/spec/proxy-features-schema.ts
Normal 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>;
|
55
src/lib/openapi/spec/proxy-metrics-schema.ts
Normal file
55
src/lib/openapi/spec/proxy-metrics-schema.ts
Normal 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>;
|
@ -72,6 +72,11 @@ const OPENAPI_TAGS = [
|
|||||||
'Create, update, and delete [tags and tag types](https://docs.getunleash.io/advanced/tags).',
|
'Create, update, and delete [tags and tag types](https://docs.getunleash.io/advanced/tags).',
|
||||||
},
|
},
|
||||||
{ name: 'Users', description: 'Manage users and passwords.' },
|
{ name: 'Users', description: 'Manage users and passwords.' },
|
||||||
|
{
|
||||||
|
name: 'Unstable',
|
||||||
|
description:
|
||||||
|
'Experimental endpoints that may change or disappear at any time.',
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// make the export mutable, so it can be used in a schema
|
// make the export mutable, so it can be used in a schema
|
||||||
|
78
src/lib/proxy/create-context.test.ts
Normal file
78
src/lib/proxy/create-context.test.ts
Normal 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');
|
||||||
|
});
|
34
src/lib/proxy/create-context.ts
Normal file
34
src/lib/proxy/create-context.ts
Normal 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;
|
||||||
|
}
|
121
src/lib/proxy/proxy-repository.ts
Normal file
121
src/lib/proxy/proxy-repository.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 3,
|
|
||||||
"links": {
|
|
||||||
"feature-toggles": {
|
|
||||||
"uri": "/api/admin/features"
|
|
||||||
},
|
|
||||||
"feature-archive": {
|
|
||||||
"uri": "/api/admin/archive"
|
|
||||||
},
|
|
||||||
"strategies": {
|
|
||||||
"uri": "/api/admin/strategies"
|
|
||||||
},
|
|
||||||
"events": {
|
|
||||||
"uri": "/api/admin/events"
|
|
||||||
},
|
|
||||||
"metrics": {
|
|
||||||
"uri": "/api/admin/metrics"
|
|
||||||
},
|
|
||||||
"state": {
|
|
||||||
"uri": "/api/admin/state"
|
|
||||||
},
|
|
||||||
"context": {
|
|
||||||
"uri": "/api/admin/context"
|
|
||||||
},
|
|
||||||
"tags": {
|
|
||||||
"uri": "/api/admin/tags"
|
|
||||||
},
|
|
||||||
"tag-types": {
|
|
||||||
"uri": "/api/admin/tag-types"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,4 +1,3 @@
|
|||||||
import apiDef from './api-def.json';
|
|
||||||
import Controller from '../controller';
|
import Controller from '../controller';
|
||||||
import { IUnleashServices } from '../../types/services';
|
import { IUnleashServices } from '../../types/services';
|
||||||
import { IUnleashConfig } from '../../types/option';
|
import { IUnleashConfig } from '../../types/option';
|
||||||
@ -30,8 +29,6 @@ class AdminApi extends Controller {
|
|||||||
constructor(config: IUnleashConfig, services: IUnleashServices) {
|
constructor(config: IUnleashConfig, services: IUnleashServices) {
|
||||||
super(config);
|
super(config);
|
||||||
|
|
||||||
this.app.get('/', this.index);
|
|
||||||
|
|
||||||
this.app.use(
|
this.app.use(
|
||||||
'/features',
|
'/features',
|
||||||
new FeatureController(config, services).router,
|
new FeatureController(config, services).router,
|
||||||
@ -105,10 +102,6 @@ class AdminApi extends Controller {
|
|||||||
new ConstraintsController(config, services).router,
|
new ConstraintsController(config, services).router,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
index(req, res) {
|
|
||||||
res.json(apiDef);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = AdminApi;
|
module.exports = AdminApi;
|
||||||
|
@ -1,19 +0,0 @@
|
|||||||
import clientApiDef from './client-api/api-def.json';
|
|
||||||
import adminApiDef from './admin-api/api-def.json';
|
|
||||||
import version from '../util/version';
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
name: 'unleash-server',
|
|
||||||
version,
|
|
||||||
uri: '/api',
|
|
||||||
links: {
|
|
||||||
admin: {
|
|
||||||
uri: '/api/admin',
|
|
||||||
links: adminApiDef.links,
|
|
||||||
},
|
|
||||||
client: {
|
|
||||||
uri: '/api/client',
|
|
||||||
links: clientApiDef.links,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 3,
|
|
||||||
"links": {
|
|
||||||
"feature-toggles": {
|
|
||||||
"uri": "/api/client/features"
|
|
||||||
},
|
|
||||||
"register": {
|
|
||||||
"uri": "/api/client/register"
|
|
||||||
},
|
|
||||||
"metrics": {
|
|
||||||
"uri": "/api/client/metrics"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,4 +1,3 @@
|
|||||||
import { Request, Response } from 'express';
|
|
||||||
import Controller from '../controller';
|
import Controller from '../controller';
|
||||||
import FeatureController from './feature';
|
import FeatureController from './feature';
|
||||||
import MetricsController from './metrics';
|
import MetricsController from './metrics';
|
||||||
@ -6,21 +5,14 @@ import RegisterController from './register';
|
|||||||
import { IUnleashConfig } from '../../types/option';
|
import { IUnleashConfig } from '../../types/option';
|
||||||
import { IUnleashServices } from '../../types';
|
import { IUnleashServices } from '../../types';
|
||||||
|
|
||||||
const apiDef = require('./api-def.json');
|
|
||||||
|
|
||||||
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.get('/', this.index);
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
index(req: Request, res: Response): void {
|
|
||||||
res.json(apiDef);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = ClientApi;
|
module.exports = ClientApi;
|
||||||
|
@ -4,11 +4,7 @@ import { IUnleashConfig, IUnleashServices } from '../../types';
|
|||||||
import ClientInstanceService from '../../services/client-metrics/instance-service';
|
import ClientInstanceService from '../../services/client-metrics/instance-service';
|
||||||
import { Logger } from '../../logger';
|
import { Logger } from '../../logger';
|
||||||
import { IAuthRequest } from '../unleash-types';
|
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 ClientMetricsServiceV2 from '../../services/client-metrics/metrics-service-v2';
|
||||||
import { User } from '../../server-impl';
|
|
||||||
import { IClientApp } from '../../types/model';
|
|
||||||
import { NONE } from '../../types/permissions';
|
import { NONE } from '../../types/permissions';
|
||||||
import { OpenApiService } from '../../services/openapi-service';
|
import { OpenApiService } from '../../services/openapi-service';
|
||||||
import { createRequestSchema } from '../../openapi/util/create-request-schema';
|
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> {
|
async registerMetrics(req: IAuthRequest, res: Response): Promise<void> {
|
||||||
const { body: data, ip: clientIp, user } = req;
|
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);
|
await this.clientInstanceService.registerInstance(data, clientIp);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -1,87 +0,0 @@
|
|||||||
import supertest from 'supertest';
|
|
||||||
import { createTestConfig } from '../../test/config/test-config';
|
|
||||||
import createStores from '../../test/fixtures/store';
|
|
||||||
import getApp from '../app';
|
|
||||||
import { createServices } from '../services';
|
|
||||||
|
|
||||||
async function getSetup() {
|
|
||||||
const base = `/random${Math.round(Math.random() * 1000)}`;
|
|
||||||
const stores = createStores();
|
|
||||||
const config = createTestConfig({
|
|
||||||
server: { baseUriPath: base },
|
|
||||||
});
|
|
||||||
const services = createServices(stores, config);
|
|
||||||
const app = await getApp(config, stores, services);
|
|
||||||
|
|
||||||
return {
|
|
||||||
base,
|
|
||||||
request: supertest(app),
|
|
||||||
destroy: () => {
|
|
||||||
services.versionService.destroy();
|
|
||||||
services.clientInstanceService.destroy();
|
|
||||||
services.apiTokenService.destroy();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let base;
|
|
||||||
let request;
|
|
||||||
let destroy;
|
|
||||||
beforeEach(async () => {
|
|
||||||
const setup = await getSetup();
|
|
||||||
base = setup.base;
|
|
||||||
request = setup.request;
|
|
||||||
destroy = setup.destroy;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('api definition', () => {
|
|
||||||
expect.assertions(5);
|
|
||||||
return request
|
|
||||||
.get(`${base}/api/`)
|
|
||||||
.expect('Content-Type', /json/)
|
|
||||||
.expect(200)
|
|
||||||
.expect((res) => {
|
|
||||||
expect(res.body).toBeTruthy();
|
|
||||||
const { admin, client } = res.body.links;
|
|
||||||
expect(admin.uri === '/api/admin').toBe(true);
|
|
||||||
expect(client.uri === '/api/client').toBe(true);
|
|
||||||
expect(
|
|
||||||
admin.links['feature-toggles'].uri === '/api/admin/features',
|
|
||||||
).toBe(true);
|
|
||||||
expect(client.links.metrics.uri === '/api/client/metrics').toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('admin api defintion', () => {
|
|
||||||
expect.assertions(2);
|
|
||||||
return request
|
|
||||||
.get(`${base}/api/admin`)
|
|
||||||
.expect('Content-Type', /json/)
|
|
||||||
.expect(200)
|
|
||||||
.expect((res) => {
|
|
||||||
expect(res.body).toBeTruthy();
|
|
||||||
expect(
|
|
||||||
res.body.links['feature-toggles'].uri === '/api/admin/features',
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('client api defintion', () => {
|
|
||||||
expect.assertions(2);
|
|
||||||
return request
|
|
||||||
.get(`${base}/api/client`)
|
|
||||||
.expect('Content-Type', /json/)
|
|
||||||
.expect(200)
|
|
||||||
.expect((res) => {
|
|
||||||
expect(res.body).toBeTruthy();
|
|
||||||
expect(res.body.links.metrics.uri === '/api/client/metrics').toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,16 +1,16 @@
|
|||||||
import { Request, Response } from 'express';
|
|
||||||
import { BackstageController } from './backstage';
|
import { BackstageController } from './backstage';
|
||||||
import ResetPasswordController from './auth/reset-password-controller';
|
import ResetPasswordController from './auth/reset-password-controller';
|
||||||
import { SimplePasswordProvider } from './auth/simple-password-provider';
|
import { SimplePasswordProvider } from './auth/simple-password-provider';
|
||||||
import { IUnleashConfig } from '../types/option';
|
import { IUnleashConfig } from '../types/option';
|
||||||
import { IUnleashServices } from '../types/services';
|
import { IUnleashServices } from '../types/services';
|
||||||
import { api } from './api-def';
|
|
||||||
import LogoutController from './logout';
|
import LogoutController from './logout';
|
||||||
|
|
||||||
const AdminApi = require('./admin-api');
|
const AdminApi = require('./admin-api');
|
||||||
const ClientApi = require('./client-api');
|
const ClientApi = require('./client-api');
|
||||||
const Controller = require('./controller');
|
const Controller = require('./controller');
|
||||||
import { HealthCheckController } from './health-check';
|
import { HealthCheckController } from './health-check';
|
||||||
|
import ProxyController from './proxy-api';
|
||||||
|
|
||||||
class IndexRouter extends Controller {
|
class IndexRouter extends Controller {
|
||||||
constructor(config: IUnleashConfig, services: IUnleashServices) {
|
constructor(config: IUnleashConfig, services: IUnleashServices) {
|
||||||
super(config);
|
super(config);
|
||||||
@ -25,13 +25,15 @@ class IndexRouter extends Controller {
|
|||||||
'/auth/reset',
|
'/auth/reset',
|
||||||
new ResetPasswordController(config, services).router,
|
new ResetPasswordController(config, services).router,
|
||||||
);
|
);
|
||||||
this.get(api.uri, this.index);
|
this.use('/api/admin', new AdminApi(config, services).router);
|
||||||
this.use(api.links.admin.uri, new AdminApi(config, services).router);
|
this.use('/api/client', new ClientApi(config, services).router);
|
||||||
this.use(api.links.client.uri, new ClientApi(config, services).router);
|
|
||||||
}
|
|
||||||
|
|
||||||
async index(req: Request, res: Response): Promise<void> {
|
if (config.experimental.embedProxy) {
|
||||||
res.json(api);
|
this.use(
|
||||||
|
'/api/frontend',
|
||||||
|
new ProxyController(config, services).router,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
177
src/lib/routes/proxy-api/index.ts
Normal file
177
src/lib/routes/proxy-api/index.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { Logger } from '../logger';
|
import { Logger } from '../logger';
|
||||||
import { ADMIN, CLIENT } from '../types/permissions';
|
import { ADMIN, CLIENT, PROXY } from '../types/permissions';
|
||||||
import { IUnleashStores } from '../types/stores';
|
import { IUnleashStores } from '../types/stores';
|
||||||
import { IUnleashConfig } from '../types/option';
|
import { IUnleashConfig } from '../types/option';
|
||||||
import ApiUser from '../types/api-user';
|
import ApiUser from '../types/api-user';
|
||||||
@ -20,6 +20,22 @@ import BadDataError from '../error/bad-data-error';
|
|||||||
import { minutesToMilliseconds } from 'date-fns';
|
import { minutesToMilliseconds } from 'date-fns';
|
||||||
import { IEnvironmentStore } from 'lib/types/stores/environment-store';
|
import { IEnvironmentStore } from 'lib/types/stores/environment-store';
|
||||||
|
|
||||||
|
const resolveTokenPermissions = (tokenType: string) => {
|
||||||
|
if (tokenType === ApiTokenType.ADMIN) {
|
||||||
|
return [ADMIN];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenType === ApiTokenType.CLIENT) {
|
||||||
|
return [CLIENT];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenType === ApiTokenType.PROXY) {
|
||||||
|
return [PROXY];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
export class ApiTokenService {
|
export class ApiTokenService {
|
||||||
private store: IApiTokenStore;
|
private store: IApiTokenStore;
|
||||||
|
|
||||||
@ -88,15 +104,13 @@ export class ApiTokenService {
|
|||||||
public getUserForToken(secret: string): ApiUser | undefined {
|
public getUserForToken(secret: string): ApiUser | undefined {
|
||||||
const token = this.activeTokens.find((t) => t.secret === secret);
|
const token = this.activeTokens.find((t) => t.secret === secret);
|
||||||
if (token) {
|
if (token) {
|
||||||
const permissions =
|
|
||||||
token.type === ApiTokenType.ADMIN ? [ADMIN] : [CLIENT];
|
|
||||||
|
|
||||||
return new ApiUser({
|
return new ApiUser({
|
||||||
username: token.username,
|
username: token.username,
|
||||||
permissions,
|
permissions: resolveTokenPermissions(token.type),
|
||||||
projects: token.projects,
|
projects: token.projects,
|
||||||
environment: token.environment,
|
environment: token.environment,
|
||||||
type: token.type,
|
type: token.type,
|
||||||
|
secret: token.secret,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
|
@ -12,6 +12,9 @@ import { hoursToMilliseconds, minutesToMilliseconds } from 'date-fns';
|
|||||||
import { IFeatureToggleStore } from '../../types/stores/feature-toggle-store';
|
import { IFeatureToggleStore } from '../../types/stores/feature-toggle-store';
|
||||||
import EventEmitter from 'events';
|
import EventEmitter from 'events';
|
||||||
import { CLIENT_METRICS } from '../../types/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 {
|
export default class ClientMetricsServiceV2 {
|
||||||
private timer: NodeJS.Timeout;
|
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 {
|
destroy(): void {
|
||||||
clearInterval(this.timer);
|
clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
|
@ -33,6 +33,7 @@ import { OpenApiService } from './openapi-service';
|
|||||||
import { ClientSpecService } from './client-spec-service';
|
import { ClientSpecService } from './client-spec-service';
|
||||||
import { PlaygroundService } from './playground-service';
|
import { PlaygroundService } from './playground-service';
|
||||||
import { GroupService } from './group-service';
|
import { GroupService } from './group-service';
|
||||||
|
import { ProxyService } from './proxy-service';
|
||||||
export const createServices = (
|
export const createServices = (
|
||||||
stores: IUnleashStores,
|
stores: IUnleashStores,
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
@ -91,6 +92,11 @@ export const createServices = (
|
|||||||
featureToggleServiceV2,
|
featureToggleServiceV2,
|
||||||
segmentService,
|
segmentService,
|
||||||
});
|
});
|
||||||
|
const proxyService = new ProxyService(config, stores, {
|
||||||
|
featureToggleServiceV2,
|
||||||
|
clientMetricsServiceV2,
|
||||||
|
segmentService,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessService,
|
accessService,
|
||||||
@ -125,6 +131,7 @@ export const createServices = (
|
|||||||
clientSpecService,
|
clientSpecService,
|
||||||
playgroundService,
|
playgroundService,
|
||||||
groupService,
|
groupService,
|
||||||
|
proxyService,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import User from '../types/user';
|
import User, { IUser } from '../types/user';
|
||||||
import { AccessService } from './access-service';
|
import { AccessService } from './access-service';
|
||||||
import NameExistsError from '../error/name-exists-error';
|
import NameExistsError from '../error/name-exists-error';
|
||||||
import InvalidOperationError from '../error/invalid-operation-error';
|
import InvalidOperationError from '../error/invalid-operation-error';
|
||||||
@ -48,7 +48,7 @@ import { arraysHaveSameItems } from '../util/arraysHaveSameItems';
|
|||||||
import { GroupService } from './group-service';
|
import { GroupService } from './group-service';
|
||||||
import { IGroupModelWithProjectRole, IGroupRole } from 'lib/types/group';
|
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 {
|
export interface AccessWithRoles {
|
||||||
users: IUserWithRole[];
|
users: IUserWithRole[];
|
||||||
@ -130,8 +130,8 @@ export default class ProjectService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createProject(
|
async createProject(
|
||||||
newProject: Pick<IProject, 'id'>,
|
newProject: Pick<IProject, 'id' | 'name'>,
|
||||||
user: User,
|
user: IUser,
|
||||||
): Promise<IProject> {
|
): Promise<IProject> {
|
||||||
const data = await projectSchema.validateAsync(newProject);
|
const data = await projectSchema.validateAsync(newProject);
|
||||||
await this.validateUniqueId(data.id);
|
await this.validateUniqueId(data.id);
|
||||||
|
120
src/lib/services/proxy-service.ts
Normal file
120
src/lib/services/proxy-service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -8,6 +8,7 @@ interface IApiUserData {
|
|||||||
project?: string;
|
project?: string;
|
||||||
environment: string;
|
environment: string;
|
||||||
type: ApiTokenType;
|
type: ApiTokenType;
|
||||||
|
secret: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class ApiUser {
|
export default class ApiUser {
|
||||||
@ -23,6 +24,8 @@ export default class ApiUser {
|
|||||||
|
|
||||||
readonly type: ApiTokenType;
|
readonly type: ApiTokenType;
|
||||||
|
|
||||||
|
readonly secret: string;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
username,
|
username,
|
||||||
permissions = [CLIENT],
|
permissions = [CLIENT],
|
||||||
@ -30,6 +33,7 @@ export default class ApiUser {
|
|||||||
project,
|
project,
|
||||||
environment,
|
environment,
|
||||||
type,
|
type,
|
||||||
|
secret,
|
||||||
}: IApiUserData) {
|
}: IApiUserData) {
|
||||||
if (!username) {
|
if (!username) {
|
||||||
throw new TypeError('username is required');
|
throw new TypeError('username is required');
|
||||||
@ -38,6 +42,7 @@ export default class ApiUser {
|
|||||||
this.permissions = permissions;
|
this.permissions = permissions;
|
||||||
this.environment = environment;
|
this.environment = environment;
|
||||||
this.type = type;
|
this.type = type;
|
||||||
|
this.secret = secret;
|
||||||
if (projects && projects.length > 0) {
|
if (projects && projects.length > 0) {
|
||||||
this.projects = projects;
|
this.projects = projects;
|
||||||
} else {
|
} else {
|
||||||
|
@ -6,6 +6,7 @@ export const ALL = '*';
|
|||||||
export enum ApiTokenType {
|
export enum ApiTokenType {
|
||||||
CLIENT = 'client',
|
CLIENT = 'client',
|
||||||
ADMIN = 'admin',
|
ADMIN = 'admin',
|
||||||
|
PROXY = 'proxy',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ILegacyApiTokenCreate {
|
export interface ILegacyApiTokenCreate {
|
||||||
@ -102,6 +103,12 @@ export const validateApiToken = ({
|
|||||||
'Client token cannot be scoped to all environments',
|
'Client token cannot be scoped to all environments',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === ApiTokenType.PROXY && environment === ALL) {
|
||||||
|
throw new BadDataError(
|
||||||
|
'Proxy token cannot be scoped to all environments',
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const validateApiTokenEnvironment = (
|
export const validateApiTokenEnvironment = (
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
//Special
|
//Special
|
||||||
export const ADMIN = 'ADMIN';
|
export const ADMIN = 'ADMIN';
|
||||||
export const CLIENT = 'CLIENT';
|
export const CLIENT = 'CLIENT';
|
||||||
|
export const PROXY = 'PROXY';
|
||||||
export const NONE = 'NONE';
|
export const NONE = 'NONE';
|
||||||
|
|
||||||
export const CREATE_FEATURE = 'CREATE_FEATURE';
|
export const CREATE_FEATURE = 'CREATE_FEATURE';
|
||||||
|
@ -29,6 +29,7 @@ import { OpenApiService } from '../services/openapi-service';
|
|||||||
import { ClientSpecService } from '../services/client-spec-service';
|
import { ClientSpecService } from '../services/client-spec-service';
|
||||||
import { PlaygroundService } from 'lib/services/playground-service';
|
import { PlaygroundService } from 'lib/services/playground-service';
|
||||||
import { GroupService } from '../services/group-service';
|
import { GroupService } from '../services/group-service';
|
||||||
|
import { ProxyService } from '../services/proxy-service';
|
||||||
|
|
||||||
export interface IUnleashServices {
|
export interface IUnleashServices {
|
||||||
accessService: AccessService;
|
accessService: AccessService;
|
||||||
@ -63,4 +64,5 @@ export interface IUnleashServices {
|
|||||||
openApiService: OpenApiService;
|
openApiService: OpenApiService;
|
||||||
clientSpecService: ClientSpecService;
|
clientSpecService: ClientSpecService;
|
||||||
playgroundService: PlaygroundService;
|
playgroundService: PlaygroundService;
|
||||||
|
proxyService: ProxyService;
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import EventEmitter from 'events';
|
|
||||||
import { IBaseEvent, IEvent } from '../events';
|
import { IBaseEvent, IEvent } from '../events';
|
||||||
import { Store } from './store';
|
import { Store } from './store';
|
||||||
import { SearchEventsSchema } from '../../openapi/spec/search-events-schema';
|
import { SearchEventsSchema } from '../../openapi/spec/search-events-schema';
|
||||||
|
import { AnyEventEmitter } from 'lib/util/anyEventEmitter';
|
||||||
|
|
||||||
export interface IEventStore extends Store<IEvent, number>, EventEmitter {
|
export interface IEventStore extends Store<IEvent, number>, AnyEventEmitter {
|
||||||
store(event: IBaseEvent): Promise<void>;
|
store(event: IBaseEvent): Promise<void>;
|
||||||
batchStore(events: IBaseEvent[]): Promise<void>;
|
batchStore(events: IBaseEvent[]): Promise<void>;
|
||||||
getEvents(): Promise<IEvent[]>;
|
getEvents(): Promise<IEvent[]>;
|
||||||
|
22
src/lib/util/anyEventEmitter.test.ts
Normal file
22
src/lib/util/anyEventEmitter.test.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { AnyEventEmitter } from './anyEventEmitter';
|
||||||
|
|
||||||
|
test('AnyEventEmitter', () => {
|
||||||
|
const events = [];
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
class MyEventEmitter extends AnyEventEmitter {}
|
||||||
|
const myEventEmitter = new MyEventEmitter();
|
||||||
|
|
||||||
|
myEventEmitter.on('a', () => events.push('a'));
|
||||||
|
myEventEmitter.on('b', () => events.push('b'));
|
||||||
|
myEventEmitter.on('c', () => events.push('c'));
|
||||||
|
myEventEmitter.on('*', () => events.push('*'));
|
||||||
|
|
||||||
|
results.push(myEventEmitter.emit('a'));
|
||||||
|
results.push(myEventEmitter.emit('b'));
|
||||||
|
results.push(myEventEmitter.emit('c'));
|
||||||
|
results.push(myEventEmitter.emit('d'));
|
||||||
|
|
||||||
|
expect(events).toEqual(['*', 'a', '*', 'b', '*', 'c', '*']);
|
||||||
|
expect(results).toEqual([true, true, true, false]);
|
||||||
|
});
|
12
src/lib/util/anyEventEmitter.ts
Normal file
12
src/lib/util/anyEventEmitter.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import EventEmitter from 'events';
|
||||||
|
|
||||||
|
export const ANY_EVENT = '*';
|
||||||
|
|
||||||
|
// Extends the built-in EventEmitter with support for listening for any event.
|
||||||
|
// See https://stackoverflow.com/a/54431931.
|
||||||
|
export class AnyEventEmitter extends EventEmitter {
|
||||||
|
emit(type: string, ...args: any[]): boolean {
|
||||||
|
super.emit(ANY_EVENT, ...args);
|
||||||
|
return super.emit(type, ...args) || super.emit('', ...args);
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ClientInitOptions,
|
ClientInitOptions,
|
||||||
mapFeaturesForBootstrap,
|
mapFeaturesForClient,
|
||||||
mapSegmentsForBootstrap,
|
mapSegmentsForClient,
|
||||||
offlineUnleashClient,
|
offlineUnleashClient,
|
||||||
} from './offline-unleash-client';
|
} from './offline-unleash-client';
|
||||||
import {
|
import {
|
||||||
@ -25,8 +25,8 @@ export const offlineUnleashClientNode = async ({
|
|||||||
url: 'not-needed',
|
url: 'not-needed',
|
||||||
storageProvider: new InMemStorageProviderNode(),
|
storageProvider: new InMemStorageProviderNode(),
|
||||||
bootstrap: {
|
bootstrap: {
|
||||||
data: mapFeaturesForBootstrap(features),
|
data: mapFeaturesForClient(features),
|
||||||
segments: mapSegmentsForBootstrap(segments),
|
segments: mapSegmentsForClient(segments),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -13,7 +13,7 @@ enum PayloadType {
|
|||||||
|
|
||||||
type NonEmptyList<T> = [T, ...T[]];
|
type NonEmptyList<T> = [T, ...T[]];
|
||||||
|
|
||||||
export const mapFeaturesForBootstrap = (
|
export const mapFeaturesForClient = (
|
||||||
features: FeatureConfigurationClient[],
|
features: FeatureConfigurationClient[],
|
||||||
): FeatureInterface[] =>
|
): FeatureInterface[] =>
|
||||||
features.map((feature) => ({
|
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[];
|
serializeDates(segments) as Segment[];
|
||||||
|
|
||||||
export type ClientInitOptions = {
|
export type ClientInitOptions = {
|
||||||
@ -61,8 +61,8 @@ export const offlineUnleashClient = async ({
|
|||||||
appName: context.appName,
|
appName: context.appName,
|
||||||
storageProvider: new InMemStorageProvider(),
|
storageProvider: new InMemStorageProvider(),
|
||||||
bootstrap: {
|
bootstrap: {
|
||||||
data: mapFeaturesForBootstrap(features),
|
data: mapFeaturesForClient(features),
|
||||||
segments: mapSegmentsForBootstrap(segments),
|
segments: mapSegmentsForClient(segments),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -34,6 +34,7 @@ process.nextTick(async () => {
|
|||||||
metricsV2: { enabled: true },
|
metricsV2: { enabled: true },
|
||||||
anonymiseEventLog: false,
|
anonymiseEventLog: false,
|
||||||
userGroups: true,
|
userGroups: true,
|
||||||
|
embedProxy: true,
|
||||||
},
|
},
|
||||||
authentication: {
|
authentication: {
|
||||||
initApiTokens: [
|
initApiTokens: [
|
||||||
|
@ -24,6 +24,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
|
|||||||
},
|
},
|
||||||
experimental: {
|
experimental: {
|
||||||
userGroups: true,
|
userGroups: true,
|
||||||
|
embedProxy: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const options = mergeAll<IUnleashOptions>([testConfig, config]);
|
const options = mergeAll<IUnleashOptions>([testConfig, config]);
|
||||||
|
@ -1862,6 +1862,7 @@ test('Should not allow changing project to target project without the same enabl
|
|||||||
project: '*',
|
project: '*',
|
||||||
type: ApiTokenType.ADMIN,
|
type: ApiTokenType.ADMIN,
|
||||||
environment: '*',
|
environment: '*',
|
||||||
|
secret: 'a',
|
||||||
});
|
});
|
||||||
await expect(async () =>
|
await expect(async () =>
|
||||||
app.services.projectService.changeProject(
|
app.services.projectService.changeProject(
|
||||||
@ -1945,6 +1946,7 @@ test('Should allow changing project to target project with the same enabled envi
|
|||||||
project: '*',
|
project: '*',
|
||||||
type: ApiTokenType.ADMIN,
|
type: ApiTokenType.ADMIN,
|
||||||
environment: '*',
|
environment: '*',
|
||||||
|
secret: 'a',
|
||||||
});
|
});
|
||||||
await expect(async () =>
|
await expect(async () =>
|
||||||
app.services.projectService.changeProject(
|
app.services.projectService.changeProject(
|
||||||
|
@ -230,7 +230,7 @@ Object {
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
},
|
},
|
||||||
"type": Object {
|
"type": Object {
|
||||||
"description": "client, admin.",
|
"description": "client, admin, proxy.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
},
|
},
|
||||||
"username": Object {
|
"username": Object {
|
||||||
@ -667,7 +667,7 @@ Object {
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
},
|
},
|
||||||
"type": Object {
|
"type": Object {
|
||||||
"description": "client, admin.",
|
"description": "client, admin, proxy.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
},
|
},
|
||||||
"username": Object {
|
"username": Object {
|
||||||
@ -2129,6 +2129,201 @@ Object {
|
|||||||
],
|
],
|
||||||
"type": "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 {
|
"resetPasswordSchema": Object {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": Object {
|
"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 {
|
"/auth/reset/password": Object {
|
||||||
"post": Object {
|
"post": Object {
|
||||||
"operationId": "changePassword",
|
"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).",
|
"description": "Create, update, and delete [tags and tag types](https://docs.getunleash.io/advanced/tags).",
|
||||||
"name": "Tags",
|
"name": "Tags",
|
||||||
},
|
},
|
||||||
|
Object {
|
||||||
|
"description": "Experimental endpoints that may change or disappear at any time.",
|
||||||
|
"name": "Unstable",
|
||||||
|
},
|
||||||
Object {
|
Object {
|
||||||
"description": "Manage users and passwords.",
|
"description": "Manage users and passwords.",
|
||||||
"name": "Users",
|
"name": "Users",
|
||||||
|
601
src/test/e2e/api/proxy/proxy.e2e.test.ts
Normal file
601
src/test/e2e/api/proxy/proxy.e2e.test.ts
Normal 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' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -19,7 +19,7 @@
|
|||||||
"contextFields": [
|
"contextFields": [
|
||||||
{ "name": "environment" },
|
{ "name": "environment" },
|
||||||
{ "name": "userId" },
|
{ "name": "userId" },
|
||||||
{ "name": "appNam" }
|
{ "name": "appName" }
|
||||||
],
|
],
|
||||||
"projects": [
|
"projects": [
|
||||||
{
|
{
|
||||||
|
4
src/test/fixtures/fake-event-store.ts
vendored
4
src/test/fixtures/fake-event-store.ts
vendored
@ -1,8 +1,8 @@
|
|||||||
import EventEmitter from 'events';
|
|
||||||
import { IEventStore } from '../../lib/types/stores/event-store';
|
import { IEventStore } from '../../lib/types/stores/event-store';
|
||||||
import { IEvent } from '../../lib/types/events';
|
import { IEvent } from '../../lib/types/events';
|
||||||
|
import { AnyEventEmitter } from '../../lib/util/anyEventEmitter';
|
||||||
|
|
||||||
class FakeEventStore extends EventEmitter implements IEventStore {
|
class FakeEventStore extends AnyEventEmitter implements IEventStore {
|
||||||
events: IEvent[];
|
events: IEvent[];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
Loading…
Reference in New Issue
Block a user