1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-20 00:08:02 +01:00

Refactor/separate client and admin store (#5006)

This PR is the first step in separating the client and admin stores.
Currently our feature toggle services uses the client store to serve
multiple purposes. 

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

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

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

Next steps: 
* Move playground to admin API
* Remove client-feature-toggle-store from feature-toggle-service
This commit is contained in:
Fredrik Strand Oseberg 2023-10-12 13:58:23 +02:00 committed by GitHub
parent 7b7a2a706c
commit f34d187cd9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 627 additions and 136 deletions

View File

@ -110,6 +110,7 @@ exports[`should create default config 1`] = `
"privateProjects": false,
"proPlanAutoCharge": false,
"responseTimeWithAppNameKillSwitch": false,
"separateAdminClientApi": false,
"strictSchemaValidation": false,
"transactionalDecorator": false,
"useLastSeenRefactor": false,
@ -153,6 +154,7 @@ exports[`should create default config 1`] = `
"privateProjects": false,
"proPlanAutoCharge": false,
"responseTimeWithAppNameKillSwitch": false,
"separateAdminClientApi": false,
"strictSchemaValidation": false,
"transactionalDecorator": false,
"useLastSeenRefactor": false,

View File

@ -19,7 +19,7 @@ import { AccessStore } from './access-store';
import { ResetTokenStore } from './reset-token-store';
import UserFeedbackStore from './user-feedback-store';
import FeatureStrategyStore from '../features/feature-toggle/feature-toggle-strategies-store';
import FeatureToggleClientStore from './feature-toggle-client-store';
import FeatureToggleClientStore from '../features/client-feature-toggles/client-feature-toggle-store';
import EnvironmentStore from './environment-store';
import FeatureTagStore from './feature-tag-store';
import { FeatureEnvironmentStore } from './feature-environment-store';
@ -91,7 +91,7 @@ export const createStores = (
getLogger,
config.flagResolver,
),
featureToggleClientStore: new FeatureToggleClientStore(
clientFeatureToggleStore: new FeatureToggleClientStore(
db,
eventBus,
getLogger,

View File

@ -0,0 +1,61 @@
import {
IFeatureNaming,
IFeatureToggleClientStore,
IFeatureToggleQuery,
IUnleashConfig,
IUnleashStores,
} from '../../types';
import { Logger } from '../../logger';
import { FeatureConfigurationClient } from '../feature-toggle/types/feature-toggle-strategies-store-type';
export class ClientFeatureToggleService {
private logger: Logger;
private clientFeatureToggleStore: IFeatureToggleClientStore;
constructor(
{
clientFeatureToggleStore,
}: Pick<IUnleashStores, 'clientFeatureToggleStore'>,
{ getLogger }: Pick<IUnleashConfig, 'getLogger' | 'flagResolver'>,
) {
this.logger = getLogger('services/client-feature-toggle-service.ts');
this.clientFeatureToggleStore = clientFeatureToggleStore;
}
async getClientFeatures(
query?: IFeatureToggleQuery,
): Promise<FeatureConfigurationClient[]> {
const result = await this.clientFeatureToggleStore.getClient(
query || {},
);
return result.map(
({
name,
type,
enabled,
project,
stale,
strategies,
variants,
description,
impressionData,
dependencies,
}) => ({
name,
type,
enabled,
project,
stale,
strategies,
variants,
description,
impressionData,
dependencies,
}),
);
}
}

View File

@ -1,7 +1,7 @@
import { Knex } from 'knex';
import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events';
import { Logger, LogProvider } from '../logger';
import metricsHelper from '../../util/metrics-helper';
import { DB_TIME } from '../../metric-events';
import { Logger, LogProvider } from '../../logger';
import {
IFeatureToggleClient,
IFeatureToggleClientStore,
@ -10,11 +10,11 @@ import {
IStrategyConfig,
ITag,
PartialDeep,
} from '../types';
import { DEFAULT_ENV, ensureStringValue, mapValues } from '../util';
} from '../../types';
import { DEFAULT_ENV, ensureStringValue, mapValues } from '../../util';
import EventEmitter from 'events';
import FeatureToggleStore from '../features/feature-toggle/feature-toggle-store';
import { Db } from './db';
import FeatureToggleStore from '../feature-toggle/feature-toggle-store';
import { Db } from '../../db/db';
import Raw = Knex.Raw;
export interface IGetAllFeatures {

View File

@ -2,17 +2,23 @@ import memoizee from 'memoizee';
import { Response } from 'express';
// eslint-disable-next-line import/no-extraneous-dependencies
import hashSum from 'hash-sum';
import Controller from '../controller';
import { IClientSegment, IUnleashConfig, IUnleashServices } from '../../types';
import FeatureToggleService from '../../features/feature-toggle/feature-toggle-service';
import Controller from '../../routes/controller';
import {
IClientSegment,
IFeatureToggleStore,
IFlagResolver,
IUnleashConfig,
IUnleashServices,
} from '../../types';
import FeatureToggleService from '../feature-toggle/feature-toggle-service';
import { Logger } from '../../logger';
import { querySchema } from '../../schema/feature-schema';
import { IFeatureToggleQuery } from '../../types/model';
import NotFoundError from '../../error/notfound-error';
import { IAuthRequest } from '../unleash-types';
import { IAuthRequest } from '../../routes/unleash-types';
import ApiUser from '../../types/api-user';
import { ALL, isAllProjects } from '../../types/models/api-token';
import { FeatureConfigurationClient } from '../../features/feature-toggle/types/feature-toggle-strategies-store-type';
import { FeatureConfigurationClient } from '../feature-toggle/types/feature-toggle-strategies-store-type';
import { ClientSpecService } from '../../services/client-spec-service';
import { OpenApiService } from '../../services/openapi-service';
import { NONE } from '../../types/permissions';
@ -27,7 +33,8 @@ import {
ClientFeaturesSchema,
} from '../../openapi/spec/client-features-schema';
import { ISegmentService } from '../../segments/segment-service-interface';
import ConfigurationRevisionService from '../../features/feature-toggle/configuration-revision-service';
import ConfigurationRevisionService from '../feature-toggle/configuration-revision-service';
import { ClientFeatureToggleService } from './client-feature-toggle-service';
const version = 2;
@ -45,7 +52,7 @@ interface IMeta {
export default class FeatureController extends Controller {
private readonly logger: Logger;
private featureToggleServiceV2: FeatureToggleService;
private clientFeatureToggleService: ClientFeatureToggleService;
private segmentService: ISegmentService;
@ -55,6 +62,10 @@ export default class FeatureController extends Controller {
private configurationRevisionService: ConfigurationRevisionService;
private featureToggleService: FeatureToggleService;
private flagResolver: IFlagResolver;
private featuresAndSegments: (
query: IFeatureToggleQuery,
etag: string,
@ -62,28 +73,32 @@ export default class FeatureController extends Controller {
constructor(
{
featureToggleServiceV2,
clientFeatureToggleService,
segmentService,
clientSpecService,
openApiService,
configurationRevisionService,
featureToggleService,
}: Pick<
IUnleashServices,
| 'featureToggleServiceV2'
| 'clientFeatureToggleService'
| 'segmentService'
| 'clientSpecService'
| 'openApiService'
| 'configurationRevisionService'
| 'featureToggleService'
>,
config: IUnleashConfig,
) {
super(config);
const { clientFeatureCaching } = config;
this.featureToggleServiceV2 = featureToggleServiceV2;
this.clientFeatureToggleService = clientFeatureToggleService;
this.segmentService = segmentService;
this.clientSpecService = clientSpecService;
this.openApiService = openApiService;
this.configurationRevisionService = configurationRevisionService;
this.featureToggleService = featureToggleService;
this.flagResolver = config.flagResolver;
this.logger = config.getLogger('client-api/feature.js');
this.route({
@ -146,8 +161,15 @@ export default class FeatureController extends Controller {
private async resolveFeaturesAndSegments(
query?: IFeatureToggleQuery,
): Promise<[FeatureConfigurationClient[], IClientSegment[]]> {
if (this.flagResolver.isEnabled('separateAdminClientApi')) {
return Promise.all([
this.clientFeatureToggleService.getClientFeatures(query),
this.segmentService.getActiveForClient(),
]);
}
return Promise.all([
this.featureToggleServiceV2.getClientFeatures(query),
this.featureToggleService.getClientFeatures(query),
this.segmentService.getActiveForClient(),
]);
}
@ -287,7 +309,15 @@ export default class FeatureController extends Controller {
const name = req.params.featureName;
const featureQuery = await this.resolveQuery(req);
const q = { ...featureQuery, namePrefix: name };
const toggles = await this.featureToggleServiceV2.getClientFeatures(q);
let toggles = await this.featureToggleService.getClientFeatures(q);
if (this.flagResolver.isEnabled('separateAdminClientApi')) {
toggles = await this.clientFeatureToggleService.getClientFeatures(
q,
);
}
const toggle = toggles.find((t) => t.name === name);
if (!toggle) {
throw new NotFoundError(`Could not find feature toggle ${name}`);

View File

@ -0,0 +1,45 @@
import FeatureToggleClientStore from '../client-feature-toggles/client-feature-toggle-store';
import { Db } from '../../db/db';
import { IUnleashConfig } from '../../types';
import FakeClientFeatureToggleStore from './fakes/fake-client-feature-toggle-store';
import { ClientFeatureToggleService } from './client-feature-toggle-service';
export const createClientFeatureToggleService = (
db: Db,
config: IUnleashConfig,
): ClientFeatureToggleService => {
const { getLogger, eventBus, flagResolver } = config;
const featureToggleClientStore = new FeatureToggleClientStore(
db,
eventBus,
getLogger,
flagResolver,
);
const clientFeatureToggleService = new ClientFeatureToggleService(
{
clientFeatureToggleStore: featureToggleClientStore,
},
{ getLogger, flagResolver },
);
return clientFeatureToggleService;
};
export const createFakeClientFeatureToggleService = (
config: IUnleashConfig,
): ClientFeatureToggleService => {
const { getLogger, flagResolver } = config;
const fakeClientFeatureToggleStore = new FakeClientFeatureToggleStore();
const clientFeatureToggleService = new ClientFeatureToggleService(
{
clientFeatureToggleStore: fakeClientFeatureToggleStore,
},
{ getLogger, flagResolver },
);
return clientFeatureToggleService;
};

View File

@ -2,11 +2,11 @@ import {
FeatureToggle,
IFeatureToggleClient,
IFeatureToggleQuery,
} from '../../lib/types/model';
import { IFeatureToggleClientStore } from '../../lib/types/stores/feature-toggle-client-store';
import { IGetAdminFeatures } from '../../lib/db/feature-toggle-client-store';
} from '../../../types/model';
import { IFeatureToggleClientStore } from '../types/client-feature-toggle-store-type';
import { IGetAdminFeatures } from '../client-feature-toggle-store';
export default class FakeFeatureToggleClientStore
export default class FakeClientFeatureToggleStore
implements IFeatureToggleClientStore
{
featureToggles: FeatureToggle[] = [];
@ -34,6 +34,7 @@ export default class FakeFeatureToggleClientStore
}
return toggle.archived === archived;
});
const clientRows: IFeatureToggleClient[] = rows.map((t) => ({
...t,
enabled: true,
@ -81,6 +82,7 @@ export default class FakeFeatureToggleClientStore
archived: false,
...feature,
});
return Promise.resolve();
}
}

View File

@ -1,12 +1,14 @@
import supertest from 'supertest';
import createStores from '../../../test/fixtures/store';
import getLogger from '../../../test/fixtures/no-logger';
import getApp from '../../app';
import { createServices } from '../../services';
import FeatureController from './feature';
import { createTestConfig } from '../../../test/config/test-config';
import createStores from '../../../../test/fixtures/store';
import getLogger from '../../../../test/fixtures/no-logger';
import getApp from '../../../app';
import { createServices } from '../../../services';
import FeatureController from '../client-feature-toggle.controller';
import { createTestConfig } from '../../../../test/config/test-config';
import { secondsToMilliseconds } from 'date-fns';
import { ClientSpecService } from '../../services/client-spec-service';
import { ClientSpecService } from '../../../services/client-spec-service';
let app;
async function getSetup() {
const base = `/random${Math.round(Math.random() * 1000)}`;
@ -16,12 +18,11 @@ async function getSetup() {
});
const services = createServices(stores, config);
const app = await getApp(config, stores, services);
app = await getApp(config, stores, services);
return {
base,
featureToggleStore: stores.featureToggleStore,
featureToggleClientStore: stores.featureToggleClientStore,
clientFeatureToggleStore: stores.clientFeatureToggleStore,
request: supertest(app),
destroy: () => {
services.versionService.destroy();
@ -44,7 +45,6 @@ const callGetAll = async (controller: FeatureController) => {
let base;
let request;
let destroy;
let featureToggleClientStore;
let flagResolver;
@ -52,7 +52,6 @@ beforeEach(async () => {
const setup = await getSetup();
base = setup.base;
request = setup.request;
featureToggleClientStore = setup.featureToggleClientStore;
destroy = setup.destroy;
flagResolver = {
isEnabled: () => {
@ -84,7 +83,8 @@ test('if caching is enabled should memoize', async () => {
const validPath = jest.fn().mockReturnValue(jest.fn());
const clientSpecService = new ClientSpecService({ getLogger });
const openApiService = { respondWithValidation, validPath };
const featureToggleServiceV2 = { getClientFeatures };
const clientFeatureToggleService = { getClientFeatures };
const featureToggleService = { getClientFeatures };
const segmentService = { getActive, getActiveForClient };
const configurationRevisionService = { getMaxRevisionId: () => 1 };
@ -94,7 +94,9 @@ test('if caching is enabled should memoize', async () => {
// @ts-expect-error due to partial implementation
openApiService,
// @ts-expect-error due to partial implementation
featureToggleServiceV2,
clientFeatureToggleService,
// @ts-expect-error due to partial implementation
featureToggleService,
// @ts-expect-error due to partial implementation
segmentService,
// @ts-expect-error due to partial implementation
@ -122,8 +124,9 @@ test('if caching is not enabled all calls goes to service', async () => {
const respondWithValidation = jest.fn().mockReturnValue({});
const validPath = jest.fn().mockReturnValue(jest.fn());
const clientSpecService = new ClientSpecService({ getLogger });
const featureToggleServiceV2 = { getClientFeatures };
const clientFeatureToggleService = { getClientFeatures };
const segmentService = { getActive, getActiveForClient };
const featureToggleService = { getClientFeatures };
const openApiService = { respondWithValidation, validPath };
const configurationRevisionService = { getMaxRevisionId: () => 1 };
@ -133,7 +136,9 @@ test('if caching is not enabled all calls goes to service', async () => {
// @ts-expect-error due to partial implementation
openApiService,
// @ts-expect-error due to partial implementation
featureToggleServiceV2,
clientFeatureToggleService,
// @ts-expect-error due to partial implementation
featureToggleService,
// @ts-expect-error due to partial implementation
segmentService,
// @ts-expect-error due to partial implementation
@ -153,58 +158,3 @@ test('if caching is not enabled all calls goes to service', async () => {
await callGetAll(controller);
expect(getClientFeatures).toHaveBeenCalledTimes(2);
});
test('fetch single feature', async () => {
expect.assertions(1);
await featureToggleClientStore.createFeature({
name: 'test_',
strategies: [{ name: 'default' }],
});
return request
.get(`${base}/api/client/features/test_`)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.name === 'test_').toBe(true);
});
});
test('support name prefix', async () => {
expect.assertions(2);
await featureToggleClientStore.createFeature({ name: 'a_test1' });
await featureToggleClientStore.createFeature({ name: 'a_test2' });
await featureToggleClientStore.createFeature({ name: 'b_test1' });
await featureToggleClientStore.createFeature({ name: 'b_test2' });
const namePrefix = 'b_';
return request
.get(`${base}/api/client/features?namePrefix=${namePrefix}`)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.features.length).toBe(2);
expect(res.body.features[1].name).toBe('b_test2');
});
});
test('support filtering on project', async () => {
expect.assertions(2);
await featureToggleClientStore.createFeature({
name: 'a_test1',
project: 'projecta',
});
await featureToggleClientStore.createFeature({
name: 'b_test2',
project: 'projectb',
});
return request
.get(`${base}/api/client/features?project=projecta`)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.features).toHaveLength(1);
expect(res.body.features[0].name).toBe('a_test1');
});
});

View File

@ -0,0 +1,109 @@
import { RoleName } from '../../../types/model';
import dbInit, { ITestDb } from '../../../../test/e2e/helpers/database-init';
import {
IUnleashTest,
setupAppWithCustomConfig,
} from '../../../../test/e2e/helpers/test-helper';
import getLogger from '../../../../test/fixtures/no-logger';
import { DEFAULT_ENV } from '../../../util/constants';
let app: IUnleashTest;
let db: ITestDb;
let dummyAdmin;
beforeAll(async () => {
db = await dbInit('client_feature_toggles', getLogger);
app = await setupAppWithCustomConfig(
db.stores,
{
experimental: {
flags: {
strictSchemaValidation: true,
dependentFeatures: true,
},
},
},
db.rawDatabase,
);
dummyAdmin = await app.services.userService.createUser({
name: 'Some Name',
email: 'test@getunleash.io',
rootRole: RoleName.ADMIN,
});
});
afterEach(async () => {
const all = await db.stores.projectStore.getEnvironmentsForProject(
'default',
);
await Promise.all(
all
.filter((env) => env.environment !== DEFAULT_ENV)
.map(async (env) =>
db.stores.projectStore.deleteEnvironmentForProject(
'default',
env.environment,
),
),
);
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
test('should fetch single feature', async () => {
expect.assertions(1);
await app.createFeature('test_', 'default');
return app.request
.get(`/api/client/features/test_`)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.name === 'test_').toBe(true);
});
});
test('should support name prefix', async () => {
expect.assertions(2);
await app.createFeature('a_test1');
await app.createFeature('a_test2');
await app.createFeature('b_test1');
await app.createFeature('b_test2');
const namePrefix = 'b_';
return app.request
.get(`/api/client/features?namePrefix=${namePrefix}`)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.features.length).toBe(2);
expect(res.body.features[1].name).toBe('b_test2');
});
});
test('should support filtering on project', async () => {
expect.assertions(2);
await app.services.projectService.createProject(
{ name: 'projectA', id: 'projecta' },
dummyAdmin,
);
await app.services.projectService.createProject(
{ name: 'projectB', id: 'projectb' },
dummyAdmin,
);
await app.createFeature('ab_test1', 'projecta');
await app.createFeature('bd_test2', 'projectb');
return app.request
.get(`/api/client/features?project=projecta`)
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.features).toHaveLength(1);
expect(res.body.features[0].name).toBe('ab_test1');
});
});

View File

@ -1,5 +1,8 @@
import { IFeatureToggleClient, IFeatureToggleQuery } from '../model';
import { IGetAdminFeatures } from '../../db/feature-toggle-client-store';
import {
IFeatureToggleClient,
IFeatureToggleQuery,
} from '../../../types/model';
import { IGetAdminFeatures } from '../client-feature-toggle-store';
export interface IFeatureToggleClientStore {
getClient(

View File

@ -6,7 +6,7 @@ import {
} from '../../services';
import FeatureStrategiesStore from './feature-toggle-strategies-store';
import FeatureToggleStore from './feature-toggle-store';
import FeatureToggleClientStore from '../../db/feature-toggle-client-store';
import FeatureToggleClientStore from '../client-feature-toggles/client-feature-toggle-store';
import ProjectStore from '../../db/project-store';
import { FeatureEnvironmentStore } from '../../db/feature-environment-store';
import ContextFieldStore from '../../db/context-field-store';
@ -20,7 +20,7 @@ import { IUnleashConfig } from '../../types';
import FakeEventStore from '../../../test/fixtures/fake-event-store';
import FakeFeatureStrategiesStore from './fakes/fake-feature-strategies-store';
import FakeFeatureToggleStore from './fakes/fake-feature-toggle-store';
import FakeFeatureToggleClientStore from '../../../test/fixtures/fake-feature-toggle-client-store';
import FakeClientFeatureToggleStore from '../client-feature-toggles/fakes/fake-client-feature-toggle-store';
import FakeProjectStore from '../../../test/fixtures/fake-project-store';
import FakeFeatureEnvironmentStore from '../../../test/fixtures/fake-feature-environment-store';
import FakeContextFieldStore from '../../../test/fixtures/fake-context-field-store';
@ -125,7 +125,7 @@ export const createFeatureToggleService = (
{
featureStrategiesStore,
featureToggleStore,
featureToggleClientStore,
clientFeatureToggleStore: featureToggleClientStore,
projectStore,
featureTagStore,
featureEnvironmentStore,
@ -152,7 +152,7 @@ export const createFakeFeatureToggleService = (
const strategyStore = new FakeStrategiesStore();
const featureStrategiesStore = new FakeFeatureStrategiesStore();
const featureToggleStore = new FakeFeatureToggleStore();
const featureToggleClientStore = new FakeFeatureToggleClientStore();
const featureToggleClientStore = new FakeClientFeatureToggleStore();
const projectStore = new FakeProjectStore();
const featureEnvironmentStore = new FakeFeatureEnvironmentStore();
const contextFieldStore = new FakeContextFieldStore();
@ -185,7 +185,7 @@ export const createFakeFeatureToggleService = (
{
featureStrategiesStore,
featureToggleStore,
featureToggleClientStore,
clientFeatureToggleStore: featureToggleClientStore,
projectStore,
featureTagStore,
featureEnvironmentStore,

View File

@ -1,5 +1,5 @@
import {
IFeatureToggleQuery,
IFeatureToggleStoreQuery,
IFeatureToggleStore,
} from '../types/feature-toggle-store-type';
import NotFoundError from '../../../error/notfound-error';
@ -7,6 +7,7 @@ import {
FeatureToggle,
FeatureToggleDTO,
IFeatureEnvironment,
IFeatureToggleQuery,
IVariant,
} from 'lib/types/model';
import { LastSeenInput } from '../../../services/client-metrics/last-seen/last-seen-service';
@ -66,7 +67,7 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
return features;
}
async count(query: Partial<IFeatureToggleQuery>): Promise<number> {
async count(query: Partial<IFeatureToggleStoreQuery>): Promise<number> {
return this.features.filter(this.getFilterQuery(query)).length;
}
@ -78,7 +79,7 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
return this.get(name).then((f) => f.project);
}
private getFilterQuery(query: Partial<IFeatureToggleQuery>) {
private getFilterQuery(query: Partial<IFeatureToggleStoreQuery>) {
return (f) => {
let projectMatch = true;
if (query.project) {
@ -135,7 +136,9 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
return this.get(name);
}
async getBy(query: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]> {
async getBy(
query: Partial<IFeatureToggleStoreQuery>,
): Promise<FeatureToggle[]> {
return this.features.filter(this.getFilterQuery(query));
}
@ -147,6 +150,14 @@ export default class FakeFeatureToggleStore implements IFeatureToggleStore {
return this.update(revive.project, revive);
}
async getFeatureToggleList(
query?: IFeatureToggleQuery,
userId?: number,
archived: boolean = false,
): Promise<FeatureToggle[]> {
return this.features.filter((f) => f.archived !== archived);
}
async update(
project: string,
data: FeatureToggleDTO,

View File

@ -140,7 +140,7 @@ class FeatureToggleService {
private featureToggleStore: IFeatureToggleStore;
private featureToggleClientStore: IFeatureToggleClientStore;
private clientFeatureToggleStore: IFeatureToggleClientStore;
private tagStore: IFeatureTagStore;
@ -170,7 +170,7 @@ class FeatureToggleService {
{
featureStrategiesStore,
featureToggleStore,
featureToggleClientStore,
clientFeatureToggleStore,
projectStore,
featureTagStore,
featureEnvironmentStore,
@ -180,7 +180,7 @@ class FeatureToggleService {
IUnleashStores,
| 'featureStrategiesStore'
| 'featureToggleStore'
| 'featureToggleClientStore'
| 'clientFeatureToggleStore'
| 'projectStore'
| 'featureTagStore'
| 'featureEnvironmentStore'
@ -203,7 +203,7 @@ class FeatureToggleService {
this.featureStrategiesStore = featureStrategiesStore;
this.strategyStore = strategyStore;
this.featureToggleStore = featureToggleStore;
this.featureToggleClientStore = featureToggleClientStore;
this.clientFeatureToggleStore = clientFeatureToggleStore;
this.tagStore = featureTagStore;
this.projectStore = projectStore;
this.featureEnvironmentStore = featureEnvironmentStore;
@ -1016,7 +1016,7 @@ class FeatureToggleService {
async getClientFeatures(
query?: IFeatureToggleQuery,
): Promise<FeatureConfigurationClient[]> {
const result = await this.featureToggleClientStore.getClient(
const result = await this.clientFeatureToggleStore.getClient(
query || {},
);
return result.map(
@ -1049,7 +1049,7 @@ class FeatureToggleService {
async getPlaygroundFeatures(
query?: IFeatureToggleQuery,
): Promise<FeatureConfigurationClient[]> {
const result = await this.featureToggleClientStore.getPlayground(
const result = await this.clientFeatureToggleStore.getPlayground(
query || {},
);
return result;
@ -1068,11 +1068,19 @@ class FeatureToggleService {
userId?: number,
archived: boolean = false,
): Promise<FeatureToggle[]> {
const features = await this.featureToggleClientStore.getAdmin({
let features = (await this.clientFeatureToggleStore.getAdmin({
featureQuery: query,
userId,
archived,
});
userId: userId,
archived: false,
})) as FeatureToggle[];
if (this.flagResolver.isEnabled('separateAdminClientApi')) {
features = await this.featureToggleStore.getFeatureToggleList(
query,
userId,
archived,
);
}
if (this.flagResolver.isEnabled('privateProjects') && userId) {
const projectAccess =

View File

@ -4,11 +4,23 @@ import metricsHelper from '../../util/metrics-helper';
import { DB_TIME } from '../../metric-events';
import NotFoundError from '../../error/notfound-error';
import { Logger, LogProvider } from '../../logger';
import { FeatureToggle, FeatureToggleDTO, IVariant } from '../../types/model';
import {
FeatureToggle,
FeatureToggleDTO,
IFeatureToggleQuery,
IVariant,
} from '../../types/model';
import { IFeatureToggleStore } from './types/feature-toggle-store-type';
import { Db } from '../../db/db';
import { LastSeenInput } from '../../services/client-metrics/last-seen/last-seen-service';
import { NameExistsError } from '../../error';
import { DEFAULT_ENV, ensureStringValue, mapValues } from '../../../lib/util';
import {
IFeatureToggleClient,
IStrategyConfig,
ITag,
PartialDeep,
} from '../../../lib/types';
export type EnvironmentFeatureNames = { [key: string]: string[] };
@ -44,6 +56,81 @@ interface VariantDTO {
const TABLE = 'features';
const FEATURE_ENVIRONMENTS_TABLE = 'feature_environments';
const isUnseenStrategyRow = (
feature: PartialDeep<IFeatureToggleClient>,
row: Record<string, any>,
): boolean => {
return (
row.strategy_id &&
!feature.strategies?.find((s) => s?.id === row.strategy_id)
);
};
const isNewTag = (
feature: PartialDeep<IFeatureToggleClient>,
row: Record<string, any>,
): boolean => {
return (
row.tag_type &&
row.tag_value &&
!feature.tags?.some(
(tag) => tag?.type === row.tag_type && tag?.value === row.tag_value,
)
);
};
const addSegmentToStrategy = (
feature: PartialDeep<IFeatureToggleClient>,
row: Record<string, any>,
) => {
feature.strategies
?.find((s) => s?.id === row.strategy_id)
?.constraints?.push(...row.segment_constraints);
};
const addSegmentIdsToStrategy = (
feature: PartialDeep<IFeatureToggleClient>,
row: Record<string, any>,
) => {
const strategy = feature.strategies?.find((s) => s?.id === row.strategy_id);
if (!strategy) {
return;
}
if (!strategy.segments) {
strategy.segments = [];
}
strategy.segments.push(row.segment_id);
};
const rowToStrategy = (row: Record<string, any>): IStrategyConfig => {
const strategy: IStrategyConfig = {
id: row.strategy_id,
name: row.strategy_name,
title: row.strategy_title,
constraints: row.constraints || [],
parameters: mapValues(row.parameters || {}, ensureStringValue),
sortOrder: row.sort_order,
};
strategy.variants = row.strategy_variants || [];
return strategy;
};
const addTag = (
feature: Record<string, any>,
row: Record<string, any>,
): void => {
const tags = feature.tags || [];
const newTag = rowToTag(row);
feature.tags = [...tags, newTag];
};
const rowToTag = (row: Record<string, any>): ITag => {
return {
value: row.tag_value,
type: row.tag_type,
};
};
export default class FeatureToggleStore implements IFeatureToggleStore {
private db: Db;
@ -91,6 +178,132 @@ export default class FeatureToggleStore implements IFeatureToggleStore {
.then(this.rowToFeature);
}
async getFeatureToggleList(
featureQuery?: IFeatureToggleQuery,
userId?: number,
archived: boolean = false,
): Promise<FeatureToggle[]> {
// Handle the admin case first
// Handle the playground case
const environment = featureQuery?.environment || DEFAULT_ENV;
const selectColumns = [
'features.name as name',
'features.description as description',
'features.type as type',
'features.project as project',
'features.stale as stale',
'features.impression_data as impression_data',
'features.last_seen_at as last_seen_at',
'features.created_at as created_at',
'fe.variants as variants',
'fe.enabled as enabled',
'fe.environment as environment',
'fs.id as strategy_id',
'fs.strategy_name as strategy_name',
'fs.title as strategy_title',
'fs.disabled as strategy_disabled',
'fs.parameters as parameters',
'fs.constraints as constraints',
'fs.sort_order as sort_order',
'fs.variants as strategy_variants',
'segments.id as segment_id',
'segments.constraints as segment_constraints',
] as (string | Knex.Raw<any>)[];
let query = this.db('features')
.modify(FeatureToggleStore.filterByArchived, archived)
.leftJoin(
this.db('feature_strategies')
.select('*')
.where({ environment })
.as('fs'),
'fs.feature_name',
'features.name',
)
.leftJoin(
this.db('feature_environments')
.select(
'feature_name',
'enabled',
'environment',
'variants',
'last_seen_at',
)
.where({ environment })
.as('fe'),
'fe.feature_name',
'features.name',
)
.leftJoin(
'feature_strategy_segment as fss',
`fss.feature_strategy_id`,
`fs.id`,
)
.leftJoin('segments', `segments.id`, `fss.segment_id`)
.leftJoin('dependent_features as df', 'df.child', 'features.name')
.leftJoin('feature_tag as ft', 'ft.feature_name', 'features.name');
if (userId) {
query = query.leftJoin(`favorite_features`, function () {
this.on('favorite_features.feature', 'features.name').andOnVal(
'favorite_features.user_id',
'=',
userId,
);
});
selectColumns.push(
this.db.raw(
'favorite_features.feature is not null as favorite',
),
);
}
query = query.select(selectColumns);
const rows = await query;
const featureToggles = rows.reduce((acc, r) => {
const feature: PartialDeep<IFeatureToggleClient> = acc[r.name] ?? {
strategies: [],
};
if (isUnseenStrategyRow(feature, r) && !r.strategy_disabled) {
feature.strategies?.push(rowToStrategy(r));
}
if (isNewTag(feature, r)) {
addTag(feature, r);
}
if (featureQuery?.inlineSegmentConstraints && r.segment_id) {
addSegmentToStrategy(feature, r);
} else if (
!featureQuery?.inlineSegmentConstraints &&
r.segment_id
) {
addSegmentIdsToStrategy(feature, r);
}
feature.impressionData = r.impression_data;
feature.enabled = !!r.enabled;
feature.name = r.name;
feature.description = r.description;
feature.project = r.project;
feature.stale = r.stale;
feature.type = r.type;
feature.lastSeenAt = r.last_seen_at;
feature.variants = r.variants || [];
feature.project = r.project;
feature.favorite = r.favorite;
feature.lastSeenAt = r.last_seen_at;
feature.createdAt = r.created_at;
acc[r.name] = feature;
return acc;
}, {});
return Object.values(featureToggles);
}
async getAll(
query: {
archived?: boolean;

View File

@ -196,7 +196,7 @@ class FeatureController extends Controller {
namePrefix,
}: any): Promise<IFeatureToggleQuery> {
if (!tag && !project && !namePrefix) {
return null;
return {};
}
const tagQuery = this.paramToArray(tag);
const projectQuery = this.paramToArray(project);
@ -216,6 +216,7 @@ class FeatureController extends Controller {
res: Response<FeaturesSchema>,
): Promise<void> {
const query = await this.prepQuery(req.query);
const { user } = req;
const features = await this.service.getFeatureToggles(query, user.id);

View File

@ -3533,3 +3533,30 @@ test('should not be allowed to update with invalid strategy type name', async ()
400,
);
});
test('should return correct data structure for /api/admin/features', async () => {
await app.createFeature('refactor-features');
const result = await app.request.get('/api/admin/features').expect(200);
expect(result.body.features).toBeInstanceOf(Array);
const feature = result.body.features.find(
(features) => features.name === 'refactor-features',
);
expect(feature).toMatchObject({
impressionData: false,
enabled: false,
name: 'refactor-features',
description: null,
project: 'default',
stale: false,
type: 'release',
lastSeenAt: null,
variants: [],
favorite: false,
createdAt: expect.anything(),
strategies: [],
});
});

View File

@ -1,12 +1,13 @@
import {
FeatureToggle,
FeatureToggleDTO,
IFeatureToggleQuery,
IVariant,
} from '../../../types/model';
import { Store } from '../../../types/stores/store';
import { LastSeenInput } from '../../../services/client-metrics/last-seen/last-seen-service';
export interface IFeatureToggleQuery {
export interface IFeatureToggleStoreQuery {
archived: boolean;
project: string;
stale: boolean;
@ -14,7 +15,7 @@ export interface IFeatureToggleQuery {
}
export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
count(query?: Partial<IFeatureToggleQuery>): Promise<number>;
count(query?: Partial<IFeatureToggleStoreQuery>): Promise<number>;
setLastSeen(data: LastSeenInput[]): Promise<void>;
getProjectId(name: string): Promise<string | undefined>;
create(project: string, data: FeatureToggleDTO): Promise<FeatureToggle>;
@ -28,8 +29,13 @@ export interface IFeatureToggleStore extends Store<FeatureToggle, string> {
batchDelete(featureNames: string[]): Promise<void>;
batchRevive(featureNames: string[]): Promise<FeatureToggle[]>;
revive(featureName: string): Promise<FeatureToggle>;
getAll(query?: Partial<IFeatureToggleQuery>): Promise<FeatureToggle[]>;
getAll(query?: Partial<IFeatureToggleStoreQuery>): Promise<FeatureToggle[]>;
getAllByNames(names: string[]): Promise<FeatureToggle[]>;
getFeatureToggleList(
featureQuery?: IFeatureToggleQuery,
userId?: number,
archived?: boolean,
): Promise<FeatureToggle[]>;
countByDate(queryModifiers: {
archived?: boolean;
project?: string;

View File

@ -1,5 +1,5 @@
import Controller from '../controller';
import FeatureController from './feature';
import FeatureController from '../../features/client-feature-toggles/client-feature-toggle.controller';
import MetricsController from './metrics';
import RegisterController from './register';
import { IUnleashConfig, IUnleashServices } from '../../types';

View File

@ -211,8 +211,6 @@ test('should set lastSeen on toggle', async () => {
await services.lastSeenService.store();
const toggle = await stores.featureToggleStore.get('toggleLastSeen');
console.log(toggle);
expect(toggle.lastSeenAt).toBeTruthy();
});

View File

@ -89,6 +89,11 @@ import {
createFakeGetProductionChanges,
createGetProductionChanges,
} from '../features/instance-stats/getProductionChanges';
import {
createClientFeatureToggleService,
createFakeClientFeatureToggleService,
} from '../features/client-feature-toggles/createClientFeatureToggleService';
import { ClientFeatureToggleService } from '../features/client-feature-toggles/client-feature-toggle-service';
// TODO: will be moved to scheduler feature directory
export const scheduleServices = async (
@ -322,6 +327,10 @@ export const createServices = (
config,
);
const clientFeatureToggleService = db
? createClientFeatureToggleService(db, config)
: createFakeClientFeatureToggleService(config);
const proxyService = new ProxyService(config, stores, {
featureToggleServiceV2,
clientMetricsServiceV2,
@ -413,6 +422,7 @@ export const createServices = (
privateProjectChecker,
dependentFeaturesService,
transactionalDependentFeaturesService,
clientFeatureToggleService,
};
};
@ -457,4 +467,5 @@ export {
FavoritesService,
SchedulerService,
DependentFeaturesService,
ClientFeatureToggleService,
};

View File

@ -35,7 +35,9 @@ export type IFlagKey =
| 'disableMetrics'
| 'transactionalDecorator'
| 'useLastSeenRefactor'
| 'internalMessageBanners';
| 'internalMessageBanners'
| 'internalMessageBanner'
| 'separateAdminClientApi';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -167,6 +169,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_INTERNAL_MESSAGE_BANNERS,
false,
),
separateAdminClientApi: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_SEPARATE_ADMIN_CLIENT_API,
false,
),
};
export const defaultExperimentalOptions: IExperimentalOptions = {

View File

@ -49,6 +49,7 @@ import EventAnnouncerService from 'lib/services/event-announcer-service';
import { IPrivateProjectChecker } from '../features/private-project/privateProjectCheckerType';
import { DependentFeaturesService } from '../features/dependent-features/dependent-features-service';
import { WithTransactional } from 'lib/db/transaction';
import { ClientFeatureToggleService } from 'lib/features/client-feature-toggles/client-feature-toggle-service';
export interface IUnleashServices {
accessService: AccessService;
@ -108,4 +109,5 @@ export interface IUnleashServices {
transactionalDependentFeaturesService: (
db: Knex.Transaction,
) => DependentFeaturesService;
clientFeatureToggleService: ClientFeatureToggleService;
}

View File

@ -20,7 +20,7 @@ import { IUserFeedbackStore } from './stores/user-feedback-store';
import { IFeatureEnvironmentStore } from './stores/feature-environment-store';
import { IFeatureStrategiesStore } from '../features/feature-toggle/types/feature-toggle-strategies-store-type';
import { IEnvironmentStore } from './stores/environment-store';
import { IFeatureToggleClientStore } from './stores/feature-toggle-client-store';
import { IFeatureToggleClientStore } from '../features/client-feature-toggles/types/client-feature-toggle-store-type';
import { IClientMetricsStoreV2 } from './stores/client-metrics-store-v2';
import { IUserSplashStore } from './stores/user-splash-store';
import { IRoleStore } from './stores/role-store';
@ -52,7 +52,7 @@ export interface IUnleashStores {
featureStrategiesStore: IFeatureStrategiesStore;
featureTagStore: IFeatureTagStore;
featureToggleStore: IFeatureToggleStore;
featureToggleClientStore: IFeatureToggleClientStore;
clientFeatureToggleStore: IFeatureToggleClientStore;
featureTypeStore: IFeatureTypeStore;
groupStore: IGroupStore;
projectStore: IProjectStore;

View File

@ -48,6 +48,7 @@ process.nextTick(async () => {
dependentFeatures: true,
transactionalDecorator: true,
useLastSeenRefactor: true,
separateAdminClientApi: true,
},
},
authentication: {

View File

@ -1,4 +1,8 @@
import { IUnleashTest, setupApp } from '../../helpers/test-helper';
import {
IUnleashTest,
setupApp,
setupAppWithCustomConfig,
} from '../../helpers/test-helper';
import dbInit, { ITestDb } from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
import { DEFAULT_ENV } from '../../../../lib/util/constants';
@ -12,7 +16,7 @@ const projectId = 'default';
beforeAll(async () => {
db = await dbInit('feature_env_api_client', getLogger);
app = await setupApp(db.stores);
app = await setupAppWithCustomConfig(db.stores, {}, db.rawDatabase);
await app.services.featureToggleServiceV2.createFeatureToggle(
projectId,
@ -43,6 +47,7 @@ test('returns feature toggle for default env', async () => {
true,
'test',
);
await app.request
.get('/api/client/features')
.expect('Content-Type', /json/)

View File

@ -20,7 +20,7 @@ const feature3 = 'f3.p2.token.access';
beforeAll(async () => {
db = await dbInit('feature_api_api_access_client', getLogger);
app = await setupAppWithAuth(db.stores);
app = await setupAppWithAuth(db.stores, {}, db.rawDatabase);
apiTokenService = app.services.apiTokenService;
const { featureToggleServiceV2, environmentService } = app.services;

View File

@ -5,14 +5,14 @@ import { setupApp } from '../helpers/test-helper';
let stores;
let app;
let db;
let featureToggleClientStore;
let clientFeatureToggleStore;
beforeAll(async () => {
getLogger.setMuteError(true);
db = await dbInit('feature_toggle_client_store_serial', getLogger);
app = await setupApp(db.stores);
stores = db.stores;
featureToggleClientStore = stores.featureToggleClientStore;
clientFeatureToggleStore = stores.clientFeatureToggleStore;
});
afterAll(async () => {
@ -27,6 +27,6 @@ test('should be able to fetch client toggles', async () => {
expect(response.status).toBe(202);
const clientToggles = await featureToggleClientStore.getClient();
const clientToggles = await clientFeatureToggleStore.getClient();
expect(clientToggles).toHaveLength(1);
});

View File

@ -25,7 +25,7 @@ import FakeFeatureEnvironmentStore from './fake-feature-environment-store';
import FakeApiTokenStore from './fake-api-token-store';
import FakeFeatureTypeStore from './fake-feature-type-store';
import FakeResetTokenStore from './fake-reset-token-store';
import FakeFeatureToggleClientStore from './fake-feature-toggle-client-store';
import FakeClientFeatureToggleStore from '../../lib/features/client-feature-toggles/fakes/fake-client-feature-toggle-store';
import FakeClientMetricsStoreV2 from './fake-client-metrics-store-v2';
import FakeUserSplashStore from './fake-user-splash-store';
import FakeRoleStore from './fake-role-store';
@ -52,7 +52,7 @@ const createStores: () => IUnleashStores = () => {
clientMetricsStoreV2: new FakeClientMetricsStoreV2(),
clientInstanceStore: new FakeClientInstanceStore(),
featureToggleStore: new FakeFeatureToggleStore(),
featureToggleClientStore: new FakeFeatureToggleClientStore(),
clientFeatureToggleStore: new FakeClientFeatureToggleStore(),
tagStore: new FakeTagStore(),
tagTypeStore: new FakeTagTypeStore(),
eventStore: new FakeEventStore(),