From 9911fe89be1b3579ccb55da30908392bd3685495 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Thu, 24 Apr 2025 09:36:06 +0200 Subject: [PATCH] feat: lifecycle count query (#9824) --- src/lib/db/index.ts | 5 +- .../feature-lifecycle-count-controller.ts | 80 +++++++++++++++++++ .../feature-lifecycle-read-model.test.ts | 12 +-- .../feature-lifecycle-read-model.ts | 6 +- .../feature-lifecycle.e2e.test.ts | 18 ++++- .../createFeatureToggleService.ts | 5 +- src/lib/metrics.test.ts | 5 +- .../spec/feature-lifecycle-count-schema.ts | 43 ++++++++++ src/lib/openapi/spec/index.ts | 1 + src/lib/routes/admin-api/index.ts | 5 ++ src/lib/services/index.ts | 4 +- src/lib/types/services.ts | 2 + 12 files changed, 153 insertions(+), 33 deletions(-) create mode 100644 src/lib/features/feature-lifecycle/feature-lifecycle-count-controller.ts create mode 100644 src/lib/openapi/spec/feature-lifecycle-count-schema.ts diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 8c1c2e62c1..f27547a8e7 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -181,10 +181,7 @@ export const createStores = ( featureStrategiesReadModel: new FeatureStrategiesReadModel(db), onboardingReadModel: createOnboardingReadModel(db), onboardingStore: new OnboardingStore(db), - featureLifecycleReadModel: new FeatureLifecycleReadModel( - db, - config.flagResolver, - ), + featureLifecycleReadModel: new FeatureLifecycleReadModel(db), largestResourcesReadModel: new LargestResourcesReadModel(db), integrationEventsStore: new IntegrationEventsStore(db, { eventBus }), featureCollaboratorsReadModel: new FeatureCollaboratorsReadModel(db), diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-count-controller.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-count-controller.ts new file mode 100644 index 0000000000..48d9333cf7 --- /dev/null +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-count-controller.ts @@ -0,0 +1,80 @@ +import { + type IFeatureLifecycleReadModel, + type IUnleashConfig, + type IUnleashServices, + NONE, +} from '../../types'; +import type { OpenApiService } from '../../services'; +import { createResponseSchema, getStandardResponses } from '../../openapi'; +import Controller from '../../routes/controller'; +import type { Request, Response } from 'express'; +import { + type FeatureLifecycleCountSchema, + featureLifecycleCountSchema, +} from '../../openapi/spec/feature-lifecycle-count-schema'; + +export default class FeatureLifecycleCountController extends Controller { + private featureLifecycleReadModel: IFeatureLifecycleReadModel; + + private openApiService: OpenApiService; + + constructor( + config: IUnleashConfig, + { + openApiService, + featureLifecycleReadModel, + }: Pick< + IUnleashServices, + 'openApiService' | 'featureLifecycleReadModel' + >, + ) { + super(config); + this.featureLifecycleReadModel = featureLifecycleReadModel; + this.openApiService = openApiService; + + this.route({ + method: 'get', + path: '/count', + handler: this.getStageCount, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['Unstable'], + summary: 'Get all features lifecycle stage count', + description: + 'Information about the number of features in each lifecycle stage.', + operationId: 'getFeatureLifecycleStageCount', + responses: { + 200: createResponseSchema( + 'featureLifecycleCountSchema', + ), + ...getStandardResponses(401, 403, 404), + }, + }), + ], + }); + } + + async getStageCount( + _: Request, + res: Response, + ): Promise { + const stageCounts = + await this.featureLifecycleReadModel.getStageCount(); + + const result: Record = stageCounts.reduce( + (acc, { stage, count }) => { + acc[stage === 'pre-live' ? 'preLive' : stage] = count; + return acc; + }, + { initial: 0, preLive: 0, live: 0, completed: 0, archived: 0 }, + ); + + this.openApiService.respondWithValidation( + 200, + res, + featureLifecycleCountSchema.$id, + result, + ); + } +} diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.test.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.test.ts index 7522dc164c..f35eb89d35 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.test.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.test.ts @@ -4,25 +4,15 @@ import { FeatureLifecycleReadModel } from './feature-lifecycle-read-model'; import type { IFeatureLifecycleStore } from './feature-lifecycle-store-type'; import type { IFeatureLifecycleReadModel } from './feature-lifecycle-read-model-type'; import type { IFeatureToggleStore } from '../feature-toggle/types/feature-toggle-store-type'; -import type { IFlagResolver } from '../../types'; let db: ITestDb; let featureLifecycleReadModel: IFeatureLifecycleReadModel; let featureLifecycleStore: IFeatureLifecycleStore; let featureToggleStore: IFeatureToggleStore; -const alwaysOnFlagResolver = { - isEnabled() { - return true; - }, -} as unknown as IFlagResolver; - beforeAll(async () => { db = await dbInit('feature_lifecycle_read_model', getLogger); - featureLifecycleReadModel = new FeatureLifecycleReadModel( - db.rawDatabase, - alwaysOnFlagResolver, - ); + featureLifecycleReadModel = new FeatureLifecycleReadModel(db.rawDatabase); featureLifecycleStore = db.stores.featureLifecycleStore; featureToggleStore = db.stores.featureToggleStore; }); diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts index f7b308095b..97f4bc3203 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts @@ -7,7 +7,6 @@ import type { import { getCurrentStage } from './get-current-stage'; import type { IFeatureLifecycleStage, - IFlagResolver, IProjectLifecycleStageDuration, StageName, } from '../../types'; @@ -28,11 +27,8 @@ type DBProjectType = DBType & { export class FeatureLifecycleReadModel implements IFeatureLifecycleReadModel { private db: Db; - private flagResolver: IFlagResolver; - - constructor(db: Db, flagResolver: IFlagResolver) { + constructor(db: Db) { this.db = db; - this.flagResolver = flagResolver; } async getStageCount(): Promise { diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts b/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts index f349f0f180..c7e7752398 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts @@ -41,10 +41,7 @@ beforeAll(async () => { ); eventStore = db.stores.eventStore; eventBus = app.config.eventBus; - featureLifecycleReadModel = new FeatureLifecycleReadModel( - db.rawDatabase, - app.config.flagResolver, - ); + featureLifecycleReadModel = new FeatureLifecycleReadModel(db.rawDatabase); featureLifecycleStore = db.stores.featureLifecycleStore; await app.request @@ -115,6 +112,10 @@ const expectFeatureStage = async (featureName: string, stage: StageName) => { }); }; +const getFeaturesLifecycleCount = async () => { + return app.request.get(`/api/admin/lifecycle/count`).expect(200); +}; + test('should return lifecycle stages', async () => { await app.createFeature('my_feature_a'); await app.enableFeature('my_feature_a', 'default'); @@ -173,6 +174,15 @@ test('should return lifecycle stages', async () => { eventStore.emit(FEATURE_REVIVED, { featureName: 'my_feature_a' }); await reachedStage('my_feature_a', 'initial'); + + const { body: lifecycleCount } = await getFeaturesLifecycleCount(); + expect(lifecycleCount).toEqual({ + initial: 1, + preLive: 0, + live: 0, + completed: 0, + archived: 0, + }); }); test('should be able to toggle between completed/uncompleted', async () => { diff --git a/src/lib/features/feature-toggle/createFeatureToggleService.ts b/src/lib/features/feature-toggle/createFeatureToggleService.ts index 48f9a55504..5d343216b6 100644 --- a/src/lib/features/feature-toggle/createFeatureToggleService.ts +++ b/src/lib/features/feature-toggle/createFeatureToggleService.ts @@ -122,10 +122,7 @@ export const createFeatureToggleService = ( const dependentFeaturesReadModel = new DependentFeaturesReadModel(db); - const featureLifecycleReadModel = new FeatureLifecycleReadModel( - db, - config.flagResolver, - ); + const featureLifecycleReadModel = new FeatureLifecycleReadModel(db); const dependentFeaturesService = createDependentFeaturesService(config)(db); diff --git a/src/lib/metrics.test.ts b/src/lib/metrics.test.ts index ec0954c765..8f9ab651e5 100644 --- a/src/lib/metrics.test.ts +++ b/src/lib/metrics.test.ts @@ -67,10 +67,7 @@ beforeAll(async () => { const versionService = new VersionService(stores, config); db = await dbInit('metrics_test', getLogger); - featureLifeCycleReadModel = new FeatureLifecycleReadModel( - db.rawDatabase, - config.flagResolver, - ); + featureLifeCycleReadModel = new FeatureLifecycleReadModel(db.rawDatabase); stores.featureLifecycleReadModel = featureLifeCycleReadModel; featureLifeCycleStore = new FeatureLifecycleStore(db.rawDatabase); stores.featureLifecycleStore = featureLifeCycleStore; diff --git a/src/lib/openapi/spec/feature-lifecycle-count-schema.ts b/src/lib/openapi/spec/feature-lifecycle-count-schema.ts new file mode 100644 index 0000000000..e0e89c0bac --- /dev/null +++ b/src/lib/openapi/spec/feature-lifecycle-count-schema.ts @@ -0,0 +1,43 @@ +import type { FromSchema } from 'json-schema-to-ts'; + +export const featureLifecycleCountSchema = { + $id: '#/components/schemas/featureLifecycleCountSchema', + type: 'object', + description: 'A number features in each of the lifecycle stages', + required: ['initial', 'preLive', 'live', 'completed', 'archived'], + additionalProperties: false, + properties: { + initial: { + type: 'number', + example: 1, + description: 'Number of features in the initial stage', + }, + preLive: { + type: 'number', + example: 1, + description: 'Number of features in the pre-live stage', + }, + live: { + type: 'number', + example: 1, + description: 'Number of features in the live stage', + }, + completed: { + type: 'number', + example: 1, + description: 'Number of features in the completed stage', + }, + archived: { + type: 'number', + example: 1, + description: 'Number of features in the archived stage', + }, + }, + components: { + schemas: {}, + }, +} as const; + +export type FeatureLifecycleCountSchema = FromSchema< + typeof featureLifecycleCountSchema +>; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 5084a747ba..8f5f6ec7d8 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -83,6 +83,7 @@ export * from './feature-environment-metrics-schema'; export * from './feature-environment-schema'; export * from './feature-events-schema'; export * from './feature-lifecycle-completed-schema'; +export * from './feature-lifecycle-count-schema'; export * from './feature-lifecycle-schema'; export * from './feature-metrics-schema'; export * from './feature-schema'; diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index 841401a854..77061487b8 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -36,6 +36,7 @@ import { InactiveUsersController } from '../../users/inactive/inactive-users-con import { UiObservabilityController } from '../../features/ui-observability-controller/ui-observability-controller'; import { SearchApi } from './search'; import PersonalDashboardController from '../../features/personal-dashboard/personal-dashboard-controller'; +import FeatureLifecycleCountController from '../../features/feature-lifecycle/feature-lifecycle-count-controller'; export class AdminApi extends Controller { constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) { @@ -121,6 +122,10 @@ export class AdminApi extends Controller { '/projects', new ProjectController(config, services, db).router, ); + this.app.use( + '/lifecycle', + new FeatureLifecycleCountController(config, services).router, + ); this.app.use( '/personal-dashboard', new PersonalDashboardController(config, services).router, diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index 21a868d561..9326b20645 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -198,7 +198,7 @@ export const createServices = ( ? new DependentFeaturesReadModel(db) : new FakeDependentFeaturesReadModel(); const featureLifecycleReadModel = db - ? new FeatureLifecycleReadModel(db, config.flagResolver) + ? new FeatureLifecycleReadModel(db) : new FakeFeatureLifecycleReadModel(); const transactionalContextService = db @@ -490,6 +490,7 @@ export const createServices = ( projectStatusService, transactionalUserSubscriptionsService, uniqueConnectionService, + featureLifecycleReadModel, }; }; @@ -544,4 +545,5 @@ export { ProjectStatusService, UserSubscriptionsService, UniqueConnectionService, + FeatureLifecycleReadModel, }; diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index 7296305a66..e7f06e1ba3 100644 --- a/src/lib/types/services.ts +++ b/src/lib/types/services.ts @@ -60,6 +60,7 @@ import type { PersonalDashboardService } from '../features/personal-dashboard/pe import type { ProjectStatusService } from '../features/project-status/project-status-service'; import type { UserSubscriptionsService } from '../features/user-subscriptions/user-subscriptions-service'; import type { UniqueConnectionService } from '../features/unique-connection/unique-connection-service'; +import type { IFeatureLifecycleReadModel } from '../features/feature-lifecycle/feature-lifecycle-read-model-type'; export interface IUnleashServices { transactionalAccessService: WithTransactional; @@ -131,4 +132,5 @@ export interface IUnleashServices { projectStatusService: ProjectStatusService; transactionalUserSubscriptionsService: WithTransactional; uniqueConnectionService: UniqueConnectionService; + featureLifecycleReadModel: IFeatureLifecycleReadModel; }