diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-controller.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-controller.ts new file mode 100644 index 00000000000..c90e932daa5 --- /dev/null +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-controller.ts @@ -0,0 +1,86 @@ +import type { FeatureLifecycleService } from './feature-lifecycle-service'; +import { + type IFlagResolver, + type IUnleashConfig, + type IUnleashServices, + NONE, + serializeDates, +} from '../../types'; +import type { OpenApiService } from '../../services'; +import { + createResponseSchema, + featureLifecycleSchema, + type FeatureLifecycleSchema, + getStandardResponses, +} from '../../openapi'; +import Controller from '../../routes/controller'; +import type { Request, Response } from 'express'; +import { NotFoundError } from '../../error'; + +interface FeatureLifecycleParams { + projectId: string; + featureName: string; +} + +const PATH = '/:projectId/features/:featureName/lifecycle'; + +export default class FeatureLifecycleController extends Controller { + private featureLifecycleService: FeatureLifecycleService; + + private openApiService: OpenApiService; + + private flagResolver: IFlagResolver; + + constructor( + config: IUnleashConfig, + { + featureLifecycleService, + openApiService, + }: Pick, + ) { + super(config); + this.featureLifecycleService = featureLifecycleService; + this.openApiService = openApiService; + this.flagResolver = config.flagResolver; + + this.route({ + method: 'get', + path: PATH, + handler: this.getFeatureLifecycle, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['Unstable'], + summary: 'Get feature lifecycle', + description: + 'Information about the lifecycle stages of the feature.', + operationId: 'getFeatureLifecycle', + responses: { + 200: createResponseSchema('featureLifecycleSchema'), + ...getStandardResponses(401, 403, 404), + }, + }), + ], + }); + } + + async getFeatureLifecycle( + req: Request, + res: Response, + ): Promise { + if (!this.flagResolver.isEnabled('featureLifecycle')) { + throw new NotFoundError('Feature lifecycle is disabled.'); + } + const { featureName } = req.params; + + const result = + await this.featureLifecycleService.getFeatureLifecycle(featureName); + + this.openApiService.respondWithValidation( + 200, + res, + featureLifecycleSchema.$id, + serializeDates(result), + ); + } +} diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts b/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts new file mode 100644 index 00000000000..eb275f14349 --- /dev/null +++ b/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts @@ -0,0 +1,52 @@ +import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init'; +import { + type IUnleashTest, + setupAppWithAuth, +} from '../../../test/e2e/helpers/test-helper'; +import getLogger from '../../../test/fixtures/no-logger'; + +let app: IUnleashTest; +let db: ITestDb; + +beforeAll(async () => { + db = await dbInit('feature_lifecycle', getLogger); + app = await setupAppWithAuth( + db.stores, + { + experimental: { + flags: { + featureLifecycle: true, + }, + }, + }, + db.rawDatabase, + ); + + await app.request + .post(`/auth/demo/login`) + .send({ + email: 'user@getunleash.io', + }) + .expect(200); +}); + +afterAll(async () => { + await app.destroy(); + await db.destroy(); +}); + +beforeEach(async () => {}); + +const getFeatureLifecycle = async (featureName: string, expectedCode = 200) => { + return app.request + .get(`/api/admin/projects/default/features/${featureName}/lifecycle`) + .expect(expectedCode); +}; + +test('should return lifecycle stages', async () => { + await app.createFeature('my_feature_a'); + + const { body } = await getFeatureLifecycle('my_feature_a'); + + expect(body).toEqual([]); +}); diff --git a/src/lib/features/project/project-controller.ts b/src/lib/features/project/project-controller.ts index 82476313493..9be55b7dfe9 100644 --- a/src/lib/features/project/project-controller.ts +++ b/src/lib/features/project/project-controller.ts @@ -40,6 +40,7 @@ import { import { projectApplicationsQueryParameters } from '../../openapi/spec/project-applications-query-parameters'; import { normalizeQueryParams } from '../feature-search/search-utils'; import ProjectInsightsController from '../project-insights/project-insights-controller'; +import FeatureLifecycleController from '../feature-lifecycle/feature-lifecycle-controller'; export default class ProjectController extends Controller { private projectService: ProjectService; @@ -181,6 +182,7 @@ export default class ProjectController extends Controller { ).router, ); this.use('/', new ProjectInsightsController(config, services).router); + this.use('/', new FeatureLifecycleController(config, services).router); } async getProjects( diff --git a/src/lib/openapi/spec/feature-lifecycle-schema.ts b/src/lib/openapi/spec/feature-lifecycle-schema.ts new file mode 100644 index 00000000000..7586fc04e9c --- /dev/null +++ b/src/lib/openapi/spec/feature-lifecycle-schema.ts @@ -0,0 +1,33 @@ +import type { FromSchema } from 'json-schema-to-ts'; + +export const featureLifecycleSchema = { + $id: '#/components/schemas/featureLifecycleSchema', + type: 'array', + description: 'A list of lifecycle stages for a given feature', + items: { + additionalProperties: false, + type: 'object', + required: ['stage', 'enteredStageAt'], + properties: { + stage: { + type: 'string', + enum: ['initial', 'pre-live', 'live', 'completed', 'archived'], + example: 'initial', + description: + 'The name of the lifecycle stage that got recorded for a given feature', + }, + enteredStageAt: { + type: 'string', + format: 'date-time', + example: '2023-01-28T16:21:39.975Z', + description: 'The date when the feature entered a given stage', + }, + }, + description: 'The lifecycle stage of the feature', + }, + components: { + schemas: {}, + }, +} as const; + +export type FeatureLifecycleSchema = FromSchema; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index b11e3123944..b4dc8ae6a74 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -75,6 +75,7 @@ export * from './feature-dependencies-schema'; export * from './feature-environment-metrics-schema'; export * from './feature-environment-schema'; export * from './feature-events-schema'; +export * from './feature-lifecycle-schema'; export * from './feature-metrics-schema'; export * from './feature-schema'; export * from './feature-search-environment-schema';