From b9f1d8414c7e485fdc8ce95119fe1e387123ee35 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Tue, 6 May 2025 14:57:52 +0200 Subject: [PATCH] feat: add links to feature read model (#9905) --- .../fake-feature-links-read-model.ts | 10 ++++++ .../feature-links/feature-link.e2e.test.ts | 4 +++ .../feature-links-read-model-type.ts | 9 ++++++ .../feature-links/feature-links-read-model.ts | 25 +++++++++++++++ .../createFeatureToggleService.ts | 7 ++++ .../feature-toggle/feature-toggle-service.ts | 32 ++++++++++++++----- src/lib/openapi/spec/feature-schema.ts | 29 +++++++++++++++++ .../feature-service-potentially-stale.test.ts | 2 ++ src/lib/services/index.ts | 7 ++++ src/lib/types/model.ts | 2 ++ 10 files changed, 119 insertions(+), 8 deletions(-) create mode 100644 src/lib/features/feature-links/fake-feature-links-read-model.ts create mode 100644 src/lib/features/feature-links/feature-links-read-model-type.ts create mode 100644 src/lib/features/feature-links/feature-links-read-model.ts diff --git a/src/lib/features/feature-links/fake-feature-links-read-model.ts b/src/lib/features/feature-links/fake-feature-links-read-model.ts new file mode 100644 index 0000000000..691a849d48 --- /dev/null +++ b/src/lib/features/feature-links/fake-feature-links-read-model.ts @@ -0,0 +1,10 @@ +import type { + IFeatureLink, + IFeatureLinksReadModel, +} from './feature-links-read-model-type'; + +export class FakeFeatureLinksReadModel implements IFeatureLinksReadModel { + async getLinks(feature: string): Promise { + return []; + } +} diff --git a/src/lib/features/feature-links/feature-link.e2e.test.ts b/src/lib/features/feature-links/feature-link.e2e.test.ts index 24a88eaabd..32a976b63d 100644 --- a/src/lib/features/feature-links/feature-link.e2e.test.ts +++ b/src/lib/features/feature-links/feature-link.e2e.test.ts @@ -97,6 +97,10 @@ test('should manage feature links', async () => { featureName: 'my_feature', }, ]); + const { body } = await app.getProjectFeatures('default', 'my_feature'); + expect(body.links).toMatchObject([ + { id: links[0].id, title: 'feature link', url: 'example.com' }, + ]); await updatedLink('my_feature', links[0].id, { url: 'example_updated.com', diff --git a/src/lib/features/feature-links/feature-links-read-model-type.ts b/src/lib/features/feature-links/feature-links-read-model-type.ts new file mode 100644 index 0000000000..26d89ee5b8 --- /dev/null +++ b/src/lib/features/feature-links/feature-links-read-model-type.ts @@ -0,0 +1,9 @@ +export interface IFeatureLink { + id: string; + url: string; + title: string | null; +} + +export interface IFeatureLinksReadModel { + getLinks(feature: string): Promise; +} diff --git a/src/lib/features/feature-links/feature-links-read-model.ts b/src/lib/features/feature-links/feature-links-read-model.ts new file mode 100644 index 0000000000..477bdee42a --- /dev/null +++ b/src/lib/features/feature-links/feature-links-read-model.ts @@ -0,0 +1,25 @@ +import type { Db } from '../../db/db'; +import type { + IFeatureLink, + IFeatureLinksReadModel, +} from './feature-links-read-model-type'; + +export class FeatureLinksReadModel implements IFeatureLinksReadModel { + private db: Db; + + constructor(db: Db) { + this.db = db; + } + + async getLinks(feature: string): Promise { + const links = await this.db + .from('feature_link') + .where('feature_name', feature); + + return links.map((link) => ({ + id: link.id, + url: link.url, + title: link.title, + })); + } +} diff --git a/src/lib/features/feature-toggle/createFeatureToggleService.ts b/src/lib/features/feature-toggle/createFeatureToggleService.ts index 5d343216b6..d1bc77a5c1 100644 --- a/src/lib/features/feature-toggle/createFeatureToggleService.ts +++ b/src/lib/features/feature-toggle/createFeatureToggleService.ts @@ -59,6 +59,8 @@ import { FeatureLifecycleReadModel } from '../feature-lifecycle/feature-lifecycl import { FakeFeatureLifecycleReadModel } from '../feature-lifecycle/fake-feature-lifecycle-read-model'; import { FakeFeatureCollaboratorsReadModel } from './fake-feature-collaborators-read-model'; import { FeatureCollaboratorsReadModel } from './feature-collaborators-read-model'; +import { FeatureLinksReadModel } from '../feature-links/feature-links-read-model'; +import { FakeFeatureLinksReadModel } from '../feature-links/fake-feature-links-read-model'; export const createFeatureToggleService = ( db: Db, @@ -128,6 +130,8 @@ export const createFeatureToggleService = ( const featureCollaboratorsReadModel = new FeatureCollaboratorsReadModel(db); + const featureLinksReadModel = new FeatureLinksReadModel(db); + const featureToggleService = new FeatureToggleService( { featureStrategiesStore, @@ -149,6 +153,7 @@ export const createFeatureToggleService = ( dependentFeaturesService, featureLifecycleReadModel, featureCollaboratorsReadModel, + featureLinksReadModel, ); return featureToggleService; }; @@ -189,6 +194,7 @@ export const createFakeFeatureToggleService = (config: IUnleashConfig) => { const featureLifecycleReadModel = new FakeFeatureLifecycleReadModel(); const featureCollaboratorsReadModel = new FakeFeatureCollaboratorsReadModel(); + const featureLinksReadModel = new FakeFeatureLinksReadModel(); const featureToggleService = new FeatureToggleService( { @@ -216,6 +222,7 @@ export const createFakeFeatureToggleService = (config: IUnleashConfig) => { dependentFeaturesService, featureLifecycleReadModel, featureCollaboratorsReadModel, + featureLinksReadModel, ); return { featureToggleService, diff --git a/src/lib/features/feature-toggle/feature-toggle-service.ts b/src/lib/features/feature-toggle/feature-toggle-service.ts index d4b669e49c..d5e5a4acbd 100644 --- a/src/lib/features/feature-toggle/feature-toggle-service.ts +++ b/src/lib/features/feature-toggle/feature-toggle-service.ts @@ -113,6 +113,10 @@ import type { ResourceLimitsSchema } from '../../openapi'; import { throwExceedsLimitError } from '../../error/exceeds-limit-error'; import type { Collaborator } from './types/feature-collaborators-read-model-type'; import { sortStrategies } from '../../util/sortStrategies'; +import type { + IFeatureLink, + IFeatureLinksReadModel, +} from '../feature-links/feature-links-read-model-type'; interface IFeatureContext { featureName: string; @@ -182,6 +186,8 @@ class FeatureToggleService { private featureCollaboratorsReadModel: IFeatureCollaboratorsReadModel; + private featureLinksReadModel: IFeatureLinksReadModel; + private dependentFeaturesService: DependentFeaturesService; private eventBus: EventEmitter; @@ -227,6 +233,7 @@ class FeatureToggleService { dependentFeaturesService: DependentFeaturesService, featureLifecycleReadModel: IFeatureLifecycleReadModel, featureCollaboratorsReadModel: IFeatureCollaboratorsReadModel, + featureLinksReadModel: IFeatureLinksReadModel, ) { this.logger = getLogger('services/feature-toggle-service.ts'); this.featureStrategiesStore = featureStrategiesStore; @@ -247,6 +254,7 @@ class FeatureToggleService { this.dependentFeaturesService = dependentFeaturesService; this.featureLifecycleReadModel = featureLifecycleReadModel; this.featureCollaboratorsReadModel = featureCollaboratorsReadModel; + this.featureLinksReadModel = featureLinksReadModel; this.eventBus = eventBus; this.resourceLimits = resourceLimits; } @@ -442,6 +450,7 @@ class FeatureToggleService { }); } } + async validateStrategyType( strategyName: string | undefined, ): Promise { @@ -1074,14 +1083,19 @@ class FeatureToggleService { let children: string[] = []; let lifecycle: IFeatureLifecycleStage | undefined = undefined; let collaborators: Collaborator[] = []; - [dependencies, children, lifecycle, collaborators] = await Promise.all([ - this.dependentFeaturesReadModel.getParents(featureName), - this.dependentFeaturesReadModel.getChildren([featureName]), - this.featureLifecycleReadModel.findCurrentStage(featureName), - this.featureCollaboratorsReadModel.getFeatureCollaborators( - featureName, - ), - ]); + let links: IFeatureLink[] = []; + [dependencies, children, lifecycle, collaborators, links] = + await Promise.all([ + this.dependentFeaturesReadModel.getParents(featureName), + this.dependentFeaturesReadModel.getChildren([featureName]), + this.featureLifecycleReadModel.findCurrentStage(featureName), + this.featureCollaboratorsReadModel.getFeatureCollaborators( + featureName, + ), + this.flagResolver.isEnabled('featureLinks') + ? this.featureLinksReadModel.getLinks(featureName) + : Promise.resolve([]), + ]); if (environmentVariants) { const result = @@ -1095,6 +1109,7 @@ class FeatureToggleService { dependencies, children, lifecycle, + links, collaborators: { users: collaborators }, }; } else { @@ -1110,6 +1125,7 @@ class FeatureToggleService { dependencies, children, lifecycle, + links, collaborators: { users: collaborators }, }; } diff --git a/src/lib/openapi/spec/feature-schema.ts b/src/lib/openapi/spec/feature-schema.ts index 7c7be1fe1c..20ccd6944b 100644 --- a/src/lib/openapi/spec/feature-schema.ts +++ b/src/lib/openapi/spec/feature-schema.ts @@ -248,6 +248,35 @@ export const featureSchema = { }, }, }, + links: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'url'], + properties: { + id: { + type: 'string', + example: '01JTJNCJ5XVP2KPJFA03YRBZCA', + description: 'The id of the link', + }, + url: { + type: 'string', + example: + 'https://github.com/search?q=cleanupReminder&type=code', + description: 'The URL the feature is linked to', + }, + title: { + type: 'string', + example: 'Github cleanup', + description: 'The description of the link', + nullable: true, + }, + }, + }, + description: + 'The list of links. This is an experimental field and may change.', + }, }, components: { schemas: { diff --git a/src/lib/services/feature-service-potentially-stale.test.ts b/src/lib/services/feature-service-potentially-stale.test.ts index c42f64c6ef..7436f114ca 100644 --- a/src/lib/services/feature-service-potentially-stale.test.ts +++ b/src/lib/services/feature-service-potentially-stale.test.ts @@ -16,6 +16,7 @@ import EventService from '../features/events/event-service'; import FakeFeatureTagStore from '../../test/fixtures/fake-feature-tag-store'; import type { DependentFeaturesService } from '../features/dependent-features/dependent-features-service'; import type { IFeatureLifecycleReadModel } from '../features/feature-lifecycle/feature-lifecycle-read-model-type'; +import type { IFeatureLinksReadModel } from '../features/feature-links/feature-links-read-model-type'; test('Should only store events for potentially stale on', async () => { expect.assertions(2); @@ -72,6 +73,7 @@ test('Should only store events for potentially stale on', async () => { {} as DependentFeaturesService, {} as IFeatureLifecycleReadModel, {} as IFeatureCollaboratorsReadModel, + {} as IFeatureLinksReadModel, ); await featureToggleService.updatePotentiallyStaleFeatures(); diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 9fdc822b68..053f7c505a 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -160,6 +160,8 @@ import { } from '../features/context/createContextService'; import { UniqueConnectionService } from '../features/unique-connection/unique-connection-service'; import { createFakeFeatureLinkService } from '../features/feature-links/createFeatureLinkService'; +import { FeatureLinksReadModel } from '../features/feature-links/feature-links-read-model'; +import { FakeFeatureLinksReadModel } from '../features/feature-links/fake-feature-links-read-model'; export const createServices = ( stores: IUnleashStores, @@ -284,6 +286,10 @@ export const createServices = ( ? new FeatureCollaboratorsReadModel(db) : new FakeFeatureCollaboratorsReadModel(); + const featureLinkReadModel = db + ? new FeatureLinksReadModel(db) + : new FakeFeatureLinksReadModel(); + const featureToggleService = new FeatureToggleService( stores, config, @@ -296,6 +302,7 @@ export const createServices = ( dependentFeaturesService, featureLifecycleReadModel, featureCollaboratorsReadModel, + featureLinkReadModel, ); const transactionalEnvironmentService = db ? withTransactional(createEnvironmentService(config), db) diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 36600eca56..585d95e646 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -14,6 +14,7 @@ import type { IntegrationEventsService } from '../features/integration-events/in import type { IFlagResolver } from './experimental'; import type { Collaborator } from '../features/feature-toggle/types/feature-collaborators-read-model-type'; import type { EventEmitter } from 'events'; +import type { IFeatureLink } from '../features/feature-links/feature-links-read-model-type'; export type Operator = (typeof ALL_OPERATORS)[number]; @@ -123,6 +124,7 @@ export interface FeatureToggleView extends FeatureToggleWithEnvironment { children: string[]; lifecycle: IFeatureLifecycleStage | undefined; collaborators?: { users: Collaborator[] }; + links: IFeatureLink[]; } export interface IEnvironmentDetail extends IEnvironmentBase {